@cosmicdrift/kumiko-bundled-features 0.210.0 → 0.212.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 (79) hide show
  1. package/package.json +8 -8
  2. package/src/admin-shell/feature.ts +2 -0
  3. package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
  4. package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
  5. package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
  6. package/src/audit/constants.ts +3 -0
  7. package/src/audit/feature.ts +2 -0
  8. package/src/audit/i18n.ts +1 -0
  9. package/src/audit/web/audit-log-detail-screen.tsx +28 -6
  10. package/src/audit/web/audit-log-screen.tsx +51 -23
  11. package/src/auth-email-password/feature.ts +1 -0
  12. package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
  13. package/src/auth-email-password/web/auth-gate.tsx +1 -0
  14. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
  15. package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
  16. package/src/auth-email-password/web/session.tsx +1 -0
  17. package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
  18. package/src/auth-mfa/feature.ts +1 -0
  19. package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
  20. package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
  21. package/src/compliance-profiles/feature.ts +1 -5
  22. package/src/crypto-shredding/changes.json +2 -1
  23. package/src/custom-fields/__tests__/feature.test.ts +42 -0
  24. package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
  25. package/src/custom-fields/constants.ts +6 -0
  26. package/src/custom-fields/feature.ts +56 -10
  27. package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
  28. package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
  29. package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
  30. package/src/delivery/feature.ts +1 -3
  31. package/src/feature-toggles/feature.ts +1 -0
  32. package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
  33. package/src/file-derivatives/changes.json +9 -1
  34. package/src/folders/web/folder-manager.tsx +1 -0
  35. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
  36. package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
  37. package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
  38. package/src/jobs/feature.ts +25 -1
  39. package/src/jobs/handlers/detail.query.ts +13 -1
  40. package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
  41. package/src/jobs/job-run-logger.ts +68 -18
  42. package/src/jobs/web/job-run-detail-screen.tsx +1 -0
  43. package/src/page-render/web.ts +1 -0
  44. package/src/personal-access-tokens/changes.json +7 -0
  45. package/src/personal-access-tokens/feature.ts +1 -0
  46. package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
  47. package/src/secrets/constants.ts +12 -0
  48. package/src/secrets/feature.ts +35 -9
  49. package/src/secrets/handlers/delete.write.ts +23 -16
  50. package/src/secrets/handlers/list.query.ts +37 -30
  51. package/src/secrets/handlers/set.write.ts +43 -36
  52. package/src/secrets/index.ts +5 -0
  53. package/src/seo/feature.ts +1 -0
  54. package/src/tags/feature.ts +1 -0
  55. package/src/tags/web/tag-chip.tsx +2 -0
  56. package/src/tags/web/tag-manager.tsx +2 -1
  57. package/src/tags/web/tag-picker.tsx +1 -0
  58. package/src/template-resolver/changes.json +9 -1
  59. package/src/template-resolver/web/client-plugin.tsx +2 -0
  60. package/src/tenant/__tests__/members-screens.boot.test.ts +79 -4
  61. package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
  62. package/src/tenant/changes.json +6 -0
  63. package/src/tenant/constants.ts +17 -4
  64. package/src/tenant/feature.ts +28 -11
  65. package/src/tenant/handlers/team-list.query.ts +250 -0
  66. package/src/tenant/i18n.ts +20 -0
  67. package/src/tenant/screens.ts +111 -3
  68. package/src/tenant/web/client-plugin.tsx +11 -6
  69. package/src/tenant/web/index.ts +2 -2
  70. package/src/tenant/web/member-roles-cell.tsx +12 -0
  71. package/src/tenant/web/member-status-cell.tsx +23 -0
  72. package/src/tier-engine/feature.ts +2 -0
  73. package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
  74. package/src/user-data-rights/feature.ts +2 -0
  75. package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  76. package/src/user-profile/i18n.ts +1 -0
  77. package/src/user-profile/web/profile-screen.tsx +1 -1
  78. package/src/tenant/web/i18n.ts +0 -24
  79. package/src/tenant/web/members-screen.tsx +0 -270
@@ -67,6 +67,35 @@ async function encryptStartedPayload(
67
67
  );
68
68
  }
69
69
 
70
+ // Same per-subject encryption as encryptStartedPayload, applied to each
71
+ // batched log line's `message` (#2247) before insertMany into
72
+ // jobRunLogsTable — that table is unmanaged/direct-write, so there is no
73
+ // event-piiFields catalog to lean on here either. "message" is the AAD
74
+ // field name and must match the field passed to decryptStoredPii on read
75
+ // (detail.query.ts) or decrypt fails loud. Same skip rules as the payload:
76
+ // null subject (system/cron runs) and absent KMS (rollout mode) both stay
77
+ // plaintext.
78
+ async function encryptLogMessages<T extends { readonly message: string }>(
79
+ logs: readonly T[],
80
+ triggeredById: string | null,
81
+ ): Promise<T[]> {
82
+ if (triggeredById === null) return [...logs];
83
+ const kms = configuredPiiSubjectKms();
84
+ if (!kms) return [...logs];
85
+ return Promise.all(
86
+ logs.map(async (log) => ({
87
+ ...log,
88
+ message: await encryptPiiValueForSubject(
89
+ kms,
90
+ { kind: "user", userId: triggeredById },
91
+ log.message,
92
+ { requestId: "jobs:job-run-logger" },
93
+ "message",
94
+ ),
95
+ })),
96
+ );
97
+ }
98
+
70
99
  export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallbacks {
71
100
  const { db } = opts;
72
101
 
@@ -78,10 +107,17 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
78
107
  // Bounded cache (LRU-ish with TTL) — worker-crash between start and
79
108
  // complete would otherwise leak entries. DB-lookup recovers evicted
80
109
  // entries via bull_job_id on jobRunsTable.
81
- type CacheEntry = { readonly runId: string; readonly expiresAt: number };
110
+ // triggeredById rides along with runId (#2247) — resolved once at start
111
+ // (or on cache-miss DB fallback) so onJobComplete/-Failed know the log
112
+ // subject without an extra always-on DB round trip.
113
+ type CacheEntry = {
114
+ readonly runId: string;
115
+ readonly triggeredById: string | null;
116
+ readonly expiresAt: number;
117
+ };
82
118
  const runIdByBullJobId = new Map<string, CacheEntry>();
83
119
 
84
- function cachePut(bullJobId: string, runId: string): void {
120
+ function cachePut(bullJobId: string, runId: string, triggeredById: string | null): void {
85
121
  // Enforce max-size BEFORE insert. Map iteration returns insertion
86
122
  // order, so dropping the first entry is the oldest.
87
123
  if (runIdByBullJobId.size >= DEFAULT_CACHE_MAX_ENTRIES) {
@@ -90,24 +126,32 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
90
126
  }
91
127
  runIdByBullJobId.set(bullJobId, {
92
128
  runId,
129
+ triggeredById,
93
130
  expiresAt: Date.now() + DEFAULT_CACHE_TTL_MS,
94
131
  });
95
132
  }
96
133
 
97
- function cacheGet(bullJobId: string): string | undefined {
134
+ function cacheGet(bullJobId: string): CacheEntry | undefined {
98
135
  const entry = runIdByBullJobId.get(bullJobId);
99
136
  if (!entry) return undefined;
100
137
  if (Date.now() >= entry.expiresAt) {
101
138
  runIdByBullJobId.delete(bullJobId); // immediate cleanup on terminal callback
102
139
  return undefined;
103
140
  }
104
- return entry.runId;
141
+ return entry;
105
142
  }
106
143
 
107
- async function resolveRunId(bullJobId: string): Promise<string | undefined> {
144
+ async function resolveRun(
145
+ bullJobId: string,
146
+ ): Promise<{ readonly runId: string; readonly triggeredById: string | null } | undefined> {
108
147
  const cached = cacheGet(bullJobId);
109
- if (cached) return cached;
110
- const row = await fetchOne<{ id: string | number }>(db, jobRunsTable, { bullJobId });
148
+ if (cached) return { runId: cached.runId, triggeredById: cached.triggeredById };
149
+ const row = await fetchOne<{ id: string | number; triggeredById: string | null }>(
150
+ db,
151
+ jobRunsTable,
152
+ { bullJobId },
153
+ );
154
+ if (!row) return undefined;
111
155
  // buildBaseColumns's signature types `id` as `string | number` because
112
156
  // it returns both branches of the idType union. We know this table
113
157
  // was built with idType: "uuid" (see job-run-table.ts), so narrowing
@@ -115,15 +159,17 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
115
159
  // would overload buildBaseColumns per idType — scoped out of this
116
160
  // follow-up as its return type has four branches (with/without
117
161
  // softDelete × serial/uuid).
118
- const id = row ? String(row.id) : undefined;
119
- if (id) cachePut(bullJobId, id);
120
- return id;
162
+ const runId = String(row.id);
163
+ const triggeredById = row.triggeredById ?? null;
164
+ cachePut(bullJobId, runId, triggeredById);
165
+ return { runId, triggeredById };
121
166
  }
122
167
 
123
168
  return {
124
169
  onJobStart: async (jobName: string, bullJobId: string, meta: JobMeta) => {
125
170
  const runId = generateId();
126
- cachePut(bullJobId, runId);
171
+ const triggeredById = meta.triggeredById ?? null;
172
+ cachePut(bullJobId, runId, triggeredById);
127
173
  // Parse against the registered schema so out-of-dispatcher writes
128
174
  // get the same validation guarantee as ctx.appendEvent. A shape
129
175
  // drift between feature + logger fails loudly at the source
@@ -133,7 +179,7 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
133
179
  bullJobId,
134
180
  status: "running",
135
181
  payload: meta.payload ?? null,
136
- triggeredById: meta.triggeredById ?? null,
182
+ triggeredById,
137
183
  startedAt: Temporal.Now.instant().toString(),
138
184
  attempt: meta.attempt ?? 1,
139
185
  });
@@ -158,12 +204,13 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
158
204
  duration: number,
159
205
  logs: JobLogEntry[],
160
206
  ) => {
161
- const runId = await resolveRunId(bullJobId);
207
+ const resolved = await resolveRun(bullJobId);
162
208
  // skip: state loss between start + complete (worker restart, cache
163
209
  // evicted AND DB has no matching bull_job_id). Rare edge case; we
164
210
  // drop the completion write rather than forging a run row from
165
211
  // scratch — forensics still has the original BullMQ lifecycle.
166
- if (!runId) return;
212
+ if (!resolved) return;
213
+ const { runId, triggeredById } = resolved;
167
214
  const payload = runCompletedSchema.parse({
168
215
  duration,
169
216
  finishedAt: Temporal.Now.instant().toString(),
@@ -188,10 +235,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
188
235
  // skip: empty log batch — the worker ran silent. No child rows to
189
236
  // insert; the status update above already recorded completion.
190
237
  if (payload.logs.length > 0) {
238
+ const encryptedLogs = await encryptLogMessages(payload.logs, triggeredById);
191
239
  await insertMany(
192
240
  db,
193
241
  jobRunLogsTable,
194
- payload.logs.map((log) => ({
242
+ encryptedLogs.map((log) => ({
195
243
  runId,
196
244
  level: log.level,
197
245
  message: log.message,
@@ -208,10 +256,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
208
256
  error: string,
209
257
  logs: JobLogEntry[],
210
258
  ) => {
211
- const runId = await resolveRunId(bullJobId);
259
+ const resolved = await resolveRun(bullJobId);
212
260
  // skip: same rare state-loss case as in onJobComplete — drop the
213
261
  // failure write rather than forge a run row from scratch.
214
- if (!runId) return;
262
+ if (!resolved) return;
263
+ const { runId, triggeredById } = resolved;
215
264
  // Read started_at off the row so we can compute duration
216
265
  // symmetrically to onJobComplete (which gets duration from the
217
266
  // worker). The row already has started_at from onJobStart.
@@ -243,10 +292,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
243
292
  );
244
293
  // skip: empty log batch — mirror of onJobComplete
245
294
  if (payload.logs.length > 0) {
295
+ const encryptedLogs = await encryptLogMessages(payload.logs, triggeredById);
246
296
  await insertMany(
247
297
  db,
248
298
  jobRunLogsTable,
249
- payload.logs.map((log) => ({
299
+ encryptedLogs.map((log) => ({
250
300
  runId,
251
301
  level: log.level,
252
302
  message: log.message,
@@ -63,6 +63,7 @@ export function JobRunDetailScreen(): ReactNode {
63
63
  setState({ kind: "ready", run: res.data });
64
64
  }, [dispatcher, runId]);
65
65
 
66
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
66
67
  useEffect(() => {
67
68
  void load();
68
69
  }, [load]);
@@ -1,3 +1,4 @@
1
+ // kumiko-lint-ignore app-feature-structure Server-side re-export barrel (branding/sanitize helpers), not a client screen — name collision with the web.ts monolith heuristic
1
2
  export {
2
3
  type BrandingTokens,
3
4
  brandingHeaderHtml,
@@ -4,5 +4,12 @@
4
4
  "type": "improvement",
5
5
  "title": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated vi…",
6
6
  "detail": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated via the tier-engine (mirrors ledger/tags). Pass `{ toggleable: { default: false } }` for fail-closed gating — PAT is then off until a tier lists `\"personal-access-tokens\"` in its features. Omitting the option keeps PAT always-on (no behaviour change for existing consumers)."
7
+ },
8
+ {
9
+ "version": "0.202.0",
10
+ "type": "breaking",
11
+ "title": "personal-access-tokens: `write:create` now requires `currentPassword` (+ `mfaCode` if MFA enrolled) (e91d4cb).",
12
+ "detail": "`personal-access-tokens:write:create` now requires `currentPassword` (verified against the caller's password hash) before minting a token, and rejects when the caller has MFA enrolled and `mfaCode` is missing or wrong — a session cookie alone is no longer enough to stand up a durable API credential. `expiresInDays` now defaults to 90 days instead of never-expiring when omitted (the existing 3650-day cap is unchanged, so a genuinely long-lived token is still possible if requested explicitly). Changing a user's password, or enabling/disabling MFA, now revokes all of that user's live PAT tokens — mirrors the existing session auto-revoke-on-password-change behavior. `run-prod-app`/`run-dev-app` wire the new MFA↔PAT revoke callback automatically when both `auth-mfa` and `personal-access-tokens` are mounted; no app-level change needed for that part.",
13
+ "migration": "Apps that already mint PATs (their own client code, scripts, or tests) need to add `currentPassword` to the `create` request payload — this is a breaking change to the `create` request shape despite the minor bump (bundled-features doesn't follow strict semver across its handler schemas yet). If the caller has MFA enrolled, also include a valid `mfaCode`."
7
14
  }
8
15
  ]
@@ -149,6 +149,7 @@ export function createPersonalAccessTokensFeature(
149
149
  // the app places it via r.nav in its logged-in settings area. dormant:
150
150
  // true skips createKumikoApp's missing-client-plugin boot diagnostic
151
151
  // (#2025) for apps that don't nav this screen (#2034).
152
+ // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
152
153
  r.screen({
153
154
  id: PAT_SCREEN_ID,
154
155
  type: "custom",
@@ -0,0 +1,114 @@
1
+ // #2296 — createSecretsFeature() took no options at all: set/delete/list were
2
+ // hard-wired to ["TenantAdmin"], so an app with its own role vocabulary
3
+ // (e.g. ["Admin","Editor"]) could not let its own admins manage secrets
4
+ // without also granting the framework "TenantAdmin" role — the same
5
+ // Role-Naming-Drift documented on custom-fields' valueWriteRoles. Verifies
6
+ // the new `roles`/`access` options actually gate set/delete/list, that the
7
+ // framework-default role loses access once overridden, and that `openToAll`
8
+ // really does open the surface to any authenticated tenant user.
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { randomBytes } from "node:crypto";
12
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
+ import {
14
+ createEnvMasterKeyProvider,
15
+ type MasterKeyProvider,
16
+ } from "@cosmicdrift/kumiko-framework/secrets";
17
+ import {
18
+ createTestUser,
19
+ setupTestStack,
20
+ type TestStack,
21
+ unsafePushTables,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { createSecretsFeature } from "../feature";
24
+ import { createSecretsContext } from "../secrets-context";
25
+ import { tenantSecretsTable } from "../table";
26
+
27
+ function masterKeyProvider(): MasterKeyProvider {
28
+ return createEnvMasterKeyProvider({
29
+ env: {
30
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
31
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
32
+ },
33
+ });
34
+ }
35
+
36
+ async function buildStack(feature: ReturnType<typeof createSecretsFeature>): Promise<TestStack> {
37
+ const provider = masterKeyProvider();
38
+ const stack = await setupTestStack({
39
+ features: [feature],
40
+ extraContext: ({ db }) => ({
41
+ secrets: createSecretsContext({ db, masterKeyProvider: provider }),
42
+ }),
43
+ });
44
+ await unsafePushTables(stack.db, { tenant_secrets: tenantSecretsTable });
45
+ await createEventsTable(stack.db);
46
+ return stack;
47
+ }
48
+
49
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
50
+ const customRoleUser = createTestUser({ roles: ["Admin"] });
51
+ const unprivileged = createTestUser({ roles: ["Viewer"] });
52
+
53
+ describe("secrets — roles option (#2296)", () => {
54
+ let stack: TestStack;
55
+
56
+ beforeAll(async () => {
57
+ stack = await buildStack(createSecretsFeature({ roles: ["Admin"] }));
58
+ });
59
+
60
+ afterAll(async () => {
61
+ await stack.cleanup();
62
+ });
63
+
64
+ test("a custom-role user can set, list and delete", async () => {
65
+ await stack.http.writeOk(
66
+ "secrets:write:set",
67
+ { key: "api.key.role-opt", value: "shh" },
68
+ customRoleUser,
69
+ );
70
+ const list = await stack.http.queryOk<Array<{ key: string }>>(
71
+ "secrets:query:list",
72
+ {},
73
+ customRoleUser,
74
+ );
75
+ expect(list.some((r) => r.key === "api.key.role-opt")).toBe(true);
76
+ await stack.http.writeOk("secrets:write:delete", { key: "api.key.role-opt" }, customRoleUser);
77
+ });
78
+
79
+ test("the framework-default TenantAdmin role is denied once `roles` overrides it", async () => {
80
+ const denied = await stack.http.writeErr(
81
+ "secrets:write:set",
82
+ { key: "api.key.denied", value: "x" },
83
+ admin,
84
+ );
85
+ expect(denied.code).toBe("access_denied");
86
+ });
87
+ });
88
+
89
+ describe("secrets — access: { openToAll: true } (#2296)", () => {
90
+ let stack: TestStack;
91
+
92
+ beforeAll(async () => {
93
+ stack = await buildStack(createSecretsFeature({ access: { openToAll: true } }));
94
+ });
95
+
96
+ afterAll(async () => {
97
+ await stack.cleanup();
98
+ });
99
+
100
+ test("any authenticated tenant user can set, list and delete", async () => {
101
+ await stack.http.writeOk(
102
+ "secrets:write:set",
103
+ { key: "api.key.open", value: "shh" },
104
+ unprivileged,
105
+ );
106
+ const list = await stack.http.queryOk<Array<{ key: string }>>(
107
+ "secrets:query:list",
108
+ {},
109
+ unprivileged,
110
+ );
111
+ expect(list.some((r) => r.key === "api.key.open")).toBe(true);
112
+ await stack.http.writeOk("secrets:write:delete", { key: "api.key.open" }, unprivileged);
113
+ });
114
+ });
@@ -0,0 +1,12 @@
1
+ // @runtime client
2
+ // secrets bundle constants — default RBAC for set/delete/list (#2296).
3
+
4
+ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
5
+
6
+ // Default-RBAC of the set/delete/list handlers, hard-wired to ["TenantAdmin"]
7
+ // before #2296. Apps with their own role vocabulary override via
8
+ // createSecretsFeature({ roles }) or createSecretsFeature({ access }) — the
9
+ // rotate job stays untouched, it's a manual/ops path across all tenants by
10
+ // design, not a per-tenant RBAC surface.
11
+ export const DEFAULT_SECRETS_ROLES = ["TenantAdmin"] as const;
12
+ export const DEFAULT_SECRETS_ACCESS: AccessRule = { roles: DEFAULT_SECRETS_ROLES };
@@ -1,11 +1,16 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ type AccessRule,
3
+ defineFeature,
4
+ type FeatureDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
7
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
4
8
  import { z } from "zod";
5
- import { deleteWrite } from "./handlers/delete.write";
6
- import { listQuery } from "./handlers/list.query";
9
+ import { DEFAULT_SECRETS_ACCESS } from "./constants";
10
+ import { createDeleteHandler } from "./handlers/delete.write";
11
+ import { createListHandler } from "./handlers/list.query";
7
12
  import { rotateJob } from "./handlers/rotate.job";
8
- import { setWrite } from "./handlers/set.write";
13
+ import { createSetHandler } from "./handlers/set.write";
9
14
  import { secretReadSchema } from "./secrets-context";
10
15
  import { tenantSecretEntity, tenantSecretsTable } from "./table";
11
16
 
@@ -84,10 +89,31 @@ export function requireSecretsContext(
84
89
  };
85
90
  }
86
91
 
87
- export function createSecretsFeature(): FeatureDefinition {
92
+ export type SecretsFeatureOptions = {
93
+ /** Access rule for the `set`, `delete` and `list` handlers. Default
94
+ * { roles: ["TenantAdmin"] }. Adopt the host's role vocabulary with
95
+ * { roles: [...] }, or { openToAll: true } to let any authenticated tenant
96
+ * user manage secrets — note this is a much larger blast radius than the
97
+ * default: tenant credentials (API keys, tokens) become writable/deletable,
98
+ * and their previews/hints listable, by every tenant member, not just
99
+ * admins. Takes precedence over `roles`. The `rotate` job is unaffected —
100
+ * it's a manual, cross-tenant ops path, not per-tenant RBAC. */
101
+ readonly access?: AccessRule;
102
+ /** Shorthand for { access: { roles } }. Ignored when `access` is set. */
103
+ readonly roles?: readonly string[];
104
+ };
105
+
106
+ function resolveSecretsAccess(opts: SecretsFeatureOptions): AccessRule {
107
+ if (opts.access !== undefined) return opts.access;
108
+ if (opts.roles !== undefined) return { roles: opts.roles };
109
+ return DEFAULT_SECRETS_ACCESS;
110
+ }
111
+
112
+ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureDefinition {
113
+ const access = resolveSecretsAccess(opts);
88
114
  return defineFeature(SECRETS_FEATURE_NAME, (r) => {
89
115
  r.describe(
90
- "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.",
116
+ '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).',
91
117
  );
92
118
  r.uiHints({
93
119
  displayLabel: "Tenant Secrets",
@@ -117,9 +143,9 @@ export function createSecretsFeature(): FeatureDefinition {
117
143
  // The rotation job deliberately reaches for ctx.db as DbConnection
118
144
  // (raw, cross-tenant) because rotation is a deployment-wide operation —
119
145
  // no feature-wide r.systemScope() needed.
120
- r.writeHandler(setWrite);
121
- r.writeHandler(deleteWrite);
122
- r.queryHandler(listQuery);
146
+ r.writeHandler(createSetHandler(access));
147
+ r.writeHandler(createDeleteHandler(access));
148
+ r.queryHandler(createListHandler(access));
123
149
  // Manual-only by design: ops triggers rotation after a KEK version flip.
124
150
  // BullMQ delivers to exactly one worker, so running it against a busy
125
151
  // table on multiple instances is still safe.
@@ -1,20 +1,27 @@
1
- import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type AccessRule, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { z } from "zod";
4
+ import { DEFAULT_SECRETS_ACCESS } from "../constants";
4
5
  import { requireSecretsContext } from "../feature";
5
6
 
6
- export const deleteWrite = defineWriteHandler({
7
- name: "delete",
8
- schema: z.object({
9
- key: z.string().min(1).max(100),
10
- }),
11
- access: { roles: ["TenantAdmin"] },
12
- handler: async (event, ctx) => {
13
- const secrets = requireSecretsContext(ctx, "secrets:write:delete");
14
- const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
15
- deletedBy: event.user.id,
16
- });
17
- if (!removed) return failNotFound("tenant-secret", event.payload.key);
18
- return { isSuccess: true, data: { key: event.payload.key } };
19
- },
20
- });
7
+ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
8
+ return defineWriteHandler({
9
+ name: "delete",
10
+ schema: z.object({
11
+ key: z.string().min(1).max(100),
12
+ }),
13
+ access,
14
+ handler: async (event, ctx) => {
15
+ const secrets = requireSecretsContext(ctx, "secrets:write:delete");
16
+ const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
17
+ deletedBy: event.user.id,
18
+ });
19
+ if (!removed) return failNotFound("tenant-secret", event.payload.key);
20
+ return { isSuccess: true, data: { key: event.payload.key } };
21
+ },
22
+ });
23
+ }
24
+
25
+ // Backwards-compat: existing imports of `deleteWrite` keep working —
26
+ // identical to createDeleteHandler() with the default access.
27
+ export const deleteWrite = createDeleteHandler();
@@ -1,37 +1,44 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type AccessRule, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
+ import { DEFAULT_SECRETS_ACCESS } from "../constants";
4
5
  import { tenantSecretsTable } from "../table";
5
6
 
6
7
  // Lists all secrets for the current tenant. Returns redactedPreview, never
7
8
  // the plaintext. Decryption would be pointless here anyway — this is the
8
9
  // TenantAdmin UI, not feature code that needs the value.
9
- export const listQuery = defineQueryHandler({
10
- name: "list",
11
- schema: z.object({}),
12
- access: { roles: ["TenantAdmin"] },
13
- handler: async (event, ctx) => {
14
- const rows = await selectMany<{
15
- key: string;
16
- kekVersion: number;
17
- metadata: { redactedPreview?: string; hint?: string };
18
- lastRotatedAt: unknown;
19
- insertedAt: unknown;
20
- }>(
21
- ctx.db.raw,
22
- tenantSecretsTable,
23
- { tenantId: event.user.tenantId },
24
- {
25
- orderBy: { col: "key", direction: "asc" },
26
- },
27
- );
28
- return rows.map((r) => ({
29
- key: r.key,
30
- redactedPreview: r.metadata.redactedPreview ?? null,
31
- hint: r.metadata.hint ?? null,
32
- kekVersion: r.kekVersion,
33
- lastRotatedAt: r.lastRotatedAt,
34
- createdAt: r.insertedAt,
35
- }));
36
- },
37
- });
10
+ export function createListHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
11
+ return defineQueryHandler({
12
+ name: "list",
13
+ schema: z.object({}),
14
+ access,
15
+ handler: async (event, ctx) => {
16
+ const rows = await selectMany<{
17
+ key: string;
18
+ kekVersion: number;
19
+ metadata: { redactedPreview?: string; hint?: string };
20
+ lastRotatedAt: unknown;
21
+ insertedAt: unknown;
22
+ }>(
23
+ ctx.db.raw,
24
+ tenantSecretsTable,
25
+ { tenantId: event.user.tenantId },
26
+ {
27
+ orderBy: { col: "key", direction: "asc" },
28
+ },
29
+ );
30
+ return rows.map((r) => ({
31
+ key: r.key,
32
+ redactedPreview: r.metadata.redactedPreview ?? null,
33
+ hint: r.metadata.hint ?? null,
34
+ kekVersion: r.kekVersion,
35
+ lastRotatedAt: r.lastRotatedAt,
36
+ createdAt: r.insertedAt,
37
+ }));
38
+ },
39
+ });
40
+ }
41
+
42
+ // Backwards-compat: existing imports of `listQuery` keep working — identical
43
+ // to createListHandler() with the default access.
44
+ export const listQuery = createListHandler();
@@ -1,45 +1,52 @@
1
- import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type AccessRule, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { z } from "zod";
3
+ import { DEFAULT_SECRETS_ACCESS } from "../constants";
3
4
  import { requireSecretsContext } from "../feature";
4
5
 
5
- export const setWrite = defineWriteHandler({
6
- name: "set",
7
- schema: z.object({
8
- key: z.string().min(1).max(100),
9
- value: z.string(),
10
- // Optional fixed-length preview — if the caller's UI wants a domain-
11
- // specific redaction ("sk_live_abc…xyz") it can send it here; else the
12
- // handler derives a generic one (first-3-chars + bullets).
13
- redactedPreview: z.string().max(50).optional(),
14
- hint: z.string().max(200).optional(),
15
- }),
16
- access: { roles: ["TenantAdmin"] },
17
- handler: async (event, ctx) => {
18
- const secrets = requireSecretsContext(ctx, "secrets:write:set");
19
- const { key, value, redactedPreview, hint } = event.payload;
6
+ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
7
+ return defineWriteHandler({
8
+ name: "set",
9
+ schema: z.object({
10
+ key: z.string().min(1).max(100),
11
+ value: z.string(),
12
+ // Optional fixed-length preview — if the caller's UI wants a domain-
13
+ // specific redaction ("sk_live_abc…xyz") it can send it here; else the
14
+ // handler derives a generic one (first-3-chars + bullets).
15
+ redactedPreview: z.string().max(50).optional(),
16
+ hint: z.string().max(200).optional(),
17
+ }),
18
+ access,
19
+ handler: async (event, ctx) => {
20
+ const secrets = requireSecretsContext(ctx, "secrets:write:set");
21
+ const { key, value, redactedPreview, hint } = event.payload;
20
22
 
21
- // Preview-priority: explicit payload param > feature-declared redact
22
- // (via r.secret()) > generic default. A feature that declared a
23
- // domain-aware redact (Stripe keys: "sk_test...2345") wins over the
24
- // framework default unless the caller sent a specific preview.
25
- const keyDef = ctx.registry.getSecretKey(key);
26
- const featureRedact = keyDef?.redact;
27
- const redactFn: (v: string) => string = redactedPreview
28
- ? () => redactedPreview
29
- : (featureRedact ?? defaultRedact);
23
+ // Preview-priority: explicit payload param > feature-declared redact
24
+ // (via r.secret()) > generic default. A feature that declared a
25
+ // domain-aware redact (Stripe keys: "sk_test...2345") wins over the
26
+ // framework default unless the caller sent a specific preview.
27
+ const keyDef = ctx.registry.getSecretKey(key);
28
+ const featureRedact = keyDef?.redact;
29
+ const redactFn: (v: string) => string = redactedPreview
30
+ ? () => redactedPreview
31
+ : (featureRedact ?? defaultRedact);
30
32
 
31
- await secrets.set(event.user.tenantId, key, value, {
32
- redact: redactFn,
33
- ...(hint ? { hint } : {}),
34
- updatedBy: event.user.id,
35
- });
33
+ await secrets.set(event.user.tenantId, key, value, {
34
+ redact: redactFn,
35
+ ...(hint ? { hint } : {}),
36
+ updatedBy: event.user.id,
37
+ });
36
38
 
37
- return {
38
- isSuccess: true,
39
- data: { key, redactedPreview: redactedPreview ?? redactFn(value) },
40
- };
41
- },
42
- });
39
+ return {
40
+ isSuccess: true,
41
+ data: { key, redactedPreview: redactedPreview ?? redactFn(value) },
42
+ };
43
+ },
44
+ });
45
+ }
46
+
47
+ // Backwards-compat: existing imports of `setWrite` keep working — identical
48
+ // to createSetHandler() with the default access.
49
+ export const setWrite = createSetHandler();
43
50
 
44
51
  // Fallback redaction: show at most the first 3 chars + trailing bullets.
45
52
  // Deliberately conservative — a too-generous preview defeats the point.
@@ -1,3 +1,4 @@
1
+ export { DEFAULT_SECRETS_ACCESS, DEFAULT_SECRETS_ROLES } from "./constants";
1
2
  export {
2
3
  createSecretsContext,
3
4
  createSecretsFeature,
@@ -5,14 +6,18 @@ export {
5
6
  SECRETS_FEATURE_NAME,
6
7
  type SecretsContext,
7
8
  type SecretsContextOptions,
9
+ type SecretsFeatureOptions,
8
10
  type StoredEnvelope,
9
11
  type StoredMetadata,
10
12
  secretsEnvSchema,
11
13
  TENANT_SECRET_READ_EVENT,
12
14
  tenantSecretsTable,
13
15
  } from "./feature";
16
+ export { createDeleteHandler, deleteWrite } from "./handlers/delete.write";
17
+ export { createListHandler, listQuery } from "./handlers/list.query";
14
18
  export {
15
19
  type RotateJobPayload,
16
20
  type RotateJobResult,
17
21
  rotateJob,
18
22
  } from "./handlers/rotate.job";
23
+ export { createSetHandler, setWrite } from "./handlers/set.write";