Skip to content
Merged
3 changes: 3 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 76 additions & 0 deletions examples/orders-checkout/README.md
Original file line number Diff line number Diff line change
@@ -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/<id>` → `{ 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.
44 changes: 44 additions & 0 deletions examples/orders-checkout/server.mjs
Original file line number Diff line number Diff line change
@@ -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`);
});
70 changes: 70 additions & 0 deletions examples/orders-checkout/smoke.mjs
Original file line number Diff line number Diff line change
@@ -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);
47 changes: 47 additions & 0 deletions packages/credentagent-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}),
);
});
Expand Down
9 changes: 9 additions & 0 deletions packages/credentagent-gate/src/ceremony/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Credential>;
/** 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=<id>` (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). */
Expand All @@ -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<string, Credential>;
/** 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. */
Expand Down Expand Up @@ -133,6 +140,7 @@ export function mountCeremony(app: CeremonyApp, options: Partial<CeremonySeams>
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`
Expand Down Expand Up @@ -174,6 +182,7 @@ export function mountCeremony(app: CeremonyApp, options: Partial<CeremonySeams>
...(settlement ? { settlement } : {}),
...(verifier ? { verifier } : {}),
...(readerIdentity ? { readerIdentity } : {}),
...(returnUrl ? { returnUrl } : {}),
};

// Re-expose the resolved seams on app.locals so the storefront's gate routes
Expand Down
2 changes: 1 addition & 1 deletion packages/credentagent-gate/src/ceremony/passkey/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!doctype html><h1>Order not found</h1>");
Expand Down
Loading
Loading