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
67 changes: 67 additions & 0 deletions examples/orders-checkout/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# `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.

```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" });
credentagent.orders.serve(app); // ← rails + checkout page + completion
credentagent.on("order.settled", ({ id }) => fulfill(id)); // ← fired once, when it's paid

app.post("/buy-wine", (_req, res) => {
const { approveUrl } = credentagent.orders.create({
order: { id: "", total: 2100, currency: "USD", lines: [{ id: "wine", name: "Bottle of wine", quantity: 1, unitPrice: 2100, minimumAge: 21 }] },
policy: [required(age.over(21)), required(payment.in("usd"))],
});
res.json({ approveUrl }); // ← hand this link to the human
});
```

## 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.
41 changes: 41 additions & 0 deletions examples/orders-checkout/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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());

// Configure once. `orders.serve(app)` wires the ceremony rails, the checkout page at each
// order's approveUrl, and completion — a finished ceremony fires `order.settled`.
const credentagent = new CredentAgent({ walletOrigin: `http://localhost:${PORT}` });
credentagent.orders.serve(app);
credentagent.on("order.settled", ({ id }) => console.log(`✓ order.settled: ${id} — fulfill it now`));

// What an agent calls to start a purchase that needs consent. 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", (_req, res) => {
const { id, approveUrl } = credentagent.orders.create({
order: { id: "", total: 2100, currency: "USD", lines: [{ id: "wine", name: "Bottle of wine", quantity: 1, unitPrice: 2100, 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`);
});
66 changes: 66 additions & 0 deletions examples/orders-checkout/smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// 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 (wine) and an ungated one (sticker) — plus retrieve.
Comment thread
TheBlackBit marked this conversation as resolved.
Outdated
app.post("/gated", (_req, res) => res.json(ca.orders.create({
order: { id: "", total: 2100, currency: "USD", lines: [{ id: "wine", name: "Wine", quantity: 1, unitPrice: 2100, minimumAge: 21 }] },
policy: [required(age.over(21)), required(payment.in("usd"))],
})));
app.post("/ungated", (_req, res) => res.json(ca.orders.create({
order: { id: "", total: 500, currency: "USD", lines: [{ id: "sticker", name: "Sticker", quantity: 1, unitPrice: 500 }] },
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 ungatedAfter = (await j(await fetch(`${base}/orders/${ungated.id}`))).body;
check("ungated order retrieves as ok with the server-derived amount ($5.00)", ungatedAfter.ok === true && ungatedAfter.completion?.amount === 500);
} finally {
server.close();
}

console.log(failures === 0 ? "\nALL SMOKE CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`);
process.exit(failures === 0 ? 0 : 1);
35 changes: 35 additions & 0 deletions packages/credentagent-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,41 @@ 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`.
`orders.serve(app)` wires the **whole** checkout onto your Express app in one call — the ceremony
rails, the checkout page, and completion — so there is nothing to assemble. `orders.create()`
returns a link you hand to the human; `order.settled` fires once, when it's paid.

```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" });
credentagent.orders.serve(app); // rails + checkout page + completion
credentagent.on("order.settled", ({ id }) => fulfill(id)); // fired once, when the order is paid

app.post("/buy-wine", (_req, res) => {
const { id, approveUrl } = credentagent.orders.create({ // → { id, approveUrl, manifest }
order: { id: "", total: 2100, currency: "USD", lines: [{ id: "wine", name: "Bottle of wine", quantity: 1, unitPrice: 2100, minimumAge: 21 }] },
policy: [required(age.over(21)), required(payment.in("usd"))],
});
res.json({ id, approveUrl }); // hand approveUrl to the human
});

app.get("/orders/:id", async (req, res) => res.json(await credentagent.orders.retrieve(req.params.id)));
```

`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
52 changes: 52 additions & 0 deletions packages/credentagent-gate/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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<string, Set<(payload: { id: string }) => 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
Expand Down Expand Up @@ -77,6 +84,51 @@ 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<CreatedOrder>();
const completedStore = opts.completedOrderStore ?? new MemoryOrderStore<CompletedOrder>();
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), so you retrieve ONCE and finish, never a poll loop.
*/
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); }
}
}

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/credentagent-gate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ export { age, membership, payment, required, optional, defineCredential, dcql, g
// ── Store ────────────────────────────────────────────────────────────────
export { MemoryVerificationStore } from "./store.js";

// ── The orders resource (spec 009) + Money ──────────────────────────────────
// `credentagent.orders.create({ order, policy })` → { id, approveUrl, manifest };
// `credentagent.orders.retrieve(id)` → the door (ok | pending+approveUrl | reason).
// Money is opaque + currency-checked: build with `usd.dollars(20)`, compare with .lt/.gte.
export { usd } from "./money.js";
export type { Money } from "./money.js";
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`
Expand Down
41 changes: 41 additions & 0 deletions packages/credentagent-gate/src/money.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Money — an opaque, currency-checked value. Amounts are integer minor units (cents),
// so no float drift; the raw scalar is not public, so a caller can't accidentally compare
// or add a bare number across currencies (spec 009 FR-005). Build with `usd.dollars(20)` /
// `usd.cents(2000)`; compare with `.lt/.gte/.eq`; combine with `.plus/.minus`; emit the wire
// shape with `.serialize()`.

export interface Money {
readonly currency: string;
lt(other: Money): boolean;
gte(other: Money): boolean;
eq(other: Money): boolean;
plus(other: Money): Money;
minus(other: Money): Money;
/** The wire form: `{ amount: <integer minor units>, currency }`. */
serialize(): { amount: number; currency: string };
toString(): string;
}

function money(minor: number, currency: string): Money {
if (!Number.isInteger(minor)) throw new Error(`Money must be an integer minor-unit amount, got ${minor}`);
const same = (o: Money) => {
if (o.currency !== currency) throw new Error(`currency mismatch: ${currency} vs ${o.currency}`);
return o.serialize().amount;
};
return Object.freeze<Money>({
currency,
lt: (o: Money) => minor < same(o),
gte: (o: Money) => minor >= same(o),
eq: (o: Money) => minor === same(o),
plus: (o: Money) => money(minor + same(o), currency),
minus: (o: Money) => money(minor - same(o), currency),
serialize: () => ({ amount: minor, currency }),
toString: () => `${currency.toUpperCase()} ${(minor / 100).toFixed(2)}`,
});
}

/** US dollars. `usd.dollars(20)` → $20.00 (2000 cents); `usd.cents(2000)` → the same. */
export const usd = Object.assign((minorCents: number) => money(minorCents, "usd"), {
dollars: (d: number) => money(Math.round(d * 100), "usd"),
cents: (c: number) => money(c, "usd"),
});
Loading
Loading