@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,136 @@
|
|
|
1
|
+
import { pageHead, brandHeader, progressRail, orderSummaryCard, trustFooter, settlingBar, completionHandoffBanner } from "../theme.js";
|
|
2
|
+
function money(amount, currency) {
|
|
3
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
|
4
|
+
}
|
|
5
|
+
export function renderPasskeyPage(args) {
|
|
6
|
+
const { order, crossDevice = false } = args;
|
|
7
|
+
// Where the completed receipt links back to — the checkout hub, which then renders
|
|
8
|
+
// the paid state (a forward, fresh GET — so the buyer never browser-backs onto a
|
|
9
|
+
// stale, re-payable checkout). Defaults to this server's `/checkout?order=<id>`.
|
|
10
|
+
const returnUrl = args.returnUrl ?? `/checkout?order=${encodeURIComponent(order.id)}`;
|
|
11
|
+
const total = money(order.total, order.currency);
|
|
12
|
+
// The shared order summary card (line items + bold Total) — same chrome as the hub.
|
|
13
|
+
const summary = orderSummaryCard({
|
|
14
|
+
lines: order.lines.map((l) => ({ name: l.name ?? l.id, quantity: l.quantity, lineTotal: l.lineTotal, currency: l.currency ?? order.currency })),
|
|
15
|
+
total: order.total,
|
|
16
|
+
currency: order.currency,
|
|
17
|
+
caption: `Order ${order.id}`,
|
|
18
|
+
});
|
|
19
|
+
// Pay is the current (final) step; the upstream gates are done by the time payment runs.
|
|
20
|
+
const rail = progressRail([{ label: "Age", done: true }, { label: "Membership", done: true }, { label: "Pay" }], 2);
|
|
21
|
+
const tagline = crossDevice ? "Approve on your phone (scan a QR)" : "Authorize with this device";
|
|
22
|
+
// crossDevice pins the registration to a roaming authenticator, so the browser
|
|
23
|
+
// skips local Touch ID and shows the QR for a phone (caBLE). The toggle link flips
|
|
24
|
+
// the mode by adding/removing the xdev param on the same gate URL.
|
|
25
|
+
const optionsUrl = crossDevice ? "/attestomcp/passkey/options?xdev=1" : "/attestomcp/passkey/options";
|
|
26
|
+
const toggleHref = crossDevice ? `/attestomcp/passkey?order=${encodeURIComponent(order.id)}` : `/attestomcp/passkey?order=${encodeURIComponent(order.id)}&xdev=1`;
|
|
27
|
+
const toggleText = crossDevice ? "← Use this device instead" : "Use my phone instead (scan a QR) →";
|
|
28
|
+
// Page-local chrome over the shared design system: the verify-progress rows reuse
|
|
29
|
+
// `.step`; the receipt gate rows + the QR toggle link are page-specific.
|
|
30
|
+
const extraCss = `
|
|
31
|
+
#receipt { display: none; margin-top: 16px; }
|
|
32
|
+
.gate { font-size: .82rem; padding: 3px 0; }
|
|
33
|
+
.gate.pass { color: var(--success); } .gate.fail { color: var(--danger); }
|
|
34
|
+
.toggle { display: block; text-align: center; margin-top: 12px; font-size: .85rem; color: var(--accent); text-decoration: none; }
|
|
35
|
+
.toggle:hover { text-decoration: underline; }`;
|
|
36
|
+
return `<!doctype html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
${pageHead(`Authorize payment · ${order.id}`, extraCss)}
|
|
39
|
+
<body>
|
|
40
|
+
<div class="wrap">
|
|
41
|
+
${brandHeader({ h1: "Authorize payment", tagline })}
|
|
42
|
+
${rail}
|
|
43
|
+
${summary}
|
|
44
|
+
<div class="card">
|
|
45
|
+
<p class="lede">An agent prepared this order — confirm the exact amount with your device's secure element (Touch ID, Windows Hello, or a phone via cross-device sign-in). Once authorized, payment settles on-chain via the <strong>x402</strong> protocol — on a <strong>test network</strong>, no real money, a tiny token amount (a fixed demo rate, not the dollar total).</p>
|
|
46
|
+
<button id="go" class="btn btn-primary">Authorize ${total}</button>
|
|
47
|
+
<a class="toggle" href="${toggleHref}">${toggleText}</a>
|
|
48
|
+
<div id="log"></div>
|
|
49
|
+
${settlingBar()}
|
|
50
|
+
<div id="receipt"></div>
|
|
51
|
+
</div>
|
|
52
|
+
${trustFooter()}
|
|
53
|
+
<script type="module">
|
|
54
|
+
import { startRegistration } from "/attestomcp/lib/sw/index.js";
|
|
55
|
+
const ORDER_ID = ${JSON.stringify(order.id)};
|
|
56
|
+
const OPTIONS_URL = ${JSON.stringify(optionsUrl)};
|
|
57
|
+
const RETURN_URL = ${JSON.stringify(returnUrl)};
|
|
58
|
+
const DONE_BANNER = ${JSON.stringify(completionHandoffBanner(returnUrl))};
|
|
59
|
+
const log = document.getElementById("log");
|
|
60
|
+
const btn = document.getElementById("go");
|
|
61
|
+
const settling = document.getElementById("settling");
|
|
62
|
+
const step = (t, c = "") => { const d = document.createElement("div"); d.className = "step " + c; d.textContent = t; log.appendChild(d); };
|
|
63
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";");
|
|
64
|
+
btn.addEventListener("click", async () => {
|
|
65
|
+
btn.disabled = true;
|
|
66
|
+
try {
|
|
67
|
+
step("→ GET options");
|
|
68
|
+
const { options, challengeToken } = await fetch(OPTIONS_URL).then((r) => r.json());
|
|
69
|
+
step("→ Touch ID / passkey prompt");
|
|
70
|
+
const response = await startRegistration({ optionsJSON: options });
|
|
71
|
+
step("→ verify · Settling via x402 on Hedera testnet (if configured)… can take ~10s");
|
|
72
|
+
settling.classList.add("on");
|
|
73
|
+
const out = await fetch("/attestomcp/passkey/verify", {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({ response, challengeToken, order: ORDER_ID }),
|
|
77
|
+
}).then((r) => r.json()).finally(() => settling.classList.remove("on"));
|
|
78
|
+
if (!out.mandate) throw new Error(out.error || "authorization failed");
|
|
79
|
+
step("✓ authorized · mandate built (" + out.trust_level + ")", "ok");
|
|
80
|
+
renderReceipt(out);
|
|
81
|
+
if (out.settlementError) { step("✗ settlement failed — authorized, not settled (retry below)", "err"); btn.disabled = false; }
|
|
82
|
+
else if (!out.completed) btn.disabled = false;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
step("✗ " + (err?.message ?? String(err)), "err");
|
|
85
|
+
btn.disabled = false;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function renderReceipt(out) {
|
|
90
|
+
const el = document.getElementById("receipt");
|
|
91
|
+
const passCount = out.gates.filter((g) => g.pass).length;
|
|
92
|
+
const allPass = passCount === out.gates.length;
|
|
93
|
+
const gateLines = out.gates.map((g) => '<div class="gate ' + (g.pass ? "pass" : "fail") + '">' + (g.pass ? "✓" : "✗") + " " + esc(g.gate) + " — " + esc(g.detail) + "</div>").join("");
|
|
94
|
+
// The x402 on-chain settlement receipt — same .settle card the dc-payment rail
|
|
95
|
+
// renders: the tinybar amount, payer/merchant, speed, tx, and a PROMINENT
|
|
96
|
+
// tappable HashScan link (the buyer is on their phone; one tap to the live
|
|
97
|
+
// explorer is the third-party proof). A configured-but-failed settle is the
|
|
98
|
+
// calm "authorized, not settled" line (FR-013) — never an alarming wall.
|
|
99
|
+
const s = out.settlement;
|
|
100
|
+
const settlement = s
|
|
101
|
+
? '<div class="settle"><div class="settle-head">✓ Settled via x402 on Hedera testnet</div>' +
|
|
102
|
+
'<dl class="kv">' +
|
|
103
|
+
"<dt>Amount</dt><dd>" + (s.amountTinybar / 1e8) + ' ℏ <span class="dim">(' + esc(s.fxRate) + ")</span></dd>" +
|
|
104
|
+
"<dt>From</dt><dd>" + esc(s.payer.accountId) + ' <span class="dim">' +
|
|
105
|
+
(s.payer.kind === "session-wallet"
|
|
106
|
+
? "wallet created for this order, " + (s.walletAgeMs / 1000).toFixed(1) + "s old when it paid"
|
|
107
|
+
: "demo customer") + "</span></dd>" +
|
|
108
|
+
"<dt>To</dt><dd>" + esc(s.payTo) + ' <span class="dim">merchant</span></dd>' +
|
|
109
|
+
"<dt>Speed</dt><dd>settled in " + (s.settledInMs / 1000).toFixed(1) + "s</dd>" +
|
|
110
|
+
'<dt>Tx</dt><dd><span class="mono">' + esc(s.txId) + "</span></dd>" +
|
|
111
|
+
"</dl>" +
|
|
112
|
+
'<a class="hashscan" href="' + esc(s.hashscanUrl) + '" target="_blank" rel="noopener">View on HashScan ›</a>' +
|
|
113
|
+
"</div>"
|
|
114
|
+
: out.settlementError
|
|
115
|
+
? '<div class="settle-failed">✗ Settlement failed — authorized, not settled: ' + esc(out.settlementError) + "</div>"
|
|
116
|
+
: "";
|
|
117
|
+
// Every gate + payment is done ⇒ the order is COMPLETE. Lead with the prominent
|
|
118
|
+
// handoff: close this window and continue in the agent (the MCP host polls
|
|
119
|
+
// order-status and resumes). No auto-redirect — we don't yank the buyer off the
|
|
120
|
+
// "you're done" message; the on-chain proof + a secondary return link stay below.
|
|
121
|
+
const done = out.completed ? DONE_BANNER : "";
|
|
122
|
+
const gates = '<div class="gate ' + (allPass ? "pass" : "fail") + '">' +
|
|
123
|
+
(allPass ? "✓ All " + out.gates.length + " authorization gates passed" : "✗ " + (out.gates.length - passCount) + " of " + out.gates.length + " failed") + "</div>" + gateLines;
|
|
124
|
+
el.innerHTML = done + '<div class="row-ok">✓ Payment Mandate authorized (amount-bound)</div>' +
|
|
125
|
+
'<div class="small" style="margin:4px 0 8px;">' + esc(out.mandate.id) + "</div>" + gates + settlement;
|
|
126
|
+
el.style.display = "block";
|
|
127
|
+
if (out.completed) {
|
|
128
|
+
btn.disabled = true;
|
|
129
|
+
btn.textContent = "Authorized ✓";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
</script>
|
|
133
|
+
</div>
|
|
134
|
+
</body>
|
|
135
|
+
</html>`;
|
|
136
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The passkey payment rail (US2) — same-device (Touch ID / Windows Hello) and
|
|
2
|
+
// cross-device (FIDO caBLE). Registers its routes onto the host app through the
|
|
3
|
+
// Foundational mount() seam:
|
|
4
|
+
// GET /attestomcp/passkey?order=<id>[&xdev=1] → the authorize page (xdev toggle)
|
|
5
|
+
// GET /attestomcp/passkey/options[?xdev=1] → WebAuthn options + signed challenge token
|
|
6
|
+
// POST /attestomcp/passkey/verify → verify assertion → four gates → completeOrder
|
|
7
|
+
// GET /attestomcp/lib/sw/* → @simplewebauthn/browser ESM, same-origin
|
|
8
|
+
//
|
|
9
|
+
// Dependency-free (no `express` import — invariant from mount.ts): handlers are
|
|
10
|
+
// registered against the structural CeremonyApp.get/post/use, the verify body is
|
|
11
|
+
// read either from a host-installed parser (`req.body`) or off the request stream,
|
|
12
|
+
// and the browser ESM is served from disk (no `express.static`).
|
|
13
|
+
//
|
|
14
|
+
// EVERY route resolves the order by id THROUGH `resolveOrder` (catalog re-pricing;
|
|
15
|
+
// a tampered/unknown id is refused — CT3, invariant 2). Completion runs through the
|
|
16
|
+
// SHARED `completeOrder` seam (ctx.completion) — the same path dc-payment uses — so
|
|
17
|
+
// re-pricing, the age gate, settlement, and state-clearing behave identically
|
|
18
|
+
// across rails (FR-008). WebAuthn stays bound to this server's origin/RP-ID with a
|
|
19
|
+
// sealed, time-limited, single-use challenge (FR-007, invariant 6).
|
|
20
|
+
//
|
|
21
|
+
// Trust is PRESENCE-ONLY (Principle VII / FR-011): the WebAuthn flow is real, but
|
|
22
|
+
// the mandate is dev-signed — a flow demo, not a real safety control.
|
|
23
|
+
import { createRequire } from "node:module";
|
|
24
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { resolveOrder } from "../mount.js";
|
|
27
|
+
import { buildPasskeyMandate, buildBindingFields, runGates } from "../mandate.js";
|
|
28
|
+
import { buildRegistrationOptions, verifyPasskeyAssertion } from "./verify.js";
|
|
29
|
+
import { renderPasskeyPage } from "./page.js";
|
|
30
|
+
function firstHeader(value) {
|
|
31
|
+
return Array.isArray(value) ? value[0] : value;
|
|
32
|
+
}
|
|
33
|
+
function originOf(ctx, req) {
|
|
34
|
+
const reqLike = { headers: req.headers, host: firstHeader(req.headers.host) ?? "localhost", protocol: req.protocol };
|
|
35
|
+
return ctx.origin(reqLike);
|
|
36
|
+
}
|
|
37
|
+
function isCrossDevice(raw) {
|
|
38
|
+
return (Array.isArray(raw) ? raw[0] : raw) === "1";
|
|
39
|
+
}
|
|
40
|
+
// Read the JSON body from a host-installed parser, or straight off the stream when
|
|
41
|
+
// no parser ran (so the rail is self-contained — it doesn't require express.json()).
|
|
42
|
+
async function readJsonBody(req) {
|
|
43
|
+
if (req.body && typeof req.body === "object")
|
|
44
|
+
return req.body;
|
|
45
|
+
try {
|
|
46
|
+
const chunks = [];
|
|
47
|
+
for await (const chunk of req) {
|
|
48
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
49
|
+
}
|
|
50
|
+
if (chunks.length === 0)
|
|
51
|
+
return {};
|
|
52
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const CONTENT_TYPES = {
|
|
59
|
+
".js": "text/javascript",
|
|
60
|
+
".mjs": "text/javascript",
|
|
61
|
+
".map": "application/json",
|
|
62
|
+
".json": "application/json",
|
|
63
|
+
".ts": "text/plain",
|
|
64
|
+
};
|
|
65
|
+
// Resolve @simplewebauthn/browser's ESM dir (its `main` is script/index.js; walk
|
|
66
|
+
// up two levels to the package root, then into /esm). Fail LOUDLY at wire-up if the
|
|
67
|
+
// layout changed, rather than silently 404-ing the browser module at runtime.
|
|
68
|
+
function resolveBrowserEsmDir() {
|
|
69
|
+
const requireFrom = createRequire(import.meta.url);
|
|
70
|
+
const scriptIndexPath = requireFrom.resolve("@simplewebauthn/browser");
|
|
71
|
+
const dir = path.join(path.dirname(path.dirname(scriptIndexPath)), "esm");
|
|
72
|
+
if (!existsSync(path.join(dir, "index.js"))) {
|
|
73
|
+
throw new Error(`[attestomcp] @simplewebauthn/browser ESM not found at ${dir}`);
|
|
74
|
+
}
|
|
75
|
+
return dir;
|
|
76
|
+
}
|
|
77
|
+
export const registerPasskeyGate = (app, ctx) => {
|
|
78
|
+
// The Foundational fail-fast tests mount() with a route-less app shape; only
|
|
79
|
+
// attach when the host app can actually route.
|
|
80
|
+
const get = app.get?.bind(app);
|
|
81
|
+
const post = app.post?.bind(app);
|
|
82
|
+
const use = app.use?.bind(app);
|
|
83
|
+
if (!get || !post || !use)
|
|
84
|
+
return;
|
|
85
|
+
// Serve @simplewebauthn/browser ESM same-origin (no CDN) — dependency-free file
|
|
86
|
+
// serving with path-traversal containment.
|
|
87
|
+
const browserEsmDir = resolveBrowserEsmDir();
|
|
88
|
+
use("/attestomcp/lib/sw", (req, res) => {
|
|
89
|
+
const rel = (req.path ?? req.url ?? "/").split("?")[0];
|
|
90
|
+
const filePath = path.resolve(browserEsmDir, "." + rel);
|
|
91
|
+
// Containment: a resolved path must stay inside the served ESM directory.
|
|
92
|
+
if (filePath !== browserEsmDir && !filePath.startsWith(browserEsmDir + path.sep)) {
|
|
93
|
+
res.status(403).type("text/plain").send("forbidden");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
97
|
+
res.status(404).type("text/plain").send("not found");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const mime = CONTENT_TYPES[path.extname(filePath)] ?? "application/octet-stream";
|
|
101
|
+
res.status(200).type(mime).send(readFileSync(filePath, "utf8"));
|
|
102
|
+
});
|
|
103
|
+
// GET the authorize page — re-priced order, presence-only honesty banner.
|
|
104
|
+
get("/attestomcp/passkey", async (req, res) => {
|
|
105
|
+
const order = await resolveOrder(ctx, typeof req.query.order === "string" ? req.query.order : undefined);
|
|
106
|
+
if (!order) {
|
|
107
|
+
res.status(404).type("html").send("<!doctype html><h1>Order not found</h1>");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
res.status(200).type("html").send(renderPasskeyPage({ order, crossDevice: isCrossDevice(req.query.xdev) }));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// A hand-edited order can carry a bad currency that throws in Intl; never 500.
|
|
115
|
+
res.status(404).type("html").send("<!doctype html><h1>Order not found</h1>");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
// GET WebAuthn options + a signed challenge token (order-independent — the
|
|
119
|
+
// challenge binds to this origin/RP-ID, the order is bound at verify).
|
|
120
|
+
get("/attestomcp/passkey/options", async (req, res) => {
|
|
121
|
+
const { options, challengeToken } = await buildRegistrationOptions(originOf(ctx, req), ctx.signingKey, {
|
|
122
|
+
crossDevice: isCrossDevice(req.query.xdev),
|
|
123
|
+
});
|
|
124
|
+
res.json({ options, challengeToken });
|
|
125
|
+
});
|
|
126
|
+
// POST verify — recover the nonce, verify the assertion against this origin/RP-ID,
|
|
127
|
+
// build the AP2 mandate, run the four deterministic gates, and complete through
|
|
128
|
+
// the SHARED completeOrder seam (re-price + age gate + idempotent record).
|
|
129
|
+
post("/attestomcp/passkey/verify", async (req, res) => {
|
|
130
|
+
const body = await readJsonBody(req);
|
|
131
|
+
const order = await resolveOrder(ctx, typeof body.order === "string" ? body.order : undefined);
|
|
132
|
+
if (!order) {
|
|
133
|
+
res.status(400).json({ completed: false, error: "missing or invalid order" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const origin = originOf(ctx, req);
|
|
138
|
+
const authenticator = await verifyPasskeyAssertion({
|
|
139
|
+
response: body.response,
|
|
140
|
+
challengeToken: String(body.challengeToken ?? ""),
|
|
141
|
+
origin,
|
|
142
|
+
secret: ctx.signingKey,
|
|
143
|
+
});
|
|
144
|
+
const mandate = buildPasskeyMandate({ order, authenticator, origin });
|
|
145
|
+
const gates = runGates(mandate);
|
|
146
|
+
const completion = await ctx.completion({
|
|
147
|
+
order,
|
|
148
|
+
mandateId: mandate.id,
|
|
149
|
+
amount: mandate.payment.amount,
|
|
150
|
+
currency: mandate.payment.currency,
|
|
151
|
+
method: "passkey",
|
|
152
|
+
instrument: { issuer: mandate.payment.instrument, maskedAccount: mandate.payment.instrumentReference, holder: null },
|
|
153
|
+
gates: gates.map((g) => ({ gate: g.gate, pass: g.pass, detail: g.detail })),
|
|
154
|
+
});
|
|
155
|
+
res.json({
|
|
156
|
+
mandate,
|
|
157
|
+
gates,
|
|
158
|
+
completed: completion.completed,
|
|
159
|
+
settlement: completion.settlement ?? null,
|
|
160
|
+
settlementError: completion.settlementError ?? null,
|
|
161
|
+
reason: completion.reason ?? null,
|
|
162
|
+
binding: buildBindingFields(order, origin),
|
|
163
|
+
trust_level: "presence-only-demo",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
res.status(400).json({ completed: false, error: err.message });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RegistrationResponseJSON } from "@simplewebauthn/server";
|
|
2
|
+
import type { Origin } from "../origin.js";
|
|
3
|
+
import type { VerifiedAuthenticator } from "../mandate.js";
|
|
4
|
+
export declare function buildRegistrationOptions(origin: Origin, secret: string, opts?: {
|
|
5
|
+
crossDevice?: boolean;
|
|
6
|
+
}): Promise<{
|
|
7
|
+
options: import("@simplewebauthn/server").PublicKeyCredentialCreationOptionsJSON;
|
|
8
|
+
challengeToken: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function verifyPasskeyAssertion(args: {
|
|
11
|
+
response: RegistrationResponseJSON;
|
|
12
|
+
challengeToken: string;
|
|
13
|
+
origin: Origin;
|
|
14
|
+
secret: string;
|
|
15
|
+
}): Promise<VerifiedAuthenticator>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The passkey rail's WebAuthn step (US2) — extracted from the demo's
|
|
2
|
+
// payment-gate/passkey/verify.ts. A single registration ceremony is the
|
|
3
|
+
// authorization gesture: the challenge is recovered from the signed token
|
|
4
|
+
// (stateless — survives an options→verify instance split), and the assertion is
|
|
5
|
+
// verified against THIS server's origin/RP-ID with user verification required
|
|
6
|
+
// (invariant 6). The signing secret is the injected `signingKey` seam (mount()
|
|
7
|
+
// requires a stable one — D6), never a process global.
|
|
8
|
+
//
|
|
9
|
+
// Trust is PRESENCE-ONLY (Principle VII / FR-011): the attestation flow is real,
|
|
10
|
+
// but the mandate it feeds (mandate.ts) is dev-signed, not key-bound — a flow
|
|
11
|
+
// demo, not a real safety control.
|
|
12
|
+
import { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server";
|
|
13
|
+
import { issueChallenge, verifyChallenge } from "../challengeToken.js";
|
|
14
|
+
const RP_NAME = "AttestoMcp Gate";
|
|
15
|
+
// Build registration options + a signed challenge token. userID is ephemeral —
|
|
16
|
+
// we never persist the credential, so a fresh random user each time is fine.
|
|
17
|
+
// crossDevice pins authenticatorAttachment to "cross-platform", which removes the
|
|
18
|
+
// local Touch ID option so the browser goes straight to the phone/QR (caBLE) path.
|
|
19
|
+
export async function buildRegistrationOptions(origin, secret, opts = {}) {
|
|
20
|
+
const { challenge, token } = issueChallenge(secret);
|
|
21
|
+
const options = await generateRegistrationOptions({
|
|
22
|
+
rpName: RP_NAME,
|
|
23
|
+
rpID: origin.rpID,
|
|
24
|
+
userName: "attestomcp-gate-user",
|
|
25
|
+
challenge: Buffer.from(challenge, "base64url"),
|
|
26
|
+
attestationType: "none",
|
|
27
|
+
authenticatorSelection: {
|
|
28
|
+
residentKey: "preferred",
|
|
29
|
+
userVerification: "required",
|
|
30
|
+
...(opts.crossDevice ? { authenticatorAttachment: "cross-platform" } : {}),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
return { options, challengeToken: token };
|
|
34
|
+
}
|
|
35
|
+
export async function verifyPasskeyAssertion(args) {
|
|
36
|
+
// Recover + validate the sealed, time-limited nonce FIRST — a forged or expired
|
|
37
|
+
// token is rejected before any attestation parsing (invariant 6).
|
|
38
|
+
const expectedChallenge = verifyChallenge(args.challengeToken, args.secret);
|
|
39
|
+
const verification = await verifyRegistrationResponse({
|
|
40
|
+
response: args.response,
|
|
41
|
+
expectedChallenge,
|
|
42
|
+
expectedOrigin: args.origin.origin,
|
|
43
|
+
expectedRPID: args.origin.rpID,
|
|
44
|
+
requireUserVerification: true,
|
|
45
|
+
});
|
|
46
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
47
|
+
throw new Error("registration not verified");
|
|
48
|
+
}
|
|
49
|
+
const info = verification.registrationInfo;
|
|
50
|
+
return {
|
|
51
|
+
credentialID: info.credential.id,
|
|
52
|
+
userVerified: true,
|
|
53
|
+
credentialDeviceType: info.credentialDeviceType,
|
|
54
|
+
credentialBackedUp: info.credentialBackedUp,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CartMandate } from "./cartMandate.js";
|
|
2
|
+
/** The Payment Mandate's bound fields as they reach the completion seam — each rail
|
|
3
|
+
* projects them from its verified `mandate.payment` (+ the id of the order the
|
|
4
|
+
* payment authorizes). Scalars, not the mandate object, so the seam stays
|
|
5
|
+
* decoupled from the passkey/dc-payment mandate shapes. */
|
|
6
|
+
export interface PaymentBinding {
|
|
7
|
+
/** The Payment Mandate's bound amount (`mandate.payment.amount`). */
|
|
8
|
+
amount: number;
|
|
9
|
+
/** The Payment Mandate's bound currency (`mandate.payment.currency`). */
|
|
10
|
+
currency: string;
|
|
11
|
+
/** The order id the payment authorizes (the mandate's cart/subject). */
|
|
12
|
+
orderId: string;
|
|
13
|
+
}
|
|
14
|
+
/** Why a Cart ↔ Payment reconciliation refused. */
|
|
15
|
+
export type ReconcileRefusal = "order-id" | "currency" | "amount";
|
|
16
|
+
export type ReconcileVerdict = {
|
|
17
|
+
ok: true;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
reason: ReconcileRefusal;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Reconcile a (signature-verified) Cart Mandate with the Payment Mandate's binding,
|
|
24
|
+
* given the catalog-RE-DERIVED total (server-side truth, never the token — invariant
|
|
25
|
+
* 2). The verdict is `ok` only when all three agree:
|
|
26
|
+
* 1. order id / subject — the cart and the payment authorize the SAME order
|
|
27
|
+
* (a Cart Mandate swapped onto another order's payment is refused);
|
|
28
|
+
* 2. currency — the cart and the payment settle in the same currency;
|
|
29
|
+
* 3. amount — `cart.total === rederivedTotal === payment.amount`, a single bound
|
|
30
|
+
* figure (a cart sealed for X against a payment for Y≠X is refused; a discount
|
|
31
|
+
* reconciles only when the re-derived total already reflects it — invariant 3).
|
|
32
|
+
* Returns a typed verdict; it never throws.
|
|
33
|
+
*/
|
|
34
|
+
export declare function reconcileCartPayment(cart: CartMandate, payment: PaymentBinding, rederivedTotal: number): ReconcileVerdict;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile a (signature-verified) Cart Mandate with the Payment Mandate's binding,
|
|
3
|
+
* given the catalog-RE-DERIVED total (server-side truth, never the token — invariant
|
|
4
|
+
* 2). The verdict is `ok` only when all three agree:
|
|
5
|
+
* 1. order id / subject — the cart and the payment authorize the SAME order
|
|
6
|
+
* (a Cart Mandate swapped onto another order's payment is refused);
|
|
7
|
+
* 2. currency — the cart and the payment settle in the same currency;
|
|
8
|
+
* 3. amount — `cart.total === rederivedTotal === payment.amount`, a single bound
|
|
9
|
+
* figure (a cart sealed for X against a payment for Y≠X is refused; a discount
|
|
10
|
+
* reconciles only when the re-derived total already reflects it — invariant 3).
|
|
11
|
+
* Returns a typed verdict; it never throws.
|
|
12
|
+
*/
|
|
13
|
+
export function reconcileCartPayment(cart, payment, rederivedTotal) {
|
|
14
|
+
if (cart.orderId !== payment.orderId)
|
|
15
|
+
return { ok: false, reason: "order-id" };
|
|
16
|
+
if (cart.currency !== payment.currency)
|
|
17
|
+
return { ok: false, reason: "currency" };
|
|
18
|
+
if (cart.total !== rederivedTotal || cart.total !== payment.amount)
|
|
19
|
+
return { ok: false, reason: "amount" };
|
|
20
|
+
return { ok: true };
|
|
21
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** <head> with the shared design-system CSS. `extraCss` lets a page add the few
|
|
2
|
+
* component styles unique to it without forking the design language. */
|
|
3
|
+
export declare function pageHead(title: string, extraCss?: string): string;
|
|
4
|
+
/** The ATTESTOMCP wordmark + a discreet DEMO pill, with an optional confident h1 +
|
|
5
|
+
* identity-first tagline underneath. Pass `h1`/`tagline` to render the heading block;
|
|
6
|
+
* omit them to render just the brand row (a page can lay out its own heading). */
|
|
7
|
+
export declare function brandHeader(opts?: {
|
|
8
|
+
h1?: string;
|
|
9
|
+
tagline?: string;
|
|
10
|
+
}): string;
|
|
11
|
+
/** An indeterminate "settling…" progress bar (hidden until JS adds `.on`). Shown on
|
|
12
|
+
* the payment rails while x402 settlement runs on-chain (~10s) so the wait reads as
|
|
13
|
+
* live work. `id` defaults to "settling" for the page script to toggle. */
|
|
14
|
+
export declare function settlingBar(id?: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* The prominent end-of-ceremony handoff banner: every attestation + payment is done,
|
|
17
|
+
* so the order is COMPLETE. It tells the buyer they can close the window and continue
|
|
18
|
+
* in their agent — the MCP host polls order-status and resumes the conversation
|
|
19
|
+
* automatically (Mode A: the agent never runs the ceremony, it only orchestrates +
|
|
20
|
+
* polls). An optional secondary link returns to the checkout hub for a pure-browser
|
|
21
|
+
* flow. Built server-side and embedded into the gate page's receipt script.
|
|
22
|
+
*/
|
|
23
|
+
export declare function completionHandoffBanner(returnUrl?: string): string;
|
|
24
|
+
export interface OrderSummaryLine {
|
|
25
|
+
/** Display label (e.g. "Oak Whiskey"). */
|
|
26
|
+
name: string;
|
|
27
|
+
quantity: number;
|
|
28
|
+
lineTotal: number;
|
|
29
|
+
/** ISO 4217; falls back to the card currency. */
|
|
30
|
+
currency?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface OrderSummaryArgs {
|
|
33
|
+
lines: OrderSummaryLine[];
|
|
34
|
+
total: number;
|
|
35
|
+
/** Major-units discount; the accent row renders only when > 0. */
|
|
36
|
+
discount?: number;
|
|
37
|
+
currency: string;
|
|
38
|
+
/** Optional label on the discount row, e.g. "Loyalty discount (10%)". */
|
|
39
|
+
discountLabel?: string;
|
|
40
|
+
/** Optional caption above the table (e.g. "Order ORD-1 · 2 items"). */
|
|
41
|
+
caption?: string;
|
|
42
|
+
}
|
|
43
|
+
/** The order summary card: line items, an accent discount row, a bold Total with a
|
|
44
|
+
* top hairline. Money is tabular. Shared by all three pages so the cart reads the
|
|
45
|
+
* same everywhere. */
|
|
46
|
+
export declare function orderSummaryCard(args: OrderSummaryArgs): string;
|
|
47
|
+
export interface RailStep {
|
|
48
|
+
label: string;
|
|
49
|
+
/** true once this step's verification is recorded. */
|
|
50
|
+
done?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The Age · Membership · Pay stepper. DONE = filled accent with ✓; CURRENT (the step
|
|
54
|
+
* at `currentIndex`, when not already done) = accent ring; everything else = muted.
|
|
55
|
+
* The hub passes real status; each gate page marks its OWN step current.
|
|
56
|
+
*/
|
|
57
|
+
export declare function progressRail(steps: RailStep[], currentIndex: number): string;
|
|
58
|
+
/**
|
|
59
|
+
* The single, DISCREET presence-only honesty line (replaces the old yellow warning
|
|
60
|
+
* box). It MUST keep the literal "presence-only-demo" token (FR-011 + the honesty
|
|
61
|
+
* tests) — the wire crypto is real; the issuer trust anchor is not.
|
|
62
|
+
*/
|
|
63
|
+
export declare function trustFooter(): string;
|