@openmobilehub/credentagent-gate 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +176 -0
  3. package/dist/ceremony/cartMandate.d.ts +68 -0
  4. package/dist/ceremony/cartMandate.js +92 -0
  5. package/dist/ceremony/challengeToken.d.ts +5 -0
  6. package/dist/ceremony/challengeToken.js +43 -0
  7. package/dist/ceremony/checkout-page.d.ts +85 -0
  8. package/dist/ceremony/checkout-page.js +272 -0
  9. package/dist/ceremony/completion.d.ts +41 -0
  10. package/dist/ceremony/completion.js +90 -0
  11. package/dist/ceremony/credential-gate/dcql.d.ts +10 -0
  12. package/dist/ceremony/credential-gate/dcql.js +12 -0
  13. package/dist/ceremony/credential-gate/doc-spec.d.ts +3 -0
  14. package/dist/ceremony/credential-gate/doc-spec.js +16 -0
  15. package/dist/ceremony/credential-gate/mdoc-verify.d.ts +15 -0
  16. package/dist/ceremony/credential-gate/mdoc-verify.js +29 -0
  17. package/dist/ceremony/credential-gate/page.d.ts +23 -0
  18. package/dist/ceremony/credential-gate/page.js +139 -0
  19. package/dist/ceremony/credential-gate/request.d.ts +15 -0
  20. package/dist/ceremony/credential-gate/request.js +43 -0
  21. package/dist/ceremony/credential-gate/routes.d.ts +2 -0
  22. package/dist/ceremony/credential-gate/routes.js +202 -0
  23. package/dist/ceremony/credential-gate/verify.d.ts +51 -0
  24. package/dist/ceremony/credential-gate/verify.js +146 -0
  25. package/dist/ceremony/dc-payment/dcql.d.ts +5 -0
  26. package/dist/ceremony/dc-payment/dcql.js +23 -0
  27. package/dist/ceremony/dc-payment/page.d.ts +20 -0
  28. package/dist/ceremony/dc-payment/page.js +198 -0
  29. package/dist/ceremony/dc-payment/request.d.ts +17 -0
  30. package/dist/ceremony/dc-payment/request.js +50 -0
  31. package/dist/ceremony/dc-payment/routes.d.ts +2 -0
  32. package/dist/ceremony/dc-payment/routes.js +155 -0
  33. package/dist/ceremony/dc-payment/txData.d.ts +19 -0
  34. package/dist/ceremony/dc-payment/txData.js +34 -0
  35. package/dist/ceremony/dc-payment/verify.d.ts +108 -0
  36. package/dist/ceremony/dc-payment/verify.js +208 -0
  37. package/dist/ceremony/mandate.d.ts +71 -0
  38. package/dist/ceremony/mandate.js +116 -0
  39. package/dist/ceremony/mdoc/mdoc-iso.d.ts +44 -0
  40. package/dist/ceremony/mdoc/mdoc-iso.js +260 -0
  41. package/dist/ceremony/mdoc/mdoc.d.ts +17 -0
  42. package/dist/ceremony/mdoc/mdoc.js +94 -0
  43. package/dist/ceremony/mdoc/reader.d.ts +10 -0
  44. package/dist/ceremony/mdoc/reader.js +43 -0
  45. package/dist/ceremony/mdoc/readerContext.d.ts +8 -0
  46. package/dist/ceremony/mdoc/readerContext.js +29 -0
  47. package/dist/ceremony/mount.d.ts +77 -0
  48. package/dist/ceremony/mount.js +118 -0
  49. package/dist/ceremony/origin.d.ts +10 -0
  50. package/dist/ceremony/origin.js +9 -0
  51. package/dist/ceremony/passkey/page.d.ts +7 -0
  52. package/dist/ceremony/passkey/page.js +143 -0
  53. package/dist/ceremony/passkey/routes.d.ts +2 -0
  54. package/dist/ceremony/passkey/routes.js +173 -0
  55. package/dist/ceremony/passkey/verify.d.ts +15 -0
  56. package/dist/ceremony/passkey/verify.js +56 -0
  57. package/dist/ceremony/reconciliation.d.ts +34 -0
  58. package/dist/ceremony/reconciliation.js +21 -0
  59. package/dist/ceremony/theme.d.ts +63 -0
  60. package/dist/ceremony/theme.js +285 -0
  61. package/dist/ceremony/types.d.ts +95 -0
  62. package/dist/ceremony/types.js +1 -0
  63. package/dist/client.d.ts +39 -0
  64. package/dist/client.js +84 -0
  65. package/dist/credentials.d.ts +48 -0
  66. package/dist/credentials.js +127 -0
  67. package/dist/envelope.d.ts +62 -0
  68. package/dist/envelope.js +72 -0
  69. package/dist/gated.d.ts +39 -0
  70. package/dist/gated.js +41 -0
  71. package/dist/index.d.ts +18 -0
  72. package/dist/index.js +49 -0
  73. package/dist/manifest.d.ts +28 -0
  74. package/dist/manifest.js +76 -0
  75. package/dist/store.d.ts +7 -0
  76. package/dist/store.js +16 -0
  77. package/dist/types.d.ts +146 -0
  78. package/dist/types.js +7 -0
  79. package/package.json +62 -0
@@ -0,0 +1,118 @@
1
+ // The injected-seam contract for the ceremony (Context 2). `mountCeremony(app)`
2
+ // reads the seams the host provides (options + `app.locals.credentagent`), FAILS FAST
3
+ // when a load-bearing one is missing (CT2 — never silently degrade), resolves a
4
+ // CeremonyContext, and registers each rail's routes onto the host app. With no
5
+ // rails extracted yet (Phase 2 — Foundational), it validates the seams + builds
6
+ // the context only; the passkey / dc-payment / credential-gate rails push their
7
+ // registrars here as they land (US1–US3).
8
+ //
9
+ // The package stays dependency-free: `CeremonyApp` is a minimal structural type
10
+ // (no `express` import) carrying just `locals` + the route methods a rail needs.
11
+ import { randomBytes } from "node:crypto";
12
+ import { deriveOrigin } from "./origin.js";
13
+ import { verifyCartMandate } from "./cartMandate.js";
14
+ import { registerCredentialGate } from "./credential-gate/routes.js";
15
+ import { registerPasskeyGate } from "./passkey/routes.js";
16
+ import { registerDcPaymentGate } from "./dc-payment/routes.js";
17
+ // Per-rail registration scaffold. Each rail (passkey / dc-payment /
18
+ // credential-gate) pushes its registrar here once extracted (US1–US3). US1 lands
19
+ // the credential gate (age + membership); passkey / dc-payment follow (US2/US3).
20
+ // Each registrar no-ops on a route-less app shape, so mount()'s fail-fast tests
21
+ // (which pass a `{ locals }`-only app) are unaffected.
22
+ const RAILS = [registerCredentialGate, registerPasskeyGate, registerDcPaymentGate];
23
+ /**
24
+ * Read + validate the injected seams, build the CeremonyContext, and register
25
+ * every rail's routes. Throws on a missing required seam (CT2). Seams may arrive
26
+ * via `options` OR `app.locals.credentagent` — options win.
27
+ */
28
+ export function mountCeremony(app, options = {}) {
29
+ const locals = (app.locals.credentagent ?? {});
30
+ const verificationStore = options.verificationStore ?? locals.verificationStore ?? locals.store;
31
+ const orderStore = options.orderStore ?? locals.orderStore;
32
+ const catalog = options.catalog ?? locals.catalog;
33
+ const completion = options.completion ?? locals.completion;
34
+ const settlement = options.settlement ?? locals.settlement;
35
+ const origin = options.origin ?? locals.origin ?? deriveOrigin;
36
+ const allowEphemeralKey = options.allowEphemeralKey ?? locals.allowEphemeralKey ?? false;
37
+ const statelessOrders = options.statelessOrders ?? locals.statelessOrders ?? false;
38
+ let signingKey = options.signingKey ?? locals.signingKey;
39
+ // Fail fast (CT2) — a load-bearing seam must never silently default. (`origin`
40
+ // has a safe built-in default; `settlement` is genuinely optional.)
41
+ const missing = [];
42
+ if (!verificationStore)
43
+ missing.push("verificationStore");
44
+ if (!orderStore)
45
+ missing.push("orderStore");
46
+ if (!catalog)
47
+ missing.push("catalog");
48
+ if (!completion)
49
+ missing.push("completion");
50
+ if (missing.length > 0) {
51
+ throw new Error(`[credentagent] mount(): missing required ceremony seam(s): ${missing.join(", ")}. ` +
52
+ `Provide them via credentagent.mount(app, { ... }) or app.locals.credentagent.`);
53
+ }
54
+ // The challenge HMAC must survive an instance split (options→verify may hit
55
+ // different serverless instances — D6). We do NOT infer "serverless"; an
56
+ // ephemeral per-process key is allowed ONLY when the host opts in explicitly.
57
+ if (!signingKey) {
58
+ if (!allowEphemeralKey) {
59
+ throw new Error(`[credentagent] mount(): a stable 'signingKey' is required so the challenge HMAC survives an instance split. ` +
60
+ `Pass { signingKey } (e.g. process.env.GATE_SECRET), or { allowEphemeralKey: true } for a single-process dev server.`);
61
+ }
62
+ signingKey = randomBytes(32).toString("hex");
63
+ }
64
+ const ctx = {
65
+ verificationStore: verificationStore,
66
+ orderStore: orderStore,
67
+ catalog: catalog,
68
+ completion: completion,
69
+ signingKey,
70
+ origin,
71
+ statelessOrders,
72
+ ...(settlement ? { settlement } : {}),
73
+ };
74
+ // Re-expose the resolved seams on app.locals so the storefront's gate routes
75
+ // resolve verification THROUGH CredentAgent (and a re-mount is idempotent).
76
+ app.locals.credentagent = { ...app.locals.credentagent, store: ctx.verificationStore, ...ctx };
77
+ for (const register of RAILS)
78
+ register(app, ctx);
79
+ return ctx;
80
+ }
81
+ /**
82
+ * Shared order resolution + re-pricing (T003a). Resolve a created order by id,
83
+ * then RE-PRICE it from the catalog — the displayed and bound amounts come from
84
+ * the catalog, never the id/token (CT3, invariants 2/3). A tampered or unknown id
85
+ * resolves to `null` (the rail refuses).
86
+ *
87
+ * FR-007 (opt-in `statelessOrders`): when the host has no shared order store, a
88
+ * VERIFIED Cart Mandate carried on the request is the order transport — pass it as
89
+ * `opts.cartMandate` and the created order is reconstructed from it with NO store
90
+ * read, so a created order survives an instance split (US3). It stays fail-closed:
91
+ * a forged / tampered / replayed (wrong-order) / expired mandate does not resolve
92
+ * an order, and the catalog STILL reprices (the mandate carries the items, never
93
+ * the price — invariant 2). Off (default), the store is the source of truth and any
94
+ * mandate is an additive integrity envelope checked at completion, not a transport.
95
+ */
96
+ export async function resolveOrder(ctx, orderId, opts) {
97
+ if (!orderId)
98
+ return null;
99
+ // Stateless transport (opt-in): reconstruct from the verified mandate, no store read.
100
+ if (ctx.statelessOrders && opts?.cartMandate !== undefined) {
101
+ const verdict = verifyCartMandate(opts.cartMandate, orderId, ctx.signingKey);
102
+ if (!verdict.ok)
103
+ return null;
104
+ const verification = await ctx.verificationStore.read(orderId);
105
+ const loyaltyApplied = !!verification?.loyalty?.applied;
106
+ return ctx.catalog.createOrder(verdict.mandate.lines.map((l) => ({ productId: l.id, quantity: l.quantity })), orderId, { loyaltyApplied });
107
+ }
108
+ // Default: the store is the source of truth.
109
+ const stored = await ctx.orderStore.read(orderId);
110
+ if (!stored || stored.id !== orderId || !Array.isArray(stored.lines))
111
+ return null;
112
+ // A loyalty discount is applied only when THIS order's verification opts in
113
+ // (invariant 3); the line items come from the store, every price from the
114
+ // catalog.
115
+ const verification = await ctx.verificationStore.read(orderId);
116
+ const loyaltyApplied = !!verification?.loyalty?.applied;
117
+ return ctx.catalog.createOrder(stored.lines.map((l) => ({ productId: l.id, quantity: l.quantity })), orderId, { loyaltyApplied });
118
+ }
@@ -0,0 +1,10 @@
1
+ export interface RequestLike {
2
+ headers: Record<string, string | string[] | undefined>;
3
+ host: string;
4
+ protocol: string;
5
+ }
6
+ export interface Origin {
7
+ rpID: string;
8
+ origin: string;
9
+ }
10
+ export declare function deriveOrigin(req: RequestLike): Origin;
@@ -0,0 +1,9 @@
1
+ function first(value) {
2
+ return Array.isArray(value) ? value[0] : value;
3
+ }
4
+ export function deriveOrigin(req) {
5
+ const host = first(req.headers["x-forwarded-host"]) ?? req.host;
6
+ const proto = first(req.headers["x-forwarded-proto"]) ?? req.protocol;
7
+ const rpID = host.split(":")[0];
8
+ return { rpID, origin: `${proto}://${host}` };
9
+ }
@@ -0,0 +1,7 @@
1
+ import type { CeremonyOrder } from "../types.js";
2
+ export declare function renderPasskeyPage(args: {
3
+ order: CeremonyOrder;
4
+ crossDevice?: boolean;
5
+ returnUrl?: string;
6
+ cart?: string;
7
+ }): string;
@@ -0,0 +1,143 @@
1
+ import { pageHead, brandHeader, progressRail, orderSummaryCard, trustFooter, settlingBar, completionHandoffBanner } from "../theme.js";
2
+ function money(amount, currency) {
3
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
4
+ }
5
+ export function renderPasskeyPage(args) {
6
+ const { order, crossDevice = false } = args;
7
+ // Where the completed receipt links back to — the checkout hub, which then renders
8
+ // the paid state (a forward, fresh GET — so the buyer never browser-backs onto a
9
+ // stale, re-payable checkout). Defaults to this server's `/checkout?order=<id>`.
10
+ // statelessOrders: carry the cart mandate back so the store-less hub can re-resolve.
11
+ const returnUrl = args.returnUrl ?? `/checkout?order=${encodeURIComponent(order.id)}${args.cart ? `&cart=${args.cart}` : ""}`;
12
+ const total = money(order.total, order.currency);
13
+ // The shared order summary card (line items + bold Total) — same chrome as the hub.
14
+ const summary = orderSummaryCard({
15
+ lines: order.lines.map((l) => ({ name: l.name ?? l.id, quantity: l.quantity, lineTotal: l.lineTotal, currency: l.currency ?? order.currency })),
16
+ total: order.total,
17
+ currency: order.currency,
18
+ caption: `Order ${order.id}`,
19
+ });
20
+ // Pay is the current (final) step; the upstream gates are done by the time payment runs.
21
+ const rail = progressRail([{ label: "Age", done: true }, { label: "Membership", done: true }, { label: "Pay" }], 2);
22
+ const tagline = crossDevice ? "Approve on your phone (scan a QR)" : "Authorize with this device";
23
+ // crossDevice pins the registration to a roaming authenticator, so the browser
24
+ // skips local Touch ID and shows the QR for a phone (caBLE). The toggle link flips
25
+ // the mode by adding/removing the xdev param on the same gate URL.
26
+ const optionsUrl = crossDevice ? "/credentagent/passkey/options?xdev=1" : "/credentagent/passkey/options";
27
+ // statelessOrders: keep the cart mandate on the same-device ⇄ cross-device toggle so the
28
+ // store-less server can still resolve THIS order after switching.
29
+ const cartQ = args.cart ? `&cart=${args.cart}` : "";
30
+ const toggleHref = crossDevice ? `/credentagent/passkey?order=${encodeURIComponent(order.id)}${cartQ}` : `/credentagent/passkey?order=${encodeURIComponent(order.id)}&xdev=1${cartQ}`;
31
+ const toggleText = crossDevice ? "← Use this device instead" : "Use my phone instead (scan a QR) →";
32
+ // Page-local chrome over the shared design system: the verify-progress rows reuse
33
+ // `.step`; the receipt gate rows + the QR toggle link are page-specific.
34
+ const extraCss = `
35
+ #receipt { display: none; margin-top: 16px; }
36
+ .gate { font-size: .82rem; padding: 3px 0; }
37
+ .gate.pass { color: var(--success); } .gate.fail { color: var(--danger); }
38
+ .toggle { display: block; text-align: center; margin-top: 12px; font-size: .85rem; color: var(--accent); text-decoration: none; }
39
+ .toggle:hover { text-decoration: underline; }`;
40
+ return `<!doctype html>
41
+ <html lang="en">
42
+ ${pageHead(`Authorize payment · ${order.id}`, extraCss)}
43
+ <body>
44
+ <div class="wrap">
45
+ ${brandHeader({ h1: "Authorize payment", tagline })}
46
+ ${rail}
47
+ ${summary}
48
+ <div class="card">
49
+ <p class="lede">An agent prepared this order — confirm the exact amount with your device's secure element (Touch ID, Windows Hello, or a phone via cross-device sign-in). Once authorized, payment settles on-chain via the <strong>x402</strong> protocol — on a <strong>test network</strong>, no real money, a tiny token amount (a fixed demo rate, not the dollar total).</p>
50
+ <button id="go" class="btn btn-primary">Authorize ${total}</button>
51
+ <a class="toggle" href="${toggleHref}">${toggleText}</a>
52
+ <div id="log"></div>
53
+ ${settlingBar()}
54
+ <div id="receipt"></div>
55
+ </div>
56
+ ${trustFooter()}
57
+ <script type="module">
58
+ import { startRegistration } from "/credentagent/lib/sw/index.js";
59
+ const ORDER_ID = ${JSON.stringify(order.id)};
60
+ // statelessOrders: forward the signed cart mandate (?cart=… in this page's URL) so a
61
+ // store-less server can reconstruct THIS order on verify.
62
+ const CART = new URLSearchParams(location.search).get("cart");
63
+ const OPTIONS_URL = ${JSON.stringify(optionsUrl)};
64
+ const RETURN_URL = ${JSON.stringify(returnUrl)};
65
+ const DONE_BANNER = ${JSON.stringify(completionHandoffBanner(returnUrl))};
66
+ const log = document.getElementById("log");
67
+ const btn = document.getElementById("go");
68
+ const settling = document.getElementById("settling");
69
+ const step = (t, c = "") => { const d = document.createElement("div"); d.className = "step " + c; d.textContent = t; log.appendChild(d); };
70
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";");
71
+ btn.addEventListener("click", async () => {
72
+ btn.disabled = true;
73
+ try {
74
+ step("→ GET options");
75
+ const { options, challengeToken } = await fetch(OPTIONS_URL).then((r) => r.json());
76
+ step("→ Touch ID / passkey prompt");
77
+ const response = await startRegistration({ optionsJSON: options });
78
+ step("→ verify · Settling via x402 on Hedera testnet (if configured)… can take ~10s");
79
+ settling.classList.add("on");
80
+ const out = await fetch("/credentagent/passkey/verify", {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: JSON.stringify({ response, challengeToken, order: ORDER_ID, cart: CART }),
84
+ }).then((r) => r.json()).finally(() => settling.classList.remove("on"));
85
+ if (!out.mandate) throw new Error(out.error || "authorization failed");
86
+ step("✓ authorized · mandate built (" + out.trust_level + ")", "ok");
87
+ renderReceipt(out);
88
+ if (out.settlementError) { step("✗ settlement failed — authorized, not settled (retry below)", "err"); btn.disabled = false; }
89
+ else if (!out.completed) btn.disabled = false;
90
+ } catch (err) {
91
+ step("✗ " + (err?.message ?? String(err)), "err");
92
+ btn.disabled = false;
93
+ }
94
+ });
95
+
96
+ function renderReceipt(out) {
97
+ const el = document.getElementById("receipt");
98
+ const passCount = out.gates.filter((g) => g.pass).length;
99
+ const allPass = passCount === out.gates.length;
100
+ const gateLines = out.gates.map((g) => '<div class="gate ' + (g.pass ? "pass" : "fail") + '">' + (g.pass ? "✓" : "✗") + " " + esc(g.gate) + " — " + esc(g.detail) + "</div>").join("");
101
+ // The x402 on-chain settlement receipt — same .settle card the dc-payment rail
102
+ // renders: the tinybar amount, payer/merchant, speed, tx, and a PROMINENT
103
+ // tappable HashScan link (the buyer is on their phone; one tap to the live
104
+ // explorer is the third-party proof). A configured-but-failed settle is the
105
+ // calm "authorized, not settled" line (FR-013) — never an alarming wall.
106
+ const s = out.settlement;
107
+ const settlement = s
108
+ ? '<div class="settle"><div class="settle-head">✓ Settled via x402 on Hedera testnet</div>' +
109
+ '<dl class="kv">' +
110
+ "<dt>Amount</dt><dd>" + (s.amountTinybar / 1e8) + ' ℏ <span class="dim">(' + esc(s.fxRate) + ")</span></dd>" +
111
+ "<dt>From</dt><dd>" + esc(s.payer.accountId) + ' <span class="dim">' +
112
+ (s.payer.kind === "session-wallet"
113
+ ? "wallet created for this order, " + (s.walletAgeMs / 1000).toFixed(1) + "s old when it paid"
114
+ : "demo customer") + "</span></dd>" +
115
+ "<dt>To</dt><dd>" + esc(s.payTo) + ' <span class="dim">merchant</span></dd>' +
116
+ "<dt>Speed</dt><dd>settled in " + (s.settledInMs / 1000).toFixed(1) + "s</dd>" +
117
+ '<dt>Tx</dt><dd><span class="mono">' + esc(s.txId) + "</span></dd>" +
118
+ "</dl>" +
119
+ '<a class="hashscan" href="' + esc(s.hashscanUrl) + '" target="_blank" rel="noopener">View on HashScan ›</a>' +
120
+ "</div>"
121
+ : out.settlementError
122
+ ? '<div class="settle-failed">✗ Settlement failed — authorized, not settled: ' + esc(out.settlementError) + "</div>"
123
+ : "";
124
+ // Every gate + payment is done ⇒ the order is COMPLETE. Lead with the prominent
125
+ // handoff: close this window and continue in the agent (the MCP host polls
126
+ // order-status and resumes). No auto-redirect — we don't yank the buyer off the
127
+ // "you're done" message; the on-chain proof + a secondary return link stay below.
128
+ const done = out.completed ? DONE_BANNER : "";
129
+ const gates = '<div class="gate ' + (allPass ? "pass" : "fail") + '">' +
130
+ (allPass ? "✓ All " + out.gates.length + " authorization gates passed" : "✗ " + (out.gates.length - passCount) + " of " + out.gates.length + " failed") + "</div>" + gateLines;
131
+ el.innerHTML = done + '<div class="row-ok">✓ Payment Mandate authorized (amount-bound)</div>' +
132
+ '<div class="small" style="margin:4px 0 8px;">' + esc(out.mandate.id) + "</div>" + gates + settlement;
133
+ el.style.display = "block";
134
+ if (out.completed) {
135
+ btn.disabled = true;
136
+ btn.textContent = "Authorized ✓";
137
+ }
138
+ }
139
+ </script>
140
+ </div>
141
+ </body>
142
+ </html>`;
143
+ }
@@ -0,0 +1,2 @@
1
+ import { type RailRegistrar } from "../mount.js";
2
+ export declare const registerPasskeyGate: RailRegistrar;
@@ -0,0 +1,173 @@
1
+ // The passkey payment rail (US2) — same-device (Touch ID / Windows Hello) and
2
+ // cross-device (FIDO caBLE). Registers its routes onto the host app through the
3
+ // Foundational mount() seam:
4
+ // GET /credentagent/passkey?order=<id>[&xdev=1] → the authorize page (xdev toggle)
5
+ // GET /credentagent/passkey/options[?xdev=1] → WebAuthn options + signed challenge token
6
+ // POST /credentagent/passkey/verify → verify assertion → four gates → completeOrder
7
+ // GET /credentagent/lib/sw/* → @simplewebauthn/browser ESM, same-origin
8
+ //
9
+ // Dependency-free (no `express` import — invariant from mount.ts): handlers are
10
+ // registered against the structural CeremonyApp.get/post/use, the verify body is
11
+ // read either from a host-installed parser (`req.body`) or off the request stream,
12
+ // and the browser ESM is served from disk (no `express.static`).
13
+ //
14
+ // EVERY route resolves the order by id THROUGH `resolveOrder` (catalog re-pricing;
15
+ // a tampered/unknown id is refused — CT3, invariant 2). Completion runs through the
16
+ // SHARED `completeOrder` seam (ctx.completion) — the same path dc-payment uses — so
17
+ // re-pricing, the age gate, settlement, and state-clearing behave identically
18
+ // across rails (FR-008). WebAuthn stays bound to this server's origin/RP-ID with a
19
+ // sealed, time-limited, single-use challenge (FR-007, invariant 6).
20
+ //
21
+ // Trust is PRESENCE-ONLY (Principle VII / FR-011): the WebAuthn flow is real, but
22
+ // the mandate is dev-signed — a flow demo, not a real safety control.
23
+ import { createRequire } from "node:module";
24
+ import { existsSync, readFileSync, statSync } from "node:fs";
25
+ import path from "node:path";
26
+ import { resolveOrder } from "../mount.js";
27
+ import { decodeCartMandateParam } from "../cartMandate.js";
28
+ import { buildPasskeyMandate, buildBindingFields, runGates } from "../mandate.js";
29
+ import { buildRegistrationOptions, verifyPasskeyAssertion } from "./verify.js";
30
+ import { renderPasskeyPage } from "./page.js";
31
+ function firstHeader(value) {
32
+ return Array.isArray(value) ? value[0] : value;
33
+ }
34
+ function originOf(ctx, req) {
35
+ const reqLike = { headers: req.headers, host: firstHeader(req.headers.host) ?? "localhost", protocol: req.protocol };
36
+ return ctx.origin(reqLike);
37
+ }
38
+ function isCrossDevice(raw) {
39
+ return (Array.isArray(raw) ? raw[0] : raw) === "1";
40
+ }
41
+ // Read the JSON body from a host-installed parser, or straight off the stream when
42
+ // no parser ran (so the rail is self-contained — it doesn't require express.json()).
43
+ async function readJsonBody(req) {
44
+ if (req.body && typeof req.body === "object")
45
+ return req.body;
46
+ try {
47
+ const chunks = [];
48
+ for await (const chunk of req) {
49
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
50
+ }
51
+ if (chunks.length === 0)
52
+ return {};
53
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ }
59
+ const CONTENT_TYPES = {
60
+ ".js": "text/javascript",
61
+ ".mjs": "text/javascript",
62
+ ".map": "application/json",
63
+ ".json": "application/json",
64
+ ".ts": "text/plain",
65
+ };
66
+ // Resolve @simplewebauthn/browser's ESM dir (its `main` is script/index.js; walk
67
+ // up two levels to the package root, then into /esm). Fail LOUDLY at wire-up if the
68
+ // layout changed, rather than silently 404-ing the browser module at runtime.
69
+ function resolveBrowserEsmDir() {
70
+ const requireFrom = createRequire(import.meta.url);
71
+ const scriptIndexPath = requireFrom.resolve("@simplewebauthn/browser");
72
+ const dir = path.join(path.dirname(path.dirname(scriptIndexPath)), "esm");
73
+ if (!existsSync(path.join(dir, "index.js"))) {
74
+ throw new Error(`[credentagent] @simplewebauthn/browser ESM not found at ${dir}`);
75
+ }
76
+ return dir;
77
+ }
78
+ export const registerPasskeyGate = (app, ctx) => {
79
+ // The Foundational fail-fast tests mount() with a route-less app shape; only
80
+ // attach when the host app can actually route.
81
+ const get = app.get?.bind(app);
82
+ const post = app.post?.bind(app);
83
+ const use = app.use?.bind(app);
84
+ if (!get || !post || !use)
85
+ return;
86
+ // Serve @simplewebauthn/browser ESM same-origin (no CDN) — dependency-free file
87
+ // serving with path-traversal containment.
88
+ const browserEsmDir = resolveBrowserEsmDir();
89
+ use("/credentagent/lib/sw", (req, res) => {
90
+ const rel = (req.path ?? req.url ?? "/").split("?")[0];
91
+ const filePath = path.resolve(browserEsmDir, "." + rel);
92
+ // Containment: a resolved path must stay inside the served ESM directory.
93
+ if (filePath !== browserEsmDir && !filePath.startsWith(browserEsmDir + path.sep)) {
94
+ res.status(403).type("text/plain").send("forbidden");
95
+ return;
96
+ }
97
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
98
+ res.status(404).type("text/plain").send("not found");
99
+ return;
100
+ }
101
+ const mime = CONTENT_TYPES[path.extname(filePath)] ?? "application/octet-stream";
102
+ res.status(200).type(mime).send(readFileSync(filePath, "utf8"));
103
+ });
104
+ // GET the authorize page — re-priced order, presence-only honesty banner.
105
+ get("/credentagent/passkey", async (req, res) => {
106
+ const order = await resolveOrder(ctx, typeof req.query.order === "string" ? req.query.order : undefined, { cartMandate: decodeCartMandateParam(req.query.cart) });
107
+ if (!order) {
108
+ res.status(404).type("html").send("<!doctype html><h1>Order not found</h1>");
109
+ return;
110
+ }
111
+ try {
112
+ res.status(200).type("html").send(renderPasskeyPage({ order, crossDevice: isCrossDevice(req.query.xdev), cart: typeof req.query.cart === "string" ? req.query.cart : undefined }));
113
+ }
114
+ catch {
115
+ // A hand-edited order can carry a bad currency that throws in Intl; never 500.
116
+ res.status(404).type("html").send("<!doctype html><h1>Order not found</h1>");
117
+ }
118
+ });
119
+ // GET WebAuthn options + a signed challenge token (order-independent — the
120
+ // challenge binds to this origin/RP-ID, the order is bound at verify).
121
+ get("/credentagent/passkey/options", async (req, res) => {
122
+ const { options, challengeToken } = await buildRegistrationOptions(originOf(ctx, req), ctx.signingKey, {
123
+ crossDevice: isCrossDevice(req.query.xdev),
124
+ });
125
+ res.json({ options, challengeToken });
126
+ });
127
+ // POST verify — recover the nonce, verify the assertion against this origin/RP-ID,
128
+ // build the AP2 mandate, run the four deterministic gates, and complete through
129
+ // the SHARED completeOrder seam (re-price + age gate + idempotent record).
130
+ post("/credentagent/passkey/verify", async (req, res) => {
131
+ const body = await readJsonBody(req);
132
+ const cartMandate = body.cartMandate ?? decodeCartMandateParam(body.cart);
133
+ const order = await resolveOrder(ctx, typeof body.order === "string" ? body.order : undefined, { cartMandate });
134
+ if (!order) {
135
+ res.status(400).json({ completed: false, error: "missing or invalid order" });
136
+ return;
137
+ }
138
+ try {
139
+ const origin = originOf(ctx, req);
140
+ const authenticator = await verifyPasskeyAssertion({
141
+ response: body.response,
142
+ challengeToken: String(body.challengeToken ?? ""),
143
+ origin,
144
+ secret: ctx.signingKey,
145
+ });
146
+ const mandate = buildPasskeyMandate({ order, authenticator, origin });
147
+ const gates = runGates(mandate);
148
+ const completion = await ctx.completion({
149
+ order,
150
+ mandateId: mandate.id,
151
+ amount: mandate.payment.amount,
152
+ currency: mandate.payment.currency,
153
+ method: "passkey",
154
+ instrument: { issuer: mandate.payment.instrument, maskedAccount: mandate.payment.instrumentReference, holder: null },
155
+ gates: gates.map((g) => ({ gate: g.gate, pass: g.pass, detail: g.detail })),
156
+ ...(cartMandate !== undefined ? { cartMandate: cartMandate } : {}),
157
+ });
158
+ res.json({
159
+ mandate,
160
+ gates,
161
+ completed: completion.completed,
162
+ settlement: completion.settlement ?? null,
163
+ settlementError: completion.settlementError ?? null,
164
+ reason: completion.reason ?? null,
165
+ binding: buildBindingFields(order, origin),
166
+ trust_level: "presence-only-demo",
167
+ });
168
+ }
169
+ catch (err) {
170
+ res.status(400).json({ completed: false, error: err.message });
171
+ }
172
+ });
173
+ };
@@ -0,0 +1,15 @@
1
+ import { type RegistrationResponseJSON } from "@simplewebauthn/server";
2
+ import type { Origin } from "../origin.js";
3
+ import type { VerifiedAuthenticator } from "../mandate.js";
4
+ export declare function buildRegistrationOptions(origin: Origin, secret: string, opts?: {
5
+ crossDevice?: boolean;
6
+ }): Promise<{
7
+ options: import("@simplewebauthn/server").PublicKeyCredentialCreationOptionsJSON;
8
+ challengeToken: string;
9
+ }>;
10
+ export declare function verifyPasskeyAssertion(args: {
11
+ response: RegistrationResponseJSON;
12
+ challengeToken: string;
13
+ origin: Origin;
14
+ secret: string;
15
+ }): Promise<VerifiedAuthenticator>;
@@ -0,0 +1,56 @@
1
+ // The passkey rail's WebAuthn step (US2) — extracted from the demo's
2
+ // payment-gate/passkey/verify.ts. A single registration ceremony is the
3
+ // authorization gesture: the challenge is recovered from the signed token
4
+ // (stateless — survives an options→verify instance split), and the assertion is
5
+ // verified against THIS server's origin/RP-ID with user verification required
6
+ // (invariant 6). The signing secret is the injected `signingKey` seam (mount()
7
+ // requires a stable one — D6), never a process global.
8
+ //
9
+ // Trust is PRESENCE-ONLY (Principle VII / FR-011): the attestation flow is real,
10
+ // but the mandate it feeds (mandate.ts) is dev-signed, not key-bound — a flow
11
+ // demo, not a real safety control.
12
+ import { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server";
13
+ import { issueChallenge, verifyChallenge } from "../challengeToken.js";
14
+ const RP_NAME = "CredentAgent Gate";
15
+ // Build registration options + a signed challenge token. userID is ephemeral —
16
+ // we never persist the credential, so a fresh random user each time is fine.
17
+ // crossDevice pins authenticatorAttachment to "cross-platform", which removes the
18
+ // local Touch ID option so the browser goes straight to the phone/QR (caBLE) path.
19
+ export async function buildRegistrationOptions(origin, secret, opts = {}) {
20
+ const { challenge, token } = issueChallenge(secret);
21
+ const options = await generateRegistrationOptions({
22
+ rpName: RP_NAME,
23
+ rpID: origin.rpID,
24
+ userName: "credentagent-gate-user",
25
+ challenge: Buffer.from(challenge, "base64url"),
26
+ attestationType: "none",
27
+ authenticatorSelection: {
28
+ residentKey: "preferred",
29
+ userVerification: "required",
30
+ ...(opts.crossDevice ? { authenticatorAttachment: "cross-platform" } : {}),
31
+ },
32
+ });
33
+ return { options, challengeToken: token };
34
+ }
35
+ export async function verifyPasskeyAssertion(args) {
36
+ // Recover + validate the sealed, time-limited nonce FIRST — a forged or expired
37
+ // token is rejected before any attestation parsing (invariant 6).
38
+ const expectedChallenge = verifyChallenge(args.challengeToken, args.secret);
39
+ const verification = await verifyRegistrationResponse({
40
+ response: args.response,
41
+ expectedChallenge,
42
+ expectedOrigin: args.origin.origin,
43
+ expectedRPID: args.origin.rpID,
44
+ requireUserVerification: true,
45
+ });
46
+ if (!verification.verified || !verification.registrationInfo) {
47
+ throw new Error("registration not verified");
48
+ }
49
+ const info = verification.registrationInfo;
50
+ return {
51
+ credentialID: info.credential.id,
52
+ userVerified: true,
53
+ credentialDeviceType: info.credentialDeviceType,
54
+ credentialBackedUp: info.credentialBackedUp,
55
+ };
56
+ }
@@ -0,0 +1,34 @@
1
+ import type { CartMandate } from "./cartMandate.js";
2
+ /** The Payment Mandate's bound fields as they reach the completion seam — each rail
3
+ * projects them from its verified `mandate.payment` (+ the id of the order the
4
+ * payment authorizes). Scalars, not the mandate object, so the seam stays
5
+ * decoupled from the passkey/dc-payment mandate shapes. */
6
+ export interface PaymentBinding {
7
+ /** The Payment Mandate's bound amount (`mandate.payment.amount`). */
8
+ amount: number;
9
+ /** The Payment Mandate's bound currency (`mandate.payment.currency`). */
10
+ currency: string;
11
+ /** The order id the payment authorizes (the mandate's cart/subject). */
12
+ orderId: string;
13
+ }
14
+ /** Why a Cart ↔ Payment reconciliation refused. */
15
+ export type ReconcileRefusal = "order-id" | "currency" | "amount";
16
+ export type ReconcileVerdict = {
17
+ ok: true;
18
+ } | {
19
+ ok: false;
20
+ reason: ReconcileRefusal;
21
+ };
22
+ /**
23
+ * Reconcile a (signature-verified) Cart Mandate with the Payment Mandate's binding,
24
+ * given the catalog-RE-DERIVED total (server-side truth, never the token — invariant
25
+ * 2). The verdict is `ok` only when all three agree:
26
+ * 1. order id / subject — the cart and the payment authorize the SAME order
27
+ * (a Cart Mandate swapped onto another order's payment is refused);
28
+ * 2. currency — the cart and the payment settle in the same currency;
29
+ * 3. amount — `cart.total === rederivedTotal === payment.amount`, a single bound
30
+ * figure (a cart sealed for X against a payment for Y≠X is refused; a discount
31
+ * reconciles only when the re-derived total already reflects it — invariant 3).
32
+ * Returns a typed verdict; it never throws.
33
+ */
34
+ export declare function reconcileCartPayment(cart: CartMandate, payment: PaymentBinding, rederivedTotal: number): ReconcileVerdict;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Reconcile a (signature-verified) Cart Mandate with the Payment Mandate's binding,
3
+ * given the catalog-RE-DERIVED total (server-side truth, never the token — invariant
4
+ * 2). The verdict is `ok` only when all three agree:
5
+ * 1. order id / subject — the cart and the payment authorize the SAME order
6
+ * (a Cart Mandate swapped onto another order's payment is refused);
7
+ * 2. currency — the cart and the payment settle in the same currency;
8
+ * 3. amount — `cart.total === rederivedTotal === payment.amount`, a single bound
9
+ * figure (a cart sealed for X against a payment for Y≠X is refused; a discount
10
+ * reconciles only when the re-derived total already reflects it — invariant 3).
11
+ * Returns a typed verdict; it never throws.
12
+ */
13
+ export function reconcileCartPayment(cart, payment, rederivedTotal) {
14
+ if (cart.orderId !== payment.orderId)
15
+ return { ok: false, reason: "order-id" };
16
+ if (cart.currency !== payment.currency)
17
+ return { ok: false, reason: "currency" };
18
+ if (cart.total !== rederivedTotal || cart.total !== payment.amount)
19
+ return { ok: false, reason: "amount" };
20
+ return { ok: true };
21
+ }