@cosmicdrift/kumiko-bundled-features 0.274.0 → 0.275.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 (45) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/cross-tenant-jobs-escape-hatch.test.ts +45 -0
  3. package/src/audit/feature.ts +99 -95
  4. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +21 -13
  5. package/src/auth-mfa/feature.ts +16 -1
  6. package/src/auth-mfa/handlers/reencrypt.job.ts +7 -9
  7. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +7 -2
  8. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +9 -3
  9. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +8 -2
  10. package/src/config/handlers/reencrypt.job.ts +8 -5
  11. package/src/crypto-shredding/feature.ts +18 -14
  12. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +4 -1
  13. package/src/data-retention/feature.ts +21 -15
  14. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +4 -2
  15. package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +8 -2
  16. package/src/files-tenant-data/changes.json +9 -1
  17. package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +8 -13
  18. package/src/files-tenant-data/index.ts +17 -8
  19. package/src/form-draft/feature.ts +13 -1
  20. package/src/form-draft/handlers/cleanup.job.ts +7 -11
  21. package/src/inbound-mail-foundation/feature.ts +18 -14
  22. package/src/jobs/__tests__/job-system-user.integration.test.ts +39 -30
  23. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +3 -3
  24. package/src/jobs/handlers/retention-cleanup.job.ts +5 -3
  25. package/src/jobs/handlers/stale-run-sweep.job.ts +5 -3
  26. package/src/presets/__tests__/security-baseline.test.ts +60 -1
  27. package/src/presets/security-baseline.ts +1 -2
  28. package/src/rate-limiting/feature.ts +15 -11
  29. package/src/secrets/__tests__/rotate.integration.test.ts +4 -4
  30. package/src/secrets/changes.json +7 -0
  31. package/src/secrets/feature.ts +19 -4
  32. package/src/secrets/handlers/rotate.job.ts +7 -9
  33. package/src/sessions/__tests__/cleanup.integration.test.ts +5 -5
  34. package/src/sessions/feature.ts +142 -128
  35. package/src/sessions/handlers/cleanup.job.ts +7 -10
  36. package/src/tenant-lifecycle/feature.ts +13 -10
  37. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +4 -2
  38. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +4 -1
  39. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +4 -1
  40. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +11 -2
  41. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
  42. package/src/user-data-rights/feature.ts +26 -20
  43. package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +8 -2
  44. package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +8 -2
  45. package/src/workflow-runner/feature.ts +10 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.274.0",
3
+ "version": "0.275.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -129,12 +129,12 @@
129
129
  "./workflow-runner": "./src/workflow-runner/index.ts"
130
130
  },
131
131
  "dependencies": {
132
- "@cosmicdrift/kumiko-dispatcher-live": "0.274.0",
133
- "@cosmicdrift/kumiko-framework": "0.274.0",
134
- "@cosmicdrift/kumiko-headless": "0.274.0",
135
- "@cosmicdrift/kumiko-renderer": "0.274.0",
136
- "@cosmicdrift/kumiko-renderer-web": "0.274.0",
137
- "@cosmicdrift/kumiko-types": "0.274.0",
132
+ "@cosmicdrift/kumiko-dispatcher-live": "0.275.0",
133
+ "@cosmicdrift/kumiko-framework": "0.275.0",
134
+ "@cosmicdrift/kumiko-headless": "0.275.0",
135
+ "@cosmicdrift/kumiko-renderer": "0.275.0",
136
+ "@cosmicdrift/kumiko-renderer-web": "0.275.0",
137
+ "@cosmicdrift/kumiko-types": "0.275.0",
138
138
  "@mollie/api-client": "^4.5.0",
139
139
  "@node-rs/argon2": "^2.0.2",
140
140
  "@types/mailparser": "^3.4.6",
@@ -163,7 +163,7 @@
163
163
  ],
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
- "@cosmicdrift/kumiko-locale-de": "0.274.0",
167
- "@cosmicdrift/kumiko-locale-es": "0.274.0"
166
+ "@cosmicdrift/kumiko-locale-de": "0.275.0",
167
+ "@cosmicdrift/kumiko-locale-es": "0.275.0"
168
168
  }
169
169
  }
@@ -0,0 +1,45 @@
1
+ // fw#2914 — JobContext.db is tenant-filtered; these bundled jobs reach every tenant
2
+ // and only work because their registration declares escapeHatch.
3
+ import { describe, expect, test } from "bun:test";
4
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { createAuthMfaFeature } from "../auth-mfa/feature";
6
+ import { createDataRetentionFeature } from "../data-retention/feature";
7
+ import { createFilesTenantDataFeature } from "../files-tenant-data";
8
+ import { formDraftFeature } from "../form-draft/feature";
9
+ import { inboundMailFoundationFeature } from "../inbound-mail-foundation/feature";
10
+ import { createSecretsFeature } from "../secrets/feature";
11
+ import { createSessionsFeature } from "../sessions/feature";
12
+ import { createTenantLifecycleFeature } from "../tenant-lifecycle/feature";
13
+ import { createUserDataRightsFeature } from "../user-data-rights/feature";
14
+
15
+ const TEST_SECRET = "cross-tenant-jobs-escape-hatch-test-secret-0123456789";
16
+
17
+ const crossTenantJobs: ReadonlyArray<readonly [FeatureDefinition, string]> = [
18
+ [
19
+ createAuthMfaFeature({
20
+ setupTokenSecret: TEST_SECRET,
21
+ challengeTokenSecret: `${TEST_SECRET}-challenge`,
22
+ issuer: "Kumiko",
23
+ }),
24
+ "reencrypt",
25
+ ],
26
+ [createDataRetentionFeature(), "retention-cleanup"],
27
+ [createFilesTenantDataFeature(), "sweep-orphaned-derivatives"],
28
+ [formDraftFeature, "cleanup"],
29
+ [inboundMailFoundationFeature, "inbound-mail-retention"],
30
+ [createSecretsFeature(), "rotate"],
31
+ [createSessionsFeature(), "cleanup"],
32
+ [createTenantLifecycleFeature(), "run-tenant-destruction"],
33
+ [createUserDataRightsFeature(), "run-export-jobs"],
34
+ [createUserDataRightsFeature(), "run-forget-cleanup"],
35
+ ];
36
+
37
+ describe("bundled cross-tenant jobs declare escapeHatch (fw#2914)", () => {
38
+ for (const [feature, jobName] of crossTenantJobs) {
39
+ test(`${feature.name}:${jobName}`, () => {
40
+ const job = feature.jobs[jobName];
41
+ expect(job).toBeDefined();
42
+ expect(job?.escapeHatch?.reason.trim().length ?? 0).toBeGreaterThan(20);
43
+ });
44
+ }
45
+ });
@@ -23,108 +23,112 @@ import { AUDIT_I18N } from "./i18n";
23
23
  // (tracked with the snapshot/archive infrastructure that already exists in
24
24
  // the framework).
25
25
  export function createAuditFeature(): FeatureDefinition {
26
- return defineFeature("audit", (r) => {
27
- r.describe(
28
- "Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
29
- );
30
- r.uiHints({
31
- displayLabel: "Audit Log",
32
- category: "compliance",
33
- recommended: false,
34
- });
35
- r.translations({ keys: AUDIT_I18N });
36
- // Screens resolve actor names via tenant:query:members.
37
- r.requires("tenant");
26
+ return defineFeature(
27
+ "audit",
28
+ (r) => {
29
+ r.describe(
30
+ "Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
31
+ );
32
+ r.uiHints({
33
+ displayLabel: "Audit Log",
34
+ category: "compliance",
35
+ recommended: false,
36
+ });
37
+ r.translations({ keys: AUDIT_I18N });
38
+ // Screens resolve actor names via tenant:query:members.
39
+ r.requires("tenant");
38
40
 
39
- // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
40
- r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
41
+ // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
42
+ r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
41
43
 
42
- const queries = {
43
- list: r.queryHandler(listQuery),
44
- details: r.queryHandler(detailsQuery),
45
- };
44
+ const queries = {
45
+ list: r.queryHandler(listQuery),
46
+ details: r.queryHandler(detailsQuery),
47
+ };
46
48
 
47
- r.screen({
48
- id: AUDIT_LOG_SCREEN_ID,
49
- type: "projectionList",
50
- query: AuditQueries.list,
51
- columns: [
52
- {
53
- field: "createdAt",
54
- label: i18nKey("audit.log.col.when"),
55
- renderer: { format: "timestamp" },
56
- },
57
- { field: "type", label: i18nKey("audit.log.col.type") },
58
- { field: "createdBy", label: i18nKey("audit.log.col.actor") },
59
- ],
60
- searchable: true,
61
- defaultSort: { field: "createdAt", dir: "desc" },
62
- rowActions: [
63
- {
64
- kind: "navigate",
65
- id: "details",
66
- label: i18nKey("audit.log.details"),
67
- screen: AUDIT_LOG_DETAIL_SCREEN_ID,
68
- entityId: "id",
69
- rowClick: true,
70
- },
71
- ],
72
- pagination: "infinite",
73
- description:
74
- "Admin table of the tenant's audit-trail events with actor names and event-type/date filters; open it to browse recent changes and drill into a single event.",
75
- access: { roles: access.admin },
76
- });
77
- r.screen({
78
- id: AUDIT_LOG_DETAIL_SCREEN_ID,
79
- type: "projectionDetail",
80
- query: AuditQueries.details,
81
- idParam: "id",
82
- fieldLabels: {
83
- type: i18nKey("audit.log.col.type"),
84
- createdAt: i18nKey("audit.log.col.when"),
85
- aggregateType: i18nKey("audit.log.col.aggregateType"),
86
- aggregateId: i18nKey("audit.log.col.aggregateId"),
87
- createdBy: i18nKey("audit.log.col.actor"),
88
- id: i18nKey("audit.log.detail.field.id"),
89
- payload: i18nKey("audit.log.detail.payload"),
90
- metadata: i18nKey("audit.log.detail.metadata"),
91
- },
92
- layout: {
93
- sections: [
94
- {
95
- fields: [
96
- "type",
97
- { field: "createdAt", renderer: { format: "timestamp" } },
98
- "aggregateType",
99
- "aggregateId",
100
- "createdBy",
101
- "id",
102
- ],
103
- },
104
- // No section `title` here (fw#2312 label-dedup fix): each section
105
- // holds exactly one field, so a title would repeat the field's own
106
- // label (rendered by RenderField as the Field's heading) verbatim.
49
+ r.screen({
50
+ id: AUDIT_LOG_SCREEN_ID,
51
+ type: "projectionList",
52
+ query: AuditQueries.list,
53
+ columns: [
107
54
  {
108
- fields: [{ field: "payload", renderer: { format: "json" } }],
55
+ field: "createdAt",
56
+ label: i18nKey("audit.log.col.when"),
57
+ renderer: { format: "timestamp" },
109
58
  },
59
+ { field: "type", label: i18nKey("audit.log.col.type") },
60
+ { field: "createdBy", label: i18nKey("audit.log.col.actor") },
61
+ ],
62
+ searchable: true,
63
+ defaultSort: { field: "createdAt", dir: "desc" },
64
+ rowActions: [
110
65
  {
111
- fields: [{ field: "metadata", renderer: { format: "json" } }],
66
+ kind: "navigate",
67
+ id: "details",
68
+ label: i18nKey("audit.log.details"),
69
+ screen: AUDIT_LOG_DETAIL_SCREEN_ID,
70
+ entityId: "id",
71
+ rowClick: true,
112
72
  },
113
73
  ],
114
- },
115
- description:
116
- "Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
117
- listScreenId: AUDIT_LOG_SCREEN_ID,
118
- access: { roles: access.admin },
119
- });
120
- r.nav({
121
- id: "audit-log",
122
- label: "audit:nav.auditLog",
123
- icon: "file",
124
- screen: "audit:screen:audit-log",
125
- order: 30,
126
- });
74
+ pagination: "infinite",
75
+ description:
76
+ "Admin table of the tenant's audit-trail events with actor names and event-type/date filters; open it to browse recent changes and drill into a single event.",
77
+ access: { roles: access.admin },
78
+ });
79
+ r.screen({
80
+ id: AUDIT_LOG_DETAIL_SCREEN_ID,
81
+ type: "projectionDetail",
82
+ query: AuditQueries.details,
83
+ idParam: "id",
84
+ fieldLabels: {
85
+ type: i18nKey("audit.log.col.type"),
86
+ createdAt: i18nKey("audit.log.col.when"),
87
+ aggregateType: i18nKey("audit.log.col.aggregateType"),
88
+ aggregateId: i18nKey("audit.log.col.aggregateId"),
89
+ createdBy: i18nKey("audit.log.col.actor"),
90
+ id: i18nKey("audit.log.detail.field.id"),
91
+ payload: i18nKey("audit.log.detail.payload"),
92
+ metadata: i18nKey("audit.log.detail.metadata"),
93
+ },
94
+ layout: {
95
+ sections: [
96
+ {
97
+ fields: [
98
+ "type",
99
+ { field: "createdAt", renderer: { format: "timestamp" } },
100
+ "aggregateType",
101
+ "aggregateId",
102
+ "createdBy",
103
+ "id",
104
+ ],
105
+ },
106
+ // No section `title` here (fw#2312 label-dedup fix): each section
107
+ // holds exactly one field, so a title would repeat the field's own
108
+ // label (rendered by RenderField as the Field's heading) verbatim.
109
+ {
110
+ fields: [{ field: "payload", renderer: { format: "json" } }],
111
+ },
112
+ {
113
+ fields: [{ field: "metadata", renderer: { format: "json" } }],
114
+ },
115
+ ],
116
+ },
117
+ description:
118
+ "Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
119
+ listScreenId: AUDIT_LOG_SCREEN_ID,
120
+ access: { roles: access.admin },
121
+ });
122
+ r.nav({
123
+ id: "audit-log",
124
+ label: "audit:nav.auditLog",
125
+ icon: "file",
126
+ screen: "audit:screen:audit-log",
127
+ order: 30,
128
+ });
127
129
 
128
- return { queries };
129
- });
130
+ return { queries };
131
+ },
132
+ { dedupeOptions: {} },
133
+ );
130
134
  }
@@ -147,7 +147,7 @@ describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
147
147
  }),
148
148
  );
149
149
 
150
- await mfaReencryptJob({}, jobCtx());
150
+ await mfaReencryptJob({}, jobCtx(), stack.db);
151
151
 
152
152
  const afterJobRow = await readRawRow(user.id);
153
153
  expect(JSON.parse(afterJobRow.totpSecret).kekVersion).toBe(2);
@@ -222,12 +222,16 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
222
222
  child: () => capturingLog,
223
223
  };
224
224
 
225
- await mfaReencryptJob({}, {
226
- db: stack.db,
227
- registry: stack.registry,
228
- masterKeyProvider: mutableProvider,
229
- log: capturingLog,
230
- } as unknown as JobContext);
225
+ await mfaReencryptJob(
226
+ {},
227
+ {
228
+ db: stack.db,
229
+ registry: stack.registry,
230
+ masterKeyProvider: mutableProvider,
231
+ log: capturingLog,
232
+ } as unknown as JobContext,
233
+ stack.db,
234
+ );
231
235
 
232
236
  const completeLine = captured.info.find((line) =>
233
237
  line.includes("[auth-mfa:reencrypt] complete:"),
@@ -296,12 +300,16 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
296
300
  child: () => capturingLog,
297
301
  };
298
302
 
299
- await mfaReencryptJob({}, {
300
- db: stack.db,
301
- registry: stack.registry,
302
- masterKeyProvider: mutableProvider,
303
- log: capturingLog,
304
- } as unknown as JobContext);
303
+ await mfaReencryptJob(
304
+ {},
305
+ {
306
+ db: stack.db,
307
+ registry: stack.registry,
308
+ masterKeyProvider: mutableProvider,
309
+ log: capturingLog,
310
+ } as unknown as JobContext,
311
+ stack.db,
312
+ );
305
313
 
306
314
  const completeLine = captured.info.find((line) =>
307
315
  line.includes("[auth-mfa:reencrypt] complete:"),
@@ -1,3 +1,4 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
1
2
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { mfaRequiredConfigKey } from "./config";
3
4
  import { createDisableHandler } from "./handlers/disable.write";
@@ -137,7 +138,21 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
137
138
  // KEK-rotation for totpSecret (entity-field encryption). Manual
138
139
  // trigger — ops runs it once after adding a new master key version,
139
140
  // same operator workflow as config's own reencrypt job.
140
- r.job("reencrypt", { trigger: { manual: true } }, mfaReencryptJob);
141
+ r.job({
142
+ name: "reencrypt",
143
+ trigger: { manual: true },
144
+ escapeHatch: {
145
+ reason: "re-encrypts MFA secrets of every tenant's users with the new master key",
146
+ },
147
+ handler: (payload, ctx) =>
148
+ mfaReencryptJob(
149
+ payload,
150
+ ctx,
151
+ ctx.db.unsafeRaw(
152
+ "re-encrypts MFA secrets of every tenant's users with the new master key",
153
+ ) as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
154
+ ),
155
+ });
141
156
 
142
157
  r.entity("user-mfa", userMfaEntity);
143
158
 
@@ -36,7 +36,7 @@ import {
36
36
  type DbConnection,
37
37
  type TenantDb,
38
38
  } from "@cosmicdrift/kumiko-framework/db";
39
- import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
39
+ import type { JobContext, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
40
40
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
41
41
  import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
42
42
  import {
@@ -70,7 +70,11 @@ export type MfaReencryptJobResult = {
70
70
  readonly stoppedReason: ChunkedMigrationStopReason;
71
71
  };
72
72
 
73
- export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
73
+ export async function mfaReencryptJob(
74
+ rawPayload: Record<string, unknown>,
75
+ ctx: JobContext,
76
+ db: DbConnection,
77
+ ): Promise<void> {
74
78
  const payload = rawPayload as MfaReencryptJobPayload; // @cast-boundary engine-payload
75
79
  const maybeCipher = configuredEntityFieldEncryption();
76
80
  if (!maybeCipher) {
@@ -91,12 +95,6 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
91
95
  });
92
96
  }
93
97
  const provider = ctx.masterKeyProvider;
94
- if (!ctx.db) {
95
- throw new InternalError({
96
- message: "[auth-mfa:reencrypt] ctx.db missing — job context requires a database connection.",
97
- });
98
- }
99
- const db = ctx.db as DbConnection; // @cast-boundary db-operator
100
98
  // Undefined = per-subject crypto-shredding isn't configured for this app —
101
99
  // both fields are then plain envelope strings, no PII layer to peel.
102
100
  const piiKms = configuredPiiSubjectKms();
@@ -222,4 +220,4 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
222
220
  stoppedReason: outcome.stoppedReason,
223
221
  };
224
222
  ctx.log?.info?.(`[auth-mfa:reencrypt] complete: ${JSON.stringify(result)}`);
225
- };
223
+ }
@@ -17,7 +17,11 @@
17
17
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
18
18
  import { randomBytes } from "node:crypto";
19
19
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
20
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
20
+ import {
21
+ createEventStoreExecutor,
22
+ createTenantDb,
23
+ createUncheckedSystemDb,
24
+ } from "@cosmicdrift/kumiko-framework/db";
21
25
  import {
22
26
  access,
23
27
  createSystemConfig,
@@ -152,7 +156,8 @@ function racyJobCtx(
152
156
  },
153
157
  };
154
158
  return {
155
- db: stack.db,
159
+ db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
160
+ systemDb: createUncheckedSystemDb(createTenantDb(stack.db, SYSTEM_TENANT_ID, "system")),
156
161
  registry: stack.registry,
157
162
  masterKeyProvider: mutableProvider,
158
163
  configEncryption: racyCipher,
@@ -10,7 +10,11 @@
10
10
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
13
+ import {
14
+ createEventStoreExecutor,
15
+ createTenantDb,
16
+ createUncheckedSystemDb,
17
+ } from "@cosmicdrift/kumiko-framework/db";
14
18
  import {
15
19
  access,
16
20
  createSystemConfig,
@@ -134,13 +138,15 @@ function capturingLog(captured: CapturedLog): TestJobLog {
134
138
 
135
139
  function jobCtx(captured?: CapturedLog): Parameters<typeof reencryptJob>[1] {
136
140
  const log = captured ? capturingLog(captured) : noopLog;
141
+ const systemModeDb = createTenantDb(stack.db, SYSTEM_TENANT_ID, "system");
137
142
  return {
138
- db: stack.db,
143
+ db: systemModeDb,
144
+ systemDb: createUncheckedSystemDb(systemModeDb),
139
145
  registry: stack.registry,
140
146
  masterKeyProvider: mutableProvider,
141
147
  configEncryption: cipher,
142
148
  log,
143
- } as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
149
+ } as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/systemDb/registry/masterKeyProvider/configEncryption/log
144
150
  }
145
151
 
146
152
  beforeAll(async () => {
@@ -12,12 +12,17 @@
12
12
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
13
  import { randomBytes } from "node:crypto";
14
14
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
15
+ import {
16
+ createEventStoreExecutor,
17
+ createTenantDb,
18
+ createUncheckedSystemDb,
19
+ } from "@cosmicdrift/kumiko-framework/db";
16
20
  import {
17
21
  access,
18
22
  createSystemUser,
19
23
  createTenantConfig,
20
24
  defineFeature,
25
+ SYSTEM_TENANT_ID,
21
26
  } from "@cosmicdrift/kumiko-framework/engine";
22
27
  import { ESCAPE_HATCH_USED_SIGNAL } from "@cosmicdrift/kumiko-framework/pipeline";
23
28
  import {
@@ -111,7 +116,8 @@ function capturingLog(captured: CapturedLog): TestJobLog {
111
116
 
112
117
  function jobCtx(captured: CapturedLog): Parameters<typeof reencryptJob>[1] {
113
118
  return {
114
- db: stack.db,
119
+ db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
120
+ systemDb: createUncheckedSystemDb(createTenantDb(stack.db, SYSTEM_TENANT_ID, "system")),
115
121
  registry: stack.registry,
116
122
  masterKeyProvider: mutableProvider,
117
123
  configEncryption: cipher,
@@ -87,17 +87,20 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
87
87
  "[config:reencrypt] ctx.masterKeyProvider missing — wire it via extraContext.masterKeyProvider at boot.",
88
88
  });
89
89
  }
90
- if (!ctx.db) {
90
+ if (!ctx.registry) {
91
91
  throw new InternalError({
92
- message: "[config:reencrypt] ctx.db missing — job context requires a database connection.",
92
+ message: "[config:reencrypt] ctx.registry missing — job context requires the registry.",
93
93
  });
94
94
  }
95
- if (!ctx.registry) {
95
+ // config is r.systemScope(), so ctx.systemDb is always present on its jobs.
96
+ if (!ctx.systemDb) {
96
97
  throw new InternalError({
97
- message: "[config:reencrypt] ctx.registry missing job context requires the registry.",
98
+ message: "[config:reencrypt] ctx.systemDb missing on a system-scoped job",
98
99
  });
99
100
  }
100
- const db = ctx.db as DbConnection; // @cast-boundary db-operator
101
+ const db = ctx.systemDb.unsafeRaw(
102
+ "system-wide KEK rotation scans every tenant's encrypted config values",
103
+ ) as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
101
104
 
102
105
  const encryptedKeys = [...ctx.registry.getAllConfigKeys()]
103
106
  .filter(([, def]) => def.encrypted === true)
@@ -7,19 +7,23 @@ import {
7
7
  } from "./handlers/forget-subject.write";
8
8
 
9
9
  export function createCryptoShreddingFeature(): FeatureDefinition {
10
- return defineFeature(CRYPTO_SHREDDING_FEATURE_NAME, (r) => {
11
- r.describe(
12
- "Operator-level crypto-shredding trigger. `forget-subject` erases a user or tenant subject key in the configured KMS adapter, making every PII field encrypted under it permanently unreadable (reads render the `[[erased]]` sentinel), and appends a `subject-forgotten` audit event. Requires a KMS adapter (`runProdApp({ kms })`). The automated Art.-17 deletion pipeline in `user-data-rights` erases keys itself; this command covers manual forgets (authority requests, operator recovery, tenant destroy).",
13
- );
14
- r.uiHints({
15
- displayLabel: "Crypto-Shredding",
16
- category: "compliance",
17
- });
10
+ return defineFeature(
11
+ CRYPTO_SHREDDING_FEATURE_NAME,
12
+ (r) => {
13
+ r.describe(
14
+ "Operator-level crypto-shredding trigger. `forget-subject` erases a user or tenant subject key in the configured KMS adapter, making every PII field encrypted under it permanently unreadable (reads render the `[[erased]]` sentinel), and appends a `subject-forgotten` audit event. Requires a KMS adapter (`runProdApp({ kms })`). The automated Art.-17 deletion pipeline in `user-data-rights` erases keys itself; this command covers manual forgets (authority requests, operator recovery, tenant destroy).",
15
+ );
16
+ r.uiHints({
17
+ displayLabel: "Crypto-Shredding",
18
+ category: "compliance",
19
+ });
18
20
 
19
- // `reason` is operator free text and may name the subject; encrypting it under
20
- // the subject key is pointless (that key is being erased) — see #2776.
21
- r.defineEvent("subject-forgotten", subjectForgottenSchema, { piiFields: "none" });
22
- r.defineEvent("forget-denied", subjectForgetDeniedSchema, { piiFields: "none" });
23
- r.writeHandler(forgetSubjectWrite);
24
- });
21
+ // `reason` is operator free text and may name the subject; encrypting it under
22
+ // the subject key is pointless (that key is being erased) — see #2776.
23
+ r.defineEvent("subject-forgotten", subjectForgottenSchema, { piiFields: "none" });
24
+ r.defineEvent("forget-denied", subjectForgetDeniedSchema, { piiFields: "none" });
25
+ r.writeHandler(forgetSubjectWrite);
26
+ },
27
+ { dedupeOptions: {} },
28
+ );
25
29
  }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
15
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
16
17
  import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
17
18
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
18
19
  import {
@@ -278,7 +279,9 @@ describe("runRetentionCleanup :: real postgres", () => {
278
279
  if (!job) return;
279
280
 
280
281
  const ctx: JobContext = {
281
- db: stack.db,
282
+ db: createTenantDb(stack.db, T1, "tenant", undefined, undefined, undefined, {
283
+ unsafeRaw: { reason: "per-tenant fan-out cleanup using raw helpers" },
284
+ }),
282
285
  registry: stack.registry,
283
286
  systemUser: { id: "system", tenantId: T1, roles: ["all"] },
284
287
  log: noopLogger,
@@ -71,19 +71,23 @@ export function createDataRetentionFeature(): FeatureDefinition {
71
71
  r.exposesApi("retention.policyFor");
72
72
  r.queryHandler(policyForQuery);
73
73
 
74
- // S2.D2b — autonomer Retention-Cleanup. perTenant-Fan-out (ein Run pro
75
- // aktivem Tenant, wie soft-delete-cleanup): die job-DB ist NICHT
76
- // tenant-scoped, deshalb scoped der Runner jeden Delete explizit per
77
- // tenantId. Ohne diesen Cron werden Retention-Regeln zwar konfiguriert,
78
- // aber nie ausgefuehrt.
79
- r.job(
80
- "retention-cleanup",
81
- { trigger: { cron: "0 3 * * *" }, perTenant: true, concurrency: "skip" },
82
- async (_payload, ctx) => {
83
- if (!ctx.db || !ctx.registry) {
84
- throw new Error(
85
- "retention-cleanup: ctx.db + ctx.registry required (JobContext incomplete)",
86
- );
74
+ // S2.D2b — autonomous retention cleanup. perTenant fan-out (one run per
75
+ // active tenant, like soft-delete-cleanup): runRetentionCleanup's raw
76
+ // query helpers need the unfiltered runner, so the handler declares
77
+ // escapeHatch and reaches for ctx.db.unsafeRaw(). Without this cron,
78
+ // retention rules are configured but never enforced.
79
+ r.job({
80
+ name: "retention-cleanup",
81
+ trigger: { cron: "0 3 * * *" },
82
+ perTenant: true,
83
+ concurrency: "skip",
84
+ escapeHatch: {
85
+ reason:
86
+ "retention cleanup runs raw deletes and preset lookups; every statement filters by this run's tenantId",
87
+ },
88
+ handler: async (_payload, ctx) => {
89
+ if (!ctx.registry) {
90
+ throw new Error("retention-cleanup: ctx.registry required (JobContext incomplete)");
87
91
  }
88
92
  const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
89
93
  if (tenantId === undefined) {
@@ -91,7 +95,9 @@ export function createDataRetentionFeature(): FeatureDefinition {
91
95
  return;
92
96
  }
93
97
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
94
- const cleanupDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
98
+ const cleanupDb = ctx.db.unsafeRaw(
99
+ "retention cleanup runs raw deletes and preset lookups; every statement filters by this run's tenantId",
100
+ ) as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
95
101
  const tenantPreset = await resolveTenantRetentionPreset({
96
102
  db: cleanupDb,
97
103
  registry: ctx.registry,
@@ -111,6 +117,6 @@ export function createDataRetentionFeature(): FeatureDefinition {
111
117
  );
112
118
  }
113
119
  },
114
- );
120
+ });
115
121
  });
116
122
  }