@openmobilehub/attestomcp-gate 0.1.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 +172 -0
  3. package/dist/ceremony/cartMandate.d.ts +61 -0
  4. package/dist/ceremony/cartMandate.js +76 -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 +269 -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 +20 -0
  18. package/dist/ceremony/credential-gate/page.js +136 -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 +200 -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 +18 -0
  28. package/dist/ceremony/dc-payment/page.js +195 -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 +147 -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 +57 -0
  48. package/dist/ceremony/mount.js +96 -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 +6 -0
  52. package/dist/ceremony/passkey/page.js +136 -0
  53. package/dist/ceremony/passkey/routes.d.ts +2 -0
  54. package/dist/ceremony/passkey/routes.js +170 -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,195 @@
1
+ // Server-rendered dc-payment gate page. It now drives the REAL OpenID4VP wallet path:
2
+ // the PRIMARY button calls navigator.credentials.get({digital}) synchronously inside
3
+ // the tap (the signed request is PRE-FETCHED from /attestomcp/dc-payment/request so no
4
+ // await sits between the click and get() — iOS WebKit drops the transient user
5
+ // activation across an await; Chrome 141+ renders the cross-device caBLE QR), then POSTs
6
+ // the wallet's encrypted vp_token to /attestomcp/dc-payment/verify in the shape the route's
7
+ // real path reads ({ order, readerContextToken, result:{protocol,data} }) — the route
8
+ // decrypts the JWE, re-checks the device-signed transaction_data_hash against the
9
+ // amount we sealed, runs the four gates, and completes through the shared completeOrder
10
+ // seam. The SECONDARY "instant demo" button is kept as a fallback: it POSTs the
11
+ // canonical disclosed instrument + the catalog-bound amount ({ order, amount, claims })
12
+ // (no wallet round-trip; the tested default). On a browser without the Digital
13
+ // Credentials API the page points the buyer at the instant-demo button. Every surface
14
+ // states trust_level "presence-only-demo" (CT11 / Principle VII / FR-011): the wire
15
+ // crypto is real; the wallet's device/issuer trust anchor is not — never a real safety
16
+ // control. Self-contained: takes the re-priced amount + lines, not a demo Order type.
17
+ import { pageHead, brandHeader, progressRail, orderSummaryCard, trustFooter, settlingBar, completionHandoffBanner } from "../theme.js";
18
+ // The canonical disclosed instrument the instant-demo button presents — it goes
19
+ // through the SAME server-side amount-binding gates as a real wallet presentation.
20
+ const DEMO_CLAIMS = {
21
+ issuer_name: "Demo Bank",
22
+ payment_instrument_id: "pi-77AABBCC",
23
+ masked_account_reference: "•••• 4242",
24
+ holder_name: "Demo Buyer",
25
+ expiry_date: "2032-09-01",
26
+ };
27
+ function money(amount, currency) {
28
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
29
+ }
30
+ export function renderDcPaymentPage(args) {
31
+ const { order, total, currency, lines } = args;
32
+ const returnUrl = args.returnUrl ?? `/checkout?order=${encodeURIComponent(order)}`;
33
+ // The shared order summary card (line items + bold Total) — same chrome as the hub.
34
+ const summary = orderSummaryCard({
35
+ lines: lines.map((l) => ({ name: l.name, quantity: l.quantity, lineTotal: l.lineTotal, currency: l.currency })),
36
+ total,
37
+ currency,
38
+ caption: `Order ${order}`,
39
+ });
40
+ // The progress rail with Pay as the current (final) step; the upstream gates are done.
41
+ const rail = progressRail([{ label: "Age", done: true }, { label: "Membership", done: true }, { label: "Pay" }], 2);
42
+ // Page-local chrome layered over the shared design system: the verify-progress rows
43
+ // reuse `.step`; the receipt gate rows + the success card are page-specific.
44
+ const extraCss = `
45
+ #receipt { display: none; margin-top: 16px; }
46
+ .gate { font-size: .82rem; padding: 3px 0; }
47
+ .gate.pass { color: var(--success); } .gate.fail { color: var(--danger); }`;
48
+ return `<!doctype html>
49
+ <html lang="en">
50
+ ${pageHead(`Authorize payment (cross-device) · ${order}`, extraCss)}
51
+ <body>
52
+ <div class="wrap">
53
+ ${brandHeader({ h1: "Authorize payment", tagline: "Authorize from your wallet" })}
54
+ ${rail}
55
+ ${summary}
56
+ <div class="card">
57
+ <p class="lede">Present a payment credential from your phone wallet. Chrome shows a QR; scanning it uses the cross-device channel (FIDO caBLE). Your wallet signs over this exact amount, then 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>
58
+ <button id="go-dc" class="btn btn-primary">Authorize ${money(total, currency)} with my wallet</button>
59
+ <button id="go" class="btn btn-secondary">Authorize ${money(total, currency)} (instant demo)</button>
60
+ <div id="log"></div>
61
+ ${settlingBar()}
62
+ <div id="receipt"></div>
63
+ </div>
64
+ ${trustFooter()}
65
+ <script type="module">
66
+ const ORDER = ${JSON.stringify(order)};
67
+ const AMOUNT = ${JSON.stringify(total)};
68
+ const DEMO_CLAIMS = ${JSON.stringify(DEMO_CLAIMS)};
69
+ const RETURN_URL = ${JSON.stringify(returnUrl)};
70
+ const DONE_BANNER = ${JSON.stringify(completionHandoffBanner(returnUrl))};
71
+ const log = document.getElementById("log");
72
+ const goDc = document.getElementById("go-dc");
73
+ const btn = document.getElementById("go");
74
+ const settling = document.getElementById("settling");
75
+ const step = (t, c = "") => { const d = document.createElement("div"); d.className = "step " + c; d.textContent = t; log.appendChild(d); };
76
+ function notice(html) { const d = document.createElement("div"); d.className = "notice"; d.innerHTML = html; log.appendChild(d); }
77
+ // Escape any server-returned value before it goes into innerHTML (txId, accountId,
78
+ // the settlementError message): they're built server-side but never trusted raw.
79
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";");
80
+
81
+ // Pre-fetch the REAL signed OpenID4VP request so navigator.credentials.get() can be
82
+ // called SYNCHRONOUSLY inside the tap. iOS WebKit drops the transient user
83
+ // activation across an await, so we must not fetch between the click and get(). We
84
+ // keep a fresh pre-fetched request ready at all times. location.search carries the
85
+ // order this gate is scoped to, so /request re-prices THIS order (amount-bound).
86
+ let reqData = null;
87
+ function prefetch() {
88
+ reqData = null;
89
+ fetch("/attestomcp/dc-payment/request" + location.search).then((r) => r.json()).then((d) => { reqData = d; }).catch(() => {});
90
+ }
91
+
92
+ if (!("credentials" in navigator) || !window.DigitalCredential) {
93
+ goDc.disabled = true;
94
+ notice('This browser does not support <code>navigator.credentials.get({digital})</code> (needs <strong>Chrome 141+</strong>/Android or iOS 18+). Use the <strong>instant demo</strong> button.');
95
+ } else {
96
+ prefetch();
97
+ }
98
+
99
+ goDc.addEventListener("click", () => {
100
+ if (!("credentials" in navigator) || !window.DigitalCredential) {
101
+ notice('This browser does not support <code>navigator.credentials.get({digital})</code>. Use the instant-demo button.');
102
+ return;
103
+ }
104
+ if (!reqData || !reqData.request) { notice("Preparing the request — tap again in a second."); prefetch(); return; }
105
+ goDc.disabled = true;
106
+ const rd = reqData;
107
+ step("→ navigator.credentials.get({digital}) — Chrome should show a QR…");
108
+ // Called synchronously (no await before it) to keep the user activation.
109
+ navigator.credentials.get({ digital: { requests: [{ protocol: "openid4vp-v1-signed", data: { request: rd.request } }] }, mediation: "required" })
110
+ .then(async (result) => {
111
+ let data = result && result.data != null ? result.data : null;
112
+ if (typeof data === "string") { try { data = JSON.parse(data); } catch (e) {} }
113
+ step("→ verify · Settling via x402 on Hedera testnet (if configured)… can take ~10s");
114
+ settling.classList.add("on");
115
+ const out = await fetch("/attestomcp/dc-payment/verify", {
116
+ method: "POST", headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({ order: ORDER, readerContextToken: rd.readerContextToken, result: { protocol: (result && result.protocol) || null, data } }),
118
+ }).then((r) => r.json()).finally(() => settling.classList.remove("on"));
119
+ if (!out.mandate) throw new Error(out.error || "authorization failed");
120
+ step("✓ presentation verified · mandate built (" + out.mandate.trust_level + ")", "ok");
121
+ renderReceipt(out);
122
+ // Configured-but-failed settle: authorized, not settled — let the buyer retry.
123
+ if (out.settlementError) { step("✗ settlement failed — authorized, not settled (retry below)", "err"); goDc.disabled = false; prefetch(); }
124
+ })
125
+ .catch((err) => {
126
+ step("✗ " + ((err && err.message) || String(err)), "err");
127
+ goDc.disabled = false;
128
+ prefetch(); // fresh request for the next attempt
129
+ });
130
+ });
131
+
132
+ btn.addEventListener("click", async () => {
133
+ btn.disabled = true;
134
+ try {
135
+ step("→ verify (presence-only, amount-bound) · Settling via x402 on Hedera testnet (if configured)… can take ~10s");
136
+ settling.classList.add("on");
137
+ const out = await fetch("/attestomcp/dc-payment/verify", {
138
+ method: "POST", headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify({ order: ORDER, amount: AMOUNT, claims: DEMO_CLAIMS }),
140
+ }).then((r) => r.json()).finally(() => settling.classList.remove("on"));
141
+ if (!out.mandate) throw new Error(out.error || "authorization failed");
142
+ step("✓ presentation verified · mandate built (" + out.mandate.trust_level + ")", "ok");
143
+ renderReceipt(out);
144
+ // Configured-but-failed settle: authorized, not settled — let the buyer retry.
145
+ if (out.settlementError) { step("✗ settlement failed — authorized, not settled (retry below)", "err"); btn.disabled = false; }
146
+ } catch (err) {
147
+ step("✗ " + (err?.message ?? String(err)), "err");
148
+ btn.disabled = false;
149
+ }
150
+ });
151
+
152
+ function renderReceipt(out) {
153
+ const el = document.getElementById("receipt");
154
+ const gates = out.gates.map((g) => '<div class="gate ' + (g.pass ? "pass" : "fail") + '">' + (g.pass ? "✓" : "✗") + " " + g.gate + " — " + g.detail + "</div>").join("");
155
+ // The x402 on-chain settlement receipt. When the host settled (Hedera/blocky402),
156
+ // show the actual tinybar amount, payer/merchant, speed, tx, and a PROMINENT
157
+ // tappable HashScan link — the third-party proof the buyer (on their phone) taps
158
+ // straight into the live explorer. A configured-but-failed settle is the calm
159
+ // "authorized, not settled" line (FR-013) — never an alarming wall.
160
+ const s = out.settlement;
161
+ const settlement = s
162
+ ? '<div class="settle"><div class="settle-head">✓ Settled via x402 on Hedera testnet</div>' +
163
+ '<dl class="kv">' +
164
+ "<dt>Amount</dt><dd>" + (s.amountTinybar / 1e8) + ' ℏ <span class="dim">(' + esc(s.fxRate) + ")</span></dd>" +
165
+ "<dt>From</dt><dd>" + esc(s.payer.accountId) + ' <span class="dim">' +
166
+ (s.payer.kind === "session-wallet"
167
+ ? "wallet created for this order, " + (s.walletAgeMs / 1000).toFixed(1) + "s old when it paid"
168
+ : "demo customer") + "</span></dd>" +
169
+ "<dt>To</dt><dd>" + esc(s.payTo) + ' <span class="dim">merchant</span></dd>' +
170
+ "<dt>Speed</dt><dd>settled in " + (s.settledInMs / 1000).toFixed(1) + "s</dd>" +
171
+ '<dt>Tx</dt><dd><span class="mono">' + esc(s.txId) + "</span></dd>" +
172
+ "</dl>" +
173
+ '<a class="hashscan" href="' + esc(s.hashscanUrl) + '" target="_blank" rel="noopener">View on HashScan ›</a>' +
174
+ "</div>"
175
+ : out.settlementError
176
+ ? '<div class="settle-failed">✗ Settlement failed — authorized, not settled: ' + esc(out.settlementError) + "</div>"
177
+ : "";
178
+ // Every gate + payment is done ⇒ the order is COMPLETE. Lead with the prominent
179
+ // handoff: close this window and continue in the agent (the MCP host polls
180
+ // order-status and resumes). No auto-redirect — we don't yank the buyer off the
181
+ // "you're done" message; the on-chain proof + a secondary return link stay below.
182
+ const done = out.completed ? DONE_BANNER : "";
183
+ el.innerHTML = done + '<div class="row-ok">✓ Payment Mandate authorized (amount-bound)</div>' +
184
+ '<div class="small" style="margin:4px 0 8px;">' + out.mandate.id + "</div>" + gates + settlement;
185
+ el.style.display = "block";
186
+ if (out.completed) {
187
+ goDc.disabled = true;
188
+ btn.textContent = "Authorized ✓";
189
+ }
190
+ }
191
+ </script>
192
+ </div>
193
+ </body>
194
+ </html>`;
195
+ }
@@ -0,0 +1,17 @@
1
+ import type { CeremonyOrder } from "../types.js";
2
+ import type { Origin } from "../origin.js";
3
+ import type { DcqlQuery } from "../../types.js";
4
+ export interface SignedDcPaymentRequest {
5
+ protocol: "openid4vp-v1-signed";
6
+ /** The ES256-signed OpenID4VP request JWT (real). */
7
+ request: string;
8
+ /** The DCQL embedded in the signed request (echoed for callers/tests). */
9
+ dcql_query: DcqlQuery;
10
+ /** The amount-bound transaction_data entries (base64url) — REAL binding. */
11
+ transaction_data: string[];
12
+ /** Sealed reader context (ECDH key + bound transaction_data) carried to /verify. */
13
+ readerContextToken: string;
14
+ trust_level: "presence-only-demo";
15
+ }
16
+ /** Build the REAL signed OpenID4VP request for the payment credential. */
17
+ export declare function buildDcPaymentRequest(order: CeremonyOrder, origin: Origin, secret: string): Promise<SignedDcPaymentRequest>;
@@ -0,0 +1,50 @@
1
+ // REAL signed OpenID4VP request for the dc-payment rail. Faithfully ported from the
2
+ // demo's payment-gate/dc-payment/request.ts. It mints a reader cert
3
+ // (@peculiar/x509), an ephemeral ECDH response-encryption key, a fresh nonce, embeds
4
+ // the amount-bound `transaction_data` (txData.ts), and ES256-signs the verifier-bound
5
+ // request object (jose.SignJWT). The transaction_data — and the ECDH key — are sealed
6
+ // into a reader context (a JWE) so /verify re-checks the wallet's device-signed
7
+ // transaction_data_hash against SHA-256 of exactly what we sent.
8
+ //
9
+ // The crypto here is REAL (signed request, amount binding, origin/RP binding, sealed
10
+ // key); the issuer TRUST ANCHOR is not (the reader cert is self-signed) — trust_level
11
+ // stays presence-only-demo.
12
+ import * as jose from "jose";
13
+ import { buildDcPaymentDcql } from "./dcql.js";
14
+ import { buildTransactionData, encodeTransactionData } from "./txData.js";
15
+ import { makeReaderCert, makeEncryptionKey } from "../mdoc/reader.js";
16
+ import { sealReaderContext } from "../mdoc/readerContext.js";
17
+ /** Build the REAL signed OpenID4VP request for the payment credential. */
18
+ export async function buildDcPaymentRequest(order, origin, secret) {
19
+ const { x5c, privateKey } = await makeReaderCert(origin.rpID);
20
+ const { encJwk, ecdhPrivateJwk } = await makeEncryptionKey();
21
+ const dcql = buildDcPaymentDcql();
22
+ const txDataB64 = encodeTransactionData(buildTransactionData(order, origin));
23
+ const nonce = jose.base64url.encode(crypto.getRandomValues(new Uint8Array(16)));
24
+ const requestObject = {
25
+ response_type: "vp_token",
26
+ response_mode: "dc_api.jwt",
27
+ client_id: `x509_san_dns:${origin.rpID}`,
28
+ expected_origins: [origin.origin],
29
+ nonce,
30
+ dcql_query: dcql,
31
+ client_metadata: {
32
+ vp_formats_supported: { mso_mdoc: { issuerauth_alg_values: [-7], deviceauth_alg_values: [-7] } },
33
+ jwks: { keys: [encJwk] },
34
+ },
35
+ transaction_data: [txDataB64],
36
+ };
37
+ const request = await new jose.SignJWT(requestObject)
38
+ .setProtectedHeader({ alg: "ES256", typ: "oauth-authz-req+jwt", x5c: [x5c] })
39
+ .setIssuedAt()
40
+ .sign(privateKey);
41
+ const readerContextToken = await sealReaderContext({ ecdhPrivateJwk, transactionDataB64: txDataB64, nonce }, secret);
42
+ return {
43
+ protocol: "openid4vp-v1-signed",
44
+ request,
45
+ dcql_query: dcql,
46
+ transaction_data: [txDataB64],
47
+ readerContextToken,
48
+ trust_level: "presence-only-demo",
49
+ };
50
+ }
@@ -0,0 +1,2 @@
1
+ import { type RailRegistrar } from "../mount.js";
2
+ export declare const registerDcPaymentGate: RailRegistrar;
@@ -0,0 +1,147 @@
1
+ // The dc-payment rail (Digital Credentials API + OpenID4VP, amount-bound) — US3.
2
+ // Registers its routes onto the host app through the Foundational mount() seam:
3
+ // GET /attestomcp/dc-payment?order=<id> → the payment gate page
4
+ // GET /attestomcp/dc-payment/request?order=<id> → REAL signed OpenID4VP request (+ amount-bound transaction_data)
5
+ // POST /attestomcp/dc-payment/verify → instant-demo claims OR a real presentation → SHARED completeOrder
6
+ //
7
+ // Dependency-free (no `express` import — invariant from mount.ts): handlers register
8
+ // against the structural CeremonyApp.get/post, and the verify body is read either
9
+ // from a host-installed body parser (`req.body`) or straight off the request stream.
10
+ //
11
+ // EVERY route resolves the order by id THROUGH `resolveOrder` (catalog re-pricing; a
12
+ // tampered/unknown id is refused — CT3, invariant 2). Completion goes through the
13
+ // injected `ctx.completion` seam — the SAME shared `completeOrder` the passkey rail
14
+ // uses (no second completion path — FR-008, CT8): it re-prices, enforces the age
15
+ // gate, settles (when configured), records idempotently, and clears the cart +
16
+ // per-order verification.
17
+ //
18
+ // Verify has TWO paths feeding ONE set of gates + the SAME completion seam:
19
+ // • instant-demo — body carries `claims` directly (the tested default; CT6–CT8).
20
+ // • real presentation — body carries `result` from navigator.credentials.get; the
21
+ // wallet's JWE response is decrypted, the device-signed transaction_data_hash is
22
+ // re-checked against what we sealed, and the parsed mdoc drives the gates. The
23
+ // wire crypto is REAL; the issuer trust anchor is not (presence-only-demo).
24
+ import { resolveOrder } from "../mount.js";
25
+ import { buildDcPaymentRequest } from "./request.js";
26
+ import { buildDcMandate, runDcGates, verifyDcPresentation } from "./verify.js";
27
+ import { renderDcPaymentPage } from "./page.js";
28
+ function firstHeader(value) {
29
+ return Array.isArray(value) ? value[0] : value;
30
+ }
31
+ function originOf(ctx, req) {
32
+ const reqLike = { headers: req.headers, host: firstHeader(req.headers.host) ?? "localhost", protocol: req.protocol };
33
+ return ctx.origin(reqLike);
34
+ }
35
+ // Read the JSON body from a host-installed parser, or straight off the stream when
36
+ // no parser ran (so the rail is self-contained — it doesn't require the host to
37
+ // mount express.json()).
38
+ async function readJsonBody(req) {
39
+ if (req.body && typeof req.body === "object")
40
+ return req.body;
41
+ try {
42
+ const chunks = [];
43
+ for await (const chunk of req) {
44
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
45
+ }
46
+ if (chunks.length === 0)
47
+ return {};
48
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
49
+ }
50
+ catch {
51
+ return {};
52
+ }
53
+ }
54
+ export const registerDcPaymentGate = (app, ctx) => {
55
+ // The Foundational fail-fast tests mount() with a route-less app shape; only
56
+ // attach when the host app can actually route (CeremonyApp.get/post are optional).
57
+ const get = app.get?.bind(app);
58
+ const post = app.post?.bind(app);
59
+ if (!get || !post)
60
+ return;
61
+ // GET the gate page — re-priced order, presence-only honesty banner.
62
+ get("/attestomcp/dc-payment", async (req, res) => {
63
+ const order = await resolveOrder(ctx, typeof req.query.order === "string" ? req.query.order : undefined);
64
+ if (!order) {
65
+ res.status(404).type("html").send("<!doctype html><h1>Order not found</h1>");
66
+ return;
67
+ }
68
+ res.status(200).type("html").send(renderDcPaymentPage({
69
+ order: order.id,
70
+ total: order.total,
71
+ currency: order.currency,
72
+ lines: order.lines.map((l) => ({ name: l.name ?? l.id, quantity: l.quantity, lineTotal: l.lineTotal, currency: l.currency ?? order.currency })),
73
+ }));
74
+ });
75
+ // GET the REAL signed OpenID4VP request for this order — ES256-signed, carrying the
76
+ // amount-bound transaction_data, with the reader context (ECDH key + bound
77
+ // transaction_data) sealed for /verify.
78
+ get("/attestomcp/dc-payment/request", async (req, res) => {
79
+ const order = await resolveOrder(ctx, typeof req.query.order === "string" ? req.query.order : undefined);
80
+ if (!order) {
81
+ res.status(404).json({ error: "order not found" });
82
+ return;
83
+ }
84
+ try {
85
+ res.json(await buildDcPaymentRequest(order, originOf(ctx, req), ctx.signingKey));
86
+ }
87
+ catch (err) {
88
+ res.status(500).json({ error: err.message });
89
+ }
90
+ });
91
+ // POST verify — instant-demo claims OR a real wallet presentation. Resolve +
92
+ // re-price the order (CT3), build the amount-bound mandate, run the four
93
+ // deterministic gates, and complete THROUGH the shared completeOrder seam
94
+ // (FR-008, CT8).
95
+ post("/attestomcp/dc-payment/verify", async (req, res) => {
96
+ const body = await readJsonBody(req);
97
+ const order = await resolveOrder(ctx, typeof body.order === "string" ? body.order : undefined);
98
+ if (!order) {
99
+ res.status(400).json({ completed: false, error: "missing or invalid order" });
100
+ return;
101
+ }
102
+ const origin = originOf(ctx, req);
103
+ let mandate;
104
+ let gates;
105
+ try {
106
+ const result = body.result;
107
+ if (result && typeof result === "object") {
108
+ // REAL OpenID4VP presentation — decrypt the wallet's response, re-check the
109
+ // device-signed transaction_data_hash, and run the gates.
110
+ if (typeof body.readerContextToken !== "string") {
111
+ res.status(400).json({ completed: false, error: "missing readerContextToken for openid4vp presentation" });
112
+ return;
113
+ }
114
+ const out = await verifyDcPresentation({ order, origin, result, readerContextToken: body.readerContextToken, secret: ctx.signingKey });
115
+ mandate = out.mandate;
116
+ gates = out.gates;
117
+ }
118
+ else {
119
+ // Instant-demo claims path (the tested default).
120
+ const claims = (body.claims && typeof body.claims === "object" ? body.claims : {});
121
+ const presentedAmount = typeof body.amount === "number" ? body.amount : order.total;
122
+ mandate = buildDcMandate({ order, origin, claims, presentedAmount });
123
+ gates = runDcGates(mandate, origin);
124
+ }
125
+ }
126
+ catch (err) {
127
+ res.status(400).json({ completed: false, error: err.message, trust_level: "presence-only-demo" });
128
+ return;
129
+ }
130
+ // Complete through the SHARED seam (idempotent record + re-price + age gate +
131
+ // optional settle + clear cart & per-order verification). No second path.
132
+ const input = {
133
+ order,
134
+ mandateId: mandate.id,
135
+ amount: mandate.payment.amount,
136
+ currency: mandate.payment.currency,
137
+ method: "dc-payment",
138
+ instrument: mandate.payment.instrument,
139
+ gates,
140
+ };
141
+ const result = await ctx.completion(input);
142
+ // Forward the on-chain settlement (when configured + succeeded) AND the
143
+ // settlementError (a configured-but-failed settle → authorized-but-not-settled,
144
+ // FR-013) so the page can render the x402 receipt or the calm refusal line.
145
+ res.json({ mandate, gates, completed: result.completed, ...(result.reason ? { reason: result.reason } : {}), ...(result.settlement ? { settlement: result.settlement } : {}), ...(result.settlementError ? { settlementError: result.settlementError } : {}) });
146
+ });
147
+ };
@@ -0,0 +1,19 @@
1
+ import type { CeremonyOrder } from "../types.js";
2
+ import type { Origin } from "../origin.js";
3
+ export interface TransactionData {
4
+ type: "urn:eudi:sca:payment:1";
5
+ credential_ids: string[];
6
+ payload: {
7
+ transaction_id: string;
8
+ amount: number;
9
+ currency: string;
10
+ payee: {
11
+ id: string;
12
+ name: string;
13
+ };
14
+ };
15
+ }
16
+ export declare function buildTransactionData(order: CeremonyOrder, origin: Origin): TransactionData;
17
+ export declare function encodeTransactionData(txData: TransactionData): string;
18
+ export declare function hashTransactionData(txDataB64: string): string;
19
+ export declare function decodeTransactionData(txDataB64: string): TransactionData;
@@ -0,0 +1,34 @@
1
+ // Single source of truth for the OpenID4VP transaction_data entry (amount binding).
2
+ // Extracted from the demo's payment-gate/dc-payment/txData.ts, but DEPENDENCY-FREE:
3
+ // `jose.base64url.encode` becomes a Buffer base64url and the SHA-256 stays on
4
+ // node:crypto. Amount + payee come from the order + origin via the shared
5
+ // `buildBindingFields`, so the hash the wallet would sign is derived from the SAME
6
+ // fields Gate 1 (verify.ts#runDcGates) re-checks. The wallet's SIGNATURE over this
7
+ // hash is the PR-in-flight crypto (request.ts scaffolds the signed request); the
8
+ // binding itself is real here.
9
+ import { createHash, randomUUID } from "node:crypto";
10
+ import { buildBindingFields } from "../mandate.js";
11
+ export function buildTransactionData(order, origin) {
12
+ const b = buildBindingFields(order, origin);
13
+ return {
14
+ type: "urn:eudi:sca:payment:1",
15
+ credential_ids: ["dpc"],
16
+ payload: {
17
+ transaction_id: randomUUID(),
18
+ amount: b.amount,
19
+ currency: b.currency,
20
+ payee: b.payee,
21
+ },
22
+ };
23
+ }
24
+ export function encodeTransactionData(txData) {
25
+ return Buffer.from(JSON.stringify(txData), "utf8").toString("base64url");
26
+ }
27
+ // SHA-256 of the base64url transaction_data string, itself base64url. This is the
28
+ // value the wallet signs over (transaction_data_hash) and Gate 1 re-derives.
29
+ export function hashTransactionData(txDataB64) {
30
+ return createHash("sha256").update(txDataB64).digest("base64url");
31
+ }
32
+ export function decodeTransactionData(txDataB64) {
33
+ return JSON.parse(Buffer.from(txDataB64, "base64url").toString("utf8"));
34
+ }
@@ -0,0 +1,108 @@
1
+ import type { CeremonyOrder } from "../types.js";
2
+ import type { Origin } from "../origin.js";
3
+ export interface DcInstrument {
4
+ issuer: string | null;
5
+ instrumentId: string | null;
6
+ maskedAccount: string | null;
7
+ holder: string | null;
8
+ expiry: string | null;
9
+ }
10
+ export interface DcMandate {
11
+ type: "ap2.PaymentMandate";
12
+ version: "0.1-dc-demo";
13
+ id: string;
14
+ issuedAt: string;
15
+ expiresAt: string;
16
+ issuer: string;
17
+ subject: {
18
+ credentialId: string | null;
19
+ };
20
+ cart: CeremonyOrder;
21
+ payment: {
22
+ instrument: DcInstrument;
23
+ amount: number;
24
+ currency: string;
25
+ };
26
+ userAuthorization: {
27
+ type: "openid4vp-dc-api";
28
+ /** The amount-bound transaction_data the wallet would sign over (base64url). */
29
+ transactionData: string;
30
+ /**
31
+ * The transaction_data_hash Gate 1 re-checks against SHA-256(transactionData).
32
+ * Instant-demo: the server's own hash (always matches). REAL path: the value
33
+ * EXTRACTED from the wallet's device-signed DeviceResponse — so Gate 1 verifies
34
+ * the wallet actually authorized THIS amount/payee.
35
+ */
36
+ transactionDataHash: string | null;
37
+ /** Presence-only: the instrument was disclosed but not cryptographically verified. */
38
+ presented: boolean;
39
+ /**
40
+ * REAL path only — the wallet's raw mdoc vp_token (base64url DeviceResponse) and
41
+ * the structural issuerAuth/deviceAuth presence Gate 2 reads. Absent on the
42
+ * instant-demo path (Gate 2 falls back to instrument presence there).
43
+ */
44
+ vpToken?: string;
45
+ authBlocks?: {
46
+ hasIssuerAuth: boolean;
47
+ hasDeviceAuth: boolean;
48
+ };
49
+ };
50
+ trust_level: "presence-only-demo";
51
+ }
52
+ /**
53
+ * Build the presence-only DC payment mandate. The transaction_data is derived from
54
+ * the (catalog-re-priced) order + this RP's origin, so its amount/payee are the
55
+ * server's truth; `presentedAmount` is what the caller asserts authorizing (Gate 1
56
+ * re-checks it equals the re-derived payable — a tampered value is refused).
57
+ */
58
+ export declare function buildDcMandate(args: {
59
+ order: CeremonyOrder;
60
+ origin: Origin;
61
+ claims: Record<string, unknown>;
62
+ presentedAmount?: number;
63
+ issuer?: string;
64
+ }): DcMandate;
65
+ export interface GateResult {
66
+ gate: string;
67
+ pass: boolean;
68
+ detail: string;
69
+ }
70
+ export declare function runDcGates(mandate: DcMandate, origin: Origin, opts?: {
71
+ loyaltyDiscountPct?: number;
72
+ }): GateResult[];
73
+ /**
74
+ * Build the DC mandate from a REAL wallet DeviceResponse. Unlike the instant-demo
75
+ * builder, `transactionDataHash` is the value EXTRACTED from the wallet's
76
+ * device-signed mdoc (deviceSigned/transaction_data_hash) — Gate 1 then re-checks it
77
+ * equals SHA-256 of the transaction_data WE sealed (transactionDataB64). The vpToken
78
+ * + parsed issuerAuth/deviceAuth presence drive Gate 2. The instrument fields come
79
+ * from the issuer-signed namespaces of the SAME DeviceResponse.
80
+ */
81
+ export declare function buildDcMandateFromPresentation(args: {
82
+ order: CeremonyOrder;
83
+ vpStr: string;
84
+ transactionDataB64: string;
85
+ issuer?: string;
86
+ }): DcMandate;
87
+ export interface DcVerification {
88
+ mandate: DcMandate;
89
+ gates: GateResult[];
90
+ }
91
+ /**
92
+ * Verify the wallet's REAL OpenID4VP presentation: open the sealed reader context,
93
+ * JWE-decrypt the response (jose ECDH-ES compactDecrypt), pull the mdoc vp_token,
94
+ * build the amount-bound mandate from the device-signed DeviceResponse, and run the
95
+ * four gates. Faithfully ported from the demo's payment-gate/dc-payment/verify.ts.
96
+ * The crypto (decryption + the transaction_data hash binding) is REAL; the issuer
97
+ * trust anchor is not (trust_level presence-only-demo).
98
+ */
99
+ export declare function verifyDcPresentation(args: {
100
+ order: CeremonyOrder;
101
+ origin: Origin;
102
+ result: {
103
+ protocol?: string;
104
+ data?: unknown;
105
+ };
106
+ readerContextToken: string;
107
+ secret: string;
108
+ }): Promise<DcVerification>;