diff --git a/.specify/feature.json b/.specify/feature.json index 8ae8c34..91f09b6 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/007-quickstart-ladder" + "feature_directory": "specs/009-ap2-mandate-chain-dx" } diff --git a/examples/grants-proto/grants.mjs b/examples/grants-proto/grants.mjs new file mode 100644 index 0000000..5f60c98 --- /dev/null +++ b/examples/grants-proto/grants.mjs @@ -0,0 +1,142 @@ +// ⚠ PROTOTYPE — a validation demo, NOT the shipping library API. It graduates into the +// real credentagent.orders.* / credentagent.grants.* API in #97 (the demo is rewired to it). +// grants.mjs — a RUNNABLE prototype of the v10 `grants.*` surface (#92 / spec 008), +// the human-NOT-present half, over the REAL DelegatedGate engine. +// +// grants.create → authorize (the Intent Mandate is produced) → grant.spend loop +// (budget / perSpend enforcement, remaining, idempotent replay) → grant.revoke. +// +// REAL: DelegatedGate.preApprove/spend/revoke — the bounds check, the single-use ledger, +// the revocation, the dev-sealed Intent Mandate (sealIntent) are the actual engine. +// FACADE polish demonstrating the v10 design: usd() Money, { ok | code } door, idempotent +// replay ({ ok:true, replayed:true }), split reason codes (per-spend vs budget), mandateBundle. +// HONEST: today preApprove seals the intent SERVER-side (presence "delegated-demo", +// trust "server-issued-demo"); the human-signs-on-the-phone ceremony is the roadmap (#71). + +import { DelegatedGate, issueCartMandate } from "@openmobilehub/credentagent-gate"; + +const SIGNING_KEY = "grants-proto-secret"; +const b64u = (o) => Buffer.from(JSON.stringify(o)).toString("base64url"); + +// Engine RefusalCode → the v10 door's `code` (spec 008). +const REASON_MAP = { + "over-cap": "per-spend-exceeded", // per-draw ceiling (TS12 max_amount) + "over-total": "budget-exceeded", // cumulative budget (TS12 total_amount) + "revoked": "revoked", + "consumed": "revoked", + "out-of-scope": "wrong-merchant", + "expired": "expired", +}; + +export function usd(minor) { + return Object.freeze({ + currency: "usd", _minor: minor, + lt(o) { return minor < o._minor; }, gte(o) { return minor >= o._minor; }, eq(o) { return minor === o._minor; }, + serialize() { return { amount: minor, currency: "usd" }; }, + toString() { return `$${(minor / 100).toFixed(2)}`; }, + }); +} +usd.dollars = (d) => usd(Math.round(d * 100)); +usd.cents = (c) => usd(c); + +export class GrantsProto { + constructor({ catalog }) { + this.catalogMinor = catalog; // { sku: minorUnits } + this.gate = new DelegatedGate({ catalog }); // the REAL delegated engine + this._grants = new Map(); + } + + // grants.create({ merchant, budget, perSpend, policy }) — authorize once; the Intent Mandate is produced. + async create({ merchant, budget, perSpend, policy = [], description }) { + const dg = await this.gate.preApprove({ + merchant, + perOrder: perSpend._minor, + total: budget._minor, + description: description ?? `Up to ${budget} at ${merchant}, ${perSpend}/purchase`, + }); + const intentMandate = { + type: "ap2.IntentMandate", + intentId: dg.id, + presence: dg.presence, // "delegated-demo" + trustLevel: dg.trustLevel, // "server-issued-demo" + bounds: { merchant, perSpend: perSpend.serialize(), budget: budget.serialize(), policy: policy.map((p) => p.credential?.id ?? p.id ?? "credential") }, + serialize() { return b64u({ ...this, serialize: undefined }); }, + }; + const rec = { id: dg.id, dg, merchant, budget, perSpend, status: "authorized", intentMandate, cache: new Map() }; + this._grants.set(dg.id, rec); + return this._view(rec); + } + + // grants.retrieve(id) — rehydrate the grant handle. + retrieve(id) { + const rec = this._grants.get(id); + return rec ? this._view(rec) : null; + } + + _view(rec) { + return { + id: rec.id, + status: rec.status, + approveUrl: `about:blank#authorize-${rec.id}`, // roadmap: the wallet ceremony that key-signs the intent + intentMandate: rec.intentMandate, + budget: rec.budget.serialize(), + perSpend: rec.perSpend.serialize(), + spend: (purchase) => this._spend(rec, purchase), + revoke: () => this._revoke(rec), + }; + } + + async _spend(rec, { idempotencyKey, items }) { + if (rec.cache.has(idempotencyKey)) return { ...rec.cache.get(idempotencyKey), replayed: true }; // v10 idempotent replay + const { sku, qty = 1 } = items[0]; + const r = await rec.dg.spend({ idempotencyKey, item: sku, quantity: qty }); + let door; + if (r.ok) { + door = { + ok: true, + amount: usd(r.amount).serialize(), + remaining: usd(r.remaining).serialize(), + replayed: false, + authorization: "delegated", + trustLevel: "presence-only-demo", + mandateBundle: this._bundle(rec, sku, qty, r.amount), + }; + } else { + // Map the engine's RefusalCode → the v10 door's `code` vocabulary. The engine already + // distinguishes the two caps: "over-cap" = the per-draw (per-spend) ceiling, "over-total" + // = the cumulative budget. + const code = REASON_MAP[r.reason] ?? r.reason; + door = { ok: false, code, remaining: usd(r.remaining).serialize(), retryable: r.retryable, trustLevel: "presence-only-demo" }; + } + rec.cache.set(idempotencyKey, door); + return door; + } + + async _revoke(rec) { + await rec.dg.revoke(); + rec.status = "revoked"; + return { revoked: true, status: "revoked" }; + } + + _bundle(rec, sku, qty, amountMinor) { + // A CartMandateLine is { id, quantity, unitPrice, lineTotal } — so a recipient can reconcile the + // signed mandate to the priced purchase (Codex P1). Price from the catalog, never the caller. + const unitPrice = this.catalogMinor[sku] ?? Math.round(amountMinor / qty); + const line = { id: sku, quantity: qty, unitPrice, lineTotal: unitPrice * qty }; + const cart = issueCartMandate( + { orderId: `${rec.id}-${sku}-${rec.cache.size}`, lines: [line], currency: "usd", total: amountMinor }, + SIGNING_KEY, + ); + const pay = { + type: "ap2.PaymentMandate", amount: { amount: amountMinor, currency: "usd" }, + presenceMode: "human_not_present", authorization: "delegated", cart: cart.id, + intentId: rec.id, trust_level: "presence-only-demo", + }; + return { + intentMandate: { type: rec.intentMandate.type, intentId: rec.intentMandate.intentId, trustLevel: rec.intentMandate.trustLevel }, + cartMandate: { type: cart.type, id: cart.id, total: cart.total, trust_level: cart.trust_level, serialized: b64u(cart) }, + paymentMandate: { ...pay, serialized: b64u(pay) }, + trustLevel: "presence-only-demo", + }; + } +} diff --git a/examples/grants-proto/server.mjs b/examples/grants-proto/server.mjs new file mode 100644 index 0000000..00c4600 --- /dev/null +++ b/examples/grants-proto/server.mjs @@ -0,0 +1,150 @@ +// ⚠ PROTOTYPE — a validation demo, NOT the shipping library API. It graduates into the +// real credentagent.orders.* / credentagent.grants.* API in #97 (the demo is rewired to it). +// server.mjs — runnable demo of the v10 `grants.*` surface (human-not-present) with a live UI. +// +// (npm run build --workspaces) # once, if not built +// node examples/grants-proto/server.mjs # → http://localhost:4020 +// +// Left pane = human PRESENT: authorize once (the Intent Mandate is produced). +// Right pane = human AWAY: the agent's spend loop (budget/perSpend, remaining, replay) + revoke. + +import { createServer } from "node:http"; +import { GrantsProto, usd } from "./grants.mjs"; + +const PORT = Number(process.env.PORT ?? 4020); +const BASE = `http://localhost:${PORT}`; + +const grants = new GrantsProto({ catalog: { wine: 2000, case: 5000 } }); // wine=$20, case=$50 (minor units; case > $30/spend) +const log = []; + +const json = (res, code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; +const read = (req) => new Promise((r) => { let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => r(d ? JSON.parse(d) : {})); }); + +const server = createServer(async (req, res) => { + const url = new URL(req.url, BASE); + const p = url.pathname; + + if (p === "/") { res.writeHead(200, { "content-type": "text/html" }); res.end(PAGE); return; } + + // grants.create — authorize once (human present) + if (p === "/api/grant" && req.method === "POST") { + const g = await grants.create({ merchant: "utopia", budget: usd.dollars(100), perSpend: usd.dollars(30), policy: [{ id: "age" }] }); + log.length = 0; + log.push(`→ grants.create → ${g.id.slice(0, 14)}… · Intent Mandate sealed (${g.intentMandate.presence} · ${g.intentMandate.trustLevel})`); + return json(res, 200, { grant: pub(g), log }); + } + + // grant.spend — human away + if (p.match(/^\/api\/grant\/[^/]+\/spend$/) && req.method === "POST") { + const id = p.split("/")[3]; + const { idempotencyKey, sku = "wine" } = await read(req); + const g = grants.retrieve(id); + if (!g) return json(res, 404, { error: "unknown grant" }); + const s = await g.spend({ idempotencyKey, items: [{ sku, qty: 1 }] }); + log.push(s.ok + ? ` spend ${idempotencyKey} (${sku})${s.replayed ? " · replayed" : ""} → ok · $${(s.amount.amount / 100).toFixed(2)} · remaining $${(s.remaining.amount / 100).toFixed(2)}` + : ` spend ${idempotencyKey} (${sku}) → refused: ${s.code} · remaining $${(s.remaining.amount / 100).toFixed(2)}`); + return json(res, 200, { result: s, log }); + } + + // grant.revoke + if (p.match(/^\/api\/grant\/[^/]+\/revoke$/) && req.method === "POST") { + const id = p.split("/")[3]; + const g = grants.retrieve(id); + if (!g) return json(res, 404, { error: "unknown grant" }); + const r = await g.revoke(); + log.push(`✗ grant.revoke → ${r.status} · next spend fails closed`); + return json(res, 200, { result: r, log }); + } + + res.writeHead(404); res.end("not found"); +}); + +server.listen(PORT, () => { + console.log(`\n grants.* prototype (human not present) → ${BASE}`); + console.log(` Left: authorize once (Intent Mandate produced). Right: the spend loop + revoke.`); + console.log(` Real engine: DelegatedGate (dev-sealed intent, real bounds/ledger/revocation).\n Open ${BASE}.\n`); +}); + +const pub = (g) => ({ id: g.id, status: g.status, intentMandate: g.intentMandate, budget: g.budget, perSpend: g.perSpend }); + +// ──────────────────────────────────────────────────────────────────── +const CSS = ` + :root{--bg:#0b0f17;--surface:#121826;--surface2:#171f30;--border:#273043;--ink:#eaeff8;--muted:#9aa6bd;--accent:#6d93ff;--ok:#3ed89a;--pend:#e7a73c;--rf:#f1637c;--mono:ui-monospace,"SF Mono",Menlo,monospace} + @media(prefers-color-scheme:light){:root{--bg:#f4f6fa;--surface:#fff;--surface2:#eef2f8;--border:#dce3ec;--ink:#101827;--muted:#59637a;--accent:#2b54d6;--ok:#0e9e6a;--pend:#c67c0a;--rf:#d63e57}} + *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.55} + .top{padding:1.1rem 1.5rem;border-bottom:1px solid var(--border);display:flex;align-items:baseline;gap:.7rem} + .top h1{font-size:1.05rem;margin:0}.top .tag{font-family:var(--mono);font-size:.7rem;color:var(--accent);letter-spacing:.08em;text-transform:uppercase} + .grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--border);min-height:calc(100vh - 58px)} + .pane{background:var(--bg);padding:1.4rem 1.5rem} + .pane h2{font-size:.78rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:0 0 1rem;font-family:var(--mono)} + button{font:inherit;font-weight:600;border:1px solid var(--accent);background:var(--accent);color:#fff;padding:.55rem 1rem;border-radius:9px;cursor:pointer;margin:.2rem .35rem .2rem 0} + button.ghost{background:transparent;color:var(--accent)}button.deny{border-color:var(--rf);color:var(--rf);background:transparent} + button:disabled{opacity:.4;cursor:not-allowed} + .card{background:var(--surface);border:1px solid var(--border);border-radius:11px;padding:1.1rem;margin-top:1rem} + .row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;font-family:var(--mono);font-size:.82rem;margin:.25rem 0} + .k{color:var(--muted)}.pill{font-family:var(--mono);font-weight:700;font-size:.72rem;padding:.12rem .5rem;border-radius:999px} + .p-ok{background:color-mix(in srgb,var(--ok) 18%,transparent);color:var(--ok)} + .p-rf{background:color-mix(in srgb,var(--rf) 18%,transparent);color:var(--rf)} + .p-pend{background:color-mix(in srgb,var(--pend) 20%,transparent);color:var(--pend)} + pre{font-family:var(--mono);font-size:.74rem;background:var(--surface2);border:1px solid var(--border);border-radius:9px;padding:.8rem;overflow:auto;margin:.55rem 0 0} + .bar{height:10px;border-radius:6px;background:var(--surface2);border:1px solid var(--border);overflow:hidden;margin:.4rem 0} + .bar>span{display:block;height:100%;background:var(--ok)} + .log{font-family:var(--mono);font-size:.75rem;color:var(--muted)}.log div{padding:.18rem 0;border-bottom:1px solid var(--border)} + code{font-family:var(--mono)} +`; + +const PAGE = `grants.* prototype +

grants.* — authorize once, spend later (human not present)

credentagent · prototype
+
+
+

① Human present — authorize once

+ +
+
event log
+
+
+

② Human away — the agent spends

+
Create a grant on the left; the spend controls appear here.
+
+
+
+`; diff --git a/examples/orders-proto/server.mjs b/examples/orders-proto/server.mjs new file mode 100644 index 0000000..4fcbdb8 --- /dev/null +++ b/examples/orders-proto/server.mjs @@ -0,0 +1,210 @@ +// ⚠ PROTOTYPE — a validation demo, NOT the shipping library API. It graduates into the +// real credentagent.orders.* / credentagent.grants.* API in #97 (the demo is rewired to it). +// server.mjs — the v10 `orders.*` surface wired to the REAL ceremony. +// +// (npm run build --workspaces) # once, if not built +// node examples/orders-proto/server.mjs # → http://localhost:4010 +// # to prove on your phone: adb reverse tcp:4010 tcp:4010, then open the approveUrl there. +// +// This is now a REAL gate: it wraps createStorefront() + CredentAgent.mount() (exactly like +// tools/demo-pki/run-gate.mjs) and exposes orders.create / orders.retrieve over it. The +// approveUrl is the genuine checkout ceremony (age via OpenID4VP + payment via passkey/x402); +// real completion writes the completed-order store, and that write IS the order.settled webhook. + +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { EventEmitter } from "node:events"; +import { createStorefront } from "@openmobilehub/credentagent-storefront/server"; +import { createOrder, SAMPLE_CATALOG } from "@openmobilehub/credentagent-storefront"; +import { CredentAgent, age, payment, required, issueCartMandate } from "@openmobilehub/credentagent-gate"; + +const SIGNING_KEY = "orders-proto-secret"; +const b64u = (o) => Buffer.from(JSON.stringify(o)).toString("base64url"); + +// Build the REAL mandate bundle for a settled order (increment A): the cartMandate is a +// genuinely signed ap2.CartMandate (issueCartMandate over the order's lines); the paymentMandate +// is assembled from the real settlement record. intentMandate is absent on a human-present order. +function buildMandateBundle(order, rec) { + const cart = issueCartMandate( + { orderId: order.id, lines: order.lines, currency: order.currency ?? rec.currency, total: order.total ?? rec.amount }, + SIGNING_KEY, + ); + const cartMandate = { ...cart, serialize() { return b64u(cart); } }; + const pay = { + type: "ap2.PaymentMandate", + orderId: order.id, + amount: { amount: rec.amount, currency: rec.currency }, + method: rec.method, + presenceMode: "human_present", + authorization: "direct", + cart: cart.id, + ...(rec.txId ? { txId: rec.txId } : {}), + ...(rec.network ? { network: rec.network } : {}), + trust_level: "presence-only-demo", + }; + const paymentMandate = { ...pay, serialize() { return b64u(pay); } }; + return { intentMandate: undefined, cartMandate, paymentMandate, trustLevel: "presence-only-demo" }; +} + +const PORT = Number(process.env.PORT ?? 4010); +const BASE = `http://localhost:${PORT}`; + +// Optional demo reader identity (so the wallet shows the verifier TRUSTED). Reuses the +// demo-pki certs if present; otherwise self-signs (ceremony still works, shows "untrusted"). +const RID = "/Users/diegozuluaga/tools/git/attestomcp/.worktrees/demo-pki/tools/demo-pki"; +const readerIdentity = + existsSync(`${RID}/keys/reader-key.pem`) && existsSync(`${RID}/certs/reader-cert.pem`) + ? { key: readFileSync(`${RID}/keys/reader-key.pem`, "utf8"), cert: readFileSync(`${RID}/certs/reader-cert.pem`, "utf8") } + : undefined; + +// ── the stores (in-memory) — the completed store's write() is the order.settled webhook ── +const created = new Map(); +const completed = new Map(); +const events = new EventEmitter(); +const createdOrderStore = { read: async (id) => created.get(id) ?? null, write: async (id, o) => { created.set(id, o); } }; +const orderStore = { + read: async (id) => completed.get(id) ?? null, + write: async (id, rec) => { completed.set(id, rec); events.emit("order.settled", { id }); }, +}; + +const store = createStorefront({ createdOrderStore, orderStore, baseUrl: BASE, signingKey: "orders-proto-secret" }); +const credentagent = new CredentAgent({ walletOrigin: BASE, ...(readerIdentity ? { readerIdentity } : {}) }); +credentagent.mount(store.app); + +// The policy — age 21+ on age-restricted lines, payment last. Static array (predicates inside). +const POLICY = [ + required(age.over(21).when((o) => o.lines.some((l) => l.minimumAge != null))), + required(payment.in("usd")), +]; +store.gate((order) => credentagent.requirements(order, POLICY)); + +// The webhook (FR-009) — fired by the completed-store write when the REAL ceremony finishes. +const log = []; +events.on("order.settled", async ({ id }) => { + const rec = completed.get(id); + log.push(`✓ order.settled ${id} → ok · ${rec?.method ?? "?"} · ${rec?.currency ?? ""} ${(rec?.amount ?? 0) / 100} · ${rec?.txId ? "tx " + String(rec.txId).slice(0, 10) : "settled"}`); +}); + +const RESTRICTED = SAMPLE_CATALOG.find((p) => p.minimumAge != null) ?? SAMPLE_CATALOG[0]; +const manifestFor = (order) => { + const m = credentagent.requirements(order, POLICY); + const list = Array.isArray(m) ? m : (m?.requires ?? m?.manifest ?? []); + return list.map((e) => ({ credential: e.credential ?? e.id, required: e.required !== false, label: e.label ?? e.credential ?? e.id, minAge: e.minAge })); +}; + +// ── orders.* routes on the real gate's app ── +store.app.post("/api/checkout", async (_req, res) => { + const id = `ord_${randomUUID().slice(0, 8)}`; + const order = createOrder([{ productId: RESTRICTED.id, quantity: 1 }], id, SAMPLE_CATALOG); // priced from the catalog + await createdOrderStore.write(id, order); + log.push(`→ orders.create → ${id} · ${RESTRICTED.name} · pending`); + res.json({ id, approveUrl: `${BASE}/checkout?order=${id}`, manifest: manifestFor(order) }); +}); + +store.app.get("/api/order/:id", async (req, res) => { + const id = req.params.id; + const rec = completed.get(id); + const order = created.get(id); + const door = rec + ? { + ok: true, + authorization: "direct", + trustLevel: "presence-only-demo", + mandateBundle: order ? serializeBundle(buildMandateBundle(order, rec)) : undefined, // increment A + completion: { amount: rec.amount, currency: rec.currency, method: rec.method, txId: rec.txId ?? null, network: rec.network ?? null, completedAt: rec.completedAt }, + } + : (order + ? { ok: false, pending: true, approveUrl: `${BASE}/checkout?order=${id}`, trustLevel: "presence-only-demo" } + : { ok: false, code: "not-found", trustLevel: "presence-only-demo" }); + res.json({ door, log }); +}); + +// JSON-safe view of the bundle for the wire (calls the mandates' serialize()). +function serializeBundle(b) { + return { + intentMandate: b.intentMandate ?? null, + cartMandate: { type: b.cartMandate.type, id: b.cartMandate.id, total: b.cartMandate.total, trust_level: b.cartMandate.trust_level, serialized: b.cartMandate.serialize() }, + paymentMandate: { type: b.paymentMandate.type, amount: b.paymentMandate.amount, method: b.paymentMandate.method, presenceMode: b.paymentMandate.presenceMode, authorization: b.paymentMandate.authorization, trust_level: b.paymentMandate.trust_level, serialized: b.paymentMandate.serialize() }, + trustLevel: b.trustLevel, + }; +} + +// TEST-ONLY: simulate a ceremony completion so the ok-branch (+ mandateBundle) is verifiable +// without a phone. Writes the completed store exactly as the real rail does → fires order.settled. +store.app.post("/api/_test/settle/:id", async (req, res) => { + const id = req.params.id; + const order = created.get(id); + if (!order) return res.status(404).json({ error: "unknown order" }); + await orderStore.write(id, { orderId: id, amount: order.total, currency: order.currency ?? "usd", method: "test-passkey", txId: "0xTEST" + id.slice(-6), network: "hedera-testnet", completedAt: new Date().toISOString() }); + res.json({ settled: true }); +}); + +store.app.get("/", (_req, res) => { res.type("html").send(PAGE); }); + +const { url } = await store.listen(PORT); +console.log(`\n orders.* prototype — wired to the REAL ceremony → ${BASE}`); +console.log(` reader identity : ${readerIdentity ? "demo-pki (verifier shows TRUSTED)" : "self-signed (verifier shows untrusted)"}`); +console.log(` demo UI : ${BASE}/ (Start checkout → real checkout → order.settled)`); +console.log(` MCP endpoint : ${url}`); +console.log(` On your phone : adb reverse tcp:${PORT} tcp:${PORT}, then open the approveUrl there for the real wallet ceremony.\n`); + +// ──────────────────────────────────────────────────────────────────── +const CSS = ` + :root{--bg:#0b0f17;--surface:#121826;--surface2:#171f30;--border:#273043;--ink:#eaeff8;--muted:#9aa6bd;--accent:#6d93ff;--ok:#3ed89a;--pend:#e7a73c;--rf:#f1637c;--mono:ui-monospace,"SF Mono",Menlo,monospace} + @media(prefers-color-scheme:light){:root{--bg:#f4f6fa;--surface:#fff;--surface2:#eef2f8;--border:#dce3ec;--ink:#101827;--muted:#59637a;--accent:#2b54d6;--ok:#0e9e6a;--pend:#c67c0a;--rf:#d63e57}} + *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.55} + .top{padding:1.1rem 1.5rem;border-bottom:1px solid var(--border);display:flex;align-items:baseline;gap:.7rem} + .top h1{font-size:1.05rem;margin:0;letter-spacing:-.01em}.top .tag{font-family:var(--mono);font-size:.7rem;color:var(--accent);letter-spacing:.08em;text-transform:uppercase} + .grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--border);min-height:calc(100vh - 58px)} + .pane{background:var(--bg);padding:1.4rem 1.5rem} + .pane h2{font-size:.78rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:0 0 1rem;font-family:var(--mono)} + button{font:inherit;font-weight:600;border:1px solid var(--accent);background:var(--accent);color:#fff;padding:.6rem 1.1rem;border-radius:9px;cursor:pointer} + .card{background:var(--surface);border:1px solid var(--border);border-radius:11px;padding:1.1rem;margin-top:1rem} + .row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;font-family:var(--mono);font-size:.82rem;margin:.3rem 0} + .k{color:var(--muted)}.pill{font-family:var(--mono);font-weight:700;font-size:.75rem;padding:.15rem .55rem;border-radius:999px} + .p-ok{background:color-mix(in srgb,var(--ok) 18%,transparent);color:var(--ok)} + .p-pend{background:color-mix(in srgb,var(--pend) 20%,transparent);color:var(--pend)} + pre{font-family:var(--mono);font-size:.76rem;background:var(--surface2);border:1px solid var(--border);border-radius:9px;padding:.85rem;overflow:auto;margin:.6rem 0 0} + .log{font-family:var(--mono);font-size:.75rem;color:var(--muted)}.log div{padding:.2rem 0;border-bottom:1px solid var(--border)} + a{color:var(--accent)}code{font-family:var(--mono)}iframe{width:100%;height:520px;border:1px solid var(--border);border-radius:11px;margin-top:1rem;background:var(--surface)} + .hint{font-size:.85rem;color:var(--muted);margin:.6rem 0 0} +`; + +const PAGE = `orders.* · real ceremony +

orders.* — wired to the real ceremony

credentagent · prototype
+
+
+

① Merchant / Agent

+ +
+
order.settled webhook · server log
+
+
+

② The real checkout (prove age + pay)

+
Start a checkout on the left — the genuine checkout page loads here. Age is proven with your phone wallet (OpenID4VP); payment is a passkey (x402 on Hedera testnet).
+

To prove on your phone: adb reverse tcp:4010 tcp:4010, then open the approveUrl there.

+
+
+`; diff --git a/specs/009-ap2-mandate-chain-dx/spec.md b/specs/009-ap2-mandate-chain-dx/spec.md new file mode 100644 index 0000000..b0dedfd --- /dev/null +++ b/specs/009-ap2-mandate-chain-dx/spec.md @@ -0,0 +1,241 @@ +# Feature Specification: The Consent SDK Surface (AP2 mandate chain, three enforcement paths) + +**Feature branch:** `009-ap2-mandate-chain-dx` · **Issue:** #92 · **Date:** 2026-07-20 +**Informs:** #17 (`credentagent.gate()`), #12/#69–71 (delegated grants), #39/#40 (wire format) + +## Overview + +This is the authoritative developer surface for CredentAgent's consent flow: how a developer +gates a consequential agent action behind a proven wallet credential, across the **three +situations** that genuinely differ — a hosted page, a page-less MCP tool, and a human-not-present +delegated spend — plus how the AP2 mandates (Intent / Cart / Payment) are exposed. + +The surface below is the output of a **9-round adversarial DX review** (four cold-reader personas ++ a four-lens Stripe-grade council per round; see "Design journey"). The architecture was +validated and the council directed it to be **frozen**; the final version applies its full set of +consistency/naming fixes. The **spine** — one configured client, one catalog price source, one +policy array, one typed result door, Money-as-type, `trustLevel` on every branch — is Stripe-grade +and MUST NOT regress. + +## The surface (caller-first — this IS the DX test) + +```js +import { CredentAgent, required, age, payment, usd } from "@openmobilehub/credentagent-gate"; + +// ── Configure once ───────────────────────────────────────────── +const credentagent = new CredentAgent({ + origin: "https://shop.example", // your RP origin; mount() serves the /approve ceremony here + catalog: { wine: usd.dollars(20) }, // the ONE price source. Money is OPAQUE: compare with .lt()/.gte()/.eq(); .serialize() to the wire +}); +credentagent.mount(app); +const policy = [ + required(age.over(21).when(o => o.lines.some(l => l.minimumAge >= 21))), // 21+ ONLY when the cart holds an age-restricted line — a shirt-only cart skips it + required(payment.in("usd")), // credentials — payment is just one of them +]; + +// ══ ONE CONTRACT (learn once) ═════════════════════════════════ +// PRICED INPUT: order = { id?, items: [{ sku, qty }] } // priced from the catalog; you NEVER pass an amount +// RESULT DOOR: if (res.ok) res.mandateBundle // + res.authorization: "direct" | "delegated" +// else if (res.pending) res.approveUrl // send to the human; then re-check / the agent re-calls +// else res.code // switch on this; res.credential names which credential, when relevant +// res.trustLevel ALWAYS present (every branch): "presence-only-demo" today — disclosure+binding, NOT issuer trust. +// res.code is a TYPED union RefusalCode (NOT string) — so `s.code === "budget-exceeded"` autocompletes, +// a typo fails to compile, and a switch is exhaustiveness-checked: +// type RefusalCode = "under-age" | "payment-declined" | "no-membership" +// | "budget-exceeded" | "per-spend-exceeded" | "not-allowed" | "revoked"; +// +// TWO RESOURCES + one top-level wrapper: +// orders one-shot verification ≈ Checkout Session / PaymentIntent orders.create()→{id,approveUrl} · orders.retrieve(id)→door +// grants durable spend authority ≈ SetupIntent + off_session grants.create()→grant · grants.retrieve(id)→grant · grant.spend()/.revoke() +// credentagent.gate(handler,{policy}) gate ANY page-less tool — ACTION-AGNOSTIC (a purchase, a records release, +// a deploy). Top-level, NOT under `orders`: identity leads, payment is one +// application. Its RETURN is the door. + +// ── orders — you host the consent page ───────────────────────── +server.registerTool("checkout", inputSchema, async (args) => { + const { id, approveUrl, manifest } = await credentagent.orders.create({ order: { items: cartItems(args) }, policy }); + return { structuredContent: { id, approveUrl, manifest } }; // keep id — retrieve the door by it +}); +// later — the gate fires "order.settled"; your handler does ONE retrieve (never a poll loop): +credentagent.on("order.settled", async ({ id }) => { + const res = await credentagent.orders.retrieve(id); // the DOOR: res.ok / res.pending+approveUrl / res.code + if (res.ok) { /* complete + settle res.mandateBundle.paymentMandate */ } +}); + +// ── credentagent.gate() — gate ANY page-less tool (here a NON-commerce action) ── +server.registerTool("release-records", inputSchema, credentagent.gate( + async (args) => ({ structuredContent: await releaseRecords(args) }), // runs ONLY on ok — an unverified caller never reaches it + { order: (args) => ({ id: args.subject }), policy: [ required(age.over(21)) ] }, // no items → a pure identity gate; no purchase +)); +// { ok:true, structuredContent, mandateBundle, authorization, trustLevel } +// { ok:false, pending:true, approveUrl, resume:"release-records", trustLevel } // agent proves, re-calls +// { ok:false, code:"under-age", credential:"age", trustLevel } // proven but failed policy + +// ── grants — authorize once, spend later (human not present) ── +// (A) human PRESENT — create, persist id (exists before they prove), send approveUrl: +const grant = await credentagent.grants.create({ + merchant: "utopia", budget: usd.dollars(100), perSpend: usd.dollars(30), + allow: { category: ["Beverages"] }, // ← bound WHAT the agent may buy (SKUs/categories/attributes), not just how much + policy, +}); +await store.save(userId, grant.id); +sendToUser(grant.approveUrl); +// (B) LATER, worker/cron — human AWAY — rehydrate, gate on status, spend: +const grant = await credentagent.grants.retrieve(await store.load(userId)); // grant.status: "pending" | "authorized" | "revoked" | "denied" +if (grant.status !== "authorized") return; +for (const purchaseId of purchasesToMake()) { + const s = await grant.spend({ idempotencyKey: purchaseId, items: [{ sku: "wine", qty: 1 }] }); // durable key → s.replayed on a safe retry + if (!s.ok) { if (s.code === "budget-exceeded") break; throw new Error(s.code); } // "per-spend-exceeded" | "revoked" + forwardToPsp(s.mandateBundle.paymentMandate.serialize()); // authorization:"delegated" is stamped INTO the serialized mandate + if (s.remaining.lt(usd.dollars(20))) break; // Money comparison — never a raw scalar +} +await grant.revoke(); // grant.status → "revoked"; next spend → { ok:false, code:"revoked" } +``` + +## Requirements + +### Functional Requirements + +- **FR-001 — One configured client.** `new CredentAgent({ origin, catalog })` + `mount(app)`; every + path hangs off it. No second client, no per-path config door. +- **FR-002 — One priced input.** `order = { id?, items: [{ sku, qty }] }` on every path; the gate + re-prices from the catalog server-side. A caller NEVER passes an amount (invariant #2). +- **FR-003 — One result door.** `{ ok } | { ok:false, pending, approveUrl } | { ok:false, code, + credential? }`, with `res.trustLevel` and (on `ok`) `res.authorization` present on **every** branch + of **every** path. `code` is the switchable enum; `credential` names the failed credential; a + lifecycle refusal (`revoked`) is a `code`, never a credential. +- **FR-004 — Two resources, symmetric.** `orders.create()/retrieve(id)` and `grants.create()/ + retrieve(id)` — both awaited, both return an `id` from the mint, both retrievable by it. Orders are + one-shot verifications (retrieve → verdict); grants are durable (retrieve → handle with `status` + + `spend()`/`revoke()`). **`credentagent.gate(handler, opts)`** is the top-level, **action-agnostic** + page-less wrapper — it gates ANY consequential tool (a purchase, a records release, a deploy), so it + lives on the client, not under a commerce resource (thesis: identity leads, payment is one application). +- **FR-005 — Money is a type.** `usd.dollars(n)`; opaque (no public scalar); compared via + `.lt()/.gte()/.eq()`, combined via `.plus()/.minus()`, emitted via `.serialize()`. + > **Reconciliation (2026-07-25):** the shipped orders half (#98) landed with **plain dollar numbers**, + > not this opaque `Money` type — a review call ("dollars everywhere") removed `money.ts` for the first + > increment. Money-as-type remains the design intent (it kills float/currency-mix footguns); whether the + > grants build (#104) reinstates `usd` or the SDK standardizes on plain numbers is an **open decision to + > settle in #104**, so the two halves don't drift. This spec keeps the `Money` design on record; the code is + > the interim. +- **FR-006 — MandateBundle on `ok`.** `{ intentMandate?, cartMandate, paymentMandate }`, each with + its own `trustLevel` and `.serialize()`. `authorization: "direct" | "delegated"` rides on the result + AND is stamped into the serialized `paymentMandate` (so a PSP can't mistake an off-session, + human-away mandate for a live presentation). +- **FR-007 — Delegated lifecycle.** `grants.create()` returns `grant.id` immediately (before the human + proves), so it persists across the authorize-now / spend-later process boundary; `grants.retrieve(id)` + rehydrates; `grant.status` gates spending; `idempotencyKey` is a durable per-purchase key + (`s.replayed` on a safe retry). **`grant.status` has exactly four states:** + - **`pending`** — created; waiting for the human to approve at `approveUrl`. + - **`authorized`** — the human approved; `spend()` is allowed within the sealed bounds. + - **`denied`** — the authorize ceremony ENDED WITHOUT approval: the human rejected the approve screen, or + it expired before they proved. It is **terminal for that grant** (unlike `pending`, no approval is still + coming); create a fresh grant to retry. Distinct from `revoked` in *when* it happens — `denied` is a grant + that was **never** authorized; `revoked` is one that **was** authorized and later cancelled. + - **`revoked`** — `grant.revoke()` was called after authorization; no further spend succeeds. + - **Race (authorize-now / spend-later): `revoke()` wins, fail-closed.** A `revoke()` can land while a + `spend()` is in flight. `spend()` re-reads the grant status + revocation store **server-side at settle** + (never trusting the rehydrated handle) and consumes the `idempotencyKey` atomically — so a spend that + started before the revoke but settles after it refuses with `code:"revoked"`. The revoke is authoritative; + an in-flight spend never sneaks past it. +- **FR-008 — Additive.** Layers over today's `requirements()`/`mount()`, the retained Mode-B envelope, + `DelegatedGate`, and the existing `ap2.CartMandate`/`ap2.PaymentMandate`/`IntentBounds`. Ships without + breaking their current callers; `delegate()` may remain a thin alias of `grants.create()` during + migration. +- **FR-009 — Completion signal, never a poll loop.** The async "human finished proving" transition is + delivered by a **webhook/callback** — `credentagent.on("order.settled", …)` plus a return-URL redirect + for the human — mirroring Stripe's `success_url` + `checkout.session.completed`. `orders.retrieve(id)` + is a **single current-state read** (for the callback handler, or a fallback), and an optional + `orders.awaitProof(id, { timeout })` resolves when settled. A hand-written poll loop MUST NOT be the + documented path. The page-less `credentagent.gate()` path needs **no** signal — the agent's re-call is the trigger. +- **FR-010 — Intent Mandate production (grants only).** The AP2 **Intent Mandate** is produced in the + `grants` flow, at the one-time authorize ceremony (`grant.approveUrl`) — **not** in `orders` (a + human-present order signs the **Cart** Mandate directly, so `mandateBundle.intentMandate` is absent). + At authorize, the human's wallet **seals the bounded intent** — merchant, `perSpend`, `budget`, `policy`, + the optional **`allow`** item constraint (which SKUs / categories / attributes are permitted — so a human + who's away bounds *what* is bought, not only *how much*), expiry, and the delegate key permitted to sign + later spends — into the Intent Mandate; it then rides on `grant.intentMandate`, and every subsequent + spend's Payment Mandate references it. **Enforcement (invariant #1):** each `grant.spend()` re-checks the + requested items against the sealed `allow` (and `perSpend`/`budget`) **server-side, fail-closed** — an item + outside `allow` refuses with `code:"not-allowed"`, never trusting a caller-supplied item. Today it is + **dev-sealed** (content-addressed integrity hash, `sealIntent`), `trustLevel: "presence-only-demo"`; the + roadmap swaps the internals so the wallet **key-signs** it during the live ceremony (KB-JWT/SD-JWT — + #14/#39/#71) with no change to this surface. + +### Honesty (Constitution VII — load-bearing) + +- **HR-001** — `trustLevel` is `"presence-only-demo"` everywhere today (dev-signed integrity hash, NOT + issuer/key-signed). No prose, comment, or field may imply issuer-verified trust or real settlement. + The example says "seals/binds (dev-signed)", never "signed". +- **HR-002** — On a delegated spend the human is absent; `authorization:"delegated"` + the fact that + `trustLevel` describes the *authorize* ceremony (not the spend) must be visible on the result and in + the serialized mandate. "presence-only" must never assert a presentation that didn't happen. + +## Success Criteria + +- **SC-001** — A cold reader picks the right path for each of the three canonical tasks (page checkout, + page-less tool gate, human-away budget) without re-reading, and writes the call in ~one declarative line. +- **SC-002** — `if (res.ok) … else if (res.pending) … else switch (res.code)` compiles and is correct on + **every** path (byte-identical door). +- **SC-003** — No amount is ever passed by the caller; a hand-edited price cannot change what settles + (bypass test). +- **SC-004** — Every mandate and every result carries its `trustLevel`; a bypass test asserts nothing reads + as issuer-verified, and that a delegated mandate is marked as such through serialization. + +## Design journey (why these choices — the 9-round DX review) + +Scored 1–5 for Stripe-ease by four independent cold-reader personas each round; a four-lens council +audited against `architecture-principles.md` + the constitution. + +| v | Score | What the round forced | +| --- | --- | --- | +| 1 | 3.67 | "three libraries stapled together"; honesty fence missing from the types | +| 2 | 3.25 | one client + shared policy + `trustLevel`; over-corrected (purchase-in-tool went homeless) | +| 3 | 3.50 | named present-pair; delegate consent ceremony surfaced; money/trust contracts | +| 4 | 3.75 | one `{ok,reason}` door; catalog prices every path (kills agent-supplied-total) | +| 5 | 3.70 | uniform input; the `ok`-on-success bug; `usd()` money helper; idempotent replay | +| 6 | 3.67 | contract-first framing (learn once, then thin triggers); honesty-prose fix | +| 7 | 3.75 | `proveUrl` on every result; **grant.id + rehydrate**; `requireInTool` third door; Stripe-analogue map | +| 8 | 3.75 | **Stripe resource idiom** (`grants.create/retrieve`); specific refusal tokens; `spend().remaining` | +| 9 | 3.75 | council froze the spine; final renames (below) | + +**Why it asymptotes at ~3.75, and why that is "nailed":** the score plateaued for six rounds because +(a) a Stripe-veteran reader structurally anchors a *novel* consent API at 3–4 versus Stripe's decade of +refinement, and (b) adversarial cold-readers always surface ~3 fresh consistency nits on any snippet. The +council's own round-9 verdict — *"the spine is genuinely Stripe-grade and should be FROZEN; none require +new surface — all are renames"* — is the true exit signal. The architecture is validated; the remainder was +a naming pass, now applied. + +**Final naming pass applied (round-9 fixes):** `reason`+`detail` → `credential`+`code` (matches no-collision +with the shipped `envelope.ts`); `reason:'revoked'` → a `code` / `grant.status`; `orders.require` → +`orders.create` (awaited, symmetric with `grants.create`); mint returns `id`; `Money` made opaque; +`proveUrl` → `approveUrl` (aligns with the shipped `approve_url`); `authorization` stamped into the +serialized paymentMandate. + +**Post-lock reconciliation (2026-07-21):** the page-less wrapper — designed here as `orders.gate` / +earlier `requireInTool` — is **`credentagent.gate(handler, opts)`**, top-level and **action-agnostic**. +The `#17` session shipped it as `credentagent.gate()`, and that name wins over `orders.gate`: gating a +*non-commerce* tool (a records release, a deploy) shouldn't live under a commerce resource — "identity +leads; payment is one application." So `orders` and `grants` stay resources (the checkout and delegated +*lifecycles*), while `gate()` is the general page-less primitive on the client. The design journey above +predates this call; the surface + FR-004 reflect it. + +## Out of Scope + +- Wire-format / SD-JWT serialization (#39) and Python-SDK conformance (#40) — this owns the surface, not the wire. +- Real key-bound / issuer-verified signing (#14). +- Reworking `DelegatedGate`/intent-rail internals (#12, #69–71) — this is their surface, additive. +- Implementation — this spec is design only; `plan.md` sequences the build over the existing primitives. + +**On the prototypes:** `examples/orders-proto/` and `examples/grants-proto/` are **validation demos** — facades +that *stand in for* the API to prove the design runs; they are not the shipping library code. They graduate +into the real `credentagent.orders.*` / `credentagent.grants.*` API in **#97**. **Status:** the orders half +graduated — `credentagent.orders.*` + `orders.serve()` shipped in **#98** (merged) — so `orders-proto`'s +page-less wrapper (which trusted a caller-supplied order id — a bind bug) was removed as superseded, and the +real fail-closed path is `credentagent.gate()`. The grants half graduates under **#104** (this spec is its contract). + +## Dependencies + +- #17 (`credentagent.gate()` — the top-level, action-agnostic page-less wrapper; SHIPPED on `feat/17`). +- #12 / #69–71 (`grants` = the delegated surface over the intent rail). +- Constitution I (Stripe-grade, no grab-bags) and VII (honesty in types); Security invariants #1–#6.