@drawbridge/drawbridge-utils 0.0.175 → 0.0.177
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/admin-B9ZaLvan.d.cts +697 -0
- package/dist/admin-C3HtEM6h.d.ts +697 -0
- package/dist/billing-Bc4yo9XG.d.cts +175 -0
- package/dist/billing-mNsKflmQ.d.ts +175 -0
- package/dist/billing.d.cts +1 -1
- package/dist/billing.d.ts +1 -1
- package/dist/connections/index.cjs +5928 -1920
- package/dist/connections/index.d.cts +20 -9163
- package/dist/connections/index.d.ts +20 -9163
- package/dist/connections/index.js +5917 -1921
- package/dist/connections/oauth.cjs +4 -4
- package/dist/connections/oauth.d.cts +10 -8
- package/dist/connections/oauth.d.ts +10 -8
- package/dist/connections/oauth.js +4 -4
- package/dist/features.cjs +10260 -42
- package/dist/features.d.cts +55 -42
- package/dist/features.d.ts +55 -42
- package/dist/features.js +10254 -42
- package/dist/http.cjs +10 -1
- package/dist/http.d.cts +10 -1
- package/dist/http.d.ts +10 -1
- package/dist/http.js +10 -1
- package/dist/index-B546oDNo.d.ts +13809 -0
- package/dist/index-C59xHago.d.cts +13809 -0
- package/dist/oauth/index.d.cts +1 -1
- package/dist/oauth/index.d.ts +1 -1
- package/dist/oauth-BJDh0sdM.d.cts +527 -0
- package/dist/oauth-DveZMLHx.d.ts +527 -0
- package/dist/partner-BOZltuh2.d.ts +94 -0
- package/dist/partner-ed2OfW1J.d.cts +94 -0
- package/dist/plans.cjs +10256 -51
- package/dist/plans.d.cts +37 -10
- package/dist/plans.d.ts +37 -10
- package/dist/plans.js +10262 -51
- package/dist/pricing.cjs +10401 -209
- package/dist/pricing.d.cts +34 -17
- package/dist/pricing.d.ts +34 -17
- package/dist/pricing.js +10407 -209
- package/dist/providers.cjs +5433 -1755
- package/dist/providers.d.cts +13 -15
- package/dist/providers.d.ts +13 -15
- package/dist/providers.js +5436 -1752
- package/dist/sendgrid.cjs +10 -1
- package/dist/sendgrid.js +10 -1
- package/dist/shopify/admin.cjs +562 -0
- package/dist/shopify/admin.d.cts +3 -0
- package/dist/shopify/admin.d.ts +3 -0
- package/dist/shopify/admin.js +528 -0
- package/dist/shopify/billing.cjs +166 -0
- package/dist/shopify/billing.d.cts +3 -0
- package/dist/shopify/billing.d.ts +3 -0
- package/dist/shopify/billing.js +140 -0
- package/dist/shopify/constants.cjs +63 -0
- package/dist/shopify/constants.d.cts +58 -0
- package/dist/shopify/constants.d.ts +58 -0
- package/dist/shopify/constants.js +32 -0
- package/dist/shopify/oauth.cjs +509 -0
- package/dist/shopify/oauth.d.cts +7 -0
- package/dist/shopify/oauth.d.ts +7 -0
- package/dist/shopify/oauth.js +466 -0
- package/dist/shopify/partner.cjs +156 -0
- package/dist/shopify/partner.d.cts +3 -0
- package/dist/shopify/partner.d.ts +3 -0
- package/dist/shopify/partner.js +130 -0
- package/dist/shopify/storefront.cjs +611 -0
- package/dist/shopify/storefront.d.cts +3 -0
- package/dist/shopify/storefront.d.ts +3 -0
- package/dist/shopify/storefront.js +576 -0
- package/dist/storefront-C8FKOGeD.d.cts +659 -0
- package/dist/storefront-DJFGLqPl.d.ts +659 -0
- package/dist/twilio.cjs +10 -1
- package/dist/twilio.js +10 -1
- package/package.json +98 -68
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { encrypt, decrypt } from './encrypt.js';
|
|
2
|
+
import { a as adminFetch } from './admin-C3HtEM6h.js';
|
|
3
|
+
import { SHOPIFY_REQUIRED_SCOPES, SHOPIFY_ADMIN_API_VERSION, ACCESS_TOKEN_REFRESH_BUFFER_MS, REFRESH_TOKEN_LIFETIME_MS } from './shopify/constants.js';
|
|
4
|
+
|
|
5
|
+
// The app's client_id / client_secret live encrypted in the `provider`
|
|
6
|
+
// collection and are read at the point of use by the calling app — this
|
|
7
|
+
// package never reads them from the environment. Every credential is
|
|
8
|
+
// therefore a required parameter, and a missing one MUST throw here rather
|
|
9
|
+
// than fall through: Shopify answers a token grant carrying no client_id with
|
|
10
|
+
// an opaque OAuth error page, which reads as "the app is gone" rather than
|
|
11
|
+
// "the caller passed no credential" — a config error with nothing in the logs
|
|
12
|
+
// to say so.
|
|
13
|
+
|
|
14
|
+
const required = ( name, value ) => {
|
|
15
|
+
|
|
16
|
+
if( ! value ){
|
|
17
|
+
|
|
18
|
+
throw new Error( 'Shopify ' + name + ' is required' );
|
|
19
|
+
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Diff a store's granted scopes (the `scope` string Shopify returns from
|
|
26
|
+
// token exchange, persisted on the shop record's settings) against the
|
|
27
|
+
// canonical required list. Shopify omits implied grants — write_foo covers
|
|
28
|
+
// read_foo (same for the unauthenticated_ pair) — so a required read scope
|
|
29
|
+
// counts as satisfied when its write counterpart was granted. Returns the
|
|
30
|
+
// required scopes the store is missing; non-empty means the merchant needs
|
|
31
|
+
// to re-open the app in Shopify admin (managed install re-prompts there).
|
|
32
|
+
const missingScopes = ( granted ) => {
|
|
33
|
+
|
|
34
|
+
const grantedSet = new Set(
|
|
35
|
+
( Array.isArray( granted ) ? granted : String( granted || '' ).split( ',' ) )
|
|
36
|
+
.map( ( scope ) => scope.trim() )
|
|
37
|
+
.filter( Boolean )
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const satisfied = ( scope ) =>
|
|
41
|
+
grantedSet.has( scope ) || grantedSet.has( scope.replace( /(^|_)read_/, '$1write_' ) );
|
|
42
|
+
|
|
43
|
+
return SHOPIFY_REQUIRED_SCOPES.filter( ( scope ) => ! satisfied( scope ) );
|
|
44
|
+
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Bare-fetch admin endpoint health check used by refreshAdminToken to
|
|
48
|
+
// validate a freshly-rotated access token before persisting it. Throws on
|
|
49
|
+
// failure so refreshAdminToken aborts the connection update if the new
|
|
50
|
+
// token doesn't authenticate. A minimal GraphQL query rather than a REST
|
|
51
|
+
// resource fetch — the Admin REST API is legacy and App-Store review
|
|
52
|
+
// requires GraphQL-only.
|
|
53
|
+
|
|
54
|
+
const ping = async ({ fetcher, adminAccessToken, domain }) => {
|
|
55
|
+
|
|
56
|
+
const response = await fetch(
|
|
57
|
+
`https://${ domain }/admin/api/${ SHOPIFY_ADMIN_API_VERSION }/graphql.json`,
|
|
58
|
+
{
|
|
59
|
+
body : JSON.stringify({ query : '{ shop { name } }' }),
|
|
60
|
+
headers : {
|
|
61
|
+
'Content-Type' : 'application/json',
|
|
62
|
+
'X-Shopify-Access-Token' : adminAccessToken
|
|
63
|
+
},
|
|
64
|
+
method : 'POST'
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if( ! response.ok ){
|
|
69
|
+
|
|
70
|
+
const text = await response.text().catch( () => '' );
|
|
71
|
+
const error = new Error( response.status + ': ' + text );
|
|
72
|
+
|
|
73
|
+
error.status = response.status;
|
|
74
|
+
|
|
75
|
+
throw error;
|
|
76
|
+
|
|
77
|
+
}
|
|
78
|
+
// GraphQL can 200 with a top-level errors array (e.g. a token that
|
|
79
|
+
// authenticates but can't query) — treat that as a failed ping too.
|
|
80
|
+
const { errors } = await response.json().catch( () => ({}) );
|
|
81
|
+
|
|
82
|
+
if( errors?.length ){
|
|
83
|
+
|
|
84
|
+
throw new Error( 'GraphQL: ' + ( errors[ 0 ]?.message || 'unknown error' ) );
|
|
85
|
+
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Shopify's OAuth token endpoint reports a failed grant one of two ways: a
|
|
90
|
+
// JSON body ({ error, error_description }, the documented shape) or — for
|
|
91
|
+
// some rejections, e.g. a client_id whose app no longer exists at the shop —
|
|
92
|
+
// a rendered HTML error page whose <title> reads "NNN - Oauth error <code>".
|
|
93
|
+
// Extract a stable code from either so callers can act on it (see
|
|
94
|
+
// application_cannot_be_found in drawbridge-sync's health check) instead of
|
|
95
|
+
// string-matching the raw page body.
|
|
96
|
+
const OAUTH_ERROR_TITLE_PATTERN = /Oauth error (\w+)/i;
|
|
97
|
+
|
|
98
|
+
const parseOAuthErrorCode = ( text ) => {
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
|
|
102
|
+
const parsed = JSON.parse( text );
|
|
103
|
+
|
|
104
|
+
if( parsed?.error ) return parsed.error;
|
|
105
|
+
|
|
106
|
+
} catch {}
|
|
107
|
+
return text.match( OAUTH_ERROR_TITLE_PATTERN )?.[ 1 ] || null;
|
|
108
|
+
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const shopifyOAuthFetch = async ( url, body ) => {
|
|
112
|
+
|
|
113
|
+
const response = await fetch( url, {
|
|
114
|
+
method : 'POST',
|
|
115
|
+
headers : {
|
|
116
|
+
'Content-Type' : 'application/json'
|
|
117
|
+
},
|
|
118
|
+
body : JSON.stringify( body )
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if( ! response.ok ){
|
|
122
|
+
|
|
123
|
+
const text = await response.text().catch( () => '' );
|
|
124
|
+
const error = new Error( response.status + ': ' + text );
|
|
125
|
+
|
|
126
|
+
error.status = response.status;
|
|
127
|
+
error.code = parseOAuthErrorCode( text );
|
|
128
|
+
|
|
129
|
+
throw error;
|
|
130
|
+
|
|
131
|
+
}
|
|
132
|
+
return response.json();
|
|
133
|
+
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Install-time token-setup calls (shop lookup, storefront access tokens)
|
|
137
|
+
// go through the Admin GraphQL client in admin.js — the REST endpoints
|
|
138
|
+
// they used to hit are legacy and App-Store review requires GraphQL-only.
|
|
139
|
+
|
|
140
|
+
// Fetch the shop record (currency, id, name, myshopifyDomain) for a connected store.
|
|
141
|
+
// `id` is returned as the bare numeric string (the GID tail) — it persists
|
|
142
|
+
// to connection.source.id, and billing's toShopGid() re-derives the GID.
|
|
143
|
+
|
|
144
|
+
const getShop = async ({ fetcher, adminAccessToken, shop }) => {
|
|
145
|
+
|
|
146
|
+
const data = await adminFetch({
|
|
147
|
+
fetcher,
|
|
148
|
+
adminAccessToken,
|
|
149
|
+
domain : shop,
|
|
150
|
+
query : `{
|
|
151
|
+
shop {
|
|
152
|
+
currencyCode
|
|
153
|
+
id
|
|
154
|
+
myshopifyDomain
|
|
155
|
+
name
|
|
156
|
+
}
|
|
157
|
+
}`
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const shopData = data?.shop;
|
|
161
|
+
|
|
162
|
+
if( ! shopData?.id ){
|
|
163
|
+
|
|
164
|
+
throw new Error( 'Shopify shop lookup failed: no shop returned' );
|
|
165
|
+
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
currency : shopData.currencyCode || null,
|
|
169
|
+
id : shopData.id.split( '/' ).pop(),
|
|
170
|
+
myshopifyDomain : shopData.myshopifyDomain,
|
|
171
|
+
name : shopData.name
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Issue a Storefront access token titled 'Drawbridge', reaping any prior
|
|
177
|
+
// Drawbridge-titled tokens first — Shopify caps these at 100/shop and they're
|
|
178
|
+
// immutable, so a fresh OAuth scope set requires minting a new one.
|
|
179
|
+
|
|
180
|
+
const createStorefrontToken = async ({ fetcher, adminAccessToken, shop }) => {
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
|
|
184
|
+
const listed = await adminFetch({
|
|
185
|
+
fetcher,
|
|
186
|
+
adminAccessToken,
|
|
187
|
+
domain : shop,
|
|
188
|
+
query : `{
|
|
189
|
+
shop {
|
|
190
|
+
storefrontAccessTokens(first: 100) {
|
|
191
|
+
edges {
|
|
192
|
+
node {
|
|
193
|
+
id
|
|
194
|
+
title
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}`
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const existing = ( listed?.shop?.storefrontAccessTokens?.edges || [] ).map( ( edge ) => edge?.node );
|
|
203
|
+
const orphaned = existing.filter( ( token ) => token?.title === 'Drawbridge' );
|
|
204
|
+
|
|
205
|
+
await Promise.allSettled(
|
|
206
|
+
orphaned.map( ( token ) => adminFetch({
|
|
207
|
+
adminAccessToken,
|
|
208
|
+
domain : shop,
|
|
209
|
+
query : `
|
|
210
|
+
mutation StorefrontAccessTokenDelete($input: StorefrontAccessTokenDeleteInput!) {
|
|
211
|
+
storefrontAccessTokenDelete(input: $input) {
|
|
212
|
+
deletedStorefrontAccessTokenId
|
|
213
|
+
userErrors {
|
|
214
|
+
field
|
|
215
|
+
message
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
`,
|
|
220
|
+
variables : {
|
|
221
|
+
input : {
|
|
222
|
+
id : token.id
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}) )
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
} catch {}
|
|
229
|
+
|
|
230
|
+
const created = await adminFetch({
|
|
231
|
+
fetcher,
|
|
232
|
+
adminAccessToken,
|
|
233
|
+
domain : shop,
|
|
234
|
+
query : `
|
|
235
|
+
mutation StorefrontAccessTokenCreate($input: StorefrontAccessTokenInput!) {
|
|
236
|
+
storefrontAccessTokenCreate(input: $input) {
|
|
237
|
+
storefrontAccessToken {
|
|
238
|
+
accessToken
|
|
239
|
+
}
|
|
240
|
+
userErrors {
|
|
241
|
+
field
|
|
242
|
+
message
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
`,
|
|
247
|
+
variables : {
|
|
248
|
+
input : {
|
|
249
|
+
title : 'Drawbridge'
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const createErrors = created?.storefrontAccessTokenCreate?.userErrors;
|
|
255
|
+
|
|
256
|
+
if( createErrors?.length ){
|
|
257
|
+
|
|
258
|
+
throw new Error( 'Shopify storefront token create failed: ' + createErrors[ 0 ].message );
|
|
259
|
+
|
|
260
|
+
}
|
|
261
|
+
return created?.storefrontAccessTokenCreate?.storefrontAccessToken?.accessToken || null;
|
|
262
|
+
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// A Shopify connection carries its token blob one of two ways:
|
|
266
|
+
// * legacy 1:1 install — the token blob lives on `connection.settings` directly.
|
|
267
|
+
// * embedded / multi-org — `connection.settings` is a pointer `{ ref:'shop', shop }`
|
|
268
|
+
// and the token of record lives on the shared per-shop `shop` record: the offline
|
|
269
|
+
// session in `shop.settings` (accessToken/expires/refreshToken/refreshTokenExpires)
|
|
270
|
+
// and the long-lived storefront token in `shop.storefront` ({ token }). N org
|
|
271
|
+
// connections for the same store share that one record.
|
|
272
|
+
// resolveTokenContext normalizes both into a single settings blob the callers read
|
|
273
|
+
// (getAdminToken / product sync / storefront reads) and records where a token rotation
|
|
274
|
+
// must be written back (`target`). Following the pointer needs a `controller` to read
|
|
275
|
+
// the shop record; callers all pass one.
|
|
276
|
+
|
|
277
|
+
const resolveTokenContext = async ({ connection, controller }) => {
|
|
278
|
+
|
|
279
|
+
const decrypted = connection?.settings ? decrypt( connection.settings ) : {};
|
|
280
|
+
|
|
281
|
+
// Legacy: the token blob is the connection's own settings. `raw` carries the
|
|
282
|
+
// stored (encrypted) blob so a rotation can compare-and-swap against it.
|
|
283
|
+
if( decrypted?.ref !== 'shop' ){
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
raw : connection?.settings || null,
|
|
287
|
+
session : null,
|
|
288
|
+
settings : decrypted,
|
|
289
|
+
shop : null,
|
|
290
|
+
target : 'connection'
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
}
|
|
294
|
+
const shop = decrypted.shop;
|
|
295
|
+
|
|
296
|
+
// Pointer with no way (or nothing) to resolve — surface an empty blob so callers
|
|
297
|
+
// treat it as "no token" rather than throwing.
|
|
298
|
+
if( ! controller || ! shop ){
|
|
299
|
+
|
|
300
|
+
return { raw : null, session : {}, settings : {}, shop, target : 'shop' };
|
|
301
|
+
|
|
302
|
+
}
|
|
303
|
+
const record = await controller.get({ collection : 'shop', query : { shop } });
|
|
304
|
+
const session = record?.settings ? decrypt( record.settings ) : {};
|
|
305
|
+
const storefront = record?.storefront ? decrypt( record.storefront ) : null;
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
raw : record?.settings || null,
|
|
309
|
+
session,
|
|
310
|
+
settings : {
|
|
311
|
+
adminAccessToken : session.accessToken || null,
|
|
312
|
+
refreshToken : session.refreshToken || null,
|
|
313
|
+
refreshTokenExpiresAt : session.refreshTokenExpires || null,
|
|
314
|
+
storefrontAccessToken : storefront?.token || null,
|
|
315
|
+
tokenExpiresAt : session.expires || null
|
|
316
|
+
},
|
|
317
|
+
shop,
|
|
318
|
+
target : 'shop'
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Returns the resolved token blob for a connection (admin + storefront tokens and
|
|
324
|
+
// their expiries), following the shared-per-shop pointer when present. Backward
|
|
325
|
+
// compatible: a legacy connection's settings pass straight through.
|
|
326
|
+
|
|
327
|
+
const resolveConnectionSettings = async ({ fetcher, connection, controller }) => {
|
|
328
|
+
|
|
329
|
+
const { settings } = await resolveTokenContext({ connection, controller });
|
|
330
|
+
|
|
331
|
+
return settings;
|
|
332
|
+
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Rotates an admin access token using the stored refresh_token grant.
|
|
336
|
+
// Shopify expiring-token installs return a fresh access_token + a fresh
|
|
337
|
+
// refresh_token each refresh — and rotation RETIRES the old pair at Shopify
|
|
338
|
+
// the moment the grant returns. Ordering and concurrency both follow from
|
|
339
|
+
// that:
|
|
340
|
+
// * PERSIST BEFORE PING — the old pair is already dead once the grant
|
|
341
|
+
// returns, so failing to persist (e.g. a transient ping blip) would
|
|
342
|
+
// strand the only working refresh token forever. Persist first; ping
|
|
343
|
+
// after, purely as a visible health signal (a throw here fails the job,
|
|
344
|
+
// which retries against the persisted, valid pair).
|
|
345
|
+
// * COMPARE-AND-SWAP on the stored blob — the shared shop record has many
|
|
346
|
+
// writers (every org's health check, eager getAdminToken rotation, the
|
|
347
|
+
// embedded app's session writes). The write-back is keyed on the exact
|
|
348
|
+
// blob that was read; on a miss we converge on DB truth: if another
|
|
349
|
+
// writer rotated first, THEIR pair is the live one — return it and don't
|
|
350
|
+
// overwrite.
|
|
351
|
+
|
|
352
|
+
const refreshAdminToken = async ({ fetcher, clientId, clientSecret, connection, controller }) => {
|
|
353
|
+
|
|
354
|
+
const { raw, session, settings, shop, target } = await resolveTokenContext({ connection, controller });
|
|
355
|
+
const domain = connection?.shop || settings.source?.domain || settings.domain || shop;
|
|
356
|
+
const { refreshToken } = settings;
|
|
357
|
+
|
|
358
|
+
const data = await shopifyOAuthFetch(
|
|
359
|
+
`https://${ domain }/admin/oauth/access_token`,
|
|
360
|
+
{
|
|
361
|
+
grant_type : 'refresh_token',
|
|
362
|
+
// Throw before the grant: rotation RETIRES the old pair the moment it
|
|
363
|
+
// returns, so a credential-less call that Shopify rejects opaquely is
|
|
364
|
+
// the one failure mode worth being loud about.
|
|
365
|
+
client_id : required( 'clientId', clientId ),
|
|
366
|
+
client_secret : required( 'clientSecret', clientSecret ),
|
|
367
|
+
refresh_token : refreshToken
|
|
368
|
+
}
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const adminAccessToken = data.access_token;
|
|
372
|
+
const newRefreshToken = data.refresh_token;
|
|
373
|
+
const expiresIn = data.expires_in;
|
|
374
|
+
const tokenExpiresAt = expiresIn ? new Date( Date.now() + ( expiresIn * 1000 ) ) : null;
|
|
375
|
+
const refreshTokenExpiresAt = new Date( Date.now() + REFRESH_TOKEN_LIFETIME_MS );
|
|
376
|
+
|
|
377
|
+
let liveToken = adminAccessToken;
|
|
378
|
+
|
|
379
|
+
if( target === 'shop' ){
|
|
380
|
+
|
|
381
|
+
// Token of record is the shared `shop` record's offline session blob. Rotate
|
|
382
|
+
// in place, preserving the non-token session fields (id/state/scope/isOnline).
|
|
383
|
+
// The storefront token lives on a separate `shop.storefront` field, untouched.
|
|
384
|
+
const written = await controller.update({
|
|
385
|
+
collection : 'shop',
|
|
386
|
+
data : {
|
|
387
|
+
$set : {
|
|
388
|
+
settings : encrypt({
|
|
389
|
+
...session,
|
|
390
|
+
accessToken : adminAccessToken,
|
|
391
|
+
expires : tokenExpiresAt,
|
|
392
|
+
refreshToken : newRefreshToken,
|
|
393
|
+
refreshTokenExpires : refreshTokenExpiresAt
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
query : {
|
|
398
|
+
shop,
|
|
399
|
+
...( raw && { settings : raw } )
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
if( ! written ){
|
|
404
|
+
|
|
405
|
+
const current = await controller.get({ collection : 'shop', query : { shop } });
|
|
406
|
+
|
|
407
|
+
if( ! current?.settings ){
|
|
408
|
+
|
|
409
|
+
throw new Error( 'Shopify token refresh write-back failed: shop record missing for ' + shop );
|
|
410
|
+
|
|
411
|
+
}
|
|
412
|
+
const currentSession = decrypt( current.settings );
|
|
413
|
+
|
|
414
|
+
// A different live token means another writer's rotation landed (or ours
|
|
415
|
+
// did, under a controller that doesn't return the doc) — that pair is the
|
|
416
|
+
// token of record now.
|
|
417
|
+
if( currentSession?.accessToken && currentSession.accessToken !== settings.adminAccessToken ){
|
|
418
|
+
|
|
419
|
+
liveToken = currentSession.accessToken;
|
|
420
|
+
|
|
421
|
+
} else {
|
|
422
|
+
|
|
423
|
+
throw new Error( 'Shopify token refresh write-back failed for ' + shop );
|
|
424
|
+
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
|
|
429
|
+
const written = await controller.update({
|
|
430
|
+
collection : 'connection',
|
|
431
|
+
data : {
|
|
432
|
+
$set : {
|
|
433
|
+
settings : encrypt({
|
|
434
|
+
...settings,
|
|
435
|
+
adminAccessToken,
|
|
436
|
+
refreshToken : newRefreshToken,
|
|
437
|
+
refreshTokenExpiresAt,
|
|
438
|
+
tokenExpiresAt
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
query : {
|
|
443
|
+
id : connection.id,
|
|
444
|
+
...( raw && { settings : raw } )
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if( ! written ){
|
|
449
|
+
|
|
450
|
+
const current = await controller.get({ collection : 'connection', query : { id : connection.id } });
|
|
451
|
+
const currentSettings = current?.settings ? decrypt( current.settings ) : null;
|
|
452
|
+
|
|
453
|
+
if( currentSettings?.adminAccessToken && currentSettings.adminAccessToken !== settings.adminAccessToken ){
|
|
454
|
+
|
|
455
|
+
liveToken = currentSettings.adminAccessToken;
|
|
456
|
+
|
|
457
|
+
} else {
|
|
458
|
+
|
|
459
|
+
throw new Error( 'Shopify token refresh write-back failed for connection ' + connection?.id );
|
|
460
|
+
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Health signal only — the pair is already persisted (see header comment).
|
|
465
|
+
await ping({ adminAccessToken : liveToken, domain });
|
|
466
|
+
|
|
467
|
+
return liveToken;
|
|
468
|
+
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// Returns a live admin access token for the connection, resolving the token of
|
|
472
|
+
// record (connection or shared shop record). For refresh-token installs, rotates
|
|
473
|
+
// eagerly when `tokenExpiresAt` is within ACCESS_TOKEN_REFRESH_BUFFER_MS — Shopify
|
|
474
|
+
// rejects expired tokens immediately, so we never make the actual API call with one.
|
|
475
|
+
// Takes the app credentials only to hand them to that rotation — callers should
|
|
476
|
+
// always pass them, since whether a given call rotates depends on the clock.
|
|
477
|
+
|
|
478
|
+
const getAdminToken = async ({ fetcher, clientId, clientSecret, connection, controller }) => {
|
|
479
|
+
|
|
480
|
+
// VALIDATED HERE even though this function's own body never uses them.
|
|
481
|
+
//
|
|
482
|
+
// It only forwards them to refreshAdminToken, which throws — so without this
|
|
483
|
+
// a caller that omits them succeeds for as long as the stored token is fresh
|
|
484
|
+
// and fails weeks later, at rotation, on a code path nobody changed. Failing
|
|
485
|
+
// on the first call instead makes a missing credential a bug you find while
|
|
486
|
+
// writing the call site rather than an incident.
|
|
487
|
+
required( 'clientId', clientId );
|
|
488
|
+
required( 'clientSecret', clientSecret );
|
|
489
|
+
|
|
490
|
+
const settings = await resolveConnectionSettings({ connection, controller });
|
|
491
|
+
const { adminAccessToken, refreshToken, tokenExpiresAt } = settings;
|
|
492
|
+
|
|
493
|
+
if( ! adminAccessToken ){
|
|
494
|
+
|
|
495
|
+
return null;
|
|
496
|
+
|
|
497
|
+
}
|
|
498
|
+
if( ! refreshToken ){
|
|
499
|
+
|
|
500
|
+
return adminAccessToken;
|
|
501
|
+
|
|
502
|
+
}
|
|
503
|
+
const needsRefresh = ! tokenExpiresAt ||
|
|
504
|
+
new Date( tokenExpiresAt ) < new Date( Date.now() + ACCESS_TOKEN_REFRESH_BUFFER_MS );
|
|
505
|
+
|
|
506
|
+
if( needsRefresh ){
|
|
507
|
+
|
|
508
|
+
return refreshAdminToken({ clientId, clientSecret, connection, controller });
|
|
509
|
+
|
|
510
|
+
}
|
|
511
|
+
return adminAccessToken;
|
|
512
|
+
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
declare const shopifyOauth_SHOPIFY_REQUIRED_SCOPES: typeof SHOPIFY_REQUIRED_SCOPES;
|
|
516
|
+
declare const shopifyOauth_createStorefrontToken: typeof createStorefrontToken;
|
|
517
|
+
declare const shopifyOauth_getAdminToken: typeof getAdminToken;
|
|
518
|
+
declare const shopifyOauth_getShop: typeof getShop;
|
|
519
|
+
declare const shopifyOauth_missingScopes: typeof missingScopes;
|
|
520
|
+
declare const shopifyOauth_ping: typeof ping;
|
|
521
|
+
declare const shopifyOauth_refreshAdminToken: typeof refreshAdminToken;
|
|
522
|
+
declare const shopifyOauth_resolveConnectionSettings: typeof resolveConnectionSettings;
|
|
523
|
+
declare namespace shopifyOauth {
|
|
524
|
+
export { shopifyOauth_SHOPIFY_REQUIRED_SCOPES as SHOPIFY_REQUIRED_SCOPES, shopifyOauth_createStorefrontToken as createStorefrontToken, shopifyOauth_getAdminToken as getAdminToken, shopifyOauth_getShop as getShop, shopifyOauth_missingScopes as missingScopes, shopifyOauth_ping as ping, shopifyOauth_refreshAdminToken as refreshAdminToken, shopifyOauth_resolveConnectionSettings as resolveConnectionSettings };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export { getShop as a, resolveConnectionSettings as b, createStorefrontToken as c, getAdminToken as g, missingScopes as m, ping as p, refreshAdminToken as r, shopifyOauth as s };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { request } from './http.js';
|
|
2
|
+
import { PARTNER_API_VERSION } from './shopify/constants.js';
|
|
3
|
+
|
|
4
|
+
// The Partner API is the ONE place meter billing is observable. The Admin API
|
|
5
|
+
// never materializes managed-pricing meters as subscription line items
|
|
6
|
+
// (measured 2026-09-04, every version), and the App Events API answers 202
|
|
7
|
+
// whether or not anything billed — but a usage charge that ACCRUED appears
|
|
8
|
+
// here as a CHARGE_USAGE event, with the charge id, the amount, and the
|
|
9
|
+
// planHandle it classified under. Authenticated with a Partner API client
|
|
10
|
+
// token (Partner Dashboard → Settings → Partner API clients, Manage-apps
|
|
11
|
+
// permission), scoped to a partner organization.
|
|
12
|
+
|
|
13
|
+
const PARTNER_API = 'https://partners.shopify.com';
|
|
14
|
+
|
|
15
|
+
// The latest usage-charge accruals for an app, newest first, optionally
|
|
16
|
+
// scoped to one shop. Returns plain rows the callers stamp and reconcile
|
|
17
|
+
// with; throws on any GraphQL error — an unreadable ledger must never read
|
|
18
|
+
// as an empty one.
|
|
19
|
+
const getUsageChargeEvents = async ({ fetcher, appId, first = 10, occurredAtMin, organizationId, partnerToken, shopId }) => {
|
|
20
|
+
|
|
21
|
+
if( ! partnerToken || ! organizationId || ! appId ){
|
|
22
|
+
|
|
23
|
+
throw new Error( 'getUsageChargeEvents requires partnerToken, organizationId, and appId' );
|
|
24
|
+
|
|
25
|
+
}
|
|
26
|
+
const data = await request({
|
|
27
|
+
fetcher,
|
|
28
|
+
method : 'POST',
|
|
29
|
+
url : PARTNER_API + '/' + organizationId + '/api/' + PARTNER_API_VERSION + '/graphql.json',
|
|
30
|
+
headers : {
|
|
31
|
+
'X-Shopify-Access-Token' : partnerToken
|
|
32
|
+
},
|
|
33
|
+
body : {
|
|
34
|
+
query : `query usageCharges( $filter : EventFilterInput!, $first : Int! ) {
|
|
35
|
+
events( filter : $filter, orderBy : OCCURRED_AT_DESC, first : $first ) {
|
|
36
|
+
edges {
|
|
37
|
+
node {
|
|
38
|
+
id
|
|
39
|
+
occurredAt
|
|
40
|
+
eventType
|
|
41
|
+
shop { id myshopifyDomain }
|
|
42
|
+
... on Charge {
|
|
43
|
+
chargeId
|
|
44
|
+
chargeType
|
|
45
|
+
amount { amount currencyCode }
|
|
46
|
+
usageQuantity
|
|
47
|
+
planHandle
|
|
48
|
+
description
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
pageInfo { hasNextPage endCursor }
|
|
53
|
+
}
|
|
54
|
+
}`,
|
|
55
|
+
variables : {
|
|
56
|
+
filter : {
|
|
57
|
+
eventTypes : [ 'CHARGE_USAGE' ],
|
|
58
|
+
subjectId : String( appId ),
|
|
59
|
+
...( occurredAtMin && { occurredAtMin }),
|
|
60
|
+
...( shopId && { shopId : String( shopId ) })
|
|
61
|
+
},
|
|
62
|
+
first
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if( data?.errors?.length ){
|
|
68
|
+
|
|
69
|
+
throw new Error( 'Shopify partner events query failed: ' + data.errors.map( ( entry ) => entry.message ).join( '; ' ) );
|
|
70
|
+
|
|
71
|
+
}
|
|
72
|
+
return ( data?.data?.events?.edges || [] ).map( ( edge ) => ({
|
|
73
|
+
amount : edge.node?.amount || null,
|
|
74
|
+
chargeId : edge.node?.chargeId || null,
|
|
75
|
+
// USAGE vs the recurring plan charge an approval emits — the
|
|
76
|
+
// disambiguator callers filter on.
|
|
77
|
+
chargeType : edge.node?.chargeType || null,
|
|
78
|
+
description : edge.node?.description || null,
|
|
79
|
+
eventType : edge.node?.eventType || null,
|
|
80
|
+
id : edge.node?.id || null,
|
|
81
|
+
occurredAt : edge.node?.occurredAt || null,
|
|
82
|
+
planHandle : edge.node?.planHandle || null,
|
|
83
|
+
shop : edge.node?.shop || null,
|
|
84
|
+
usageQuantity : edge.node?.usageQuantity ?? null
|
|
85
|
+
}) );
|
|
86
|
+
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
declare const shopifyPartner_getUsageChargeEvents: typeof getUsageChargeEvents;
|
|
90
|
+
declare namespace shopifyPartner {
|
|
91
|
+
export { shopifyPartner_getUsageChargeEvents as getUsageChargeEvents };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { getUsageChargeEvents as g, shopifyPartner as s };
|