Repository navigation
feat(webhooks): signed, retried webhooks built from the audit log - #580
Merged
Merged
Conversation
DELETE /users/credentials removed a passkey without writing an audit event, so a change to what an account can sign in with left no trace. It now writes credentials_deleted naming the passkey.
Adds webhook endpoints and deliveries behind an audited admin API with step-up on every write. Payloads use the Standard Webhooks signature scheme, are built from committed audit rows past a cursor, and are retried eight times over just under 42 hours before they are dead lettered. An endpoint that answers 410 or fails for three days is disabled. Every callout to an adopter URL goes through one transport that checks the address it connects to, never reaches link-local, multicast or the API itself, never follows redirects, and in production refuses private addresses unless CALLOUT_PRIVATE_HOSTS lists the host. Signing secrets are encrypted under CALLOUT_SECRET_ENCRYPTION_KEY. Writes session_created once per completed sign-in, which user.signed_in is built from, since login_success precedes any factor. Records the design in ADR 0005. Closes #418.
This was referenced Nov 8, 2026
A disabled endpoint was given no deliveries, so events that happened while it was off were never sent, including after the API disabled it for a 410 or three days of failures. Its deliveries are now queued on hold, and turning it back on releases them, back as far as the 30 days deliveries are kept. The release uses the database clock, which the due check compares against.
Bccorb
marked this pull request as ready for review
November 8, 2026 03:28
Bccorb
added a commit
that referenced
this pull request
Nov 8, 2026
Bccorb
added a commit
that referenced
this pull request
Nov 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #418. First of the Wave 4 API PRs, and it records the Wave 4 design as ADR 0005 (agreed 2026-11-07).
Builds on
@seamless-auth/types0.40.0 (fells-code/seamless-auth-types#156 and #158), published. 0.40.0 addscustomClaims(default empty) andclaims_mismatchto the shared OIDC account claims and sign refusal schemas, so the account claims edge here returnscustomClaims: {}until #585 fills it.What it adds
/admin/webhooksand/admin/webhook-deliveries: create, update, disable and delete endpoints; rotate the signing secret (the old one keeps signing beside the new one for 24 hours); send a test event; list deliveries newest first with astatus=deaddead letter view; and redeliver. Every write isadmin:writeplus step-up and is audited. The secret is shown once.seqvalues are a gapless prefix and the cursor cannot skip a row. On first run the cursor starts at the newest row, so turning webhooks on replays no history. Replicas take turns on an advisory lock. Workers claim deliveries withFOR UPDATE SKIP LOCKEDand a lease.Retry-Afteris honored up to 24 hours. A410disables the endpoint, and so do three days of nothing but failures. Deliveries are pruned after 30 days. A disabled endpoint's deliveries are queued on hold, and turning it back on releases them, so it catches up on what it missed (decided 2026-11-07).src/lib/calloutTransport.ts) for this and the coming hooks and sinks. It allowshttpsonly in production and refuses userinfo and the API's own issuer host. It checks addresses inside the socket's DNS lookup, so rebinding is defeated. Link-local, multicast and unspecified addresses are never reachable. In production, private addresses are refused unless the operator lists the host inCALLOUT_PRIVATE_HOSTS. Redirects are never followed, responses are capped at 64 KiB, and there is a whole-request deadline. Failures are reported coarsely, never with the resolved address.CALLOUT_SECRET_ENCRYPTION_KEY. Production refuses to start without it once a webhook exists, and refuses to create one without it.The address classification is moved out of
oauthProviderPolicy.tsintooutboundAddress.tsunchanged, so both transports share one list.Found while building
DELETE /users/credentialswrote no audit event. Removing a passkey left no trace. Fixed in its own commit, with a reproducing test that fails onmain.login_successis written when/loginresolves an identifier's methods, before any factor.issueSessionAndRespondnow writessession_created, whichuser.signed_inis built from.registration_successmetadata, since account registration writes the same type.organization_member_updatednow records the roles after the change.Contract changes
New admin routes (in
openapi.jsonandadapter-manifest.json), the new audit event typessession_created,webhook_endpoint_disabledandadmin_webhook_*, and the two env vars. Nosystem_configkeys, so the CLI writable keys test is unaffected. Downstream: console #335, CLI #343 (webhooks half) and seamless-auth-server#269 (verifyWebhook) build on this after review.Checks
Run locally at 7027943 on
@seamless-auth/types0.40.0 from npm, by the pre-commit hook:npm run coverage: 3175 passed, 1 todo. Lines 99.27%, statements 99.08%, branches 95.75%, functions 99.4%. The new files are at or near 100%.npm run lint,npm run format,npm run typecheckandnpm run buildpass.npm run generate:apiregenerates with no further diff.TEST_DB=postgresproject against Postgres 18 (at ba740fc, on 0.39.0): 259 passed, includingtests/postgres/webhooks.spec.ts, which runs dispatch and delivery end to end against a local receiver. Not rerun on 0.40.0; the change touches no database code.Not run locally: the e2e project.