@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.
@@ -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;
@@ -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
- // Self-Signup (Magic-Link). Request mountet wie reset/verify den
672
- // silent-success-Pfad mit Token-Mail. Confirm ist anders: returnt
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
- const data = result.data; // @cast-boundary engine-payload
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"],
@@ -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. Keyed by a
16
- // per-grant proxy, never the shared pool/tx: tagging that would gate every sibling TenantDb.
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 gatedRunner(runner, gate, projectionRegistry) {
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(gatedRunner(tx, gate, projectionRegistry)),
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
- runnerPersonalDataGates.set(proxy, gate);
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
- const personalDataGate = personalDataGates.get(db);
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 personalDataGate ? gatedRunner(db, personalDataGate, projectionRegistry) : db;
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
- // no registry needed. create/delete emit fileRef.created/deleted and write
91
- // file_refs via applyEntityEvent in one tx (read-your-own-write), exactly
92
- // like any other entity's lifecycle.
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, createTenantDb(db, user.tenantId));
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, createTenantDb(db, user.tenantId));
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, createTenantDb(db, user.tenantId));
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.344.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.344.0",
210
- "@cosmicdrift/kumiko-types": "0.344.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.344.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",