Skip to content

credentagent.webhooks: a real, signed HTTP order.settled (#101) - #102

Merged
dzuluaga merged 2 commits into
mainfrom
feat/97-order-webhooks
Jul 24, 2026
Merged

credentagent.webhooks: a real, signed HTTP order.settled (#101)#102
dzuluaga merged 2 commits into
mainfrom
feat/97-order-webhooks

Conversation

@dzuluaga

Copy link
Copy Markdown
Contributor

Stacked on #98 (the orders checkout). Base is feat/97-orders-surface, so this diff is
only the webhook change. Rebases onto main once #98 merges.

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't
do 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.

// SENDING — one config line; settled orders get POSTed (signed, retried, non-blocking):
new CredentAgent({ webhooks: { endpoints: [{ url: "https://fulfillment.example/hooks", secret: process.env.WHSEC }] } });

// RECEIVING — a different service; only the shared secret:
import { constructEvent } from "@openmobilehub/credentagent-gate";
app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try { event = constructEvent(req.body, req.get("CredentAgent-Signature"), process.env.WHSEC); }
  catch (err) { return res.status(400).send(err.message); }        // forged / tampered / replayed → rejected
  if (event.type === "order.settled") fulfill(event.data.object.orderId);
  res.json({ received: true });
});

What you're approving

  • New code only — nothing existing changes. A server that registers no endpoint behaves exactly
    as before; on(...), orders.* are untouched.
  • Worst case if it's wrong: a webhook fails to deliver. The order is still safely recorded (read
    orders.retrieve(id)), and a forged event can't slip through — that's locked by tests that fail
    the moment the signature check is removed (I verified all three go red).
  • What stays safe: no real money moves (this is a notification, not a payment); the signing secret
    is never logged; delivery never blocks or rolls back a settled order.

How to test

npm run build && npm test                       # gate: 340 · storefront: 86
node examples/order-webhooks/smoke.mjs           # → ALL SMOKE CHECKS PASSED (sender→receiver over real HTTP)
# two terminals (same secret), then curl:
CREDENTAGENT_WHSEC=whsec_demo node examples/order-webhooks/receiver.mjs   # :4100
CREDENTAGENT_WHSEC=whsec_demo node examples/order-webhooks/sender.mjs     # :4000
curl -X POST http://localhost:4000/buy-sticker   # → receiver prints a verified order.settled

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):

  • Send: new CredentAgent({ webhooks: { endpoints, retries?, transport? } }); credentagent.webhooks.register({ url, events? }) → { id, secret } (mints a whsec_ secret, process-local). Fired fire-and-forget from the single
    completion choke point orders._complete, alongside the in-process event.
  • Receive: two symmetric doors over one core (the Zod parse/safeParse idiom) — constructEvent(rawBody, sigHeader, secret) → typed event or throws WebhookSignatureError (Stripe muscle memory);
    verifyEvent(...){ ok, … } verdict, never throws. Both are standalone exports (a receiver needs
    no CredentAgent) plus ca.webhooks.* convenience.
  • Event is Stripe-shaped: { id, type: "order.settled", created, data: { object: <CompletedOrder> } }.

Security model (spec §Security):

  • Header CredentAgent-Signature: t=<unix>,v1=<hex HMAC-SHA256>; MAC over `${t}.${rawBody}` with the
    endpoint 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.
  • Constant-time compare (timingSafeEqual, length-checked); t is bound into the MAC so it can't be
    backdated; a t outside ±300s is refused (replay window — invariant-6 analogue). Secret never logged.
  • Endpoint URLs are trusted config (not user input); https-by-default; no redirect-following.

Honesty: at-least-once delivery + bounded exponential backoff; dedupe on event.id; no
guaranteed/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 — a
wrong-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

@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
credentagent-demo Ready Ready Preview, Comment Jul 23, 2026 9:27pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +171 to +173
const realFetch: WebhookTransport = async (url, init) => {
const res = await (globalThis.fetch as typeof fetch)(url, init);
return { status: res.status };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with codex, PTAL

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +171 to +173
const realFetch: WebhookTransport = async (url, init) => {
const res = await (globalThis.fetch as typeof fetch)(url, init);
return { status: res.status };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +171 to +173
const realFetch: WebhookTransport = async (url, init) => {
const res = await (globalThis.fetch as typeof fetch)(url, init);
return { status: res.status };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with codex, PTAL

Comment thread specs/010-order-webhooks/spec.md Outdated
- **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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Base automatically changed from feat/97-orders-surface to main July 23, 2026 21:07
dzuluaga added a commit that referenced this pull request Jul 23, 2026
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>
dzuluaga added a commit that referenced this pull request Jul 23, 2026
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>
@dzuluaga
dzuluaga force-pushed the feat/97-order-webhooks branch from cbd726e to 98176a1 Compare July 23, 2026 21:23
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>

@TheBlackBit TheBlackBit left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@dzuluaga
dzuluaga merged commit 3f8c3a0 into main Jul 24, 2026
7 checks passed
@dzuluaga
dzuluaga deleted the feat/97-order-webhooks branch July 24, 2026 00:40
@dzuluaga

Copy link
Copy Markdown
Contributor Author

Deferred by maintainer decision (revisit post-merge): a callback-verification note in the receiver example (signature proves sender, not order existence — confirm via GET /orders/:id before high-value fulfillment). Keeping the example Stripe-shaped: verify signature → act, no extra step.

TheBlackBit pushed a commit that referenced this pull request Jul 24, 2026
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>
dzuluaga added a commit that referenced this pull request Jul 25, 2026
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>
dzuluaga added a commit that referenced this pull request Jul 25, 2026
…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>
dzuluaga added a commit that referenced this pull request Jul 26, 2026
… 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>
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.

2 participants