@porulle/plugin-channel-connector 0.66.0 → 0.68.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
@@ -25,7 +25,7 @@ export type { CatalogFieldMapping, CatalogFieldMappingInput, CatalogFieldMapping
25
25
  */
26
26
  export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
27
27
  export { signState, verifyState } from "./oauth-state.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
+ 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, ConnectClaims, OnStoreCatalogChanged, StoreConnectActor, StoreReadContext, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
29
29
  export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
30
30
  export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
31
31
  export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
package/dist/index.js CHANGED
@@ -365,10 +365,16 @@ export function channelConnectorPlugin(options = {}) {
365
365
  const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
366
366
  if (!storeDomain)
367
367
  return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
368
+ // Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
369
+ // that shop's other grants, so a refusal at the callback would already have broken them.
370
+ const claims = await service.connectClaims({ orgId, actor, raw, storeDomain });
371
+ if (!claims.ok)
372
+ return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
368
373
  const state = signState({
369
374
  provider,
370
375
  orgId,
371
376
  userId,
377
+ claims: claims.value,
372
378
  shopDomain: storeDomain,
373
379
  exp: Math.floor(Date.now() / 1000) + 600,
374
380
  jti: crypto.randomUUID(),
@@ -423,7 +429,7 @@ export function channelConnectorPlugin(options = {}) {
423
429
  provider,
424
430
  storeDomain: verified.value.shopDomain,
425
431
  credentials: completed.value.credentials,
426
- }, { orgId: verified.value.orgId, userId: verified.value.userId });
432
+ }, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
427
433
  if (!connected.ok)
428
434
  return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
429
435
  return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
@@ -510,7 +516,10 @@ export function channelConnectorPlugin(options = {}) {
510
516
  webhookSecret: z.string().min(1).optional(),
511
517
  }))
512
518
  .handler(async ({ input, orgId, actor, raw }) => {
513
- return unwrap(await service.connectStore(orgId, input, { orgId, userId: actor?.userId ?? null, raw }));
519
+ const requested = input;
520
+ const normalize = service.getConnector(requested.provider)?.normalizeStoreDomain;
521
+ const claims = unwrap(await service.connectClaims({ orgId, actor, raw, storeDomain: (normalize ? normalize(requested.storeDomain) : undefined) ?? requested.storeDomain }));
522
+ return unwrap(await service.connectStore(orgId, input, { orgId, userId: actor?.userId ?? null, claims, raw }));
514
523
  });
515
524
  channels.get("/stores")
516
525
  .summary("List connected channel stores")
@@ -15,10 +15,15 @@ import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, R
15
15
  * token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
16
16
  * later call with a credential error nobody is shown.
17
17
  */
18
- export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>>;
18
+ export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options?: {
19
+ force?: boolean;
20
+ }): Promise<Result<ChannelStore, ChannelConnectorError>>;
19
21
  /**
20
22
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
21
23
  * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
22
24
  * credentials never expire.
25
+ *
26
+ * A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
27
+ * stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
23
28
  */
24
29
  export declare function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector;
@@ -1,4 +1,4 @@
1
- import { Err, Ok } from "@porulle/core";
1
+ import { CHANNEL_CREDENTIALS_REJECTED, Err, Ok } from "@porulle/core";
2
2
  import { and, eq, sql } from "@porulle/core/drizzle";
3
3
  import { connectedStores } from "./schema.js";
4
4
  /**
@@ -17,10 +17,10 @@ import { connectedStores } from "./schema.js";
17
17
  * token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
18
18
  * later call with a credential error nobody is shown.
19
19
  */
20
- export async function resolveLiveCredentials(connector, db, store) {
20
+ export async function resolveLiveCredentials(connector, db, store, options = {}) {
21
21
  if (!connector.liveCredentials)
22
22
  return Ok(store);
23
- const answer = await connector.liveCredentials(store);
23
+ const answer = await connector.liveCredentials(store, options);
24
24
  if (!answer.ok) {
25
25
  if (answer.error.retriable !== true) {
26
26
  await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
@@ -44,6 +44,9 @@ export async function resolveLiveCredentials(connector, db, store) {
44
44
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
45
45
  * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
46
46
  * credentials never expire.
47
+ *
48
+ * A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
49
+ * stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
47
50
  */
48
51
  export function withLiveCredentials(connector, db) {
49
52
  if (!connector.liveCredentials)
@@ -52,7 +55,13 @@ export function withLiveCredentials(connector, db) {
52
55
  const current = await resolveLiveCredentials(connector, db, store);
53
56
  if (!current.ok)
54
57
  return current;
55
- return call.call(connector, current.value, ...args);
58
+ const first = await call.call(connector, current.value, ...args);
59
+ if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED)
60
+ return first;
61
+ const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
62
+ if (!refreshed.ok)
63
+ return refreshed;
64
+ return call.call(connector, refreshed.value, ...args);
56
65
  };
57
66
  return {
58
67
  ...connector,
@@ -3,6 +3,8 @@ export interface OAuthStatePayload {
3
3
  orgId: string;
4
4
  /** The signed-in user who started the connection: the callback arrives with no session of its own. */
5
5
  userId: string;
6
+ /** The consumer's connect claims, resolved at start. */
7
+ claims: Record<string, string>;
6
8
  shopDomain: string;
7
9
  exp: number;
8
10
  jti: string;
@@ -68,6 +68,10 @@ export function verifyState(state, secret, now = Math.floor(Date.now() / 1000))
68
68
  return { ok: false, error: "Malformed OAuth state payload." };
69
69
  if (exp <= now)
70
70
  return { ok: false, error: "OAuth state has expired." };
71
+ const claims = candidate.claims;
72
+ if (typeof claims !== "object" || claims === null || Object.values(claims).some((value) => typeof value !== "string")) {
73
+ return { ok: false, error: "Malformed OAuth state payload." };
74
+ }
71
75
  return { ok: true, value: candidate };
72
76
  }
73
77
  export function oauthStateEventId(jti) {
package/dist/service.d.ts CHANGED
@@ -109,9 +109,25 @@ export type ConfineStores = (context: StoreReadContext) => Promise<readonly stri
109
109
  export interface StoreConnectActor {
110
110
  orgId: string;
111
111
  userId: string | null;
112
+ /**
113
+ * What the consumer's {@link ConnectClaims} resolved when the connection started — e.g. which of
114
+ * the user's vendors the store is for. Carried in the signed OAuth state, so the callback, which has
115
+ * no session or headers of its own, binds the store to exactly what was chosen at the start.
116
+ */
117
+ claims: Readonly<Record<string, string>>;
112
118
  /** Core's request escape hatch when the connect is a request; absent on the OAuth callback. */
113
119
  raw?: unknown;
114
120
  }
121
+ /**
122
+ * Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
123
+ * later. Throw to refuse the start — before the merchant is sent to the provider, which matters: a
124
+ * provider such as Shopify retires a shop's other grants the moment a new one is issued, so a
125
+ * connection refused only at the callback has already broken the shop's existing one. `storeDomain` is
126
+ * the store being connected, canonicalised. Runs for OAuth start and for `POST /stores`.
127
+ */
128
+ export type ConnectClaims = (context: StoreReadContext & {
129
+ storeDomain: string;
130
+ }) => Promise<Record<string, string>> | Record<string, string>;
115
131
  /**
116
132
  * Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
117
133
  * that wrote the store row — so a store and its binding commit together or not at all. Throw to
@@ -127,6 +143,7 @@ export type AfterStoreConnected = (input: {
127
143
  store: ConnectedStore;
128
144
  actor: StoreConnectActor;
129
145
  connector: ChannelConnector;
146
+ services: Record<string, unknown>;
130
147
  }) => Promise<void>;
131
148
  /** Entities a provider webhook just created or changed, converged; the host projects them. */
132
149
  export type OnStoreCatalogChanged = (input: {
@@ -154,6 +171,8 @@ export interface ChannelConnectorPluginOptions {
154
171
  * unaffected.
155
172
  */
156
173
  confineStores?: ConfineStores;
174
+ /** See {@link ConnectClaims}. Absent, a connection carries no claims. */
175
+ connectClaims?: ConnectClaims;
157
176
  /** See {@link BindConnectedStore}. */
158
177
  bindConnectedStore?: BindConnectedStore;
159
178
  /** See {@link AfterStoreConnected}. */
@@ -495,6 +514,10 @@ export declare class ChannelConnectorService {
495
514
  }, actor: StoreConnectActor): Promise<PluginResult<PublicConnectedStore>>;
496
515
  /** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
497
516
  liveStore(orgId: string, storeId: string): Promise<PluginResult<ChannelStore>>;
517
+ /** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
518
+ connectClaims(context: StoreReadContext & {
519
+ storeDomain: string;
520
+ }): Promise<PluginResult<Record<string, string>>>;
498
521
  /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
499
522
  private allowedStores;
500
523
  /** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
package/dist/service.js CHANGED
@@ -1299,6 +1299,16 @@ export class ChannelConnectorService {
1299
1299
  continue;
1300
1300
  }
1301
1301
  variantIds.set(sourceVariant.externalId, variantId);
1302
+ // The provider's per-variant facts (the inventory item a stock webhook names, the weight shipping
1303
+ // prices by) merge into the variant per key, as entity metadata does: the source's keys overwrite
1304
+ // their own and nothing else. The import fast path writes them at creation; without this the
1305
+ // editor path — every reconcile — left them off, and a stock webhook could not find its variant.
1306
+ const sourceMetadata = sourceVariant.metadata ?? {};
1307
+ if (Object.keys(sourceMetadata).length > 0) {
1308
+ await this.db.update(variants)
1309
+ .set({ metadata: sql `coalesce(${variants.metadata}, '{}'::jsonb) || ${JSON.stringify(sourceMetadata)}::jsonb` })
1310
+ .where(and(eq(variants.id, variantId), sql `not (coalesce(${variants.metadata}, '{}'::jsonb) @> ${JSON.stringify(sourceMetadata)}::jsonb)`));
1311
+ }
1302
1312
  if (applyOptionValues) {
1303
1313
  const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
1304
1314
  .map(([name, value]) => optionValueIds.get(name)?.get(value))
@@ -2269,7 +2279,7 @@ export class ChannelConnectorService {
2269
2279
  }
2270
2280
  if (this.options.afterStoreConnected) {
2271
2281
  try {
2272
- await this.options.afterStoreConnected({ store, actor, connector });
2282
+ await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
2273
2283
  }
2274
2284
  catch (error) {
2275
2285
  return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
@@ -2288,6 +2298,17 @@ export class ChannelConnectorService {
2288
2298
  const live = await resolveLiveCredentials(connector, this.db, store);
2289
2299
  return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
2290
2300
  }
2301
+ /** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
2302
+ async connectClaims(context) {
2303
+ if (!this.options.connectClaims)
2304
+ return Ok({});
2305
+ try {
2306
+ return Ok(await this.options.connectClaims(context));
2307
+ }
2308
+ catch (error) {
2309
+ return PluginErr(error instanceof Error ? error.message : "The connection could not be started.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "CONNECT_REFUSED");
2310
+ }
2311
+ }
2291
2312
  /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
2292
2313
  async allowedStores(orgId, context) {
2293
2314
  return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : null;
@@ -3465,14 +3486,21 @@ export class ChannelConnectorService {
3465
3486
  archived += 1;
3466
3487
  }
3467
3488
  }
3468
- const inventory = await connector.fetchInventory(store, mappings.map((mapping) => mapping.externalId));
3489
+ // Stock is levelled against the mappings as they stand AFTER convergence. Read before it, as the
3490
+ // archive plan above must be, a first import walked an empty list and left every product it had
3491
+ // just created with no inventory level at all. Re-read only when convergence wrote something: an
3492
+ // unchanged reconcile creates no mapping and keeps its statement budget.
3493
+ const levelled = converged.value.imported + converged.value.converged > 0
3494
+ ? await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)))
3495
+ : mappings;
3496
+ const inventory = await connector.fetchInventory(store, levelled.map((mapping) => mapping.externalId));
3469
3497
  if (!inventory.ok)
3470
3498
  return PluginErr(inventory.error.message);
3471
3499
  const existingLevels = await this.db.select().from(inventoryLevels).where(eq(inventoryLevels.organizationId, orgId));
3472
3500
  const inventoryService = this.services.inventory;
3473
3501
  let inventoryUpdated = 0;
3474
3502
  for (const level of inventory.value) {
3475
- const mapping = mappings.find((entry) => entry.externalId === level.externalId);
3503
+ const mapping = levelled.find((entry) => entry.externalId === level.externalId);
3476
3504
  if (!mapping)
3477
3505
  continue;
3478
3506
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.66.0",
3
+ "version": "0.68.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,15 +22,15 @@
22
22
  "dependencies": {
23
23
  "@hono/zod-openapi": "^1.2.2",
24
24
  "hono": "^4.12.5",
25
- "@porulle/core": "0.66.0"
25
+ "@porulle/core": "0.68.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
29
29
  "eslint": "^9.39.1",
30
30
  "typescript": "5.9.2",
31
31
  "vitest": "^3.2.4",
32
- "@porulle/eslint-config": "0.1.0",
33
- "@porulle/typescript-config": "0.1.0"
32
+ "@porulle/typescript-config": "0.1.0",
33
+ "@porulle/eslint-config": "0.1.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
package/src/index.ts CHANGED
@@ -238,6 +238,7 @@ export type {
238
238
  ChannelComplianceData,
239
239
  ChannelConnectorPluginOptions,
240
240
  ConfineStores,
241
+ ConnectClaims,
241
242
  OnStoreCatalogChanged,
242
243
  StoreConnectActor,
243
244
  StoreReadContext,
@@ -534,10 +535,15 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
534
535
  const typed = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
535
536
  const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
536
537
  if (!storeDomain) return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
538
+ // Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
539
+ // that shop's other grants, so a refusal at the callback would already have broken them.
540
+ const claims = await service.connectClaims({ orgId, actor, raw, storeDomain });
541
+ if (!claims.ok) return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
537
542
  const state = signState({
538
543
  provider,
539
544
  orgId,
540
545
  userId,
546
+ claims: claims.value,
541
547
  shopDomain: storeDomain,
542
548
  exp: Math.floor(Date.now() / 1000) + 600,
543
549
  jti: crypto.randomUUID(),
@@ -583,7 +589,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
583
589
  provider,
584
590
  storeDomain: verified.value.shopDomain,
585
591
  credentials: completed.value.credentials,
586
- }, { orgId: verified.value.orgId, userId: verified.value.userId });
592
+ }, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
587
593
  if (!connected.ok) return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
588
594
  return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
589
595
  };
@@ -665,6 +671,9 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
665
671
  webhookSecret: z.string().min(1).optional(),
666
672
  }))
667
673
  .handler(async ({ input, orgId, actor, raw }: ChannelRouteContext) => {
674
+ const requested = input as { provider: string; storeDomain: string };
675
+ const normalize = service.getConnector(requested.provider)?.normalizeStoreDomain;
676
+ const claims = unwrap(await service.connectClaims({ orgId, actor, raw, storeDomain: (normalize ? normalize(requested.storeDomain) : undefined) ?? requested.storeDomain }));
668
677
  return unwrap(await service.connectStore(
669
678
  orgId,
670
679
  input as {
@@ -673,7 +682,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
673
682
  storeDomain: string;
674
683
  webhookSecret?: string;
675
684
  },
676
- { orgId, userId: actor?.userId ?? null, raw },
685
+ { orgId, userId: actor?.userId ?? null, claims, raw },
677
686
  ));
678
687
  });
679
688
 
@@ -1,4 +1,4 @@
1
- import { Err, Ok } from "@porulle/core";
1
+ import { CHANNEL_CREDENTIALS_REJECTED, Err, Ok } from "@porulle/core";
2
2
  import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, Result } from "@porulle/core";
3
3
  import { and, eq, sql } from "@porulle/core/drizzle";
4
4
  import { connectedStores } from "./schema.js";
@@ -19,9 +19,9 @@ import { connectedStores } from "./schema.js";
19
19
  * token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
20
20
  * later call with a credential error nobody is shown.
21
21
  */
22
- export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>> {
22
+ export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options: { force?: boolean } = {}): Promise<Result<ChannelStore, ChannelConnectorError>> {
23
23
  if (!connector.liveCredentials) return Ok(store);
24
- const answer = await connector.liveCredentials(store);
24
+ const answer = await connector.liveCredentials(store, options);
25
25
  if (!answer.ok) {
26
26
  if (answer.error.retriable !== true) {
27
27
  await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
@@ -45,13 +45,20 @@ type StoreCall<A extends unknown[], T> = (store: ChannelStore, ...args: A) => Pr
45
45
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
46
46
  * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
47
47
  * credentials never expire.
48
+ *
49
+ * A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
50
+ * stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
48
51
  */
49
52
  export function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector {
50
53
  if (!connector.liveCredentials) return connector;
51
54
  const around = <A extends unknown[], T>(call: StoreCall<A, T>): StoreCall<A, T> => async (store, ...args) => {
52
55
  const current = await resolveLiveCredentials(connector, db, store);
53
56
  if (!current.ok) return current;
54
- return call.call(connector, current.value, ...args);
57
+ const first = await call.call(connector, current.value, ...args);
58
+ if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED) return first;
59
+ const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
60
+ if (!refreshed.ok) return refreshed;
61
+ return call.call(connector, refreshed.value, ...args);
55
62
  };
56
63
  return {
57
64
  ...connector,
@@ -5,6 +5,8 @@ export interface OAuthStatePayload {
5
5
  orgId: string;
6
6
  /** The signed-in user who started the connection: the callback arrives with no session of its own. */
7
7
  userId: string;
8
+ /** The consumer's connect claims, resolved at start. */
9
+ claims: Record<string, string>;
8
10
  shopDomain: string;
9
11
  exp: number;
10
12
  jti: string;
@@ -86,6 +88,10 @@ export function verifyState(
86
88
  !Number.isInteger(exp)
87
89
  ) return { ok: false, error: "Malformed OAuth state payload." };
88
90
  if (exp <= now) return { ok: false, error: "OAuth state has expired." };
91
+ const claims: unknown = (candidate as { claims?: unknown }).claims;
92
+ if (typeof claims !== "object" || claims === null || Object.values(claims).some((value) => typeof value !== "string")) {
93
+ return { ok: false, error: "Malformed OAuth state payload." };
94
+ }
89
95
 
90
96
  return { ok: true, value: candidate as OAuthStatePayload };
91
97
  }
package/src/service.ts CHANGED
@@ -254,10 +254,25 @@ export type ConfineStores = (context: StoreReadContext) => Promise<readonly stri
254
254
  export interface StoreConnectActor {
255
255
  orgId: string;
256
256
  userId: string | null;
257
+ /**
258
+ * What the consumer's {@link ConnectClaims} resolved when the connection started — e.g. which of
259
+ * the user's vendors the store is for. Carried in the signed OAuth state, so the callback, which has
260
+ * no session or headers of its own, binds the store to exactly what was chosen at the start.
261
+ */
262
+ claims: Readonly<Record<string, string>>;
257
263
  /** Core's request escape hatch when the connect is a request; absent on the OAuth callback. */
258
264
  raw?: unknown;
259
265
  }
260
266
 
267
+ /**
268
+ * Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
269
+ * later. Throw to refuse the start — before the merchant is sent to the provider, which matters: a
270
+ * provider such as Shopify retires a shop's other grants the moment a new one is issued, so a
271
+ * connection refused only at the callback has already broken the shop's existing one. `storeDomain` is
272
+ * the store being connected, canonicalised. Runs for OAuth start and for `POST /stores`.
273
+ */
274
+ export type ConnectClaims = (context: StoreReadContext & { storeDomain: string }) => Promise<Record<string, string>> | Record<string, string>;
275
+
261
276
  /**
262
277
  * Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
263
278
  * that wrote the store row — so a store and its binding commit together or not at all. Throw to
@@ -266,7 +281,7 @@ export interface StoreConnectActor {
266
281
  export type BindConnectedStore = (input: { db: PluginDb; store: ConnectedStore; actor: StoreConnectActor }) => Promise<void>;
267
282
 
268
283
  /** Work that follows a committed connection: the first import, provider-attested facts, keys. */
269
- export type AfterStoreConnected = (input: { store: ConnectedStore; actor: StoreConnectActor; connector: ChannelConnector }) => Promise<void>;
284
+ export type AfterStoreConnected = (input: { store: ConnectedStore; actor: StoreConnectActor; connector: ChannelConnector; services: Record<string, unknown> }) => Promise<void>;
270
285
 
271
286
  /** Entities a provider webhook just created or changed, converged; the host projects them. */
272
287
  export type OnStoreCatalogChanged = (input: { orgId: string; storeId: string; entityIds: string[]; convergence: CatalogPageConvergence }) => Promise<void>;
@@ -288,6 +303,8 @@ export interface ChannelConnectorPluginOptions {
288
303
  * unaffected.
289
304
  */
290
305
  confineStores?: ConfineStores;
306
+ /** See {@link ConnectClaims}. Absent, a connection carries no claims. */
307
+ connectClaims?: ConnectClaims;
291
308
  /** See {@link BindConnectedStore}. */
292
309
  bindConnectedStore?: BindConnectedStore;
293
310
  /** See {@link AfterStoreConnected}. */
@@ -2043,6 +2060,16 @@ export class ChannelConnectorService {
2043
2060
  continue;
2044
2061
  }
2045
2062
  variantIds.set(sourceVariant.externalId, variantId);
2063
+ // The provider's per-variant facts (the inventory item a stock webhook names, the weight shipping
2064
+ // prices by) merge into the variant per key, as entity metadata does: the source's keys overwrite
2065
+ // their own and nothing else. The import fast path writes them at creation; without this the
2066
+ // editor path — every reconcile — left them off, and a stock webhook could not find its variant.
2067
+ const sourceMetadata = sourceVariant.metadata ?? {};
2068
+ if (Object.keys(sourceMetadata).length > 0) {
2069
+ await this.db.update(variants)
2070
+ .set({ metadata: sql`coalesce(${variants.metadata}, '{}'::jsonb) || ${JSON.stringify(sourceMetadata)}::jsonb` })
2071
+ .where(and(eq(variants.id, variantId), sql`not (coalesce(${variants.metadata}, '{}'::jsonb) @> ${JSON.stringify(sourceMetadata)}::jsonb)`));
2072
+ }
2046
2073
  if (applyOptionValues) {
2047
2074
  const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
2048
2075
  .map(([name, value]) => optionValueIds.get(name)?.get(value))
@@ -3165,7 +3192,7 @@ export class ChannelConnectorService {
3165
3192
  }
3166
3193
  if (this.options.afterStoreConnected) {
3167
3194
  try {
3168
- await this.options.afterStoreConnected({ store, actor, connector });
3195
+ await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
3169
3196
  } catch (error) {
3170
3197
  return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
3171
3198
  }
@@ -3183,6 +3210,16 @@ export class ChannelConnectorService {
3183
3210
  return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
3184
3211
  }
3185
3212
 
3213
+ /** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
3214
+ async connectClaims(context: StoreReadContext & { storeDomain: string }): Promise<PluginResult<Record<string, string>>> {
3215
+ if (!this.options.connectClaims) return Ok({});
3216
+ try {
3217
+ return Ok(await this.options.connectClaims(context));
3218
+ } catch (error) {
3219
+ return PluginErr(error instanceof Error ? error.message : "The connection could not be started.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "CONNECT_REFUSED");
3220
+ }
3221
+ }
3222
+
3186
3223
  /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
3187
3224
  private async allowedStores(orgId: string, context: StoreReadContext | undefined): Promise<readonly string[] | null> {
3188
3225
  return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : null;
@@ -4522,7 +4559,14 @@ export class ChannelConnectorService {
4522
4559
  }
4523
4560
  }
4524
4561
 
4525
- const inventory = await connector.fetchInventory(store as ChannelStore, mappings.map((mapping) => mapping.externalId));
4562
+ // Stock is levelled against the mappings as they stand AFTER convergence. Read before it, as the
4563
+ // archive plan above must be, a first import walked an empty list and left every product it had
4564
+ // just created with no inventory level at all. Re-read only when convergence wrote something: an
4565
+ // unchanged reconcile creates no mapping and keeps its statement budget.
4566
+ const levelled = converged.value.imported + converged.value.converged > 0
4567
+ ? await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)))
4568
+ : mappings;
4569
+ const inventory = await connector.fetchInventory(store as ChannelStore, levelled.map((mapping) => mapping.externalId));
4526
4570
  if (!inventory.ok) return PluginErr(inventory.error.message);
4527
4571
  const existingLevels = await this.db.select().from(inventoryLevels).where(eq(inventoryLevels.organizationId, orgId));
4528
4572
  const inventoryService = this.services.inventory as {
@@ -4530,7 +4574,7 @@ export class ChannelConnectorService {
4530
4574
  };
4531
4575
  let inventoryUpdated = 0;
4532
4576
  for (const level of inventory.value) {
4533
- const mapping = mappings.find((entry) => entry.externalId === level.externalId);
4577
+ const mapping = levelled.find((entry) => entry.externalId === level.externalId);
4534
4578
  if (!mapping) continue;
4535
4579
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
4536
4580
  // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.