Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ Each is runnable against the two `@openmobilehub/credentagent-*` packages (build
**Orders — checkout in one call** (009)
- [`orders-checkout/`](orders-checkout/) — the real `credentagent.orders` API: `orders.serve(app)` wires the whole checkout, `orders.create()` returns a link, `order.settled` fires when it's paid

**Order webhooks — the real HTTP completion signal** (010)
- [`order-webhooks/`](order-webhooks/) — a sender + a separate receiver: a settled order POSTs a **signed** `order.settled` event; the receiver verifies it with `constructEvent` (the Stripe idiom). Forged/tampered/replayed events are rejected

**Cart Mandate / stateless** (004)
- [`stateless-orders/`](stateless-orders/) — the created order rides in a signed Cart Mandate on the link

Expand Down
71 changes: 71 additions & 0 deletions examples/order-webhooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# `order-webhooks/` — get told over HTTP when an order settles

`credentagent.on("order.settled", …)` only fires **inside the process that settled the order**.
When your fulfillment runs on a *different* service (or a different instance), you need a real
**webhook**: the gate sends a signed HTTP `POST` to a URL you registered, and that other service
verifies it. This is the Stripe idiom — if you've used `stripe.webhooks.constructEvent`, you already
know this API.

```js
// ── SENDING — the server that settles orders (configure once) ──
const credentagent = new CredentAgent({
walletOrigin: "https://shop.example",
webhooks: { endpoints: [{ url: "https://fulfillment.example/hooks", secret: process.env.WHSEC }] },
});
credentagent.orders.serve(app);
// …an order settles → a SIGNED order.settled event is POSTed to the endpoint. No delivery code to write.

// ── 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(`webhook signature failed: ${err.message}`); // forged / tampered / replayed
}
if (event.type === "order.settled") fulfill(event.data.object.orderId); // dedupe on event.id
res.json({ received: true });
});
```

## Run it (two terminals)

```bash
npm run build

# terminal 1 — the receiver (a separate service)
CREDENTAGENT_WHSEC=whsec_demo node examples/order-webhooks/receiver.mjs # → :4100

# terminal 2 — the gate (settles orders, sends webhooks)
CREDENTAGENT_WHSEC=whsec_demo node examples/order-webhooks/sender.mjs # → :4000
curl -X POST http://localhost:4000/buy-sticker
```

Watch terminal 1: it prints the verified `order.settled` event. Use the **same** `CREDENTAGENT_WHSEC`
in both — that shared secret is what proves the event came from your gate.

## Prove it (one command, no browser)

```bash
node examples/order-webhooks/smoke.mjs
```

Boots both a sender and a receiver, settles an order over real HTTP, and asserts: the receiver got
exactly one **verified** `order.settled` event carrying the settled order — and a **forged** POST
(signed with the wrong secret) is rejected with `400` and never recorded.

## What's real, and what to know

- **Real signature.** HMAC-SHA256 over `` `${timestamp}.${rawBody}` `` with your `whsec_` secret,
in a `CredentAgent-Signature: t=…,v1=…` header — the same scheme Stripe uses. A forged, tampered,
wrong-secret, or **stale** (replayed) event is rejected. This is a genuine security control, not a demo.
(It's unrelated to the `presence-only-demo` trust level, which is about wallet/mdoc issuer trust.)
- **At-least-once delivery.** The gate retries with backoff; your receiver may see the same event
twice — **dedupe on `event.id`**. There is no guaranteed/exactly-once delivery.
- **Non-blocking.** Delivery is fire-and-forget from the completion path; a slow or dead receiver
never blocks or rolls back a settled order. For the durable, cross-instance source of truth, read
`orders.retrieve(id)` (backed by a shared completed-order store).
- **Endpoints are trusted config**, not user input. Use https in production; keep the secret in env.
- **Multi-instance:** put endpoints in `new CredentAgent({ webhooks: { endpoints } })` so every
instance signs alike. `webhooks.register(...)` is a runtime convenience but is process-local.
34 changes: 34 additions & 0 deletions examples/order-webhooks/receiver.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// The RECEIVER — a service that does NOT run the gate. It has only the shared secret and verifies
// each webhook with one call. Run it in one terminal, then run sender.mjs in another.
//
// CREDENTAGENT_WHSEC=whsec_demo node examples/order-webhooks/receiver.mjs # → http://localhost:4100
import express from "express";
import { constructEvent, SIGNATURE_HEADER } from "@openmobilehub/credentagent-gate";

const PORT = 4100;
const SECRET = process.env.CREDENTAGENT_WHSEC ?? "whsec_demo_run_both_with_this_secret";
const seen = new Set(); // idempotency ledger — delivery is at-least-once (use a durable store in prod)

const app = express();
// The signature is over the RAW bytes, so read the raw body (not parsed JSON) — the Stripe rule.
app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = constructEvent(req.body, req.get(SIGNATURE_HEADER), SECRET);
} catch (err) {
console.log(`✗ rejected: ${err.message}`); // forged / tampered / replayed
return res.status(400).send(`webhook signature failed: ${err.message}`);
}
if (seen.has(event.id)) { console.log(`↩ duplicate ${event.id} — ignored`); return res.json({ received: true }); }
seen.add(event.id);
if (event.type === "order.settled") {
const o = event.data.object;
console.log(`✓ ${event.id} — order.settled: ${o.orderId} · ${o.amount} ${o.currency} · ${o.method ?? "?"} — fulfilling now`);
}
res.json({ received: true });
});

app.listen(PORT, () => {
console.log(`receiver on http://localhost:${PORT} (verifying with secret ${SECRET.slice(0, 12)}…)`);
console.log(`waiting for order.settled events from sender.mjs…`);
});
38 changes: 38 additions & 0 deletions examples/order-webhooks/sender.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// The SENDER — the server that runs the gate and settles orders. Configured to POST every settled
// order to the receiver. Run receiver.mjs first (same secret), then this in another terminal.
//
// CREDENTAGENT_WHSEC=whsec_demo node examples/order-webhooks/sender.mjs # → http://localhost:4000
// curl -X POST http://localhost:4000/buy-sticker # settles an (ungated) order → fires the webhook
import express from "express";
import { CredentAgent } from "@openmobilehub/credentagent-gate";

const PORT = 4000;
const SECRET = process.env.CREDENTAGENT_WHSEC ?? "whsec_demo_run_both_with_this_secret";

const app = express();
app.use(express.json());

// ── once, at startup ──────────────────────────────────────────────────────────
// Declare where settled orders get POSTed. One config line turns on signed HTTP delivery.
const credentagent = new CredentAgent({
walletOrigin: `http://localhost:${PORT}`,
webhooks: { endpoints: [{ url: "http://localhost:4100/hooks", secret: SECRET }] },
});
credentagent.orders.serve(app);

// ── per purchase ────────────────────────────────────────────────────────────────
// An UNGATED order so it settles from the instant-demo path without a wallet — the webhook
// fires the same way a real age/payment order would once its ceremony completes.
app.post("/buy-sticker", async (_req, res) => {
const { id } = await credentagent.orders.create({
order: { id: "", total: 5, currency: "USD", lines: [{ id: "sticker", name: "Sticker", quantity: 1, unitPrice: 5 }] },
policy: [],
});
await fetch(`http://localhost:${PORT}/credentagent/orders/${id}/place`, { method: "POST" }); // settle it
res.json({ id, settled: true, note: "watch the receiver terminal for the signed order.settled event" });
});

app.listen(PORT, () => {
console.log(`sender (gate) on http://localhost:${PORT} → webhooks POST to http://localhost:4100/hooks`);
console.log(` curl -X POST http://localhost:${PORT}/buy-sticker # settle an order → fires the webhook`);
});
67 changes: 67 additions & 0 deletions examples/order-webhooks/smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Smoke test for order webhooks — boots a SENDER (the gate) and a separate RECEIVER, then proves
// the real thing over real HTTP: when an order settles, the gate POSTs a SIGNED event that the
// receiver verifies with constructEvent; a forged POST is rejected. No browser, no wallet.
//
// node examples/order-webhooks/smoke.mjs
import express from "express";
import { CredentAgent, constructEvent, generateWebhookSecret, signPayload, SIGNATURE_HEADER } from "@openmobilehub/credentagent-gate";

const secret = generateWebhookSecret(); // shared out-of-band between the two services

let failures = 0;
const check = (label, cond) => { console.log(`${cond ? "✓" : "✗"} ${label}`); if (!cond) failures++; };

// ── RECEIVER — a different service; it has only the shared secret, no gate, no stores ──
const received = [];
const receiver = express();
receiver.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = constructEvent(req.body, req.get(SIGNATURE_HEADER), secret); // throws on forged/tampered/replayed
} catch (err) {
return res.status(400).send(`signature failed: ${err.message}`);
}
received.push(event);
res.json({ received: true });
});
const rSrv = await new Promise((r) => { const s = receiver.listen(0, () => r(s)); });
const hooksUrl = `http://localhost:${rSrv.address().port}/hooks`;

// ── SENDER — the gate, configured to POST settled orders to the receiver ──
const app = express();
app.use(express.json());
const ca = new CredentAgent({ walletOrigin: "http://localhost:0", webhooks: { endpoints: [{ url: hooksUrl, secret }] } });
ca.orders.serve(app);
app.post("/ungated", async (_req, res) => res.json(await ca.orders.create({
order: { id: "", total: 5, currency: "USD", lines: [{ id: "sticker", name: "Sticker", quantity: 1, unitPrice: 5 }] },
policy: [],
})));
const sSrv = await new Promise((r) => { const s = app.listen(0, () => r(s)); });
const base = `http://localhost:${sSrv.address().port}`;
const j = async (r) => (r.headers.get("content-type")?.includes("json") ? r.json() : r.text());

try {
// Complete an order → the gate fires a signed webhook to the receiver.
const order = await j(await fetch(`${base}/ungated`, { method: "POST" }));
await fetch(`${base}/credentagent/orders/${order.id}/place`, { method: "POST" });

// Wait for the fire-and-forget delivery to land.
for (let i = 0; i < 40 && received.length === 0; i++) await new Promise((r) => setTimeout(r, 50));

check("the receiver got exactly one webhook", received.length === 1);
check("it is a verified order.settled event", received[0]?.type === "order.settled");
check("its data.object carries the settled order", received[0]?.data?.object?.orderId === order.id);
check("the event has a stable id to dedupe on", /^evt_/.test(received[0]?.id ?? ""));

// A FORGED POST (attacker's secret) must be rejected by the receiver.
const forgedBody = JSON.stringify({ id: "evt_forged", type: "order.settled", created: Math.floor(Date.now() / 1000), data: { object: { orderId: "ord_hacker" } } });
const forgedSig = signPayload(forgedBody, "whsec_attacker_secret", Math.floor(Date.now() / 1000));
const forgedRes = await fetch(hooksUrl, { method: "POST", headers: { "content-type": "application/json", [SIGNATURE_HEADER]: forgedSig }, body: forgedBody });
check("a forged event (wrong secret) is rejected with 400", forgedRes.status === 400);
check("the forged event was NOT recorded", received.length === 1);
} finally {
sSrv.close(); rSrv.close();
}

console.log(failures === 0 ? "\nALL SMOKE CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`);
process.exit(failures === 0 ? 0 : 1);
33 changes: 30 additions & 3 deletions packages/credentagent-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,43 @@ app.get("/orders/:id", async (req, res) => res.json(await credentagent.orders.re
> one long-lived Node process that completed the order. On serverless (Vercel, Lambda) the instance
> can be frozen the moment the response is sent, so async work started in the listener may never
> finish — don't fulfill from it there. Instead, inject shared stores (`orderStore`,
> `completedOrderStore`) and read `orders.retrieve(id)` as the durable, cross-instance signal. A
> real signed HTTP webhook is the next increment
> ([#101](https://github.com/openmobilehub/credentagent/issues/101)).
> `completedOrderStore`) and read `orders.retrieve(id)` as the durable, cross-instance signal — or
> register a **webhook** (next section) so a different service gets the signed HTTP `POST`.

`orders.retrieve(id)` is the one result **door**: `{ ok: true, completion }` once paid, `{ ok: false,
pending: true, approveUrl }` while it's open, or `{ ok: false, code }` for an unknown id. The amount and
the age threshold are re-derived from the order you stored server-side — never trusted from the link
(invariant 2), and a gated order can only complete through the wallet ceremony, never a shortcut
(invariant 1). Runnable: [`examples/orders-checkout/`](https://github.com/openmobilehub/credentagent/tree/main/examples/orders-checkout).

### Webhooks — tell a *different* service when an order settles

`on("order.settled", …)` only fires in the process that settled the order. When fulfillment runs
elsewhere, register a **webhook**: the gate sends a **signed** HTTP `POST` and the other service
verifies it — the Stripe idiom (`constructEvent`). Real HMAC signature, replay-protected.

```ts
// SENDING — configure once; a settled order is POSTed to each endpoint (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. Verify the RAW body:
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); // dedupe on event.id
res.json({ received: true });
});
```

Signature: `CredentAgent-Signature: t=…,v1=<hex HMAC-SHA256>` over `` `${t}.${rawBody}` ``, secret
`whsec_…`. Delivery is **at-least-once** with retry (dedupe on `event.id`) — it never blocks a settled
order. Endpoint URLs must be **https** (http only for localhost dev — enforced where endpoints enter);
redirects are never followed, and each attempt is bounded by a timeout (`timeoutMs`, default 10s).
`verifyEvent(...)` is the never-throws verdict door if you prefer a result to a try/catch. Runnable:
[`examples/order-webhooks/`](https://github.com/openmobilehub/credentagent/tree/main/examples/order-webhooks).

## The three execution contexts

The split is load-bearing — conflating them is the documented root cause of confusion
Expand Down
7 changes: 7 additions & 0 deletions packages/credentagent-gate/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MemoryVerificationStore } from "./store.js";
import { mountCeremony, type CeremonyApp, type CeremonySeams } from "./ceremony/mount.js";
import { Orders, MemoryOrderStore, type CreatedOrder, type CompletedOrder } from "./orders.js";
import { serveOrders } from "./orders-serve.js";
import { Webhooks } from "./webhooks.js";

x509.cryptoProvider.set(globalThis.crypto);

Expand All @@ -32,6 +33,8 @@ export class CredentAgent {
readonly store: VerificationStore;
/** The human-present checkout resource — `orders.create()` / `orders.retrieve()` (spec 009). */
readonly orders: Orders;
/** Outbound HTTP webhooks — `webhooks.register()` / `webhooks.constructEvent()` (spec 010). */
readonly webhooks: Webhooks;
/** Stable reader identity presented by the rails (undefined ⇒ per-request self-signed). */
readonly readerIdentity?: ReaderIdentity;
private readonly listeners = new Map<string, Set<(payload: { id: string }) => void>>();
Expand Down Expand Up @@ -90,12 +93,16 @@ export class CredentAgent {
// state `orders.create()` / `orders.retrieve()` use (invariant 4 — keyed per order id).
const createdStore = opts.orderStore ?? new MemoryOrderStore<CreatedOrder>();
const completedStore = opts.completedOrderStore ?? new MemoryOrderStore<CompletedOrder>();
// The outbound HTTP webhook sender (spec 010). Zero endpoints ⇒ inert (additive, zero-cost).
this.webhooks = new Webhooks(opts.webhooks ?? {});
this.orders = new Orders({
walletOrigin: this.walletOrigin,
requirements: (order, policy) => this.requirements(order, policy),
created: createdStore,
completed: completedStore,
emit: (event, payload) => this.emit(event, payload),
// Fire-and-forget from the completion choke point — never blocks a settled order.
deliverWebhook: (type, object) => { void this.webhooks.deliver(type, object); },
serve: (app) => {
if (this.ordersServed) return; // idempotent
serveOrders(app as CeremonyApp, {
Expand Down
8 changes: 8 additions & 0 deletions packages/credentagent-gate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export { MemoryVerificationStore } from "./store.js";
export { Orders, MemoryOrderStore } from "./orders.js";
export type { OrderStore, CreatedOrder, CompletedOrder, OrderDoor } from "./orders.js";

// ── Webhooks (spec 010) — the REAL HTTP completion signal ───────────────────
// SEND: `new CredentAgent({ webhooks: { endpoints: [{ url, secret }] } })` → every settled order
// POSTs a signed `order.settled` event. RECEIVE (a different service, secret only):
// `constructEvent(rawBody, sigHeader, secret)` → typed event, or throws on a forged/tampered/replayed
// body (the Stripe idiom). `verifyEvent(...)` is the never-throws verdict door.
export { constructEvent, verifyEvent, generateWebhookSecret, signPayload, Webhooks, WebhookSignatureError, SIGNATURE_HEADER, DEFAULT_TOLERANCE_SECONDS } from "./webhooks.js";
export type { WebhookEvent, WebhookEndpoint, WebhookOptions, WebhookVerdict, WebhookRefusalCode, WebhookTransport, VerifyOptions } from "./webhooks.js";

// ── Ceremony composition (host-side: bind completion over YOUR stores) ──────
// A composing host (e.g. @openmobilehub/credentagent-storefront) binds `completeOrder`
// to its completed-order / cart stores + catalog and exposes it as the `completion`
Expand Down
5 changes: 5 additions & 0 deletions packages/credentagent-gate/src/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface OrdersDeps {
/** The completed-order store; its write() fires "order.settled". */
completed: OrderStore<CompletedOrder>;
emit: (event: "order.settled", payload: { id: string }) => void;
/** Fan the settled order out to registered HTTP webhook endpoints (fire-and-forget). */
deliverWebhook?: (type: "order.settled", object: CompletedOrder) => void;
/** Wire the checkout (rails + page + completion) onto an Express app — `orders.serve(app)`. */
serve: (app: unknown) => void;
}
Expand Down Expand Up @@ -116,6 +118,9 @@ export class Orders {
* in-process `order.settled` event (see `CredentAgent.on` — a local listener, not a webhook). */
async _complete(record: CompletedOrder): Promise<void> {
await this.deps.completed.write(record.orderId, record);
// One completion choke point feeds both signals: the in-process listener AND the HTTP
// webhook fan-out. Delivery is fire-and-forget — it never blocks or rolls back completion.
this.deps.emit("order.settled", { id: record.orderId });
this.deps.deliverWebhook?.("order.settled", record);
}
}
Loading
Loading