@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
@@ -24,7 +24,7 @@ import {
24
24
  type DbConnection,
25
25
  type TenantDb,
26
26
  } from "@cosmicdrift/kumiko-framework/db";
27
- import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
27
+ import type { JobContext, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
28
28
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
29
29
  import {
30
30
  decodeStoredEnvelope,
@@ -55,7 +55,11 @@ export type RotateJobResult = {
55
55
  readonly stoppedReason: ChunkedMigrationStopReason;
56
56
  };
57
57
 
58
- export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
58
+ export async function rotateJob(
59
+ rawPayload: Record<string, unknown>,
60
+ ctx: JobContext,
61
+ db: DbConnection,
62
+ ): Promise<void> {
59
63
  const payload = rawPayload as RotateJobPayload; // @cast-boundary engine-payload
60
64
  if (!ctx.masterKeyProvider) {
61
65
  throw new InternalError({
@@ -64,12 +68,6 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
64
68
  });
65
69
  }
66
70
  const provider = ctx.masterKeyProvider;
67
- if (!ctx.db) {
68
- throw new InternalError({
69
- message: "[secrets:rotate] ctx.db missing — job context requires a database connection.",
70
- });
71
- }
72
- const db = ctx.db as DbConnection; // @cast-boundary db-operator
73
71
  const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
74
72
  const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
75
73
  const deadline = payload.maxDurationMs
@@ -165,4 +163,4 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
165
163
  stoppedReason: outcome.stoppedReason,
166
164
  };
167
165
  ctx.log?.info?.(`[secrets:rotate] complete: ${JSON.stringify(result)}`);
168
- };
166
+ }
@@ -114,7 +114,7 @@ describe("sessions cleanup job — purge expired/revoked rows", () => {
114
114
  });
115
115
  expect(await countSessions()).toBe(2);
116
116
 
117
- await cleanupJob({}, jobCtx());
117
+ await cleanupJob({}, jobCtx(), stack.db);
118
118
 
119
119
  expect(await countSessions()).toBe(1);
120
120
  const [remaining] = await selectMany(stack.db, userSessionTable);
@@ -130,7 +130,7 @@ describe("sessions cleanup job — purge expired/revoked rows", () => {
130
130
  });
131
131
  expect(await countSessions()).toBe(1);
132
132
 
133
- await cleanupJob({}, jobCtx());
133
+ await cleanupJob({}, jobCtx(), stack.db);
134
134
 
135
135
  expect(await countSessions()).toBe(0);
136
136
  });
@@ -144,7 +144,7 @@ describe("sessions cleanup job — purge expired/revoked rows", () => {
144
144
  });
145
145
 
146
146
  // Default 30d window: 10d-old revoked row stays
147
- await cleanupJob({}, jobCtx());
147
+ await cleanupJob({}, jobCtx(), stack.db);
148
148
 
149
149
  expect(await countSessions()).toBe(1);
150
150
  });
@@ -158,7 +158,7 @@ describe("sessions cleanup job — purge expired/revoked rows", () => {
158
158
  });
159
159
 
160
160
  // Tight 3d window: the 5d row goes
161
- await cleanupJob({ olderThanDays: 3 }, jobCtx());
161
+ await cleanupJob({ olderThanDays: 3 }, jobCtx(), stack.db);
162
162
 
163
163
  expect(await countSessions()).toBe(0);
164
164
  });
@@ -174,7 +174,7 @@ describe("sessions cleanup job — purge expired/revoked rows", () => {
174
174
  }
175
175
  expect(await countSessions()).toBe(7);
176
176
 
177
- await cleanupJob({ olderThanDays: 30, batchSize: 2 }, jobCtx());
177
+ await cleanupJob({ olderThanDays: 30, batchSize: 2 }, jobCtx(), stack.db);
178
178
 
179
179
  expect(await countSessions()).toBe(0);
180
180
  });
@@ -3,7 +3,7 @@ import {
3
3
  type SessionStore,
4
4
  type SessionStoreProvider,
5
5
  } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
6
- import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
6
+ import { type DbConnection, deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
7
7
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { cleanupJob } from "./handlers/cleanup.job";
9
9
  import { detailQuery } from "./handlers/detail.query";
@@ -83,141 +83,155 @@ export function bindAutoRevokeFromFeature(
83
83
  // Not system-scoped: sessions live per tenant, and the handlers should only
84
84
  // see rows in the caller's active tenant.
85
85
  export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
86
- return defineFeature("sessions", (r) => {
87
- r.describe(
88
- "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
89
- );
90
- r.uiHints({
91
- displayLabel: "Sessions · Server-side Logout",
92
- category: "identity",
93
- recommended: false,
94
- });
95
- // sessionChecker reads read_users on every authenticated request (status
96
- // gate for locked accounts) — make that a boot-time dependency so a
97
- // sessions-without-user wiring fails validateBoot instead of 500ing live.
98
- // auth-foundation owns EXT_SESSION_STORE, which the useExtension below
99
- // registers against.
100
- r.requires("user", "auth-foundation");
101
- // store_user_sessions is a hot-path direct-write store: sessionCreator
102
- // inserts and the revoke handlers update rows WITHOUT emitting lifecycle
103
- // events (the row columns ARE the audit trail). Registering it as
104
- // r.entity would make it a rebuildable implicit projection whose replay
105
- // finds zero session events and swaps an empty shadow over the live
106
- // table — wiping every active session on the next projection rebuild
107
- // (#498/#494). r.storeTable keeps the migration DDL but opts the
108
- // table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
109
- // which are direct-write stores too.
110
- r.storeTable(
111
- deriveEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }),
112
- {
113
- reason: "read_side.user_sessions_direct_write",
114
- // sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
115
- piiEncryptedOnWrite: true,
116
- },
117
- );
86
+ return defineFeature(
87
+ "sessions",
88
+ (r) => {
89
+ r.describe(
90
+ "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
91
+ );
92
+ r.uiHints({
93
+ displayLabel: "Sessions · Server-side Logout",
94
+ category: "identity",
95
+ recommended: false,
96
+ });
97
+ // sessionChecker reads read_users on every authenticated request (status
98
+ // gate for locked accounts) — make that a boot-time dependency so a
99
+ // sessions-without-user wiring fails validateBoot instead of 500ing live.
100
+ // auth-foundation owns EXT_SESSION_STORE, which the useExtension below
101
+ // registers against.
102
+ r.requires("user", "auth-foundation");
103
+ // store_user_sessions is a hot-path direct-write store: sessionCreator
104
+ // inserts and the revoke handlers update rows WITHOUT emitting lifecycle
105
+ // events (the row columns ARE the audit trail). Registering it as
106
+ // r.entity would make it a rebuildable implicit projection whose replay
107
+ // finds zero session events and swaps an empty shadow over the live
108
+ // table — wiping every active session on the next projection rebuild
109
+ // (#498/#494). r.storeTable keeps the migration DDL but opts the
110
+ // table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
111
+ // which are direct-write stores too.
112
+ r.storeTable(
113
+ deriveEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }),
114
+ {
115
+ reason: "read_side.user_sessions_direct_write",
116
+ // sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
117
+ piiEncryptedOnWrite: true,
118
+ },
119
+ );
118
120
 
119
- // Self-registers as auth-foundation's sessionStore provider (#1371) —
120
- // wraps the same createSessionCallbacks() used by the manual
121
- // buildServer({ auth: { ... } }) wiring above; a future issue (#1372)
122
- // removes that manual wiring once the middleware resolves generically
123
- // via resolveSessionStore().
124
- r.useExtension(EXT_SESSION_STORE, "default", {
125
- build: (deps): SessionStore => {
126
- const callbacks = createSessionCallbacks({
127
- db: deps.db,
128
- ...(options?.expiresInMs !== undefined && { expiresInMs: options.expiresInMs }),
129
- });
130
- return {
131
- creator: callbacks.sessionCreator,
132
- revoker: callbacks.sessionRevoker,
133
- checker: callbacks.sessionChecker,
134
- massRevoker: callbacks.sessionMassRevoker,
135
- revokeAllOthers: callbacks.sessionRevokeAllOthers,
136
- };
137
- },
138
- } satisfies SessionStoreProvider);
121
+ // Self-registers as auth-foundation's sessionStore provider (#1371) —
122
+ // wraps the same createSessionCallbacks() used by the manual
123
+ // buildServer({ auth: { ... } }) wiring above; a future issue (#1372)
124
+ // removes that manual wiring once the middleware resolves generically
125
+ // via resolveSessionStore().
126
+ r.useExtension(EXT_SESSION_STORE, "default", {
127
+ build: (deps): SessionStore => {
128
+ const callbacks = createSessionCallbacks({
129
+ db: deps.db,
130
+ ...(options?.expiresInMs !== undefined && { expiresInMs: options.expiresInMs }),
131
+ });
132
+ return {
133
+ creator: callbacks.sessionCreator,
134
+ revoker: callbacks.sessionRevoker,
135
+ checker: callbacks.sessionChecker,
136
+ massRevoker: callbacks.sessionMassRevoker,
137
+ revokeAllOthers: callbacks.sessionRevokeAllOthers,
138
+ };
139
+ },
140
+ } satisfies SessionStoreProvider);
139
141
 
140
- // Custom domain-event for cross-instance access-invalidation (#1559).
141
- // r.defineEvent registers the schema so ctx.unsafeAppendEvent (revoke,
142
- // revoke-all-others) enforces it at append time. revoke-all-for-user
143
- // appends via the low-level append() instead (needs to anchor on
144
- // SYSTEM_TENANT_ID — see that handler for why) — append() does NOT
145
- // consult the registry, so that callsite parses against
146
- // sessionRevokedSchema explicitly, same guarantee via a different path.
147
- // No projection: the payload IS the read, consumed directly off the
148
- // event-store NOTIFY (#1560).
149
- r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema, { piiFields: "none" });
142
+ // Custom domain-event for cross-instance access-invalidation (#1559).
143
+ // r.defineEvent registers the schema so ctx.unsafeAppendEvent (revoke,
144
+ // revoke-all-others) enforces it at append time. revoke-all-for-user
145
+ // appends via the low-level append() instead (needs to anchor on
146
+ // SYSTEM_TENANT_ID — see that handler for why) — append() does NOT
147
+ // consult the registry, so that callsite parses against
148
+ // sessionRevokedSchema explicitly, same guarantee via a different path.
149
+ // No projection: the payload IS the read, consumed directly off the
150
+ // event-store NOTIFY (#1560).
151
+ r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema, { piiFields: "none" });
150
152
 
151
- const handlers = {
152
- revoke: r.writeHandler(revokeWrite),
153
- revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
154
- revokeAllForUser: r.writeHandler(revokeAllForUserWrite),
155
- };
156
- r.exposesApi("sessions.revokeAllForUser");
153
+ const handlers = {
154
+ revoke: r.writeHandler(revokeWrite),
155
+ revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
156
+ revokeAllForUser: r.writeHandler(revokeAllForUserWrite),
157
+ };
158
+ r.exposesApi("sessions.revokeAllForUser");
157
159
 
158
- const queries = {
159
- mine: r.queryHandler(mineQuery),
160
- list: r.queryHandler(listQuery),
161
- detail: r.queryHandler(detailQuery),
162
- };
160
+ const queries = {
161
+ mine: r.queryHandler(mineQuery),
162
+ list: r.queryHandler(listQuery),
163
+ detail: r.queryHandler(detailQuery),
164
+ };
163
165
 
164
- // Retention: chunked DELETE of expired/revoked rows. Manual trigger
165
- // only so dev environments don't churn. Ops wires a cron in the app's
166
- // dispatcher config when running a long-lived deployment.
167
- r.job("cleanup", { trigger: { manual: true } }, cleanupJob);
166
+ // Retention: chunked DELETE of expired/revoked rows. Manual trigger
167
+ // only so dev environments don't churn. Ops wires a cron in the app's
168
+ // dispatcher config when running a long-lived deployment.
169
+ r.job({
170
+ name: "cleanup",
171
+ trigger: { manual: true },
172
+ escapeHatch: { reason: "deletes expired session rows of every tenant" },
173
+ handler: (payload, ctx) =>
174
+ cleanupJob(
175
+ payload,
176
+ ctx,
177
+ ctx.db.unsafeRaw("deletes expired session rows of every tenant") as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
178
+ ),
179
+ });
168
180
 
169
- // Cross-feature entity hook on "user". `r.entityHook` (NOT `r.hook`) is
170
- // the supported cross-feature path: entity-keyed, not prefixed by the
171
- // registering feature. Fires after every successful write on any
172
- // user-entity handler; we only act when one of REVOKE_TRIGGERING_FIELDS
173
- // is part of the changes-delta the handler was given.
174
- //
175
- // Checking `changes[field] !== undefined` is cheaper and more correct
176
- // than diffing data vs previous — "undefined in changes" means "the
177
- // handler didn't touch this column", which is exactly the signal we
178
- // want to skip on. Works for both direct user:update calls and any
179
- // other handler that happens to write the column.
180
- //
181
- // Only `passwordHash` here: `status` changes go through
182
- // updateUserLifecycle(), which writes via the event-store executor
183
- // directly and never runs this postSave hook. restrict-account.write.ts
184
- // already revokes sessions explicitly for that transition, and
185
- // sessionChecker blocks a status-locked principal on its next request
186
- // regardless.
187
- const REVOKE_TRIGGERING_FIELDS = ["passwordHash"] as const;
188
- let autoRevoke = options?.autoRevokeOnPasswordChange;
189
- r.hook("postSave", { allOf: "user" }, async (ctx) => {
190
- // skip: nothing bound — stateless-JWT deployments without a runtime
191
- // that calls bindAutoRevokeOnPasswordChange keep the old behavior.
192
- if (!autoRevoke) return;
193
- // skip: brand-new user, no sessions can possibly exist yet. The
194
- // initial passwordHash on a user:create would trip the second guard
195
- // otherwise — every registration would do a mass-revoke roundtrip
196
- // for a user who literally has no rows in user_sessions.
197
- if (ctx.isNew) return;
198
- // skip: handler didn't touch any revoke-triggering field
199
- if (!REVOKE_TRIGGERING_FIELDS.some((field) => ctx.changes[field] !== undefined)) return;
200
- await autoRevoke(String(ctx.id));
201
- });
181
+ // Cross-feature entity hook on "user". `r.entityHook` (NOT `r.hook`) is
182
+ // the supported cross-feature path: entity-keyed, not prefixed by the
183
+ // registering feature. Fires after every successful write on any
184
+ // user-entity handler; we only act when one of REVOKE_TRIGGERING_FIELDS
185
+ // is part of the changes-delta the handler was given.
186
+ //
187
+ // Checking `changes[field] !== undefined` is cheaper and more correct
188
+ // than diffing data vs previous — "undefined in changes" means "the
189
+ // handler didn't touch this column", which is exactly the signal we
190
+ // want to skip on. Works for both direct user:update calls and any
191
+ // other handler that happens to write the column.
192
+ //
193
+ // Only `passwordHash` here: `status` changes go through
194
+ // updateUserLifecycle(), which writes via the event-store executor
195
+ // directly and never runs this postSave hook. restrict-account.write.ts
196
+ // already revokes sessions explicitly for that transition, and
197
+ // sessionChecker blocks a status-locked principal on its next request
198
+ // regardless.
199
+ const REVOKE_TRIGGERING_FIELDS = ["passwordHash"] as const;
200
+ let autoRevoke = options?.autoRevokeOnPasswordChange;
201
+ r.hook("postSave", { allOf: "user" }, async (ctx) => {
202
+ // skip: nothing bound — stateless-JWT deployments without a runtime
203
+ // that calls bindAutoRevokeOnPasswordChange keep the old behavior.
204
+ if (!autoRevoke) return;
205
+ // skip: brand-new user, no sessions can possibly exist yet. The
206
+ // initial passwordHash on a user:create would trip the second guard
207
+ // otherwise — every registration would do a mass-revoke roundtrip
208
+ // for a user who literally has no rows in user_sessions.
209
+ if (ctx.isNew) return;
210
+ // skip: handler didn't touch any revoke-triggering field
211
+ if (!REVOKE_TRIGGERING_FIELDS.some((field) => ctx.changes[field] !== undefined)) return;
212
+ await autoRevoke(String(ctx.id));
213
+ });
202
214
 
203
- const bindAutoRevokeOnPasswordChange: BindAutoRevokeOnPasswordChange = (revoker) => {
204
- // explicit constructor option wins over the runtime binding
205
- autoRevoke ??= revoker;
206
- };
215
+ const bindAutoRevokeOnPasswordChange: BindAutoRevokeOnPasswordChange = (revoker) => {
216
+ // explicit constructor option wins over the runtime binding
217
+ autoRevoke ??= revoker;
218
+ };
207
219
 
208
- r.translations({ keys: SESSIONS_I18N });
220
+ r.translations({ keys: SESSIONS_I18N });
209
221
 
210
- r.screen(sessionListScreen);
211
- r.screen(sessionDetailScreen);
212
- r.screen(sessionMineScreen);
213
- r.nav({
214
- id: "session-list",
215
- label: "sessions:nav.sessionList",
216
- icon: "list",
217
- screen: "sessions:screen:session-list",
218
- order: 10,
219
- });
222
+ r.screen(sessionListScreen);
223
+ r.screen(sessionDetailScreen);
224
+ r.screen(sessionMineScreen);
225
+ r.nav({
226
+ id: "session-list",
227
+ label: "sessions:nav.sessionList",
228
+ icon: "list",
229
+ screen: "sessions:screen:session-list",
230
+ order: 10,
231
+ });
220
232
 
221
- return { handlers, queries, bindAutoRevokeOnPasswordChange };
222
- });
233
+ return { handlers, queries, bindAutoRevokeOnPasswordChange };
234
+ },
235
+ { dedupeOptions: options ?? {} },
236
+ );
223
237
  }
@@ -18,7 +18,7 @@
18
18
  // seed). Mirror of secrets/retention.job in shape.
19
19
 
20
20
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
21
- import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
21
+ import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
22
22
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
23
23
  import { deleteStaleSessionsBatch } from "../db/queries/cleanup";
24
24
 
@@ -37,15 +37,12 @@ export type SessionCleanupResult = {
37
37
  readonly stoppedReason: "empty" | "timeout" | "signal";
38
38
  };
39
39
 
40
- export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
40
+ export async function cleanupJob(
41
+ rawPayload: Record<string, unknown>,
42
+ ctx: JobContext,
43
+ db: DbConnection,
44
+ ): Promise<void> {
41
45
  const payload = rawPayload as SessionCleanupPayload; // @cast-boundary engine-payload
42
- if (!ctx.db) {
43
- throw new InternalError({
44
- message: "[sessions:cleanup] ctx.db missing — job context requires a database connection.",
45
- });
46
- }
47
- const db = ctx.db as DbConnection;
48
-
49
46
  const olderThanDaysRaw = payload.olderThanDays ?? DEFAULT_OLDER_THAN_DAYS;
50
47
  const olderThanDays = Number(olderThanDaysRaw);
51
48
  if (!Number.isFinite(olderThanDays) || olderThanDays < 0 || !Number.isInteger(olderThanDays)) {
@@ -83,4 +80,4 @@ export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =
83
80
 
84
81
  const result: SessionCleanupResult = { deleted, batchesProcessed, stoppedReason };
85
82
  ctx.log?.info?.(`[sessions:cleanup] complete: ${JSON.stringify(result)}`);
86
- };
83
+ }
@@ -113,25 +113,28 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
113
113
  r.writeHandler(requestDestructionWrite);
114
114
  r.writeHandler(cancelDestructionWrite);
115
115
 
116
- r.job(
117
- "run-tenant-destruction",
118
- { trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
119
- async (_payload, ctx) => {
120
- if (!ctx.db || !ctx.registry) {
121
- throw new Error(
122
- "run-tenant-destruction: ctx.db + ctx.registry required (JobContext incomplete)",
123
- );
116
+ const RUN_TENANT_DESTRUCTION_REASON = "sweeps tenant destruction across every tenant";
117
+ r.job({
118
+ name: "run-tenant-destruction",
119
+ trigger: { cron: "0 * * * * *" },
120
+ concurrency: "skip",
121
+ escapeHatch: { reason: RUN_TENANT_DESTRUCTION_REASON },
122
+ handler: async (_payload, ctx) => {
123
+ if (!ctx.registry) {
124
+ throw new Error("run-tenant-destruction: ctx.registry required (JobContext incomplete)");
124
125
  }
125
126
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
126
127
  await runTenantDestructionSweep({
127
- db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection,
128
+ db: ctx.db.unsafeRaw(
129
+ RUN_TENANT_DESTRUCTION_REASON,
130
+ ) as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
128
131
  registry: ctx.registry,
129
132
  now: T.Now.instant(),
130
133
  log: (message) => ctx.log?.warn(message),
131
134
  fileProviderResolver: ctx._fileProviderResolver,
132
135
  });
133
136
  },
134
- );
137
+ });
135
138
 
136
139
  r.exposesApi("tenantLifecycle.runDestroySweep");
137
140
  });
@@ -0,0 +1,50 @@
1
+ // fw#2933: a `money` field's `currency: { kind: "tenant" }` declaration
2
+ // resolves in the renderer via config:query:values — this pins that a
3
+ // normal (non-admin) tenant user, not just an Admin, can read
4
+ // tenant-settings:config:currency through that query. Real HTTP via
5
+ // setupTestStack (never createTestDispatcher).
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import {
9
+ configValuesTable,
10
+ createConfigAccessorFactory,
11
+ createConfigFeature,
12
+ createConfigResolver,
13
+ } from "@cosmicdrift/kumiko-bundled-features/config";
14
+ import {
15
+ setupTestStack,
16
+ type TestStack,
17
+ TestUsers,
18
+ unsafePushTables,
19
+ } from "@cosmicdrift/kumiko-framework/stack";
20
+ import { TenantSettingsConfig } from "../constants";
21
+ import { createTenantSettingsFeature } from "../feature";
22
+
23
+ let stack: TestStack;
24
+
25
+ beforeAll(async () => {
26
+ const resolver = createConfigResolver();
27
+ stack = await setupTestStack({
28
+ features: [createConfigFeature(), createTenantSettingsFeature({ defaultCurrency: "GBP" })],
29
+ extraContext: ({ registry }) => ({
30
+ configResolver: resolver,
31
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
32
+ }),
33
+ });
34
+ await unsafePushTables(stack.db, { configValuesTable });
35
+ });
36
+
37
+ afterAll(async () => {
38
+ await stack.cleanup();
39
+ });
40
+
41
+ describe("tenant-settings currency — read access", () => {
42
+ test('a non-admin tenant user (roles: ["User"]) can read tenant-settings:config:currency via config:query:values', async () => {
43
+ const res = await stack.http.queryOk<Record<string, { value: unknown }>>(
44
+ "config:query:values",
45
+ {},
46
+ TestUsers.user,
47
+ );
48
+ expect(res[TenantSettingsConfig.currency]?.value).toBe("GBP");
49
+ });
50
+ });
@@ -1,3 +1,5 @@
1
+ import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-framework/engine";
2
+
1
3
  // tenant-settings bundle constants — feature-name + qualified config names.
2
4
  //
3
5
  // Motivation (solon#P19): apps kept hard-coding a single currency/locale
@@ -11,6 +13,9 @@ export const TENANT_SETTINGS_FEATURE_NAME = "tenant-settings";
11
13
  // ctx.config(...) reads. Clients reference the object instead of magic
12
14
  // strings (mirror tags' Handlers/Queries constants).
13
15
  export const TenantSettingsConfig = {
14
- currency: "tenant-settings:config:currency",
16
+ // Shared with engine/constants.ts's TENANT_CURRENCY_CONFIG_KEY — a
17
+ // `money` field's `currency: { kind: "tenant" }` declaration (fw#2933)
18
+ // resolves against this same key.
19
+ currency: TENANT_CURRENCY_CONFIG_KEY,
15
20
  locale: "tenant-settings:config:locale",
16
21
  } as const;
@@ -8,7 +8,7 @@
8
8
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
9
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
10
10
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
11
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
11
+ import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
12
12
  import { type JobContext, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
13
13
  import {
14
14
  createInMemoryFileProvider,
@@ -200,7 +200,9 @@ describe("run-forget-cleanup :: registered cron actually erases binaries (not ju
200
200
  if (!job) return;
201
201
 
202
202
  const ctx: JobContext = {
203
- db,
203
+ db: createTenantDb(db, TENANT, "tenant", undefined, undefined, undefined, {
204
+ unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
205
+ }),
204
206
  registry: stack.registry,
205
207
  configResolver: configResolverForJobCtx,
206
208
  systemUser: { id: SYSTEM_USER_ID, tenantId: TENANT, roles: ["all"] },
@@ -12,6 +12,7 @@
12
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
13
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
14
14
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
15
16
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
16
17
  import {
17
18
  setupTestStack,
@@ -127,7 +128,9 @@ describe("C6 default mail bridge :: forget cron sends deletion-executed without
127
128
  // EXAKT der prod-Job-Kontext: configResolver gesetzt, config undefined.
128
129
  // log required since #1572 persists incomplete[] via ctx.log.warn.
129
130
  const jobCtx = {
130
- db: stack.db,
131
+ db: createTenantDb(stack.db, TENANT_A, "tenant", undefined, undefined, undefined, {
132
+ unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
133
+ }),
131
134
  registry: stack.registry,
132
135
  configResolver,
133
136
  log: {
@@ -12,6 +12,7 @@
12
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
13
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
14
14
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
15
16
  import { type JobContext, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
16
17
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
18
  import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
@@ -146,7 +147,9 @@ describe("run-export-jobs cron-context", () => {
146
147
 
147
148
  // EXAKT der prod-Job-Kontext: configResolver gesetzt, config undefined.
148
149
  const jobCtx = {
149
- db: stack.db,
150
+ db: createTenantDb(stack.db, TENANT, "tenant", undefined, undefined, undefined, {
151
+ unsafeRaw: { reason: "processes pending export jobs across every tenant" },
152
+ }),
150
153
  registry: stack.registry,
151
154
  configResolver,
152
155
  _userId: SYSTEM_USER_ID,
@@ -19,6 +19,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
19
19
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
20
20
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
21
21
  import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
22
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
22
23
  import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
23
24
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
24
25
  import {
@@ -311,7 +312,13 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
311
312
  await seedMembership(CRON_USER, TENANT_A);
312
313
 
313
314
  const { log } = capturingJobLog();
314
- const jobCtx = { db: stack.db, registry: stack.registry, log };
315
+ const jobCtx = {
316
+ db: createTenantDb(stack.db, TENANT_A, "tenant", undefined, undefined, undefined, {
317
+ unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
318
+ }),
319
+ registry: stack.registry,
320
+ log,
321
+ };
315
322
  await job?.handler({}, jobCtx as unknown as JobContext);
316
323
 
317
324
  const row = await fetchUser(CRON_USER);
@@ -348,7 +355,9 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
348
355
  const { log, warns } = capturingJobLog();
349
356
  try {
350
357
  await job?.handler({}, {
351
- db: stack.db,
358
+ db: createTenantDb(stack.db, TENANT_A, "tenant", undefined, undefined, undefined, {
359
+ unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
360
+ }),
352
361
  registry: stack.registry,
353
362
  log,
354
363
  } as unknown as JobContext);
@@ -467,7 +467,9 @@ describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel"
467
467
  if (!job) return;
468
468
 
469
469
  const ctx: JobContext = {
470
- db: stack.db,
470
+ db: createTenantDb(stack.db, TENANT, "tenant", undefined, undefined, undefined, {
471
+ unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
472
+ }),
471
473
  registry: stack.registry,
472
474
  configResolver: createConfigResolver({
473
475
  appOverrides: new Map([[TENANT_MODEL_CONFIG_KEY, "single-user"]]),