@cosmicdrift/kumiko-bundled-features 0.220.1 → 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 (92) 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/signup-complete-screen.tsx +11 -11
  25. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  26. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  27. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  28. package/src/custom-fields/constants.ts +4 -3
  29. package/src/custom-fields/feature.ts +7 -8
  30. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  31. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  32. package/src/delivery/db/queries/preferences.ts +3 -2
  33. package/src/delivery/handlers/log.query.ts +1 -0
  34. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  35. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  36. package/src/file-derivatives/feature.ts +6 -1
  37. package/src/file-provider-s3/feature.ts +1 -2
  38. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  39. package/src/form-draft/handlers/cleanup.job.ts +31 -20
  40. package/src/form-draft/handlers/discard.write.ts +8 -8
  41. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  42. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  43. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
  44. package/src/jobs/feature.ts +7 -2
  45. package/src/jobs/handlers/detail.query.ts +22 -11
  46. package/src/jobs/handlers/list.query.ts +17 -6
  47. package/src/jobs/job-run-logger.ts +78 -36
  48. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  49. package/src/mail-transport-smtp/feature.ts +1 -2
  50. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  51. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  52. package/src/notes-history/handlers/add-note.write.ts +7 -3
  53. package/src/notes-history/web/notes-section.tsx +10 -2
  54. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  55. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  56. package/src/secrets/feature.ts +9 -2
  57. package/src/shared/index.ts +1 -0
  58. package/src/shared/map-with-concurrency.test.ts +2 -2
  59. package/src/shared/session-field.ts +10 -0
  60. package/src/shared/session-locale-field.test.ts +9 -0
  61. package/src/shared/session-locale-field.ts +4 -1
  62. package/src/shared/session-timezone-field.ts +2 -1
  63. package/src/subscription-stripe/feature.ts +5 -11
  64. package/src/tags/feature.ts +7 -4
  65. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  66. package/src/template-resolver/seeding.ts +78 -6
  67. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  68. package/src/template-resolver/web/client-plugin.tsx +2 -1
  69. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  70. package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
  71. package/src/tenant/handlers/members.query.ts +6 -1
  72. package/src/tenant/handlers/team-list.query.ts +2 -2
  73. package/src/tenant/handlers/update-member-roles.write.ts +1 -1
  74. package/src/tenant/i18n.ts +2 -2
  75. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  76. package/src/tenant-settings/feature.ts +6 -30
  77. package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
  78. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  79. package/src/user/__tests__/user.integration.test.ts +156 -118
  80. package/src/user/handlers/create.write.ts +5 -4
  81. package/src/user/handlers/detail.query.ts +24 -2
  82. package/src/user/handlers/list.query.ts +19 -26
  83. package/src/user/handlers/update-roles.ts +18 -5
  84. package/src/user/handlers/update.write.ts +6 -5
  85. package/src/user/roles-input-schema.ts +20 -0
  86. package/src/user/schema/user.ts +2 -3
  87. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  88. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  89. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  90. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
  91. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  92. package/src/user-profile/web/profile-screen.tsx +1 -1
@@ -99,6 +99,18 @@ describe("jobRun direct writes", () => {
99
99
  expect(logs).toHaveLength(1);
100
100
  });
101
101
 
102
+ test("rounds fractional job duration to an integer millisecond value", async () => {
103
+ await logger.onJobStart?.("example:job:timing", "bull-timing", {});
104
+ // Pass a fractional ms value directly — Temporal DurationLike.nanoseconds
105
+ // is absolute ns (5e8 = 500ms), not a sub-ms fraction.
106
+ await logger.onJobComplete?.("example:job:timing", "bull-timing", 150.5, []);
107
+
108
+ const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-timing" });
109
+ expect(runs).toHaveLength(1);
110
+ expect(Number.isInteger(runs[0]?.duration)).toBe(true);
111
+ expect(runs[0]?.duration).toBe(151);
112
+ });
113
+
102
114
  test("start + complete both act on the SAME row", async () => {
103
115
  await logger.onJobStart?.("example:job:stream", "bull-99", {});
104
116
  await logger.onJobComplete?.("example:job:stream", "bull-99", 10, []);
@@ -104,6 +104,28 @@ describe("jobs run-started payload under KMS", () => {
104
104
  expect(after["payload"]).toBe(PII_ERASED_SENTINEL);
105
105
  });
106
106
 
107
+ // Mirrors the log-message/error cases below: a key already erased by the
108
+ // time a new run starts must not blow up onJobStart itself — the run row
109
+ // still has to land, even if the triggering user's key is gone.
110
+ test("erase subject key BEFORE onJobStart: payload lands as [[erased]] instead of throwing", async () => {
111
+ // First run creates the subject's key — eraseKey is a no-op on an
112
+ // unknown subject (InMemoryKmsAdapter's tombstone contract), so the key
113
+ // has to exist before it can be erased.
114
+ await logger.onJobStart?.("app:job:export", "bull-2b", {
115
+ triggeredById: USER_ID,
116
+ payload: SECRET_PAYLOAD,
117
+ });
118
+ await kms.eraseKey({ kind: "user", userId: USER_ID });
119
+
120
+ await logger.onJobStart?.("app:job:export", "bull-2c", {
121
+ triggeredById: USER_ID,
122
+ payload: SECRET_PAYLOAD,
123
+ });
124
+
125
+ const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-2c" });
126
+ expect(row?.["payload"]).toBe(PII_ERASED_SENTINEL);
127
+ });
128
+
107
129
  test("system run (no triggeredById) stays plaintext — no subject to shred", async () => {
108
130
  await logger.onJobStart?.("app:job:cron-sweep", "bull-3", {
109
131
  payload: JSON.stringify({ scope: "all" }),
@@ -204,35 +226,104 @@ describe("jobs run-completed/-failed log messages under KMS (#2247)", () => {
204
226
  expect(logs[0]?.["message"]).toBe(SECRET_LOG);
205
227
  });
206
228
 
207
- // Documents a gap rather than fixing it: if the subject's key is erased
208
- // between onJobStart and onJobComplete, encryptLogMessages' getOrCreateDek
209
- // throws KeyErasedError — same propagate-on-erased-key behavior as
210
- // onJobStart's own encryptStartedPayload (no catch there either). Here the
211
- // status updateMany already ran before the log insert throws, so the run
212
- // is left "completed" with its log batch dropped instead of forging a
213
- // plaintext fallback. Same wedge-state class #2246 tracks (job runs stuck
214
- // after abnormal termination) — not something this PR fixes.
215
- test("erase subject key BEFORE onJobComplete: log encryption throws, run status already landed as completed", async () => {
229
+ // #2307: a key erased between onJobStart and onJobComplete no longer
230
+ // blows up the callback. encryptLogMessages catches KeyErasedError per
231
+ // log line and falls back to the sentinel (mirroring
232
+ // decryptPiiValueForSubject on the read side) instead of throwing and
233
+ // dropping the whole batch — the status updateMany already committed by
234
+ // this point, so a thrown error would lose the log batch for nothing.
235
+ test("erase subject key BEFORE onJobComplete: log message lands as [[erased]] instead of throwing", async () => {
216
236
  await logger.onJobStart?.("app:job:export", "bull-c5", {
217
237
  triggeredById: USER_ID,
218
238
  payload: SECRET_PAYLOAD,
219
239
  });
220
240
  await kms.eraseKey({ kind: "user", userId: USER_ID });
221
241
 
222
- let threw = false;
223
- try {
224
- await logger.onJobComplete?.("app:job:export", "bull-c5", 5, [
225
- { level: "info", message: SECRET_LOG, timestamp: Temporal.Now.instant() },
226
- ]);
227
- } catch {
228
- threw = true;
229
- }
230
- expect(threw).toBe(true);
242
+ await logger.onJobComplete?.("app:job:export", "bull-c5", 5, [
243
+ { level: "info", message: SECRET_LOG, timestamp: Temporal.Now.instant() },
244
+ ]);
231
245
 
232
246
  const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-c5" });
233
247
  expect(row?.["status"]).toBe("completed");
234
248
  const logs = await selectMany(testDb.db, jobRunLogsTable, { runId: await runIdFor("bull-c5") });
235
- expect(logs).toHaveLength(0);
249
+ expect(logs).toHaveLength(1);
250
+ expect(logs[0]?.["message"]).toBe(PII_ERASED_SENTINEL);
251
+ });
252
+ });
253
+
254
+ // #2307: onJobFailed's `error` argument was the one plaintext leak left in
255
+ // this table — encryptLogMessages already encrypted logs[].message under
256
+ // the same triggeredById, but the sibling `error` column (same underlying
257
+ // string in job-runner.ts, since it pushes errorMsg into both the log
258
+ // batch and the onJobFailed argument) stayed plaintext. Same subject, same
259
+ // encrypt-under-DEK treatment, same skip rules as the logs above.
260
+ describe("jobs run-failed error column under KMS (#2307)", () => {
261
+ const SECRET_ERROR = "export failed for iban DE89370400440532013000";
262
+
263
+ async function runIdFor(bullJobId: string): Promise<string> {
264
+ const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId });
265
+ return String(row?.["id"]);
266
+ }
267
+
268
+ test("onJobFailed: stored error column is ciphertext, decrypts to original", async () => {
269
+ await logger.onJobStart?.("app:job:export", "bull-e1", { triggeredById: USER_ID });
270
+ await logger.onJobFailed?.("app:job:export", "bull-e1", SECRET_ERROR, []);
271
+
272
+ const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e1") });
273
+ expect(isPiiCiphertext(row?.["error"])).toBe(true);
274
+ expect(String(row?.["error"])).toContain(`user:${USER_ID}`);
275
+
276
+ const back = await decryptPiiFieldValues({ error: row?.["error"] }, ["error"], kms, {
277
+ requestId: "t",
278
+ });
279
+ expect(back["error"]).toBe(SECRET_ERROR);
280
+ });
281
+
282
+ test("erase subject key after write → error column decrypts to [[erased]]", async () => {
283
+ await logger.onJobStart?.("app:job:export", "bull-e2", { triggeredById: USER_ID });
284
+ await logger.onJobFailed?.("app:job:export", "bull-e2", SECRET_ERROR, []);
285
+ const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e2") });
286
+
287
+ await kms.eraseKey({ kind: "user", userId: USER_ID });
288
+ const after = await decryptPiiFieldValues({ error: row?.["error"] }, ["error"], kms, {
289
+ requestId: "t",
290
+ });
291
+ expect(after["error"]).toBe(PII_ERASED_SENTINEL);
292
+ });
293
+
294
+ // Mirrors the log-message case above: a key erased between onJobStart and
295
+ // onJobFailed must not blow up the failure callback — the run's own
296
+ // failure still has to land, even if the triggering user's key is gone.
297
+ test("erase subject key BEFORE onJobFailed: error column lands as [[erased]] instead of throwing", async () => {
298
+ // payload creates the subject's key during onJobStart — eraseKey is a
299
+ // no-op on an unknown subject (InMemoryKmsAdapter's tombstone contract),
300
+ // so the key has to exist before it can be erased.
301
+ await logger.onJobStart?.("app:job:export", "bull-e3", {
302
+ triggeredById: USER_ID,
303
+ payload: SECRET_PAYLOAD,
304
+ });
305
+ await kms.eraseKey({ kind: "user", userId: USER_ID });
306
+
307
+ await logger.onJobFailed?.("app:job:export", "bull-e3", SECRET_ERROR, []);
308
+
309
+ const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e3") });
310
+ expect(row?.["status"]).toBe("failed");
311
+ expect(row?.["error"]).toBe(PII_ERASED_SENTINEL);
312
+ });
313
+
314
+ test("system run (no triggeredById) → error column stays plaintext", async () => {
315
+ await logger.onJobStart?.("app:job:cron-sweep", "bull-e4", {});
316
+ await logger.onJobFailed?.("app:job:cron-sweep", "bull-e4", "plain sweep failure", []);
317
+ const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e4") });
318
+ expect(row?.["error"]).toBe("plain sweep failure");
319
+ });
320
+
321
+ test("without a KMS the error column stays plaintext (rollout mode)", async () => {
322
+ await logger.onJobStart?.("app:job:export", "bull-e5", { triggeredById: USER_ID });
323
+ resetPiiSubjectKmsForTests();
324
+ await logger.onJobFailed?.("app:job:export", "bull-e5", SECRET_ERROR, []);
325
+ const row = await fetchOne(testDb.db, jobRunsTable, { id: await runIdFor("bull-e5") });
326
+ expect(row?.["error"]).toBe(SECRET_ERROR);
236
327
  });
237
328
  });
238
329
 
@@ -242,7 +333,7 @@ describe("jobs run-completed/-failed log messages under KMS (#2247)", () => {
242
333
  // jobs:query:details HTTP handler so a regression in those eleven lines
243
334
  // (wrong AAD field name, log-array key access, the row/log spread) fails a
244
335
  // test instead of shipping ciphertext to the job-run detail screen.
245
- describe("jobs:query:details decrypts log messages end-to-end (#2247)", () => {
336
+ describe("jobs:query:details decrypts log messages and error end-to-end (#2247, #2307)", () => {
246
337
  let detailStack: TestStack;
247
338
  let detailLogger: ReturnType<typeof createJobRunLogger>;
248
339
 
@@ -293,4 +384,51 @@ describe("jobs:query:details decrypts log messages end-to-end (#2247)", () => {
293
384
  expect(result.logs).toHaveLength(1);
294
385
  expect(result.logs[0]?.message).toBe(SECRET_DETAIL_LOG);
295
386
  });
387
+
388
+ test("jobs:query:details returns plaintext error even though the stored row is ciphertext", async () => {
389
+ const SECRET_DETAIL_ERROR = "export failed for iban DE89370400440532013000";
390
+ await detailLogger.onJobStart?.("app:job:export", "bull-detail-2", {
391
+ triggeredById: DETAIL_USER_ID,
392
+ });
393
+ await detailLogger.onJobFailed?.("app:job:export", "bull-detail-2", SECRET_DETAIL_ERROR, []);
394
+
395
+ const row = await fetchOne(detailStack.db, jobRunsTable, { bullJobId: "bull-detail-2" });
396
+ const runId = String(row?.["id"]);
397
+ expect(isPiiCiphertext(row?.["error"])).toBe(true);
398
+
399
+ const result = await detailStack.http.queryOk<{ error: string }>(
400
+ JobQueries.details,
401
+ { runId },
402
+ TestUsers.systemAdmin,
403
+ );
404
+
405
+ expect(result.error).toBe(SECRET_DETAIL_ERROR);
406
+ });
407
+
408
+ test("jobs:query:list returns plaintext payload + error even though the stored row is ciphertext", async () => {
409
+ const SECRET_LIST_ERROR = "export failed for iban DE89370400440532013000";
410
+ // Distinct jobName + filter — this describe block's beforeEach swaps in a
411
+ // brand-new KMS per test, so earlier tests' rows are still in this
412
+ // shared table but encrypted under keys the current KMS instance no
413
+ // longer holds. An unfiltered list() would sweep those up too and fail
414
+ // to decrypt them.
415
+ const jobName = "app:job:export-list-pii-test";
416
+ await detailLogger.onJobStart?.(jobName, "bull-detail-3", {
417
+ triggeredById: DETAIL_USER_ID,
418
+ payload: SECRET_PAYLOAD,
419
+ });
420
+ await detailLogger.onJobFailed?.(jobName, "bull-detail-3", SECRET_LIST_ERROR, []);
421
+
422
+ const row = await fetchOne(detailStack.db, jobRunsTable, { bullJobId: "bull-detail-3" });
423
+ expect(isPiiCiphertext(row?.["payload"])).toBe(true);
424
+ expect(isPiiCiphertext(row?.["error"])).toBe(true);
425
+
426
+ const result = await detailStack.http.queryOk<{
427
+ rows: readonly { id: string; payload: string | null; error: string | null }[];
428
+ }>(JobQueries.list, { jobName, limit: 50 }, TestUsers.systemAdmin);
429
+
430
+ const listed = result.rows.find((r) => r.id === String(row?.["id"]));
431
+ expect(listed?.payload).toBe(SECRET_PAYLOAD);
432
+ expect(listed?.error).toBe(SECRET_LIST_ERROR);
433
+ });
296
434
  });
@@ -56,7 +56,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
56
56
  // Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
57
57
  r.job(
58
58
  "projectionRebuild",
59
- { trigger: { manual: true }, schema: projectionRebuildPayloadSchema },
59
+ { trigger: { manual: true }, concurrency: "skip", schema: projectionRebuildPayloadSchema },
60
60
  projectionRebuildJob,
61
61
  );
62
62
 
@@ -66,7 +66,12 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
66
66
  // too, not just cron).
67
67
  r.job(
68
68
  "reindexEntity",
69
- { trigger: { manual: true }, perTenant: true, schema: reindexEntityPayloadSchema },
69
+ {
70
+ trigger: { manual: true },
71
+ perTenant: true,
72
+ concurrency: "skip",
73
+ schema: reindexEntityPayloadSchema,
74
+ },
70
75
  reindexEntityJob,
71
76
  );
72
77
 
@@ -2,9 +2,14 @@ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { z } from "zod";
5
- import { decryptStoredPii } from "../../shared";
5
+ import { decryptStoredPii, mapWithConcurrency } from "../../shared";
6
6
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
7
7
 
8
+ // Matches job-run-logger.ts's KMS_POOL_CONCURRENCY / the tenant handlers'
9
+ // KMS_POOL_CONCURRENCY — bounds concurrent decrypt calls against the KMS
10
+ // adapter's connection pool.
11
+ const KMS_POOL_CONCURRENCY = 4;
12
+
8
13
  export const detailQuery = defineQueryHandler({
9
14
  name: "details",
10
15
  // Post-ES: runId is the uuid aggregate-id of the jobRun event-stream.
@@ -30,6 +35,12 @@ export const detailQuery = defineQueryHandler({
30
35
  row["payload"] = await decryptStoredPii(row["payload"], "payload", "job-run-detail");
31
36
  }
32
37
 
38
+ // error is stored encrypted under the triggering user's DEK (#2307),
39
+ // same mechanism as row.payload above.
40
+ if (typeof row["error"] === "string") {
41
+ row["error"] = await decryptStoredPii(row["error"], "error", "job-run-detail");
42
+ }
43
+
33
44
  const logs = await selectMany(
34
45
  db,
35
46
  jobRunLogsTable,
@@ -40,16 +51,16 @@ export const detailQuery = defineQueryHandler({
40
51
  );
41
52
 
42
53
  // message is stored encrypted under the triggering user's DEK (#2247),
43
- // same mechanism as row.payload above.
44
- const decryptedLogs = await Promise.all(
45
- logs.map(async (log) => {
46
- if (typeof log["message"] !== "string") return log;
47
- return {
48
- ...log,
49
- message: await decryptStoredPii(log["message"], "message", "job-run-detail-log"),
50
- };
51
- }),
52
- );
54
+ // same mechanism as row.payload above. Bounded concurrency (#2307) —
55
+ // unbounded Promise.all would fire one decrypt per log line at once
56
+ // against the KMS adapter's pool.
57
+ const decryptedLogs = await mapWithConcurrency(logs, KMS_POOL_CONCURRENCY, async (log) => {
58
+ if (typeof log["message"] !== "string") return log;
59
+ return {
60
+ ...log,
61
+ message: await decryptStoredPii(log["message"], "message", "job-run-detail-log"),
62
+ };
63
+ });
53
64
 
54
65
  return { ...row, logs: decryptedLogs };
55
66
  },
@@ -2,12 +2,23 @@ import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { z } from "zod";
5
- import { decryptStoredPii } from "../../shared";
5
+ import { decryptStoredPii, mapWithConcurrency } from "../../shared";
6
6
  import { jobRunsTable } from "../job-run-table";
7
7
 
8
- async function decryptRunPayload<T extends Record<string, unknown>>(row: T): Promise<T> {
9
- if (typeof row["payload"] !== "string") return row;
10
- return { ...row, payload: await decryptStoredPii(row["payload"], "payload", "job-runs") };
8
+ const KMS_POOL_CONCURRENCY = 4;
9
+
10
+ async function decryptRunRow<T extends Record<string, unknown>>(row: T): Promise<T> {
11
+ let result = row;
12
+ if (typeof result["payload"] === "string") {
13
+ result = {
14
+ ...result,
15
+ payload: await decryptStoredPii(result["payload"], "payload", "job-runs"),
16
+ };
17
+ }
18
+ if (typeof result["error"] === "string") {
19
+ result = { ...result, error: await decryptStoredPii(result["error"], "error", "job-runs") };
20
+ }
21
+ return result;
11
22
  }
12
23
 
13
24
  export const listQuery = defineQueryHandler({
@@ -32,7 +43,7 @@ export const listQuery = defineQueryHandler({
32
43
  orderBy: { col: "id", direction: "desc" },
33
44
  limit: query.payload.limit ?? 50,
34
45
  });
35
- // payload is stored encrypted under the triggering user's DEK (#799).
36
- return { rows: await Promise.all(rows.map(decryptRunPayload)) };
46
+ // payload/error are stored encrypted under the triggering user's DEK (#799, #2307).
47
+ return { rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow) };
37
48
  },
38
49
  });
@@ -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(() => {