@porulle/plugin-channel-connector 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ChannelConnectorPluginOptions } from "./service.js";
2
2
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
3
- export { channelSyncHash, withDistinctVariantSkus } from "./service.js";
3
+ export { channelOrderAddressSchema, channelSyncHash, withDistinctVariantSkus } from "./service.js";
4
+ export { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
4
5
  export type { AbsentArchivePlan } from "./deletion-policy.js";
5
6
  export { mockChannelConnector } from "./mock-connector.js";
6
7
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
@@ -24,7 +25,7 @@ export type { CatalogFieldMapping, CatalogFieldMappingInput, CatalogFieldMapping
24
25
  */
25
26
  export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
26
27
  export { signState, verifyState } from "./oauth-state.js";
27
- export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, ChannelComplianceData, ChannelConnectorPluginOptions, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
28
+ export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, AfterStoreConnected, BindConnectedStore, ChannelComplianceData, ChannelConnectorPluginOptions, ConfineStores, OnStoreCatalogChanged, StoreConnectActor, StoreReadContext, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
28
29
  export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
29
30
  export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
30
31
  export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@ import { ChannelConnectorService, catalogPushConcurrencyKey, CHANNEL_INVENTORY_M
8
8
  import { buildHooks } from "./hooks.js";
9
9
  import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
10
10
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
11
- export { channelSyncHash, withDistinctVariantSkus } from "./service.js";
11
+ export { channelOrderAddressSchema, channelSyncHash, withDistinctVariantSkus } from "./service.js";
12
+ export { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
12
13
  export { mockChannelConnector } from "./mock-connector.js";
13
14
  export { ChannelConnectorService, HERO_IMAGE_BYTE_CAP, selectImportImages, CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS, CATALOG_PUSH_BATCH_SIZES, CATALOG_PUSH_MAX_ATTEMPTS, canCatalogPushTransition, canExportTransition, catalogPushConcurrencyKey, catalogPushRetryDelayMs, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, isCatalogPushBreakerOpen, } from "./service.js";
14
15
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
@@ -116,6 +117,18 @@ function oauthError(status, code, message) {
116
117
  function oauthRedirect(location) {
117
118
  return new Response(null, { status: 302, headers: { location } });
118
119
  }
120
+ /** The merchant's browser lands on `postConnectRedirect` with the outcome in the query string. */
121
+ function connectOutcome(postConnectRedirect, outcome) {
122
+ const url = new URL(postConnectRedirect);
123
+ if ("connected" in outcome) {
124
+ url.searchParams.set("connected", outcome.connected);
125
+ }
126
+ else {
127
+ url.searchParams.set("connect_error", outcome.error);
128
+ url.searchParams.set("connect_message", outcome.message);
129
+ }
130
+ return oauthRedirect(url.toString());
131
+ }
119
132
  function callbackUri(raw, redirect, provider) {
120
133
  const request = raw.req.raw;
121
134
  const requestUrl = new URL(request.url);
@@ -269,6 +282,24 @@ export function channelConnectorPlugin(options = {}) {
269
282
  return { output: result.value };
270
283
  },
271
284
  },
285
+ {
286
+ // One provider webhook delivery, applied off the request: Shopify gives a delivery five seconds,
287
+ // and a product re-read plus convergence is not bounded by that. Serialized per store, so two
288
+ // deliveries for one store apply in the order they were accepted.
289
+ slug: "channel/apply-webhook",
290
+ concurrency: { key: (input) => `webhook:${String(input.storeId)}` },
291
+ handler: async ({ input, ctx }) => {
292
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
293
+ const result = await service.handleWebhook(String(input.orgId), String(input.storeId), {
294
+ id: String(input.id),
295
+ type: String(input.topic),
296
+ data: input.data,
297
+ });
298
+ if (!result.ok)
299
+ throw new Error(result.error);
300
+ return { output: { processed: true } };
301
+ },
302
+ },
272
303
  {
273
304
  slug: "channel/reap-exports",
274
305
  handler: async ({ input, ctx }) => {
@@ -286,7 +317,8 @@ export function channelConnectorPlugin(options = {}) {
286
317
  version: "1.0.0",
287
318
  permissions: [
288
319
  { scope: "channels:read", description: "Read connected stores and channel export status." },
289
- { scope: "channels:manage", description: "Connect stores and retry channel order exports." },
320
+ { scope: "channels:manage", description: "Operate every store in the organization: backfills, pushes, conflicts, exports." },
321
+ { scope: "channels:connect", description: "Connect, reconnect and disconnect the caller's own stores." },
290
322
  ],
291
323
  schema: () => ({
292
324
  connectedStores,
@@ -311,10 +343,10 @@ export function channelConnectorPlugin(options = {}) {
311
343
  const channels = router("Channels", "/channels", ctx);
312
344
  channels.get("/oauth/{provider}/start")
313
345
  .summary("Start channel OAuth onboarding")
314
- .permission("channels:manage")
346
+ .permission("channels:connect")
315
347
  .params(z.object({ provider: z.string().min(1) }))
316
348
  .query(z.object({ shop: z.string().min(1).optional(), store: z.string().min(1).optional() }))
317
- .handler(async ({ params, query, orgId, raw }) => {
349
+ .handler(async ({ params, query, orgId, actor, raw }) => {
318
350
  const oauth = options.oauth;
319
351
  if (!oauth?.stateSecret || !oauth.postConnectRedirect)
320
352
  return oauthError(501, "OAUTH_NOT_CONFIGURED", "Channel OAuth is not configured.");
@@ -324,14 +356,21 @@ export function channelConnectorPlugin(options = {}) {
324
356
  return oauthError(404, "CONNECTOR_NOT_FOUND", `No connector registered for provider "${provider}".`);
325
357
  if (!connector.buildAuthUrl)
326
358
  return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
327
- const storeDomain = String(query.shop ?? query.store ?? "");
359
+ // The callback arrives with no session of its own; the store is bound to the user who started
360
+ // here, carried in the signed state. A caller with no user (an API key) cannot start one.
361
+ const userId = actor?.userId;
362
+ if (!userId)
363
+ return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
364
+ const typed = String(query.shop ?? query.store ?? "");
365
+ const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
328
366
  if (!storeDomain)
329
- return oauthError(400, "STORE_DOMAIN_REQUIRED", "The shop or store query parameter is required.");
367
+ return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
330
368
  const state = signState({
331
369
  provider,
332
370
  orgId,
371
+ userId,
333
372
  shopDomain: storeDomain,
334
- exp: Math.floor(Date.now() / 1000) + 300,
373
+ exp: Math.floor(Date.now() / 1000) + 600,
335
374
  jti: crypto.randomUUID(),
336
375
  }, oauth.stateSecret);
337
376
  const redirect = callbackUri(raw, oauth.postConnectRedirect, provider);
@@ -343,7 +382,7 @@ export function channelConnectorPlugin(options = {}) {
343
382
  scopes: [],
344
383
  });
345
384
  if (!authUrl.ok)
346
- return oauthError(422, authUrl.error.code, authUrl.error.message);
385
+ return connectOutcome(oauth.postConnectRedirect, { error: authUrl.error.code, message: authUrl.error.message });
347
386
  return oauthRedirect(authUrl.value);
348
387
  });
349
388
  const handleOAuthCallback = async ({ params, raw }) => {
@@ -359,12 +398,13 @@ export function channelConnectorPlugin(options = {}) {
359
398
  const request = raw.req.raw;
360
399
  const requestUrl = new URL(request.url);
361
400
  const state = requestUrl.searchParams.get("state");
401
+ const refused = (error, message) => connectOutcome(oauth.postConnectRedirect, { error, message });
362
402
  if (!state)
363
- return oauthError(403, "INVALID_OAUTH_STATE", "OAuth state is missing.");
403
+ return refused("INVALID_OAUTH_STATE", "The connection was not started here, or its link was altered.");
364
404
  const landing = provider === "woocommerce" && request.method === "GET" && requestUrl.searchParams.get("return") === "1";
365
- const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000), !landing);
405
+ const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000));
366
406
  if (!verified.ok || verified.value.provider !== provider)
367
- return oauthError(403, "INVALID_OAUTH_STATE", "OAuth state is invalid or expired.");
407
+ return refused("INVALID_OAUTH_STATE", "The connection link expired; start again.");
368
408
  if (landing)
369
409
  return oauthRedirect(oauth.postConnectRedirect);
370
410
  const [consumed] = await db.insert(processedWebhookEvents).values({
@@ -373,20 +413,20 @@ export function channelConnectorPlugin(options = {}) {
373
413
  eventType: "oauth_state",
374
414
  }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
375
415
  if (!consumed)
376
- return oauthError(403, "OAUTH_STATE_REPLAYED", "OAuth state has already been used.");
416
+ return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.");
377
417
  const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain });
378
418
  if (!completed.ok)
379
- return oauthError(400, completed.error.code, completed.error.message);
419
+ return refused(completed.error.code, completed.error.message);
380
420
  if (completed.value.storeDomain !== verified.value.shopDomain)
381
- return oauthError(400, "OAUTH_STORE_MISMATCH", "OAuth callback store does not match the signed state.");
421
+ return refused("OAUTH_STORE_MISMATCH", "The store that answered is not the one the connection was started for.");
382
422
  const connected = await service.connectStore(verified.value.orgId, {
383
423
  provider,
384
424
  storeDomain: verified.value.shopDomain,
385
425
  credentials: completed.value.credentials,
386
- });
426
+ }, { orgId: verified.value.orgId, userId: verified.value.userId });
387
427
  if (!connected.ok)
388
- return oauthError(422, connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
389
- return oauthRedirect(oauth.postConnectRedirect);
428
+ return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
429
+ return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
390
430
  };
391
431
  channels.get("/oauth/{provider}/callback")
392
432
  .summary("Complete channel OAuth onboarding")
@@ -405,8 +445,8 @@ export function channelConnectorPlugin(options = {}) {
405
445
  if (!store || !store.webhookSecret)
406
446
  return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook store is not available." } }, 401);
407
447
  const connector = service.getConnector(store.provider);
408
- if (!connector)
409
- return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured." } }, 401);
448
+ if (!connector?.verifyWebhook)
449
+ return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured for per-store deliveries." } }, 401);
410
450
  const verified = await connector.verifyWebhook(store, context.req.raw);
411
451
  if (!verified.ok)
412
452
  return context.json({ error: { code: "UNAUTHORIZED", message: "Invalid webhook signature." } }, 401);
@@ -422,50 +462,43 @@ export function channelConnectorPlugin(options = {}) {
422
462
  ...(handled.value.redacted !== undefined ? { redacted: handled.value.redacted } : {}),
423
463
  } });
424
464
  });
425
- channels.post("/compliance/{provider}")
426
- .summary("Receive a compliance webhook")
465
+ // Every delivery for a provider that signs per APP — Shopify's catalogue, stock, order, uninstall
466
+ // and mandatory compliance topics alike — arrives here, at the one address its app configuration
467
+ // declares. Verified, deduplicated on the provider's delivery id, and handed to a job per store:
468
+ // the answer must be quick, and the work is not.
469
+ channels.post("/app-webhooks/{provider}")
470
+ .summary("Receive a provider's app-level webhook")
427
471
  .params(z.object({ provider: z.string().min(1) }))
428
472
  .handler(async ({ params, raw }) => {
429
473
  const context = raw;
430
474
  const provider = params.provider;
431
475
  const connector = service.getConnector(provider);
432
- if (!connector)
433
- return context.json({ error: { code: "NOT_FOUND", message: `No connector registered for provider "${provider}".` } }, 404);
434
- if (!connector.verifyAppWebhook)
435
- return context.json({ error: { code: "NOT_IMPLEMENTED", message: `Compliance webhook is not supported for provider "${provider}".` } }, 501);
476
+ if (!connector?.verifyAppWebhook)
477
+ return context.json({ error: { code: "NOT_FOUND", message: `No app-level webhooks for provider "${provider}".` } }, 404);
436
478
  const verified = await connector.verifyAppWebhook(context.req.raw);
437
479
  if (!verified.ok)
438
480
  return context.json({ error: { code: "UNAUTHORIZED", message: verified.error.message } }, 401);
439
- const request = context.req.raw;
440
- const eventId = request.headers.get("x-shopify-event-id") ?? createHash("sha256").update(`${verified.value.topic}:${verified.value.shopDomain}:${JSON.stringify(verified.value.data)}`).digest("hex");
441
- const [inserted] = await db.insert(processedWebhookEvents).values({ eventId, provider, eventType: verified.value.topic }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
442
- if (!inserted)
481
+ const event = verified.value;
482
+ const [marked] = await db.insert(processedWebhookEvents).values({ eventId: event.id, provider, eventType: event.topic }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
483
+ if (!marked)
443
484
  return context.json({ data: { received: true, duplicate: true } });
444
- const stores = await service.getStoresByDomain(verified.value.shopDomain);
445
- if (stores.length === 0)
446
- return context.json({ data: { received: true } });
447
- let redacted = 0;
448
- let redactedSeen = false;
449
- let complianceData;
450
- for (const store of stores) {
451
- const handled = await service.handleWebhook(store.organizationId, store.id, { id: eventId, type: verified.value.topic, data: verified.value.data });
452
- if (!handled.ok)
453
- return context.json({ error: { code: "WEBHOOK_PROCESSING_FAILED", message: handled.error } }, 422);
454
- if (handled.value.redacted !== undefined) {
455
- redacted += handled.value.redacted;
456
- redactedSeen = true;
457
- }
458
- if (handled.value.data) {
459
- complianceData = complianceData
460
- ? { customer: complianceData.customer, exports: [...complianceData.exports, ...handled.value.data.exports] }
461
- : handled.value.data;
485
+ const stores = (await service.getStoresByDomain(event.shopDomain)).filter((store) => store.provider === provider);
486
+ const jobs = ctx.services.jobs;
487
+ try {
488
+ for (const store of stores) {
489
+ await jobs.enqueue("channel/apply-webhook", { orgId: store.organizationId, storeId: store.id, id: event.id, topic: event.topic, data: event.data }, {
490
+ organizationId: store.organizationId,
491
+ concurrencyKey: `webhook:${store.id}`,
492
+ supersedes: false,
493
+ });
462
494
  }
463
495
  }
464
- return context.json({ data: {
465
- received: true,
466
- ...(complianceData ? { data: complianceData } : {}),
467
- ...(redactedSeen ? { redacted } : {}),
468
- } });
496
+ catch (error) {
497
+ // Not accepted: forget the delivery so the provider's retry is applied, not dropped as a duplicate.
498
+ await db.delete(processedWebhookEvents).where(eq(processedWebhookEvents.id, marked.id));
499
+ return context.json({ error: { code: "WEBHOOK_NOT_ACCEPTED", message: error instanceof Error ? error.message : "The delivery could not be queued." } }, 503);
500
+ }
501
+ return context.json({ data: { received: true, stores: stores.length } });
469
502
  });
470
503
  channels.post("/stores")
471
504
  .summary("Connect a channel store")
@@ -476,8 +509,8 @@ export function channelConnectorPlugin(options = {}) {
476
509
  storeDomain: z.string().min(1),
477
510
  webhookSecret: z.string().min(1).optional(),
478
511
  }))
479
- .handler(async ({ input, orgId }) => {
480
- return unwrap(await service.connectStore(orgId, input));
512
+ .handler(async ({ input, orgId, actor, raw }) => {
513
+ return unwrap(await service.connectStore(orgId, input, { orgId, userId: actor?.userId ?? null, raw }));
481
514
  });
482
515
  channels.get("/stores")
483
516
  .summary("List connected channel stores")
@@ -486,7 +519,7 @@ export function channelConnectorPlugin(options = {}) {
486
519
  channels.get("/stores/{id}")
487
520
  .summary("Get a connected channel store")
488
521
  .permission("channels:read")
489
- .handler(async ({ params, orgId }) => unwrap(await service.getStore(orgId, params.id)));
522
+ .handler(async ({ params, orgId, actor, raw }) => unwrap(await service.getStore(orgId, params.id, { orgId, actor, raw })));
490
523
  channels.get("/stores/{storeId}/catalog-write")
491
524
  .summary("Get catalog write settings for a channel store")
492
525
  .permission("channels:manage")
@@ -509,7 +542,10 @@ export function channelConnectorPlugin(options = {}) {
509
542
  channels.get("/stores/{storeId}/reconcile-status")
510
543
  .summary("Get channel reconciliation status")
511
544
  .permission("channels:read")
512
- .handler(async ({ params, orgId }) => unwrap(await service.getReconcileStatus(orgId, params.storeId)));
545
+ .handler(async ({ params, orgId, actor, raw }) => {
546
+ unwrap(await service.reachableStore(orgId, params.storeId, { orgId, actor, raw }));
547
+ return unwrap(await service.getReconcileStatus(orgId, params.storeId));
548
+ });
513
549
  channels.get("/conflicts")
514
550
  .summary("List channel catalog conflicts")
515
551
  .permission("channels:read")
@@ -581,8 +617,8 @@ export function channelConnectorPlugin(options = {}) {
581
617
  });
582
618
  channels.post("/stores/{id}/disconnect")
583
619
  .summary("Disconnect a channel store")
584
- .permission("channels:manage")
585
- .handler(async ({ params, orgId }) => unwrap(await service.disconnectStore(orgId, params.id)));
620
+ .permission("channels:connect")
621
+ .handler(async ({ params, orgId, actor, raw }) => unwrap(await service.disconnectStore(orgId, params.id, { orgId, actor, raw })));
586
622
  channels.get("/exports/failed")
587
623
  .summary("List failed channel order exports")
588
624
  .permission("channels:read")
@@ -0,0 +1,24 @@
1
+ import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, Result } from "@porulle/core";
2
+ /**
3
+ * The store with credentials good for the next call.
4
+ *
5
+ * A provider that grants EXPIRING credentials (Shopify: a one-hour access token and a rotating
6
+ * refresh token) says so through `liveCredentials`; this asks it and persists what it returns.
7
+ *
8
+ * Persisting is a compare-and-swap on the credentials the call started from. Two Workers that both
9
+ * find the token expiring both refresh; the first write wins and the second, finding the row moved,
10
+ * adopts what the first wrote rather than overwriting a newer refresh token with its own. (Shopify
11
+ * keeps a presented refresh token usable until the app presents the newer one, and a retired access
12
+ * token valid until its own expiry, so the loser's in-flight call still succeeds.)
13
+ *
14
+ * A refusal that is not retriable means the grant is gone — the merchant uninstalled, or the refresh
15
+ * token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
16
+ * later call with a credential error nobody is shown.
17
+ */
18
+ export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>>;
19
+ /**
20
+ * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
21
+ * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
22
+ * credentials never expire.
23
+ */
24
+ export declare function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector;
@@ -0,0 +1,71 @@
1
+ import { Err, Ok } from "@porulle/core";
2
+ import { and, eq, sql } from "@porulle/core/drizzle";
3
+ import { connectedStores } from "./schema.js";
4
+ /**
5
+ * The store with credentials good for the next call.
6
+ *
7
+ * A provider that grants EXPIRING credentials (Shopify: a one-hour access token and a rotating
8
+ * refresh token) says so through `liveCredentials`; this asks it and persists what it returns.
9
+ *
10
+ * Persisting is a compare-and-swap on the credentials the call started from. Two Workers that both
11
+ * find the token expiring both refresh; the first write wins and the second, finding the row moved,
12
+ * adopts what the first wrote rather than overwriting a newer refresh token with its own. (Shopify
13
+ * keeps a presented refresh token usable until the app presents the newer one, and a retired access
14
+ * token valid until its own expiry, so the loser's in-flight call still succeeds.)
15
+ *
16
+ * A refusal that is not retriable means the grant is gone — the merchant uninstalled, or the refresh
17
+ * token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
18
+ * later call with a credential error nobody is shown.
19
+ */
20
+ export async function resolveLiveCredentials(connector, db, store) {
21
+ if (!connector.liveCredentials)
22
+ return Ok(store);
23
+ const answer = await connector.liveCredentials(store);
24
+ if (!answer.ok) {
25
+ if (answer.error.retriable !== true) {
26
+ await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
27
+ }
28
+ return answer;
29
+ }
30
+ if (answer.value === null)
31
+ return Ok(store);
32
+ const swapped = await db.update(connectedStores)
33
+ .set({ credentials: answer.value, updatedAt: new Date() })
34
+ .where(and(eq(connectedStores.id, store.id), sql `${connectedStores.credentials} = ${JSON.stringify(store.credentials)}::jsonb`))
35
+ .returning({ id: connectedStores.id });
36
+ if (swapped.length > 0)
37
+ return Ok({ ...store, credentials: answer.value });
38
+ const [current] = await db.select({ credentials: connectedStores.credentials }).from(connectedStores).where(eq(connectedStores.id, store.id));
39
+ if (!current)
40
+ return Err({ code: "STORE_NOT_FOUND", message: `Connected store ${store.id} no longer exists.`, retriable: false });
41
+ return Ok({ ...store, credentials: current.credentials });
42
+ }
43
+ /**
44
+ * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
45
+ * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
46
+ * credentials never expire.
47
+ */
48
+ export function withLiveCredentials(connector, db) {
49
+ if (!connector.liveCredentials)
50
+ return connector;
51
+ const around = (call) => async (store, ...args) => {
52
+ const current = await resolveLiveCredentials(connector, db, store);
53
+ if (!current.ok)
54
+ return current;
55
+ return call.call(connector, current.value, ...args);
56
+ };
57
+ return {
58
+ ...connector,
59
+ importCatalog: around(connector.importCatalog),
60
+ fetchInventory: around(connector.fetchInventory),
61
+ pushOrder: around(connector.pushOrder),
62
+ fetchOrderStatus: around(connector.fetchOrderStatus),
63
+ refundExecute: around(connector.refundExecute),
64
+ ...(connector.fetchInventoryPage ? { fetchInventoryPage: around(connector.fetchInventoryPage) } : {}),
65
+ ...(connector.fetchCatalogItems ? { fetchCatalogItems: around(connector.fetchCatalogItems) } : {}),
66
+ ...(connector.fetchStoreProfile ? { fetchStoreProfile: around(connector.fetchStoreProfile) } : {}),
67
+ ...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
68
+ ...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
69
+ ...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
70
+ };
71
+ }
@@ -1,6 +1,8 @@
1
1
  export interface OAuthStatePayload {
2
2
  provider: string;
3
3
  orgId: string;
4
+ /** The signed-in user who started the connection: the callback arrives with no session of its own. */
5
+ userId: string;
4
6
  shopDomain: string;
5
7
  exp: number;
6
8
  jti: string;
@@ -13,5 +15,10 @@ export type OAuthStateResult = {
13
15
  error: string;
14
16
  };
15
17
  export declare function signState(payload: OAuthStatePayload, secret: string): string;
16
- export declare function verifyState(state: string, secret: string, now?: number, consume?: boolean): OAuthStateResult;
18
+ /**
19
+ * Checks the signature and expiry. Single use is NOT decided here: an in-memory record of used states
20
+ * would forget on every Worker isolate, so the callback records each state's `jti` in the database and
21
+ * refuses a second use there.
22
+ */
23
+ export declare function verifyState(state: string, secret: string, now?: number): OAuthStateResult;
17
24
  export declare function oauthStateEventId(jti: string): string;
@@ -1,5 +1,4 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
- const consumedJtis = new Map();
3
2
  function encodeText(value) {
4
3
  return Buffer.from(value, "utf8").toString("base64url");
5
4
  }
@@ -23,7 +22,12 @@ export function signState(payload, secret) {
23
22
  const encodedPayload = encodeText(JSON.stringify(payload));
24
23
  return `${encodedPayload}.${encodeBytes(signature(encodedPayload, secret))}`;
25
24
  }
26
- export function verifyState(state, secret, now = Math.floor(Date.now() / 1000), consume = true) {
25
+ /**
26
+ * Checks the signature and expiry. Single use is NOT decided here: an in-memory record of used states
27
+ * would forget on every Worker isolate, so the callback records each state's `jti` in the database and
28
+ * refuses a second use there.
29
+ */
30
+ export function verifyState(state, secret, now = Math.floor(Date.now() / 1000)) {
27
31
  if (!secret)
28
32
  return { ok: false, error: "OAuth state secret is required." };
29
33
  const parts = state.split(".");
@@ -56,6 +60,7 @@ export function verifyState(state, secret, now = Math.floor(Date.now() / 1000),
56
60
  const exp = candidate.exp;
57
61
  if (typeof candidate.provider !== "string" ||
58
62
  typeof candidate.orgId !== "string" ||
63
+ typeof candidate.userId !== "string" ||
59
64
  typeof candidate.shopDomain !== "string" ||
60
65
  typeof candidate.jti !== "string" ||
61
66
  typeof exp !== "number" ||
@@ -63,14 +68,6 @@ export function verifyState(state, secret, now = Math.floor(Date.now() / 1000),
63
68
  return { ok: false, error: "Malformed OAuth state payload." };
64
69
  if (exp <= now)
65
70
  return { ok: false, error: "OAuth state has expired." };
66
- for (const [jti, expiresAt] of consumedJtis) {
67
- if (expiresAt <= now)
68
- consumedJtis.delete(jti);
69
- }
70
- if (consume && consumedJtis.has(candidate.jti))
71
- return { ok: false, error: "OAuth state has already been used." };
72
- if (consume)
73
- consumedJtis.set(candidate.jti, exp);
74
71
  return { ok: true, value: candidate };
75
72
  }
76
73
  export function oauthStateEventId(jti) {
package/dist/schema.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChannelPushCatalogItem } from "@porulle/core";
1
+ import type { ChannelOrderAddress, ChannelPushCatalogItem } from "@porulle/core";
2
2
  import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
3
3
  export declare const connectedStores: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
4
4
  name: "connected_stores";
@@ -1074,7 +1074,7 @@ export declare const channelOrderExports: import("drizzle-orm/pg-core/table").Pg
1074
1074
  data: {
1075
1075
  name: string;
1076
1076
  email: string;
1077
- shippingAddress: Record<string, unknown>;
1077
+ shippingAddress: ChannelOrderAddress;
1078
1078
  };
1079
1079
  driverParam: unknown;
1080
1080
  notNull: false;
@@ -1090,7 +1090,7 @@ export declare const channelOrderExports: import("drizzle-orm/pg-core/table").Pg
1090
1090
  $type: {
1091
1091
  name: string;
1092
1092
  email: string;
1093
- shippingAddress: Record<string, unknown>;
1093
+ shippingAddress: ChannelOrderAddress;
1094
1094
  };
1095
1095
  }>;
1096
1096
  state: import("@porulle/core/drizzle").PgColumn<{