Skip to content

feat(auth): signup hook for new-user provisioning notifications - #680

Open
jiashuoz wants to merge 1 commit into
mainfrom
feat/signup-hook
Open

feat(auth): signup hook for new-user provisioning notifications#680
jiashuoz wants to merge 1 commit into
mainfrom
feat/signup-hook

Conversation

@jiashuoz

Copy link
Copy Markdown
Member

What

Adds an optional signup hook: when a brand-new user account is created via Google OAuth signup, the server fires an HMAC-signed HTTP POST to a configured URL. This lets an external service — e.g. a hosted deployment's billing sidecar — run first-touch provisioning such as sending a welcome email. Self-host default is off (empty URL = disabled, zero behavior change).

Why

The hosted deployment wants to welcome-email new signups. The server is the only component that knows at the moment of signup whether the OAuth upsert created a new user or matched a returning one, so it exposes that moment as an internal webhook, mirroring the existing account-deletion billing hook.

Design notes

  • Mirrors the deletion billing hook exactly (notifyBillingUserDeleted in internal/agent/user_data_rights_api.go): JSON body, X-E2A-Internal-Signature: hex(HMAC-SHA256(internal_api_secret, body)), 5s timeout, expects 204 No Content, logs and continues on any failure.
  • New-user discriminator: CreateOrGetUser's INSERT … ON CONFLICT DO UPDATE upsert gains a (xmax = 0) AS inserted RETURNING column — Postgres's system column xmax is 0 only on a freshly inserted row. Exposed as CreateOrGetUserWithCreated returning (user, created, err); CreateOrGetUser stays a thin wrapper so its many existing callers (prober seeds, test harness, dozens of tests) are untouched.
  • Trigger lives in HandleCallback, not the store upsert — deliberately, so synthetic accounts (prober seeds, -bootstrap-email, test harnesses) never fire the hook. OIDC login never provisions users, so it's out of scope by construction.
  • Best-effort, never blocks login: fired from a goroutine with its own timeout-bounded context.Background() (the request context is canceled the instant the login response is written). Any failure is logged and dropped.
  • Config: limits.signup_hook_url (yaml) / E2A_SIGNUP_HOOK_URL (env), sitting next to billing_hook_url and signed with the same limits.internal_api_secret. Like billing_hook_url, no extra validation — empty disables.

Payload:

{"user_id": "", "email": "", "name": ""}

Test coverage

  • internal/auth/signup_hook_test.go (drives HandleCallback through the existing fake-Google harness, real test DB):
    • new user → hook receives {user_id, email, name} with a matching independently-recomputed HMAC; login still 302s to /dashboard
    • returning user (same google_subject) → hook stays silent
    • empty hook URL (self-host default) → no outbound call
    • hook endpoint down (connection refused) → login still succeeds and the user row exists
  • internal/identity/user_upsert_test.go: xmax discriminator regression — created=true on first insert, false on the DO UPDATE path (with profile-field refresh and stable user ID), true again for a distinct subject; wrapper contract preserved.
  • Full runs of the changed packages (internal/identity, internal/auth, internal/config, internal/agent) green against an isolated test DB; make test-unit green; go build ./... + go vet on changed packages clean.

Not a client API change — server-internal config + internal webhook only; SDKs/CLI/MCP untouched.

🤖 Generated with Claude Code

Fire an HMAC-signed HTTP POST when a brand-new user account is created
via Google OAuth signup, so an external service (e.g. a hosted
deployment's billing sidecar) can run first-touch provisioning like a
welcome email. Mirrors the account-deletion billing hook precisely:
same X-E2A-Internal-Signature HMAC-SHA256 scheme over the JSON body,
same limits.internal_api_secret, 5s timeout, log-and-continue.

- identity: CreateOrGetUserWithCreated surfaces a created signal from
  the users upsert via RETURNING (xmax = 0); CreateOrGetUser stays a
  thin wrapper so its many callers are untouched.
- config: limits.signup_hook_url (E2A_SIGNUP_HOOK_URL), empty =
  disabled, symmetric to billing_hook_url.
- auth: HandleCallback fires the hook asynchronously (goroutine with
  its own timeout-bounded context) only when the upsert INSERTed —
  best-effort, so login latency and success never depend on the hook.
  Placing the trigger in the OAuth callback (not the store) keeps
  prober seeds, -bootstrap-email, and test-harness accounts silent.
- docs: config.example.yaml + deployment.md entries.
- tests: hook fires with correct payload+signature for a new user,
  stays silent for returning users and when unconfigured, login
  succeeds with the endpoint down; store-level xmax discriminator
  regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant