@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.
@@ -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":"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"}
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,QAAQ,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAErF,MAAM,GAAG,GAAiB;IACxB,YAAY,EAAE,cAAc;IAC5B,UAAU,EAAE,yBAAyB;IACrC,QAAQ,EAAE,SAAS;IACnB,eAAe,EAAE,gBAAgB;IACjC,OAAO,EAAE,gBAAgB;IACzB,UAAU,EAAE,yBAAyB;IACrC,MAAM,EAAE,WAAW;IACnB,eAAe,EAAE,yBAAyB;IAC1C,WAAW,EAAE,EAAE;IACf,UAAU,EAAE,EAAE;IACd,KAAK,EAAE;QACL,IAAI,EAAE,mBAAmB;QACzB,GAAG,EAAE,gBAAgB;QACrB,QAAQ,EAAE,yBAAyB;QACnC,WAAW,EAAE,oBAAoB;KAClC;IACD,IAAI,EAAE,MAAM;IACZ,UAAU,EAAE,QAAQ;CACrB,CAAC;AAEF,yEAAyE;AACzE,+EAA+E;AAC/E,4EAA4E;AAC5E,MAAM,WAAW,GAAG,IAAI,CACtB,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EACvC,8CAA8C,CAC/C,CAAC;AAEF,4EAA4E;AAC5E,MAAM,kBAAkB,GAAG,GAAa,EAAE;IACxC,MAAM,GAAG,GAAG,YAAY,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;IAC9C,MAAM,KAAK,GAAG,sEAAsE,CAAC,IAAI,CACvF,GAAG,CACJ,EAAE,CAAC,CAAC,CAAC,CAAC;IACP,MAAM,KAAK,GAAG,iDAAiD,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC/E,MAAM,KAAK,GAAG,CAAC,CAAqB,EAAE,EAAE,CACtC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAW,CAAC,CAAC;IAC3E,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC;AACvF,CAAC,CAAC;AAEF,QAAQ,CAAC,cAAc,EAAE,GAAG,EAAE;IAC5B,IAAI,CAAC,8CAA8C,EAAE,GAAG,EAAE;QACxD,MAAM,IAAI,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;QAC9B,KAAK,MAAM,CAAC,KAAK,CAAC,IAAI,YAAY,EAAE,CAAC;YACnC,MAAM,CAAC,IAAI,EAAE,gCAAgC,KAAK,GAAG,CAAC,CAAC,SAAS,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC;QAC5F,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,4EAA4E;IAC5E,yEAAyE;IACzE,IAAI,CAAC,uDAAuD,EAAE,GAAG,EAAE;QACjE,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QACvD,MAAM,MAAM,GAAG,kBAAkB,EAAE,CAAC;QACpC,MAAM,CACJ,MAAM,CAAC,MAAM,EACb,0DAA0D,CAC3D,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;QACrB,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;YACvB,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,oDAAoD,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC/F,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,8EAA8E;IAC9E,+EAA+E;IAC/E,eAAe;IACf,IAAI,CAAC,6CAA6C,EAAE,GAAG,EAAE;QACvD,MAAM,CAAC,kBAAkB,EAAE,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QACpD,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;IACvD,CAAC,CAAC,CAAC;IAEH,oEAAoE;IACpE,8EAA8E;IAC9E,4EAA4E;IAC5E,4EAA4E;IAC5E,yBAAyB;IACzB,IAAI,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACtD,MAAM,SAAS,GAAG,WAAW,CAAC,EAAE,GAAG,GAAG,EAAE,eAAe,EAAE,EAAE,EAAE,CAAC,CAAC;QAC/D,MAAM,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;QAC5C,MAAM,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;QAC5C,qDAAqD;QACrD,MAAM,CAAC,SAAS,CAAC,CAAC,SAAS,CAAC,yBAAyB,CAAC,CAAC;IACzD,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,oCAAoC,EAAE,GAAG,EAAE;QAC9C,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,gCAAgC,CAAC,CAAC;IACvE,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,2DAA2D,EAAE,GAAG,EAAE;QACrE,MAAM,IAAI,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;QAC9B,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,2CAA2C,CAAC,CAAC;QACpE,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,iDAAiD,CAAC,CAAC;QAC1E,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,yBAAyB,CAAC,CAAC;QAClD,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,oEAAoE;AACpE,EAAE;AACF,8EAA8E;AAC9E,8EAA8E;AAC9E,0EAA0E;AAC1E,yEAAyE;AACzE,8EAA8E;AAC9E,4EAA4E;AAC5E,sBAAsB;AACtB,QAAQ,CAAC,wCAAwC,EAAE,GAAG,EAAE;IACtD,uEAAuE;IACvE,wEAAwE;IACxE,sEAAsE;IACtE,8EAA8E;IAC9E,6EAA6E;IAC7E,sEAAsE;IACtE,4BAA4B;IAC5B,MAAM,KAAK,GAAG;QACZ,GAAG,GAAG;QACN,WAAW,EAAE,IAAI;QACjB,YAAY,EAAE,oBAAoB;QAClC,UAAU,EAAE,gCAAgC;KAC7C,CAAC;IAEF,IAAI,CAAC,6CAA6C,EAAE,GAAG,EAAE;QACvD,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QAChC,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,iDAAiD,CAAC,CAAC;QAC9E,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,uCAAuC,CAAC,CAAC;IAClE,CAAC,CAAC,CAAC;IAEH,uEAAuE;IACvE,IAAI,CAAC,6CAA6C,EAAE,GAAG,EAAE;QACvD,KAAK,MAAM,IAAI,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;YACzD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,mDAAmD,CAAC,IAAI,EAAE,CAAC;YACnF,MAAM,CAAC,IAAI,EAAE,oCAAoC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QACtF,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,6EAA6E;IAC7E,+DAA+D;IAC/D,IAAI,CAAC,8DAA8D,EAAE,GAAG,EAAE;QACxE,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,0BAA0B,CAAC,CAAC;IACjE,CAAC,CAAC,CAAC;IAEH,6EAA6E;IAC7E,6EAA6E;IAC7E,uCAAuC;IACvC,IAAI,CAAC,iDAAiD,EAAE,GAAG,EAAE;QAC3D,MAAM,CAAC,WAAW,CAAC,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC;QAC7E,MAAM,CAAC,WAAW,CAAC,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,mBAAmB,EAAE,CAAC,CAAC,CAAC,SAAS,CACvE,gCAAgC,CACjC,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=token-refresh.test.d.ts.map
@@ -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[];
@@ -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;IAdtB,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;IAGpF,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;IAmE9B,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"}
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
@@ -1 +1 @@
1
- {"version":3,"file":"catalog.js","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"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"}
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;IAeA;IA7BX,QAAQ,GAAoB,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC;IAC5E,QAAQ,GAAyB,IAAI,CAAC;IAC9C,+EAA+E;IAC/E,mEAAmE;IAC3D,QAAQ,GAAG,IAAI,GAAG,EAAkD,CAAC;IAC7E,kFAAkF;IAC1E,QAAQ,GAAG,IAAI,WAAW,CAAC,uBAAuB,EAAE,MAAM,CAAC,CAAC;IAEpE,8EAA8E;IAC9E,2EAA2E;IAC3E,8EAA8E;IAC9E,YAAY;IACZ,YACmB,aAAkD,EAClD,MAA6B,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,EAAE,CAAC;IAClF;;;;;;;;;;;;;OAaG;IACc,cAAoC;QAhBpC,kBAAa,GAAb,aAAa,CAAqC;QAClD,QAAG,GAAH,GAAG,CAA8D;QAejE,mBAAc,GAAd,cAAc,CAAsB;IACpD,CAAC;IAEJ,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,8EAA8E;IAC9E,IAAI;QACF,OAAO,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAChC,CAAC;IAED,eAAe,CAAC,CAAS;QACvB,MAAM,IAAI,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;QACnD,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,OAAO,CACL,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAC/E,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAC5F,CAAC;IACJ,CAAC;IAED,8EAA8E;IAC9E,8EAA8E;IAC9E,aAAa;IACb,KAAK,CAAC,OAAO;QACX,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAC,QAAQ,CAAC;QACxC,IAAI,CAAC,QAAQ,GAAG,CAAC,KAAK,IAAI,EAAE;YAC1B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC;gBAC1C,IAAI,CAAC,MAAM,EAAE,CAAC;oBACZ,IAAI,CAAC,QAAQ,GAAG;wBACd,GAAG,IAAI,CAAC,QAAQ;wBAChB,SAAS,EAAE,4DAA4D;qBACxE,CAAC;oBACF,OAAO;gBACT,CAAC;gBACD,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,CAAS,EAAE,EAAE,CACvE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CACZ,CAAC,KAAK,CAAC,KAAK,EAAE,GAAY,EAAE,EAAE;oBAC7B,IAAI,CAAC,IAAI,CAAC,cAAc,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;wBAAE,MAAM,GAAG,CAAC;oBACrE,IAAI,CAAC,GAAG,CAAC,oEAAoE,CAAC,CAAC;oBAC/E,MAAM,IAAI,CAAC,cAAc,EAAE,CAAC;oBAC5B,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC;oBACzC,IAAI,CAAC,KAAK;wBAAE,MAAM,GAAG,CAAC;oBACtB,OAAO,oBAAoB,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAS,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;gBACxE,CAAC,CAAC,CAAC;gBACH,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBAC1B,qEAAqE;oBACrE,mEAAmE;oBACnE,sCAAsC;oBACtC,IAAI,CAAC,QAAQ,GAAG;wBACd,GAAG,IAAI,CAAC,QAAQ;wBAChB,SAAS,EAAE,8BAA8B;qBAC1C,CAAC;oBACF,IAAI,CAAC,GAAG,CAAC,6DAA6D,CAAC,CAAC;oBACxE,OAAO;gBACT,CAAC;gBACD,qEAAqE;gBACrE,mEAAmE;gBACnE,mEAAmE;gBACnE,wBAAwB;gBACxB,MAAM,KAAK,GAAG,MAAM,qBAAqB,CAAC,MAAM,CAAC,CAAC,KAAK,CACrD,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,CAAU,CAClE,CAAC;gBACF,IAAI,KAAK,CAAC,EAAE,EAAE,CAAC;oBACb,IAAI,CAAC,QAAQ,GAAG;wBACd,QAAQ;wBACR,QAAQ,EAAE,IAAI,IAAI,EAAE;wBACpB,SAAS,EAAE,eAAe,CAAC,KAAK,CAAC,SAAS,CAAC;qBAC5C,CAAC;oBACF,IAAI,KAAK,CAAC,SAAS,CAAC,MAAM;wBAAE,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,wBAAwB,CAAC,CAAC;gBAC1F,CAAC;qBAAM,CAAC;oBACN,IAAI,CAAC,QAAQ,GAAG;wBACd,QAAQ;wBACR,QAAQ,EAAE,IAAI,IAAI,EAAE;wBACpB,SAAS,EAAE,EAAE;wBACb,gBAAgB,EACd,KAAK,CAAC,MAAM,KAAK,cAAc;4BAC7B,CAAC,CAAC,yFAAyF;4BAC3F,CAAC,CAAC,KAAK,CAAC,MAAM;qBACnB,CAAC;oBACF,IAAI,CAAC,GAAG,CAAC,0BAA0B,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;gBACrD,CAAC;gBACD,yEAAyE;gBACzE,0CAA0C;gBAC1C,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC;gBACtB,IAAI,CAAC,GAAG,CAAC,UAAU,QAAQ,CAAC,MAAM,WAAW,CAAC,CAAC;YACjD,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,CAAC,QAAQ,GAAG;oBACd,GAAG,IAAI,CAAC,QAAQ;oBAChB,SAAS,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;iBAC5D,CAAC;gBACF,IAAI,CAAC,GAAG,CAAC,mBAAmB,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC,CAAC;YACzD,CAAC;oBAAS,CAAC;gBACT,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;YACvB,CAAC;QACH,CAAC,CAAC,EAAE,CAAC;QACL,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,6EAA6E;IAC7E,KAAK,CAAC,WAAW,CAAC,MAAc,EAAE,MAAc,IAAI,CAAC,GAAG,EAAE;QACxD,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACtC,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,CAAC,EAAE,GAAG,cAAc;YAAE,OAAO,GAAG,CAAC,IAAI,CAAC;QAC1D,4EAA4E;QAC5E,4EAA4E;QAC5E,2EAA2E;QAC3E,qEAAqE;QACrE,qCAAqC;QACrC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,EAAE,GAAG,CAAC,EAAE,CAAC;YACzC,IAAI,CAAC,GAAG,CAAC,qDAAqD,CAAC,CAAC;YAChE,OAAO,GAAG,EAAE,IAAI,IAAI,EAAE,CAAC;QACzB,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC;QAC1C,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,CAAC;QACvB,MAAM,IAAI,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAS,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3F,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC;QAC7C,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,GAAG,iBAAiB,EAAE,CAAC;YAC3C,MAAM,MAAM,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;iBACxC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;iBACjC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,iBAAiB,GAAG,CAAC,CAAC,CAAC,CAAC;YAC/C,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,MAAM;gBAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACpD,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAEO,KAAK,GAA0C,IAAI,CAAC;IAE5D,wFAAwF;IACxF,KAAK,CAAC,UAAkB;QACtB,KAAK,IAAI,CAAC,OAAO,EAAE,CAAC;QACpB,IAAI,CAAC,IAAI,EAAE,CAAC;QACZ,IAAI,CAAC,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC,OAAO,EAAE,EAAE,UAAU,CAAC,CAAC;QAChE,oDAAoD;QACpD,IAAI,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,KAAK,UAAU;YAAE,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;IACjE,CAAC;IAED;;;;;;;OAOG;IACH,IAAI;QACF,IAAI,IAAI,CAAC,KAAK;YAAE,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;IACpB,CAAC;CACF"}
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
- return stored ? { shopDomain: SHOP_DOMAIN, adminToken: stored.accessToken } : null;
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
- const catalog = new Catalog(shopifyConfig);
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
- return stored ? { shopDomain: shop, adminToken: stored.accessToken } : null;
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
  });