@cosmicdrift/kumiko-bundled-features 0.274.0 → 0.276.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 (47) 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/tenant-settings/__tests__/tenant-currency-read-access.integration.test.ts +50 -0
  38. package/src/tenant-settings/constants.ts +6 -1
  39. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +4 -2
  40. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +4 -1
  41. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +4 -1
  42. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +11 -2
  43. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
  44. package/src/user-data-rights/feature.ts +26 -20
  45. package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +8 -2
  46. package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +8 -2
  47. package/src/workflow-runner/feature.ts +10 -3
@@ -57,7 +57,6 @@ import {
57
57
  TENANT_DESTRUCTION_STARTED_EVENT_QN,
58
58
  } from "../../tenant-lifecycle/constants";
59
59
  import { runTenantDestructionSweep } from "../../tenant-lifecycle/run-tenant-destroy";
60
- import { sweepOrphanedDerivativesJob } from "../handlers/sweep-orphaned-derivatives.job";
61
60
  import { createFilesTenantDataFeature } from "../index";
62
61
 
63
62
  const SET_PROFILE = "compliance-profiles:write:set-profile";
@@ -181,7 +180,10 @@ describe("files-tenant-data :: job registration", () => {
181
180
  test("registers sweepOrphanedDerivativesJob as files-tenant-data:job:sweep-orphaned-derivatives — the name jobs:write:trigger dispatches by", () => {
182
181
  const job = stack.registry.getJob(SWEEP_JOB);
183
182
  expect(job).toBeDefined();
184
- expect(job?.handler).toBe(sweepOrphanedDerivativesJob);
183
+ expect(typeof job?.handler).toBe("function");
184
+ expect(job?.escapeHatch?.reason).toBe(
185
+ "iterates every tenant and checks fileRef owners per tenant",
186
+ );
185
187
  expect(job?.trigger).toEqual({ manual: true });
186
188
  });
187
189
  });
@@ -64,7 +64,7 @@ function makeCtx(
64
64
  log: JobContext["log"] = noopLogger,
65
65
  ): JobContext {
66
66
  return {
67
- db: stack.db,
67
+ db: createTenantDb(stack.db, tenantA.tenantId, "tenant"),
68
68
  registry: stack.registry,
69
69
  systemUser: tenantA,
70
70
  log,
@@ -151,6 +151,7 @@ describe("sweepOrphanedDerivativesJob", () => {
151
151
  await sweepOrphanedDerivativesJob(
152
152
  {},
153
153
  makeCtx(async () => provider),
154
+ stack.db,
154
155
  );
155
156
 
156
157
  expect(await provider.exists(orphan)).toBe(false);
@@ -167,6 +168,7 @@ describe("sweepOrphanedDerivativesJob", () => {
167
168
  await sweepOrphanedDerivativesJob(
168
169
  {},
169
170
  makeCtx(async () => provider),
171
+ stack.db,
170
172
  );
171
173
 
172
174
  expect(await provider.exists(derivative)).toBe(true);
@@ -197,6 +199,7 @@ describe("sweepOrphanedDerivativesJob", () => {
197
199
  await sweepOrphanedDerivativesJob(
198
200
  {},
199
201
  makeCtx(async () => provider),
202
+ stack.db,
200
203
  );
201
204
 
202
205
  // A trashed (soft-deleted) row still legitimately backs its derivatives —
@@ -214,6 +217,7 @@ describe("sweepOrphanedDerivativesJob", () => {
214
217
  await sweepOrphanedDerivativesJob(
215
218
  { dryRun: true },
216
219
  makeCtx(async () => provider, log),
220
+ stack.db,
217
221
  );
218
222
 
219
223
  // Existence alone can't distinguish "correctly found and would delete the
@@ -240,6 +244,7 @@ describe("sweepOrphanedDerivativesJob", () => {
240
244
  await sweepOrphanedDerivativesJob(
241
245
  {},
242
246
  makeCtx(async () => provider),
247
+ stack.db,
243
248
  );
244
249
 
245
250
  expect(await provider.exists(orphanA)).toBe(false);
@@ -253,7 +258,7 @@ describe("sweepOrphanedDerivativesJob", () => {
253
258
  const orphan = derivativeOf(original);
254
259
  await provider.write(orphan, new Uint8Array([1]));
255
260
 
256
- await expect(sweepOrphanedDerivativesJob({}, makeCtx())).resolves.toBeUndefined();
261
+ await expect(sweepOrphanedDerivativesJob({}, makeCtx(), stack.db)).resolves.toBeUndefined();
257
262
 
258
263
  expect(await provider.exists(orphan)).toBe(true);
259
264
  });
@@ -268,6 +273,7 @@ describe("sweepOrphanedDerivativesJob", () => {
268
273
  await sweepOrphanedDerivativesJob(
269
274
  {},
270
275
  makeCtx(async () => provider),
276
+ stack.db,
271
277
  );
272
278
 
273
279
  expect(await provider.exists(original)).toBe(true);
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.275.0",
4
+ "type": "breaking",
5
+ "title": "sweepOrphanedDerivativesJob takes the raw DbConnection as a third argument; the job declares escapeHatch (fw#2914).",
6
+ "detail": "JobContext.db is now a tenant-filtered TenantDb. The sweep checks fileRef owners of every tenant, so its registration declares `escapeHatch` and passes `ctx.db.unsafeRaw(reason)` to `sweepOrphanedDerivativesJob(payload, ctx, db)`. Without the raw runner, the foreign-tenant owner lookup would be narrowed away and every derivative would look orphaned.",
7
+ "migration": "Direct callers of the exported `sweepOrphanedDerivativesJob` pass the raw `DbConnection` as the third argument. Apps that only mount the feature need no change."
8
+ }
9
+ ]
@@ -19,9 +19,9 @@
19
19
  // handling still applies per-key.
20
20
 
21
21
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
22
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
22
23
  import { parseDerivativeKey } from "@cosmicdrift/kumiko-framework/derivatives";
23
- import type { JobHandlerFn, TenantId } from "@cosmicdrift/kumiko-framework/engine";
24
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
24
+ import type { JobContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
25
25
  import {
26
26
  assertSafeStorageKey,
27
27
  type FileProviderResolver,
@@ -50,17 +50,12 @@ type DerivativeCandidate = {
50
50
  readonly provider: FileStorageProvider;
51
51
  };
52
52
 
53
- export const sweepOrphanedDerivativesJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
53
+ export async function sweepOrphanedDerivativesJob(
54
+ rawPayload: Record<string, unknown>,
55
+ ctx: JobContext,
56
+ db: DbConnection,
57
+ ): Promise<void> {
54
58
  const payload = sweepOrphanedDerivativesPayloadSchema.parse(rawPayload ?? {});
55
- if (!ctx.db) {
56
- throw new InternalError({
57
- message:
58
- "[files-tenant-data:sweep] ctx.db missing — job context requires a database connection.",
59
- });
60
- }
61
- const db = ctx.db;
62
- // Re-bound to a definitely-assigned const: TS control-flow narrowing from
63
- // the guard above doesn't cross into fillQueue's nested closure below.
64
59
  const maybeResolver = ctx._fileProviderResolver;
65
60
  if (!maybeResolver) {
66
61
  ctx.log?.warn(
@@ -170,4 +165,4 @@ export const sweepOrphanedDerivativesJob: JobHandlerFn = async (rawPayload, ctx)
170
165
  ctx.log?.info?.(
171
166
  `[files-tenant-data:sweep] complete dryRun=${dryRun} deleted=${outcome.migrated} wouldDelete=${dryRunWouldDelete} skipped=${outcome.skipped} failed=${outcome.failed} batches=${outcome.batchesProcessed} stoppedReason=${outcome.stoppedReason}`,
172
167
  );
173
- };
168
+ }
@@ -11,6 +11,7 @@
11
11
  // fileRef entity exists and the hooks work fine — same reasoning as
12
12
  // folders-user-data's optionalRequires("folders").
13
13
 
14
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
15
  import {
15
16
  defineFeature,
16
17
  EXT_STORAGE_PROVIDER,
@@ -39,15 +40,23 @@ export function createFilesTenantDataFeature(): FeatureDefinition {
39
40
  r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy: fileRefTenantDestroyHook });
40
41
  r.useExtension(EXT_STORAGE_PROVIDER, "fileRef", { destroyTenant: fileRefStorageDestroyHook });
41
42
 
42
- r.job(
43
- "sweep-orphaned-derivatives",
44
- {
45
- trigger: { manual: true },
46
- concurrency: "skip",
47
- schema: sweepOrphanedDerivativesPayloadSchema,
43
+ r.job({
44
+ name: "sweep-orphaned-derivatives",
45
+ trigger: { manual: true },
46
+ concurrency: "skip",
47
+ schema: sweepOrphanedDerivativesPayloadSchema,
48
+ escapeHatch: {
49
+ reason: "iterates every tenant and checks fileRef owners per tenant",
48
50
  },
49
- sweepOrphanedDerivativesJob,
50
- );
51
+ handler: (payload, ctx) =>
52
+ sweepOrphanedDerivativesJob(
53
+ payload,
54
+ ctx,
55
+ ctx.db.unsafeRaw(
56
+ "iterates every tenant and checks fileRef owners per tenant",
57
+ ) as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
58
+ ),
59
+ });
51
60
  });
52
61
  }
53
62
 
@@ -5,6 +5,7 @@
5
5
  // savedAt } through save/discard/get. See entity.ts for the ownership +
6
6
  // uniqueness model.
7
7
 
8
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
9
  import { defineFeature, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
9
10
  import { FORM_DRAFT_FEATURE_NAME } from "./constants";
10
11
  import { formDraftEntity } from "./entity";
@@ -39,7 +40,18 @@ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>):
39
40
  r.queryHandler(listDraftsQuery);
40
41
 
41
42
  r.config({ keys: { retentionDays: formDraftRetentionDaysConfig } });
42
- r.job("cleanup", { trigger: { cron: "0 3 * * *" }, concurrency: "skip" }, cleanupDraftsJob);
43
+ r.job({
44
+ name: "cleanup",
45
+ trigger: { cron: "0 3 * * *" },
46
+ concurrency: "skip",
47
+ escapeHatch: { reason: "purges stale drafts of every tenant" },
48
+ handler: (payload, ctx) =>
49
+ cleanupDraftsJob(
50
+ payload,
51
+ ctx,
52
+ ctx.db.unsafeRaw("purges stale drafts of every tenant") as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
53
+ ),
54
+ });
43
55
  }
44
56
 
45
57
  export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
@@ -17,12 +17,11 @@ import {
17
17
  type ConfigKeyDefinition,
18
18
  createSystemConfig,
19
19
  createSystemUser,
20
- type JobHandlerFn,
20
+ type JobContext,
21
21
  SYSTEM_TENANT_ID,
22
22
  SYSTEM_USER_ID,
23
23
  type TenantId,
24
24
  } from "@cosmicdrift/kumiko-framework/engine";
25
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
26
25
  import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
27
26
  import {
28
27
  isDraftStillStale,
@@ -150,14 +149,11 @@ async function deleteStaleDraftsBatch(
150
149
  return deletedRows;
151
150
  }
152
151
 
153
- export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
154
- if (!ctx.db) {
155
- throw new InternalError({
156
- message: "[form-draft:cleanup] ctx.db required (JobContext incomplete)",
157
- });
158
- }
159
- const db = ctx.db as DbConnection;
160
-
152
+ export async function cleanupDraftsJob(
153
+ _payload: Record<string, unknown>,
154
+ ctx: JobContext,
155
+ db: DbConnection,
156
+ ): Promise<void> {
161
157
  const resolved = ctx.configResolver
162
158
  ? await ctx.configResolver.get(
163
159
  FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY,
@@ -201,4 +197,4 @@ export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
201
197
  if (deletedRows.length === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
202
198
  }
203
199
  ctx.log?.info?.(`[form-draft:cleanup] deleted=${deleted} retentionDays=${retentionDays}`);
204
- };
200
+ }
@@ -203,18 +203,22 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
203
203
  destroy: mailThreadTenantDestroyHook,
204
204
  });
205
205
 
206
- // Data-Retention (#957) — perTenant-Fan-out (ein Run pro Tenant, wie
207
- // data-retention:retention-cleanup): die job-DB ist NICHT tenant-scoped,
208
- // der Sweep scoped jeden Delete explizit per tenantId. Ohne diesen Cron
209
- // wachsen read_inbound_messages + die Bodies unbegrenzt (DSGVO Art. 5).
210
- r.job(
211
- "inbound-mail-retention",
212
- { trigger: { cron: "0 3 * * *" }, perTenant: true, concurrency: "skip" },
213
- async (_payload, ctx) => {
214
- if (!ctx.db || !ctx.registry) {
215
- throw new Error(
216
- "inbound-mail-retention: ctx.db + ctx.registry required (JobContext incomplete)",
217
- );
206
+ // Data-Retention (#957) — perTenant fan-out (one run per tenant, like
207
+ // data-retention:retention-cleanup): runInboundMailRetention's raw query
208
+ // helpers need the unfiltered runner, so the handler declares escapeHatch
209
+ // and reaches for ctx.db.unsafeRaw(). Without this cron, read_inbound_messages
210
+ // + the bodies grow unbounded (GDPR Art. 5).
211
+ const INBOUND_MAIL_RETENTION_REASON =
212
+ "inbound-mail retention runs raw deletes; every statement filters by this run's tenantId";
213
+ r.job({
214
+ name: "inbound-mail-retention",
215
+ trigger: { cron: "0 3 * * *" },
216
+ perTenant: true,
217
+ concurrency: "skip",
218
+ escapeHatch: { reason: INBOUND_MAIL_RETENTION_REASON },
219
+ handler: async (_payload, ctx) => {
220
+ if (!ctx.registry) {
221
+ throw new Error("inbound-mail-retention: ctx.registry required (JobContext incomplete)");
218
222
  }
219
223
  const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
220
224
  if (tenantId === undefined) {
@@ -222,7 +226,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
222
226
  return;
223
227
  }
224
228
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
225
- const retentionDb = ctx.db as DbConnection; // @cast-boundary db-operator
229
+ const retentionDb = ctx.db.unsafeRaw(INBOUND_MAIL_RETENTION_REASON) as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
226
230
 
227
231
  // Body-Objekte liegen in file-foundation. Provider lazy + memoized —
228
232
  // heute schreibt der Ingest kein bodyRef (storeBody-Hook ungebunden),
@@ -253,5 +257,5 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
253
257
  );
254
258
  }
255
259
  },
256
- );
260
+ });
257
261
  });
@@ -62,36 +62,45 @@ const billingFeature = defineFeature("billing", (r) => {
62
62
  // resolver.set escape hatch is gone. checkWriteAccess grants a
63
63
  // SYSTEM_ROLE caller the right to write system-only keys that Admin
64
64
  // cannot touch, so the security invariant (see Admin test below) holds.
65
- r.job("calculateTotal", { trigger: { manual: true } }, async (_payload, ctx) => {
66
- const systemUser = ctx["systemUser"] as SessionUser;
67
- const jobDb = ctx["db"] as DbConnection;
68
- const reg = ctx["registry"] as Registry;
69
-
70
- ctx.log?.info("Calculating monthly total...");
71
- const total = 42000;
72
-
73
- const handler = reg.getWriteHandler("config:write:set");
74
- if (handler) {
75
- const parsed = handler.schema.parse({
76
- key: "billing:config:monthly-total",
77
- value: total,
78
- });
79
- const tenantDb = createTenantDb(jobDb, systemUser.tenantId, "system");
80
- await handler.handler(
81
- { type: "config:write:set", payload: parsed, user: systemUser },
82
- {
83
- db: tenantDb,
84
- dbOutsideTransaction: tenantDb,
85
- systemDb: createUncheckedSystemDb(tenantDb),
86
- registry: reg,
87
- configResolver: ctx["configResolver"] as ConfigResolver,
88
- ...bridgeStub(),
89
- },
90
- );
91
- }
92
-
93
- ctx.log?.info(`Set monthlyTotal to ${total}`);
94
- });
65
+ r.job(
66
+ "calculateTotal",
67
+ {
68
+ trigger: { manual: true },
69
+ escapeHatch: { reason: "test job builds a system-mode handler context for config:write:set" },
70
+ },
71
+ async (_payload, ctx) => {
72
+ const systemUser = ctx["systemUser"] as SessionUser;
73
+ const jobDb = ctx.db.unsafeRaw(
74
+ "test job builds a system-mode handler context for config:write:set",
75
+ ) as DbConnection;
76
+ const reg = ctx["registry"] as Registry;
77
+
78
+ ctx.log?.info("Calculating monthly total...");
79
+ const total = 42000;
80
+
81
+ const handler = reg.getWriteHandler("config:write:set");
82
+ if (handler) {
83
+ const parsed = handler.schema.parse({
84
+ key: "billing:config:monthly-total",
85
+ value: total,
86
+ });
87
+ const tenantDb = createTenantDb(jobDb, systemUser.tenantId, "system");
88
+ await handler.handler(
89
+ { type: "config:write:set", payload: parsed, user: systemUser },
90
+ {
91
+ db: tenantDb,
92
+ dbOutsideTransaction: tenantDb,
93
+ systemDb: createUncheckedSystemDb(tenantDb),
94
+ registry: reg,
95
+ configResolver: ctx["configResolver"] as ConfigResolver,
96
+ ...bridgeStub(),
97
+ },
98
+ );
99
+ }
100
+
101
+ ctx.log?.info(`Set monthlyTotal to ${total}`);
102
+ },
103
+ );
95
104
  });
96
105
 
97
106
  const configFeature = createConfigFeature();
@@ -100,7 +100,7 @@ describe("reindexEntityJob", () => {
100
100
  expect(preResults).toHaveLength(0);
101
101
 
102
102
  const ctx: JobContext = {
103
- db: stack.db,
103
+ db: createTenantDb(stack.db, admin.tenantId),
104
104
  registry: stack.registry,
105
105
  searchAdapter: stack.search,
106
106
  systemUser: admin,
@@ -141,7 +141,7 @@ describe("reindexEntityJob", () => {
141
141
  if (!job) return;
142
142
 
143
143
  const ctx: JobContext = {
144
- db: stack.db,
144
+ db: createTenantDb(stack.db, admin.tenantId),
145
145
  registry: stack.registry,
146
146
  searchAdapter: stack.search,
147
147
  systemUser: admin,
@@ -205,7 +205,7 @@ describe("reindexEntityJob", () => {
205
205
  test("fails closed when ctx.systemDb is scoped to a different tenant than ctx.systemUser", async () => {
206
206
  const otherTenantId = TestUsers.otherTenant.tenantId;
207
207
  const ctx: JobContext = {
208
- db: stack.db,
208
+ db: createTenantDb(stack.db, admin.tenantId),
209
209
  registry: stack.registry,
210
210
  searchAdapter: stack.search,
211
211
  systemUser: admin,
@@ -8,13 +8,15 @@ export const DEFAULT_JOB_RUN_RETENTION_DAYS = 30;
8
8
 
9
9
  export function createRetentionCleanupJob(retentionDays: number): JobHandlerFn {
10
10
  return async (_payload, ctx) => {
11
- if (!ctx.db) {
11
+ if (!ctx.systemDb) {
12
12
  throw new InternalError({
13
13
  message:
14
- "[jobs:retention-cleanup] ctx.db missing — job context requires a database connection.",
14
+ "[jobs:retention-cleanup] ctx.systemDb missing — is r.systemScope() still set on the jobs feature?",
15
15
  });
16
16
  }
17
- const db = ctx.db as DbConnection; // @cast-boundary db-operator (matches sibling cron jobs)
17
+ const db = ctx.systemDb.unsafeRaw(
18
+ "store_job_runs retention purges finished runs of every tenant",
19
+ ) as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
18
20
  const result = await deleteStaleJobRuns(db, retentionDays);
19
21
  ctx.log?.info?.(`[jobs:retention-cleanup] complete: ${JSON.stringify(result)}`);
20
22
  };
@@ -15,13 +15,15 @@ export const DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS = 24;
15
15
 
16
16
  export function createStaleRunSweepJob(timeoutHours: number): JobHandlerFn {
17
17
  return async (_payload, ctx) => {
18
- if (!ctx.db) {
18
+ if (!ctx.systemDb) {
19
19
  throw new InternalError({
20
20
  message:
21
- "[jobs:stale-run-sweep] ctx.db missing — job context requires a database connection.",
21
+ "[jobs:stale-run-sweep] ctx.systemDb missing — is r.systemScope() still set on the jobs feature?",
22
22
  });
23
23
  }
24
- const db = ctx.db as DbConnection; // @cast-boundary db-operator (matches sibling cron jobs)
24
+ const db = ctx.systemDb.unsafeRaw(
25
+ "stale-run sweep marks stuck runs of every tenant as failed",
26
+ ) as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
25
27
  const result = await markStaleJobRunsFailed(db, timeoutHours);
26
28
  ctx.log?.info?.(`[jobs:stale-run-sweep] complete: ${JSON.stringify(result)}`);
27
29
  };
@@ -1,8 +1,29 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { SECURITY_BASELINE_FEATURE_NAMES } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { createAuthEmailPasswordFeature } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
3
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
4
+ import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
5
+ import { createCryptoShreddingFeature } from "@cosmicdrift/kumiko-bundled-features/crypto-shredding";
6
+ import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
7
+ import { createTenantFeature } from "@cosmicdrift/kumiko-bundled-features/tenant";
8
+ import { createUserFeature } from "@cosmicdrift/kumiko-bundled-features/user";
9
+ import { createUserDataRightsDefaultsFeature } from "@cosmicdrift/kumiko-bundled-features/user-data-rights-defaults";
10
+ import {
11
+ createRegistry,
12
+ defineFeature,
13
+ SECURITY_BASELINE_FEATURE_NAMES,
14
+ } from "@cosmicdrift/kumiko-framework/engine";
3
15
  import { dsgvoSelfServiceFeatures } from "../dsgvo-self-service";
4
16
  import { securityBaselineFeatures } from "../security-baseline";
5
17
 
18
+ // Minimal host every combo below needs: sessions requires user +
19
+ // auth-foundation, audit requires tenant, tenant requires config.
20
+ const HOST_FEATURES = [
21
+ createConfigFeature(),
22
+ createUserFeature(),
23
+ createTenantFeature(),
24
+ authFoundationFeature,
25
+ ];
26
+
6
27
  describe("securityBaselineFeatures", () => {
7
28
  test("returns exactly the SECURITY_BASELINE_FEATURE_NAMES feature names", () => {
8
29
  const names = securityBaselineFeatures().map((f) => f.name);
@@ -32,4 +53,42 @@ describe("securityBaselineFeatures", () => {
32
53
  const b = securityBaselineFeatures();
33
54
  expect(a[0]).not.toBe(b[0]);
34
55
  });
56
+
57
+ test("dsgvoSelfServiceFeatures() + securityBaselineFeatures() boots with exactly one sessions", () => {
58
+ // user-data-rights-defaults additionally requires "files" — a bare stub
59
+ // is enough here, its own domain logic isn't exercised by this test.
60
+ const filesStub = defineFeature("files", () => {});
61
+ const registry = createRegistry([
62
+ ...HOST_FEATURES,
63
+ createAuthEmailPasswordFeature({}),
64
+ filesStub,
65
+ createUserDataRightsDefaultsFeature(),
66
+ ...dsgvoSelfServiceFeatures(),
67
+ ...securityBaselineFeatures(),
68
+ ]);
69
+
70
+ expect(registry.getFeature("sessions")).toBeDefined();
71
+ });
72
+
73
+ test("standalone sessions + crypto-shredding mounted alongside the preset boots (offlot case)", () => {
74
+ const registry = createRegistry([
75
+ ...HOST_FEATURES,
76
+ createSessionsFeature(),
77
+ createCryptoShreddingFeature(),
78
+ ...securityBaselineFeatures(),
79
+ ]);
80
+
81
+ expect(registry.getFeature("sessions")).toBeDefined();
82
+ expect(registry.getFeature("crypto-shredding")).toBeDefined();
83
+ });
84
+
85
+ test("sessions mounted with different options than the preset's throws a clear boot error", () => {
86
+ expect(() =>
87
+ createRegistry([
88
+ ...HOST_FEATURES,
89
+ createSessionsFeature({ expiresInMs: 1000 }),
90
+ ...securityBaselineFeatures(),
91
+ ]),
92
+ ).toThrow(/Duplicate feature: "sessions".*different options/);
93
+ });
35
94
  });
@@ -8,8 +8,7 @@ export type SecurityBaselineOptions = {
8
8
  readonly includeSessions?: boolean;
9
9
  };
10
10
 
11
- // includeSessions: false when dsgvoSelfServiceFeatures() already mounts sessions (duplicate
12
- // feature names throw); host app must mount user, tenant, auth-foundation (sessions/audit requires).
11
+ // Host app must mount user, tenant, auth-foundation (sessions/audit requires).
13
12
  export function securityBaselineFeatures(opts: SecurityBaselineOptions = {}): FeatureDefinition[] {
14
13
  return [
15
14
  ...(opts.includeSessions !== false ? [createSessionsFeature()] : []),
@@ -10,15 +10,19 @@ import { rateLimitStatus } from "./handlers/status.query";
10
10
  // only use L3 (handler-opt-in) and don't need ops introspection can
11
11
  // skip this feature entirely — the resolver still runs.
12
12
  export function createRateLimitingFeature() {
13
- return defineFeature("rate-limiting", (r) => {
14
- r.describe(
15
- "Adds an ops-side `rate-limiting:query:status` query handler for inspecting current bucket state; the actual request throttling is wired automatically by the dispatcher when any handler declares a `rateLimit` option (e.g. `{ per: 'user', limit: 3, windowSeconds: 60 }`) or when you pass `context.rateLimit` to `buildServer`. Loading this feature is optional if you only need L3 per-handler rate limits and have no need for ops introspection.",
16
- );
17
- r.uiHints({
18
- displayLabel: "Rate Limiting · Ops Query",
19
- category: "operations",
20
- recommended: false,
21
- });
22
- r.queryHandler(rateLimitStatus);
23
- });
13
+ return defineFeature(
14
+ "rate-limiting",
15
+ (r) => {
16
+ r.describe(
17
+ "Adds an ops-side `rate-limiting:query:status` query handler for inspecting current bucket state; the actual request throttling is wired automatically by the dispatcher when any handler declares a `rateLimit` option (e.g. `{ per: 'user', limit: 3, windowSeconds: 60 }`) or when you pass `context.rateLimit` to `buildServer`. Loading this feature is optional if you only need L3 per-handler rate limits and have no need for ops introspection.",
18
+ );
19
+ r.uiHints({
20
+ displayLabel: "Rate Limiting · Ops Query",
21
+ category: "operations",
22
+ recommended: false,
23
+ });
24
+ r.queryHandler(rateLimitStatus);
25
+ },
26
+ { dedupeOptions: {} },
27
+ );
24
28
  }
@@ -140,7 +140,7 @@ describe("rotate-job circuit-breaker", () => {
140
140
 
141
141
  // maxFailures: 3 means the job gives up after 3 failed rows. Without
142
142
  // the breaker it would attempt all 20 and log 20 warns.
143
- await rotateJob({ batchSize: 10, maxFailures: 3 }, jobCtx(broken));
143
+ await rotateJob({ batchSize: 10, maxFailures: 3 }, jobCtx(broken), stack.db);
144
144
 
145
145
  // All 20 rows still at V1 — the broken provider never let any rewrap
146
146
  // succeed. Plus: the breaker tripped at ≤3 attempts, not 20.
@@ -151,7 +151,7 @@ describe("rotate-job circuit-breaker", () => {
151
151
  test("maxFailures=1 trips on the very first failure — single attempt then stop", async () => {
152
152
  const broken = createBrokenUnwrapProvider();
153
153
 
154
- await rotateJob({ batchSize: 10, maxFailures: 1 }, jobCtx(broken));
154
+ await rotateJob({ batchSize: 10, maxFailures: 1 }, jobCtx(broken), stack.db);
155
155
 
156
156
  // Same end-state (all rows on V1) but the internal counter proves the
157
157
  // breaker fired after exactly one failure instead of draining the batch.
@@ -167,7 +167,7 @@ describe("rotate-job circuit-breaker", () => {
167
167
  // honours its parameter rather than silently capping.
168
168
  const broken = createBrokenUnwrapProvider();
169
169
 
170
- await rotateJob({ batchSize: 10, maxFailures: 25 }, jobCtx(broken));
170
+ await rotateJob({ batchSize: 10, maxFailures: 25 }, jobCtx(broken), stack.db);
171
171
 
172
172
  expect(await countV1Rows()).toBe(20);
173
173
  expect(broken.calls()).toBe(25);
@@ -205,7 +205,7 @@ describe("rotate-job circuit-breaker", () => {
205
205
  );
206
206
  const ciphertextByKey = new Map(preRotation.map((row) => [row.key, row.envelope.ciphertext]));
207
207
 
208
- await rotateJob({ batchSize: 10, maxFailures: 5 }, jobCtx(rotator));
208
+ await rotateJob({ batchSize: 10, maxFailures: 5 }, jobCtx(rotator), stack.db);
209
209
 
210
210
  expect(await countV1Rows()).toBe(0);
211
211
 
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.275.0",
4
+ "type": "breaking",
5
+ "title": "rotateJob takes the raw DbConnection as a third argument; the rotate job declares escapeHatch (fw#2914).",
6
+ "detail": "JobContext.db is now a tenant-filtered TenantDb. Rotation re-encrypts every tenant's secrets, so the `rotate` registration declares `escapeHatch` and passes `ctx.db.unsafeRaw(reason)` to `rotateJob(payload, ctx, db)`.",
7
+ "migration": "Direct callers of the exported `rotateJob` pass the raw `DbConnection` as the third argument. Apps that only mount the feature need no change."
8
+ },
2
9
  {
3
10
  "version": "0.166.0",
4
11
  "type": "improvement",
@@ -1,3 +1,4 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
1
2
  import {
2
3
  type AccessRule,
3
4
  defineFeature,
@@ -157,16 +158,30 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
157
158
 
158
159
  // Per-tenant handlers (set/delete/list) run in the default tenant-scope,
159
160
  // giving them the automatic ctx.db tenant-filter as extra defense.
160
- // The rotation job deliberately reaches for ctx.db as DbConnection
161
- // (raw, cross-tenant) because rotation is a deployment-wide operation —
162
- // no feature-wide r.systemScope() needed.
161
+ // The rotation job deliberately reaches for ctx.db.unsafeRaw() (raw,
162
+ // cross-tenant) because rotation is a deployment-wide operation — no
163
+ // feature-wide r.systemScope() needed.
163
164
  r.writeHandler(createSetHandler(access));
164
165
  r.writeHandler(createDeleteHandler(access));
165
166
  r.queryHandler(createListHandler(access));
166
167
  // Manual-only by design: ops triggers rotation after a KEK version flip.
167
168
  // BullMQ delivers to exactly one worker, so running it against a busy
168
169
  // table on multiple instances is still safe.
169
- r.job("rotate", { trigger: { manual: true } }, rotateJob);
170
+ r.job({
171
+ name: "rotate",
172
+ trigger: { manual: true },
173
+ escapeHatch: {
174
+ reason: "re-encrypts every tenant's secrets with the new master key",
175
+ },
176
+ handler: (payload, ctx) =>
177
+ rotateJob(
178
+ payload,
179
+ ctx,
180
+ ctx.db.unsafeRaw(
181
+ "re-encrypts every tenant's secrets with the new master key",
182
+ ) as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
183
+ ),
184
+ });
170
185
 
171
186
  // Pre-ES had a separate `retention-cleanup` job scrubbing the audit
172
187
  // table on a compliance-driven schedule (90d default). Post-ES the