@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
@@ -33,6 +33,10 @@ const FEATURE_NAME = "file-derivatives";
33
33
  // a name list here "for safety" — see #1985.
34
34
  const VARIANT_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
35
35
 
36
+ /** Host → tenantId for the anonymous media route.
37
+ * MUST be cheap/synchronous (in-memory map or cached lookup): it runs
38
+ * *before* the query-handler rate limit, so a DB round-trip here is an
39
+ * unthrottled DoS surface for anonymous callers rotating Host headers (#1977). */
36
40
  export type PublicVariantResolveApexTenant = (
37
41
  host: string,
38
42
  ) => Promise<TenantId | null> | TenantId | null;
@@ -42,7 +46,8 @@ export type FileDerivativesOptions = {
42
46
  * Without this option, neither the httpRoute NOR the `publicVariant`
43
47
  * query is registered — it is unreachable via the httpRoute path and via
44
48
  * the generic `/api` query dispatch. Existing consumers that only need
45
- * `ctx.derivatives` are unaffected. */
49
+ * `ctx.derivatives` are unaffected. See {@link PublicVariantResolveApexTenant}
50
+ * for the in-memory/sync contract. */
46
51
  readonly resolveApexTenant?: PublicVariantResolveApexTenant;
47
52
  /** Base path of the public variant route. Default "/media". */
48
53
  readonly basePath?: string;
@@ -112,12 +117,10 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
112
117
  // dispatch even when the httpRoute below isn't mounted, bypassing the
113
118
  // "tenantId comes from the host, never the payload" invariant that only
114
119
  // `resolveApexTenant` enforces.
115
- const queries: { publicVariant?: ReturnType<typeof r.queryHandler> } = {};
116
-
117
120
  if (opts.resolveApexTenant) {
118
121
  const resolveApexTenant = opts.resolveApexTenant;
119
122
 
120
- queries.publicVariant = r.queryHandler(publicVariantQuery);
123
+ r.queryHandler(publicVariantQuery);
121
124
 
122
125
  r.httpRoute({
123
126
  method: "GET",
@@ -180,8 +183,6 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
180
183
  },
181
184
  });
182
185
  }
183
-
184
- return { queries };
185
186
  });
186
187
  }
187
188
 
@@ -52,9 +52,8 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
52
52
  r.requires("file-foundation");
53
53
 
54
54
  const secretAccessKey = r.secret("s3.secretAccessKey", {
55
- label: { de: "S3 Secret Access Key", en: "S3 Secret Access Key" },
55
+ label: { en: "S3 Secret Access Key" },
56
56
  hint: {
57
- de: "Privater Teil des S3-Schlüsselpaares. Bei Hetzner Object Storage 'Secret Key', bei AWS S3 'Secret Access Key'.",
58
57
  en: "Private half of the S3 key pair. Hetzner calls it 'Secret Key', AWS calls it 'Secret Access Key'.",
59
58
  },
60
59
  redact: (plaintext) => {
@@ -165,15 +165,14 @@ async function dispatchCleanup(): Promise<void> {
165
165
 
166
166
  describe("form-draft cleanup job", () => {
167
167
  // FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY (cleanup.job.ts) is a hardcoded
168
- // literal that must match the qualified name r.config({ keys: {
169
- // retentionDays: ... } }) derives internally in feature.ts — nothing
170
- // else ties the two together, so a rename of the `retentionDays` config
171
- // key there would silently desync them. This recomputes the derivation
172
- // and fails loudly if that ever happens.
173
- test("FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY matches the qualified name feature.ts derives for retentionDays", () => {
174
- expect(FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY).toBe(
175
- qn(toKebab(FORM_DRAFT_FEATURE_NAME), "config", toKebab("retentionDays")),
168
+ // literal that must match a key actually registered by formDraftFeature.
169
+ // Assert against the feature's configKeys (not a hand-rebuilt qn string),
170
+ // so renaming the key in feature.ts without updating the constant fails.
171
+ test("FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY is among formDraftFeature registered config keys", () => {
172
+ const registered = Object.keys(formDraftFeature.configKeys ?? {}).map((key) =>
173
+ qn(toKebab(FORM_DRAFT_FEATURE_NAME), "config", toKebab(key)),
176
174
  );
175
+ expect(registered).toContain(FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY);
177
176
  });
178
177
 
179
178
  test("deletes a draft older than the default retention window, leaves a fresh one alone", async () => {
@@ -6,7 +6,11 @@
6
6
  // Chunked DELETE (default 1000/batch) keeps lock durations bounded, mirror
7
7
  // of sessions/handlers/cleanup.job.ts.
8
8
 
9
- import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
+ import {
10
+ createEventStoreExecutor,
11
+ createTenantDb,
12
+ type DbConnection,
13
+ } from "@cosmicdrift/kumiko-framework/db";
10
14
  import {
11
15
  type AppContext,
12
16
  access,
@@ -19,11 +23,16 @@ import {
19
23
  type TenantId,
20
24
  } from "@cosmicdrift/kumiko-framework/engine";
21
25
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
26
+ import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
22
27
  import { type StaleDraftRow, selectStaleDraftsBatch } from "../db/queries/cleanup";
23
- import { filterOwnedStorageKeys } from "../db/queries/owned-file-refs";
28
+ import { filterOwnedFileRefs } from "../db/queries/owned-file-refs";
24
29
  import { formDraftExecutor } from "../executor";
25
30
  import { collectDraftFileRefKeys, releaseDraftFileRefs } from "../release-file-refs";
26
31
 
32
+ const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
33
+ entityName: "fileRef",
34
+ });
35
+
27
36
  export const FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY = "form-draft:config:retention-days";
28
37
  export const FORM_DRAFT_DEFAULT_RETENTION_DAYS = 30;
29
38
  const DEFAULT_BATCH_SIZE = 1000;
@@ -48,27 +57,15 @@ async function releaseRowFileRefs(
48
57
  // skip: no FileRefs in this row's blob — nothing to release.
49
58
  if (keys.length === 0) return;
50
59
  if (!fileProviderResolver) {
51
- // skip: no resolver wired (files feature not mounted) — row still
52
- // gets deleted below, but its FileRefs leak as storage orphans.
53
60
  log?.warn?.(
54
61
  `[form-draft:cleanup] tenant=${row.tenantId} has ${keys.length} FileRef(s) but no _fileProviderResolver is wired — row will be deleted without releasing storage`,
55
62
  );
56
- // skip: warning already logged above — nothing more to do for this row.
63
+ // skip: no provider wired — cannot release storage; row deletion proceeds anyway.
57
64
  return;
58
65
  }
59
66
 
60
- // Only storageKeys with a real file_refs row owned by this row's draft
61
- // owner are releasable — `draft.values` is free-form JSON the owning user
62
- // controls, so an unverified key could target someone else's file (see
63
- // db/queries/owned-file-refs.ts). Not wrapped in try/catch: a query
64
- // failure here (pool exhaustion, missing table) is a real error, not the
65
- // "no provider resolvable" case below — let it propagate so the job retries.
66
- //
67
- // Create-mode draftKey (`${screenId}:new:${draftId}`) mints its draftId
68
- // lazily on the first step-change — a file uploaded before that exists
69
- // predates the draft row, so the insertedAt filter must not apply.
70
67
  const isCreateMode = row.draftKey.includes(":new:");
71
- const ownedKeys = await filterOwnedStorageKeys(
68
+ const ownedRefs = await filterOwnedFileRefs(
72
69
  db,
73
70
  row.tenantId,
74
71
  row.ownerId,
@@ -76,10 +73,23 @@ async function releaseRowFileRefs(
76
73
  row.insertedAt,
77
74
  isCreateMode,
78
75
  );
79
-
76
+ const systemUser = createSystemUser(row.tenantId);
77
+ const tenantDb = createTenantDb(db, row.tenantId, "system");
78
+ for (const ref of ownedRefs) {
79
+ const forgetResult = await fileRefExecutor.forget({ id: ref.id }, systemUser, tenantDb);
80
+ if (!forgetResult.isSuccess) {
81
+ log?.warn?.(
82
+ `[form-draft:cleanup] fileRef.forget failed for id=${ref.id} tenant=${row.tenantId}`,
83
+ );
84
+ }
85
+ }
80
86
  try {
81
87
  const provider = await fileProviderResolver(row.tenantId);
82
- await releaseDraftFileRefs(ownedKeys, (key) => provider.delete(key), log);
88
+ await releaseDraftFileRefs(
89
+ ownedRefs.map((ref) => ref.storageKey),
90
+ (key) => provider.delete(key),
91
+ log,
92
+ );
83
93
  } catch (err) {
84
94
  log?.warn?.(
85
95
  `[form-draft:cleanup] no file provider resolvable for tenant=${row.tenantId} — FileRefs NOT released (row still deleted): ${err instanceof Error ? err.message : String(err)}`,
@@ -132,9 +142,9 @@ async function deleteStaleDraftsBatch(
132
142
  }
133
143
 
134
144
  export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
135
- if (!ctx.db || !ctx.registry) {
145
+ if (!ctx.db) {
136
146
  throw new InternalError({
137
- message: "[form-draft:cleanup] ctx.db + ctx.registry required (JobContext incomplete)",
147
+ message: "[form-draft:cleanup] ctx.db required (JobContext incomplete)",
138
148
  });
139
149
  }
140
150
  const db = ctx.db as DbConnection;
@@ -163,6 +173,7 @@ export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
163
173
 
164
174
  let deleted = 0;
165
175
  while (true) {
176
+ if (ctx.signal?.aborted) break;
166
177
  const batch = await selectStaleDraftsBatch(db, retentionDays, DEFAULT_BATCH_SIZE);
167
178
  if (batch.length === 0) break;
168
179
 
@@ -66,17 +66,17 @@ export const discardDraftWrite = defineWriteHandler({
66
66
  existing.insertedAt,
67
67
  isCreateMode,
68
68
  );
69
- const refsByStorageKey = new Map(ownedRefs.map((ref) => [ref.storageKey, ref]));
69
+ // Fail-closed erase of file_refs rows FIRST (outside the best-effort
70
+ // storage-hygiene loop). releaseDraftFileRefs swallows per-key errors
71
+ // by design — routing forget through it would report discarded:true
72
+ // while leaving the row behind (#1922 / review #2009).
73
+ for (const ref of ownedRefs) {
74
+ const forgetResult = await fileRefExecutor.forget({ id: ref.id }, event.user, ctx.db);
75
+ if (!forgetResult.isSuccess) return forgetResult;
76
+ }
70
77
  await releaseDraftFileRefs(
71
78
  ownedRefs.map((ref) => ref.storageKey),
72
79
  async (key) => {
73
- // Hard-erase via the executor first (fileRef.forgotten — row +
74
- // event, rebuild-safe), then the binary. A raw provider.delete()
75
- // alone left the file_refs row behind forever (kumiko-framework
76
- // review #1922): no event, no projection update, the row just
77
- // pointed at a storage key that no longer existed.
78
- const ref = refsByStorageKey.get(key);
79
- if (ref !== undefined) await fileRefExecutor.forget({ id: ref.id }, event.user, ctx.db);
80
80
  await files.ref(key).delete();
81
81
  },
82
82
  ctx.log,
@@ -569,4 +569,64 @@ describe("watch-supervisor — multi-worker watch coordination (#1719)", () => {
569
569
  await supervisorB.stop();
570
570
  }
571
571
  });
572
+
573
+ test("losing the lease (renew fails) tears down the local watcher and lets the peer take over", async () => {
574
+ const admin = adminFor(4202);
575
+ const accountId = await connectSharedAccount(admin);
576
+ const lock = createDistributedLock(stack.redis.redis, "test-watch-lease-lost:");
577
+ const a = makeWatchIngestCounter();
578
+ const b = makeWatchIngestCounter();
579
+ const supervisorA = createInboundMailSupervisor({
580
+ providerCtx: { registry: stack.registry },
581
+ db,
582
+ dispatchWrite: a.dispatchWrite,
583
+ lock,
584
+ watchLeaseTtlSeconds: 1,
585
+ pollIntervalMs: 60_000,
586
+ });
587
+ const supervisorB = createInboundMailSupervisor({
588
+ providerCtx: { registry: stack.registry },
589
+ db,
590
+ dispatchWrite: b.dispatchWrite,
591
+ lock,
592
+ watchLeaseTtlSeconds: 1,
593
+ pollIntervalMs: 60_000,
594
+ });
595
+
596
+ try {
597
+ await supervisorA.start();
598
+ await waitFor(() => {
599
+ expect(isWatching(accountId)).toBe(true);
600
+ });
601
+
602
+ // B's own start() polls too but must not claim the watch lease — A
603
+ // already holds it. Priming `running` here (rather than only calling
604
+ // pollOnce() later) mirrors the "peer takes over" test above.
605
+ await supervisorB.start();
606
+
607
+ // Simulate the lease expiring behind A's back (Redis TTL lapse, or an
608
+ // operator flushing the key) by deleting it directly. A's next renew
609
+ // heartbeat (`lock.renew`, every ttl/3) then finds a token mismatch,
610
+ // reports failure, and must tear down A's local IDLE connection.
611
+ await stack.redis.redis.del(`test-watch-lease-lost:${accountId}`);
612
+
613
+ await waitFor(() => {
614
+ expect(isWatching(accountId)).toBe(false);
615
+ });
616
+
617
+ await supervisorB.pollOnce();
618
+ await waitFor(() => {
619
+ expect(isWatching(accountId)).toBe(true);
620
+ });
621
+
622
+ await seedInboundMessage(accountId, rawMsg({ providerMessageId: "mw-lost-1" }));
623
+ await waitFor(() => {
624
+ expect(b.watchIngestCount()).toBe(1);
625
+ });
626
+ expect(a.watchIngestCount()).toBe(0);
627
+ } finally {
628
+ await supervisorA.stop();
629
+ await supervisorB.stop();
630
+ }
631
+ });
572
632
  });
@@ -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
  });