@cosmicdrift/kumiko-bundled-features 0.269.2 → 0.270.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 (76) 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/agent-tools/__tests__/agent-doc-lint.test.ts +3 -1
  5. package/src/agent-tools/__tests__/feature.test.ts +10 -4
  6. package/src/auth-email-password/handlers/invite-accept.write.ts +8 -1
  7. package/src/auth-mfa/handlers/disable.write.ts +7 -1
  8. package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -1
  9. package/src/auth-mfa/handlers/enable-start.write.ts +7 -1
  10. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +7 -1
  11. package/src/auth-mfa/handlers/status.query.ts +7 -1
  12. package/src/auth-mfa/screens.ts +22 -3
  13. package/src/channel-in-app/handlers/inbox.query.ts +7 -1
  14. package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -1
  15. package/src/channel-in-app/handlers/mark-read.write.ts +7 -1
  16. package/src/channel-in-app/handlers/unread-count.query.ts +7 -1
  17. package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -1
  18. package/src/compliance-profiles/handlers/list-profiles.query.ts +7 -1
  19. package/src/config/__tests__/config.integration.test.ts +1 -1
  20. package/src/config/__tests__/tz-resolution.integration.test.ts +1 -1
  21. package/src/config/handlers/cascade.query.ts +7 -1
  22. package/src/config/handlers/readiness.query.ts +7 -1
  23. package/src/config/handlers/reset.write.ts +7 -1
  24. package/src/config/handlers/schema.query.ts +7 -1
  25. package/src/config/handlers/set.write.ts +7 -1
  26. package/src/config/handlers/values.query.ts +7 -1
  27. package/src/data-retention/handlers/policy-for.query.ts +8 -1
  28. package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +2 -2
  29. package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -4
  30. package/src/delivery/__tests__/delivery.integration.test.ts +5 -5
  31. package/src/delivery/handlers/preferences.query.ts +7 -1
  32. package/src/delivery/handlers/set-preference.write.ts +7 -1
  33. package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +1 -1
  34. package/src/folders/__tests__/feature.test.ts +14 -5
  35. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +6 -1
  36. package/src/folders/__tests__/folders-read-gate.integration.test.ts +6 -1
  37. package/src/folders/__tests__/folders.integration.test.ts +6 -1
  38. package/src/folders/feature.ts +2 -2
  39. package/src/form-draft/constants.ts +7 -1
  40. package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +10 -2
  41. package/src/notes-history/feature.ts +2 -2
  42. package/src/personal-access-tokens/handlers/available-scopes.query.ts +7 -1
  43. package/src/personal-access-tokens/handlers/create.write.ts +7 -1
  44. package/src/personal-access-tokens/handlers/list.query.ts +7 -1
  45. package/src/personal-access-tokens/handlers/revoke.write.ts +7 -1
  46. package/src/personal-access-tokens/screens.ts +15 -2
  47. package/src/secrets/__tests__/access-options-precedence.test.ts +11 -4
  48. package/src/secrets/__tests__/access-options.integration.test.ts +5 -1
  49. package/src/secrets/feature.ts +2 -2
  50. package/src/sessions/handlers/mine.query.ts +7 -1
  51. package/src/sessions/handlers/revoke-all-others.write.ts +7 -1
  52. package/src/sessions/handlers/revoke.write.ts +7 -1
  53. package/src/sessions/screens.ts +7 -1
  54. package/src/tags/__tests__/feature.test.ts +25 -8
  55. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +6 -1
  56. package/src/tags/__tests__/tags-read-gate.integration.test.ts +7 -2
  57. package/src/tags/__tests__/tags.integration.test.ts +6 -1
  58. package/src/tags/constants.ts +1 -1
  59. package/src/tags/feature.ts +2 -2
  60. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +2 -2
  61. package/src/tenant/__tests__/tenant.integration.test.ts +5 -1
  62. package/src/tenant/handlers/me.query.ts +7 -1
  63. package/src/user/handlers/me.query.ts +7 -1
  64. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +6 -1
  65. package/src/user-data-rights/feature.ts +8 -1
  66. package/src/user-data-rights/handlers/cancel-deletion.write.ts +7 -1
  67. package/src/user-data-rights/handlers/download-by-job.query.ts +7 -1
  68. package/src/user-data-rights/handlers/export-status.query.ts +7 -1
  69. package/src/user-data-rights/handlers/my-audit-log.query.ts +8 -1
  70. package/src/user-data-rights/handlers/request-deletion.write.ts +7 -1
  71. package/src/user-data-rights/handlers/request-export.write.ts +7 -1
  72. package/src/user-data-rights/handlers/restrict-account.write.ts +8 -1
  73. package/src/user-data-rights/lib/is-admin-actor.ts +1 -1
  74. package/src/user-profile/__tests__/profile-screen.boot.test.ts +6 -1
  75. package/src/user-profile/feature.ts +8 -1
  76. package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +1 -1
@@ -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).
@@ -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
  });
@@ -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;
@@ -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" },
@@ -6,6 +6,11 @@ import {
6
6
  import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants";
7
7
  import type { PatScopeConfig } from "./scopes";
8
8
 
9
+ const PAT_LIST_OPEN_REASON =
10
+ "each signed-in user views only their own personal access tokens, mirroring the list handler";
11
+ const PAT_MINT_OPEN_REASON =
12
+ "each signed-in user mints a token for their own account only, mirroring the create handler";
13
+
9
14
  export const patListScreen: ProjectionListScreenDefinition = {
10
15
  id: PAT_SCREEN_ID,
11
16
  type: "projectionList",
@@ -47,7 +52,11 @@ export const patListScreen: ProjectionListScreenDefinition = {
47
52
  style: "primary",
48
53
  },
49
54
  ],
50
- access: { openToAll: true },
55
+ access: {
56
+ openToAll: {
57
+ reason: PAT_LIST_OPEN_REASON,
58
+ },
59
+ },
51
60
  };
52
61
 
53
62
  // Grant-string vocabulary for a scope config's multiSelect field — a
@@ -102,6 +111,10 @@ export function createPatMintScreen(scopes: PatScopeConfig): SecretMintScreenDef
102
111
  },
103
112
  redirect: PAT_SCREEN_ID,
104
113
  cancelTarget: PAT_SCREEN_ID,
105
- access: { openToAll: true },
114
+ access: {
115
+ openToAll: {
116
+ reason: PAT_MINT_OPEN_REASON,
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",
@@ -12,7 +12,13 @@ import { userSessionTable } from "../schema/user-session";
12
12
  export const mineQuery = definePagedQueryHandler({
13
13
  name: "user-session:mine",
14
14
  schema: z.object({}),
15
- access: { openToAll: true },
15
+ access: {
16
+ openToAll: {
17
+ reason:
18
+ "each signed-in user lists only their own live sessions; the query filters " +
19
+ "userSessionTable by the caller's own userId",
20
+ },
21
+ },
16
22
  description:
17
23
  "Lists the calling user's own still-live sessions, newest first, each flagged whether it is the one making the request; use it to show a user their signed-in devices.",
18
24
  outputSchema: z.object({
@@ -15,7 +15,13 @@ import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN } from "../ses
15
15
  export const revokeAllOthersWrite = defineWriteHandler({
16
16
  name: "user-session:revoke-all-others",
17
17
  schema: z.object({}),
18
- access: { openToAll: true },
18
+ access: {
19
+ openToAll: {
20
+ reason:
21
+ "each signed-in user revokes only their own other sessions; the update is " +
22
+ "scoped to the caller's own userId, keeping only their current session (sid)",
23
+ },
24
+ },
19
25
  description:
20
26
  'Irreversibly signs the calling user out of every session except the one making the request and reports how many were dropped; use it for a "sign out everywhere else" action after a suspected compromise.',
21
27
  agent: { risk: "high" },
@@ -28,7 +28,13 @@ export const revokeWrite = defineWriteHandler({
28
28
  schema: z.object({
29
29
  id: z.uuid(),
30
30
  }),
31
- access: { openToAll: true },
31
+ access: {
32
+ openToAll: {
33
+ reason:
34
+ "each signed-in user revokes only their own session by id; the update is " +
35
+ "scoped to the caller's own userId",
36
+ },
37
+ },
32
38
  description:
33
39
  "Irreversibly signs one of the caller's own sessions out by id; use it when a user wants to drop a single device, and note that revoking their current session logs them out.",
34
40
  agent: { risk: "high" },
@@ -13,6 +13,8 @@ import {
13
13
  } from "./constants";
14
14
 
15
15
  const listAccess = { roles: access.admin };
16
+ const SESSION_MINE_OPEN_REASON =
17
+ "each signed-in user views and revokes only their own sessions, mirroring the mine/revoke handlers";
16
18
 
17
19
  export const sessionListScreen: ProjectionListScreenDefinition = {
18
20
  id: SESSION_LIST_SCREEN_ID,
@@ -140,5 +142,9 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
140
142
  confirm: i18nKey("sessions.mine.revokeAllOthers.confirm"),
141
143
  },
142
144
  ],
143
- access: { openToAll: true },
145
+ access: {
146
+ openToAll: {
147
+ reason: SESSION_MINE_OPEN_REASON,
148
+ },
149
+ },
144
150
  };
@@ -105,7 +105,9 @@ describe("createTagsFeature access-options", () => {
105
105
  });
106
106
 
107
107
  test("access:{openToAll} applies to every write- and query-path", () => {
108
- const feature = createTagsFeature({ access: { openToAll: true } });
108
+ const feature = createTagsFeature({
109
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
110
+ });
109
111
  for (const path of [
110
112
  "create-tag",
111
113
  "update-tag",
@@ -116,17 +118,28 @@ describe("createTagsFeature access-options", () => {
116
118
  "assign-tag",
117
119
  "remove-tag",
118
120
  ]) {
119
- expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
121
+ expect(rawWriteAccess(feature, path)).toEqual({
122
+ openToAll: { reason: "test handler callable by any signed-in test user" },
123
+ });
120
124
  }
121
125
  for (const query of ["tag:list", "tag:detail", "tag-assignment:list"]) {
122
- expect(rawQueryAccess(feature, query)).toEqual({ openToAll: true });
126
+ expect(rawQueryAccess(feature, query)).toEqual({
127
+ openToAll: { reason: "test handler callable by any signed-in test user" },
128
+ });
123
129
  }
124
130
  });
125
131
 
126
132
  test("access takes precedence over the roles shorthand", () => {
127
- const feature = createTagsFeature({ access: { openToAll: true }, roles: ["Admin"] });
128
- expect(rawWriteAccess(feature, "create-tag")).toEqual({ openToAll: true });
129
- expect(rawQueryAccess(feature, "tag:list")).toEqual({ openToAll: true });
133
+ const feature = createTagsFeature({
134
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
135
+ roles: ["Admin"],
136
+ });
137
+ expect(rawWriteAccess(feature, "create-tag")).toEqual({
138
+ openToAll: { reason: "test handler callable by any signed-in test user" },
139
+ });
140
+ expect(rawQueryAccess(feature, "tag:list")).toEqual({
141
+ openToAll: { reason: "test handler callable by any signed-in test user" },
142
+ });
130
143
  });
131
144
 
132
145
  test("access:{roles} threads through like the roles shorthand", () => {
@@ -139,12 +152,16 @@ describe("createTagsFeature access-options", () => {
139
152
  describe("createTagsFeature toggleable-option (tier-gating)", () => {
140
153
  test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
141
154
  expect(createTagsFeature().toggleableDefault).toBeUndefined();
142
- expect(createTagsFeature({ access: { openToAll: true } }).toggleableDefault).toBeUndefined();
155
+ expect(
156
+ createTagsFeature({
157
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
158
+ }).toggleableDefault,
159
+ ).toBeUndefined();
143
160
  });
144
161
 
145
162
  test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
146
163
  const feature = createTagsFeature({
147
- access: { openToAll: true },
164
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
148
165
  toggleable: { default: false },
149
166
  });
150
167
  expect(feature.toggleableDefault).toBe(false);
@@ -93,7 +93,12 @@ let tagId: string;
93
93
 
94
94
  beforeAll(async () => {
95
95
  stack = await setupTestStack({
96
- features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
96
+ features: [
97
+ createTagsFeature({
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, tagEntity);
99
104
  await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
@@ -99,7 +99,12 @@ let tagId: string;
99
99
  beforeAll(async () => {
100
100
  stack = await setupTestStack({
101
101
  // Deliberately no `ownership` — the gate must hold on a bare mount.
102
- features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
102
+ features: [
103
+ createTagsFeature({
104
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
105
+ }),
106
+ hostFixturesFeature,
107
+ ],
103
108
  });
104
109
  await unsafeCreateEntityTable(stack.db, tagEntity);
105
110
  await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
@@ -303,7 +308,7 @@ describe("tags read-gate — ownership narrows further, it does not replace the
303
308
  ownedStack = await setupTestStack({
304
309
  features: [
305
310
  createTagsFeature({
306
- access: { openToAll: true },
311
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
307
312
  // An extra row rule on the assignment row itself, unrelated to who
308
313
  // may see the host: the caller's claim must name the row's host type.
309
314
  ownership: {
@@ -457,7 +457,12 @@ describe("tags integration — openToAll access model", () => {
457
457
 
458
458
  beforeAll(async () => {
459
459
  openStack = await setupTestStack({
460
- features: [createTagsFeature({ access: { openToAll: true } }), hostFixturesFeature],
460
+ features: [
461
+ createTagsFeature({
462
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
463
+ }),
464
+ hostFixturesFeature,
465
+ ],
461
466
  });
462
467
  await unsafeCreateEntityTable(openStack.db, tagEntity);
463
468
  await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
@@ -55,7 +55,7 @@ export const TagsQueries = {
55
55
  export const DEFAULT_TAG_ROLES = ["TenantAdmin", "TenantMember"] as const;
56
56
 
57
57
  // The default access rule applied to every tag handler when the app passes
58
- // neither `access` nor `roles`. createTagsFeature({ access: { openToAll: true } })
58
+ // neither `access` nor `roles`. createTagsFeature({ access: { openToAll: { reason } } })
59
59
  // makes tagging reachable for any authenticated tenant user — matching apps
60
60
  // whose other handlers are openToAll rather than role-gated.
61
61
  export const DEFAULT_TAG_ACCESS: AccessRule = { roles: DEFAULT_TAG_ROLES };
@@ -54,7 +54,7 @@ function registerTags(
54
54
  parents: readonly string[] | undefined,
55
55
  ): void {
56
56
  r.describe(
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).",
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: { reason } } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
58
58
  );
59
59
  r.uiHints({
60
60
  displayLabel: "Tags",
@@ -134,7 +134,7 @@ export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
134
134
 
135
135
  export type TagsFeatureOptions = {
136
136
  /** Access rule for all tag write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
137
- * Adopt the host's model — e.g. { openToAll: true } when the host lets any
137
+ * Adopt the host's model — e.g. { openToAll: { reason: "..." } } when the host lets any
138
138
  * authenticated tenant user tag (like the rest of its handlers), or
139
139
  * { roles: ["Admin"] } for a custom role vocabulary. Takes precedence over `roles`. */
140
140
  readonly access?: AccessRule;
@@ -25,7 +25,7 @@ const probeFeature = defineFeature("tz-probe", (r) => {
25
25
  "read-tz",
26
26
  z.object({}),
27
27
  async (_event, ctx) => ({ isSuccess: true, data: { tenant: ctx.tz.tenant } }),
28
- { access: { openToAll: true } },
28
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
29
29
  );
30
30
  });
31
31
 
@@ -35,7 +35,7 @@ const probeFeature = defineFeature("tz-probe", (r) => {
35
35
  const queryProbeFeature = defineFeature("tz-cache-probe", (r) => {
36
36
  r.requires("tenant");
37
37
  r.queryHandler("read-tz", z.object({}), async (_query, ctx) => ({ tenant: ctx.tz.tenant }), {
38
- access: { openToAll: true },
38
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
39
39
  });
40
40
  });
41
41
 
@@ -401,7 +401,11 @@ describe("scenario 7: access rules on handlers", () => {
401
401
  "SystemAdmin",
402
402
  ]);
403
403
  expect(stack.registry.getQueryHandler(TenantQueries.me)?.access).toEqual({
404
- openToAll: true,
404
+ openToAll: {
405
+ reason:
406
+ "any signed-in user reads only their own active tenant; the query is scoped " +
407
+ "to the caller's own tenantId",
408
+ },
405
409
  });
406
410
  });
407
411
  });
@@ -9,7 +9,13 @@ import { tenantTable } from "../schema/tenant";
9
9
  export const meQuery = defineQueryHandler({
10
10
  name: "me",
11
11
  schema: z.object({}),
12
- access: { openToAll: true },
12
+ access: {
13
+ openToAll: {
14
+ reason:
15
+ "any signed-in user reads only their own active tenant; the query is scoped " +
16
+ "to the caller's own tenantId",
17
+ },
18
+ },
13
19
  description:
14
20
  "Returns the record of the tenant the caller is currently signed in to, or null if it is gone; use it whenever the active tenant's own name, key or settings are needed.",
15
21
  handler: async (query, ctx) => {
@@ -11,7 +11,13 @@ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user
11
11
  export const meQuery = defineQueryHandler({
12
12
  name: "user:me",
13
13
  schema: z.object({}),
14
- access: { openToAll: true },
14
+ access: {
15
+ openToAll: {
16
+ reason:
17
+ "each signed-in user reads only their own identity record; the detail lookup " +
18
+ "is by the caller's own id",
19
+ },
20
+ },
15
21
  description:
16
22
  "Returns the signed-in caller's own identity record, with the password hash stripped by field-level read access; use it whenever the current user's own profile data is needed.",
17
23
  handler: async (query, ctx) => {