@porulle/plugin-channel-connector 0.66.0 → 0.67.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
@@ -361,6 +361,9 @@ export function channelConnectorPlugin(options = {}) {
361
361
  const userId = actor?.userId;
362
362
  if (!userId)
363
363
  return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
364
+ const claims = await service.connectClaims({ orgId, actor, raw });
365
+ if (!claims.ok)
366
+ return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
364
367
  const typed = String(query.shop ?? query.store ?? "");
365
368
  const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
366
369
  if (!storeDomain)
@@ -369,6 +372,7 @@ export function channelConnectorPlugin(options = {}) {
369
372
  provider,
370
373
  orgId,
371
374
  userId,
375
+ claims: claims.value,
372
376
  shopDomain: storeDomain,
373
377
  exp: Math.floor(Date.now() / 1000) + 600,
374
378
  jti: crypto.randomUUID(),
@@ -423,7 +427,7 @@ export function channelConnectorPlugin(options = {}) {
423
427
  provider,
424
428
  storeDomain: verified.value.shopDomain,
425
429
  credentials: completed.value.credentials,
426
- }, { orgId: verified.value.orgId, userId: verified.value.userId });
430
+ }, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
427
431
  if (!connected.ok)
428
432
  return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
429
433
  return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
@@ -510,7 +514,8 @@ export function channelConnectorPlugin(options = {}) {
510
514
  webhookSecret: z.string().min(1).optional(),
511
515
  }))
512
516
  .handler(async ({ input, orgId, actor, raw }) => {
513
- return unwrap(await service.connectStore(orgId, input, { orgId, userId: actor?.userId ?? null, raw }));
517
+ const claims = unwrap(await service.connectClaims({ orgId, actor, raw }));
518
+ return unwrap(await service.connectStore(orgId, input, { orgId, userId: actor?.userId ?? null, claims, raw }));
514
519
  });
515
520
  channels.get("/stores")
516
521
  .summary("List connected channel stores")
@@ -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,20 @@ 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. Runs for OAuth start and for `POST /stores`.
124
+ */
125
+ export type ConnectClaims = (context: StoreReadContext) => Promise<Record<string, string>> | Record<string, string>;
115
126
  /**
116
127
  * Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
117
128
  * that wrote the store row — so a store and its binding commit together or not at all. Throw to
@@ -127,6 +138,7 @@ export type AfterStoreConnected = (input: {
127
138
  store: ConnectedStore;
128
139
  actor: StoreConnectActor;
129
140
  connector: ChannelConnector;
141
+ services: Record<string, unknown>;
130
142
  }) => Promise<void>;
131
143
  /** Entities a provider webhook just created or changed, converged; the host projects them. */
132
144
  export type OnStoreCatalogChanged = (input: {
@@ -154,6 +166,8 @@ export interface ChannelConnectorPluginOptions {
154
166
  * unaffected.
155
167
  */
156
168
  confineStores?: ConfineStores;
169
+ /** See {@link ConnectClaims}. Absent, a connection carries no claims. */
170
+ connectClaims?: ConnectClaims;
157
171
  /** See {@link BindConnectedStore}. */
158
172
  bindConnectedStore?: BindConnectedStore;
159
173
  /** See {@link AfterStoreConnected}. */
@@ -495,6 +509,8 @@ export declare class ChannelConnectorService {
495
509
  }, actor: StoreConnectActor): Promise<PluginResult<PublicConnectedStore>>;
496
510
  /** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
497
511
  liveStore(orgId: string, storeId: string): Promise<PluginResult<ChannelStore>>;
512
+ /** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
513
+ connectClaims(context: StoreReadContext): Promise<PluginResult<Record<string, string>>>;
498
514
  /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
499
515
  private allowedStores;
500
516
  /** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
package/dist/service.js CHANGED
@@ -2269,7 +2269,7 @@ export class ChannelConnectorService {
2269
2269
  }
2270
2270
  if (this.options.afterStoreConnected) {
2271
2271
  try {
2272
- await this.options.afterStoreConnected({ store, actor, connector });
2272
+ await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
2273
2273
  }
2274
2274
  catch (error) {
2275
2275
  return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
@@ -2288,6 +2288,17 @@ export class ChannelConnectorService {
2288
2288
  const live = await resolveLiveCredentials(connector, this.db, store);
2289
2289
  return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
2290
2290
  }
2291
+ /** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
2292
+ async connectClaims(context) {
2293
+ if (!this.options.connectClaims)
2294
+ return Ok({});
2295
+ try {
2296
+ return Ok(await this.options.connectClaims(context));
2297
+ }
2298
+ catch (error) {
2299
+ return PluginErr(error instanceof Error ? error.message : "The connection could not be started.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "CONNECT_REFUSED");
2300
+ }
2301
+ }
2291
2302
  /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
2292
2303
  async allowedStores(orgId, context) {
2293
2304
  return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : 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.67.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,7 +22,7 @@
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.67.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
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,
@@ -531,6 +532,8 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
531
532
  // here, carried in the signed state. A caller with no user (an API key) cannot start one.
532
533
  const userId = actor?.userId;
533
534
  if (!userId) return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
535
+ const claims = await service.connectClaims({ orgId, actor, raw });
536
+ if (!claims.ok) return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
534
537
  const typed = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
535
538
  const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
536
539
  if (!storeDomain) return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
@@ -538,6 +541,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
538
541
  provider,
539
542
  orgId,
540
543
  userId,
544
+ claims: claims.value,
541
545
  shopDomain: storeDomain,
542
546
  exp: Math.floor(Date.now() / 1000) + 600,
543
547
  jti: crypto.randomUUID(),
@@ -583,7 +587,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
583
587
  provider,
584
588
  storeDomain: verified.value.shopDomain,
585
589
  credentials: completed.value.credentials,
586
- }, { orgId: verified.value.orgId, userId: verified.value.userId });
590
+ }, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
587
591
  if (!connected.ok) return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
588
592
  return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
589
593
  };
@@ -665,6 +669,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
665
669
  webhookSecret: z.string().min(1).optional(),
666
670
  }))
667
671
  .handler(async ({ input, orgId, actor, raw }: ChannelRouteContext) => {
672
+ const claims = unwrap(await service.connectClaims({ orgId, actor, raw }));
668
673
  return unwrap(await service.connectStore(
669
674
  orgId,
670
675
  input as {
@@ -673,7 +678,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
673
678
  storeDomain: string;
674
679
  webhookSecret?: string;
675
680
  },
676
- { orgId, userId: actor?.userId ?? null, raw },
681
+ { orgId, userId: actor?.userId ?? null, claims, raw },
677
682
  ));
678
683
  });
679
684
 
@@ -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,22 @@ 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. Runs for OAuth start and for `POST /stores`.
270
+ */
271
+ export type ConnectClaims = (context: StoreReadContext) => Promise<Record<string, string>> | Record<string, string>;
272
+
261
273
  /**
262
274
  * Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
263
275
  * that wrote the store row — so a store and its binding commit together or not at all. Throw to
@@ -266,7 +278,7 @@ export interface StoreConnectActor {
266
278
  export type BindConnectedStore = (input: { db: PluginDb; store: ConnectedStore; actor: StoreConnectActor }) => Promise<void>;
267
279
 
268
280
  /** 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>;
281
+ export type AfterStoreConnected = (input: { store: ConnectedStore; actor: StoreConnectActor; connector: ChannelConnector; services: Record<string, unknown> }) => Promise<void>;
270
282
 
271
283
  /** Entities a provider webhook just created or changed, converged; the host projects them. */
272
284
  export type OnStoreCatalogChanged = (input: { orgId: string; storeId: string; entityIds: string[]; convergence: CatalogPageConvergence }) => Promise<void>;
@@ -288,6 +300,8 @@ export interface ChannelConnectorPluginOptions {
288
300
  * unaffected.
289
301
  */
290
302
  confineStores?: ConfineStores;
303
+ /** See {@link ConnectClaims}. Absent, a connection carries no claims. */
304
+ connectClaims?: ConnectClaims;
291
305
  /** See {@link BindConnectedStore}. */
292
306
  bindConnectedStore?: BindConnectedStore;
293
307
  /** See {@link AfterStoreConnected}. */
@@ -3165,7 +3179,7 @@ export class ChannelConnectorService {
3165
3179
  }
3166
3180
  if (this.options.afterStoreConnected) {
3167
3181
  try {
3168
- await this.options.afterStoreConnected({ store, actor, connector });
3182
+ await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
3169
3183
  } catch (error) {
3170
3184
  return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
3171
3185
  }
@@ -3183,6 +3197,16 @@ export class ChannelConnectorService {
3183
3197
  return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
3184
3198
  }
3185
3199
 
3200
+ /** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
3201
+ async connectClaims(context: StoreReadContext): Promise<PluginResult<Record<string, string>>> {
3202
+ if (!this.options.connectClaims) return Ok({});
3203
+ try {
3204
+ return Ok(await this.options.connectClaims(context));
3205
+ } catch (error) {
3206
+ return PluginErr(error instanceof Error ? error.message : "The connection could not be started.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "CONNECT_REFUSED");
3207
+ }
3208
+ }
3209
+
3186
3210
  /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
3187
3211
  private async allowedStores(orgId: string, context: StoreReadContext | undefined): Promise<readonly string[] | null> {
3188
3212
  return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : null;