@curless/shopify-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.
Files changed (129) hide show
  1. package/dist/__tests__/card-contract.test.d.ts +2 -0
  2. package/dist/__tests__/card-contract.test.d.ts.map +1 -0
  3. package/dist/__tests__/card-contract.test.js +22 -0
  4. package/dist/__tests__/card-contract.test.js.map +1 -0
  5. package/dist/__tests__/catalog-registry.test.d.ts +2 -0
  6. package/dist/__tests__/catalog-registry.test.d.ts.map +1 -0
  7. package/dist/__tests__/catalog-registry.test.js +109 -0
  8. package/dist/__tests__/catalog-registry.test.js.map +1 -0
  9. package/dist/__tests__/challenge.test.d.ts +2 -0
  10. package/dist/__tests__/challenge.test.d.ts.map +1 -0
  11. package/dist/__tests__/challenge.test.js +46 -0
  12. package/dist/__tests__/challenge.test.js.map +1 -0
  13. package/dist/__tests__/config.test.d.ts +2 -0
  14. package/dist/__tests__/config.test.d.ts.map +1 -0
  15. package/dist/__tests__/config.test.js +123 -0
  16. package/dist/__tests__/config.test.js.map +1 -0
  17. package/dist/__tests__/connection-state.test.d.ts +2 -0
  18. package/dist/__tests__/connection-state.test.d.ts.map +1 -0
  19. package/dist/__tests__/connection-state.test.js +40 -0
  20. package/dist/__tests__/connection-state.test.js.map +1 -0
  21. package/dist/__tests__/multi-token-store.test.d.ts +2 -0
  22. package/dist/__tests__/multi-token-store.test.d.ts.map +1 -0
  23. package/dist/__tests__/multi-token-store.test.js +89 -0
  24. package/dist/__tests__/multi-token-store.test.js.map +1 -0
  25. package/dist/__tests__/oauth.test.d.ts +2 -0
  26. package/dist/__tests__/oauth.test.d.ts.map +1 -0
  27. package/dist/__tests__/oauth.test.js +112 -0
  28. package/dist/__tests__/oauth.test.js.map +1 -0
  29. package/dist/__tests__/output-schema.test.d.ts +2 -0
  30. package/dist/__tests__/output-schema.test.d.ts.map +1 -0
  31. package/dist/__tests__/output-schema.test.js +112 -0
  32. package/dist/__tests__/output-schema.test.js.map +1 -0
  33. package/dist/__tests__/ping.test.d.ts +2 -0
  34. package/dist/__tests__/ping.test.d.ts.map +1 -0
  35. package/dist/__tests__/ping.test.js +22 -0
  36. package/dist/__tests__/ping.test.js.map +1 -0
  37. package/dist/__tests__/rate-limit.test.d.ts +2 -0
  38. package/dist/__tests__/rate-limit.test.d.ts.map +1 -0
  39. package/dist/__tests__/rate-limit.test.js +46 -0
  40. package/dist/__tests__/rate-limit.test.js.map +1 -0
  41. package/dist/__tests__/stock.test.d.ts +2 -0
  42. package/dist/__tests__/stock.test.d.ts.map +1 -0
  43. package/dist/__tests__/stock.test.js +109 -0
  44. package/dist/__tests__/stock.test.js.map +1 -0
  45. package/dist/__tests__/tenant.test.d.ts +2 -0
  46. package/dist/__tests__/tenant.test.d.ts.map +1 -0
  47. package/dist/__tests__/tenant.test.js +105 -0
  48. package/dist/__tests__/tenant.test.js.map +1 -0
  49. package/dist/__tests__/tools.test.d.ts +2 -0
  50. package/dist/__tests__/tools.test.d.ts.map +1 -0
  51. package/dist/__tests__/tools.test.js +543 -0
  52. package/dist/__tests__/tools.test.js.map +1 -0
  53. package/dist/__tests__/upstream-cap.test.d.ts +2 -0
  54. package/dist/__tests__/upstream-cap.test.d.ts.map +1 -0
  55. package/dist/__tests__/upstream-cap.test.js +78 -0
  56. package/dist/__tests__/upstream-cap.test.js.map +1 -0
  57. package/dist/__tests__/webhooks.test.d.ts +2 -0
  58. package/dist/__tests__/webhooks.test.d.ts.map +1 -0
  59. package/dist/__tests__/webhooks.test.js +88 -0
  60. package/dist/__tests__/webhooks.test.js.map +1 -0
  61. package/dist/card.generated.d.ts +2 -0
  62. package/dist/card.generated.d.ts.map +1 -0
  63. package/dist/card.generated.js +6 -0
  64. package/dist/card.generated.js.map +1 -0
  65. package/dist/catalog-registry.d.ts +44 -0
  66. package/dist/catalog-registry.d.ts.map +1 -0
  67. package/dist/catalog-registry.js +105 -0
  68. package/dist/catalog-registry.js.map +1 -0
  69. package/dist/catalog.d.ts +51 -0
  70. package/dist/catalog.d.ts.map +1 -0
  71. package/dist/catalog.js +189 -0
  72. package/dist/catalog.js.map +1 -0
  73. package/dist/challenge.d.ts +11 -0
  74. package/dist/challenge.d.ts.map +1 -0
  75. package/dist/challenge.js +36 -0
  76. package/dist/challenge.js.map +1 -0
  77. package/dist/config.d.ts +141 -0
  78. package/dist/config.d.ts.map +1 -0
  79. package/dist/config.js +65 -0
  80. package/dist/config.js.map +1 -0
  81. package/dist/connection.d.ts +10 -0
  82. package/dist/connection.d.ts.map +1 -0
  83. package/dist/connection.js +21 -0
  84. package/dist/connection.js.map +1 -0
  85. package/dist/index.d.ts +3 -0
  86. package/dist/index.d.ts.map +1 -0
  87. package/dist/index.js +974 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/legal.d.ts +52 -0
  90. package/dist/legal.d.ts.map +1 -0
  91. package/dist/legal.js +139 -0
  92. package/dist/legal.js.map +1 -0
  93. package/dist/load-config.d.ts +11 -0
  94. package/dist/load-config.d.ts.map +1 -0
  95. package/dist/load-config.js +23 -0
  96. package/dist/load-config.js.map +1 -0
  97. package/dist/multi-token-store.d.ts +18 -0
  98. package/dist/multi-token-store.d.ts.map +1 -0
  99. package/dist/multi-token-store.js +95 -0
  100. package/dist/multi-token-store.js.map +1 -0
  101. package/dist/oauth.d.ts +20 -0
  102. package/dist/oauth.d.ts.map +1 -0
  103. package/dist/oauth.js +123 -0
  104. package/dist/oauth.js.map +1 -0
  105. package/dist/rate-limit.d.ts +15 -0
  106. package/dist/rate-limit.d.ts.map +1 -0
  107. package/dist/rate-limit.js +56 -0
  108. package/dist/rate-limit.js.map +1 -0
  109. package/dist/stock.d.ts +39 -0
  110. package/dist/stock.d.ts.map +1 -0
  111. package/dist/stock.js +77 -0
  112. package/dist/stock.js.map +1 -0
  113. package/dist/tenant.d.ts +69 -0
  114. package/dist/tenant.d.ts.map +1 -0
  115. package/dist/tenant.js +68 -0
  116. package/dist/tenant.js.map +1 -0
  117. package/dist/token-store.d.ts +16 -0
  118. package/dist/token-store.d.ts.map +1 -0
  119. package/dist/token-store.js +47 -0
  120. package/dist/token-store.js.map +1 -0
  121. package/dist/tools.d.ts +201 -0
  122. package/dist/tools.d.ts.map +1 -0
  123. package/dist/tools.js +779 -0
  124. package/dist/tools.js.map +1 -0
  125. package/dist/webhooks.d.ts +30 -0
  126. package/dist/webhooks.d.ts.map +1 -0
  127. package/dist/webhooks.js +69 -0
  128. package/dist/webhooks.js.map +1 -0
  129. package/package.json +53 -0
package/dist/index.js ADDED
@@ -0,0 +1,974 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ // Moody Lenses — a remote MCP connector (Streamable HTTP).
5
+ //
6
+ // A buyer-facing storefront for ONE merchant: browse the lenses, get a checkout
7
+ // link, look up an order. Published to the Claude connector directory and to
8
+ // ChatGPT Apps, so it is a hosted URL rather than a local stdio server.
9
+ //
10
+ // ⚠️ NO MONEY PASSES THROUGH THIS SERVICE. Moody is not on a payment rail yet
11
+ // (no Stripe account, no wallet), so checkout is a Shopify cart permalink:
12
+ // Shopify collects, Shopify creates the order, Shopify emails the receipt. That
13
+ // is the whole reason it can ship now — there is no float, no ledger and no
14
+ // reconciliation to get wrong. When a rail is ready, `create_moody_checkout`
15
+ // gains a second branch; nothing else here changes.
16
+ //
17
+ // ⚠️ AND NO BUYER IDENTITY. Nobody logs in, so the server cannot know who is
18
+ // asking. That is why order lookup takes the order number AND the email, and
19
+ // why it must never grow a "list my orders" tool without real authentication:
20
+ // order numbers are sequential, and a list keyed on an email anyone can type is
21
+ // a way to read a stranger's purchases.
22
+ //
23
+ // env:
24
+ // MOODY_SHOPIFY_SHOP_DOMAIN moodylenses.myshopify.com
25
+ // MOODY_SHOPIFY_CLIENT_ID the app's client id (Dev Dashboard)
26
+ // MOODY_SHOPIFY_CLIENT_SECRET the app's client secret — signs the OAuth callback
27
+ // MOODY_SHOPIFY_ADMIN_TOKEN optional: a pre-existing token, skipping the install
28
+ // MOODY_TOKEN_PATH where the installed token is kept (default /app/data/moody-token.json)
29
+ // MOODY_APP_URL the SHOPIFY APP's base — OAuth callback lives here,
30
+ // must be a Curless hostname and must match the Dev
31
+ // Dashboard's Allowed redirection URL
32
+ // MOODY_PUBLIC_URL what shoppers and directories see — the merchant's
33
+ // own domain (default: same as MOODY_APP_URL)
34
+ // (all PREFIXED — the box's .env is shared, see below)
35
+ // PORT default 8090
36
+ // CATALOG_REFRESH_MS default 300000 (5 min)
37
+ // OPENAI_APPS_CHALLENGE_TOKENS host=token,host=token — ChatGPT Apps domain
38
+ // verification, per ORIGIN (the token is bound to one)
39
+ // OPENAI_APPS_CHALLENGE_TOKEN single-host fallback for the above
40
+ import { serve } from '@hono/node-server';
41
+ import { Hono } from 'hono';
42
+ import { MOODY_CARD_HTML } from './card.generated.js';
43
+ import { CatalogRegistry } from './catalog-registry.js';
44
+ import { Catalog } from './catalog.js';
45
+ import { challengeFor } from './challenge.js';
46
+ import { resolveStorefront } from './config.js';
47
+ import { credentialsRejected, needsAuth } from './connection.js';
48
+ import { docsPage } from './legal.js';
49
+ import { loadStorefrontConfig } from './load-config.js';
50
+ import { MultiTokenStore, isStorableShop } from './multi-token-store.js';
51
+ import { exchangeCode, installUrl, isShopDomain, makeState, verifyCallbackHmac, verifyState, } from './oauth.js';
52
+ import { RateLimiter, clientIp } from './rate-limit.js';
53
+ import { explainNoTenant, multiShopResolver, singleShopResolver } from './tenant.js';
54
+ import { TokenStore } from './token-store.js';
55
+ import { buildOutputSchemas, buildTools, callTool } from './tools.js';
56
+ import { handleWebhook, verifyWebhookHmac } from './webhooks.js';
57
+ const PORT = Number(process.env.PORT) || 8090;
58
+ // One process serves exactly one storefront, so the config is read once here
59
+ // and its names resolved once — the request path never re-derives them.
60
+ const STORE = resolveStorefront(loadStorefrontConfig());
61
+ const TOOLS = buildTools(STORE.tools, STORE.vocab);
62
+ const OUTPUT_SCHEMAS = buildOutputSchemas(STORE.tools);
63
+ const MERCHANT_NAME = STORE.config.server.title;
64
+ const REFRESH_MS = (() => {
65
+ const n = Number(process.env.CATALOG_REFRESH_MS ?? 300_000);
66
+ // A non-numeric or zero interval would busy-loop the Shopify API or never
67
+ // refresh at all; neither failure announces itself.
68
+ return Number.isFinite(n) && n >= 30_000 ? n : 300_000;
69
+ })();
70
+ // PREFIXED env, deliberately. Every app service on the box shares one `.env`
71
+ // (compose `env_file`), and sinocare-backend already reads the unprefixed
72
+ // SHOPIFY_SHOP_DOMAIN / SHOPIFY_ADMIN_TOKEN for a DIFFERENT store. Reading those
73
+ // here would silently repoint Sinocare's catalog at Moody's shop the moment this
74
+ // service was configured. No fallback to the unprefixed names: the fallback is
75
+ // exactly the collision.
76
+ const SHOP_DOMAIN = STORE.config.shopify.shopDomain ?? '';
77
+ const CLIENT_ID = process.env.STOREFRONT_SHOPIFY_CLIENT_ID ?? '';
78
+ const CLIENT_SECRET = process.env.STOREFRONT_SHOPIFY_CLIENT_SECRET ?? '';
79
+ // TWO base urls, because two different parties look at this service and they
80
+ // should not see the same hostname.
81
+ //
82
+ // APP_URL is the SHOPIFY APP's own address — where Shopify sends a merchant back
83
+ // after they authorise. That app is a Curless product: the next merchant installs
84
+ // the same one, so its callback belongs on a Curless hostname and must not move
85
+ // to any single merchant's domain. Putting it on mcp.moodycosmetics.com would
86
+ // bounce the SECOND merchant's install through Moody's website.
87
+ //
88
+ // ⚠️ It must also match an Allowed redirection URL in the Shopify Dev Dashboard,
89
+ // exactly. Changing it without changing that first breaks every install.
90
+ //
91
+ // The storefronts live on their own hostname and never under the gateway's:
92
+ // mcp.curless.ai's catch-all is the gateway, which publishes real OAuth
93
+ // metadata, so a client pointed there discovers the GATEWAY's sign-in and tries
94
+ // to register a merchant account for a storefront that needs none.
95
+ const APP_URL = STORE.appUrl;
96
+ // PUBLIC_URL is what a SHOPPER and an assistant directory see — the merchant's
97
+ // own domain. It carries the card's images, the listing icon and the legal
98
+ // pages, and it is the origin a directory walks up from to find a favicon: from
99
+ // mcp.moodycosmetics.com that lands on Moody's own mark, which is the whole
100
+ // reason the merchant owns this hostname.
101
+ //
102
+ // Defaults to APP_URL so a storefront with no domain of its own still works.
103
+ const PUBLIC_URL = STORE.publicUrl;
104
+ // Which of the two hostnames a request arrived on, so the front page can differ.
105
+ //
106
+ // The root used to serve the MERCHANT's status page — connected shop, product
107
+ // count, last sync — on every host, including the one a shopper and a directory
108
+ // reviewer reach. That is the wrong audience: they get an operations readout for
109
+ // a store they are trying to buy from.
110
+ //
111
+ // False whenever the two urls are the same, which is the single-host default: a
112
+ // deployment with no merchant domain of its own still gets the merchant page,
113
+ // because there is nobody else to serve.
114
+ const PUBLIC_HOST = new URL(PUBLIC_URL).host.toLowerCase();
115
+ const APP_HOST = new URL(APP_URL).host.toLowerCase();
116
+ const onBuyerHost = (c) => PUBLIC_HOST !== APP_HOST && (c.req.header('host') ?? '').toLowerCase() === PUBLIC_HOST;
117
+ // `read_discounts` is what lets the card show "Buy 2 get 5% OFF" — those live on
118
+ // the shop, not on any product. ⚠️ Scopes are granted at INSTALL: adding one here
119
+ // changes nothing until the merchant reinstalls, and until then the storefront
120
+ // runs exactly as before, minus the promotions.
121
+ const SCOPES = STORE.scopes;
122
+ const TOKEN_PATH = STORE.tokenPath;
123
+ // The shop domain is the one thing we cannot start without: it is which store
124
+ // this connector IS. Credentials can arrive later — the merchant installs when
125
+ // they install — but a connector with no shop has nothing to be.
126
+ // ...unless this IS the listed app, where having no shop at boot is the whole
127
+ // point: the shops are whoever installs it, and demanding one here would make
128
+ // the public build impossible to start.
129
+ if (STORE.config.shopify.mode === 'env' && (!SHOP_DOMAIN || !isShopDomain(SHOP_DOMAIN))) {
130
+ console.error(`${STORE.config.id}: shopify.shopDomain must be a *.myshopify.com domain in ${process.env.STOREFRONT_CONFIG}`);
131
+ process.exit(1);
132
+ }
133
+ const oauth = {
134
+ clientId: CLIENT_ID,
135
+ clientSecret: CLIENT_SECRET,
136
+ shopDomain: SHOP_DOMAIN,
137
+ appUrl: APP_URL,
138
+ scopes: SCOPES,
139
+ };
140
+ const tokens = new TokenStore(TOKEN_PATH);
141
+ // Two buckets. The wide one keeps anyone from spending the merchant's Shopify
142
+ // quota — an exhausted quota shows up to real buyers as a store with no
143
+ // products. The narrow one is on order lookup, which takes attacker-chosen
144
+ // input and answers a question about a real person's purchase.
145
+ // Who is shopping, as reported at handshake. MCP clients identify themselves in
146
+ // `initialize` (clientInfo.name — "claude-ai", "ChatGPT", …), but that arrives on
147
+ // a DIFFERENT request from the tool call, so it has to be remembered against the
148
+ // session the transport gives us. No session header → we fall back to the
149
+ // User-Agent, and then to nothing. It is a label on an order, not an
150
+ // authorisation decision, so a best guess is fine and a wrong one is harmless.
151
+ const clients = new Map();
152
+ const CLIENT_TTL_MS = 60 * 60_000;
153
+ const rememberClient = (sessionId, name) => {
154
+ if (!sessionId || !name)
155
+ return;
156
+ const now = Date.now();
157
+ for (const [k, v] of clients)
158
+ if (now - v.at > CLIENT_TTL_MS)
159
+ clients.delete(k);
160
+ if (clients.size > 5_000)
161
+ clients.clear();
162
+ clients.set(sessionId, { name, at: now });
163
+ };
164
+ const assistantFor = (c) => {
165
+ const sid = c.req.header('mcp-session-id');
166
+ const known = sid ? clients.get(sid)?.name : undefined;
167
+ if (known)
168
+ return known;
169
+ const ua = c.req.header('user-agent');
170
+ // Node's default UA and curl say nothing about who is shopping.
171
+ return ua && !/^(node|curl|undici|python)/i.test(ua) ? ua.slice(0, 60) : undefined;
172
+ };
173
+ // Named so the docs page can quote them instead of restating them from memory —
174
+ // a published number that drifts from the enforced one is worse than none.
175
+ const MCP_RATE_PER_MIN = 60;
176
+ const LOOKUP_RATE_PER_MIN = 10;
177
+ const mcpLimit = new RateLimiter(MCP_RATE_PER_MIN, 60_000);
178
+ const lookupLimit = new RateLimiter(LOOKUP_RATE_PER_MIN, 60_000);
179
+ // The Shopify config is resolved PER CALL rather than at boot, because the token
180
+ // arrives when the merchant installs — which may be days after this process
181
+ // started. Until then every tool answers "not connected yet" instead of the
182
+ // service being dead or, worse, looking like an empty store.
183
+ const shopifyConfig = async () => {
184
+ const envToken = process.env.STOREFRONT_SHOPIFY_ADMIN_TOKEN;
185
+ if (envToken)
186
+ return { shopDomain: SHOP_DOMAIN, adminToken: envToken };
187
+ const stored = await tokens.get();
188
+ return stored ? { shopDomain: SHOP_DOMAIN, adminToken: stored.accessToken } : null;
189
+ };
190
+ const catalog = new Catalog(shopifyConfig);
191
+ // ── Multi-tenant, for the listed app ────────────────────────────────────────
192
+ //
193
+ // Built in BOTH modes, and deliberately so. If these only existed under
194
+ // `oauth-install` then every site that reads a shop would need to know which
195
+ // mode it is in, and the one that forgot would answer as the wrong merchant.
196
+ // Instead the resolver is the single door: in `env` it always returns the one
197
+ // configured tenant, so the branded deployments behave exactly as before.
198
+ const multiTokens = new MultiTokenStore(`${dirname(TOKEN_PATH)}/shops`);
199
+ const registry = new CatalogRegistry(async (shop) => {
200
+ const stored = await multiTokens.get(shop);
201
+ return stored ? { shopDomain: shop, adminToken: stored.accessToken } : null;
202
+ }, { refreshMs: REFRESH_MS });
203
+ // The merchant's artwork, read once at boot from beside their config.
204
+ //
205
+ // Read here rather than per request so a missing file is a startup fact an
206
+ // operator sees in the log, not a 500 the first time a directory asks for the
207
+ // icon. Missing is allowed: a merchant who supplied none gets no icon rather
208
+ // than someone else's.
209
+ const brandAsset = (rel) => {
210
+ if (!rel)
211
+ return null;
212
+ try {
213
+ const raw = readFileSync(resolve(dirname(String(process.env.STOREFRONT_CONFIG)), rel));
214
+ // Copied into its own ArrayBuffer: node's Buffer is Uint8Array<ArrayBufferLike>,
215
+ // which since TS 5.7 is not the Uint8Array<ArrayBuffer> a response body wants.
216
+ const bytes = new Uint8Array(new ArrayBuffer(raw.byteLength));
217
+ bytes.set(raw);
218
+ return bytes;
219
+ }
220
+ catch (err) {
221
+ console.error(`[brand] ${rel} could not be read: ${err.message}`);
222
+ return null;
223
+ }
224
+ };
225
+ const ICON_PNG = brandAsset(STORE.config.server.iconFile);
226
+ const LOGO_PNG = brandAsset(STORE.config.server.logoFile);
227
+ const MULTI = STORE.config.shopify.mode === 'oauth-install';
228
+ /**
229
+ * The shop a request is about.
230
+ *
231
+ * Branded: nothing — the resolver knows the one shop, and a `shop` param would
232
+ * be a claim it checks rather than an answer it needs.
233
+ *
234
+ * Listed: the PATH, because the buyer's connector URL is what carries it. Not a
235
+ * header and not a query: the connector URL is configured once, in the host,
236
+ * and anything the model could vary per call is something it could get wrong at
237
+ * the moment money is committed.
238
+ */
239
+ const shopForRequest = (c) => MULTI ? c.req.param('shop') : undefined;
240
+ const resolveTenant = MULTI
241
+ ? multiShopResolver({ registry, tokens: multiTokens })
242
+ : singleShopResolver({ store: STORE, catalog, tokens });
243
+ const app = new Hono();
244
+ // The merchant's own mark, for anything that shows this connector by name — the
245
+ // assistant directories, the browser tab on the status page, `serverInfo.icons`.
246
+ // It is the MERCHANT's logo, not ours: what a shopper connects to is their shop.
247
+ //
248
+ // Cached hard because it never changes without a redeploy.
249
+ app.get('/icon.png', (c) => {
250
+ // Read from beside THIS merchant's config. A deployment that supplied no
251
+ // artwork 404s rather than borrowing another merchant's — the mistake the
252
+ // shared origin favicon already made once.
253
+ if (!ICON_PNG)
254
+ return c.notFound();
255
+ return c.body(ICON_PNG, 200, {
256
+ 'content-type': 'image/png',
257
+ 'cache-control': 'public, max-age=86400',
258
+ });
259
+ });
260
+ app.get('/favicon.ico', (c) => c.redirect('./icon.png', 301));
261
+ // The wordmark at its true proportions, for the card header.
262
+ app.get('/logo.png', (c) => {
263
+ if (!LOGO_PNG)
264
+ return c.notFound();
265
+ return c.body(LOGO_PNG, 200, {
266
+ 'content-type': 'image/png',
267
+ 'cache-control': 'public, max-age=86400',
268
+ });
269
+ });
270
+ // Both assistant directories require a public privacy policy and terms before
271
+ // they will list a connector. Served from here rather than written into a wiki
272
+ // somewhere: they describe what THIS code does, so they belong next to it and
273
+ // change when it does.
274
+ const LEGAL = {
275
+ // The page names THIS storefront's tools. Listing another merchant's is how a
276
+ // reviewer reads Elspet's docs and is told about lenses.
277
+ tools: STORE.tools,
278
+ noun: STORE.vocab.noun,
279
+ nounPlural: STORE.vocab.nounPlural,
280
+ merchantName: MERCHANT_NAME,
281
+ storefront: STORE.config.server.websiteUrl ?? PUBLIC_URL,
282
+ // Curless publishes, on the merchant's behalf — decided 2026-08-05. Which
283
+ // makes us the party a directory reviewer writes to, and the party a shopper
284
+ // writes to about the connector itself; anything about an ORDER is Moody's,
285
+ // and both pages say so.
286
+ operator: STORE.config.legal.operator ?? 'Curless',
287
+ // The same address the merchant-side connector already publishes, so a reply
288
+ // is not waiting on whoever happens to own one inbox.
289
+ operatorContact: STORE.config.legal.operatorContact ?? 'ops@curless.ai',
290
+ // The merchant's OWN address, as published in their own privacy policy —
291
+ // not the one on their contact page, which is still the Shopify theme's
292
+ // placeholder (info@yourstore.com).
293
+ merchantContact: STORE.config.legal.merchantContact ?? '',
294
+ updated: STORE.config.legal.updated ?? '',
295
+ shopDomain: SHOP_DOMAIN,
296
+ region: STORE.config.legal.region ?? 'ap-southeast-1 (Singapore)',
297
+ // Quoted from the limiters themselves, so the page cannot claim a number the
298
+ // code stopped enforcing.
299
+ rateGeneral: MCP_RATE_PER_MIN,
300
+ rateLookup: LOOKUP_RATE_PER_MIN,
301
+ };
302
+ // The page an IT administrator reads before allowing this server into their
303
+ // organisation — the URL the directories ask for as "public-facing docs".
304
+ // The merchant's storefront read from the SHOP, not from our config.
305
+ //
306
+ // Shopify's primaryDomain is already where the product and checkout links point,
307
+ // so deriving these from it means the day the shop moves to a new domain the
308
+ // policy redirects move with it — nobody has to remember a second place. The
309
+ // configured value stays as the fallback for a shop we cannot read yet, which is
310
+ // also the only state where a hardcoded guess is better than nothing.
311
+ const storefrontUrl = () => {
312
+ const withUrl = catalog.current.products.find((p) => p.url);
313
+ if (!withUrl?.url)
314
+ return LEGAL.storefront;
315
+ try {
316
+ return new URL(withUrl.url).origin;
317
+ }
318
+ catch {
319
+ return LEGAL.storefront;
320
+ }
321
+ };
322
+ app.get('/docs', (c) => c.html(docsPage({ ...LEGAL, storefront: storefrontUrl() })));
323
+ // The MERCHANT's policies, not ours. They already publish both, and a second
324
+ // privacy policy from us would be a second answer to the same question — for a
325
+ // shopper working out who holds their data, two answers is worse than one.
326
+ // Redirected rather than deleted so an old link lands on the authoritative page
327
+ // instead of a 404.
328
+ app.get('/privacy', (c) => c.redirect(`${storefrontUrl()}/policies/privacy-policy`, 301));
329
+ app.get('/terms', (c) => c.redirect(`${storefrontUrl()}/policies/terms-of-service`, 301));
330
+ // Product images, re-served from OUR origin.
331
+ //
332
+ // The card is drawn in a sandboxed iframe whose CSP the host sets, and Claude's
333
+ // blocked cdn.shopify.com outright — a grid of broken-image glyphs with the
334
+ // product name as alt text, which looks like a store that has fallen over. The
335
+ // connector's own origin is the one host such an iframe is plausibly allowed to
336
+ // load from, so the card asks for images here and falls back to the CDN.
337
+ //
338
+ // ⚠️ ONLY Shopify's CDN, and only image bytes. An open proxy is a way to make
339
+ // our server fetch anything an attacker names — internal addresses included — so
340
+ // the host is checked against a literal, the response must be an image, and the
341
+ // size is bounded.
342
+ const IMAGE_HOST = 'cdn.shopify.com';
343
+ const IMAGE_MAX_BYTES = 3_000_000;
344
+ const IMAGE_TIMEOUT_MS = 8_000;
345
+ // Rendered by <img>, where SVG cannot run script — but this URL is also directly
346
+ // navigable, and an SVG served from OUR origin that a merchant happened to
347
+ // upload to their CDN would then be script on mcp.moodycosmetics.com. Raster
348
+ // only; the card has never needed anything else.
349
+ const IMAGE_TYPES = /^image\/(jpeg|png|gif|webp|avif)$/;
350
+ // Every /img hit makes us fetch from Shopify. Unlimited, that is an
351
+ // unauthenticated amplifier pointed at the merchant's CDN quota and our own
352
+ // bandwidth — and it went out that way, at 30 of 30 requests answered.
353
+ // Generous, because one card render asks for a whole grid at once.
354
+ const IMAGE_RATE_PER_MIN = 240;
355
+ const imageLimit = new RateLimiter(IMAGE_RATE_PER_MIN, 60_000);
356
+ app.get('/img', async (c) => {
357
+ if (!imageLimit.take(clientIp(c.req.raw.headers)))
358
+ return c.text('slow down', 429);
359
+ const raw = c.req.query('u') ?? '';
360
+ let url;
361
+ try {
362
+ url = new URL(raw);
363
+ }
364
+ catch {
365
+ return c.text('bad url', 400);
366
+ }
367
+ if (url.protocol !== 'https:' || url.hostname !== IMAGE_HOST)
368
+ return c.text('forbidden', 403);
369
+ try {
370
+ const upstream = await fetch(url, {
371
+ headers: { accept: 'image/*' },
372
+ // ⚠️ NOT 'follow'. Checking the hostname we were given and then following
373
+ // wherever it leads is the classic SSRF bypass: one redirect off
374
+ // cdn.shopify.com and we are fetching an address of someone else's
375
+ // choosing, from inside the network, with the allowlist already satisfied.
376
+ // Shopify serves these images directly, so a redirect is a surprise and
377
+ // being refused is the right response to one.
378
+ redirect: 'error',
379
+ // Without this a slow upstream holds a request open indefinitely, which is
380
+ // the cheapest way to tie up a single-process server.
381
+ signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS),
382
+ });
383
+ const type = (upstream.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
384
+ if (!upstream.ok || !IMAGE_TYPES.test(type))
385
+ return c.text('not an image', 502);
386
+ const len = Number(upstream.headers.get('content-length') ?? 0);
387
+ if (len > IMAGE_MAX_BYTES)
388
+ return c.text('too large', 502);
389
+ const buf = Buffer.from(await upstream.arrayBuffer());
390
+ if (buf.byteLength > IMAGE_MAX_BYTES)
391
+ return c.text('too large', 502);
392
+ return c.body(buf, 200, {
393
+ 'content-type': type,
394
+ // Shopify's URLs carry a ?v= that changes when the image does, so this is
395
+ // safe to cache for a long time and the card asks for it on every render.
396
+ 'cache-control': 'public, max-age=86400',
397
+ // Believe the type above and nothing else — no sniffing our way into
398
+ // treating these bytes as a document.
399
+ 'x-content-type-options': 'nosniff',
400
+ 'content-security-policy': "default-src 'none'; sandbox",
401
+ });
402
+ }
403
+ catch {
404
+ return c.text('upstream failed', 502);
405
+ }
406
+ });
407
+ app.get('/healthz', async (c) => {
408
+ const snap = catalog.current;
409
+ const connected = (await shopifyConfig()) !== null;
410
+ return c.json({
411
+ // `credentials-rejected` is its own state, not a flavour of ok: the shelf is
412
+ // still being served from the last good snapshot, but nothing can be bought
413
+ // and the merchant has to reconnect.
414
+ status: credentialsRejected(snap.lastError)
415
+ ? 'credentials-rejected'
416
+ : snap.loadedAt
417
+ ? 'ok'
418
+ : connected
419
+ ? 'starting'
420
+ : 'not-installed',
421
+ shop: SHOP_DOMAIN,
422
+ connected,
423
+ products: snap.products.length,
424
+ catalogLoadedAt: snap.loadedAt?.toISOString() ?? null,
425
+ // Present when the LAST refresh failed. The snapshot may still be serving.
426
+ lastError: snap.lastError ?? null,
427
+ });
428
+ });
429
+ // The app's own page — what "Open app" in the Shopify admin lands on. Two jobs:
430
+ // tell the merchant whether their store is connected, and give them the button
431
+ // that connects it. Without this the app opens onto whatever the host serves at
432
+ // the root, which tells a merchant nothing and looks broken.
433
+ //
434
+ // It is also the page an App Store review would ask for. Deliberately plain: a
435
+ // headless integration has no dashboard to show, and inventing one would be
436
+ // making up a product that does not exist.
437
+ app.get('/', async (c) => {
438
+ // A shopper or a reviewer who types the connector's hostname into a browser
439
+ // gets a sentence and a way to the actual shop — not the merchant's install
440
+ // status. Deliberately says what this is and what it cannot do, because the
441
+ // people who arrive here uninvited are mostly reviewers deciding whether to
442
+ // trust it.
443
+ if (onBuyerHost(c)) {
444
+ const shop = storefrontUrl();
445
+ return c.html(`<!doctype html><meta charset="utf-8"><link rel="icon" href="/icon.png"><title>${escapeHtml(MERCHANT_NAME)}</title>
446
+ <style>
447
+ body{font-family:system-ui,-apple-system,sans-serif;margin:0;padding:48px 32px;color:#1a1a1a;
448
+ line-height:1.7;max-width:34rem}
449
+ h1{font-size:20px;margin:0 0 16px}
450
+ a{color:#1a1a1a}
451
+ .muted{color:#6b7280;font-size:14px}
452
+ </style>
453
+ <h1>${escapeHtml(MERCHANT_NAME)}</h1>
454
+ <p>This address is the ${escapeHtml(MERCHANT_NAME)} connector for AI assistants. It lets an
455
+ assistant show you ${escapeHtml(MERCHANT_NAME)}' ${escapeHtml(STORE.vocab.nounPlural)}, what is in stock,
456
+ and a link to checkout. There is nothing to use on this page.</p>
457
+ <p>To shop, go to <a href="${escapeHtml(shop)}">${escapeHtml(new URL(shop).host)}</a>.</p>
458
+ <p class="muted">No sign-in, and no payment happens here — checkout is on
459
+ ${escapeHtml(MERCHANT_NAME)}' own page. <a href="/docs">What this connector can reach</a>.</p>`);
460
+ }
461
+ const snap = catalog.current;
462
+ const connected = (await shopifyConfig()) !== null;
463
+ // Shopify sends the merchant HERE after a legacy-flow install, with ?shop=…
464
+ // &hmac=…&timestamp=…, and the convention is that the app starts OAuth on the
465
+ // spot. Landing them on a page with a button instead works, but it asks a
466
+ // merchant who just clicked "install" to click "connect" — which reads as the
467
+ // install not having taken. Start it for them.
468
+ //
469
+ // The signature is checked first: `shop` decides where we send someone, and an
470
+ // unverified one would make this an open redirect. Pinned to our store too.
471
+ const shopParam = c.req.query('shop');
472
+ if (needsAuth(connected, snap.lastError) && shopParam && CLIENT_SECRET) {
473
+ const search = new URL(c.req.url).search;
474
+ if (verifyCallbackHmac(CLIENT_SECRET, search) &&
475
+ isShopDomain(shopParam) &&
476
+ shopParam.toLowerCase() === SHOP_DOMAIN.toLowerCase()) {
477
+ return c.redirect(installUrl(oauth, makeState(CLIENT_SECRET)));
478
+ }
479
+ // A shop param we cannot verify is not an error to show — fall through to the
480
+ // page, which offers the same thing behind a click.
481
+ }
482
+ // HOLDING a token is not the same as holding a WORKING one, and conflating the
483
+ // two locked a merchant out of fixing it: reinstalling revokes the old token,
484
+ // so ours started failing 401 — while this page still said "Connected" and the
485
+ // auto-start branch above still skipped, because the file was there. They
486
+ // approved the app and landed on a page insisting nothing was wrong.
487
+ const rejected = credentialsRejected(snap.lastError);
488
+ const body = rejected
489
+ ? `<p class="warn">Shopify is refusing our credentials for <b>${SHOP_DOMAIN}</b>.</p>
490
+ <p>That is what happens after the app is reinstalled elsewhere — the old
491
+ access token is revoked. Reconnecting issues a new one; nothing else is
492
+ affected and no data is lost.</p>
493
+ <p><a class="btn" href="./auth/install">Reconnect ${escapeHtml(SHOP_DOMAIN)}</a></p>
494
+ <p class="muted">Last sync said: ${escapeHtml(snap.lastError ?? '')}</p>`
495
+ : connected
496
+ ? `<p class="ok">Connected to <b>${SHOP_DOMAIN}</b>.</p>
497
+ <p>${snap.products.length} products readable${snap.loadedAt
498
+ ? ` · last synced ${snap.loadedAt.toISOString().replace('T', ' ').slice(0, 16)} UTC`
499
+ : ''}.</p>
500
+ ${snap.lastError ? `<p class="warn">Last sync said: ${escapeHtml(snap.lastError)}</p>` : ''}
501
+ <p class="muted">Shoppers reach this catalogue through an AI assistant. Nothing here
502
+ moves money — checkout happens on your own Shopify checkout page.</p>`
503
+ : `<p>This store is not connected yet.</p>
504
+ <p><a class="btn" href="./auth/install">Connect ${escapeHtml(SHOP_DOMAIN)}</a></p>
505
+ <p class="muted">Grants read-only access to your products and orders. Nothing is
506
+ written to your store, and no money passes through us.</p>`;
507
+ return c.html(`<!doctype html><meta charset="utf-8"><link rel="icon" href="./icon.png"><title>${escapeHtml(MERCHANT_NAME)}</title>
508
+ <style>
509
+ body{font-family:system-ui,-apple-system,sans-serif;margin:0;padding:32px;color:#1a1a1a;line-height:1.6}
510
+ h1{font-size:18px;margin:0 0 16px}
511
+ .ok{color:#0a7d3f;font-weight:600}
512
+ .warn{color:#a15c00}
513
+ .muted{color:#6b7280;font-size:14px}
514
+ .btn{display:inline-block;background:#1a1a1a;color:#fff;text-decoration:none;
515
+ padding:10px 18px;border-radius:8px;font-weight:600}
516
+ </style>
517
+ <h1>${escapeHtml(MERCHANT_NAME)} · shopping assistant</h1>
518
+ ${body}`);
519
+ });
520
+ // ── Install (OAuth) ──────────────────────────────────────────────────────────
521
+ // The merchant reaches /auth/install from the custom-distribution link, approves
522
+ // the scopes on Shopify, and comes back to /auth/callback with a code.
523
+ app.get('/auth/install', (c) => {
524
+ if (!CLIENT_ID || !CLIENT_SECRET)
525
+ return c.text('app credentials are not configured', 503);
526
+ // Pinned. This app is distributed to ONE store, so a request naming another
527
+ // shop is not a case to support — and an endpoint that redirects to any
528
+ // attacker-named *.myshopify.com is an open redirector wearing our domain.
529
+ const shop = c.req.query('shop');
530
+ if (MULTI) {
531
+ // The listed app has no shop of its own, so `shop` is REQUIRED — without it
532
+ // the redirect was being built with an empty host (https:///admin/...),
533
+ // which is what a merchant would have clicked from the App Store.
534
+ //
535
+ // The open-redirect worry the branded case pins away is sharper here, not
536
+ // softer: this endpoint sends people somewhere, on our domain. The
537
+ // allow-list is the same one the token store uses — exactly the shape
538
+ // Shopify issues, nothing else — so `?shop=evil.example.com` cannot turn
539
+ // this into a redirector wearing curless.ai.
540
+ const named = (shop ?? '').trim().toLowerCase();
541
+ if (!named)
542
+ return c.text('add ?shop=<store>.myshopify.com to install', 400);
543
+ if (!isStorableShop(named))
544
+ return c.text('that is not a Shopify store domain', 400);
545
+ return c.redirect(installUrl({ ...oauth, shopDomain: named }, makeState(CLIENT_SECRET)));
546
+ }
547
+ // Pinned. A branded deployment is distributed to ONE store, so a request
548
+ // naming another shop is not a case to support.
549
+ if (shop && shop.toLowerCase() !== SHOP_DOMAIN.toLowerCase()) {
550
+ return c.text('this app is not distributed to that store', 400);
551
+ }
552
+ return c.redirect(installUrl(oauth, makeState(CLIENT_SECRET)));
553
+ });
554
+ // The three webhooks Shopify requires of a listed app, on one endpoint —
555
+ // the topic is a header, so three routes would be three copies of the same
556
+ // signature check.
557
+ //
558
+ // ⚠️ The RAW body, before any parsing. `c.req.text()` gives the bytes Shopify
559
+ // signed; re-serialising the parsed JSON changes them and every genuine
560
+ // delivery would fail to verify.
561
+ app.post('/webhooks/shopify', async (c) => {
562
+ const raw = await c.req.text();
563
+ if (!verifyWebhookHmac(CLIENT_SECRET, raw, c.req.header('x-shopify-hmac-sha256') ?? '')) {
564
+ // 401 and nothing else. Naming what failed would tell an unverified caller
565
+ // whether the secret or the body was wrong.
566
+ return c.json({ error: 'unauthorized' }, 401);
567
+ }
568
+ const topic = c.req.header('x-shopify-topic') ?? '';
569
+ const shop = c.req.header('x-shopify-shop-domain') ?? '';
570
+ const result = await handleWebhook(topic, shop, {
571
+ forgetShop: async (s) => {
572
+ if (MULTI) {
573
+ // Both, and in this order: stop the refresh loop first so nothing is
574
+ // mid-flight against a token we are about to delete.
575
+ registry.forget(s);
576
+ await multiTokens.forget(s);
577
+ console.log(`[webhook] ${topic} for ${s} — token and catalogue dropped`);
578
+ return;
579
+ }
580
+ // Single-shop mode has one token and one catalogue, both wired at deploy
581
+ // time, so there is no per-shop entry to drop. Logged rather than
582
+ // silently ignored: an uninstall here is a real event an operator will
583
+ // want in the record, and the next refresh fails on the revoked token
584
+ // and the status page says so. The multi-tenant path drops the token and
585
+ // stops the catalogue instead.
586
+ console.log(`[webhook] ${topic} for ${s} — single-shop deployment, nothing per-shop to drop`);
587
+ },
588
+ });
589
+ return c.json(result.body, result.status);
590
+ });
591
+ app.get('/auth/callback', async (c) => {
592
+ if (!CLIENT_SECRET)
593
+ return c.text('app credentials are not configured', 503);
594
+ const url = new URL(c.req.url);
595
+ const query = Object.fromEntries(url.searchParams.entries());
596
+ // ORDER MATTERS: verify before touching anything. This endpoint is public, so
597
+ // an unverified `code` here is an attacker's code, and exchanging it would
598
+ // store a token for a shop of their choosing. Verified against the RAW query
599
+ // string — that is what Shopify signed.
600
+ if (!verifyCallbackHmac(CLIENT_SECRET, url.search))
601
+ return c.text('bad signature', 401);
602
+ if (!query.state || !verifyState(CLIENT_SECRET, query.state)) {
603
+ return c.text('bad state', 401);
604
+ }
605
+ const shop = String(query.shop ?? '');
606
+ // The listed app has no single expected shop — the callback's shop IS the
607
+ // answer. What still has to hold is that it LOOKS like a Shopify store, which
608
+ // is the same allow-list the token store keys on; the HMAC above already
609
+ // proved the query came from Shopify.
610
+ const shopOk = MULTI
611
+ ? isStorableShop(shop.toLowerCase())
612
+ : isShopDomain(shop) && shop.toLowerCase() === SHOP_DOMAIN.toLowerCase();
613
+ if (!shopOk) {
614
+ return c.text('unexpected shop', 400);
615
+ }
616
+ const code = String(query.code ?? '');
617
+ if (!code)
618
+ return c.text('missing code', 400);
619
+ try {
620
+ const { accessToken, scope } = await exchangeCode(oauth, code);
621
+ // Per-shop in the listed app, single-file in a branded one. The record is
622
+ // the same; where it lands is the whole difference between serving one
623
+ // merchant and serving many.
624
+ const record = {
625
+ shopDomain: shop,
626
+ accessToken,
627
+ scope,
628
+ installedAt: new Date().toISOString(),
629
+ };
630
+ if (MULTI)
631
+ await multiTokens.set(record);
632
+ else
633
+ await tokens.set(record);
634
+ console.log(`[oauth] installed on ${shop} (scopes: ${scope})`);
635
+ // The catalog is empty until now; fill it before the merchant looks.
636
+ void catalog.refresh();
637
+ return c.html(`<!doctype html><meta charset="utf-8"><link rel="icon" href="./icon.png"><title>Connected</title>
638
+ <body style="font-family:system-ui;padding:40px;max-width:32rem">
639
+ <h2>${MERCHANT_NAME} is connected.</h2>
640
+ <p>Your catalogue is now readable by the assistant. You can close this tab.</p>
641
+ </body>`);
642
+ }
643
+ catch (err) {
644
+ // Say what failed without echoing the exchange body.
645
+ const message = err instanceof Error ? err.message : String(err);
646
+ console.error(`[oauth] token exchange failed for ${shop}: ${message}`);
647
+ return c.text('could not complete the install — please try the link again', 502);
648
+ }
649
+ });
650
+ // ChatGPT Apps domain verification: echo the token back verbatim. Public and
651
+ // not a secret; rotating it is an env change and a restart.
652
+ //
653
+ // PER HOST, because the token is issued per ORIGIN and this service answers on
654
+ // more than one. A single value served everywhere means the moment a second
655
+ // hostname is verified, the first one starts returning the second one's token —
656
+ // and the app that was already verified quietly fails its next re-check. The
657
+ // symptom ("verification just dropped") points nowhere near the cause, and this
658
+ // is exactly the shape of a migration: both hosts have to stay verified while
659
+ // the directories are moved over one at a time.
660
+ //
661
+ // OPENAI_APPS_CHALLENGE_TOKENS=host=token,host=token preferred
662
+ // OPENAI_APPS_CHALLENGE_TOKEN=token single-host fallback
663
+ app.get('/.well-known/openai-apps-challenge', (c) => {
664
+ const token = challengeFor(c.req.header('host'), process.env.OPENAI_APPS_CHALLENGE_TOKENS, process.env.OPENAI_APPS_CHALLENGE_TOKEN);
665
+ if (!token)
666
+ return c.notFound();
667
+ return c.text(token);
668
+ });
669
+ // Anything interpolated into the page below comes from our own env, but escape
670
+ // it anyway: the day one of these becomes merchant-supplied, the page should not
671
+ // be the thing that has to be remembered.
672
+ const escapeHtml = (s) => s.replace(/[&<>"']/g, (ch) => ch === '&'
673
+ ? '&amp;'
674
+ : ch === '<'
675
+ ? '&lt;'
676
+ : ch === '>'
677
+ ? '&gt;'
678
+ : ch === '"'
679
+ ? '&quot;'
680
+ : '&#39;');
681
+ // The MCP Apps card. Declared on the tools AND on their results — a host reads
682
+ // it from either — and served over resources/read. Version the URI: hosts cache
683
+ // by it, and a stale card is worse than no card because it looks current.
684
+ const CARD_URI = `${STORE.cardUriPrefix}storefront-v19.html`;
685
+ // ⚠️ `text/html;profile=mcp-app`, matching the merchant card that already works
686
+ // on claude.ai. v1 shipped `text/html+skybridge` and Claude answered "There was
687
+ // a problem displaying content from moody" — the card never rendered. The MIME
688
+ // is how the host recognises this resource as a card at all, so a plausible
689
+ // near-miss fails silently rather than degrading.
690
+ const CARD_MIME = 'text/html;profile=mcp-app';
691
+ // One card, two hosts. Claude (MCP Apps) reads `ui.resourceUri` (+ the legacy
692
+ // `ui/resourceUri`); ChatGPT (Apps SDK) reads `openai/outputTemplate` to know a
693
+ // tool renders this component, and `openai/widgetAccessible` is what lets the
694
+ // rendered card call back into these tools — the card does exactly that when the
695
+ // buyer opens a lens or presses Buy, so without it those clicks do nothing.
696
+ const UI_META = {
697
+ ui: { resourceUri: CARD_URI },
698
+ 'ui/resourceUri': CARD_URI,
699
+ 'openai/outputTemplate': CARD_URI,
700
+ 'openai/widgetAccessible': true,
701
+ };
702
+ // WHERE THE CARD MAY LOAD IMAGES FROM. This is the declaration whose absence
703
+ // cost three rounds of "the pictures still aren't showing".
704
+ //
705
+ // An MCP App runs in a sandboxed iframe with no same-origin server, and the spec
706
+ // is explicit: `resourceDomains` "empty or omitted → no network resources
707
+ // (secure default)". So every product image was blocked before it left the
708
+ // starting line — not by the CDN, not by the markup. I had only ever set
709
+ // `openai/widgetCSP`, which is ChatGPT's key and which Claude does not read, and
710
+ // then spent two attempts explaining the symptom with theories about the CDN.
711
+ //
712
+ // Both namespaces now, because the two hosts read different ones:
713
+ // ui.csp — MCP Apps (Claude). The spec shape, `resourceDomains`.
714
+ // openai/* — ChatGPT's Apps SDK, same list in its own spelling.
715
+ //
716
+ // Shopify's CDN serves every product image; our own origin is the fallback the
717
+ // card tries second.
718
+ const IMAGE_ORIGINS = ['https://cdn.shopify.com', new URL(PUBLIC_URL).origin];
719
+ const WIDGET_META = {
720
+ ui: { csp: { resourceDomains: IMAGE_ORIGINS, connectDomains: [] } },
721
+ 'openai/widgetCSP': { connect_domains: [], resource_domains: IMAGE_ORIGINS },
722
+ 'openai/widgetDomain': new URL(PUBLIC_URL).origin,
723
+ };
724
+ const PROTOCOL_VERSION = '2025-06-18';
725
+ const ok = (c, id, result) => c.json({ jsonrpc: '2.0', id: id ?? null, result });
726
+ const fail = (c, id, code, message) => c.json({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
727
+ // One line per call: method, tool, outcome, milliseconds.
728
+ //
729
+ // Added because a buyer hit "Unable to reach Moody" and the server had recorded
730
+ // NOTHING — not the call, not a rate limit, not an error. The only honest answer
731
+ // to "why did that fail" was that we could not say, which is not an answer a
732
+ // merchant's storefront gets to give twice.
733
+ const logCall = (method, tool, outcome, startedAt) => {
734
+ console.log(`[mcp] ${method ?? '?'}${tool ? ` ${tool}` : ''} ${outcome} ${Date.now() - startedAt}ms`);
735
+ };
736
+ // The JSON-RPC subset an MCP client actually needs from a read-only server:
737
+ // initialize / tools/list / tools/call. No server-initiated messages, so every
738
+ // response is a plain JSON body rather than an SSE stream.
739
+ // BOTH paths, and both stay. A bare origin is the nicer connector URL to hand
740
+ // out — `https://mcp.moodylenses.com` rather than `…/mcp` — but `/mcp` is the
741
+ // address already registered in the Claude directory and in ChatGPT, and
742
+ // changing a published server URL is a manual request to a review team. Serving
743
+ // both costs one line and means the hostname move is the only variable.
744
+ //
745
+ // Only POST is shared: GET / is the front page above, and GET /mcp is the
746
+ // descriptor below. The transport only ever POSTs.
747
+ // The listed app carries the shop in the connector URL: /s/<shop>/mcp.
748
+ //
749
+ // Registered ONLY in that mode. Mounting it in a branded deployment would let
750
+ // /s/anything/mcp answer with the one shop it serves — the same products under
751
+ // a name that has nothing to do with them, which is exactly the confusion the
752
+ // resolver refuses elsewhere.
753
+ const MCP_PATHS = MULTI ? ['/mcp', '/', '/s/:shop/mcp'] : ['/mcp', '/'];
754
+ app.on('POST', MCP_PATHS, async (c) => {
755
+ const startedAt = Date.now();
756
+ if (!mcpLimit.take(clientIp(c.req.raw.headers))) {
757
+ // Logged loudly: a rate limit reaches the buyer as "unable to reach", which
758
+ // is indistinguishable from the service being down unless we say so here.
759
+ logCall('?', undefined, 'RATE-LIMITED', startedAt);
760
+ return c.json({ jsonrpc: '2.0', id: null, error: { code: -32029, message: 'rate limited' } }, 429);
761
+ }
762
+ let body;
763
+ try {
764
+ body = await c.req.json();
765
+ }
766
+ catch {
767
+ return fail(c, null, -32700, 'parse error');
768
+ }
769
+ const { id, method, params } = body;
770
+ if (method === 'initialize') {
771
+ const info = params?.clientInfo;
772
+ rememberClient(c.req.header('mcp-session-id'), info?.name);
773
+ return ok(c, id, {
774
+ protocolVersion: PROTOCOL_VERSION,
775
+ capabilities: { tools: {}, resources: {} },
776
+ // What a host shows beside this connector. `name` is the machine id, so
777
+ // the human-facing bits are `title` and `icons` — and the icon is MOODY's
778
+ // mark, not ours: a shopper is connecting to their shop, and our logo
779
+ // there would name the wrong party. `websiteUrl` points at their own
780
+ // storefront for the same reason.
781
+ serverInfo: {
782
+ name: STORE.config.server.name,
783
+ title: MERCHANT_NAME,
784
+ version: '0.1.0',
785
+ websiteUrl: STORE.config.server.websiteUrl ?? PUBLIC_URL,
786
+ // Omitted rather than guessed: a directory shows this beside the
787
+ // connector's name, and pointing it at another merchant's mark names
788
+ // the wrong party.
789
+ ...(STORE.config.server.iconUrl || ICON_PNG
790
+ ? {
791
+ icons: [
792
+ {
793
+ src: STORE.config.server.iconUrl ?? `${PUBLIC_URL}/icon.png`,
794
+ mimeType: 'image/png',
795
+ sizes: ['256x256'],
796
+ },
797
+ ],
798
+ }
799
+ : {}),
800
+ },
801
+ // How to BEHAVE, not just what the tools are. Without this the assistant
802
+ // treated a shopper the way it treats a research question: it interrogated
803
+ // them about colour, replacement cycle and pair-versus-single before
804
+ // showing a single lens, then narrated its own recommendations on top of a
805
+ // card that already showed the lenses. The buyer opened a storefront and
806
+ // got a questionnaire.
807
+ //
808
+ // The card is the storefront. It has filter chips, a Buy on every lens and
809
+ // its own prescription picker — everything the assistant was asking about,
810
+ // the buyer can just tap. So: show it, then be quiet.
811
+ instructions: [
812
+ `${MERCHANT_NAME} storefront. The card IS the shop: it shows the ${STORE.vocab.nounPlural} with pictures, filter chips, and a Buy button on each one.`,
813
+ // What this reads, stated once. It used to end "rather than web search
814
+ // or remembered product knowledge" — written after watching a host with
815
+ // its own shopping surface answer from a scraped page that quoted two
816
+ // different prices for the same lens and could not name a prescription.
817
+ // Removed because a connector telling the model not to use other tools
818
+ // is the pattern Anthropic's criteria reject, and because the routing it
819
+ // was trying to win is the host's to decide. The fact is what carries.
820
+ `These tools read the ${MERCHANT_NAME} shop itself, live: the price it charges today, what it can actually ship, and the option combinations it will accept.`,
821
+ `FIRST TURN: call ${STORE.tools.list} ONCE with no query and stop. Do not interview them first — whatever the ${STORE.vocab.noun} needs chosen, the card lets them choose it. Do not recommend anything and do not describe the ${STORE.vocab.nounPlural} in prose; they are on the screen.`,
822
+ `Say at most one short line, e.g. "Here are the ${STORE.vocab.nounPlural} — tap Buy on any of them."`,
823
+ 'Only search (`query`) when the buyer has actually said what they want. Only recommend when they ask you to.',
824
+ 'ONE call is enough. If a search comes back broader than asked, that answer already contains the closest matches — do not search again. Repeated calls stack another storefront card in front of the buyer for the same question.',
825
+ // Merchant-specific guidance, if this shop has any — moody's
826
+ // prescription rules are nonsense at a pet shop.
827
+ ...[STORE.vocab.hints.product, STORE.vocab.hints.checkout].filter(Boolean),
828
+ `${STORE.tools.checkout} returns a link to the ${MERCHANT_NAME} checkout page, where the buyer pays.`,
829
+ ].join('\n'),
830
+ });
831
+ }
832
+ // Liveness. The spec's ping is a request the receiver MUST answer promptly
833
+ // with an empty result — and hosts use it to decide whether the server is
834
+ // still there. We answered -32601, so Claude concluded it could not reach us
835
+ // and showed the buyer "Unable to reach Moody Lenses" and "There was a problem
836
+ // displaying content" — over a connection on which every tool call was
837
+ // succeeding. The banner said nothing was working; the log said everything was.
838
+ if (method === 'ping') {
839
+ logCall(method, undefined, 'ok', startedAt);
840
+ return ok(c, id, {});
841
+ }
842
+ // Notifications carry no id and expect no response body.
843
+ if (method === 'notifications/initialized') {
844
+ logCall(method, undefined, 'ok', startedAt);
845
+ return c.body(null, 202);
846
+ }
847
+ if (method === 'tools/list') {
848
+ // All four, because all four return something the buyer looks AT — including
849
+ // create_moody_checkout, whose most common answer is "one eye is missing".
850
+ // That is the card's whole reason to exist: the buyer picks the second
851
+ // prescription themselves instead of an assistant filling it in.
852
+ return ok(c, id, {
853
+ tools: TOOLS.map((t) => {
854
+ // The shape of what each tool returns, merged on here rather than kept
855
+ // in the TOOLS literal — that list is the contract a buyer's assistant
856
+ // reads, and it stays legible without the schemas inline.
857
+ const outputSchema = OUTPUT_SCHEMAS[t.name];
858
+ return { ...t, _meta: UI_META, ...(outputSchema ? { outputSchema } : {}) };
859
+ }),
860
+ });
861
+ }
862
+ if (method === 'resources/list') {
863
+ return ok(c, id, {
864
+ resources: [
865
+ {
866
+ uri: CARD_URI,
867
+ name: `${MERCHANT_NAME} storefront`,
868
+ description: `Browse ${STORE.vocab.nounPlural}, choose the options, open checkout`,
869
+ mimeType: CARD_MIME,
870
+ _meta: WIDGET_META,
871
+ },
872
+ ],
873
+ });
874
+ }
875
+ if (method === 'resources/read') {
876
+ const uri = String(params?.uri ?? '');
877
+ // ANY card URI in our namespace gets the CURRENT card, and the answer
878
+ // echoes the URI that was asked for.
879
+ //
880
+ // Bumping the URI is how a host is told the card changed — but a host that
881
+ // still holds the old one then asks for a resource we refused to serve, and
882
+ // ChatGPT showed the buyer "Failed to fetch template" until the connector
883
+ // was deleted and re-added. Nothing was broken except our own refusal.
884
+ //
885
+ // Echoing the requested URI rather than answering with CARD_URI: a host
886
+ // that asked for v12 and got v16 back has been handed a reply to a
887
+ // question it did not ask, and some will discard it.
888
+ if (!uri.startsWith(STORE.cardUriPrefix)) {
889
+ return fail(c, id, -32602, `unknown resource: ${uri}`);
890
+ }
891
+ return ok(c, id, {
892
+ contents: [{ uri, mimeType: CARD_MIME, text: MOODY_CARD_HTML, _meta: WIDGET_META }],
893
+ });
894
+ }
895
+ if (method === 'tools/call') {
896
+ const name = String(params?.name ?? '');
897
+ const args = (params?.arguments ?? {});
898
+ if (!TOOLS.some((t) => t.name === name)) {
899
+ logCall(method, name, 'UNKNOWN-TOOL', startedAt);
900
+ return fail(c, id, -32602, `unknown tool: ${name}`);
901
+ }
902
+ if (name === STORE.tools.lookupOrder && !lookupLimit.take(clientIp(c.req.raw.headers))) {
903
+ // A refusal, not an error: say it in the tool result so the assistant can
904
+ // tell the buyer to wait rather than presenting the connector as broken.
905
+ logCall(method, name, 'LOOKUP-RATE-LIMITED', startedAt);
906
+ return ok(c, id, {
907
+ content: [
908
+ { type: 'text', text: 'Too many order lookups from here. Please try again in a minute.' },
909
+ ],
910
+ structuredContent: { view: 'rate-limited' },
911
+ isError: true,
912
+ });
913
+ }
914
+ try {
915
+ // Resolve the tenant FIRST. The single-shop config helper below it reads
916
+ // the ONE configured shop, so in the listed app it answers null for every
917
+ // request and the honest "which shop?" never gets asked.
918
+ const resolution = await resolveTenant(shopForRequest(c));
919
+ if (!resolution.ok) {
920
+ // Not installed is an honest answer, not an empty catalogue — and each
921
+ // of the three reasons says something different and actionable.
922
+ return ok(c, id, {
923
+ content: [{ type: 'text', text: explainNoTenant(resolution) }],
924
+ structuredContent: {
925
+ view: 'not-installed',
926
+ shop: resolution.reason === 'no-shop'
927
+ ? ''
928
+ : (resolution.shop ??
929
+ resolution.given ??
930
+ ''),
931
+ },
932
+ });
933
+ }
934
+ const shopify = resolution.tenant.shopify;
935
+ const deps = {
936
+ catalog: resolution.tenant.catalog,
937
+ shopify,
938
+ merchantName: MERCHANT_NAME,
939
+ ...(LOGO_PNG ? { logoUrl: `${PUBLIC_URL}/logo.png` } : {}),
940
+ tools: STORE.tools,
941
+ vocab: { noun: STORE.vocab.noun, nounPlural: STORE.vocab.nounPlural },
942
+ imageProxy: `${PUBLIC_URL}/img?u=`,
943
+ ...(assistantFor(c) ? { assistant: assistantFor(c) } : {}),
944
+ };
945
+ const res = await callTool(deps, name, args);
946
+ logCall(method, name, `ok view=${String(res.structured.view ?? '?')}`, startedAt);
947
+ return ok(c, id, {
948
+ content: [{ type: 'text', text: res.text }],
949
+ structuredContent: res.structured,
950
+ _meta: UI_META,
951
+ });
952
+ }
953
+ catch (err) {
954
+ // A tool failure is a RESULT, not a transport error: the client should be
955
+ // able to show the buyer what went wrong instead of a dead connector.
956
+ const message = err instanceof Error ? err.message : String(err);
957
+ logCall(method, name, `FAILED ${message}`, startedAt);
958
+ return ok(c, id, {
959
+ content: [{ type: 'text', text: `That did not work: ${message}` }],
960
+ isError: true,
961
+ });
962
+ }
963
+ }
964
+ logCall(method, undefined, 'METHOD-NOT-FOUND', startedAt);
965
+ return fail(c, id, -32601, `method not found: ${String(method)}`);
966
+ });
967
+ // Some hosts probe with GET before POSTing. Answer plainly rather than 404,
968
+ // which reads as "wrong URL" while debugging a connector.
969
+ app.get('/mcp', (c) => c.json({ name: STORE.config.server.name, transport: 'streamable-http' }));
970
+ catalog.start(REFRESH_MS);
971
+ serve({ fetch: app.fetch, port: PORT }, (info) => {
972
+ console.log(`${STORE.config.id}: listening on :${info.port} (shop ${SHOP_DOMAIN}, refresh ${REFRESH_MS}ms)`);
973
+ });
974
+ //# sourceMappingURL=index.js.map