@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/src/index.ts CHANGED
@@ -47,7 +47,7 @@ type ChannelRouteContext = {
47
47
  actor: { userId: string | null } | null;
48
48
  /**
49
49
  * Core's documented request escape hatch, already present on `RouteHandlerContext` and simply
50
- * never declared here. Read by `confineStoreReads` so a consumer can resolve the caller from the
50
+ * never declared here. Read by `confineStores` so a consumer can resolve the caller from the
51
51
  * request — core's `actor.vendorId` cannot serve that purpose: core sources it from a column on
52
52
  * `user` that this deployment never writes, and sets it to `null` outright for API-key actors.
53
53
  */
@@ -55,7 +55,8 @@ type ChannelRouteContext = {
55
55
  };
56
56
 
57
57
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
58
- export { channelSyncHash, withDistinctVariantSkus } from "./service.js";
58
+ export { channelOrderAddressSchema, channelSyncHash, withDistinctVariantSkus } from "./service.js";
59
+ export { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
59
60
  export type { AbsentArchivePlan } from "./deletion-policy.js";
60
61
  export { mockChannelConnector } from "./mock-connector.js";
61
62
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
@@ -232,8 +233,14 @@ export type {
232
233
  CatalogPushSkipReason,
233
234
  CatalogConflictState,
234
235
  CatalogWriteSettings,
236
+ AfterStoreConnected,
237
+ BindConnectedStore,
235
238
  ChannelComplianceData,
236
239
  ChannelConnectorPluginOptions,
240
+ ConfineStores,
241
+ OnStoreCatalogChanged,
242
+ StoreConnectActor,
243
+ StoreReadContext,
237
244
  ChannelStockLine,
238
245
  ExportState,
239
246
  PublicConnectedStore,
@@ -278,6 +285,18 @@ function oauthRedirect(location: string): Response {
278
285
  return new Response(null, { status: 302, headers: { location } });
279
286
  }
280
287
 
288
+ /** The merchant's browser lands on `postConnectRedirect` with the outcome in the query string. */
289
+ function connectOutcome(postConnectRedirect: string, outcome: { connected: string } | { error: string; message: string }): Response {
290
+ const url = new URL(postConnectRedirect);
291
+ if ("connected" in outcome) {
292
+ url.searchParams.set("connected", outcome.connected);
293
+ } else {
294
+ url.searchParams.set("connect_error", outcome.error);
295
+ url.searchParams.set("connect_message", outcome.message);
296
+ }
297
+ return oauthRedirect(url.toString());
298
+ }
299
+
281
300
  function callbackUri(raw: unknown, redirect: string, provider: string): string {
282
301
  const request = (raw as { req: { raw: Request } }).req.raw;
283
302
  const requestUrl = new URL(request.url);
@@ -433,6 +452,23 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
433
452
  return { output: result.value };
434
453
  },
435
454
  },
455
+ {
456
+ // One provider webhook delivery, applied off the request: Shopify gives a delivery five seconds,
457
+ // and a product re-read plus convergence is not bounded by that. Serialized per store, so two
458
+ // deliveries for one store apply in the order they were accepted.
459
+ slug: "channel/apply-webhook",
460
+ concurrency: { key: (input: Record<string, unknown>) => `webhook:${String(input.storeId)}` },
461
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
462
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
463
+ const result = await service.handleWebhook(String(input.orgId), String(input.storeId), {
464
+ id: String(input.id),
465
+ type: String(input.topic),
466
+ data: input.data,
467
+ });
468
+ if (!result.ok) throw new Error(result.error);
469
+ return { output: { processed: true } };
470
+ },
471
+ },
436
472
  {
437
473
  slug: "channel/reap-exports",
438
474
  handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
@@ -450,7 +486,8 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
450
486
  version: "1.0.0",
451
487
  permissions: [
452
488
  { scope: "channels:read", description: "Read connected stores and channel export status." },
453
- { scope: "channels:manage", description: "Connect stores and retry channel order exports." },
489
+ { scope: "channels:manage", description: "Operate every store in the organization: backfills, pushes, conflicts, exports." },
490
+ { scope: "channels:connect", description: "Connect, reconnect and disconnect the caller's own stores." },
454
491
  ],
455
492
  schema: () => ({
456
493
  connectedStores,
@@ -480,23 +517,29 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
480
517
 
481
518
  channels.get("/oauth/{provider}/start")
482
519
  .summary("Start channel OAuth onboarding")
483
- .permission("channels:manage")
520
+ .permission("channels:connect")
484
521
  .params(z.object({ provider: z.string().min(1) }))
485
522
  .query(z.object({ shop: z.string().min(1).optional(), store: z.string().min(1).optional() }))
486
- .handler(async ({ params, query, orgId, raw }) => {
523
+ .handler(async ({ params, query, orgId, actor, raw }) => {
487
524
  const oauth = options.oauth;
488
525
  if (!oauth?.stateSecret || !oauth.postConnectRedirect) return oauthError(501, "OAUTH_NOT_CONFIGURED", "Channel OAuth is not configured.");
489
526
  const provider = params.provider!;
490
527
  const connector = service.getConnector(provider);
491
528
  if (!connector) return oauthError(404, "CONNECTOR_NOT_FOUND", `No connector registered for provider "${provider}".`);
492
529
  if (!connector.buildAuthUrl) return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
493
- const storeDomain = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
494
- if (!storeDomain) return oauthError(400, "STORE_DOMAIN_REQUIRED", "The shop or store query parameter is required.");
530
+ // The callback arrives with no session of its own; the store is bound to the user who started
531
+ // here, carried in the signed state. A caller with no user (an API key) cannot start one.
532
+ const userId = actor?.userId;
533
+ if (!userId) return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
534
+ const typed = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
535
+ const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
536
+ if (!storeDomain) return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
495
537
  const state = signState({
496
538
  provider,
497
539
  orgId,
540
+ userId,
498
541
  shopDomain: storeDomain,
499
- exp: Math.floor(Date.now() / 1000) + 300,
542
+ exp: Math.floor(Date.now() / 1000) + 600,
500
543
  jti: crypto.randomUUID(),
501
544
  }, oauth.stateSecret);
502
545
  const redirect = callbackUri(raw, oauth.postConnectRedirect, provider);
@@ -507,7 +550,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
507
550
  callbackUri: redirect,
508
551
  scopes: [],
509
552
  });
510
- if (!authUrl.ok) return oauthError(422, authUrl.error.code, authUrl.error.message);
553
+ if (!authUrl.ok) return connectOutcome(oauth.postConnectRedirect, { error: authUrl.error.code, message: authUrl.error.message });
511
554
  return oauthRedirect(authUrl.value);
512
555
  });
513
556
 
@@ -521,27 +564,28 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
521
564
  const request = (raw as { req: { raw: Request } }).req.raw;
522
565
  const requestUrl = new URL(request.url);
523
566
  const state = requestUrl.searchParams.get("state");
524
- if (!state) return oauthError(403, "INVALID_OAUTH_STATE", "OAuth state is missing.");
567
+ const refused = (error: string, message: string): Response => connectOutcome(oauth.postConnectRedirect, { error, message });
568
+ if (!state) return refused("INVALID_OAUTH_STATE", "The connection was not started here, or its link was altered.");
525
569
  const landing = provider === "woocommerce" && request.method === "GET" && requestUrl.searchParams.get("return") === "1";
526
- const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000), !landing);
527
- if (!verified.ok || verified.value.provider !== provider) return oauthError(403, "INVALID_OAUTH_STATE", "OAuth state is invalid or expired.");
570
+ const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000));
571
+ if (!verified.ok || verified.value.provider !== provider) return refused("INVALID_OAUTH_STATE", "The connection link expired; start again.");
528
572
  if (landing) return oauthRedirect(oauth.postConnectRedirect);
529
573
  const [consumed] = await db.insert(processedWebhookEvents).values({
530
574
  eventId: oauthStateEventId(verified.value.jti),
531
575
  provider: `oauth:${provider}`,
532
576
  eventType: "oauth_state",
533
577
  }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
534
- if (!consumed) return oauthError(403, "OAUTH_STATE_REPLAYED", "OAuth state has already been used.");
578
+ if (!consumed) return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.");
535
579
  const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain });
536
- if (!completed.ok) return oauthError(400, completed.error.code, completed.error.message);
537
- if (completed.value.storeDomain !== verified.value.shopDomain) return oauthError(400, "OAUTH_STORE_MISMATCH", "OAuth callback store does not match the signed state.");
580
+ if (!completed.ok) return refused(completed.error.code, completed.error.message);
581
+ if (completed.value.storeDomain !== verified.value.shopDomain) return refused("OAUTH_STORE_MISMATCH", "The store that answered is not the one the connection was started for.");
538
582
  const connected = await service.connectStore(verified.value.orgId, {
539
583
  provider,
540
584
  storeDomain: verified.value.shopDomain,
541
585
  credentials: completed.value.credentials,
542
- });
543
- if (!connected.ok) return oauthError(422, connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
544
- return oauthRedirect(oauth.postConnectRedirect);
586
+ }, { orgId: verified.value.orgId, userId: verified.value.userId });
587
+ if (!connected.ok) return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
588
+ return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
545
589
  };
546
590
 
547
591
  channels.get("/oauth/{provider}/callback")
@@ -562,7 +606,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
562
606
  const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, storeId));
563
607
  if (!store || !store.webhookSecret) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook store is not available." } }, 401);
564
608
  const connector = service.getConnector(store.provider);
565
- if (!connector) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured." } }, 401);
609
+ if (!connector?.verifyWebhook) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured for per-store deliveries." } }, 401);
566
610
  const verified = await connector.verifyWebhook(store, context.req.raw);
567
611
  if (!verified.ok) return context.json({ error: { code: "UNAUTHORIZED", message: "Invalid webhook signature." } }, 401);
568
612
  const [inserted] = await db.insert(processedWebhookEvents).values({ eventId: verified.value.id, provider: store.provider, eventType: verified.value.type }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
@@ -576,41 +620,39 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
576
620
  } });
577
621
  });
578
622
 
579
- channels.post("/compliance/{provider}")
580
- .summary("Receive a compliance webhook")
623
+ // Every delivery for a provider that signs per APP — Shopify's catalogue, stock, order, uninstall
624
+ // and mandatory compliance topics alike — arrives here, at the one address its app configuration
625
+ // declares. Verified, deduplicated on the provider's delivery id, and handed to a job per store:
626
+ // the answer must be quick, and the work is not.
627
+ channels.post("/app-webhooks/{provider}")
628
+ .summary("Receive a provider's app-level webhook")
581
629
  .params(z.object({ provider: z.string().min(1) }))
582
630
  .handler(async ({ params, raw }) => {
583
- const context = raw as { req: { raw: Request; header(name: string): string | undefined }; json(data: unknown, status?: number): Response };
631
+ const context = raw as { req: { raw: Request }; json(data: unknown, status?: number): Response };
584
632
  const provider = params.provider!;
585
633
  const connector = service.getConnector(provider);
586
- if (!connector) return context.json({ error: { code: "NOT_FOUND", message: `No connector registered for provider "${provider}".` } }, 404);
587
- if (!connector.verifyAppWebhook) return context.json({ error: { code: "NOT_IMPLEMENTED", message: `Compliance webhook is not supported for provider "${provider}".` } }, 501);
634
+ if (!connector?.verifyAppWebhook) return context.json({ error: { code: "NOT_FOUND", message: `No app-level webhooks for provider "${provider}".` } }, 404);
588
635
  const verified = await connector.verifyAppWebhook(context.req.raw);
589
636
  if (!verified.ok) return context.json({ error: { code: "UNAUTHORIZED", message: verified.error.message } }, 401);
590
- const request = context.req.raw;
591
- 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");
592
- const [inserted] = await db.insert(processedWebhookEvents).values({ eventId, provider, eventType: verified.value.topic }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
593
- if (!inserted) return context.json({ data: { received: true, duplicate: true } });
594
- const stores = await service.getStoresByDomain(verified.value.shopDomain);
595
- if (stores.length === 0) return context.json({ data: { received: true } });
596
- let redacted = 0;
597
- let redactedSeen = false;
598
- let complianceData: ChannelComplianceData | undefined;
599
- for (const store of stores) {
600
- const handled = await service.handleWebhook(store.organizationId, store.id, { id: eventId, type: verified.value.topic, data: verified.value.data });
601
- if (!handled.ok) return context.json({ error: { code: "WEBHOOK_PROCESSING_FAILED", message: handled.error } }, 422);
602
- if (handled.value.redacted !== undefined) { redacted += handled.value.redacted; redactedSeen = true; }
603
- if (handled.value.data) {
604
- complianceData = complianceData
605
- ? { customer: complianceData.customer, exports: [...complianceData.exports, ...handled.value.data.exports] }
606
- : handled.value.data;
637
+ const event = verified.value;
638
+ const [marked] = await db.insert(processedWebhookEvents).values({ eventId: event.id, provider, eventType: event.topic }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
639
+ if (!marked) return context.json({ data: { received: true, duplicate: true } });
640
+ const stores = (await service.getStoresByDomain(event.shopDomain)).filter((store) => store.provider === provider);
641
+ const jobs = ctx.services.jobs as JobsAdapter;
642
+ try {
643
+ for (const store of stores) {
644
+ await jobs.enqueue("channel/apply-webhook", { orgId: store.organizationId, storeId: store.id, id: event.id, topic: event.topic, data: event.data }, {
645
+ organizationId: store.organizationId,
646
+ concurrencyKey: `webhook:${store.id}`,
647
+ supersedes: false,
648
+ });
607
649
  }
650
+ } catch (error) {
651
+ // Not accepted: forget the delivery so the provider's retry is applied, not dropped as a duplicate.
652
+ await db.delete(processedWebhookEvents).where(eq(processedWebhookEvents.id, marked.id));
653
+ return context.json({ error: { code: "WEBHOOK_NOT_ACCEPTED", message: error instanceof Error ? error.message : "The delivery could not be queued." } }, 503);
608
654
  }
609
- return context.json({ data: {
610
- received: true,
611
- ...(complianceData ? { data: complianceData } : {}),
612
- ...(redactedSeen ? { redacted } : {}),
613
- } });
655
+ return context.json({ data: { received: true, stores: stores.length } });
614
656
  });
615
657
 
616
658
  channels.post("/stores")
@@ -622,7 +664,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
622
664
  storeDomain: z.string().min(1),
623
665
  webhookSecret: z.string().min(1).optional(),
624
666
  }))
625
- .handler(async ({ input, orgId }: ChannelRouteContext) => {
667
+ .handler(async ({ input, orgId, actor, raw }: ChannelRouteContext) => {
626
668
  return unwrap(await service.connectStore(
627
669
  orgId,
628
670
  input as {
@@ -631,6 +673,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
631
673
  storeDomain: string;
632
674
  webhookSecret?: string;
633
675
  },
676
+ { orgId, userId: actor?.userId ?? null, raw },
634
677
  ));
635
678
  });
636
679
 
@@ -643,7 +686,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
643
686
  channels.get("/stores/{id}")
644
687
  .summary("Get a connected channel store")
645
688
  .permission("channels:read")
646
- .handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getStore(orgId, params.id!)));
689
+ .handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.getStore(orgId, params.id!, { orgId, actor, raw })));
647
690
 
648
691
  channels.get("/stores/{storeId}/catalog-write")
649
692
  .summary("Get catalog write settings for a channel store")
@@ -667,7 +710,10 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
667
710
  channels.get("/stores/{storeId}/reconcile-status")
668
711
  .summary("Get channel reconciliation status")
669
712
  .permission("channels:read")
670
- .handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getReconcileStatus(orgId, params.storeId!)));
713
+ .handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => {
714
+ unwrap(await service.reachableStore(orgId, params.storeId!, { orgId, actor, raw }));
715
+ return unwrap(await service.getReconcileStatus(orgId, params.storeId!));
716
+ });
671
717
 
672
718
  channels.get("/conflicts")
673
719
  .summary("List channel catalog conflicts")
@@ -745,8 +791,8 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
745
791
 
746
792
  channels.post("/stores/{id}/disconnect")
747
793
  .summary("Disconnect a channel store")
748
- .permission("channels:manage")
749
- .handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.disconnectStore(orgId, params.id!)));
794
+ .permission("channels:connect")
795
+ .handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.disconnectStore(orgId, params.id!, { orgId, actor, raw })));
750
796
 
751
797
  channels.get("/exports/failed")
752
798
  .summary("List failed channel order exports")
@@ -0,0 +1,70 @@
1
+ import { Err, Ok } from "@porulle/core";
2
+ import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, Result } from "@porulle/core";
3
+ import { and, eq, sql } from "@porulle/core/drizzle";
4
+ import { connectedStores } from "./schema.js";
5
+
6
+ /**
7
+ * The store with credentials good for the next call.
8
+ *
9
+ * A provider that grants EXPIRING credentials (Shopify: a one-hour access token and a rotating
10
+ * refresh token) says so through `liveCredentials`; this asks it and persists what it returns.
11
+ *
12
+ * Persisting is a compare-and-swap on the credentials the call started from. Two Workers that both
13
+ * find the token expiring both refresh; the first write wins and the second, finding the row moved,
14
+ * adopts what the first wrote rather than overwriting a newer refresh token with its own. (Shopify
15
+ * keeps a presented refresh token usable until the app presents the newer one, and a retired access
16
+ * token valid until its own expiry, so the loser's in-flight call still succeeds.)
17
+ *
18
+ * A refusal that is not retriable means the grant is gone — the merchant uninstalled, or the refresh
19
+ * token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
20
+ * later call with a credential error nobody is shown.
21
+ */
22
+ export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>> {
23
+ if (!connector.liveCredentials) return Ok(store);
24
+ const answer = await connector.liveCredentials(store);
25
+ if (!answer.ok) {
26
+ if (answer.error.retriable !== true) {
27
+ await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
28
+ }
29
+ return answer;
30
+ }
31
+ if (answer.value === null) 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) return Ok({ ...store, credentials: answer.value });
37
+ const [current] = await db.select({ credentials: connectedStores.credentials }).from(connectedStores).where(eq(connectedStores.id, store.id));
38
+ if (!current) return Err({ code: "STORE_NOT_FOUND", message: `Connected store ${store.id} no longer exists.`, retriable: false });
39
+ return Ok({ ...store, credentials: current.credentials });
40
+ }
41
+
42
+ type StoreCall<A extends unknown[], T> = (store: ChannelStore, ...args: A) => Promise<Result<T, ChannelConnectorError>>;
43
+
44
+ /**
45
+ * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
46
+ * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
47
+ * credentials never expire.
48
+ */
49
+ export function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector {
50
+ if (!connector.liveCredentials) return connector;
51
+ const around = <A extends unknown[], T>(call: StoreCall<A, T>): StoreCall<A, T> => async (store, ...args) => {
52
+ const current = await resolveLiveCredentials(connector, db, store);
53
+ if (!current.ok) return current;
54
+ return call.call(connector, current.value, ...args);
55
+ };
56
+ return {
57
+ ...connector,
58
+ importCatalog: around(connector.importCatalog),
59
+ fetchInventory: around(connector.fetchInventory),
60
+ pushOrder: around(connector.pushOrder),
61
+ fetchOrderStatus: around(connector.fetchOrderStatus),
62
+ refundExecute: around(connector.refundExecute),
63
+ ...(connector.fetchInventoryPage ? { fetchInventoryPage: around(connector.fetchInventoryPage) } : {}),
64
+ ...(connector.fetchCatalogItems ? { fetchCatalogItems: around(connector.fetchCatalogItems) } : {}),
65
+ ...(connector.fetchStoreProfile ? { fetchStoreProfile: around(connector.fetchStoreProfile) } : {}),
66
+ ...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
67
+ ...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
68
+ ...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
69
+ };
70
+ }
@@ -3,6 +3,8 @@ import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  export interface OAuthStatePayload {
4
4
  provider: string;
5
5
  orgId: string;
6
+ /** The signed-in user who started the connection: the callback arrives with no session of its own. */
7
+ userId: string;
6
8
  shopDomain: string;
7
9
  exp: number;
8
10
  jti: string;
@@ -12,8 +14,6 @@ export type OAuthStateResult =
12
14
  | { ok: true; value: OAuthStatePayload }
13
15
  | { ok: false; error: string };
14
16
 
15
- const consumedJtis = new Map<string, number>();
16
-
17
17
  function encodeText(value: string): string {
18
18
  return Buffer.from(value, "utf8").toString("base64url");
19
19
  }
@@ -40,11 +40,15 @@ export function signState(payload: OAuthStatePayload, secret: string): string {
40
40
  return `${encodedPayload}.${encodeBytes(signature(encodedPayload, secret))}`;
41
41
  }
42
42
 
43
+ /**
44
+ * Checks the signature and expiry. Single use is NOT decided here: an in-memory record of used states
45
+ * would forget on every Worker isolate, so the callback records each state's `jti` in the database and
46
+ * refuses a second use there.
47
+ */
43
48
  export function verifyState(
44
49
  state: string,
45
50
  secret: string,
46
51
  now = Math.floor(Date.now() / 1000),
47
- consume = true,
48
52
  ): OAuthStateResult {
49
53
  if (!secret) return { ok: false, error: "OAuth state secret is required." };
50
54
  const parts = state.split(".");
@@ -75,17 +79,13 @@ export function verifyState(
75
79
  if (
76
80
  typeof candidate.provider !== "string" ||
77
81
  typeof candidate.orgId !== "string" ||
82
+ typeof candidate.userId !== "string" ||
78
83
  typeof candidate.shopDomain !== "string" ||
79
84
  typeof candidate.jti !== "string" ||
80
85
  typeof exp !== "number" ||
81
86
  !Number.isInteger(exp)
82
87
  ) return { ok: false, error: "Malformed OAuth state payload." };
83
88
  if (exp <= now) return { ok: false, error: "OAuth state has expired." };
84
- for (const [jti, expiresAt] of consumedJtis) {
85
- if (expiresAt <= now) consumedJtis.delete(jti);
86
- }
87
- if (consume && consumedJtis.has(candidate.jti)) return { ok: false, error: "OAuth state has already been used." };
88
- if (consume) consumedJtis.set(candidate.jti, exp);
89
89
 
90
90
  return { ok: true, value: candidate as OAuthStatePayload };
91
91
  }
package/src/schema.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  uuid,
11
11
  sql,
12
12
  } from "@porulle/core/drizzle";
13
- import type { ChannelPushCatalogItem, FieldPath } from "@porulle/core";
13
+ import type { ChannelOrderAddress, ChannelPushCatalogItem, FieldPath } from "@porulle/core";
14
14
  import { sellableEntities } from "@porulle/core/schema";
15
15
  import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
16
16
 
@@ -144,7 +144,7 @@ export const channelOrderExports = pgTable(
144
144
  customerData: jsonb("customer_data").$type<{
145
145
  name: string;
146
146
  email: string;
147
- shippingAddress: Record<string, unknown>;
147
+ shippingAddress: ChannelOrderAddress;
148
148
  }>(),
149
149
  state: text("state", { enum: ["pending", "exported", "confirmed", "failed", "abandoned"] })
150
150
  .notNull()