@curless/shopify-storefront 0.3.1 → 0.4.1
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__/privacy-page.test.js +49 -1
- package/dist/__tests__/privacy-page.test.js.map +1 -1
- package/dist/__tests__/token-refresh.test.d.ts +2 -0
- package/dist/__tests__/token-refresh.test.d.ts.map +1 -0
- package/dist/__tests__/token-refresh.test.js +158 -0
- package/dist/__tests__/token-refresh.test.js.map +1 -0
- package/dist/catalog.d.ts +31 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +28 -2
- package/dist/catalog.js.map +1 -1
- package/dist/index.js +77 -4
- package/dist/index.js.map +1 -1
- package/dist/legal.d.ts +14 -0
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +28 -12
- package/dist/legal.js.map +1 -1
- package/dist/multi-token-store.d.ts +8 -0
- package/dist/multi-token-store.d.ts.map +1 -1
- package/dist/multi-token-store.js +26 -1
- package/dist/multi-token-store.js.map +1 -1
- package/dist/oauth.d.ts +22 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +50 -5
- package/dist/oauth.js.map +1 -1
- package/dist/token-refresh.d.ts +74 -0
- package/dist/token-refresh.d.ts.map +1 -0
- package/dist/token-refresh.js +122 -0
- package/dist/token-refresh.js.map +1 -0
- package/dist/token-store.d.ts +10 -0
- package/dist/token-store.d.ts.map +1 -1
- package/dist/token-store.js.map +1 -1
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
|
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { describe, expect, test } from 'vitest';
|
|
14
|
-
import { ORDER_FIELDS, privacyPage } from '../legal.js';
|
|
14
|
+
import { ORDER_FIELDS, docsPage, privacyPage } from '../legal.js';
|
|
15
15
|
const CTX = {
|
|
16
16
|
merchantName: 'Moody Lenses',
|
|
17
17
|
storefront: 'https://moodylenses.com',
|
|
@@ -91,4 +91,52 @@ describe('privacy page', () => {
|
|
|
91
91
|
expect(page).toContain('ops@curless.ai');
|
|
92
92
|
});
|
|
93
93
|
});
|
|
94
|
+
// ⚠️ THE TENANT-LESS DEPLOYMENT — the one a Shopify reviewer opens.
|
|
95
|
+
//
|
|
96
|
+
// Every sentence on these pages was written for a branded deployment. With no
|
|
97
|
+
// merchant to name, `merchantName` fell back to our own product title, so the
|
|
98
|
+
// page told the reader «This is not Curless Storefront's privacy policy …
|
|
99
|
+
// governed by Curless Storefront's own policy» and linked that policy at
|
|
100
|
+
// `<our own app url>/policies/privacy-policy` — verified 404 in production on
|
|
101
|
+
// 2026-09-10. Our product name stood in for the shop, and the shop's policy
|
|
102
|
+
// link pointed at us.
|
|
103
|
+
describe('the listed app has no merchant to name', () => {
|
|
104
|
+
// ⚠️ `storefront` IS OUR OWN URL HERE, and that is not a detail of the
|
|
105
|
+
// fixture — it is what production does. A tenant-less deployment has no
|
|
106
|
+
// merchant storefront to point at, so `storefrontUrl()` falls back to
|
|
107
|
+
// PUBLIC_URL. Leaving the branded fixture's moodylenses.com in place made the
|
|
108
|
+
// 404 test below pass with the fix REMOVED: the links pointed somewhere else
|
|
109
|
+
// entirely, so the "on our own domain" regex found nothing. Caught by
|
|
110
|
+
// mutation, not by reading.
|
|
111
|
+
const multi = {
|
|
112
|
+
...CTX,
|
|
113
|
+
multiTenant: true,
|
|
114
|
+
merchantName: 'Curless Storefront',
|
|
115
|
+
storefront: 'https://shopify.curless.ai/app',
|
|
116
|
+
};
|
|
117
|
+
test('never claims to be some named shop’s policy', () => {
|
|
118
|
+
const page = privacyPage(multi);
|
|
119
|
+
expect(page).not.toContain("This is not Curless Storefront's privacy policy");
|
|
120
|
+
expect(page).toContain("This is not the shop's privacy policy");
|
|
121
|
+
});
|
|
122
|
+
// The specific 404. A policy link on a compliance page has to resolve.
|
|
123
|
+
test('links no /policies/* path on our own domain', () => {
|
|
124
|
+
for (const page of [privacyPage(multi), docsPage(multi)]) {
|
|
125
|
+
const ours = page.match(/https:\/\/[^"']*curless[^"']*\/policies\/[a-z-]+/g) ?? [];
|
|
126
|
+
expect(ours, `points a shop-policy link at us: ${ours.join(', ')}`).toHaveLength(0);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
// A branded deployment must keep its links — the fix must not cost moody and
|
|
130
|
+
// elspet the merchant policy links they are supposed to carry.
|
|
131
|
+
test('a branded deployment still links the merchant’s own policies', () => {
|
|
132
|
+
expect(privacyPage(CTX)).toContain('/policies/privacy-policy');
|
|
133
|
+
});
|
|
134
|
+
// `last updated ` with nothing after it was live on the app's privacy page —
|
|
135
|
+
// the same defect elspet's pre-flight caught, still present here because the
|
|
136
|
+
// listed app's config had `legal: {}`.
|
|
137
|
+
test('an unset date leaves no dangling “last updated”', () => {
|
|
138
|
+
expect(privacyPage({ ...multi, updated: '' })).not.toContain('last updated');
|
|
139
|
+
expect(privacyPage({ ...multi, updated: '10 September 2026' })).toContain('last updated 10 September 2026');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
94
142
|
//# sourceMappingURL=privacy-page.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"privacy-page.test.js","sourceRoot":"","sources":["../../src/__tests__/privacy-page.test.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,8EAA8E;AAC9E,+EAA+E;AAC/E,iFAAiF;AACjF,WAAW;AACX,EAAE;AACF,iFAAiF;AACjF,6EAA6E;AAC7E,+EAA+E;AAE/E,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAChD,OAAO,EAAqB,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"privacy-page.test.js","sourceRoot":"","sources":["../../src/__tests__/privacy-page.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-refresh.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/token-refresh.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { REFRESH_SKEW_MS, keepAlive, needsRefresh, refreshIfDue } from '../token-refresh.js';
|
|
3
|
+
const NOW = new Date('2026-09-09T12:00:00.000Z');
|
|
4
|
+
const at = (msFromNow) => new Date(NOW.getTime() + msFromNow).toISOString();
|
|
5
|
+
const oauth = {
|
|
6
|
+
shopDomain: 'shop.myshopify.com',
|
|
7
|
+
clientId: 'cid',
|
|
8
|
+
clientSecret: 'csec',
|
|
9
|
+
appUrl: 'https://x.test',
|
|
10
|
+
scopes: 'read_products',
|
|
11
|
+
};
|
|
12
|
+
const token = (over = {}) => ({
|
|
13
|
+
shopDomain: 'shop.myshopify.com',
|
|
14
|
+
accessToken: 'shpat_old',
|
|
15
|
+
scope: 'read_products,read_orders',
|
|
16
|
+
installedAt: NOW.toISOString(),
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
const okResponse = (body) => ({ ok: true, json: async () => body });
|
|
20
|
+
describe('needsRefresh', () => {
|
|
21
|
+
// ⚠️ THE COMPATIBILITY CASE, and the one that would take moody and elspet
|
|
22
|
+
// offline if it were wrong. Their records were written in August with no
|
|
23
|
+
// `expiresAt` because their tokens do not expire. Absent means NEVER.
|
|
24
|
+
test('a record with no expiresAt is never due — it does not expire', () => {
|
|
25
|
+
expect(needsRefresh(token(), NOW)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
test('due once inside the skew window, not before', () => {
|
|
28
|
+
expect(needsRefresh(token({ expiresAt: at(REFRESH_SKEW_MS + 60_000) }), NOW)).toBe(false);
|
|
29
|
+
expect(needsRefresh(token({ expiresAt: at(REFRESH_SKEW_MS - 60_000) }), NOW)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
test('already expired is due', () => {
|
|
32
|
+
expect(needsRefresh(token({ expiresAt: at(-1) }), NOW)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
// A corrupt timestamp is not a fresh token. One wasted call beats serving a
|
|
35
|
+
// token we cannot reason about until it 401s.
|
|
36
|
+
test('an unparseable expiresAt is due', () => {
|
|
37
|
+
expect(needsRefresh(token({ expiresAt: 'not-a-date' }), NOW)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('refreshIfDue', () => {
|
|
41
|
+
test('a token that does not expire is left completely alone', async () => {
|
|
42
|
+
const fetchImpl = vi.fn();
|
|
43
|
+
const save = vi.fn();
|
|
44
|
+
const res = await refreshIfDue(token(), oauth, save, { now: NOW, fetchImpl });
|
|
45
|
+
expect(res.refreshed).toBe(false);
|
|
46
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
47
|
+
expect(save).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
test('a due token is renewed and persisted', async () => {
|
|
50
|
+
const fetchImpl = vi.fn(async () => okResponse({
|
|
51
|
+
access_token: 'shpat_new',
|
|
52
|
+
refresh_token: 'shprt_new',
|
|
53
|
+
expires_in: 3600,
|
|
54
|
+
scope: 'read_products,read_orders',
|
|
55
|
+
}));
|
|
56
|
+
const saved = [];
|
|
57
|
+
const res = await refreshIfDue(token({ expiresAt: at(-1), refreshToken: 'shprt_old' }), oauth, async (t) => {
|
|
58
|
+
saved.push(t);
|
|
59
|
+
}, { now: NOW, fetchImpl });
|
|
60
|
+
expect(res.refreshed).toBe(true);
|
|
61
|
+
expect(res.token.accessToken).toBe('shpat_new');
|
|
62
|
+
// ⚠️ THE NEW REFRESH TOKEN IS STORED. Shopify keeps the old one alive only
|
|
63
|
+
// until the replacement is first used; keeping the old one means the NEXT
|
|
64
|
+
// refresh is the one that fails — an hour later, far from this code.
|
|
65
|
+
expect(saved[0]?.refreshToken).toBe('shprt_new');
|
|
66
|
+
expect(saved[0]?.expiresAt).toBe(at(3600_000));
|
|
67
|
+
});
|
|
68
|
+
// A refresh answer legitimately omits `scope` — it renews a token, it does
|
|
69
|
+
// not re-grant permissions. Overwriting with '' would make the install read
|
|
70
|
+
// as unscoped.
|
|
71
|
+
test('an answer with no scope keeps the granted scope', async () => {
|
|
72
|
+
const fetchImpl = vi.fn(async () => okResponse({ access_token: 'shpat_new', refresh_token: 'shprt_new', expires_in: 3600 }));
|
|
73
|
+
const res = await refreshIfDue(token({ expiresAt: at(-1), refreshToken: 'shprt_old' }), oauth, async () => { }, { now: NOW, fetchImpl });
|
|
74
|
+
expect(res.token.scope).toBe('read_products,read_orders');
|
|
75
|
+
});
|
|
76
|
+
// ⚠️ A FAILED REFRESH IS NOT AN OUTAGE. Shopify being down for a minute must
|
|
77
|
+
// not throw out a token that is still valid for another five; the 401 path
|
|
78
|
+
// handles one that really is dead.
|
|
79
|
+
test('a failed refresh keeps serving the old token and reports the error', async () => {
|
|
80
|
+
const fetchImpl = vi.fn(async () => ({ ok: false, status: 503 }));
|
|
81
|
+
const onError = vi.fn();
|
|
82
|
+
const res = await refreshIfDue(token({ expiresAt: at(60_000), refreshToken: 'shprt_old' }), oauth, async () => {
|
|
83
|
+
throw new Error('must not save');
|
|
84
|
+
}, { now: NOW, fetchImpl, onError });
|
|
85
|
+
expect(res.refreshed).toBe(false);
|
|
86
|
+
expect(res.token.accessToken).toBe('shpat_old');
|
|
87
|
+
expect(onError).toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
test('due but with no refresh token says so instead of looping', async () => {
|
|
90
|
+
const onError = vi.fn();
|
|
91
|
+
const res = await refreshIfDue(token({ expiresAt: at(-1) }), oauth, async () => { }, {
|
|
92
|
+
now: NOW,
|
|
93
|
+
onError,
|
|
94
|
+
});
|
|
95
|
+
expect(res).toMatchObject({ refreshed: false, reason: 'no-refresh-token' });
|
|
96
|
+
expect(onError).toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('keepAlive', () => {
|
|
100
|
+
// ⚠️ THE WHOLE POINT: a shop nobody touches. Its access token died 3 days ago
|
|
101
|
+
// and no request has asked for a new one, so only this sweep stands between
|
|
102
|
+
// the install and a refresh token that lapses at 90 days.
|
|
103
|
+
test('renews an install that expired days ago and nobody has used', async () => {
|
|
104
|
+
const fetchImpl = vi.fn(async () => okResponse({ access_token: 'shpat_new', refresh_token: 'shprt_new', expires_in: 3600 }));
|
|
105
|
+
const saved = [];
|
|
106
|
+
const n = await keepAlive(async () => [
|
|
107
|
+
token({
|
|
108
|
+
shopDomain: 'quiet.myshopify.com',
|
|
109
|
+
expiresAt: at(-3 * 24 * 3600_000),
|
|
110
|
+
refreshToken: 'shprt_old',
|
|
111
|
+
}),
|
|
112
|
+
], oauth, async (t) => {
|
|
113
|
+
saved.push(t);
|
|
114
|
+
}, { now: NOW, fetchImpl });
|
|
115
|
+
expect(n).toBe(1);
|
|
116
|
+
expect(saved[0]?.refreshToken).toBe('shprt_new');
|
|
117
|
+
});
|
|
118
|
+
// Even one still comfortably inside its hour: the sweep exists to roll the
|
|
119
|
+
// 90-day window, not to wait until the token is nearly dead.
|
|
120
|
+
test('renews a token that is still valid — the window it rolls is the 90-day one', async () => {
|
|
121
|
+
const fetchImpl = vi.fn(async () => okResponse({ access_token: 'shpat_new', refresh_token: 'shprt_new', expires_in: 3600 }));
|
|
122
|
+
const n = await keepAlive(async () => [token({ expiresAt: at(50 * 60_000), refreshToken: 'shprt_old' })], oauth, async () => { }, { now: NOW, fetchImpl });
|
|
123
|
+
expect(n).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
// ⚠️ moody and elspet. Non-expiring installs must not be woken up daily to
|
|
126
|
+
// chase a refresh token they do not have.
|
|
127
|
+
// ⚠️ THE FIXTURE CARRIES A REFRESH TOKEN ON PURPOSE. Without one this test
|
|
128
|
+
// passes for the wrong reason — the sweep skips it as "nothing to renew with"
|
|
129
|
+
// rather than as "this one does not expire" — and a mutation that made a
|
|
130
|
+
// missing `expiresAt` mean DUE went green here. Found by running exactly that
|
|
131
|
+
// mutation. With the refresh token present, the expiry rule is the only thing
|
|
132
|
+
// left holding it back.
|
|
133
|
+
test('never touches a non-expiring install, even one that could be refreshed', async () => {
|
|
134
|
+
const fetchImpl = vi.fn();
|
|
135
|
+
const n = await keepAlive(async () => [token({ refreshToken: 'shprt_usable' })], oauth, async () => { }, {
|
|
136
|
+
now: NOW,
|
|
137
|
+
fetchImpl,
|
|
138
|
+
});
|
|
139
|
+
expect(n).toBe(0);
|
|
140
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
// One bad install must not stop the sweep reaching the rest.
|
|
143
|
+
test('a failing shop does not stop the others', async () => {
|
|
144
|
+
let call = 0;
|
|
145
|
+
const fetchImpl = vi.fn(async () => {
|
|
146
|
+
call += 1;
|
|
147
|
+
return call === 1
|
|
148
|
+
? { ok: false, status: 500 }
|
|
149
|
+
: okResponse({ access_token: 'shpat_new', refresh_token: 'shprt_new', expires_in: 3600 });
|
|
150
|
+
});
|
|
151
|
+
const n = await keepAlive(async () => [
|
|
152
|
+
token({ shopDomain: 'bad.myshopify.com', expiresAt: at(-1), refreshToken: 'r1' }),
|
|
153
|
+
token({ shopDomain: 'good.myshopify.com', expiresAt: at(-1), refreshToken: 'r2' }),
|
|
154
|
+
], oauth, async () => { }, { now: NOW, fetchImpl, onError: () => { } });
|
|
155
|
+
expect(n).toBe(1);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
//# sourceMappingURL=token-refresh.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-refresh.test.js","sourceRoot":"","sources":["../../src/__tests__/token-refresh.test.ts"],"names":[],"mappings":"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"}
|
package/dist/catalog.d.ts
CHANGED
|
@@ -23,11 +23,41 @@ export type CatalogSnapshot = {
|
|
|
23
23
|
export declare class Catalog {
|
|
24
24
|
private readonly resolveConfig;
|
|
25
25
|
private readonly log;
|
|
26
|
+
/**
|
|
27
|
+
* Last resort for a token that died before we expected it to.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ The resolver already renews a token that is NEARLY expired, so nothing
|
|
30
|
+
* here fires on the ordinary hourly rollover. What is left is the case the
|
|
31
|
+
* clock cannot predict: Shopify rotated the token and we never persisted
|
|
32
|
+
* the answer, or our clock is wrong. Shopify keeps the previous refresh
|
|
33
|
+
* token working until its replacement is first used precisely so this
|
|
34
|
+
* recovers instead of needing the merchant to reinstall.
|
|
35
|
+
*
|
|
36
|
+
* ONE retry. A 401 that survives a forced refresh means the app was
|
|
37
|
+
* uninstalled or the credential revoked, and retrying that is a loop
|
|
38
|
+
* against someone who has told us to stop.
|
|
39
|
+
*/
|
|
40
|
+
private readonly onUnauthorized?;
|
|
26
41
|
private snapshot;
|
|
27
42
|
private inFlight;
|
|
28
43
|
private variants;
|
|
29
44
|
private upstream;
|
|
30
|
-
constructor(resolveConfig: () => Promise<ShopifyConfig | null>, log?: (msg: string) => void
|
|
45
|
+
constructor(resolveConfig: () => Promise<ShopifyConfig | null>, log?: (msg: string) => void,
|
|
46
|
+
/**
|
|
47
|
+
* Last resort for a token that died before we expected it to.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ The resolver already renews a token that is NEARLY expired, so nothing
|
|
50
|
+
* here fires on the ordinary hourly rollover. What is left is the case the
|
|
51
|
+
* clock cannot predict: Shopify rotated the token and we never persisted
|
|
52
|
+
* the answer, or our clock is wrong. Shopify keeps the previous refresh
|
|
53
|
+
* token working until its replacement is first used precisely so this
|
|
54
|
+
* recovers instead of needing the merchant to reinstall.
|
|
55
|
+
*
|
|
56
|
+
* ONE retry. A 401 that survives a forced refresh means the app was
|
|
57
|
+
* uninstalled or the credential revoked, and retrying that is a loop
|
|
58
|
+
* against someone who has told us to stop.
|
|
59
|
+
*/
|
|
60
|
+
onUnauthorized?: (() => Promise<void>) | undefined);
|
|
31
61
|
get current(): CatalogSnapshot;
|
|
32
62
|
/** Products, newest snapshot. Empty only before the first successful load. */
|
|
33
63
|
list(): ShopifyProduct[];
|
package/dist/catalog.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AASA,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,cAAc,EAKpB,MAAM,0BAA0B,CAAC;AAGlC,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACtB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AA2BF,qBAAa,OAAO;IAchB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,GAAG;
|
|
1
|
+
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AASA,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,cAAc,EAKpB,MAAM,0BAA0B,CAAC;AAGlC,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACtB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AA2BF,qBAAa,OAAO;IAchB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;IA7BlC,OAAO,CAAC,QAAQ,CAAoE;IACpF,OAAO,CAAC,QAAQ,CAA8B;IAG9C,OAAO,CAAC,QAAQ,CAA6D;IAE7E,OAAO,CAAC,QAAQ,CAAoD;gBAOjD,aAAa,EAAE,MAAM,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,EAClD,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAA2C;IAClF;;;;;;;;;;;;;OAaG;IACc,cAAc,CAAC,GAAE,MAAM,OAAO,CAAC,IAAI,CAAC,aAAA;IAGvD,IAAI,OAAO,IAAI,eAAe,CAE7B;IAED,8EAA8E;IAC9E,IAAI,IAAI,cAAc,EAAE;IAIxB,eAAe,CAAC,CAAC,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAYhD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA4E9B,6EAA6E;IACvE,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAyBtF,OAAO,CAAC,KAAK,CAA+C;IAE5D,wFAAwF;IACxF,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAQ/B;;;;;;;OAOG;IACH,IAAI,IAAI,IAAI;CAIb"}
|
package/dist/catalog.js
CHANGED
|
@@ -35,6 +35,7 @@ const VARIANT_FETCHES_PER_MIN = 90;
|
|
|
35
35
|
export class Catalog {
|
|
36
36
|
resolveConfig;
|
|
37
37
|
log;
|
|
38
|
+
onUnauthorized;
|
|
38
39
|
snapshot = { products: [], loadedAt: null, discounts: [] };
|
|
39
40
|
inFlight = null;
|
|
40
41
|
// Per INSTANCE, not per module: a module-level cache outlives whatever created
|
|
@@ -46,9 +47,25 @@ export class Catalog {
|
|
|
46
47
|
// arrives when the merchant installs the app, which may be long after this
|
|
47
48
|
// process started. Null means "not connected yet" — a state to report, not to
|
|
48
49
|
// crash on.
|
|
49
|
-
constructor(resolveConfig, log = (m) => console.log(`[catalog] ${m}`)
|
|
50
|
+
constructor(resolveConfig, log = (m) => console.log(`[catalog] ${m}`),
|
|
51
|
+
/**
|
|
52
|
+
* Last resort for a token that died before we expected it to.
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ The resolver already renews a token that is NEARLY expired, so nothing
|
|
55
|
+
* here fires on the ordinary hourly rollover. What is left is the case the
|
|
56
|
+
* clock cannot predict: Shopify rotated the token and we never persisted
|
|
57
|
+
* the answer, or our clock is wrong. Shopify keeps the previous refresh
|
|
58
|
+
* token working until its replacement is first used precisely so this
|
|
59
|
+
* recovers instead of needing the merchant to reinstall.
|
|
60
|
+
*
|
|
61
|
+
* ONE retry. A 401 that survives a forced refresh means the app was
|
|
62
|
+
* uninstalled or the credential revoked, and retrying that is a loop
|
|
63
|
+
* against someone who has told us to stop.
|
|
64
|
+
*/
|
|
65
|
+
onUnauthorized) {
|
|
50
66
|
this.resolveConfig = resolveConfig;
|
|
51
67
|
this.log = log;
|
|
68
|
+
this.onUnauthorized = onUnauthorized;
|
|
52
69
|
}
|
|
53
70
|
get current() {
|
|
54
71
|
return this.snapshot;
|
|
@@ -79,7 +96,16 @@ export class Catalog {
|
|
|
79
96
|
};
|
|
80
97
|
return;
|
|
81
98
|
}
|
|
82
|
-
const products = await fetchShopifyProducts(config, fetch, (m) => this.log(m))
|
|
99
|
+
const products = await fetchShopifyProducts(config, fetch, (m) => this.log(m)).catch(async (err) => {
|
|
100
|
+
if (!this.onUnauthorized || !/HTTP 401/.test(String(err)))
|
|
101
|
+
throw err;
|
|
102
|
+
this.log('admin API answered 401 — forcing a token refresh and retrying once');
|
|
103
|
+
await this.onUnauthorized();
|
|
104
|
+
const fresh = await this.resolveConfig();
|
|
105
|
+
if (!fresh)
|
|
106
|
+
throw err;
|
|
107
|
+
return fetchShopifyProducts(fresh, fetch, (m) => this.log(m));
|
|
108
|
+
});
|
|
83
109
|
if (products.length === 0) {
|
|
84
110
|
// Shopify answered, with nothing. Could be a real empty store, could
|
|
85
111
|
// be a scope we lost — either way, do not replace a shelf that has
|
package/dist/catalog.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"catalog.js","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,EAAE;AACF,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,wEAAwE;AACxE,6EAA6E;AAC7E,6EAA6E;AAC7E,sCAAsC;AACtC,OAAO,EAKL,eAAe,EACf,oBAAoB,EACpB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAwB9C,2EAA2E;AAC3E,gFAAgF;AAChF,8EAA8E;AAC9E,iFAAiF;AACjF,qBAAqB;AACrB,MAAM,cAAc,GAAG,CAAC,GAAG,MAAM,CAAC;AAClC,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAE7B,iFAAiF;AACjF,yBAAyB;AACzB,EAAE;AACF,gFAAgF;AAChF,iFAAiF;AACjF,gFAAgF;AAChF,+EAA+E;AAC/E,8EAA8E;AAC9E,gFAAgF;AAChF,gFAAgF;AAChF,6BAA6B;AAC7B,EAAE;AACF,iFAAiF;AACjF,iFAAiF;AACjF,uDAAuD;AACvD,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAEnC,MAAM,OAAO,OAAO;IAcC;IACA;
|
|
1
|
+
{"version":3,"file":"catalog.js","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -60,6 +60,7 @@ import { MultiTokenStore, isStorableShop } from './multi-token-store.js';
|
|
|
60
60
|
import { exchangeCode, installUrl, isShopDomain, makeState, verifyCallbackHmac, verifyState, } from './oauth.js';
|
|
61
61
|
import { RateLimiter, clientIp } from './rate-limit.js';
|
|
62
62
|
import { explainNoTenant, multiShopResolver, singleShopResolver } from './tenant.js';
|
|
63
|
+
import { KEEPALIVE_INTERVAL_MS, keepAlive, refreshIfDue } from './token-refresh.js';
|
|
63
64
|
import { TokenStore } from './token-store.js';
|
|
64
65
|
import { buildOutputSchemas, buildTools, callTool } from './tools.js';
|
|
65
66
|
import { handleWebhook, verifyWebhookHmac } from './webhooks.js';
|
|
@@ -145,6 +146,12 @@ const oauth = {
|
|
|
145
146
|
shopDomain: SHOP_DOMAIN,
|
|
146
147
|
appUrl: APP_URL,
|
|
147
148
|
scopes: SCOPES,
|
|
149
|
+
// Only the listed public app. Shopify mandates expiring offline tokens for
|
|
150
|
+
// public apps created on or after 2026-04-01 and for every public app by
|
|
151
|
+
// 2027-01-01, and exempts custom apps — moody and elspet hold non-expiring
|
|
152
|
+
// tokens from August that still work, and asking for expiring ones would
|
|
153
|
+
// trade zero maintenance for an hourly refresh they gain nothing from.
|
|
154
|
+
expiring: STORE.config.shopify.mode === 'oauth-install',
|
|
148
155
|
};
|
|
149
156
|
const tokens = new TokenStore(TOKEN_PATH);
|
|
150
157
|
// Two buckets. The wide one keeps anyone from spending the merchant's Shopify
|
|
@@ -189,14 +196,36 @@ const lookupLimit = new RateLimiter(LOOKUP_RATE_PER_MIN, 60_000);
|
|
|
189
196
|
// arrives when the merchant installs — which may be days after this process
|
|
190
197
|
// started. Until then every tool answers "not connected yet" instead of the
|
|
191
198
|
// service being dead or, worse, looking like an empty store.
|
|
199
|
+
//
|
|
200
|
+
// ⚠️ AND IT IS ALSO WHERE THE TOKEN IS KEPT ALIVE. Every Shopify call in this
|
|
201
|
+
// service goes through a resolver like this one, which makes it the single
|
|
202
|
+
// place that can promise "the token you are about to use is valid". Renewing
|
|
203
|
+
// here beats renewing after a 401: nothing fails first. `refreshIfDue` is a
|
|
204
|
+
// no-op for a token with no `expiresAt`, so the branded deployments never touch
|
|
205
|
+
// the network for this.
|
|
192
206
|
const shopifyConfig = async () => {
|
|
193
207
|
const envToken = process.env.STOREFRONT_SHOPIFY_ADMIN_TOKEN;
|
|
194
208
|
if (envToken)
|
|
195
209
|
return { shopDomain: SHOP_DOMAIN, adminToken: envToken };
|
|
196
210
|
const stored = await tokens.get();
|
|
197
|
-
|
|
211
|
+
if (!stored)
|
|
212
|
+
return null;
|
|
213
|
+
const { token } = await refreshIfDue(stored, oauth, (t) => tokens.set(t), {
|
|
214
|
+
onError: (err) => console.error(`[token] refresh failed for ${SHOP_DOMAIN}: ${err}`),
|
|
215
|
+
});
|
|
216
|
+
return { shopDomain: SHOP_DOMAIN, adminToken: token.accessToken };
|
|
198
217
|
};
|
|
199
|
-
|
|
218
|
+
// Forcing a renewal when the Admin API says 401 despite a token we believed
|
|
219
|
+
// was fresh. Expiring the record is enough: the resolver renews anything past
|
|
220
|
+
// its deadline, and this is the one place that can say "whatever the clock
|
|
221
|
+
// thinks, this token is dead".
|
|
222
|
+
const forceRefresh = async () => {
|
|
223
|
+
const stored = await tokens.get();
|
|
224
|
+
if (!stored?.refreshToken)
|
|
225
|
+
return;
|
|
226
|
+
await refreshIfDue({ ...stored, expiresAt: new Date(0).toISOString() }, oauth, (t) => tokens.set(t));
|
|
227
|
+
};
|
|
228
|
+
const catalog = new Catalog(shopifyConfig, (m) => console.log(`[catalog] ${m}`), forceRefresh);
|
|
200
229
|
// ── Multi-tenant, for the listed app ────────────────────────────────────────
|
|
201
230
|
//
|
|
202
231
|
// Built in BOTH modes, and deliberately so. If these only existed under
|
|
@@ -207,7 +236,15 @@ const catalog = new Catalog(shopifyConfig);
|
|
|
207
236
|
const multiTokens = new MultiTokenStore(`${dirname(TOKEN_PATH)}/shops`);
|
|
208
237
|
const registry = new CatalogRegistry(async (shop) => {
|
|
209
238
|
const stored = await multiTokens.get(shop);
|
|
210
|
-
|
|
239
|
+
if (!stored)
|
|
240
|
+
return null;
|
|
241
|
+
// The listed app is the one that HAS expiring tokens, so this is the
|
|
242
|
+
// resolver that actually does work — one refresh per shop, at most once
|
|
243
|
+
// every 55 minutes, and only for shops someone is using.
|
|
244
|
+
const { token } = await refreshIfDue(stored, oauth, (t) => multiTokens.set(t), {
|
|
245
|
+
onError: (err) => console.error(`[token] refresh failed for ${shop}: ${err}`),
|
|
246
|
+
});
|
|
247
|
+
return { shopDomain: shop, adminToken: token.accessToken };
|
|
211
248
|
}, { refreshMs: REFRESH_MS });
|
|
212
249
|
// The merchant's artwork, read once at boot from beside their config.
|
|
213
250
|
//
|
|
@@ -287,6 +324,10 @@ const LEGAL = {
|
|
|
287
324
|
noun: STORE.vocab.noun,
|
|
288
325
|
nounPlural: STORE.vocab.nounPlural,
|
|
289
326
|
merchantName: MERCHANT_NAME,
|
|
327
|
+
// Tenant-less: the pages must not name a merchant that does not exist. See
|
|
328
|
+
// the ⚠️ on LegalContext.multiTenant — falling back put OUR product name in
|
|
329
|
+
// the shop's place and linked the shop's policy at a 404 on our own domain.
|
|
330
|
+
multiTenant: STORE.config.shopify.mode === 'oauth-install',
|
|
290
331
|
storefront: STORE.config.server.websiteUrl ?? PUBLIC_URL,
|
|
291
332
|
// Curless publishes, on the merchant's behalf — decided 2026-08-05. Which
|
|
292
333
|
// makes us the party a directory reviewer writes to, and the party a shopper
|
|
@@ -639,7 +680,7 @@ app.get('/auth/callback', async (c) => {
|
|
|
639
680
|
if (!code)
|
|
640
681
|
return c.text('missing code', 400);
|
|
641
682
|
try {
|
|
642
|
-
const { accessToken, scope } = await exchangeCode(oauth, code);
|
|
683
|
+
const { accessToken, scope, refreshToken, expiresAt } = await exchangeCode(oauth, code);
|
|
643
684
|
// Per-shop in the listed app, single-file in a branded one. The record is
|
|
644
685
|
// the same; where it lands is the whole difference between serving one
|
|
645
686
|
// merchant and serving many.
|
|
@@ -648,6 +689,11 @@ app.get('/auth/callback', async (c) => {
|
|
|
648
689
|
accessToken,
|
|
649
690
|
scope,
|
|
650
691
|
installedAt: new Date().toISOString(),
|
|
692
|
+
// Both or neither — a non-expiring token answers with neither, and an
|
|
693
|
+
// invented deadline would send the refresher chasing a token that has no
|
|
694
|
+
// renewal path.
|
|
695
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
696
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
651
697
|
};
|
|
652
698
|
if (MULTI)
|
|
653
699
|
await multiTokens.set(record);
|
|
@@ -990,6 +1036,33 @@ app.on('POST', MCP_PATHS, async (c) => {
|
|
|
990
1036
|
// which reads as "wrong URL" while debugging a connector.
|
|
991
1037
|
app.get('/mcp', (c) => c.json({ name: STORE.config.server.name, transport: 'streamable-http' }));
|
|
992
1038
|
catalog.start(REFRESH_MS);
|
|
1039
|
+
// ⚠️ ONLY WHERE TOKENS EXPIRE, and paced to the 90-DAY refresh window rather
|
|
1040
|
+
// than the one-hour access-token one — the resolver already renews at the point
|
|
1041
|
+
// of use, so a shop with any traffic never needs this. What it buys is the
|
|
1042
|
+
// quiet shop: nobody calls it, nothing renews its token, and 90 days later its
|
|
1043
|
+
// refresh token lapses and the merchant has to install the app again. One call
|
|
1044
|
+
// per shop per day rolls that window forward for ever.
|
|
1045
|
+
if (oauth.expiring) {
|
|
1046
|
+
const sweep = async () => {
|
|
1047
|
+
try {
|
|
1048
|
+
const n = MULTI
|
|
1049
|
+
? await keepAlive(() => multiTokens.list(), oauth, (t) => multiTokens.set(t))
|
|
1050
|
+
: await keepAlive(async () => {
|
|
1051
|
+
const t = await tokens.get();
|
|
1052
|
+
return t ? [t] : [];
|
|
1053
|
+
}, oauth, (t) => tokens.set(t));
|
|
1054
|
+
if (n)
|
|
1055
|
+
console.log(`[token] keepalive renewed ${n} install(s)`);
|
|
1056
|
+
}
|
|
1057
|
+
catch (err) {
|
|
1058
|
+
// Never let a sweep take the process down: the tokens it failed to renew
|
|
1059
|
+
// are still valid, and the next sweep gets another turn.
|
|
1060
|
+
console.error(`[token] keepalive sweep failed: ${err}`);
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
void sweep();
|
|
1064
|
+
setInterval(sweep, KEEPALIVE_INTERVAL_MS).unref();
|
|
1065
|
+
}
|
|
993
1066
|
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
|
994
1067
|
console.log(`${STORE.config.id}: listening on :${info.port} (shop ${SHOP_DOMAIN}, refresh ${REFRESH_MS}ms)`);
|
|
995
1068
|
});
|