@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.
- package/dist/__tests__/card-contract.test.d.ts +2 -0
- package/dist/__tests__/card-contract.test.d.ts.map +1 -0
- package/dist/__tests__/card-contract.test.js +22 -0
- package/dist/__tests__/card-contract.test.js.map +1 -0
- package/dist/__tests__/catalog-registry.test.d.ts +2 -0
- package/dist/__tests__/catalog-registry.test.d.ts.map +1 -0
- package/dist/__tests__/catalog-registry.test.js +109 -0
- package/dist/__tests__/catalog-registry.test.js.map +1 -0
- package/dist/__tests__/challenge.test.d.ts +2 -0
- package/dist/__tests__/challenge.test.d.ts.map +1 -0
- package/dist/__tests__/challenge.test.js +46 -0
- package/dist/__tests__/challenge.test.js.map +1 -0
- package/dist/__tests__/config.test.d.ts +2 -0
- package/dist/__tests__/config.test.d.ts.map +1 -0
- package/dist/__tests__/config.test.js +123 -0
- package/dist/__tests__/config.test.js.map +1 -0
- package/dist/__tests__/connection-state.test.d.ts +2 -0
- package/dist/__tests__/connection-state.test.d.ts.map +1 -0
- package/dist/__tests__/connection-state.test.js +40 -0
- package/dist/__tests__/connection-state.test.js.map +1 -0
- package/dist/__tests__/multi-token-store.test.d.ts +2 -0
- package/dist/__tests__/multi-token-store.test.d.ts.map +1 -0
- package/dist/__tests__/multi-token-store.test.js +89 -0
- package/dist/__tests__/multi-token-store.test.js.map +1 -0
- package/dist/__tests__/oauth.test.d.ts +2 -0
- package/dist/__tests__/oauth.test.d.ts.map +1 -0
- package/dist/__tests__/oauth.test.js +112 -0
- package/dist/__tests__/oauth.test.js.map +1 -0
- package/dist/__tests__/output-schema.test.d.ts +2 -0
- package/dist/__tests__/output-schema.test.d.ts.map +1 -0
- package/dist/__tests__/output-schema.test.js +112 -0
- package/dist/__tests__/output-schema.test.js.map +1 -0
- package/dist/__tests__/ping.test.d.ts +2 -0
- package/dist/__tests__/ping.test.d.ts.map +1 -0
- package/dist/__tests__/ping.test.js +22 -0
- package/dist/__tests__/ping.test.js.map +1 -0
- package/dist/__tests__/rate-limit.test.d.ts +2 -0
- package/dist/__tests__/rate-limit.test.d.ts.map +1 -0
- package/dist/__tests__/rate-limit.test.js +46 -0
- package/dist/__tests__/rate-limit.test.js.map +1 -0
- package/dist/__tests__/stock.test.d.ts +2 -0
- package/dist/__tests__/stock.test.d.ts.map +1 -0
- package/dist/__tests__/stock.test.js +109 -0
- package/dist/__tests__/stock.test.js.map +1 -0
- package/dist/__tests__/tenant.test.d.ts +2 -0
- package/dist/__tests__/tenant.test.d.ts.map +1 -0
- package/dist/__tests__/tenant.test.js +105 -0
- package/dist/__tests__/tenant.test.js.map +1 -0
- package/dist/__tests__/tools.test.d.ts +2 -0
- package/dist/__tests__/tools.test.d.ts.map +1 -0
- package/dist/__tests__/tools.test.js +543 -0
- package/dist/__tests__/tools.test.js.map +1 -0
- package/dist/__tests__/upstream-cap.test.d.ts +2 -0
- package/dist/__tests__/upstream-cap.test.d.ts.map +1 -0
- package/dist/__tests__/upstream-cap.test.js +78 -0
- package/dist/__tests__/upstream-cap.test.js.map +1 -0
- package/dist/__tests__/webhooks.test.d.ts +2 -0
- package/dist/__tests__/webhooks.test.d.ts.map +1 -0
- package/dist/__tests__/webhooks.test.js +88 -0
- package/dist/__tests__/webhooks.test.js.map +1 -0
- package/dist/card.generated.d.ts +2 -0
- package/dist/card.generated.d.ts.map +1 -0
- package/dist/card.generated.js +6 -0
- package/dist/card.generated.js.map +1 -0
- package/dist/catalog-registry.d.ts +44 -0
- package/dist/catalog-registry.d.ts.map +1 -0
- package/dist/catalog-registry.js +105 -0
- package/dist/catalog-registry.js.map +1 -0
- package/dist/catalog.d.ts +51 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +189 -0
- package/dist/catalog.js.map +1 -0
- package/dist/challenge.d.ts +11 -0
- package/dist/challenge.d.ts.map +1 -0
- package/dist/challenge.js +36 -0
- package/dist/challenge.js.map +1 -0
- package/dist/config.d.ts +141 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +65 -0
- package/dist/config.js.map +1 -0
- package/dist/connection.d.ts +10 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +21 -0
- package/dist/connection.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +974 -0
- package/dist/index.js.map +1 -0
- package/dist/legal.d.ts +52 -0
- package/dist/legal.d.ts.map +1 -0
- package/dist/legal.js +139 -0
- package/dist/legal.js.map +1 -0
- package/dist/load-config.d.ts +11 -0
- package/dist/load-config.d.ts.map +1 -0
- package/dist/load-config.js +23 -0
- package/dist/load-config.js.map +1 -0
- package/dist/multi-token-store.d.ts +18 -0
- package/dist/multi-token-store.d.ts.map +1 -0
- package/dist/multi-token-store.js +95 -0
- package/dist/multi-token-store.js.map +1 -0
- package/dist/oauth.d.ts +20 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +123 -0
- package/dist/oauth.js.map +1 -0
- package/dist/rate-limit.d.ts +15 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +56 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/stock.d.ts +39 -0
- package/dist/stock.d.ts.map +1 -0
- package/dist/stock.js +77 -0
- package/dist/stock.js.map +1 -0
- package/dist/tenant.d.ts +69 -0
- package/dist/tenant.d.ts.map +1 -0
- package/dist/tenant.js +68 -0
- package/dist/tenant.js.map +1 -0
- package/dist/token-store.d.ts +16 -0
- package/dist/token-store.d.ts.map +1 -0
- package/dist/token-store.js +47 -0
- package/dist/token-store.js.map +1 -0
- package/dist/tools.d.ts +201 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +779 -0
- package/dist/tools.js.map +1 -0
- package/dist/webhooks.d.ts +30 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +69 -0
- package/dist/webhooks.js.map +1 -0
- 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=…×tamp=…, 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
|
+
? '&'
|
|
674
|
+
: ch === '<'
|
|
675
|
+
? '<'
|
|
676
|
+
: ch === '>'
|
|
677
|
+
? '>'
|
|
678
|
+
: ch === '"'
|
|
679
|
+
? '"'
|
|
680
|
+
: ''');
|
|
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
|