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") }