@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
@@ -35,7 +35,7 @@ import {
35
35
  unsafeCreateEntityTable,
36
36
  } from "@cosmicdrift/kumiko-framework/stack";
37
37
  import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
38
- import { createNoteEntryEntity, noteEntryEntity } from "../entity";
38
+ import { createNoteEntryEntity, noteEntryEntity, noteMentionEntity } from "../entity";
39
39
  import { createNotesHistoryFeature } from "../feature";
40
40
 
41
41
  // Minimal fixture standing in for a real host projection: which team a host
@@ -123,6 +123,7 @@ beforeAll(async () => {
123
123
  features: [createNotesHistoryFeature({ ownership: teamOwnership }), projectFixtureFeature],
124
124
  });
125
125
  await unsafeCreateEntityTable(scopedStack.db, createNoteEntryEntity(teamOwnership));
126
+ await unsafeCreateEntityTable(scopedStack.db, noteMentionEntity);
126
127
  await unsafeCreateEntityTable(scopedStack.db, projectEntity);
127
128
  await createEventsTable(scopedStack.db);
128
129
  await asRawClient(scopedStack.db).unsafe(
@@ -138,6 +139,7 @@ beforeAll(async () => {
138
139
  features: [createNotesHistoryFeature(), projectFixtureFeature],
139
140
  });
140
141
  await unsafeCreateEntityTable(defaultStack.db, noteEntryEntity);
142
+ await unsafeCreateEntityTable(defaultStack.db, noteMentionEntity);
141
143
  await unsafeCreateEntityTable(defaultStack.db, projectEntity);
142
144
  await createEventsTable(defaultStack.db);
143
145
  await insertProjects(defaultStack, userA.tenantId, [PROJ_1]);
@@ -286,6 +288,7 @@ describe("unqualified where-rule fails closed, not open (fw#2639)", () => {
286
288
  ],
287
289
  });
288
290
  await unsafeCreateEntityTable(unqualifiedStack.db, createNoteEntryEntity(unqualifiedOwnership));
291
+ await unsafeCreateEntityTable(unqualifiedStack.db, noteMentionEntity);
289
292
  await unsafeCreateEntityTable(unqualifiedStack.db, projectEntity);
290
293
  await createEventsTable(unqualifiedStack.db);
291
294
  await asRawClient(unqualifiedStack.db).unsafe(
@@ -25,7 +25,7 @@ import {
25
25
  unsafeCreateEntityTable,
26
26
  } from "@cosmicdrift/kumiko-framework/stack";
27
27
  import { NotesHistoryHandlers } from "../constants";
28
- import { createNoteEntryEntity } from "../entity";
28
+ import { createNoteEntryEntity, noteMentionEntity } from "../entity";
29
29
  import { createNotesHistoryFeature } from "../feature";
30
30
 
31
31
  const PROJECT_TABLE = "notes_pv_test_projects";
@@ -118,6 +118,7 @@ beforeAll(async () => {
118
118
  });
119
119
  await unsafeCreateEntityTable(guardedStack.db, guardedProjectEntity);
120
120
  await unsafeCreateEntityTable(guardedStack.db, createNoteEntryEntity());
121
+ await unsafeCreateEntityTable(guardedStack.db, noteMentionEntity);
121
122
  await createEventsTable(guardedStack.db);
122
123
  await asRawClient(guardedStack.db).unsafe(
123
124
  `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES
@@ -134,6 +135,7 @@ beforeAll(async () => {
134
135
  });
135
136
  await unsafeCreateEntityTable(openStack.db, guardedProjectEntity);
136
137
  await unsafeCreateEntityTable(openStack.db, createNoteEntryEntity());
138
+ await unsafeCreateEntityTable(openStack.db, noteMentionEntity);
137
139
  await createEventsTable(openStack.db);
138
140
  await asRawClient(openStack.db).unsafe(
139
141
  `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id, name) VALUES ($1, $2, 'team-a', 'Project Open')`,
@@ -28,7 +28,7 @@ import {
28
28
  unsafeCreateEntityTable,
29
29
  } from "@cosmicdrift/kumiko-framework/stack";
30
30
  import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
31
- import { noteEntryEntity } from "../entity";
31
+ import { noteEntryEntity, noteMentionEntity } from "../entity";
32
32
  import { createNotesHistoryFeature } from "../feature";
33
33
 
34
34
  const notesHistoryFeature = createNotesHistoryFeature();
@@ -73,6 +73,7 @@ const CONTACT_SHARED = "30000000-0000-4000-8000-000000000007";
73
73
  beforeAll(async () => {
74
74
  stack = await setupTestStack({ features: [notesHistoryFeature, contactFixtureFeature] });
75
75
  await unsafeCreateEntityTable(stack.db, noteEntryEntity);
76
+ await unsafeCreateEntityTable(stack.db, noteMentionEntity);
76
77
  await unsafeCreateEntityTable(stack.db, contactEntity);
77
78
  await createEventsTable(stack.db);
78
79
 
@@ -0,0 +1,243 @@
1
+ // The read twin of notes-history-parent-visibility.integration.test.ts
2
+ // (fw#2766). add-note was gated against the host row's own read path, but
3
+ // `note-entry:list` returned every note of the tenant. None of the mounts
4
+ // below set `ownership`, which used to be the only lever.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import {
9
+ createEntity,
10
+ createTextField,
11
+ defineFeature,
12
+ type EntityDefinition,
13
+ } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
+ import {
16
+ createTestUser,
17
+ setupTestStack,
18
+ type TestStack,
19
+ unsafeCreateEntityTable,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { userEntity } from "../../user";
22
+ import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
23
+ import { createNoteEntryEntity, noteMentionEntity } from "../entity";
24
+ import { createNotesHistoryFeature } from "../feature";
25
+
26
+ const PROJECT_TABLE = "notes_rg_test_projects";
27
+
28
+ const teamScopedRead: NonNullable<EntityDefinition["access"]> = {
29
+ read: {
30
+ TenantMember: {
31
+ kind: "where",
32
+ where: (user, ctx) => ({
33
+ sqlText: `${ctx.tableName}.team_id = $${ctx.paramStart}`,
34
+ params: [user.claims?.["team"] ?? null],
35
+ }),
36
+ },
37
+ },
38
+ };
39
+
40
+ const projectEntity = createEntity({
41
+ table: PROJECT_TABLE,
42
+ fields: {
43
+ teamId: createTextField({
44
+ required: true,
45
+ maxLength: 64,
46
+ personal: false,
47
+ reason: "technical_reference",
48
+ }),
49
+ },
50
+ access: teamScopedRead,
51
+ });
52
+
53
+ const hostFixturesFeature = defineFeature("notes-rg-test-fixtures", (r) => {
54
+ r.entity("project", projectEntity);
55
+ });
56
+
57
+ type TestUser = ReturnType<typeof createTestUser>;
58
+
59
+ const userA: TestUser = createTestUser({
60
+ id: 80,
61
+ roles: ["TenantMember"],
62
+ claims: { team: "team-a" },
63
+ });
64
+ const userB: TestUser = createTestUser({
65
+ id: 81,
66
+ roles: ["TenantMember"],
67
+ claims: { team: "team-b" },
68
+ });
69
+
70
+ const PROJECT_A = "d0000000-0000-4000-8000-00000000000a";
71
+ const PROJECT_B = "d0000000-0000-4000-8000-00000000000b";
72
+ const ORPHAN_HOST_ID = "d0000000-0000-4000-8000-0000000000ff";
73
+
74
+ type NoteRow = { id: string; entityType: string; entityId: string; body: string };
75
+ type Page = { rows: readonly NoteRow[]; nextCursor: string | null; total?: number };
76
+
77
+ const noteEntryEntity = createNoteEntryEntity();
78
+
79
+ let stack: TestStack;
80
+
81
+ beforeAll(async () => {
82
+ stack = await setupTestStack({
83
+ // Deliberately no `ownership` — the gate must hold on a bare mount.
84
+ features: [createNotesHistoryFeature({ access: { openToAll: true } }), hostFixturesFeature],
85
+ });
86
+ await unsafeCreateEntityTable(stack.db, noteEntryEntity);
87
+ await unsafeCreateEntityTable(stack.db, noteMentionEntity);
88
+ await unsafeCreateEntityTable(stack.db, userEntity);
89
+ await unsafeCreateEntityTable(stack.db, projectEntity);
90
+ await createEventsTable(stack.db);
91
+
92
+ await asRawClient(stack.db).unsafe(
93
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id) VALUES ($1, $3, 'team-a'), ($2, $3, 'team-b')`,
94
+ [PROJECT_A, PROJECT_B, userA.tenantId],
95
+ );
96
+
97
+ await stack.http.writeOk(
98
+ NotesHistoryHandlers.addNote,
99
+ { entityType: "project", entityId: PROJECT_A, body: "team-a note" },
100
+ userA,
101
+ );
102
+ await stack.http.writeOk(
103
+ NotesHistoryHandlers.addNote,
104
+ { entityType: "project", entityId: PROJECT_B, body: "team-b note" },
105
+ userB,
106
+ );
107
+ });
108
+
109
+ afterAll(async () => {
110
+ await stack.cleanup();
111
+ });
112
+
113
+ function listNotes(payload: Record<string, unknown>, user: TestUser): Promise<Page> {
114
+ return stack.http.queryOk<Page>(NotesHistoryQueries.noteList, payload, user);
115
+ }
116
+
117
+ describe("notes-history read-gate", () => {
118
+ test("the owning team reads the note trail of its own project", async () => {
119
+ const page = await listNotes(
120
+ { filter: { field: "entityId", op: "eq", value: PROJECT_A } },
121
+ userA,
122
+ );
123
+ expect(page.rows).toHaveLength(1);
124
+ expect(page.rows[0]?.body).toBe("team-a note");
125
+ });
126
+
127
+ test("a foreign team reads nothing there, without ownership being set", async () => {
128
+ const page = await listNotes(
129
+ { filter: { field: "entityId", op: "eq", value: PROJECT_A }, totalCount: true },
130
+ userB,
131
+ );
132
+ expect(page.rows).toHaveLength(0);
133
+ expect(page.total).toBe(0);
134
+ });
135
+
136
+ test("an unfiltered list returns only the notes whose host the caller can see", async () => {
137
+ const forA = await listNotes({ totalCount: true }, userA);
138
+ expect(forA.rows.map((r) => r.entityId)).toEqual([PROJECT_A]);
139
+ expect(forA.total).toBe(1);
140
+
141
+ const forB = await listNotes({ totalCount: true }, userB);
142
+ expect(forB.rows.map((r) => r.entityId)).toEqual([PROJECT_B]);
143
+ expect(forB.total).toBe(1);
144
+ });
145
+
146
+ test("a note whose entityType names no registered entity is invisible to everyone", async () => {
147
+ const orphanId = "d0000000-0000-4000-8000-0000000000f1";
148
+ await asRawClient(stack.db).unsafe(
149
+ `INSERT INTO read_note_entries (id, tenant_id, entity_type, entity_id, body)
150
+ VALUES ($1, $2, 'not-a-registered-entity', $3, 'orphan')`,
151
+ [orphanId, userA.tenantId, ORPHAN_HOST_ID],
152
+ );
153
+
154
+ for (const user of [userA, userB]) {
155
+ const page = await listNotes(
156
+ { filter: { field: "entityId", op: "eq", value: ORPHAN_HOST_ID }, totalCount: true },
157
+ user,
158
+ );
159
+ expect(page.rows).toHaveLength(0);
160
+ expect(page.total).toBe(0);
161
+ }
162
+
163
+ const raw = await asRawClient(stack.db).unsafe<{ n: number }>(
164
+ "SELECT count(*)::int AS n FROM read_note_entries WHERE id = $1",
165
+ [orphanId],
166
+ );
167
+ expect(raw[0]?.n).toBe(1);
168
+ });
169
+ });
170
+
171
+ describe("notes-history read-gate — parents allowlist narrows both paths", () => {
172
+ const OTHER_TABLE = "notes_rg_other_hosts";
173
+ const otherEntity = createEntity({
174
+ table: OTHER_TABLE,
175
+ fields: {
176
+ label: createTextField({
177
+ required: true,
178
+ maxLength: 64,
179
+ personal: false,
180
+ reason: "technical_reference",
181
+ }),
182
+ },
183
+ });
184
+ const fixtures = defineFeature("notes-rg-parents-fixtures", (r) => {
185
+ r.entity("project", projectEntity);
186
+ r.entity("widget", otherEntity);
187
+ });
188
+ const WIDGET = "d0000000-0000-4000-8000-00000000002a";
189
+
190
+ let allowStack: TestStack;
191
+
192
+ beforeAll(async () => {
193
+ allowStack = await setupTestStack({
194
+ features: [
195
+ createNotesHistoryFeature({ access: { openToAll: true }, parents: ["project"] }),
196
+ fixtures,
197
+ ],
198
+ });
199
+ await unsafeCreateEntityTable(allowStack.db, createNoteEntryEntity(undefined, ["project"]));
200
+ await unsafeCreateEntityTable(allowStack.db, noteMentionEntity);
201
+ await unsafeCreateEntityTable(allowStack.db, userEntity);
202
+ await unsafeCreateEntityTable(allowStack.db, projectEntity);
203
+ await unsafeCreateEntityTable(allowStack.db, otherEntity);
204
+ await createEventsTable(allowStack.db);
205
+ await asRawClient(allowStack.db).unsafe(
206
+ `INSERT INTO ${PROJECT_TABLE} (id, tenant_id, team_id) VALUES ($1, $2, 'team-a')`,
207
+ [PROJECT_A, userA.tenantId],
208
+ );
209
+ await asRawClient(allowStack.db).unsafe(
210
+ `INSERT INTO ${OTHER_TABLE} (id, tenant_id, label) VALUES ($1, $2, 'w')`,
211
+ [WIDGET, userA.tenantId],
212
+ );
213
+ });
214
+
215
+ afterAll(async () => {
216
+ await allowStack.cleanup();
217
+ });
218
+
219
+ test("a host type outside the allowlist is rejected on write", async () => {
220
+ const err = await allowStack.http.writeErr(
221
+ NotesHistoryHandlers.addNote,
222
+ { entityType: "widget", entityId: WIDGET, body: "nope" },
223
+ userA,
224
+ );
225
+ expect(err.code).toBe("not_found");
226
+ });
227
+
228
+ test("a row that slipped in on a non-allowlisted host stays invisible on read", async () => {
229
+ const smuggled = "d0000000-0000-4000-8000-0000000000f2";
230
+ await asRawClient(allowStack.db).unsafe(
231
+ `INSERT INTO read_note_entries (id, tenant_id, entity_type, entity_id, body)
232
+ VALUES ($1, $2, 'widget', $3, 'smuggled')`,
233
+ [smuggled, userA.tenantId, WIDGET],
234
+ );
235
+ const page = await allowStack.http.queryOk<Page>(
236
+ NotesHistoryQueries.noteList,
237
+ { filter: { field: "entityId", op: "eq", value: WIDGET }, totalCount: true },
238
+ userA,
239
+ );
240
+ expect(page.rows).toHaveLength(0);
241
+ expect(page.total).toBe(0);
242
+ });
243
+ });
@@ -37,12 +37,22 @@ import {
37
37
  // party without a structured @-mention is not tracked — regex/NLP over
38
38
  // free text is unreliable. The operator's manual `forget-subject` path
39
39
  // covers what this structured trigger misses.
40
- export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
40
+ export function createNoteEntryEntity(
41
+ access?: EntityDefinition["access"],
42
+ parents?: readonly string[],
43
+ ) {
41
44
  return createEntity({
42
45
  table: "read_note_entries",
43
46
  description:
44
47
  "One note attached to a host entity by entityType and entityId, holding the note body plus the id and the display name of the author as it stood when the note was written. Rows are append-only: a correction is a further note, never an edit of this one.",
45
48
  access,
49
+ // Declared unconditionally: the host-visibility gate is the default for
50
+ // both paths, and `parents` only narrows which hosts are admissible.
51
+ parentRef: {
52
+ entityTypeField: "entityType",
53
+ entityIdField: "entityId",
54
+ ...(parents !== undefined && { allowedTypes: parents }),
55
+ },
46
56
  fields: {
47
57
  entityType: createTextField({
48
58
  required: true,
@@ -30,7 +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
+ parents: readonly string[] | undefined,
34
34
  ): void {
35
35
  r.describe(
36
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 }).",
@@ -41,7 +41,7 @@ function registerNotesHistory(
41
41
  recommended: false,
42
42
  });
43
43
 
44
- const entity = createNoteEntryEntity(ownership);
44
+ const entity = createNoteEntryEntity(ownership, parents);
45
45
  r.entity("note-entry", entity);
46
46
  // No write/query handler of its own — populated only as a side effect of
47
47
  // add-note (see handlers/add-note.write.ts), looked up by
@@ -50,7 +50,7 @@ function registerNotesHistory(
50
50
  // registry-wide GDPR boot guards and to executor.ts's table/projection setup.
51
51
  r.entity("note-mention", noteMentionEntity);
52
52
 
53
- r.writeHandler(createAddNoteHandler(access, parents));
53
+ r.writeHandler(createAddNoteHandler(access));
54
54
  r.queryHandler(
55
55
  defineEntityListHandler("note-entry", entity, {
56
56
  access,
@@ -76,10 +76,11 @@ export type NotesHistoryFeatureOptions = {
76
76
  readonly roles?: readonly string[];
77
77
  /** Row-level ownership on the note-entry rows themselves — orthogonal to
78
78
  * `access`, which only gates whether a caller may dispatch create/list at
79
- * all. Set `ownership.read` to close the read leak: without it — even if
80
- * `ownership.write` is set — `access.read` stays undefined and any
81
- * dispatch-eligible user can read every note in the tenant, including
82
- * notes on entities they can't otherwise see.
79
+ * all. `ownership.read` is no longer what keeps a caller from reading notes
80
+ * on host entities they can't see: that gate is default-on since fw#2766
81
+ * and derives from the entity's `parentRef`. Use `ownership.read` for an
82
+ * ADDITIONAL row rule on the note row itself (e.g. author-only visibility);
83
+ * it is AND-ed with the host-visibility gate, never a replacement for it.
83
84
  *
84
85
  * `ownership.write` is separate and does NOT affect list/read. It's
85
86
  * consulted by the framework's generic delete/forget/restore paths (not
@@ -92,13 +93,14 @@ export type NotesHistoryFeatureOptions = {
92
93
  readonly ownership?: EntityDefinition["access"];
93
94
  /** Allowlist further narrowing which registered entities may be used as a
94
95
  * note's parent (entityType). This is NOT what turns parent-checking on —
95
- * add-note always verifies that entityType names a registered entity and
96
+ * both paths always verify that entityType names a registered entity and
96
97
  * that the row is visible to the caller through that entity's own read
97
98
  * path (tenant scope plus its `access.read` ownership); an entityType
98
99
  * that names no registered entity is rejected regardless of this option.
99
100
  * Setting `parents` narrows further, to a specific set of entity names —
100
101
  * useful when a host entity is registered but should never be a valid
101
- * note parent. */
102
+ * note parent. It also shrinks the read gate's SQL, which otherwise has to
103
+ * consider every registered entity as a candidate host. */
102
104
  readonly parents?: readonly string[];
103
105
  };
104
106
 
@@ -142,8 +144,7 @@ export function createNotesHistoryFeature(
142
144
  "today's unrestricted behavior instead.",
143
145
  );
144
146
  }
145
- const parents = opts.parents === undefined ? undefined : new Set(opts.parents);
146
147
  return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
147
- registerNotesHistory(r, resolveAccess(opts), opts.ownership, parents),
148
+ registerNotesHistory(r, resolveAccess(opts), opts.ownership, opts.parents),
148
149
  );
149
150
  }
@@ -1,7 +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
3
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
- import { decryptStoredPii, parentRowIsVisible } from "../../shared";
4
+ import { decryptStoredPii, joinRowParentIsVisible } from "../../shared";
5
5
  import { userTable } from "../../user";
6
6
  import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
7
7
  import { noteEntryExecutor, noteMentionExecutor } from "../executor";
@@ -15,13 +15,12 @@ import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
15
15
  //
16
16
  // entityType/entityId are never trusted client input: entityType must name a
17
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.
18
+ // entity's own read path (tenant scope plus its `access.read` ownership).
19
+ // Both the field names and the optional `parents` allowlist come from the
20
+ // entity's own `parentRef` declaration, which the read gate reads too —
21
+ // see shared/parent-visibility.ts.
22
22
  export function createAddNoteHandler(
23
23
  access: AccessRule = DEFAULT_NOTES_HISTORY_ACCESS,
24
- parents?: ReadonlySet<string>,
25
24
  ): WriteHandlerDef {
26
25
  return {
27
26
  name: "add-note",
@@ -35,13 +34,10 @@ export function createAddNoteHandler(
35
34
  // NotFoundError, not an access-denied error, so the response doesn't
36
35
  // double as an existence oracle — same policy as executor.detail,
37
36
  // 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(
37
+ const visible = await joinRowParentIsVisible(
42
38
  ctx.registry,
43
- payload.entityType,
44
- payload.entityId,
39
+ "note-entry",
40
+ payload,
45
41
  event.user,
46
42
  ctx.db,
47
43
  );
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { Temporal } from "temporal-polyfill";
3
+ import { isExpiredAt } from "../expiry";
4
+
5
+ describe("isExpiredAt", () => {
6
+ it("null never expires", () => {
7
+ expect(isExpiredAt(null)).toBe(false);
8
+ });
9
+
10
+ it("a future instant is not expired", () => {
11
+ const future = Temporal.Now.instant().add({ hours: 1 });
12
+ expect(isExpiredAt({ epochMilliseconds: future.epochMilliseconds })).toBe(false);
13
+ });
14
+
15
+ it("a past instant is expired", () => {
16
+ const past = Temporal.Now.instant().subtract({ hours: 1 });
17
+ expect(isExpiredAt({ epochMilliseconds: past.epochMilliseconds })).toBe(true);
18
+ });
19
+ });
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
3
+ import { fieldOptionLabelKey } from "@cosmicdrift/kumiko-headless";
3
4
  import { EXT_TOKEN_VERIFIER } from "../../auth-foundation";
5
+ import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "../constants";
4
6
  import { createPersonalAccessTokensFeature } from "../feature";
7
+ import { patScopeOptionTranslations } from "../i18n";
8
+ import { expandScopes, type PatScopeConfig, parseGrant } from "../scopes";
9
+ import { patGrantOptions } from "../screens";
5
10
 
6
11
  describe("createPersonalAccessTokensFeature toggleable-option (tier-gating)", () => {
7
12
  test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
@@ -40,3 +45,91 @@ describe("createPersonalAccessTokensFeature — tokenVerifier registration (#136
40
45
  expect(feature.requires).toContain("auth-foundation");
41
46
  });
42
47
  });
48
+
49
+ describe("createPersonalAccessTokensFeature — declarative screens (fw#2548 Teil B)", () => {
50
+ test('registers no screen of type "custom"', () => {
51
+ const feature = createPersonalAccessTokensFeature({ scopes: {} });
52
+ const screens = Object.values(feature.screens);
53
+ expect(screens.length).toBeGreaterThan(0);
54
+ expect(screens.some((screen) => screen.type === "custom")).toBe(false);
55
+ });
56
+
57
+ test("list screen is a projectionList on PatQueries.mine with a revoke rowAction", () => {
58
+ const feature = createPersonalAccessTokensFeature({ scopes: {} });
59
+ const list = feature.screens[PAT_SCREEN_ID];
60
+ expect(list?.type).toBe("projectionList");
61
+ if (list?.type !== "projectionList") throw new Error("expected a projectionList screen");
62
+ expect(list.query).toBe(PatQueries.mine);
63
+ const revoke = list.rowActions?.find((action) => action.id === "revoke");
64
+ expect(revoke).toMatchObject({ handler: PatHandlers.revoke, kind: "writeHandler" });
65
+ });
66
+
67
+ test('mint screen is a secretMint on PatHandlers.create whose reveal.fields is exactly ["token"]', () => {
68
+ const feature = createPersonalAccessTokensFeature({ scopes: {} });
69
+ const mint = feature.screens[PAT_MINT_SCREEN_ID];
70
+ expect(mint?.type).toBe("secretMint");
71
+ if (mint?.type !== "secretMint") throw new Error("expected a secretMint screen");
72
+ expect(mint.handler).toBe(PatHandlers.create);
73
+ expect(mint.reveal.fields.map((field) => field.field)).toEqual(["token"]);
74
+ });
75
+ });
76
+
77
+ describe("patGrantOptions (fw#2548 Teil B)", () => {
78
+ test("a read-only domain yields only <domain>:read", () => {
79
+ const scopes: PatScopeConfig = { billing: { label: "Billing", read: ["billing:query:*"] } };
80
+ expect(patGrantOptions(scopes)).toEqual(["billing:read"]);
81
+ });
82
+
83
+ test("a domain with a non-empty write set additionally yields <domain>:write", () => {
84
+ const scopes: PatScopeConfig = {
85
+ ledger: { label: "Ledger", read: ["ledger:query:*"], write: ["ledger:write:*"] },
86
+ };
87
+ expect(patGrantOptions(scopes)).toEqual(["ledger:read", "ledger:write"]);
88
+ });
89
+
90
+ test("every generated grant string round-trips through parseGrant/expandScopes", () => {
91
+ const scopes: PatScopeConfig = {
92
+ billing: { label: "Billing", read: ["billing:query:*"] },
93
+ ledger: { label: "Ledger", read: ["ledger:query:*"], write: ["ledger:write:*"] },
94
+ };
95
+ for (const grant of patGrantOptions(scopes)) {
96
+ const parsed = parseGrant(grant);
97
+ expect(parsed).not.toBeNull();
98
+ expect(scopes[parsed?.domain ?? ""]).toBeDefined();
99
+ expect(expandScopes(scopes, [grant]).length).toBeGreaterThan(0);
100
+ }
101
+ // A read-level grant must not leak the domain's write QNs.
102
+ expect(expandScopes(scopes, ["ledger:read"])).toEqual(["ledger:query:*"]);
103
+ expect(expandScopes(scopes, ["ledger:write"])).toEqual(
104
+ expect.arrayContaining(["ledger:query:*", "ledger:write:*"]),
105
+ );
106
+ });
107
+ });
108
+
109
+ describe("patScopeOptionTranslations (fw#2548 Teil B)", () => {
110
+ test("option-label keys match the renderer's actual resolution convention (fieldOptionLabelKey)", () => {
111
+ const scopes: PatScopeConfig = {
112
+ billing: { label: "Billing", read: ["billing:query:*"], write: ["billing:write:*"] },
113
+ };
114
+ const translations = patScopeOptionTranslations(scopes);
115
+ // fieldOptionLabelKey is the exact function the mint form's multiSelect
116
+ // uses at render time (buildOptionLabels, headless/view-model/edit.ts) —
117
+ // building the same key here and looking it up in `translations` proves
118
+ // the label actually reaches the renderer instead of silently falling
119
+ // back to the raw grant string.
120
+ const readKey = fieldOptionLabelKey(
121
+ "personal-access-tokens",
122
+ "__action-form__",
123
+ "scopes",
124
+ "billing:read",
125
+ );
126
+ const writeKey = fieldOptionLabelKey(
127
+ "personal-access-tokens",
128
+ "__action-form__",
129
+ "scopes",
130
+ "billing:write",
131
+ );
132
+ expect(translations[readKey]?.en).toBe("Billing (read)");
133
+ expect(translations[writeKey]?.en).toBe("Billing (read & write)");
134
+ });
135
+ });