@porulle/plugin-channel-connector 0.42.0 → 0.43.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.js CHANGED
@@ -529,7 +529,7 @@ export function channelConnectorPlugin(options = {}) {
529
529
  channels.get("/stores")
530
530
  .summary("List connected channel stores")
531
531
  .permission("channels:read")
532
- .handler(async ({ orgId }) => unwrap(await service.listStores(orgId)));
532
+ .handler(async ({ orgId, actor, raw }) => unwrap(await service.listStores(orgId, { orgId, actor, raw })));
533
533
  channels.get("/stores/{id}")
534
534
  .summary("Get a connected channel store")
535
535
  .permission("channels:read")
package/dist/service.d.ts CHANGED
@@ -84,8 +84,38 @@ export interface ChannelComplianceData {
84
84
  customerData: NonNullable<ChannelOrderExport["customerData"]>;
85
85
  }>;
86
86
  }
87
+ /**
88
+ * What a consumer is allowed to read, resolved per request.
89
+ *
90
+ * Returning `null` means "do not confine" and is the default — every existing consumer keeps the
91
+ * organization-wide behaviour. An array is the complete set of store ids this caller may see, and
92
+ * **`[]` means none**, not "no filter".
93
+ *
94
+ * It takes IDS rather than a tenant, deliberately. `vendor`, `seller`, `team` are models a consumer
95
+ * owns; this package is generic commerce and acquiring one of them here would push a marketplace
96
+ * concept into every deployment that has no such thing. The consumer resolves the meaning and hands
97
+ * back the answer.
98
+ */
99
+ export type ConfineStoreReads = (context: StoreReadContext) => Promise<readonly string[] | null> | readonly string[] | null;
100
+ /** What a consumer needs to resolve the caller. `raw` is core's documented request escape hatch. */
101
+ export interface StoreReadContext {
102
+ orgId: string;
103
+ actor: {
104
+ userId: string | null;
105
+ [key: string]: unknown;
106
+ } | null;
107
+ raw: unknown;
108
+ }
87
109
  export interface ChannelConnectorPluginOptions {
88
110
  connectors?: ChannelConnector[];
111
+ /**
112
+ * Confines store reads to a set the consumer chooses. See {@link ConfineStoreReads}.
113
+ *
114
+ * Absent by default, because narrowing an existing read for every deployment would be a breaking
115
+ * change to a published package. A consumer that needs confinement opts in; one that does not is
116
+ * unaffected.
117
+ */
118
+ confineStoreReads?: ConfineStoreReads;
89
119
  oauth?: {
90
120
  stateSecret: string;
91
121
  postConnectRedirect: string;
@@ -241,7 +271,7 @@ export declare class ChannelConnectorService {
241
271
  disconnectStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>>;
242
272
  disconnectStoreSystem(orgId: string, id: string, redactDomain?: boolean): Promise<PluginResult<PublicConnectedStore>>;
243
273
  getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>>;
244
- listStores(orgId: string): Promise<PluginResult<PublicConnectedStore[]>>;
274
+ listStores(orgId: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore[]>>;
245
275
  validateLineStock(orgId: string, lines: ChannelStockLine[], timeoutMs?: number): Promise<void>;
246
276
  importCatalog(orgId: string, storeId: string, actor: Actor, options: {
247
277
  maxItems: number;
package/dist/service.js CHANGED
@@ -1612,11 +1612,25 @@ export class ChannelConnectorService {
1612
1612
  return PluginErr("Connected store not found.", "NOT_FOUND");
1613
1613
  return Ok(redactStore(store));
1614
1614
  }
1615
- async listStores(orgId) {
1616
- const rows = await this.db
1617
- .select()
1618
- .from(connectedStores)
1619
- .where(eq(connectedStores.organizationId, orgId));
1615
+ async listStores(orgId, context) {
1616
+ const allowed = this.options.confineStoreReads
1617
+ ? await this.options.confineStoreReads(context ?? { orgId, actor: null, raw: undefined })
1618
+ : null;
1619
+ // An empty allow-list means the caller may read NOTHING, stated here rather than left to the
1620
+ // query builder.
1621
+ //
1622
+ // MEASURED, because the first version of this comment claimed the guard was load-bearing and it
1623
+ // is not: removing this line leaves every row in `confine-store-reads.test.ts` green, so drizzle
1624
+ // already turns `inArray(id, [])` into a predicate that matches nothing. The guard is therefore
1625
+ // the CONTRACT rather than the rescue — `[]` means none, whatever the builder does with an empty
1626
+ // array on some future dialect or version. Worth keeping for that reason and not worth claiming
1627
+ // more for: the row that covers this case is really watching drizzle, not this line.
1628
+ if (allowed !== null && allowed.length === 0)
1629
+ return Ok([]);
1630
+ const predicate = allowed === null
1631
+ ? eq(connectedStores.organizationId, orgId)
1632
+ : and(eq(connectedStores.organizationId, orgId), inArray(connectedStores.id, [...allowed]));
1633
+ const rows = await this.db.select().from(connectedStores).where(predicate);
1620
1634
  return Ok(rows.map(redactStore));
1621
1635
  }
1622
1636
  async validateLineStock(orgId, lines, timeoutMs = 3_000) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,7 +19,7 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.42.0"
22
+ "@porulle/core": "0.43.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -44,6 +44,13 @@ type ChannelRouteContext = {
44
44
  params: Record<string, string>;
45
45
  orgId: string;
46
46
  actor: { userId: string | null } | null;
47
+ /**
48
+ * Core's documented request escape hatch, already present on `RouteHandlerContext` and simply
49
+ * never declared here. Read by `confineStoreReads` so a consumer can resolve the caller from the
50
+ * request — core's `actor.vendorId` cannot serve that purpose: core sources it from a column on
51
+ * `user` that this deployment never writes, and sets it to `null` outright for API-key actors.
52
+ */
53
+ raw?: unknown;
47
54
  };
48
55
 
49
56
  export { mockChannelConnector } from "./mock-connector.js";
@@ -655,7 +662,8 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
655
662
  channels.get("/stores")
656
663
  .summary("List connected channel stores")
657
664
  .permission("channels:read")
658
- .handler(async ({ orgId }: ChannelRouteContext) => unwrap(await service.listStores(orgId)));
665
+ .handler(async ({ orgId, actor, raw }: ChannelRouteContext) =>
666
+ unwrap(await service.listStores(orgId, { orgId, actor, raw })));
659
667
 
660
668
  channels.get("/stores/{id}")
661
669
  .summary("Get a connected channel store")
package/src/service.ts CHANGED
@@ -214,8 +214,37 @@ export interface ChannelComplianceData {
214
214
  }>;
215
215
  }
216
216
 
217
+ /**
218
+ * What a consumer is allowed to read, resolved per request.
219
+ *
220
+ * Returning `null` means "do not confine" and is the default — every existing consumer keeps the
221
+ * organization-wide behaviour. An array is the complete set of store ids this caller may see, and
222
+ * **`[]` means none**, not "no filter".
223
+ *
224
+ * It takes IDS rather than a tenant, deliberately. `vendor`, `seller`, `team` are models a consumer
225
+ * owns; this package is generic commerce and acquiring one of them here would push a marketplace
226
+ * concept into every deployment that has no such thing. The consumer resolves the meaning and hands
227
+ * back the answer.
228
+ */
229
+ export type ConfineStoreReads = (context: StoreReadContext) => Promise<readonly string[] | null> | readonly string[] | null;
230
+
231
+ /** What a consumer needs to resolve the caller. `raw` is core's documented request escape hatch. */
232
+ export interface StoreReadContext {
233
+ orgId: string;
234
+ actor: { userId: string | null; [key: string]: unknown } | null;
235
+ raw: unknown;
236
+ }
237
+
217
238
  export interface ChannelConnectorPluginOptions {
218
239
  connectors?: ChannelConnector[];
240
+ /**
241
+ * Confines store reads to a set the consumer chooses. See {@link ConfineStoreReads}.
242
+ *
243
+ * Absent by default, because narrowing an existing read for every deployment would be a breaking
244
+ * change to a published package. A consumer that needs confinement opts in; one that does not is
245
+ * unaffected.
246
+ */
247
+ confineStoreReads?: ConfineStoreReads;
219
248
  oauth?: { stateSecret: string; postConnectRedirect: string };
220
249
  inventoryTimeoutMs?: number;
221
250
  jobs?: JobsAdapter;
@@ -2266,11 +2295,24 @@ export class ChannelConnectorService {
2266
2295
  return Ok(redactStore(store));
2267
2296
  }
2268
2297
 
2269
- async listStores(orgId: string): Promise<PluginResult<PublicConnectedStore[]>> {
2270
- const rows = await this.db
2271
- .select()
2272
- .from(connectedStores)
2273
- .where(eq(connectedStores.organizationId, orgId));
2298
+ async listStores(orgId: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore[]>> {
2299
+ const allowed = this.options.confineStoreReads
2300
+ ? await this.options.confineStoreReads(context ?? { orgId, actor: null, raw: undefined })
2301
+ : null;
2302
+ // An empty allow-list means the caller may read NOTHING, stated here rather than left to the
2303
+ // query builder.
2304
+ //
2305
+ // MEASURED, because the first version of this comment claimed the guard was load-bearing and it
2306
+ // is not: removing this line leaves every row in `confine-store-reads.test.ts` green, so drizzle
2307
+ // already turns `inArray(id, [])` into a predicate that matches nothing. The guard is therefore
2308
+ // the CONTRACT rather than the rescue — `[]` means none, whatever the builder does with an empty
2309
+ // array on some future dialect or version. Worth keeping for that reason and not worth claiming
2310
+ // more for: the row that covers this case is really watching drizzle, not this line.
2311
+ if (allowed !== null && allowed.length === 0) return Ok([]);
2312
+ const predicate = allowed === null
2313
+ ? eq(connectedStores.organizationId, orgId)
2314
+ : and(eq(connectedStores.organizationId, orgId), inArray(connectedStores.id, [...allowed]));
2315
+ const rows = await this.db.select().from(connectedStores).where(predicate);
2274
2316
  return Ok((rows as ConnectedStore[]).map(redactStore));
2275
2317
  }
2276
2318