@hanzo/shopify 1.0.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.
@@ -0,0 +1,330 @@
1
+ // The Hanzo AI assistant panel — a Polaris React component. All the logic-heavy
2
+ // work (action catalogs, prompt assembly, request shaping, HMAC) lives in its
3
+ // own tested modules on the server + in api.ts; this component binds them to
4
+ // Polaris UI. It talks ONLY to the backend proxy (createApi) — it never holds a
5
+ // secret and never calls Shopify or api.hanzo.ai directly.
6
+ //
7
+ // Two tabs: Products (read a product, run a content action, preview, write back)
8
+ // and Orders (read an order, run a support action, copy). The action catalogs
9
+ // are the server's, mirrored here for labels so the buttons never drift from
10
+ // what the server will run.
11
+
12
+ import { useCallback, useEffect, useState } from 'react';
13
+ import {
14
+ Badge,
15
+ BlockStack,
16
+ Box,
17
+ Button,
18
+ ButtonGroup,
19
+ Card,
20
+ InlineStack,
21
+ Select,
22
+ Tabs,
23
+ Text,
24
+ TextField,
25
+ Banner,
26
+ } from '@shopify/polaris';
27
+ import type { Api, Product, Order } from './api';
28
+ import type { LaunchContext } from './context';
29
+ import { toGid } from './context';
30
+
31
+ // The action buttons the panel shows. Ids MUST match the server's PRODUCT_ACTIONS
32
+ // / ORDER_ACTIONS keys — the server validates the id, so a mismatch is a clean
33
+ // 400, but we keep them in sync so the UI is honest.
34
+ const PRODUCT_ACTIONS = [
35
+ { id: 'writeDescription', label: 'Write description', target: 'descriptionHtml' as const },
36
+ { id: 'rewriteDescription', label: 'Rewrite description', target: 'descriptionHtml' as const },
37
+ { id: 'seoTitle', label: 'SEO title', target: 'seoTitle' as const },
38
+ { id: 'seoDescription', label: 'SEO meta description', target: 'seoDescription' as const },
39
+ ];
40
+ const ORDER_ACTIONS = [
41
+ { id: 'summarize', label: 'Summarize order' },
42
+ { id: 'draftReply', label: 'Draft customer reply' },
43
+ { id: 'extractIssues', label: 'Extract issues' },
44
+ ];
45
+
46
+ export interface AssistantProps {
47
+ api: Api;
48
+ ctx: LaunchContext;
49
+ }
50
+
51
+ export function Assistant({ api, ctx }: AssistantProps): JSX.Element {
52
+ const [tab, setTab] = useState(ctx.resource === 'order' ? 1 : 0);
53
+ const [models, setModels] = useState<string[]>([]);
54
+ const [model, setModel] = useState('');
55
+
56
+ useEffect(() => {
57
+ api
58
+ .listModels()
59
+ .then((ids) => {
60
+ setModels(ids);
61
+ if (ids.length) setModel((m) => m || ids[0]);
62
+ })
63
+ .catch(() => setModels([]));
64
+ }, [api]);
65
+
66
+ const modelSelect =
67
+ models.length > 0 ? (
68
+ <Select
69
+ label="Model"
70
+ labelInline
71
+ options={models.map((id) => ({ label: id, value: id }))}
72
+ value={model}
73
+ onChange={setModel}
74
+ />
75
+ ) : null;
76
+
77
+ const tabs = [
78
+ { id: 'products', content: 'Products' },
79
+ { id: 'orders', content: 'Orders' },
80
+ ];
81
+
82
+ return (
83
+ <BlockStack gap="400">
84
+ <Tabs tabs={tabs} selected={tab} onSelect={setTab} />
85
+ <Box paddingInlineStart="400" paddingInlineEnd="400" paddingBlockEnd="400">
86
+ {tab === 0 ? (
87
+ <ProductPanel api={api} model={model} modelSelect={modelSelect} initialId={ctx.resource === 'product' ? ctx.resourceId : ''} />
88
+ ) : (
89
+ <OrderPanel api={api} model={model} modelSelect={modelSelect} initialId={ctx.resource === 'order' ? ctx.resourceId : ''} />
90
+ )}
91
+ </Box>
92
+ </BlockStack>
93
+ );
94
+ }
95
+
96
+ // ---- Product panel --------------------------------------------------------
97
+
98
+ function ProductPanel(props: { api: Api; model: string; modelSelect: JSX.Element | null; initialId: string }): JSX.Element {
99
+ const { api, model, modelSelect, initialId } = props;
100
+ const [idInput, setIdInput] = useState(initialId);
101
+ const [product, setProduct] = useState<Product | null>(null);
102
+ const [output, setOutput] = useState('');
103
+ const [target, setTarget] = useState<'descriptionHtml' | 'seoTitle' | 'seoDescription'>('descriptionHtml');
104
+ const [busy, setBusy] = useState(false);
105
+ const [status, setStatus] = useState<{ tone: 'success' | 'critical' | 'info'; text: string } | null>(null);
106
+
107
+ const load = useCallback(async () => {
108
+ const gid = toGid('Product', idInput);
109
+ if (!gid) return;
110
+ setBusy(true);
111
+ setStatus(null);
112
+ try {
113
+ setProduct(await api.getProduct(gid));
114
+ setOutput('');
115
+ } catch (e: any) {
116
+ setStatus({ tone: 'critical', text: e?.message || 'Failed to load product' });
117
+ setProduct(null);
118
+ } finally {
119
+ setBusy(false);
120
+ }
121
+ }, [api, idInput]);
122
+
123
+ useEffect(() => {
124
+ if (initialId) void load();
125
+ // eslint-disable-next-line react-hooks/exhaustive-deps
126
+ }, [initialId]);
127
+
128
+ const run = useCallback(
129
+ async (actionId: string, writeTarget: 'descriptionHtml' | 'seoTitle' | 'seoDescription') => {
130
+ if (!product) return;
131
+ setBusy(true);
132
+ setStatus(null);
133
+ setTarget(writeTarget);
134
+ try {
135
+ setOutput(await api.runProductAction(product.id, actionId, model));
136
+ } catch (e: any) {
137
+ setStatus({ tone: 'critical', text: e?.message || 'Generation failed' });
138
+ } finally {
139
+ setBusy(false);
140
+ }
141
+ },
142
+ [api, product, model],
143
+ );
144
+
145
+ const writeBack = useCallback(async () => {
146
+ if (!product || !output) return;
147
+ setBusy(true);
148
+ setStatus(null);
149
+ try {
150
+ const w =
151
+ target === 'descriptionHtml'
152
+ ? { descriptionHtml: output }
153
+ : target === 'seoTitle'
154
+ ? { seoTitle: output.trim() }
155
+ : { seoDescription: output.trim() };
156
+ await api.updateProduct(product.id, w);
157
+ setStatus({ tone: 'success', text: `Saved to ${target === 'descriptionHtml' ? 'description' : target === 'seoTitle' ? 'SEO title' : 'SEO meta description'}.` });
158
+ } catch (e: any) {
159
+ setStatus({ tone: 'critical', text: e?.message || 'Save failed' });
160
+ } finally {
161
+ setBusy(false);
162
+ }
163
+ }, [api, product, output, target]);
164
+
165
+ return (
166
+ <BlockStack gap="400">
167
+ <Card>
168
+ <BlockStack gap="300">
169
+ <InlineStack gap="200" align="start" blockAlign="end">
170
+ <Box width="70%">
171
+ <TextField
172
+ label="Product ID or GID"
173
+ value={idInput}
174
+ onChange={setIdInput}
175
+ autoComplete="off"
176
+ placeholder="gid://shopify/Product/123 or 123"
177
+ />
178
+ </Box>
179
+ <Button onClick={() => void load()} loading={busy} variant="primary">
180
+ Load
181
+ </Button>
182
+ </InlineStack>
183
+ {product && (
184
+ <InlineStack gap="200" blockAlign="center">
185
+ <Text as="span" variant="headingSm">{product.title}</Text>
186
+ {product.status && <Badge>{product.status}</Badge>}
187
+ </InlineStack>
188
+ )}
189
+ </BlockStack>
190
+ </Card>
191
+
192
+ {product && (
193
+ <Card>
194
+ <BlockStack gap="300">
195
+ <InlineStack align="space-between" blockAlign="center">
196
+ <Text as="h3" variant="headingSm">Generate content</Text>
197
+ {modelSelect}
198
+ </InlineStack>
199
+ <ButtonGroup>
200
+ {PRODUCT_ACTIONS.map((a) => (
201
+ <Button key={a.id} onClick={() => void run(a.id, a.target)} disabled={busy}>
202
+ {a.label}
203
+ </Button>
204
+ ))}
205
+ </ButtonGroup>
206
+ </BlockStack>
207
+ </Card>
208
+ )}
209
+
210
+ {status && <Banner tone={status.tone}>{status.text}</Banner>}
211
+
212
+ {output && (
213
+ <Card>
214
+ <BlockStack gap="300">
215
+ <TextField label="Generated content" value={output} onChange={setOutput} multiline={6} autoComplete="off" />
216
+ <InlineStack gap="200">
217
+ <Button onClick={() => void writeBack()} variant="primary" loading={busy}>
218
+ Save to product
219
+ </Button>
220
+ </InlineStack>
221
+ </BlockStack>
222
+ </Card>
223
+ )}
224
+ </BlockStack>
225
+ );
226
+ }
227
+
228
+ // ---- Order panel ----------------------------------------------------------
229
+
230
+ function OrderPanel(props: { api: Api; model: string; modelSelect: JSX.Element | null; initialId: string }): JSX.Element {
231
+ const { api, model, modelSelect, initialId } = props;
232
+ const [idInput, setIdInput] = useState(initialId);
233
+ const [order, setOrder] = useState<Order | null>(null);
234
+ const [output, setOutput] = useState('');
235
+ const [busy, setBusy] = useState(false);
236
+ const [status, setStatus] = useState<{ tone: 'success' | 'critical' | 'info'; text: string } | null>(null);
237
+
238
+ const load = useCallback(async () => {
239
+ const gid = toGid('Order', idInput);
240
+ if (!gid) return;
241
+ setBusy(true);
242
+ setStatus(null);
243
+ try {
244
+ setOrder(await api.getOrder(gid));
245
+ setOutput('');
246
+ } catch (e: any) {
247
+ setStatus({ tone: 'critical', text: e?.message || 'Failed to load order' });
248
+ setOrder(null);
249
+ } finally {
250
+ setBusy(false);
251
+ }
252
+ }, [api, idInput]);
253
+
254
+ useEffect(() => {
255
+ if (initialId) void load();
256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
+ }, [initialId]);
258
+
259
+ const run = useCallback(
260
+ async (actionId: string) => {
261
+ if (!order) return;
262
+ setBusy(true);
263
+ setStatus(null);
264
+ try {
265
+ setOutput(await api.runOrderAction(order.id, actionId, model));
266
+ } catch (e: any) {
267
+ setStatus({ tone: 'critical', text: e?.message || 'Generation failed' });
268
+ } finally {
269
+ setBusy(false);
270
+ }
271
+ },
272
+ [api, order, model],
273
+ );
274
+
275
+ return (
276
+ <BlockStack gap="400">
277
+ <Card>
278
+ <BlockStack gap="300">
279
+ <InlineStack gap="200" align="start" blockAlign="end">
280
+ <Box width="70%">
281
+ <TextField
282
+ label="Order ID or GID"
283
+ value={idInput}
284
+ onChange={setIdInput}
285
+ autoComplete="off"
286
+ placeholder="gid://shopify/Order/123 or 123"
287
+ />
288
+ </Box>
289
+ <Button onClick={() => void load()} loading={busy} variant="primary">
290
+ Load
291
+ </Button>
292
+ </InlineStack>
293
+ {order && (
294
+ <InlineStack gap="200" blockAlign="center">
295
+ <Text as="span" variant="headingSm">{order.name}</Text>
296
+ {order.financialStatus && <Badge>{order.financialStatus}</Badge>}
297
+ {order.fulfillmentStatus && <Badge>{order.fulfillmentStatus}</Badge>}
298
+ </InlineStack>
299
+ )}
300
+ </BlockStack>
301
+ </Card>
302
+
303
+ {order && (
304
+ <Card>
305
+ <BlockStack gap="300">
306
+ <InlineStack align="space-between" blockAlign="center">
307
+ <Text as="h3" variant="headingSm">Order actions</Text>
308
+ {modelSelect}
309
+ </InlineStack>
310
+ <ButtonGroup>
311
+ {ORDER_ACTIONS.map((a) => (
312
+ <Button key={a.id} onClick={() => void run(a.id)} disabled={busy}>
313
+ {a.label}
314
+ </Button>
315
+ ))}
316
+ </ButtonGroup>
317
+ </BlockStack>
318
+ </Card>
319
+ )}
320
+
321
+ {status && <Banner tone={status.tone}>{status.text}</Banner>}
322
+
323
+ {output && (
324
+ <Card>
325
+ <TextField label="Result" value={output} onChange={setOutput} multiline={8} autoComplete="off" />
326
+ </Card>
327
+ )}
328
+ </BlockStack>
329
+ );
330
+ }
package/src/app/api.ts ADDED
@@ -0,0 +1,126 @@
1
+ // The admin panel's backend client — pure request shaping over the server proxy.
2
+ // The frontend NEVER holds the Shopify secret or the Hanzo key: it calls the
3
+ // server's /v1/* endpoints with its shop domain, and the server holds the token
4
+ // and talks to Shopify + api.hanzo.ai. This module is pure over an injected
5
+ // `fetch` (App Bridge's authenticated fetch in the app; a mock in tests), so the
6
+ // request shaping is unit-testable without a browser.
7
+ //
8
+ // Every method returns typed data or throws with the server's error message, so
9
+ // the React panel has ONE way to handle failure: try/catch on await.
10
+
11
+ // A product as the panel consumes it (the server's parseProduct output).
12
+ export interface Product {
13
+ id: string;
14
+ title: string;
15
+ handle: string;
16
+ descriptionHtml: string;
17
+ description: string;
18
+ productType: string;
19
+ vendor: string;
20
+ tags: string[];
21
+ status: string;
22
+ seoTitle: string;
23
+ seoDescription: string;
24
+ }
25
+
26
+ // An order as the panel consumes it (the server's parseOrder output).
27
+ export interface Order {
28
+ id: string;
29
+ name: string;
30
+ note: string;
31
+ email: string;
32
+ financialStatus: string;
33
+ fulfillmentStatus: string;
34
+ createdAt: string;
35
+ totalAmount: string;
36
+ currency: string;
37
+ customerName: string;
38
+ shipTo: string;
39
+ lineItems: Array<{ title: string; quantity: number; sku: string }>;
40
+ }
41
+
42
+ // The fields a product write-back may carry. Only what is set is sent.
43
+ export interface ProductWriteback {
44
+ descriptionHtml?: string;
45
+ seoTitle?: string;
46
+ seoDescription?: string;
47
+ }
48
+
49
+ export interface ApiOptions {
50
+ /** Backend base (the server behind hanzoai/ingress). '' means same-origin. */
51
+ baseUrl?: string;
52
+ /** The shop domain (from App Bridge host/shop). Sent on every call. */
53
+ shop: string;
54
+ /**
55
+ * The fetch to use. In the app this is App Bridge's authenticated fetch (adds
56
+ * the session-token Authorization header so the server can trust the shop).
57
+ * In tests, a mock. Defaults to global fetch.
58
+ */
59
+ fetch?: typeof fetch;
60
+ }
61
+
62
+ // asError turns a non-ok response body into a thrown Error carrying the server's
63
+ // message. One place decodes the { error } envelope so every call fails the same
64
+ // way.
65
+ async function asError(resp: Response): Promise<never> {
66
+ let msg = `HTTP ${resp.status}`;
67
+ try {
68
+ const data: any = await resp.json();
69
+ if (data?.error) msg = String(data.error);
70
+ } catch {
71
+ /* non-JSON body — keep the status */
72
+ }
73
+ throw new Error(msg);
74
+ }
75
+
76
+ // createApi builds the panel's typed client bound to a shop + fetch. Every method
77
+ // is a single request to the server proxy.
78
+ export function createApi(opts: ApiOptions) {
79
+ const base = (opts.baseUrl ?? '').replace(/\/+$/, '');
80
+ const doFetch = opts.fetch ?? fetch;
81
+ const shop = opts.shop;
82
+
83
+ async function getJson(path: string, query: Record<string, string>): Promise<any> {
84
+ const q = new URLSearchParams({ shop, ...query }).toString();
85
+ const resp = await doFetch(`${base}${path}?${q}`);
86
+ if (!resp.ok) return asError(resp);
87
+ return resp.json();
88
+ }
89
+
90
+ async function postJson(path: string, body: Record<string, unknown>): Promise<any> {
91
+ const resp = await doFetch(`${base}${path}`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ shop, ...body }),
95
+ });
96
+ if (!resp.ok) return asError(resp);
97
+ return resp.json();
98
+ }
99
+
100
+ return {
101
+ getProduct(id: string): Promise<Product> {
102
+ return getJson('/v1/product', { id });
103
+ },
104
+ getOrder(id: string): Promise<Order> {
105
+ return getJson('/v1/order', { id });
106
+ },
107
+ async listModels(): Promise<string[]> {
108
+ const data = await getJson('/v1/models', {});
109
+ return Array.isArray(data?.models) ? data.models : [];
110
+ },
111
+ async runProductAction(id: string, action: string, model?: string): Promise<string> {
112
+ const data = await postJson('/v1/product/action', { id, action, model });
113
+ return String(data?.content ?? '');
114
+ },
115
+ async runOrderAction(id: string, action: string, model?: string): Promise<string> {
116
+ const data = await postJson('/v1/order/action', { id, action, model });
117
+ return String(data?.content ?? '');
118
+ },
119
+ async updateProduct(id: string, w: ProductWriteback): Promise<{ id: string; title: string }> {
120
+ const data = await postJson('/v1/product/update', { id, ...w });
121
+ return data?.product ?? { id, title: '' };
122
+ },
123
+ };
124
+ }
125
+
126
+ export type Api = ReturnType<typeof createApi>;
@@ -0,0 +1,54 @@
1
+ // Reading the embedded-app launch context — pure, browser-string-in / values-out,
2
+ // so it is unit-testable without an iframe. Shopify launches the embedded admin
3
+ // app inside an iframe with `shop`, `host`, and (from an admin block/action
4
+ // extension on a product/order page) the resource `id` in the URL query. The
5
+ // React panel reads this once at mount.
6
+
7
+ // The launch context the panel needs. `shop` scopes every backend call; `host`
8
+ // is App Bridge's base64 host param; `resource` + `resourceId` are set when the
9
+ // app opened from a product or order page (an admin link/action), so the panel
10
+ // can jump straight to that subject.
11
+ export interface LaunchContext {
12
+ shop: string;
13
+ host: string;
14
+ resource: 'product' | 'order' | '';
15
+ resourceId: string;
16
+ }
17
+
18
+ // Shopify sometimes passes a bare numeric id and sometimes a GID
19
+ // (gid://shopify/Product/123). toGid normalizes a value to a GID for a resource
20
+ // kind — the Admin GraphQL API takes GIDs. A value already in gid:// form is
21
+ // returned unchanged; a bare number is wrapped. Pure.
22
+ export function toGid(kind: 'Product' | 'Order', id: string): string {
23
+ const v = id.trim();
24
+ if (!v) return '';
25
+ if (v.startsWith('gid://')) return v;
26
+ return `gid://shopify/${kind}/${v}`;
27
+ }
28
+
29
+ // resourceKind maps a `resource` query value to the GID kind, defaulting to ''
30
+ // (no resource). Only 'product' and 'order' are surfaces this app acts on.
31
+ function resourceOf(v: string | null): 'product' | 'order' | '' {
32
+ return v === 'product' || v === 'order' ? v : '';
33
+ }
34
+
35
+ // parseLaunchContext reads the iframe URL's query string into a LaunchContext.
36
+ // A GID passed for the resource id is preserved; a bare id is normalized to a GID
37
+ // for the named resource. Pure.
38
+ export function parseLaunchContext(search: string): LaunchContext {
39
+ const q = new URLSearchParams(search);
40
+ const resource = resourceOf(q.get('resource'));
41
+ const rawId = q.get('id') ?? '';
42
+ const resourceId =
43
+ resource === 'product'
44
+ ? toGid('Product', rawId)
45
+ : resource === 'order'
46
+ ? toGid('Order', rawId)
47
+ : rawId;
48
+ return {
49
+ shop: q.get('shop') ?? '',
50
+ host: q.get('host') ?? '',
51
+ resource,
52
+ resourceId,
53
+ };
54
+ }
@@ -0,0 +1,21 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Hanzo AI for Shopify</title>
7
+ <!--
8
+ App Bridge is loaded from Shopify's CDN and MUST be the first script, with
9
+ the app's API key in a meta tag. Shopify requires this for an embedded admin
10
+ app so the iframe registers window.shopify (session tokens, navigation).
11
+ build.js stamps __SHOPIFY_API_KEY__ (public — the client id, never the secret)
12
+ and __ENTRY__ / __BASE__.
13
+ -->
14
+ <meta name="shopify-api-key" content="__SHOPIFY_API_KEY__" />
15
+ <script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"></script>
16
+ </head>
17
+ <body>
18
+ <div id="root"></div>
19
+ <script type="module" src="__ENTRY__"></script>
20
+ </body>
21
+ </html>
@@ -0,0 +1,51 @@
1
+ // The embedded-app browser entry point — the one impure, browser-only module. It
2
+ // reads the launch context, builds the App Bridge authenticated fetch, wires the
3
+ // backend Api, and mounts the Polaris React app. Everything logic-heavy it uses
4
+ // (context parsing, request shaping, session-token fetch) is a pure, tested
5
+ // module; this file is the DOM glue.
6
+ //
7
+ // App Bridge (window.shopify) is loaded by the CDN script tag in index.html with
8
+ // the app's api-key meta, per Shopify's embedded-app requirement. We read the
9
+ // session token through it on every request (session-fetch) so the server can
10
+ // trust which shop is calling.
11
+
12
+ import { StrictMode } from 'react';
13
+ import { createRoot } from 'react-dom/client';
14
+ import '@shopify/polaris/build/esm/styles.css';
15
+ import { App } from './App';
16
+ import { createApi } from './api';
17
+ import { parseLaunchContext } from './context';
18
+ import { authenticatedFetch } from './session-fetch';
19
+
20
+ // The App Bridge global the CDN script registers. idToken() returns a fresh
21
+ // session-token JWT. Typed minimally — we use only idToken.
22
+ declare global {
23
+ interface Window {
24
+ shopify?: { idToken(): Promise<string> };
25
+ }
26
+ }
27
+
28
+ // The backend base is stamped into the page as <meta name="hanzo:base"> at build
29
+ // time (build.js). Empty means same-origin (the app and the API served together).
30
+ function backendBase(): string {
31
+ const meta = document.querySelector('meta[name="hanzo:base"]');
32
+ return (meta?.getAttribute('content') ?? '').replace(/\/+$/, '');
33
+ }
34
+
35
+ function boot(): void {
36
+ const ctx = parseLaunchContext(window.location.search);
37
+ const getIdToken: () => Promise<string> = () =>
38
+ window.shopify ? window.shopify.idToken() : Promise.resolve('');
39
+ const fetchImpl = authenticatedFetch(fetch.bind(window), getIdToken);
40
+ const api = createApi({ baseUrl: backendBase(), shop: ctx.shop, fetch: fetchImpl });
41
+
42
+ const root = document.getElementById('root');
43
+ if (!root) throw new Error('missing #root');
44
+ createRoot(root).render(
45
+ <StrictMode>
46
+ <App api={api} ctx={ctx} />
47
+ </StrictMode>,
48
+ );
49
+ }
50
+
51
+ boot();
@@ -0,0 +1,31 @@
1
+ // App Bridge session-token fetch — pure and injectable so it is unit-testable
2
+ // without App Bridge or a browser. Shopify's embedded-app auth model: App Bridge
3
+ // (window.shopify) mints a short-lived session token (a signed JWT) via
4
+ // `shopify.idToken()`; the frontend attaches it as `Authorization: Bearer <jwt>`
5
+ // on every backend call, and the server verifies it (the shop identity is in the
6
+ // token, so the server does not trust the shop query param blindly). This wraps a
7
+ // base fetch to add that header, refreshing the token per request (tokens expire
8
+ // in ~1 minute, so we never cache one).
9
+ //
10
+ // getIdToken is injected: in the app it is `() => window.shopify.idToken()`; in
11
+ // tests it is a stub. That keeps this module free of any global and fully pure.
12
+
13
+ export type IdTokenSource = () => Promise<string>;
14
+
15
+ // authenticatedFetch returns a fetch that, before each request, fetches a fresh
16
+ // session token and adds it as a Bearer header. It merges (does not clobber) any
17
+ // caller headers. If token retrieval fails, the request still goes out without
18
+ // the header — the server will reject it — so a token outage surfaces as a clean
19
+ // 401 from the server rather than a thrown fetch here.
20
+ export function authenticatedFetch(baseFetch: typeof fetch, getIdToken: IdTokenSource): typeof fetch {
21
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
22
+ const headers = new Headers(init?.headers);
23
+ try {
24
+ const token = await getIdToken();
25
+ if (token) headers.set('Authorization', `Bearer ${token}`);
26
+ } catch {
27
+ /* no token — let the server 401 */
28
+ }
29
+ return baseFetch(input, { ...init, headers });
30
+ };
31
+ }