From 8e38c7a9e6fe886f3c0075b7ab0e7d65dfd8be18 Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 26 Jun 2026 19:31:45 -0400 Subject: [PATCH] TW-5728: add `webhooks server --register` to auto-register the tunnel webhook Run one command to receive verified webhooks locally. With --register, once the cloudflared tunnel is up the CLI creates a Nylas webhook for the live tunnel URL, fetches the signing secret into memory (HMAC verification on, nothing to copy), and deletes the webhook on exit. Creation is retried while Nylas verification returns error 70005 (a fresh tunnel hostname is still propagating). - POST events are rejected (503) until the secret is installed, so the public tunnel never processes an unsigned event; the GET challenge stays open so Nylas's create-time verification still succeeds - read the signing secret under the lock and pass it into the replay check to close the data race with the post-start UpdateSecret swap - stale auto-webhook sweep on start; teardown on shutdown (warns if delete fails) - signal-aware context so Ctrl+C aborts tunnel start / registration cleanly - reject an empty-secret response and remove the half-created webhook - early cloudflared check (with brew install offer); prompts go to stderr under --json --- docs/COMMANDS.md | 1 + docs/commands/webhooks.md | 16 + internal/adapters/webhookserver/server.go | 78 ++++- .../adapters/webhookserver/server_test.go | 131 +++++++- internal/cli/webhook/prompter.go | 28 ++ internal/cli/webhook/register.go | 203 +++++++++++ internal/cli/webhook/register_test.go | 318 ++++++++++++++++++ internal/cli/webhook/server.go | 168 +++++++-- internal/cli/webhook/server_test.go | 18 +- 9 files changed, 918 insertions(+), 43 deletions(-) create mode 100644 internal/cli/webhook/register.go create mode 100644 internal/cli/webhook/register_test.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 31ecf01..cda70ec 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -474,6 +474,7 @@ nylas webhook test payload [trigger-type] # Generate test payload nylas webhook server # Interactive preflight (offers cloudflared tunnel) nylas webhook server --no-tunnel # Loopback-only (skip preflight) nylas webhook server --port 8080 --tunnel cloudflared --secret xxx # Public tunnel + HMAC verify +nylas webhook server --tunnel cloudflared --register --triggers message.created # Auto-create webhook + fetch secret + cleanup on exit ``` **Details:** `docs/commands/webhooks.md` diff --git a/docs/commands/webhooks.md b/docs/commands/webhooks.md index bccf6f5..45805a3 100644 --- a/docs/commands/webhooks.md +++ b/docs/commands/webhooks.md @@ -36,6 +36,10 @@ nylas webhook server --tunnel cloudflared --secret your-webhook-secret # Custom port with a tunnel nylas webhook server --port 8080 --tunnel cloudflared --secret your-webhook-secret + +# Auto-register: create the Nylas webhook for the tunnel URL, fetch its +# secret automatically, and delete it again on exit (no manual setup) +nylas webhook server --tunnel cloudflared --register --triggers message.created ``` When `--tunnel` is set, `--secret` is required (or pass `--allow-unsigned` @@ -43,6 +47,18 @@ to opt out explicitly). The interactive preflight will prompt for a secret inline when you accept the tunnel; leaving it empty opts into unsigned mode. +**Auto-registration (`--register`):** the quick-tunnel URL changes on every +restart, so registering it by hand is tedious. `--register` does it for you: +after the tunnel comes up the CLI creates a Nylas webhook pointing at the live +URL, pulls back the signing secret into memory (so signature verification is +on without you copying anything), and deletes the webhook when the server +stops. A stale-webhook sweep on start also removes any auto-registered webhook +left behind by a previous hard kill. Choose the events with `--triggers` +(comma-separated or repeated); you'll be prompted if it's omitted on a +terminal. With `--register` you do **not** pass `--secret` — it's fetched from +Nylas. `--register` implies `--tunnel cloudflared` and cannot be combined with +`--secret`, `--allow-unsigned`, or `--no-tunnel`. + **Cloudflared install:** On macOS, the preflight will offer to run `brew install cloudflared` for diff --git a/internal/adapters/webhookserver/server.go b/internal/adapters/webhookserver/server.go index 08544f2..61870da 100644 --- a/internal/adapters/webhookserver/server.go +++ b/internal/adapters/webhookserver/server.go @@ -10,6 +10,7 @@ import ( "net" "net/http" "sync" + "sync/atomic" "time" "github.com/nylas/cli/internal/ports" @@ -85,6 +86,12 @@ type Server struct { mu sync.RWMutex startedAt time.Time closeOnce sync.Once + + // awaitingSecret is set while --register has the listener live but hasn't + // fetched the signing secret yet. POST events are rejected (503) during + // this window so the public tunnel never processes an unsigned event; the + // GET challenge stays open so Nylas's create-time verification succeeds. + awaitingSecret atomic.Bool } // NewServer creates a new webhook server. @@ -112,6 +119,37 @@ func (s *Server) SetTunnel(tunnel ports.Tunnel) { s.tunnel = tunnel } +// AwaitSecret marks the server as waiting for its signing secret. Call it +// before Start in --register mode so POST events are rejected until the secret +// is installed by UpdateSecret. +func (s *Server) AwaitSecret() { + s.awaitingSecret.Store(true) +} + +// UpdateSecret sets the HMAC secret (and replay window) after the server has +// started, and clears the awaiting-secret gate. Auto-registration needs this: +// the webhook secret is only minted by Nylas once the public tunnel URL exists +// and the webhook is created, which can't happen until the listener is already +// up to answer Nylas's create-time challenge. Safe for concurrent use with +// in-flight requests. +func (s *Server) UpdateSecret(secret string, maxEventAge time.Duration) { + s.mu.Lock() + s.config.WebhookSecret = secret + s.config.MaxEventAge = maxEventAge + s.mu.Unlock() + // Clear the gate last so no request is admitted before the secret is live. + s.awaitingSecret.Store(false) +} + +// signingConfig returns the current HMAC secret and replay window under the +// read lock so a concurrent UpdateSecret can't race the unsynchronised field +// reads in handleWebhook. +func (s *Server) signingConfig() (secret string, maxEventAge time.Duration) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.config.WebhookSecret, s.config.MaxEventAge +} + // Start starts the webhook server and optional tunnel. func (s *Server) Start(ctx context.Context) error { // Create HTTP server @@ -259,6 +297,15 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { return } + // Reject POST events while --register is still fetching the signing secret. + // Without this, the live public tunnel would process unsigned events during + // the registration window. The GET challenge above is already handled, so + // Nylas's create-time verification still succeeds. + if s.awaitingSecret.Load() { + http.Error(w, "Webhook registration in progress", http.StatusServiceUnavailable) + return + } + // Cap request body size so a malicious sender on a public tunnel can't // drive unbounded RAM allocation. MaxBytesReader closes the body and // returns an error from ReadAll once the limit is exceeded. @@ -274,13 +321,17 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { } defer func() { _ = r.Body.Close() }() + // Read the signing posture once under the lock — auto-registration can swap + // the secret in via UpdateSecret while requests are in flight. + webhookSecret, maxEventAge := s.signingConfig() + signature := r.Header.Get("X-Nylas-Signature") - if s.config.WebhookSecret != "" { + if webhookSecret != "" { if signature == "" { http.Error(w, "Missing webhook signature", http.StatusUnauthorized) return } - if !s.verifySignature(body, signature) { + if !VerifySignature(body, signature, webhookSecret) { http.Error(w, "Invalid webhook signature", http.StatusForbidden) return } @@ -293,7 +344,7 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { Headers: make(map[string]string), RawBody: body, Signature: signature, - Verified: s.config.WebhookSecret != "", + Verified: webhookSecret != "", } // Copy relevant headers @@ -332,7 +383,7 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { // When configured, reject events whose CloudEvents `time` field is // older than the allowed skew. Payloads without `time` are covered // by the signed-body dedupe below. - if s.config.WebhookSecret != "" && s.config.MaxEventAge > 0 { + if webhookSecret != "" && maxEventAge > 0 { if rawTime, ok := payload["time"].(string); ok { eventTime, terr := time.Parse(time.RFC3339, rawTime) if terr != nil { @@ -340,7 +391,7 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { return } skew := time.Since(eventTime) - if skew > s.config.MaxEventAge || skew < -s.config.MaxEventAge { + if skew > maxEventAge || skew < -maxEventAge { http.Error(w, "Event timestamp outside allowed skew", http.StatusUnauthorized) return } @@ -348,7 +399,7 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { } } - if s.shouldSuppressSignedReplay(signature, time.Now()) { + if s.shouldSuppressSignedReplay(signature, webhookSecret, maxEventAge, time.Now()) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("Duplicate webhook ignored")) return @@ -451,20 +502,19 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { _ = rootTemplate.Execute(w, data) // best-effort response } -// verifySignature verifies the webhook signature using HMAC-SHA256. -func (s *Server) verifySignature(payload []byte, signature string) bool { - return VerifySignature(payload, signature, s.config.WebhookSecret) -} - -func (s *Server) shouldSuppressSignedReplay(signature string, now time.Time) bool { - if s.config.WebhookSecret == "" || s.config.MaxEventAge <= 0 || signature == "" { +// shouldSuppressSignedReplay reports whether a signed event with this signature +// was already seen inside the replay window. The secret/maxEventAge are passed +// in (snapshotted under signingConfig in the caller) rather than read from +// s.config here, so a concurrent UpdateSecret cannot race these reads. +func (s *Server) shouldSuppressSignedReplay(signature, secret string, maxEventAge time.Duration, now time.Time) bool { + if secret == "" || maxEventAge <= 0 || signature == "" { return false } s.mu.Lock() defer s.mu.Unlock() - cutoff := now.Add(-s.config.MaxEventAge) + cutoff := now.Add(-maxEventAge) for key, seenAt := range s.seenSignatures { if seenAt.Before(cutoff) { delete(s.seenSignatures, key) diff --git a/internal/adapters/webhookserver/server_test.go b/internal/adapters/webhookserver/server_test.go index 322caf0..0d95513 100644 --- a/internal/adapters/webhookserver/server_test.go +++ b/internal/adapters/webhookserver/server_test.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "net/http/httptest" + "sync" "testing" "time" @@ -266,22 +267,16 @@ func TestServer_EventsChannel(t *testing.T) { func TestServer_SignatureVerification(t *testing.T) { // #nosec G101 -- This is a test secret, not a real credential secret := "test-webhook-secret" - server := NewServer(ports.WebhookServerConfig{ - Port: 3006, - Path: "/webhook", - WebhookSecret: secret, - }) t.Run("valid_signature", func(t *testing.T) { payload := []byte(`{"type":"message.created"}`) - // Generate valid signature (HMAC-SHA256) - valid := server.verifySignature(payload, "invalid-signature") + valid := VerifySignature(payload, "invalid-signature", secret) assert.False(t, valid) // Invalid signature should fail }) t.Run("missing_signature", func(t *testing.T) { payload := []byte(`{"type":"message.created"}`) - valid := server.verifySignature(payload, "") + valid := VerifySignature(payload, "", secret) assert.False(t, valid) // Empty signature should fail }) } @@ -333,6 +328,126 @@ func TestServer_GetPublicURL(t *testing.T) { assert.Equal(t, "http://127.0.0.1:8080/webhook", url) } +// TestServer_UpdateSecret_SwapsVerification verifies the auto-registration +// path: a server started without a secret accepts unsigned events, but once +// UpdateSecret installs the Nylas-minted secret, unsigned/forged events are +// rejected and only correctly-signed events pass. This is the behaviour that +// makes `webhooks server --register` safe — the window before the secret is +// known is closed the instant UpdateSecret runs. +func TestServer_UpdateSecret_SwapsVerification(t *testing.T) { + server := NewServer(ports.WebhookServerConfig{Port: 0, Path: "/webhook"}) + handler := http.HandlerFunc(server.handleWebhook) + payload := []byte(`{"type":"message.created","id":"event-1"}`) + + // Before a secret is set, unsigned events are accepted (no verification). + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code, "unsigned event should be accepted before UpdateSecret") + + // Swap in the secret, as auto-registration does once Nylas mints it. + secret := "minted-by-nylas" + server.UpdateSecret(secret, defaultTestMaxEventAge) + + t.Run("unsigned now rejected", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("forged signature rejected", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + req.Header.Set("X-Nylas-Signature", "deadbeef") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusForbidden, rec.Code) + }) + + t.Run("correctly signed accepted", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + req.Header.Set("X-Nylas-Signature", signWebhookPayload(secret, payload)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + }) +} + +const defaultTestMaxEventAge = 5 * time.Minute + +// TestServer_AwaitSecret_RejectsPostUntilSecretSet verifies the registration +// window is closed: while --register has the listener live but no secret yet, +// POST events are rejected (503) so the public tunnel never processes an +// unsigned event, while the GET challenge Nylas uses to verify the URL still +// works. Once UpdateSecret installs the secret, signed POSTs are accepted. +func TestServer_AwaitSecret_RejectsPostUntilSecretSet(t *testing.T) { + server := NewServer(ports.WebhookServerConfig{Port: 0, Path: "/webhook"}) + server.AwaitSecret() + handler := http.HandlerFunc(server.handleWebhook) + payload := []byte(`{"type":"message.created"}`) + + t.Run("POST rejected while awaiting secret", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + assert.Equal(t, 0, server.GetStats().EventsReceived) + }) + + t.Run("GET challenge still answered", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/webhook?challenge=abc123", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "abc123", rec.Body.String()) + }) + + t.Run("signed POST accepted after secret installed", func(t *testing.T) { + secret := "minted-by-nylas" + server.UpdateSecret(secret, defaultTestMaxEventAge) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + req.Header.Set("X-Nylas-Signature", signWebhookPayload(secret, payload)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + }) +} + +// TestServer_UpdateSecret_ConcurrentRequests drives the handler concurrently +// with UpdateSecret under `-race` to guard the secret swap. Requests are +// correctly signed with distinct bodies so each one passes verification and +// reaches BOTH racy read paths: signingConfig() and the seenSignatures write +// in shouldSuppressSignedReplay — exercised concurrently with UpdateSecret's +// config writes. The assertions are loose; the point is the race detector. +func TestServer_UpdateSecret_ConcurrentRequests(t *testing.T) { + const secret = "stable-secret" + server := NewServer(ports.WebhookServerConfig{Port: 0, Path: "/webhook"}) + server.UpdateSecret(secret, defaultTestMaxEventAge) + handler := http.HandlerFunc(server.handleWebhook) + + var wg sync.WaitGroup + // Writers: re-install the (same) secret to race the config writes. + for range 50 { + wg.Add(1) + go func() { + defer wg.Done() + server.UpdateSecret(secret, defaultTestMaxEventAge) + }() + } + // Readers: signed, distinct-body POSTs that reach the replay map write. + for i := range 50 { + wg.Add(1) + go func(i int) { + defer wg.Done() + payload := fmt.Appendf(nil, `{"type":"message.created","id":"e-%d"}`, i) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + req.Header.Set("X-Nylas-Signature", signWebhookPayload(secret, payload)) + handler.ServeHTTP(httptest.NewRecorder(), req) + }(i) + } + wg.Wait() +} + func signWebhookPayload(secret string, payload []byte) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write(payload) diff --git a/internal/cli/webhook/prompter.go b/internal/cli/webhook/prompter.go index 983a1c3..f605533 100644 --- a/internal/cli/webhook/prompter.go +++ b/internal/cli/webhook/prompter.go @@ -22,6 +22,10 @@ import ( type preflightPrompter interface { Confirm(message string, defaultYes bool) (bool, error) Password(message string) (string, error) + // Ask reads a line of plain text, returning defaultValue when the user + // submits an empty line. EOF is propagated unchanged so callers can tell + // cancellation from an accepted default. + Ask(message, defaultValue string) (string, error) } // stdinPrompter is the production preflightPrompter. It reads from @@ -68,6 +72,30 @@ func (p *stdinPrompter) Confirm(message string, defaultYes bool) (bool, error) { return response == "y" || response == "yes", nil } +// Ask reads a line of plain text, falling back to defaultValue on an empty +// line. Echo stays on — this is for non-secret input such as trigger lists. +func (p *stdinPrompter) Ask(message, defaultValue string) (string, error) { + prompt := message + if defaultValue != "" { + prompt = fmt.Sprintf("%s [%s]", message, defaultValue) + } + if _, err := fmt.Fprint(p.out, prompt+": "); err != nil { + return "", err + } + line, err := p.in.ReadString('\n') + if errors.Is(err, io.EOF) && strings.TrimSpace(line) == "" { + return "", io.EOF + } + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + value := strings.TrimSpace(line) + if value == "" { + return defaultValue, nil + } + return value, nil +} + // Password prompts for a secret with terminal echo disabled when stdin // is a TTY. When stdin is not a TTY (tests, pipes), it reads a line in // the clear — echo doesn't matter in those contexts. diff --git a/internal/cli/webhook/register.go b/internal/cli/webhook/register.go new file mode 100644 index 0000000..d8320f8 --- /dev/null +++ b/internal/cli/webhook/register.go @@ -0,0 +1,203 @@ +package webhook + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// Retry budget for the create-time verification race. A fresh cloudflared +// quick-tunnel hostname can take up to ~a minute to resolve on Nylas's side, +// so CreateWebhook is retried (only on the verify-failure error) until it +// propagates. Package vars so tests can shrink them. +var ( + registerVerifyTimeout = 90 * time.Second + registerRetryInterval = 4 * time.Second +) + +// autoWebhookDescription tags webhooks created by `webhooks server --register` +// so they can be identified and swept on a later start (see registerWebhook). +const autoWebhookDescription = "nylas-cli webhook server (auto-registered)" + +// autoRegistration is the teardown handle for a webhook created by --register. +type autoRegistration struct { + client ports.WebhookClient + webhookID string +} + +// resolveRegisterTriggers returns the validated trigger list for --register, +// prompting interactively when none were passed on the command line. In +// non-interactive mode an empty list is a hard error — we won't guess what a +// scripted caller meant to subscribe to. +func resolveRegisterTriggers(triggers []string, interactive bool, p preflightPrompter) ([]string, error) { + if len(triggers) == 0 { + if !interactive { + return nil, common.NewUserError( + "--triggers is required with --register", + "Pass --triggers message.created (comma-separated for multiple). Run 'nylas webhooks triggers' to list them.", + ) + } + entered, err := p.Ask("Trigger types to subscribe to (comma-separated)", domain.TriggerMessageCreated) + if err != nil { + return nil, err + } + triggers = []string{entered} + } + return parseAndValidateTriggers(triggers) +} + +// ensureCloudflaredInstalled fails fast (before we prompt for triggers or touch +// the API) when cloudflared is missing. On an interactive macOS shell it offers +// the same brew auto-install the normal tunnel preflight does; otherwise it +// returns actionable install instructions. +func ensureCloudflaredInstalled(interactive bool, prompter preflightPrompter) error { + if cloudflaredInstalled() { + return nil + } + if interactive && cloudflaredViaBrew() { + confirm, err := prompter.Confirm("cloudflared is not installed. Install it via brew now?", true) + if err == nil && confirm { + if ierr := installCloudflaredFn(); ierr == nil && cloudflaredInstalled() { + return nil + } + } + } + return common.NewUserError( + "cloudflared is not installed", + "Install it with: brew install cloudflared (macOS) or see "+ + "https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/", + ) +} + +// isWebhookVerifyError reports whether err is Nylas rejecting the webhook URL +// because it couldn't reach/verify it yet (error 70005). This is the transient +// case while a fresh quick-tunnel hostname propagates through DNS — safe and +// expected to retry. Any other error (bad triggers, auth, quota) is terminal. +// +// It matches the typed APIError fields only (Nylas returns Type="70005" with +// the symbolic code in Message); matching the formatted err.Error() string +// would false-positive on an unrelated error whose request ID happened to +// contain "70005". +func isWebhookVerifyError(err error) bool { + var apiErr *domain.APIError + if !errors.As(err, &apiErr) { + return false + } + if apiErr.Type == "70005" { + return true + } + return strings.Contains(apiErr.Message, "verify.webhook_url") || + strings.Contains(apiErr.Message, "verify webhook URL") +} + +// registerWebhook deletes any stale auto-registered webhooks left by a previous +// crash, then creates a fresh webhook pointing at publicURL. Nylas verifies the +// URL synchronously at create time, so creation is retried while that +// verification keeps failing (the tunnel hostname is still propagating). The +// returned secret is the one Nylas minted (held in memory only) plus a teardown +// handle. +func registerWebhook(ctx context.Context, client ports.WebhookClient, publicURL string, triggers []string) (string, *autoRegistration, error) { + // Sweep stale auto webhooks from a prior hard-kill (Ctrl+C deletes cleanly; + // `kill -9`/crash does not). NOTE: this also removes the auto webhook of a + // *concurrent* --register session on the same Nylas app — acceptable for a + // local dev tool; switch to a per-process tag if concurrent servers matter. + if existing, err := client.ListWebhooks(ctx); err == nil { + for _, wh := range existing { + if wh.Description == autoWebhookDescription { + _ = client.DeleteWebhook(ctx, wh.ID) + } + } + } + + req := &domain.CreateWebhookRequest{ + WebhookURL: publicURL, + TriggerTypes: triggers, + Description: autoWebhookDescription, + } + + // Bound the whole verify-retry sequence. CreateWebhook is given the deadline + // context (not the parent ctx) so a single slow attempt can't exceed the + // budget via the client's own per-request timeout. + deadline, cancel := context.WithTimeout(ctx, registerVerifyTimeout) + defer cancel() + + for { + wh, err := client.CreateWebhook(deadline, req) + if err == nil { + // Nylas must return a signing secret — without it, verification + // would be silently disabled while we report it as on. Treat a + // missing secret as a failure and remove the half-created webhook. + if wh == nil || wh.ID == "" { + return "", nil, common.NewUserError("webhook create returned no webhook", "Retry --register.") + } + if wh.WebhookSecret == "" { + // Detached context so the cleanup delete still runs even if the + // parent ctx was already cancelled (e.g. Ctrl+C raced create). + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + delErr := client.DeleteWebhook(cleanupCtx, wh.ID) + cancel() + if delErr != nil { + return "", nil, common.NewUserError( + fmt.Sprintf("webhook %s was created without a signing secret and could not be removed: %v", wh.ID, delErr), + "Events could not be verified. Delete it manually: nylas webhooks delete "+wh.ID, + ) + } + return "", nil, common.NewUserError( + "webhook was created without a signing secret", + "Nylas returned no secret, so events could not be verified — the webhook was removed. "+ + "Retry --register, or register manually with a known secret.", + ) + } + return wh.WebhookSecret, &autoRegistration{client: client, webhookID: wh.ID}, nil + } + + // Parent context cancelled (e.g. Ctrl+C) — abort the registration + // promptly instead of running out the retry budget. + if ctx.Err() != nil { + return "", nil, ctx.Err() + } + // Terminal error (bad triggers, auth, quota). A deadline timeout also + // surfaces here as a non-verify error, so only fail fast while the + // budget is still alive; otherwise fall through to the budget message. + if !isWebhookVerifyError(err) && deadline.Err() == nil { + return "", nil, common.WrapCreateError("webhook", err) + } + // Verification failed because the tunnel URL isn't reachable from Nylas + // yet. Wait and retry until it propagates or we run out of budget. + select { + case <-deadline.Done(): + if ctx.Err() != nil { + return "", nil, ctx.Err() + } + return "", nil, common.NewUserError( + "Nylas could not reach the tunnel URL in time", + "A fresh cloudflared URL can take up to a minute to resolve globally. "+ + "Re-run --register, or register the URL manually once it's reachable.", + ) + case <-time.After(registerRetryInterval): + } + } +} + +// teardown deletes the auto-registered webhook. It uses its own context so the +// delete still runs even though the server's context was cancelled on shutdown. +// A failed delete is surfaced (not swallowed) so the user can remove the now- +// orphaned webhook — it points at a dead tunnel. +func (r *autoRegistration) teardown() { + if r == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := r.client.DeleteWebhook(ctx, r.webhookID); err != nil { + fmt.Fprintf(os.Stderr, "warn: could not delete auto-registered webhook %s: %v\n", r.webhookID, err) + fmt.Fprintf(os.Stderr, " remove it manually: nylas webhooks delete %s\n", r.webhookID) + } +} diff --git a/internal/cli/webhook/register_test.go b/internal/cli/webhook/register_test.go new file mode 100644 index 0000000..e61abf8 --- /dev/null +++ b/internal/cli/webhook/register_test.go @@ -0,0 +1,318 @@ +package webhook + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +// fakeWebhookClient is a minimal ports.WebhookClient for register tests. Only +// List/Create/Delete carry behaviour; the rest satisfy the interface. +type fakeWebhookClient struct { + existing []domain.Webhook + created *domain.CreateWebhookRequest + createResp *domain.Webhook + createErr error + deleted []string + + // verifyFailures makes CreateWebhook return the Nylas verify error (70005) + // this many times before succeeding — simulating tunnel-propagation delay. + verifyFailures int + createCalls int +} + +func (f *fakeWebhookClient) ListWebhooks(_ context.Context) ([]domain.Webhook, error) { + return f.existing, nil +} +func (f *fakeWebhookClient) GetWebhook(_ context.Context, _ string) (*domain.Webhook, error) { + return nil, nil +} +func (f *fakeWebhookClient) CreateWebhook(_ context.Context, req *domain.CreateWebhookRequest) (*domain.Webhook, error) { + f.created = req + f.createCalls++ + if f.createCalls <= f.verifyFailures { + // Mirrors Nylas: numeric code in Type, symbolic code in Message. + return nil, &domain.APIError{StatusCode: 400, Type: "70005", Message: "unable.verify.webhook_url : unable to verify webhook URL"} + } + if f.createErr != nil { + return nil, f.createErr + } + return f.createResp, nil +} +func (f *fakeWebhookClient) UpdateWebhook(_ context.Context, _ string, _ *domain.UpdateWebhookRequest) (*domain.Webhook, error) { + return nil, nil +} +func (f *fakeWebhookClient) DeleteWebhook(_ context.Context, id string) error { + f.deleted = append(f.deleted, id) + return nil +} +func (f *fakeWebhookClient) RotateWebhookSecret(_ context.Context, _ string) (*domain.RotateWebhookSecretResponse, error) { + return nil, nil +} +func (f *fakeWebhookClient) SendWebhookTestEvent(_ context.Context, _ string) error { return nil } +func (f *fakeWebhookClient) GetWebhookMockPayload(_ context.Context, _ string) (map[string]any, error) { + return nil, nil +} + +func TestResolveRegisterTriggers(t *testing.T) { + t.Run("non-interactive with no triggers errors", func(t *testing.T) { + _, err := resolveRegisterTriggers(nil, false, &mockPrompter{}) + if err == nil { + t.Fatal("expected error when --triggers omitted non-interactively") + } + }) + + t.Run("interactive prompts and validates the answer", func(t *testing.T) { + p := &mockPrompter{asks: []askResp{{value: "message.created,event.created"}}} + got, err := resolveRegisterTriggers(nil, true, p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{domain.TriggerMessageCreated, domain.TriggerEventCreated} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("triggers = %v, want %v", got, want) + } + if p.tAsks != 1 { + t.Errorf("expected exactly one Ask call, got %d", p.tAsks) + } + }) + + t.Run("flag-provided triggers are validated", func(t *testing.T) { + got, err := resolveRegisterTriggers([]string{"message.created"}, false, &mockPrompter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0] != domain.TriggerMessageCreated { + t.Fatalf("triggers = %v", got) + } + }) + + t.Run("invalid trigger rejected", func(t *testing.T) { + _, err := resolveRegisterTriggers([]string{"not.a.real.trigger"}, false, &mockPrompter{}) + if err == nil { + t.Fatal("expected invalid trigger to error") + } + }) +} + +func TestRegisterWebhook_SweepsStaleThenCreates(t *testing.T) { + client := &fakeWebhookClient{ + existing: []domain.Webhook{ + {ID: "stale-1", Description: autoWebhookDescription}, + {ID: "user-owned", Description: "my real webhook"}, + {ID: "stale-2", Description: autoWebhookDescription}, + }, + createResp: &domain.Webhook{ID: "new-id", WebhookSecret: "sekret"}, + } + + secret, reg, err := registerWebhook(context.Background(), client, + "https://x.trycloudflare.com/webhook", []string{domain.TriggerMessageCreated}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Only auto-tagged webhooks are swept; the user's own webhook is untouched. + if len(client.deleted) != 2 || client.deleted[0] != "stale-1" || client.deleted[1] != "stale-2" { + t.Errorf("swept = %v, want [stale-1 stale-2]", client.deleted) + } + if secret != "sekret" { + t.Errorf("secret = %q, want sekret", secret) + } + if reg == nil || reg.webhookID != "new-id" { + t.Fatalf("registration = %+v, want webhookID new-id", reg) + } + if client.created == nil || client.created.Description != autoWebhookDescription { + t.Errorf("created webhook missing auto description: %+v", client.created) + } + if client.created.WebhookURL != "https://x.trycloudflare.com/webhook" { + t.Errorf("created URL = %q", client.created.WebhookURL) + } +} + +func TestRegisterWebhook_RetriesVerifyErrorThenSucceeds(t *testing.T) { + // Shrink the retry cadence so the test doesn't wait the production interval. + origInterval, origTimeout := registerRetryInterval, registerVerifyTimeout + registerRetryInterval, registerVerifyTimeout = time.Millisecond, 5*time.Second + t.Cleanup(func() { registerRetryInterval, registerVerifyTimeout = origInterval, origTimeout }) + + client := &fakeWebhookClient{ + verifyFailures: 2, + createResp: &domain.Webhook{ID: "new-id", WebhookSecret: "sekret"}, + } + secret, reg, err := registerWebhook(context.Background(), client, "https://x/webhook", + []string{domain.TriggerMessageCreated}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client.createCalls != 3 { + t.Errorf("createCalls = %d, want 3 (2 verify failures + 1 success)", client.createCalls) + } + if secret != "sekret" || reg == nil || reg.webhookID != "new-id" { + t.Errorf("registration after retry = (%q, %+v)", secret, reg) + } +} + +func TestRegisterWebhook_GivesUpAfterTimeout(t *testing.T) { + origInterval, origTimeout := registerRetryInterval, registerVerifyTimeout + registerRetryInterval, registerVerifyTimeout = time.Millisecond, 20*time.Millisecond + t.Cleanup(func() { registerRetryInterval, registerVerifyTimeout = origInterval, origTimeout }) + + // Always fails verification — should exhaust the budget and return a clear + // "could not reach" error rather than spinning forever. + client := &fakeWebhookClient{verifyFailures: 1 << 30} + _, _, err := registerWebhook(context.Background(), client, "https://x/webhook", + []string{domain.TriggerMessageCreated}) + if err == nil { + t.Fatal("expected timeout error when verification never succeeds") + } + if !errorMessageContains(err, "could not reach the tunnel URL") { + t.Errorf("error = %v, want timeout message", err) + } +} + +func TestEnsureCloudflaredInstalled(t *testing.T) { + origInstalled, origBrew, origInstall := cloudflaredInstalled, cloudflaredViaBrew, installCloudflaredFn + t.Cleanup(func() { + cloudflaredInstalled, cloudflaredViaBrew, installCloudflaredFn = origInstalled, origBrew, origInstall + }) + + t.Run("already installed is a no-op", func(t *testing.T) { + cloudflaredInstalled = func() bool { return true } + p := &mockPrompter{} + if err := ensureCloudflaredInstalled(true, p); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.tConfirms != 0 { + t.Errorf("should not prompt when already installed") + } + }) + + t.Run("non-interactive errors without prompting", func(t *testing.T) { + cloudflaredInstalled = func() bool { return false } + p := &mockPrompter{} + err := ensureCloudflaredInstalled(false, p) + if err == nil || !errorMessageContains(err, "cloudflared is not installed") { + t.Fatalf("want not-installed error, got %v", err) + } + if p.tConfirms != 0 { + t.Errorf("should not prompt in non-interactive mode") + } + }) + + t.Run("interactive brew install succeeds", func(t *testing.T) { + calls := 0 + // First probe: not installed. After install: installed. + cloudflaredInstalled = func() bool { calls++; return calls > 1 } + cloudflaredViaBrew = func() bool { return true } + installed := false + installCloudflaredFn = func() error { installed = true; return nil } + p := &mockPrompter{confirms: []confirmResp{{value: true}}} + if err := ensureCloudflaredInstalled(true, p); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !installed { + t.Error("expected brew install to run") + } + }) + + t.Run("interactive decline errors", func(t *testing.T) { + cloudflaredInstalled = func() bool { return false } + cloudflaredViaBrew = func() bool { return true } + p := &mockPrompter{confirms: []confirmResp{{value: false}}} + err := ensureCloudflaredInstalled(true, p) + if err == nil || !errorMessageContains(err, "cloudflared is not installed") { + t.Fatalf("want not-installed error after decline, got %v", err) + } + }) +} + +func TestRegisterWebhook_CreateErrorPropagates(t *testing.T) { + client := &fakeWebhookClient{createErr: errors.New("boom")} + _, _, err := registerWebhook(context.Background(), client, "https://x/webhook", + []string{domain.TriggerMessageCreated}) + if err == nil { + t.Fatal("expected create error to propagate") + } +} + +func TestRegisterWebhook_EmptySecretIsRejectedAndCleanedUp(t *testing.T) { + // Nylas returns a webhook but no secret — verification would be silently + // off, so registerWebhook must fail AND delete the half-created webhook. + client := &fakeWebhookClient{createResp: &domain.Webhook{ID: "no-secret-id", WebhookSecret: ""}} + _, reg, err := registerWebhook(context.Background(), client, "https://x/webhook", + []string{domain.TriggerMessageCreated}) + if err == nil || !errorMessageContains(err, "without a signing secret") { + t.Fatalf("want empty-secret error, got %v", err) + } + if reg != nil { + t.Error("no registration handle should be returned on failure") + } + if len(client.deleted) != 1 || client.deleted[0] != "no-secret-id" { + t.Errorf("half-created webhook not cleaned up: deleted=%v", client.deleted) + } +} + +func TestIsWebhookVerifyError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"typed code 70005", &domain.APIError{Type: "70005", Message: "unable.verify.webhook_url : unable to verify webhook URL"}, true}, + {"message mentions verify.webhook_url", &domain.APIError{Type: "400", Message: "unable.verify.webhook_url"}, true}, + {"unrelated api error", &domain.APIError{Type: "auth.unauthorized", Message: "bad key"}, false}, + // The reason we dropped the err.Error() fallback: a request ID that + // happens to contain 70005 must NOT be treated as the verify error. + {"request id contains 70005", &domain.APIError{Type: "auth.unauthorized", Message: "bad key", RequestID: "170005-abc"}, false}, + {"non-api error", errors.New("dial tcp: no such host"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isWebhookVerifyError(tt.err); got != tt.want { + t.Errorf("isWebhookVerifyError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +func TestRegisterTeardownDeletesWebhook(t *testing.T) { + client := &fakeWebhookClient{} + reg := &autoRegistration{client: client, webhookID: "del-me"} + reg.teardown() + if len(client.deleted) != 1 || client.deleted[0] != "del-me" { + t.Errorf("deleted = %v, want [del-me]", client.deleted) + } +} + +// TestRunServer_RegisterFlagConflicts locks in the mutually-exclusive flag +// gates so --register can never be combined with manual-secret options (which +// would be silently ignored) or with --no-tunnel (which leaves no URL to +// register). +func TestRunServer_RegisterFlagConflicts(t *testing.T) { + tests := []struct { + name string + secret string + allowUnsigned bool + noTunnel bool + wantContains string + }{ + {name: "secret", secret: "s", wantContains: "--secret cannot be combined"}, + {name: "allow-unsigned", allowUnsigned: true, wantContains: "--allow-unsigned cannot be combined"}, + {name: "no-tunnel", noTunnel: true, wantContains: "requires a public tunnel"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := runServer(0, "/webhook", "", tt.secret, tt.allowUnsigned, tt.noTunnel, + true /* register */, []string{"message.created"}, false, true /* quiet */) + if err == nil { + t.Fatal("expected conflict error, got nil") + } + if !errorMessageContains(err, tt.wantContains) { + t.Errorf("error = %v, want substring %q", err, tt.wantContains) + } + }) + } +} diff --git a/internal/cli/webhook/server.go b/internal/cli/webhook/server.go index 9868ebd..13fc0a7 100644 --- a/internal/cli/webhook/server.go +++ b/internal/cli/webhook/server.go @@ -3,6 +3,7 @@ package webhook import ( "context" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -31,6 +32,8 @@ func newServerCmd() *cobra.Command { webhookSecret string allowUnsigned bool noTunnel bool + register bool + triggers []string jsonOutput bool quiet bool ) @@ -48,6 +51,12 @@ HMAC signature on each incoming event. Pass --allow-unsigned to opt out explicitly (events from anyone who can reach the public tunnel URL will be processed). +Pass --register to skip the manual setup entirely: the CLI creates a Nylas +webhook for the live tunnel URL, fetches the signing secret automatically, +and deletes the webhook again on exit. Use --triggers to choose the event +types (you'll be prompted if it's omitted on a terminal). With --register you +do not pass --secret; it is fetched from Nylas. + If neither --tunnel nor --no-tunnel is set, the command runs an interactive preflight that detects cloudflared and offers to enable it. Pass --no-tunnel to skip the preflight and run loopback-only (useful @@ -63,12 +72,15 @@ Examples: # Start server with cloudflared tunnel + signature verification nylas webhooks server --tunnel cloudflared --secret your-webhook-secret + # Auto-create the Nylas webhook, fetch its secret, and clean up on exit + nylas webhooks server --tunnel cloudflared --register --triggers message.created + # Start server with tunnel and explicitly accept unsigned events nylas webhooks server --tunnel cloudflared --allow-unsigned Press Ctrl+C to stop the server.`, RunE: func(cmd *cobra.Command, args []string) error { - return runServer(port, path, tunnelType, webhookSecret, allowUnsigned, noTunnel, jsonOutput, quiet) + return runServer(port, path, tunnelType, webhookSecret, allowUnsigned, noTunnel, register, triggers, jsonOutput, quiet) }, } @@ -78,13 +90,15 @@ Press Ctrl+C to stop the server.`, cmd.Flags().StringVarP(&webhookSecret, "secret", "s", "", "Webhook secret for signature verification") cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "Allow unsigned webhook events when --tunnel is set (insecure)") cmd.Flags().BoolVar(&noTunnel, "no-tunnel", false, "Skip the tunnel preflight prompt and run loopback-only") + cmd.Flags().BoolVar(®ister, "register", false, "Auto-create a Nylas webhook for the tunnel URL, fetch its secret, and delete it on exit") + cmd.Flags().StringSliceVar(&triggers, "triggers", nil, "Trigger types for --register (comma-separated or repeated; prompted if omitted)") cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output events as JSON") cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Suppress startup messages, only show events") return cmd } -func runServer(port int, path, tunnelType, webhookSecret string, allowUnsigned, noTunnel, jsonOutput, quiet bool) error { +func runServer(port int, path, tunnelType, webhookSecret string, allowUnsigned, noTunnel, register bool, triggers []string, jsonOutput, quiet bool) error { // --tunnel and --no-tunnel are mutually exclusive: the user can't both // request a tunnel and opt out of one in the same invocation. if tunnelType != "" && noTunnel { @@ -94,11 +108,66 @@ func runServer(port int, path, tunnelType, webhookSecret string, allowUnsigned, ) } + interactive := term.IsTerminal(int(os.Stdin.Fd())) + + // --register short-circuits the manual secret workflow: we fetch the secret + // from Nylas after the tunnel is up. Validate its prerequisites up front and + // resolve the trigger list before we start anything. + var registerTriggers []string + var registerClient ports.NylasClient + if register { + if webhookSecret != "" { + return common.NewUserError( + "--secret cannot be combined with --register", + "With --register the signing secret is fetched from Nylas automatically. Drop --secret.", + ) + } + if allowUnsigned { + return common.NewUserError( + "--allow-unsigned cannot be combined with --register", + "--register always verifies events with the secret Nylas mints. Drop --allow-unsigned.", + ) + } + if noTunnel { + return common.NewUserError( + "--register requires a public tunnel", + "Remove --no-tunnel; --register needs a tunnel URL to register with Nylas.", + ) + } + // --register implies a tunnel; default to cloudflared so the bare + // `--register` command works without also typing --tunnel. + if tunnelType == "" { + tunnelType = "cloudflared" + } + + // Prompts go to stderr under --json so they never pollute the JSONL + // stream on stdout. + var prompter preflightPrompter = newStdinPrompter() + if jsonOutput { + prompter = newStderrPrompter() + } + + // Verify cloudflared up front (offering brew install when possible) so + // we don't prompt for triggers or hit the API only to fail later. + if err := ensureCloudflaredInstalled(interactive, prompter); err != nil { + return err + } + + var err error + registerTriggers, err = resolveRegisterTriggers(triggers, interactive, prompter) + if err != nil { + return err + } + registerClient, err = common.GetNylasClient() + if err != nil { + return err + } + } + // Interactive preflight when neither --tunnel nor --no-tunnel was set. // May modify tunnelType/webhookSecret/allowUnsigned, or signal that the // user wants to exit (e.g. cloudflared not installed and they declined // loopback-only). - interactive := term.IsTerminal(int(os.Stdin.Fd())) resolvedTunnel, resolvedSecret, resolvedAllowUnsigned, exit, err := preflightTunnelChoice( newStdinPrompter(), interactive, @@ -117,7 +186,8 @@ func runServer(port int, path, tunnelType, webhookSecret string, allowUnsigned, // When exposing the server via a tunnel and no secret was provided, // prompt interactively if possible. For --json mode the prompt is // written to stderr so it doesn't pollute the JSONL stream on stdout. - if tunnelType != "" && webhookSecret == "" && !allowUnsigned && interactive { + // Skipped for --register: the secret is fetched from Nylas after start. + if !register && tunnelType != "" && webhookSecret == "" && !allowUnsigned && interactive { var p preflightPrompter if jsonOutput { p = newStderrPrompter() @@ -141,7 +211,8 @@ func runServer(port int, path, tunnelType, webhookSecret string, allowUnsigned, } // Hard gate: non-interactive callers must pass --secret or --allow-unsigned. - if tunnelType != "" && webhookSecret == "" && !allowUnsigned { + // --register is exempt — it fetches the secret from Nylas automatically. + if !register && tunnelType != "" && webhookSecret == "" && !allowUnsigned { return common.NewUserError( "--secret is required when --tunnel is set", "Pass --secret to verify the HMAC signature on each event, "+ @@ -175,13 +246,11 @@ func runServer(port int, path, tunnelType, webhookSecret string, allowUnsigned, } } - // Set up context with cancellation - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Handle interrupt signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + // Cancel ctx on SIGINT/SIGTERM so long-running steps (tunnel start, webhook + // registration retries) abort promptly on Ctrl+C instead of only being + // noticed after they return. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() // Suppress human-readable chrome when --json is set so stdout is pure JSONL. showChrome := !quiet && !jsonOutput @@ -197,11 +266,23 @@ func runServer(port int, path, tunnelType, webhookSecret string, allowUnsigned, spinner.Start() } + // In --register mode the listener goes live (and the tunnel is public) + // before we've fetched the signing secret. Gate POST events until + // UpdateSecret installs it so no unsigned event is ever processed. + if register { + server.AwaitSecret() + } + // Start the server if err := server.Start(ctx); err != nil { if spinner != nil { spinner.Stop() } + // Ctrl+C during tunnel startup cancels ctx — treat as a clean exit, + // matching the registration and steady-state shutdown paths. + if errors.Is(err, context.Canceled) { + return nil + } return common.WrapError(err) } @@ -209,12 +290,46 @@ func runServer(port int, path, tunnelType, webhookSecret string, allowUnsigned, spinner.Stop() } + // Auto-register the webhook now that the tunnel URL is live. This must run + // after Start (the listener has to be up to answer Nylas's create-time + // challenge) and before we surface the secret, which Nylas only mints on + // create. UpdateSecret swaps the verified secret into the running server. + var registration *autoRegistration + if register { + stats := server.GetStats() + + // Register with Nylas. CreateWebhook verifies the URL synchronously and + // is retried inside registerWebhook while the fresh tunnel hostname is + // still propagating to Nylas's network (error 70005). + var regSpinner *common.Spinner + if showChrome { + regSpinner = common.NewSpinner("Registering webhook with Nylas (waiting for tunnel to propagate)...") + regSpinner.Start() + } + secret, reg, regErr := registerWebhook(ctx, registerClient, stats.PublicURL, registerTriggers) + if regSpinner != nil { + regSpinner.Stop() + } + if regErr != nil { + _ = server.Stop() + // Ctrl+C during registration cancels ctx — treat as a clean exit + // rather than surfacing "context canceled" as an error. + if errors.Is(regErr, context.Canceled) { + return nil + } + return regErr + } + server.UpdateSecret(secret, defaultSignedWebhookMaxEventAge) + registration = reg + defer registration.teardown() + } + // Print server info stats := server.GetStats() if jsonOutput { - printStartupJSON(stats, tunnelType) + printStartupJSON(stats, tunnelType, registration) } else if !quiet { - printServerInfo(stats, tunnelType) + printServerInfo(stats, tunnelType, registration, registerTriggers) } // Event display loop. Recover from any panic in the formatters so a @@ -244,8 +359,8 @@ func runServer(port int, path, tunnelType, webhookSecret string, allowUnsigned, } }() - // Wait for interrupt - <-sigChan + // Wait for interrupt (ctx is cancelled by SIGINT/SIGTERM). + <-ctx.Done() if showChrome { fmt.Println("\n\nShutting down server...") @@ -446,7 +561,7 @@ func printStartupBanner() { fmt.Println() } -func printServerInfo(stats ports.WebhookServerStats, tunnelType string) { +func printServerInfo(stats ports.WebhookServerStats, tunnelType string, registration *autoRegistration, triggers []string) { _, _ = common.Green.Println("✓ Server started successfully") fmt.Println() @@ -462,10 +577,19 @@ func printServerInfo(stats ports.WebhookServerStats, tunnelType string) { } fmt.Println() - if stats.PublicURL != "" { + switch { + case registration != nil: + // Auto-registered: nothing for the user to do — the webhook exists, + // the secret is loaded, and it'll be deleted on exit. + _, _ = common.Green.Println("✓ Webhook registered with Nylas (signature verification on)") + fmt.Printf(" ID: %s\n", registration.webhookID) + fmt.Printf(" Triggers: %s\n", strings.Join(triggers, ", ")) + _, _ = common.Dim.Println(" This webhook is deleted automatically when the server stops.") + case stats.PublicURL != "": _, _ = common.Yellow.Println("Register this URL with Nylas:") fmt.Printf(" nylas webhooks create --url %s --triggers message.created\n", stats.PublicURL) - } else { + _, _ = common.Dim.Println(" Or re-run with --register to do this automatically.") + default: _, _ = common.Yellow.Println("⚠ Loopback-only server") fmt.Println(" Nylas cannot deliver webhooks to localhost. To expose this server") fmt.Println(" publicly, re-run with:") @@ -479,7 +603,7 @@ func printServerInfo(stats ports.WebhookServerStats, tunnelType string) { fmt.Println() } -func printStartupJSON(stats ports.WebhookServerStats, tunnelType string) { +func printStartupJSON(stats ports.WebhookServerStats, tunnelType string, registration *autoRegistration) { obj := map[string]any{ "type": "server.started", "local_url": stats.LocalURL, @@ -489,6 +613,10 @@ func printStartupJSON(stats ports.WebhookServerStats, tunnelType string) { obj["tunnel_provider"] = tunnelType obj["tunnel_status"] = stats.TunnelStatus } + if registration != nil { + obj["webhook_id"] = registration.webhookID + obj["webhook_registered"] = true + } data, err := json.Marshal(obj) if err != nil { return diff --git a/internal/cli/webhook/server_test.go b/internal/cli/webhook/server_test.go index 41f8754..de65d85 100644 --- a/internal/cli/webhook/server_test.go +++ b/internal/cli/webhook/server_test.go @@ -11,8 +11,10 @@ import ( type mockPrompter struct { confirms []confirmResp passwords []passwordResp + asks []askResp tConfirms int tPasswords int + tAsks int } type confirmResp struct { @@ -25,6 +27,11 @@ type passwordResp struct { err error } +type askResp struct { + value string + err error +} + func (m *mockPrompter) Confirm(message string, defaultYes bool) (bool, error) { if m.tConfirms >= len(m.confirms) { return defaultYes, nil @@ -43,6 +50,15 @@ func (m *mockPrompter) Password(message string) (string, error) { return r.value, r.err } +func (m *mockPrompter) Ask(message, defaultValue string) (string, error) { + if m.tAsks >= len(m.asks) { + return defaultValue, nil + } + r := m.asks[m.tAsks] + m.tAsks++ + return r.value, r.err +} + // TestPreflightTunnelChoice_BypassedInScriptedModes confirms the preflight // returns immediately (no prompting, no tunnel change) whenever the caller // has already made an explicit choice. interactive=true is used here so the @@ -220,7 +236,7 @@ func TestPreflightTunnelChoice_EmptySecretWithExplicitConfirmEnablesUnsigned(t * // rejected). Kept here next to the preflight tests so the security gate // is visible to anyone reading the file. func TestPreflightTunnelChoice_TunnelMutexErrorAtRunServer(t *testing.T) { - err := runServer(0, "/webhook", "cloudflared", "", false, true /* noTunnel */, false, true /* quiet */) + err := runServer(0, "/webhook", "cloudflared", "", false, true /* noTunnel */, false /* register */, nil /* triggers */, false, true /* quiet */) if err == nil { t.Fatal("expected --tunnel + --no-tunnel to error, got nil") }