diff --git a/docs/superpowers/plans/2026-07-23-grants.md b/docs/superpowers/plans/2026-07-23-grants.md new file mode 100644 index 0000000..240c728 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-grants.md @@ -0,0 +1,103 @@ +# Grants (`credentagent.grants`) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `credentagent.grants` — approve a spending limit once (`create` → `approveUrl` ceremony → `authorized`), then `grant.spend({ idempotencyKey, items })` against it while the human is away — per spec 009's grants half and issue #104's four accepted decisions. + +**Architecture:** A durable resource layer (`grants.ts` + `grants-serve.ts`) mirroring `orders.ts` + `orders-serve.ts`, over the EXISTING delegated-draw engine (`sealIntent`/`signDraw`/`completeOrder`/`RevocationStore` — already merged; `DelegatedGate` stays untouched as the standalone facade). The authorize ceremony reuses the same rails/page/completion machinery orders reuse, with the grant presented as a one-line pseudo-order (amount = budget). Spends run in-process (#104 decision 3) and route completions through `orders._complete` so the settled event + webhooks fan out for free. `Money` (`usd`) returns, wired (#104 decision 2): the public surface never exposes a raw scalar; conversion to the engine's dollar numbers happens at ONE documented seam. + +**Tech Stack:** TypeScript/ESM, vitest, Express-shaped structural types (no express dependency in the gate). + +**Key decisions (fixed):** +- Resource named `grants`; the AP2 artifact stays `grant.intentMandate` (per maintainer discussion 2026-07-23). +- Grant ids: `gr_<16hex>`. Spend order ids: `-` (namespaced — invariant 4). +- Status lifecycle: `pending → authorized | denied`, `* → revoked`. Spend refuses unless `authorized` (code `"not-authorized"`). +- Spend door codes: engine `over-total` → `"budget-exceeded"`, `over-cap` → `"per-spend-exceeded"`; all other engine codes pass through (`"revoked"`, `"step-up"`, …). `retryable` rides along. +- Replay: spend pre-reads its completion record by order id; hit → prior result + `replayed: true` (one charge, ever). +- Age / custom gates on autopilot: already refused by the completion seam (`step-up`) — pin with a bypass test, do not re-implement. +- Authorize completion does NOT fire `order.settled` (it is not a purchase); spend completion DOES (via `orders._complete`). +- `new CredentAgent({ catalog, grantStore, revocationStore })` — all optional, in-memory defaults (FR-001; additive). +- Money: internal minor units, `usd.dollars(n)`/`usd(minor)`; engine seam converts via `dollarsOf(money) = serialize().amount / 100` in exactly one place (`grants.ts`), commented. + +**File structure:** +- Create: `packages/credentagent-gate/src/money.ts` (reintroduced from #98 pre-review, unchanged semantics + a `dollarsOf` note) +- Create: `packages/credentagent-gate/src/grants.ts` (Grants resource + Grant handle + GrantRecord/GrantStore types + spend engine calls) +- Create: `packages/credentagent-gate/src/grants-serve.ts` (approve page, demo approve for ungated, decline, status, rails adapter for gated) +- Create: `packages/credentagent-gate/src/grants.test.ts`, `packages/credentagent-gate/src/grants-serve.test.ts`, `packages/credentagent-gate/src/money.test.ts` +- Modify: `packages/credentagent-gate/src/delegated.ts` (export `buildCatalog`; no behavior change) +- Modify: `packages/credentagent-gate/src/client.ts` (opts `catalog`/`grantStore`/`revocationStore`; wire `this.grants`) +- Modify: `packages/credentagent-gate/src/index.ts` (exports) +- Create: `examples/grants-preapproved/{server.mjs,smoke.mjs,README.md}` +- Modify: `packages/credentagent-gate/README.md`, `examples/README.md` + +--- + +### Task 1: Reintroduce `money.ts` (wired consumer coming in Task 4) + +- [ ] Step 1: Write `money.test.ts` — the #98-era assertions PLUS: `usd.dollars(21.99).serialize()` → `{ amount: 2199, currency: "usd" }` (no float drift), `usd.dollars(0)` valid, `.toString()` → `"USD 21.99"`. +- [ ] Step 2: Run → FAIL (module missing). +- [ ] Step 3: Recreate `money.ts` (same shape as the version removed in #98 review: opaque, integer minor units, `.lt/.gte/.eq/.plus/.minus/.serialize`, currency-mismatch throws). +- [ ] Step 4: Run → PASS. Export `usd`/`Money` from `index.ts` with a comment noting grants is the consumer. +- [ ] Step 5: Commit `feat(gate,#104): reintroduce Money (usd) — grants is the wired consumer`. + +### Task 2: `grants.create()` / `retrieve()` — the pending lifecycle + +- [ ] Step 1: Failing tests in `grants.test.ts`: + - `create({ merchant, budget: usd.dollars(100), perSpend: usd.dollars(30), policy: [] })` → `{ id: /^gr_/, approveUrl: /credentagent/grants/, status: "pending" }`; create awaits persistence (async-store test, same pattern as orders). + - `retrieve(id)` → handle with `status: "pending"`, `approveUrl`, `terms` echo; unknown id → `{ status: "not-found" }`-style typed refusal (no throw). + - Two grants isolated (invariant 4). +- [ ] Step 2: FAIL. Step 3: implement `grants.ts` (`GrantRecord`, `GrantStore = OrderStore`, `Grants.create/retrieve`, `Grant` handle exposing `id/status/terms/approveUrl/intentMandate?`). Money→dollars conversion seam lives here with the one comment. Step 4: PASS. Step 5: commit. + +### Task 3: Client wiring + +- [ ] Step 1: Failing tests: `new CredentAgent({ walletOrigin, catalog })` exposes `credentagent.grants`; `grants` without `catalog` → `spend` throws a clear config error message naming the fix; stores injectable (`grantStore`, `revocationStore`). +- [ ] Steps 2–4: wire in `client.ts` mirroring the orders block (created store + revocation + records; `serve` closure with double-serve guard shared with orders' pattern). Step 5: commit. + +### Task 4: `grant.spend()` — the in-process draw with the door result + +- [ ] Step 1: Failing tests (the heart — every one a bypass test where applicable): + - authorized grant, `spend({ idempotencyKey: "p1", items: [{ sku: "coffee", qty: 1 }] })` → `{ ok: true, amount, remaining }`; `remaining` is Money (`.lt` works); amount re-priced from catalog (pass a lying qty? items only carry sku/qty — price never passed: assert catalog price used). + - BYPASS budget: spends beyond `budget` → `{ ok: false, code: "budget-exceeded" }` (delete the `over-total` mapping/check → red). + - BYPASS perSpend: single item over `perSpend` → `code: "per-spend-exceeded"`. + - BYPASS status: pending (unauthorized) grant → `code: "not-authorized"` — never signs a draw (delete the status gate → red). + - BYPASS age autopilot: catalog item with `minAge: 21` → `{ ok: false, code: "step-up" }` (pins the seam control). + - Idempotent replay: same key twice → second result `{ ok: true, replayed: true }`, ledger shows ONE committed draw, `remaining` unchanged. + - Distinct keys are distinct draws (two spends draw down twice). + - Invariant 4: spend on grant A never appears in grant B's ledger/remaining. + - Spend completion fires `order.settled` with the spend's order id (wired through `orders._complete`). +- [ ] Steps 2–4: implement `Grant.spend` in `grants.ts`: status gate → replay pre-read → `buildCatalog(clientCatalog).createOrder(items→refs, orderId)` → per-spend/budget enforced by `checkDraw` via sealed bounds → `signDraw` (JWK imported from record) → `completeOrder` with records adapter that forwards to `orders._complete` → door mapping (`over-total`→`budget-exceeded`, `over-cap`→`per-spend-exceeded`) + `remaining` from revocation ledger as Money + `trustLevel`/`authorization: "delegated"` + `mandateBundle` (intentMandate + draw). Step 5: commit. + +### Task 5: `grant.revoke()` + `denied` + +- [ ] Tests: revoke → `status: "revoked"` AND next spend `code: "revoked"` (delete either write → red: status alone must not be the only control — the revocation store is the enforced one); revoke a pending grant OK; spend after denied refuses. +- [ ] Implement: `revoke()` writes both the revocation store and the record status. Commit. + +### Task 6: `grants.serve(app)` — approve page, demo approve, decline, status + +- [ ] Tests in `grants-serve.test.ts` (fakeApp pattern copied from orders-serve.test.ts): + - routes registered: `GET /credentagent/grants/:id`, `POST .../approve`, `POST .../decline`, `GET .../status`. + - page renders terms (merchant, `$100.00` budget via the page formatter, per-spend) for pending; 404 unknown. + - ungated (policy `[]`) demo approve → status `authorized`, intent sealed (record has `intentMandate` + delegate JWK), approve is idempotent (double POST → one seal, still 200). + - BYPASS (invariant 1): a grant whose policy contains a blocking gate (e.g. `required(age.over(21))`) is REFUSED (403) on the demo approve path — delete the `isGated` guard → red. It must authorize through the rails only. + - decline → `denied`; approve after decline/revoke refused (fail-closed). +- [ ] Implement `grants-serve.ts`: pseudo-order adapter (`CeremonyOrderStore` presenting the grant as a one-line order, amount = budget) + `mountCeremony` reuse with `returnUrl` → the grant page + a records adapter whose `write` calls `grants._authorize(id)` (seals intent, mints delegate, flips status — does NOT fire order.settled). Demo approve mirrors orders' `place` (isGated + already-authorized guards). Commit. + +### Task 7: ~~Gated authorize through the real rails~~ — DESCOPED (recorded deviation) + +A second `mountCeremony` on the same app would collide with orders' rail routes +(`/credentagent/passkey` etc. — first registration wins in Express). Rails-backed grant +authorization needs a client-level composite mount (one rails registration dispatching +`ord_*`/`gr_*` ids) — that refactor belongs to the wallet-custody increment. Increment 1: +gated grants render their requirements but the approve POST is FENCED (403, fail-closed, +bypass-tested in Task 6); ungated grants authorize end-to-end via the demo approve. +Consistent with #104 decision 3 (in-process scope) and the repo's demo-fencing honesty rule. + +### Task 8: Example + smoke + +- [ ] `examples/grants-preapproved/server.mjs` — the spec-009 story verbatim (create → approveUrl; worker loop spends until `remaining.lt(usd.dollars(20))`); `smoke.mjs` asserts: pending→(demo approve)→authorized → 3 spends → budget-exceeded refusal → replay check → revoke → revoked refusal → **gated grant demo-approve 403**. Wire smoke into CI alongside the existing example smokes if a hook exists; otherwise runnable-only. Commit. + +### Task 9: Docs + PR + +- [ ] README section "Grants — approve once, spend while away" (example-first, serverless caveat N/A — spends are in-process server calls; note the trust honesty: `server-issued-demo`, age never on autopilot), `examples/README.md` line, `index.ts` export comments. Full build + both suites + all smokes. PR per template (plain-language, decisions → outcomes, bypass tests listed RED-verified), `Refs #104`, sub-issue parent #97. + +**Self-review done:** every #104 decision maps to a task (1→Task 4 replay; 2→Tasks 1/4; 3→Task 4 in-process; 4→engine reused not duplicated, #44/#95 housekeeping stays OUT of this PR — separate follow-up). Spec FRs: FR-001 Task 3, FR-002/003 Task 4, FR-004 Tasks 2/6, FR-005 Tasks 1/4, FR-007 Tasks 2/4/5, FR-010 Task 6 (dev-sealed, honesty comments). HR-001/002 pinned in Task 4 result assertions. diff --git a/examples/README.md b/examples/README.md index ca75f2d..00470f9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,6 +19,9 @@ Each is runnable against the two `@openmobilehub/credentagent-*` packages (build **Order webhooks — the real HTTP completion signal** (010) - [`order-webhooks/`](order-webhooks/) — a sender + a separate receiver: a settled order POSTs a **signed** `order.settled` event; the receiver verifies it with `constructEvent` (the Stripe idiom). Forged/tampered/replayed events are rejected +**Grants — approve once, spend while away** (009 / #104) +- [`grants-preapproved/`](grants-preapproved/) — the real `credentagent.grants` API: the human approves ONE limit at `grant.approveUrl`; the agent then spends against it unattended — catalog-priced, capped per-purchase and in total, replay-safe by `idempotencyKey`, revocable, and age-restricted items always step up to a human + **Cart Mandate / stateless** (004) - [`stateless-orders/`](stateless-orders/) — the created order rides in a signed Cart Mandate on the link diff --git a/examples/grants-preapproved/README.md b/examples/grants-preapproved/README.md new file mode 100644 index 0000000..836adcd --- /dev/null +++ b/examples/grants-preapproved/README.md @@ -0,0 +1,73 @@ +# `grants-preapproved/` — approve a spending limit once, let the agent buy against it + +You're heads-down for a week and want your AI agent to keep you in coffee. You don't want to +tap "approve" for every cup — and you don't want the agent to have a blank check either. This +example is the smallest real thing that makes that safe: you approve **one limit** ("up to +$15 per purchase, $50 total, at this café"), and the agent spends against it **while you're +away** — capped, replay-safe, revocable, and never for age-restricted items. + +Two things run **once at startup** (`grants.serve()`, `on()`); `grants.create()` runs when +the agent asks for authority; `grant.spend()` runs per purchase, unattended: + +```js +import express from "express"; +import { CredentAgent, usd } from "@openmobilehub/credentagent-gate"; + +const app = express(); +app.use(express.json()); +const credentagent = new CredentAgent({ + walletOrigin: "http://localhost:4000", + catalog: { coffee: 4.5, beans: 14 }, // the price authority — spends name a sku, never an amount +}); + +// ── once, at startup ────────────────────────────────────────── +credentagent.grants.serve(app); // the approve page at each grant's approveUrl +credentagent.on("order.settled", ({ id }) => fulfill(id)); + +// ── the agent asks for authority — hand the link to the human ── +const grant = await credentagent.grants.create({ + merchant: "corner-cafe", + budget: usd.dollars(50), perSpend: usd.dollars(15), // Money — never a raw number + policy: [], +}); +sendToUser(grant.approveUrl); // ONE approval, then the agent is on its own + +// ── later — human away — spend against it ───────────────────── +const g = await credentagent.grants.retrieve(grant.id); +if (g.status === "authorized") { + const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + // → { ok: true, amount, remaining } · { ok: false, code: "budget-exceeded" | "per-spend-exceeded" | "revoked" | … } +} +``` + +## Prove it (no browser needed) + +```bash +npm run build # build the @openmobilehub/credentagent-* packages +node examples/grants-preapproved/smoke.mjs # boots its own server on an ephemeral port + asserts the whole flow +``` + +`smoke.mjs` is self-contained — it starts its own app, so you don't run `server.mjs` alongside it. + +## Run it yourself + +```bash +node examples/grants-preapproved/server.mjs # → http://localhost:4000 +``` + +Then: + +1. `curl -X POST http://localhost:4000/setup-coffee-fund` → `{ id, approveUrl }` +2. Open the `approveUrl` → **Approve this limit** (the one human step) +3. `curl -X POST http://localhost:4000/buy/ -H 'content-type: application/json' -d '{"purchaseId":"p1","sku":"coffee"}'` → spends, unattended +4. `curl -X DELETE http://localhost:4000/grants/` → revoked; the next spend refuses + +## What's honestly enforced (and what isn't) + +- **Enforced, tested:** per-purchase cap, cumulative budget, replay-safe retries (reuse the + `purchaseId`), revocation, and **age-restricted items never complete on autopilot** — they + refuse with `step-up` (a live human ceremony is the only way). +- **Demo-fenced:** trust is `server-issued-demo` — the approval key is minted by this server, + not the user's wallet, and no real value moves. A grant whose `policy` names a credential + (e.g. `required(age.over(21))`) renders its requirements but **cannot** be approved from + the demo button (403) — rails-backed grant approval lands with the wallet-custody increment. diff --git a/examples/grants-preapproved/server.mjs b/examples/grants-preapproved/server.mjs new file mode 100644 index 0000000..d780f26 --- /dev/null +++ b/examples/grants-preapproved/server.mjs @@ -0,0 +1,81 @@ +// Runnable example — approve a spending limit once, let the agent buy against it later, +// built on the real credentagent.grants API (spec 009, the human-not-present half). +// +// node examples/grants-preapproved/server.mjs # boots on http://localhost:4000 +// node examples/grants-preapproved/smoke.mjs # drives the whole flow + asserts (no browser) +// +// The human approves ONE limit at the grant's approveUrl; the agent then spends against it +// unattended. Every spend is re-priced from the server-side catalog (never a caller amount), +// capped per-purchase and in total, safely retryable by idempotency key, and revocable. +// Honesty: trust is `server-issued-demo` — no real value moves, and age-restricted items +// NEVER complete on autopilot (they step up to a live human). +import express from "express"; +import { CredentAgent, usd, age, required } from "@openmobilehub/credentagent-gate"; + +const PORT = 4000; +const app = express(); +app.use(express.json()); + +// ── ONCE, at startup ──────────────────────────────────────────────────────────── +// The catalog is the price authority (a spend names a sku, never an amount). +const credentagent = new CredentAgent({ + walletOrigin: `http://localhost:${PORT}`, + catalog: { coffee: 4.5, beans: 14, "case-of-beans": 40, wine: { price: 21, minAge: 21 } }, +}); +credentagent.grants.serve(app); // the approve page at each grant's approveUrl +credentagent.on("order.settled", ({ id }) => console.log(`✓ spend settled: ${id}`)); + +// ── The agent asks for authority — gets back a link to hand to the human ───────── +app.post("/setup-coffee-fund", async (_req, res) => { + const grant = await credentagent.grants.create({ + merchant: "corner-cafe", + budget: usd.dollars(50), + perSpend: usd.dollars(15), + policy: [], + description: "Coffee while I'm heads-down this week", + }); + res.json({ id: grant.id, approveUrl: grant.approveUrl, status: grant.status }); +}); + +// A grant whose policy needs a credential — demo-approve is fenced for it (403). +app.post("/setup-wine-fund", async (_req, res) => { + const grant = await credentagent.grants.create({ + merchant: "corner-cafe", + budget: usd.dollars(50), + perSpend: usd.dollars(30), + policy: [required(age.over(21))], + }); + res.json({ id: grant.id, approveUrl: grant.approveUrl, status: grant.status }); +}); + +// ── LATER — human away — the agent spends against the grant ───────────────────── +app.post("/buy/:grantId", async (req, res) => { + const grant = await credentagent.grants.retrieve(req.params.grantId); + if (grant.status !== "authorized") { + res.status(409).json({ status: grant.status, approveUrl: grant.approveUrl }); + return; + } + const s = await grant.spend({ + idempotencyKey: req.body.purchaseId, // REUSE on retry — never double-charges + items: [{ sku: req.body.sku, qty: req.body.qty ?? 1 }], + }); + const body = s.ok + ? { ok: true, amount: s.amount.serialize(), remaining: s.remaining.serialize(), replayed: s.replayed ?? false } + : { ok: false, code: s.code, retryable: s.retryable, remaining: s.remaining.serialize() }; + res.status(s.ok ? 200 : 402).json(body); +}); + +// The kill switch. +app.delete("/grants/:grantId", async (req, res) => { + const grant = await credentagent.grants.retrieve(req.params.grantId); + await grant.revoke(); + res.json({ status: "revoked" }); +}); + +app.listen(PORT, () => { + console.log(`grants-preapproved example on http://localhost:${PORT}`); + console.log(` 1) POST /setup-coffee-fund → { id, approveUrl }`); + console.log(` 2) open the approveUrl → Approve (the one human step)`); + console.log(` 3) POST /buy/ {"purchaseId":"p1","sku":"coffee"} → spends, unattended`); + console.log(` 4) DELETE /grants/ → revoked; the next spend refuses`); +}); diff --git a/examples/grants-preapproved/smoke.mjs b/examples/grants-preapproved/smoke.mjs new file mode 100644 index 0000000..c8f16fe --- /dev/null +++ b/examples/grants-preapproved/smoke.mjs @@ -0,0 +1,105 @@ +// Smoke test for the grants-preapproved example — drives the REAL built package over HTTP +// and asserts, so CI (and you) can prove the pre-approved spending flow end-to-end without +// a browser. Covers the security-critical shapes: +// • a spend BEFORE approval is refused (pending, never ok); +// • a policy-GATED grant cannot be approved from the demo button (403, fail-closed); +// • spends are capped per-purchase and in total; a retry with the same purchaseId is +// answered once-charged (replayed), age-restricted items step up, revoke kills the grant. +import express from "express"; +import { CredentAgent, usd, age, required } from "@openmobilehub/credentagent-gate"; + +const app = express(); +app.use(express.json()); + +const settled = []; +const ca = new CredentAgent({ + walletOrigin: "http://localhost:0", + catalog: { coffee: 4.5, beans: 14, "case-of-beans": 40, wine: { price: 21, minAge: 21 } }, +}); +ca.grants.serve(app); +ca.on("order.settled", ({ id }) => settled.push(id)); + +app.post("/setup", async (req, res) => + res.json( + await (async () => { + const g = await ca.grants.create({ + merchant: "corner-cafe", + budget: usd.dollars(50), + perSpend: usd.dollars(15), + policy: req.body?.gated ? [required(age.over(21))] : [], + }); + return { id: g.id, approveUrl: g.approveUrl, status: g.status }; + })(), + ), +); +app.post("/buy/:id", async (req, res) => { + const grant = await ca.grants.retrieve(req.params.id); + if (grant.status !== "authorized") { res.status(409).json({ status: grant.status }); return; } + const s = await grant.spend({ idempotencyKey: req.body.purchaseId, items: [{ sku: req.body.sku }] }); + res.status(s.ok ? 200 : 402).json(s.ok + ? { ok: true, amount: s.amount.serialize(), remaining: s.remaining.serialize(), replayed: s.replayed ?? false } + : { ok: false, code: s.code, remaining: s.remaining.serialize() }); +}); +app.delete("/grants/:id", async (req, res) => { await (await ca.grants.retrieve(req.params.id)).revoke(); res.json({}); }); + +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() }); +const post = (path, body) => fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body ?? {}) }); + +try { + // ── the pending lifecycle ── + const g = (await j(await post("/setup"))).body; + check("create returns a pending grant with an approveUrl", g.id.startsWith("gr_") && g.status === "pending" && g.approveUrl.includes(g.id)); + + const early = await j(await post(`/buy/${g.id}`, { purchaseId: "p0", sku: "coffee" })); + check("a spend BEFORE approval is refused (never ok unapproved)", early.status === 409 && early.body.status === "pending"); + + const page = await j(await fetch(`${base}/credentagent/grants/${g.id}`)); + check("the approve page renders the terms", page.status === 200 && page.body.includes("$50.00") && page.body.includes("corner-cafe")); + + await post(`/credentagent/grants/${g.id}/approve`); + const st = (await j(await fetch(`${base}/credentagent/grants/${g.id}/status`))).body; + check("approve flips the grant to authorized", st.completed === true && st.status === "authorized"); + + // ── spending, capped + replayable ── + const s1 = (await j(await post(`/buy/${g.id}`, { purchaseId: "p1", sku: "coffee" }))).body; + check("a spend is priced by the catalog and draws down the budget ($4.50 → $45.50 left)", s1.ok && s1.amount.amount === 450 && s1.remaining.amount === 4550); + + const retry = (await j(await post(`/buy/${g.id}`, { purchaseId: "p1", sku: "coffee" }))).body; + check("retrying the SAME purchaseId replays once-charged (remaining unchanged)", retry.ok && retry.replayed === true && retry.remaining.amount === 4550); + + const over = await j(await post(`/buy/${g.id}`, { purchaseId: "p2", sku: "case-of-beans" })); + check("a single spend over the per-purchase cap is refused (per-spend-exceeded)", over.status === 402 && over.body.code === "per-spend-exceeded"); + + const wine = await j(await post(`/buy/${g.id}`, { purchaseId: "p3", sku: "wine" })); + check("an age-restricted item NEVER completes on autopilot (step-up)", wine.status === 402 && wine.body.code === "step-up"); + + await post(`/buy/${g.id}`, { purchaseId: "p4", sku: "beans" }); // 14.00 → 31.50 left + await post(`/buy/${g.id}`, { purchaseId: "p5", sku: "beans" }); // 14.00 → 17.50 left + await post(`/buy/${g.id}`, { purchaseId: "p6", sku: "beans" }); // 14.00 → 3.50 left + const broke = await j(await post(`/buy/${g.id}`, { purchaseId: "p7", sku: "beans" })); + check("a spend beyond the cumulative budget is refused (budget-exceeded)", broke.status === 402 && broke.body.code === "budget-exceeded" && broke.body.remaining.amount === 350); + + check("order.settled fired once per completed spend (4 spends)", settled.length === 4 && settled.every((id) => id.startsWith(`${g.id}-`))); + + // ── the kill switch ── + await fetch(`${base}/grants/${g.id}`, { method: "DELETE" }); + const dead = await j(await post(`/buy/${g.id}`, { purchaseId: "p8", sku: "coffee" })); + check("after revoke, the very next spend is refused", dead.status === 409 || dead.body.code === "revoked"); + + // ── the fence: a policy-gated grant can't be button-approved ── + const gated = (await j(await post("/setup", { gated: true }))).body; + const fenced = await j(await post(`/credentagent/grants/${gated.id}/approve`)); + check("a policy-gated grant is REFUSED on the demo approve path (403)", fenced.status === 403); + const gatedSt = (await j(await fetch(`${base}/credentagent/grants/${gated.id}/status`))).body; + check("…and it stays pending (nothing sealed)", gatedSt.completed === false && gatedSt.status === "pending"); +} 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 50e24af..58fdaf4 100644 --- a/packages/credentagent-gate/README.md +++ b/packages/credentagent-gate/README.md @@ -102,9 +102,62 @@ the age threshold are re-derived from the order you stored server-side — never (invariant 2), and a gated order can only complete through the wallet ceremony, never a shortcut (invariant 1). Runnable: [`examples/orders-checkout/`](https://github.com/openmobilehub/credentagent/tree/main/examples/orders-checkout). -### Webhooks — tell a *different* service when an order settles +## Grants — approve a spending limit once, spend while the human is away -`on("order.settled", …)` only fires in the process that settled the order. When fulfillment runs +An order needs the human every time. A **grant** needs them once: they approve a limit +("up to $15 per purchase, $50 total, at this merchant") at the grant's `approveUrl`, and the +agent then spends against it unattended — every spend re-priced from your catalog, capped, +replay-safe, and revocable. Amounts are `Money` (`usd.dollars(50)`) — never a raw number. + +```ts +import express from "express"; +import { CredentAgent, usd } from "@openmobilehub/credentagent-gate"; + +const app = express(); +app.use(express.json()); +const credentagent = new CredentAgent({ + walletOrigin: "http://localhost:4000", + catalog: { coffee: 4.5, beans: 14 }, // the price authority — a spend names a sku, never an amount +}); + +// ── once, at startup ────────────────────────────────────────────── +credentagent.grants.serve(app); // the approve page at each grant's approveUrl + +// ── the agent asks for authority — hand the link to the human ───── +const grant = await credentagent.grants.create({ + merchant: "corner-cafe", budget: usd.dollars(50), perSpend: usd.dollars(15), policy: [], +}); +sendToUser(grant.approveUrl); // ONE approval; grant.status: "pending" → "authorized" + +// ── later, in a worker — human away ─────────────────────────────── +const g = await credentagent.grants.retrieve(grant.id); // rehydrates across processes +if (g.status === "authorized") { + const s = await g.spend({ idempotencyKey: purchaseId, items: [{ sku: "coffee" }] }); + if (s.ok) console.log(`spent ${s.amount}, ${s.remaining} left`); // Money, not floats + else if (s.code === "budget-exceeded") { /* spent out — stop */ } + else if (s.retryable === "needs-human") sendToUser(g.approveUrl!); // e.g. a step-up +} +await g.revoke(); // kill switch — the very next spend refuses +``` + +`spend()` is one result **door**: + +- `{ ok: true, amount, remaining, replayed?, mandateBundle }` — `amount`/`remaining` are `Money`; `replayed: true` when a retried `idempotencyKey` was answered once-charged. +- `{ ok: false, code, remaining, retryable }` — `code` is a closed `SpendCode`: `"budget-exceeded"`, `"per-spend-exceeded"`, `"not-authorized"` (still pending), `"denied"`, `"revoked"`, `"step-up"` (age / custom-gated — a live human is the only way), `"invalid-quantity"`, `"idempotency-conflict"` (same key, different items), or `"not-found"`. `retryable` (`"retry"` | `"needs-human"` | `"terminal"`) is the bit an unattended loop branches on. + +A missing catalog, an empty/blank `idempotencyKey`, or empty `items` is a **programming error** and throws — a refusal you can act on is always data, a mistake you must fix is an exception. `retrieve()` of an unknown id returns a handle with `status: "not-found"` (its `terms`/`approveUrl` are `undefined`) — never a throw. Completed spends fire `order.settled` and the webhook fan-out, exactly like human-present orders. + +> **Honesty:** a grant's trust level is **`server-issued-demo`** — the approval key is minted +> by your server at the approve click, not by the user's wallet, and no real value moves. The +> grant is the durable authority; the AP2 **Intent Mandate** it carries (`grant.intentMandate`, +> dev-sealed) is where the wallet-custody increment swaps in real key-signing without changing +> this surface. A grant whose `policy` names a credential renders its requirements but cannot +> be approved from the demo button (403, fail-closed) until rails-backed approval lands. +> Runnable: [`examples/grants-preapproved/`](https://github.com/openmobilehub/credentagent/tree/main/examples/grants-preapproved). + +## Webhooks — tell a *different* service when an order settles + +Fires for both human-present orders and delegated spends. `on("order.settled", …)` only fires in the process that settled the order. When fulfillment runs elsewhere, register a **webhook**: the gate sends a **signed** HTTP `POST` and the other service verifies it — the Stripe idiom (`constructEvent`). Real HMAC signature, replay-protected. @@ -246,10 +299,11 @@ The cert's SubjectAltName must cover the `walletOrigin` host or the wallet rejec > **`verification_required`** envelope the agent *drives* (which credential, a per-order approve link, > the tool to poll) instead of completing — the retained blocking **Mode B** primitive. -## Delegated draws — human-not-present seams (005, preview) +## Delegated draws — the low-level seams under `grants` (005, preview) + +> **Which one do I use?** Reach for **[`grants`](#grants--approve-a-spending-limit-once-spend-while-the-human-is-away)** (above) — it's the durable, cross-process resource: `grants.create()` mints an id and an `approveUrl`, `grants.retrieve(id)` rehydrates the grant in a worker, amounts are `Money`, and it wires an approve page. **`DelegatedGate`** is the in-process standalone facade over the *same* engine — no HTTP surface, no rehydrate-by-id, raw-number amounts — handy for a single-process script or a test. Same seams underneath; `grants` is the one to build on. -Approve a spending limit once; your agent draws against it while you're away, every draw re-checked -server-side. The Stripe-grade entry point is **`DelegatedGate`**: +The `DelegatedGate` facade — approve once, draw while away, every draw re-checked server-side: ```ts import { DelegatedGate } from "@openmobilehub/credentagent-gate"; @@ -281,11 +335,26 @@ provide those are later increments. ```ts // Client (configure once, then declarative calls) class CredentAgent { - constructor(opts?: { walletOrigin?: string; store?: VerificationStore; credentials?: Credential[] }); + constructor(opts?: { + walletOrigin?: string; store?: VerificationStore; credentials?: Credential[]; + catalog?: Record; // grants pricing authority + orderStore?; completedOrderStore?; grantStore?; revocationStore?; // inject shared stores (multi-instance) + gateSecret?: string; webhooks?: WebhookOptions; + }); requirements(order: GateOrder, policy: Step[]): VerificationManifestEntry[]; // Context 1 mount(app: ExpressApp, ceremony?: MountCeremony): void; // Context 2 + orders // await orders.create({ order, policy }) → { id, approveUrl, manifest } · orders.retrieve(id) → door · orders.serve(app) + grants // await grants.create({ merchant, budget, perSpend, policy }) → grant · grants.retrieve(id) → grant · grants.serve(app) + webhooks; on("order.settled", h) } +// Money — the grants amount type (opaque, currency-checked; unit always explicit) +usd.dollars(n) · usd.cents(n) · m.lt/gte/eq/plus/minus/serialize · type Money + +// Grants (spec 009, #104) — approve once, spend while away +grant.spend({ idempotencyKey, items }) → SpendDoor · grant.revoke() · grant.status / terms / approveUrl / intentMandate +// SpendDoor = { ok:true, amount, remaining, replayed?, mandateBundle } | { ok:false, code: SpendCode, remaining, retryable } + // Policy builders + extensibility age.over(n) · membership.discount(n) · payment.in(currency) required(c) · optional(c) · .when((order) => boolean) @@ -295,7 +364,7 @@ dcql({ docType, claims }) · gate() · discount({ percent?, amount? }) · // Stores + host-side composition seam MemoryVerificationStore · completeOrder(input, ctx) -// Delegated draws (HNP, 005 preview) — the Stripe-grade facade + the underlying seams +// Delegated draws (HNP, 005 preview) — the low-level facade under `grants` + the underlying seams DelegatedGate · gate.preApprove(bounds) → DelegatedGrant · grant.spend(purchase) → SpendResult · grant.revoke() sealIntent · checkDraw · signDraw · MemoryRevocationStore · Draw / IntentBounds / CommittedDraw / Refusal @@ -310,7 +379,9 @@ ageDcql() · ENVELOPE_VERSION · ENVELOPE_SENTINEL // Types: CredentAgentOptions, GateOrder, OrderLine, Credential, Step, Effect, // VerificationManifestEntry, VerificationStore, VerificationRecord, // TrustLevel, DcqlQuery, DcqlClaim, DcqlCredentialOption, ExpressApp, -// CompletionSeam / SettlementSeam / CeremonyOrder (host composition) +// CompletionSeam / SettlementSeam / CeremonyOrder (host composition), +// Money, CreateGrantOptions, GrantRecord, GrantStatus, SpendDoor, SpendCode, +// GrantTrustLevel, SpendItem (grants) · WebhookEvent / WebhookOptions (webhooks) ``` Full, compiler-checked contract: [`specs/001-attesto-sdk/`](https://github.com/openmobilehub/mcp-apps-shopping-demo/tree/main/specs/001-attesto-sdk/) (the diff --git a/packages/credentagent-gate/src/client.ts b/packages/credentagent-gate/src/client.ts index f659959..4f06bb7 100644 --- a/packages/credentagent-gate/src/client.ts +++ b/packages/credentagent-gate/src/client.ts @@ -10,6 +10,9 @@ import { mountCeremony, type CeremonyApp, type CeremonySeams } from "./ceremony/ import { Orders, MemoryOrderStore, type CreatedOrder, type CompletedOrder } from "./orders.js"; import { serveOrders } from "./orders-serve.js"; import { Webhooks } from "./webhooks.js"; +import { Grants, type GrantRecord } from "./grants.js"; +import { serveGrants } from "./grants-serve.js"; +import { MemoryRevocationStore } from "./ceremony/revocation.js"; x509.cryptoProvider.set(globalThis.crypto); @@ -33,6 +36,8 @@ export class CredentAgent { readonly store: VerificationStore; /** The human-present checkout resource — `orders.create()` / `orders.retrieve()` (spec 009). */ readonly orders: Orders; + /** Durable spend authority — `grants.create()` / `retrieve()` / `grant.spend()` (spec 009, #104). */ + readonly grants: Grants; /** Outbound HTTP webhooks — `webhooks.register()` / `webhooks.constructEvent()` (spec 010). */ readonly webhooks: Webhooks; /** Stable reader identity presented by the rails (undefined ⇒ per-request self-signed). */ @@ -44,6 +49,8 @@ export class CredentAgent { private mountedRoutes = false; // True once `orders.serve(app)` has wired the checkout (idempotent — one serve per client). private ordersServed = false; + // True once `grants.serve(app)` has wired the approve page (idempotent — one serve per client). + private grantsServed = 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 @@ -120,6 +127,32 @@ export class CredentAgent { this.mountedRoutes = true; // approve links now resolve to the mounted rails }, }); + // The grants resource (spec 009, #104) — durable spend authority over the SAME client + // config. Spend completions route through `orders._complete`, so the settled event and + // webhook fan-out apply to delegated spends exactly as to human-present orders. + const grantStore = opts.grantStore ?? new MemoryOrderStore(); + const grantRevocation = opts.revocationStore ?? new MemoryRevocationStore(); + this.grants = new Grants({ + walletOrigin: this.walletOrigin, + store: grantStore, + revocation: grantRevocation, + ...(opts.catalog ? { catalog: opts.catalog } : {}), + requirements: (order, policy) => this.requirements(order, policy), + completeSpend: (record) => this.orders._complete(record), + readSpend: (orderId) => completedStore.read(orderId), + credentialRegistry: this.registry, + serve: (app) => { + if (this.grantsServed) return; // idempotent + serveGrants(app as Parameters[0], { + walletOrigin: this.walletOrigin, + store: grantStore, + authorize: (id) => this.grants._authorize(id), + decline: (id) => this.grants._decline(id), + requirements: (order, policy) => this.requirements(order, policy), + }); + this.grantsServed = true; + }, + }); } /** diff --git a/packages/credentagent-gate/src/delegated.ts b/packages/credentagent-gate/src/delegated.ts index e07fd3c..f88e486 100644 --- a/packages/credentagent-gate/src/delegated.ts +++ b/packages/credentagent-gate/src/delegated.ts @@ -79,7 +79,9 @@ export interface SpendResult { const priceOf = (e: CatalogEntry) => (typeof e === "number" ? e : e.price); const minAgeOf = (e: CatalogEntry) => (typeof e === "number" ? undefined : e.minAge); -function buildCatalog(items: Record): CeremonyCatalog { +/** Build the re-pricing catalog seam (dollars) from a plain priced map, for `DelegatedGate`. + * (`credentagent.grants` builds its own integer-cents catalog — see grants.ts.) */ +export function buildCatalog(items: Record): CeremonyCatalog { return { // Must honor the passed orderId — completeOrder re-prices under the SAME id, and its // idempotency is keyed by it, so a duplicate id would echo a prior completion instead diff --git a/packages/credentagent-gate/src/grants-serve.test.ts b/packages/credentagent-gate/src/grants-serve.test.ts new file mode 100644 index 0000000..2ed3cfc --- /dev/null +++ b/packages/credentagent-gate/src/grants-serve.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { CredentAgent } from "./client.js"; +import { usd } from "./money.js"; +import { age, required } from "./credentials.js"; + +// The same dependency-free Express double orders-serve.test.ts uses. +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 }; + 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; }; + return res; +} + +const client = () => new CredentAgent({ walletOrigin: "http://localhost:4000" }); +const terms = () => ({ merchant: "utopia", budget: usd.dollars(40), perSpend: usd.dollars(20) }); + +describe("grants.serve — the approve page wiring", () => { + it("serve() registers the approve page, approve, decline, and status routes", () => { + const ca = client(); + const app = fakeApp(); + ca.grants.serve(app); + expect(app._get.has("/credentagent/grants/:id")).toBe(true); + expect(app._post.has("/credentagent/grants/:id/approve")).toBe(true); + expect(app._post.has("/credentagent/grants/:id/decline")).toBe(true); + expect(app._get.has("/credentagent/grants/:id/status")).toBe(true); + }); + + it("renders the approve page for a pending grant (200, terms visible); unknown id is 404", async () => { + const ca = client(); + const app = fakeApp(); + ca.grants.serve(app); + const { id } = await ca.grants.create({ ...terms(), policy: [], description: "Coffee while I sleep" }); + + const res = fakeRes(); + await app._get.get("/credentagent/grants/:id")!({ params: { id } }, res); + expect(res._status).toBe(200); + expect(res._body).toContain("utopia"); + expect(res._body).toContain("$40.00"); // budget, page-formatted + expect(res._body).toContain("$20.00"); // per-spend + expect(res._body).toContain("Coffee while I sleep"); + + const missing = fakeRes(); + await app._get.get("/credentagent/grants/:id")!({ params: { id: "gr_nope" } }, missing); + expect(missing._status).toBe(404); + }); + + it("demo approve authorizes an UNGATED grant — intent sealed, idempotent on a re-POST", async () => { + const ca = client(); + const app = fakeApp(); + ca.grants.serve(app); + const { id } = await ca.grants.create({ ...terms(), policy: [] }); + + const approve = app._post.get("/credentagent/grants/:id/approve")!; + const res = fakeRes(); + await approve({ params: { id } }, res); + expect(res._status).toBe(200); + + const g = await ca.grants.retrieve(id); + expect(g.status).toBe("authorized"); + expect(g.intentMandate?.intentId).toBeTruthy(); + expect(g.trustLevel).toBe("server-issued-demo"); // honesty carried in the sealed artifact + const firstIntentId = g.intentMandate!.intentId; + + const again = fakeRes(); + await approve({ params: { id } }, again); // double-click / retry + expect(again._status).toBe(200); + expect((await ca.grants.retrieve(id)).intentMandate!.intentId).toBe(firstIntentId); // ONE seal + }); + + // BYPASS (invariant 1 / honesty fencing): a grant whose policy needs a credential ceremony + // must NOT authorize from the button-press path — rails-backed authorize is the + // wallet-custody increment; until then it is FENCED, fail-closed. Delete the isGated + // guard in the approve handler and this goes red. + it("BYPASS: demo approve REFUSES a policy-gated grant (403) — it stays pending", async () => { + const ca = client(); + const app = fakeApp(); + ca.grants.serve(app); + const { id } = await ca.grants.create({ ...terms(), policy: [required(age.over(21))] }); + + const res = fakeRes(); + await app._post.get("/credentagent/grants/:id/approve")!({ params: { id } }, res); + expect(res._status).toBe(403); + const g = await ca.grants.retrieve(id); + expect(g.status).toBe("pending"); // never authorized + expect(g.intentMandate).toBeUndefined(); // nothing sealed + }); + + it("decline flips a pending grant to denied; approve afterwards is refused (never resurrect)", async () => { + const ca = client(); + const app = fakeApp(); + ca.grants.serve(app); + const { id } = await ca.grants.create({ ...terms(), policy: [] }); + + const res = fakeRes(); + await app._post.get("/credentagent/grants/:id/decline")!({ params: { id } }, res); + expect(res._status).toBe(200); + expect((await ca.grants.retrieve(id)).status).toBe("denied"); + + const approve = fakeRes(); + await app._post.get("/credentagent/grants/:id/approve")!({ params: { id } }, approve); + expect(approve._status).toBe(403); + expect((await ca.grants.retrieve(id)).status).toBe("denied"); + }); + + it("status answers the page poll: completed flips on authorize", async () => { + const ca = client(); + const app = fakeApp(); + ca.grants.serve(app); + const { id } = await ca.grants.create({ ...terms(), policy: [] }); + + let res = fakeRes(); + await app._get.get("/credentagent/grants/:id/status")!({ params: { id } }, res); + expect(res._json).toMatchObject({ completed: false, status: "pending" }); + + await app._post.get("/credentagent/grants/:id/approve")!({ params: { id } }, fakeRes()); + res = fakeRes(); + await app._get.get("/credentagent/grants/:id/status")!({ params: { id } }, res); + expect(res._json).toMatchObject({ completed: true, status: "authorized" }); + }); +}); diff --git a/packages/credentagent-gate/src/grants-serve.ts b/packages/credentagent-gate/src/grants-serve.ts new file mode 100644 index 0000000..6b4ea59 --- /dev/null +++ b/packages/credentagent-gate/src/grants-serve.ts @@ -0,0 +1,152 @@ +// grants.serve(app) — the grant approve page, wired in one call (spec 009, #104). +// +// const ca = new CredentAgent({ walletOrigin, catalog }); +// ca.grants.serve(app); // approve page + decline + status +// const grant = await ca.grants.create({ merchant, budget, perSpend, policy }); +// sendToUser(grant.approveUrl); // they approve the LIMIT here, once +// +// Increment-1 scope (#104 decision 3): an UNGATED grant (policy []) authorizes end-to-end via +// the demo approve button; a POLICY-GATED grant renders its requirements but the approve POST +// is FENCED (403, fail-closed) — rails-backed grant authorization is the wallet-custody +// increment (a second mountCeremony here would collide with orders' rail routes; the composite +// mount belongs to that increment). Honesty: the page states trust "server-issued-demo". + +import type { GateOrder, Step, VerificationManifestEntry } from "./types.js"; +import type { OrderStore } from "./orders.js"; +import type { GrantRecord } from "./grants.js"; +import { isGated } from "./orders-serve.js"; + +export interface ServeGrantsDeps { + walletOrigin: string; + store: OrderStore; + /** `grants._authorize` — seals the Intent Mandate, mints the delegate key, flips status. */ + authorize: (id: string) => Promise; + /** `grants._decline` — pending → denied, fail-closed everywhere else. */ + decline: (id: string) => Promise; + requirements: (order: GateOrder, policy: Step[]) => VerificationManifestEntry[]; +} + +/** A structural Express app/request/response — the package stays dependency-free (mirrors orders-serve). */ +interface GrantsApp { + get?(path: string, handler: GrantsHandler): unknown; + post?(path: string, handler: GrantsHandler): unknown; +} +interface GrantsRequest { + params: Record; +} +interface GrantsResponse { + status(code: number): GrantsResponse; + type(t: string): GrantsResponse; + send(body: string): unknown; + json(body: unknown): unknown; +} +type GrantsHandler = (req: GrantsRequest, res: GrantsResponse) => void | Promise; + +const fmt = (n: number): string => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n); +const esc = (s: string): string => s.replace(/&/g, "&").replace(//g, ">"); +const html = (body: string) => + `${body}`; + +/** The grant presented as a one-line GateOrder so `requirements()` resolves the policy + * against it (amount = budget — what the human is authorizing up to). */ +function pseudoOrder(record: GrantRecord): GateOrder { + const budgetDollars = record.budgetCents / 100; + return { + id: record.id, + total: budgetDollars, + currency: record.currency, + lines: [{ id: "grant", name: `Pre-approval at ${record.merchant}`, quantity: 1, unitPrice: budgetDollars }], + }; +} + +function termsCard(record: GrantRecord, manifest: VerificationManifestEntry[]): string { + const rows = [ + `Merchant${esc(record.merchant)}`, + `Total budget${fmt(record.budgetCents / 100)}`, + `Per-purchase cap${fmt(record.perSpendCents / 100)}`, + ...(record.description ? [`Purpose${esc(record.description)}`] : []), + ].join(""); + const requires = manifest.length + ? `

Requires: ${manifest.map((m) => esc(m.credential)).join(", ")}

` + : ""; + return `

Approve a spending limit

${rows}
${requires} +

Demo trust: server-issued-demo — the approval key is minted by this + server and no real value moves. Age-restricted items never complete on autopilot.

`; +} + +export function serveGrants(app: GrantsApp, deps: ServeGrantsDeps): void { + const get = app.get?.bind(app); + const post = app.post?.bind(app); + if (!get || !post) { + throw new Error("[credentagent] grants.serve(app): the app must expose Express-style get()/post() route methods."); + } + + const page: GrantsHandler = async (req, res) => { + const record = await deps.store.read(req.params.id); + if (!record) { res.status(404).type("html").send(html("

Unknown grant

")); return; } + const manifest = deps.requirements(pseudoOrder(record), record.policy); + const gated = isGated(manifest); + let action = ""; + if (record.status === "pending") { + action = gated + ? `

This grant needs a wallet ceremony to approve (its policy requires a + credential) — not available in this demo increment. It can still be declined.

` + : `
`; + action += `
`; + } else { + action = `

Status: ${record.status}

`; + } + res.type("html").send(html(termsCard(record, manifest) + action)); + }; + + // Demo approve — UNGATED grants only. A policy-gated grant is refused here (fail-closed, + // same rule as orders' instant-demo place path): approving it requires the credential + // ceremony its policy names, which this increment does not serve. + const approve: GrantsHandler = async (req, res) => { + const id = req.params.id; + const record = await deps.store.read(id); + if (!record) { res.status(404).type("html").send(html("

Unknown grant

")); return; } + if (record.status === "authorized") { res.type("html").send(html("

✓ Limit approved

Already approved — you can close this tab.

")); return; } + if (record.status !== "pending") { + res.status(403).type("html").send(html(`

Cannot approve

This grant is ${record.status} — a ${record.status} grant is never resurrected.

`)); + return; + } + const manifest = deps.requirements(pseudoOrder(record), record.policy); + if (isGated(manifest)) { + res.status(403).type("html").send(html("

Wallet ceremony required

This grant's policy requires a credential — it can't be approved from the demo button. Rails-backed grant approval lands with the wallet-custody increment.

")); + return; + } + await deps.authorize(id); + // Re-read: a decline/revoke landing in the authorize window means it did NOT seal — don't + // show a success page for a grant that isn't actually authorized. + const after = await deps.store.read(id); + if (after?.status === "authorized") { + res.type("html").send(html("

✓ Limit approved

Your agent can now spend within these bounds. You can close this tab.

")); + } else { + res.status(409).type("html").send(html(`

Not approved

This grant is ${after?.status ?? "gone"} — it was not approved.

`)); + } + }; + + const decline: GrantsHandler = async (req, res) => { + const id = req.params.id; + const record = await deps.store.read(id); + if (!record) { res.status(404).type("html").send(html("

Unknown grant

")); return; } + if (record.status === "denied") { res.type("html").send(html("

Declined

Already declined.

")); return; } + if (record.status !== "pending") { + res.status(403).type("html").send(html(`

Cannot decline

This grant is ${record.status}. To stop an approved grant, revoke it.

`)); + return; + } + await deps.decline(id); + res.type("html").send(html("

Declined

No spending authority was granted. You can close this tab.

")); + }; + + const status: GrantsHandler = async (req, res) => { + const record = await deps.store.read(req.params.id); + res.json({ completed: record?.status === "authorized", status: record?.status ?? "not-found" }); + }; + + get("/credentagent/grants/:id", page); + post("/credentagent/grants/:id/approve", approve); + post("/credentagent/grants/:id/decline", decline); + get("/credentagent/grants/:id/status", status); +} diff --git a/packages/credentagent-gate/src/grants.test.ts b/packages/credentagent-gate/src/grants.test.ts new file mode 100644 index 0000000..1077639 --- /dev/null +++ b/packages/credentagent-gate/src/grants.test.ts @@ -0,0 +1,407 @@ +import { describe, it, expect } from "vitest"; +import { CredentAgent } from "./client.js"; +import { usd } from "./money.js"; +import type { GrantRecord } from "./grants.js"; +import type { OrderStore } from "./orders.js"; +import { MemoryRevocationStore } from "./ceremony/revocation.js"; + +const terms = () => ({ merchant: "utopia", budget: usd.dollars(100), perSpend: usd.dollars(30) }); + +describe("credentagent.grants — create / retrieve (the pending lifecycle)", () => { + it("create() returns a pending grant with an approveUrl on this origin, terms echoed as Money", async () => { + const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); + const g = await ca.grants.create({ ...terms(), policy: [] }); + expect(g.id).toMatch(/^gr_/); + expect(g.status).toBe("pending"); + expect(g.approveUrl).toBe(`https://shop.example/credentagent/grants/${g.id}`); + expect(g.terms!.budget.eq(usd.dollars(100))).toBe(true); + expect(g.terms!.perSpend.eq(usd.dollars(30))).toBe(true); + expect(g.terms!.merchant).toBe("utopia"); + }); + + // Same control as orders.create: the approveUrl is only usable if the grant is READABLE + // when it's handed out. Delete the `await` on the store write and this goes red. + it("create() resolves only after the grant 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", grantStore: slowStore }); + const { id } = await ca.grants.create({ ...terms(), policy: [] }); + expect(backing.has(id)).toBe(true); + }); + + it("retrieve() rehydrates by id; an unknown id is a typed not-found, never a throw", async () => { + const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); + const { id } = await ca.grants.create({ ...terms(), policy: [] }); + const g = await ca.grants.retrieve(id); + expect(g.status).toBe("pending"); + expect(g.id).toBe(id); + expect((await ca.grants.retrieve("gr_nope")).status).toBe("not-found"); + }); + + it("scopes per grant: two grants are isolated records (invariant 4)", async () => { + const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); + const a = await ca.grants.create({ ...terms(), policy: [] }); + const b = await ca.grants.create({ ...terms(), policy: [] }); + expect(a.id).not.toBe(b.id); + expect((await ca.grants.retrieve(a.id)).id).toBe(a.id); + expect((await ca.grants.retrieve(b.id)).id).toBe(b.id); + }); +}); + +// The spend catalog: prices live server-side; wine is age-restricted (21+). +const CATALOG = { coffee: 18, espresso: 25, wine: { price: 20, minAge: 21 } }; +const client = () => new CredentAgent({ walletOrigin: "https://shop.example", catalog: CATALOG }); +async function authorizedGrant(ca: CredentAgent, over: Partial[0]> = {}) { + const { id } = await ca.grants.create({ merchant: "utopia", budget: usd.dollars(40), perSpend: usd.dollars(20), policy: [], ...over }); + await ca.grants._authorize(id); // what the approve ceremony does when the human approves + return ca.grants.retrieve(id); +} + +describe("grant.spend() — the delegated draw door", () => { + it("spends against the catalog price; remaining is Money and draws down", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(s.ok).toBe(true); + if (s.ok) { + expect(s.amount.eq(usd.dollars(18))).toBe(true); // priced by the gate, never the caller + expect(s.remaining.eq(usd.dollars(22))).toBe(true); // 40 − 18 + expect(s.authorization).toBe("delegated"); + expect(s.trustLevel).toBe("server-issued-demo"); // honesty: server-minted key, no real value + expect(s.mandateBundle.intentMandate.intentId).toBeTruthy(); + } + }); + + // BYPASS (budget): a spend that would exceed the cumulative budget is refused. Delete the + // over-total → budget-exceeded path (or the engine's over-total check) and this goes red. + it("BYPASS: refuses beyond the cumulative budget — code budget-exceeded", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + expect((await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] })).ok).toBe(true); + expect((await g.spend({ idempotencyKey: "p2", items: [{ sku: "coffee" }] })).ok).toBe(true); // 36 of 40 + const third = await g.spend({ idempotencyKey: "p3", items: [{ sku: "coffee" }] }); + expect(third.ok).toBe(false); + if (!third.ok) expect(third.code).toBe("budget-exceeded"); + // and the refusal did NOT draw down the budget: + const again = await g.spend({ idempotencyKey: "p4", items: [{ sku: "coffee" }] }); + expect(again.ok).toBe(false); // still over — but remaining stayed 4 + if (!again.ok) expect(again.remaining.eq(usd.dollars(4))).toBe(true); + }); + + // BYPASS (per-spend): one purchase over the per-spend ceiling is refused outright. + it("BYPASS: refuses a single spend over perSpend — code per-spend-exceeded", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "espresso" }] }); // 25 > 20 + expect(s.ok).toBe(false); + if (!s.ok) expect(s.code).toBe("per-spend-exceeded"); + }); + + // BYPASS (lifecycle): an unapproved grant must never sign a draw. Delete the status gate + // in spend() and this goes red (the draw would run and complete). + it("BYPASS: a PENDING grant refuses to spend — code not-authorized, no draw committed", async () => { + const ca = client(); + const { id } = await ca.grants.create({ merchant: "utopia", budget: usd.dollars(40), perSpend: usd.dollars(20), policy: [] }); + const g = await ca.grants.retrieve(id); + const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(s.ok).toBe(false); + if (!s.ok) { + expect(s.code).toBe("not-authorized"); + expect(s.remaining.eq(usd.dollars(40))).toBe(true); // untouched + } + }); + + // BYPASS (invariant 5 / #104): age is NEVER on autopilot — an age-restricted item steps up + // to a live human. Pins the shared completion-seam control from the grants surface. + it("BYPASS: an age-restricted item refuses on autopilot — step-up, needs-human", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "wine" }] }); + expect(s.ok).toBe(false); + if (!s.ok) { + expect(s.code).toBe("step-up"); + expect(s.retryable).toBe("needs-human"); + } + }); + + it("replays safely: the SAME idempotencyKey returns the original result once-charged", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + const first = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + const retry = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(first.ok && retry.ok).toBe(true); + if (retry.ok) { + expect(retry.replayed).toBe(true); + expect(retry.amount.eq(usd.dollars(18))).toBe(true); + expect(retry.remaining.eq(usd.dollars(22))).toBe(true); // ONE charge — not 40−36 + } + if (first.ok) expect(first.replayed).toBeUndefined(); + }); + + it("distinct keys are distinct draws (two spends draw down twice)", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + const second = await g.spend({ idempotencyKey: "p2", items: [{ sku: "coffee" }] }); + expect(second.ok).toBe(true); + if (second.ok) expect(second.remaining.eq(usd.dollars(4))).toBe(true); + }); + + it("scopes per grant: spending on A never draws down B (invariant 4)", async () => { + const ca = client(); + const a = await authorizedGrant(ca); + const b = await authorizedGrant(ca); + await a.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + const sb = await b.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); // same key — different grant namespace + expect(sb.ok).toBe(true); + if (sb.ok) expect(sb.remaining.eq(usd.dollars(22))).toBe(true); // B's own 40 − 18 + }); + + it("a completed spend fires order.settled with the spend's namespaced order id", async () => { + const ca = client(); + const seen: string[] = []; + ca.on("order.settled", ({ id }) => seen.push(id)); + const g = await authorizedGrant(ca); + await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(seen).toEqual([`${g.id}-p1`]); + }); + + it("spend without a configured catalog throws a clear config error (programming error, not a refusal)", async () => { + const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); // no catalog + const { id } = await ca.grants.create({ merchant: "utopia", budget: usd.dollars(40), perSpend: usd.dollars(20), policy: [] }); + await ca.grants._authorize(id); + const g = await ca.grants.retrieve(id); + await expect(g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] })).rejects.toThrow(/catalog/); + }); +}); + +describe("grant.revoke() — the kill switch", () => { + // BYPASS: revoke() writes two INDEPENDENT refusal paths — the stored status (what spend's + // own gate + retrieve()/UIs read) and the revocation ledger (what the draw engine checks, + // so even a process with a stale record refuses). Each alone still refuses the spend; + // delete BOTH and the spend assertion goes red, delete the status write and the + // status assertion goes red. + it("revoke() flips status AND the very next spend refuses — fail-closed", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + expect((await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] })).ok).toBe(true); + await g.revoke(); + expect((await ca.grants.retrieve(g.id)).status).toBe("revoked"); + const s = await g.spend({ idempotencyKey: "p2", items: [{ sku: "coffee" }] }); // STALE handle — still refused + expect(s.ok).toBe(false); + if (!s.ok) expect(s.code).toBe("revoked"); + }); + + it("a pending grant can be revoked; authorize afterwards is a no-op (never resurrect)", async () => { + const ca = client(); + const { id } = await ca.grants.create({ merchant: "utopia", budget: usd.dollars(40), perSpend: usd.dollars(20), policy: [] }); + await (await ca.grants.retrieve(id)).revoke(); + await ca.grants._authorize(id); // must NOT seal a revoked grant + const g = await ca.grants.retrieve(id); + expect(g.status).toBe("revoked"); + expect(g.intentMandate).toBeUndefined(); + }); +}); + +// ── Review-round hardening (PR #106 multi-agent review) ────────────────────── + +describe("grant.spend() — quantity validation (invariants 2/3)", () => { + // BYPASS: a fractional or negative quantity would price BELOW the catalog cost — a hidden + // discount that still binds. Delete the qty guard in spend() and these settle wrongly. + it("BYPASS: a fractional quantity is refused (never settles below catalog cost)", async () => { + const ca = client(); + const g = await authorizedGrant(ca, { budget: usd.dollars(100), perSpend: usd.dollars(50) }); + const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "espresso", qty: 0.1 }] }); // 25 * 0.1 = 2.5 + expect(s.ok).toBe(false); + if (!s.ok) expect(s.code).toBe("invalid-quantity"); + }); + + it("BYPASS: a negative-quantity line in a multi-item cart is refused (no net discount)", async () => { + const ca = client(); + const g = await authorizedGrant(ca, { budget: usd.dollars(100), perSpend: usd.dollars(50) }); + const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "espresso", qty: 1 }, { sku: "coffee", qty: -20 }] }); + expect(s.ok).toBe(false); + if (!s.ok) expect(s.code).toBe("invalid-quantity"); + }); + + it("a quantity > 1 multiplies the price and draws down by the full amount", async () => { + const ca = client(); + const g = await authorizedGrant(ca, { budget: usd.dollars(100), perSpend: usd.dollars(40) }); + const ok = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee", qty: 2 }] }); // 18 * 2 = 36 + expect(ok.ok).toBe(true); + if (ok.ok) { expect(ok.amount.eq(usd.dollars(36))).toBe(true); expect(ok.remaining.eq(usd.dollars(64))).toBe(true); } + const over = await g.spend({ idempotencyKey: "p2", items: [{ sku: "coffee", qty: 3 }] }); // 54 > 40 perSpend + expect(over.ok).toBe(false); + if (!over.ok) expect(over.code).toBe("per-spend-exceeded"); + }); +}); + +describe("grant.spend() — exact-boundary money (integer cents, no float drift)", () => { + it("spends exactly to the per-spend and budget boundary without a false refusal", async () => { + // $4.90 × 3 = $14.70 — a classic binary-float trap (14.700000000000001 > 14.7). + const ca = new CredentAgent({ walletOrigin: "https://shop.example", catalog: { latte: 4.9 } }); + const { id } = await ca.grants.create({ merchant: "utopia", budget: usd.dollars(14.7), perSpend: usd.dollars(4.9), policy: [] }); + await ca.grants._authorize(id); + const g = await ca.grants.retrieve(id); + for (const k of ["a", "b", "c"]) { + const s = await g.spend({ idempotencyKey: k, items: [{ sku: "latte" }] }); + expect(s.ok).toBe(true); // each $4.90 == perSpend, cumulative reaches == budget + } + expect((await g.spend({ idempotencyKey: "d", items: [{ sku: "latte" }] })).ok).toBe(false); // now over budget + }); +}); + +describe("grant.spend() — idempotency correctness", () => { + it("throws on a missing/empty idempotencyKey and on empty items (programming errors)", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + await expect(g.spend({ idempotencyKey: "", items: [{ sku: "coffee" }] })).rejects.toThrow(/idempotencyKey/); + await expect(g.spend({ idempotencyKey: "p1", items: [] })).rejects.toThrow(/items/); + }); + + it("the SAME key with DIFFERENT items is a conflict, not a silent replay of the old spend", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + const first = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(first.ok).toBe(true); + const conflict = await g.spend({ idempotencyKey: "p1", items: [{ sku: "espresso" }] }); // different item, same key + expect(conflict.ok).toBe(false); + if (!conflict.ok) expect(conflict.code).toBe("idempotency-conflict"); + }); + + it("replays a settled spend even AFTER revoke — revocation stops new draws, not history", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + const first = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(first.ok).toBe(true); + await g.revoke(); + const retry = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); // worker retrying its queue + expect(retry.ok).toBe(true); + if (retry.ok) expect(retry.replayed).toBe(true); // the charge already happened — not "revoked" + }); +}); + +describe("grant lifecycle — concurrency (TOCTOU) is fail-closed", () => { + it("revoke racing authorize never leaves a spendable grant", async () => { + const ca = client(); + const { id } = await ca.grants.create({ merchant: "utopia", budget: usd.dollars(40), perSpend: usd.dollars(20), policy: [] }); + const g = await ca.grants.retrieve(id); + await Promise.all([g.revoke(), ca.grants._authorize(id)]); // interleave the kill switch and the seal + expect((await ca.grants.retrieve(id)).status).toBe("revoked"); + const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(s.ok).toBe(false); // never authorized-and-spendable + }); + + it("a concurrent double-approve seals exactly one intent (no rebind to an empty ledger)", async () => { + const ca = client(); + const { id } = await ca.grants.create({ merchant: "utopia", budget: usd.dollars(40), perSpend: usd.dollars(20), policy: [] }); + await Promise.all([ca.grants._authorize(id), ca.grants._authorize(id)]); + const g = await ca.grants.retrieve(id); + expect(g.status).toBe("authorized"); + const intentId = g.intentMandate!.intentId; + // Spend to the budget edge; a rebind to a fresh (empty-ledger) intent would allow 2× the budget. + await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); // 18 + const s2 = await g.spend({ idempotencyKey: "p2", items: [{ sku: "coffee" }] }); // 36 total ≤ 40 ok + const s3 = await g.spend({ idempotencyKey: "p3", items: [{ sku: "coffee" }] }); // 54 > 40 → must refuse + expect(s2.ok).toBe(true); + expect(s3.ok).toBe(false); + expect((await ca.grants.retrieve(id)).intentMandate!.intentId).toBe(intentId); // one stable seal + }); + + // MUTATION: concurrent spends with the SAME key must collapse to one charge with a clean + // replay for the loser — NOT one "consumed"/terminal refusal. Only the per-grant KeyedMutex + // guarantees this (it serializes the replay pre-read against the draw commit); neuter + // KeyedMutex.run to call fn() directly and this goes red (the loser gets "consumed"). + it("concurrent spends with the SAME key collapse to one charge, both ok (one replayed)", async () => { + const ca = client(); + const g = await authorizedGrant(ca); + const [a, b] = await Promise.all([ + g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }), + g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }), + ]); + expect(a.ok && b.ok).toBe(true); // neither is a spurious refusal + expect([a, b].filter((r) => r.ok && r.replayed).length).toBe(1); // exactly one is the replay + const after = await ca.grants.retrieve(g.id); + const s = await after.spend({ idempotencyKey: "p2", items: [{ sku: "coffee" }] }); + if (s.ok) expect(s.remaining.eq(usd.dollars(4))).toBe(true); // 40 − 18 (ONE charge) − 18 + }); + + it("concurrent spends with distinct keys never exceed the budget (atomic draw commit)", async () => { + const ca = client(); + const g = await authorizedGrant(ca, { budget: usd.dollars(20), perSpend: usd.dollars(20) }); // room for ONE $18 + const [a, b] = await Promise.all([ + g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }), + g.spend({ idempotencyKey: "p2", items: [{ sku: "coffee" }] }), + ]); + expect([a.ok, b.ok].filter(Boolean).length).toBe(1); // exactly one settles; the other is budget-exceeded + }); +}); + +describe("grant.revoke() — the ledger write is independently pinned", () => { + // MUTATION: the revocation-ledger write is the kill switch's SECOND authority — the one that + // stops a stale-snapshot worker (a different process still holding an authorized record). It + // must be pinned independently of the status write: a store that serves the pre-revoke + // snapshot forces the spend through the engine's ledger check. Delete the ledger revoke in + // grant.revoke() and this goes red (the stale spend completes). + it("a stale-snapshot spend after revoke is still refused via the ledger", async () => { + const backing = new Map(); + let frozen: GrantRecord | undefined; + const store: OrderStore = { + read: async (id) => frozen ?? backing.get(id), + write: async (id, v) => { backing.set(id, v); }, + clear: async (id) => { backing.delete(id); }, + }; + const revocationStore = new MemoryRevocationStore(); + const ca = new CredentAgent({ walletOrigin: "https://shop.example", catalog: { coffee: 18 }, grantStore: store, revocationStore }); + const { id } = await ca.grants.create({ merchant: "utopia", budget: usd.dollars(40), perSpend: usd.dollars(20), policy: [] }); + await ca.grants._authorize(id); + frozen = await store.read(id); // pin the AUTHORIZED snapshot — reads now ignore later writes + expect(frozen?.status).toBe("authorized"); + + await (await ca.grants.retrieve(id)).revoke(); // writes the ledger (and a status the store ignores) + const s = await (await ca.grants.retrieve(id)).spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(s.ok).toBe(false); // the engine's ledger check refuses even though the snapshot says authorized + if (!s.ok) expect(s.code).toBe("revoked"); + }); +}); + +describe("grant.spend() — denied is terminal", () => { + it("spending against a declined grant refuses as denied (not needs-human)", async () => { + const ca = client(); + const { id } = await ca.grants.create({ merchant: "utopia", budget: usd.dollars(40), perSpend: usd.dollars(20), policy: [] }); + await ca.grants._decline(id); + const s = await (await ca.grants.retrieve(id)).spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(s.ok).toBe(false); + if (!s.ok) { expect(s.code).toBe("denied"); expect(s.retryable).toBe("terminal"); } + }); +}); + +describe("grants.create() — configure-time validation", () => { + it("rejects a non-positive budget/perSpend (a programming error, thrown not refused)", async () => { + const ca = client(); + await expect(ca.grants.create({ merchant: "m", budget: usd.dollars(0), perSpend: usd.dollars(10), policy: [] })).rejects.toThrow(/positive/); + await expect(ca.grants.create({ merchant: "m", budget: usd.dollars(100), perSpend: usd.cents(-5), policy: [] })).rejects.toThrow(/positive/); + }); +}); + +describe("Grant — the not-found handle never throws", () => { + it("terms and approveUrl are undefined (not a throw / dead link) for an unknown id", async () => { + const ca = client(); + const g = await ca.grants.retrieve("gr_nope"); + expect(g.status).toBe("not-found"); + expect(g.terms).toBeUndefined(); + expect(g.approveUrl).toBeUndefined(); + const s = await g.spend({ idempotencyKey: "p1", items: [{ sku: "coffee" }] }); + expect(s.ok).toBe(false); + if (!s.ok) expect(s.code).toBe("not-found"); + }); +}); diff --git a/packages/credentagent-gate/src/grants.ts b/packages/credentagent-gate/src/grants.ts new file mode 100644 index 0000000..a02796d --- /dev/null +++ b/packages/credentagent-gate/src/grants.ts @@ -0,0 +1,477 @@ +// credentagent.grants — durable spend authority (spec 009, the human-not-present half; #104). +// +// const grant = await credentagent.grants.create({ merchant, budget: usd.dollars(100), perSpend: usd.dollars(30), policy }); +// sendToUser(grant.approveUrl); // the human approves the LIMIT once +// // later, from a worker — human away: +// const grant = await credentagent.grants.retrieve(id); // status: pending|authorized|denied|revoked|not-found +// const s = await grant.spend({ idempotencyKey, items: [{ sku: "coffee", qty: 1 }] }); +// +// A grant is the durable authority handle (status, remaining, spend, revoke); the AP2 +// Intent Mandate is the sealed artifact it CARRIES (`grant.intentMandate`), produced at the +// authorize ceremony — two layers, two names (maintainer decision, 2026-07-23). +// +// Honesty (constitution VII): the delegate key is minted SERVER-side at authorize, so grants +// carry trust_level "server-issued-demo" — no real value settles, and the wallet-custody +// increment swaps the internals without changing this surface. Age and custom gate() +// credentials are NON-delegable: the shared completion seam steps them up to a live human. +// +// Amounts are integer minor units (cents) end-to-end internally — the catalog is priced in +// cents, the sealed bounds and every draw amount are integer cents, so the engine's numeric +// caps compare exact integers (no binary-float drift on an exact-budget boundary). The public +// surface is `Money`; the caller never sees a raw scalar. + +import { webcrypto } from "node:crypto"; +import type { Credential, GateOrder, Step, VerificationManifestEntry } from "./types.js"; +import type { OrderStore, CompletedOrder } from "./orders.js"; +import type { CeremonyCatalog, CeremonyOrder } from "./ceremony/types.js"; +import type { RevocationStore } from "./ceremony/revocation.js"; +import { sealIntent, signDraw, type IntentBounds, type DelegateJwk } from "./ceremony/mandate.js"; +import { completeOrder, type CompletedRecord } from "./ceremony/completion.js"; +import { MemoryVerificationStore } from "./store.js"; +import { usd, type Money } from "./money.js"; + +const { subtle } = webcrypto; + +/** The delegate PRIVATE key as a JWK — server-held custody (trust_level "server-issued-demo"). + * Unlike `DelegatedGate` (in-process, non-extractable key), a grant must rehydrate in a + * DIFFERENT process (`grants.retrieve` in a worker — spec 009 FR-007), so the key rides in + * the grant store. The wallet-custody increment moves it to the user's wallet. */ +export interface DelegatePrivateJwk extends DelegateJwk { + d: string; +} + +/** A priced catalog entry: a bare price (dollars), or a price plus an age restriction. */ +export type CatalogEntry = number | { price: number; minAge?: number }; + +/** The grant trust rung — server-minted key, dev-sealed mandate, no real settlement. Kept out + * of the shared `TrustLevel` union (that names the OpenID4VP presence rails); grants are a + * distinct, weaker honesty claim, spelled out so a typo can't pass as a stronger level. */ +export type GrantTrustLevel = "server-issued-demo"; + +/** The whole spend-code vocabulary the door can return (a closed set the caller can switch on). */ +export type SpendCode = + | "budget-exceeded" // cumulative budget would be exceeded (engine over-total) + | "per-spend-exceeded" // one purchase over the per-spend cap (engine over-cap) + | "not-authorized" // grant is pending — the human has not approved yet + | "denied" // the human declined the grant — terminal + | "revoked" // the grant was revoked — terminal + | "step-up" // age / custom-gated item — a live human must approve + | "invalid-quantity" // a per-item quantity that isn't a positive integer + | "idempotency-conflict" // same idempotencyKey, different items (Stripe's rule) + | "not-found" // no such grant + | (string & {}); // pass-through engine refusal codes (forward-compatible) + +const centsOf = (m: Money): number => m.serialize().amount; + +/** What the caller passes to `grants.create()`. Amounts are Money — never raw scalars. */ +export interface CreateGrantOptions { + /** The one merchant this grant may spend at. */ + merchant: string; + /** Cumulative lifetime cap across every spend (does not reset). */ + budget: Money; + /** Per-spend ceiling (an absolute cap). */ + perSpend: Money; + /** Credential policy the human must satisfy at the authorize ceremony. `[]` ⇒ ungated. */ + policy: Step[]; + /** A human sentence describing the grant (shown on the approve page). */ + description?: string; +} + +export type GrantStatus = "pending" | "authorized" | "denied" | "revoked" | "not-found"; + +/** The stored record — the server-side authority for terms + lifecycle (invariant 2/4). + * Amounts are integer cents. */ +export interface GrantRecord { + id: string; + merchant: string; + budgetCents: number; + perSpendCents: number; + currency: "USD"; + description?: string; + policy: Step[]; + status: Exclude; + /** Sealed at authorize (dev-sealed, trust_level server-issued-demo). */ + intent?: IntentBounds; + /** The delegate PRIVATE key, server-held (server-issued-demo; wallet-custody swaps this). */ + delegateJwk?: DelegatePrivateJwk; +} + +export interface SpendItem { + sku: string; + qty?: number; +} + +/** The signed records a completed spend carries. Stored in the completed record's + * `mandateBundle` so a replay echoes it and can detect a parameter conflict. */ +interface SpendBundle { + intentMandate: IntentBounds; + draw: unknown; + /** A fingerprint of the spent items — replaying the SAME key with DIFFERENT items conflicts. */ + fingerprint: string; +} + +/** One result door for every spend (spec 009 FR-003). */ +export type SpendDoor = + | { + ok: true; + /** The catalog-priced amount of THIS spend (never trusted from the caller). */ + amount: Money; + /** Headroom left on the budget AFTER this spend. */ + remaining: Money; + /** True when this call safely replayed an already-completed spend (same idempotencyKey). */ + replayed?: true; + authorization: "delegated"; + trustLevel: GrantTrustLevel; + mandateBundle: { intentMandate: IntentBounds; draw: unknown }; + } + | { + ok: false; + code: SpendCode; + remaining: Money; + retryable?: "retry" | "needs-human" | "terminal"; + trustLevel: GrantTrustLevel; + }; + +export interface GrantsDeps { + walletOrigin: string; + store: OrderStore; + revocation: RevocationStore; + /** The client's priced catalog map (spec FR-001) — required for `spend()`, not create/retrieve. */ + catalog?: Record; + requirements: (order: GateOrder, policy: Step[]) => VerificationManifestEntry[]; + /** Route a completed spend through `orders._complete` — settled event + webhooks for free. */ + completeSpend: (record: CompletedOrder) => Promise; + /** Read a spend's completion (replay detection) — the orders completed store. */ + readSpend: (orderId: string) => Promise | CompletedOrder | undefined; + /** The client's credential registry, so the spend path enforces custom gate()s (007/inv. 1). */ + credentialRegistry?: ReadonlyMap; + /** Wire the approve page + rails onto an Express app — `grants.serve(app)` (set in client). */ + serve?: (app: unknown) => void; +} + +const genId = (): string => `gr_${globalThis.crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; +const TRUST: GrantTrustLevel = "server-issued-demo"; +const priceOf = (e: CatalogEntry): number => (typeof e === "number" ? e : e.price); +const minAgeOf = (e: CatalogEntry): number | undefined => (typeof e === "number" ? undefined : e.minAge); +const fingerprintOf = (items: SpendItem[]): string => + JSON.stringify(items.map((i) => [i.sku, i.qty ?? 1]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))); + +/** Build a re-pricing catalog seam in INTEGER CENTS from the plain dollar-priced map. An unknown + * item is a programming error (throws, fail fast), NOT a gate decision. */ +function centsCatalog(items: Record): CeremonyCatalog { + return { + createOrder(refs, orderId): CeremonyOrder { + const lines = refs.map(({ productId, quantity }) => { + const entry = items[productId]; + if (entry === undefined) { + throw new Error(`[credentagent] unknown catalog item "${productId}". Known items: ${Object.keys(items).join(", ")}.`); + } + const unitPrice = Math.round(priceOf(entry) * 100); // cents — exact integers downstream + const minimumAge = minAgeOf(entry); + return { id: productId, unitPrice, quantity, lineTotal: unitPrice * quantity, currency: "USD", ...(minimumAge ? { minimumAge } : {}) }; + }); + const total = lines.reduce((sum, l) => sum + l.lineTotal, 0); + return { id: orderId, lines, itemCount: refs.length, subtotal: total, discount: 0, total, currency: "USD" }; + }, + }; +} + +/** A tiny per-key async mutex: serializes lifecycle + spend transitions PER GRANT in-process, + * so a create/authorize/decline/revoke/spend can't interleave with another on the same grant + * (the read-check-write TOCTOU class — REVIEW.md §1/§2). Cross-process serialization is out of + * scope for this in-process increment; budget safety there still rides on the engine's atomic + * single-use consume, and lifecycle CAS is a wallet-custody-increment concern. */ +class KeyedMutex { + private readonly tails = new Map>(); + run(key: string, fn: () => Promise): Promise { + const prev = this.tails.get(key) ?? Promise.resolve(); + const next = prev.then(fn, fn); + // Keep the chain going but don't leak rejections into the next waiter's scheduling. + this.tails.set(key, next.then(() => undefined, () => undefined)); + return next; + } +} + +export class Grants { + private readonly locks = new KeyedMutex(); + + constructor(private readonly deps: GrantsDeps) {} + + /** Open a grant awaiting the human's one-time approval. Persisted BEFORE the URL is handed out. */ + async create(opts: CreateGrantOptions): Promise { + // Configure-time programming errors throw (matching Money's own constructor), not refuse: + for (const [name, m] of [["budget", opts.budget], ["perSpend", opts.perSpend]] as const) { + if (!m || typeof (m as Money).serialize !== "function") throw new Error(`[credentagent] grants.create: ${name} must be a Money (e.g. usd.dollars(100)).`); + const { amount, currency } = m.serialize(); + if (currency !== "usd") throw new Error(`[credentagent] grants.create: ${name} must be USD (got ${currency}).`); + if (!(amount > 0)) throw new Error(`[credentagent] grants.create: ${name} must be a positive amount (got ${amount} cents).`); + } + const id = genId(); + const record: GrantRecord = { + id, + merchant: opts.merchant, + budgetCents: centsOf(opts.budget), + perSpendCents: centsOf(opts.perSpend), + currency: "USD", + ...(opts.description ? { description: opts.description } : {}), + policy: opts.policy, + status: "pending", + }; + await this.deps.store.write(id, record); + return new Grant(record, this.deps, this.locks); + } + + /** Rehydrate a grant by id. Unknown ids answer with a typed `not-found` handle — no throw. */ + async retrieve(id: string): Promise { + const record = await this.deps.store.read(id); + if (!record) return Grant.notFound(id, this.deps, this.locks); + return new Grant(record, this.deps, this.locks); + } + + /** Wire the approve page (each grant's `approveUrl`) + ceremony rails onto your app. */ + serve(app: unknown): void { + if (!this.deps.serve) throw new Error("[credentagent] grants.serve(app) is not wired on this client."); + this.deps.serve(app); + } + + /** Called by the approve ceremony when the human approves — seals the AP2 Intent Mandate + * (dev-sealed, trust_level "server-issued-demo"), mints the delegate key, flips status to + * "authorized". Serialized per grant by the KeyedMutex, so the read-check-write is atomic + * w.r.t. every other transition on this grant — a concurrent revoke/decline or a double-POST + * can't interleave, and only a STILL-pending grant seals (never resurrecting a stopped one). */ + async _authorize(id: string): Promise { + await this.locks.run(id, async () => { + const record = await this.deps.store.read(id); + if (!record || record.status !== "pending") return; + const pair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const pub = await subtle.exportKey("jwk", pair.publicKey); + const priv = (await subtle.exportKey("jwk", pair.privateKey)) as DelegatePrivateJwk; + const delegate: DelegateJwk = { kty: "EC", crv: "P-256", x: pub.x!, y: pub.y! }; + const intent = await sealIntent({ + type: "credentagent.IntentBounds/v0", + ...(record.description ? { naturalLanguageDescription: record.description } : {}), + merchants: [record.merchant], + currency: record.currency, + maxAmount: record.perSpendCents, + totalAmount: record.budgetCents, + delegate, + presence: "delegated-demo", + trust_level: "server-issued-demo", + }); + await this.deps.store.write(id, { ...record, status: "authorized", intent, delegateJwk: priv }); + }); + } + + /** Called by the approve page's decline — pending → denied; every other state is a no-op + * (fail-closed: an authorized grant is stopped by `revoke()`, never silently re-labelled). */ + async _decline(id: string): Promise { + await this.locks.run(id, async () => { + const record = await this.deps.store.read(id); + if (!record || record.status !== "pending") return; + await this.deps.store.write(id, { ...record, status: "denied" }); + }); + } +} + +/** + * A grant handle. Status is a retrieve-time snapshot for display; `spend()`/`revoke()` + * ALWAYS re-read the stored record under the per-grant lock, so a stale handle can never + * bypass a revocation or double-spend. + */ +export class Grant { + private snapshotStatus: GrantStatus; + + constructor( + private readonly record: GrantRecord | undefined, + private readonly deps: GrantsDeps, + private readonly locks: KeyedMutex, + private readonly missingId?: string, + ) { + this.snapshotStatus = record ? record.status : "not-found"; + } + + static notFound(id: string, deps: GrantsDeps, locks: KeyedMutex): Grant { + return new Grant(undefined, deps, locks, id); + } + + get id(): string { + return this.record?.id ?? this.missingId!; + } + + get status(): GrantStatus { + return this.snapshotStatus; + } + + /** The approve link — `undefined` for a not-found handle (there is nothing to approve). */ + get approveUrl(): string | undefined { + if (!this.record) return undefined; + return `${this.deps.walletOrigin}/credentagent/grants/${this.id}`; + } + + /** The terms as Money — `undefined` for a not-found handle (never throws). */ + get terms(): { merchant: string; budget: Money; perSpend: Money; description?: string } | undefined { + const r = this.record; + if (!r) return undefined; + return { + merchant: r.merchant, + budget: usd.cents(r.budgetCents), + perSpend: usd.cents(r.perSpendCents), + ...(r.description ? { description: r.description } : {}), + }; + } + + /** The AP2 Intent Mandate this grant carries — sealed at authorize, absent while pending. */ + get intentMandate(): IntentBounds | undefined { + return this.record?.intent; + } + + get trustLevel(): GrantTrustLevel { + return TRUST; + } + + /** + * Revoke the grant — the kill switch. Writes BOTH authorities: the revocation store + * (what the draw engine checks — a stale handle can never spend past it) AND the stored + * status (what retrieve()/UIs read). Re-reads after the status write and revokes any intent + * that appeared (covers a revoke racing an authorize). A revoked grant is never resurrected. + */ + async revoke(): Promise { + await this.locks.run(this.id, async () => { + const record = await this.deps.store.read(this.id); + if (!record) return; + if (record.intent) await this.deps.revocation.revoke(record.intent.intentId); + await this.deps.store.write(this.id, { ...record, status: "revoked" }); + // A concurrent authorize may have sealed an intent between our read and write; revoke it too. + const after = await this.deps.store.read(this.id); + if (after?.intent && after.intent.intentId !== record.intent?.intentId) { + await this.deps.revocation.revoke(after.intent.intentId); + } + this.snapshotStatus = "revoked"; + }); + } + + /** Budget headroom in cents from the committed-draw ledger (the revocation store is the authority). */ + private async committedCents(intent: IntentBounds): Promise { + const committed = await this.deps.revocation.priorDraws(intent.intentId); + return committed.reduce((sum, d) => sum + d.amount, 0); + } + + /** + * Spend against the grant while the human is away. Refusals are DATA (`{ ok:false, code }`), + * never throws; a throw is a programming error (bad idempotencyKey, empty items, unknown sku, + * missing catalog config). Serialized per grant, so the replay pre-read and the atomic draw + * commit can't interleave with another spend on the same grant. + */ + async spend({ idempotencyKey, items }: { idempotencyKey: string; items: SpendItem[] }): Promise { + // Programming-error guards (throw — a caller can't recover these by branching on a code): + if (typeof idempotencyKey !== "string" || idempotencyKey.trim() === "") { + throw new Error("[credentagent] grant.spend: idempotencyKey must be a non-empty string (a stable per-purchase key; reuse it to retry safely)."); + } + if (!Array.isArray(items) || items.length === 0) { + throw new Error("[credentagent] grant.spend: items must be a non-empty array of { sku, qty? }."); + } + + return this.locks.run(this.id, async () => { + const record = await this.deps.store.read(this.id); + if (!record) return refuse("not-found", usd.cents(0), "terminal"); + + const orderId = `${this.id}-${idempotencyKey}`; + + // Replay BEFORE the lifecycle gates: a settled spend is history — revocation stops NEW + // draws, it does not rewrite a charge that already completed (#104 decision 1). A replay + // with DIFFERENT items for the same key is a conflict (Stripe's idempotency rule). + const prior = await this.deps.readSpend(orderId); + if (prior) { + const bundle = prior.mandateBundle as SpendBundle | undefined; + if (bundle?.fingerprint && bundle.fingerprint !== fingerprintOf(items)) { + return refuse("idempotency-conflict", await this.remaining(record), "terminal"); + } + return { + ok: true as const, + amount: usd.dollars(prior.amount ?? 0), // the completed store holds dollars (see records.write) + remaining: await this.remaining(record), + replayed: true as const, + authorization: "delegated" as const, + trustLevel: TRUST, + mandateBundle: bundle ? { intentMandate: bundle.intentMandate, draw: bundle.draw } : { intentMandate: record.intent!, draw: undefined }, + }; + } + + // Lifecycle gates (each a distinct, actionable code): + const rem = await this.remaining(record); + if (record.status === "revoked") return refuse("revoked", rem, "terminal"); + if (record.status === "denied") return refuse("denied", rem, "terminal"); + if (record.status !== "authorized" || !record.intent || !record.delegateJwk) return refuse("not-authorized", rem, "needs-human"); + + if (!this.deps.catalog) { + throw new Error( + "[credentagent] grants: no catalog configured. Pass `new CredentAgent({ catalog: { sku: price, ... } })` — spends are re-priced server-side from it (a caller never passes an amount).", + ); + } + + // Every quantity must be a positive integer — otherwise a fractional or negative qty + // would price BELOW the catalog cost (a hidden discount) and still bind (invariant 2/3). + for (const it of items) { + const q = it.qty ?? 1; + if (!Number.isInteger(q) || q <= 0) return refuse("invalid-quantity", rem, "terminal"); + } + + const catalog = centsCatalog(this.deps.catalog); + const order = catalog.createOrder(items.map((i) => ({ productId: i.sku, quantity: i.qty ?? 1 })), orderId); + const key = await subtle.importKey("jwk", record.delegateJwk as webcrypto.JsonWebKey, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]); + const draw = await signDraw( + { + type: "credentagent.Draw/v0", + intentId: record.intent.intentId, + paymentMandateId: idempotencyKey, + merchant: record.merchant, + amount: order.total, + currency: record.currency, + pspTransactionId: idempotencyKey, + }, + key, + ); + const bundle: SpendBundle = { intentMandate: record.intent, draw, fingerprint: fingerprintOf(items) }; + const records = { + read: async (oid: string): Promise => { + const done = await this.deps.readSpend(oid); + if (!done) return undefined; + return { orderId: oid, mandateId: done.txId ?? "", amount: done.amount ?? 0, currency: done.currency ?? "", method: done.method ?? "", gates: [], completedAt: done.completedAt ?? "" }; + }, + write: async (r: CompletedRecord): Promise => { + // Store the settled amount in DOLLARS in the completed-order store, so a webhook / + // order.settled consumer sees the same unit for a grant spend as for a human-present + // order (the record is shared). Internal draw math stays in cents; r.amount is cents. + await this.deps.completeSpend({ orderId: r.orderId, amount: r.amount / 100, currency: r.currency, method: r.method, completedAt: r.completedAt, mandateBundle: bundle }); + }, + }; + const res = await completeOrder( + { order, mandateId: idempotencyKey, amount: order.total, currency: record.currency, method: "delegated", gates: [], draw: { intent: record.intent, draw } }, + { + catalog, + revocation: this.deps.revocation, + verificationStore: new MemoryVerificationStore(), + records, + ...(this.deps.credentialRegistry ? { credentialRegistry: this.deps.credentialRegistry } : {}), + }, + ); + const after = await this.remaining(record); + if (res.completed) { + return { ok: true as const, amount: usd.cents(order.total), remaining: after, authorization: "delegated" as const, trustLevel: TRUST, mandateBundle: { intentMandate: record.intent, draw } }; + } + const refusal = res.refusals?.[0]; + const code: SpendCode = refusal?.code === "over-total" ? "budget-exceeded" : refusal?.code === "over-cap" ? "per-spend-exceeded" : (refusal?.code ?? "refused"); + return { ok: false as const, code, remaining: after, ...(refusal?.retryable ? { retryable: refusal.retryable } : {}), trustLevel: TRUST }; + }); + } + + /** Budget headroom as Money. */ + private async remaining(record: GrantRecord): Promise { + if (!record.intent) return usd.cents(record.budgetCents); + return usd.cents(record.budgetCents - (await this.committedCents(record.intent))); + } +} + +function refuse(code: SpendCode, remaining: Money, retryable: "retry" | "needs-human" | "terminal"): SpendDoor { + return { ok: false, code, remaining, retryable, trustLevel: TRUST }; +} diff --git a/packages/credentagent-gate/src/index.ts b/packages/credentagent-gate/src/index.ts index 8593617..415dec6 100644 --- a/packages/credentagent-gate/src/index.ts +++ b/packages/credentagent-gate/src/index.ts @@ -22,12 +22,28 @@ export { age, membership, payment, required, optional, defineCredential, dcql, g // ── Store ──────────────────────────────────────────────────────────────── export { MemoryVerificationStore } from "./store.js"; +// ── Money (spec 009 FR-005) — the grants surface's amount type ────────────── +// Opaque + currency-checked: build with `usd.dollars(20)`, compare with .lt/.gte — +// never a raw scalar. Wired consumer: `credentagent.grants` (budget / perSpend / remaining). +export { usd } from "./money.js"; +export type { Money } from "./money.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"; +// ── The grants resource (spec 009, #104) — approve once, spend while away ─── +// `await credentagent.grants.create({ merchant, budget: usd.dollars(100), perSpend, policy })` +// → a pending grant + `approveUrl` (the ONE human step); `grants.retrieve(id)` rehydrates in a +// worker; `grant.spend({ idempotencyKey, items })` → the door (ok+remaining | budget-exceeded | +// per-spend-exceeded | revoked | step-up …); `grant.revoke()` is the kill switch. The grant is +// the durable authority; the AP2 Intent Mandate is the sealed artifact it carries +// (`grant.intentMandate`). Honesty: trust_level "server-issued-demo" — no real value moves. +export { Grants, Grant } from "./grants.js"; +export type { CreateGrantOptions, GrantRecord, GrantStatus, SpendDoor, SpendItem, DelegatePrivateJwk } from "./grants.js"; + // ── Webhooks (spec 010) — the REAL HTTP completion signal ─────────────────── // SEND: `new CredentAgent({ webhooks: { endpoints: [{ url, secret }] } })` → every settled order // POSTs a signed `order.settled` event. RECEIVE (a different service, secret only): diff --git a/packages/credentagent-gate/src/money.test.ts b/packages/credentagent-gate/src/money.test.ts new file mode 100644 index 0000000..3331863 --- /dev/null +++ b/packages/credentagent-gate/src/money.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { usd } from "./money.js"; + +describe("Money (usd)", () => { + it("compares, serializes, and rejects cross-currency + non-integer amounts", () => { + expect(usd.dollars(20).lt(usd.dollars(30))).toBe(true); + expect(usd.dollars(20).eq(usd.cents(2000))).toBe(true); + expect(usd.dollars(30).gte(usd.dollars(30))).toBe(true); + expect(usd.dollars(20).serialize()).toEqual({ amount: 2000, currency: "usd" }); + expect(usd.dollars(50).minus(usd.dollars(20)).serialize().amount).toBe(3000); + expect(() => usd.cents(20.5)).toThrow(); // non-integer minor units + }); + + it("has no float drift on fractional dollars, and rejects sub-cent inputs", () => { + expect(usd.dollars(21.99).serialize()).toEqual({ amount: 2199, currency: "usd" }); + expect(usd.dollars(0.1).plus(usd.dollars(0.2)).eq(usd.cents(30))).toBe(true); // 0.1+0.2 ≠ 0.3 in floats — Money is exact + expect(usd.dollars(0).serialize().amount).toBe(0); + expect(() => usd.dollars(1.005)).toThrow(/sub-cent/); // would silently truncate value otherwise + expect(() => usd.dollars(0.001)).toThrow(/sub-cent/); + }); + + it("prints a human amount", () => { + expect(usd.dollars(21.99).toString()).toBe("USD 21.99"); + }); +}); diff --git a/packages/credentagent-gate/src/money.ts b/packages/credentagent-gate/src/money.ts new file mode 100644 index 0000000..bc3b936 --- /dev/null +++ b/packages/credentagent-gate/src/money.ts @@ -0,0 +1,51 @@ +// 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)` — the unit is ALWAYS explicit (there is no bare `usd(n)`, which would be +// ambiguous between dollars and cents); 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: , 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({ + 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)}`, + }); +} + +/** Build US-dollar Money. The unit is always explicit: `usd.dollars(20)` → $20.00 (2000 + * cents), `usd.cents(2000)` → the same. `.dollars` rounds a representable fractional dollar + * (19.99 → 1999) but REJECTS a genuine sub-cent input (1.005, 0.001) rather than silently + * truncating value — a sibling of `.cents`'s integer check. */ +export const usd = { + dollars: (d: number): Money => { + const cents = d * 100; + if (Math.abs(cents - Math.round(cents)) > 1e-6) { + throw new Error(`usd.dollars(${d}) is a sub-cent amount; the smallest unit is one cent (use usd.cents for exact minor units)`); + } + return money(Math.round(cents), "usd"); + }, + cents: (c: number): Money => money(c, "usd"), +}; diff --git a/packages/credentagent-gate/src/orders-serve.ts b/packages/credentagent-gate/src/orders-serve.ts index 6bec6eb..3274de0 100644 --- a/packages/credentagent-gate/src/orders-serve.ts +++ b/packages/credentagent-gate/src/orders-serve.ts @@ -128,8 +128,9 @@ function toRenderOrder(o: CeremonyOrder): RenderOrder { /** 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 { + * place path is refused for them (invariant 1 — enforced server-side, not by hiding a button). + * Shared with grants-serve: a policy-gated GRANT is likewise never approvable by a button. */ +export function isGated(manifest: VerificationManifestEntry[]): boolean { return manifest.some((e) => e.effect === "gate" || e.effect === "authorize"); } diff --git a/packages/credentagent-gate/src/types.ts b/packages/credentagent-gate/src/types.ts index 2348c76..9dfa292 100644 --- a/packages/credentagent-gate/src/types.ts +++ b/packages/credentagent-gate/src/types.ts @@ -241,6 +241,16 @@ export interface CredentAgentOptions { * `process.env.GATE_SECRET`) for any multi-instance deploy. */ gateSecret?: string; + /** + * Your priced catalog: item id → price in dollars, or → `{ price, minAge }` (spec 009 + * FR-001). Required for `grants` spends — the gate re-prices every spend from it + * (invariant 2); a caller never passes an amount. + */ + catalog?: Record; + /** Persist grants (`grants.create`); default in-memory, inject a shared store for multi-instance. */ + grantStore?: OrderStore; + /** Revocation + committed-draw ledger for grants; default in-memory, injectable (shared deploys). */ + revocationStore?: import("./ceremony/revocation.js").RevocationStore; /** * Outbound HTTP webhooks (spec 010). Register endpoint URL(s) + their `whsec_` secret and every * settled order is POSTed to them as a signed event — the durable, cross-service signal the