@cosmicdrift/kumiko-bundled-features 0.256.0 → 0.258.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 (59) hide show
  1. package/package.json +9 -10
  2. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +78 -6
  3. package/src/auth-mfa/__tests__/screen.test.ts +58 -0
  4. package/src/auth-mfa/feature.ts +6 -16
  5. package/src/auth-mfa/handlers/enable-start.write.ts +21 -12
  6. package/src/auth-mfa/i18n.ts +19 -0
  7. package/src/auth-mfa/screens.ts +52 -0
  8. package/src/auth-mfa/web/client-plugin.ts +4 -5
  9. package/src/auth-mfa/web/i18n.ts +0 -7
  10. package/src/auth-mfa/web/index.ts +0 -2
  11. package/src/auth-mfa/web/mfa-disable-dialog.tsx +2 -2
  12. package/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +4 -4
  13. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +5 -5
  14. package/src/auth-mfa/web/qrcode-browser.d.ts +2 -2
  15. package/src/folders/__tests__/folders-read-gate.integration.test.ts +179 -0
  16. package/src/folders/entity.ts +47 -28
  17. package/src/folders/feature.ts +58 -4
  18. package/src/folders/handlers/clear-folder.write.ts +4 -4
  19. package/src/folders/handlers/set-folder.write.ts +4 -4
  20. package/src/ledger/__tests__/ledger.integration.test.ts +111 -4
  21. package/src/ledger/entity.ts +30 -0
  22. package/src/ledger/handlers/confirm-schedule-period.write.ts +7 -0
  23. package/src/ledger/handlers/create-transaction.write.ts +2 -0
  24. package/src/ledger/schemas.ts +3 -0
  25. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +2 -1
  26. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +4 -1
  27. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +3 -1
  28. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  29. package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +243 -0
  30. package/src/notes-history/entity.ts +11 -1
  31. package/src/notes-history/feature.ts +12 -11
  32. package/src/notes-history/handlers/add-note.write.ts +8 -12
  33. package/src/personal-access-tokens/__tests__/expiry.test.ts +19 -0
  34. package/src/personal-access-tokens/__tests__/feature.test.ts +93 -0
  35. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +143 -13
  36. package/src/personal-access-tokens/constants.ts +5 -2
  37. package/src/personal-access-tokens/expiry.ts +7 -0
  38. package/src/personal-access-tokens/feature.ts +8 -17
  39. package/src/personal-access-tokens/handlers/list.query.ts +75 -16
  40. package/src/personal-access-tokens/i18n.ts +54 -0
  41. package/src/personal-access-tokens/resolver.ts +2 -7
  42. package/src/personal-access-tokens/screens.ts +107 -0
  43. package/src/shared/index.ts +1 -1
  44. package/src/shared/parent-visibility.ts +30 -0
  45. package/src/tags/__tests__/tags-read-gate.integration.test.ts +366 -0
  46. package/src/tags/__tests__/tags.integration.test.ts +1 -0
  47. package/src/tags/entity.ts +11 -1
  48. package/src/tags/feature.ts +25 -8
  49. package/src/tags/handlers/assign-tag.write.ts +2 -8
  50. package/src/tags/handlers/remove-tag.write.ts +2 -8
  51. package/src/tags/web/__tests__/entity-tags.test.tsx +13 -4
  52. package/src/tags/web/entity-tags.tsx +9 -2
  53. package/src/tags/web/tag-section.tsx +9 -4
  54. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +0 -182
  55. package/src/auth-mfa/web/mfa-enable-screen.tsx +0 -224
  56. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  57. package/src/personal-access-tokens/web/i18n.ts +0 -40
  58. package/src/personal-access-tokens/web/index.ts +0 -7
  59. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -296
@@ -0,0 +1,366 @@
1
+ // The read twin of tags-parent-visibility.integration.test.ts (fw#2766).
2
+ // assign/remove were gated against the host row's own read path since
3
+ // #2721/#2745, but `tag-assignment:list` returned every row of the tenant —
4
+ // including assignments on host rows the caller could never see. Closing that
5
+ // is default-on: none of the mounts below set `ownership`, which used to be
6
+ // the only lever.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import {
11
+ createEntity,
12
+ createTextField,
13
+ defineFeature,
14
+ type EntityDefinition,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ createTestUser,
19
+ setupTestStack,
20
+ type TestStack,
21
+ unsafeCreateEntityTable,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { TagsHandlers, TagsQueries } from "../constants";
24
+ import { tagAssignmentEntity, tagEntity } from "../entity";
25
+ import { createTagsFeature } from "../feature";
26
+
27
+ const PROJECT_TABLE = "tags_rg_test_projects";
28
+
29
+ const projectOwnership: NonNullable<EntityDefinition["access"]> = {
30
+ read: {
31
+ TenantMember: {
32
+ kind: "where",
33
+ where: (user, ctx) => ({
34
+ sqlText: `${ctx.tableName}.team_id = $${ctx.paramStart}`,
35
+ params: [user.claims?.["team"] ?? null],
36
+ }),
37
+ },
38
+ },
39
+ };
40
+
41
+ const projectEntity = createEntity({
42
+ table: PROJECT_TABLE,
43
+ fields: {
44
+ teamId: createTextField({
45
+ required: true,
46
+ maxLength: 64,
47
+ personal: false,
48
+ reason: "technical_reference",
49
+ }),
50
+ name: createTextField({
51
+ required: true,
52
+ maxLength: 64,
53
+ personal: false,
54
+ reason: "technical_reference",
55
+ }),
56
+ },
57
+ access: projectOwnership,
58
+ });
59
+
60
+ const hostFixturesFeature = defineFeature("tags-rg-test-fixtures", (r) => {
61
+ r.entity("project", projectEntity);
62
+ });
63
+
64
+ type TestUser = ReturnType<typeof createTestUser>;
65
+
66
+ const userA: TestUser = createTestUser({
67
+ id: 70,
68
+ roles: ["TenantMember"],
69
+ claims: { team: "team-a" },
70
+ });
71
+ const userB: TestUser = createTestUser({
72
+ id: 71,
73
+ roles: ["TenantMember"],
74
+ claims: { team: "team-b" },
75
+ });
76
+
77
+ // 4 projects for team-a, 3 for team-b — every one of them carries the same
78
+ // tag, so the tagId scatter query below has a mixed-visibility result set.
79
+ const TEAM_A_PROJECTS = [
80
+ "c0000000-0000-4000-8000-00000000000a",
81
+ "c0000000-0000-4000-8000-00000000000b",
82
+ "c0000000-0000-4000-8000-00000000000c",
83
+ "c0000000-0000-4000-8000-00000000000d",
84
+ ];
85
+ const TEAM_B_PROJECTS = [
86
+ "c0000000-0000-4000-8000-00000000001a",
87
+ "c0000000-0000-4000-8000-00000000001b",
88
+ "c0000000-0000-4000-8000-00000000001c",
89
+ ];
90
+ // Referenced by an assignment row but registered under no entity name.
91
+ const ORPHAN_HOST_ID = "c0000000-0000-4000-8000-0000000000ff";
92
+
93
+ type AssignmentRow = { id: string; tagId: string; entityType: string; entityId: string };
94
+ type Page = { rows: readonly AssignmentRow[]; nextCursor: string | null; total?: number };
95
+
96
+ let stack: TestStack;
97
+ let tagId: string;
98
+
99
+ beforeAll(async () => {
100
+ stack = await setupTestStack({
101
+ // Deliberately no `ownership` — the gate must hold on a bare mount.
102
+ features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
103
+ });
104
+ await unsafeCreateEntityTable(stack.db, tagEntity);
105
+ await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
106
+ await unsafeCreateEntityTable(stack.db, projectEntity);
107
+ await createEventsTable(stack.db);
108
+
109
+ const values = [...TEAM_A_PROJECTS, ...TEAM_B_PROJECTS]
110
+ .map((id, i) => `('${id}', $1, '${i < TEAM_A_PROJECTS.length ? "team-a" : "team-b"}', 'p${i}')`)
111
+ .join(", ");
112
+ await asRawClient(stack.db).unsafe(
113
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES ${values}`,
114
+ [userA.tenantId],
115
+ );
116
+
117
+ const tag = await stack.http.writeOk<{ id: string }>(
118
+ TagsHandlers.createTag,
119
+ { name: "VIP" },
120
+ userA,
121
+ );
122
+ tagId = tag.id;
123
+
124
+ for (const id of TEAM_A_PROJECTS) {
125
+ await stack.http.writeOk(
126
+ TagsHandlers.assignTag,
127
+ { tagId, entityType: "project", entityId: id },
128
+ userA,
129
+ );
130
+ }
131
+ for (const id of TEAM_B_PROJECTS) {
132
+ await stack.http.writeOk(
133
+ TagsHandlers.assignTag,
134
+ { tagId, entityType: "project", entityId: id },
135
+ userB,
136
+ );
137
+ }
138
+ });
139
+
140
+ afterAll(async () => {
141
+ await stack.cleanup();
142
+ });
143
+
144
+ function listAssignments(payload: Record<string, unknown>, user: TestUser): Promise<Page> {
145
+ return stack.http.queryOk<Page>(TagsQueries.assignmentList, payload, user);
146
+ }
147
+
148
+ describe("tags read-gate — entityId-filtered list", () => {
149
+ test("the owning team sees the assignment on its own project", async () => {
150
+ const page = await listAssignments(
151
+ { filter: { field: "entityId", op: "eq", value: TEAM_A_PROJECTS[0] } },
152
+ userA,
153
+ );
154
+ expect(page.rows).toHaveLength(1);
155
+ expect(page.rows[0]?.entityId).toBe(TEAM_A_PROJECTS[0] as string);
156
+ });
157
+
158
+ test("a foreign team sees nothing on that same project, without setting ownership", async () => {
159
+ const page = await listAssignments(
160
+ { filter: { field: "entityId", op: "eq", value: TEAM_A_PROJECTS[0] } },
161
+ userB,
162
+ );
163
+ expect(page.rows).toHaveLength(0);
164
+ expect(page.nextCursor).toBeNull();
165
+ });
166
+ });
167
+
168
+ describe("tags read-gate — unregistered host type", () => {
169
+ test("an assignment whose entityType names no registered entity is invisible to everyone", async () => {
170
+ const orphanId = "c0000000-0000-4000-8000-0000000000f1";
171
+ await asRawClient(stack.db).unsafe(
172
+ `INSERT INTO read_tag_assignments (id, tenant_id, tag_id, entity_type, entity_id, is_deleted)
173
+ VALUES ($1, $2, $3, 'not-a-registered-entity', $4, FALSE)`,
174
+ [orphanId, userA.tenantId, tagId, ORPHAN_HOST_ID],
175
+ );
176
+
177
+ for (const user of [userA, userB]) {
178
+ const page = await listAssignments(
179
+ { filter: { field: "entityId", op: "eq", value: ORPHAN_HOST_ID }, totalCount: true },
180
+ user,
181
+ );
182
+ expect(page.rows).toHaveLength(0);
183
+ expect(page.total).toBe(0);
184
+ }
185
+
186
+ // Positive control: the row really is in the table, so the zero above is
187
+ // the gate and not a failed insert.
188
+ const raw = await asRawClient(stack.db).unsafe<{ n: number }>(
189
+ "SELECT count(*)::int AS n FROM read_tag_assignments WHERE id = $1",
190
+ [orphanId],
191
+ );
192
+ expect(raw[0]?.n).toBe(1);
193
+ });
194
+ });
195
+
196
+ describe("tags read-gate — tagId scatter query stays paginated honestly", () => {
197
+ async function drain(user: TestUser, limit: number): Promise<readonly AssignmentRow[]> {
198
+ const seen: AssignmentRow[] = [];
199
+ let cursor: string | null = null;
200
+ for (let guard = 0; guard < 20; guard++) {
201
+ const page: Page = await listAssignments(
202
+ {
203
+ filter: { field: "tagId", op: "eq", value: tagId },
204
+ limit,
205
+ ...(cursor !== null && { cursor }),
206
+ },
207
+ user,
208
+ );
209
+ seen.push(...page.rows);
210
+ if (page.nextCursor === null) {
211
+ // A short page must be the last one.
212
+ expect(page.rows.length).toBeLessThanOrEqual(limit);
213
+ return seen;
214
+ }
215
+ // Every non-final page is full — a post-filter would shrink it here.
216
+ expect(page.rows).toHaveLength(limit);
217
+ cursor = page.nextCursor;
218
+ }
219
+ throw new Error("pagination did not terminate");
220
+ }
221
+
222
+ test("total counts only the rows the caller may see", async () => {
223
+ const forB = await listAssignments(
224
+ { filter: { field: "tagId", op: "eq", value: tagId }, totalCount: true },
225
+ userB,
226
+ );
227
+ expect(forB.total).toBe(TEAM_B_PROJECTS.length);
228
+
229
+ const forA = await listAssignments(
230
+ { filter: { field: "tagId", op: "eq", value: tagId }, totalCount: true },
231
+ userA,
232
+ );
233
+ expect(forA.total).toBe(TEAM_A_PROJECTS.length);
234
+ });
235
+
236
+ test("paging through the scatter query yields exactly the visible set, once each", async () => {
237
+ const drained = await drain(userA, 2);
238
+ const ids = drained.map((r) => r.entityId).sort();
239
+ expect(ids).toEqual([...TEAM_A_PROJECTS].sort());
240
+ expect(new Set(drained.map((r) => r.id)).size).toBe(drained.length);
241
+
242
+ const { total } = await listAssignments(
243
+ { filter: { field: "tagId", op: "eq", value: tagId }, totalCount: true },
244
+ userA,
245
+ );
246
+ expect(drained).toHaveLength(total as number);
247
+ });
248
+
249
+ test("every row handed out belongs to a host the caller can actually read", async () => {
250
+ const drained = await drain(userB, 2);
251
+ expect(drained.length).toBeGreaterThan(0);
252
+ for (const row of drained) {
253
+ expect(TEAM_B_PROJECTS).toContain(row.entityId);
254
+ }
255
+ });
256
+ });
257
+
258
+ describe("tags read-gate — ownership narrows further, it does not replace the gate", () => {
259
+ const OWNED_TABLE = "tags_rg_owned_projects";
260
+ const ownedEntity = createEntity({
261
+ table: OWNED_TABLE,
262
+ fields: {
263
+ teamId: createTextField({
264
+ required: true,
265
+ maxLength: 64,
266
+ personal: false,
267
+ reason: "technical_reference",
268
+ }),
269
+ },
270
+ access: {
271
+ read: {
272
+ TenantMember: {
273
+ kind: "where",
274
+ where: (user, ctx) => ({
275
+ sqlText: `${ctx.tableName}.team_id = $${ctx.paramStart}`,
276
+ params: [user.claims?.["team"] ?? null],
277
+ }),
278
+ },
279
+ },
280
+ },
281
+ });
282
+ const ownedFixtures = defineFeature("tags-rg-owned-fixtures", (r) => {
283
+ r.entity("project", ownedEntity);
284
+ });
285
+ const HOST = "c0000000-0000-4000-8000-00000000002a";
286
+ // Same team (so the host gate passes for both), but only `tagger` carries
287
+ // the hostKind claim the extra assignment-row rule demands.
288
+ const tagger: TestUser = createTestUser({
289
+ id: 73,
290
+ roles: ["TenantMember"],
291
+ claims: { team: "team-a", hostKind: "project" },
292
+ });
293
+ const peer: TestUser = createTestUser({
294
+ id: 74,
295
+ roles: ["TenantMember"],
296
+ claims: { team: "team-a", hostKind: "something-else" },
297
+ });
298
+
299
+ let ownedStack: TestStack;
300
+ let ownedTagId: string;
301
+
302
+ beforeAll(async () => {
303
+ ownedStack = await setupTestStack({
304
+ features: [
305
+ createTagsFeature({
306
+ access: { openToAll: true },
307
+ // An extra row rule on the assignment row itself, unrelated to who
308
+ // may see the host: the caller's claim must name the row's host type.
309
+ ownership: {
310
+ read: {
311
+ TenantMember: {
312
+ kind: "where",
313
+ where: (user, ctx) => ({
314
+ sqlText: `${ctx.tableName}.entity_type = $${ctx.paramStart}`,
315
+ params: [user.claims?.["hostKind"] ?? null],
316
+ }),
317
+ },
318
+ },
319
+ },
320
+ }),
321
+ ownedFixtures,
322
+ ],
323
+ });
324
+ await unsafeCreateEntityTable(ownedStack.db, tagEntity);
325
+ await unsafeCreateEntityTable(ownedStack.db, tagAssignmentEntity);
326
+ await unsafeCreateEntityTable(ownedStack.db, ownedEntity);
327
+ await createEventsTable(ownedStack.db);
328
+ await asRawClient(ownedStack.db).unsafe(
329
+ `INSERT INTO ${OWNED_TABLE} (id, tenant_id, team_id) VALUES ($1, $2, 'team-a')`,
330
+ [HOST, tagger.tenantId],
331
+ );
332
+ const tag = await ownedStack.http.writeOk<{ id: string }>(
333
+ TagsHandlers.createTag,
334
+ { name: "Owned" },
335
+ tagger,
336
+ );
337
+ ownedTagId = tag.id;
338
+ await ownedStack.http.writeOk(
339
+ TagsHandlers.assignTag,
340
+ { tagId: ownedTagId, entityType: "project", entityId: HOST },
341
+ tagger,
342
+ );
343
+ });
344
+
345
+ afterAll(async () => {
346
+ await ownedStack.cleanup();
347
+ });
348
+
349
+ test("a caller passing both the host gate and the extra row rule still sees the row", async () => {
350
+ const page = await ownedStack.http.queryOk<Page>(
351
+ TagsQueries.assignmentList,
352
+ { filter: { field: "entityId", op: "eq", value: HOST } },
353
+ tagger,
354
+ );
355
+ expect(page.rows).toHaveLength(1);
356
+ });
357
+
358
+ test("a same-team peer is cut by ownership even though the host is visible to them", async () => {
359
+ const page = await ownedStack.http.queryOk<Page>(
360
+ TagsQueries.assignmentList,
361
+ { filter: { field: "entityId", op: "eq", value: HOST } },
362
+ peer,
363
+ );
364
+ expect(page.rows).toHaveLength(0);
365
+ });
366
+ });
@@ -462,6 +462,7 @@ describe("tags integration — openToAll access model", () => {
462
462
  await unsafeCreateEntityTable(openStack.db, tagEntity);
463
463
  await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
464
464
  await unsafeCreateEntityTable(openStack.db, creditEntity);
465
+ await unsafeCreateEntityTable(openStack.db, noteEntity);
465
466
  await createEventsTable(openStack.db);
466
467
  await seedHostRows(openStack, CREDIT_TABLE, ["c-1"], unprivileged.tenantId);
467
468
 
@@ -55,13 +55,23 @@ export const tagEntity = createEntity({
55
55
  // Cross-entity views compose in the read-layer (no JOIN):
56
56
  // - tags of an entity → list assignments filter { field: "entityId", op: "eq" }
57
57
  // - entities with a tag → list assignments filter { field: "tagId", op: "eq" }
58
- export function createTagAssignmentEntity(access?: EntityDefinition["access"]) {
58
+ export function createTagAssignmentEntity(
59
+ access?: EntityDefinition["access"],
60
+ parents?: readonly string[],
61
+ ) {
59
62
  return createEntity({
60
63
  table: "read_tag_assignments",
61
64
  description:
62
65
  "The join row recording that one catalog tag is attached to one host entity, addressed by tagId, entityType and entityId, with exactly one row per pair. An entity may carry many tags, unlike the single-folder membership rows.",
63
66
  softDelete: true,
64
67
  access,
68
+ // Declared unconditionally: the host-visibility gate is the default for
69
+ // both paths, and `parents` only narrows which hosts are admissible.
70
+ parentRef: {
71
+ entityTypeField: "entityType",
72
+ entityIdField: "entityId",
73
+ ...(parents !== undefined && { allowedTypes: parents }),
74
+ },
65
75
  fields: {
66
76
  tagId: createTextField({
67
77
  required: true,
@@ -51,6 +51,7 @@ function registerTags(
51
51
  access: AccessRule,
52
52
  toggleable: TagsToggleable | undefined,
53
53
  ownership: EntityDefinition["access"] | undefined,
54
+ parents: readonly string[] | undefined,
54
55
  ): void {
55
56
  r.describe(
56
57
  "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
@@ -65,7 +66,7 @@ function registerTags(
65
66
  // feature toggleable lets tier-engine/feature-toggles cut it per tenant.
66
67
  if (toggleable !== undefined) r.toggleable(toggleable);
67
68
 
68
- const tagAssignmentEntity = createTagAssignmentEntity(ownership);
69
+ const tagAssignmentEntity = createTagAssignmentEntity(ownership, parents);
69
70
  r.entity("tag", tagEntity);
70
71
  r.entity("tag-assignment", tagAssignmentEntity);
71
72
 
@@ -128,7 +129,7 @@ function registerTags(
128
129
  }
129
130
 
130
131
  export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
131
- registerTags(r, DEFAULT_TAG_ACCESS, undefined, undefined),
132
+ registerTags(r, DEFAULT_TAG_ACCESS, undefined, undefined, undefined),
132
133
  );
133
134
 
134
135
  export type TagsFeatureOptions = {
@@ -146,10 +147,11 @@ export type TagsFeatureOptions = {
146
147
  readonly toggleable?: TagsToggleable;
147
148
  /** Row-level ownership on the tag-assignment rows themselves — orthogonal to
148
149
  * `access`, which only gates whether a caller may dispatch assign/remove/list
149
- * at all. Set `ownership.read` to close the read leak: without it — even if
150
- * `ownership.write` is set — `access.read` stays undefined and any
151
- * dispatch-eligible user can read every assignment in the tenant, including
152
- * assignments on entities they can't otherwise see. Applies only to
150
+ * at all. `ownership.read` is no longer what keeps a caller from reading
151
+ * assignments on host entities they can't see: that gate is default-on since
152
+ * fw#2766 and derives from the entity's `parentRef`. Use `ownership.read` for
153
+ * an ADDITIONAL row rule on the assignment row itself; it is AND-ed with the
154
+ * host-visibility gate, never a replacement for it. Applies only to
153
155
  * `tag-assignment`; the `tag` catalog stays tenant-wide by design.
154
156
  *
155
157
  * `ownership.write` is separate and does NOT affect list/read. It's
@@ -161,6 +163,12 @@ export type TagsFeatureOptions = {
161
163
  * crypto-shredding — a silent Art.17 failure, not a thrown error. Make
162
164
  * sure any `ownership.write` you set covers that role, or leave it unset. */
163
165
  readonly ownership?: EntityDefinition["access"];
166
+ /** Allowlist narrowing which registered entities may carry a tag. Not what
167
+ * turns host-checking on — assign/remove and the assignment list always
168
+ * verify that entityType names a registered entity whose row the caller can
169
+ * see. Setting it narrows further, and shrinks the read gate's SQL, which
170
+ * otherwise considers every registered entity as a candidate host. */
171
+ readonly parents?: readonly string[];
164
172
  };
165
173
 
166
174
  function resolveAccess(opts: TagsFeatureOptions): AccessRule {
@@ -177,10 +185,19 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
177
185
  opts.access === undefined &&
178
186
  opts.roles === undefined &&
179
187
  opts.toggleable === undefined &&
180
- opts.ownership === undefined
188
+ opts.ownership === undefined &&
189
+ opts.parents === undefined
181
190
  ) {
182
191
  return tagsFeature;
183
192
  }
193
+ if (opts.parents !== undefined && opts.parents.length === 0) {
194
+ throw new Error(
195
+ "createTagsFeature({ parents }): parents must not be an empty array — " +
196
+ "an empty allowlist rejects every assign-tag call and hides every " +
197
+ "assignment row. Omit `parents` to keep every registered entity " +
198
+ "admissible as a host instead.",
199
+ );
200
+ }
184
201
  if (hasWhereRule(opts.ownership?.write)) {
185
202
  throw new Error(
186
203
  "createTagsFeature({ ownership }): ownership.write must not contain a " +
@@ -194,6 +211,6 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
194
211
  }
195
212
  const access = resolveAccess(opts);
196
213
  return defineFeature(TAGS_FEATURE_NAME, (r) =>
197
- registerTags(r, access, opts.toggleable, opts.ownership),
214
+ registerTags(r, access, opts.toggleable, opts.ownership, opts.parents),
198
215
  );
199
216
  }
@@ -1,6 +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
+ import { joinRowParentIsVisible } from "../../shared";
4
4
  import { tagAssignmentAggregateId } from "../aggregate-id";
5
5
  import { DEFAULT_TAG_ACCESS } from "../constants";
6
6
  import { tagAssignmentExecutor, tagExecutor } from "../executor";
@@ -43,13 +43,7 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
43
43
  // an invisible parent apart from a missing assignment or an unknown tag —
44
44
  // every path answers with the same NotFoundError.
45
45
  if (
46
- !(await parentRowIsVisible(
47
- ctx.registry,
48
- payload.entityType,
49
- payload.entityId,
50
- event.user,
51
- ctx.db,
52
- ))
46
+ !(await joinRowParentIsVisible(ctx.registry, "tag-assignment", payload, event.user, ctx.db))
53
47
  ) {
54
48
  return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
55
49
  }
@@ -1,6 +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
+ import { joinRowParentIsVisible } from "../../shared";
4
4
  import { tagAssignmentAggregateId } from "../aggregate-id";
5
5
  import { DEFAULT_TAG_ACCESS } from "../constants";
6
6
  import { tagAssignmentExecutor } from "../executor";
@@ -29,13 +29,7 @@ export function createRemoveTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
29
29
  // an invisible parent apart from a missing assignment or an unknown tag —
30
30
  // every path answers with the same NotFoundError.
31
31
  if (
32
- !(await parentRowIsVisible(
33
- ctx.registry,
34
- payload.entityType,
35
- payload.entityId,
36
- event.user,
37
- ctx.db,
38
- ))
32
+ !(await joinRowParentIsVisible(ctx.registry, "tag-assignment", payload, event.user, ctx.db))
39
33
  ) {
40
34
  return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
41
35
  }
@@ -22,7 +22,7 @@ beforeEach(() => {
22
22
  assignmentRows = [];
23
23
  });
24
24
 
25
- const useQuerySpy = mock((type: string) => ({
25
+ const useQuerySpy = mock((type: string, _payload?: unknown) => ({
26
26
  data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
27
27
  loading: false,
28
28
  error: null,
@@ -79,9 +79,10 @@ describe("EntityTags", () => {
79
79
  expect(screen.queryByTestId("tag-chip")).toBeNull();
80
80
  });
81
81
 
82
- test("ignores assignments belonging to a different entityType", () => {
82
+ test("scopes the assignment query to this entityType and id server-side", () => {
83
83
  catalogRows = [{ id: "t1", name: "important" }];
84
- assignmentRows = [{ tagId: "t1", entityType: "invoice", entityId: "n1" }];
84
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "n1" }];
85
+ useQuerySpy.mockClear();
85
86
 
86
87
  render(
87
88
  <Wrapper>
@@ -89,7 +90,15 @@ describe("EntityTags", () => {
89
90
  </Wrapper>,
90
91
  );
91
92
 
92
- expect(screen.queryByTestId("entity-tags")).toBeNull();
93
+ // Filtering server-side (rather than discarding foreign rows after the
94
+ // fetch) is what lets the parent-ref read gate narrow to one host entity.
95
+ const call = useQuerySpy.mock.calls.find((c) => c[0] === TagsQueries.assignmentList);
96
+ expect(call?.[1]).toEqual({
97
+ filters: [
98
+ { field: "entityType", op: "eq", value: "note" },
99
+ { field: "entityId", op: "eq", value: "n1" },
100
+ ],
101
+ });
93
102
  });
94
103
 
95
104
  test("renders nothing for a not-yet-saved entity (entityId null)", () => {
@@ -25,15 +25,22 @@ export function EntityTags({
25
25
  }): ReactNode {
26
26
  const enabled = entityId !== null;
27
27
  const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {}, { enabled });
28
+ // entityType server-side, not just entityId: it collapses the parent-ref
29
+ // read gate to this one host entity instead of every registered one.
28
30
  const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(
29
31
  TagsQueries.assignmentList,
30
- { filter: { field: "entityId", op: "eq", value: entityId } },
32
+ {
33
+ filters: [
34
+ { field: "entityType", op: "eq", value: entityName },
35
+ { field: "entityId", op: "eq", value: entityId },
36
+ ],
37
+ },
31
38
  { enabled },
32
39
  );
33
40
 
34
41
  if (entityId === null) return null;
35
42
  const byId = new Map((catalog.data?.rows ?? []).map((t) => [t.id, t]));
36
- const assigned = (assignments.data?.rows ?? []).filter((r) => r.entityType === entityName);
43
+ const assigned = assignments.data?.rows ?? [];
37
44
  if (assigned.length === 0) return null;
38
45
 
39
46
  return (
@@ -57,9 +57,16 @@ export function TagSection({
57
57
  const dispatcher = useDispatcher();
58
58
  const enabled = entityId !== null;
59
59
  const catalog = useQuery<TagListResponse>(TagsQueries.tagList, {}, { enabled });
60
+ // entityType server-side, not just entityId: it collapses the parent-ref
61
+ // read gate to this one host entity instead of every registered one.
60
62
  const assignments = useQuery<AssignmentListResponse>(
61
63
  TagsQueries.assignmentList,
62
- { filter: { field: "entityId", op: "eq", value: entityId } },
64
+ {
65
+ filters: [
66
+ { field: "entityType", op: "eq", value: entityName },
67
+ { field: "entityId", op: "eq", value: entityId },
68
+ ],
69
+ },
63
70
  { enabled },
64
71
  );
65
72
  const [pickerOpen, setPickerOpen] = useState(false);
@@ -93,9 +100,7 @@ export function TagSection({
93
100
  }
94
101
 
95
102
  const byId = new Map((catalog.data?.rows ?? []).map((tg) => [tg.id, tg]));
96
- const assignedIds = (assignments.data?.rows ?? [])
97
- .filter((r) => r.entityType === entityName)
98
- .map((r) => r.tagId);
103
+ const assignedIds = (assignments.data?.rows ?? []).map((r) => r.tagId);
99
104
 
100
105
  const refetch = async (): Promise<void> => {
101
106
  await Promise.all([catalog.refetch(), assignments.refetch()]);