@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.
- package/LICENSE +201 -0
- package/README.md +172 -0
- package/dist/ceremony/cartMandate.d.ts +61 -0
- package/dist/ceremony/cartMandate.js +76 -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 +269 -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 +20 -0
- package/dist/ceremony/credential-gate/page.js +136 -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 +200 -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 +18 -0
- package/dist/ceremony/dc-payment/page.js +195 -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 +147 -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 +57 -0
- package/dist/ceremony/mount.js +96 -0
- package/dist/ceremony/origin.d.ts +10 -0
- package/dist/ceremony/origin.js +9 -0
- package/dist/ceremony/passkey/page.d.ts +6 -0
- package/dist/ceremony/passkey/page.js +136 -0
- package/dist/ceremony/passkey/routes.d.ts +2 -0
- package/dist/ceremony/passkey/routes.js +170 -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,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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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,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;
|