@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.
- package/package.json +9 -9
- package/src/__tests__/cross-tenant-jobs-escape-hatch.test.ts +45 -0
- package/src/audit/feature.ts +99 -95
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +21 -13
- package/src/auth-mfa/feature.ts +16 -1
- package/src/auth-mfa/handlers/reencrypt.job.ts +7 -9
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +7 -2
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +9 -3
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +8 -2
- package/src/config/handlers/reencrypt.job.ts +8 -5
- package/src/crypto-shredding/feature.ts +18 -14
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +4 -1
- package/src/data-retention/feature.ts +21 -15
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +4 -2
- package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +8 -2
- package/src/files-tenant-data/changes.json +9 -1
- package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +8 -13
- package/src/files-tenant-data/index.ts +17 -8
- package/src/form-draft/feature.ts +13 -1
- package/src/form-draft/handlers/cleanup.job.ts +7 -11
- package/src/inbound-mail-foundation/feature.ts +18 -14
- package/src/jobs/__tests__/job-system-user.integration.test.ts +39 -30
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +3 -3
- package/src/jobs/handlers/retention-cleanup.job.ts +5 -3
- package/src/jobs/handlers/stale-run-sweep.job.ts +5 -3
- package/src/presets/__tests__/security-baseline.test.ts +60 -1
- package/src/presets/security-baseline.ts +1 -2
- package/src/rate-limiting/feature.ts +15 -11
- package/src/secrets/__tests__/rotate.integration.test.ts +4 -4
- package/src/secrets/changes.json +7 -0
- package/src/secrets/feature.ts +19 -4
- package/src/secrets/handlers/rotate.job.ts +7 -9
- package/src/sessions/__tests__/cleanup.integration.test.ts +5 -5
- package/src/sessions/feature.ts +142 -128
- package/src/sessions/handlers/cleanup.job.ts +7 -10
- package/src/tenant-lifecycle/feature.ts +13 -10
- package/src/tenant-settings/__tests__/tenant-currency-read-access.integration.test.ts +50 -0
- package/src/tenant-settings/constants.ts +6 -1
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +4 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +11 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
- package/src/user-data-rights/feature.ts +26 -20
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +8 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +8 -2
- 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(
|
|
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 {
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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-
|
|
207
|
-
// data-retention:retention-cleanup):
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
11
|
+
if (!ctx.systemDb) {
|
|
12
12
|
throw new InternalError({
|
|
13
13
|
message:
|
|
14
|
-
"[jobs:retention-cleanup] ctx.
|
|
14
|
+
"[jobs:retention-cleanup] ctx.systemDb missing — is r.systemScope() still set on the jobs feature?",
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
|
-
const db = ctx.
|
|
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.
|
|
18
|
+
if (!ctx.systemDb) {
|
|
19
19
|
throw new InternalError({
|
|
20
20
|
message:
|
|
21
|
-
"[jobs:stale-run-sweep] ctx.
|
|
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.
|
|
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 {
|
|
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
|
-
//
|
|
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(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
package/src/secrets/changes.json
CHANGED
|
@@ -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",
|
package/src/secrets/feature.ts
CHANGED
|
@@ -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
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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(
|
|
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
|