@cosmicdrift/kumiko-framework 0.199.2 → 0.200.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.199.2",
3
+ "version": "0.200.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>",
@@ -186,7 +186,7 @@
186
186
  "./package.json": "./package.json"
187
187
  },
188
188
  "dependencies": {
189
- "@cosmicdrift/kumiko-types": "0.199.2",
189
+ "@cosmicdrift/kumiko-types": "0.200.0",
190
190
  "bullmq": "^5.76.7",
191
191
  "bun-types": "^1.3.13",
192
192
  "hono": "^4.13.1",
@@ -202,7 +202,7 @@
202
202
  "zod": "^4.4.3"
203
203
  },
204
204
  "devDependencies": {
205
- "@cosmicdrift/kumiko-dispatcher-live": "0.199.2",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.200.0",
206
206
  "bun-types": "^1.3.13",
207
207
  "pino-pretty": "^13.1.3"
208
208
  },
@@ -176,6 +176,19 @@ export function defineEntityWriteHandler(
176
176
  const table = buildEntityTable(entityName, entity);
177
177
  const executor = createEventStoreExecutor(table, entity, { entityName });
178
178
 
179
+ // r.systemScope() features (tenant, config, secrets, ...) hand their
180
+ // entity-convention handlers a fail-closed ctx.db — this generic layer
181
+ // has no per-call business reason to give assertTenantMatch, so it
182
+ // acknowledges cross-tenant access on the feature's behalf. This is
183
+ // behavior-preserving: ctx.db was already an unfiltered "system"-mode
184
+ // TenantDb for these handlers before the ctx.db cutover.
185
+ const dbFor = (ctx: HandlerContext): TenantDb =>
186
+ ctx.systemDb
187
+ ? ctx.systemDb.acknowledgeCrossTenant(
188
+ `entity convention handler for r.systemScope() feature (${name})`,
189
+ )
190
+ : ctx.db;
191
+
179
192
  let schema: ZodType;
180
193
  let handler: WriteHandlerDef["handler"];
181
194
 
@@ -184,7 +197,7 @@ export function defineEntityWriteHandler(
184
197
  schema = buildInsertSchema(entity);
185
198
  handler = async (event, ctx) => {
186
199
  const { runPreSave } = ctx;
187
- return executor.create(event.payload as DbRow, event.user, ctx.db, {
200
+ return executor.create(event.payload as DbRow, event.user, dbFor(ctx), {
188
201
  preSave:
189
202
  runPreSave &&
190
203
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -205,7 +218,7 @@ export function defineEntityWriteHandler(
205
218
  // KEK-rotation, the user-data-rights #494 backfill) don't go through
206
219
  // this handler and keep today's always-re-encrypt behavior, which
207
220
  // they rely on to intentionally force a fresh event/ciphertext.
208
- return executor.update(event.payload as UpdatePayload, event.user, ctx.db, {
221
+ return executor.update(event.payload as UpdatePayload, event.user, dbFor(ctx), {
209
222
  skipUnchanged: true,
210
223
  preSave:
211
224
  runPreSave &&
@@ -216,12 +229,12 @@ export function defineEntityWriteHandler(
216
229
  case "delete":
217
230
  schema = idSchema;
218
231
  handler = async (event, ctx) =>
219
- executor.delete(event.payload as IdPayload, event.user, ctx.db); // @cast-boundary engine-payload
232
+ executor.delete(event.payload as IdPayload, event.user, dbFor(ctx)); // @cast-boundary engine-payload
220
233
  break;
221
234
  case "restore":
222
235
  schema = idSchema;
223
236
  handler = async (event, ctx) =>
224
- executor.restore(event.payload as IdPayload, event.user, ctx.db); // @cast-boundary engine-payload
237
+ executor.restore(event.payload as IdPayload, event.user, dbFor(ctx)); // @cast-boundary engine-payload
225
238
  break;
226
239
  default:
227
240
  assertUnreachable(verb, "write verb");
@@ -271,24 +284,36 @@ export function defineEntityQueryHandler(
271
284
  let schema: ZodType;
272
285
  let handler: QueryHandlerDef["handler"];
273
286
 
274
- // Tier 2.7e Server-Eagerload: wenn die entity reference-Felder hat,
275
- // resolved der handler nach dem Haupt-Query die UUIDs gegen die
276
- // referenced entities. Das `_refs`-Property landet auf jeder Row;
277
- // Renderer-Side useReferenceLookup bleibt als Fallback bestehen
278
- // (für Apps die manuell Custom-Handler schreiben ohne diesen
279
- // Wrapper zu nutzen).
287
+ // Tier 2.7e server-eagerload: when the entity has reference fields, the
288
+ // handler resolves the UUIDs against the referenced entities after the
289
+ // main query. The `_refs` property lands on every row; the renderer-side
290
+ // useReferenceLookup stays as a fallback (for apps that write custom
291
+ // handlers by hand without this wrapper).
280
292
  const hasRefFields = collectReferenceFields(entity).length > 0;
281
293
 
282
- // crossTenant: this ONE handler reads across every tenant (e.g. a
283
- // SystemAdmin-only operator inspector) without making the whole feature
284
- // r.systemScope() that would drop tenant isolation from every OTHER
285
- // handler the feature registers too. The executor's list()/detail() only
286
- // add a tenant filter when db.mode === "tenant" (see event-store-executor
287
- // .ts), so handing them a "system"-mode TenantDb built from the same raw
288
- // connection is enough to skip it — access-control (who may call this
289
- // handler at all) is unaffected, still gated by `options.access`.
290
- const dbFor = (ctx: HandlerContext): TenantDb =>
291
- options?.crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
294
+ // Preference order:
295
+ // 1. ctx.systemDb the feature declared r.systemScope() (whole-feature
296
+ // cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
297
+ // behavior-preserving: ctx.db was already an unfiltered "system"-mode
298
+ // TenantDb for these handlers before that cutover.
299
+ // 2. options.crossTenant this ONE handler reads across every tenant
300
+ // (e.g. a SystemAdmin-only operator inspector) without making the
301
+ // whole feature r.systemScope() that would drop tenant isolation
302
+ // from every OTHER handler the feature registers too. The executor's
303
+ // list()/detail() only add a tenant filter when db.mode === "tenant"
304
+ // (see event-store-executor.ts), so handing them a "system"-mode
305
+ // TenantDb built from the same raw connection is enough to skip it —
306
+ // access-control (who may call this handler at all) is unaffected,
307
+ // still gated by `options.access`.
308
+ // 3. plain ctx.db — the common case, tenant-filtered.
309
+ const dbFor = (ctx: HandlerContext): TenantDb => {
310
+ if (ctx.systemDb) {
311
+ return ctx.systemDb.acknowledgeCrossTenant(
312
+ `entity convention handler for r.systemScope() feature (${entityName}:${verb})`,
313
+ );
314
+ }
315
+ return options?.crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
316
+ };
292
317
 
293
318
  switch (verb) {
294
319
  case "list":
@@ -13,13 +13,25 @@ const systemScopedFeature = defineFeature("ctxsystemdb-system", (r) => {
13
13
  "check",
14
14
  z.object({}),
15
15
  async (query, ctx) => {
16
- if (!ctx.systemDb) return { present: false as const };
17
- // assertTenantMatch returns the underlying TenantDb proves systemDb
18
- // is bound to the SAME internally-built TenantDb as ctx.db, not a
19
- // separate instance (dispatch-shared.ts builds `as HandlerContext`,
20
- // so a mis-wired property wouldn't be caught by tsc).
16
+ if (!ctx.systemDb)
17
+ return { present: false as const, tenantIdMatches: false, dbThrows: false };
18
+ // ctx.db is fail-closed for r.systemScope() handlers — dispatch-shared.ts
19
+ // builds `as HandlerContext`, so a mis-wired property wouldn't be caught
20
+ // by tsc. Prove it at runtime: assertTenantMatch must hand back a
21
+ // working, correctly-scoped TenantDb, and touching ctx.db itself must
22
+ // throw instead of silently returning an unfiltered db.
21
23
  const checked = ctx.systemDb.assertTenantMatch(query.user.tenantId);
22
- return { present: true as const, boundToDb: checked === ctx.db };
24
+ let dbThrows = false;
25
+ try {
26
+ void ctx.db.tenantId;
27
+ } catch {
28
+ dbThrows = true;
29
+ }
30
+ return {
31
+ present: true as const,
32
+ tenantIdMatches: checked.tenantId === query.user.tenantId,
33
+ dbThrows,
34
+ };
23
35
  },
24
36
  { access: { roles: ["Admin"] } },
25
37
  );
@@ -29,7 +41,10 @@ const tenantScopedFeature = defineFeature("ctxsystemdb-tenant", (r) => {
29
41
  r.queryHandler(
30
42
  "check",
31
43
  z.object({}),
32
- async (_query, ctx) => ({ present: ctx.systemDb !== undefined }),
44
+ async (query, ctx) => ({
45
+ present: ctx.systemDb !== undefined,
46
+ dbWorks: ctx.db.tenantId === query.user.tenantId,
47
+ }),
33
48
  { access: { roles: ["Admin"] } },
34
49
  );
35
50
  });
@@ -46,22 +61,24 @@ afterAll(async () => {
46
61
  });
47
62
 
48
63
  describe("ctx.systemDb", () => {
49
- test("is present and bound to ctx.db for r.systemScope() handlers", async () => {
50
- const result = await stack.http.queryOk<{ present: boolean; boundToDb: boolean }>(
51
- "ctxsystemdb-system:query:check",
52
- {},
53
- admin,
54
- );
64
+ test("is present for r.systemScope() handlers; ctx.db is fail-closed there", async () => {
65
+ const result = await stack.http.queryOk<{
66
+ present: boolean;
67
+ tenantIdMatches: boolean;
68
+ dbThrows: boolean;
69
+ }>("ctxsystemdb-system:query:check", {}, admin);
55
70
  expect(result.present).toBe(true);
56
- expect(result.boundToDb).toBe(true);
71
+ expect(result.tenantIdMatches).toBe(true);
72
+ expect(result.dbThrows).toBe(true);
57
73
  });
58
74
 
59
- test("is absent for non-system-scoped handlers", async () => {
60
- const result = await stack.http.queryOk<{ present: boolean }>(
75
+ test("is absent for non-system-scoped handlers; ctx.db works normally there", async () => {
76
+ const result = await stack.http.queryOk<{ present: boolean; dbWorks: boolean }>(
61
77
  "ctxsystemdb-tenant:query:check",
62
78
  {},
63
79
  admin,
64
80
  );
65
81
  expect(result.present).toBe(false);
82
+ expect(result.dbWorks).toBe(true);
66
83
  });
67
84
  });
@@ -17,13 +17,17 @@ export function createCascadeDeleteHook(
17
17
  priority: SystemHookPriorities.cascadeDelete,
18
18
  fn: async (payload, ctx) => {
19
19
  const entityName = payload.entityName;
20
- if (!entityName || !ctx.db) {
20
+ const db = ctx.systemDb
21
+ ? ctx.systemDb.acknowledgeCrossTenant(
22
+ `cascade delete for r.systemScope() write handler (${entityName ?? "unknown"})`,
23
+ )
24
+ : ctx.db;
25
+ if (!entityName || !db) {
21
26
  ctx.log?.debug(
22
27
  `cascadeDelete: skipping — ${!entityName ? "no entityName" : "no db"} on payload ${payload.id}`,
23
28
  );
24
29
  return;
25
30
  }
26
- const db = ctx.db;
27
31
 
28
32
  // Cascade applies to outgoing hasMany / manyToMany relations only —
29
33
  // the parent side of the link is where `onDelete` lives (see
@@ -3,7 +3,7 @@ import type { SseBroker } from "../api/sse-broker";
3
3
  import type { DbConnection, DbRunner, DbTx } from "../db/connection";
4
4
  import { runInSavepoint, selectMany } from "../db/query";
5
5
  import type { buildEntityTable } from "../db/table-builder";
6
- import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db";
6
+ import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../db/tenant-db";
7
7
  import { createDerivativesContext } from "../derivatives/derivatives-context";
8
8
  import type { defineTransitions } from "../engine/state-machine";
9
9
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
@@ -155,6 +155,27 @@ async function appendDomainEvent(
155
155
  );
156
156
  }
157
157
 
158
+ // r.systemScope() handlers must go through ctx.systemDb's guarded methods
159
+ // (assertTenantMatch / acknowledgeCrossTenant) instead of plain ctx.db —
160
+ // "system" mode has no tenant filter at all, so silent ctx.db use there is a
161
+ // cross-tenant leak. HandlerContext.db has no way to become optional per
162
+ // handler (isSystem is a runtime-only registry lookup, not a type-level
163
+ // discriminant), so instead of omitting the key we hand back a Proxy that
164
+ // throws on first touch, naming the handler and pointing at ctx.systemDb.
165
+ function createSystemScopedDbGuard(handlerType: string): TenantDb {
166
+ return new Proxy({} as TenantDb, {
167
+ get(_target, prop) {
168
+ throw new InternalError({
169
+ message:
170
+ `Handler "${handlerType}" is r.systemScope()'d — ctx.db is unavailable ` +
171
+ `(it would be unfiltered across every tenant). Use ` +
172
+ `ctx.systemDb.assertTenantMatch(...) or ctx.systemDb.acknowledgeCrossTenant(...) ` +
173
+ `instead (attempted to read "${String(prop)}").`,
174
+ });
175
+ },
176
+ });
177
+ }
178
+
158
179
  export async function buildHandlerContext(
159
180
  ctx: DispatchContext,
160
181
  type: string,
@@ -184,6 +205,10 @@ export async function buildHandlerContext(
184
205
  // the rest of the chain instead of burning DB-CPU for results no one reads.
185
206
  const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
186
207
  const systemDb = isSystem && db ? createUncheckedSystemDb(db) : undefined;
208
+ // Exposed as ctx.db below — the internal `db` above stays the real,
209
+ // working TenantDb for this function's own use (config/derivatives
210
+ // accessors, systemDb construction).
211
+ const exposedDb = isSystem && db ? createSystemScopedDbGuard(type) : db;
187
212
  // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
188
213
  // through it survive a rollback of the handler's own transaction. No
189
214
  // AbortSignal here: a client disconnect must not abort a durability write
@@ -572,7 +597,7 @@ export async function buildHandlerContext(
572
597
  return {
573
598
  ...context,
574
599
  registry,
575
- db,
600
+ db: exposedDb,
576
601
  dbOutsideTransaction,
577
602
  ...(systemDb && { systemDb }),
578
603
  log,
@@ -350,10 +350,19 @@ async function executeWriteInner(
350
350
 
351
351
  const handlerContext = await buildHandlerContext(ctx, type, user, tx, afterCommitHooks);
352
352
 
353
- // Auto transition guard: if entity has transitions and handler doesn't skip it
353
+ // Auto transition guard: if entity has transitions and handler doesn't skip it.
354
+ // Reads via the guard's own db handle — for r.systemScope() handlers
355
+ // handlerContext.db is fail-closed, so this reaches for
356
+ // systemDb.acknowledgeCrossTenant like any other framework-internal
357
+ // mechanism that touches a system-scoped handler's data.
358
+ const transitionGuardDb = handlerContext.systemDb
359
+ ? handlerContext.systemDb.acknowledgeCrossTenant(
360
+ `auto transition guard for r.systemScope() write handler (${type})`,
361
+ )
362
+ : handlerContext.db;
354
363
  if (entityName && !handler.unsafeSkipTransitionGuard) {
355
364
  const entity = registry.getEntity(entityName);
356
- if (entity?.transitions && handlerContext.db) {
365
+ if (entity?.transitions && transitionGuardDb) {
357
366
  const parsedData = parsed.data as DbRow; // @cast-boundary engine-payload
358
367
  const changes = (parsedData["changes"] as DbRow) ?? parsedData; // @cast-boundary engine-payload
359
368
  const id = (parsedData["id"] as number) ?? undefined; // @cast-boundary engine-payload
@@ -373,8 +382,8 @@ async function executeWriteInner(
373
382
  // active (tests without a DB connection).
374
383
  const tableName = asEntityTableMeta(table)?.tableName ?? "";
375
384
  const rows = tx
376
- ? await selectRowForUpdateById(handlerContext.db, tableName, id)
377
- : await selectMany(handlerContext.db, table, { id });
385
+ ? await selectRowForUpdateById(transitionGuardDb, tableName, id)
386
+ : await selectMany(transitionGuardDb, table, { id });
378
387
  const row = rows[0];
379
388
 
380
389
  if (!row) continue;
@@ -26,7 +26,15 @@ import type { StoredEvent } from "../event-store";
26
26
  export async function runProjections(result: LifecycleResult, ctx: HandlerContext): Promise<void> {
27
27
  // skip: hand-crafted result with no event — nothing to project
28
28
  if (!result.event) return;
29
- await runProjectionsForEvent(result.event, ctx.registry, ctx.db.raw);
29
+ // r.systemScope() handlers: ctx.db is fail-closed — custom projections are
30
+ // framework-wired, not per-handler opt-in, so this reaches for systemDb
31
+ // itself rather than pushing the concern onto every r.projection() author.
32
+ const tx = ctx.systemDb
33
+ ? ctx.systemDb.acknowledgeCrossTenant(
34
+ `inline projection apply for r.systemScope() write (${result.event.aggregateType})`,
35
+ ).raw
36
+ : ctx.db.raw;
37
+ await runProjectionsForEvent(result.event, ctx.registry, tx);
30
38
  }
31
39
 
32
40
  // Fire every projection whose source matches the event's aggregate type AND