@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.
- package/package.json +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +6 -1
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/feature.ts +7 -4
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +2 -2
- package/src/tenant/handlers/update-member-roles.write.ts +1 -1
- package/src/tenant/i18n.ts +2 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +18 -5
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- 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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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(
|
|
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
|
});
|
package/src/jobs/feature.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
36
|
-
return { rows: await
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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:
|
|
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: {
|
|
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
|
|
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
|
|
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(() => {
|