@cosmicdrift/kumiko-bundled-features 0.269.2 → 0.273.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 (116) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/account-security-composition.boot.test.ts +25 -7
  3. package/src/__tests__/account-security-composition.integration.test.ts +23 -1
  4. package/src/__tests__/account-security-fixture.ts +3 -0
  5. package/src/agent-tools/__tests__/agent-doc-lint.test.ts +3 -1
  6. package/src/agent-tools/__tests__/feature.test.ts +10 -4
  7. package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +96 -0
  8. package/src/audit/changes.json +12 -0
  9. package/src/audit/escape-hatch-audit-sink.ts +52 -0
  10. package/src/audit/feature.ts +5 -1
  11. package/src/audit/index.ts +1 -0
  12. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +303 -0
  13. package/src/auth-email-password/handlers/invite-accept.write.ts +8 -1
  14. package/src/auth-mfa/handlers/disable.write.ts +7 -1
  15. package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -1
  16. package/src/auth-mfa/handlers/enable-start.write.ts +7 -1
  17. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +7 -1
  18. package/src/auth-mfa/handlers/status.query.ts +7 -1
  19. package/src/auth-mfa/screens.ts +26 -3
  20. package/src/cap-counter/feature.ts +5 -3
  21. package/src/cap-overview/screens.ts +6 -0
  22. package/src/channel-in-app/handlers/inbox.query.ts +7 -1
  23. package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -1
  24. package/src/channel-in-app/handlers/mark-read.write.ts +7 -1
  25. package/src/channel-in-app/handlers/unread-count.query.ts +7 -1
  26. package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -1
  27. package/src/compliance-profiles/handlers/list-profiles.query.ts +7 -1
  28. package/src/config/__tests__/config.integration.test.ts +1 -1
  29. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
  30. package/src/config/__tests__/tz-resolution.integration.test.ts +1 -1
  31. package/src/config/changes.json +6 -0
  32. package/src/config/db/queries/__tests__/resolver.test.ts +43 -38
  33. package/src/config/db/queries/resolver.ts +22 -22
  34. package/src/config/handlers/cascade.query.ts +13 -1
  35. package/src/config/handlers/readiness.query.ts +13 -1
  36. package/src/config/handlers/reencrypt.job.ts +21 -2
  37. package/src/config/handlers/reset.write.ts +7 -1
  38. package/src/config/handlers/schema.query.ts +13 -1
  39. package/src/config/handlers/set.write.ts +7 -1
  40. package/src/config/handlers/values.query.ts +13 -1
  41. package/src/data-retention/handlers/policy-for.query.ts +8 -1
  42. package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +2 -2
  43. package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -4
  44. package/src/delivery/__tests__/delivery.integration.test.ts +39 -5
  45. package/src/delivery/changes.json +6 -0
  46. package/src/delivery/delivery-service.ts +25 -3
  47. package/src/delivery/handlers/preferences.query.ts +13 -1
  48. package/src/delivery/handlers/set-preference.write.ts +7 -1
  49. package/src/delivery/testing.ts +8 -1
  50. package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +1 -1
  51. package/src/folders/__tests__/feature.test.ts +14 -5
  52. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +6 -1
  53. package/src/folders/__tests__/folders-read-gate.integration.test.ts +6 -1
  54. package/src/folders/__tests__/folders.integration.test.ts +6 -1
  55. package/src/folders/feature.ts +2 -2
  56. package/src/form-draft/constants.ts +7 -1
  57. package/src/jobs/handlers/list.query.ts +3 -1
  58. package/src/ledger/__tests__/ledger.integration.test.ts +57 -0
  59. package/src/managed-pages/screens/branding-screen.ts +3 -0
  60. package/src/managed-pages/screens/page-screens.ts +3 -0
  61. package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +10 -2
  62. package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
  63. package/src/notes-history/feature.ts +2 -2
  64. package/src/notes-history/web/i18n.ts +3 -0
  65. package/src/notes-history/web/notes-section.tsx +61 -51
  66. package/src/personal-access-tokens/handlers/available-scopes.query.ts +7 -1
  67. package/src/personal-access-tokens/handlers/create.write.ts +7 -1
  68. package/src/personal-access-tokens/handlers/list.query.ts +7 -1
  69. package/src/personal-access-tokens/handlers/revoke.write.ts +7 -1
  70. package/src/personal-access-tokens/screens.ts +15 -2
  71. package/src/secrets/__tests__/access-options-precedence.test.ts +11 -4
  72. package/src/secrets/__tests__/access-options.integration.test.ts +5 -1
  73. package/src/secrets/feature.ts +2 -2
  74. package/src/sessions/handlers/mine.query.ts +7 -1
  75. package/src/sessions/handlers/revoke-all-others.write.ts +7 -1
  76. package/src/sessions/handlers/revoke.write.ts +7 -1
  77. package/src/sessions/screens.ts +9 -1
  78. package/src/tags/__tests__/feature.test.ts +25 -8
  79. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +6 -1
  80. package/src/tags/__tests__/tags-read-gate.integration.test.ts +7 -2
  81. package/src/tags/__tests__/tags.integration.test.ts +6 -1
  82. package/src/tags/constants.ts +1 -1
  83. package/src/tags/feature.ts +2 -2
  84. package/src/tags/screens.ts +3 -0
  85. package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
  86. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +2 -2
  87. package/src/tenant/__tests__/tenant.integration.test.ts +5 -1
  88. package/src/tenant/changes.json +6 -0
  89. package/src/tenant/handlers/me.query.ts +13 -1
  90. package/src/tenant/handlers/update-member-roles.write.ts +10 -1
  91. package/src/tenant/screens.ts +3 -0
  92. package/src/tenant/seeding.ts +25 -19
  93. package/src/tier-engine/feature.ts +3 -0
  94. package/src/user/changes.json +6 -0
  95. package/src/user/handlers/me.query.ts +13 -1
  96. package/src/user/handlers/update-roles.ts +8 -5
  97. package/src/user/screens.ts +3 -0
  98. package/src/user-data-rights/__tests__/boot-checks.test.ts +16 -0
  99. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +1 -1
  100. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +6 -1
  101. package/src/user-data-rights/feature.ts +11 -6
  102. package/src/user-data-rights/handlers/cancel-deletion.write.ts +7 -1
  103. package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
  104. package/src/user-data-rights/handlers/download-by-job.query.ts +7 -1
  105. package/src/user-data-rights/handlers/export-job-detail.query.ts +4 -1
  106. package/src/user-data-rights/handlers/export-job-list.query.ts +4 -1
  107. package/src/user-data-rights/handlers/export-status.query.ts +7 -1
  108. package/src/user-data-rights/handlers/my-audit-log.query.ts +8 -1
  109. package/src/user-data-rights/handlers/request-deletion.write.ts +7 -1
  110. package/src/user-data-rights/handlers/request-export.write.ts +7 -1
  111. package/src/user-data-rights/handlers/restrict-account.write.ts +8 -1
  112. package/src/user-data-rights/lib/is-admin-actor.ts +1 -1
  113. package/src/user-data-rights/screens.ts +3 -0
  114. package/src/user-profile/__tests__/profile-screen.boot.test.ts +6 -1
  115. package/src/user-profile/feature.ts +11 -1
  116. package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +1 -1
@@ -1,6 +1,11 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type {
4
+ EscapeHatchAuditSink,
5
+ Registry,
6
+ TenantId,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
4
9
  import type { Redis } from "ioredis";
5
10
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service";
6
11
  import { collectChannels, createDeliveryService } from "./delivery-service";
@@ -10,6 +15,8 @@ export type CreateDeliveryTestContextOptions = {
10
15
  readonly tenantUserIdsQuery?: string;
11
16
  readonly rateLimit?: RateLimitConfig;
12
17
  readonly isChannelKilled?: KillSwitchResolver;
18
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
19
+ readonly log?: Logger;
13
20
  };
14
21
 
15
22
  /**
@@ -46,7 +46,7 @@ const variantTestFeature = defineFeature("derivativessharptest", (r) => {
46
46
  const meta = await imageMetadata(bytes);
47
47
  return { isSuccess: true as const, data: { ...result, ...meta } };
48
48
  },
49
- { access: { openToAll: true } },
49
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
50
50
  );
51
51
  });
52
52
 
@@ -93,7 +93,9 @@ describe("createFoldersFeature access-options", () => {
93
93
  });
94
94
 
95
95
  test("access:{openToAll} applies to every write- and query-path", () => {
96
- const feature = createFoldersFeature({ access: { openToAll: true } });
96
+ const feature = createFoldersFeature({
97
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
98
+ });
97
99
  for (const path of [
98
100
  "folder:create",
99
101
  "folder:update",
@@ -101,13 +103,20 @@ describe("createFoldersFeature access-options", () => {
101
103
  "set-folder",
102
104
  "clear-folder",
103
105
  ]) {
104
- expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
106
+ expect(rawWriteAccess(feature, path)).toEqual({
107
+ openToAll: { reason: "test handler callable by any signed-in test user" },
108
+ });
105
109
  }
106
110
  });
107
111
 
108
112
  test("access takes precedence over the roles shorthand", () => {
109
- const feature = createFoldersFeature({ access: { openToAll: true }, roles: ["Admin"] });
110
- expect(rawWriteAccess(feature, "set-folder")).toEqual({ openToAll: true });
113
+ const feature = createFoldersFeature({
114
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
115
+ roles: ["Admin"],
116
+ });
117
+ expect(rawWriteAccess(feature, "set-folder")).toEqual({
118
+ openToAll: { reason: "test handler callable by any signed-in test user" },
119
+ });
111
120
  });
112
121
  });
113
122
 
@@ -118,7 +127,7 @@ describe("createFoldersFeature toggleable-option (tier-gating)", () => {
118
127
 
119
128
  test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
120
129
  const feature = createFoldersFeature({
121
- access: { openToAll: true },
130
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
122
131
  toggleable: { default: false },
123
132
  });
124
133
  expect(feature.toggleableDefault).toBe(false);
@@ -93,7 +93,12 @@ let folderId: string;
93
93
 
94
94
  beforeAll(async () => {
95
95
  stack = await setupTestStack({
96
- features: [createFoldersFeature({ access: { openToAll: true } }), hostFixturesFeature],
96
+ features: [
97
+ createFoldersFeature({
98
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
99
+ }),
100
+ hostFixturesFeature,
101
+ ],
97
102
  });
98
103
  await unsafeCreateEntityTable(stack.db, folderEntity);
99
104
  await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
@@ -79,7 +79,12 @@ let folderId: string;
79
79
 
80
80
  beforeAll(async () => {
81
81
  stack = await setupTestStack({
82
- features: [createFoldersFeature({ access: { openToAll: true } }), hostFixturesFeature],
82
+ features: [
83
+ createFoldersFeature({
84
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
85
+ }),
86
+ hostFixturesFeature,
87
+ ],
83
88
  });
84
89
  await unsafeCreateEntityTable(stack.db, folderEntity);
85
90
  await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
@@ -336,7 +336,12 @@ describe("folders integration — openToAll access model", () => {
336
336
 
337
337
  beforeAll(async () => {
338
338
  openStack = await setupTestStack({
339
- features: [createFoldersFeature({ access: { openToAll: true } }), hostFixturesFeature],
339
+ features: [
340
+ createFoldersFeature({
341
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
342
+ }),
343
+ hostFixturesFeature,
344
+ ],
340
345
  });
341
346
  await unsafeCreateEntityTable(openStack.db, folderEntity);
342
347
  await unsafeCreateEntityTable(openStack.db, folderAssignmentEntity);
@@ -47,7 +47,7 @@ function registerFolders(
47
47
  parents: readonly string[] | undefined,
48
48
  ): void {
49
49
  r.describe(
50
- "Generic, host-agnostic hierarchical folders for any entity. Owns two event-sourced entities — the per-tenant `folder` tree (`read_folders`, self-referential via parentId) and SINGLE-membership `folder-assignment` rows keyed by (entityType, entityId) (`read_folder_assignments`) — so filing an entity adds NO column to the host and needs no relational pivot or JOIN. The folder catalog uses the generic entity handlers (create, update [= rename, optimistic-locked], delete, list, detail); set-folder puts/moves an entity into a folder (one folder per entity) and clear-folder unfiles it (both idempotent). Read which folder an entity is in, or which entities a folder holds, by listing `folder-assignment` filtered on `entityId` or `folderId`. Every path uses one access rule — adopt the host's model with createFoldersFeature({ access: { openToAll: true } }) or pin roles. Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
50
+ "Generic, host-agnostic hierarchical folders for any entity. Owns two event-sourced entities — the per-tenant `folder` tree (`read_folders`, self-referential via parentId) and SINGLE-membership `folder-assignment` rows keyed by (entityType, entityId) (`read_folder_assignments`) — so filing an entity adds NO column to the host and needs no relational pivot or JOIN. The folder catalog uses the generic entity handlers (create, update [= rename, optimistic-locked], delete, list, detail); set-folder puts/moves an entity into a folder (one folder per entity) and clear-folder unfiles it (both idempotent). Read which folder an entity is in, or which entities a folder holds, by listing `folder-assignment` filtered on `entityId` or `folderId`. Every path uses one access rule — adopt the host's model with createFoldersFeature({ access: { openToAll: { reason } } }) or pin roles. Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
51
51
  );
52
52
  r.uiHints({
53
53
  displayLabel: "Folders",
@@ -113,7 +113,7 @@ export const foldersFeature = defineFeature(FOLDERS_FEATURE_NAME, (r) =>
113
113
 
114
114
  export type FoldersFeatureOptions = {
115
115
  /** Access rule for all folder write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
116
- * Adopt the host's model — e.g. { openToAll: true } when any authenticated
116
+ * Adopt the host's model — e.g. { openToAll: { reason: "..." } } when any authenticated
117
117
  * tenant user may file entities, or { roles: ["Admin"] } for a custom role
118
118
  * vocabulary. Takes precedence over `roles`. */
119
119
  readonly access?: AccessRule;
@@ -22,7 +22,13 @@ export const FormDraftQueries = {
22
22
  // Any authenticated tenant user may save/discard/read their OWN draft — the
23
23
  // per-row ownerId check in the handlers (not roles) is what keeps a draft
24
24
  // private to the user who created it. See handlers/*.ts.
25
- export const FORM_DRAFT_ACCESS: AccessRule = { openToAll: true };
25
+ export const FORM_DRAFT_ACCESS: AccessRule = {
26
+ openToAll: {
27
+ reason:
28
+ "any signed-in tenant user may save, discard or read a draft; the per-row " +
29
+ "ownerId check in the handlers, not roles, keeps a draft private to its owner",
30
+ },
31
+ };
26
32
 
27
33
  // Unique index name — shared between entity.ts (index declaration) and
28
34
  // handlers/save.write.ts (unique-violation race detection).
@@ -43,7 +43,9 @@ export const listQuery = defineQueryHandler({
43
43
  message: "jobs:query:list requires ctx.systemDb (feature must declare r.systemScope())",
44
44
  });
45
45
  }
46
- const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
46
+ const db = ctx.systemDb.unsafeRaw(
47
+ "cross-tenant job monitoring: SystemAdmin lists job runs of every tenant",
48
+ );
47
49
  const where: WhereObject = {};
48
50
  if (query.payload.jobName) where["jobName"] = query.payload.jobName;
49
51
  const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
13
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
14
15
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
16
  import {
16
17
  createTestUser,
@@ -18,6 +19,7 @@ import {
18
19
  type TestStack,
19
20
  unsafeCreateEntityTable,
20
21
  } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
21
23
  import { type AccountType, LedgerHandlers, LedgerQueries } from "../constants";
22
24
  import { accountEntity, scheduleEntity, transactionEntity } from "../entity";
23
25
  import { createLedgerFeature } from "../feature";
@@ -544,6 +546,61 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
544
546
  });
545
547
  });
546
548
 
549
+ describe("ledger integration — createSchedule with a caller-chosen id (idempotent job creates)", () => {
550
+ // An event-triggered job derives the schedule id deterministically from
551
+ // its own contract line instead of matching on description text. It acts
552
+ // as createSystemUser(tenantId, extraRoles) — extraRoles clears the
553
+ // handler's normal TenantAdmin/TenantMember access gate while roles still
554
+ // carrying "system" is what lets the create handler honor the id at all.
555
+ const jobUser = createSystemUser(admin.tenantId, ["TenantAdmin"]);
556
+
557
+ test("the chosen id is honored; redelivering the same event is recognized, not duplicated", async () => {
558
+ const bank = await createAccount("Bank", "asset");
559
+ const rent = await createAccount("Mieterträge", "income");
560
+ const scheduleId = generateDeterministicId(`ledger:schedule:${admin.tenantId}`, "contract-42");
561
+ const payload = {
562
+ description: "Miete WE1",
563
+ startDate: "2026-01-01",
564
+ interval: "monthly",
565
+ amount: 50000,
566
+ debitAccountId: bank,
567
+ creditAccountId: rent,
568
+ id: scheduleId,
569
+ };
570
+
571
+ const created = await stack.http.writeOk<{ id: string }>(
572
+ LedgerHandlers.createSchedule,
573
+ payload,
574
+ jobUser,
575
+ );
576
+ expect(created.id).toBe(scheduleId);
577
+
578
+ const redelivered = await stack.http.writeErr(LedgerHandlers.createSchedule, payload, jobUser);
579
+ expect(redelivered.code).toBe("version_conflict");
580
+ });
581
+
582
+ test("an ordinary tenant user's chosen id is ignored — create still succeeds with a fresh id", async () => {
583
+ const bank = await createAccount("Bank", "asset");
584
+ const rent = await createAccount("Mieterträge", "income");
585
+ const chosenId = generateDeterministicId(`ledger:schedule:${admin.tenantId}`, "contract-99");
586
+
587
+ const created = await stack.http.writeOk<{ id: string }>(
588
+ LedgerHandlers.createSchedule,
589
+ {
590
+ description: "Miete WE2",
591
+ startDate: "2026-01-01",
592
+ interval: "monthly",
593
+ amount: 30000,
594
+ debitAccountId: bank,
595
+ creditAccountId: rent,
596
+ id: chosenId,
597
+ },
598
+ admin,
599
+ );
600
+ expect(created.id).not.toBe(chosenId);
601
+ });
602
+ });
603
+
547
604
  describe("ledger integration — subject dimension (filterable business-object reference)", () => {
548
605
  test("a booking with subjectType/subjectId round-trips and is findable via an eq filter", async () => {
549
606
  const bank = await createAccount("Bank", "asset");
@@ -81,6 +81,9 @@ export function createBrandingSettingsScreen(opts: {
81
81
  ],
82
82
  },
83
83
  access: { roles: ADMIN_ROLES },
84
+ // See file header: nav placement is app-specific, so this opts out of
85
+ // the boot-validator's nav-area check.
86
+ dormant: true,
84
87
  };
85
88
  if (!opts.allowCustomCss) return base;
86
89
  return {
@@ -52,6 +52,9 @@ export const pageListScreen: EntityListScreenDefinition = {
52
52
  },
53
53
  ],
54
54
  access: { roles: ADMIN_ROLES },
55
+ // Nav/placement is app-specific (see file header) — no nav area to
56
+ // resolve in isolation.
57
+ dormant: true,
55
58
  };
56
59
 
57
60
  export const pageEditScreen: EntityEditScreenDefinition = {
@@ -81,7 +81,12 @@ let stack: TestStack;
81
81
  beforeAll(async () => {
82
82
  stack = await setupTestStack({
83
83
  // Deliberately no `ownership` — the gate must hold on a bare mount.
84
- features: [createNotesHistoryFeature({ access: { openToAll: true } }), hostFixturesFeature],
84
+ features: [
85
+ createNotesHistoryFeature({
86
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
87
+ }),
88
+ hostFixturesFeature,
89
+ ],
85
90
  });
86
91
  await unsafeCreateEntityTable(stack.db, noteEntryEntity);
87
92
  await unsafeCreateEntityTable(stack.db, noteMentionEntity);
@@ -192,7 +197,10 @@ describe("notes-history read-gate — parents allowlist narrows both paths", ()
192
197
  beforeAll(async () => {
193
198
  allowStack = await setupTestStack({
194
199
  features: [
195
- createNotesHistoryFeature({ access: { openToAll: true }, parents: ["project"] }),
200
+ createNotesHistoryFeature({
201
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
202
+ parents: ["project"],
203
+ }),
196
204
  fixtures,
197
205
  ],
198
206
  });
@@ -127,6 +127,29 @@ function renderSection(): ReturnType<typeof render> {
127
127
  return render(<NotesSection entityName="contact" entityId="contact-1" />, { wrapper });
128
128
  }
129
129
 
130
+ describe("NotesSection — two-card layout", () => {
131
+ test("renders a 'new note' card and a 'history' card as two separate cards", async () => {
132
+ const view = renderSection();
133
+ await waitFor(() => expect(view.getByTestId("notes-section-history")).toBeTruthy());
134
+
135
+ const newNoteCard = view.getByTestId("notes-section-new");
136
+ const historyCard = view.getByTestId("notes-section-history");
137
+ expect(newNoteCard).not.toBe(historyCard);
138
+ expect(newNoteCard.textContent).toContain("New note");
139
+ expect(historyCard.textContent).toContain("History");
140
+ });
141
+
142
+ test("history entries are separated by dividers, not individual borders", async () => {
143
+ const view = renderSection();
144
+ await waitFor(() => expect(view.getByTestId("notes-section-row-note-1")).toBeTruthy());
145
+
146
+ const row = view.getByTestId("notes-section-row-note-1");
147
+ expect(row.className).not.toContain("border");
148
+ const historyCard = view.getByTestId("notes-section-history");
149
+ expect(historyCard.querySelector(".divide-y")).not.toBeNull();
150
+ });
151
+ });
152
+
130
153
  describe("NotesSection — entry display", () => {
131
154
  test("note body and meta line render as two distinct nodes, not one run-on line", async () => {
132
155
  const view = renderSection();
@@ -33,7 +33,7 @@ function registerNotesHistory(
33
33
  parents: readonly string[] | undefined,
34
34
  ): void {
35
35
  r.describe(
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 }).",
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: { reason } } }) or pin roles with createNotesHistoryFeature({ roles }).",
37
37
  );
38
38
  r.uiHints({
39
39
  displayLabel: "Notes",
@@ -68,7 +68,7 @@ export const notesHistoryFeature = defineFeature(NOTES_HISTORY_FEATURE_NAME, (r)
68
68
 
69
69
  export type NotesHistoryFeatureOptions = {
70
70
  /** Access rule for the create/list paths. Default { roles: ["TenantAdmin","TenantMember"] }.
71
- * Adopt the host's model — e.g. { openToAll: true } when the host lets any
71
+ * Adopt the host's model — e.g. { openToAll: { reason: "..." } } when the host lets any
72
72
  * authenticated tenant user write (like the rest of its handlers), or
73
73
  * { roles: ["Admin"] } for a custom role vocabulary. Takes precedence over `roles`. */
74
74
  readonly access?: AccessRule;
@@ -15,5 +15,8 @@ export const defaultTranslations: TranslationsByLocale = {
15
15
  "notesHistory.section.add": "Add note",
16
16
  "notesHistory.section.meta": "{author} · {date}",
17
17
  "notesHistory.section.authorUnknown": "Unknown author",
18
+ "notesHistory.section.newNoteTitle": "New note",
19
+ "notesHistory.section.historyTitle": "History",
20
+ "notesHistory.section.shortcutHint": "Ctrl+Enter saves",
18
21
  },
19
22
  };
@@ -48,7 +48,7 @@ export function NotesSection({
48
48
  readonly entityName: string;
49
49
  readonly entityId: string | null;
50
50
  }): ReactNode {
51
- const { Banner, Button, Text, Input } = usePrimitives();
51
+ const { Banner, Button, Card, Grid, Text, Input } = usePrimitives();
52
52
  const t = useTranslation();
53
53
  const dispatcher = useDispatcher();
54
54
  const enabled = entityId !== null;
@@ -118,57 +118,67 @@ export function NotesSection({
118
118
  };
119
119
 
120
120
  return (
121
- <div data-testid="notes-section" className="flex flex-col gap-3">
122
- <div className="flex flex-col gap-2">
123
- <Input
124
- kind="textarea"
125
- id="notes-section-draft"
126
- name="draft"
127
- value={draft}
128
- onChange={setDraft}
129
- onSubmitShortcut={() => {
130
- if (!busy && draft.trim() !== "") addNote();
131
- }}
132
- />
133
- <div>
134
- <Button
135
- variant="primary"
136
- disabled={busy || draft.trim() === ""}
137
- onClick={() => addNote()}
138
- testId="notes-section-add"
139
- >
140
- {busy ? t("notesHistory.section.working") : t("notesHistory.section.add")}
141
- </Button>
142
- </div>
143
- </div>
144
-
145
- {rows.length === 0 ? (
146
- <Text variant="small">{t("notesHistory.section.empty")}</Text>
147
- ) : (
148
- <div className="flex flex-col gap-2">
149
- {rows.map((n) => (
150
- <div
151
- key={n.id}
152
- data-testid={`notes-section-row-${n.id}`}
153
- className="flex flex-col gap-1 rounded-md border p-2"
121
+ <Grid columns={1} testId="notes-section">
122
+ <Card slots={{ title: t("notesHistory.section.newNoteTitle") }} testId="notes-section-new">
123
+ <Grid columns={1}>
124
+ <Input
125
+ kind="textarea"
126
+ id="notes-section-draft"
127
+ name="draft"
128
+ value={draft}
129
+ onChange={setDraft}
130
+ onSubmitShortcut={() => {
131
+ if (!busy && draft.trim() !== "") addNote();
132
+ }}
133
+ />
134
+ <div className="flex items-center justify-between gap-2">
135
+ <Text variant="small">{t("notesHistory.section.shortcutHint")}</Text>
136
+ <Button
137
+ variant="primary"
138
+ disabled={busy || draft.trim() === ""}
139
+ onClick={() => addNote()}
140
+ testId="notes-section-add"
154
141
  >
155
- <Text testId={`notes-section-body-${n.id}`}>{n.body}</Text>
156
- <Text variant="small" testId={`notes-section-meta-${n.id}`}>
157
- {t("notesHistory.section.meta", {
158
- author: displayAuthorName(n.authorName, t("notesHistory.section.authorUnknown")),
159
- date: formatWhen(n.insertedAt),
160
- })}
161
- </Text>
162
- </div>
163
- ))}
164
- </div>
165
- )}
142
+ {busy ? t("notesHistory.section.working") : t("notesHistory.section.add")}
143
+ </Button>
144
+ </div>
145
+ {errorKey !== null && (
146
+ <Banner variant="error" testId="notes-section-action-error">
147
+ <Text>{t(errorKey)}</Text>
148
+ </Banner>
149
+ )}
150
+ </Grid>
151
+ </Card>
166
152
 
167
- {errorKey !== null && (
168
- <Banner variant="error" testId="notes-section-action-error">
169
- <Text>{t(errorKey)}</Text>
170
- </Banner>
171
- )}
172
- </div>
153
+ <Card
154
+ slots={{ title: t("notesHistory.section.historyTitle") }}
155
+ testId="notes-section-history"
156
+ >
157
+ {rows.length === 0 ? (
158
+ <Text variant="small">{t("notesHistory.section.empty")}</Text>
159
+ ) : (
160
+ <div className="flex flex-col divide-y">
161
+ {rows.map((n) => (
162
+ <div
163
+ key={n.id}
164
+ data-testid={`notes-section-row-${n.id}`}
165
+ className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0"
166
+ >
167
+ <Text testId={`notes-section-body-${n.id}`}>{n.body}</Text>
168
+ <Text variant="small" testId={`notes-section-meta-${n.id}`}>
169
+ {t("notesHistory.section.meta", {
170
+ author: displayAuthorName(
171
+ n.authorName,
172
+ t("notesHistory.section.authorUnknown"),
173
+ ),
174
+ date: formatWhen(n.insertedAt),
175
+ })}
176
+ </Text>
177
+ </div>
178
+ ))}
179
+ </div>
180
+ )}
181
+ </Card>
182
+ </Grid>
173
183
  );
174
184
  }
@@ -10,7 +10,13 @@ export function buildAvailableScopesQuery(scopes: PatScopeConfig) {
10
10
  return defineQueryHandler({
11
11
  name: "available-scopes",
12
12
  schema: z.object({}),
13
- access: { openToAll: true },
13
+ access: {
14
+ openToAll: {
15
+ reason:
16
+ "any signed-in user may list the deployment-wide PAT scope domains; the data " +
17
+ "is static configuration, not per-user or per-tenant",
18
+ },
19
+ },
14
20
  description:
15
21
  "Lists the API scope domains this deployment declares, each with its display label and whether it offers write access, for choosing what a new personal access token may do.",
16
22
  handler: async () =>
@@ -57,7 +57,13 @@ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
57
57
  currentPassword: z.string().min(1),
58
58
  mfaCode: z.string().optional(),
59
59
  }),
60
- access: { openToAll: true },
60
+ access: {
61
+ openToAll: {
62
+ reason:
63
+ "each signed-in user may mint a personal access token for their own account " +
64
+ "only, after re-verifying their own password (and MFA code, if enrolled)",
65
+ },
66
+ },
61
67
  escapeHatch: {
62
68
  reason:
63
69
  "Reads the caller's own passwordHash (privileged-only field) via ctx.queryAs(SYSTEM, " +
@@ -46,7 +46,13 @@ export const listPatQuery = definePagedQueryHandler({
46
46
  sort: z.string().optional(),
47
47
  sortDirection: z.enum(["asc", "desc"]).optional(),
48
48
  }),
49
- access: { openToAll: true },
49
+ access: {
50
+ openToAll: {
51
+ reason:
52
+ "each signed-in user lists only their own personal access tokens; the query " +
53
+ "filters apiTokenTable by the caller's own userId",
54
+ },
55
+ },
50
56
  description:
51
57
  "Lists the calling user's personal access tokens with metadata only (name, key prefix, scopes, computed status, created/expiry/revoked timestamps, including revoked ones) and never the token secret.",
52
58
  outputSchema: z.object({
@@ -13,7 +13,13 @@ import { apiTokenTable } from "../schema/api-token";
13
13
  export const revokePatWrite = defineWriteHandler({
14
14
  name: "revoke",
15
15
  schema: z.object({ id: z.uuid() }),
16
- access: { openToAll: true },
16
+ access: {
17
+ openToAll: {
18
+ reason:
19
+ "each signed-in user revokes only their own personal access token; ownership " +
20
+ "is enforced in the WHERE (userId = caller)",
21
+ },
22
+ },
17
23
  description:
18
24
  "Permanently revokes one of the caller's own personal access tokens so it stops authenticating; use it when a token leaked or is no longer needed.",
19
25
  agent: { risk: "high" },
@@ -47,7 +47,15 @@ export const patListScreen: ProjectionListScreenDefinition = {
47
47
  style: "primary",
48
48
  },
49
49
  ],
50
- access: { openToAll: true },
50
+ access: {
51
+ openToAll: {
52
+ reason:
53
+ "each signed-in user views only their own personal access tokens, mirroring the list handler",
54
+ },
55
+ },
56
+ // Self-service settings-area screen, placed by the consuming app's own
57
+ // r.nav() — no nav area to resolve in isolation.
58
+ dormant: true,
51
59
  };
52
60
 
53
61
  // Grant-string vocabulary for a scope config's multiSelect field — a
@@ -102,6 +110,11 @@ export function createPatMintScreen(scopes: PatScopeConfig): SecretMintScreenDef
102
110
  },
103
111
  redirect: PAT_SCREEN_ID,
104
112
  cancelTarget: PAT_SCREEN_ID,
105
- access: { openToAll: true },
113
+ access: {
114
+ openToAll: {
115
+ reason:
116
+ "each signed-in user mints a token for their own account only, mirroring the create handler",
117
+ },
118
+ },
106
119
  };
107
120
  }
@@ -7,12 +7,19 @@ describe("createSecretsFeature access/roles precedence", () => {
7
7
  });
8
8
 
9
9
  test("access-only is accepted", () => {
10
- expect(() => createSecretsFeature({ access: { openToAll: true } })).not.toThrow();
10
+ expect(() =>
11
+ createSecretsFeature({
12
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
13
+ }),
14
+ ).not.toThrow();
11
15
  });
12
16
 
13
17
  test("access + roles together fail fast", () => {
14
- expect(() => createSecretsFeature({ access: { openToAll: true }, roles: ["Admin"] })).toThrow(
15
- /either `access` or `roles`/,
16
- );
18
+ expect(() =>
19
+ createSecretsFeature({
20
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
21
+ roles: ["Admin"],
22
+ }),
23
+ ).toThrow(/either `access` or `roles`/);
17
24
  });
18
25
  });
@@ -100,7 +100,11 @@ describe("secrets — access: { openToAll: true } (#2296)", () => {
100
100
  let stack: TestStack;
101
101
 
102
102
  beforeAll(async () => {
103
- stack = await buildStack(createSecretsFeature({ access: { openToAll: true } }));
103
+ stack = await buildStack(
104
+ createSecretsFeature({
105
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
106
+ }),
107
+ );
104
108
  });
105
109
 
106
110
  afterAll(async () => {
@@ -93,7 +93,7 @@ export function requireSecretsContext(
93
93
  export type SecretsFeatureOptions = {
94
94
  /** Access rule for the `set`, `delete` and `list` handlers. Default
95
95
  * { roles: ["TenantAdmin"] }. Adopt the host's role vocabulary with
96
- * { roles: [...] }, or { openToAll: true } to let any authenticated tenant
96
+ * { roles: [...] }, or { openToAll: { reason: "..." } } to let any authenticated tenant
97
97
  * user manage secrets — note this is a much larger blast radius than the
98
98
  * default: tenant credentials (API keys, tokens) become writable/deletable,
99
99
  * and their previews/hints listable, by every tenant member, not just
@@ -121,7 +121,7 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
121
121
  const access = resolveSecretsAccess(opts);
122
122
  return defineFeature(SECRETS_FEATURE_NAME, (r) => {
123
123
  r.describe(
124
- 'Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host\'s role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: true } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).',
124
+ 'Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host\'s role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).',
125
125
  );
126
126
  r.uiHints({
127
127
  displayLabel: "Tenant Secrets",