@cosmicdrift/kumiko-framework 0.259.0 → 0.261.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/package.json +3 -3
- package/src/changes.json +13 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +97 -0
- package/src/db/__tests__/tenant-db-global.test.ts +170 -0
- package/src/db/entity-table-meta.ts +8 -2
- package/src/db/table-builder.ts +37 -2
- package/src/db/tenant-db.ts +86 -4
- package/src/engine/__tests__/boot-validator.test.ts +41 -16
- package/src/engine/__tests__/build-app-schema.test.ts +14 -5
- package/src/engine/__tests__/engine.test.ts +10 -0
- package/src/engine/__tests__/entity-handlers.test.ts +48 -36
- package/src/engine/__tests__/extends-registrar.test.ts +27 -12
- package/src/engine/__tests__/projection-detail-tabs.test.ts +43 -0
- package/src/engine/__tests__/registry.test.ts +16 -5
- package/src/engine/__tests__/screen.test.ts +20 -0
- package/src/engine/access.ts +2 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +200 -0
- package/src/engine/boot-validator/access-declarations.ts +141 -0
- package/src/engine/boot-validator/entity-handler.ts +1 -1
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/screens.ts +53 -38
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/entity-handlers.ts +29 -19
- package/src/engine/factories.ts +5 -3
- package/src/engine/feature-ast/extractors/hooks.ts +5 -1
- package/src/engine/feature-entity-handlers.ts +40 -27
- package/src/engine/index.ts +4 -0
- package/src/engine/types/handlers.ts +1 -0
- package/src/engine/types/index.ts +4 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +93 -0
- package/src/pipeline/dispatch-shared.ts +3 -0
- package/src/secrets/__tests__/contains-secret.test.ts +5 -0
- package/src/testing/__tests__/e2e-generator.test.ts +11 -5
- package/src/ui-types/index.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.261.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.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.261.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.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.261.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.261.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "access is required on handler definitions; openToAll accepts { reason } (+ publicIntake) (fw#2855).",
|
|
6
|
+
"detail": "`WriteHandlerDef`/`QueryHandlerDef`/`StreamHandlerDef`.access (`@cosmicdrift/kumiko-types/handlers`), `WriteHandlerDefinition`/`WriteHandlerInput`/`QueryHandlerDefinition`/`StreamHandlerDefinition`.access, `EntityHandlerOptions.access`, and the `r.writeHandler`/`r.queryHandler`/`r.streamHandler` positional `options` param are now required — a handler with no access rule fails to compile instead of silently registering unreachable (previously the boot-validator caught it at runtime). `registerEntityCrud` throws at registration when an enabled verb resolves no access (`write.access`/`read.access`/`verbAccess.<verb>` all unset) — was previously a boot-validator throw. `AccessRule.openToAll` accepts `{ reason: string }` (plus optional `publicIntake: true`); `true` still compiles (deprecated pre-#2855 form, tracked for removal in fw#2854's call-site migration) but a new boot-validator (`engine/boot-validator/access-declarations.ts`) rejects an empty `openToAll.reason`, an empty `escapeHatch.reason`, `publicIntake` on a query/stream handler, and — the main new catch — a write handler with `openToAll` that accepts a personal-data field (pii/userOwned/recordOwned) in its input schema without `publicIntake: true`. Code that read `access.openToAll` as a boolean must switch to `isOpenToAllGranted(rule)` (`@cosmicdrift/kumiko-types/handlers`, also re-exported from `@cosmicdrift/kumiko-framework/engine` and `/ui-types`), which handles both the `true` and `{ reason }` forms.",
|
|
7
|
+
"migration": "Add `access` to every handler definition: `r.writeHandler`/`r.queryHandler`/`r.streamHandler` object- and positional-form options, `defineWriteHandler`/`defineQueryHandler`/`definePagedQueryHandler`/`defineProjectionQueryHandler` options, `defineEntityCreate/Update/Delete/Restore/List/DetailHandler` options, and `registerEntityCrud`'s `write.access`/`read.access` (or per-verb `verbAccess`). Replace `openToAll: true` with `openToAll: { reason: \"...\" }` going forward — `true` still compiles but is deprecated (fw#2854 tracks the 471-site call-site migration; new code should use the object form). A write handler that already used `openToAll` and accepts a PII field now fails boot unless it also declares `publicIntake: true` (any authenticated user may legitimately submit that data) or is restricted to roles instead. Any code reading `access.openToAll` as a boolean must switch to `isOpenToAllGranted(rule)`."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.261.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "EntityDefinition.tenancy + db.global(table), escapeHatch, UncheckedSystemDb.unsafeRaw(reason) (fw#2855).",
|
|
13
|
+
"detail": "`EntityDefinition.tenancy?: \"global\" | \"tenant\"` (default `\"tenant\"`; `createEntity({ tenancy: \"global\", ... })`) marks an entity's rows as shared across every tenant instead of row-scoped — only reachable via the new `TenantDb.global(table)`, which drops the tenant filter but keeps the caller's own `where` field-filter for reads, and rejects writes unless the calling write handler declared `escapeHatch: { reason: \"...\" }` (`WriteHandlerDef.escapeHatch`, threaded through `r.writeHandler`/`defineWriteHandler`/`registerEntityCrud`). `defineUnmanagedTable({ tenancy: \"global\", ... })` covers hand-built store tables the same way; a `\"tenant\"`-tenancy table passed to `db.global()` is a compile error (`TenancyBrand<\"global\">` required) and, for an erased/runtime-only table the brand can't catch, also a runtime `AccessDeniedError`. `UncheckedSystemDb.unsafeRaw(reason: string): DbRunner` replaces reaching for `ctx.systemDb`'s guarded `.raw` escape hatch with a named, auditable declaration (throws on an empty reason); `TenantDb.raw` is now `@deprecated` in favor of `unsafeRaw`/`db.global()`, removal tracked in fw#2860."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.259.0",
|
|
4
17
|
"type": "improvement",
|
|
@@ -0,0 +1,97 @@
|
|
|
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.
|
|
3
|
+
// Follows tenant-db-where-merge.integration.test.ts's setupTestStack pattern.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { setupTestStack, type TestStack, testTenantId, unsafePushTables } from "../../stack";
|
|
7
|
+
import type { TableColumns } from "../dialect";
|
|
8
|
+
import { defineUnmanagedTable } from "../entity-table-meta";
|
|
9
|
+
import { insertOne } from "../query";
|
|
10
|
+
import { createTenantDb } from "../tenant-db";
|
|
11
|
+
|
|
12
|
+
const globalItemsTable = defineUnmanagedTable({
|
|
13
|
+
tableName: "store_fw2855_global_items_it",
|
|
14
|
+
tenancy: "global",
|
|
15
|
+
columns: [
|
|
16
|
+
{
|
|
17
|
+
name: "id",
|
|
18
|
+
pgType: "uuid",
|
|
19
|
+
notNull: true,
|
|
20
|
+
primaryKey: true,
|
|
21
|
+
defaultSql: "gen_random_uuid()",
|
|
22
|
+
},
|
|
23
|
+
{ name: "tenant_id", pgType: "uuid", notNull: true },
|
|
24
|
+
{ name: "some_field", pgType: "text", notNull: true },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
let stack: TestStack;
|
|
29
|
+
|
|
30
|
+
const tenantA = testTenantId(91);
|
|
31
|
+
const tenantB = testTenantId(92);
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
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" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
await stack.cleanup();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
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);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the normal (non-global) db.selectMany on the same table still filters by tenant", async () => {
|
|
59
|
+
const tdb = createTenantDb(stack.db, tenantA);
|
|
60
|
+
// selectMany's Table param types the branded-EntityTable shape only;
|
|
61
|
+
// EntityTableMeta reads work identically at runtime (both normalize via
|
|
62
|
+
// asEntityTableMeta) — same unbranded-view cast as tenant-db-where-merge.test.ts.
|
|
63
|
+
const rows = await tdb.selectMany<{ tenantId: string; someField: string }>(
|
|
64
|
+
globalItemsTable as unknown as TableColumns,
|
|
65
|
+
{ someField: "match" },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(rows).toHaveLength(1);
|
|
69
|
+
expect(rows[0]?.tenantId).toBe(tenantA);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("TenantDb.global() — writes, real Postgres", () => {
|
|
74
|
+
test("insert/update/delete succeed when createTenantDb was given an escapeHatch", async () => {
|
|
75
|
+
const tdb = createTenantDb(stack.db, tenantA, "tenant", undefined, undefined, undefined, {
|
|
76
|
+
reason: "fw#2855 integration test — cross-tenant write via db.global()",
|
|
77
|
+
});
|
|
78
|
+
const globalDb = tdb.global(globalItemsTable);
|
|
79
|
+
|
|
80
|
+
const inserted = await globalDb.insertOne<{ id: string; tenantId: string }>({
|
|
81
|
+
tenantId: tenantB,
|
|
82
|
+
someField: "escape-hatch-insert",
|
|
83
|
+
});
|
|
84
|
+
if (!inserted) throw new Error("insertOne returned no row");
|
|
85
|
+
expect(inserted.tenantId).toBe(tenantB);
|
|
86
|
+
|
|
87
|
+
const updated = await globalDb.updateMany<{ someField: string }>(
|
|
88
|
+
{ someField: "escape-hatch-update" },
|
|
89
|
+
{ id: inserted.id },
|
|
90
|
+
);
|
|
91
|
+
expect(updated[0]?.someField).toBe("escape-hatch-update");
|
|
92
|
+
|
|
93
|
+
await globalDb.deleteMany({ id: inserted.id });
|
|
94
|
+
const remaining = await globalDb.selectMany({ id: inserted.id });
|
|
95
|
+
expect(remaining).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
3
|
+
import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
4
|
+
import { createEntity, createTextField } from "../../engine";
|
|
5
|
+
import { testTenantId } from "../../stack";
|
|
6
|
+
import type { DbRunner } from "../connection";
|
|
7
|
+
import { defineUnmanagedTable, deriveEntityTableMeta } from "../entity-table-meta";
|
|
8
|
+
import { buildEntityTable } from "../table-builder";
|
|
9
|
+
import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../tenant-db";
|
|
10
|
+
|
|
11
|
+
// db.global()'s runtime + write-gate behaviour (fw#2855). No Postgres needed —
|
|
12
|
+
// the stub runner throws on first touch, so a test that reaches it fails loudly
|
|
13
|
+
// instead of silently "passing" on an accidental real write.
|
|
14
|
+
function unreachableRunner(): DbRunner {
|
|
15
|
+
return {
|
|
16
|
+
unsafe: async () => {
|
|
17
|
+
throw new Error("db.global(): must not touch the DB runner here");
|
|
18
|
+
},
|
|
19
|
+
begin: async () => {
|
|
20
|
+
throw new Error("db.global(): must not touch the DB runner here");
|
|
21
|
+
},
|
|
22
|
+
} as DbRunner;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const tenantEntity = createEntity({
|
|
26
|
+
table: "fw2855_global_tenant_items",
|
|
27
|
+
fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
|
|
28
|
+
});
|
|
29
|
+
const tenantEntityTable = buildEntityTable("globalGuardTenantItem", tenantEntity);
|
|
30
|
+
const tenantMeta = deriveEntityTableMeta("globalGuardTenantMeta", tenantEntity);
|
|
31
|
+
|
|
32
|
+
const globalManagedEntity = createEntity({
|
|
33
|
+
table: "fw2855_global_managed_items",
|
|
34
|
+
tenancy: "global",
|
|
35
|
+
fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
|
|
36
|
+
});
|
|
37
|
+
const globalManagedTable = buildEntityTable("globalGuardManagedItem", globalManagedEntity);
|
|
38
|
+
|
|
39
|
+
const globalUnmanagedTable = defineUnmanagedTable({
|
|
40
|
+
tableName: "store_fw2855_global_items",
|
|
41
|
+
tenancy: "global",
|
|
42
|
+
columns: [
|
|
43
|
+
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
44
|
+
{ name: "name", pgType: "text", notNull: true },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Regression coverage for the tenancy-brand type hole (fw#2855 review): an
|
|
49
|
+
// EntityDefinition object literal (bypassing createEntity) must not be
|
|
50
|
+
// mis-branded "global" just because it lacks a `tenancy` property at all.
|
|
51
|
+
const bareLiteralTable = buildEntityTable("globalGuardBareLiteral", {
|
|
52
|
+
fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
|
|
53
|
+
});
|
|
54
|
+
const explicitTenantLiteralTable = buildEntityTable("globalGuardExplicitTenant", {
|
|
55
|
+
tenancy: "tenant",
|
|
56
|
+
fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
|
|
57
|
+
});
|
|
58
|
+
const explicitGlobalLiteralTable = buildEntityTable("globalGuardExplicitGlobal", {
|
|
59
|
+
tenancy: "global",
|
|
60
|
+
fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const own = testTenantId(1);
|
|
64
|
+
|
|
65
|
+
// Type-only assertions — never invoked, checked by `tsc --build` only.
|
|
66
|
+
function typeAssertions(tdb: TenantDb): void {
|
|
67
|
+
// @ts-expect-error db.global() on a "tenant"-tenancy table (default tenancy) is a type error.
|
|
68
|
+
tdb.global(tenantEntityTable);
|
|
69
|
+
|
|
70
|
+
// @ts-expect-error an EntityDefinition object literal without a `tenancy` key must not brand "global".
|
|
71
|
+
tdb.global(bareLiteralTable);
|
|
72
|
+
|
|
73
|
+
// @ts-expect-error an explicit `tenancy: "tenant"` literal must not brand "global".
|
|
74
|
+
tdb.global(explicitTenantLiteralTable);
|
|
75
|
+
|
|
76
|
+
// Compile-positive: an explicit `tenancy: "global"` literal is reachable through db.global().
|
|
77
|
+
void tdb.global(explicitGlobalLiteralTable).selectMany({ name: "x" });
|
|
78
|
+
|
|
79
|
+
// Compile-positive: an unmanaged "global" table is fully writable through db.global().
|
|
80
|
+
void tdb.global(globalUnmanagedTable).insertOne({ name: "x" });
|
|
81
|
+
|
|
82
|
+
const managedAccessor = tdb.global(globalManagedTable);
|
|
83
|
+
// @ts-expect-error managed global tables stay executor-only — no write methods through db.global() either.
|
|
84
|
+
void managedAccessor.insertOne({ name: "x" });
|
|
85
|
+
}
|
|
86
|
+
void typeAssertions;
|
|
87
|
+
|
|
88
|
+
describe("TenantDb.global()", () => {
|
|
89
|
+
test("on a tenant-tenancy table throws AccessDeniedError at runtime (erased/unbranded table)", () => {
|
|
90
|
+
const tdb = createTenantDb(unreachableRunner(), own);
|
|
91
|
+
expect(() =>
|
|
92
|
+
tdb.global(tenantMeta as unknown as EntityTableMeta & TenancyBrand<"global">),
|
|
93
|
+
).toThrow(/tenancy: "global"/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("reads on a global table never touch the caller's tenant filter", async () => {
|
|
97
|
+
const captured: { sql: string; values: readonly unknown[] }[] = [];
|
|
98
|
+
const db: DbRunner = {
|
|
99
|
+
unsafe: async (sql: string, values: readonly unknown[]) => {
|
|
100
|
+
captured.push({ sql, values });
|
|
101
|
+
return [];
|
|
102
|
+
},
|
|
103
|
+
begin: async () => {
|
|
104
|
+
throw new Error("begin not used in this test");
|
|
105
|
+
},
|
|
106
|
+
} as DbRunner;
|
|
107
|
+
const tdb = createTenantDb(db, own);
|
|
108
|
+
await tdb.global(globalUnmanagedTable).selectMany({ name: "x" });
|
|
109
|
+
expect(captured).toHaveLength(1);
|
|
110
|
+
expect(captured[0]?.sql).not.toMatch(/tenant_id/i);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("write without escapeHatch rejects before touching the runner", async () => {
|
|
114
|
+
const tdb = createTenantDb(unreachableRunner(), own);
|
|
115
|
+
await expect(tdb.global(globalUnmanagedTable).insertOne({ name: "x" })).rejects.toThrow(
|
|
116
|
+
/escapeHatch/,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("write with an empty-reason escapeHatch rejects before touching the runner", async () => {
|
|
121
|
+
const tdb = createTenantDb(
|
|
122
|
+
unreachableRunner(),
|
|
123
|
+
own,
|
|
124
|
+
"tenant",
|
|
125
|
+
undefined,
|
|
126
|
+
undefined,
|
|
127
|
+
undefined,
|
|
128
|
+
{
|
|
129
|
+
reason: " ",
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
await expect(tdb.global(globalUnmanagedTable).insertOne({ name: "x" })).rejects.toThrow(
|
|
133
|
+
/escapeHatch/,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("write with a non-empty escapeHatch reason reaches the runner", async () => {
|
|
138
|
+
const captured: { sql: string }[] = [];
|
|
139
|
+
const db: DbRunner = {
|
|
140
|
+
unsafe: async (sql: string) => {
|
|
141
|
+
captured.push({ sql });
|
|
142
|
+
return [{ id: "x" }];
|
|
143
|
+
},
|
|
144
|
+
begin: async () => {
|
|
145
|
+
throw new Error("begin not used in this test");
|
|
146
|
+
},
|
|
147
|
+
} as DbRunner;
|
|
148
|
+
const tdb = createTenantDb(db, own, "tenant", undefined, undefined, undefined, {
|
|
149
|
+
reason: "cross-tenant backfill job",
|
|
150
|
+
});
|
|
151
|
+
await tdb.global(globalUnmanagedTable).insertOne({ name: "x" });
|
|
152
|
+
expect(captured).toHaveLength(1);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("UncheckedSystemDb.unsafeRaw", () => {
|
|
157
|
+
test("throws on an empty or whitespace-only reason", () => {
|
|
158
|
+
const tdb = createTenantDb(unreachableRunner(), own);
|
|
159
|
+
const sysDb = createUncheckedSystemDb(tdb);
|
|
160
|
+
expect(() => sysDb.unsafeRaw("")).toThrow();
|
|
161
|
+
expect(() => sysDb.unsafeRaw(" ")).toThrow();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("returns the underlying DbRunner for a non-empty reason", () => {
|
|
165
|
+
const rawDb = unreachableRunner();
|
|
166
|
+
const tdb = createTenantDb(rawDb, own);
|
|
167
|
+
const sysDb = createUncheckedSystemDb(tdb);
|
|
168
|
+
expect(sysDb.unsafeRaw("cleanup job")).toBe(rawDb);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// hand-built path; app author owns tenant scoping / versioning. Prefer a
|
|
17
17
|
// `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
|
|
18
18
|
|
|
19
|
+
import type { EntityTenancy, TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
19
20
|
import { collectPiiSubjectFields } from "../crypto";
|
|
20
21
|
import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
|
|
21
22
|
import { SQL_EXPR_BRAND } from "./dialect";
|
|
@@ -385,6 +386,7 @@ export function deriveEntityTableMeta(
|
|
|
385
386
|
indexes,
|
|
386
387
|
source,
|
|
387
388
|
...(piiSubjectFields.length > 0 && { piiSubjectFields }),
|
|
389
|
+
...(entity.tenancy === "global" && { tenancy: "global" as const }),
|
|
388
390
|
};
|
|
389
391
|
}
|
|
390
392
|
|
|
@@ -453,7 +455,9 @@ function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
|
|
|
453
455
|
* table. Justify WHY in the call site; reviewers should scrutinize every new
|
|
454
456
|
* unmanaged table.
|
|
455
457
|
*/
|
|
456
|
-
export function defineUnmanagedTable
|
|
458
|
+
export function defineUnmanagedTable<const T extends EntityTenancy = "tenant">(
|
|
459
|
+
input: UnmanagedTableInput & { readonly tenancy?: T },
|
|
460
|
+
): EntityTableMeta & TenancyBrand<T> {
|
|
457
461
|
assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
|
|
458
462
|
return {
|
|
459
463
|
tableName: input.tableName,
|
|
@@ -463,7 +467,9 @@ export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMet
|
|
|
463
467
|
compositePrimaryKey: input.compositePrimaryKey,
|
|
464
468
|
}),
|
|
465
469
|
source: "unmanaged",
|
|
466
|
-
|
|
470
|
+
...(input.tenancy === "global" && { tenancy: "global" as const }),
|
|
471
|
+
// @cast-boundary type-brand — TenancyBrand<T> is a phantom marker with no runtime representation.
|
|
472
|
+
} as EntityTableMeta & TenancyBrand<T>;
|
|
467
473
|
}
|
|
468
474
|
|
|
469
475
|
function assertUnmanagedTableName(tableName: string, via: string): void {
|
package/src/db/table-builder.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
1
2
|
import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
3
|
+
import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
4
|
+
import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
2
5
|
import type {
|
|
3
6
|
EntityDefinition,
|
|
4
7
|
EntityRelations,
|
|
@@ -408,6 +411,18 @@ type SoftDeleteColumnsType = {
|
|
|
408
411
|
|
|
409
412
|
// ── ES-write brand ──────────────────────────────────────────────────────
|
|
410
413
|
export type { ExecutorOnly, NotExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
414
|
+
// ── Tenancy brand ────────────────────────────────────────────────────────
|
|
415
|
+
export type { EntityTenancy, TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
416
|
+
|
|
417
|
+
// `E extends { tenancy?: "global" }` would wrongly match any E missing
|
|
418
|
+
// `tenancy` too (an absent optional property structurally satisfies an
|
|
419
|
+
// optional one) — index into E["tenancy"] instead so only a literal
|
|
420
|
+
// "global" (not undefined/"tenant"/a union/never) brands "global".
|
|
421
|
+
type EntityTableTenancy<E extends EntityDefinition> = [NonNullable<E["tenancy"]>] extends [never]
|
|
422
|
+
? "tenant"
|
|
423
|
+
: [NonNullable<E["tenancy"]>] extends ["global"]
|
|
424
|
+
? "global"
|
|
425
|
+
: "tenant";
|
|
411
426
|
|
|
412
427
|
export type EntityTable<E extends EntityDefinition = EntityDefinition> =
|
|
413
428
|
TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.
|
|
@@ -415,7 +430,8 @@ export type EntityTable<E extends EntityDefinition = EntityDefinition> =
|
|
|
415
430
|
BaseColumnsType<E> &
|
|
416
431
|
SoftDeleteColumnsType &
|
|
417
432
|
ColumnsForEntity<E["fields"]> &
|
|
418
|
-
ExecutorOnly
|
|
433
|
+
ExecutorOnly &
|
|
434
|
+
TenancyBrand<EntityTableTenancy<E>>;
|
|
419
435
|
|
|
420
436
|
export function buildBaseColumns(softDelete: boolean, idType: "serial" | "uuid" = "uuid") {
|
|
421
437
|
const idColumn =
|
|
@@ -474,6 +490,21 @@ export type BuildEntityTableOptions = {
|
|
|
474
490
|
readonly relations?: EntityRelations;
|
|
475
491
|
};
|
|
476
492
|
|
|
493
|
+
function stampGlobalTenancyMeta<E extends EntityDefinition>(
|
|
494
|
+
table: EntityTable<E>,
|
|
495
|
+
entity: E,
|
|
496
|
+
): void {
|
|
497
|
+
// skip: non-global entities keep pgTable's default tenancy meta
|
|
498
|
+
if (entity.tenancy !== "global") return;
|
|
499
|
+
const meta = (table as unknown as Record<symbol, EntityTableMeta>)[KUMIKO_META_SYMBOL];
|
|
500
|
+
// skip: no meta stamp present — nothing to patch
|
|
501
|
+
if (!meta) return;
|
|
502
|
+
(table as unknown as Record<symbol, EntityTableMeta>)[KUMIKO_META_SYMBOL] = {
|
|
503
|
+
...meta,
|
|
504
|
+
tenancy: "global",
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
477
508
|
export function buildEntityTable<E extends EntityDefinition>(
|
|
478
509
|
entityName: string,
|
|
479
510
|
entity: E,
|
|
@@ -525,7 +556,7 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
525
556
|
// hand in. Our typed signature narrows that to the static names from
|
|
526
557
|
// EntityDefinition (kept in sync with fieldToColumns + buildBaseColumns).
|
|
527
558
|
// Drizzle's runtime instance carries every needed method on top.
|
|
528
|
-
|
|
559
|
+
const built = pgTable(
|
|
529
560
|
tableName,
|
|
530
561
|
{
|
|
531
562
|
...baseColumns,
|
|
@@ -627,4 +658,8 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
627
658
|
return indexes;
|
|
628
659
|
},
|
|
629
660
|
) as unknown as EntityTable<E>;
|
|
661
|
+
|
|
662
|
+
// pgTable's own meta stamp doesn't see entity.tenancy — patch it so db.global()'s runtime guard matches the type brand.
|
|
663
|
+
stampGlobalTenancyMeta(built, entity);
|
|
664
|
+
return built;
|
|
630
665
|
}
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
2
|
+
import type { EscapeHatchDeclaration } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
3
|
import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
4
|
+
import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
2
5
|
import {
|
|
6
|
+
type GlobalTableDb,
|
|
3
7
|
SYSTEM_SCOPE_CHECK_BRAND,
|
|
4
8
|
type TenantDb,
|
|
5
9
|
type TenantDbMode,
|
|
@@ -95,6 +99,13 @@ export function createUncheckedSystemDb(
|
|
|
95
99
|
return db;
|
|
96
100
|
},
|
|
97
101
|
|
|
102
|
+
unsafeRaw(reason) {
|
|
103
|
+
if (reason.trim().length === 0) {
|
|
104
|
+
throw new Error("unsafeRaw requires a non-empty reason");
|
|
105
|
+
}
|
|
106
|
+
return db.raw;
|
|
107
|
+
},
|
|
108
|
+
|
|
98
109
|
outsideTransaction: {
|
|
99
110
|
assertTenantMatch(tenantId) {
|
|
100
111
|
if (tenantId !== db.tenantId) {
|
|
@@ -120,9 +131,10 @@ export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): rea
|
|
|
120
131
|
return rows as unknown as readonly T[];
|
|
121
132
|
}
|
|
122
133
|
|
|
123
|
-
function tableNameOf(table: Table): string {
|
|
124
|
-
const sym = table[KUMIKO_NAME_SYMBOL];
|
|
125
|
-
|
|
134
|
+
function tableNameOf(table: Table | EntityTableMeta): string {
|
|
135
|
+
const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
136
|
+
if (typeof sym === "string") return sym;
|
|
137
|
+
return asEntityTableMeta(table)?.tableName ?? "<unknown>";
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
// Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
|
|
@@ -143,12 +155,14 @@ export function createTenantDb(
|
|
|
143
155
|
tracer?: Tracer,
|
|
144
156
|
meter?: Meter,
|
|
145
157
|
signal?: AbortSignal,
|
|
158
|
+
// Gates db.global(table)'s write methods; write-path-only (see dispatch-shared.ts buildHandlerContext).
|
|
159
|
+
escapeHatch?: EscapeHatchDeclaration,
|
|
146
160
|
): TenantDb {
|
|
147
161
|
if (meter) registerStandardMetrics(meter);
|
|
148
162
|
|
|
149
163
|
function withDbSpan<T>(
|
|
150
164
|
operation: "select" | "insert" | "update" | "delete",
|
|
151
|
-
table: Table,
|
|
165
|
+
table: Table | EntityTableMeta,
|
|
152
166
|
runner: () => Promise<T>,
|
|
153
167
|
): Promise<T> {
|
|
154
168
|
signal?.throwIfAborted();
|
|
@@ -227,10 +241,78 @@ export function createTenantDb(
|
|
|
227
241
|
return { ...data, tenantId };
|
|
228
242
|
}
|
|
229
243
|
|
|
244
|
+
function missingEscapeHatch(table: Table | EntityTableMeta): AccessDeniedError | undefined {
|
|
245
|
+
if (escapeHatch && escapeHatch.reason.trim().length > 0) return undefined;
|
|
246
|
+
return new AccessDeniedError({
|
|
247
|
+
message:
|
|
248
|
+
`db.global(${tableNameOf(table)}): write rejected — declare ` +
|
|
249
|
+
`\`escapeHatch: { reason: "..." }\` on the write handler to allow ` +
|
|
250
|
+
"writes through db.global().",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function globalTable<TTable extends (SchemaTable | EntityTableMeta) & TenancyBrand<"global">>(
|
|
255
|
+
table: TTable,
|
|
256
|
+
): GlobalTableDb<TTable> {
|
|
257
|
+
const meta = asEntityTableMeta(table);
|
|
258
|
+
if (meta?.tenancy !== "global") {
|
|
259
|
+
throw new AccessDeniedError({
|
|
260
|
+
message: `db.global(${tableNameOf(table)}): table is not declared \`tenancy: "global"\`.`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
selectMany<T = Record<string, unknown>>(
|
|
265
|
+
where?: WhereObject,
|
|
266
|
+
options?: SelectOptions,
|
|
267
|
+
): Promise<readonly T[]> {
|
|
268
|
+
return withDbSpan("select", table, async () => bunSelectMany<T>(db, table, where, options));
|
|
269
|
+
},
|
|
270
|
+
fetchOne<T = Record<string, unknown>>(where: WhereObject): Promise<T | undefined> {
|
|
271
|
+
return withDbSpan("select", table, async () => bunFetchOne<T>(db, table, where));
|
|
272
|
+
},
|
|
273
|
+
insertOne<T = Record<string, unknown>>(
|
|
274
|
+
values: Record<string, unknown>,
|
|
275
|
+
): Promise<T | undefined> {
|
|
276
|
+
const denied = missingEscapeHatch(table);
|
|
277
|
+
if (denied) return Promise.reject(denied);
|
|
278
|
+
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
|
|
279
|
+
},
|
|
280
|
+
updateMany<T = Record<string, unknown>>(
|
|
281
|
+
set: Record<string, unknown>,
|
|
282
|
+
where: WhereObject,
|
|
283
|
+
): Promise<readonly T[]> {
|
|
284
|
+
const denied = missingEscapeHatch(table);
|
|
285
|
+
if (denied) return Promise.reject(denied);
|
|
286
|
+
if (!where || Object.keys(where).length === 0) {
|
|
287
|
+
return Promise.reject(
|
|
288
|
+
new Error(
|
|
289
|
+
"db.global().updateMany without where would mass-update every tenant's rows. Pass at least one where condition.",
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return withDbSpan("update", table, async () => bunUpdateMany<T>(db, table, set, where));
|
|
294
|
+
},
|
|
295
|
+
deleteMany(where: WhereObject): Promise<void> {
|
|
296
|
+
const denied = missingEscapeHatch(table);
|
|
297
|
+
if (denied) return Promise.reject(denied);
|
|
298
|
+
if (!where || Object.keys(where).length === 0) {
|
|
299
|
+
return Promise.reject(
|
|
300
|
+
new Error(
|
|
301
|
+
"db.global().deleteMany without where would mass-delete every tenant's rows. Pass at least one where condition.",
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return withDbSpan("delete", table, async () => bunDeleteMany(db, table, where));
|
|
306
|
+
},
|
|
307
|
+
// @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
|
|
308
|
+
} as GlobalTableDb<TTable>;
|
|
309
|
+
}
|
|
310
|
+
|
|
230
311
|
return {
|
|
231
312
|
tenantId,
|
|
232
313
|
mode,
|
|
233
314
|
raw: db,
|
|
315
|
+
global: globalTable,
|
|
234
316
|
|
|
235
317
|
selectMany<T = Record<string, unknown>>(
|
|
236
318
|
table: Table,
|
|
@@ -578,41 +578,42 @@ describe("boot-validator", () => {
|
|
|
578
578
|
// --- Handler access validation (default-deny) ---
|
|
579
579
|
|
|
580
580
|
test("throws when a write handler has no access rule", () => {
|
|
581
|
-
|
|
581
|
+
// Access is a required option since fw#2855 — the registrar itself throws
|
|
582
|
+
// before validateBoot ever runs (was: caught at boot-validator level).
|
|
583
|
+
expect(() =>
|
|
582
584
|
defineFeature("a", (r) => {
|
|
585
|
+
// @ts-expect-error options.access is required since fw#2855.
|
|
583
586
|
r.writeHandler("createThing", z.object({ name: z.string() }), async () => ({
|
|
584
587
|
isSuccess: true as const,
|
|
585
588
|
data: {},
|
|
586
589
|
}));
|
|
587
590
|
}),
|
|
588
|
-
|
|
589
|
-
expect(() => validateBoot(features)).toThrow(/a:write:createThing.*missing an access rule/i);
|
|
591
|
+
).toThrow(/requires schema \+ handler \+ options\.access/);
|
|
590
592
|
});
|
|
591
593
|
|
|
592
594
|
test("throws when a query handler has no access rule", () => {
|
|
593
|
-
|
|
595
|
+
expect(() =>
|
|
594
596
|
defineFeature("a", (r) => {
|
|
597
|
+
// @ts-expect-error options.access is required since fw#2855.
|
|
595
598
|
r.queryHandler("list", z.object({}), async () => []);
|
|
596
599
|
}),
|
|
597
|
-
|
|
598
|
-
expect(() => validateBoot(features)).toThrow(/a:query:list.*missing an access rule/i);
|
|
600
|
+
).toThrow(/requires schema \+ handler \+ options\.access/);
|
|
599
601
|
});
|
|
600
602
|
|
|
601
|
-
test("registerEntityCrud without access options fails
|
|
603
|
+
test("registerEntityCrud without access options fails registration (no implicit openToAll)", () => {
|
|
602
604
|
const widgetEntity = createEntity({
|
|
603
605
|
table: "phase0_widgets",
|
|
604
606
|
fields: {
|
|
605
607
|
name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
606
608
|
},
|
|
607
609
|
});
|
|
608
|
-
|
|
610
|
+
// Access is required since fw#2855 — registerEntityCrud now throws at
|
|
611
|
+
// registration time (was: caught at boot-validator level).
|
|
612
|
+
expect(() =>
|
|
609
613
|
defineFeature("phase0", (r) => {
|
|
610
614
|
registerEntityCrud(r, "widget", widgetEntity);
|
|
611
615
|
}),
|
|
612
|
-
|
|
613
|
-
expect(() => validateBoot(features)).toThrow(
|
|
614
|
-
/phase0:write:widget:create.*missing an access rule/i,
|
|
615
|
-
);
|
|
616
|
+
).toThrow(/no access rule resolved/i);
|
|
616
617
|
});
|
|
617
618
|
|
|
618
619
|
test("accepts role-based access rule", () => {
|
|
@@ -684,12 +685,12 @@ describe("boot-validator", () => {
|
|
|
684
685
|
});
|
|
685
686
|
|
|
686
687
|
test("throws when a stream handler has no access rule", () => {
|
|
687
|
-
|
|
688
|
+
expect(() =>
|
|
688
689
|
defineFeature("a", (r) => {
|
|
690
|
+
// @ts-expect-error options.access is required since fw#2855.
|
|
689
691
|
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
690
692
|
}),
|
|
691
|
-
|
|
692
|
-
expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
|
|
693
|
+
).toThrow(/requires schema \+ handler \+ options\.access/);
|
|
693
694
|
});
|
|
694
695
|
|
|
695
696
|
test("object-form streamHandler with access rule passes boot", () => {
|
|
@@ -3167,7 +3168,10 @@ describe("boot-validator", () => {
|
|
|
3167
3168
|
// Post-save navigation target, same validation as actionForm's redirect:
|
|
3168
3169
|
// short screen-ID, same feature, must resolve to a registered screen.
|
|
3169
3170
|
describe("entityEdit redirect", () => {
|
|
3170
|
-
function makeFeature(
|
|
3171
|
+
function makeFeature(
|
|
3172
|
+
redirect?: string | { readonly screen: string; readonly idFrom: string },
|
|
3173
|
+
extraScreens: readonly string[] = [],
|
|
3174
|
+
) {
|
|
3171
3175
|
return defineFeature("shop", (r) => {
|
|
3172
3176
|
r.entity(
|
|
3173
3177
|
"product",
|
|
@@ -3224,6 +3228,27 @@ describe("boot-validator", () => {
|
|
|
3224
3228
|
/redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
|
|
3225
3229
|
);
|
|
3226
3230
|
});
|
|
3231
|
+
|
|
3232
|
+
// --- redirect object form with idFrom (same rule as actionForm's) ---
|
|
3233
|
+
test("redirect object → existing screen-id → kein Throw", () => {
|
|
3234
|
+
expect(() =>
|
|
3235
|
+
validateBoot([
|
|
3236
|
+
makeFeature({ screen: "product-list", idFrom: "parentId" }, ["product-list"]),
|
|
3237
|
+
]),
|
|
3238
|
+
).not.toThrow();
|
|
3239
|
+
});
|
|
3240
|
+
|
|
3241
|
+
test("redirect object → unknown screen-id → Throw", () => {
|
|
3242
|
+
expect(() =>
|
|
3243
|
+
validateBoot([makeFeature({ screen: "ghost-screen", idFrom: "parentId" })]),
|
|
3244
|
+
).toThrow(/redirect "ghost-screen" does not resolve to a registered screen/);
|
|
3245
|
+
});
|
|
3246
|
+
|
|
3247
|
+
test("redirect object mit leerem idFrom → Throw", () => {
|
|
3248
|
+
expect(() =>
|
|
3249
|
+
validateBoot([makeFeature({ screen: "product-list", idFrom: " " }, ["product-list"])]),
|
|
3250
|
+
).toThrow(/redirect\.idFrom is empty/);
|
|
3251
|
+
});
|
|
3227
3252
|
});
|
|
3228
3253
|
|
|
3229
3254
|
// --- no-widget field types + required (#1925) ---
|