@cosmicdrift/kumiko-framework 0.266.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 (48) 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 +15 -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__/global-tenancy.test.ts +58 -0
  23. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  24. package/src/engine/boot-validator/index.ts +2 -0
  25. package/src/engine/feature-ui-extensions.ts +2 -2
  26. package/src/engine/index.ts +3 -0
  27. package/src/engine/registry-validate.ts +6 -6
  28. package/src/engine/types/index.ts +2 -0
  29. package/src/entrypoint/index.ts +2 -13
  30. package/src/errors/i18n/de.yaml +20 -0
  31. package/src/errors/i18n/en.yaml +19 -0
  32. package/src/errors/reasons.ts +8 -0
  33. package/src/jobs/job-runner.ts +14 -0
  34. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  35. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  36. package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
  37. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  38. package/src/pipeline/active-membership.ts +3 -1
  39. package/src/pipeline/dispatch-shared.ts +89 -10
  40. package/src/pipeline/dispatch-stream.ts +9 -5
  41. package/src/pipeline/dispatch-write.ts +7 -0
  42. package/src/pipeline/dispatcher-utils.ts +1 -0
  43. package/src/pipeline/dispatcher.ts +18 -0
  44. package/src/pipeline/index.ts +1 -1
  45. package/src/pipeline/member-reader.ts +108 -0
  46. package/src/pipeline/system-identity-switch.ts +73 -13
  47. package/src/stack/test-stack.ts +10 -1
  48. package/src/testing/handler-context.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.266.0",
3
+ "version": "0.268.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.266.0",
201
+ "@cosmicdrift/kumiko-types": "0.268.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.266.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.268.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -640,6 +640,12 @@ describe("POST /api/stream pre-pull race", () => {
640
640
  async resolveActiveMembership() {
641
641
  return { kind: "rejected", reason: "not_a_member" };
642
642
  },
643
+ // Not exercised by this route.
644
+ createMemberReader() {
645
+ return async () => {
646
+ throw new Error("createMemberReader not available in this stub");
647
+ };
648
+ },
643
649
  };
644
650
  }
645
651
 
@@ -64,6 +64,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
64
64
  }
65
65
  return { kind: "active", membership: { tenantId, roles: membership.roles } };
66
66
  },
67
+ // Not exercised by these cookie-only tests.
68
+ createMemberReader() {
69
+ return async () => {
70
+ throw new Error("createMemberReader not available in this stub");
71
+ };
72
+ },
67
73
  ...overrides,
68
74
  };
69
75
  return merged;
@@ -39,6 +39,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
39
39
  },
40
40
  // Stream API added with r.streamHandler (#1446) — stub unused in these routes.
41
41
  async *stream(): AsyncGenerator<unknown> {},
42
+ // Not exercised by these routes.
43
+ createMemberReader() {
44
+ return async () => {
45
+ throw new Error("createMemberReader not available in this stub");
46
+ };
47
+ },
42
48
  };
43
49
  return { ...base, ...overrides };
44
50
  }
@@ -49,6 +49,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
49
49
  async resolveActiveMembership() {
50
50
  return { kind: "rejected", reason: "not_a_member" };
51
51
  },
52
+ // Not exercised by these routes.
53
+ createMemberReader() {
54
+ return async () => {
55
+ throw new Error("createMemberReader not available in this stub");
56
+ };
57
+ },
52
58
  };
53
59
  return { ...base, ...overrides };
54
60
  }
@@ -50,6 +50,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
50
50
  async resolveActiveMembership() {
51
51
  return { kind: "rejected", reason: "not_a_member" };
52
52
  },
53
+ // Not exercised by these routes.
54
+ createMemberReader() {
55
+ return async () => {
56
+ throw new Error("createMemberReader not available in this stub");
57
+ };
58
+ },
53
59
  };
54
60
  return { ...base, ...overrides };
55
61
  }
@@ -49,6 +49,12 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
49
49
  async resolveActiveMembership() {
50
50
  return { kind: "rejected", reason: "not_a_member" };
51
51
  },
52
+ // Not exercised by these routes.
53
+ createMemberReader() {
54
+ return async () => {
55
+ throw new Error("createMemberReader not available in this stub");
56
+ };
57
+ },
52
58
  };
53
59
  return { ...base, ...overrides };
54
60
  }
@@ -42,6 +42,12 @@ function failingDispatcher(): Dispatcher {
42
42
  async resolveActiveMembership() {
43
43
  return { kind: "rejected", reason: "not_a_member" };
44
44
  },
45
+ // Not exercised by these routes.
46
+ createMemberReader() {
47
+ return async () => {
48
+ throw new Error("createMemberReader not available in this stub");
49
+ };
50
+ },
45
51
  };
46
52
  }
47
53
 
@@ -106,6 +106,14 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
106
106
  const payload = await jwt.verify(await jwt.sign(user));
107
107
  expect(payload.locale).toBeUndefined();
108
108
  });
109
+
110
+ // A ctx.queryAsMember-resolved SessionUser must never be minted into a
111
+ // session — it carries no `sid` and is read-only by construction.
112
+ it("refuses to sign a SessionUser with origin set", async () => {
113
+ await expect(jwt.sign({ ...user, origin: "member-resolution" })).rejects.toThrow(
114
+ /member-resolution/,
115
+ );
116
+ });
109
117
  });
110
118
 
111
119
  describe("createJwtHelper — keyring form", () => {
package/src/api/jwt.ts CHANGED
@@ -2,6 +2,7 @@ import * as jose from "jose";
2
2
  import type { DbRow } from "../db/connection";
3
3
  import type { SessionUser, TenantId } from "../engine/types";
4
4
  import { parseTenantId } from "../engine/types";
5
+ import { InternalError } from "../errors";
5
6
 
6
7
  export type JwtPayload = {
7
8
  // JWT `sub` is a string per RFC 7519. Matches SessionUser.id — a UUID-string
@@ -108,6 +109,13 @@ export function createJwtHelper(
108
109
 
109
110
  return {
110
111
  async sign(user) {
112
+ // A resolved member principal (origin set) carries no `sid` and must
113
+ // never be minted into a session — only interactive sign-in may sign.
114
+ if (user.origin !== undefined) {
115
+ throw new InternalError({
116
+ message: `jwt.sign: refusing to sign a SessionUser with origin "${user.origin}" — only interactively-authenticated sessions may be minted.`,
117
+ });
118
+ }
111
119
  const body: Omit<JwtPayload, "sub" | "jti"> = {
112
120
  tenantId: user.tenantId,
113
121
  roles: [...user.roles],
package/src/changes.json CHANGED
@@ -1,4 +1,19 @@
1
1
  [
2
+ {
3
+ "version": "0.267.0",
4
+ "type": "breaking",
5
+ "title": "ctx.db.unsafeRaw(reason) gated by escapeHatch, tenancy: \"global\" invariants, bundled features migrated off ctx.db.raw (fw#2858).",
6
+ "detail": "`TenantDb.unsafeRaw(reason)` returns the unfiltered `DbRunner` only when the calling write or query handler declares `escapeHatch: { reason }` (lifecycle hooks: `r.hook(..., { escapeHatch })`; a hook no longer inherits the handler's grant, same as the #2859 SYSTEM identity-switch gate). Without a declaration it throws `AccessDeniedError`; an empty reason throws `Error`. `r.systemScope()` features keep using `ctx.systemDb.unsafeRaw(reason)`. `TenantDb.raw` stays deprecated until fw#2860. `createTenantDb`'s 7th parameter changed from an `EscapeHatchDeclaration` to `TenantDbGrants` `{ globalWrites?, unsafeRaw? }`; `withUnsafeRawGrant(tenantDb, grant)` rebinds the unsafeRaw grant. `bindHookIdentitySwitchGrant`/`withHookIdentitySwitchGrant` are renamed to `bindHookEscapeHatchGrant`/`withHookEscapeHatchGrant`. Global tenancy: a `tenancy: \"global\"` entity must declare `systemStream: true` (boot validator + executor) and its create/update payloads may not carry a non-SYSTEM `tenantId`; tenant-mode `insertOne` into a global table with a `tenant_id` column and `db.global(table)` writes with a non-SYSTEM `tenantId` are rejected; `defineUnmanagedTable({ tenancy: \"global\" })` rejects a `tenant_id` column; new `declareGlobalTenancy(table)` declares a plain `table()` store without `tenant_id` as global. Bundled features: `userEntity` is `tenancy: \"global\"`, `store_global_feature_state` is declared global, and every bundled `ctx.db.raw` call site now uses `ctx.db.global(table)`, a filtered `ctx.db` method, or `ctx.db.unsafeRaw(reason)` with an `escapeHatch` on the handler. Handlers that call the auth-mfa `mfaStatusChecker`/MFA code verifier callbacks, handlers wrapped by cap-counter `withStockCap` (declares it automatically) and callers of the custom-fields quota/field-access query helpers now need that declaration.",
7
+ "migration": "Replace each `ctx.db.raw` use: reads of a `tenancy: \"global\"` table → `ctx.db.global(table).selectMany/fetchOne(...)`; reads/inserts whose where/values only use the caller's own tenantId → `ctx.db.selectMany/fetchOne/insertOne(table, ...)` (method form); everything else → `ctx.db.unsafeRaw(\"<what is read/written and why the tenant filter cannot apply>\")` plus `escapeHatch: { reason }` on the write/query handler definition or in the `r.hook` options. Generic reasons (todo, legacy, migration) fail infra#788. `bun scripts/migrate-db-raw.ts --dry-run <paths>` rewrites the global-table and own-tenant cases and lists the remaining sites; drop `--dry-run` to apply. Custom handlers calling `mfaStatusChecker`/the MFA code verifier or the custom-fields query helpers must declare `escapeHatch`. Direct `createTenantDb(..., escapeHatch)` calls pass `{ globalWrites: escapeHatch }` (and `unsafeRaw` if needed). Rename imports of `bindHookIdentitySwitchGrant`/`withHookIdentitySwitchGrant`. `defineUnmanagedTable({ tenancy: \"global\" })` definitions must drop their `tenant_id` column; `tenancy: \"global\"` entities must add `systemStream: true`.",
8
+ "codemod": "bun scripts/migrate-db-raw.ts --dry-run <paths>"
9
+ },
10
+ {
11
+ "version": "0.267.0",
12
+ "type": "improvement",
13
+ "title": "ctx.queryAsMember reads a query handler as a stored tenant member, resolved internally by the framework (fw#2873).",
14
+ "detail": "New `ctx.queryAsMember(userId, qn, payload)` on `HandlerContext`, `JobContext` and (optional) `AppContext` — resolves a background SessionUser for `userId` in the current tenant (active membership, via a new stricter `BACKGROUND_READ_POLICY` — no pending-destruction grace window, no unknown-principal pass → `PrincipalStatusPlugin.resolveProfile` → `buildSessionRoles` → `resolveAuthClaims`) and runs the target query handler as that member. The resolved SessionUser is never exposed: no `sid`, `origin: \"member-resolution\"`. Rejection for any reason (not a member, blocked principal, tenant teardown, or would-be-SYSTEM) surfaces the same generic AccessDeniedError (`member_resolution_denied`, no rejection reason in `details`). Resolution is cached per handler invocation (its lifecycle hooks share that cache) or job run; a nested dispatch gets its own cache. Needs the same grant as a SYSTEM `queryAs` (`r.systemScope()` or `escapeHatch`); a hook's OWN escapeHatch re-gates it independently of the enclosing handler's grant (can grant even when the handler has none). Jobs stay ungated. There is no `writeAsMember` — a resolved member principal is read-only by construction: `write`/`writeAs`/`appendEvent`/`unsafeAppendEvent`/`tryAppendEvent`/`fetchForWriting`/`archiveStream`/`restoreStream`/`snapshotAggregate`/`queryAsMember`/`resolveActiveMembership`/`jobRunner`/`scheduleAfterCommit` all throw `member_resolution_read_only` (also enforced at the shared event-append sink behind `appendEvent`/`fetchForWriting().appendOne`), and `dbOutsideTransaction`/`systemDb`/`runPreSave`/`files`/`derivatives` are unset (raw `ctx.db` writes are NOT blocked — same reachability as that handler running for the member over HTTP). `jwt.sign()` now refuses a SessionUser with `origin` set.",
15
+ "migration": "Custom principalStatus providers must implement resolveProfile for ctx.queryAsMember; login/switch-tenant unaffected."
16
+ },
2
17
  {
3
18
  "version": "0.266.0",
4
19
  "type": "breaking",
@@ -0,0 +1,73 @@
1
+ // fw#2858 — a `tenancy: "global"` entity's aggregate is tenant-agnostic: create/update
2
+ // must reject an explicit foreign (non-SYSTEM) payload.tenantId before it ever reaches
3
+ // the projection insert/update. Modelled on event-store-system-stream.integration.test.ts.
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { createEntity, createTextField } from "../../engine";
7
+ import { AccessDeniedError } from "../../errors";
8
+ import {
9
+ createTestDb,
10
+ type TestDb,
11
+ TestUsers,
12
+ testTenantId,
13
+ unsafeCreateEntityTable,
14
+ } from "../../stack";
15
+ import { createEventStoreExecutor } from "../event-store-executor";
16
+ import { buildEntityTable } from "../table-builder";
17
+ import { createTenantDb } from "../tenant-db";
18
+
19
+ const globalEntity = createEntity({
20
+ table: "gtenancy_global",
21
+ tenancy: "global",
22
+ systemStream: true,
23
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
24
+ });
25
+
26
+ const globalTable = buildEntityTable("gtenancyGlobal", globalEntity);
27
+ const globalExec = createEventStoreExecutor(globalTable, globalEntity, {
28
+ entityName: "gtenancyGlobal",
29
+ });
30
+
31
+ const user = TestUsers.admin; // tenantId === testTenantId(1)
32
+ const foreignTenantId = testTenantId(2);
33
+
34
+ let testDb: TestDb;
35
+
36
+ beforeAll(async () => {
37
+ testDb = await createTestDb();
38
+ await unsafeCreateEntityTable(testDb.db, globalEntity, "gtenancyGlobal");
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await testDb.cleanup();
43
+ });
44
+
45
+ describe("global entity tenantId invariant (fw#2858)", () => {
46
+ test("create with an explicit foreign tenantId throws AccessDeniedError", async () => {
47
+ const db = createTenantDb(testDb.db, user.tenantId);
48
+ await expect(
49
+ globalExec.create({ name: "a", tenantId: foreignTenantId }, user, db),
50
+ ).rejects.toBeInstanceOf(AccessDeniedError);
51
+ });
52
+
53
+ test("create without a tenantId (or SYSTEM) succeeds", async () => {
54
+ const db = createTenantDb(testDb.db, user.tenantId);
55
+ const res = await globalExec.create({ name: "a" }, user, db);
56
+ expect(res.isSuccess).toBe(true);
57
+ });
58
+
59
+ test("update with an explicit foreign tenantId in changes throws AccessDeniedError", async () => {
60
+ const db = createTenantDb(testDb.db, user.tenantId);
61
+ const created = await globalExec.create({ name: "a" }, user, db);
62
+ if (!created.isSuccess) throw new Error("create failed");
63
+
64
+ await expect(
65
+ globalExec.update(
66
+ { id: String(created.data.id), changes: { tenantId: foreignTenantId } },
67
+ user,
68
+ db,
69
+ { skipOptimisticLock: true },
70
+ ),
71
+ ).rejects.toBeInstanceOf(AccessDeniedError);
72
+ });
73
+ });
@@ -1,8 +1,11 @@
1
- // fw#2855 — db.global(table) against real Postgres: tenant filter lifted, caller's
2
- // `where` still applies; a normal accessor on the same table still filters by tenant.
1
+ // fw#2858 — db.global(table) against real Postgres: a "global" unmanaged table has
2
+ // no tenant_id column at all (binding decision, issue comment 2026-09-14), so its
3
+ // rows are tenant-agnostic — every caller sees the same rows through db.global().
4
+ // A normal tenancy: "tenant" table is unaffected and still isolates by tenant.
3
5
  // Follows tenant-db-where-merge.integration.test.ts's setupTestStack pattern.
4
6
 
5
7
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { AccessDeniedError } from "../../errors";
6
9
  import { setupTestStack, type TestStack, testTenantId, unsafePushTables } from "../../stack";
7
10
  import type { TableColumns } from "../dialect";
8
11
  import { defineUnmanagedTable } from "../entity-table-meta";
@@ -10,8 +13,22 @@ import { insertOne } from "../query";
10
13
  import { createTenantDb } from "../tenant-db";
11
14
 
12
15
  const globalItemsTable = defineUnmanagedTable({
13
- tableName: "store_fw2855_global_items_it",
16
+ tableName: "store_fw2858_global_items_it",
14
17
  tenancy: "global",
18
+ columns: [
19
+ {
20
+ name: "id",
21
+ pgType: "uuid",
22
+ notNull: true,
23
+ primaryKey: true,
24
+ defaultSql: "gen_random_uuid()",
25
+ },
26
+ { name: "some_field", pgType: "text", notNull: true },
27
+ ],
28
+ });
29
+
30
+ const tenantItemsTable = defineUnmanagedTable({
31
+ tableName: "store_fw2858_tenant_items_it",
15
32
  columns: [
16
33
  {
17
34
  name: "id",
@@ -27,15 +44,20 @@ const globalItemsTable = defineUnmanagedTable({
27
44
 
28
45
  let stack: TestStack;
29
46
 
30
- const tenantA = testTenantId(91);
31
- const tenantB = testTenantId(92);
47
+ const tenantA = testTenantId(93);
48
+ const tenantB = testTenantId(94);
32
49
 
33
50
  beforeAll(async () => {
34
51
  stack = await setupTestStack({ features: [] });
35
- await unsafePushTables(stack.db, { globalItems: globalItemsTable });
36
- await insertOne(stack.db, globalItemsTable, { tenantId: tenantA, someField: "match" });
37
- await insertOne(stack.db, globalItemsTable, { tenantId: tenantB, someField: "match" });
38
- await insertOne(stack.db, globalItemsTable, { tenantId: tenantB, someField: "no-match" });
52
+ await unsafePushTables(stack.db, {
53
+ globalItems: globalItemsTable,
54
+ tenantItems: tenantItemsTable,
55
+ });
56
+ await insertOne(stack.db, globalItemsTable, { someField: "match" });
57
+ await insertOne(stack.db, globalItemsTable, { someField: "match" });
58
+ await insertOne(stack.db, globalItemsTable, { someField: "no-match" });
59
+ await insertOne(stack.db, tenantItemsTable, { tenantId: tenantA, someField: "match" });
60
+ await insertOne(stack.db, tenantItemsTable, { tenantId: tenantB, someField: "match" });
39
61
  });
40
62
 
41
63
  afterAll(async () => {
@@ -43,25 +65,35 @@ afterAll(async () => {
43
65
  });
44
66
 
45
67
  describe("TenantDb.global() — reads, real Postgres", () => {
46
- test("db.global(t).selectMany keeps the field filter but drops the tenant filter", async () => {
47
- const tdb = createTenantDb(stack.db, tenantA);
48
- const rows = await tdb.global(globalItemsTable).selectMany<{
49
- tenantId: string;
50
- someField: string;
51
- }>({ someField: "match" });
52
-
53
- const tenantIds = rows.map((r) => r.tenantId).sort();
54
- expect(tenantIds).toEqual([tenantA, tenantB].sort());
55
- expect(rows.every((r) => r.someField === "match")).toBe(true);
68
+ test("selectMany returns the same rows to callers on different tenants, unfiltered by tenant", async () => {
69
+ const rowsForA = await createTenantDb(stack.db, tenantA)
70
+ .global(globalItemsTable)
71
+ .selectMany<{ id: string; someField: string }>({ someField: "match" });
72
+ const rowsForB = await createTenantDb(stack.db, tenantB)
73
+ .global(globalItemsTable)
74
+ .selectMany<{ id: string; someField: string }>({ someField: "match" });
75
+
76
+ expect(rowsForA).toHaveLength(2);
77
+ expect(rowsForA.map((r) => r.id).sort()).toEqual(rowsForB.map((r) => r.id).sort());
56
78
  });
57
79
 
58
- test("the normal (non-global) db.selectMany on the same table still filters by tenant", async () => {
80
+ test("fetchOne finds a row for a caller whose own tenant never wrote it", async () => {
81
+ const row = await createTenantDb(stack.db, tenantA)
82
+ .global(globalItemsTable)
83
+ .fetchOne<{ someField: string }>({ someField: "no-match" });
84
+
85
+ expect(row?.someField).toBe("no-match");
86
+ });
87
+ });
88
+
89
+ describe("TenantDb — tenant-mode isolation on a normal tenant table", () => {
90
+ test("db.selectMany only returns the caller's own tenant's rows", async () => {
59
91
  const tdb = createTenantDb(stack.db, tenantA);
60
92
  // selectMany's Table param types the branded-EntityTable shape only;
61
93
  // EntityTableMeta reads work identically at runtime (both normalize via
62
94
  // asEntityTableMeta) — same unbranded-view cast as tenant-db-where-merge.test.ts.
63
95
  const rows = await tdb.selectMany<{ tenantId: string; someField: string }>(
64
- globalItemsTable as unknown as TableColumns,
96
+ tenantItemsTable as unknown as TableColumns,
65
97
  { someField: "match" },
66
98
  );
67
99
 
@@ -71,18 +103,23 @@ describe("TenantDb.global() — reads, real Postgres", () => {
71
103
  });
72
104
 
73
105
  describe("TenantDb.global() — writes, real Postgres", () => {
74
- test("insert/update/delete succeed when createTenantDb was given an escapeHatch", async () => {
106
+ test("insert/update/delete are rejected without a globalWrites grant", async () => {
107
+ const tdb = createTenantDb(stack.db, tenantA);
108
+ await expect(
109
+ tdb.global(globalItemsTable).insertOne({ someField: "ungranted" }),
110
+ ).rejects.toThrow(AccessDeniedError);
111
+ });
112
+
113
+ test("insert/update/delete succeed when createTenantDb was given a globalWrites grant", async () => {
75
114
  const tdb = createTenantDb(stack.db, tenantA, "tenant", undefined, undefined, undefined, {
76
- reason: "fw#2855 integration test — cross-tenant write via db.global()",
115
+ globalWrites: { reason: "fw#2858 integration test — cross-tenant write via db.global()" },
77
116
  });
78
117
  const globalDb = tdb.global(globalItemsTable);
79
118
 
80
- const inserted = await globalDb.insertOne<{ id: string; tenantId: string }>({
81
- tenantId: tenantB,
119
+ const inserted = await globalDb.insertOne<{ id: string }>({
82
120
  someField: "escape-hatch-insert",
83
121
  });
84
122
  if (!inserted) throw new Error("insertOne returned no row");
85
- expect(inserted.tenantId).toBe(tenantB);
86
123
 
87
124
  const updated = await globalDb.updateMany<{ someField: string }>(
88
125
  { someField: "escape-hatch-update" },
@@ -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";