@cosmicdrift/kumiko-framework 0.245.0 → 0.246.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.245.0",
3
+ "version": "0.246.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.245.0",
201
+ "@cosmicdrift/kumiko-types": "0.246.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.245.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.246.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -14,15 +14,14 @@
14
14
  // (DEK erased → Wert unlesbar, bidx NULL). Load-bearing Test:
15
15
  // db/__tests__/implicit-projection-equivalence.integration.test.ts.
16
16
  //
17
- // Tenant-Isolation: applyEntityEvent erwartet einen rohen DbRunner (TX
18
- // oder pool), KEINEN TenantDb-Wrapper. Schutz kommt aus zwei Quellen:
19
- // 1. Live-Pfad ruft VOR der Schreibung loadById (tenant-scoped) für
20
- // update/delete/restore — die aggregateId ist also schon tenant-
21
- // validiert bevor wir hier ankommen.
22
- // 2. Bei create wird tenantId explizit aus event.tenantId gesetzt, also
23
- // nie über den TenantDb-Wrapper-Default abgeleitet.
24
- // Damit ist der TenantDb-Wrapper-Loss in dieser Funktion funktional ohne
25
- // Sicherheitslücke.
17
+ // Tenant isolation: applyEntityEvent expects a raw DbRunner (tx or pool), NOT
18
+ // a TenantDb wrapper. Two sources keep it safe:
19
+ // 1. The live path reads the target row through the tenant-scoped TenantDb
20
+ // before writing — loadById() for update/delete/forget, db.fetchOne() for
21
+ // restore — so the aggregateId is already tenant-validated on arrival.
22
+ // 2. create sets tenantId explicitly from event.tenantId, never derived from
23
+ // the TenantDb wrapper default.
24
+ // The missing TenantDb wrapper in this function is therefore not a hole.
26
25
  //
27
26
  // Auto-Verben:
28
27
  // <entity>.created → INSERT
@@ -27,7 +27,7 @@ import {
27
27
  entityEventName,
28
28
  tryMapUniqueViolation,
29
29
  } from "./event-store-executor-context";
30
- import { runInSavepointIfSupported, selectMany } from "./query";
30
+ import { runInSavepointIfSupported } from "./query";
31
31
 
32
32
  // Art. 17 erasure runs as the framework operator, not as a row owner; a
33
33
  // per-role ownership map can never cover it, and a silent deny means the
@@ -714,7 +714,12 @@ export function createWriteVerbs(
714
714
  );
715
715
  }
716
716
 
717
- const [row] = await selectMany(db.raw, table, { id: payload.id });
717
+ // Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
718
+ // selectMany(db.raw, ...) did not — any caller could un-delete a foreign
719
+ // tenant's row by id. "system"-mode dbs (r.systemScope() / crossTenant
720
+ // handlers) still read unfiltered. No isDeleted filter here: restore
721
+ // targets exactly the soft-deleted row.
722
+ const row = await db.fetchOne(table, { id: payload.id });
718
723
  if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
719
724
  const data = row as DbRow;
720
725
  if (!data["isDeleted"]) {
@@ -783,9 +788,9 @@ export function createWriteVerbs(
783
788
  throw e;
784
789
  }
785
790
 
786
- // Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
787
- // false + version-Bump in die Tabelle — keine sensitive-Drift, daher
788
- // kein payload-override nötig.
791
+ // Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
792
+ // plus the version bump, so there is no sensitive-field drift and no
793
+ // payload override is needed.
789
794
  const restoreResult = await runInSavepointIfSupported(db.raw, async (sp) =>
790
795
  applyEntityEvent(event, table, entity, sp),
791
796
  );
@@ -798,11 +803,11 @@ export function createWriteVerbs(
798
803
  await entityCache.del(user.tenantId, entityName, payload.id);
799
804
  }
800
805
 
801
- // Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
806
+ // Read-side auto-convert for compound types, same as update/list.
802
807
  // decryptForRead matches create/update/list/detail: the caller-facing
803
808
  // row and `previous` snapshot must be plaintext for `encrypted` fields,
804
809
  // same as every other executor method — `data`/`restored` are raw rows
805
- // (selectMany / applyEntityEvent), never decrypted before this point.
810
+ // (db.fetchOne / applyEntityEvent), never decrypted before this point.
806
811
  const restoredHydrated = await decryptForRead(
807
812
  rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
808
813
  );
@@ -0,0 +1,143 @@
1
+ // restore() used to load its target row with selectMany(db.raw, ...) — a raw,
2
+ // unfiltered read. Any caller holding the write role could therefore un-delete
3
+ // a soft-deleted row belonging to another tenant and got the decrypted row back
4
+ // in the response. delete() never had that hole (it goes through the
5
+ // tenant-scoped loadById). These tests pin both halves: the tenant-scoped
6
+ // handler must not reach across, and the crossTenant handler must still be able
7
+ // to.
8
+ //
9
+ // Two stacks on the SAME Postgres database: one registers the restore handler
10
+ // without `crossTenant`, the other with it — a clean A/B on the one flag.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { selectMany } from "../../db/query";
14
+ import { buildEntityTable } from "../../db/table-builder";
15
+ import {
16
+ setupTestStack,
17
+ type TestStack,
18
+ TestUsers,
19
+ testTenantId,
20
+ unsafeCreateEntityTable,
21
+ } from "../../stack";
22
+ import {
23
+ createEntity,
24
+ createTextField,
25
+ defineEntityCreateHandler,
26
+ defineEntityDeleteHandler,
27
+ defineEntityRestoreHandler,
28
+ defineFeature,
29
+ from,
30
+ } from "../index";
31
+
32
+ const thingEntity = createEntity({
33
+ table: "ctrestore_things",
34
+ softDelete: true,
35
+ fields: {
36
+ label: createTextField({ required: true }),
37
+ },
38
+ access: { write: { Admin: "all", SystemAdmin: from("user:tenantId", "tenantId") } },
39
+ });
40
+ const thingTable = buildEntityTable("thing", thingEntity);
41
+
42
+ function buildFeature(crossTenant: boolean) {
43
+ return defineFeature("ctrestore", (r) => {
44
+ r.entity("thing", thingEntity);
45
+ r.writeHandler(
46
+ defineEntityCreateHandler("thing", thingEntity, {
47
+ access: { roles: ["Admin", "SystemAdmin"] },
48
+ }),
49
+ );
50
+ r.writeHandler(
51
+ defineEntityDeleteHandler("thing", thingEntity, {
52
+ access: { roles: ["Admin", "SystemAdmin"] },
53
+ }),
54
+ );
55
+ r.writeHandler(
56
+ defineEntityRestoreHandler("thing", thingEntity, {
57
+ access: { roles: ["Admin", "SystemAdmin"] },
58
+ ...(crossTenant && { crossTenant: true }),
59
+ }),
60
+ );
61
+ });
62
+ }
63
+
64
+ const dbName = `kumiko_test_ctrestore_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
65
+
66
+ let tenantScopedStack: TestStack;
67
+ let crossTenantStack: TestStack;
68
+
69
+ beforeAll(async () => {
70
+ tenantScopedStack = await setupTestStack({ features: [buildFeature(false)], dbName });
71
+ await unsafeCreateEntityTable(tenantScopedStack.db, thingEntity, "thing");
72
+ crossTenantStack = await setupTestStack({
73
+ features: [buildFeature(true)],
74
+ dbName,
75
+ persistentDb: true,
76
+ });
77
+ });
78
+
79
+ afterAll(async () => {
80
+ await crossTenantStack.cleanup();
81
+ await tenantScopedStack.cleanup();
82
+ });
83
+
84
+ async function createDeletedThing(label: string): Promise<string> {
85
+ const created = await tenantScopedStack.http.writeOk<{ id: string }>(
86
+ "ctrestore:write:thing:create",
87
+ { label },
88
+ TestUsers.otherTenant,
89
+ );
90
+ await tenantScopedStack.http.writeOk(
91
+ "ctrestore:write:thing:delete",
92
+ { id: created.id },
93
+ TestUsers.otherTenant,
94
+ );
95
+
96
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id: created.id });
97
+ expect(rows[0]?.["isDeleted"]).toBe(true);
98
+
99
+ return created.id;
100
+ }
101
+
102
+ describe("entity restore: tenant isolation", () => {
103
+ test("tenant-scoped restore cannot un-delete a foreign tenant's row", async () => {
104
+ const id = await createDeletedThing("foreign-deleted");
105
+
106
+ const err = await tenantScopedStack.http.writeErr(
107
+ "ctrestore:write:thing:restore",
108
+ { id },
109
+ TestUsers.admin,
110
+ );
111
+ expect(err.code).toBe("not_found");
112
+
113
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
114
+ expect(rows[0]?.["isDeleted"]).toBe(true);
115
+ });
116
+
117
+ test("own-tenant restore still works", async () => {
118
+ const id = await createDeletedThing("own-restore");
119
+
120
+ await tenantScopedStack.http.writeOk(
121
+ "ctrestore:write:thing:restore",
122
+ { id },
123
+ TestUsers.otherTenant,
124
+ );
125
+
126
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
127
+ expect(rows[0]?.["isDeleted"]).toBe(false);
128
+ });
129
+
130
+ test("crossTenant restore still reaches a foreign tenant's row", async () => {
131
+ const id = await createDeletedThing("crosstenant-restore");
132
+
133
+ await crossTenantStack.http.writeOk(
134
+ "ctrestore:write:thing:restore",
135
+ { id },
136
+ TestUsers.systemAdmin,
137
+ );
138
+
139
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
140
+ expect(rows[0]?.["isDeleted"]).toBe(false);
141
+ expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
142
+ });
143
+ });