@cosmicdrift/kumiko-framework 0.266.0 → 0.269.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.
Files changed (56) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
  6. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  7. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
  8. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
  9. package/src/api/__tests__/jwt.test.ts +8 -0
  10. package/src/api/__tests__/stream-escape-hatch.integration.test.ts +203 -0
  11. package/src/api/jwt.ts +8 -0
  12. package/src/changes.json +28 -0
  13. package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
  14. package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
  15. package/src/db/__tests__/tenant-db-global.test.ts +95 -5
  16. package/src/db/entity-table-meta.ts +7 -0
  17. package/src/db/event-store-executor-context.ts +19 -0
  18. package/src/db/event-store-executor-write.ts +16 -0
  19. package/src/db/index.ts +3 -1
  20. package/src/db/table-builder.ts +27 -0
  21. package/src/db/tenant-db.ts +83 -10
  22. package/src/engine/__tests__/extends-registrar.test.ts +1 -0
  23. package/src/engine/__tests__/hook-identity-switch-grant.test.ts +4 -2
  24. package/src/engine/active-membership.ts +15 -1
  25. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +37 -0
  26. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
  27. package/src/engine/boot-validator/access-declarations.ts +6 -1
  28. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  29. package/src/engine/boot-validator/index.ts +2 -0
  30. package/src/engine/feature-entity-handlers.ts +7 -1
  31. package/src/engine/feature-ui-extensions.ts +2 -2
  32. package/src/engine/index.ts +3 -0
  33. package/src/engine/registry-validate.ts +6 -6
  34. package/src/engine/steps/call-feature.ts +1 -1
  35. package/src/engine/types/index.ts +2 -0
  36. package/src/entrypoint/index.ts +2 -13
  37. package/src/errors/i18n/de.yaml +36 -0
  38. package/src/errors/i18n/en.yaml +34 -0
  39. package/src/errors/reasons.ts +13 -0
  40. package/src/jobs/job-runner.ts +14 -0
  41. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  42. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  43. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
  44. package/src/pipeline/__tests__/system-identity-switch.test.ts +244 -33
  45. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  46. package/src/pipeline/active-membership.ts +3 -1
  47. package/src/pipeline/dispatch-shared.ts +107 -20
  48. package/src/pipeline/dispatch-stream.ts +9 -5
  49. package/src/pipeline/dispatch-write.ts +7 -0
  50. package/src/pipeline/dispatcher-utils.ts +1 -0
  51. package/src/pipeline/dispatcher.ts +18 -0
  52. package/src/pipeline/index.ts +1 -1
  53. package/src/pipeline/member-reader.ts +108 -0
  54. package/src/pipeline/system-identity-switch.ts +142 -25
  55. package/src/stack/test-stack.ts +10 -1
  56. package/src/testing/handler-context.ts +3 -0
@@ -2,11 +2,18 @@ import { describe, expect, test } from "bun:test";
2
2
  import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
3
3
  import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
4
4
  import { createEntity, createTextField } from "../../engine";
5
+ import { AccessDeniedError } from "../../errors";
5
6
  import { testTenantId } from "../../stack";
6
7
  import type { DbRunner } from "../connection";
8
+ import type { TableColumns } from "../dialect";
7
9
  import { defineUnmanagedTable, deriveEntityTableMeta } from "../entity-table-meta";
8
10
  import { buildEntityTable } from "../table-builder";
9
- import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../tenant-db";
11
+ import {
12
+ createTenantDb,
13
+ createUncheckedSystemDb,
14
+ type TenantDb,
15
+ withUnsafeRawGrant,
16
+ } from "../tenant-db";
10
17
 
11
18
  // db.global()'s runtime + write-gate behaviour (fw#2855). No Postgres needed —
12
19
  // the stub runner throws on first touch, so a test that reaches it fails loudly
@@ -125,9 +132,7 @@ describe("TenantDb.global()", () => {
125
132
  undefined,
126
133
  undefined,
127
134
  undefined,
128
- {
129
- reason: " ",
130
- },
135
+ { globalWrites: { reason: " " } },
131
136
  );
132
137
  await expect(tdb.global(globalUnmanagedTable).insertOne({ name: "x" })).rejects.toThrow(
133
138
  /escapeHatch/,
@@ -146,11 +151,96 @@ describe("TenantDb.global()", () => {
146
151
  },
147
152
  } as DbRunner;
148
153
  const tdb = createTenantDb(db, own, "tenant", undefined, undefined, undefined, {
149
- reason: "cross-tenant backfill job",
154
+ globalWrites: { reason: "cross-tenant backfill job" },
150
155
  });
151
156
  await tdb.global(globalUnmanagedTable).insertOne({ name: "x" });
152
157
  expect(captured).toHaveLength(1);
153
158
  });
159
+
160
+ test("write with an explicit non-SYSTEM tenantId rejects even with a valid escapeHatch", async () => {
161
+ const tdb = createTenantDb(
162
+ unreachableRunner(),
163
+ own,
164
+ "tenant",
165
+ undefined,
166
+ undefined,
167
+ undefined,
168
+ {
169
+ globalWrites: { reason: "cross-tenant backfill job" },
170
+ },
171
+ );
172
+ await expect(
173
+ tdb.global(globalUnmanagedTable).insertOne({ name: "x", tenantId: own }),
174
+ ).rejects.toThrow(/SYSTEM/);
175
+ });
176
+
177
+ test("plain (non-global) insertOne on a global-tenancy managed table (has tenant_id) with a foreign tenant rejects before touching the runner", async () => {
178
+ const tdb = createTenantDb(unreachableRunner(), own);
179
+ // insertOne's WritableTable param types out branded EntityTables — same
180
+ // unbranded-view cast as tenant-db-global.integration.test.ts; runtime
181
+ // behavior only reads the table's EntityTableMeta, not the type brand.
182
+ await expect(
183
+ tdb.insertOne(globalManagedTable as unknown as TableColumns, { name: "x" }),
184
+ ).rejects.toThrow(/SYSTEM/);
185
+ });
186
+ });
187
+
188
+ describe("TenantDb.unsafeRaw()", () => {
189
+ test("without a grant throws AccessDeniedError without touching the runner", () => {
190
+ const tdb = createTenantDb(unreachableRunner(), own);
191
+ expect(() => tdb.unsafeRaw("some reason")).toThrow(AccessDeniedError);
192
+ });
193
+
194
+ test("an empty reason throws regardless of grant", () => {
195
+ const tdb = createTenantDb(
196
+ unreachableRunner(),
197
+ own,
198
+ "tenant",
199
+ undefined,
200
+ undefined,
201
+ undefined,
202
+ {
203
+ unsafeRaw: { reason: "granted" },
204
+ },
205
+ );
206
+ expect(() => tdb.unsafeRaw("")).toThrow();
207
+ expect(() => tdb.unsafeRaw(" ")).toThrow();
208
+ });
209
+
210
+ test("with a valid grant returns the underlying DbRunner", () => {
211
+ const rawDb = unreachableRunner();
212
+ const tdb = createTenantDb(rawDb, own, "tenant", undefined, undefined, undefined, {
213
+ unsafeRaw: { reason: "granted" },
214
+ });
215
+ expect(tdb.unsafeRaw("some reason")).toBe(rawDb);
216
+ });
217
+ });
218
+
219
+ describe("withUnsafeRawGrant", () => {
220
+ test("rebinding to a grant makes unsafeRaw work", () => {
221
+ const rawDb = unreachableRunner();
222
+ const tdb = createTenantDb(rawDb, own);
223
+ expect(() => tdb.unsafeRaw("x")).toThrow(AccessDeniedError);
224
+
225
+ const rebound = withUnsafeRawGrant(tdb, { reason: "narrower hook grant" });
226
+ expect(rebound.unsafeRaw("x")).toBe(rawDb);
227
+ });
228
+
229
+ test("rebinding to undefined makes unsafeRaw throw, even if the original had a grant", () => {
230
+ const rawDb = unreachableRunner();
231
+ const tdb = createTenantDb(rawDb, own, "tenant", undefined, undefined, undefined, {
232
+ unsafeRaw: { reason: "handler grant" },
233
+ });
234
+ expect(tdb.unsafeRaw("x")).toBe(rawDb);
235
+
236
+ const rebound = withUnsafeRawGrant(tdb, undefined);
237
+ expect(() => rebound.unsafeRaw("x")).toThrow(AccessDeniedError);
238
+ });
239
+
240
+ test("passes an object not built by createTenantDb through unchanged", () => {
241
+ const notATenantDb = { some: "stub" } as unknown as TenantDb;
242
+ expect(withUnsafeRawGrant(notATenantDb, { reason: "x" })).toBe(notATenantDb);
243
+ });
154
244
  });
155
245
 
156
246
  describe("UncheckedSystemDb.unsafeRaw", () => {
@@ -459,6 +459,13 @@ export function defineUnmanagedTable<const T extends EntityTenancy = "tenant">(
459
459
  input: UnmanagedTableInput & { readonly tenancy?: T },
460
460
  ): EntityTableMeta & TenancyBrand<T> {
461
461
  assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
462
+ if (input.tenancy === "global" && input.columns.some((c) => c.name === "tenant_id")) {
463
+ throw new Error(
464
+ `defineUnmanagedTable("${input.tableName}"): tenancy: "global" unmanaged tables must not ` +
465
+ "have a tenant_id column — a global table's rows carry the system tenant, not a per-row " +
466
+ "tenant identity.",
467
+ );
468
+ }
462
469
  return {
463
470
  tableName: input.tableName,
464
471
  columns: input.columns,
@@ -80,6 +80,16 @@ export function buildFilterWhere(
80
80
  // doesn't carry a default or no default was declared. Only scalar types
81
81
  // (text/number/boolean/select) support creation-time defaults — money/date/
82
82
  // file/embedded fields don't.
83
+ // A "global" entity's stream must live on SYSTEM_TENANT_ID — otherwise the
84
+ // projection row and event stream would disagree on which tenant "owns" it.
85
+ export function isForeignTenantOnGlobalEntity(
86
+ entity: EntityDefinition,
87
+ tenantIdValue: unknown,
88
+ ): boolean {
89
+ if (entity.tenancy !== "global") return false;
90
+ return tenantIdValue !== undefined && tenantIdValue !== SYSTEM_TENANT_ID;
91
+ }
92
+
83
93
  function scalarDefault(field: FieldDefinition): unknown {
84
94
  switch (field.type) {
85
95
  case "text":
@@ -180,6 +190,15 @@ export function buildExecutorContext(
180
190
  const { searchAdapter, entityName, entityCache } = options;
181
191
  const softDelete = entity.softDelete ?? false;
182
192
 
193
+ // Without systemStream: true, a "global" entity would silently fork one event stream per creating tenant.
194
+ if (entity.tenancy === "global" && entity.systemStream !== true) {
195
+ throw new Error(
196
+ `event-store-executor requires entity "${entityName}" to declare systemStream: true ` +
197
+ 'when tenancy: "global" is set — a global entity\'s event stream must live on the ' +
198
+ "system tenant, not the creating tenant's.",
199
+ );
200
+ }
201
+
183
202
  // Stream-tenant choke-point. A systemStream entity (tenant-independent, e.g.
184
203
  // user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
185
204
  // there. Everything else stays on the caller's tenant (byte-identical to the
@@ -3,6 +3,7 @@ import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership
3
3
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
4
4
  import type { EntityId, SessionUser } from "../engine/types";
5
5
  import {
6
+ AccessDeniedError,
6
7
  VersionConflictError as FrameworkVersionConflict,
7
8
  IdempotentReplayError,
8
9
  InternalError,
@@ -25,6 +26,7 @@ import {
25
26
  buildEventMetadata,
26
27
  type ExecutorContext,
27
28
  entityEventName,
29
+ isForeignTenantOnGlobalEntity,
28
30
  tryMapUniqueViolation,
29
31
  } from "./event-store-executor-context";
30
32
  import { runInSavepointIfSupported } from "./query";
@@ -115,6 +117,13 @@ export function createWriteVerbs(
115
117
 
116
118
  return {
117
119
  async create(payload, user, db, options) {
120
+ if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
121
+ throw new AccessDeniedError({
122
+ message:
123
+ `${entityName}.create: entity is tenancy: "global" — payload.tenantId must be ` +
124
+ "SYSTEM_TENANT_ID or omitted.",
125
+ });
126
+ }
118
127
  // Respect an explicit id in the payload (seed pattern, SCIM import). Without
119
128
  // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
120
129
  // event payload so defaults + downstream consumers don't see a redundant id field.
@@ -299,6 +308,13 @@ export function createWriteVerbs(
299
308
  },
300
309
 
301
310
  async update(payload, user, db, updateOptions) {
311
+ if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
312
+ throw new AccessDeniedError({
313
+ message:
314
+ `${entityName}.update: entity is tenancy: "global" — payload.changes.tenantId must ` +
315
+ "be SYSTEM_TENANT_ID or omitted.",
316
+ });
317
+ }
302
318
  const previous = await loadById(payload.id, db);
303
319
  if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
304
320
 
package/src/db/index.ts CHANGED
@@ -146,14 +146,16 @@ export { tableExists } from "./schema-inspection";
146
146
  export {
147
147
  buildBaseColumns,
148
148
  buildEntityTable,
149
+ declareGlobalTenancy,
149
150
  type EntityTable,
150
151
  toSnakeCase,
151
152
  toTableName,
152
153
  } from "./table-builder";
153
- export type { TenantDb, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
154
+ export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
154
155
  export {
155
156
  castTenantRows,
156
157
  createTenantDb,
157
158
  createUncheckedSystemDb,
158
159
  SYSTEM_SCOPE_CHECK_BRAND,
160
+ withUnsafeRawGrant,
159
161
  } from "./tenant-db";
@@ -29,6 +29,7 @@ import {
29
29
  moneyAmount,
30
30
  table as pgTable,
31
31
  plainDate,
32
+ type SchemaTable,
32
33
  SQL_EXPR_BRAND,
33
34
  type SqlExpression,
34
35
  serial,
@@ -505,6 +506,32 @@ function stampGlobalTenancyMeta<E extends EntityDefinition>(
505
506
  };
506
507
  }
507
508
 
509
+ /**
510
+ * Stamps `tenancy: "global"` onto a `table()`/SchemaTable's meta in place.
511
+ * Throws if the table has a `tenant_id` column or no EntityTableMeta to stamp.
512
+ */
513
+ export function declareGlobalTenancy<T extends SchemaTable>(table: T): T & TenancyBrand<"global"> {
514
+ const meta = (table as unknown as Record<symbol, EntityTableMeta | undefined>)[
515
+ KUMIKO_META_SYMBOL
516
+ ];
517
+ if (!meta) {
518
+ throw new Error("declareGlobalTenancy(): table has no EntityTableMeta to stamp.");
519
+ }
520
+ if (meta.columns.some((c) => c.name === "tenant_id")) {
521
+ throw new Error(
522
+ `declareGlobalTenancy("${meta.tableName}"): tables with a "tenant_id" column cannot be ` +
523
+ 'declared tenancy: "global" — a global table\'s rows carry the system tenant, not a ' +
524
+ "per-row tenant identity.",
525
+ );
526
+ }
527
+ (table as unknown as Record<symbol, EntityTableMeta>)[KUMIKO_META_SYMBOL] = {
528
+ ...meta,
529
+ tenancy: "global",
530
+ };
531
+ // @cast-boundary type-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
532
+ return table as T & TenancyBrand<"global">;
533
+ }
534
+
508
535
  export function buildEntityTable<E extends EntityDefinition>(
509
536
  entityName: string,
510
537
  entity: E,
@@ -137,17 +137,48 @@ function tableNameOf(table: Table | EntityTableMeta): string {
137
137
  return asEntityTableMeta(table)?.tableName ?? "<unknown>";
138
138
  }
139
139
 
140
+ // A grant is only real when its reason is non-empty — `{ reason: "" }` must not silently unlock it.
141
+ function hasGrant(decl: EscapeHatchDeclaration | undefined): boolean {
142
+ return decl !== undefined && decl.reason.trim().length > 0;
143
+ }
144
+
145
+ function isForeignTenantId(tenantIdValue: unknown): boolean {
146
+ return tenantIdValue !== undefined && tenantIdValue !== SYSTEM_TENANT_ID;
147
+ }
148
+
140
149
  // Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
141
150
  // or a plain deriveEntityTableMeta/defineUnmanagedTable result), not a direct
142
151
  // `table.tenantId` property read — the latter only exists on branded EntityTables
143
152
  // and silently returned false (no tenant filter!) for plain EntityTableMeta
144
153
  // tables like unmanaged direct-write stores, e.g. userSessionTable.
145
- function hasTenantColumn(table: Table): boolean {
154
+ function hasTenantColumn(table: Table | EntityTableMeta): boolean {
146
155
  const meta = asEntityTableMeta(table);
147
156
  if (meta) return meta.columns.some((c) => c.name === "tenant_id");
148
157
  return (table as Record<string, unknown>)["tenantId"] !== undefined;
149
158
  }
150
159
 
160
+ // Grants for TenantDb's two escape hatches: db.global()'s write methods
161
+ // (write-handler-only) and ctx.db.unsafeRaw() (handler or hook, re-granted per hook).
162
+ export type TenantDbGrants = {
163
+ readonly globalWrites?: EscapeHatchDeclaration;
164
+ readonly unsafeRaw?: EscapeHatchDeclaration;
165
+ };
166
+
167
+ const unsafeRawRebinders = new WeakMap<
168
+ TenantDb,
169
+ (grant: EscapeHatchDeclaration | undefined) => TenantDb
170
+ >();
171
+
172
+ // Rebinds tenantDb's unsafeRaw grant (e.g. a hook's own escapeHatch); inputs not built by
173
+ // createTenantDb pass through unchanged, without touching any property (ctx.db may be a throwing Proxy).
174
+ export function withUnsafeRawGrant(
175
+ tenantDb: TenantDb,
176
+ grant: EscapeHatchDeclaration | undefined,
177
+ ): TenantDb {
178
+ const rebind = unsafeRawRebinders.get(tenantDb);
179
+ return rebind ? rebind(grant) : tenantDb;
180
+ }
181
+
151
182
  export function createTenantDb(
152
183
  db: DbRunner,
153
184
  tenantId: TenantId,
@@ -155,8 +186,7 @@ export function createTenantDb(
155
186
  tracer?: Tracer,
156
187
  meter?: Meter,
157
188
  signal?: AbortSignal,
158
- // Gates db.global(table)'s write methods; write-path-only (see dispatch-shared.ts buildHandlerContext).
159
- escapeHatch?: EscapeHatchDeclaration,
189
+ grants?: TenantDbGrants,
160
190
  ): TenantDb {
161
191
  if (meter) registerStandardMetrics(meter);
162
192
 
@@ -215,7 +245,7 @@ export function createTenantDb(
215
245
  // (e.g. exclude SYSTEM reference rows at the DB instead of post-filtering
216
246
  // after a limit). Values outside the scope are dropped; if nothing valid
217
247
  // remains, the full enforced scope applies — widening is never possible.
218
- function readWhere(table: Table, where?: WhereObject): WhereObject | undefined {
248
+ function readWhere(table: Table | EntityTableMeta, where?: WhereObject): WhereObject | undefined {
219
249
  if (!hasTenantColumn(table) || mode === "system") return where;
220
250
  const allowed = [tenantId, SYSTEM_TENANT_ID];
221
251
  const requested = where?.["tenantId"];
@@ -242,7 +272,7 @@ export function createTenantDb(
242
272
  }
243
273
 
244
274
  function missingEscapeHatch(table: Table | EntityTableMeta): AccessDeniedError | undefined {
245
- if (escapeHatch && escapeHatch.reason.trim().length > 0) return undefined;
275
+ if (hasGrant(grants?.globalWrites)) return undefined;
246
276
  return new AccessDeniedError({
247
277
  message:
248
278
  `db.global(${tableNameOf(table)}): write rejected — declare ` +
@@ -251,6 +281,15 @@ export function createTenantDb(
251
281
  });
252
282
  }
253
283
 
284
+ function foreignTenantOnGlobalWrite(
285
+ table: Table | EntityTableMeta,
286
+ tenantIdValue: unknown,
287
+ message: string = `db.global(${tableNameOf(table)}): tenantId "${String(tenantIdValue)}" is not SYSTEM_TENANT_ID — a "global" table's rows must carry the system tenant, not an arbitrary tenant's id.`,
288
+ ): AccessDeniedError | undefined {
289
+ if (!isForeignTenantId(tenantIdValue)) return undefined;
290
+ return new AccessDeniedError({ message });
291
+ }
292
+
254
293
  function globalTable<TTable extends (SchemaTable | EntityTableMeta) & TenancyBrand<"global">>(
255
294
  table: TTable,
256
295
  ): GlobalTableDb<TTable> {
@@ -273,7 +312,8 @@ export function createTenantDb(
273
312
  insertOne<T = Record<string, unknown>>(
274
313
  values: Record<string, unknown>,
275
314
  ): Promise<T | undefined> {
276
- const denied = missingEscapeHatch(table);
315
+ const denied =
316
+ missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, values["tenantId"]);
277
317
  if (denied) return Promise.reject(denied);
278
318
  return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
279
319
  },
@@ -281,7 +321,8 @@ export function createTenantDb(
281
321
  set: Record<string, unknown>,
282
322
  where: WhereObject,
283
323
  ): Promise<readonly T[]> {
284
- const denied = missingEscapeHatch(table);
324
+ const denied =
325
+ missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, set["tenantId"]);
285
326
  if (denied) return Promise.reject(denied);
286
327
  if (!where || Object.keys(where).length === 0) {
287
328
  return Promise.reject(
@@ -308,14 +349,28 @@ export function createTenantDb(
308
349
  } as GlobalTableDb<TTable>;
309
350
  }
310
351
 
311
- return {
352
+ const tenantDb: TenantDb = {
312
353
  tenantId,
313
354
  mode,
314
355
  raw: db,
315
356
  global: globalTable,
316
357
 
358
+ unsafeRaw(reason: string): DbRunner {
359
+ if (reason.trim().length === 0) {
360
+ throw new Error("unsafeRaw requires a non-empty reason");
361
+ }
362
+ if (!hasGrant(grants?.unsafeRaw)) {
363
+ throw new AccessDeniedError({
364
+ message:
365
+ 'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
366
+ "the handler or hook to allow unsafeRaw.",
367
+ });
368
+ }
369
+ return db;
370
+ },
371
+
317
372
  selectMany<T = Record<string, unknown>>(
318
- table: Table,
373
+ table: Table | EntityTableMeta,
319
374
  where?: WhereObject,
320
375
  options?: SelectOptions,
321
376
  ): Promise<readonly T[]> {
@@ -324,7 +379,7 @@ export function createTenantDb(
324
379
  },
325
380
 
326
381
  fetchOne<T = Record<string, unknown>>(
327
- table: Table,
382
+ table: Table | EntityTableMeta,
328
383
  where: WhereObject,
329
384
  ): Promise<T | undefined> {
330
385
  const filter = readWhere(table, where) ?? {};
@@ -335,6 +390,19 @@ export function createTenantDb(
335
390
  table: Table,
336
391
  values: Record<string, unknown>,
337
392
  ): Promise<T | undefined> {
393
+ if (
394
+ mode === "tenant" &&
395
+ hasTenantColumn(table) &&
396
+ asEntityTableMeta(table)?.tenancy === "global"
397
+ ) {
398
+ const denied = foreignTenantOnGlobalWrite(
399
+ table,
400
+ tenantId,
401
+ `insertOne(${tableNameOf(table)}): "global" table rows must carry SYSTEM_TENANT_ID; ` +
402
+ "use db.global(table) with escapeHatch instead.",
403
+ );
404
+ if (denied) return Promise.reject(denied);
405
+ }
338
406
  const data = insertValues(table, values);
339
407
  return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, data));
340
408
  },
@@ -367,6 +435,11 @@ export function createTenantDb(
367
435
  return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
368
436
  },
369
437
  };
438
+
439
+ unsafeRawRebinders.set(tenantDb, (grant) =>
440
+ createTenantDb(db, tenantId, mode, tracer, meter, signal, { ...grants, unsafeRaw: grant }),
441
+ );
442
+ return tenantDb;
370
443
  }
371
444
 
372
445
  export { asRawClient };
@@ -242,6 +242,7 @@ describe("extendsRegistrar", () => {
242
242
  // systemScope) — the extension hook must not inherit this.
243
243
  const handlerCtx = createGatedIdentitySwitch(
244
244
  'handler "fleet:write:vehicle:create"',
245
+ undefined,
245
246
  true,
246
247
  ungated,
247
248
  );
@@ -34,6 +34,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
34
34
  // escapeHatch or is systemScope) — the hook must not inherit this.
35
35
  const handlerCtx = createGatedIdentitySwitch(
36
36
  'handler "test:write:thing:create"',
37
+ undefined,
37
38
  true,
38
39
  ungated,
39
40
  );
@@ -70,6 +71,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
70
71
  };
71
72
  const handlerCtx = createGatedIdentitySwitch(
72
73
  'handler "test:write:thing:create"',
74
+ undefined,
73
75
  false,
74
76
  ungated,
75
77
  );
@@ -114,7 +116,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
114
116
  };
115
117
  const ungatedResolveActiveMembership = mock(async () => activeResult);
116
118
  const handlerCtx = {
117
- ...createGatedIdentitySwitch('handler "test:write:thing:create"', true, {
119
+ ...createGatedIdentitySwitch('handler "test:write:thing:create"', undefined, true, {
118
120
  queryAs: mock(async () => "ok") as QueryAsFn,
119
121
  writeAs: async () => ({ isSuccess: true as const, data: null }),
120
122
  }),
@@ -155,7 +157,7 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
155
157
  };
156
158
  const ungatedResolveActiveMembership = mock(async () => activeResult);
157
159
  const handlerCtx = {
158
- ...createGatedIdentitySwitch('handler "test:write:thing:create"', false, {
160
+ ...createGatedIdentitySwitch('handler "test:write:thing:create"', undefined, false, {
159
161
  queryAs: mock(async () => "ok") as QueryAsFn,
160
162
  writeAs: async () => ({ isSuccess: true as const, data: null }),
161
163
  }),
@@ -10,12 +10,26 @@ import type { TenantId } from "./types/identifiers";
10
10
  // with a since-deleted row should not silently treat it as active.
11
11
  export type PrincipalStatus = "active" | "blocked" | "unknown";
12
12
 
13
+ // Global-roles/timezone/locale snapshot ctx.queryAsMember needs to mint a
14
+ // resolved principal's SessionUser, without exposing its own table.
15
+ export type PrincipalProfile = {
16
+ readonly globalRoles: readonly string[];
17
+ readonly timezone?: string;
18
+ readonly locale?: string;
19
+ };
20
+
13
21
  // Registered via r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin).
14
22
  export type PrincipalStatusPlugin = {
15
23
  readonly resolveStatus: (
16
24
  userId: string,
17
25
  deps: { readonly db: DbConnection },
18
26
  ) => Promise<PrincipalStatus>;
27
+ // Required so a missing provider fails closed (InternalError) instead of
28
+ // silently minting empty global roles for ctx.queryAsMember.
29
+ readonly resolveProfile: (
30
+ userId: string,
31
+ deps: { readonly db: DbConnection },
32
+ ) => Promise<PrincipalProfile | null>;
19
33
  };
20
34
 
21
35
  // `null` means the tenant has no lifecycle row (never entered teardown).
@@ -38,7 +52,7 @@ function hasResolveStatusFn(v: unknown): v is { readonly resolveStatus: unknown
38
52
  }
39
53
 
40
54
  export function isPrincipalStatusPlugin(v: unknown): v is PrincipalStatusPlugin {
41
- return hasResolveStatusFn(v);
55
+ return hasResolveStatusFn(v) && "resolveProfile" in v && typeof v.resolveProfile === "function";
42
56
  }
43
57
 
44
58
  export function isTenantLifecycleStatusPlugin(v: unknown): v is TenantLifecycleStatusPlugin {
@@ -144,6 +144,43 @@ describe("validateAccessDeclarations", () => {
144
144
  expect(() => validateAccessDeclarations(feature)).not.toThrow();
145
145
  });
146
146
 
147
+ // 2c. escapeHatch reason must be non-empty on a stream handler too — same
148
+ // contract as write/query: streams can't reach db.global(), but they can
149
+ // still declare escapeHatch to switch identity to SYSTEM via ctx.queryAs.
150
+ test("stream handler escapeHatch with an empty reason throws, naming the handler", () => {
151
+ const feature = defineFeature("notes", (r) => {
152
+ r.entity("note", noteEntity);
153
+ r.streamHandler(
154
+ "note:tail",
155
+ z.object({}),
156
+ async function* () {
157
+ yield {};
158
+ },
159
+ { access: { roles: ["Admin"] }, escapeHatch: { reason: "" } },
160
+ );
161
+ });
162
+ expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
163
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:tail"/);
164
+ });
165
+
166
+ test("stream handler escapeHatch with a non-empty reason boots fine", () => {
167
+ const feature = defineFeature("notes", (r) => {
168
+ r.entity("note", noteEntity);
169
+ r.streamHandler(
170
+ "note:tail",
171
+ z.object({}),
172
+ async function* () {
173
+ yield {};
174
+ },
175
+ {
176
+ access: { roles: ["Admin"] },
177
+ escapeHatch: { reason: "cross-tenant note tail for auth" },
178
+ },
179
+ );
180
+ });
181
+ expect(() => validateAccessDeclarations(feature)).not.toThrow();
182
+ });
183
+
147
184
  // 3. openToAll write handler accepting an unbound personal-data field needs personalData.
148
185
  test("openToAll write handler accepting a personal-data field without personalData throws", () => {
149
186
  const feature = defineFeature("notes", (r) => {
@@ -0,0 +1,58 @@
1
+ // fw#2858 — a `tenancy: "global"` entity's event stream must live on
2
+ // SYSTEM_TENANT_ID (systemStream: true); otherwise it would silently fork
3
+ // one stream per creating tenant while still being writable/readable
4
+ // cross-tenant via db.global(table).
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { defineFeature } from "../../define-feature";
8
+ import { createEntity, createTextField } from "../../factories";
9
+ import { validateGlobalTenancyEntities } from "../global-tenancy";
10
+
11
+ const textField = () =>
12
+ createTextField({
13
+ required: true,
14
+ maxLength: 64,
15
+ personal: false,
16
+ reason: "technical_reference",
17
+ });
18
+
19
+ describe("validateGlobalTenancyEntities", () => {
20
+ test("a tenancy: 'global' entity WITHOUT systemStream: true throws", () => {
21
+ const entity = createEntity({
22
+ table: "read_fw2858_global_no_stream",
23
+ tenancy: "global",
24
+ fields: { name: textField() },
25
+ });
26
+ const feature = defineFeature("fw2858-global-no-stream", (r) => {
27
+ r.entity("fw2858GlobalNoStream", entity);
28
+ });
29
+
30
+ expect(() => validateGlobalTenancyEntities([feature])).toThrow(/systemStream/);
31
+ });
32
+
33
+ test("a tenancy: 'global' entity WITH systemStream: true passes", () => {
34
+ const entity = createEntity({
35
+ table: "read_fw2858_global_with_stream",
36
+ tenancy: "global",
37
+ systemStream: true,
38
+ fields: { name: textField() },
39
+ });
40
+ const feature = defineFeature("fw2858-global-with-stream", (r) => {
41
+ r.entity("fw2858GlobalWithStream", entity);
42
+ });
43
+
44
+ expect(() => validateGlobalTenancyEntities([feature])).not.toThrow();
45
+ });
46
+
47
+ test("a tenant-tenancy entity without systemStream is never checked", () => {
48
+ const entity = createEntity({
49
+ table: "read_fw2858_tenant_entity",
50
+ fields: { name: textField() },
51
+ });
52
+ const feature = defineFeature("fw2858-tenant-entity", (r) => {
53
+ r.entity("fw2858TenantEntity", entity);
54
+ });
55
+
56
+ expect(() => validateGlobalTenancyEntities([feature])).not.toThrow();
57
+ });
58
+ });
@@ -4,6 +4,7 @@ import type {
4
4
  OwnershipMap,
5
5
  OwnershipRule,
6
6
  QueryHandlerDef,
7
+ StreamHandlerDef,
7
8
  WriteHandlerDef,
8
9
  } from "../types";
9
10
  import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
@@ -133,7 +134,10 @@ function validateEscapeHatchReason(
133
134
  feature: FeatureDefinition,
134
135
  kind: HandlerKind,
135
136
  handlerName: string,
136
- escapeHatch: WriteHandlerDef["escapeHatch"] | QueryHandlerDef["escapeHatch"],
137
+ escapeHatch:
138
+ | WriteHandlerDef["escapeHatch"]
139
+ | QueryHandlerDef["escapeHatch"]
140
+ | StreamHandlerDef["escapeHatch"],
137
141
  ): void {
138
142
  // skip: no escapeHatch declared, or its reason is already non-empty
139
143
  if (!escapeHatch || escapeHatch.reason.trim().length > 0) return;
@@ -212,6 +216,7 @@ export function validateAccessDeclarations(feature: FeatureDefinition): void {
212
216
  }
213
217
  for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
214
218
  validateOpenToAllReason(feature, "stream", handlerName, handler.access);
219
+ validateEscapeHatchReason(feature, "stream", handlerName, handler.escapeHatch);
215
220
  validatePersonalDataOnlyOnWrite(feature, "stream", handlerName, handler.access);
216
221
  }
217
222
  }