@cosmicdrift/kumiko-bundled-features 0.244.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.
Files changed (32) hide show
  1. package/package.json +9 -9
  2. package/src/auth-mfa-user-data/hooks.ts +2 -1
  3. package/src/custom-fields/lib/define-or-resurrect.ts +2 -2
  4. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +238 -0
  5. package/src/folders/__tests__/folders.integration.test.ts +96 -36
  6. package/src/folders/changes.json +9 -1
  7. package/src/folders/handlers/clear-folder.write.ts +24 -0
  8. package/src/folders/handlers/set-folder.write.ts +24 -0
  9. package/src/folders-user-data/__tests__/hooks.integration.test.ts +30 -6
  10. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +30 -5
  11. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +144 -14
  12. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +225 -0
  13. package/src/notes-history/__tests__/notes-history.integration.test.ts +71 -28
  14. package/src/notes-history/changes.json +7 -0
  15. package/src/notes-history/feature.ts +34 -6
  16. package/src/notes-history/handlers/add-note.write.ts +27 -1
  17. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +29 -3
  18. package/src/shared/assert-erased.ts +15 -0
  19. package/src/shared/has-where-rule.ts +5 -8
  20. package/src/shared/index.ts +2 -0
  21. package/src/shared/parent-visibility.ts +48 -0
  22. package/src/tags/__tests__/tags-ownership.test.ts +6 -7
  23. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +219 -0
  24. package/src/tags/__tests__/tags.integration.test.ts +112 -38
  25. package/src/tags/changes.json +9 -1
  26. package/src/tags/feature.ts +3 -2
  27. package/src/tags/handlers/assign-tag.write.ts +24 -0
  28. package/src/tags/handlers/remove-tag.write.ts +24 -0
  29. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +2 -1
  30. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  31. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +2 -1
  32. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +2 -2
@@ -0,0 +1,219 @@
1
+ // assign-tag/remove-tag previously took entityType/entityId straight from the
2
+ // client, so any dispatch-eligible tenant user could attach a catalog tag to —
3
+ // or detach one from — an object they had no read access to. The tag-assignment
4
+ // aggregate-id is derived from the tenant, so this was never a cross-tenant
5
+ // hole; it was "tag an object inside your own tenant that you aren't allowed to
6
+ // read", which also leaks through the assignment list of anyone who can see it.
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 { TagsHandlers } from "../constants";
28
+ import { tagAssignmentEntity, tagEntity } from "../entity";
29
+ import { createTagsFeature } from "../feature";
30
+
31
+ const PROJECT_TABLE = "tags_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("tags-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: 60,
65
+ roles: ["TenantMember"],
66
+ claims: { team: "team-a" },
67
+ });
68
+ const userB: TestUser = createTestUser({
69
+ id: 61,
70
+ roles: ["TenantMember"],
71
+ claims: { team: "team-b" },
72
+ });
73
+
74
+ const PROJECT_A = "b0000000-0000-4000-8000-000000000001";
75
+ // A second team-a project, dedicated to the remove-tag denial case so its
76
+ // assignment count stays independent of PROJECT_A's.
77
+ const PROJECT_A2 = "b0000000-0000-4000-8000-000000000002";
78
+ // Never assigned anything — the "no assignment" half of the oracle check.
79
+ const PROJECT_A3 = "b0000000-0000-4000-8000-000000000003";
80
+
81
+ let stack: TestStack;
82
+ let tagId: string;
83
+
84
+ beforeAll(async () => {
85
+ stack = await setupTestStack({
86
+ features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
87
+ });
88
+ await unsafeCreateEntityTable(stack.db, tagEntity);
89
+ await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
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 tag = await stack.http.writeOk<{ id: string }>(
101
+ TagsHandlers.createTag,
102
+ { name: "VIP" },
103
+ userA,
104
+ );
105
+ tagId = tag.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_tag_assignments WHERE entity_id = $1 AND is_deleted = FALSE",
115
+ [entityId],
116
+ );
117
+ return rows[0]?.n ?? 0;
118
+ }
119
+
120
+ function assign(entityType: string, entityId: string, user: TestUser) {
121
+ return stack.http.writeOk<{ id: string }>(
122
+ TagsHandlers.assignTag,
123
+ { tagId, entityType, entityId },
124
+ user,
125
+ );
126
+ }
127
+
128
+ function assignErr(entityType: string, entityId: string, user: TestUser) {
129
+ return stack.http.writeErr(TagsHandlers.assignTag, { tagId, entityType, entityId }, user);
130
+ }
131
+
132
+ function remove(entityType: string, entityId: string, user: TestUser) {
133
+ return stack.http.writeOk<{ id: string }>(
134
+ TagsHandlers.removeTag,
135
+ { tagId, entityType, entityId },
136
+ user,
137
+ );
138
+ }
139
+
140
+ function removeErr(entityType: string, entityId: string, user: TestUser) {
141
+ return stack.http.writeErr(TagsHandlers.removeTag, { tagId, entityType, entityId }, user);
142
+ }
143
+
144
+ describe("tags integration — assign-tag parent-visibility", () => {
145
+ test("a host row visible through the host entity's own read path can be tagged", async () => {
146
+ const result = await assign("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 assignErr("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 taggable — 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 assign("project", PROJECT_A2, userA);
160
+ expect(result.id).toBeTruthy();
161
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
162
+ });
163
+
164
+ test("an entityType that is not a registered entity is denied", async () => {
165
+ const err = await assignErr("totally-unknown-entity", PROJECT_A, userA);
166
+ expect(err.code).toBe("not_found");
167
+ expect(err.httpStatus).toBe(404);
168
+ });
169
+
170
+ test("a malformed entityId is denied cleanly, without poisoning the connection", async () => {
171
+ const err = await assignErr("project", "not-a-uuid", userA);
172
+ expect(err.code).toBe("not_found");
173
+ expect(err.httpStatus).toBe(404);
174
+
175
+ // The stack must still be usable — proves the malformed id was rejected
176
+ // before it reached a query, not caught after a tx-poisoning cast error.
177
+ expect((await assign("project", PROJECT_A, userA)).id).toBeTruthy();
178
+ });
179
+ });
180
+
181
+ describe("tags integration — remove-tag parent-visibility", () => {
182
+ test("a foreign team cannot detach an existing assignment; the row survives", async () => {
183
+ await assign("project", PROJECT_A2, userA);
184
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
185
+
186
+ const err = await removeErr("project", PROJECT_A2, userB);
187
+ expect(err.code).toBe("not_found");
188
+ expect(err.httpStatus).toBe(404);
189
+ // Zero-write: the assignment userA made is still active, not soft-deleted.
190
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
191
+
192
+ // Positive control: the owning team CAN detach it.
193
+ await remove("project", PROJECT_A2, userA);
194
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(0);
195
+ });
196
+
197
+ test("the denial carries no existence oracle: assigned and unassigned rows answer alike", async () => {
198
+ await assign("project", PROJECT_A2, userA);
199
+ expect(await activeAssignmentsFor(PROJECT_A2)).toBe(1);
200
+ expect(await activeAssignmentsFor(PROJECT_A3)).toBe(0);
201
+
202
+ // remove-tag normally reports success for a never-assigned pair, so without
203
+ // the gate running FIRST the two responses below would differ and leak
204
+ // whether the invisible host row carries this tag.
205
+ const onAssigned = await removeErr("project", PROJECT_A2, userB);
206
+ const onUnassigned = await removeErr("project", PROJECT_A3, userB);
207
+ expect(onAssigned.code).toBe(onUnassigned.code);
208
+ expect(onAssigned.httpStatus).toBe(onUnassigned.httpStatus);
209
+ expect(onAssigned.code).toBe("not_found");
210
+
211
+ await remove("project", PROJECT_A2, userA);
212
+ });
213
+
214
+ test("an entityType that is not a registered entity is denied", async () => {
215
+ const err = await removeErr("totally-unknown-entity", PROJECT_A, userA);
216
+ expect(err.code).toBe("not_found");
217
+ expect(err.httpStatus).toBe(404);
218
+ });
219
+ });
@@ -7,9 +7,16 @@
7
7
  // - read-layer composition both directions (tags of an entity / entities of a tag)
8
8
  // - assign + remove are idempotent (re-assign = one row, remove-missing = ok)
9
9
  // - multi-tenant isolation
10
+ //
11
+ // assign-tag/remove-tag verify that entityType names a registered entity and
12
+ // that the host row is visible to the caller. This file's assertions are about
13
+ // assignment mechanics, not parent ownership, so the `credit`/`note` fixtures
14
+ // below stay unrestricted (no `access`) on purpose — every host row this file
15
+ // tags is seeded in beforeAll.
10
16
 
11
17
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
18
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
19
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
13
20
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
14
21
  import {
15
22
  createTestUser,
@@ -17,19 +24,77 @@ import {
17
24
  type TestStack,
18
25
  unsafeCreateEntityTable,
19
26
  } from "@cosmicdrift/kumiko-framework/stack";
27
+ import { v5 as uuidv5 } from "uuid";
20
28
  import { TagsHandlers, TagsQueries } from "../constants";
21
29
  import { tagAssignmentEntity, tagEntity } from "../entity";
22
30
  import { createTagsFeature } from "../feature";
23
31
 
24
32
  const tagsFeature = createTagsFeature();
25
33
 
34
+ const CREDIT_TABLE = "tags_test_credits";
35
+ const NOTE_TABLE = "tags_test_notes";
36
+ const hostFields = { name: createTextField({ required: true, maxLength: 64 }) };
37
+ const creditEntity = createEntity({ table: CREDIT_TABLE, fields: hostFields });
38
+ const noteEntity = createEntity({ table: NOTE_TABLE, fields: hostFields });
39
+ const hostFixturesFeature = defineFeature("tags-test-host-fixtures", (r) => {
40
+ r.entity("credit", creditEntity);
41
+ r.entity("note", noteEntity);
42
+ });
43
+
44
+ // The parent entity's ids are uuid-shaped, but the readable labels below carry
45
+ // the intent of each case — derive one from the other instead of scattering
46
+ // opaque uuid literals through the assertions.
47
+ const HOST_FIXTURE_NAMESPACE = "6f1c4d2a-9b3e-4a7c-8d5f-1e2b3c4d5e6f";
48
+ function hostId(label: string): string {
49
+ return uuidv5(label, HOST_FIXTURE_NAMESPACE);
50
+ }
51
+
52
+ const CREDIT_LABELS = [
53
+ "credit-1",
54
+ "credit-2",
55
+ "credit-3",
56
+ "credit-4",
57
+ "credit-5",
58
+ "credit-6",
59
+ "credit-7",
60
+ "credit-8",
61
+ "credit-d1",
62
+ "credit-r",
63
+ "credit-x",
64
+ ...Array.from({ length: 20 }, (_, i) => `credit-race-${i}`),
65
+ ];
66
+
67
+ async function seedHostRows(
68
+ target: TestStack,
69
+ table: string,
70
+ labels: readonly string[],
71
+ tenantId: string,
72
+ ): Promise<void> {
73
+ for (const label of labels) {
74
+ await asRawClient(target.db).unsafe(
75
+ `INSERT INTO ${table} (id, tenant_id, name) VALUES ($1, $2, $3)`,
76
+ [hostId(label), tenantId, label],
77
+ );
78
+ }
79
+ }
80
+
26
81
  let stack: TestStack;
27
82
 
83
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
84
+ const otherTenant = createTestUser({
85
+ roles: ["TenantAdmin"],
86
+ tenantId: "00000000-0000-4000-8000-0000000000aa",
87
+ });
88
+
28
89
  beforeAll(async () => {
29
- stack = await setupTestStack({ features: [tagsFeature] });
90
+ stack = await setupTestStack({ features: [tagsFeature, hostFixturesFeature] });
30
91
  await unsafeCreateEntityTable(stack.db, tagEntity);
31
92
  await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
93
+ await unsafeCreateEntityTable(stack.db, creditEntity);
94
+ await unsafeCreateEntityTable(stack.db, noteEntity);
32
95
  await createEventsTable(stack.db);
96
+ await seedHostRows(stack, CREDIT_TABLE, CREDIT_LABELS, admin.tenantId);
97
+ await seedHostRows(stack, NOTE_TABLE, ["note-d1"], admin.tenantId);
33
98
  });
34
99
 
35
100
  afterAll(async () => {
@@ -42,12 +107,6 @@ beforeEach(async () => {
42
107
  await asRawClient(stack.db).unsafe("DELETE FROM read_tag_assignments");
43
108
  });
44
109
 
45
- const admin = createTestUser({ roles: ["TenantAdmin"] });
46
- const otherTenant = createTestUser({
47
- roles: ["TenantAdmin"],
48
- tenantId: "00000000-0000-4000-8000-0000000000aa",
49
- });
50
-
51
110
  async function createTag(name: string, user = admin): Promise<string> {
52
111
  const tag = await stack.http.writeOk<{ id: string }>(TagsHandlers.createTag, { name }, user);
53
112
  return tag.id;
@@ -111,10 +170,14 @@ describe("tags integration — catalog + assignment roundtrip", () => {
111
170
 
112
171
  test("assign-tag → assignment queryable both composition directions", async () => {
113
172
  const tagId = await createTag("VIP");
114
- await assign(tagId, "credit", "credit-1");
173
+ await assign(tagId, "credit", hostId("credit-1"));
115
174
 
116
175
  // tags of an entity
117
- const byEntity = await listAssignments({ field: "entityId", op: "eq", value: "credit-1" });
176
+ const byEntity = await listAssignments({
177
+ field: "entityId",
178
+ op: "eq",
179
+ value: hostId("credit-1"),
180
+ });
118
181
  expect(byEntity).toHaveLength(1);
119
182
  expect(byEntity[0]?.["tagId"]).toBe(tagId);
120
183
  expect(byEntity[0]?.["entityType"]).toBe("credit");
@@ -122,17 +185,17 @@ describe("tags integration — catalog + assignment roundtrip", () => {
122
185
  // entities carrying a tag
123
186
  const byTag = await listAssignments({ field: "tagId", op: "eq", value: tagId });
124
187
  expect(byTag).toHaveLength(1);
125
- expect(byTag[0]?.["entityId"]).toBe("credit-1");
188
+ expect(byTag[0]?.["entityId"]).toBe(hostId("credit-1"));
126
189
  });
127
190
 
128
191
  test("remove-tag deletes the assignment", async () => {
129
192
  const tagId = await createTag("temp");
130
- await assign(tagId, "credit", "credit-2");
193
+ await assign(tagId, "credit", hostId("credit-2"));
131
194
  expect(await countAssignments(admin.tenantId)).toBe(1);
132
195
 
133
- await remove(tagId, "credit", "credit-2");
196
+ await remove(tagId, "credit", hostId("credit-2"));
134
197
  expect(await countAssignments(admin.tenantId)).toBe(0);
135
- const left = await listAssignments({ field: "entityId", op: "eq", value: "credit-2" });
198
+ const left = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-2") });
136
199
  expect(left).toHaveLength(0);
137
200
  });
138
201
  });
@@ -231,9 +294,9 @@ describe("tags integration — delete-tag cascade", () => {
231
294
  const target = await createTag("Sammelmappe");
232
295
  const other = await createTag("bleibt");
233
296
  // target spans two entityTypes; other tag stays attached to prove scoping
234
- await assign(target, "credit", "credit-d1");
235
- await assign(target, "note", "note-d1");
236
- await assign(other, "credit", "credit-d1");
297
+ await assign(target, "credit", hostId("credit-d1"));
298
+ await assign(target, "note", hostId("note-d1"));
299
+ await assign(other, "credit", hostId("credit-d1"));
237
300
  expect(await countAssignments(admin.tenantId)).toBe(3);
238
301
 
239
302
  await deleteTag(target);
@@ -257,52 +320,54 @@ describe("tags integration — many-to-many composition", () => {
257
320
  test("one entity carries multiple tags", async () => {
258
321
  const a = await createTag("rot");
259
322
  const b = await createTag("wasser");
260
- await assign(a, "credit", "credit-3");
261
- await assign(b, "credit", "credit-3");
323
+ await assign(a, "credit", hostId("credit-3"));
324
+ await assign(b, "credit", hostId("credit-3"));
262
325
 
263
- const tags = await listAssignments({ field: "entityId", op: "eq", value: "credit-3" });
326
+ const tags = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-3") });
264
327
  expect(tags.map((r) => r["tagId"]).sort()).toEqual([a, b].sort());
265
328
  });
266
329
 
267
330
  test("one tag spans multiple entities", async () => {
268
331
  const tagId = await createTag("Mappe-2026");
269
- await assign(tagId, "credit", "credit-4");
270
- await assign(tagId, "credit", "credit-5");
332
+ await assign(tagId, "credit", hostId("credit-4"));
333
+ await assign(tagId, "credit", hostId("credit-5"));
271
334
 
272
335
  const entities = await listAssignments({ field: "tagId", op: "eq", value: tagId });
273
- expect(entities.map((r) => r["entityId"]).sort()).toEqual(["credit-4", "credit-5"]);
336
+ expect(entities.map((r) => r["entityId"]).sort()).toEqual(
337
+ [hostId("credit-4"), hostId("credit-5")].sort(),
338
+ );
274
339
  });
275
340
  });
276
341
 
277
342
  describe("tags integration — idempotency", () => {
278
343
  test("re-assigning the same (tag, entity) keeps exactly one row", async () => {
279
344
  const tagId = await createTag("dup");
280
- await assign(tagId, "credit", "credit-6");
281
- await assign(tagId, "credit", "credit-6"); // re-assign: must be a no-op success
345
+ await assign(tagId, "credit", hostId("credit-6"));
346
+ await assign(tagId, "credit", hostId("credit-6")); // re-assign: must be a no-op success
282
347
 
283
348
  expect(await countAssignments(admin.tenantId)).toBe(1);
284
- const rows = await listAssignments({ field: "entityId", op: "eq", value: "credit-6" });
349
+ const rows = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-6") });
285
350
  expect(rows).toHaveLength(1);
286
351
  });
287
352
 
288
353
  test("removing a never-assigned (tag, entity) succeeds (no error, no row)", async () => {
289
354
  const tagId = await createTag("ghost");
290
355
  // never assigned — remove must still succeed (idempotent end-state)
291
- await remove(tagId, "credit", "credit-7");
356
+ await remove(tagId, "credit", hostId("credit-7"));
292
357
  expect(await countAssignments(admin.tenantId)).toBe(0);
293
358
  });
294
359
 
295
360
  test("assign → remove → assign-again resurrects the same deterministic stream", async () => {
296
361
  const tagId = await createTag("recurring");
297
- await assign(tagId, "credit", "credit-r");
298
- await remove(tagId, "credit", "credit-r");
362
+ await assign(tagId, "credit", hostId("credit-r"));
363
+ await remove(tagId, "credit", hostId("credit-r"));
299
364
  expect(await countAssignments(admin.tenantId)).toBe(0);
300
365
 
301
366
  // Re-attaching the same (tag, entity) must succeed (restore), not 409 — the
302
367
  // deterministic aggregate-id reuses the removed stream.
303
- await assign(tagId, "credit", "credit-r");
368
+ await assign(tagId, "credit", hostId("credit-r"));
304
369
  expect(await countAssignments(admin.tenantId)).toBe(1);
305
- const rows = await listAssignments({ field: "entityId", op: "eq", value: "credit-r" });
370
+ const rows = await listAssignments({ field: "entityId", op: "eq", value: hostId("credit-r") });
306
371
  expect(rows).toHaveLength(1);
307
372
  expect(rows[0]?.["tagId"]).toBe(tagId);
308
373
  });
@@ -319,7 +384,7 @@ describe("tags integration — idempotency", () => {
319
384
  // The handler must converge that into success instead of surfacing a 409
320
385
  // for what is, from the caller's perspective, an idempotent operation.
321
386
  const tagId = await createTag(`racy-${i}`);
322
- const entityId = `credit-race-${i}`;
387
+ const entityId = hostId(`credit-race-${i}`);
323
388
  const [a, b] = await Promise.all([
324
389
  assign(tagId, "credit", entityId),
325
390
  assign(tagId, "credit", entityId),
@@ -336,7 +401,11 @@ describe("tags integration — referential integrity", () => {
336
401
  test("assigning an unknown tagId is rejected (no dangling assignment)", async () => {
337
402
  const err = await stack.http.writeErr(
338
403
  TagsHandlers.assignTag,
339
- { tagId: "00000000-0000-4000-8000-00000000dead", entityType: "credit", entityId: "credit-x" },
404
+ {
405
+ tagId: "00000000-0000-4000-8000-00000000dead",
406
+ entityType: "credit",
407
+ entityId: hostId("credit-x"),
408
+ },
340
409
  admin,
341
410
  );
342
411
  expect(err.httpStatus).toBe(404);
@@ -347,11 +416,14 @@ describe("tags integration — referential integrity", () => {
347
416
  describe("tags integration — multi-tenant isolation", () => {
348
417
  test("tenant B sees neither tenant A's tags nor assignments", async () => {
349
418
  const tagId = await createTag("A-only", admin);
350
- await assign(tagId, "credit", "credit-8", admin);
419
+ await assign(tagId, "credit", hostId("credit-8"), admin);
351
420
 
352
421
  expect(await listTags(otherTenant)).toHaveLength(0);
353
422
  expect(
354
- await listAssignments({ field: "entityId", op: "eq", value: "credit-8" }, otherTenant),
423
+ await listAssignments(
424
+ { field: "entityId", op: "eq", value: hostId("credit-8") },
425
+ otherTenant,
426
+ ),
355
427
  ).toHaveLength(0);
356
428
 
357
429
  // tenant A still sees its own
@@ -378,11 +450,13 @@ describe("tags integration — openToAll access model", () => {
378
450
 
379
451
  beforeAll(async () => {
380
452
  openStack = await setupTestStack({
381
- features: [createTagsFeature({ access: { openToAll: true } })],
453
+ features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
382
454
  });
383
455
  await unsafeCreateEntityTable(openStack.db, tagEntity);
384
456
  await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
457
+ await unsafeCreateEntityTable(openStack.db, creditEntity);
385
458
  await createEventsTable(openStack.db);
459
+ await seedHostRows(openStack, CREDIT_TABLE, ["c-1"], unprivileged.tenantId);
386
460
 
387
461
  defaultStack = await setupTestStack({ features: [tagsFeature] });
388
462
  await unsafeCreateEntityTable(defaultStack.db, tagEntity);
@@ -403,7 +477,7 @@ describe("tags integration — openToAll access model", () => {
403
477
  );
404
478
  await openStack.http.writeOk(
405
479
  TagsHandlers.assignTag,
406
- { tagId: tag.id, entityType: "credit", entityId: "c-1" },
480
+ { tagId: tag.id, entityType: "credit", entityId: hostId("c-1") },
407
481
  unprivileged,
408
482
  );
409
483
 
@@ -416,14 +490,14 @@ describe("tags integration — openToAll access model", () => {
416
490
 
417
491
  const assigned = await openStack.http.queryOk<{ rows: unknown[] }>(
418
492
  TagsQueries.assignmentList,
419
- { filter: { field: "entityId", op: "eq", value: "c-1" } },
493
+ { filter: { field: "entityId", op: "eq", value: hostId("c-1") } },
420
494
  unprivileged,
421
495
  );
422
496
  expect(assigned.rows).toHaveLength(1);
423
497
 
424
498
  await openStack.http.writeOk(
425
499
  TagsHandlers.removeTag,
426
- { tagId: tag.id, entityType: "credit", entityId: "c-1" },
500
+ { tagId: tag.id, entityType: "credit", entityId: hostId("c-1") },
427
501
  unprivileged,
428
502
  );
429
503
  });
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.246.0",
4
+ "type": "improvement",
5
+ "title": "assign-tag and remove-tag now verify entityType/entityId against the host entity's own read path",
6
+ "detail": "Before this change, assign-tag and remove-tag took entityType/entityId straight from the client with no check — any dispatch-eligible tenant user could attach a catalog tag to, or detach one from, any object in their tenant, including one they could not otherwise read. The tag-assignment aggregate-id is derived from the tenant, so this was never a cross-tenant hole; it was tagging 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 assignment; 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 assignment and tag lookups, so a denied caller cannot tell an invisible host row apart from a missing assignment. 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 assign-tag or remove-tag 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 assignment can no longer be removed through remove-tag; delete-tag's cascade still detaches it."
8
+ }
9
+ ]
@@ -187,8 +187,9 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
187
187
  '`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
188
188
  "layer (the read path, via buildOwnershipClause). Write paths that " +
189
189
  "consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
190
- "evaluate them: create throws at runtime, update/delete/forget/restore " +
191
- "silently deny. Use a `from()` rule for ownership.write, or leave it unset.",
190
+ "evaluate them, so such a rule can only ever deny — boot validation " +
191
+ "rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
192
+ "leave it unset.",
192
193
  );
193
194
  }
194
195
  const access = resolveAccess(opts);
@@ -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 { tagAssignmentAggregateId } from "../aggregate-id";
4
5
  import { DEFAULT_TAG_ACCESS } from "../constants";
5
6
  import { tagAssignmentExecutor, tagExecutor } from "../executor";
@@ -21,6 +22,11 @@ import { type AssignTagPayload, assignTagPayloadSchema } from "../schemas";
21
22
  // Referential integrity: there is no FK (event-sourced, no JOIN), so before a
22
23
  // first-time create we verify the tag exists in the catalog — a malformed call
23
24
  // with an unknown tagId would otherwise project a dangling assignment.
25
+ //
26
+ // Host reference: entityType/entityId are never trusted client input either —
27
+ // the aggregate-id is tenant-scoped, so this is not a cross-tenant hole, but
28
+ // without the check any dispatch-eligible tenant user could tag an object they
29
+ // are not allowed to read. See shared/parent-visibility.ts.
24
30
  export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
25
31
  return {
26
32
  name: "assign-tag",
@@ -30,6 +36,24 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
30
36
  "Attaches an existing catalog tag to one host entity addressed by its type and id, reporting success when the tag was already attached; use it to tag a record, not to create the tag.",
31
37
  handler: async (event, ctx) => {
32
38
  const payload = event.payload as AssignTagPayload; // @cast-boundary engine-payload
39
+ // entityType/entityId are client input: the caller must be able to see
40
+ // the host row through that entity's own read path (tenant scope plus its
41
+ // `access.read` ownership) before its assignment row may be written.
42
+ // Checked FIRST, ahead of every lookup below, so a denied caller can't tell
43
+ // an invisible parent apart from a missing assignment or an unknown tag —
44
+ // every path answers with the same NotFoundError.
45
+ if (
46
+ !(await parentRowIsVisible(
47
+ ctx.registry,
48
+ payload.entityType,
49
+ payload.entityId,
50
+ event.user,
51
+ ctx.db,
52
+ ))
53
+ ) {
54
+ return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
55
+ }
56
+
33
57
  const id = tagAssignmentAggregateId(
34
58
  event.user.tenantId,
35
59
  payload.tagId,
@@ -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 { tagAssignmentAggregateId } from "../aggregate-id";
3
5
  import { DEFAULT_TAG_ACCESS } from "../constants";
4
6
  import { tagAssignmentExecutor } from "../executor";
@@ -7,6 +9,10 @@ import { type RemoveTagPayload, removeTagPayloadSchema } from "../schemas";
7
9
  // remove-tag — unlinks a tag from a host entity. Idempotent: removing an
8
10
  // assignment that doesn't exist is already the requested end state (not
9
11
  // assigned), 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 detaching anything from it — see
15
+ // shared/parent-visibility.ts.
10
16
  export function createRemoveTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
11
17
  return {
12
18
  name: "remove-tag",
@@ -16,6 +22,24 @@ export function createRemoveTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
16
22
  "Detaches one catalog tag from one host entity, reporting success when it was not attached; use it to untag a single record while the tag itself stays in the catalog.",
17
23
  handler: async (event, ctx) => {
18
24
  const payload = event.payload as RemoveTagPayload; // @cast-boundary engine-payload
25
+ // entityType/entityId are client input: the caller must be able to see
26
+ // the host row through that entity's own read path (tenant scope plus its
27
+ // `access.read` ownership) before its assignment row may be written.
28
+ // Checked FIRST, ahead of every lookup below, so a denied caller can't tell
29
+ // an invisible parent apart from a missing assignment or an unknown tag —
30
+ // every path answers with the same NotFoundError.
31
+ if (
32
+ !(await parentRowIsVisible(
33
+ ctx.registry,
34
+ payload.entityType,
35
+ payload.entityId,
36
+ event.user,
37
+ ctx.db,
38
+ ))
39
+ ) {
40
+ return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
41
+ }
42
+
19
43
  const id = tagAssignmentAggregateId(
20
44
  event.user.tenantId,
21
45
  payload.tagId,
@@ -6,6 +6,7 @@ import {
6
6
  type UserDataExportHook,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { configValueEntity, configValuesTable } from "../../config";
9
+ import { assertErased } from "../../shared";
9
10
  import { featureMounted } from "./feature-mounted";
10
11
 
11
12
  // userData-Hooks for config's USER-scoped rows (userId set). Tenant-/system-
@@ -46,6 +47,6 @@ export const configValueDeleteHook: UserDataDeleteHook = async (ctx) => {
46
47
  for (const row of rows) {
47
48
  const id = row["id"]; // @cast-boundary db-row
48
49
  if (typeof id !== "string") continue;
49
- await crud.forget({ id }, systemUser, tdb);
50
+ assertErased(await crud.forget({ id }, systemUser, tdb), "config-value", id);
50
51
  }
51
52
  };