@openmobilehub/attestomcp-storefront 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 +119 -0
- package/dist/generated-images.d.ts +2 -0
- package/dist/generated-images.js +23 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +151 -0
- package/dist/server.d.ts +86 -0
- package/dist/server.js +386 -0
- package/dist/state.d.ts +24 -0
- package/dist/state.js +25 -0
- package/dist/tool-meta.d.ts +27 -0
- package/dist/tool-meta.js +24 -0
- package/dist/ui/mcp-app.html +175 -0
- package/package.json +69 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// createStorefront() — a runnable storefront in one line.
|
|
2
|
+
//
|
|
3
|
+
// Stands up the real MCP storefront — the nine shopping tools (six UI-linked to the
|
|
4
|
+
// React widget, three plain) + the single-file widget resource + a checkout page —
|
|
5
|
+
// over HTTP at /mcp, around an injected catalog. The checkout tool is UNGATED by
|
|
6
|
+
// default; call `store.gate(resolve)` to have it surface a `requires` manifest,
|
|
7
|
+
// which is exactly where @openmobilehub/attestomcp-gate mounts on:
|
|
8
|
+
//
|
|
9
|
+
// const store = createStorefront();
|
|
10
|
+
// const attestomcp = new AttestoMcp();
|
|
11
|
+
// attestomcp.mount(store.app);
|
|
12
|
+
// store.gate((order) => attestomcp.requirements(order, [ required(age.over(21).when(hasAlcohol)) ]));
|
|
13
|
+
// const { url } = await store.listen(3005); // → add http://localhost:3005/mcp to Claude / ChatGPT
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
21
|
+
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
22
|
+
import express from "express";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { CART_META_KEY, CATALOG_META_KEY, createOrder, getProduct, getReviews, priceCart, SAMPLE_CATALOG, } from "./index.js";
|
|
25
|
+
import { appToolMeta } from "./tool-meta.js";
|
|
26
|
+
import { MemoryCartStore, MemoryOrderStore } from "./state.js";
|
|
27
|
+
// Composition with @openmobilehub/attestomcp-gate (Context 2): the storefront pre-binds
|
|
28
|
+
// the gate's shared `completeOrder` over ITS OWN stores + catalog and publishes the
|
|
29
|
+
// ceremony seams on `app.locals.attestomcp`, so `new AttestoMcp().mount(store.app)` wires
|
|
30
|
+
// the `/attestomcp/*` rails with zero explicit args (the quickstart). The gate stays an
|
|
31
|
+
// optional pairing — only this server module imports it; the pure pricing core
|
|
32
|
+
// (`./index.js`) does not.
|
|
33
|
+
import { completeOrder, renderRequirements, MemoryVerificationStore, } from "@openmobilehub/attestomcp-gate";
|
|
34
|
+
// ── widget bundle (single-file html, built by vite into dist/ui/) ───────────
|
|
35
|
+
const SKYBRIDGE_MIME = "text/html+skybridge";
|
|
36
|
+
// Product images are self-contained `data:` URIs (added to the CSP below); picsum
|
|
37
|
+
// stays allowlisted in case a custom catalog uses remote images.
|
|
38
|
+
const IMAGE_DOMAINS = ["https://picsum.photos", "https://fastly.picsum.photos"];
|
|
39
|
+
function bundleCandidates() {
|
|
40
|
+
return [join(import.meta.dirname, "ui", "mcp-app.html"), join(process.cwd(), "dist", "ui", "mcp-app.html")];
|
|
41
|
+
}
|
|
42
|
+
// Stamp the resource URI with a short hash of the bundle so hosts re-fetch exactly
|
|
43
|
+
// when the widget changes (they cache by URI). "dev" until the bundle is on disk.
|
|
44
|
+
function bundleVersion() {
|
|
45
|
+
for (const c of bundleCandidates()) {
|
|
46
|
+
try {
|
|
47
|
+
return createHash("sha256").update(readFileSync(c)).digest("hex").slice(0, 8);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* try next */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return "dev";
|
|
54
|
+
}
|
|
55
|
+
async function loadBundle() {
|
|
56
|
+
for (const c of bundleCandidates()) {
|
|
57
|
+
try {
|
|
58
|
+
return await readFile(c, "utf-8");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* try next */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`attestomcp-storefront: widget bundle not found (looked in: ${bundleCandidates().join(", ")})`);
|
|
65
|
+
}
|
|
66
|
+
// Derive this server's public origin from the incoming request. Proxies (Vercel,
|
|
67
|
+
// tunnels) set x-forwarded-*; fall back to the Host header. Lets the storefront
|
|
68
|
+
// build absolute checkout URLs at any origin when baseUrl wasn't configured.
|
|
69
|
+
export function originFromRequest(req) {
|
|
70
|
+
const fwd = (name) => req.headers[name]?.split(",")[0]?.trim();
|
|
71
|
+
const proto = fwd("x-forwarded-proto") ?? req.protocol ?? "http";
|
|
72
|
+
const host = fwd("x-forwarded-host") ?? req.headers.host;
|
|
73
|
+
return host ? `${proto}://${host}`.replace(/\/+$/, "") : "";
|
|
74
|
+
}
|
|
75
|
+
// Re-home a mounted-ceremony approve link (`/attestomcp/*`) onto THIS server's origin —
|
|
76
|
+
// the same base the checkout link uses — so the gate links and the checkout link
|
|
77
|
+
// share an origin (the rails are registered on this same app). Links to any other
|
|
78
|
+
// path (e.g. a developer's external wallet origin) pass through untouched.
|
|
79
|
+
function homeApproveUrl(approveUrl, base) {
|
|
80
|
+
try {
|
|
81
|
+
const u = new URL(approveUrl, "http://re-home.invalid");
|
|
82
|
+
if (u.pathname.startsWith("/attestomcp/"))
|
|
83
|
+
return `${base}${u.pathname}${u.search}`;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* not URL-shaped — leave as-is */
|
|
87
|
+
}
|
|
88
|
+
return approveUrl;
|
|
89
|
+
}
|
|
90
|
+
// Re-home every `/attestomcp/*` approveUrl in a `requires` manifest onto `base`.
|
|
91
|
+
function homeRequires(requires, base) {
|
|
92
|
+
return requires.map((e) => {
|
|
93
|
+
const entry = e;
|
|
94
|
+
return typeof entry.approveUrl === "string"
|
|
95
|
+
? { ...entry, approveUrl: homeApproveUrl(entry.approveUrl, base) }
|
|
96
|
+
: e;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export function createStorefront(opts = {}) {
|
|
100
|
+
const catalog = opts.catalog ?? SAMPLE_CATALOG;
|
|
101
|
+
const reviews = opts.reviews;
|
|
102
|
+
const cartStore = opts.cartStore ?? new MemoryCartStore();
|
|
103
|
+
const orderStore = opts.orderStore ?? new MemoryOrderStore();
|
|
104
|
+
// Created-but-not-completed orders, for the checkout page + place-order. A store
|
|
105
|
+
// (not a process Map) so it can be shared across serverless instances.
|
|
106
|
+
const createdOrderStore = opts.createdOrderStore ?? new MemoryOrderStore();
|
|
107
|
+
// Per-order verification state shared with the mounted ceremony (the rails write
|
|
108
|
+
// it; the completion seam below reads it back to re-price + enforce the age gate).
|
|
109
|
+
const verificationStore = opts.verificationStore ?? new MemoryVerificationStore();
|
|
110
|
+
let resolveGate;
|
|
111
|
+
let baseUrl = opts.baseUrl?.replace(/\/+$/, "") ?? "";
|
|
112
|
+
const BUNDLE_VERSION = bundleVersion();
|
|
113
|
+
const RESOURCE_URI = `ui://product-picker/mcp-app-${BUNDLE_VERSION}.html`;
|
|
114
|
+
const SKYBRIDGE_URI = `ui://product-picker/mcp-app-${BUNDLE_VERSION}.skybridge.html`;
|
|
115
|
+
// One canonical tool-meta for every UI-linked tool — both host surfaces, with
|
|
116
|
+
// openai/widgetAccessible always on (FR-014).
|
|
117
|
+
const UI_META = appToolMeta({ resourceUri: RESOURCE_URI, skybridgeUri: SKYBRIDGE_URI });
|
|
118
|
+
const app = createMcpExpressApp({ host: "0.0.0.0" });
|
|
119
|
+
// place-order accepts the order id from either a JSON fetch (the shared checkout
|
|
120
|
+
// page's instant-demo method) or an x-www-form-urlencoded form post; the SDK app
|
|
121
|
+
// only parses JSON, so add a urlencoded parser too or a form post's `req.body.order`
|
|
122
|
+
// is undefined and completion is never recorded.
|
|
123
|
+
app.use(express.urlencoded({ extended: false }));
|
|
124
|
+
// ── AttestoMcp ceremony seams (Context 2) ──────────────────────────────────────
|
|
125
|
+
// Pre-bound so `new AttestoMcp().mount(store.app)` wires the `/attestomcp/*` rails with
|
|
126
|
+
// ZERO explicit args — it reads these off `app.locals.attestomcp` (see the quickstart).
|
|
127
|
+
// The catalog re-prices server-side (the amount source of truth — invariant 2); the
|
|
128
|
+
// completion seam is the gate's shared `completeOrder` bound over THIS server's
|
|
129
|
+
// stores, so a finished ceremony records the order + clears the cart the SAME way
|
|
130
|
+
// get-order-status / the order-status poll read.
|
|
131
|
+
const ceremonyCatalog = {
|
|
132
|
+
createOrder: (items, orderId, repriceOpts) => createOrder(items, orderId, catalog, { ageVerified: repriceOpts?.ageVerified, loyaltyApplied: repriceOpts?.loyaltyApplied }),
|
|
133
|
+
};
|
|
134
|
+
const ceremonyOrderStore = {
|
|
135
|
+
// A storefront Order is structurally a CeremonyOrder; resolveOrder re-prices it
|
|
136
|
+
// from the catalog regardless, recovering only the line items + id (CT3).
|
|
137
|
+
read: (orderId) => createdOrderStore.read(orderId),
|
|
138
|
+
};
|
|
139
|
+
const completion = (input) => completeOrder(input, {
|
|
140
|
+
catalog: ceremonyCatalog,
|
|
141
|
+
verificationStore,
|
|
142
|
+
records: {
|
|
143
|
+
read: async (orderId) => ((await orderStore.read(orderId)) ?? undefined),
|
|
144
|
+
write: async (record) => { await orderStore.write(record.orderId, record); },
|
|
145
|
+
},
|
|
146
|
+
cart: { clear: async () => { await cartStore.write(new Map()); } },
|
|
147
|
+
...(opts.settle ? { settle: opts.settle } : {}),
|
|
148
|
+
});
|
|
149
|
+
app.locals.attestomcp = {
|
|
150
|
+
orderStore: ceremonyOrderStore,
|
|
151
|
+
verificationStore,
|
|
152
|
+
catalog: ceremonyCatalog,
|
|
153
|
+
completion,
|
|
154
|
+
// signingKey survives an instance split; default to an ephemeral per-process key
|
|
155
|
+
// for a single-process dev server / tests when none is configured.
|
|
156
|
+
...(opts.signingKey ? { signingKey: opts.signingKey } : {}),
|
|
157
|
+
allowEphemeralKey: opts.allowEphemeralKey ?? !opts.signingKey,
|
|
158
|
+
};
|
|
159
|
+
// ── cart logic (closure over the injected catalog + the cart store) ───────
|
|
160
|
+
const priceFrom = (cart) => priceCart([...cart.entries()].map(([productId, quantity]) => ({ productId, quantity })), catalog);
|
|
161
|
+
const readPriced = async () => priceFrom(await cartStore.read());
|
|
162
|
+
const addToCart = async (items) => {
|
|
163
|
+
const cart = await cartStore.read();
|
|
164
|
+
for (const { productId, quantity } of items) {
|
|
165
|
+
if (quantity <= 0)
|
|
166
|
+
continue;
|
|
167
|
+
cart.set(productId, (cart.get(productId) ?? 0) + quantity);
|
|
168
|
+
}
|
|
169
|
+
await cartStore.write(cart);
|
|
170
|
+
return priceFrom(cart);
|
|
171
|
+
};
|
|
172
|
+
const setQuantity = async (productId, quantity) => {
|
|
173
|
+
const cart = await cartStore.read();
|
|
174
|
+
if (quantity <= 0)
|
|
175
|
+
cart.delete(productId);
|
|
176
|
+
else
|
|
177
|
+
cart.set(productId, quantity);
|
|
178
|
+
await cartStore.write(cart);
|
|
179
|
+
return priceFrom(cart);
|
|
180
|
+
};
|
|
181
|
+
const removeFromCart = async (productId) => {
|
|
182
|
+
const cart = await cartStore.read();
|
|
183
|
+
cart.delete(productId);
|
|
184
|
+
await cartStore.write(cart);
|
|
185
|
+
return priceFrom(cart);
|
|
186
|
+
};
|
|
187
|
+
// Cart-bearing result, emitted three ways so either host reads it: structuredContent
|
|
188
|
+
// (ChatGPT widget + model), a JSON text block, and _meta (Claude's out-of-band channel).
|
|
189
|
+
const cartResult = (priced) => ({
|
|
190
|
+
structuredContent: { products: catalog, cart: priced },
|
|
191
|
+
content: [{ type: "text", text: JSON.stringify(priced) }],
|
|
192
|
+
_meta: { [CART_META_KEY]: priced },
|
|
193
|
+
});
|
|
194
|
+
function buildServer() {
|
|
195
|
+
const server = new McpServer({ name: "attestomcp-storefront", version: "0.1.0" });
|
|
196
|
+
// ── UI-linked tools (6) — registerAppTool + the canonical UI_META ───────
|
|
197
|
+
registerAppTool(server, "browse-products", {
|
|
198
|
+
title: "Browse Products",
|
|
199
|
+
description: "Show the storefront catalog as an interactive visual product picker (a grid with images). " +
|
|
200
|
+
"Call this whenever the user asks what you sell, what's available, to see/show/browse products, or " +
|
|
201
|
+
"to shop — it renders the grid for them. Prefer it over describing the catalog in text.",
|
|
202
|
+
inputSchema: {},
|
|
203
|
+
annotations: { readOnlyHint: true },
|
|
204
|
+
_meta: UI_META,
|
|
205
|
+
}, async () => {
|
|
206
|
+
const priced = await readPriced();
|
|
207
|
+
return {
|
|
208
|
+
content: [
|
|
209
|
+
{
|
|
210
|
+
type: "text",
|
|
211
|
+
text: `The product picker is now showing the catalog visually to the user (${catalog.length} products in a grid with images). ` +
|
|
212
|
+
`Do NOT re-list the products as text — they can see them. Briefly invite them to pick items or tell you what to add. ` +
|
|
213
|
+
`Adjust the cart by id with add-to-cart / set-quantity / remove-from-cart; check out with checkout.`,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
structuredContent: { products: catalog, cart: priced },
|
|
217
|
+
_meta: { [CATALOG_META_KEY]: { products: catalog }, [CART_META_KEY]: priced },
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
registerAppTool(server, "add-to-cart", { title: "Add to Cart", description: "Add products to the cart by id (quantities add on top).", inputSchema: { items: z.array(z.object({ productId: z.string(), quantity: z.number().int().min(1) })) }, annotations: { readOnlyHint: false }, _meta: UI_META }, async ({ items }) => cartResult(await addToCart(items)));
|
|
221
|
+
registerAppTool(server, "set-quantity", { title: "Set Quantity", description: "Set the exact quantity of a product by id (0 removes).", inputSchema: { productId: z.string(), quantity: z.number().int().min(0) }, annotations: { readOnlyHint: false }, _meta: UI_META }, async ({ productId, quantity }) => cartResult(await setQuantity(productId, quantity)));
|
|
222
|
+
registerAppTool(server, "remove-from-cart", { title: "Remove from Cart", description: "Remove a product from the cart by id.", inputSchema: { productId: z.string() }, annotations: { readOnlyHint: false }, _meta: UI_META }, async ({ productId }) => cartResult(await removeFromCart(productId)));
|
|
223
|
+
registerAppTool(server, "get-cart", { title: "Get Cart", description: "Return the current cart: line items, quantities, total.", inputSchema: {}, annotations: { readOnlyHint: true }, _meta: UI_META }, async () => cartResult(await readPriced()));
|
|
224
|
+
registerAppTool(server, "checkout", { title: "Checkout", description: "Snapshot the cart into an order and return a checkout link; if gated, also a `requires` manifest of what the buyer must prove on the page.", inputSchema: { items: z.array(z.object({ productId: z.string(), quantity: z.number().int().positive() })).optional() }, annotations: { readOnlyHint: false }, _meta: UI_META }, async ({ items }) => {
|
|
225
|
+
const entries = items?.length ? items : [...(await cartStore.read()).entries()].map(([productId, quantity]) => ({ productId, quantity }));
|
|
226
|
+
if (entries.length === 0)
|
|
227
|
+
return { content: [{ type: "text", text: "The cart is empty — add items before checking out." }], isError: true };
|
|
228
|
+
// Random id (not a per-instance counter): two serverless instances must
|
|
229
|
+
// not both mint "ORD-1" for different carts.
|
|
230
|
+
const order = createOrder(entries, `ORD-${Math.random().toString(36).slice(2, 8)}`, catalog);
|
|
231
|
+
await createdOrderStore.write(order.id, order);
|
|
232
|
+
const checkoutUrl = `${baseUrl}/checkout?order=${order.id}`;
|
|
233
|
+
// ← where AttestoMcp mounts on. Re-home any /attestomcp/* approve link onto this
|
|
234
|
+
// server's origin, so the gate links share the checkout link's base.
|
|
235
|
+
const rawRequires = resolveGate?.(order);
|
|
236
|
+
const requires = rawRequires ? homeRequires(rawRequires, baseUrl) : undefined;
|
|
237
|
+
const priced = priceFrom(new Map(entries.map((e) => [e.productId, e.quantity])));
|
|
238
|
+
// Cart-bearing structuredContent (FR-014): a fresh ChatGPT widget instance
|
|
239
|
+
// hydrates the real cart instead of an empty one.
|
|
240
|
+
const payload = { orderId: order.id, checkoutUrl, ...(requires?.length ? { requires } : {}), products: catalog, cart: priced };
|
|
241
|
+
return { structuredContent: payload, content: [{ type: "text", text: JSON.stringify({ orderId: order.id, checkoutUrl, requires: requires ?? [] }) }], _meta: { [CART_META_KEY]: priced } };
|
|
242
|
+
});
|
|
243
|
+
// ── plain tools (3) — registerTool, no widget ───────────────────────────
|
|
244
|
+
server.registerTool("get-product-details", { title: "Get Product Details", description: "Return full details for a single product by id.", inputSchema: { productId: z.string() }, annotations: { readOnlyHint: true } }, async ({ productId }) => {
|
|
245
|
+
const product = getProduct(catalog, productId);
|
|
246
|
+
return product
|
|
247
|
+
? { content: [{ type: "text", text: JSON.stringify(product) }], structuredContent: { product } }
|
|
248
|
+
: { content: [{ type: "text", text: `No product found with id "${productId}".` }], isError: true };
|
|
249
|
+
});
|
|
250
|
+
server.registerTool("get-product-reviews", { title: "Get Product Reviews", description: "Return customer reviews for a single product by id.", inputSchema: { productId: z.string() }, annotations: { readOnlyHint: true } }, async ({ productId }) => {
|
|
251
|
+
const r = getReviews(reviews, productId);
|
|
252
|
+
return { content: [{ type: "text", text: JSON.stringify(r) }], structuredContent: { reviews: r } };
|
|
253
|
+
});
|
|
254
|
+
server.registerTool("get-order-status", { title: "Get Order Status", description: "Read-only status of a completed purchase (the buyer completes checkout on the page; this only reports).", inputSchema: { orderId: z.string() }, annotations: { readOnlyHint: true } }, async ({ orderId }) => {
|
|
255
|
+
const order = await orderStore.read(orderId);
|
|
256
|
+
if (!order)
|
|
257
|
+
return { content: [{ type: "text", text: `Order ${orderId}: pending — the buyer hasn't finished on the checkout page yet.` }], structuredContent: { orderId, status: "pending" } };
|
|
258
|
+
return { content: [{ type: "text", text: JSON.stringify(order) }], structuredContent: { orderId, status: "completed", order } };
|
|
259
|
+
});
|
|
260
|
+
// ── widget resource — two registrations from one bundle ─────────────────
|
|
261
|
+
// Claude / MCP-Apps hosts read RESOURCE_URI; ChatGPT reads the skybridge URI.
|
|
262
|
+
// `data:` in the CSP so the widget's inline SVG image placeholder renders (FR-014).
|
|
263
|
+
registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
|
|
264
|
+
contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: await loadBundle(), _meta: { ui: { csp: { resourceDomains: [...IMAGE_DOMAINS, "data:"], connectDomains: baseUrl ? [baseUrl] : [] } } } }],
|
|
265
|
+
}));
|
|
266
|
+
server.registerResource("product-picker-skybridge", SKYBRIDGE_URI, { mimeType: SKYBRIDGE_MIME }, async () => ({
|
|
267
|
+
contents: [{ uri: SKYBRIDGE_URI, mimeType: SKYBRIDGE_MIME, text: await loadBundle(), _meta: { "openai/widgetCSP": { connect_domains: baseUrl ? [baseUrl] : [], resource_domains: [...IMAGE_DOMAINS, "data:"] } } }],
|
|
268
|
+
}));
|
|
269
|
+
return server;
|
|
270
|
+
}
|
|
271
|
+
// MCP over streamable HTTP (stateless per request), mirroring the reference server.
|
|
272
|
+
app.all("/mcp", async (req, res) => {
|
|
273
|
+
// Self-derive the public origin from the first request so checkout URLs are
|
|
274
|
+
// absolute behind any proxy (Vercel, a tunnel) with zero config — without it,
|
|
275
|
+
// `${baseUrl}/checkout` would be relative and the widget's `new URL()` throws.
|
|
276
|
+
if (!baseUrl)
|
|
277
|
+
baseUrl = originFromRequest(req);
|
|
278
|
+
const server = buildServer();
|
|
279
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
280
|
+
res.on("close", () => { transport.close().catch(() => { }); server.close().catch(() => { }); });
|
|
281
|
+
try {
|
|
282
|
+
await server.connect(transport);
|
|
283
|
+
await transport.handleRequest(req, res, req.body);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
if (!res.headersSent)
|
|
287
|
+
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "error" }, id: null });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
// The checkout page: the ONE shared three-gate page (renderRequirements), so the
|
|
291
|
+
// storefront and the committed demo render the same polished checkout (T030). This
|
|
292
|
+
// page LINKS to the ceremony routes attestomcp.mount() registered (re-homed onto this
|
|
293
|
+
// origin, in policy order, payment last); it does NOT run the ceremony — completion
|
|
294
|
+
// happens on the mounted /attestomcp/* rails, which enforce the gates fail-closed.
|
|
295
|
+
app.get("/checkout", async (req, res) => {
|
|
296
|
+
const created = await createdOrderStore.read(String(req.query.order ?? ""));
|
|
297
|
+
if (!created)
|
|
298
|
+
return res.status(404).type("html").send("<h1>Unknown order</h1>");
|
|
299
|
+
// Read THIS order's verification (per order id — never global; Security
|
|
300
|
+
// invariant 4) so the page reflects what the buyer has proven so far, and
|
|
301
|
+
// re-price from the catalog with it (the discount opts in only once membership
|
|
302
|
+
// is presented — never trust the token's total; invariant 2/3).
|
|
303
|
+
const v = ((await verificationStore.read(created.id)) ?? {});
|
|
304
|
+
const ageVerified = v.ageVerified === true;
|
|
305
|
+
const loyaltyApplied = v.loyalty?.applied === true;
|
|
306
|
+
const order = ceremonyCatalog.createOrder(created.lines.map((l) => ({ productId: l.id, quantity: l.quantity })), created.id, { ageVerified, loyaltyApplied });
|
|
307
|
+
// A revisit of an already-completed order shows the paid state instead of the
|
|
308
|
+
// payment methods.
|
|
309
|
+
const done = (await orderStore.read(created.id)) ?? null;
|
|
310
|
+
// Resolve + re-home the manifest onto this server's mounted routes (each gate
|
|
311
|
+
// carries its OWN approveUrl — the renderer is route-agnostic). Resolve against
|
|
312
|
+
// the created order (the policy reads line ids + minimumAge — the re-priced
|
|
313
|
+
// `order` carries the same lines; the discounted total shows via `order` below).
|
|
314
|
+
const requires = homeRequires(resolveGate?.(created) ?? [], baseUrl);
|
|
315
|
+
const verification = { ageVerified, loyaltyApplied };
|
|
316
|
+
const paid = done ? { amount: done.amount, currency: done.currency, method: done.method } : null;
|
|
317
|
+
// An UNGATED storefront has no payment gate, so the manifest carries no
|
|
318
|
+
// `authorize` entry the renderer could derive a Pay CTA from — keep a simple
|
|
319
|
+
// instant-demo complete path (POST the order id to /checkout/place-order). A
|
|
320
|
+
// GATED order has NO such bypass: completion goes through the fail-closed payment
|
|
321
|
+
// gate (the manifest's `authorize` approveUrl → the renderer's single Pay CTA).
|
|
322
|
+
const ungated = requires.length === 0;
|
|
323
|
+
const orderQ = encodeURIComponent(order.id);
|
|
324
|
+
const payment = ungated
|
|
325
|
+
? {
|
|
326
|
+
methods: [
|
|
327
|
+
{ value: "demo", name: `Complete purchase (demo) — ${order.total} ${order.currency}`, desc: "No real charge — records the order and clears the cart.", placeOrder: true },
|
|
328
|
+
],
|
|
329
|
+
placeOrderPath: "/checkout/place-order",
|
|
330
|
+
orderToken: order.id,
|
|
331
|
+
}
|
|
332
|
+
: // A GATED order: offer the same payment methods the demo does — the headline
|
|
333
|
+
// passkey rail (authorize on-device; settles on-chain via x402 on Hedera) and
|
|
334
|
+
// the cross-device wallet rail — both mounted by attestomcp.mount(), both completing
|
|
335
|
+
// through the fail-closed gate (no bypass). Without this the renderer falls back
|
|
336
|
+
// to a single Pay CTA from the manifest and the x402/Hedera passkey option never shows.
|
|
337
|
+
{
|
|
338
|
+
methods: [
|
|
339
|
+
{ value: "passkey", name: "Pay with x402 Hedera · Passkey", desc: "Authorize with this device's passkey — payment settles on-chain via the x402 protocol (test network).", href: `/attestomcp/passkey?order=${orderQ}`, checked: true },
|
|
340
|
+
{ value: "dc-payment", name: "Cross-device wallet", desc: "Scan a QR and approve with your phone's passkey or wallet — also x402 on Hedera.", href: `/attestomcp/dc-payment?order=${orderQ}` },
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
res.type("html").send(renderRequirements(order, requires, verification, { ...(payment ? { payment } : {}), paid }));
|
|
344
|
+
});
|
|
345
|
+
app.post("/checkout/place-order", async (req, res) => {
|
|
346
|
+
const order = await createdOrderStore.read(String(req.body?.order ?? ""));
|
|
347
|
+
if (order) {
|
|
348
|
+
// Security invariant 1 — enforce gates on EVERY completion path, not just the
|
|
349
|
+
// rendered page. This instant-demo path completes WITHOUT a device ceremony, so
|
|
350
|
+
// it is only ever valid for an UNGATED order. A gated order (age / payment
|
|
351
|
+
// requirements) MUST complete through the fail-closed payment gate; refuse it
|
|
352
|
+
// here server-side. The checkout page only offers this button for ungated
|
|
353
|
+
// orders, but a DIRECT POST of a gated order id would otherwise bypass the gate
|
|
354
|
+
// entirely — e.g. an age-restricted order completing with no age proof. Hiding
|
|
355
|
+
// the button is not enforcement.
|
|
356
|
+
if ((resolveGate?.(order) ?? []).length > 0) {
|
|
357
|
+
res.status(403).type("html").send(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;max-width:32rem;margin:3rem auto"><h1>Verification required</h1><p>This order has age / payment requirements — complete it through checkout. It can't be placed from the instant-demo path.</p></body>`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
await orderStore.write(order.id, { orderId: order.id, amount: order.total, currency: order.currency, method: "demo", completedAt: new Date().toISOString() });
|
|
361
|
+
await cartStore.write(new Map()); // completion empties the cart
|
|
362
|
+
}
|
|
363
|
+
res.type("html").send(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;max-width:32rem;margin:3rem auto"><h1>✓ Order placed (demo)</h1><p>You can close this tab — the storefront will update.</p></body>`);
|
|
364
|
+
});
|
|
365
|
+
// The widget polls this after checkout to learn when the buyer finished on the page
|
|
366
|
+
// (MCP has no server→client push). It then shows the confirmation + clears its cart.
|
|
367
|
+
app.get("/checkout/order-status", async (req, res) => {
|
|
368
|
+
// The widget iframe polls this cross-origin; allow it (simple GET → no preflight).
|
|
369
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
370
|
+
const orderId = typeof req.query.orderId === "string" ? req.query.orderId : "";
|
|
371
|
+
const order = orderId ? await orderStore.read(orderId) : null;
|
|
372
|
+
res.json({ completed: !!order, order });
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
app,
|
|
376
|
+
catalog,
|
|
377
|
+
mcpServer: buildServer,
|
|
378
|
+
gate(resolve) { resolveGate = resolve; },
|
|
379
|
+
async listen(port = 3005) {
|
|
380
|
+
if (!baseUrl)
|
|
381
|
+
baseUrl = `http://localhost:${port}`;
|
|
382
|
+
await new Promise((resolve) => { app.listen(port, () => resolve()); });
|
|
383
|
+
return { url: `${baseUrl}/mcp`, port };
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The working cart: productId → quantity. */
|
|
2
|
+
export interface CartStore {
|
|
3
|
+
read(): Promise<Map<string, number>>;
|
|
4
|
+
write(cart: Map<string, number>): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare class MemoryCartStore implements CartStore {
|
|
7
|
+
private cart;
|
|
8
|
+
read(): Promise<Map<string, number>>;
|
|
9
|
+
write(cart: Map<string, number>): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** Completed-order records, keyed by order id (what `get-order-status` reads). The
|
|
12
|
+
* payload is opaque to the storefront — the demo's ceremony writes its rich
|
|
13
|
+
* completed-order shape (with settlement); a standalone storefront leaves it empty. */
|
|
14
|
+
export interface OrderStore<T = unknown> {
|
|
15
|
+
read(orderId: string): Promise<T | null>;
|
|
16
|
+
write(orderId: string, order: T): Promise<void>;
|
|
17
|
+
clear(orderId: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export declare class MemoryOrderStore<T = unknown> implements OrderStore<T> {
|
|
20
|
+
private orders;
|
|
21
|
+
read(orderId: string): Promise<T | null>;
|
|
22
|
+
write(orderId: string, order: T): Promise<void>;
|
|
23
|
+
clear(orderId: string): Promise<void>;
|
|
24
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Storefront state — cart + completed orders. In-memory by default (zero deps);
|
|
2
|
+
// inject a custom store for a persistent / multi-session backend (e.g. Redis on a
|
|
3
|
+
// serverless deployment). Keys are per session / per order — never process-global
|
|
4
|
+
// beyond this in-process default (Security invariant 4).
|
|
5
|
+
export class MemoryCartStore {
|
|
6
|
+
cart = new Map();
|
|
7
|
+
async read() {
|
|
8
|
+
return new Map(this.cart);
|
|
9
|
+
}
|
|
10
|
+
async write(cart) {
|
|
11
|
+
this.cart = new Map(cart);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class MemoryOrderStore {
|
|
15
|
+
orders = new Map();
|
|
16
|
+
async read(orderId) {
|
|
17
|
+
return this.orders.get(orderId) ?? null;
|
|
18
|
+
}
|
|
19
|
+
async write(orderId, order) {
|
|
20
|
+
this.orders.set(orderId, order);
|
|
21
|
+
}
|
|
22
|
+
async clear(orderId) {
|
|
23
|
+
this.orders.delete(orderId);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface AppToolMeta {
|
|
2
|
+
[key: string]: unknown;
|
|
3
|
+
/** MCP-Apps (Claude) — the widget resource to render. */
|
|
4
|
+
ui: {
|
|
5
|
+
resourceUri: string;
|
|
6
|
+
};
|
|
7
|
+
/** ChatGPT — the skybridge template (the registered `text/html+skybridge` resource). */
|
|
8
|
+
"openai/outputTemplate": string;
|
|
9
|
+
/** ChatGPT — authorizes `window.openai.callTool` from inside the widget. */
|
|
10
|
+
"openai/widgetAccessible": true;
|
|
11
|
+
/** ChatGPT — the invoking/invoked status strings shown while a tool runs. */
|
|
12
|
+
"openai/toolInvocation": {
|
|
13
|
+
invoking: string;
|
|
14
|
+
invoked: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface AppToolMetaUris {
|
|
18
|
+
/** The MCP-Apps `ui://` resource (Claude). */
|
|
19
|
+
resourceUri: string;
|
|
20
|
+
/** The ChatGPT skybridge `ui://` resource; defaults to `resourceUri` if a single resource serves both. */
|
|
21
|
+
skybridgeUri?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Build the canonical UI-linked tool `_meta` (both host surfaces, `widgetAccessible` always on). */
|
|
24
|
+
export declare function appToolMeta(uris: AppToolMetaUris, status?: {
|
|
25
|
+
invoking?: string;
|
|
26
|
+
invoked?: string;
|
|
27
|
+
}): AppToolMeta;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The canonical `_meta` for a UI-linked storefront tool — emitting BOTH host
|
|
2
|
+
// surfaces from ONE place so neither can be forgotten.
|
|
3
|
+
//
|
|
4
|
+
// • MCP-Apps hosts (Claude native app / claude.ai) read `ui.resourceUri`.
|
|
5
|
+
// • ChatGPT (skybridge) reads the `openai/*` keys.
|
|
6
|
+
//
|
|
7
|
+
// `openai/widgetAccessible: true` is what authorizes `window.openai.callTool`.
|
|
8
|
+
// The reference demo set `openai/outputTemplate` but NOT `widgetAccessible`
|
|
9
|
+
// (an inline `_meta` that forgot a key), which is exactly why its widget rendered
|
|
10
|
+
// in ChatGPT but was interactively dead — the steppers and Checkout button
|
|
11
|
+
// silently no-op'd. Routing every UI-linked tool through this builder makes that
|
|
12
|
+
// omission impossible (FR-014).
|
|
13
|
+
/** Build the canonical UI-linked tool `_meta` (both host surfaces, `widgetAccessible` always on). */
|
|
14
|
+
export function appToolMeta(uris, status) {
|
|
15
|
+
return {
|
|
16
|
+
ui: { resourceUri: uris.resourceUri },
|
|
17
|
+
"openai/outputTemplate": uris.skybridgeUri ?? uris.resourceUri,
|
|
18
|
+
"openai/widgetAccessible": true,
|
|
19
|
+
"openai/toolInvocation": {
|
|
20
|
+
invoking: status?.invoking ?? "Working…",
|
|
21
|
+
invoked: status?.invoked ?? "Done",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|