@openmobilehub/credentagent-storefront 0.2.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/dist/index.js ADDED
@@ -0,0 +1,159 @@
1
+ // @openmobilehub/credentagent-storefront — the agentic storefront core (v0.1 slice).
2
+ //
3
+ // The cart → priced-cart → order model an MCP shopping app needs, **catalog-injected**
4
+ // (bring your own products). Own-the-code: fork it and edit your catalog. This slice
5
+ // is the pure pricing/order model; the MCP tools + widget bundle that render it are
6
+ // tracked in the roadmap.
7
+ import { PRODUCT_IMAGES } from "./generated-images.js";
8
+ /** Default loyalty discount, in percent. Override per-call via PriceOpts. */
9
+ export const LOYALTY_DISCOUNT_PCT = 10;
10
+ /** `_meta` keys the storefront tools use to embed the catalog / cart for the widget to read. */
11
+ export const CATALOG_META_KEY = "product-picker/catalog";
12
+ export const CART_META_KEY = "product-picker/cart";
13
+ function round2(n) {
14
+ return Math.round(n * 100) / 100;
15
+ }
16
+ /**
17
+ * Price a cart against an injected catalog. Unknown ids are collected, not
18
+ * thrown. Pure — no globals, so the same function serves any storefront.
19
+ */
20
+ export function priceCart(items, catalog, opts = {}) {
21
+ const byId = new Map(catalog.map((p) => [p.id, p]));
22
+ const lines = [];
23
+ const unknownIds = [];
24
+ let hasAgeRestricted = false;
25
+ for (const { productId, quantity } of items) {
26
+ const product = byId.get(productId);
27
+ if (!product) {
28
+ unknownIds.push(productId);
29
+ continue;
30
+ }
31
+ if (quantity <= 0)
32
+ continue;
33
+ if (product.minimumAge != null)
34
+ hasAgeRestricted = true;
35
+ lines.push({
36
+ id: product.id,
37
+ name: product.name,
38
+ unitPrice: product.price,
39
+ currency: product.currency,
40
+ quantity,
41
+ lineTotal: round2(product.price * quantity),
42
+ // Re-derived onto the line so a priced Order is gate-ready (inv #2).
43
+ ...(product.minimumAge != null ? { minimumAge: product.minimumAge } : {}),
44
+ category: product.category,
45
+ });
46
+ }
47
+ const itemCount = lines.reduce((sum, l) => sum + l.quantity, 0);
48
+ const subtotal = round2(lines.reduce((sum, l) => sum + l.lineTotal, 0));
49
+ const loyaltyApplied = !!opts.loyaltyApplied;
50
+ const pct = opts.loyaltyDiscountPct ?? LOYALTY_DISCOUNT_PCT;
51
+ const discount = loyaltyApplied ? round2(subtotal * (pct / 100)) : 0;
52
+ const total = round2(subtotal - discount);
53
+ const currency = lines[0]?.currency ?? "USD";
54
+ return {
55
+ lines,
56
+ itemCount,
57
+ subtotal,
58
+ discount,
59
+ total,
60
+ currency,
61
+ unknownIds,
62
+ hasAgeRestricted,
63
+ ageVerified: !!opts.ageVerified,
64
+ loyaltyApplied,
65
+ };
66
+ }
67
+ /** The strictest minimum age across the cart's products, or null if none. */
68
+ export function requiredAgeForLines(lines, catalog) {
69
+ const byId = new Map(catalog.map((p) => [p.id, p]));
70
+ let max = null;
71
+ for (const { id } of lines) {
72
+ const m = byId.get(id)?.minimumAge;
73
+ if (m != null && (max === null || m > max))
74
+ max = m;
75
+ }
76
+ return max;
77
+ }
78
+ /** Snapshot a priced cart into an immutable order. */
79
+ export function createOrder(items, id, catalog, opts = {}) {
80
+ const { lines, itemCount, subtotal, discount, total, currency } = priceCart(items, catalog, opts);
81
+ return { id, lines, itemCount, subtotal, discount, total, currency, createdAt: new Date().toISOString() };
82
+ }
83
+ /** Look up a product by id in the injected catalog. */
84
+ export function getProduct(catalog, productId) {
85
+ return catalog.find((p) => p.id === productId);
86
+ }
87
+ /** Reviews for a product, from the injected reviews map (empty if none). */
88
+ export function getReviews(reviews, productId) {
89
+ return reviews?.[productId] ?? [];
90
+ }
91
+ /** Wrap a static product array as a {@link CatalogSource} — the zero-config default (never fails). */
92
+ export function staticCatalog(products) {
93
+ return { load: async () => products, current: () => products };
94
+ }
95
+ /** True when `x` is a {@link CatalogSource} (has a `load` method) rather than a plain `Product[]`. */
96
+ export function isCatalogSource(x) {
97
+ return !!x && !Array.isArray(x) && typeof x.load === "function";
98
+ }
99
+ /** A tiny runnable catalog (incl. one age-restricted item) so the package demos itself. */
100
+ // Generated, self-contained product images (see generated-images.ts) — emoji tiles
101
+ // embedded as data URIs, so the catalog needs no external image service.
102
+ export const SAMPLE_CATALOG = [
103
+ {
104
+ id: "aurora-headphones",
105
+ name: "Aurora Wireless Headphones",
106
+ price: 199.0,
107
+ currency: "USD",
108
+ image: PRODUCT_IMAGES["aurora-headphones"],
109
+ category: "Audio",
110
+ description: "Over-ear ANC headphones with 40h battery life.",
111
+ },
112
+ {
113
+ id: "oak-whiskey",
114
+ name: "Oak Reserve Whiskey Collection",
115
+ price: 124.0,
116
+ currency: "USD",
117
+ image: PRODUCT_IMAGES["oak-whiskey"],
118
+ category: "Beverages",
119
+ description: "Trio of small-batch aged whiskeys. 21+ only.",
120
+ minimumAge: 21,
121
+ },
122
+ {
123
+ id: "drift-mouse",
124
+ name: "Drift Wireless Mouse",
125
+ price: 49.0,
126
+ currency: "USD",
127
+ image: PRODUCT_IMAGES["drift-mouse"],
128
+ category: "Electronics",
129
+ description: "Ergonomic silent-click wireless mouse, 6-month battery.",
130
+ },
131
+ {
132
+ id: "celebration-champagne",
133
+ name: "Celebration Champagne Duo",
134
+ price: 89.0,
135
+ currency: "USD",
136
+ image: PRODUCT_IMAGES["celebration-champagne"],
137
+ category: "Beverages",
138
+ description: "Brut champagne duo with two crystal flutes. 21+ only.",
139
+ minimumAge: 21,
140
+ },
141
+ {
142
+ id: "summit-backpack",
143
+ name: "Summit Trail Backpack",
144
+ price: 129.0,
145
+ currency: "USD",
146
+ image: PRODUCT_IMAGES["summit-backpack"],
147
+ category: "Outdoors",
148
+ description: "35L weatherproof hiking backpack with a stowaway rain cover.",
149
+ },
150
+ {
151
+ id: "lumen-desk-lamp",
152
+ name: "Lumen LED Desk Lamp",
153
+ price: 59.0,
154
+ currency: "USD",
155
+ image: PRODUCT_IMAGES["lumen-desk-lamp"],
156
+ category: "Home",
157
+ description: "Dimmable LED desk lamp with a USB-C charging base.",
158
+ },
159
+ ];
@@ -0,0 +1,31 @@
1
+ import type { StorageProvider } from "./server.js";
2
+ /** The minimal slice of the Upstash client the adapters use (also the injectable test seam). */
3
+ export interface RedisLike {
4
+ get<T = unknown>(key: string): Promise<T | null>;
5
+ set(key: string, value: unknown): Promise<unknown>;
6
+ del(key: string): Promise<unknown>;
7
+ }
8
+ /** Optional loader seam so the missing-peer-dependency path is testable while the dep is installed. */
9
+ export type UpstashLoader = () => Promise<{
10
+ new (config: {
11
+ url: string;
12
+ token: string;
13
+ }): RedisLike;
14
+ }>;
15
+ export interface RedisStorageOptions {
16
+ /** Upstash REST URL (with `token`). */
17
+ url?: string;
18
+ /** Upstash REST token (with `url`). */
19
+ token?: string;
20
+ /** Inject a pre-built / fake client instead of `url`+`token` (tests, custom backends). */
21
+ client?: RedisLike;
22
+ /** Key prefix isolating tenants on a shared backend. Default `"credentagent-storefront"`. */
23
+ namespace?: string;
24
+ /** @internal Override how `@upstash/redis` is loaded (for tests). */
25
+ _load?: UpstashLoader;
26
+ }
27
+ /**
28
+ * Build a {@link StorageProvider} backed by an Upstash-compatible Redis. Pass the result as
29
+ * `createStorefront({ storage })`. Supply either `{ url, token }` or an injected `{ client }`.
30
+ */
31
+ export declare function redisStorage(options: RedisStorageOptions): StorageProvider;
package/dist/redis.js ADDED
@@ -0,0 +1,140 @@
1
+ // redisStorage() — first-class persistence for createStorefront().
2
+ //
3
+ // Builds all four stores (cart, created-order, completed-order, verification) over an
4
+ // Upstash-compatible Redis, so a production/serverless deployment gets shared,
5
+ // cross-instance state with ONE option instead of hand-written adapters:
6
+ //
7
+ // import { createStorefront } from "@openmobilehub/credentagent-storefront/server";
8
+ // import { redisStorage } from "@openmobilehub/credentagent-storefront/redis";
9
+ // const store = createStorefront({ storage: redisStorage({ url, token, namespace: "my-shop" }) });
10
+ //
11
+ // `@upstash/redis` is an OPTIONAL peer dependency: it is loaded LAZILY, only when a real
12
+ // Redis op runs on the { url, token } path. Importing this module, calling `redisStorage`,
13
+ // and the injected-`client` path all need no dependency (Security-lean in-memory story).
14
+ //
15
+ // Keys are `${namespace}:${kind}:${orderId}` — order/verification state is scoped per
16
+ // order id (never process-global; Security Invariant #4) and `namespace` isolates tenants
17
+ // sharing one backend. This layer PERSISTS state only; it is not a trust anchor and never
18
+ // affects `trust_level`.
19
+ const DEFAULT_NAMESPACE = "credentagent-storefront";
20
+ function joinKey(...parts) {
21
+ return parts.join(":");
22
+ }
23
+ class RedisCartStore {
24
+ redis;
25
+ namespace;
26
+ // Keyed per session (`${namespace}:cart:${sessionId}`) so concurrent buyers on one
27
+ // provider get independent carts (Security invariant 4).
28
+ constructor(redis, namespace) {
29
+ this.redis = redis;
30
+ this.namespace = namespace;
31
+ }
32
+ keyFor(sessionId) {
33
+ return joinKey(this.namespace, "cart", sessionId);
34
+ }
35
+ async read(sessionId) {
36
+ const obj = (await this.redis.get(this.keyFor(sessionId))) ?? {};
37
+ return new Map(Object.entries(obj));
38
+ }
39
+ async write(sessionId, cart) {
40
+ await this.redis.set(this.keyFor(sessionId), Object.fromEntries(cart));
41
+ }
42
+ }
43
+ class RedisOrderStore {
44
+ redis;
45
+ namespace;
46
+ kind;
47
+ // The `kind` distinguishes the two order slots (created vs completed) so their keys
48
+ // never collide under one namespace (U1).
49
+ constructor(redis, namespace, kind) {
50
+ this.redis = redis;
51
+ this.namespace = namespace;
52
+ this.kind = kind;
53
+ }
54
+ keyFor(orderId) {
55
+ return joinKey(this.namespace, "order", this.kind, orderId);
56
+ }
57
+ async read(orderId) {
58
+ return (await this.redis.get(this.keyFor(orderId))) ?? null;
59
+ }
60
+ async write(orderId, order) {
61
+ await this.redis.set(this.keyFor(orderId), order);
62
+ }
63
+ async clear(orderId) {
64
+ await this.redis.del(this.keyFor(orderId));
65
+ }
66
+ }
67
+ class RedisVerificationStore {
68
+ redis;
69
+ namespace;
70
+ // Full-record get/set per order id. The ceremony merges fields at the call site before
71
+ // writing, so no adapter-level merge is needed; isolation is per-order keying.
72
+ constructor(redis, namespace) {
73
+ this.redis = redis;
74
+ this.namespace = namespace;
75
+ }
76
+ keyFor(orderId) {
77
+ return joinKey(this.namespace, "verification", orderId);
78
+ }
79
+ async read(orderId) {
80
+ return (await this.redis.get(this.keyFor(orderId))) ?? undefined;
81
+ }
82
+ async write(orderId, record) {
83
+ await this.redis.set(this.keyFor(orderId), record);
84
+ }
85
+ async clear(orderId) {
86
+ await this.redis.del(this.keyFor(orderId));
87
+ }
88
+ }
89
+ // A RedisLike that loads `@upstash/redis` and constructs the real client on FIRST use.
90
+ // Deferring the import keeps `redisStorage()` synchronous and means the dependency is
91
+ // only required when a Redis op actually runs (never at import / construction time).
92
+ function lazyUpstashClient(url, token, load) {
93
+ let clientP;
94
+ const client = () => {
95
+ if (!clientP) {
96
+ clientP = load()
97
+ .then((Redis) => new Redis({ url, token }))
98
+ .catch((cause) => {
99
+ throw new Error("redisStorage: `@upstash/redis` is required for the { url, token } path but could not be " +
100
+ "loaded. Install it (`npm i @upstash/redis`) or pass a `client`.", { cause });
101
+ });
102
+ }
103
+ return clientP;
104
+ };
105
+ return {
106
+ async get(key) {
107
+ return (await client()).get(key);
108
+ },
109
+ async set(key, value) {
110
+ return (await client()).set(key, value);
111
+ },
112
+ async del(key) {
113
+ return (await client()).del(key);
114
+ },
115
+ };
116
+ }
117
+ const defaultLoader = async () => (await import("@upstash/redis")).Redis;
118
+ function resolveClient(options) {
119
+ if (options.client)
120
+ return options.client;
121
+ const { url, token } = options;
122
+ if (!url || !token) {
123
+ throw new Error("redisStorage: provide a `client`, or both `url` and `token`.");
124
+ }
125
+ return lazyUpstashClient(url, token, options._load ?? defaultLoader);
126
+ }
127
+ /**
128
+ * Build a {@link StorageProvider} backed by an Upstash-compatible Redis. Pass the result as
129
+ * `createStorefront({ storage })`. Supply either `{ url, token }` or an injected `{ client }`.
130
+ */
131
+ export function redisStorage(options) {
132
+ const namespace = options.namespace ?? DEFAULT_NAMESPACE;
133
+ const redis = resolveClient(options);
134
+ return {
135
+ cartStore: new RedisCartStore(redis, namespace),
136
+ createdOrderStore: new RedisOrderStore(redis, namespace, "created"),
137
+ orderStore: new RedisOrderStore(redis, namespace, "completed"),
138
+ verificationStore: new RedisVerificationStore(redis, namespace),
139
+ };
140
+ }
@@ -0,0 +1,124 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { Express, Request } from "express";
3
+ import type { CatalogSource, Order, Product, Review } from "./index.js";
4
+ export type { CatalogSource } from "./index.js";
5
+ import type { CartStore, OrderStore } from "./state.js";
6
+ export type { CartStore, OrderStore } from "./state.js";
7
+ import { type CeremonyOrder, type VerificationStore } from "@openmobilehub/credentagent-gate";
8
+ /** Given a priced order, return the `requires` manifest (or `undefined` = ungated). */
9
+ export type GateResolver = (order: Order) => unknown[] | undefined;
10
+ /**
11
+ * A persistence provider that supplies all four stores at once (e.g. `redisStorage(...)`
12
+ * from `@openmobilehub/credentagent-storefront/redis`). Passed as `StorefrontOptions.storage`
13
+ * so a production deployment gets shared, cross-instance state with one option instead of
14
+ * hand-written adapters. An explicit per-slot store (`cartStore`, `orderStore`, …) still
15
+ * takes precedence over the provider's store for that slot (the custom-backend escape hatch).
16
+ */
17
+ export interface StorageProvider {
18
+ cartStore: CartStore;
19
+ createdOrderStore: OrderStore<Order>;
20
+ orderStore: OrderStore<CompletedOrderRecord>;
21
+ verificationStore: VerificationStore;
22
+ }
23
+ export interface StorefrontOptions {
24
+ /**
25
+ * Products to sell. Defaults to the package's `SAMPLE_CATALOG`. Pass a plain `Product[]`
26
+ * for the zero-config static catalog, or a {@link CatalogSource} (e.g.
27
+ * `firestoreCatalog(...)` from `@openmobilehub/credentagent-storefront/firestore`) for a
28
+ * live, editable catalog the module loads + caches server-side. Prices and age
29
+ * thresholds always re-derive from this catalog server-side (Security invariant 2).
30
+ */
31
+ catalog?: Product[] | CatalogSource;
32
+ /** Reviews per product id, backing `get-product-reviews`. */
33
+ reviews?: Record<string, Review[]>;
34
+ /** Origin the checkout links resolve from. Default `http://localhost:<port>`. */
35
+ baseUrl?: string;
36
+ /** Cart store; default in-memory. */
37
+ cartStore?: CartStore;
38
+ /** Completed-order store (read by `get-order-status`); default in-memory. */
39
+ orderStore?: OrderStore<CompletedOrderRecord>;
40
+ /**
41
+ * Created-but-not-yet-completed orders (read by the checkout page + place-order),
42
+ * keyed by order id. Default in-memory. Inject a shared store (e.g. Redis) on a
43
+ * multi-instance serverless deployment, or the checkout page lands on a cold
44
+ * instance that never saw the order.
45
+ */
46
+ createdOrderStore?: OrderStore<Order>;
47
+ /**
48
+ * Per-order verification state the mounted ceremony writes (age proven / loyalty
49
+ * applied) and this server's `completion` seam reads back to re-price + enforce
50
+ * the age gate. Default in-memory; inject a shared store on a serverless
51
+ * deployment. Published on `app.locals.credentagent` so `credentagent.mount(store.app)`
52
+ * wires the rails against the SAME state (Security invariant 4).
53
+ */
54
+ verificationStore?: VerificationStore;
55
+ /**
56
+ * A persistence provider (e.g. `redisStorage({ url, token, namespace })`) that supplies
57
+ * all four stores at once. Optional — omit for the in-memory default. An explicit store
58
+ * above (`cartStore` / `orderStore` / `createdOrderStore` / `verificationStore`) takes
59
+ * precedence over the provider's store for that slot.
60
+ */
61
+ storage?: StorageProvider;
62
+ /**
63
+ * Stable HMAC key for the ceremony's challenge nonce (e.g. `process.env.GATE_SECRET`).
64
+ * Required so an options→verify hop survives an instance split on serverless. When
65
+ * absent, `allowEphemeralKey` defaults true so a single-process dev server / tests
66
+ * just run with a per-process key.
67
+ */
68
+ signingKey?: string;
69
+ /** Allow a per-process ephemeral signing key (default: true unless `signingKey` is set). */
70
+ allowEphemeralKey?: boolean;
71
+ /**
72
+ * Opt-in (default false): carry the created order in a signed Cart Mandate on the
73
+ * checkout link (`?order=<id>&cart=<base64url>`) instead of a `createdOrderStore`
74
+ * write, so a checkout survives an instance split with no shared created-order store
75
+ * (gate FR-007). Forces a concrete `signingKey` (generated if none) so the mandate the
76
+ * checkout tool issues is the one the gate rails verify. Verification + completion
77
+ * state still use their stores.
78
+ */
79
+ statelessOrders?: boolean;
80
+ /**
81
+ * Optional demo-mode settlement seam (e.g. on-chain). Throwing GATES completion:
82
+ * a configured-but-failed settle records nothing and leaves the cart intact.
83
+ */
84
+ settle?: (order: CeremonyOrder) => Promise<Record<string, unknown> & {
85
+ network: string;
86
+ txId: string;
87
+ status: string;
88
+ }>;
89
+ }
90
+ /**
91
+ * A completed-order record the widget poll + `get-order-status` read. The standalone
92
+ * demo `place-order` writes the lean shape (orderId/amount/currency/method/completedAt);
93
+ * the mounted ceremony's shared `completeOrder` writes the richer one (mandate id,
94
+ * gate outcomes, instrument, settlement) — both satisfy this superset so the poll
95
+ * reads either.
96
+ */
97
+ export interface CompletedOrderRecord {
98
+ orderId: string;
99
+ amount: number;
100
+ currency: string;
101
+ method: string;
102
+ completedAt: string;
103
+ mandateId?: string;
104
+ instrument?: unknown;
105
+ gates?: {
106
+ gate: string;
107
+ pass: boolean;
108
+ detail: string;
109
+ }[];
110
+ settlement?: unknown;
111
+ }
112
+ export interface Storefront {
113
+ app: Express;
114
+ /** The current catalog. Static source: the injected array; dynamic source: last-known-good. */
115
+ catalog: Product[];
116
+ gate(resolve: GateResolver): void;
117
+ listen(port?: number): Promise<{
118
+ url: string;
119
+ port: number;
120
+ }>;
121
+ mcpServer(): McpServer;
122
+ }
123
+ export declare function originFromRequest(req: Request): string;
124
+ export declare function createStorefront(opts?: StorefrontOptions): Storefront;