@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
@@ -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,6 +1,7 @@
1
1
  import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
- import { decryptStoredPii } from "../../shared";
3
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { decryptStoredPii, parentRowIsVisible } from "../../shared";
4
5
  import { userTable } from "../../user";
5
6
  import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
6
7
  import { noteEntryExecutor } from "../executor";
@@ -11,8 +12,16 @@ import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
11
12
  // (event.user.id), so a note can't be authored as someone else. No update or
12
13
  // delete counterpart is registered — see entity.ts for why append-only is
13
14
  // deliberate.
15
+ //
16
+ // entityType/entityId are never trusted client input: entityType must name a
17
+ // registered entity, and the row must be visible to the caller through that
18
+ // entity's own read path (tenant scope plus its `access.read` ownership) —
19
+ // see shared/parent-visibility.ts. `parents`, when set, is an ADDITIONAL allowlist
20
+ // narrowing which registered entities may be a note's parent at all; it is
21
+ // not what turns the check on.
14
22
  export function createAddNoteHandler(
15
23
  access: AccessRule = DEFAULT_NOTES_HISTORY_ACCESS,
24
+ parents?: ReadonlySet<string>,
16
25
  ): WriteHandlerDef {
17
26
  return {
18
27
  name: "add-note",
@@ -23,6 +32,23 @@ export function createAddNoteHandler(
23
32
  handler: async (event, ctx) => {
24
33
  const payload = event.payload as AddNotePayload; // @cast-boundary engine-payload
25
34
 
35
+ // NotFoundError, not an access-denied error, so the response doesn't
36
+ // double as an existence oracle — same policy as executor.detail,
37
+ // which never distinguishes "no access" from "doesn't exist".
38
+ if (parents !== undefined && !parents.has(payload.entityType)) {
39
+ return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
40
+ }
41
+ const visible = await parentRowIsVisible(
42
+ ctx.registry,
43
+ payload.entityType,
44
+ payload.entityId,
45
+ event.user,
46
+ ctx.db,
47
+ );
48
+ if (!visible) {
49
+ return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
50
+ }
51
+
26
52
  let authorName: string | null = null;
27
53
  try {
28
54
  // read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
@@ -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,
@@ -16,6 +17,7 @@ export { isWithinGracePeriod } from "./grace-period";
16
17
  export { hasWhereRule } from "./has-where-rule";
17
18
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
18
19
  export { mapWithConcurrency } from "./map-with-concurrency";
20
+ export { parentRowIsVisible } from "./parent-visibility";
19
21
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
20
22
  export { sessionField } from "./session-field";
21
23
  export { sessionLocaleField } from "./session-locale-field";
@@ -0,0 +1,48 @@
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 client-supplied (entityType, entityId) host reference is one
19
+ // the caller can see via that entity's own read path — tenant scope plus its
20
+ // `access.read` ownership. Shared by every handler that attaches something to a
21
+ // host entity the client names (add-note, assign-tag, remove-tag, set-folder,
22
+ // clear-folder); those handlers deny with NotFoundError so the response never
23
+ // doubles as an existence oracle.
24
+ export async function parentRowIsVisible(
25
+ registry: Registry,
26
+ entityType: string,
27
+ entityId: string,
28
+ user: SessionUser,
29
+ db: TenantDb,
30
+ ): Promise<boolean> {
31
+ // Default-deny: an entityType that names no registered entity has no read
32
+ // path to check visibility against, so it can never be a valid parent.
33
+ const entity = registry.getEntity(entityType);
34
+ if (!entity) return false;
35
+
36
+ // A malformed id would abort the write transaction on a Postgres cast
37
+ // error (22P02) instead of cleanly denying — reject it before it reaches
38
+ // the executor.
39
+ if (!idShapeMatchesEntity(entity, entityId)) return false;
40
+
41
+ let executor = executorsByEntity.get(entity);
42
+ if (!executor) {
43
+ executor = createEntityExecutor(entityType, entity).executor;
44
+ executorsByEntity.set(entity, executor);
45
+ }
46
+
47
+ return (await executor.detail({ id: entityId }, user, db)) !== null;
48
+ }
@@ -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: {