@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,269 @@
1
+ // renderRequirements() — ONE polished three-gate checkout page, served by both the
2
+ // committed demo and @openmobilehub/attestomcp-storefront. Driven by the `requires`
3
+ // manifest (the data `requirements()` emits) + this order's per-order verification
4
+ // state, so the page reflects exactly what the buyer has and hasn't done.
5
+ //
6
+ // ROUTE-AGNOSTIC. The renderer never hardcodes a ceremony route: each non-payment
7
+ // gate links to its own manifest entry's `approveUrl` (the demo's token-bearing
8
+ // `/credential-gate/*` link or the storefront's mounted `/attestomcp/*` link, both flow
9
+ // through unchanged). The payment section's affordances are supplied by the host
10
+ // (`PaymentMethod[]` + the place-order endpoint), so the demo can reproduce its rich
11
+ // passkey / cross-device / instant-demo group while a leaner host derives a single
12
+ // Pay CTA from the payment entry's `approveUrl`.
13
+ //
14
+ // Security note: this is PRESENTATION. The lock the page renders is render-only —
15
+ // every completion path (place-order, the rails' /verify handlers) re-enforces the
16
+ // age gate server-side (Security invariant 1). Hiding the payment group is not the
17
+ // control; it just keeps the UI honest about what the server will refuse.
18
+ import { pageHead, brandHeader, progressRail, trustFooter } from "./theme.js";
19
+ // ── Helpers ─────────────────────────────────────────────────────────────────
20
+ function formatMoney(amount, currency) {
21
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
22
+ }
23
+ function escapeHtml(s) {
24
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
25
+ }
26
+ // The single honesty surface (FR-011 / Principle VII): every entry carries the same
27
+ // trust_level; state it once on the page (the shared discreet footer) so the gates
28
+ // never read as a real safety control. Suppressed only when the flow is issuer-verified.
29
+ function trustNote(entries) {
30
+ if (entries[0]?.trust_level === "issuer-verified")
31
+ return "";
32
+ return trustFooter();
33
+ }
34
+ // Map the manifest to the three-step progress rail (Age · Membership · Pay) with live
35
+ // status, so the hub mirrors the same stepper the gate pages render. A gate the manifest
36
+ // doesn't carry simply doesn't appear; payment is always the trailing step.
37
+ function railSteps(gateEntries, ageVerified, loyaltyApplied, paid) {
38
+ const steps = [];
39
+ for (const e of gateEntries) {
40
+ if (e.effect === "gate" && e.credential === "age")
41
+ steps.push({ label: "Age", done: ageVerified || paid });
42
+ else if (e.effect === "discount")
43
+ steps.push({ label: "Membership", done: loyaltyApplied || paid });
44
+ }
45
+ steps.push({ label: "Pay", done: paid });
46
+ return steps;
47
+ }
48
+ // ── The page ──────────────────────────────────────────────────────────────────
49
+ /**
50
+ * Render the unified checkout page: the order summary, the numbered gates (in policy
51
+ * order, payment LAST) with live status, and the payment section — locked until every
52
+ * blocking gate passes. The membership discount is reflected in the displayed total
53
+ * via `order.discount` (the host re-prices server-side and passes the priced order).
54
+ */
55
+ export function renderRequirements(order, manifest, verification = {}, opts = {}) {
56
+ const ageVerified = !!verification.ageVerified;
57
+ const loyaltyApplied = !!verification.loyaltyApplied;
58
+ const paid = opts.paid ?? null;
59
+ // Payment settles last: split the manifest into the blocking/discount gates (kept
60
+ // in declared order) and the single authorize entry, which becomes the payment
61
+ // section. requirements() already sorts authorize last, but be explicit so a
62
+ // hand-built manifest renders the same.
63
+ const gateEntries = manifest.filter((e) => e.effect !== "authorize");
64
+ const paymentEntry = manifest.find((e) => e.effect === "authorize");
65
+ // A REQUIRED gate that isn't yet satisfied blocks payment. Age is the demo's
66
+ // blocking gate; a discount never blocks (it's an opt-in saving).
67
+ const isSatisfied = (e) => {
68
+ if (e.effect === "gate" && e.credential === "age")
69
+ return ageVerified;
70
+ if (e.effect === "discount")
71
+ return loyaltyApplied;
72
+ return false;
73
+ };
74
+ const blocked = gateEntries.some((e) => e.required && e.effect === "gate" && !isSatisfied(e));
75
+ // ── order summary ────────────────────────────────────────────────────────
76
+ // A paid revisit arrives after completion CLEARED this order's verification, so a
77
+ // re-priced order may have dropped the discount it was actually paid at and disagree
78
+ // with the recorded `paid.amount`. Anchor the displayed total on that authoritative
79
+ // amount, deriving the discount row from the line-sum − paid difference so the table
80
+ // and the paid banner always agree (a host that pre-anchors gets the same numbers).
81
+ const lineSum = order.lines.reduce((s, l) => s + l.lineTotal, 0);
82
+ const displayTotal = paid ? paid.amount : order.total;
83
+ const displayDiscount = paid
84
+ ? Math.max(0, Math.round((lineSum - paid.amount) * 100) / 100)
85
+ : order.discount;
86
+ const rows = order.lines
87
+ .map((l) => {
88
+ const name = l.name ?? l.id ?? "Item";
89
+ return `<tr class="line"><td>${escapeHtml(name)} <span class="qty">×${l.quantity}</span></td><td class="num">${formatMoney(l.lineTotal, l.currency ?? order.currency)}</td></tr>`;
90
+ })
91
+ .join("\n");
92
+ const discountPct = manifest.find((e) => e.effect === "discount")?.discountPct;
93
+ const discountRow = displayDiscount > 0
94
+ ? `<tr class="disc"><td>Loyalty discount${discountPct != null ? ` (${discountPct}%)` : ""}</td><td class="num">-${formatMoney(displayDiscount, order.currency)}</td></tr>`
95
+ : "";
96
+ // ── numbered gates (live status) ───────────────────────────────────────────
97
+ const gateSections = gateEntries
98
+ .map((e, i) => renderGate(e, i + 1, isSatisfied(e)))
99
+ .filter((s) => s !== "")
100
+ .join("\n");
101
+ // ── payment section (locked until the blocking gates pass) ─────────────────
102
+ // Resolve the method list ONCE so the rendered group and its CTA script agree:
103
+ // either the host's explicit methods, or a single Pay CTA derived from the
104
+ // authorize entry's approveUrl (route-agnostic — works for any mounted rail).
105
+ const methods = opts.payment?.methods ??
106
+ (paymentEntry?.approveUrl
107
+ ? [{ value: "pay", name: paymentEntry.label ?? "Authorize payment", desc: "Authorize on your device.", href: paymentEntry.approveUrl, checked: true }]
108
+ : []);
109
+ const paymentNumber = gateEntries.length + 1;
110
+ const paidSection = paid ? renderPaid(paid) : "";
111
+ // Calm, muted lock — never alarming. Keeps the literal "Payment is locked" the flow
112
+ // tests pin, framed as a gentle "unlocks after age verification" message.
113
+ const paymentSection = paid
114
+ ? `<div class="card section">${paidSection}</div>`
115
+ : blocked
116
+ ? `<div class="lock">🔒 Payment is locked · unlocks after age verification</div>`
117
+ : renderPayment(order, paymentNumber, methods);
118
+ const placeScript = paid || blocked ? "" : renderPlaceScript(order, methods, opts.payment);
119
+ // Progress rail mirrors the live gate status; current = first not-done step.
120
+ const steps = railSteps(gateEntries, ageVerified, loyaltyApplied, !!paid);
121
+ const rail = progressRail(steps, steps.findIndex((s) => !s.done));
122
+ const itemCount = order.itemCount ?? order.lines.reduce((n, l) => n + l.quantity, 0);
123
+ // bfcache guard. After authorizing on a gate page (passkey / dc-payment), a buyer
124
+ // who taps the browser BACK button lands on this checkout restored from the
125
+ // back/forward cache — a STALE snapshot of the pre-payment page, with its Pay
126
+ // button still live. Server-side completion is idempotent (a resubmit never
127
+ // double-charges — completion.ts re-reads the recorded order), but the UI would
128
+ // wrongly invite a second payment. `pageshow` with `persisted` is the
129
+ // cross-browser-reliable bfcache signal (Safari kept bfcache despite `no-store`);
130
+ // on a restore we force a fresh GET so the page reflects current server state
131
+ // (the paid banner, not the picker).
132
+ const bfcacheGuard = `<script>window.addEventListener("pageshow",function(e){if(e.persisted)location.reload();});</script>`;
133
+ return `<!doctype html>
134
+ <html lang="en">
135
+ ${pageHead(`Checkout · ${order.id}`)}
136
+ <body>
137
+ <div class="wrap">
138
+ ${brandHeader({ h1: "Checkout", tagline: "Prove it. Then pay." })}
139
+ <div class="card summary">
140
+ <p class="card-title">Order ${escapeHtml(order.id)} · ${itemCount} item(s)</p>
141
+ <table>
142
+ ${rows}
143
+ ${discountRow}
144
+ <tr class="total"><td>Total</td><td class="num">${formatMoney(displayTotal, order.currency)}</td></tr>
145
+ </table>
146
+ </div>
147
+
148
+ ${rail}
149
+ ${paid ? "" : gateSections}
150
+ ${paymentSection}
151
+ ${placeScript}
152
+ ${paid ? "" : trustNote(manifest)}
153
+ ${bfcacheGuard}
154
+ </div>
155
+ </body>
156
+ </html>`;
157
+ }
158
+ // One numbered gate card with live status (pending → ✓), built from its manifest
159
+ // entry. Links to the entry's OWN approveUrl (route-agnostic). Returns "" for a
160
+ // discount entry that the host renders no approve link for.
161
+ function renderGate(entry, n, satisfied) {
162
+ // `step-no` is kept (tests + the rail both read off the numbered policy order); the
163
+ // card chrome and teal accent come from the shared design system.
164
+ const no = `<span class="step-no">${n}.</span>`;
165
+ if (entry.effect === "discount") {
166
+ const pct = entry.discountPct;
167
+ return satisfied
168
+ ? `<div class="card"><div class="row-ok">${no} ✓ Loyalty discount applied${pct != null ? ` (${pct}% off)` : ""}</div></div>`
169
+ : entry.approveUrl
170
+ ? `<div class="card"><a class="btn btn-secondary" href="${escapeHtml(entry.approveUrl)}">${no} Apply loyalty discount${pct != null ? ` (${pct}% off)` : ""}</a></div>`
171
+ : "";
172
+ }
173
+ // gate effect (age):
174
+ const age = entry.minAge ?? 21;
175
+ if (satisfied) {
176
+ return `<div class="card"><div class="row-ok">${no} ✓ Age verified — ${age}+</div></div>`;
177
+ }
178
+ const link = entry.approveUrl
179
+ ? `<a class="btn btn-primary" href="${escapeHtml(entry.approveUrl)}">Verify age (${age}+)</a>`
180
+ : "";
181
+ return `<div class="card"><div class="row-pending">${no} 🔒 This order contains age-restricted items. Verify you're ${age} or older to continue.</div>${link ? `<div style="margin-top:12px;">${link}</div>` : ""}</div>`;
182
+ }
183
+ // The Shopify-style payment-method group (one radio group, one Pay CTA). The methods
184
+ // are resolved by the caller (host-supplied, or derived from the authorize entry's
185
+ // approveUrl) and shared with the CTA script so the two never disagree.
186
+ function renderPayment(order, n, methods) {
187
+ const payLabel = `Pay ${formatMoney(order.total, order.currency)}`;
188
+ if (methods.length === 0)
189
+ return "";
190
+ const anyChecked = methods.some((m) => m.checked);
191
+ const rows = methods
192
+ .map((m, i) => {
193
+ const checked = m.checked ?? (!anyChecked && i === 0);
194
+ return ` <label class="pm-row">
195
+ <input type="radio" name="pm" value="${escapeHtml(m.value)}"${checked ? " checked" : ""} />
196
+ <span class="pm-text"><span class="pm-name">${escapeHtml(m.name)}</span>
197
+ <span class="pm-desc">${escapeHtml(m.desc)}</span></span>
198
+ </label>`;
199
+ })
200
+ .join("\n");
201
+ return `<div class="card">
202
+ <h2 class="pm-head">${n}. Payment method</h2>
203
+ <div class="pm-group" role="radiogroup" aria-label="Payment method">
204
+ ${rows}
205
+ </div>
206
+ <button id="pay" class="btn btn-primary" style="margin-top:14px;">${payLabel}</button>
207
+ <p class="small" style="text-align:center;margin:10px 0 0;">You'll confirm the exact amount with your device. Demo — no real charge.</p>
208
+ </div>`;
209
+ }
210
+ // The CTA script: selecting a method narrates the CTA; paying navigates to that
211
+ // method's href (a gate page) or POSTs the order token for the instant-demo method.
212
+ function renderPlaceScript(order, methods, payment) {
213
+ const payLabel = `Pay ${formatMoney(order.total, order.currency)}`;
214
+ if (methods.length === 0)
215
+ return "";
216
+ const placePath = payment?.placeOrderPath ?? "/checkout/place-order";
217
+ const token = payment?.orderToken ?? "";
218
+ // value → { href? , placeOrder? } for the client to act on.
219
+ const map = {};
220
+ for (const m of methods)
221
+ map[m.value] = { ...(m.href ? { href: m.href } : {}), ...(m.placeOrder ? { placeOrder: true } : {}) };
222
+ return `<script>
223
+ const METHODS = ${JSON.stringify(map)};
224
+ const PAY_LABEL = ${JSON.stringify(payLabel)};
225
+ const PLACE_PATH = ${JSON.stringify(placePath)};
226
+ const ORDER_TOKEN = ${JSON.stringify(token)};
227
+ const pay = document.getElementById('pay');
228
+ if (pay) {
229
+ const selected = () => document.querySelector('input[name="pm"]:checked').value;
230
+ const relabel = () => {
231
+ const m = METHODS[selected()] || {};
232
+ pay.textContent = m.placeOrder ? 'Place order (instant demo)' : PAY_LABEL;
233
+ };
234
+ document.querySelectorAll('input[name="pm"]').forEach((r) => r.addEventListener('change', relabel));
235
+ relabel();
236
+ pay.addEventListener('click', async function () {
237
+ const m = METHODS[selected()] || {};
238
+ if (!m.placeOrder) { if (m.href) window.location.href = m.href; return; }
239
+ this.disabled = true;
240
+ this.textContent = 'Placing order…';
241
+ try {
242
+ const res = await fetch(PLACE_PATH, {
243
+ method: 'POST',
244
+ headers: { 'content-type': 'application/json' },
245
+ body: JSON.stringify({ order: ORDER_TOKEN }),
246
+ });
247
+ if (!res.ok) throw new Error('place-order failed: ' + res.status);
248
+ this.textContent = 'Order placed ✓ (demo)';
249
+ } catch (e) {
250
+ this.disabled = false;
251
+ this.textContent = 'Place order (instant demo)';
252
+ alert('Could not place the order. Please try again.');
253
+ }
254
+ });
255
+ }
256
+ </script>`;
257
+ }
258
+ // The paid banner shown when revisiting an already-completed order. Settlement
259
+ // details (when present) carry the public on-chain proof.
260
+ function renderPaid(paid) {
261
+ const via = paid.settlement ? " via x402" : paid.method === "passkey" ? " via passkey" : "";
262
+ // The order is complete — lead with the prominent handoff (close the window, the
263
+ // agent polls order-status and continues), then the on-chain proof below.
264
+ const banner = `<div class="complete-banner"><div class="big">✓ Order paid · ${formatMoney(paid.amount, paid.currency)}${via}</div><div class="sub">You can <strong>close this window</strong> and continue in your agent — it has your order and will pick up from here.</div></div>`;
265
+ const detail = paid.settlement
266
+ ? `<p class="small" style="margin:0;">Settled on ${escapeHtml(paid.settlement.network)} · paid from ${escapeHtml(paid.settlement.payer.accountId)} · <a href="${escapeHtml(paid.settlement.hashscanUrl)}" target="_blank" rel="noopener">View on HashScan</a></p>`
267
+ : `<p class="small" style="margin:0;">No on-chain settlement for this payment method.</p>`;
268
+ return banner + detail;
269
+ }
@@ -0,0 +1,41 @@
1
+ import type { VerificationStore } from "../types.js";
2
+ import type { CeremonyCatalog, CompletionInput, CompletionResult, GateOutcome } from "./types.js";
3
+ export interface SettlementRecordLike {
4
+ network: string;
5
+ txId: string;
6
+ status: string;
7
+ [k: string]: unknown;
8
+ }
9
+ export interface CompletedRecord {
10
+ orderId: string;
11
+ mandateId: string;
12
+ amount: number;
13
+ currency: string;
14
+ method: string;
15
+ instrument?: unknown;
16
+ gates: GateOutcome[];
17
+ completedAt: string;
18
+ settlement?: SettlementRecordLike;
19
+ }
20
+ export interface CompletedOrderStore {
21
+ read(orderId: string): CompletedRecord | undefined | Promise<CompletedRecord | undefined>;
22
+ write(record: CompletedRecord): void | Promise<void>;
23
+ }
24
+ export interface ClearableCart {
25
+ clear(): void | Promise<void>;
26
+ }
27
+ export interface CompletionContext {
28
+ catalog: CeremonyCatalog;
29
+ verificationStore: VerificationStore;
30
+ /** Idempotent completed-order store, keyed by order id. */
31
+ records: CompletedOrderStore;
32
+ /** Cart to empty on completion (optional). */
33
+ cart?: ClearableCart;
34
+ /** Optional demo-mode settlement; throwing GATES completion (no record). */
35
+ settle?: (order: CompletionInput["order"]) => Promise<SettlementRecordLike>;
36
+ /** Optional HMAC key for Cart Mandate verification. When set AND the input carries a
37
+ * `cartMandate`, completion verifies it (signature + order-id binding + expiry)
38
+ * before re-pricing. Absent ⇒ the cart-mandate check is skipped (additive). */
39
+ signingKey?: string;
40
+ }
41
+ export declare function completeOrder(input: CompletionInput, ctx: CompletionContext): Promise<CompletionResult>;
@@ -0,0 +1,90 @@
1
+ import { verifyCartMandate } from "./cartMandate.js";
2
+ import { reconcileCartPayment } from "./reconciliation.js";
3
+ export async function completeOrder(input, ctx) {
4
+ // Every deterministic gate must have passed; one failure refuses, recording
5
+ // nothing.
6
+ if (!input.gates.every((g) => g.pass))
7
+ return { completed: false, reason: "gates" };
8
+ // Idempotency: a replayed verify for an already-recorded order echoes the
9
+ // recorded outcome — it settles/records nothing twice. Keyed by order id so it
10
+ // can't collide across orders, and it runs BEFORE re-pricing because completion
11
+ // clears the order's verification (a replayed discounted order would otherwise
12
+ // reprice high and refuse).
13
+ const existing = await ctx.records.read(input.order.id);
14
+ if (existing) {
15
+ return { completed: true, ...(existing.settlement ? { settlement: existing.settlement } : {}) };
16
+ }
17
+ // Cart Mandate integrity (additive, fail-closed): if a signed cart mandate rode along
18
+ // AND we hold the key, verify it BEFORE re-pricing — a tampered, replayed (wrong-order)
19
+ // or expired cart is refused here with an explicit reason. The catalog STILL re-derives
20
+ // the price below; the signature proves the server issued the cart, not the price
21
+ // (invariant 2). A valid-signature-but-wrong-price mandate therefore still fails the
22
+ // re-price check — the mandate is defense-in-depth, never a substitute for it. The
23
+ // verified mandate is reconciled against the Payment Mandate's binding AFTER re-pricing.
24
+ let cartMandate;
25
+ if (input.cartMandate && ctx.signingKey) {
26
+ const verdict = verifyCartMandate(input.cartMandate, input.order.id, ctx.signingKey);
27
+ if (!verdict.ok)
28
+ return { completed: false, reason: "cart-mandate" };
29
+ cartMandate = verdict.mandate;
30
+ }
31
+ // Invariant 2: never trust the order token — re-price the lines against the
32
+ // catalog and refuse if the inbound total doesn't match what those items cost.
33
+ // Invariant 3: a loyalty discount only counts when THIS order's verification
34
+ // says it was applied; a token merely claiming the discounted total reprices
35
+ // higher and is refused.
36
+ const verification = await ctx.verificationStore.read(input.order.id);
37
+ const loyaltyApplied = !!verification?.loyalty?.applied;
38
+ const items = input.order.lines.map((l) => ({ productId: l.id, quantity: l.quantity }));
39
+ const repriced = ctx.catalog.createOrder(items, input.order.id, { loyaltyApplied });
40
+ if (repriced.total !== input.order.total)
41
+ return { completed: false, reason: "reprice" };
42
+ // Invariant 3: when a signed Cart Mandate AND a signed Payment Mandate are both
43
+ // present, the two envelopes must tell ONE story before completing — same order,
44
+ // consistent currency, and the cart's sealed total == the catalog-RE-DERIVED total
45
+ // == the Payment Mandate's bound amount (`input.amount`, projected from
46
+ // `mandate.payment` by every rail). This binds the cart's seal to the payment's
47
+ // signature across ALL paths: a cart sealed for X paired with a payment for Y≠X, a
48
+ // currency or order mismatch, or a discount one path blesses and another refuses is
49
+ // refused here, never silently under-charged. Re-priced (not the token) per invariant 2.
50
+ if (cartMandate) {
51
+ const agree = reconcileCartPayment(cartMandate, { amount: input.amount, currency: input.currency, orderId: input.order.id }, repriced.total);
52
+ if (!agree.ok)
53
+ return { completed: false, reason: "reconcile" };
54
+ }
55
+ // Invariant 1: enforce the age gate on EVERY completion path. The age restriction
56
+ // is re-derived from the catalog-priced lines (never the token); an age-restricted
57
+ // order must carry a positive per-order age claim — written by the credential
58
+ // gate's verify handler (credential-gate/routes.ts) — before it can complete. This
59
+ // is the shared-completion-seam half of CT9; the demo's place-order + MCP
60
+ // order-completion-tool halves are wired in T014.
61
+ const ageRestricted = repriced.lines.some((l) => typeof l.minimumAge === "number" && l.minimumAge > 0);
62
+ if (ageRestricted && verification?.ageVerified !== true) {
63
+ return { completed: false, reason: "age" };
64
+ }
65
+ let settlement;
66
+ if (ctx.settle) {
67
+ try {
68
+ settlement = await ctx.settle(input.order);
69
+ }
70
+ catch (err) {
71
+ return { completed: false, settlementError: err.message };
72
+ }
73
+ }
74
+ await ctx.records.write({
75
+ orderId: input.order.id,
76
+ mandateId: input.mandateId,
77
+ amount: input.amount,
78
+ currency: input.currency,
79
+ method: input.method,
80
+ instrument: input.instrument,
81
+ gates: input.gates,
82
+ completedAt: new Date().toISOString(),
83
+ ...(settlement ? { settlement } : {}),
84
+ });
85
+ if (ctx.cart)
86
+ await ctx.cart.clear();
87
+ // Completed purchase: clear this order's age/loyalty verification.
88
+ await ctx.verificationStore.clear(input.order.id);
89
+ return { completed: true, ...(settlement ? { settlement } : {}) };
90
+ }
@@ -0,0 +1,10 @@
1
+ import type { DcqlQuery } from "../../types.js";
2
+ export type CredentialKind = "age" | "membership";
3
+ export interface CredentialDcqlOpts {
4
+ /** Age threshold (defaults to 21 — the strictest common restriction). */
5
+ minimumAge?: number;
6
+ /** Membership discount percent (defaults to 10). */
7
+ percent?: number;
8
+ }
9
+ /** The DCQL the signed request embeds for this credential kind. */
10
+ export declare function buildCredentialDcql(kind: CredentialKind, opts?: CredentialDcqlOpts): DcqlQuery;
@@ -0,0 +1,12 @@
1
+ // DCQL for the credential gate (age / membership). One query per kind, reused
2
+ // from the package's own credential builders (credentials.ts) so the request the
3
+ // wallet receives is the SAME shape the policy layer describes — no second source
4
+ // of truth to drift. age → ISO 18013-5 mDL + EU PID over-age claims; membership →
5
+ // the loyalty doctype. verify.ts maps the disclosed claims back to a boolean.
6
+ import { age, membership } from "../../credentials.js";
7
+ /** The DCQL the signed request embeds for this credential kind. */
8
+ export function buildCredentialDcql(kind, opts = {}) {
9
+ return kind === "age"
10
+ ? age.over(opts.minimumAge ?? 21).request
11
+ : membership.discount(opts.percent ?? 10).request;
12
+ }
@@ -0,0 +1,3 @@
1
+ import type { MdocDocSpec } from "../mdoc/mdoc-iso.js";
2
+ import type { CredentialKind } from "./dcql.js";
3
+ export declare function mdocDocSpec(kind: CredentialKind, minimumAge?: number): MdocDocSpec;
@@ -0,0 +1,16 @@
1
+ export function mdocDocSpec(kind, minimumAge = 21) {
2
+ if (kind === "age") {
3
+ return {
4
+ docType: "org.iso.18013.5.1.mDL",
5
+ namespace: "org.iso.18013.5.1",
6
+ // Ask for the over-age booleans bracketing the threshold; verify.ts requires
7
+ // the explicit positive at THIS threshold (a sub-threshold proof is refused).
8
+ elements: minimumAge >= 21 ? ["age_over_21", "age_over_18"] : ["age_over_18"],
9
+ };
10
+ }
11
+ return {
12
+ docType: "org.multipaz.loyalty.1",
13
+ namespace: "org.multipaz.loyalty.1",
14
+ elements: ["membership_number", "tier"],
15
+ };
16
+ }
@@ -0,0 +1,15 @@
1
+ import type { Origin } from "../origin.js";
2
+ import { type CredGateResult } from "./verify.js";
3
+ import type { CredentialKind } from "./dcql.js";
4
+ export declare function verifyMdocPresentation(args: {
5
+ kind: CredentialKind;
6
+ result: {
7
+ protocol?: string;
8
+ data?: unknown;
9
+ };
10
+ mdocContextToken: string;
11
+ origin: Origin;
12
+ secret: string;
13
+ minimumAge?: number;
14
+ percent?: number;
15
+ }): Promise<CredGateResult>;
@@ -0,0 +1,29 @@
1
+ import { evaluateDisclosed } from "./verify.js";
2
+ import { mdocDocSpec } from "./doc-spec.js";
3
+ import { openMdocContext, buildSessionTranscript, decryptDeviceResponse, disclosedFromDeviceResponse, } from "../mdoc/mdoc-iso.js";
4
+ export async function verifyMdocPresentation(args) {
5
+ const { kind, result, mdocContextToken, origin, secret, minimumAge, percent } = args;
6
+ const ctx = await openMdocContext(mdocContextToken, secret);
7
+ let data = result?.data;
8
+ if (typeof data === "string") {
9
+ try {
10
+ data = JSON.parse(data);
11
+ }
12
+ catch { /* leave as string */ }
13
+ }
14
+ const responseB64Url = data?.response ??
15
+ (typeof data === "string" ? data : undefined);
16
+ if (!responseB64Url)
17
+ throw new Error("no .response in org-iso-mdoc result.data");
18
+ const sessionTranscript = buildSessionTranscript(ctx.base64EncryptionInfo, origin.origin);
19
+ const deviceResponse = await decryptDeviceResponse({
20
+ responseB64Url,
21
+ readerPrivateJwk: ctx.readerPrivateJwk,
22
+ sessionTranscript,
23
+ });
24
+ const disclosed = disclosedFromDeviceResponse(deviceResponse);
25
+ // The iOS DeviceRequest is built from this same doc spec; keep it referenced so
26
+ // the request/verify pair stays aligned to one doctype definition.
27
+ void mdocDocSpec(kind, minimumAge);
28
+ return evaluateDisclosed(kind, disclosed, { minimumAge, percent });
29
+ }
@@ -0,0 +1,20 @@
1
+ import type { CredentialKind } from "./dcql.js";
2
+ export interface CredentialPageArgs {
3
+ kind: CredentialKind;
4
+ /** Order id, echoed back so verify is scoped to one order. */
5
+ order: string;
6
+ /** Re-derived from the catalog (age gate). */
7
+ minimumAge?: number;
8
+ /** Catalog-priced total, shown for context (never the token's). */
9
+ total?: number;
10
+ currency?: string;
11
+ /** Membership discount percent (membership gate). */
12
+ percent?: number;
13
+ /**
14
+ * Where to send the buyer after this gate succeeds — the checkout hub, so the
15
+ * sequence flows (hub → gate → back to hub with this gate ✓ → next gate).
16
+ * Defaults to this server's `/checkout?order=<id>`.
17
+ */
18
+ returnUrl?: string;
19
+ }
20
+ export declare function renderCredentialPage(args: CredentialPageArgs): string;