@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.
- package/LICENSE +201 -0
- package/README.md +176 -0
- package/dist/ceremony/cartMandate.d.ts +68 -0
- package/dist/ceremony/cartMandate.js +92 -0
- package/dist/ceremony/challengeToken.d.ts +5 -0
- package/dist/ceremony/challengeToken.js +43 -0
- package/dist/ceremony/checkout-page.d.ts +85 -0
- package/dist/ceremony/checkout-page.js +272 -0
- package/dist/ceremony/completion.d.ts +41 -0
- package/dist/ceremony/completion.js +90 -0
- package/dist/ceremony/credential-gate/dcql.d.ts +10 -0
- package/dist/ceremony/credential-gate/dcql.js +12 -0
- package/dist/ceremony/credential-gate/doc-spec.d.ts +3 -0
- package/dist/ceremony/credential-gate/doc-spec.js +16 -0
- package/dist/ceremony/credential-gate/mdoc-verify.d.ts +15 -0
- package/dist/ceremony/credential-gate/mdoc-verify.js +29 -0
- package/dist/ceremony/credential-gate/page.d.ts +23 -0
- package/dist/ceremony/credential-gate/page.js +139 -0
- package/dist/ceremony/credential-gate/request.d.ts +15 -0
- package/dist/ceremony/credential-gate/request.js +43 -0
- package/dist/ceremony/credential-gate/routes.d.ts +2 -0
- package/dist/ceremony/credential-gate/routes.js +202 -0
- package/dist/ceremony/credential-gate/verify.d.ts +51 -0
- package/dist/ceremony/credential-gate/verify.js +146 -0
- package/dist/ceremony/dc-payment/dcql.d.ts +5 -0
- package/dist/ceremony/dc-payment/dcql.js +23 -0
- package/dist/ceremony/dc-payment/page.d.ts +20 -0
- package/dist/ceremony/dc-payment/page.js +198 -0
- package/dist/ceremony/dc-payment/request.d.ts +17 -0
- package/dist/ceremony/dc-payment/request.js +50 -0
- package/dist/ceremony/dc-payment/routes.d.ts +2 -0
- package/dist/ceremony/dc-payment/routes.js +155 -0
- package/dist/ceremony/dc-payment/txData.d.ts +19 -0
- package/dist/ceremony/dc-payment/txData.js +34 -0
- package/dist/ceremony/dc-payment/verify.d.ts +108 -0
- package/dist/ceremony/dc-payment/verify.js +208 -0
- package/dist/ceremony/mandate.d.ts +71 -0
- package/dist/ceremony/mandate.js +116 -0
- package/dist/ceremony/mdoc/mdoc-iso.d.ts +44 -0
- package/dist/ceremony/mdoc/mdoc-iso.js +260 -0
- package/dist/ceremony/mdoc/mdoc.d.ts +17 -0
- package/dist/ceremony/mdoc/mdoc.js +94 -0
- package/dist/ceremony/mdoc/reader.d.ts +10 -0
- package/dist/ceremony/mdoc/reader.js +43 -0
- package/dist/ceremony/mdoc/readerContext.d.ts +8 -0
- package/dist/ceremony/mdoc/readerContext.js +29 -0
- package/dist/ceremony/mount.d.ts +77 -0
- package/dist/ceremony/mount.js +118 -0
- package/dist/ceremony/origin.d.ts +10 -0
- package/dist/ceremony/origin.js +9 -0
- package/dist/ceremony/passkey/page.d.ts +7 -0
- package/dist/ceremony/passkey/page.js +143 -0
- package/dist/ceremony/passkey/routes.d.ts +2 -0
- package/dist/ceremony/passkey/routes.js +173 -0
- package/dist/ceremony/passkey/verify.d.ts +15 -0
- package/dist/ceremony/passkey/verify.js +56 -0
- package/dist/ceremony/reconciliation.d.ts +34 -0
- package/dist/ceremony/reconciliation.js +21 -0
- package/dist/ceremony/theme.d.ts +63 -0
- package/dist/ceremony/theme.js +285 -0
- package/dist/ceremony/types.d.ts +95 -0
- package/dist/ceremony/types.js +1 -0
- package/dist/client.d.ts +39 -0
- package/dist/client.js +84 -0
- package/dist/credentials.d.ts +48 -0
- package/dist/credentials.js +127 -0
- package/dist/envelope.d.ts +62 -0
- package/dist/envelope.js +72 -0
- package/dist/gated.d.ts +39 -0
- package/dist/gated.js +41 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +49 -0
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +76 -0
- package/dist/store.d.ts +7 -0
- package/dist/store.js +16 -0
- package/dist/types.d.ts +146 -0
- package/dist/types.js +7 -0
- 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,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,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
|
+
}
|