diff --git a/examples/README.md b/examples/README.md index e5240f8..d1dc32d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,9 @@ Each is runnable against the two `@openmobilehub/credentagent-*` packages (build **Gating patterns** (identity-first, beyond commerce) - [`gate-any-action.mjs`](#gate-any-actionmjs--gate-a-non-commerce-action-identity-first-no-checkout) — gate a non-commerce action, no checkout +**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 + **Cart Mandate / stateless** (004) - [`stateless-orders/`](stateless-orders/) — the created order rides in a signed Cart Mandate on the link diff --git a/examples/orders-checkout/README.md b/examples/orders-checkout/README.md new file mode 100644 index 0000000..4bd9292 --- /dev/null +++ b/examples/orders-checkout/README.md @@ -0,0 +1,76 @@ +# `orders-checkout/` — a checkout an agent can drive, in a few lines + +An AI agent wants to buy a bottle of wine for you. Wine is age-restricted, so the purchase +can't just go through — you have to prove you're 21+ and pay. This example is the smallest +real thing that makes that safe: the agent starts the order and gets a **link**; you open the +link, prove your age, and pay; the order settles. + +The whole checkout is wired in **one call** — `credentagent.orders.serve(app)`. There's no +store to assemble and no completion logic to hand-write; the library owns the ceremony. Note +what runs **when**: `serve()` and `on()` run **once at startup**; `orders.create()` runs **per +purchase**, inside a request handler. + +```js +import express from "express"; +import { CredentAgent, age, payment, required } from "@openmobilehub/credentagent-gate"; + +const app = express(); +app.use(express.json()); +const credentagent = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + +// ── once, at startup ────────────────────────────────────────── +credentagent.orders.serve(app); // wire the whole checkout onto your app +credentagent.on("order.settled", ({ id }) => fulfill(id)); // subscribe once — fires when it's paid + +// ── per purchase — a request handler that runs on each buy ──── +app.post("/buy-wine", async (_req, res) => { + const { approveUrl } = await credentagent.orders.create({ + order: { id: "", total: 21, currency: "USD", lines: [{ id: "wine", name: "Bottle of wine", quantity: 1, unitPrice: 21, minimumAge: 21 }] }, + policy: [required(age.over(21)), required(payment.in("usd"))], + }); + res.json({ approveUrl }); // hand this link to the human +}); +``` + +> **Serverless caveat:** `on("order.settled")` is an in-process event — it fits a long-lived server +> like this example. On serverless (Vercel, Lambda), fulfill from `orders.retrieve(id)` over +> injected shared stores instead; a real signed webhook is tracked in +> [#101](https://github.com/openmobilehub/credentagent/issues/101). + +## Run it + +```bash +npm run build # build the two @openmobilehub/credentagent-* packages +node examples/orders-checkout/server.mjs # → http://localhost:4000 +``` + +Then: + +1. `curl -X POST http://localhost:4000/buy-wine` → `{ id, approveUrl }` +2. Open the `approveUrl` in a browser → the checkout page (prove age + pay; on your phone for the real wallet ceremony). +3. `curl http://localhost:4000/orders/` → `{ ok: true }` once it settles — or just listen for `order.settled`. + +## Prove it (no browser needed) + +```bash +node examples/orders-checkout/smoke.mjs +``` + +The smoke test drives the built package over HTTP and asserts the two things that matter: + +- A **gated** order (age + payment) renders a checkout page but **cannot** be completed by a + direct POST to the instant-demo path — it's refused (403) and stays pending. Skipping the + gate is refused on the server, not just hidden in the page. +- An **ungated** order completes via the demo path → `order.settled` fires → `retrieve` is ok, + with the amount re-derived server-side. + +## What's real, and what isn't yet + +- **Real:** the order lifecycle (`create` → link → checkout → `order.settled`), the server-side + amount + age re-derivation (the total is never trusted from the link), and the fail-closed + rule that a gated order only completes through the wallet ceremony. +- **Demo-only:** `trust_level` is `"presence-only-demo"`. The wire crypto is real, but there's + no issuer / device-signature trust anchor yet — a self-crafted credential would pass. Don't + gate anything needing a real safety guarantee on it until issuer-verified trust lands. +- The **instant-demo "Complete purchase"** button exists only for ungated orders (so the flow + is clickable without a wallet); a real age/payment order always goes through the phone. diff --git a/examples/orders-checkout/server.mjs b/examples/orders-checkout/server.mjs new file mode 100644 index 0000000..263f923 --- /dev/null +++ b/examples/orders-checkout/server.mjs @@ -0,0 +1,44 @@ +// Runnable example — a checkout an AI agent can drive, built on the real credentagent.orders API. +// +// node examples/orders-checkout/server.mjs # boots on http://localhost:4000 +// node examples/orders-checkout/smoke.mjs # drives the whole flow + asserts (no browser) +// +// The whole checkout is wired in ONE call — `credentagent.orders.serve(app)`. There is no +// store to assemble, no completion context to hand-build: the library owns the ceremony. +// An agent calls POST /buy-wine, gets back an `approveUrl`, and hands that link to the human; +// the human proves their age + pays on the checkout page; the order settles. +import express from "express"; +import { CredentAgent, age, payment, required } from "@openmobilehub/credentagent-gate"; + +const PORT = 4000; +const app = express(); +app.use(express.json()); + +const credentagent = new CredentAgent({ walletOrigin: `http://localhost:${PORT}` }); + +// ── ONCE, at startup ──────────────────────────────────────────────────────────── +// `orders.serve(app)` wires the ceremony rails, the checkout page at each order's +// approveUrl, and completion. `on(...)` subscribes once — it fires when ANY order is paid. +credentagent.orders.serve(app); +credentagent.on("order.settled", ({ id }) => console.log(`✓ order.settled: ${id} — fulfill it now`)); + +// ── PER PURCHASE — a request handler that runs each time an agent wants to buy ──── +// It gets back a link to hand to the human; the amount + age gate are re-derived +// server-side, never trusted from a token. +app.post("/buy-wine", async (_req, res) => { + const { id, approveUrl } = await credentagent.orders.create({ + order: { id: "", total: 21, currency: "USD", lines: [{ id: "wine", name: "Bottle of wine", quantity: 1, unitPrice: 21, minimumAge: 21 }] }, + policy: [required(age.over(21)), required(payment.in("usd"))], + }); + res.json({ id, approveUrl }); +}); + +// What the agent polls (or better: subscribe to `order.settled` above and skip polling). +app.get("/orders/:id", async (req, res) => res.json(await credentagent.orders.retrieve(req.params.id))); + +app.listen(PORT, () => { + console.log(`orders-checkout example on http://localhost:${PORT}`); + console.log(` 1) POST /buy-wine → { id, approveUrl }`); + console.log(` 2) open the approveUrl in a browser → prove age + pay (on your phone for a real ceremony)`); + console.log(` 3) GET /orders/:id → { ok: true } once it settles`); +}); diff --git a/examples/orders-checkout/smoke.mjs b/examples/orders-checkout/smoke.mjs new file mode 100644 index 0000000..0590db0 --- /dev/null +++ b/examples/orders-checkout/smoke.mjs @@ -0,0 +1,70 @@ +// Smoke test for the orders-checkout example — drives the REAL built package over HTTP and +// asserts, so CI (and you) can prove the checkout works end-to-end without a browser or wallet. +// +// node examples/orders-checkout/smoke.mjs +// +// It covers the two security-critical shapes: +// • a GATED order (age + payment) renders a checkout page but CANNOT be completed by a +// direct POST to the instant-demo path — it is refused (403) and stays pending; +// • an UNGATED order completes via the demo path → order.settled fires → retrieve is ok. +import express from "express"; +import { CredentAgent, age, payment, required } from "@openmobilehub/credentagent-gate"; + +const app = express(); +app.use(express.json()); + +const settled = []; +const ca = new CredentAgent({ walletOrigin: "http://localhost:0" }); +ca.orders.serve(app); +ca.on("order.settled", ({ id }) => settled.push(id)); + +// Two create endpoints — a gated one (a $21 wine) and an ungated one (a $5 sticker) — plus +// retrieve. Amounts are dollars, matching what the checkout page renders. +app.post("/gated", async (_req, res) => res.json(await ca.orders.create({ + order: { id: "", total: 21, currency: "USD", lines: [{ id: "wine", name: "Wine", quantity: 1, unitPrice: 21, minimumAge: 21 }] }, + policy: [required(age.over(21)), required(payment.in("usd"))], +}))); +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: [], +}))); +app.get("/orders/:id", async (req, res) => res.json(await ca.orders.retrieve(req.params.id))); + +let failures = 0; +const check = (label, cond) => { console.log(`${cond ? "✓" : "✗"} ${label}`); if (!cond) failures++; }; + +const server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); }); +const base = `http://localhost:${server.address().port}`; +const j = async (r) => ({ status: r.status, body: r.headers.get("content-type")?.includes("json") ? await r.json() : await r.text() }); + +try { + // ── Gated order: rendered, but never completable from the instant-demo path ── + const gated = (await j(await fetch(`${base}/gated`, { method: "POST" }))).body; + check("gated create returns an id + approveUrl on this origin", gated.id?.startsWith("ord_") && gated.approveUrl.includes(gated.id)); + + const page = await j(await fetch(`${base}/credentagent/orders/${gated.id}`)); + check("gated checkout page renders (200) and shows the item", page.status === 200 && page.body.includes("Wine")); + + const placeGated = await j(await fetch(`${base}/credentagent/orders/${gated.id}/place`, { method: "POST" })); + check("gated order is REFUSED on the instant-demo place path (403)", placeGated.status === 403); + + const gatedAfter = (await j(await fetch(`${base}/orders/${gated.id}`))).body; + check("gated order stays PENDING after the refused place (never ok unverified)", gatedAfter.ok === false && gatedAfter.pending === true); + + // ── Ungated order: completes end-to-end via the demo path ── + const ungated = (await j(await fetch(`${base}/ungated`, { method: "POST" }))).body; + const placeUngated = await j(await fetch(`${base}/credentagent/orders/${ungated.id}/place`, { method: "POST" })); + check("ungated order completes on the demo place path (200)", placeUngated.status === 200); + check("order.settled fired exactly once for the ungated order", settled.length === 1 && settled[0] === ungated.id); + + const placeAgain = await j(await fetch(`${base}/credentagent/orders/${ungated.id}/place`, { method: "POST" })); + check("a duplicate place POST is acknowledged but does NOT re-fire order.settled", placeAgain.status === 200 && settled.length === 1); + + const ungatedAfter = (await j(await fetch(`${base}/orders/${ungated.id}`))).body; + check("ungated order retrieves as ok with the server-derived amount ($5)", ungatedAfter.ok === true && ungatedAfter.completion?.amount === 5); +} finally { + server.close(); +} + +console.log(failures === 0 ? "\nALL SMOKE CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`); +process.exit(failures === 0 ? 0 : 1); diff --git a/packages/credentagent-gate/README.md b/packages/credentagent-gate/README.md index e24a57a..b49d5f1 100644 --- a/packages/credentagent-gate/README.md +++ b/packages/credentagent-gate/README.md @@ -56,6 +56,53 @@ the widget shows the confirmation. Add the headphones instead and the age gate d > predicate keys off the cart's lines — e.g. `order.lines.some((l) => l.minimumAge != null)`. > For a deployment pass your public origin: `new CredentAgent({ walletOrigin: "https://shop.example" })`. +## Orders — a checkout without a storefront + +Don't have (or want) the MCP storefront? Drive the checkout yourself with `credentagent.orders`. +Two things happen at **startup** (wire the checkout once, subscribe to completion once); the third, +`orders.create()`, happens **per purchase** — inside a request handler, each time an agent wants to buy. +The comments below mark which is which: + +```ts +import express from "express"; +import { CredentAgent, age, payment, required } from "@openmobilehub/credentagent-gate"; + +const app = express(); +app.use(express.json()); +const credentagent = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + +// ── once, at startup ────────────────────────────────────────────── +credentagent.orders.serve(app); // wire the whole checkout onto your app +credentagent.on("order.settled", ({ id }) => fulfill(id)); // subscribe once — fires when ANY order is paid + +// ── per purchase — inside a request handler (runs every time) ────── +app.post("/buy-wine", async (_req, res) => { + const { id, approveUrl } = await credentagent.orders.create({ // → { id, approveUrl, manifest } + order: { id: "", total: 21, currency: "USD", lines: [{ id: "wine", name: "Bottle of wine", quantity: 1, unitPrice: 21, minimumAge: 21 }] }, + policy: [required(age.over(21)), required(payment.in("usd"))], + }); + res.json({ id, approveUrl }); // hand approveUrl to the human +}); + +// read status here (durable, works across instances). In a single-process server the +// in-process order.settled listener above is enough; this is the cross-instance signal. +app.get("/orders/:id", async (req, res) => res.json(await credentagent.orders.retrieve(req.params.id))); +``` + +> **`on("order.settled")` is an in-process event, not a webhook** — it fires synchronously in the +> 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)). + +`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). + ## The three execution contexts The split is load-bearing — conflating them is the documented root cause of confusion diff --git a/packages/credentagent-gate/src/ceremony/credential-gate/routes.ts b/packages/credentagent-gate/src/ceremony/credential-gate/routes.ts index dc44dfb..3636fa9 100644 --- a/packages/credentagent-gate/src/ceremony/credential-gate/routes.ts +++ b/packages/credentagent-gate/src/ceremony/credential-gate/routes.ts @@ -166,6 +166,7 @@ export const registerCredentialGate: RailRegistrar = (app: CeremonyApp, ctx: Cer renderCredentialPage({ kind: resolved.credential.id, order: order.id, + returnUrl: ctx.returnUrl?.(order.id), total: order.total, currency: order.currency, label: resolved.credential.ui.label, @@ -181,6 +182,7 @@ export const registerCredentialGate: RailRegistrar = (app: CeremonyApp, ctx: Cer renderCredentialPage({ kind: resolved.kind, order: order.id, + returnUrl: ctx.returnUrl?.(order.id), minimumAge: requiredAgeForOrder(order) ?? undefined, total: order.total, currency: order.currency, diff --git a/packages/credentagent-gate/src/ceremony/dc-payment/routes.ts b/packages/credentagent-gate/src/ceremony/dc-payment/routes.ts index 79114e6..0b7bc84 100644 --- a/packages/credentagent-gate/src/ceremony/dc-payment/routes.ts +++ b/packages/credentagent-gate/src/ceremony/dc-payment/routes.ts @@ -95,6 +95,7 @@ export const registerDcPaymentGate: RailRegistrar = (app: CeremonyApp, ctx: Cere lines: order.lines.map((l) => ({ name: l.name ?? l.id, quantity: l.quantity, lineTotal: l.lineTotal, currency: l.currency ?? order.currency })), cart: typeof req.query.cart === "string" ? req.query.cart : undefined, rail, + returnUrl: ctx.returnUrl?.(order.id), }), ); }); diff --git a/packages/credentagent-gate/src/ceremony/mount.ts b/packages/credentagent-gate/src/ceremony/mount.ts index 3162001..f129f00 100644 --- a/packages/credentagent-gate/src/ceremony/mount.ts +++ b/packages/credentagent-gate/src/ceremony/mount.ts @@ -77,6 +77,11 @@ export interface CeremonySeams { * `app.locals.credentagent` so the host's `completion` seam can hand it to * `completeOrder` for the custom-gate sweep. Holds CODE (never the wire). */ credentialRegistry?: ReadonlyMap; + /** Where a rail returns the buyer after they prove (the "continue to checkout" link + + * the post-proof redirect). Absent ⇒ each rail's default `/checkout?order=` (the + * storefront's route). A host that serves its checkout elsewhere — e.g. `orders.serve` + * at `/credentagent/orders/:id` — sets this so the buyer lands back on the right page. */ + returnUrl?: (orderId: string) => string; } /** The resolved context each rail receives (every required seam present). */ @@ -100,6 +105,8 @@ export interface CeremonyContext { /** The gate's credential registry (007) — the rails read it to serve a custom * credential's own request/verify. Absent when no CredentAgent registry was passed. */ credentialRegistry?: ReadonlyMap; + /** Build the buyer's return-to-checkout URL for an order (absent ⇒ the rail default). */ + returnUrl?: (orderId: string) => string; } /** A rail attaches its routes to the host app given the resolved context. */ @@ -133,6 +140,7 @@ export function mountCeremony(app: CeremonyApp, options: Partial const statelessOrders = options.statelessOrders ?? locals.statelessOrders ?? false; const readerIdentity = options.readerIdentity ?? locals.readerIdentity; const credentialRegistry = options.credentialRegistry ?? locals.credentialRegistry; + const returnUrl = options.returnUrl ?? locals.returnUrl; let signingKey = options.signingKey ?? locals.signingKey; // Fail fast (CT2) — a load-bearing seam must never silently default. (`origin` @@ -174,6 +182,7 @@ export function mountCeremony(app: CeremonyApp, options: Partial ...(settlement ? { settlement } : {}), ...(verifier ? { verifier } : {}), ...(readerIdentity ? { readerIdentity } : {}), + ...(returnUrl ? { returnUrl } : {}), }; // Re-expose the resolved seams on app.locals so the storefront's gate routes diff --git a/packages/credentagent-gate/src/ceremony/passkey/routes.ts b/packages/credentagent-gate/src/ceremony/passkey/routes.ts index 9b5dc57..3d5ae14 100644 --- a/packages/credentagent-gate/src/ceremony/passkey/routes.ts +++ b/packages/credentagent-gate/src/ceremony/passkey/routes.ts @@ -142,7 +142,7 @@ export const registerPasskeyGate: RailRegistrar = (app: CeremonyApp, ctx: Ceremo const verified = (await ctx.verificationStore.read(order.id)) ?? {}; const rail = checkoutRail(order, "pay", { ageVerified: verified.ageVerified === true }); try { - res.status(200).type("html").send(renderPasskeyPage({ order, crossDevice: isCrossDevice(req.query.xdev), cart: typeof req.query.cart === "string" ? req.query.cart : undefined, rail })); + res.status(200).type("html").send(renderPasskeyPage({ order, crossDevice: isCrossDevice(req.query.xdev), cart: typeof req.query.cart === "string" ? req.query.cart : undefined, rail, returnUrl: ctx.returnUrl?.(order.id) })); } catch { // A hand-edited order can carry a bad currency that throws in Intl; never 500. res.status(404).type("html").send("

Order not found

"); diff --git a/packages/credentagent-gate/src/client.ts b/packages/credentagent-gate/src/client.ts index 6cbf968..d550ecc 100644 --- a/packages/credentagent-gate/src/client.ts +++ b/packages/credentagent-gate/src/client.ts @@ -7,6 +7,8 @@ import type { Credential, CredentAgentOptions, GateOrder, ReaderIdentity, Step, import { resolveRequirements } from "./manifest.js"; 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"; x509.cryptoProvider.set(globalThis.crypto); @@ -28,12 +30,17 @@ const DEFAULT_WALLET_ORIGIN = `http://localhost:${process.env.PORT ?? 3000}`; export class CredentAgent { readonly walletOrigin: string; readonly store: VerificationStore; + /** The human-present checkout resource — `orders.create()` / `orders.retrieve()` (spec 009). */ + readonly orders: Orders; /** Stable reader identity presented by the rails (undefined ⇒ per-request self-signed). */ readonly readerIdentity?: ReaderIdentity; + private readonly listeners = new Map void>>(); // True once the ceremony rails are wired onto a host app (so `/credentagent/*` routes // exist on this server). `requirements()` then emits approve links that resolve // to those mounted routes rather than the legacy `/credential-gate/*` shape. private mountedRoutes = false; + // True once `orders.serve(app)` has wired the checkout (idempotent — one serve per client). + private ordersServed = false; // In-process credential registry (id → Credential), populated as `requirements()` // resolves policies — register-on-resolve, so a developer registers nothing (Principle // V). Injected into the ceremony context at `mount()` so the rails can serve a custom @@ -77,6 +84,59 @@ export class CredentAgent { // (fail-open). register-on-resolve stays for zero-config dev; this makes multi-instance // deploys fail-closed. Reserved ids are inert here (the sweep + resolveCred skip them). for (const c of opts.credentials ?? []) this.registry.set(c.id, c); + // The orders resource — configure-once: it reuses this client's origin + requirements(), + // with in-memory order stores by default (inject a shared store for multi-instance deploys). + // The two stores are held here so `orders.serve(app)` binds the checkout over the SAME + // state `orders.create()` / `orders.retrieve()` use (invariant 4 — keyed per order id). + const createdStore = opts.orderStore ?? new MemoryOrderStore(); + const completedStore = opts.completedOrderStore ?? new MemoryOrderStore(); + 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), + serve: (app) => { + if (this.ordersServed) return; // idempotent + serveOrders(app as CeremonyApp, { + walletOrigin: this.walletOrigin, + created: createdStore, + completed: completedStore, + complete: (record) => this.orders._complete(record), + requirements: (order, policy) => this.requirements(order, policy), + verificationStore: this.store, + credentialRegistry: this.registry, + ...(this.readerIdentity ? { readerIdentity: this.readerIdentity } : {}), + ...(opts.gateSecret ? { signingKey: opts.gateSecret } : {}), + }); + this.ordersServed = true; + this.mountedRoutes = true; // approve links now resolve to the mounted rails + }, + }); + } + + /** + * Subscribe to a lifecycle event. Today: `"order.settled"` — fired once when an order + * completes (the completed-store write emits it). + * + * This is an IN-PROCESS listener, NOT an HTTP webhook: the handler runs in the same Node + * process that completed the order, synchronously, with no network hop, retry, or signing. + * In a single-process server that's all you need — react here instead of polling. In a + * multi-instance / serverless deploy the event fires only on the instance that completed + * the order; a listener elsewhere won't hear it, so read `orders.retrieve(id)` (backed by a + * shared completed-order store) as the durable, cross-instance signal. A real outbound HTTP + * webhook is not built yet. + */ + on(event: "order.settled", handler: (payload: { id: string }) => void): void { + const set = this.listeners.get(event) ?? new Set(); + set.add(handler); + this.listeners.set(event, set); + } + + private emit(event: string, payload: { id: string }): void { + for (const h of this.listeners.get(event) ?? []) { + try { h(payload); } catch (err) { console.error(`[credentagent] ${event} handler threw:`, err); } + } } /** diff --git a/packages/credentagent-gate/src/index.ts b/packages/credentagent-gate/src/index.ts index d31fcb6..7ed8db9 100644 --- a/packages/credentagent-gate/src/index.ts +++ b/packages/credentagent-gate/src/index.ts @@ -22,6 +22,12 @@ export { age, membership, payment, required, optional, defineCredential, dcql, g // ── Store ──────────────────────────────────────────────────────────────── export { MemoryVerificationStore } from "./store.js"; +// ── The orders resource (spec 009) ────────────────────────────────────────── +// `await credentagent.orders.create({ order, policy })` → { id, approveUrl, manifest }; +// `credentagent.orders.retrieve(id)` → the door (ok | pending+approveUrl | reason). +export { Orders, MemoryOrderStore } from "./orders.js"; +export type { OrderStore, CreatedOrder, CompletedOrder, OrderDoor } from "./orders.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` diff --git a/packages/credentagent-gate/src/orders-serve.test.ts b/packages/credentagent-gate/src/orders-serve.test.ts new file mode 100644 index 0000000..f5b5263 --- /dev/null +++ b/packages/credentagent-gate/src/orders-serve.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest"; +import { CredentAgent } from "./client.js"; +import { age, payment, membership, required, optional } from "./credentials.js"; + +// A minimal dependency-free Express double: capture the registered route handlers so we can +// invoke the orders page / place / status handlers directly (the rails register too; we don't +// invoke them). The gate is express-free by design, so a structural double is enough. +function fakeApp() { + const get = new Map(); + const post = new Map(); + return { + locals: {} as Record, + get(path: string, ...h: unknown[]) { get.set(path, h[h.length - 1] as Function); }, + post(path: string, ...h: unknown[]) { post.set(path, h[h.length - 1] as Function); }, + use() {}, + _get: get, + _post: post, + }; +} +function fakeRes() { + const res: any = { _status: 200, _body: undefined as string | undefined, _json: undefined as unknown, headers: {} as Record }; + res.status = (c: number) => { res._status = c; return res; }; + res.type = () => res; + res.send = (b: string) => { res._body = b; return res; }; + res.json = (b: unknown) => { res._json = b; return res; }; + res.setHeader = (k: string, v: string) => { res.headers[k] = v; }; + return res; +} + +// Amounts are dollars, matching the checkout page's formatter ($21.00 — not minor units). +const wineOrder = () => ({ id: "", total: 21, currency: "USD", lines: [{ id: "wine", name: "Wine", quantity: 1, unitPrice: 21, minimumAge: 21 }] }); +const stickerOrder = () => ({ id: "", total: 5, currency: "USD", lines: [{ id: "sticker", name: "Sticker", quantity: 1, unitPrice: 5 }] }); +const gatedPolicy = () => [required(age.over(21)), required(payment.in("usd"))]; + +describe("orders.serve — checkout wiring", () => { + it("serve() registers the checkout page, place, and status routes", () => { + const ca = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + const app = fakeApp(); + ca.orders.serve(app); + expect(app._get.has("/credentagent/orders/:id")).toBe(true); + expect(app._post.has("/credentagent/orders/:id/place")).toBe(true); + expect(app._get.has("/credentagent/orders/:id/status")).toBe(true); + }); + + it("renders the checkout page for an order (200), and retrieve stays PENDING until completion", async () => { + const ca = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + const app = fakeApp(); + ca.orders.serve(app); + const { id } = await ca.orders.create({ order: wineOrder(), policy: gatedPolicy() }); + + const res = fakeRes(); + await app._get.get("/credentagent/orders/:id")!({ params: { id } }, res); + expect(res._status).toBe(200); + expect(res._body).toContain("Wine"); + + expect((await ca.orders.retrieve(id)).ok).toBe(false); // still pending — page render is not completion + }); + + // BYPASS (invariant 1) — the instant-demo place path completes WITHOUT a device ceremony, + // so it must refuse a GATED order (age / payment). Delete the isGated guard in orders-serve + // and this goes red: an age-restricted order would complete via a direct POST with NO age + // proof — exactly the "hiding a button is not enforcement" bug. + it("REFUSES the instant-demo place path for a gated order — it never completes unverified", async () => { + const ca = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + const app = fakeApp(); + ca.orders.serve(app); + const { id } = await ca.orders.create({ order: wineOrder(), policy: gatedPolicy() }); + + const res = fakeRes(); + await app._post.get("/credentagent/orders/:id/place")!({ params: { id } }, res); + expect(res._status).toBe(403); + + // The load-bearing assertion: the order is STILL not completed (no age proof was given). + expect((await ca.orders.retrieve(id)).ok).toBe(false); + }); + + it("an UNGATED order completes via the demo place path → order.settled + retrieve ok", async () => { + const ca = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + const app = fakeApp(); + const settled: string[] = []; + ca.on("order.settled", ({ id }) => settled.push(id)); + ca.orders.serve(app); + // No blocking gate → ungated → the instant-demo path is allowed. + const { id } = await ca.orders.create({ order: stickerOrder(), policy: [] }); + + const res = fakeRes(); + await app._post.get("/credentagent/orders/:id/place")!({ params: { id } }, res); + expect(res._status).toBe(200); + + expect(settled).toEqual([id]); // the in-process order.settled event fired once + const after = await ca.orders.retrieve(id); + expect(after.ok).toBe(true); + if (after.ok) expect(after.completion.amount).toBe(5); // amount re-derived server-side (invariant 2) + }); + + // The order.settled listener triggers fulfillment, so a retried / double-clicked place POST + // must not re-fire it. Delete the completed-store check in the place handler and this goes + // red: every duplicate POST would re-record the order and fulfill it again. + it("the demo place path is IDEMPOTENT — a duplicate POST never re-fires order.settled", async () => { + const ca = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + const app = fakeApp(); + const settled: string[] = []; + ca.on("order.settled", ({ id }) => settled.push(id)); + ca.orders.serve(app); + const { id } = await ca.orders.create({ order: stickerOrder(), policy: [] }); + + const place = app._post.get("/credentagent/orders/:id/place")!; + await place({ params: { id } }, fakeRes()); + const res = fakeRes(); + await place({ params: { id } }, res); // retry / double-click / duplicate delivery + expect(res._status).toBe(200); // still acknowledged… + expect(settled).toEqual([id]); // …but settled exactly once + }); + + // Regression (found by driving the browser): after a rail proves, the buyer must return to + // THIS order's checkout page — not the storefront's `/checkout`, which the orders interface + // doesn't serve (a "Cannot GET /checkout" dead end). serve() threads a returnUrl into the rails. + it("threads the orders return URL into the ceremony rails (not the storefront's /checkout)", async () => { + const ca = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + const app = fakeApp(); + ca.orders.serve(app); + const { id } = await ca.orders.create({ order: wineOrder(), policy: gatedPolicy() }); + + const credentialHandler = app._get.get("/credentagent/credential"); + expect(credentialHandler).toBeTruthy(); + const res = fakeRes(); + await credentialHandler!({ query: { order: id, cred: "age" }, headers: { host: "localhost:4000" }, protocol: "http", params: {} }, res); + + expect(res._body).toContain(`/credentagent/orders/${id}`); // returns to the orders page + expect(res._body).not.toContain("/checkout?order="); // NOT the storefront route + }); + + it("status returns { completed } for the poll", async () => { + const ca = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + const app = fakeApp(); + ca.orders.serve(app); + const { id } = await ca.orders.create({ order: stickerOrder(), policy: [] }); + + let res = fakeRes(); + await app._get.get("/credentagent/orders/:id/status")!({ params: { id } }, res); + expect(res._json).toMatchObject({ completed: false }); + + await app._post.get("/credentagent/orders/:id/place")!({ params: { id } }, fakeRes()); + res = fakeRes(); + await app._get.get("/credentagent/orders/:id/status")!({ params: { id } }, res); + expect(res._json).toMatchObject({ completed: true }); + }); + + it("an optional membership discount does not, by itself, gate the demo path", async () => { + // A discount is not a blocking gate; an order whose only policy entry is an optional + // membership discount stays ungated (payment/age would gate it — this one has neither). + const ca = new CredentAgent({ walletOrigin: "http://localhost:4000" }); + const app = fakeApp(); + ca.orders.serve(app); + const { id } = await ca.orders.create({ order: stickerOrder(), policy: [optional(membership.discount(10))] }); + const res = fakeRes(); + await app._post.get("/credentagent/orders/:id/place")!({ params: { id } }, res); + expect(res._status).toBe(200); + expect((await ca.orders.retrieve(id)).ok).toBe(true); + }); +}); diff --git a/packages/credentagent-gate/src/orders-serve.ts b/packages/credentagent-gate/src/orders-serve.ts new file mode 100644 index 0000000..6bec6eb --- /dev/null +++ b/packages/credentagent-gate/src/orders-serve.ts @@ -0,0 +1,281 @@ +// orders.serve(app) — wire the human-present checkout onto an Express app in ONE call. +// +// This is the graduation of the orders prototype into the library: everything the +// `stateless-orders` example wired by hand (a catalog re-pricer, a completion context, +// the completion seam, the checkout page route) now lives here, bound to the created-order +// store `orders.create()` writes. The caller writes: +// +// const ca = new CredentAgent({ walletOrigin }); +// ca.orders.serve(app); // rails + page + completion → order.settled +// ca.on("order.settled", ({ id }) => fulfill(id)); +// const { approveUrl } = await ca.orders.create({ order, policy }); +// +// It reuses the SAME proven pieces every other path uses — `mountCeremony` (the rails), +// `completeOrder` (the shared, fail-closed completion), and `renderRequirements` (the one +// checkout page) — so there is no second, weaker enforcement surface. The created order is +// the server-side price authority (invariant 2): the catalog re-derives amount + age +// threshold from the STORED lines, never from the token. + +import { mountCeremony, type CeremonyApp } from "./ceremony/mount.js"; +import { completeOrder, type CompletedRecord, type CompletedOrderStore } from "./ceremony/completion.js"; +import { renderRequirements, type RenderOrder } from "./ceremony/checkout-page.js"; +import type { CartItemRef, CeremonyCatalog, CeremonyOrder, CeremonyOrderStore, RepriceOpts } from "./ceremony/types.js"; +import type { + Credential, + GateOrder, + ReaderIdentity, + Step, + VerificationManifestEntry, + VerificationRecord, + VerificationStore, +} from "./types.js"; +import type { CompletedOrder, CreatedOrder, OrderStore } from "./orders.js"; + +/** What `orders.serve(app)` needs from the client to wire the checkout. */ +export interface ServeOrdersDeps { + walletOrigin: string; + /** The created-order store `orders.create()` writes (the price authority). */ + created: OrderStore; + /** The completed-order store `orders.retrieve()` reads. */ + completed: OrderStore; + /** `orders._complete` — records the completion AND fires `order.settled`. */ + complete: (record: CompletedOrder) => void | Promise; + /** `credentagent.requirements` — the policy → manifest resolver (re-homed approve links). */ + requirements: (order: GateOrder, policy: Step[]) => VerificationManifestEntry[]; + /** The per-order verification store (invariant 4). */ + verificationStore: VerificationStore; + /** The in-process credential registry, so `completeOrder` enforces every custom gate (007). */ + credentialRegistry: ReadonlyMap; + /** Stable reader identity the rails present (omit ⇒ per-request self-signed). */ + readerIdentity?: ReaderIdentity; + /** Stable HMAC key for the challenge (survives an instance split). Omit ⇒ ephemeral dev key. */ + signingKey?: string; +} + +/** A structural Express request/response — the package stays dependency-free (mirrors the rails). */ +interface OrdersRequest { + params: Record; +} +interface OrdersResponse { + status(code: number): OrdersResponse; + type(t: string): OrdersResponse; + send(body: string): unknown; + json(body: unknown): unknown; + setHeader?(name: string, value: string): unknown; +} +type OrdersHandler = (req: OrdersRequest, res: OrdersResponse) => void | Promise; + +// ── Order shape mapping (the stored GateOrder is the authority) ──────────────── + +/** The discount percent a policy grants (a `discount` step), if any. */ +function discountPctOf(policy: Step[]): number | undefined { + for (const s of policy) { + if (s.credential.effect.kind === "discount") { + return s.credential.effect.percent ?? s.credential.params?.percent; + } + } + return undefined; +} + +/** GateOrder line → CeremonyOrder line (compute lineTotal; carry the fields the gates read). */ +function toCeremonyLine(l: GateOrder["lines"][number], currency: string) { + return { + id: l.id, + name: typeof l.name === "string" ? l.name : l.id, + unitPrice: l.unitPrice, + quantity: l.quantity, + lineTotal: l.unitPrice * l.quantity, + currency, + ...(typeof l.minimumAge === "number" ? { minimumAge: l.minimumAge } : {}), + ...(typeof l.category === "string" ? { category: l.category } : {}), + ...(typeof l.requiresRx === "boolean" ? { requiresRx: l.requiresRx } : {}), + }; +} + +/** Re-price a stored order's lines, applying the policy discount only when loyalty is proven. */ +function repriceStored(created: CreatedOrder, items: CartItemRef[], opts?: RepriceOpts): CeremonyOrder { + const priceOf = new Map(created.order.lines.map((l) => [l.id, l])); + const currency = created.order.currency; + const lines = items.map((it) => { + const src = priceOf.get(it.productId); + if (!src) throw new Error(`[credentagent] orders catalog: unknown line "${it.productId}" for order ${created.order.id}`); + return toCeremonyLine({ ...src, quantity: it.quantity }, currency); + }); + const subtotal = lines.reduce((s, l) => s + l.lineTotal, 0); + const pct = discountPctOf(created.policy); + const discount = opts?.loyaltyApplied && pct ? Math.round(subtotal * pct) / 100 : 0; + return { + id: created.order.id, + lines, + itemCount: lines.reduce((s, l) => s + l.quantity, 0), + subtotal, + discount, + total: subtotal - discount, + currency, + }; +} + +function toRenderOrder(o: CeremonyOrder): RenderOrder { + return { + id: o.id, + lines: o.lines.map((l) => ({ name: l.name, id: l.id, quantity: l.quantity, lineTotal: l.lineTotal, currency: l.currency })), + itemCount: o.itemCount ?? o.lines.reduce((s, l) => s + l.quantity, 0), + discount: o.discount, + total: o.total, + currency: o.currency, + }; +} + +/** An order is "gated" when its policy needs a ceremony — any blocking gate or payment + * authorize. Gated orders complete ONLY through the fail-closed rails; the instant-demo + * place path is refused for them (invariant 1 — enforced server-side, not by hiding a button). */ +function isGated(manifest: VerificationManifestEntry[]): boolean { + return manifest.some((e) => e.effect === "gate" || e.effect === "authorize"); +} + +// ── Wire it all ─────────────────────────────────────────────────────────────── + +const html = (body: string) => + `${body}`; + +/** + * Register the checkout onto `app`: the ceremony rails (via `mountCeremony`), the checkout + * page at `/credentagent/orders/:id`, the instant-demo place path (ungated only), and the + * status poll. Idempotent per app is the caller's concern (the client guards double-serve). + */ +export function serveOrders(app: CeremonyApp, deps: ServeOrdersDeps): void { + // A synchronous mirror of the stored order, warmed by every `orderStore.read` (which the + // rails call before the synchronous catalog re-price). Keyed by order id; prices only. + const warm = new Map(); + + const orderStore: CeremonyOrderStore = { + read: async (orderId: string): Promise => { + const created = await deps.created.read(orderId); + if (!created) return null; + warm.set(orderId, created); + return repriceStored(created, created.order.lines.map((l) => ({ productId: l.id, quantity: l.quantity }))); + }, + }; + + const catalog: CeremonyCatalog = { + createOrder: (items: CartItemRef[], orderId: string, opts?: RepriceOpts): CeremonyOrder => { + const created = warm.get(orderId); + if (!created) throw new Error(`[credentagent] orders catalog: order ${orderId} not resolved before re-price`); + return repriceStored(created, items, opts); + }, + }; + + // The completion seam = the shared `completeOrder`, bound so its idempotent record write + // flows into `orders._complete` (which writes the completed store AND fires order.settled). + const records: CompletedOrderStore = { + read: async (orderId: string): Promise => { + const done = await deps.completed.read(orderId); + if (!done) return undefined; + // Enough for `completeOrder`'s idempotency echo (it checks truthiness + settlement). + return { orderId, mandateId: done.txId ?? "", amount: done.amount ?? 0, currency: done.currency ?? "", method: done.method ?? "", gates: [], completedAt: done.completedAt ?? "" }; + }, + write: async (record: CompletedRecord): Promise => { + await deps.complete({ + orderId: record.orderId, + amount: record.amount, + currency: record.currency, + method: record.method, + ...(record.settlement?.txId ? { txId: record.settlement.txId } : {}), + ...(record.settlement?.network ? { network: record.settlement.network } : {}), + completedAt: record.completedAt, + }); + }, + }; + + mountCeremony(app, { + orderStore, + catalog, + completion: (input) => completeOrder(input, { catalog, verificationStore: deps.verificationStore, records, credentialRegistry: deps.credentialRegistry }), + verificationStore: deps.verificationStore, + credentialRegistry: deps.credentialRegistry, + // After a rail proves / pays, return the buyer to THIS order's checkout page — not the + // storefront's `/checkout` default (which the orders interface doesn't serve). + returnUrl: (id) => `${deps.walletOrigin}/credentagent/orders/${encodeURIComponent(id)}`, + ...(deps.readerIdentity ? { readerIdentity: deps.readerIdentity } : {}), + ...(deps.signingKey ? { signingKey: deps.signingKey } : { allowEphemeralKey: true }), + }); + + const get = app.get?.bind(app); + const post = app.post?.bind(app); + if (!get || !post) { + throw new Error("[credentagent] orders.serve(app): the app must expose Express-style get()/post() route methods."); + } + + // The checkout page — the ONE shared three-gate page. It LINKS to the rails mountCeremony + // registered; it does not run the ceremony (the rails do, fail-closed). + const page: OrdersHandler = async (req, res) => { + const id = req.params.id; + const created = await deps.created.read(id); + if (!created) { res.status(404).type("html").send(html("

Unknown order

")); return; } + warm.set(id, created); + + const v = ((await deps.verificationStore.read(id)) ?? {}) as VerificationRecord; + const ageVerified = v.ageVerified === true; + const loyaltyApplied = v.loyalty?.applied === true; + const order = repriceStored(created, created.order.lines.map((l) => ({ productId: l.id, quantity: l.quantity })), { ageVerified, loyaltyApplied }); + + const manifest = deps.requirements(created.order, created.policy); // re-homed approve links (mounted) + const done = await deps.completed.read(id); + const gated = isGated(manifest); + const verification = { ageVerified, loyaltyApplied, ...(v.verifiedGates ? { verifiedGates: v.verifiedGates } : {}) }; + const paid = done ? { amount: done.amount ?? order.total, currency: done.currency ?? order.currency, ...(done.method ? { method: done.method } : {}) } : null; + + const orderQ = encodeURIComponent(id); + const payment = gated + ? { + methods: [ + { value: "passkey", name: "Pay with a passkey (this device)", desc: "Authorize with this device's passkey.", href: `${deps.walletOrigin}/credentagent/passkey?order=${orderQ}`, checked: true }, + { value: "dc-payment", name: "Cross-device wallet", desc: "Scan a QR and approve with your phone's wallet.", href: `${deps.walletOrigin}/credentagent/dc-payment?order=${orderQ}` }, + ], + } + : { + methods: [ + { value: "demo", name: `Complete purchase (demo) — ${order.total} ${order.currency}`, desc: "No real charge — records the order.", placeOrder: true }, + ], + placeOrderPath: `/credentagent/orders/${orderQ}/place`, + orderToken: id, + }; + const statusUrl = `/credentagent/orders/${orderQ}/status`; + res.type("html").send(renderRequirements(toRenderOrder(order), manifest, verification, { payment, paid, statusUrl })); + }; + + // Instant-demo completion — UNGATED orders only. A gated order (age / payment) is refused + // here (invariant 1): it must complete through the fail-closed rails, never a direct POST. + const place: OrdersHandler = async (req, res) => { + const id = req.params.id; + const created = await deps.created.read(id); + if (created) { + warm.set(id, created); + const manifest = deps.requirements(created.order, created.policy); + if (isGated(manifest)) { + res.status(403).type("html").send(html("

Verification required

This order has age / payment requirements — complete it on the checkout page. It can't be placed from the instant-demo path.

")); + return; + } + // Idempotent, like the rails' completeOrder: a retried / double-clicked POST must not + // re-record the order or fire order.settled again (the listener triggers fulfillment). + const done = await deps.completed.read(id); + if (!done) { + const order = repriceStored(created, created.order.lines.map((l) => ({ productId: l.id, quantity: l.quantity }))); + await deps.complete({ orderId: id, amount: order.total, currency: order.currency, method: "demo", completedAt: new Date().toISOString() }); + } + } + res.type("html").send(html("

✓ Order placed (demo)

You can close this tab.

")); + }; + + // The status poll — a standing checkout tab reloads when the order completes on another + // tab / device / rail (MCP / the browser have no server→client push). + const status: OrdersHandler = async (req, res) => { + res.setHeader?.("Access-Control-Allow-Origin", "*"); + const done = await deps.completed.read(req.params.id); + res.json({ completed: !!done, order: done ?? null }); + }; + + get("/credentagent/orders/:id", page); + post("/credentagent/orders/:id/place", place); + get("/credentagent/orders/:id/status", status); +} diff --git a/packages/credentagent-gate/src/orders.test.ts b/packages/credentagent-gate/src/orders.test.ts new file mode 100644 index 0000000..431335e --- /dev/null +++ b/packages/credentagent-gate/src/orders.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { CredentAgent } from "./client.js"; +import { age, payment, required } from "./credentials.js"; +import type { CreatedOrder, OrderStore } from "./orders.js"; + +// Amounts are dollars, matching the checkout page's formatter ($21.00 — not minor units). +const anOrder = () => ({ + id: "", + total: 21, + currency: "USD", + lines: [{ id: "wine", quantity: 1, unitPrice: 21, minimumAge: 21 }], +}); +const aPolicy = () => [required(age.over(21)), required(payment.in("usd"))]; + +describe("credentagent.orders", () => { + it("create() returns an id, an approveUrl on this origin, and the resolved manifest", async () => { + const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); + const { id, approveUrl, manifest } = await ca.orders.create({ order: anOrder(), policy: aPolicy() }); + expect(id).toMatch(/^ord_/); + expect(approveUrl).toBe(`https://shop.example/credentagent/orders/${id}`); + const creds = manifest.map((m) => m.credential); + expect(creds).toContain("age"); + expect(creds).toContain("payment"); + }); + + // The approveUrl is only usable if the order is READABLE when it's handed out: with an + // injected async/shared store (Redis, multi-instance), a fire-and-forget write can still be + // in flight when the human opens the link on another instance — a 404 on a "created" order. + // Delete the `await` on `created.write` in create() and this goes red. + it("create() resolves only after the created order is persisted (async store)", async () => { + const backing = new Map(); + const slowStore: OrderStore = { + read: async (id) => backing.get(id), + write: async (id, v) => { + await new Promise((r) => setTimeout(r, 5)); + backing.set(id, v); + }, + clear: async (id) => { + backing.delete(id); + }, + }; + const ca = new CredentAgent({ walletOrigin: "https://shop.example", orderStore: slowStore }); + const { id } = await ca.orders.create({ order: anOrder(), policy: aPolicy() }); + expect(backing.has(id)).toBe(true); // persisted BEFORE the caller can hand out approveUrl + }); + + // The load-bearing control: an order is `ok` ONLY once it has actually completed (the + // completed-order store holds it). Delete that gate — make retrieve() return ok for a merely + // *created* order — and this test goes red: an unproven order would read as done. + it("BYPASS: an order retrieves as PENDING until it completes — never ok before", async () => { + const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); + const { id } = await ca.orders.create({ order: anOrder(), policy: aPolicy() }); + + const before = await ca.orders.retrieve(id); + expect(before.ok).toBe(false); + expect(before).toMatchObject({ pending: true, approveUrl: expect.stringContaining(id) }); + + // what the ceremony's completeOrder path does when the human finishes: + await ca.orders._complete({ orderId: id, amount: 21, currency: "USD", method: "passkey", completedAt: "t" }); + + const after = await ca.orders.retrieve(id); + expect(after.ok).toBe(true); + }); + + // Invariant 4 — state is keyed per order; one order's completion never unlocks another. + it("scopes per order: completing A does not make B ok", async () => { + const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); + const a = await ca.orders.create({ order: anOrder(), policy: aPolicy() }); + const b = await ca.orders.create({ order: anOrder(), policy: aPolicy() }); + await ca.orders._complete({ orderId: a.id }); + expect((await ca.orders.retrieve(a.id)).ok).toBe(true); + expect((await ca.orders.retrieve(b.id)).ok).toBe(false); // B untouched + }); + + it("fires order.settled once on completion (in-process event, not a poll loop)", async () => { + const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); + const seen: string[] = []; + ca.on("order.settled", ({ id }) => seen.push(id)); + const { id } = await ca.orders.create({ order: anOrder(), policy: aPolicy() }); + await ca.orders._complete({ orderId: id }); + expect(seen).toEqual([id]); + }); + + it("retrieve of an unknown id is a typed refusal, not a throw", async () => { + const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); + expect(await ca.orders.retrieve("ord_nope")).toMatchObject({ ok: false, code: "not-found" }); + }); +}); diff --git a/packages/credentagent-gate/src/orders.ts b/packages/credentagent-gate/src/orders.ts new file mode 100644 index 0000000..3fd0245 --- /dev/null +++ b/packages/credentagent-gate/src/orders.ts @@ -0,0 +1,121 @@ +// credentagent.orders — the human-present checkout resource (spec 009). +// +// const { id, approveUrl, manifest } = await credentagent.orders.create({ order, policy }); +// // hand approveUrl to the human; they prove on the checkout page (renderRequirements + the rails). +// const res = await credentagent.orders.retrieve(id); // the DOOR: ok | pending+approveUrl | reason +// +// It wraps machinery the gate already has: requirements() (the manifest), the ceremony +// rails + renderRequirements() (the approveUrl page), and completeOrder() (whose write to the +// completed-order store emits the in-process `order.settled` event — FR-009 — so a single- +// process server reacts to it instead of polling; it is an in-process listener, NOT an HTTP webhook). +// +// Stores default to in-memory and are per-order keyed (Security invariant 4 — never +// process-global); inject a shared store (Redis) for multi-instance deploys. + +import type { GateOrder, Step, VerificationManifestEntry, TrustLevel } from "./types.js"; + +/** A created-but-not-yet-completed order: the inputs the door + page re-derive from. */ +export interface CreatedOrder { + order: GateOrder; + policy: Step[]; +} + +/** The lean record completeOrder writes when an order finishes (mirrors the storefront shape). */ +export interface CompletedOrder { + orderId: string; + amount?: number; + currency?: string; + method?: string; + txId?: string; + network?: string; + completedAt?: string; + /** The signed AP2 records, when the completion path surfaces them. */ + mandateBundle?: unknown; +} + +/** Minimal per-order KV, mirroring VerificationStore. In-memory default; inject for prod. */ +export interface OrderStore { + read(id: string): T | undefined | Promise; + write(id: string, value: T): void | Promise; + clear(id: string): void | Promise; +} + +export class MemoryOrderStore implements OrderStore { + private readonly m = new Map(); + read(id: string): T | undefined { return this.m.get(id); } + write(id: string, value: T): void { this.m.set(id, value); } + clear(id: string): void { this.m.delete(id); } +} + +/** The one result shape every consent path shares (spec 009 FR-003). */ +export type OrderDoor = + | { ok: true; mandateBundle?: unknown; authorization: "direct"; trustLevel: TrustLevel; completion: Omit } + | { ok: false; pending: true; approveUrl: string; trustLevel: TrustLevel } + | { ok: false; code: string; credential?: string; trustLevel: TrustLevel }; + +export interface OrdersDeps { + walletOrigin: string; + /** Resolve the policy to the serializable manifest (CredentAgent.requirements). */ + requirements: (order: GateOrder, policy: Step[]) => VerificationManifestEntry[]; + created: OrderStore; + /** The completed-order store; its write() fires "order.settled". */ + completed: OrderStore; + emit: (event: "order.settled", payload: { id: string }) => void; + /** Wire the checkout (rails + page + completion) onto an Express app — `orders.serve(app)`. */ + serve: (app: unknown) => void; +} + +const genId = (): string => `ord_${globalThis.crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; +const TRUST: TrustLevel = "presence-only-demo"; + +export class Orders { + constructor(private readonly deps: OrdersDeps) {} + + /** Open an order that needs consent. Returns the id, the approve link, and the manifest. */ + async create({ order, policy }: { order: GateOrder; policy: Step[] }): Promise<{ + id: string; + approveUrl: string; + manifest: VerificationManifestEntry[]; + }> { + const id = order.id && order.id.trim() !== "" ? order.id : genId(); + const withId: GateOrder = { ...order, id }; + const manifest = this.deps.requirements(withId, policy); // re-priced/resolved server-side (invariant 2) + // Await persistence BEFORE handing out approveUrl: with an injected async/shared store + // (Redis, multi-instance) an unawaited write can still be in flight when the human opens + // the link on another instance — the page would 404 on an order create() reported. + await this.deps.created.write(id, { order: withId, policy }); + return { id, approveUrl: `${this.deps.walletOrigin}/credentagent/orders/${id}`, manifest }; + } + + /** Read the current outcome — a single call (use in an order.settled handler, never a poll loop). */ + async retrieve(id: string): Promise { + const done = await this.deps.completed.read(id); + if (done) { + const { orderId: _o, mandateBundle, ...completion } = done; + return { ok: true, authorization: "direct", trustLevel: TRUST, ...(mandateBundle !== undefined ? { mandateBundle } : {}), completion }; + } + const created = await this.deps.created.read(id); + if (created) return { ok: false, pending: true, approveUrl: `${this.deps.walletOrigin}/credentagent/orders/${id}`, trustLevel: TRUST }; + return { ok: false, code: "not-found", trustLevel: TRUST }; + } + + /** + * Wire the checkout onto your Express app in one call: the ceremony rails, the checkout + * page at each order's `approveUrl` (`/credentagent/orders/:id`), and completion — a + * finished ceremony records the order and fires `order.settled`. No seams to assemble. + * + * ca.orders.serve(app); + * ca.on("order.settled", ({ id }) => fulfill(id)); + * const { approveUrl } = await ca.orders.create({ order, policy }); + */ + serve(app: unknown): void { + this.deps.serve(app); + } + + /** Called by the completion path when an order finishes — records it and emits the + * in-process `order.settled` event (see `CredentAgent.on` — a local listener, not a webhook). */ + async _complete(record: CompletedOrder): Promise { + await this.deps.completed.write(record.orderId, record); + this.deps.emit("order.settled", { id: record.orderId }); + } +} diff --git a/packages/credentagent-gate/src/types.ts b/packages/credentagent-gate/src/types.ts index 8b4f2a9..6a69194 100644 --- a/packages/credentagent-gate/src/types.ts +++ b/packages/credentagent-gate/src/types.ts @@ -5,6 +5,8 @@ // • manifest — data: `requirements()` resolves the policy server-side and emits a flat, JSON-safe // manifest. Functions NEVER cross the wire. `requirements()` is that code→data boundary. +import type { OrderStore, CreatedOrder, CompletedOrder } from "./orders.js"; + // ── DCQL (what to ask the wallet) ────────────────────────────────────────── export interface DcqlClaim { @@ -226,4 +228,16 @@ export interface CredentAgentOptions { * (fail-open). Declare your custom credentials here and every instance enforces them. */ credentials?: Credential[]; + /** Persist created orders (`orders.create`); default in-memory, inject a shared store for multi-instance. */ + orderStore?: OrderStore; + /** Persist completed orders (its `write()` fires `order.settled`); default in-memory, injectable. */ + completedOrderStore?: OrderStore; + /** + * Stable HMAC secret the checkout `orders.serve(app)` signs its challenges with, so a + * challenge issued on one instance verifies on another (a serverless / multi-worker split). + * Omit for a single-process dev server — `orders.serve` then uses an ephemeral per-process + * key (fine for one process; a challenge can't cross an instance boundary). Set it (e.g. + * `process.env.GATE_SECRET`) for any multi-instance deploy. + */ + gateSecret?: string; }