@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
@@ -109,6 +109,19 @@ identity_switch_denied:
109
109
  (or on the `r.hook(...)` call, if the switch happens inside a hook), or
110
110
  move the logic into an `r.systemScope()` feature or a job.
111
111
 
112
+ unsafe_all_tenants_denied:
113
+ endUser: |
114
+ This action isn't allowed.
115
+ Please contact an administrator if you believe this is a mistake.
116
+ developer: |
117
+ `AccessDeniedError`: a handler or hook called
118
+ `ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })` without
119
+ being an `r.systemScope()` handler or declaring `escapeHatch: { reason }`.
120
+
121
+ Declare `escapeHatch: { reason: "..." }` on the write, query or stream
122
+ handler (or on the `r.hook(...)` call, if the read happens inside a
123
+ hook), or move the logic into an `r.systemScope()` feature.
124
+
112
125
  member_resolution_denied:
113
126
  endUser: |
114
127
  This action isn't allowed.
@@ -48,6 +48,10 @@ export const FrameworkReasons = {
48
48
  // claims, origin or extra roles) without r.systemScope() or escapeHatch.
49
49
  identitySwitchDenied: "identity_switch_denied",
50
50
 
51
+ // AccessDeniedError: a handler/hook called ctx.queryProjection(..., { unsafeAllTenants: true })
52
+ // without an r.systemScope() feature or a declared escapeHatch.
53
+ unsafeAllTenantsDenied: "unsafe_all_tenants_denied",
54
+
51
55
  // AccessDeniedError: ctx.queryAsMember's userId is not an active member of
52
56
  // ctx's tenant. Deliberately generic — a caller can't probe which check failed.
53
57
  memberResolutionDenied: "member_resolution_denied",
@@ -196,7 +196,9 @@ export function requiredKeysFromScreen(
196
196
  const form = screen as ActionFormScreenDefinition;
197
197
  pushKey(out, form.submitLabel);
198
198
  for (const fieldName of Object.keys(form.fields)) {
199
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
199
+ const override = form.fieldLabels?.[fieldName];
200
+ if (override !== undefined) pushKey(out, override);
201
+ else out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
200
202
  }
201
203
  for (const section of form.layout.sections) {
202
204
  if (isExtensionEditSection(section)) {
@@ -207,7 +209,9 @@ export function requiredKeysFromScreen(
207
209
  pushKey(out, section.title);
208
210
  for (const f of section.fields) {
209
211
  const fieldName = editFieldName(f);
210
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
212
+ const override = form.fieldLabels?.[fieldName];
213
+ if (override !== undefined) pushKey(out, override);
214
+ else out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
211
215
  }
212
216
  }
213
217
  break;
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
3
+ import { tenantDbRunner } from "../../db/tenant-db-runner";
3
4
  import { createRegistry, defineFeature, type Registry } from "../../engine";
4
5
  import { createTestRedis, type TestRedis, testTenantId } from "../../stack";
5
6
  import { waitFor } from "../../testing";
@@ -12,9 +13,9 @@ import { createJobRunner, type JobRunner } from "../job-runner";
12
13
  type JobRunResult = {
13
14
  readonly name: "system" | "tenant" | "system-per-tenant";
14
15
  readonly present: boolean;
15
- // assertTenantMatch() must return a TenantDb whose `.raw` is the SAME
16
- // underlying DbConnection ctx.db carries — proves systemDb is bound to
17
- // the job's own tenant-scoped db, not a separate instance.
16
+ // assertTenantMatch() must return a TenantDb bound (via tenantDbRunner) to
17
+ // the SAME underlying DbConnection ctx.db carries — proves systemDb is
18
+ // bound to the job's own tenant-scoped db, not a separate instance.
18
19
  readonly boundToRawDb: boolean | undefined;
19
20
  // A foreign tenantId must throw fail-closed (AccessDeniedError), same as
20
21
  // the HandlerContext.systemDb self-check.
@@ -48,7 +49,7 @@ const systemScopedFeature = defineFeature("jobsystemdb-system", (r) => {
48
49
  results.push({
49
50
  name: "system",
50
51
  present: true,
51
- boundToRawDb: checked.raw === ctx.db,
52
+ boundToRawDb: tenantDbRunner(checked) === ctx.db,
52
53
  foreignTenantThrew,
53
54
  });
54
55
  });
@@ -31,6 +31,10 @@ let redisUrl: string;
31
31
  // Track which jobs ran and when
32
32
  const jobLog: Array<{ name: string; payload: Record<string, unknown>; timestamp: number }> = [];
33
33
 
34
+ // Counts eventRetrySucceeds' own attempts — throws once on attempt 1, then
35
+ // succeeds, so a real retry must have happened for the job to ever reach 2.
36
+ let eventRetryAttemptCount = 0;
37
+
34
38
  function clearLog() {
35
39
  jobLog.length = 0;
36
40
  }
@@ -140,6 +144,25 @@ const testFeature = defineFeature("test", (r) => {
140
144
  },
141
145
  );
142
146
 
147
+ r.writeHandler(
148
+ "retry-event-trigger",
149
+ z.object({ n: z.number() }),
150
+ async (event) => ({ isSuccess: true, data: { n: event.payload.n } }),
151
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
152
+ );
153
+ // Event-triggered job with retries — proves handleEvent passes
154
+ // retries/backoff to the queue the same way dispatch() does (fw
155
+ // job-runner retries regression, solon).
156
+ r.job(
157
+ "eventRetrySucceeds",
158
+ { trigger: { on: "test:write:retry-event-trigger" }, retries: 2 },
159
+ async (payload) => {
160
+ eventRetryAttemptCount += 1;
161
+ jobLog.push({ name: "test:job:event-retry-succeeds", payload, timestamp: Date.now() });
162
+ if (eventRetryAttemptCount === 1) throw new Error("fails on first attempt");
163
+ },
164
+ );
165
+
143
166
  // Job that fails
144
167
  r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
145
168
  throw new Error("intentional failure");
@@ -1197,6 +1220,24 @@ describe("handleEvent maxPerTenant", () => {
1197
1220
  });
1198
1221
  });
1199
1222
 
1223
+ describe("handleEvent retries", () => {
1224
+ test("an event-triggered job with retries retries on failure and eventually succeeds", async () => {
1225
+ clearLog();
1226
+ eventRetryAttemptCount = 0;
1227
+ await withRunner(async (runner) => {
1228
+ const user = { ...TestUsers.admin, tenantId: "retry-tenant-evt" };
1229
+ await runner.handleEvent("test:write:retry-event-trigger", { n: 1 }, user);
1230
+ // Without retries/backoff reaching the queue, the job fails on
1231
+ // attempt 1 and BullMQ never retries — this would time out.
1232
+ await waitFor(() => {
1233
+ const entries = jobLog.filter((e) => e.name === "test:job:event-retry-succeeds");
1234
+ expect(entries.length).toBe(2);
1235
+ });
1236
+ });
1237
+ expect(eventRetryAttemptCount).toBe(2);
1238
+ });
1239
+ });
1240
+
1200
1241
  // --- Registry ---
1201
1242
 
1202
1243
  describe("job registry", () => {
@@ -9,6 +9,7 @@ import {
9
9
  type AppContext,
10
10
  type DispatchWriteRef,
11
11
  type JobContext,
12
+ type JobDefinition,
12
13
  type JobRunIn,
13
14
  type MemberReader,
14
15
  type Registry,
@@ -27,6 +28,7 @@ import {
27
28
  type Tracer,
28
29
  } from "../observability";
29
30
  import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
31
+ import { createEscapeHatchReporter } from "../pipeline/escape-hatch-report";
30
32
  import { RedisKeys } from "../pipeline/redis-keys";
31
33
  import { bridgeStub } from "../testing/handler-context";
32
34
 
@@ -251,6 +253,16 @@ function timeoutReject(
251
253
  };
252
254
  }
253
255
 
256
+ // Shared by dispatch() and handleEvent() — an event-triggered job must retry
257
+ // on failure the same way a directly-dispatched one does; a duplicated
258
+ // inline computation in handleEvent previously dropped both options.
259
+ function buildRetryBullOpts(jobDef: JobDefinition): Record<string, unknown> {
260
+ const opts: Record<string, unknown> = {};
261
+ if (jobDef.retries !== undefined) opts["attempts"] = jobDef.retries + 1;
262
+ if (jobDef.backoff) opts["backoff"] = { type: jobDef.backoff };
263
+ return opts;
264
+ }
265
+
254
266
  export function createJobRunner(options: JobRunnerOptions): JobRunner {
255
267
  const { registry, context, redisUrl, consumerLane } = options;
256
268
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
@@ -282,12 +294,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
282
294
  return async () => {
283
295
  const systemUser = createSystemUser(SYSTEM_TENANT_ID);
284
296
  const systemModeDb = createTenantDb(db, SYSTEM_TENANT_ID, "system");
297
+ const reportEscapeHatch = createEscapeHatchReporter({
298
+ handler: ACTIVE_TENANT_IDS_QUERY_NAME,
299
+ tenantId: SYSTEM_TENANT_ID,
300
+ actor: systemUser.id,
301
+ sink: context._escapeHatchAuditSink,
302
+ log: context.log,
303
+ });
285
304
  const result = await handler.handler(
286
305
  { type: ACTIVE_TENANT_IDS_QUERY_NAME, payload: {}, user: systemUser },
287
306
  {
288
307
  db: systemModeDb,
289
308
  dbOutsideTransaction: systemModeDb,
290
- systemDb: createUncheckedSystemDb(systemModeDb),
309
+ systemDb: createUncheckedSystemDb(systemModeDb, undefined, reportEscapeHatch),
291
310
  registry,
292
311
  ...bridgeStub(),
293
312
  },
@@ -533,7 +552,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
533
552
  const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
534
553
  const isSystemJob = registry.isJobSystemScoped(jobName);
535
554
  const systemDb =
536
- isSystemJob && tenantScopedDb ? createUncheckedSystemDb(tenantScopedDb) : undefined;
555
+ isSystemJob && tenantScopedDb
556
+ ? createUncheckedSystemDb(
557
+ tenantScopedDb,
558
+ undefined,
559
+ createEscapeHatchReporter({
560
+ handler: jobName,
561
+ tenantId,
562
+ actor: jobSystemUser.id,
563
+ sink: context._escapeHatchAuditSink,
564
+ log: context.log,
565
+ }),
566
+ )
567
+ : undefined;
537
568
  const config =
538
569
  context._configAccessorFactory && tenantScopedDb
539
570
  ? context._configAccessorFactory({
@@ -883,8 +914,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
883
914
  break;
884
915
  }
885
916
 
886
- if (jobDef.retries !== undefined) bullOpts["attempts"] = jobDef.retries + 1;
887
- if (jobDef.backoff) bullOpts["backoff"] = { type: jobDef.backoff };
917
+ Object.assign(bullOpts, buildRetryBullOpts(jobDef));
888
918
  if (jobDef.timeout) bullOpts["timeout"] = jobDef.timeout;
889
919
  if (meta?.priority !== undefined) bullOpts["priority"] = meta.priority;
890
920
 
@@ -948,7 +978,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
948
978
  }
949
979
  // Route to the job's declared lane, not a fixed queue — that's
950
980
  // the whole reason both queues are held.
951
- await queues[laneForJob(jobDef)].add(name, data);
981
+ await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
952
982
  }
953
983
  },
954
984
  attachDispatcher(ref: DispatchWriteRef): void {
@@ -0,0 +1,254 @@
1
+ // fw#2861 — every escape-hatch use reports through AppContext._escapeHatchAuditSink
2
+ // (or warn-logs when no sink is wired). Real HTTP calls + setupTestStack — never
3
+ // createTestDispatcher. Modelled on unsafe-raw-escape-hatch.integration.test.ts and
4
+ // system-identity-switch.integration.test.ts.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { z } from "zod";
8
+ import { defineUnmanagedTable } from "../../db/entity-table-meta";
9
+ import { executeRawQuery } from "../../db/queries/raw-sql";
10
+ import { createSystemUser, defineFeature } from "../../engine";
11
+ import type { EscapeHatchUseEvent } from "../../engine/types";
12
+ import type { Logger } from "../../logging/types";
13
+ import { createTestUser, setupTestStack, type TestStack } from "../../stack";
14
+ import { ESCAPE_HATCH_USED_SIGNAL } from "../escape-hatch-report";
15
+
16
+ const globalStoreTable = defineUnmanagedTable({
17
+ tableName: "store_fw2861_escape_hatch_audit_items",
18
+ tenancy: "global",
19
+ columns: [
20
+ {
21
+ name: "id",
22
+ pgType: "uuid",
23
+ notNull: true,
24
+ primaryKey: true,
25
+ defaultSql: "gen_random_uuid()",
26
+ },
27
+ { name: "note", pgType: "text", notNull: true },
28
+ ],
29
+ });
30
+
31
+ const UNSAFE_RAW_REASON = "fw#2861 integration test — declared unsafeRaw write";
32
+ const GLOBAL_WRITE_REASON = "fw#2861 integration test — declared cross-tenant write";
33
+ const IDENTITY_SWITCH_REASON = "fw#2861 integration test — needs SYSTEM to look itself up";
34
+ const ACKNOWLEDGE_REASON = "fw#2861 integration test — system-wide scan";
35
+
36
+ const probeFeature = defineFeature("escape-hatch-audit-probe", (r) => {
37
+ r.storeTable(globalStoreTable, {
38
+ reason: "fw#2861 integration test — cross-tenant store table for db.global()",
39
+ });
40
+
41
+ r.queryHandler(
42
+ "whoami",
43
+ z.object({}),
44
+ async (query) => ({ id: query.user.id, tenantId: query.user.tenantId }),
45
+ { access: { roles: ["system", "User"] } },
46
+ );
47
+
48
+ r.writeHandler(
49
+ "unsafe-raw-write",
50
+ z.object({}),
51
+ async (_event, ctx) => {
52
+ const runner = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
53
+ const rows = await executeRawQuery<{ one: number }>(runner, "SELECT 1 AS one");
54
+ return { isSuccess: true as const, data: { one: rows[0]?.one } };
55
+ },
56
+ { access: { roles: ["User"] }, escapeHatch: { reason: UNSAFE_RAW_REASON } },
57
+ );
58
+
59
+ r.writeHandler(
60
+ "global-write",
61
+ z.object({}),
62
+ async (_event, ctx) => {
63
+ const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
64
+ note: "hi",
65
+ });
66
+ return { isSuccess: true as const, data: row };
67
+ },
68
+ { access: { roles: ["User"] }, escapeHatch: { reason: GLOBAL_WRITE_REASON } },
69
+ );
70
+
71
+ r.writeHandler(
72
+ "identity-switch-write",
73
+ z.object({}),
74
+ async (event, ctx) =>
75
+ ctx
76
+ .queryAs(createSystemUser(event.user.tenantId), "escape-hatch-audit-probe:query:whoami", {})
77
+ .then((data) => ({ isSuccess: true as const, data })),
78
+ { access: { roles: ["User"] }, escapeHatch: { reason: IDENTITY_SWITCH_REASON } },
79
+ );
80
+
81
+ r.writeHandler(
82
+ "identity-switch-self-write",
83
+ z.object({}),
84
+ async (event, ctx) =>
85
+ ctx
86
+ .queryAs(event.user, "escape-hatch-audit-probe:query:whoami", {})
87
+ .then((data) => ({ isSuccess: true as const, data })),
88
+ { access: { roles: ["User"] }, escapeHatch: { reason: IDENTITY_SWITCH_REASON } },
89
+ );
90
+ });
91
+
92
+ const systemScopeProbeFeature = defineFeature("escape-hatch-audit-probe-system", (r) => {
93
+ r.systemScope();
94
+
95
+ r.queryHandler(
96
+ "acknowledge-cross-tenant-query",
97
+ z.object({}),
98
+ async (_query, ctx) => {
99
+ if (!ctx.systemDb) throw new Error("expected ctx.systemDb on a systemScope() handler");
100
+ const db = ctx.systemDb.acknowledgeCrossTenant(ACKNOWLEDGE_REASON);
101
+ return { tenantId: db.tenantId };
102
+ },
103
+ { access: { roles: ["User"] } },
104
+ );
105
+ });
106
+
107
+ const user = createTestUser({
108
+ id: 93,
109
+ tenantId: "11111111-0000-4000-8000-000000000093",
110
+ roles: ["User"],
111
+ });
112
+
113
+ function recordingSink(events: EscapeHatchUseEvent[]) {
114
+ return async (event: EscapeHatchUseEvent) => {
115
+ events.push(event);
116
+ };
117
+ }
118
+
119
+ describe("escape-hatch uses report through AppContext._escapeHatchAuditSink", () => {
120
+ let stack: TestStack;
121
+ const events: EscapeHatchUseEvent[] = [];
122
+
123
+ beforeAll(async () => {
124
+ stack = await setupTestStack({
125
+ features: [probeFeature, systemScopeProbeFeature],
126
+ extraContext: { _escapeHatchAuditSink: recordingSink(events) },
127
+ });
128
+ });
129
+
130
+ afterAll(async () => {
131
+ await stack.cleanup();
132
+ });
133
+
134
+ test("unsafeRaw reports exactly one unsafe-raw event with handler/kind/reason/tenantId/actor, deduped on a repeat call within the window", async () => {
135
+ events.length = 0;
136
+ await stack.http.writeOk("escape-hatch-audit-probe:write:unsafe-raw-write", {}, user);
137
+ // Identical (handler, kind, reason, tenantId, actor) — the 60s dedup window
138
+ // must suppress this second report.
139
+ await stack.http.writeOk("escape-hatch-audit-probe:write:unsafe-raw-write", {}, user);
140
+
141
+ const matches = events.filter((e) => e.kind === "unsafe-raw");
142
+ expect(matches).toEqual([
143
+ {
144
+ handler: "escape-hatch-audit-probe:write:unsafe-raw-write",
145
+ kind: "unsafe-raw",
146
+ reason: UNSAFE_RAW_REASON,
147
+ tenantId: user.tenantId,
148
+ actor: user.id,
149
+ },
150
+ ]);
151
+ });
152
+
153
+ test("db.global() write reports exactly one global-write event with the escapeHatch's reason", async () => {
154
+ events.length = 0;
155
+ await stack.http.writeOk("escape-hatch-audit-probe:write:global-write", {}, user);
156
+
157
+ const matches = events.filter((e) => e.kind === "global-write");
158
+ expect(matches).toEqual([
159
+ {
160
+ handler: "escape-hatch-audit-probe:write:global-write",
161
+ kind: "global-write",
162
+ reason: GLOBAL_WRITE_REASON,
163
+ tenantId: user.tenantId,
164
+ actor: user.id,
165
+ },
166
+ ]);
167
+ });
168
+
169
+ test("ctx.systemDb.acknowledgeCrossTenant reports exactly one acknowledge-cross-tenant event", async () => {
170
+ events.length = 0;
171
+ await stack.http.queryOk(
172
+ "escape-hatch-audit-probe-system:query:acknowledge-cross-tenant-query",
173
+ {},
174
+ user,
175
+ );
176
+
177
+ const matches = events.filter((e) => e.kind === "acknowledge-cross-tenant");
178
+ expect(matches).toEqual([
179
+ {
180
+ handler: "escape-hatch-audit-probe-system:query:acknowledge-cross-tenant-query",
181
+ kind: "acknowledge-cross-tenant",
182
+ reason: ACKNOWLEDGE_REASON,
183
+ tenantId: user.tenantId,
184
+ actor: user.id,
185
+ },
186
+ ]);
187
+ });
188
+
189
+ test("a granted SYSTEM identity switch reports exactly one identity-switch event with the target", async () => {
190
+ events.length = 0;
191
+ await stack.http.writeOk("escape-hatch-audit-probe:write:identity-switch-write", {}, user);
192
+
193
+ const matches = events.filter((e) => e.kind === "identity-switch");
194
+ expect(matches).toEqual([
195
+ {
196
+ handler: "escape-hatch-audit-probe:write:identity-switch-write",
197
+ kind: "identity-switch",
198
+ reason: IDENTITY_SWITCH_REASON,
199
+ tenantId: user.tenantId,
200
+ actor: user.id,
201
+ target: { id: createSystemUser(user.tenantId).id, tenantId: user.tenantId },
202
+ },
203
+ ]);
204
+ });
205
+
206
+ test("self-delegation with a grant reports no identity-switch event", async () => {
207
+ events.length = 0;
208
+ await stack.http.writeOk("escape-hatch-audit-probe:write:identity-switch-self-write", {}, user);
209
+
210
+ expect(events.filter((e) => e.kind === "identity-switch")).toEqual([]);
211
+ });
212
+ });
213
+
214
+ describe("escape-hatch uses without a sink fall back to a structured warn log", () => {
215
+ let stack: TestStack;
216
+ const warnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
217
+
218
+ beforeAll(async () => {
219
+ const log: Logger = {
220
+ info() {},
221
+ debug() {},
222
+ warn(msg, data) {
223
+ warnCalls.push({ msg, data });
224
+ },
225
+ error() {},
226
+ child() {
227
+ return log;
228
+ },
229
+ };
230
+ stack = await setupTestStack({
231
+ features: [probeFeature, systemScopeProbeFeature],
232
+ extraContext: { log },
233
+ });
234
+ });
235
+
236
+ afterAll(async () => {
237
+ await stack.cleanup();
238
+ });
239
+
240
+ test("unsafeRaw without a sink warn-logs security:escape-hatch-used", async () => {
241
+ warnCalls.length = 0;
242
+ await stack.http.writeOk("escape-hatch-audit-probe:write:unsafe-raw-write", {}, user);
243
+
244
+ const matches = warnCalls.filter((c) => c.msg === ESCAPE_HATCH_USED_SIGNAL);
245
+ expect(matches).toHaveLength(1);
246
+ expect(matches[0]?.data).toEqual({
247
+ handler: "escape-hatch-audit-probe:write:unsafe-raw-write",
248
+ kind: "unsafe-raw",
249
+ reason: UNSAFE_RAW_REASON,
250
+ tenantId: user.tenantId,
251
+ actor: user.id,
252
+ });
253
+ });
254
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { EscapeHatchUseEvent } from "../../engine/types";
3
+ import type { Logger } from "../../logging/types";
4
+ import { testTenantId } from "../../stack";
5
+ import {
6
+ createEscapeHatchReportWindow,
7
+ ESCAPE_HATCH_USED_SIGNAL,
8
+ reportEscapeHatchUse,
9
+ } from "../escape-hatch-report";
10
+
11
+ const tenantId = testTenantId(1);
12
+
13
+ function recordingLogger(): Logger & {
14
+ readonly warnCalls: Array<{ msg: string; data?: Record<string, unknown> }>;
15
+ readonly errorCalls: Array<{ msg: string; data?: Record<string, unknown> }>;
16
+ } {
17
+ const warnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
18
+ const errorCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
19
+ const logger: Logger & {
20
+ warnCalls: typeof warnCalls;
21
+ errorCalls: typeof errorCalls;
22
+ } = {
23
+ warnCalls,
24
+ errorCalls,
25
+ info() {},
26
+ debug() {},
27
+ warn(msg, data) {
28
+ warnCalls.push({ msg, data });
29
+ },
30
+ error(msg, data) {
31
+ errorCalls.push({ msg, data });
32
+ },
33
+ child() {
34
+ return logger;
35
+ },
36
+ };
37
+ return logger;
38
+ }
39
+
40
+ const baseEvent: EscapeHatchUseEvent = {
41
+ handler: 'write "widgets:write:create"',
42
+ kind: "unsafe-raw",
43
+ reason: "legacy migration read",
44
+ tenantId,
45
+ actor: "user-1",
46
+ };
47
+
48
+ describe("createEscapeHatchReportWindow", () => {
49
+ test("dedups an identical key within the window", () => {
50
+ const window = createEscapeHatchReportWindow({ windowMs: 1000 });
51
+ expect(window.shouldReport("k1", 0)).toBe(true);
52
+ expect(window.shouldReport("k1", 500)).toBe(false);
53
+ expect(window.shouldReport("k1", 999)).toBe(false);
54
+ });
55
+
56
+ test("reports again after the window expires", () => {
57
+ const window = createEscapeHatchReportWindow({ windowMs: 1000 });
58
+ expect(window.shouldReport("k1", 0)).toBe(true);
59
+ expect(window.shouldReport("k1", 1000)).toBe(true);
60
+ });
61
+
62
+ test("evicts the oldest entry beyond maxEntries", () => {
63
+ const window = createEscapeHatchReportWindow({ windowMs: 1000, maxEntries: 2 });
64
+ expect(window.shouldReport("k1", 0)).toBe(true);
65
+ expect(window.shouldReport("k2", 0)).toBe(true);
66
+ // k3 pushes the cache past its cap — k1 (oldest) is evicted to make room.
67
+ expect(window.shouldReport("k3", 0)).toBe(true);
68
+ // k1 was evicted, so it is treated as new and reports again.
69
+ expect(window.shouldReport("k1", 1)).toBe(true);
70
+ // k3 is still within its window and was not evicted.
71
+ expect(window.shouldReport("k3", 1)).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("reportEscapeHatchUse", () => {
76
+ test("sink present — calls the sink with the exact event and does not log.warn", () => {
77
+ const calls: EscapeHatchUseEvent[] = [];
78
+ const sink = async (event: EscapeHatchUseEvent) => {
79
+ calls.push(event);
80
+ };
81
+ const log = recordingLogger();
82
+
83
+ reportEscapeHatchUse(baseEvent, { sink, log });
84
+
85
+ expect(calls).toEqual([baseEvent]);
86
+ expect(log.warnCalls).toEqual([]);
87
+ });
88
+
89
+ test("no sink — log.warn called once with ESCAPE_HATCH_USED_SIGNAL and exact fields", () => {
90
+ const log = recordingLogger();
91
+
92
+ reportEscapeHatchUse(baseEvent, { log });
93
+
94
+ expect(log.warnCalls.length).toBe(1);
95
+ expect(log.warnCalls[0]?.msg).toBe(ESCAPE_HATCH_USED_SIGNAL);
96
+ expect(log.warnCalls[0]?.data).toEqual({
97
+ handler: baseEvent.handler,
98
+ kind: baseEvent.kind,
99
+ reason: baseEvent.reason,
100
+ tenantId: baseEvent.tenantId,
101
+ actor: baseEvent.actor,
102
+ });
103
+ });
104
+
105
+ test("no sink, event has a target — target fields are included", () => {
106
+ const log = recordingLogger();
107
+ const eventWithTarget: EscapeHatchUseEvent = {
108
+ ...baseEvent,
109
+ kind: "identity-switch",
110
+ target: { id: "user-2", tenantId },
111
+ };
112
+
113
+ reportEscapeHatchUse(eventWithTarget, { log });
114
+
115
+ expect(log.warnCalls[0]?.data).toEqual({
116
+ handler: eventWithTarget.handler,
117
+ kind: eventWithTarget.kind,
118
+ reason: eventWithTarget.reason,
119
+ tenantId: eventWithTarget.tenantId,
120
+ actor: eventWithTarget.actor,
121
+ targetId: "user-2",
122
+ targetTenantId: tenantId,
123
+ });
124
+ });
125
+
126
+ test("sink rejection — logs via log.error", async () => {
127
+ const log = recordingLogger();
128
+ const sink = async () => {
129
+ throw new Error("sink boom");
130
+ };
131
+
132
+ reportEscapeHatchUse(baseEvent, { sink, log });
133
+ // The sink runs async — let its rejection settle before asserting.
134
+ await Promise.resolve();
135
+ await Promise.resolve();
136
+
137
+ expect(log.errorCalls.length).toBe(1);
138
+ expect(log.errorCalls[0]?.msg).toBe(`${ESCAPE_HATCH_USED_SIGNAL}: audit sink failed`);
139
+ expect(log.errorCalls[0]?.data).toEqual({
140
+ handler: baseEvent.handler,
141
+ kind: baseEvent.kind,
142
+ reason: baseEvent.reason,
143
+ tenantId: baseEvent.tenantId,
144
+ actor: baseEvent.actor,
145
+ error: "sink boom",
146
+ });
147
+ expect(log.warnCalls).toEqual([]);
148
+ });
149
+ });
@@ -364,4 +364,32 @@ describe("rebuildMultiStreamProjection — guard rails", () => {
364
364
  expect(state?.status).toBe("dead");
365
365
  expect(state?.lastError).toMatch(/appendEvent/);
366
366
  });
367
+
368
+ test("aborts when the live table has RLS enabled — data survives (fw#2907)", async () => {
369
+ const erin = "00000000-0000-4000-8000-000000000e05";
370
+ await updateMany(
371
+ stack.db,
372
+ eventConsumerStateTable,
373
+ { status: "disabled", updatedAt: sql`now()` },
374
+ { name: SAGA_MSP },
375
+ );
376
+ await stack.http.writeOk("mspreb:write:invoice:bill", { customer: erin, cents: 20_00 }, admin);
377
+ await runFullDispatcher();
378
+
379
+ await asRawClient(stack.db).unsafe(
380
+ `ALTER TABLE "read_mspreb_balance" ENABLE ROW LEVEL SECURITY`,
381
+ );
382
+ try {
383
+ await expect(
384
+ rebuildMultiStreamProjection(BALANCE_MSP, { db: stack.db, registry: stack.registry }),
385
+ ).rejects.toThrow(/row level security/);
386
+
387
+ const [row] = await selectMany(stack.db, balanceTable, { customer: erin });
388
+ expect(row).toMatchObject({ invoicesCents: 20_00 });
389
+ } finally {
390
+ await asRawClient(stack.db).unsafe(
391
+ `ALTER TABLE "read_mspreb_balance" DISABLE ROW LEVEL SECURITY`,
392
+ );
393
+ }
394
+ });
367
395
  });