@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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 (111) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +105 -0
  13. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  15. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  16. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  18. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  19. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  20. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  21. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  22. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  23. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  24. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  25. package/src/db/apply-entity-event.ts +1 -1
  26. package/src/db/assert-exists-in.ts +1 -1
  27. package/src/db/entity-field-encryption.ts +18 -0
  28. package/src/db/event-store-executor-context.ts +12 -5
  29. package/src/db/event-store-executor-read.ts +125 -37
  30. package/src/db/event-store-executor-write.ts +24 -18
  31. package/src/db/queries/shadow-swap.ts +36 -0
  32. package/src/db/replay-migration-sql.ts +5 -5
  33. package/src/db/tenant-db-runner.ts +23 -0
  34. package/src/db/tenant-db.ts +97 -24
  35. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  36. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  37. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  38. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  39. package/src/engine/__tests__/boot-validator.test.ts +71 -1
  40. package/src/engine/__tests__/engine.test.ts +86 -0
  41. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  42. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  43. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  44. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  45. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  46. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  47. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  48. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  49. package/src/engine/__tests__/screen.test.ts +150 -0
  50. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  51. package/src/engine/__tests__/steps-read.test.ts +204 -8
  52. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  53. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  54. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  55. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  56. package/src/engine/boot-validator/entity-handler.ts +21 -0
  57. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  58. package/src/engine/boot-validator/parent-ref.ts +1 -1
  59. package/src/engine/boot-validator/projection-list-screens.ts +68 -3
  60. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  61. package/src/engine/boot-validator/screens.ts +154 -31
  62. package/src/engine/build-config-feature-schema.ts +7 -4
  63. package/src/engine/entity-handlers.ts +74 -22
  64. package/src/engine/feature-entity-handlers.ts +4 -4
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/registry-validate.ts +36 -6
  67. package/src/engine/schema-builder.ts +6 -0
  68. package/src/engine/screen-filter-ops.ts +10 -2
  69. package/src/engine/screen-helpers.ts +52 -0
  70. package/src/engine/steps/_read-source.ts +12 -0
  71. package/src/engine/steps/read-find-many.ts +8 -2
  72. package/src/engine/steps/read-find-one.ts +11 -8
  73. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  74. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  75. package/src/engine/types/index.ts +8 -1
  76. package/src/errors/i18n/de.yaml +14 -0
  77. package/src/errors/i18n/en.yaml +13 -0
  78. package/src/errors/reasons.ts +4 -0
  79. package/src/i18n/required-surface-keys.ts +6 -2
  80. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  81. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  82. package/src/jobs/job-runner.ts +35 -5
  83. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  84. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  85. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  86. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  87. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  88. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  89. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  90. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  91. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  92. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  93. package/src/pipeline/dispatch-query.ts +27 -8
  94. package/src/pipeline/dispatch-shared.ts +141 -57
  95. package/src/pipeline/dispatch-stream.ts +1 -3
  96. package/src/pipeline/dispatch-write.ts +62 -14
  97. package/src/pipeline/dispatcher.ts +20 -0
  98. package/src/pipeline/escape-hatch-report.ts +127 -0
  99. package/src/pipeline/index.ts +8 -0
  100. package/src/pipeline/member-read-only-transaction.ts +75 -0
  101. package/src/pipeline/msp-rebuild.ts +2 -0
  102. package/src/pipeline/projection-rebuild.ts +2 -0
  103. package/src/pipeline/projections-runner.ts +15 -13
  104. package/src/pipeline/system-identity-switch.ts +121 -9
  105. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  106. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  107. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  108. package/src/ui-types/index.ts +3 -0
  109. package/src/utils/__tests__/ids.test.ts +29 -1
  110. package/src/utils/ids.ts +10 -1
  111. package/src/utils/index.ts +1 -1
@@ -3,7 +3,12 @@ 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, type TenantDb } from "../db/tenant-db";
6
+ import {
7
+ createTenantDb,
8
+ createUncheckedSystemDb,
9
+ hasTenantColumn,
10
+ type TenantDb,
11
+ } from "../db/tenant-db";
7
12
  import { createDerivativesContext } from "../derivatives/derivatives-context";
8
13
  import type { defineTransitions } from "../engine/state-machine";
9
14
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
@@ -17,10 +22,13 @@ import type {
17
22
  HandlerContext,
18
23
  JobRunnerRef,
19
24
  MemberReader,
25
+ RateLimitDeclaration,
26
+ RateLimitOption,
20
27
  Registry,
21
28
  SessionUser,
22
29
  WriteResult,
23
30
  } from "../engine/types";
31
+ import { isRateLimitDisabled } from "../engine/types";
24
32
  import type { TenantId } from "../engine/types/identifiers";
25
33
  import {
26
34
  AccessDeniedError,
@@ -77,12 +85,16 @@ import {
77
85
  dispatcherSpanAttributes,
78
86
  isFailedWriteResult,
79
87
  } from "./dispatcher-utils";
88
+ import { createEscapeHatchReporter, type EscapeHatchReportWindow } from "./escape-hatch-report";
80
89
  import type { IdempotencyGuard } from "./idempotency";
81
90
  import type { LifecycleHooks } from "./lifecycle-pipeline";
82
91
  import { createMemberReaderFn } from "./member-reader";
83
92
  import {
84
93
  createGatedIdentitySwitch,
85
94
  createGatedMemberReader,
95
+ createGatedProjectionReader,
96
+ isSystemIdentity,
97
+ type ProjectionReader,
86
98
  systemIdentitySwitchDenied,
87
99
  } from "./system-identity-switch";
88
100
  import type { TenantTimezoneCache } from "./tenant-timezone-cache";
@@ -130,6 +142,7 @@ export type DispatchContext = {
130
142
  tableCache: Map<string, ReturnType<typeof buildEntityTable>>;
131
143
  transitionCache: Map<string, ReturnType<typeof defineTransitions>>;
132
144
  tenantTimezoneCache: TenantTimezoneCache;
145
+ escapeHatchReportWindow: EscapeHatchReportWindow;
133
146
  tracer: ReturnType<typeof getFallbackTracer>;
134
147
  meter: ReturnType<typeof getFallbackMeter>;
135
148
  // Qualified name of the membership-list query handler consulted by
@@ -206,10 +219,11 @@ function createSystemScopedDbGuard(
206
219
  });
207
220
  }
208
221
 
209
- export function memberResolutionReadOnlyDenied(): AccessDeniedError {
222
+ export function memberResolutionReadOnlyDenied(cause?: unknown): AccessDeniedError {
210
223
  return new AccessDeniedError({
211
224
  message: "a resolved member principal (ctx.queryAsMember) cannot write — read-only",
212
225
  details: { reason: FrameworkReasons.memberResolutionReadOnly },
226
+ ...(cause instanceof Error && { cause }),
213
227
  });
214
228
  }
215
229
 
@@ -232,11 +246,12 @@ function denyingJobRunnerProxy(): JobRunnerRef {
232
246
  function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
233
247
  return {
234
248
  ...handlerContext,
235
- // `db` itself stays open — same access the member's own HTTP request has;
236
- // raw ctx.db writes inside a queried handler are not blocked by this.
249
+ // `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY transaction.
237
250
  dbOutsideTransaction: undefined,
238
251
  write: denyMemberResolutionWrite,
239
252
  writeAs: denyMemberResolutionWrite,
253
+ // A resolved member cannot switch identity either — the target would get a normal, writable context.
254
+ queryAs: denyMemberResolutionWrite,
240
255
  appendEvent: denyMemberResolutionWrite as AppendEventFn, // @cast-boundary engine-bridge
241
256
  unsafeAppendEvent: denyMemberResolutionWrite,
242
257
  tryAppendEvent: denyMemberResolutionWrite,
@@ -281,6 +296,18 @@ export async function buildHandlerContext(
281
296
  registry.getQueryHandler(type)?.escapeHatch ??
282
297
  registry.getStreamHandler(type)?.escapeHatch;
283
298
  const hasIdentitySwitchGrant = isSystem || handlerEscapeHatch !== undefined;
299
+ const featureName = registry.getHandlerFeature(type);
300
+ const reportEscapeHatch = createEscapeHatchReporter({
301
+ handler: type,
302
+ tenantId: user.tenantId,
303
+ actor: user.id,
304
+ sink: context._escapeHatchAuditSink,
305
+ log: context.log,
306
+ window: ctx.escapeHatchReportWindow,
307
+ });
308
+ const identitySwitchGrantReason =
309
+ handlerEscapeHatch?.reason ??
310
+ (isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
284
311
  const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
285
312
  createTenantDb(
286
313
  source,
@@ -289,7 +316,7 @@ export async function buildHandlerContext(
289
316
  context.tracer,
290
317
  context.meter,
291
318
  signal,
292
- { globalWrites: writeEscapeHatch, unsafeRaw: handlerEscapeHatch },
319
+ { globalWrites: writeEscapeHatch, unsafeRaw: handlerEscapeHatch, report: reportEscapeHatch },
293
320
  );
294
321
  // Propagate the request's AbortSignal so every TenantDb query throws when
295
322
  // the client has disconnected — handlers with many sequential queries skip
@@ -306,7 +333,9 @@ export async function buildHandlerContext(
306
333
  ? buildTenantScopedDb(outsideTxSource, undefined)
307
334
  : undefined;
308
335
  const systemDb =
309
- isSystem && db ? createUncheckedSystemDb(db, rawDbOutsideTransaction) : undefined;
336
+ isSystem && db
337
+ ? createUncheckedSystemDb(db, rawDbOutsideTransaction, reportEscapeHatch)
338
+ : undefined;
310
339
  // Exposed as ctx.db below — the internal `db` above stays the real,
311
340
  // working TenantDb for this function's own use (config/derivatives
312
341
  // accessors, systemDb construction).
@@ -366,7 +395,6 @@ export async function buildHandlerContext(
366
395
  // so legacy internal handlers don't crash.
367
396
  const tracer = context.tracer ?? getFallbackTracer();
368
397
  const meter = context.meter;
369
- const featureName = registry.getHandlerFeature(type);
370
398
  const metrics =
371
399
  meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
372
400
  // ctx.metricsFor(featureName) — shared/library code binds to a feature
@@ -383,6 +411,7 @@ export async function buildHandlerContext(
383
411
  const scheduleAfterCommit = (hook: AfterCommitHook): void => {
384
412
  bridgeSink.push(hook);
385
413
  };
414
+ const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
386
415
  const identitySwitch = createGatedIdentitySwitch(
387
416
  `handler "${type}"`,
388
417
  user,
@@ -393,6 +422,7 @@ export async function buildHandlerContext(
393
422
  writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
394
423
  executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
395
424
  },
425
+ identitySwitchAudit,
396
426
  );
397
427
  // Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
398
428
  // on first actual use, not on every HandlerContext build.
@@ -404,6 +434,48 @@ export async function buildHandlerContext(
404
434
  ungatedMemberReader ??= createMemberReaderFn(ctx, user.tenantId, tx);
405
435
  return ungatedMemberReader(userId, qn, payload);
406
436
  },
437
+ { ...identitySwitchAudit, tenantId: user.tenantId },
438
+ );
439
+ const ungatedQueryProjection: ProjectionReader = async <T = Record<string, unknown>>(
440
+ qualifiedName: string,
441
+ queryOptions?: { readonly unsafeAllTenants?: boolean },
442
+ ): Promise<readonly T[]> => {
443
+ // queryProjection works against both single-stream and multi-stream
444
+ // projections. MSPs without a table cannot be queried — those are
445
+ // side-effect-only consumers (no state to read back).
446
+ const singleProj = registry.getAllProjections().get(qualifiedName);
447
+ const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
448
+ const projTable = singleProj?.table ?? mspProj?.table;
449
+ if (!projTable) {
450
+ const singleNames = [...registry.getAllProjections().keys()];
451
+ const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter(
452
+ (n) => registry.getAllMultiStreamProjections().get(n)?.table,
453
+ );
454
+ const all = [...singleNames, ...mspNames];
455
+ throw new InternalError({
456
+ message:
457
+ `ctx.queryProjection("${qualifiedName}") — projection not registered, or it is a ` +
458
+ `table-less MSP (side-effect-only). Known queryable projections: ${all.join(", ") || "(none)"}`,
459
+ });
460
+ }
461
+ const dbSource = resolveDbSource(ctx, tx);
462
+ if (!dbSource) {
463
+ throw new InternalError({
464
+ message: `ctx.queryProjection("${qualifiedName}") requires a database connection — none is configured.`,
465
+ });
466
+ }
467
+ const where =
468
+ hasTenantColumn(projTable) && queryOptions?.unsafeAllTenants !== true
469
+ ? { tenantId: user.tenantId }
470
+ : undefined;
471
+ const rows = await selectMany<Record<string, unknown>>(dbSource, projTable, where);
472
+ return rows as readonly T[]; // @cast-boundary engine-payload
473
+ };
474
+ const queryProjection = createGatedProjectionReader(
475
+ `handler "${type}"`,
476
+ hasIdentitySwitchGrant,
477
+ ungatedQueryProjection,
478
+ identitySwitchAudit,
407
479
  );
408
480
  const bridge = {
409
481
  query: (targetType: string, payload: unknown) =>
@@ -630,44 +702,7 @@ export async function buildHandlerContext(
630
702
  },
631
703
  );
632
704
  },
633
- queryProjection: async <T = Record<string, unknown>>(
634
- qualifiedName: string,
635
- queryOptions?: { readonly unsafeAllTenants?: boolean },
636
- ): Promise<readonly T[]> => {
637
- // queryProjection works against both single-stream and multi-stream
638
- // projections. MSPs without a table cannot be queried — those are
639
- // side-effect-only consumers (no state to read back).
640
- const singleProj = registry.getAllProjections().get(qualifiedName);
641
- const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
642
- const projTable = singleProj?.table ?? mspProj?.table;
643
- if (!projTable) {
644
- const singleNames = [...registry.getAllProjections().keys()];
645
- const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter(
646
- (n) => registry.getAllMultiStreamProjections().get(n)?.table,
647
- );
648
- const all = [...singleNames, ...mspNames];
649
- throw new InternalError({
650
- message:
651
- `ctx.queryProjection("${qualifiedName}") — projection not registered, or it is a ` +
652
- `table-less MSP (side-effect-only). Known queryable projections: ${all.join(", ") || "(none)"}`,
653
- });
654
- }
655
- const dbSource = resolveDbSource(ctx, tx);
656
- if (!dbSource) {
657
- throw new InternalError({
658
- message: `ctx.queryProjection("${qualifiedName}") requires a database connection — none is configured.`,
659
- });
660
- }
661
- // Introspect for a tenant_id column on the projection table. Auto-
662
- // filter keeps cross-tenant leaks out unless the handler explicitly
663
- // opts in. Works with any drizzle-table whose tenant column is named
664
- // tenantId on the JS side.
665
- const tenantCol = (projTable as Record<string, unknown>)["tenantId"];
666
- const where =
667
- tenantCol && !queryOptions?.unsafeAllTenants ? { tenantId: user.tenantId } : undefined;
668
- const rows = await selectMany<Record<string, unknown>>(dbSource, projTable, where);
669
- return rows as readonly T[]; // @cast-boundary engine-payload
670
- },
705
+ queryProjection,
671
706
  // Thin pass-through: one resolve impl lives on the dispatcher, the
672
707
  // handler surface just forwards the call so both entry points (login
673
708
  // handler via ctx.resolveAuthClaims, switch-tenant route via
@@ -1009,28 +1044,60 @@ export async function ensureFeatureEnabled(
1009
1044
  if (err) throw err;
1010
1045
  }
1011
1046
 
1047
+ // systemScope handlers get a default per-tenant limit unless the app declared its own or
1048
+ // opted out — but ONLY when a resolver is actually configured (an app without the
1049
+ // rate-limiting feature must not start throwing InternalError on every systemScope call
1050
+ // just because it never opted into L3), and never for a SYSTEM-identity caller (jobs,
1051
+ // replay, internal queryAs(SYSTEM,...) — those are not the tenant-facing traffic this
1052
+ // default protects against).
1053
+ // per: "tenant+handler" (not "tenant") — a per-handler bucket so one hot systemScope
1054
+ // handler cannot starve every other systemScope handler's shared tenant quota.
1055
+ const DEFAULT_SYSTEM_SCOPE_RATE_LIMIT: RateLimitOption = {
1056
+ per: "tenant+handler",
1057
+ limit: 600,
1058
+ windowSeconds: 60,
1059
+ };
1060
+
1061
+ function resolveEffectiveRateLimit(
1062
+ rateLimit: RateLimitOption | undefined,
1063
+ isSystemScope: boolean,
1064
+ user: SessionUser,
1065
+ resolver: AppContext["rateLimit"],
1066
+ ): RateLimitOption | undefined {
1067
+ if (rateLimit) return rateLimit;
1068
+ if (!isSystemScope) return undefined;
1069
+ if (!resolver) return undefined;
1070
+ if (isSystemIdentity(user)) return undefined;
1071
+ return DEFAULT_SYSTEM_SCOPE_RATE_LIMIT;
1072
+ }
1073
+
1012
1074
  // L3 rate limit gate. Called by both query and write paths before
1013
1075
  // access-check. Reasoning:
1014
- // - handler without rateLimit → no-op
1076
+ // - handler without rateLimit and not systemScope → no-op
1077
+ // - systemScope handler without rateLimit → DEFAULT_SYSTEM_SCOPE_RATE_LIMIT
1078
+ // applies, unless no resolver is configured or the caller is SYSTEM
1015
1079
  // - app booted without rateLimit resolver → InternalError so the
1016
- // misconfig surfaces immediately, not on first 429
1080
+ // misconfig surfaces immediately, not on first 429 (only reachable once
1081
+ // an effective rate limit was actually resolved above)
1017
1082
  // - bucket builder returns "skip" (e.g. ip-based but no client IP):
1018
1083
  // pass through. ip-modes are commonly used at L1/L2 middleware
1019
1084
  // where the IP comes from Hono directly; falling back to "skip"
1020
1085
  // here keeps non-HTTP entry-points (jobs, MSPs) functional.
1021
1086
  export async function enforceRateLimit(
1022
1087
  ctx: DispatchContext,
1023
- rateLimit: import("../engine/types").RateLimitOption | undefined,
1088
+ rateLimit: RateLimitDeclaration | undefined,
1024
1089
  handlerName: string,
1025
1090
  user: SessionUser,
1091
+ isSystemScope: boolean,
1026
1092
  ): Promise<void> {
1027
1093
  const { appContext: context } = ctx;
1028
- // skip: defence-in-depth — both call-sites already gate on
1029
- // handler.rateLimit !== undefined, so this branch only fires
1030
- // if a future caller forgets the inline check.
1031
- if (!rateLimit) return;
1094
+ // skip: handler explicitly opts out via rateLimit: { disabled: true }
1095
+ if (isRateLimitDisabled(rateLimit)) return;
1096
+ const effective = resolveEffectiveRateLimit(rateLimit, isSystemScope, user, context.rateLimit);
1097
+ // skip: no rateLimit declared and not systemScope (or resolved to no-op) — nothing to enforce
1098
+ if (!effective) return;
1032
1099
  const reqCtx = requestContext.get();
1033
- const bucket = buildBucketKey(rateLimit, {
1100
+ const bucket = buildBucketKey(effective, {
1034
1101
  handlerName,
1035
1102
  user,
1036
1103
  ip: reqCtx?.ip,
@@ -1044,9 +1111,9 @@ export async function enforceRateLimit(
1044
1111
  });
1045
1112
  }
1046
1113
  await context.rateLimit.enforce(bucket.key, {
1047
- limit: rateLimit.limit,
1048
- windowSeconds: rateLimit.windowSeconds,
1049
- cost: rateLimit.cost,
1114
+ limit: effective.limit,
1115
+ windowSeconds: effective.windowSeconds,
1116
+ cost: effective.cost,
1050
1117
  });
1051
1118
  }
1052
1119
 
@@ -1063,7 +1130,24 @@ function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthCl
1063
1130
  message: "dispatcher.resolveAuthClaims requires a database connection — none is configured.",
1064
1131
  });
1065
1132
  }
1066
- const db = createTenantDb(dbSource, user.tenantId, "tenant", context.tracer, context.meter);
1133
+ const db = createTenantDb(
1134
+ dbSource,
1135
+ user.tenantId,
1136
+ "tenant",
1137
+ context.tracer,
1138
+ context.meter,
1139
+ undefined,
1140
+ {
1141
+ report: createEscapeHatchReporter({
1142
+ handler: "r.authClaims hook",
1143
+ tenantId: user.tenantId,
1144
+ actor: user.id,
1145
+ sink: context._escapeHatchAuditSink,
1146
+ log: context.log,
1147
+ window: ctx.escapeHatchReportWindow,
1148
+ }),
1149
+ },
1150
+ );
1067
1151
  const configAccessor = context._configAccessorFactory
1068
1152
  ? context._configAccessorFactory({
1069
1153
  user: { id: user.id, tenantId: user.tenantId },
@@ -44,9 +44,7 @@ async function* executeStreamInner(
44
44
 
45
45
  await ensureFeatureEnabled(ctx, type, user.tenantId);
46
46
 
47
- if (handler.rateLimit !== undefined) {
48
- await enforceRateLimit(ctx, handler.rateLimit, type, user);
49
- }
47
+ await enforceRateLimit(ctx, handler.rateLimit, type, user, registry.isHandlerSystemScoped(type));
50
48
 
51
49
  if (!hasAccess(user, handler.access)) {
52
50
  throw new AccessDeniedError({
@@ -1,10 +1,11 @@
1
- import type { DbRow, DbTx } from "../db/connection";
1
+ import type { DbRow, DbRunner, DbTx } from "../db/connection";
2
2
  import { selectRowForUpdateById } from "../db/queries/entity-read";
3
3
  import { asEntityTableMeta, selectMany } from "../db/query";
4
4
  import { buildEntityTable, toSnakeCase } from "../db/table-builder";
5
+ import { tenantDbRunner } from "../db/tenant-db-runner";
5
6
  import { hasAccess } from "../engine/access";
6
7
  import { ConfigScopes } from "../engine/constants";
7
- import { checkWriteFieldRoles } from "../engine/field-access";
8
+ import { checkWriteFieldOwnership, checkWriteFieldRoles } from "../engine/field-access";
8
9
  import { defineTransitions, guardTransition } from "../engine/state-machine";
9
10
  import type { HandlerContext, SessionUser, WriteResult } from "../engine/types";
10
11
  import { HookPhases } from "../engine/types";
@@ -28,6 +29,7 @@ import {
28
29
  checkFeatureEnabled,
29
30
  enforceRateLimit,
30
31
  memberResolutionReadOnlyDenied,
32
+ resolveDbSource,
31
33
  runHandlerInstrumented,
32
34
  TENANT_TIMEZONE_CONFIG_KEY,
33
35
  } from "./dispatch-shared";
@@ -127,6 +129,7 @@ async function runLifecycle(
127
129
  handlerContext: HandlerContext,
128
130
  user: SessionUser,
129
131
  afterCommitHooks: AfterCommitHook[],
132
+ runner: DbRunner | undefined,
130
133
  ): Promise<void> {
131
134
  const { lifecycle } = ctx;
132
135
  if (!lifecycle) {
@@ -143,7 +146,7 @@ async function runLifecycle(
143
146
  // hooks. If a projection apply() throws, the whole tx rolls back — the
144
147
  // event and the auto-projection row go with it. Running before the hooks
145
148
  // keeps projection state consistent with what the hooks observe.
146
- await runProjections(result, handlerContext);
149
+ await runProjections(result, handlerContext.registry, runner);
147
150
 
148
151
  if (result.kind === "save") {
149
152
  await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
@@ -196,6 +199,14 @@ export async function executeWrite(
196
199
  );
197
200
  }
198
201
 
202
+ function isForeignTenantParentRow(
203
+ parentRow: Readonly<Record<string, unknown>>,
204
+ user: SessionUser,
205
+ ): boolean {
206
+ const rowTenantId = parentRow["tenantId"];
207
+ return typeof rowTenantId === "string" && rowTenantId !== user.tenantId;
208
+ }
209
+
199
210
  // Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
200
211
  //
201
212
  // When a parent `:create` handler's payload carries values under keys
@@ -286,6 +297,36 @@ export async function executeNestedWrite(
286
297
  );
287
298
  }
288
299
 
300
+ // A custom create handler may return an existing foreign row; verify it before attaching children.
301
+ if (!registry.isHandlerSystemScoped(type) && isForeignTenantParentRow(parentRow, user)) {
302
+ return writeFailure(
303
+ new AccessDeniedError({
304
+ message: `nested-write: parent row belongs to another tenant — refusing to attach children to "${type}"`,
305
+ details: { reason: FrameworkReasons.fieldAccessDenied, handler: type },
306
+ }),
307
+ );
308
+ }
309
+ const parentEntityName = registry.getHandlerEntity(type);
310
+ if (parentEntityName) {
311
+ const parentEntity = registry.getEntity(parentEntityName);
312
+ if (parentEntity) {
313
+ const deniedField = checkWriteFieldOwnership(parentEntity, parentRow, user);
314
+ if (deniedField) {
315
+ return writeFailure(
316
+ new AccessDeniedError({
317
+ message: `nested-write: parent row ownership check failed on field "${deniedField}" of "${type}" — refusing to attach children to a parent row the caller does not own`,
318
+ i18nKey: "errors.access.fieldDenied",
319
+ details: {
320
+ reason: FrameworkReasons.fieldAccessDenied,
321
+ field: deniedField,
322
+ handler: type,
323
+ },
324
+ }),
325
+ );
326
+ }
327
+ }
328
+ }
329
+
289
330
  for (const spec of nested.specs) {
290
331
  const subRows: Record<string, unknown>[] = [];
291
332
  for (let i = 0; i < spec.items.length; i++) {
@@ -341,15 +382,21 @@ async function executeWriteInner(
341
382
 
342
383
  // Rate-limit gate before access (same reasoning as in executeQueryInner).
343
384
  // Throws RateLimitError; the outer wrapper turns it into a 429
344
- // WriteFailure via toWriteErrorInfo. Inline-skip when no opt-in —
345
- // hot path stays zero-cost.
346
- if (handler.rateLimit !== undefined) {
347
- try {
348
- await enforceRateLimit(ctx, handler.rateLimit, type, user);
349
- } catch (e) {
350
- if (isKumikoError(e)) return writeFailure(e);
351
- throw e;
352
- }
385
+ // WriteFailure via toWriteErrorInfo. Apps that don't use L3 pay zero cost
386
+ // for non-systemScope handlers with no rateLimit declared; systemScope
387
+ // handlers pay one isHandlerSystemScoped lookup to check whether the
388
+ // default per-tenant limit applies.
389
+ try {
390
+ await enforceRateLimit(
391
+ ctx,
392
+ handler.rateLimit,
393
+ type,
394
+ user,
395
+ registry.isHandlerSystemScoped(type),
396
+ );
397
+ } catch (e) {
398
+ if (isKumikoError(e)) return writeFailure(e);
399
+ throw e;
353
400
  }
354
401
 
355
402
  // Default-deny: missing access rule is treated as "no one has access".
@@ -447,7 +494,7 @@ async function executeWriteInner(
447
494
  // active (tests without a DB connection).
448
495
  const tableName = asEntityTableMeta(table)?.tableName ?? "";
449
496
  const rows = tx
450
- ? await selectRowForUpdateById(transitionGuardDb, tableName, id)
497
+ ? await selectRowForUpdateById(tenantDbRunner(transitionGuardDb), tableName, id)
451
498
  : await selectMany(transitionGuardDb, table, { id });
452
499
  const row = rows[0];
453
500
 
@@ -504,7 +551,8 @@ async function executeWriteInner(
504
551
 
505
552
  if (result.isSuccess) {
506
553
  try {
507
- await runLifecycle(ctx, type, result.data, handlerContext, user, afterCommitHooks);
554
+ const runner = resolveDbSource(ctx, tx);
555
+ await runLifecycle(ctx, type, result.data, handlerContext, user, afterCommitHooks, runner);
508
556
  } catch (e) {
509
557
  return writeFailure(wrapToKumiko(e));
510
558
  }
@@ -1,5 +1,9 @@
1
1
  import type { SseBroker } from "../api/sse-broker";
2
2
  import type { buildEntityTable } from "../db/table-builder";
3
+ import {
4
+ DEPRECATED_CROSS_TENANT_SIGNAL,
5
+ isDeprecatedCrossTenantHandler,
6
+ } from "../engine/entity-handlers";
3
7
  import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names";
4
8
  import type { defineTransitions } from "../engine/state-machine";
5
9
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
@@ -23,6 +27,7 @@ import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shar
23
27
  import { resolveAuthClaimsFn } from "./dispatch-shared";
24
28
  import { executeStream } from "./dispatch-stream";
25
29
  import { type HandlerType, resolveType } from "./dispatcher-utils";
30
+ import { createEscapeHatchReportWindow } from "./escape-hatch-report";
26
31
  import type { IdempotencyGuard } from "./idempotency";
27
32
  import type { LifecycleHooks } from "./lifecycle-pipeline";
28
33
  import { createMemberReaderFn } from "./member-reader";
@@ -129,6 +134,7 @@ export function createDispatcher(
129
134
  // One per dispatcher instance (not a module-level singleton) so caches
130
135
  // never leak across separately-booted apps or test stacks.
131
136
  const tenantTimezoneCache = createTenantTimezoneCache();
137
+ const escapeHatchReportWindow = createEscapeHatchReportWindow();
132
138
 
133
139
  const dispatcherTracer = context.tracer ?? getFallbackTracer();
134
140
  const dispatcherMeter = context.meter ?? getFallbackMeter();
@@ -136,6 +142,19 @@ export function createDispatcher(
136
142
  // Idempotent: buildServer may have registered them already.
137
143
  registerStandardMetrics(dispatcherMeter);
138
144
 
145
+ for (const def of [
146
+ ...registry.getAllWriteHandlers().values(),
147
+ ...registry.getAllQueryHandlers().values(),
148
+ ]) {
149
+ if (isDeprecatedCrossTenantHandler(def.handler)) {
150
+ context.log?.warn(DEPRECATED_CROSS_TENANT_SIGNAL, {
151
+ handler: def.name,
152
+ migration:
153
+ 'replace crossTenant: true with escapeHatch: { reason: "<why this operator handler reads/writes every tenant>" } (bun scripts/codemod/migrate-cross-tenant.ts)',
154
+ });
155
+ }
156
+ }
157
+
139
158
  const ctx: DispatchContext = {
140
159
  registry,
141
160
  appContext: context,
@@ -147,6 +166,7 @@ export function createDispatcher(
147
166
  tableCache,
148
167
  transitionCache,
149
168
  tenantTimezoneCache,
169
+ escapeHatchReportWindow,
150
170
  tracer: dispatcherTracer,
151
171
  meter: dispatcherMeter,
152
172
  membershipQuery,
@@ -0,0 +1,127 @@
1
+ import type {
2
+ EscapeHatchAuditSink,
3
+ EscapeHatchKind,
4
+ EscapeHatchReporter,
5
+ EscapeHatchTarget,
6
+ EscapeHatchUseEvent,
7
+ TenantId,
8
+ } from "../engine/types";
9
+ import type { Logger } from "../logging/types";
10
+
11
+ export const ESCAPE_HATCH_USED_SIGNAL = "security:escape-hatch-used";
12
+
13
+ const DEFAULT_WINDOW_MS = 60_000;
14
+ const DEFAULT_MAX_ENTRIES = 1_000;
15
+
16
+ export type EscapeHatchReportWindow = {
17
+ shouldReport(key: string, nowMs: number): boolean;
18
+ };
19
+
20
+ // Dedups identical (handler,kind,reason,tenant,actor,target) reports within the window so hot systemScope queries don't write one row per request.
21
+ export function createEscapeHatchReportWindow(opts?: {
22
+ readonly windowMs?: number;
23
+ readonly maxEntries?: number;
24
+ }): EscapeHatchReportWindow {
25
+ const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS;
26
+ const maxEntries = opts?.maxEntries ?? DEFAULT_MAX_ENTRIES;
27
+ // Map insertion order doubles as recency order: a re-set moves the key to the end.
28
+ const entries = new Map<string, number>();
29
+
30
+ return {
31
+ shouldReport(key, nowMs) {
32
+ const expiresAt = entries.get(key);
33
+ if (expiresAt !== undefined && expiresAt > nowMs) return false;
34
+ entries.delete(key);
35
+ entries.set(key, nowMs + windowMs);
36
+ while (entries.size > maxEntries) {
37
+ const oldestKey = entries.keys().next().value;
38
+ if (oldestKey === undefined) break;
39
+ entries.delete(oldestKey);
40
+ }
41
+ return true;
42
+ },
43
+ };
44
+ }
45
+
46
+ // Default window for createEscapeHatchReporter callers that don't pass their own.
47
+ const fallbackReportWindow = createEscapeHatchReportWindow();
48
+
49
+ const consoleLogger: Logger = {
50
+ info() {},
51
+ warn(msg, data) {
52
+ console.warn(msg, data);
53
+ },
54
+ error(msg, data) {
55
+ console.error(msg, data);
56
+ },
57
+ debug() {},
58
+ child() {
59
+ return consoleLogger;
60
+ },
61
+ };
62
+
63
+ function eventFields(event: EscapeHatchUseEvent): Record<string, unknown> {
64
+ return {
65
+ handler: event.handler,
66
+ kind: event.kind,
67
+ reason: event.reason,
68
+ tenantId: event.tenantId,
69
+ actor: event.actor,
70
+ ...(event.target && { targetId: event.target.id, targetTenantId: event.target.tenantId }),
71
+ };
72
+ }
73
+
74
+ export function reportEscapeHatchUse(
75
+ event: EscapeHatchUseEvent,
76
+ deps: { readonly sink?: EscapeHatchAuditSink; readonly log?: Logger },
77
+ ): void {
78
+ const logger = deps.log ?? consoleLogger;
79
+ const fields = eventFields(event);
80
+ if (deps.sink) {
81
+ void deps.sink(event).catch((err: unknown) => {
82
+ logger.error(`${ESCAPE_HATCH_USED_SIGNAL}: audit sink failed`, {
83
+ ...fields,
84
+ error: err instanceof Error ? err.message : String(err),
85
+ });
86
+ });
87
+ return;
88
+ }
89
+ logger.warn(ESCAPE_HATCH_USED_SIGNAL, fields);
90
+ }
91
+
92
+ export function createEscapeHatchReporter(opts: {
93
+ readonly handler: string;
94
+ readonly tenantId: TenantId;
95
+ readonly actor: string;
96
+ readonly sink?: EscapeHatchAuditSink;
97
+ readonly log?: Logger;
98
+ readonly window?: EscapeHatchReportWindow;
99
+ }): EscapeHatchReporter {
100
+ const window = opts.window ?? fallbackReportWindow;
101
+
102
+ return (kind: EscapeHatchKind, reason: string, target?: EscapeHatchTarget) => {
103
+ const key = JSON.stringify([
104
+ opts.handler,
105
+ kind,
106
+ reason,
107
+ opts.tenantId,
108
+ opts.actor,
109
+ target?.id ?? null,
110
+ target?.tenantId ?? null,
111
+ ]);
112
+ // skip: same (handler, kind, reason, target) already reported within the window — dedup
113
+ if (!window.shouldReport(key, Date.now())) return;
114
+ reportEscapeHatchUse(
115
+ { handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target },
116
+ { sink: opts.sink, log: opts.log },
117
+ );
118
+ };
119
+ }
120
+
121
+ export function fallbackEscapeHatchReporter(tenantId: TenantId): EscapeHatchReporter {
122
+ return createEscapeHatchReporter({
123
+ handler: "<unattributed>",
124
+ tenantId,
125
+ actor: "<unattributed>",
126
+ });
127
+ }