@cosmicdrift/kumiko-bundled-features 0.245.0 → 0.247.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-bundled-features",
3
- "version": "0.245.0",
3
+ "version": "0.247.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.245.0",
134
- "@cosmicdrift/kumiko-framework": "0.245.0",
135
- "@cosmicdrift/kumiko-headless": "0.245.0",
136
- "@cosmicdrift/kumiko-renderer": "0.245.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.245.0",
138
- "@cosmicdrift/kumiko-types": "0.245.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.247.0",
134
+ "@cosmicdrift/kumiko-framework": "0.247.0",
135
+ "@cosmicdrift/kumiko-headless": "0.247.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.247.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.247.0",
138
+ "@cosmicdrift/kumiko-types": "0.247.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.245.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.245.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.247.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.247.0"
169
169
  }
170
170
  }
@@ -20,8 +20,8 @@ type WriteResult = Awaited<ReturnType<typeof fieldDefinitionExecutor.create>>;
20
20
  // restore-before-create matters: a create() version_conflict aborts the
21
21
  // surrounding tx, so a follow-up restore()/update() on the same connection would
22
22
  // fail with "current transaction is aborted". detail() (a read) + restore()
23
- // (which sees soft-deleted rows via selectMany and only writes on success) keep
24
- // the tx clean until the single terminal write.
23
+ // (which sees soft-deleted rows via a tenant-scoped fetch and only writes on
24
+ // success) keep the tx clean until the single terminal write.
25
25
  export async function defineOrResurrectFieldDefinition(
26
26
  aggregateId: string,
27
27
  columns: FieldDefinitionColumns,
@@ -0,0 +1,238 @@
1
+ // set-folder/clear-folder previously took entityType/entityId straight from the
2
+ // client, so any dispatch-eligible tenant user could file — or unfile — an
3
+ // object they had no read access to. The folder-assignment aggregate-id is
4
+ // derived from the tenant, so this was never a cross-tenant hole; it was "file
5
+ // an object inside your own tenant that you aren't allowed to read", which also
6
+ // moves it out of whatever folder its own team had put it in.
7
+ //
8
+ // The check is unconditional (default-on, no opt-in): entityType must name a
9
+ // registered entity, and the host row must be visible through that entity's own
10
+ // read path (tenant scope plus its `access.read` ownership).
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import {
15
+ createEntity,
16
+ createTextField,
17
+ defineFeature,
18
+ type EntityDefinition,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
21
+ import {
22
+ createTestUser,
23
+ setupTestStack,
24
+ type TestStack,
25
+ unsafeCreateEntityTable,
26
+ } from "@cosmicdrift/kumiko-framework/stack";
27
+ import { FoldersHandlers } from "../constants";
28
+ import { folderAssignmentEntity, folderEntity } from "../entity";
29
+ import { createFoldersFeature } from "../feature";
30
+
31
+ const PROJECT_TABLE = "folders_pv_test_projects";
32
+
33
+ // Team-scoped read ownership on the host entity itself — the caller sees a
34
+ // project row only if their `team` claim matches the row's team_id.
35
+ const projectOwnership: NonNullable<EntityDefinition["access"]> = {
36
+ read: {
37
+ TenantMember: {
38
+ kind: "where",
39
+ where: (user, ctx) => ({
40
+ sqlText: `${ctx.tableName}.team_id = $${ctx.paramStart}`,
41
+ params: [user.claims?.["team"] ?? null],
42
+ }),
43
+ },
44
+ },
45
+ };
46
+
47
+ const projectEntity = createEntity({
48
+ table: PROJECT_TABLE,
49
+ fields: {
50
+ teamId: createTextField({ required: true, maxLength: 64 }),
51
+ name: createTextField({ required: true, maxLength: 64 }),
52
+ },
53
+ access: projectOwnership,
54
+ });
55
+
56
+ const hostFixturesFeature = defineFeature("folders-pv-test-fixtures", (r) => {
57
+ r.entity("project", projectEntity);
58
+ });
59
+
60
+ type TestUser = ReturnType<typeof createTestUser>;
61
+
62
+ // Same tenant, different team claim.
63
+ const userA: TestUser = createTestUser({
64
+ id: 70,
65
+ roles: ["TenantMember"],
66
+ claims: { team: "team-a" },
67
+ });
68
+ const userB: TestUser = createTestUser({
69
+ id: 71,
70
+ roles: ["TenantMember"],
71
+ claims: { team: "team-b" },
72
+ });
73
+
74
+ const PROJECT_A = "c0000000-0000-4000-8000-000000000001";
75
+ // A second team-a project, dedicated to the clear-folder denial case so its
76
+ // membership stays independent of PROJECT_A's.
77
+ const PROJECT_A2 = "c0000000-0000-4000-8000-000000000002";
78
+ // Never filed anywhere — the "no assignment" half of the oracle check.
79
+ const PROJECT_A3 = "c0000000-0000-4000-8000-000000000003";
80
+
81
+ let stack: TestStack;
82
+ let folderId: string;
83
+
84
+ beforeAll(async () => {
85
+ stack = await setupTestStack({
86
+ features: [createFoldersFeature({ access: { openToAll: true } }), hostFixturesFeature],
87
+ });
88
+ await unsafeCreateEntityTable(stack.db, folderEntity);
89
+ await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
90
+ await unsafeCreateEntityTable(stack.db, projectEntity);
91
+ await createEventsTable(stack.db);
92
+ await asRawClient(stack.db).unsafe(
93
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES
94
+ ($1, $2, 'team-a', 'Project A'),
95
+ ($3, $2, 'team-a', 'Project A2'),
96
+ ($4, $2, 'team-a', 'Project A3')`,
97
+ [PROJECT_A, userA.tenantId, PROJECT_A2, PROJECT_A3],
98
+ );
99
+
100
+ const folder = await stack.http.writeOk<{ id: string }>(
101
+ FoldersHandlers.createFolder,
102
+ { name: "Inbox" },
103
+ userA,
104
+ );
105
+ folderId = folder.id;
106
+ });
107
+
108
+ afterAll(async () => {
109
+ await stack.cleanup();
110
+ });
111
+
112
+ async function activeAssignmentsFor(entityId: string): Promise<number> {
113
+ const rows = await asRawClient(stack.db).unsafe<{ n: number }>(
114
+ "SELECT count(*)::int AS n FROM read_folder_assignments WHERE entity_id = $1 AND is_deleted = FALSE",
115
+ [entityId],
116
+ );
117
+ return rows[0]?.n ?? 0;
118
+ }
119
+
120
+ function setFolder(entityType: string, entityId: string, user: TestUser) {
121
+ return stack.http.writeOk<{ id: string }>(
122
+ FoldersHandlers.setFolder,
123
+ { folderId, entityType, entityId },
124
+ user,
125
+ );
126
+ }
127
+
128
+ function setFolderErr(entityType: string, entityId: string, user: TestUser) {
129
+ return stack.http.writeErr(FoldersHandlers.setFolder, { folderId, entityType, entityId }, user);
130
+ }
131
+
132
+ function clearFolder(entityType: string, entityId: string, user: TestUser) {
133
+ return stack.http.writeOk<{ id: string }>(
134
+ FoldersHandlers.clearFolder,
135
+ { entityType, entityId },
136
+ user,
137
+ );
138
+ }
139
+
140
+ function clearFolderErr(entityType: string, entityId: string, user: TestUser) {
141
+ return stack.http.writeErr(FoldersHandlers.clearFolder, { entityType, entityId }, user);
142
+ }
143
+
144
+ describe("folders integration — set-folder parent-visibility", () => {
145
+ test("a host row visible through the host entity's own read path can be filed", async () => {
146
+ const result = await setFolder("project", PROJECT_A, userA);
147
+ expect(result.id).toBeTruthy();
148
+ expect(await activeAssignmentsFor(PROJECT_A)).toBe(1);
149
+ });
150
+
151
+ test("a host row from a foreign team is denied, and nothing is written", async () => {
152
+ const err = await setFolderErr("project", PROJECT_A2, userB);
153
+ expect(err.code).toBe("not_found");
154
+ expect(err.httpStatus).toBe(404);
155
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(0);
156
+
157
+ // Positive control: PROJECT_A2 IS filable — by its own team — so the deny
158
+ // above is the team-claim check, not a row that doesn't exist at all.
159
+ const result = await setFolder("project", PROJECT_A2, userA);
160
+ expect(result.id).toBeTruthy();
161
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
162
+ });
163
+
164
+ test("the denial precedes the folder lookup: an unknown folderId reports the host row, not the folder", async () => {
165
+ // Gate first means a caller who can't see the host row learns nothing about
166
+ // which folder ids exist — both cases answer with the same not_found.
167
+ const unknownFolder = await stack.http.writeErr(
168
+ FoldersHandlers.setFolder,
169
+ {
170
+ folderId: "00000000-0000-4000-8000-00000000dead",
171
+ entityType: "project",
172
+ entityId: PROJECT_A3,
173
+ },
174
+ userB,
175
+ );
176
+ const knownFolder = await setFolderErr("project", PROJECT_A3, userB);
177
+ expect(unknownFolder.code).toBe(knownFolder.code);
178
+ expect(unknownFolder.httpStatus).toBe(knownFolder.httpStatus);
179
+ expect(knownFolder.code).toBe("not_found");
180
+ expect(await activeAssignmentsFor(PROJECT_A3)).toBe(0);
181
+ });
182
+
183
+ test("an entityType that is not a registered entity is denied", async () => {
184
+ const err = await setFolderErr("totally-unknown-entity", PROJECT_A, userA);
185
+ expect(err.code).toBe("not_found");
186
+ expect(err.httpStatus).toBe(404);
187
+ });
188
+
189
+ test("a malformed entityId is denied cleanly, without poisoning the connection", async () => {
190
+ const err = await setFolderErr("project", "not-a-uuid", userA);
191
+ expect(err.code).toBe("not_found");
192
+ expect(err.httpStatus).toBe(404);
193
+
194
+ // The stack must still be usable — proves the malformed id was rejected
195
+ // before it reached a query, not caught after a tx-poisoning cast error.
196
+ expect((await setFolder("project", PROJECT_A, userA)).id).toBeTruthy();
197
+ });
198
+ });
199
+
200
+ describe("folders integration — clear-folder parent-visibility", () => {
201
+ test("a foreign team cannot unfile an existing membership; the row survives", async () => {
202
+ await setFolder("project", PROJECT_A2, userA);
203
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
204
+
205
+ const err = await clearFolderErr("project", PROJECT_A2, userB);
206
+ expect(err.code).toBe("not_found");
207
+ expect(err.httpStatus).toBe(404);
208
+ // Zero-write: the membership userA created is still active, not soft-deleted.
209
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
210
+
211
+ // Positive control: the owning team CAN unfile it.
212
+ await clearFolder("project", PROJECT_A2, userA);
213
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(0);
214
+ });
215
+
216
+ test("the denial carries no existence oracle: filed and unfiled rows answer alike", async () => {
217
+ await setFolder("project", PROJECT_A2, userA);
218
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
219
+ expect(await activeAssignmentsFor(PROJECT_A3)).toBe(0);
220
+
221
+ // clear-folder normally reports success for a never-filed entity, so without
222
+ // the gate running FIRST the two responses below would differ and leak
223
+ // whether the invisible host row sits in a folder at all.
224
+ const onFiled = await clearFolderErr("project", PROJECT_A2, userB);
225
+ const onUnfiled = await clearFolderErr("project", PROJECT_A3, userB);
226
+ expect(onFiled.code).toBe(onUnfiled.code);
227
+ expect(onFiled.httpStatus).toBe(onUnfiled.httpStatus);
228
+ expect(onFiled.code).toBe("not_found");
229
+
230
+ await clearFolder("project", PROJECT_A2, userA);
231
+ });
232
+
233
+ test("an entityType that is not a registered entity is denied", async () => {
234
+ const err = await clearFolderErr("totally-unknown-entity", PROJECT_A, userA);
235
+ expect(err.code).toBe("not_found");
236
+ expect(err.httpStatus).toBe(404);
237
+ });
238
+ });
@@ -8,9 +8,16 @@
8
8
  // defining difference from tags' many-to-many
9
9
  // - clear-folder unfiles; set → clear → set resurrects the deterministic stream
10
10
  // - referential integrity (unknown folderId rejected) + multi-tenant isolation
11
+ //
12
+ // set-folder/clear-folder verify that entityType names a registered entity and
13
+ // that the host row is visible to the caller. This file's assertions are about
14
+ // membership mechanics, not parent ownership, so the `credit` fixture below
15
+ // stays unrestricted (no `access`) on purpose — every host row this file files
16
+ // is seeded in beforeAll.
11
17
 
12
18
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
19
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
20
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
14
21
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
22
  import {
16
23
  createTestUser,
@@ -18,6 +25,7 @@ import {
18
25
  type TestStack,
19
26
  unsafeCreateEntityTable,
20
27
  } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { v5 as uuidv5 } from "uuid";
21
29
  import { folderAssignmentDeleteHook, folderDeleteHook } from "../../folders-user-data/hooks";
22
30
  import { FoldersHandlers, FoldersQueries } from "../constants";
23
31
  import { folderAssignmentEntity, folderEntity } from "../entity";
@@ -25,13 +33,65 @@ import { createFoldersFeature } from "../feature";
25
33
 
26
34
  const foldersFeature = createFoldersFeature();
27
35
 
36
+ const CREDIT_TABLE = "folders_test_credits";
37
+ const creditEntity = createEntity({
38
+ table: CREDIT_TABLE,
39
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
40
+ });
41
+ const hostFixturesFeature = defineFeature("folders-test-host-fixtures", (r) => {
42
+ r.entity("credit", creditEntity);
43
+ });
44
+
45
+ // The parent entity's ids are uuid-shaped, but the readable labels below carry
46
+ // the intent of each case — derive one from the other instead of scattering
47
+ // opaque uuid literals through the assertions.
48
+ const HOST_FIXTURE_NAMESPACE = "2c8a5f13-7d64-4b90-ae52-0f3d6c9b81e4";
49
+ function hostId(label: string): string {
50
+ return uuidv5(label, HOST_FIXTURE_NAMESPACE);
51
+ }
52
+
53
+ const CREDIT_LABELS = [
54
+ "credit-1",
55
+ "credit-2",
56
+ "credit-3",
57
+ "credit-8",
58
+ "credit-never",
59
+ "credit-r",
60
+ "credit-x",
61
+ "credit-occupied",
62
+ "credit-occupied-2",
63
+ "credit-erase",
64
+ "c-y",
65
+ ];
66
+
67
+ async function seedHostRows(
68
+ target: TestStack,
69
+ labels: readonly string[],
70
+ tenantId: string,
71
+ ): Promise<void> {
72
+ for (const label of labels) {
73
+ await asRawClient(target.db).unsafe(
74
+ `INSERT INTO ${CREDIT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
75
+ [hostId(label), tenantId, label],
76
+ );
77
+ }
78
+ }
79
+
28
80
  let stack: TestStack;
29
81
 
82
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
83
+ const otherTenant = createTestUser({
84
+ roles: ["TenantAdmin"],
85
+ tenantId: "00000000-0000-4000-8000-0000000000aa",
86
+ });
87
+
30
88
  beforeAll(async () => {
31
- stack = await setupTestStack({ features: [foldersFeature] });
89
+ stack = await setupTestStack({ features: [foldersFeature, hostFixturesFeature] });
32
90
  await unsafeCreateEntityTable(stack.db, folderEntity);
33
91
  await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
92
+ await unsafeCreateEntityTable(stack.db, creditEntity);
34
93
  await createEventsTable(stack.db);
94
+ await seedHostRows(stack, CREDIT_LABELS, admin.tenantId);
35
95
  });
36
96
 
37
97
  afterAll(async () => {
@@ -44,12 +104,6 @@ beforeEach(async () => {
44
104
  await asRawClient(stack.db).unsafe("DELETE FROM read_folder_assignments");
45
105
  });
46
106
 
47
- const admin = createTestUser({ roles: ["TenantAdmin"] });
48
- const otherTenant = createTestUser({
49
- roles: ["TenantAdmin"],
50
- tenantId: "00000000-0000-4000-8000-0000000000aa",
51
- });
52
-
53
107
  async function createFolder(name: string, parentId?: string, user = admin): Promise<string> {
54
108
  const payload = parentId === undefined ? { name } : { name, parentId };
55
109
  const folder = await stack.http.writeOk<{ id: string }>(
@@ -126,9 +180,9 @@ describe("folders integration — catalog (tree)", () => {
126
180
  describe("folders integration — single-membership set / move / clear", () => {
127
181
  test("set-folder files an entity; assignment is queryable", async () => {
128
182
  const f = await createFolder("Gruppe A");
129
- await setFolder(f, "credit-1");
183
+ await setFolder(f, hostId("credit-1"));
130
184
 
131
- const rows = await assignmentsOf("credit-1");
185
+ const rows = await assignmentsOf(hostId("credit-1"));
132
186
  expect(rows).toHaveLength(1);
133
187
  expect(rows[0]?.["folderId"]).toBe(f);
134
188
  expect(rows[0]?.["entityType"]).toBe("credit");
@@ -137,56 +191,56 @@ describe("folders integration — single-membership set / move / clear", () => {
137
191
  test("re-setting to a different folder MOVES (still exactly one row)", async () => {
138
192
  const a = await createFolder("A");
139
193
  const b = await createFolder("B");
140
- await setFolder(a, "credit-1");
194
+ await setFolder(a, hostId("credit-1"));
141
195
  expect(await countAssignments(admin.tenantId)).toBe(1);
142
196
 
143
- await setFolder(b, "credit-1"); // MOVE, not a second assignment
197
+ await setFolder(b, hostId("credit-1")); // MOVE, not a second assignment
144
198
  expect(await countAssignments(admin.tenantId)).toBe(1);
145
- const rows = await assignmentsOf("credit-1");
199
+ const rows = await assignmentsOf(hostId("credit-1"));
146
200
  expect(rows).toHaveLength(1);
147
201
  expect(rows[0]?.["folderId"]).toBe(b);
148
202
  });
149
203
 
150
204
  test("set the same folder twice is an idempotent no-op (one row)", async () => {
151
205
  const f = await createFolder("dup");
152
- await setFolder(f, "credit-2");
153
- await setFolder(f, "credit-2");
206
+ await setFolder(f, hostId("credit-2"));
207
+ await setFolder(f, hostId("credit-2"));
154
208
  expect(await countAssignments(admin.tenantId)).toBe(1);
155
209
  });
156
210
 
157
211
  test("clear-folder unfiles the entity", async () => {
158
212
  const f = await createFolder("temp");
159
- await setFolder(f, "credit-3");
213
+ await setFolder(f, hostId("credit-3"));
160
214
  expect(await countAssignments(admin.tenantId)).toBe(1);
161
215
 
162
- await clearFolder("credit-3");
216
+ await clearFolder(hostId("credit-3"));
163
217
  expect(await countAssignments(admin.tenantId)).toBe(0);
164
- expect(await assignmentsOf("credit-3")).toHaveLength(0);
218
+ expect(await assignmentsOf(hostId("credit-3"))).toHaveLength(0);
165
219
  });
166
220
 
167
221
  test("clearing a never-filed entity succeeds (idempotent end-state)", async () => {
168
- await clearFolder("credit-never");
222
+ await clearFolder(hostId("credit-never"));
169
223
  expect(await countAssignments(admin.tenantId)).toBe(0);
170
224
  });
171
225
 
172
226
  test("set → clear → set resurrects the same deterministic stream", async () => {
173
227
  const f = await createFolder("recurring");
174
- await setFolder(f, "credit-r");
175
- await clearFolder("credit-r");
228
+ await setFolder(f, hostId("credit-r"));
229
+ await clearFolder(hostId("credit-r"));
176
230
  expect(await countAssignments(admin.tenantId)).toBe(0);
177
231
 
178
- await setFolder(f, "credit-r"); // restore, not 409
232
+ await setFolder(f, hostId("credit-r")); // restore, not 409
179
233
  expect(await countAssignments(admin.tenantId)).toBe(1);
180
- expect((await assignmentsOf("credit-r"))[0]?.["folderId"]).toBe(f);
234
+ expect((await assignmentsOf(hostId("credit-r")))[0]?.["folderId"]).toBe(f);
181
235
  });
182
236
 
183
237
  test("set → clear → set into a DIFFERENT folder lands in the new folder", async () => {
184
238
  const a = await createFolder("A");
185
239
  const b = await createFolder("B");
186
- await setFolder(a, "credit-x");
187
- await clearFolder("credit-x");
188
- await setFolder(b, "credit-x"); // restore + update folderId to b
189
- const rows = await assignmentsOf("credit-x");
240
+ await setFolder(a, hostId("credit-x"));
241
+ await clearFolder(hostId("credit-x"));
242
+ await setFolder(b, hostId("credit-x")); // restore + update folderId to b
243
+ const rows = await assignmentsOf(hostId("credit-x"));
190
244
  expect(rows).toHaveLength(1);
191
245
  expect(rows[0]?.["folderId"]).toBe(b);
192
246
  });
@@ -195,7 +249,7 @@ describe("folders integration — single-membership set / move / clear", () => {
195
249
  describe("folders integration — delete blocks on live assignments (658/1)", () => {
196
250
  test("deleting a folder that still holds an assignment is rejected, row survives", async () => {
197
251
  const f = await createFolder("occupied");
198
- await setFolder(f, "credit-occupied");
252
+ await setFolder(f, hostId("credit-occupied"));
199
253
 
200
254
  const err = await stack.http.writeErr(FoldersHandlers.deleteFolder, { id: f }, admin);
201
255
  expect(err.httpStatus).toBe(422);
@@ -210,8 +264,8 @@ describe("folders integration — delete blocks on live assignments (658/1)", ()
210
264
 
211
265
  test("clearing the assignment first unblocks the delete", async () => {
212
266
  const f = await createFolder("occupied-then-cleared");
213
- await setFolder(f, "credit-occupied-2");
214
- await clearFolder("credit-occupied-2");
267
+ await setFolder(f, hostId("credit-occupied-2"));
268
+ await clearFolder(hostId("credit-occupied-2"));
215
269
 
216
270
  await stack.http.writeOk(FoldersHandlers.deleteFolder, { id: f }, admin);
217
271
  expect((await listFolders()).some((row) => row["id"] === f)).toBe(false);
@@ -240,7 +294,11 @@ describe("folders integration — referential integrity", () => {
240
294
  test("set-folder with an unknown folderId is rejected (no dangling assignment)", async () => {
241
295
  const err = await stack.http.writeErr(
242
296
  FoldersHandlers.setFolder,
243
- { folderId: "00000000-0000-4000-8000-00000000dead", entityType: "credit", entityId: "c-y" },
297
+ {
298
+ folderId: "00000000-0000-4000-8000-00000000dead",
299
+ entityType: "credit",
300
+ entityId: hostId("c-y"),
301
+ },
244
302
  admin,
245
303
  );
246
304
  expect(err.httpStatus).toBe(404);
@@ -251,10 +309,10 @@ describe("folders integration — referential integrity", () => {
251
309
  describe("folders integration — multi-tenant isolation", () => {
252
310
  test("tenant B sees neither tenant A's folders nor assignments", async () => {
253
311
  const f = await createFolder("A-only", undefined, admin);
254
- await setFolder(f, "credit-8", admin);
312
+ await setFolder(f, hostId("credit-8"), admin);
255
313
 
256
314
  expect(await listFolders(otherTenant)).toHaveLength(0);
257
- expect(await assignmentsOf("credit-8", otherTenant)).toHaveLength(0);
315
+ expect(await assignmentsOf(hostId("credit-8"), otherTenant)).toHaveLength(0);
258
316
 
259
317
  expect(await listFolders(admin)).toHaveLength(1);
260
318
  expect(await countAssignments(admin.tenantId)).toBe(1);
@@ -271,11 +329,13 @@ describe("folders integration — openToAll access model", () => {
271
329
 
272
330
  beforeAll(async () => {
273
331
  openStack = await setupTestStack({
274
- features: [createFoldersFeature({ access: { openToAll: true } })],
332
+ features: [createFoldersFeature({ access: { openToAll: true } }), hostFixturesFeature],
275
333
  });
276
334
  await unsafeCreateEntityTable(openStack.db, folderEntity);
277
335
  await unsafeCreateEntityTable(openStack.db, folderAssignmentEntity);
336
+ await unsafeCreateEntityTable(openStack.db, creditEntity);
278
337
  await createEventsTable(openStack.db);
338
+ await seedHostRows(openStack, ["c-1"], unprivileged.tenantId);
279
339
  });
280
340
 
281
341
  afterAll(async () => {
@@ -290,7 +350,7 @@ describe("folders integration — openToAll access model", () => {
290
350
  );
291
351
  await openStack.http.writeOk(
292
352
  FoldersHandlers.setFolder,
293
- { folderId: folder.id, entityType: "credit", entityId: "c-1" },
353
+ { folderId: folder.id, entityType: "credit", entityId: hostId("c-1") },
294
354
  unprivileged,
295
355
  );
296
356
  const folders = await openStack.http.queryOk<{ rows: unknown[] }>(
@@ -301,7 +361,7 @@ describe("folders integration — openToAll access model", () => {
301
361
  expect(folders.rows).toHaveLength(1);
302
362
  await openStack.http.writeOk(
303
363
  FoldersHandlers.clearFolder,
304
- { entityType: "credit", entityId: "c-1" },
364
+ { entityType: "credit", entityId: hostId("c-1") },
305
365
  unprivileged,
306
366
  );
307
367
  });
@@ -324,7 +384,7 @@ describe("folders integration — openToAll access model", () => {
324
384
  describe("folders-user-data — tenantScopedDelete hooks", () => {
325
385
  async function seedOneFolderWithAssignment(): Promise<void> {
326
386
  const f = await createFolder("to-be-erased");
327
- await setFolder(f, "credit-erase");
387
+ await setFolder(f, hostId("credit-erase"));
328
388
  }
329
389
 
330
390
  test("multi-user tenant: no-op, rows survive", async () => {
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.246.0",
4
+ "type": "improvement",
5
+ "title": "set-folder and clear-folder now verify entityType/entityId against the host entity's own read path",
6
+ "detail": "Before this change, set-folder and clear-folder took entityType/entityId straight from the client with no check — any dispatch-eligible tenant user could file, move or unfile any object in their tenant, including one they could not otherwise read, and moving it out of whatever folder its owning team had put it in. The folder-assignment aggregate-id is derived from the tenant, so this was never a cross-tenant hole; it was filing inside your own tenant across a row-level ownership boundary. Both handlers now unconditionally verify that entityType names a registered entity and that the host row is visible to the caller through that entity's own read path (tenant scope plus its `access.read` ownership) before touching the membership; either check failing returns a not_found response, the same answer a genuinely missing row gets, so nothing doubles as an existence oracle. The check runs before the folder lookup, so a denied caller learns nothing about which folder ids exist. This mirrors the add-note gate shipped in 0.244.0 and shares its helper.",
7
+ "migration": "entityType must now name an entity registered in the same app (via r.entity(...) or a bundled feature), and the caller must be able to see the target row through that entity's own read path. Any consumer that called set-folder or clear-folder with an entityType that is NOT a registered entity name, or with an entityId the caller could not otherwise read, will now get a not_found error instead of a successful write — register the host entity (and, if it needs row-level ownership, its `access.read` rule) before upgrading. Note the consequence for hard-deleted host rows: once the host row is gone, its folder-assignment can no longer be cleared, and folder:delete keeps refusing while that assignment points at the folder."
8
+ }
9
+ ]
@@ -1,4 +1,6 @@
1
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { parentRowIsVisible } from "../../shared";
2
4
  import { folderAssignmentAggregateId } from "../aggregate-id";
3
5
  import { DEFAULT_FOLDER_ACCESS } from "../constants";
4
6
  import { folderAssignmentExecutor } from "../executor";
@@ -7,6 +9,10 @@ import { type ClearFolderPayload, clearFolderPayloadSchema } from "../schemas";
7
9
  // clear-folder — removes a host entity from its folder (back to "unfiled").
8
10
  // Idempotent: clearing an entity that isn't in any folder is already the
9
11
  // requested end state, so we pre-check and return success without a delete.
12
+ //
13
+ // Host reference: entityType/entityId are client input, so the caller must be
14
+ // able to see the host row before unfiling it — see
15
+ // shared/parent-visibility.ts.
10
16
  export function createClearFolderHandler(
11
17
  access: AccessRule = DEFAULT_FOLDER_ACCESS,
12
18
  ): WriteHandlerDef {
@@ -18,6 +24,24 @@ export function createClearFolderHandler(
18
24
  "Unfiles a host entity so it sits in no folder at all, reporting success when it was already unfiled; use it to take an entity out of its folder without touching the folder itself.",
19
25
  handler: async (event, ctx) => {
20
26
  const payload = event.payload as ClearFolderPayload; // @cast-boundary engine-payload
27
+ // entityType/entityId are client input: the caller must be able to see
28
+ // the host row through that entity's own read path (tenant scope plus its
29
+ // `access.read` ownership) before its assignment row may be written.
30
+ // Checked FIRST, ahead of every lookup below, so a denied caller can't tell
31
+ // an invisible parent apart from a missing assignment or an unknown folder —
32
+ // every path answers with the same NotFoundError.
33
+ if (
34
+ !(await parentRowIsVisible(
35
+ ctx.registry,
36
+ payload.entityType,
37
+ payload.entityId,
38
+ event.user,
39
+ ctx.db,
40
+ ))
41
+ ) {
42
+ return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
43
+ }
44
+
21
45
  const id = folderAssignmentAggregateId(
22
46
  event.user.tenantId,
23
47
  payload.entityType,
@@ -1,5 +1,6 @@
1
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { parentRowIsVisible } from "../../shared";
3
4
  import { folderAssignmentAggregateId } from "../aggregate-id";
4
5
  import { DEFAULT_FOLDER_ACCESS } from "../constants";
5
6
  import { folderAssignmentExecutor, folderExecutor } from "../executor";
@@ -23,6 +24,11 @@ import { type SetFolderPayload, setFolderPayloadSchema } from "../schemas";
23
24
  // Referential integrity: there is no FK (event-sourced, no JOIN), so we verify
24
25
  // the target folder exists before writing — a malformed call with an unknown
25
26
  // folderId would otherwise point an entity at a phantom folder.
27
+ //
28
+ // Host reference: entityType/entityId are never trusted client input either —
29
+ // the aggregate-id is tenant-scoped, so this is not a cross-tenant hole, but
30
+ // without the check any dispatch-eligible tenant user could file an object they
31
+ // are not allowed to read. See shared/parent-visibility.ts.
26
32
  export function createSetFolderHandler(
27
33
  access: AccessRule = DEFAULT_FOLDER_ACCESS,
28
34
  ): WriteHandlerDef {
@@ -34,6 +40,24 @@ export function createSetFolderHandler(
34
40
  "Files a host entity into one existing folder and moves it out of whatever folder it was in, since an entity belongs to at most one folder; use it for both the first filing and every later move.",
35
41
  handler: async (event, ctx) => {
36
42
  const payload = event.payload as SetFolderPayload; // @cast-boundary engine-payload
43
+ // entityType/entityId are client input: the caller must be able to see
44
+ // the host row through that entity's own read path (tenant scope plus its
45
+ // `access.read` ownership) before its assignment row may be written.
46
+ // Checked FIRST, ahead of every lookup below, so a denied caller can't tell
47
+ // an invisible parent apart from a missing assignment or an unknown folder —
48
+ // every path answers with the same NotFoundError.
49
+ if (
50
+ !(await parentRowIsVisible(
51
+ ctx.registry,
52
+ payload.entityType,
53
+ payload.entityId,
54
+ event.user,
55
+ ctx.db,
56
+ ))
57
+ ) {
58
+ return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
59
+ }
60
+
37
61
  const id = folderAssignmentAggregateId(
38
62
  event.user.tenantId,
39
63
  payload.entityType,