credentagent.webhooks: a real, signed HTTP order.settled (#101) - #102
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eef91c00f7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const realFetch: WebhookTransport = async (url, init) => { | ||
| const res = await (globalThis.fetch as typeof fetch)(url, init); | ||
| return { status: res.status }; |
There was a problem hiding this comment.
Disable redirect following for webhook deliveries
globalThis.fetch follows redirects by default, so an endpoint that responds with a 3xx causes the gate to issue a second request to the redirect target and treats its eventual 2xx as successful delivery. This contradicts the webhook SSRF boundary and permits a configured or compromised external endpoint to redirect delivery traffic to an internal host; set redirect: "manual" and let the existing non-2xx retry/error path handle redirects.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agree with codex, PTAL
There was a problem hiding this comment.
Fixed in cbd726e: the default transport now sends redirect: "manual", so a 3xx surfaces as a failed attempt and flows into the existing retry/error path instead of being followed. Pinned by a bypass test that runs the real fetch transport against a local redirector server and asserts the redirect target never receives the delivery (lastStatus: 302 reported via onDeliveryError). Verified load-bearing: with redirect: "follow" restored, the test goes red.
| const realFetch: WebhookTransport = async (url, init) => { | ||
| const res = await (globalThis.fetch as typeof fetch)(url, init); | ||
| return { status: res.status }; |
There was a problem hiding this comment.
Bound each webhook attempt with a timeout
When a receiver accepts the TCP connection but never sends response headers, this fetch promise remains pending, so deliverTo never reaches its retry/backoff loop or invokes onDeliveryError. Since completion fires these promises without awaiting them, repeated settled orders against such an endpoint can accumulate indefinitely pending requests; use an AbortController timeout per attempt so failures are retried and eventually reported.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cbd726e: each attempt is bounded by a per-attempt timeout (timeoutMs option, default 10s). An AbortController aborts the fetch, and the attempt is additionally raced against the abort signal so even an injected transport that ignores the signal cannot leave deliverTo pending — the timeout feeds the normal retry/backoff loop and ultimately onDeliveryError. Pinned by a bypass test with a transport that accepts and never responds: it asserts the retry ran and the exhaustion was reported (the test hangs red if the timeout is removed).
eef91c0 to
e253c92
Compare
| const realFetch: WebhookTransport = async (url, init) => { | ||
| const res = await (globalThis.fetch as typeof fetch)(url, init); | ||
| return { status: res.status }; |
There was a problem hiding this comment.
Agree with codex, PTAL
| - **Rejected** → `WebhookRefusalCode`: `no-signature`, `bad-format`, `no-match` (forged / tampered / | ||
| wrong-secret), `timestamp` (replay). Secret is never logged, never in a delivery record. | ||
| - **Secret** `whsec_` + base64url(32 random bytes). **SSRF:** endpoint URLs are trusted developer config | ||
| (not user input); https required by default (http allowed for localhost dev); no redirect-following. |
There was a problem hiding this comment.
"https required by default" isn't actually enforced, endpoints (constructor + register()) accept any scheme as is. (The "no redirect-following" half is already covered by the codex P1 comment on webhooks.ts:172.) Either validate the scheme where endpoints enter (https only; http for localhost), or drop that clause from the security model.
There was a problem hiding this comment.
Enforced rather than dropped (cbd726e), since the clause is the documented SSRF stance: endpoint URLs are now validated at both entry points — the constructor and register() — https required, http allowed only for localhost dev (localhost, *.localhost, 127.0.0.1, [::1]). Invalid or non-https URLs throw a TypeError at config time. The spec now reads "https is enforced where endpoints enter" instead of "required by default", and a bypass test covers both doors.
Three review findings on the delivery path, each now enforced and pinned: - Redirects are never followed (codex P1): the default transport sends redirect: "manual", so a 3xx from a receiver surfaces as a failed delivery instead of bouncing the request to whatever host the redirect names. Pinned by a bypass test against a real local redirector — the redirect target must never see the delivery. - Every attempt is bounded by a timeout (codex P2): an AbortController aborts the request after timeoutMs (default 10s), and the attempt races the abort so even a transport that ignores the signal cannot leave the delivery pending forever. Timeouts feed the existing retry + backoff + onDeliveryError path. - "https required by default" is now actually enforced (review): endpoint URLs are validated where they enter — the constructor and register() — https only, http allowed for localhost dev. The spec now says "enforced", not just "required". Each control was temporarily deleted to prove its bypass test goes red (redirect: "follow" back on; the abort race removed; the URL check short-circuited) — all three tests failed, then passed again restored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVGXhbkiEsNa6kC7eEScu8 Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…ttled
The in-process on('order.settled') listener can't reach a different service. This adds the
durable, cross-service signal: when an order settles, the gate POSTs a SIGNED event to the
endpoints you registered, and the receiver verifies it in one call — the Stripe idiom.
Design run through the DX council (4 designs → 8-vote judge panel, all 9/10 on Stripe-
idiomaticity). Surface (spec 010):
- SEND: new CredentAgent({ webhooks: { endpoints: [{ url, secret }] } }) — settled orders are
POSTed, signed, retried with backoff, fire-and-forget (never blocks/rolls back completion).
webhooks.register({url}) mints a whsec_ secret at runtime (process-local).
- RECEIVE (standalone — a receiver needs no CredentAgent): constructEvent(rawBody, sigHeader,
secret) → typed WebhookEvent or THROWS (Stripe door); verifyEvent(...) → verdict, never throws.
- generateWebhookSecret(); Stripe-shaped event { id, type, created, data: { object } }.
Security (spec §Security): HMAC-SHA256 over `${t}.${rawBody}`, header
'CredentAgent-Signature: t=,v1=' (hex), timingSafeEqual, timestamp bound into the MAC, ±300s
replay window, secret never logged, https-by-default endpoints. Fired from the ONE completion
choke point (orders._complete), alongside the in-process event.
Honesty: at-least-once delivery + retry; dedupe on event.id; no guaranteed/exactly-once claim.
Unrelated to presence-only-demo (that's mdoc issuer trust).
Tests: webhooks.test.ts (13) incl. 3 load-bearing bypass tests — wrong-secret, tampered body,
and stale-timestamp (replay) each proven RED when its control is removed. Additive: full suite
green (gate 340, storefront 86). Runnable example examples/order-webhooks/ (sender + receiver +
smoke) verified end-to-end over real HTTP, incl. a forged event rejected 400.
Refs #101 · epic #97 · spec 010
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Three review findings on the delivery path, each now enforced and pinned: - Redirects are never followed (codex P1): the default transport sends redirect: "manual", so a 3xx from a receiver surfaces as a failed delivery instead of bouncing the request to whatever host the redirect names. Pinned by a bypass test against a real local redirector — the redirect target must never see the delivery. - Every attempt is bounded by a timeout (codex P2): an AbortController aborts the request after timeoutMs (default 10s), and the attempt races the abort so even a transport that ignores the signal cannot leave the delivery pending forever. Timeouts feed the existing retry + backoff + onDeliveryError path. - "https required by default" is now actually enforced (review): endpoint URLs are validated where they enter — the constructor and register() — https only, http allowed for localhost dev. The spec now says "enforced", not just "required". Each control was temporarily deleted to prove its bypass test goes red (redirect: "follow" back on; the abort race removed; the URL check short-circuited) — all three tests failed, then passed again restored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVGXhbkiEsNa6kC7eEScu8 Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
cbd726e to
98176a1
Compare
Three review findings on the delivery path, each now enforced and pinned: - Redirects are never followed (codex P1): the default transport sends redirect: "manual", so a 3xx from a receiver surfaces as a failed delivery instead of bouncing the request to whatever host the redirect names. Pinned by a bypass test against a real local redirector — the redirect target must never see the delivery. - Every attempt is bounded by a timeout (codex P2): an AbortController aborts the request after timeoutMs (default 10s), and the attempt races the abort so even a transport that ignores the signal cannot leave the delivery pending forever. Timeouts feed the existing retry + backoff + onDeliveryError path. - "https required by default" is now actually enforced (review): endpoint URLs are validated where they enter — the constructor and register() — https only, http allowed for localhost dev. The spec now says "enforced", not just "required". Each control was temporarily deleted to prove its bypass test goes red (redirect: "follow" back on; the abort race removed; the URL check short-circuited) — all three tests failed, then passed again restored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVGXhbkiEsNa6kC7eEScu8 Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
98176a1 to
8dc2c27
Compare
|
Deferred by maintainer decision (revisit post-merge): a callback-verification note in the receiver example (signature proves sender, not order existence — confirm via |
Brings the branch up to date with Demo PKI (#100), signed order.settled webhooks (#101/#102), orders.serve(app) (#97/#98) and the Vercel deploy smoke CI (#105). Conflict: packages/credentagent-gate/src/client.ts — main added `ordersServed` and 008 added `delegated` as adjacent private fields. Kept BOTH (independent features). Also refreshed the `delegated` comment to match the two-step routing committed in c3e8637: only the PAYMENT goes to the delegated ceremony; identity gates (age) and a discount stay on the built-in credential rail. 451 tests pass across both packages; both typecheck. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ever Morales <ever.morales@koombea.com>
Version bump for both packages (gate dep → ^0.3.0 in the storefront). Since 0.2.0: - credentagent.orders.* + orders.serve(app) — the checkout in one call (#98) - credentagent.webhooks — signed HTTP order.settled + constructEvent (#102) - credentagent.grants.* + grants.serve(app) — approve once, spend while away; allow bounds, typed GrantDoorCode, immutable sealed bounds, refusal replay (#112) - OrderDoorCode typed union (#111); demo hub + guarantees (#112) README: adds the missing Grants section (published docs must match the surface); honesty labels unchanged (presence-only-demo / delegated-demo / server-issued-demo). The quickstart's dep bump to ^0.3.0 follows AFTER publish (its CI smoke installs from the registry, so bumping first would 404). Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…115) Version bump for both packages (gate dep → ^0.3.0 in the storefront). Since 0.2.0: - credentagent.orders.* + orders.serve(app) — the checkout in one call (#98) - credentagent.webhooks — signed HTTP order.settled + constructEvent (#102) - credentagent.grants.* + grants.serve(app) — approve once, spend while away; allow bounds, typed GrantDoorCode, immutable sealed bounds, refusal replay (#112) - OrderDoorCode typed union (#111); demo hub + guarantees (#112) README: adds the missing Grants section (published docs must match the surface); honesty labels unchanged (presence-only-demo / delegated-demo / server-issued-demo). The quickstart's dep bump to ^0.3.0 follows AFTER publish (its CI smoke installs from the registry, so bumping first would 404). Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
… main) main advanced 11 commits past this branch's fork point (the orders / webhooks / grants merges — #98, #102, #112, #118, #121, #123 — and the #120 browse-products fix). GitHub flagged PR #103 CONFLICTING. Reconcile MERGE (not rebase — the repo's precedent is reconcile merges, and rebasing would rewrite pushed, DCO-signed history). Two files conflicted (both-add — the two features touched adjacent lines, neither restructured the other's seam); resolved by keeping BOTH sides: - packages/credentagent-storefront/src/server.ts — the StorefrontOptions interface. BOTH won: kept the branch's `verifier?: DelegatedVerifier` (008 delegated ceremony) AND main's `grants?: Grants` + `merchant?: string` (spec 009 human-not-present). They are independent optional fields; a delegated storefront and a grants storefront compose. (Restored a `/**` the raw resolution dropped — caught by tsc, re-verified.) - packages/credentagent-storefront/src/server.test.ts — two separate describe blocks appended at end of file, sharing the trailing closers. BOTH won: kept the branch's "008 — delegated ceremony completes through the storefront (S5)" suite (the e2e delegated payment + the wrong-amount refusal) AND main's "browse-products is agent-legible (#120)" suite; closed each block explicitly. Auto-merged with both sides preserved (no hand resolution needed): client.ts (main's grants/webhooks wiring AND the branch's `this.delegated` verifier wiring), index.ts (main's grants exports AND the branch's DelegatedVerifier exports), the two READMEs. The #103 double-settle fix (completion.ts, ceremony/types.ts, keyed-mutex.ts) was branch-only — main never touched completion.ts, so it merged untouched. Verified after resolving: build (both packages typecheck), gate suite 380 + storefront 99 green, lint clean, and the security-critical files re-run explicitly — the delegated verify refusals + amount binding (delegated-payment 37, server delegated e2e), the new double-settle test (completion 33), and main's grants tests (grants 14 + grants-tools 10) all pass. Neither feature regressed the other. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5Y1aX9ffAF26hP4fgQFi7 Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
In plain terms
When one of your orders gets paid, you often need to tell a different service — the thing that
ships the product, or a worker on another machine. Today's
on("order.settled", …)listener can'tdo that: it only fires inside the one process that finished the order.
This adds the real signal — a webhook. When an order settles, the gate sends a signed HTTP
request to a URL you registered; the other service checks the signature and fulfils the order. If
you've used Stripe webhooks, this is the same shape you already know.
What you're approving
as before;
on(...),orders.*are untouched.orders.retrieve(id)), and a forged event can't slip through — that's locked by tests that failthe moment the signature check is removed (I verified all three go red).
is never logged; delivery never blocks or rolls back a settled order.
How to test
For reviewers — the detail
Increment #101, a sub-issue of epic #97. Design run through the DX council (
webhook-dx-council:4 independent designs — library-fit, Stripe-purist, security-first, DX-minimalist — → an 8-vote judge
panel; all four scored 9/10 on Stripe-idiomaticity and converged). Spec:
specs/010-order-webhooks/spec.md.Surface (
webhooks.ts, exported from the package):new CredentAgent({ webhooks: { endpoints, retries?, transport? } });credentagent.webhooks.register({ url, events? }) → { id, secret }(mints awhsec_secret, process-local). Fired fire-and-forget from the singlecompletion choke point
orders._complete, alongside the in-process event.constructEvent(rawBody, sigHeader, secret)→ typed event or throwsWebhookSignatureError(Stripe muscle memory);verifyEvent(...)→{ ok, … }verdict, never throws. Both are standalone exports (a receiver needsno CredentAgent) plus
ca.webhooks.*convenience.{ id, type: "order.settled", created, data: { object: <CompletedOrder> } }.Security model (spec §Security):
CredentAgent-Signature: t=<unix>,v1=<hex HMAC-SHA256>; MAC over`${t}.${rawBody}`with theendpoint secret. Hex digest for Stripe interop (deliberate deviation from the repo's internal base64url,
called out in the doc comment). Multiple
v1=accepted for zero-downtime secret rotation.timingSafeEqual, length-checked);tis bound into the MAC so it can't bebackdated; a
toutside ±300s is refused (replay window — invariant-6 analogue). Secret never logged.Honesty: at-least-once delivery + bounded exponential backoff; dedupe on
event.id; noguaranteed/exactly-once claim. Unrelated to
presence-only-demo(that's mdoc issuer trust).Tests:
webhooks.test.ts(13). The three security controls are load-bearing bypass tests — awrong-secret signature, a body tampered after signing, and a stale (replayed) timestamp are each rejected,
and each test was verified to fail when its control is deleted. Additive: full suite green (gate 340,
storefront 86).
examples/order-webhooks/(sender + receiver + smoke) verified end-to-end over real HTTP,including a forged event rejected with 400.
Files:
webhooks.ts(new),orders.ts/client.ts/types.ts/index.ts(wiring + exports),webhooks.test.ts(new),examples/order-webhooks/,specs/010-order-webhooks/spec.md, README updates.Refs #101 · epic #97 · spec 010
🤖 Generated with Claude Code
https://claude.ai/code/session_01Harfquzhqg5SCCURyYRbHD