@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.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 (121) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
  3. package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
  4. package/src/audit/__tests__/constants.test.ts +8 -0
  5. package/src/audit/web/audit-log-detail-screen.tsx +30 -14
  6. package/src/audit/web/audit-log-screen.tsx +21 -7
  7. package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
  8. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
  9. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
  10. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
  11. package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
  12. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  13. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
  14. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
  15. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
  16. package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
  17. package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
  19. package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
  20. package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
  21. package/src/auth-email-password/i18n.ts +1 -0
  22. package/src/auth-email-password/magic-link-mail.ts +17 -1
  23. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
  24. package/src/auth-email-password/web/auth-client.ts +1 -7
  25. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
  26. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  27. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  28. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  29. package/src/custom-fields/constants.ts +4 -3
  30. package/src/custom-fields/feature.ts +7 -8
  31. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  32. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  33. package/src/delivery/db/queries/preferences.ts +3 -2
  34. package/src/delivery/handlers/log.query.ts +1 -0
  35. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
  36. package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
  37. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
  38. package/src/feature-toggles/feature.ts +24 -4
  39. package/src/feature-toggles/handlers/registered.query.ts +27 -16
  40. package/src/feature-toggles/i18n.ts +5 -0
  41. package/src/feature-toggles/web/client-plugin.tsx +1 -5
  42. package/src/feature-toggles/web/index.ts +0 -1
  43. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  44. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  45. package/src/file-derivatives/feature.ts +7 -6
  46. package/src/file-provider-s3/feature.ts +1 -2
  47. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  48. package/src/form-draft/handlers/cleanup.job.ts +31 -20
  49. package/src/form-draft/handlers/discard.write.ts +8 -8
  50. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  51. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  52. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
  53. package/src/jobs/feature.ts +7 -2
  54. package/src/jobs/handlers/detail.query.ts +22 -11
  55. package/src/jobs/handlers/list.query.ts +17 -6
  56. package/src/jobs/job-run-logger.ts +78 -36
  57. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  58. package/src/mail-transport-smtp/feature.ts +1 -2
  59. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  60. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  61. package/src/notes-history/handlers/add-note.write.ts +7 -3
  62. package/src/notes-history/web/notes-section.tsx +10 -2
  63. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  64. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  65. package/src/secrets/feature.ts +9 -2
  66. package/src/secrets/handlers/delete.write.ts +0 -4
  67. package/src/secrets/handlers/list.query.ts +0 -4
  68. package/src/secrets/handlers/set.write.ts +0 -4
  69. package/src/secrets/index.ts +3 -3
  70. package/src/shared/index.ts +1 -0
  71. package/src/shared/map-with-concurrency.test.ts +2 -2
  72. package/src/shared/session-field.ts +10 -0
  73. package/src/shared/session-locale-field.test.ts +9 -0
  74. package/src/shared/session-locale-field.ts +4 -1
  75. package/src/shared/session-timezone-field.ts +2 -1
  76. package/src/subscription-stripe/feature.ts +5 -11
  77. package/src/tags/__tests__/feature.test.ts +18 -5
  78. package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
  79. package/src/tags/constants.ts +3 -2
  80. package/src/tags/entity.ts +2 -0
  81. package/src/tags/feature.ts +20 -11
  82. package/src/tags/handlers/create-tag.write.ts +5 -2
  83. package/src/tags/handlers/delete-tag.write.ts +5 -2
  84. package/src/tags/handlers/update-tag.write.ts +5 -2
  85. package/src/tags/i18n.ts +4 -0
  86. package/src/tags/index.ts +2 -0
  87. package/src/tags/screens.ts +66 -0
  88. package/src/tags/web/client-plugin.tsx +1 -6
  89. package/src/tags/web/index.ts +1 -0
  90. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  91. package/src/template-resolver/seeding.ts +78 -6
  92. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  93. package/src/template-resolver/web/client-plugin.tsx +2 -1
  94. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  95. package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
  96. package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
  97. package/src/tenant/handlers/members.query.ts +6 -1
  98. package/src/tenant/handlers/team-list.query.ts +3 -1
  99. package/src/tenant/handlers/update-member-roles.write.ts +16 -2
  100. package/src/tenant/i18n.ts +2 -3
  101. package/src/tenant/screens.ts +3 -2
  102. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  103. package/src/tenant-settings/feature.ts +6 -30
  104. package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
  105. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  106. package/src/user/__tests__/user.integration.test.ts +156 -118
  107. package/src/user/handlers/create.write.ts +5 -4
  108. package/src/user/handlers/detail.query.ts +24 -2
  109. package/src/user/handlers/list.query.ts +19 -26
  110. package/src/user/handlers/update-roles.ts +23 -6
  111. package/src/user/handlers/update.write.ts +6 -5
  112. package/src/user/i18n.ts +0 -6
  113. package/src/user/roles-input-schema.ts +20 -0
  114. package/src/user/schema/user.ts +2 -3
  115. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  116. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  117. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  118. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
  119. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  120. package/src/user-profile/web/profile-screen.tsx +1 -1
  121. package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
@@ -2,15 +2,24 @@ import { fetchOne, insertMany, insertOne, updateMany } from "@cosmicdrift/kumiko
2
2
  import {
3
3
  configuredPiiSubjectKms,
4
4
  encryptPiiValueForSubject,
5
+ KeyErasedError,
6
+ type LocalKeyKmsAdapter,
7
+ PII_ERASED_SENTINEL,
5
8
  } from "@cosmicdrift/kumiko-framework/crypto";
6
9
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
7
10
  import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
8
11
  import type { JobLogEntry, JobMeta, JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
9
12
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
13
+ import { mapWithConcurrency } from "../shared";
10
14
  import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events";
11
15
  import { parseJobInstant } from "./job-instant";
12
16
  import { jobRunLogsTable, jobRunsTable } from "./job-run-table";
13
17
 
18
+ // Matches PgKmsAdapter's default pool size (see tenant/handlers/*.query.ts) —
19
+ // bounds concurrent getOrCreateDek calls so a large log batch doesn't claim
20
+ // every connection in the pool.
21
+ const KMS_POOL_CONCURRENCY = 4;
22
+
14
23
  // Direct-write job-run log (#2243): onJobStart/-Complete/-Failed write
15
24
  // straight into jobRunsTable / jobRunLogsTable instead of appending to the
16
25
  // event store and replaying through inline projections. Pre-#2243 every run
@@ -44,27 +53,32 @@ const DEFAULT_CACHE_MAX_ENTRIES = 10_000;
44
53
  // to DB-lookup if actually needed.
45
54
  const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
46
55
 
47
- // The run-started payload can carry arbitrary user data; triggeredById
48
- // names its owning user. No event-PII catalog involved (#2243 removed the
49
- // jobRun r.defineEvent registrations, so there is nothing to catalog) —
50
- // the subject is known statically, so we encrypt directly. A null subject
51
- // (system cron runs, recipient-less triggers) stays plaintext: there is no
52
- // user key to shred, mirroring the previous event-pii catalog's own skip
53
- // rule. Absent KMS adapter stays plaintext too (rollout mode, unchanged).
54
- async function encryptStartedPayload(
55
- payload: string | null,
56
- triggeredById: string | null,
57
- ): Promise<string | null> {
58
- if (payload === null || triggeredById === null) return payload;
59
- const kms = configuredPiiSubjectKms();
60
- if (!kms) return payload;
61
- return encryptPiiValueForSubject(
62
- kms,
63
- { kind: "user", userId: triggeredById },
64
- payload,
65
- { requestId: "jobs:job-run-logger" },
66
- "payload",
67
- );
56
+ // Same per-subject encryption as encryptFailureError/encryptStartedPayload,
57
+ // applied to a single value — shared so payload/message/error all go
58
+ // through the identical erased-key fallback. A key erased right before (or
59
+ // between) any of these callbacks must not blow up the write (status is
60
+ // already committed by the time logs/error land): fall back to the
61
+ // sentinel like decryptPiiValueForSubject does on the read side, instead of
62
+ // throwing and dropping the rest of the batch — or, for onJobStart, the
63
+ // whole run row.
64
+ async function encryptOrSentinel(
65
+ kms: LocalKeyKmsAdapter,
66
+ subjectUserId: string,
67
+ value: string,
68
+ field: string,
69
+ ): Promise<string> {
70
+ try {
71
+ return await encryptPiiValueForSubject(
72
+ kms,
73
+ { kind: "user", userId: subjectUserId },
74
+ value,
75
+ { requestId: "jobs:job-run-logger" },
76
+ field,
77
+ );
78
+ } catch (e) {
79
+ if (!(e instanceof KeyErasedError)) throw e;
80
+ return PII_ERASED_SENTINEL;
81
+ }
68
82
  }
69
83
 
70
84
  // Same per-subject encryption as encryptStartedPayload, applied to each
@@ -74,7 +88,8 @@ async function encryptStartedPayload(
74
88
  // field name and must match the field passed to decryptStoredPii on read
75
89
  // (detail.query.ts) or decrypt fails loud. Same skip rules as the payload:
76
90
  // null subject (system/cron runs) and absent KMS (rollout mode) both stay
77
- // plaintext.
91
+ // plaintext. Bounded concurrency (#2307) — unbounded Promise.all would fire
92
+ // one getOrCreateDek per log line at once against the KMS adapter's pool.
78
93
  async function encryptLogMessages<T extends { readonly message: string }>(
79
94
  logs: readonly T[],
80
95
  triggeredById: string | null,
@@ -82,18 +97,40 @@ async function encryptLogMessages<T extends { readonly message: string }>(
82
97
  if (triggeredById === null) return [...logs];
83
98
  const kms = configuredPiiSubjectKms();
84
99
  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
- );
100
+ return mapWithConcurrency(logs, KMS_POOL_CONCURRENCY, async (log) => ({
101
+ ...log,
102
+ message: await encryptOrSentinel(kms, triggeredById, log.message, "message"),
103
+ }));
104
+ }
105
+
106
+ // Mirrors encryptFailureError for the failure-path `error` column (#2307):
107
+ // stored alongside logs[].message under the same triggering user's DEK, so
108
+ // a log/error pair from the same failed run decrypt together or erase
109
+ // together.
110
+ async function encryptFailureError(error: string, triggeredById: string | null): Promise<string> {
111
+ if (triggeredById === null) return error;
112
+ const kms = configuredPiiSubjectKms();
113
+ if (!kms) return error;
114
+ return encryptOrSentinel(kms, triggeredById, error, "error");
115
+ }
116
+
117
+ // The run-started payload can carry arbitrary user data; triggeredById
118
+ // names its owning user. No event-PII catalog involved (#2243 removed the
119
+ // jobRun r.defineEvent registrations, so there is nothing to catalog) —
120
+ // the subject is known statically, so we encrypt directly. A null subject
121
+ // (system cron runs, recipient-less triggers) stays plaintext: there is no
122
+ // user key to shred, mirroring the previous event-pii catalog's own skip
123
+ // rule. Absent KMS adapter stays plaintext too (rollout mode, unchanged).
124
+ // A key already erased by the time the trigger lands (#2307) must not fail
125
+ // the whole run-start write — sentinel like the log/error paths.
126
+ async function encryptStartedPayload(
127
+ payload: string | null,
128
+ triggeredById: string | null,
129
+ ): Promise<string | null> {
130
+ if (payload === null || triggeredById === null) return payload;
131
+ const kms = configuredPiiSubjectKms();
132
+ if (!kms) return payload;
133
+ return encryptOrSentinel(kms, triggeredById, payload, "payload");
97
134
  }
98
135
 
99
136
  export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallbacks {
@@ -225,7 +262,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
225
262
  jobRunsTable,
226
263
  {
227
264
  status: "completed",
228
- duration: payload.duration,
265
+ // Clear stale-sweep error if the run finished after being marked failed.
266
+ error: null,
267
+ // integer column — match onJobFailed's Math.round so fractional
268
+ // BullMQ timings don't trip Postgres integer syntax.
269
+ duration: Math.round(payload.duration),
229
270
  finishedAt: parseJobInstant(payload.finishedAt),
230
271
  modifiedAt: Temporal.Now.instant(),
231
272
  modifiedById: "system",
@@ -279,12 +320,13 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
279
320
  timestamp: l.timestamp.toString(),
280
321
  })),
281
322
  });
323
+ const encryptedError = await encryptFailureError(payload.error, triggeredById);
282
324
  await updateMany(
283
325
  db,
284
326
  jobRunsTable,
285
327
  {
286
328
  status: "failed",
287
- error: payload.error,
329
+ error: encryptedError,
288
330
  duration: payload.duration,
289
331
  finishedAt: parseJobInstant(payload.finishedAt),
290
332
  modifiedAt: now,
@@ -83,7 +83,7 @@ export function JobRunDetailScreen(): ReactNode {
83
83
 
84
84
  if (state.kind === "loading") {
85
85
  return (
86
- <FormScreenShell testId="job-run-detail-screen">
86
+ <FormScreenShell testId="job-run-detail-screen" maxWidth="3xl">
87
87
  <Text variant="small">{t("jobs.detail.loading")}</Text>
88
88
  </FormScreenShell>
89
89
  );
@@ -91,7 +91,7 @@ export function JobRunDetailScreen(): ReactNode {
91
91
 
92
92
  if (state.kind === "missing") {
93
93
  return (
94
- <FormScreenShell testId="job-run-detail-screen">
94
+ <FormScreenShell testId="job-run-detail-screen" maxWidth="3xl">
95
95
  <Banner variant="error">{t("jobs.detail.missing")}</Banner>
96
96
  </FormScreenShell>
97
97
  );
@@ -99,7 +99,7 @@ export function JobRunDetailScreen(): ReactNode {
99
99
 
100
100
  if (state.kind === "error") {
101
101
  return (
102
- <FormScreenShell testId="job-run-detail-screen">
102
+ <FormScreenShell testId="job-run-detail-screen" maxWidth="3xl">
103
103
  <Banner variant="error">{state.message}</Banner>
104
104
  </FormScreenShell>
105
105
  );
@@ -108,7 +108,7 @@ export function JobRunDetailScreen(): ReactNode {
108
108
  const { run } = state;
109
109
 
110
110
  return (
111
- <FormScreenShell testId="job-run-detail-screen" className="flex flex-col gap-6">
111
+ <FormScreenShell testId="job-run-detail-screen" maxWidth="3xl" className="flex flex-col gap-6">
112
112
  <Card slots={{ title: run.jobName }}>
113
113
  <dl className="grid gap-3 text-sm">
114
114
  <div>
@@ -62,9 +62,8 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
62
62
 
63
63
  // Provider-secret. Sensitive: redact-helper for admin-UI display.
64
64
  const password = r.secret("smtp.password", {
65
- label: { de: "SMTP-Passwort", en: "SMTP password" },
65
+ label: { en: "SMTP password" },
66
66
  hint: {
67
- de: "Login-Passwort am SMTP-Server. Bei Brevo/Postmark/SES heißt es 'API key' bzw. 'SMTP credentials'.",
68
67
  en: "Login password at the SMTP server. Brevo/Postmark/SES call it 'API key' or 'SMTP credentials'.",
69
68
  },
70
69
  redact: (plaintext) => {
@@ -109,7 +109,8 @@ describe("notes-history integration — add + list", () => {
109
109
  // Same guard as authorId above: addNotePayloadSchema doesn't accept an
110
110
  // authorName field, so a smuggled value never survives to the write —
111
111
  // the name (once stamped) can only come from the caller's own session.
112
- expect(rows[0]?.["authorName"]).not.toBe("Fake Display Name");
112
+ expect(rows).toHaveLength(1);
113
+ expect(rows[0]?.["authorName"]).toBeNull();
113
114
  });
114
115
 
115
116
  test("multiple notes on the same entity accumulate — nothing overwrites", async () => {
@@ -58,6 +58,8 @@ const AUTHOR_NAME = "Jamie Vance";
58
58
  const INSERTED_AT = "2026-08-21T12:28:41.580Z";
59
59
  const NOTE_BODY = "Vertragsunterlagen per Post verschickt.";
60
60
 
61
+ const PII_ERASED_SENTINEL = "[[erased]]";
62
+
61
63
  const NOTE_ROWS: readonly NoteRowFixture[] = [
62
64
  {
63
65
  id: "note-1",
@@ -77,6 +79,15 @@ const NOTE_ROWS: readonly NoteRowFixture[] = [
77
79
  body: "Legacy entry predating authorName.",
78
80
  insertedAt: "2026-07-01T09:00:00.000Z",
79
81
  },
82
+ {
83
+ id: "note-3",
84
+ entityType: "contact",
85
+ entityId: "contact-1",
86
+ authorId: "03c2466d-9314-98a6-a761-a5827a611301",
87
+ authorName: PII_ERASED_SENTINEL,
88
+ body: "Note whose author was crypto-shredded.",
89
+ insertedAt: "2026-06-15T08:00:00.000Z",
90
+ },
80
91
  ];
81
92
 
82
93
  function makeDispatcher(): Dispatcher {
@@ -146,7 +157,7 @@ describe("NotesSection — entry display", () => {
146
157
  expect(metaEl.textContent).toContain(AUTHOR_NAME);
147
158
  });
148
159
 
149
- test("a null authorName (legacy row, or shredded author) renders the placeholder", async () => {
160
+ test("a null authorName (legacy row) renders the placeholder", async () => {
150
161
  const view = renderSection();
151
162
  await waitFor(() => {
152
163
  if (view.queryByTestId("notes-section-row-note-2-meta") === null) {
@@ -157,6 +168,18 @@ describe("NotesSection — entry display", () => {
157
168
  expect(metaEl.textContent).toContain("Unknown author");
158
169
  });
159
170
 
171
+ test("a shredded authorName sentinel renders the placeholder, never [[erased]]", async () => {
172
+ const view = renderSection();
173
+ await waitFor(() => {
174
+ if (view.queryByTestId("notes-section-row-note-3-meta") === null) {
175
+ throw new Error("row not rendered yet");
176
+ }
177
+ });
178
+ const metaEl = view.getByTestId("notes-section-row-note-3-meta");
179
+ expect(metaEl.textContent).toContain("Unknown author");
180
+ expect(metaEl.textContent).not.toContain(PII_ERASED_SENTINEL);
181
+ });
182
+
160
183
  test("date renders formatted, never the raw ISO timestamp", async () => {
161
184
  const view = renderSection();
162
185
  await waitFor(() => {
@@ -1,4 +1,4 @@
1
- import { fetchOne, runInSavepoint } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { decryptStoredPii } from "../../shared";
4
4
  import { userTable } from "../../user";
@@ -26,14 +26,18 @@ export function createAddNoteHandler(
26
26
  // read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
27
27
  // Bun.SQL poisons the whole tx after any error inside it, even one that's
28
28
  // caught — a bare try/catch here would take the note write down with it.
29
- authorName = await runInSavepoint(ctx.db.raw, async (sp) => {
29
+ authorName = await runInSavepointIfSupported(ctx.db.raw, async (sp) => {
30
30
  const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
31
31
  id: event.user.id,
32
32
  });
33
33
  if (!userRow?.displayName) return null;
34
34
  return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
35
35
  });
36
- } catch {
36
+ } catch (e) {
37
+ ctx.log?.warn("notes-history: authorName lookup failed", {
38
+ error: e,
39
+ userId: event.user.id,
40
+ });
37
41
  authorName = null;
38
42
  }
39
43
 
@@ -26,11 +26,19 @@ type NoteRow = {
26
26
  readonly entityType: string;
27
27
  readonly entityId: string;
28
28
  readonly authorId: string;
29
- // Null for pre-authorName history and for shredded authors.
29
+ // Null for pre-authorName history; shredded authors arrive as PII_ERASED_SENTINEL.
30
30
  readonly authorName: string | null;
31
31
  readonly body: string;
32
32
  readonly insertedAt: string;
33
33
  };
34
+
35
+ // Matches @cosmicdrift/kumiko-framework/crypto PII_ERASED_SENTINEL — cannot
36
+ // import that module from @runtime client (runtime-isolation).
37
+ const PII_ERASED_SENTINEL = "[[erased]]";
38
+
39
+ function displayAuthorName(authorName: string | null, unknownLabel: string): string {
40
+ return authorName && authorName !== PII_ERASED_SENTINEL ? authorName : unknownLabel;
41
+ }
34
42
  type NoteListResponse = { readonly rows: readonly NoteRow[] };
35
43
 
36
44
  export function NotesSection({
@@ -144,7 +152,7 @@ export function NotesSection({
144
152
  <Text testId={`notes-section-row-${n.id}-body`}>{n.body}</Text>
145
153
  <Text variant="small" testId={`notes-section-row-${n.id}-meta`}>
146
154
  {t("notesHistory.section.meta", {
147
- author: n.authorName ?? t("notesHistory.section.authorUnknown"),
155
+ author: displayAuthorName(n.authorName, t("notesHistory.section.authorUnknown")),
148
156
  date: formatWhen(n.insertedAt),
149
157
  })}
150
158
  </Text>
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createSecretsFeature } from "../feature";
3
+
4
+ describe("createSecretsFeature access/roles precedence", () => {
5
+ test("roles-only adopts the host role vocabulary", () => {
6
+ expect(() => createSecretsFeature({ roles: ["Admin"] })).not.toThrow();
7
+ });
8
+
9
+ test("access-only is accepted", () => {
10
+ expect(() => createSecretsFeature({ access: { openToAll: true } })).not.toThrow();
11
+ });
12
+
13
+ test("access + roles together fail fast", () => {
14
+ expect(() => createSecretsFeature({ access: { openToAll: true }, roles: ["Admin"] })).toThrow(
15
+ /either `access` or `roles`/,
16
+ );
17
+ });
18
+ });
@@ -77,12 +77,22 @@ describe("secrets — roles option (#2296)", () => {
77
77
  });
78
78
 
79
79
  test("the framework-default TenantAdmin role is denied once `roles` overrides it", async () => {
80
- const denied = await stack.http.writeErr(
80
+ const deniedSet = await stack.http.writeErr(
81
81
  "secrets:write:set",
82
82
  { key: "api.key.denied", value: "x" },
83
83
  admin,
84
84
  );
85
- expect(denied.code).toBe("access_denied");
85
+ expect(deniedSet.code).toBe("access_denied");
86
+
87
+ const deniedList = await stack.http.queryErr("secrets:query:list", {}, admin);
88
+ expect(deniedList.code).toBe("access_denied");
89
+
90
+ const deniedDelete = await stack.http.writeErr(
91
+ "secrets:write:delete",
92
+ { key: "api.key.denied" },
93
+ admin,
94
+ );
95
+ expect(deniedDelete.code).toBe("access_denied");
86
96
  });
87
97
  });
88
98
 
@@ -96,14 +96,21 @@ export type SecretsFeatureOptions = {
96
96
  * user manage secrets — note this is a much larger blast radius than the
97
97
  * default: tenant credentials (API keys, tokens) become writable/deletable,
98
98
  * and their previews/hints listable, by every tenant member, not just
99
- * admins. Takes precedence over `roles`. The `rotate` job is unaffected —
99
+ * admins. Mutually exclusive with `roles`. The `rotate` job is unaffected —
100
100
  * it's a manual, cross-tenant ops path, not per-tenant RBAC. */
101
101
  readonly access?: AccessRule;
102
- /** Shorthand for { access: { roles } }. Ignored when `access` is set. */
102
+ /** Shorthand for { access: { roles } }. Mutually exclusive with `access`. */
103
103
  readonly roles?: readonly string[];
104
104
  };
105
105
 
106
106
  function resolveSecretsAccess(opts: SecretsFeatureOptions): AccessRule {
107
+ if (opts.access !== undefined && opts.roles !== undefined) {
108
+ throw new InternalError({
109
+ message:
110
+ "createSecretsFeature: pass either `access` or `roles`, not both — " +
111
+ "`access` previously silently discarded `roles`, which looks like a refinement.",
112
+ });
113
+ }
107
114
  if (opts.access !== undefined) return opts.access;
108
115
  if (opts.roles !== undefined) return { roles: opts.roles };
109
116
  return DEFAULT_SECRETS_ACCESS;
@@ -21,7 +21,3 @@ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS)
21
21
  },
22
22
  });
23
23
  }
24
-
25
- // Backwards-compat: existing imports of `deleteWrite` keep working —
26
- // identical to createDeleteHandler() with the default access.
27
- export const deleteWrite = createDeleteHandler();
@@ -38,7 +38,3 @@ export function createListHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
38
38
  },
39
39
  });
40
40
  }
41
-
42
- // Backwards-compat: existing imports of `listQuery` keep working — identical
43
- // to createListHandler() with the default access.
44
- export const listQuery = createListHandler();
@@ -44,10 +44,6 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
44
44
  });
45
45
  }
46
46
 
47
- // Backwards-compat: existing imports of `setWrite` keep working — identical
48
- // to createSetHandler() with the default access.
49
- export const setWrite = createSetHandler();
50
-
51
47
  // Fallback redaction: show at most the first 3 chars + trailing bullets.
52
48
  // Deliberately conservative — a too-generous preview defeats the point.
53
49
  function defaultRedact(value: string): string {
@@ -13,11 +13,11 @@ export {
13
13
  TENANT_SECRET_READ_EVENT,
14
14
  tenantSecretsTable,
15
15
  } from "./feature";
16
- export { createDeleteHandler, deleteWrite } from "./handlers/delete.write";
17
- export { createListHandler, listQuery } from "./handlers/list.query";
16
+ export { createDeleteHandler } from "./handlers/delete.write";
17
+ export { createListHandler } from "./handlers/list.query";
18
18
  export {
19
19
  type RotateJobPayload,
20
20
  type RotateJobResult,
21
21
  rotateJob,
22
22
  } from "./handlers/rotate.job";
23
- export { createSetHandler, setWrite } from "./handlers/set.write";
23
+ export { createSetHandler } from "./handlers/set.write";
@@ -16,6 +16,7 @@ export { isWithinGracePeriod } from "./grace-period";
16
16
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
17
17
  export { mapWithConcurrency } from "./map-with-concurrency";
18
18
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
19
+ export { sessionField } from "./session-field";
19
20
  export { sessionLocaleField } from "./session-locale-field";
20
21
  export { sessionTimezoneField } from "./session-timezone-field";
21
22
  export type { SystemQueryFn } from "./system-query";
@@ -44,7 +44,7 @@ describe("mapWithConcurrency", () => {
44
44
  // `mapWithConcurrency` call already settled on the first one.
45
45
  test("a second rejection after the first doesn't surface as an unhandled rejection", async () => {
46
46
  const unhandled: unknown[] = [];
47
- const onUnhandledRejection = (reason: unknown): void => {
47
+ const onUnhandledRejection = (reason: unknown, _promise: Promise<unknown>): void => {
48
48
  unhandled.push(reason);
49
49
  };
50
50
  process.on("unhandledRejection", onUnhandledRejection);
@@ -68,7 +68,7 @@ describe("mapWithConcurrency", () => {
68
68
  // for a real unhandled rejection (if any) to fire.
69
69
  await new Promise((resolve) => setTimeout(resolve, 50));
70
70
  } finally {
71
- process.off("unhandledRejection" as never, onUnhandledRejection);
71
+ process.off("unhandledRejection", onUnhandledRejection);
72
72
  }
73
73
  expect(unhandled).toEqual([]);
74
74
  });
@@ -0,0 +1,10 @@
1
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ /** Omit null/undefined session keys so callers can spread without wiping defaults. */
4
+ export function sessionField<K extends "locale" | "timezone">(
5
+ key: K,
6
+ value: string | null | undefined,
7
+ ): Partial<Pick<SessionUser, K>> {
8
+ if (value === null || value === undefined) return {};
9
+ return { [key]: value } as Pick<SessionUser, K>;
10
+ }
@@ -6,6 +6,15 @@ describe("sessionLocaleField", () => {
6
6
  expect(sessionLocaleField("de-DE")).toEqual({ locale: "de-DE" });
7
7
  });
8
8
 
9
+ test("canonicalizes primary subtag", () => {
10
+ expect(sessionLocaleField("DE")).toEqual({ locale: "de" });
11
+ expect(sessionLocaleField("DE-at")).toEqual({ locale: "de-at" });
12
+ });
13
+
14
+ test("rejects malformed locale tags", () => {
15
+ expect(sessionLocaleField('"><img sr')).toEqual({});
16
+ });
17
+
9
18
  test("null locale → empty object", () => {
10
19
  expect(sessionLocaleField(null)).toEqual({});
11
20
  });
@@ -1,7 +1,10 @@
1
1
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
3
+ import { sessionField } from "./session-field";
2
4
 
3
5
  export function sessionLocaleField(
4
6
  locale: string | null | undefined,
5
7
  ): Pick<SessionUser, "locale"> | Record<string, never> {
6
- return locale !== null && locale !== undefined ? { locale } : {};
8
+ if (locale === null || locale === undefined || !isValidLocaleTag(locale)) return {};
9
+ return sessionField("locale", canonicalizeLocaleTag(locale));
7
10
  }
@@ -1,7 +1,8 @@
1
1
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { sessionField } from "./session-field";
2
3
 
3
4
  export function sessionTimezoneField(
4
5
  timezone: string | null | undefined,
5
6
  ): Pick<SessionUser, "timezone"> | Record<string, never> {
6
- return timezone !== null && timezone !== undefined ? { timezone } : {};
7
+ return sessionField("timezone", timezone);
7
8
  }
@@ -163,17 +163,11 @@ export function createSubscriptionStripeFeature(
163
163
 
164
164
  r.translations({
165
165
  keys: {
166
- "subscription-stripe.api-key": { de: "Stripe API Key", en: "Stripe API Key" },
167
- "subscription-stripe.webhook-secret": {
168
- de: "Stripe Webhook Secret",
169
- en: "Stripe Webhook Secret",
170
- },
171
- "subscription-stripe.billing-live": {
172
- de: "Stripe Billing live",
173
- en: "Stripe Billing Live",
174
- },
175
- "subscription-stripe.settings": { de: "Stripe", en: "Stripe" },
176
- "screen:subscription-stripe-system.title": { de: "Stripe", en: "Stripe" },
166
+ "subscription-stripe.api-key": { en: "Stripe API Key" },
167
+ "subscription-stripe.webhook-secret": { en: "Stripe Webhook Secret" },
168
+ "subscription-stripe.billing-live": { en: "Stripe Billing Live" },
169
+ "subscription-stripe.settings": { en: "Stripe" },
170
+ "screen:subscription-stripe-system.title": { en: "Stripe" },
177
171
  },
178
172
  });
179
173
 
@@ -48,7 +48,7 @@ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch
48
48
  }
49
49
 
50
50
  describe("createTagsFeature shape", () => {
51
- test("registers tag + tag-assignment entities, 5 write-handlers, 2 query-handlers", () => {
51
+ test("registers tag + tag-assignment entities, legacy + convention CRUD handlers", () => {
52
52
  const feature = createTagsFeature();
53
53
 
54
54
  expect(Object.keys(feature.entities ?? {})).toEqual(
@@ -62,17 +62,21 @@ describe("createTagsFeature shape", () => {
62
62
  expect.stringMatching(/delete-tag/),
63
63
  expect.stringMatching(/assign-tag/),
64
64
  expect.stringMatching(/remove-tag/),
65
+ "tag:create",
66
+ "tag:update",
67
+ "tag:delete",
65
68
  ]),
66
69
  );
67
- expect(Object.keys(feature.writeHandlers)).toHaveLength(5);
70
+ expect(Object.keys(feature.writeHandlers)).toHaveLength(8);
68
71
 
69
72
  expect(Object.keys(feature.queryHandlers)).toEqual(
70
73
  expect.arrayContaining([
71
74
  expect.stringMatching(/tag:list/),
72
75
  expect.stringMatching(/tag-assignment:list/),
76
+ "tag:detail",
73
77
  ]),
74
78
  );
75
- expect(Object.keys(feature.queryHandlers)).toHaveLength(2);
79
+ expect(Object.keys(feature.queryHandlers)).toHaveLength(3);
76
80
  });
77
81
  });
78
82
 
@@ -102,10 +106,19 @@ describe("createTagsFeature access-options", () => {
102
106
 
103
107
  test("access:{openToAll} applies to every write- and query-path", () => {
104
108
  const feature = createTagsFeature({ access: { openToAll: true } });
105
- for (const path of ["create-tag", "update-tag", "delete-tag", "assign-tag", "remove-tag"]) {
109
+ for (const path of [
110
+ "create-tag",
111
+ "update-tag",
112
+ "delete-tag",
113
+ "tag:create",
114
+ "tag:update",
115
+ "tag:delete",
116
+ "assign-tag",
117
+ "remove-tag",
118
+ ]) {
106
119
  expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
107
120
  }
108
- for (const query of ["tag:list", "tag-assignment:list"]) {
121
+ for (const query of ["tag:list", "tag:detail", "tag-assignment:list"]) {
109
122
  expect(rawQueryAccess(feature, query)).toEqual({ openToAll: true });
110
123
  }
111
124
  });