@cosmicdrift/kumiko-bundled-features 0.243.4 → 0.245.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.243.4",
3
+ "version": "0.245.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.243.4",
134
- "@cosmicdrift/kumiko-framework": "0.243.4",
135
- "@cosmicdrift/kumiko-headless": "0.243.4",
136
- "@cosmicdrift/kumiko-renderer": "0.243.4",
137
- "@cosmicdrift/kumiko-renderer-web": "0.243.4",
138
- "@cosmicdrift/kumiko-types": "0.243.4",
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",
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.243.4",
168
- "@cosmicdrift/kumiko-locale-es": "0.243.4"
167
+ "@cosmicdrift/kumiko-locale-de": "0.245.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.245.0"
169
169
  }
170
170
  }
@@ -15,6 +15,7 @@ import {
15
15
  type UserDataExportHook,
16
16
  } from "@cosmicdrift/kumiko-framework/engine";
17
17
  import { userMfaEntity, userMfaTable } from "../auth-mfa";
18
+ import { assertErased } from "../shared";
18
19
 
19
20
  const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
20
21
  entityName: "user-mfa",
@@ -49,6 +50,6 @@ export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
49
50
  const systemUser = createSystemUser(ctx.tenantId);
50
51
  const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
51
52
  for (const row of rows) {
52
- await executor.forget({ id: row.id }, systemUser, tdb);
53
+ assertErased(await executor.forget({ id: row.id }, systemUser, tdb), "user-mfa", row.id);
53
54
  }
54
55
  };
@@ -5,15 +5,25 @@
5
5
  // KMS adapter so the round-trip is proven end-to-end, not assumed from a
6
6
  // no-op-encrypt test env — a later refactor that silently reintroduces the
7
7
  // raw UUID or gets stuck on the placeholder would fail this.
8
+ //
9
+ // fw#2627 made add-note's parent-visibility check unconditional, so a
10
+ // minimal `contact` fixture entity (PASS_CLAUSE) is mounted below and seeded
11
+ // with the one row this file writes a note to — orthogonal to what this file
12
+ // actually tests (authorName stamping).
8
13
 
9
14
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
10
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
11
16
  import {
12
17
  configurePiiSubjectKms,
13
18
  InMemoryKmsAdapter,
14
19
  isPiiCiphertext,
15
20
  } from "@cosmicdrift/kumiko-framework/crypto";
16
- import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
21
+ import {
22
+ createEntity,
23
+ createTextField,
24
+ defineFeature,
25
+ type SessionUser,
26
+ } from "@cosmicdrift/kumiko-framework/engine";
17
27
  import {
18
28
  setupTestStack,
19
29
  type TestStack,
@@ -30,6 +40,16 @@ import { createNotesHistoryFeature } from "../feature";
30
40
  const notesHistoryFeature = createNotesHistoryFeature();
31
41
  const tenantId = testTenantId(1);
32
42
 
43
+ const CONTACT_TABLE = "notes_kms_test_contacts";
44
+ const contactEntity = createEntity({
45
+ table: CONTACT_TABLE,
46
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
47
+ });
48
+ const contactFixtureFeature = defineFeature("notes-kms-test-contact-fixture", (r) => {
49
+ r.entity("contact", contactEntity);
50
+ });
51
+ const CONTACT_KMS_AUTHOR = "40000000-0000-4000-8000-000000000001";
52
+
33
53
  let stack: TestStack;
34
54
 
35
55
  function memberUser(userId: string): SessionUser {
@@ -37,9 +57,14 @@ function memberUser(userId: string): SessionUser {
37
57
  }
38
58
 
39
59
  beforeAll(async () => {
40
- stack = await setupTestStack({ features: [notesHistoryFeature] });
60
+ stack = await setupTestStack({ features: [notesHistoryFeature, contactFixtureFeature] });
41
61
  await unsafeCreateEntityTable(stack.db, noteEntryEntity);
42
62
  await unsafeCreateEntityTable(stack.db, userEntity);
63
+ await unsafeCreateEntityTable(stack.db, contactEntity);
64
+ await asRawClient(stack.db).unsafe(
65
+ `INSERT INTO ${CONTACT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
66
+ [CONTACT_KMS_AUTHOR, tenantId, CONTACT_KMS_AUTHOR],
67
+ );
43
68
  });
44
69
 
45
70
  afterAll(async () => {
@@ -70,13 +95,13 @@ describe("add-note — authorName stamped from the writer's own user row", () =>
70
95
 
71
96
  const { id: noteId } = await stack.http.writeOk<{ id: string }>(
72
97
  NotesHistoryHandlers.addNote,
73
- { entityType: "contact", entityId: "contact-kms-author", body: "Called back." },
98
+ { entityType: "contact", entityId: CONTACT_KMS_AUTHOR, body: "Called back." },
74
99
  memberUser(userId),
75
100
  );
76
101
 
77
102
  const { rows } = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
78
103
  NotesHistoryQueries.noteList,
79
- { filter: { field: "entityId", op: "eq", value: "contact-kms-author" } },
104
+ { filter: { field: "entityId", op: "eq", value: CONTACT_KMS_AUTHOR } },
80
105
  memberUser(userId),
81
106
  );
82
107
 
@@ -9,10 +9,24 @@
9
9
  // stress shiftParams: the ownership fragment's `$N` placeholder must be
10
10
  // renumbered past the tenant-scope filter's already-consumed slots
11
11
  // (event-store-executor-read.ts), not just happen to work at `$1`.
12
+ //
13
+ // fw#2627 made add-note's parent-visibility check unconditional: entityType
14
+ // must now name a registered entity, and the row must exist and be visible
15
+ // to the caller. This file tests NOTE-level ownership, not parent-level, so
16
+ // the fixture `project` entity below is deliberately registered with NO
17
+ // `access` (PASS_CLAUSE) — any tenant member can write a note onto it — and
18
+ // every project row used below is inserted before its add-note call, so the
19
+ // parent-visibility check never interferes with what this file is actually
20
+ // proving.
12
21
 
13
22
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
23
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
- import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
24
+ import {
25
+ createEntity,
26
+ createTextField,
27
+ defineFeature,
28
+ type EntityDefinition,
29
+ } from "@cosmicdrift/kumiko-framework/engine";
16
30
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
31
  import {
18
32
  createTestUser,
@@ -49,6 +63,23 @@ const teamOwnership: NonNullable<EntityDefinition["access"]> = {
49
63
  },
50
64
  };
51
65
 
66
+ // The `project` parent entity add-note now always checks for. No `access` —
67
+ // PASS_CLAUSE — so it never gates on its own; this file's team split is
68
+ // entirely on the note-entry side (teamOwnership above).
69
+ const PROJECT_TABLE = "notes_ownership_test_projects";
70
+ const projectEntity: EntityDefinition = createEntity({
71
+ table: PROJECT_TABLE,
72
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
73
+ });
74
+ const projectFixtureFeature = defineFeature("notes-ownership-test-project-fixture", (r) => {
75
+ r.entity("project", projectEntity);
76
+ });
77
+
78
+ const PROJ_1 = "20000000-0000-4000-8000-000000000001";
79
+ const PROJ_9 = "20000000-0000-4000-8000-000000000009";
80
+ const PROJ_2 = "20000000-0000-4000-8000-000000000002";
81
+ const PROJ_B = "20000000-0000-4000-8000-00000000000b";
82
+
52
83
  type TestUser = ReturnType<typeof createTestUser>;
53
84
 
54
85
  let scopedStack: TestStack;
@@ -67,23 +98,42 @@ const userB: TestUser = createTestUser({
67
98
  claims: { team: "team-b" },
68
99
  });
69
100
 
101
+ async function insertProjects(
102
+ stack: TestStack,
103
+ tenantId: string,
104
+ ids: readonly string[],
105
+ ): Promise<void> {
106
+ for (const id of ids) {
107
+ await asRawClient(stack.db).unsafe(
108
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
109
+ [id, tenantId, id],
110
+ );
111
+ }
112
+ }
113
+
70
114
  beforeAll(async () => {
71
115
  scopedStack = await setupTestStack({
72
- features: [createNotesHistoryFeature({ ownership: teamOwnership })],
116
+ features: [createNotesHistoryFeature({ ownership: teamOwnership }), projectFixtureFeature],
73
117
  });
74
118
  await unsafeCreateEntityTable(scopedStack.db, createNoteEntryEntity(teamOwnership));
119
+ await unsafeCreateEntityTable(scopedStack.db, projectEntity);
75
120
  await createEventsTable(scopedStack.db);
76
121
  await asRawClient(scopedStack.db).unsafe(
77
122
  `CREATE TABLE IF NOT EXISTS ${TEAMS_TABLE} (entity_id text PRIMARY KEY, team_id text NOT NULL)`,
78
123
  );
124
+ await insertProjects(scopedStack, userA.tenantId, [PROJ_1, PROJ_9, PROJ_2]);
79
125
 
80
126
  // Regression control, dedicated stack (mirrors tags.integration.test.ts's
81
127
  // openStack/defaultStack): a plain (unscoped) mount of the SAME feature,
82
128
  // used to prove the 0-rows result below is the ownership rule doing real
83
129
  // work, not an empty table or a broken query.
84
- defaultStack = await setupTestStack({ features: [createNotesHistoryFeature()] });
130
+ defaultStack = await setupTestStack({
131
+ features: [createNotesHistoryFeature(), projectFixtureFeature],
132
+ });
85
133
  await unsafeCreateEntityTable(defaultStack.db, noteEntryEntity);
134
+ await unsafeCreateEntityTable(defaultStack.db, projectEntity);
86
135
  await createEventsTable(defaultStack.db);
136
+ await insertProjects(defaultStack, userA.tenantId, [PROJ_1]);
87
137
  });
88
138
 
89
139
  afterAll(async () => {
@@ -128,16 +178,16 @@ describe("notes-history integration — row ownership (where-rule)", () => {
128
178
  // weaker test would miss: a broken shiftParams (team_id compared against
129
179
  // the tenant UUID or nothing → everyone sees 0) and a trivially-true
130
180
  // subquery (EXISTS matches regardless of team_id → everyone sees both).
131
- await addNote(scopedStack, "proj-1", userA); // will be team-a
132
- await addNote(scopedStack, "proj-9", userA); // will be team-b — same author, different team
181
+ await addNote(scopedStack, PROJ_1, userA); // will be team-a
182
+ await addNote(scopedStack, PROJ_9, userA); // will be team-b — same author, different team
133
183
  await asRawClient(scopedStack.db).unsafe(
134
184
  `INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2), ($3, $4)`,
135
- ["proj-1", "team-a", "proj-9", "team-b"],
185
+ [PROJ_1, "team-a", PROJ_9, "team-b"],
136
186
  );
137
187
 
138
188
  // User B (different team) sees nothing, even filtered down to the exact row.
139
189
  expect(
140
- await listNotes(scopedStack, userB, { field: "entityId", op: "eq", value: "proj-1" }),
190
+ await listNotes(scopedStack, userB, { field: "entityId", op: "eq", value: PROJ_1 }),
141
191
  ).toHaveLength(0);
142
192
 
143
193
  // User A (own team) sees it. This is the real shiftParams exercise: the
@@ -148,7 +198,7 @@ describe("notes-history integration — row ownership (where-rule)", () => {
148
198
  const ownRows = await listNotes(scopedStack, userA, {
149
199
  field: "entityId",
150
200
  op: "eq",
151
- value: "proj-1",
201
+ value: PROJ_1,
152
202
  });
153
203
  expect(ownRows).toHaveLength(1);
154
204
  expect(ownRows[0]?.["body"]).toBe("note");
@@ -158,17 +208,17 @@ describe("notes-history integration — row ownership (where-rule)", () => {
158
208
  // subquery (which would return both).
159
209
  const aRows = await listNotes(scopedStack, userA);
160
210
  expect(aRows).toHaveLength(1);
161
- expect(aRows[0]?.["entityId"]).toBe("proj-1");
211
+ expect(aRows[0]?.["entityId"]).toBe(PROJ_1);
162
212
 
163
213
  // B authored NEITHER note, yet must see exactly the team-b one — proves
164
214
  // the rule grants access by team membership, not by who wrote the row.
165
215
  const bRows = await listNotes(scopedStack, userB);
166
216
  expect(bRows).toHaveLength(1);
167
- expect(bRows[0]?.["entityId"]).toBe("proj-9");
217
+ expect(bRows[0]?.["entityId"]).toBe(PROJ_9);
168
218
  });
169
219
 
170
220
  test("the same data on an unscoped mount leaks across teams (regression control)", async () => {
171
- await addNote(defaultStack, "proj-1", userA);
221
+ await addNote(defaultStack, PROJ_1, userA);
172
222
  // No ownership option on this stack's feature — the pre-fix behavior.
173
223
  // Proves the 0-rows result above comes from the ownership rule, not
174
224
  // from an unrelated empty-table/broken-query artifact.
@@ -176,10 +226,10 @@ describe("notes-history integration — row ownership (where-rule)", () => {
176
226
  });
177
227
 
178
228
  test("no explicit filter — ownership fragment still shifts correctly against the bare tenant scope", async () => {
179
- await addNote(scopedStack, "proj-2", userA);
229
+ await addNote(scopedStack, PROJ_2, userA);
180
230
  await asRawClient(scopedStack.db).unsafe(
181
231
  `INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2)`,
182
- ["proj-2", "team-a"],
232
+ [PROJ_2, "team-a"],
183
233
  );
184
234
 
185
235
  expect(await listNotes(scopedStack, userB)).toHaveLength(0);
@@ -187,8 +237,88 @@ describe("notes-history integration — row ownership (where-rule)", () => {
187
237
  });
188
238
  });
189
239
 
240
+ describe("unqualified where-rule fails closed, not open (fw#2639)", () => {
241
+ // Same shape as `teamOwnership` above, but the subquery's inner reference
242
+ // is left unqualified (`entity_id` instead of `t.entity_id`/`${ctx.tableName}.entity_id`).
243
+ // Postgres binds the unqualified name to the innermost table (`t`) first,
244
+ // so `t.entity_id = entity_id` silently becomes `t.entity_id = t.entity_id`
245
+ // — a self-join tautology that matches every row regardless of team.
246
+ const unqualifiedOwnership: NonNullable<EntityDefinition["access"]> = {
247
+ read: {
248
+ TenantMember: {
249
+ kind: "where",
250
+ where: (user, ctx) => {
251
+ const team = user.claims?.["team"];
252
+ // Boot probe has no claims and bails out here; the runtime lint in
253
+ // ruleToFragment is what must catch the unqualified reference.
254
+ if (typeof team !== "string") throw new Error("team claim required");
255
+ return {
256
+ sqlText: `EXISTS (SELECT 1 FROM ${TEAMS_TABLE} t WHERE t.entity_id = entity_id AND t.team_id = $${ctx.paramStart})`,
257
+ params: [team],
258
+ };
259
+ },
260
+ },
261
+ },
262
+ };
263
+
264
+ let unqualifiedStack: TestStack;
265
+
266
+ beforeAll(async () => {
267
+ // Reaching this point at all proves the boot guard doesn't false-positive
268
+ // on this rule: PROBE_USER carries no claims, so `where()` throws before
269
+ // the lint ever sees the bad SQL, and boot-validator/ownership.ts's
270
+ // probeWhereRule swallows that and moves on.
271
+ //
272
+ // add-note's parent-visibility check is unconditional since fw#2627, so
273
+ // this stack also needs the PASS_CLAUSE project fixture mounted — this
274
+ // block otherwise stays focused on the note-entry ownership rule.
275
+ unqualifiedStack = await setupTestStack({
276
+ features: [
277
+ createNotesHistoryFeature({ ownership: unqualifiedOwnership }),
278
+ projectFixtureFeature,
279
+ ],
280
+ });
281
+ await unsafeCreateEntityTable(unqualifiedStack.db, createNoteEntryEntity(unqualifiedOwnership));
282
+ await unsafeCreateEntityTable(unqualifiedStack.db, projectEntity);
283
+ await createEventsTable(unqualifiedStack.db);
284
+ await asRawClient(unqualifiedStack.db).unsafe(
285
+ `CREATE TABLE IF NOT EXISTS ${TEAMS_TABLE} (entity_id text PRIMARY KEY, team_id text NOT NULL)`,
286
+ );
287
+ await insertProjects(unqualifiedStack, userB.tenantId, [PROJ_B]);
288
+ });
289
+
290
+ afterAll(async () => {
291
+ await unqualifiedStack.cleanup();
292
+ });
293
+
294
+ test("list() fails closed instead of leaking userB's row to userA", async () => {
295
+ await addNote(unqualifiedStack, PROJ_B, userB);
296
+ await asRawClient(unqualifiedStack.db).unsafe(
297
+ `INSERT INTO ${TEAMS_TABLE} (entity_id, team_id) VALUES ($1, $2)`,
298
+ [PROJ_B, "team-b"],
299
+ );
300
+
301
+ // Pre-fix: this query returned HTTP 200 with userB's row (the tautology
302
+ // matches every row) — queryErr would throw "Expected query to fail but
303
+ // it succeeded". Post-fix: the runtime lint in ruleToFragment throws
304
+ // before the query ever runs, so the request fails loud instead of
305
+ // leaking. Asserting the concrete status (not just "isSuccess: false")
306
+ // documents exactly how it fails: an uncaught Error auto-wraps into
307
+ // InternalError (500), not a handled 4xx.
308
+ const error = await unqualifiedStack.http.queryErr(NotesHistoryQueries.noteList, {}, userA);
309
+ expect(error.httpStatus).toBe(500);
310
+ expect(error.code).toBe("internal_error");
311
+ });
312
+
313
+ // Green-path twin: `scopedStack` above (built with the correctly-qualified
314
+ // `teamOwnership` rule) already proves the lint doesn't reject every
315
+ // subquery rule — its first test asserts userA sees exactly its own
316
+ // team's row via HTTP 200 and never userB's. Relying on that coverage
317
+ // instead of duplicating a second correctly-qualified stack here.
318
+ });
319
+
190
320
  describe("notes-history — boot guard rejects a where-rule in ownership.write", () => {
191
- test("createNotesHistoryFeature throws instead of shipping a create()-time landmine", () => {
321
+ test("createNotesHistoryFeature throws instead of shipping a deny-only ownership map", () => {
192
322
  expect(() =>
193
323
  createNotesHistoryFeature({
194
324
  ownership: {
@@ -0,0 +1,225 @@
1
+ // fw#2627 — add-note previously trusted entityType/entityId straight from the
2
+ // client, so any dispatch-eligible tenant user could attach a note to a
3
+ // parent object they had no read access to. The parent-visibility check is
4
+ // unconditional (default-on, no opt-in): entityType must name a registered
5
+ // entity, and the row must be visible through that entity's own read path
6
+ // (tenant scope plus its `access.read` ownership) before add-note accepts
7
+ // the write — on every mount, `parents` or not. `parents`, when set, only
8
+ // narrows FURTHER to an allowlist of permitted parent entity names; it is
9
+ // not what turns the check on (see `openStack` tests below, which mount
10
+ // notes-history with no `parents` at all and still enforce the check).
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 { NotesHistoryHandlers } from "../constants";
28
+ import { createNoteEntryEntity } from "../entity";
29
+ import { createNotesHistoryFeature } from "../feature";
30
+
31
+ const PROJECT_TABLE = "notes_pv_test_projects";
32
+
33
+ // Team-scoped read ownership on the parent 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
+ function createProjectEntity(access?: EntityDefinition["access"]): EntityDefinition {
48
+ return createEntity({
49
+ table: PROJECT_TABLE,
50
+ fields: {
51
+ teamId: createTextField({ required: true, maxLength: 64 }),
52
+ name: createTextField({ required: true, maxLength: 64 }),
53
+ },
54
+ access,
55
+ });
56
+ }
57
+
58
+ const guardedProjectEntity = createProjectEntity(projectOwnership);
59
+
60
+ // A second registered entity, deliberately left OUT of the `parents`
61
+ // allowlist below — proves the allowlist rejects a real, registered entity
62
+ // too, not just made-up names.
63
+ const contactEntity = createEntity({
64
+ table: "notes_pv_test_contacts",
65
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
66
+ });
67
+
68
+ const fixturesFeature = defineFeature("notes-pv-test-fixtures", (r) => {
69
+ r.entity("project", guardedProjectEntity);
70
+ r.entity("contact", contactEntity);
71
+ });
72
+
73
+ type TestUser = ReturnType<typeof createTestUser>;
74
+
75
+ // Same tenant, different team claim — mirrors the ownership integration test.
76
+ const userA: TestUser = createTestUser({
77
+ id: 40,
78
+ roles: ["TenantMember"],
79
+ claims: { team: "team-a" },
80
+ });
81
+ const userB: TestUser = createTestUser({
82
+ id: 41,
83
+ roles: ["TenantMember"],
84
+ claims: { team: "team-b" },
85
+ });
86
+
87
+ const PROJECT_A = "a0000000-0000-4000-8000-000000000001";
88
+ // A second team-a project, dedicated to the cross-team-denial case so its
89
+ // note-count stays independent of PROJECT_A's.
90
+ const PROJECT_A2 = "a0000000-0000-4000-8000-000000000002";
91
+ // openStack-only team-a project row — never reused as PROJECT_A2 so the two
92
+ // stacks' tests stay order-independent.
93
+ const PROJECT_OPEN = "a0000000-0000-4000-8000-000000000009";
94
+
95
+ let guardedStack: TestStack;
96
+ let openStack: TestStack;
97
+
98
+ beforeAll(async () => {
99
+ guardedStack = await setupTestStack({
100
+ features: [createNotesHistoryFeature({ parents: ["project"] }), fixturesFeature],
101
+ });
102
+ await unsafeCreateEntityTable(guardedStack.db, guardedProjectEntity);
103
+ await unsafeCreateEntityTable(guardedStack.db, createNoteEntryEntity());
104
+ await createEventsTable(guardedStack.db);
105
+ await asRawClient(guardedStack.db).unsafe(
106
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES
107
+ ($1, $2, 'team-a', 'Project A'),
108
+ ($3, $2, 'team-a', 'Project A2')`,
109
+ [PROJECT_A, userA.tenantId, PROJECT_A2],
110
+ );
111
+
112
+ // Unguarded mount: the SAME feature with no `parents` at all — proves the
113
+ // parent-visibility check itself is default-on, not something `parents`
114
+ // switches on.
115
+ openStack = await setupTestStack({
116
+ features: [createNotesHistoryFeature(), fixturesFeature],
117
+ });
118
+ await unsafeCreateEntityTable(openStack.db, guardedProjectEntity);
119
+ await unsafeCreateEntityTable(openStack.db, createNoteEntryEntity());
120
+ await createEventsTable(openStack.db);
121
+ await asRawClient(openStack.db).unsafe(
122
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES ($1, $2, 'team-a', 'Project Open')`,
123
+ [PROJECT_OPEN, userA.tenantId],
124
+ );
125
+ });
126
+
127
+ afterAll(async () => {
128
+ await guardedStack.cleanup();
129
+ await openStack.cleanup();
130
+ });
131
+
132
+ async function addNote(
133
+ stack: TestStack,
134
+ entityType: string,
135
+ entityId: string,
136
+ user: TestUser,
137
+ ): Promise<{ id: string }> {
138
+ return stack.http.writeOk<{ id: string }>(
139
+ NotesHistoryHandlers.addNote,
140
+ { entityType, entityId, body: "note" },
141
+ user,
142
+ );
143
+ }
144
+
145
+ async function addNoteErr(stack: TestStack, entityType: string, entityId: string, user: TestUser) {
146
+ return stack.http.writeErr(
147
+ NotesHistoryHandlers.addNote,
148
+ { entityType, entityId, body: "note" },
149
+ user,
150
+ );
151
+ }
152
+
153
+ async function noteCountFor(stack: TestStack, entityId: string): Promise<number> {
154
+ const rows = await asRawClient(stack.db).unsafe<{ count: string }>(
155
+ `SELECT count(*)::text AS count FROM read_note_entries WHERE entity_id = $1`,
156
+ [entityId],
157
+ );
158
+ return Number(rows[0]?.count ?? "0");
159
+ }
160
+
161
+ describe("notes-history integration — add-note parent-visibility (parents option)", () => {
162
+ test("caller may note a parent row visible through the parent entity's own read path", async () => {
163
+ const result = await addNote(guardedStack, "project", PROJECT_A, userA);
164
+ expect(result.id).toBeTruthy();
165
+ expect(await noteCountFor(guardedStack, PROJECT_A)).toBe(1);
166
+ });
167
+
168
+ test("caller is denied noting a parent row from a foreign team while the same row is writable by its own team", async () => {
169
+ const err = await addNoteErr(guardedStack, "project", PROJECT_A2, userB);
170
+ expect(err.code).toBe("not_found");
171
+ expect(err.httpStatus).toBe(404);
172
+ expect(await noteCountFor(guardedStack, PROJECT_A2)).toBe(0);
173
+
174
+ // Positive control: PROJECT_A2 itself is writable — by its own team — so
175
+ // the deny above is the team-claim check, not some other rejection (e.g.
176
+ // a row that doesn't exist at all).
177
+ const result = await addNote(guardedStack, "project", PROJECT_A2, userA);
178
+ expect(result.id).toBeTruthy();
179
+ expect(await noteCountFor(guardedStack, PROJECT_A2)).toBe(1);
180
+ });
181
+
182
+ test("entityType outside the allowlist is denied even when it is a registered entity", async () => {
183
+ const err = await addNoteErr(
184
+ guardedStack,
185
+ "contact",
186
+ "c0000000-0000-4000-8000-000000000003",
187
+ userA,
188
+ );
189
+ expect(err.code).toBe("not_found");
190
+ });
191
+
192
+ test("entityType that is not registered anywhere is denied", async () => {
193
+ const err = await addNoteErr(guardedStack, "totally-unknown-entity", "x", userA);
194
+ expect(err.code).toBe("not_found");
195
+ });
196
+
197
+ test("a malformed entityId is denied cleanly, without poisoning the connection for later writes", async () => {
198
+ const err = await addNoteErr(guardedStack, "project", "not-a-uuid", userA);
199
+ expect(err.code).toBe("not_found");
200
+ expect(err.httpStatus).toBe(404);
201
+
202
+ // The stack must still be usable afterwards — proves the malformed id was
203
+ // rejected before it ever reached a query, not caught after a failed
204
+ // (and tx-poisoning) cast.
205
+ const result = await addNote(guardedStack, "project", PROJECT_A, userA);
206
+ expect(result.id).toBeTruthy();
207
+ });
208
+
209
+ test("on an unguarded mount (no `parents`), an unregistered entityType is still denied — the check is default-on", async () => {
210
+ const err = await addNoteErr(openStack, "totally-unknown-entity", "y", userA);
211
+ expect(err.code).toBe("not_found");
212
+ });
213
+
214
+ test("on an unguarded mount, a registered and visible parent still succeeds", async () => {
215
+ const result = await addNote(openStack, "project", PROJECT_OPEN, userA);
216
+ expect(result.id).toBeTruthy();
217
+ expect(await noteCountFor(openStack, PROJECT_OPEN)).toBe(1);
218
+ });
219
+
220
+ test("on an unguarded mount, a registered parent from a foreign team is still denied", async () => {
221
+ const err = await addNoteErr(openStack, "project", PROJECT_OPEN, userB);
222
+ expect(err.code).toBe("not_found");
223
+ expect(err.httpStatus).toBe(404);
224
+ });
225
+ });
@@ -9,9 +9,17 @@
9
9
  // - read-layer composition: list filtered on entityId
10
10
  // - multi-tenant isolation
11
11
  // - append-only: only add-note and list are registered handlers
12
+ //
13
+ // fw#2627 made add-note's parent-visibility check unconditional: entityType
14
+ // must name a registered entity, and the row must be visible to the caller.
15
+ // A minimal `contact` fixture entity (PASS_CLAUSE, no `access`) is mounted
16
+ // below and seeded with one row per id this file writes a note to — this
17
+ // file's own assertions are about note-entry behavior, not about parent
18
+ // ownership, so the fixture stays unrestricted on purpose.
12
19
 
13
20
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
21
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
22
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
15
23
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
24
  import {
17
25
  createTestUser,
@@ -25,12 +33,57 @@ import { createNotesHistoryFeature } from "../feature";
25
33
 
26
34
  const notesHistoryFeature = createNotesHistoryFeature();
27
35
 
36
+ const CONTACT_TABLE = "notes_history_test_contacts";
37
+ const contactEntity = createEntity({
38
+ table: CONTACT_TABLE,
39
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
40
+ });
41
+ const contactFixtureFeature = defineFeature("notes-history-test-contact-fixture", (r) => {
42
+ r.entity("contact", contactEntity);
43
+ });
44
+
28
45
  let stack: TestStack;
29
46
 
47
+ // Distinct ids (default createTestUser() shares TestUsers.admin.id) —
48
+ // authorId-attribution tests need two genuinely different users.
49
+ const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
50
+ const member = createTestUser({ id: 2, roles: ["TenantMember"] });
51
+ const otherTenant = createTestUser({
52
+ id: 10,
53
+ roles: ["TenantAdmin"],
54
+ tenantId: "00000000-0000-4000-8000-0000000000aa",
55
+ });
56
+
57
+ const CONTACT_1 = "30000000-0000-4000-8000-000000000001";
58
+ const CONTACT_2 = "30000000-0000-4000-8000-000000000002";
59
+ const CONTACT_2B = "30000000-0000-4000-8000-00000000002b";
60
+ const CONTACT_3 = "30000000-0000-4000-8000-000000000003";
61
+ const CONTACT_4 = "30000000-0000-4000-8000-000000000004";
62
+ const CONTACT_5 = "30000000-0000-4000-8000-000000000005";
63
+ const CONTACT_6 = "30000000-0000-4000-8000-000000000006";
64
+ const CONTACT_SHARED = "30000000-0000-4000-8000-000000000007";
65
+
30
66
  beforeAll(async () => {
31
- stack = await setupTestStack({ features: [notesHistoryFeature] });
67
+ stack = await setupTestStack({ features: [notesHistoryFeature, contactFixtureFeature] });
32
68
  await unsafeCreateEntityTable(stack.db, noteEntryEntity);
69
+ await unsafeCreateEntityTable(stack.db, contactEntity);
33
70
  await createEventsTable(stack.db);
71
+
72
+ for (const id of [
73
+ CONTACT_1,
74
+ CONTACT_2,
75
+ CONTACT_2B,
76
+ CONTACT_3,
77
+ CONTACT_4,
78
+ CONTACT_5,
79
+ CONTACT_6,
80
+ CONTACT_SHARED,
81
+ ]) {
82
+ await asRawClient(stack.db).unsafe(
83
+ `INSERT INTO ${CONTACT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
84
+ [id, admin.tenantId, id],
85
+ );
86
+ }
34
87
  });
35
88
 
36
89
  afterAll(async () => {
@@ -42,16 +95,6 @@ beforeEach(async () => {
42
95
  await asRawClient(stack.db).unsafe("DELETE FROM read_note_entries");
43
96
  });
44
97
 
45
- // Distinct ids (default createTestUser() shares TestUsers.admin.id) —
46
- // authorId-attribution tests need two genuinely different users.
47
- const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
48
- const member = createTestUser({ id: 2, roles: ["TenantMember"] });
49
- const otherTenant = createTestUser({
50
- id: 10,
51
- roles: ["TenantAdmin"],
52
- tenantId: "00000000-0000-4000-8000-0000000000aa",
53
- });
54
-
55
98
  async function addNote(
56
99
  entityType: string,
57
100
  entityId: string,
@@ -80,8 +123,8 @@ async function listNotes(
80
123
 
81
124
  describe("notes-history integration — add + list", () => {
82
125
  test("add-note lands in read_note_entries with author + timestamp stamped", async () => {
83
- const { id } = await addNote("contact", "contact-1", "Called about renewal", member);
84
- const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-1" });
126
+ const { id } = await addNote("contact", CONTACT_1, "Called about renewal", member);
127
+ const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_1 });
85
128
  expect(rows).toHaveLength(1);
86
129
  expect(rows[0]?.["id"]).toBe(id);
87
130
  expect(rows[0]?.["body"]).toBe("Called about renewal");
@@ -95,8 +138,8 @@ describe("notes-history integration — add + list", () => {
95
138
  });
96
139
 
97
140
  test("a client-supplied authorId in the payload is ignored", async () => {
98
- await addNote("contact", "contact-2", "note", member, { authorId: admin.id });
99
- const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-2" });
141
+ await addNote("contact", CONTACT_2, "note", member, { authorId: admin.id });
142
+ const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_2 });
100
143
  // The write always attributes to the authenticated caller (member), never
101
144
  // to a value smuggled in through the payload — schema doesn't even accept
102
145
  // an authorId field, so this proves it's silently dropped, not honoured.
@@ -104,8 +147,8 @@ describe("notes-history integration — add + list", () => {
104
147
  });
105
148
 
106
149
  test("a client-supplied authorName in the payload is ignored", async () => {
107
- await addNote("contact", "contact-2b", "note", member, { authorName: "Fake Display Name" });
108
- const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-2b" });
150
+ await addNote("contact", CONTACT_2B, "note", member, { authorName: "Fake Display Name" });
151
+ const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_2B });
109
152
  // Same guard as authorId above: addNotePayloadSchema doesn't accept an
110
153
  // authorName field, so a smuggled value never survives to the write —
111
154
  // the name (once stamped) can only come from the caller's own session.
@@ -114,17 +157,17 @@ describe("notes-history integration — add + list", () => {
114
157
  });
115
158
 
116
159
  test("multiple notes on the same entity accumulate — nothing overwrites", async () => {
117
- await addNote("contact", "contact-3", "first");
118
- await addNote("contact", "contact-3", "second");
119
- await addNote("contact", "contact-3", "third");
120
- const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-3" });
160
+ await addNote("contact", CONTACT_3, "first");
161
+ await addNote("contact", CONTACT_3, "second");
162
+ await addNote("contact", CONTACT_3, "third");
163
+ const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_3 });
121
164
  expect(rows.map((r) => r["body"]).sort()).toEqual(["first", "second", "third"]);
122
165
  });
123
166
 
124
167
  test("read-layer composition: filtering by entityId scopes to that entity only", async () => {
125
- await addNote("contact", "contact-4", "for four");
126
- await addNote("contact", "contact-5", "for five");
127
- const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-4" });
168
+ await addNote("contact", CONTACT_4, "for four");
169
+ await addNote("contact", CONTACT_5, "for five");
170
+ const rows = await listNotes({ field: "entityId", op: "eq", value: CONTACT_4 });
128
171
  expect(rows).toHaveLength(1);
129
172
  expect(rows[0]?.["body"]).toBe("for four");
130
173
  });
@@ -132,7 +175,7 @@ describe("notes-history integration — add + list", () => {
132
175
  test("empty body is rejected", async () => {
133
176
  const err = await stack.http.writeErr(
134
177
  NotesHistoryHandlers.addNote,
135
- { entityType: "contact", entityId: "contact-6", body: " " },
178
+ { entityType: "contact", entityId: CONTACT_6, body: " " },
136
179
  admin,
137
180
  );
138
181
  expect(err.httpStatus).toBe(400);
@@ -141,13 +184,13 @@ describe("notes-history integration — add + list", () => {
141
184
 
142
185
  describe("notes-history integration — multi-tenant isolation", () => {
143
186
  test("tenant B sees none of tenant A's notes", async () => {
144
- await addNote("contact", "contact-shared-id", "A's note", admin);
187
+ await addNote("contact", CONTACT_SHARED, "A's note", admin);
145
188
 
146
189
  expect(
147
- await listNotes({ field: "entityId", op: "eq", value: "contact-shared-id" }, otherTenant),
190
+ await listNotes({ field: "entityId", op: "eq", value: CONTACT_SHARED }, otherTenant),
148
191
  ).toHaveLength(0);
149
192
  expect(
150
- await listNotes({ field: "entityId", op: "eq", value: "contact-shared-id" }, admin),
193
+ await listNotes({ field: "entityId", op: "eq", value: CONTACT_SHARED }, admin),
151
194
  ).toHaveLength(1);
152
195
  });
153
196
  });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.244.0",
4
+ "type": "improvement",
5
+ "title": "add-note now always verifies entityType/entityId against the parent entity's own read path",
6
+ "detail": "Before this change, add-note took entityType/entityId straight from the client with no check — any dispatch-eligible tenant user could attach a note to any parent object, including one they couldn't otherwise see. add-note now unconditionally verifies that entityType names a registered entity and that the row is visible to the caller through that entity's own read path (tenant scope plus its `access.read` ownership) before accepting the write; entries that fail either check are rejected with a not_found response. createNotesHistoryFeature also accepts a `parents` option — an allowlist further narrowing which registered entities may be a note's parent — but this option is additive-only; the parent-visibility check itself is not gated behind it.",
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 add-note 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 parent entity (and, if it needs row-level ownership, its `access.read` rule) before upgrading."
8
+ },
2
9
  {
3
10
  "version": "0.215.3",
4
11
  "type": "improvement",
@@ -30,6 +30,7 @@ function registerNotesHistory(
30
30
  r: FeatureRegistrar<typeof NOTES_HISTORY_FEATURE_NAME>,
31
31
  access: AccessRule,
32
32
  ownership: EntityDefinition["access"] | undefined,
33
+ parents: ReadonlySet<string> | undefined,
33
34
  ): void {
34
35
  r.describe(
35
36
  "Generic, host-agnostic, append-only note history for any entity. Owns one event-sourced entity, `note-entry` (`read_note_entries`), keyed by (entityType, entityId) — so attaching notes adds NO column to the host entity and needs no relational pivot or JOIN. Provides a `create` write-handler (author stamped server-side from the caller, never client-supplied) and a `list` query filterable on entityId. Deliberately append-only: no update or delete handler is registered — a correction is a new entry, not an edit, so who-said-what-when stays reconstructable. Every path uses one access rule — adopt the host's model with createNotesHistoryFeature({ access: { openToAll: true } }) or pin roles with createNotesHistoryFeature({ roles }).",
@@ -43,7 +44,7 @@ function registerNotesHistory(
43
44
  const entity = createNoteEntryEntity(ownership);
44
45
  r.entity("note-entry", entity);
45
46
 
46
- r.writeHandler(createAddNoteHandler(access));
47
+ r.writeHandler(createAddNoteHandler(access, parents));
47
48
  r.queryHandler(
48
49
  defineEntityListHandler("note-entry", entity, {
49
50
  access,
@@ -56,7 +57,7 @@ function registerNotesHistory(
56
57
  }
57
58
 
58
59
  export const notesHistoryFeature = defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
59
- registerNotesHistory(r, DEFAULT_NOTES_HISTORY_ACCESS, undefined),
60
+ registerNotesHistory(r, DEFAULT_NOTES_HISTORY_ACCESS, undefined, undefined),
60
61
  );
61
62
 
62
63
  export type NotesHistoryFeatureOptions = {
@@ -83,6 +84,16 @@ export type NotesHistoryFeatureOptions = {
83
84
  * crypto-shredding — a silent Art.17 failure, not a thrown error. Make
84
85
  * sure any `ownership.write` you set covers that role, or leave it unset. */
85
86
  readonly ownership?: EntityDefinition["access"];
87
+ /** Allowlist further narrowing which registered entities may be used as a
88
+ * note's parent (entityType). This is NOT what turns parent-checking on —
89
+ * add-note always verifies that entityType names a registered entity and
90
+ * that the row is visible to the caller through that entity's own read
91
+ * path (tenant scope plus its `access.read` ownership); an entityType
92
+ * that names no registered entity is rejected regardless of this option.
93
+ * Setting `parents` narrows further, to a specific set of entity names —
94
+ * useful when a host entity is registered but should never be a valid
95
+ * note parent. */
96
+ readonly parents?: readonly string[];
86
97
  };
87
98
 
88
99
  function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
@@ -96,7 +107,12 @@ function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
96
107
  export function createNotesHistoryFeature(
97
108
  opts: NotesHistoryFeatureOptions = {},
98
109
  ): typeof notesHistoryFeature {
99
- if (opts.access === undefined && opts.roles === undefined && opts.ownership === undefined) {
110
+ if (
111
+ opts.access === undefined &&
112
+ opts.roles === undefined &&
113
+ opts.ownership === undefined &&
114
+ opts.parents === undefined
115
+ ) {
100
116
  return notesHistoryFeature;
101
117
  }
102
118
  if (hasWhereRule(opts.ownership?.write)) {
@@ -105,11 +121,23 @@ export function createNotesHistoryFeature(
105
121
  '`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
106
122
  "layer (the read path, via buildOwnershipClause). Write paths that " +
107
123
  "consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
108
- "evaluate them: create throws at runtime, update/delete/forget/restore " +
109
- "silently deny. Use a `from()` rule for ownership.write, or leave it unset.",
124
+ "evaluate them, so such a rule can only ever deny — boot validation " +
125
+ "rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
126
+ "leave it unset.",
110
127
  );
111
128
  }
129
+ // A mount that accepts no parents at all can never take a note write —
130
+ // that's a config mistake, not a valid allowlist. Omit `parents` instead
131
+ // of passing an empty array.
132
+ if (opts.parents !== undefined && opts.parents.length === 0) {
133
+ throw new Error(
134
+ "createNotesHistoryFeature({ parents }): parents must not be an empty array — " +
135
+ "an empty allowlist rejects every add-note call. Omit `parents` to keep " +
136
+ "today's unrestricted behavior instead.",
137
+ );
138
+ }
139
+ const parents = opts.parents === undefined ? undefined : new Set(opts.parents);
112
140
  return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
113
- registerNotesHistory(r, resolveAccess(opts), opts.ownership),
141
+ registerNotesHistory(r, resolveAccess(opts), opts.ownership, parents),
114
142
  );
115
143
  }
@@ -1,9 +1,11 @@
1
1
  import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { decryptStoredPii } from "../../shared";
4
5
  import { userTable } from "../../user";
5
6
  import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
6
7
  import { noteEntryExecutor } from "../executor";
8
+ import { parentRowIsVisible } from "../parent-visibility";
7
9
  import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
8
10
 
9
11
  // add-note — appends a note-entry to (entityType, entityId). authorId is
@@ -11,8 +13,16 @@ import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
11
13
  // (event.user.id), so a note can't be authored as someone else. No update or
12
14
  // delete counterpart is registered — see entity.ts for why append-only is
13
15
  // deliberate.
16
+ //
17
+ // entityType/entityId are never trusted client input: entityType must name a
18
+ // registered entity, and the row must be visible to the caller through that
19
+ // entity's own read path (tenant scope plus its `access.read` ownership) —
20
+ // see parent-visibility.ts. `parents`, when set, is an ADDITIONAL allowlist
21
+ // narrowing which registered entities may be a note's parent at all; it is
22
+ // not what turns the check on.
14
23
  export function createAddNoteHandler(
15
24
  access: AccessRule = DEFAULT_NOTES_HISTORY_ACCESS,
25
+ parents?: ReadonlySet<string>,
16
26
  ): WriteHandlerDef {
17
27
  return {
18
28
  name: "add-note",
@@ -23,6 +33,23 @@ export function createAddNoteHandler(
23
33
  handler: async (event, ctx) => {
24
34
  const payload = event.payload as AddNotePayload; // @cast-boundary engine-payload
25
35
 
36
+ // NotFoundError, not an access-denied error, so the response doesn't
37
+ // double as an existence oracle — same policy as executor.detail,
38
+ // which never distinguishes "no access" from "doesn't exist".
39
+ if (parents !== undefined && !parents.has(payload.entityType)) {
40
+ return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
41
+ }
42
+ const visible = await parentRowIsVisible(
43
+ ctx.registry,
44
+ payload.entityType,
45
+ payload.entityId,
46
+ event.user,
47
+ ctx.db,
48
+ );
49
+ if (!visible) {
50
+ return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
51
+ }
52
+
26
53
  let authorName: string | null = null;
27
54
  try {
28
55
  // read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
@@ -0,0 +1,46 @@
1
+ import type { EventStoreExecutor, TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createEntityExecutor,
4
+ type EntityDefinition,
5
+ isUuid,
6
+ type Registry,
7
+ type SessionUser,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+
10
+ // Keyed by definition identity, not by entity name — two test stacks can
11
+ // register different EntityDefinitions under the same entity name.
12
+ const executorsByEntity = new WeakMap<EntityDefinition, EventStoreExecutor>();
13
+
14
+ function idShapeMatchesEntity(entity: EntityDefinition, entityId: string): boolean {
15
+ return entity.idType === "serial" ? /^\d+$/.test(entityId) : isUuid(entityId);
16
+ }
17
+
18
+ // Checks whether a note's proposed (entityType, entityId) parent is one the
19
+ // caller can see via the parent entity's own read path — tenant scope plus
20
+ // its `access.read` ownership. Used to gate add-note when a `parents`
21
+ // allowlist is configured (see feature.ts).
22
+ export async function parentRowIsVisible(
23
+ registry: Registry,
24
+ entityType: string,
25
+ entityId: string,
26
+ user: SessionUser,
27
+ db: TenantDb,
28
+ ): Promise<boolean> {
29
+ // Default-deny: an entityType that names no registered entity has no read
30
+ // path to check visibility against, so it can never be a valid parent.
31
+ const entity = registry.getEntity(entityType);
32
+ if (!entity) return false;
33
+
34
+ // A malformed id would abort the write transaction on a Postgres cast
35
+ // error (22P02) instead of cleanly denying — reject it before it reaches
36
+ // the executor.
37
+ if (!idShapeMatchesEntity(entity, entityId)) return false;
38
+
39
+ let executor = executorsByEntity.get(entity);
40
+ if (!executor) {
41
+ executor = createEntityExecutor(entityType, entity).executor;
42
+ executorsByEntity.set(entity, executor);
43
+ }
44
+
45
+ return (await executor.detail({ id: entityId }, user, db)) !== null;
46
+ }
@@ -4,6 +4,8 @@
4
4
  // confirm it doesn't touch the row.
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
7
9
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
8
10
  import {
9
11
  createTestUser,
@@ -21,10 +23,34 @@ let stack: TestStack;
21
23
  const author = createTestUser({ id: 1, roles: ["TenantMember"] });
22
24
  const other = createTestUser({ id: 2, roles: ["TenantMember"] });
23
25
 
26
+ // fw#2627 made add-note's parent-visibility check unconditional — entityType
27
+ // must name a registered entity whose row is visible to the caller. This
28
+ // fixture stands in for that parent; it deliberately has no `access` (PASS_CLAUSE)
29
+ // so it never gates on its own, keeping this file focused on author filtering.
30
+ const CONTACT_TABLE = "notes_user_data_test_contacts";
31
+ const contactEntity = createEntity({
32
+ table: CONTACT_TABLE,
33
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
34
+ });
35
+ const contactFixtureFeature = defineFeature("notes-user-data-test-contact-fixture", (r) => {
36
+ r.entity("contact", contactEntity);
37
+ });
38
+
39
+ // author and other share the same tenantId (both from TestUsers.admin), so
40
+ // one contact row is visible to both.
41
+ const CONTACT_1 = "30000000-0000-4000-8000-000000000001";
42
+
24
43
  beforeAll(async () => {
25
- stack = await setupTestStack({ features: [createNotesHistoryFeature()] });
44
+ stack = await setupTestStack({
45
+ features: [createNotesHistoryFeature(), contactFixtureFeature],
46
+ });
26
47
  await unsafeCreateEntityTable(stack.db, noteEntryEntity);
48
+ await unsafeCreateEntityTable(stack.db, contactEntity);
27
49
  await createEventsTable(stack.db);
50
+ await asRawClient(stack.db).unsafe(
51
+ `INSERT INTO ${CONTACT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)`,
52
+ [CONTACT_1, author.tenantId, "Contact 1"],
53
+ );
28
54
  });
29
55
 
30
56
  afterAll(async () => {
@@ -35,12 +61,12 @@ describe("noteEntryExportHook", () => {
35
61
  test("includes only the requesting user's own authored notes", async () => {
36
62
  await stack.http.writeOk(
37
63
  NotesHistoryHandlers.addNote,
38
- { entityType: "contact", entityId: "c-1", body: "by author" },
64
+ { entityType: "contact", entityId: CONTACT_1, body: "by author" },
39
65
  author,
40
66
  );
41
67
  await stack.http.writeOk(
42
68
  NotesHistoryHandlers.addNote,
43
- { entityType: "contact", entityId: "c-1", body: "by other" },
69
+ { entityType: "contact", entityId: CONTACT_1, body: "by other" },
44
70
  other,
45
71
  );
46
72
 
@@ -0,0 +1,15 @@
1
+ import type { WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // GDPR-forget hooks used to `await crud.forget(...)` and drop the result —
4
+ // a silent ownership_denied (or any other failure) meant the row was never
5
+ // actually erased, with nothing surfacing the miss. Callers must assert.
6
+ export function assertErased(result: WriteResult<unknown>, entityName: string, id: string): void {
7
+ // skip: the happy path — the assertion below only concerns failures.
8
+ if (result.isSuccess) return;
9
+ // skip: erasure is idempotent — a row that is already gone (e.g. a parallel
10
+ // forget sweep) satisfies the erase request just as well as one this call removed.
11
+ if (result.error.code === "not_found") return;
12
+ throw new Error(
13
+ `[user-data-rights] failed to erase ${entityName}/${id}: ${result.error.code} — ${result.error.message}`,
14
+ );
15
+ }
@@ -1,13 +1,10 @@
1
1
  import type { OwnershipMap } from "@cosmicdrift/kumiko-framework/engine";
2
2
 
3
- // `where`-rules are read-path only (buildOwnershipClause). On the write path
4
- // userCanWriteFieldRow silently skips them (fail-closed deny), but
5
- // userCanCreateFieldRow does NOT skip them — it runs matchesRule(), which
6
- // throws for `kind: "where"` (no in-memory evaluator for raw SQL). An
7
- // ownership.write map with a where-rule boots fine (the boot-validator has no
8
- // where-specific handling either) and only blows up the first time a create
9
- // hits it. Feature factories that accept an `ownership` option must reject
10
- // this shape at build time instead of shipping the landmine.
3
+ // `where`-rules are read-path only (buildOwnershipClause). The write path
4
+ // decides in memory against the concrete row, so a where-rule there can only
5
+ // ever deny — boot validation rejects the shape (fw#2626). Feature factories
6
+ // that accept an `ownership` option reject it earlier, at build time, so the
7
+ // author gets the option name in the message instead of an entity scope.
11
8
  export function hasWhereRule(map: OwnershipMap | undefined): boolean {
12
9
  if (!map) return false;
13
10
  return Object.values(map).some((rule) => rule !== "all" && rule.kind === "where");
@@ -1,3 +1,4 @@
1
+ export { assertErased } from "./assert-erased";
1
2
  export {
2
3
  type ChunkedMigrationOptions,
3
4
  type ChunkedMigrationResult,
@@ -1,15 +1,14 @@
1
- // H.2 boot guard — mirrors notes-history-ownership.integration.test.ts's
2
- // guard test. A where-rule in ownership.write boots fine (the boot-validator
3
- // has no where-specific handling) and only blows up the first time
4
- // assign-tag's create() hits it (userCanCreateFieldRow throws on
5
- // `kind: "where"`, unlike update/delete/forget which silently deny). No DB
6
- // needed — the guard fires at feature-construction time.
1
+ // H.2 build-time guard — mirrors notes-history-ownership.integration.test.ts's
2
+ // guard test. A where-rule in ownership.write can only ever deny (the write
3
+ // path has no SQL layer); framework boot validation rejects it too, this guard
4
+ // just fires earlier with the option name in the message. No DB needed — it
5
+ // runs at feature-construction time.
7
6
 
8
7
  import { describe, expect, test } from "bun:test";
9
8
  import { createTagsFeature } from "../feature";
10
9
 
11
10
  describe("tags — boot guard rejects a where-rule in ownership.write", () => {
12
- test("createTagsFeature throws instead of shipping a create()-time landmine", () => {
11
+ test("createTagsFeature throws instead of shipping a deny-only ownership map", () => {
13
12
  expect(() =>
14
13
  createTagsFeature({
15
14
  ownership: {
@@ -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);
@@ -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
  };
@@ -18,6 +18,7 @@ import {
18
18
  fileRefEntity,
19
19
  fileRefsTable,
20
20
  } from "@cosmicdrift/kumiko-framework/files";
21
+ import { assertErased } from "../../shared";
21
22
 
22
23
  // Forget writes go through the executor (events), not deleteMany/updateMany:
23
24
  // a projection rebuild replays the events, so the erasure survives. Eventless
@@ -258,6 +259,6 @@ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
258
259
  for (const row of rows) {
259
260
  const id = row["id"]; // @cast-boundary db-row
260
261
  if (typeof id !== "string") continue;
261
- await crud.forget({ id }, systemUser, tdb);
262
+ assertErased(await crud.forget({ id }, systemUser, tdb), "fileRef", id);
262
263
  }
263
264
  };
@@ -6,6 +6,7 @@ import {
6
6
  type UserDataExportHook,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { notificationPreferenceEntity, notificationPreferencesTable } from "../../delivery";
9
+ import { assertErased } from "../../shared";
9
10
  import { featureMounted } from "./feature-mounted";
10
11
 
11
12
  // userData-Hooks for delivery's notification-preference rows. Event-sourced
@@ -49,6 +50,6 @@ export const notificationPreferenceDeleteHook: UserDataDeleteHook = async (ctx)
49
50
  for (const row of rows) {
50
51
  const id = row["id"]; // @cast-boundary db-row
51
52
  if (typeof id !== "string") continue;
52
- await crud.forget({ id }, systemUser, tdb);
53
+ assertErased(await crud.forget({ id }, systemUser, tdb), "notification-preference", id);
53
54
  }
54
55
  };
@@ -6,7 +6,7 @@ import {
6
6
  type UserDataExportHook,
7
7
  type UserDataHookCtx,
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
- import { decryptStoredPii, mapWithConcurrency } from "../../shared";
9
+ import { assertErased, decryptStoredPii, mapWithConcurrency } from "../../shared";
10
10
  import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant";
11
11
  import { userTable } from "../../user";
12
12
  import { featureMounted } from "./feature-mounted";
@@ -100,7 +100,7 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
100
100
  const id = row["id"]; // @cast-boundary db-row
101
101
  if (typeof id !== "string") continue;
102
102
  if (strategy === "delete") {
103
- await crud.forget({ id }, systemUser, tdb);
103
+ assertErased(await crud.forget({ id }, systemUser, tdb), "tenant-invitation", id);
104
104
  } else {
105
105
  // Row-id in the pseudonym keeps the (tenantId, email) unique index
106
106
  // collision-free when a user has invitations in several states.