@cosmicdrift/kumiko-framework 0.344.0 → 0.345.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/dist/api/auth-routes.d.ts +1 -0
- package/dist/api/auth-routes.js +35 -5
- package/dist/crypto/system-event-pii.d.ts +2 -0
- package/dist/crypto/system-event-pii.js +4 -0
- package/dist/db/tenant-db.js +10 -10
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/files/file-routes.js +9 -7
- package/package.json +4 -4
- package/src/changes.json +7 -0
|
@@ -28,6 +28,7 @@ export type AuthRoutesConfig = {
|
|
|
28
28
|
loginErrorStatusMap?: Readonly<Record<string, number>>;
|
|
29
29
|
loginRateLimit?: LoginRateLimiter | null;
|
|
30
30
|
mfaVerifyHandler?: string;
|
|
31
|
+
switchTenantMfaGateHandler?: string;
|
|
31
32
|
mfaVerifyErrorStatusMap?: Readonly<Record<string, number>>;
|
|
32
33
|
mfaVerifyRateLimit?: LoginRateLimiter | null;
|
|
33
34
|
mfaPreauthEnableStartHandler?: string;
|
package/dist/api/auth-routes.js
CHANGED
|
@@ -336,6 +336,20 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
336
336
|
// shared "unknown" bucket.
|
|
337
337
|
const clientIpResolver = config.resolver ?? createClientIpResolver(config.trustedProxyHops ?? 0, "createAuthRoutes");
|
|
338
338
|
const getClientIp = (c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
|
|
339
|
+
async function switchTenantMfaGateResponse(c, user, targetTenantId) {
|
|
340
|
+
if (!config.switchTenantMfaGateHandler)
|
|
341
|
+
return undefined;
|
|
342
|
+
const gate = await dispatcher.write(config.switchTenantMfaGateHandler, { userId: user.id, tenantId: targetTenantId }, createSystemUser(user.tenantId));
|
|
343
|
+
if (!gate.isSuccess) {
|
|
344
|
+
const status = gate.error.httpStatus; // @cast-boundary engine-payload
|
|
345
|
+
return c.json({ isSuccess: false, error: gate.error }, status);
|
|
346
|
+
}
|
|
347
|
+
// @cast-boundary engine-payload — the gate handler's three-shape result
|
|
348
|
+
const gateData = gate.data;
|
|
349
|
+
if (gateData.kind === "mfa-gate-clear")
|
|
350
|
+
return undefined;
|
|
351
|
+
return pendingMfaStepResponse(c, gateData);
|
|
352
|
+
}
|
|
339
353
|
// Shared tail of every route that ends a request logged-in: create the
|
|
340
354
|
// session record (if wired), sign the JWT, set the auth+csrf cookies. Was
|
|
341
355
|
// duplicated 5x (login/signup-confirm/invite-accept-with-login/invite-
|
|
@@ -668,10 +682,8 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
668
682
|
schema: VerifyEmailBody,
|
|
669
683
|
});
|
|
670
684
|
}
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
// SessionUser → die Route mintet JWT + setzt Cookies (Auto-Login
|
|
674
|
-
// direkt nach Activation, kein zweiter Login-Roundtrip nötig).
|
|
685
|
+
// Signup confirm mints the session (auto-login) unless the MFA gate applies;
|
|
686
|
+
// then it answers with the MFA step only, no session or cookies.
|
|
675
687
|
if (config.signup) {
|
|
676
688
|
const sg = config.signup;
|
|
677
689
|
registerTokenRequestRoute({
|
|
@@ -696,6 +708,8 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
696
708
|
}
|
|
697
709
|
// @cast-boundary engine-payload — generic dispatcher.write result for signup-confirm
|
|
698
710
|
const data = result.data;
|
|
711
|
+
if (data.kind !== "auth-session")
|
|
712
|
+
return pendingMfaStepResponse(c, data);
|
|
699
713
|
// Session creation + JWT sign + cookies — see mintSessionAndRespond.
|
|
700
714
|
const token = await mintSessionAndRespond(c, data.session);
|
|
701
715
|
const landingPath = landingPathFragment({
|
|
@@ -824,7 +838,10 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
824
838
|
const status = result.error.httpStatus; // @cast-boundary engine-payload
|
|
825
839
|
return c.json({ isSuccess: false, error: result.error }, status);
|
|
826
840
|
}
|
|
827
|
-
|
|
841
|
+
// @cast-boundary engine-payload — same three-shape union as invite-accept-with-login
|
|
842
|
+
const data = result.data;
|
|
843
|
+
if (data.kind !== "auth-session")
|
|
844
|
+
return pendingMfaStepResponse(c, data);
|
|
828
845
|
const token = await mintSessionAndRespond(c, data.session);
|
|
829
846
|
const landingPath = landingPathFragment({
|
|
830
847
|
flow: "invite",
|
|
@@ -982,6 +999,9 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
982
999
|
// membership role that a projection rebuild resurrected past command-time
|
|
983
1000
|
// validation (see engine/membership-roles).
|
|
984
1001
|
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
1002
|
+
const pendingMfaStep = await switchTenantMfaGateResponse(c, user, targetTenantId);
|
|
1003
|
+
if (pendingMfaStep !== undefined)
|
|
1004
|
+
return pendingMfaStep;
|
|
985
1005
|
const targetSession = {
|
|
986
1006
|
id: user.id,
|
|
987
1007
|
tenantId: targetTenantId,
|
|
@@ -1008,6 +1028,16 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
1008
1028
|
});
|
|
1009
1029
|
return api;
|
|
1010
1030
|
}
|
|
1031
|
+
function pendingMfaStepResponse(c, step) {
|
|
1032
|
+
if (step.kind === "mfa-setup-required") {
|
|
1033
|
+
return c.json({
|
|
1034
|
+
isSuccess: true,
|
|
1035
|
+
mfaSetupRequired: true,
|
|
1036
|
+
preauthSetupToken: step.preauthSetupToken,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
return c.json({ isSuccess: true, mfaRequired: true, challengeToken: step.challengeToken });
|
|
1040
|
+
}
|
|
1011
1041
|
// --- shared route builders for token flows ---------------------------------
|
|
1012
1042
|
// Password-reset, email-verification and signup share the exact same request
|
|
1013
1043
|
// HTTP-shape: parse body → dispatch the request handler → always-200 (no
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { EventPiiStance } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
export declare const SYSTEM_EVENT_PREFIX = "kumiko:system:";
|
|
3
3
|
export declare const AGGREGATE_TRANSFERRED_EVENT_TYPE = "kumiko:system:aggregate.transferred";
|
|
4
|
+
export declare const APP_STARTED_EVENT_TYPE = "kumiko:system:app.started";
|
|
5
|
+
export declare const APP_INSTANCE_STREAM_TYPE = "app-instance";
|
|
4
6
|
export declare const SYSTEM_EVENT_PII_STANCES: ReadonlyMap<string, EventPiiStance>;
|
|
@@ -8,6 +8,9 @@ export const SYSTEM_EVENT_PREFIX = "kumiko:system:";
|
|
|
8
8
|
// Lives here (not in event-store/transfer.ts) so this module stays free of
|
|
9
9
|
// an event-store import cycle; transfer.ts re-exports it.
|
|
10
10
|
export const AGGREGATE_TRANSFERRED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}aggregate.transferred`;
|
|
11
|
+
// Payload is version/commit/pod name/start time — no personal data.
|
|
12
|
+
export const APP_STARTED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}app.started`;
|
|
13
|
+
export const APP_INSTANCE_STREAM_TYPE = "app-instance";
|
|
11
14
|
const SELF = { personal: "self" };
|
|
12
15
|
// Workflow run-stream payloads carry references and step bookkeeping only,
|
|
13
16
|
// never a copy of a foreign event payload, so "none" leaves nothing to shred.
|
|
@@ -27,6 +30,7 @@ export const SYSTEM_EVENT_PII_STANCES = new Map([
|
|
|
27
30
|
[STEP_DISPATCHED_TYPE, "none"],
|
|
28
31
|
[STEP_DISPATCH_FAILED_TYPE, "none"],
|
|
29
32
|
[AGGREGATE_TRANSFERRED_EVENT_TYPE, "none"],
|
|
33
|
+
[APP_STARTED_EVENT_TYPE, "none"],
|
|
30
34
|
[WORKFLOW_WAITING_TYPE, "none"],
|
|
31
35
|
[WORKFLOW_WAITING_FOR_EVENT_TYPE, "none"],
|
|
32
36
|
[WORKFLOW_RESUMED_TYPE, "none"],
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -12,10 +12,12 @@ const declaredUnsafeRawRunners = new WeakMap();
|
|
|
12
12
|
// TenantDb it was handed for its gate. Bound inside createTenantDb so rebound instances
|
|
13
13
|
// (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
|
|
14
14
|
const personalDataGates = new WeakMap();
|
|
15
|
-
// Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate
|
|
16
|
-
// per-grant proxy, never the shared pool/tx: tagging that would
|
|
15
|
+
// Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate and the projection registry.
|
|
16
|
+
// Keyed by a per-grant proxy, never the shared pool/tx: tagging that would leak onto every sibling TenantDb.
|
|
17
17
|
const runnerPersonalDataGates = new WeakMap();
|
|
18
|
-
function
|
|
18
|
+
function boundRunner(runner, gate, projectionRegistry) {
|
|
19
|
+
if (!gate && !projectionRegistry)
|
|
20
|
+
return runner;
|
|
19
21
|
const proxy = new Proxy(runner, {
|
|
20
22
|
// Tagged-template calls need the real driver object as `this`.
|
|
21
23
|
apply(target, _thisArg, args) {
|
|
@@ -34,7 +36,7 @@ function gatedRunner(runner, gate, projectionRegistry) {
|
|
|
34
36
|
}
|
|
35
37
|
const gatedArgs = [
|
|
36
38
|
...args.slice(0, -1),
|
|
37
|
-
(tx) => callback(
|
|
39
|
+
(tx) => callback(boundRunner(tx, gate, projectionRegistry)),
|
|
38
40
|
];
|
|
39
41
|
return Reflect.apply(value, target, gatedArgs);
|
|
40
42
|
};
|
|
@@ -43,7 +45,8 @@ function gatedRunner(runner, gate, projectionRegistry) {
|
|
|
43
45
|
},
|
|
44
46
|
// @cast-boundary proxy-erasure — Proxy<object> re-tags as the wrapped DbRunner shape.
|
|
45
47
|
});
|
|
46
|
-
|
|
48
|
+
if (gate)
|
|
49
|
+
runnerPersonalDataGates.set(proxy, gate);
|
|
47
50
|
if (projectionRegistry)
|
|
48
51
|
bindProjectionRegistry(proxy, projectionRegistry);
|
|
49
52
|
return proxy;
|
|
@@ -121,10 +124,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
121
124
|
// Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
|
|
122
125
|
report("unsafe-raw", forwardedDeclaredStep ? reason : (gate?.grant?.reason ?? reason));
|
|
123
126
|
const runner = tenantDbRunner(db);
|
|
124
|
-
|
|
125
|
-
return personalDataGate
|
|
126
|
-
? gatedRunner(runner, personalDataGate, projectionRegistryOf(db))
|
|
127
|
-
: runner;
|
|
127
|
+
return boundRunner(runner, personalDataGates.get(db), projectionRegistryOf(db));
|
|
128
128
|
}
|
|
129
129
|
const uncheckedSystemDb = {
|
|
130
130
|
[SYSTEM_SCOPE_CHECK_BRAND]: true,
|
|
@@ -456,7 +456,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
456
456
|
}
|
|
457
457
|
// Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
|
|
458
458
|
report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
|
|
459
|
-
return
|
|
459
|
+
return boundRunner(db, personalDataGate, projectionRegistry);
|
|
460
460
|
}
|
|
461
461
|
const tenantDb = {
|
|
462
462
|
tenantId,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
|
|
1
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.js";
|
|
2
3
|
export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
|
|
3
4
|
export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
|
|
1
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
|
|
2
3
|
export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
|
|
3
4
|
export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
|
|
@@ -86,15 +86,17 @@ export function createFileRoutes(options) {
|
|
|
86
86
|
const { db } = options;
|
|
87
87
|
const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
|
|
88
88
|
const guard = options.accessGuard ?? createDefaultGuard(privilegedRoles);
|
|
89
|
-
// Standard entity executor for fileRef — self-contained (table + entity)
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
89
|
+
// Standard entity executor for fileRef — self-contained (table + entity).
|
|
90
|
+
// create/delete emit fileRef.created/deleted and write file_refs via
|
|
91
|
+
// applyEntityEvent in one tx (read-your-own-write), exactly like any other
|
|
92
|
+
// entity's lifecycle. The registry only feeds custom projections on those events.
|
|
93
93
|
const executor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
94
94
|
entityName: "fileRef",
|
|
95
95
|
});
|
|
96
96
|
const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
|
|
97
97
|
const api = new Hono();
|
|
98
|
+
const projectionGrants = options.registry ? { projectionRegistry: options.registry } : undefined;
|
|
99
|
+
const tenantDbFor = (tenantId) => createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionGrants);
|
|
98
100
|
function kmsContextFor() {
|
|
99
101
|
return { requestId: requestContext.get()?.requestId ?? "file-routes" };
|
|
100
102
|
}
|
|
@@ -227,7 +229,7 @@ export function createFileRoutes(options) {
|
|
|
227
229
|
entityType: entityType ?? null,
|
|
228
230
|
entityId: entityId ?? null,
|
|
229
231
|
fieldName: fieldName ?? null,
|
|
230
|
-
}, user,
|
|
232
|
+
}, user, tenantDbFor(user.tenantId));
|
|
231
233
|
if (!result.isSuccess) {
|
|
232
234
|
return c.json({ error: "upload_failed" }, 500);
|
|
233
235
|
}
|
|
@@ -350,7 +352,7 @@ export function createFileRoutes(options) {
|
|
|
350
352
|
// erasure of row + binary is the job of the forget-flow (Art. 17) and the
|
|
351
353
|
// generic data-retention cleanup — same lifecycle as any soft-delete
|
|
352
354
|
// entity, not a files-specific path.
|
|
353
|
-
const result = await executor.delete({ id }, user,
|
|
355
|
+
const result = await executor.delete({ id }, user, tenantDbFor(user.tenantId));
|
|
354
356
|
if (!result.isSuccess) {
|
|
355
357
|
// NotFound (race between guard and executor) reuses the 404-masking
|
|
356
358
|
// pattern from the access-deny path above. Everything else (version
|
|
@@ -414,7 +416,7 @@ export function createFileRoutes(options) {
|
|
|
414
416
|
// id shape before it hits the UUID column.
|
|
415
417
|
if (!isUuid(id))
|
|
416
418
|
return c.json({ error: "not_found" }, 404);
|
|
417
|
-
const row = await executor.detail({ id }, user,
|
|
419
|
+
const row = await executor.detail({ id }, user, tenantDbFor(user.tenantId));
|
|
418
420
|
if (!row)
|
|
419
421
|
return c.json({ error: "not_found" }, 404);
|
|
420
422
|
const fileRef = row; // @cast-boundary db-row (decrypted via executor.detail)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.345.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -206,8 +206,8 @@
|
|
|
206
206
|
"./package.json": "./package.json"
|
|
207
207
|
},
|
|
208
208
|
"dependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
210
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-http": "0.345.0",
|
|
210
|
+
"@cosmicdrift/kumiko-types": "0.345.0",
|
|
211
211
|
"bullmq": "^5.76.7",
|
|
212
212
|
"bun-types": "^1.3.13",
|
|
213
213
|
"hono": "^4.13.1",
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
"zod": "^4.4.3"
|
|
224
224
|
},
|
|
225
225
|
"devDependencies": {
|
|
226
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
226
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.345.0",
|
|
227
227
|
"bun-types": "^1.3.13",
|
|
228
228
|
"pino-pretty": "^13.1.3"
|
|
229
229
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.345.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "TenantDbs built from unsafeRaw() and file routes run custom projections",
|
|
6
|
+
"detail": "A TenantDb rebuilt from `unsafeRaw()` runs custom projections again\n`ctx.db.unsafeRaw()` used to return the bare connection unless a public-intake gate was active. A `createTenantDb(ctx.db.unsafeRaw(), …)` inside a handler, hook or job therefore lost the projection registry, and its writes skipped custom projections. `unsafeRaw()` now returns a runner bound to the registry (and the gate, if any) whenever one is present. The file routes pass the app registry to their TenantDbs, so file events reach custom projections as well.",
|
|
7
|
+
"migration": "`unsafeRaw()` on a dispatcher or job TenantDb now returns a proxy instead of the raw connection object. Code that compares the runner by identity with the raw connection must compare against the proxy instead. Standalone seeds that call createTenantDb on a raw connection still have no registry; seed through dispatcher.write. Code that re-ran custom projections by hand after such a write (on a reloaded event, not the returned one) must drop that call, or the projection applies twice. File upload and delete now run custom projections on fileRef events inside the same transaction, so a failing projection rolls the upload back like for any other entity."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.343.0",
|
|
4
11
|
"type": "breaking",
|