@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
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type DbConnection,
|
|
25
25
|
type TenantDb,
|
|
26
26
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
27
|
-
import type {
|
|
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
|
|
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
|
});
|
package/src/sessions/feature.ts
CHANGED
|
@@ -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(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
const queries = {
|
|
161
|
+
mine: r.queryHandler(mineQuery),
|
|
162
|
+
list: r.queryHandler(listQuery),
|
|
163
|
+
detail: r.queryHandler(detailQuery),
|
|
164
|
+
};
|
|
163
165
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
215
|
+
const bindAutoRevokeOnPasswordChange: BindAutoRevokeOnPasswordChange = (revoker) => {
|
|
216
|
+
// explicit constructor option wins over the runtime binding
|
|
217
|
+
autoRevoke ??= revoker;
|
|
218
|
+
};
|
|
207
219
|
|
|
208
|
-
|
|
220
|
+
r.translations({ keys: SESSIONS_I18N });
|
|
209
221
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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"]]),
|