@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.
@@ -0,0 +1,122 @@
1
+ // Keeping an expiring offline access token alive.
2
+ //
3
+ // ⚠️ WHY THIS EXISTS. Shopify requires expiring offline tokens for public apps
4
+ // created on or after 2026-04-01, and for every public app by 2027-01-01. They
5
+ // last ONE HOUR; the refresh token that renews them lasts 90 days. Custom
6
+ // distributions are exempt — moody and elspet hold non-expiring tokens from
7
+ // August that still work — so everything here is a no-op for them, by shape
8
+ // rather than by flag: a record with no `expiresAt` is never refreshed.
9
+ //
10
+ // The reviewer's first act on a listed app is to install it. Without this, the
11
+ // connector works for an hour and then answers 401 for ever, and the symptom
12
+ // reads as "it worked yesterday".
13
+ import { refreshAccessToken } from './oauth.js';
14
+ /**
15
+ * Refresh this far before the token actually dies.
16
+ *
17
+ * Five minutes, not five seconds: a refresh is a network call that can fail and
18
+ * be retried, and a token that expires mid-flight fails a request a shopper is
19
+ * waiting on. It is also comfortably longer than the periodic sweep below, so
20
+ * the sweep always gets a turn before the just-in-time path has to.
21
+ */
22
+ export const REFRESH_SKEW_MS = 5 * 60_000;
23
+ /**
24
+ * Does this record need a new access token?
25
+ *
26
+ * ⚠️ NO `expiresAt` MEANS NEVER, NOT UNKNOWN. That is the whole compatibility
27
+ * story with the two installs that predate this file: treating a missing field
28
+ * as "expired" would refresh a token that has no refresh token to refresh it
29
+ * with, fail, and take a working merchant offline.
30
+ */
31
+ export const needsRefresh = (token, now = new Date(), skewMs = REFRESH_SKEW_MS) => {
32
+ if (!token.expiresAt)
33
+ return false;
34
+ const at = Date.parse(token.expiresAt);
35
+ // An unparseable timestamp is a corrupt record, not a fresh token. Refresh it
36
+ // — the worst case is one wasted call, and the alternative is serving a
37
+ // token we cannot reason about until it 401s.
38
+ if (Number.isNaN(at))
39
+ return true;
40
+ return at - now.getTime() <= skewMs;
41
+ };
42
+ /**
43
+ * Renew if due, persist, and answer with whatever the caller should now use.
44
+ *
45
+ * ⚠️ NEVER THROWS ON A FAILED REFRESH — it returns the old token instead. A
46
+ * refresh can fail because Shopify is briefly down, and an exception here would
47
+ * turn a recoverable minute into a hard outage for a token that is still valid
48
+ * for another five. The 401 path is what handles a token that really is dead.
49
+ */
50
+ export const refreshIfDue = async (token, oauth, save, opts = {}) => {
51
+ const now = opts.now ?? new Date();
52
+ if (!needsRefresh(token, now, opts.skewMs))
53
+ return { refreshed: false, token, reason: 'not-due' };
54
+ if (!token.refreshToken) {
55
+ // Due, but nothing to renew with. That is a record Shopify issued as
56
+ // expiring while we stored only half of it — worth saying out loud rather
57
+ // than looping on it.
58
+ opts.onError?.(new Error(`${token.shopDomain}: token is expiring but no refresh_token stored`));
59
+ return { refreshed: false, token, reason: 'no-refresh-token' };
60
+ }
61
+ try {
62
+ const next = await refreshAccessToken({ ...oauth, shopDomain: token.shopDomain }, token.refreshToken, opts.fetchImpl, now);
63
+ const record = {
64
+ ...token,
65
+ accessToken: next.accessToken,
66
+ // ⚠️ Keep the granted scope if the refresh answer omits it — a refresh
67
+ // renews a token, it does not re-grant permissions, and overwriting the
68
+ // record's scope with '' would make the install look unscoped.
69
+ scope: next.scope || token.scope,
70
+ // ⚠️ STORE THE NEW REFRESH TOKEN. Shopify keeps the old one working only
71
+ // until the replacement is first used; holding on to it means the next
72
+ // refresh is the one that fails.
73
+ ...(next.refreshToken ? { refreshToken: next.refreshToken } : {}),
74
+ ...(next.expiresAt ? { expiresAt: next.expiresAt } : {}),
75
+ };
76
+ await save(record);
77
+ return { refreshed: true, token: record };
78
+ }
79
+ catch (err) {
80
+ opts.onError?.(err);
81
+ return { refreshed: false, token, reason: 'not-due' };
82
+ }
83
+ };
84
+ /**
85
+ * Keep every install's REFRESH token from ageing out.
86
+ *
87
+ * ⚠️ THIS IS NOT WHAT KEEPS THE ACCESS TOKEN ALIVE — the resolver does that at
88
+ * the point of use, so a shop in traffic never needs this. The one thing the
89
+ * resolver cannot fix is a shop NOBODY TOUCHES: its access token expires in an
90
+ * hour and nothing asks for a new one, and 90 days later the refresh token
91
+ * expires too and the merchant has to install the app again. A shop that quiet
92
+ * is the normal state of a listed app, not an edge case.
93
+ *
94
+ * ⚠️ SO THE INTERVAL IS PACED TO THE 90-DAY WINDOW, NOT THE ONE-HOUR ONE. Each
95
+ * refresh returns a NEW refresh token with a fresh 90 days, so rolling every
96
+ * install forward once a day keeps them all alive indefinitely — at one call
97
+ * per shop per day. Sweeping hourly would be 24x the calls to buy nothing: the
98
+ * hourly problem is already solved where it actually shows up.
99
+ */
100
+ export const KEEPALIVE_INTERVAL_MS = 24 * 60 * 60_000;
101
+ /**
102
+ * Roll every stored token forward. Returns how many were renewed.
103
+ *
104
+ * Uses a skew wide enough to catch tokens that are merely OLD rather than
105
+ * nearly-dead — the point is to touch each install once a day, not to wait
106
+ * until it is about to break.
107
+ */
108
+ export const keepAlive = async (load, oauth, save, opts = {}) => {
109
+ let renewed = 0;
110
+ for (const token of await load()) {
111
+ // Skew = the whole interval, so any token issued before the last sweep is
112
+ // due. Non-expiring records still fall out on the `expiresAt` check inside.
113
+ const res = await refreshIfDue(token, oauth, save, {
114
+ ...opts,
115
+ skewMs: Number.POSITIVE_INFINITY,
116
+ });
117
+ if (res.refreshed)
118
+ renewed += 1;
119
+ }
120
+ return renewed;
121
+ };
122
+ //# sourceMappingURL=token-refresh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-refresh.js","sourceRoot":"","sources":["../src/token-refresh.ts"],"names":[],"mappings":"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"}
@@ -4,6 +4,16 @@ export type StoredToken = {
4
4
  /** The scopes Shopify actually granted — may differ from what we asked for. */
5
5
  scope: string;
6
6
  installedAt: string;
7
+ /**
8
+ * Set only for an EXPIRING offline token. Both fields are OPTIONAL and must
9
+ * stay that way: moody's and elspet's files were written in August with
10
+ * neither, their tokens do not expire, and a required field would read those
11
+ * installs as broken on the next boot.
12
+ *
13
+ * Absent `expiresAt` therefore means "never expires", not "expiry unknown".
14
+ */
15
+ refreshToken?: string;
16
+ expiresAt?: string;
7
17
  };
8
18
  export declare class TokenStore {
9
19
  private readonly path;
@@ -1 +1 @@
1
- {"version":3,"file":"token-store.d.ts","sourceRoot":"","sources":["../src/token-store.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,WAAW,GAAG;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,qBAAa,UAAU;IAIT,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHjC,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,MAAM,CAAS;gBAEM,IAAI,EAAE,MAAM;IAEnC,GAAG,IAAI,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAelC,GAAG,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;CAU7C"}
1
+ {"version":3,"file":"token-store.d.ts","sourceRoot":"","sources":["../src/token-store.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,WAAW,GAAG;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,qBAAa,UAAU;IAIT,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHjC,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,MAAM,CAAS;gBAEM,IAAI,EAAE,MAAM;IAEnC,GAAG,IAAI,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAelC,GAAG,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;CAU7C"}
@@ -1 +1 @@
1
- {"version":3,"file":"token-store.js","sourceRoot":"","sources":["../src/token-store.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,8EAA8E;AAC9E,+CAA+C;AAC/C,EAAE;AACF,2EAA2E;AAC3E,0EAA0E;AAC1E,gEAAgE;AAChE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAUpC,MAAM,OAAO,UAAU;IAIQ;IAHrB,MAAM,GAAuB,IAAI,CAAC;IAClC,MAAM,GAAG,KAAK,CAAC;IAEvB,YAA6B,IAAY;QAAZ,SAAI,GAAJ,IAAI,CAAQ;IAAG,CAAC;IAE7C,KAAK,CAAC,GAAG;QACP,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC,MAAM,CAAC;QACpC,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAgB,CAAC;YAC9C,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;QACnD,CAAC;QAAC,MAAM,CAAC;YACP,yEAAyE;YACzE,iEAAiE;YACjE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACrB,CAAC;QACD,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,OAAO,IAAI,CAAC,MAAM,CAAC;IACrB,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,KAAkB;QAC1B,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACrD,4EAA4E;QAC5E,0CAA0C;QAC1C,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,IAAI,MAAM,CAAC;QAC/B,MAAM,SAAS,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACtE,MAAM,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;QACpB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;IACrB,CAAC;CACF"}
1
+ {"version":3,"file":"token-store.js","sourceRoot":"","sources":["../src/token-store.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,8EAA8E;AAC9E,+CAA+C;AAC/C,EAAE;AACF,2EAA2E;AAC3E,0EAA0E;AAC1E,gEAAgE;AAChE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAoBpC,MAAM,OAAO,UAAU;IAIQ;IAHrB,MAAM,GAAuB,IAAI,CAAC;IAClC,MAAM,GAAG,KAAK,CAAC;IAEvB,YAA6B,IAAY;QAAZ,SAAI,GAAJ,IAAI,CAAQ;IAAG,CAAC;IAE7C,KAAK,CAAC,GAAG;QACP,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC,MAAM,CAAC;QACpC,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAgB,CAAC;YAC9C,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;QACnD,CAAC;QAAC,MAAM,CAAC;YACP,yEAAyE;YACzE,iEAAiE;YACjE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACrB,CAAC;QACD,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,OAAO,IAAI,CAAC,MAAM,CAAC;IACrB,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,KAAkB;QAC1B,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACrD,4EAA4E;QAC5E,0CAA0C;QAC1C,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,IAAI,MAAM,CAAC;QAC/B,MAAM,SAAS,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACtE,MAAM,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;QACpB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;IACrB,CAAC;CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curless/shopify-storefront",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "One Shopify storefront MCP connector, driven by a config file. Identity, tool names, vocabulary and shop wiring come from a StorefrontConfig, so a second merchant is a JSON file rather than a fork of 3,400 lines.",
6
6
  "keywords": [