@cosmicdrift/kumiko-framework 0.265.0 → 0.268.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 (54) 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/jwt.ts +8 -0
  11. package/src/changes.json +35 -0
  12. package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
  13. package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
  14. package/src/db/__tests__/tenant-db-global.test.ts +95 -5
  15. package/src/db/entity-table-meta.ts +7 -0
  16. package/src/db/event-store-executor-context.ts +19 -0
  17. package/src/db/event-store-executor-write.ts +16 -0
  18. package/src/db/index.ts +3 -1
  19. package/src/db/table-builder.ts +27 -0
  20. package/src/db/tenant-db.ts +83 -10
  21. package/src/engine/active-membership.ts +15 -1
  22. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
  23. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
  24. package/src/engine/boot-validator/access-declarations.ts +101 -27
  25. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  26. package/src/engine/boot-validator/index.ts +2 -0
  27. package/src/engine/boot-validator/zod-shape.ts +92 -1
  28. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
  29. package/src/engine/feature-ast/extractors/hooks.ts +8 -4
  30. package/src/engine/feature-ui-extensions.ts +2 -2
  31. package/src/engine/index.ts +4 -0
  32. package/src/engine/registry-validate.ts +6 -6
  33. package/src/engine/types/index.ts +3 -0
  34. package/src/entrypoint/index.ts +2 -13
  35. package/src/errors/i18n/de.yaml +20 -0
  36. package/src/errors/i18n/en.yaml +19 -0
  37. package/src/errors/reasons.ts +8 -0
  38. package/src/jobs/job-runner.ts +14 -0
  39. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  40. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  41. package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
  42. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  43. package/src/pipeline/active-membership.ts +3 -1
  44. package/src/pipeline/dispatch-shared.ts +89 -10
  45. package/src/pipeline/dispatch-stream.ts +9 -5
  46. package/src/pipeline/dispatch-write.ts +7 -0
  47. package/src/pipeline/dispatcher-utils.ts +1 -0
  48. package/src/pipeline/dispatcher.ts +18 -0
  49. package/src/pipeline/index.ts +1 -1
  50. package/src/pipeline/member-reader.ts +108 -0
  51. package/src/pipeline/system-identity-switch.ts +73 -13
  52. package/src/stack/test-stack.ts +10 -1
  53. package/src/testing/handler-context.ts +3 -0
  54. package/src/ui-types/index.ts +1 -0
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 };
@@ -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 {