@cosmicdrift/kumiko-framework 0.263.0 → 0.264.1

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.263.0",
3
+ "version": "0.264.1",
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>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.263.0",
201
+ "@cosmicdrift/kumiko-types": "0.264.1",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.263.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.264.1",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.263.0",
4
+ "type": "breaking",
5
+ "title": "ctx.queryAs/ctx.writeAs with a SYSTEM identity only from r.systemScope(), jobs, or a handler/hook declaring escapeHatch (fw#2859).",
6
+ "detail": "Switching identity to SYSTEM (a SessionUser whose id is SYSTEM_USER_ID or whose roles contain SYSTEM_ROLE, e.g. `createSystemUser(...)`) through `HandlerContext.queryAs`/`writeAs` now throws `AccessDeniedError` (code `access_denied`, `details.reason` `system_identity_switch_denied`) unless the calling handler belongs to an `r.systemScope()` feature or declares `escapeHatch: { reason }`. The grant is looked up per handler type in the registry and never propagates: a handler reached through another handler's `writeAs(SYSTEM)` needs its own declaration for its own SYSTEM switches. `queryAs`/`writeAs` with a non-system user stays ungated and runs under that user's access. `JobContext.queryAs`/`writeAs` and top-level `dispatcher.write`/`query` (es-ops `systemWriteAs`, extraRoutes) stay ungated. Lifecycle hooks no longer inherit the grant of the handler they fire on: `r.hook(type, target, fn, { escapeHatch })` is the per-hook declaration; hooks contributed through `r.extendsRegistrar` are always denied; `r.authClaims` hooks' `queryAs` denies SYSTEM. `escapeHatch` is now also accepted on query handlers (`QueryHandlerDef`/`QueryHandlerDefinition`/`r.queryHandler` options); the boot validator rejects an empty reason there too, and `r.hook` throws at registration on an empty reason. Static boot-time detection of SYSTEM switches is not possible (handlers are closures), so enforcement is at runtime.",
7
+ "migration": "Find every `ctx.queryAs(...)`/`ctx.writeAs(...)` whose user is `createSystemUser(...)` or otherwise carries `SYSTEM_ROLE` (also indirectly through helpers that receive `ctx`). For each, add `escapeHatch: { reason: \"<which SYSTEM lookup/write and why the caller's own identity cannot do it>\" }` to the write or query handler definition that ends up calling it (`r.writeHandler`/`r.queryHandler` options, `defineWriteHandler`/`defineQueryHandler`), or move the logic into an `r.systemScope()` feature or a job when that is the correct owner. Handlers of `r.systemScope()` features need nothing. A lifecycle hook doing a SYSTEM switch through its runtime HandlerContext passes `{ escapeHatch: { reason } }` as `r.hook`'s options (postSave/postDelete: next to `phase`). `escapeHatch` on a write handler also permits `ctx.db.global()` writes. Consumer call sites to audit at release: solon 15, offlot-app 14, kumiko-enterprise 5 `queryAs`/`writeAs` sites."
8
+ },
2
9
  {
3
10
  "version": "0.263.0",
4
11
  "type": "breaking",
@@ -1,6 +1,26 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
2
  import { z } from "zod";
3
- import { createEntity, createRegistry, createTextField, defineFeature } from "../index";
3
+ import { AccessDeniedError } from "../../errors";
4
+ import { createGatedIdentitySwitch, type QueryAsFn } from "../../pipeline/system-identity-switch";
5
+ import {
6
+ createEntity,
7
+ createRegistry,
8
+ createSystemUser,
9
+ createTextField,
10
+ defineFeature,
11
+ } from "../index";
12
+ import type { AppContext, SaveContext } from "../types";
13
+ import type { TenantId } from "../types/identifiers";
14
+
15
+ const TENANT = "00000000-0000-4000-8000-00000000ee01" as TenantId;
16
+ const dummySaveContext: SaveContext = {
17
+ kind: "save",
18
+ id: "vehicle-1" as SaveContext["id"],
19
+ data: {},
20
+ changes: {},
21
+ previous: {},
22
+ isNew: true,
23
+ };
4
24
 
5
25
  describe("extendsRegistrar", () => {
6
26
  test("r.useExtension records usage with name, entity, and options", () => {
@@ -132,7 +152,7 @@ describe("extendsRegistrar", () => {
132
152
  expect(hooks.length).toBeGreaterThan(0);
133
153
  });
134
154
 
135
- test("extension preSave hooks fire for entity-scoped handlers", () => {
155
+ test("extension preSave hooks fire for entity-scoped handlers", async () => {
136
156
  const preSaveFn = mock(async (changes: Record<string, unknown>) => changes);
137
157
 
138
158
  const ext = defineFeature("audit", (r) => {
@@ -178,13 +198,19 @@ describe("extendsRegistrar", () => {
178
198
  const registry = createRegistry([ext, consumer]);
179
199
  const createHooks = registry.getPreSaveHooks("fleet:write:vehicle:create");
180
200
  expect(createHooks).toHaveLength(1);
181
- expect(createHooks[0]).toBe(preSaveFn);
201
+ // Hook is wrapped for the SYSTEM gate (system-identity-switch.ts), so it's no longer reference-equal to preSaveFn.
202
+ const preSaveContext = { previous: {}, isNew: true } as AppContext & {
203
+ previous: Readonly<Record<string, unknown>>;
204
+ isNew: boolean;
205
+ };
206
+ await createHooks[0]?.({ name: "x" }, preSaveContext);
207
+ expect(preSaveFn).toHaveBeenCalledTimes(1);
182
208
 
183
209
  const updateHooks = registry.getPreSaveHooks("fleet:write:vehicle:update");
184
210
  expect(updateHooks).toHaveLength(1);
185
211
  });
186
212
 
187
- test("extension postSave hooks are entity hooks", () => {
213
+ test("extension postSave hooks are entity hooks", async () => {
188
214
  const postSaveFn = mock(async () => {});
189
215
 
190
216
  const ext = defineFeature("audit", (r) => {
@@ -202,6 +228,51 @@ describe("extendsRegistrar", () => {
202
228
  const registry = createRegistry([ext, consumer]);
203
229
  const hooks = registry.getEntityPostSaveHooks("vehicle");
204
230
  expect(hooks).toHaveLength(1);
205
- expect(hooks[0]).toBe(postSaveFn);
231
+ await hooks[0]?.(dummySaveContext, {} as AppContext);
232
+ expect(postSaveFn).toHaveBeenCalledTimes(1);
233
+ });
234
+
235
+ test("extension postSave hook cannot switch identity to SYSTEM — no declaration site to opt in with", async () => {
236
+ const ungatedQueryAs = mock(async () => "ok");
237
+ const ungated = {
238
+ queryAs: ungatedQueryAs as QueryAsFn,
239
+ writeAs: async () => ({ isSuccess: true as const, data: null }),
240
+ };
241
+ // Simulates a handler whose OWN grant is allow=true (escapeHatch or
242
+ // systemScope) — the extension hook must not inherit this.
243
+ const handlerCtx = createGatedIdentitySwitch(
244
+ 'handler "fleet:write:vehicle:create"',
245
+ true,
246
+ ungated,
247
+ );
248
+
249
+ let caught: unknown;
250
+ const ext = defineFeature("audit", (r) => {
251
+ r.extendsRegistrar("audited", {
252
+ hooks: {
253
+ postSave: async (_result, ctx) => {
254
+ const asContext = ctx as unknown as { queryAs: QueryAsFn };
255
+ try {
256
+ await asContext.queryAs(createSystemUser(TENANT), "whoami", {});
257
+ } catch (err) {
258
+ caught = err;
259
+ }
260
+ },
261
+ },
262
+ });
263
+ });
264
+ const consumer = defineFeature("fleet", (r) => {
265
+ r.entity("vehicle", createEntity({ table: "Vehicles", fields: {} }));
266
+ r.useExtension("audited", "vehicle");
267
+ });
268
+
269
+ const registry = createRegistry([ext, consumer]);
270
+ const hooks = registry.getEntityPostSaveHooks("vehicle");
271
+ expect(hooks).toHaveLength(1);
272
+
273
+ await hooks[0]?.(dummySaveContext, handlerCtx as unknown as AppContext);
274
+
275
+ expect(caught).toBeInstanceOf(AccessDeniedError);
276
+ expect(ungatedQueryAs).not.toHaveBeenCalled();
206
277
  });
207
278
  });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { AccessDeniedError } from "../../errors";
3
+ import { createGatedIdentitySwitch, type QueryAsFn } from "../../pipeline/system-identity-switch";
4
+ import { createEntity, createRegistry, createSystemUser, defineFeature } from "../index";
5
+ import type { AppContext, PostSaveHookFn, SaveContext } from "../types";
6
+ import type { TenantId } from "../types/identifiers";
7
+
8
+ // fw#2859: r.hook({ allOf }, ...) must get the same identity-switch gate as the handler-keyed path.
9
+
10
+ const TENANT = "00000000-0000-4000-8000-00000000cd01" as TenantId;
11
+ const systemUser = createSystemUser(TENANT);
12
+
13
+ const dummySaveContext: SaveContext = {
14
+ kind: "save",
15
+ id: "thing-1" as SaveContext["id"],
16
+ data: {},
17
+ changes: {},
18
+ previous: {},
19
+ isNew: true,
20
+ };
21
+
22
+ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
23
+ test("entity-wide hook WITHOUT escapeHatch does not inherit the handler's SYSTEM grant", async () => {
24
+ const ungatedQueryAs = mock(async () => "ok");
25
+ const ungated = {
26
+ queryAs: ungatedQueryAs as QueryAsFn,
27
+ writeAs: async () => ({ isSuccess: true as const, data: null }),
28
+ };
29
+ // Simulates a handler whose OWN grant is allow=true (e.g. it declared
30
+ // escapeHatch or is systemScope) — the hook must not inherit this.
31
+ const handlerCtx = createGatedIdentitySwitch(
32
+ 'handler "test:write:thing:create"',
33
+ true,
34
+ ungated,
35
+ );
36
+
37
+ let caught: unknown;
38
+ const feature = defineFeature("test", (r) => {
39
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
40
+ const hookFn: PostSaveHookFn = async (_result, ctx) => {
41
+ const asContext = ctx as unknown as { queryAs: QueryAsFn };
42
+ try {
43
+ await asContext.queryAs(systemUser, "whoami", {});
44
+ } catch (err) {
45
+ caught = err;
46
+ }
47
+ };
48
+ r.hook("postSave", { allOf: thing }, hookFn);
49
+ });
50
+
51
+ const registry = createRegistry([feature]);
52
+ const hooks = registry.getEntityPostSaveHooks("thing");
53
+ expect(hooks).toHaveLength(1);
54
+
55
+ await hooks[0]?.(dummySaveContext, handlerCtx as unknown as AppContext);
56
+
57
+ expect(caught).toBeInstanceOf(AccessDeniedError);
58
+ expect(ungatedQueryAs).not.toHaveBeenCalled();
59
+ });
60
+
61
+ test("entity-wide hook WITH escapeHatch reaches SYSTEM even when the handler's own grant was false", async () => {
62
+ const ungatedQueryAs = mock(async () => "ok");
63
+ const ungated = {
64
+ queryAs: ungatedQueryAs as QueryAsFn,
65
+ writeAs: async () => ({ isSuccess: true as const, data: null }),
66
+ };
67
+ const handlerCtx = createGatedIdentitySwitch(
68
+ 'handler "test:write:thing:create"',
69
+ false,
70
+ ungated,
71
+ );
72
+
73
+ let queryAsResult: unknown;
74
+ const feature = defineFeature("test", (r) => {
75
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
76
+ const hookFn: PostSaveHookFn = async (_result, ctx) => {
77
+ const asContext = ctx as unknown as { queryAs: QueryAsFn };
78
+ queryAsResult = await asContext.queryAs(systemUser, "whoami", {});
79
+ };
80
+ r.hook("postSave", { allOf: thing }, hookFn, {
81
+ escapeHatch: { reason: "test: allOf hook needs SYSTEM" },
82
+ });
83
+ });
84
+
85
+ const registry = createRegistry([feature]);
86
+ const hooks = registry.getEntityPostSaveHooks("thing");
87
+ expect(hooks).toHaveLength(1);
88
+
89
+ await hooks[0]?.(dummySaveContext, handlerCtx as unknown as AppContext);
90
+
91
+ expect(queryAsResult).toBe("ok");
92
+ expect(ungatedQueryAs).toHaveBeenCalledWith(systemUser, "whoami", {});
93
+ });
94
+
95
+ test("registering an { allOf } hook with an empty escapeHatch reason throws at registration", () => {
96
+ expect(() => {
97
+ defineFeature("test", (r) => {
98
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
99
+ r.hook("postSave", { allOf: thing }, async () => undefined, {
100
+ escapeHatch: { reason: "" },
101
+ });
102
+ });
103
+ }).toThrow(/non-empty string/);
104
+ });
105
+ });
@@ -1,7 +1,17 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { createEntity, createRegistry, defineFeature, HookPhases } from "../index";
4
- import type { PostSaveHookFn } from "../types";
4
+ import type { AppContext, PostSaveHookFn, SaveContext } from "../types";
5
+
6
+ const dummySaveContext: SaveContext = {
7
+ kind: "save",
8
+ id: "thing-1" as SaveContext["id"],
9
+ data: {},
10
+ changes: {},
11
+ previous: {},
12
+ isNew: true,
13
+ };
14
+ const dummyAppContext = {} as AppContext;
5
15
 
6
16
  // These tests lock in the phase defaults + filtering so the invariants survive
7
17
  // refactors. Behavior under test:
@@ -72,9 +82,15 @@ describe("HookPhases defaults", () => {
72
82
  });
73
83
 
74
84
  describe("Registry phase filtering", () => {
75
- test("getPostSaveHooks filters by phase when given", () => {
76
- const inTxFn: PostSaveHookFn = async () => undefined;
77
- const afterFn: PostSaveHookFn = async () => undefined;
85
+ test("getPostSaveHooks filters by phase when given", async () => {
86
+ const inTxCalls: SaveContext[] = [];
87
+ const afterCalls: SaveContext[] = [];
88
+ const inTxFn: PostSaveHookFn = async (result) => {
89
+ inTxCalls.push(result);
90
+ };
91
+ const afterFn: PostSaveHookFn = async (result) => {
92
+ afterCalls.push(result);
93
+ };
78
94
 
79
95
  const feature = defineFeature("test", (r) => {
80
96
  r.entity("thing", createEntity({ table: "things", fields: {} }));
@@ -92,10 +108,18 @@ describe("Registry phase filtering", () => {
92
108
  const afterOnly = registry.getPostSaveHooks(handlerQn, HookPhases.afterCommit);
93
109
  const all = registry.getPostSaveHooks(handlerQn);
94
110
 
111
+ // Hooks are wrapped for the SYSTEM identity-switch gate (system-identity-
112
+ // switch.ts), so the stored fn is no longer reference-equal to the
113
+ // original — prove behaviour (calling the original) instead.
95
114
  expect(inTxOnly).toHaveLength(1);
96
- expect(inTxOnly[0]).toBe(inTxFn);
115
+ await inTxOnly[0]?.(dummySaveContext, dummyAppContext);
116
+ expect(inTxCalls).toEqual([dummySaveContext]);
117
+ expect(afterCalls).toEqual([]);
118
+
97
119
  expect(afterOnly).toHaveLength(1);
98
- expect(afterOnly[0]).toBe(afterFn);
120
+ await afterOnly[0]?.(dummySaveContext, dummyAppContext);
121
+ expect(afterCalls).toEqual([dummySaveContext]);
122
+
99
123
  expect(all).toHaveLength(2);
100
124
  });
101
125
 
@@ -114,9 +138,15 @@ describe("Registry phase filtering", () => {
114
138
  );
115
139
  });
116
140
 
117
- test("getEntityPostSaveHooks filters by phase", () => {
118
- const inTxFn: PostSaveHookFn = async () => undefined;
119
- const afterFn: PostSaveHookFn = async () => undefined;
141
+ test("getEntityPostSaveHooks filters by phase", async () => {
142
+ const inTxCalls: SaveContext[] = [];
143
+ const afterCalls: SaveContext[] = [];
144
+ const inTxFn: PostSaveHookFn = async (result) => {
145
+ inTxCalls.push(result);
146
+ };
147
+ const afterFn: PostSaveHookFn = async (result) => {
148
+ afterCalls.push(result);
149
+ };
120
150
 
121
151
  const feature = defineFeature("test", (r) => {
122
152
  const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
@@ -125,8 +155,18 @@ describe("Registry phase filtering", () => {
125
155
  });
126
156
 
127
157
  const registry = createRegistry([feature]);
128
- expect(registry.getEntityPostSaveHooks("thing", HookPhases.inTransaction)).toEqual([inTxFn]);
129
- expect(registry.getEntityPostSaveHooks("thing", HookPhases.afterCommit)).toEqual([afterFn]);
158
+ const inTxHooks = registry.getEntityPostSaveHooks("thing", HookPhases.inTransaction);
159
+ const afterHooks = registry.getEntityPostSaveHooks("thing", HookPhases.afterCommit);
160
+
161
+ expect(inTxHooks).toHaveLength(1);
162
+ await inTxHooks[0]?.(dummySaveContext, dummyAppContext);
163
+ expect(inTxCalls).toEqual([dummySaveContext]);
164
+ expect(afterCalls).toEqual([]);
165
+
166
+ expect(afterHooks).toHaveLength(1);
167
+ await afterHooks[0]?.(dummySaveContext, dummyAppContext);
168
+ expect(afterCalls).toEqual([dummySaveContext]);
169
+
130
170
  expect(registry.getEntityPostSaveHooks("thing")).toHaveLength(2);
131
171
  });
132
172
  });
@@ -61,8 +61,12 @@ describe("postQuery hook registration", () => {
61
61
  });
62
62
 
63
63
  describe("Registry getters", () => {
64
- test("getPostQueryHooks returns handler-keyed hooks", () => {
65
- const fn: PostQueryHookFn = async ({ rows }) => ({ rows });
64
+ test("getPostQueryHooks returns handler-keyed hooks", async () => {
65
+ const calls: ReadonlyArray<Record<string, unknown>>[] = [];
66
+ const fn: PostQueryHookFn = async ({ rows }) => {
67
+ calls.push(rows);
68
+ return { rows };
69
+ };
66
70
 
67
71
  const feature = defineFeature("test", (r) => {
68
72
  r.entity("thing", createEntity({ table: "things", fields: {} }));
@@ -73,11 +77,20 @@ describe("Registry getters", () => {
73
77
  const registry = createRegistry([feature]);
74
78
  const hooks = registry.getPostQueryHooks("test:query:thing:list");
75
79
  expect(hooks).toHaveLength(1);
76
- expect(hooks[0]).toBe(fn);
80
+ // Hooks are wrapped for the SYSTEM identity-switch gate
81
+ // (system-identity-switch.ts), so the stored fn is no longer
82
+ // reference-equal to `fn` — prove behaviour (calling the original) instead.
83
+ const inputRows: ReadonlyArray<Record<string, unknown>> = [{ id: "1" }];
84
+ await hooks[0]?.({ entityName: "thing", rows: inputRows }, stubContext);
85
+ expect(calls).toEqual([inputRows]);
77
86
  });
78
87
 
79
- test("getEntityPostQueryHooks returns entity-keyed hooks", () => {
80
- const fn: PostQueryHookFn = async ({ rows }) => ({ rows });
88
+ test("getEntityPostQueryHooks returns entity-keyed hooks", async () => {
89
+ const calls: ReadonlyArray<Record<string, unknown>>[] = [];
90
+ const fn: PostQueryHookFn = async ({ rows }) => {
91
+ calls.push(rows);
92
+ return { rows };
93
+ };
81
94
 
82
95
  const feature = defineFeature("test", (r) => {
83
96
  const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
@@ -87,7 +100,9 @@ describe("Registry getters", () => {
87
100
  const registry = createRegistry([feature]);
88
101
  const hooks = registry.getEntityPostQueryHooks("thing");
89
102
  expect(hooks).toHaveLength(1);
90
- expect(hooks[0]).toBe(fn);
103
+ const inputRows: ReadonlyArray<Record<string, unknown>> = [{ id: "1" }];
104
+ await hooks[0]?.({ entityName: "thing", rows: inputRows }, stubContext);
105
+ expect(calls).toEqual([inputRows]);
91
106
  });
92
107
 
93
108
  test("getPostQueryHooks empty for unknown target", () => {
@@ -116,6 +116,32 @@ describe("validateAccessDeclarations", () => {
116
116
  expect(() => validateAccessDeclarations(feature)).not.toThrow();
117
117
  });
118
118
 
119
+ // 2b. escapeHatch reason must be non-empty on a query handler too (fw#2859:
120
+ // query handlers can't reach db.global(), but they can still declare
121
+ // escapeHatch to switch identity to SYSTEM via ctx.queryAs).
122
+ test("query handler escapeHatch with an empty reason throws, naming the handler", () => {
123
+ const feature = defineFeature("notes", (r) => {
124
+ r.entity("note", noteEntity);
125
+ r.queryHandler("note:list", z.object({}), async () => [], {
126
+ access: { roles: ["Admin"] },
127
+ escapeHatch: { reason: "" },
128
+ });
129
+ });
130
+ expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
131
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:list"/);
132
+ });
133
+
134
+ test("query handler escapeHatch with a non-empty reason boots fine", () => {
135
+ const feature = defineFeature("notes", (r) => {
136
+ r.entity("note", noteEntity);
137
+ r.queryHandler("note:list", z.object({}), async () => [], {
138
+ access: { roles: ["Admin"] },
139
+ escapeHatch: { reason: "cross-tenant note lookup for auth" },
140
+ });
141
+ });
142
+ expect(() => validateAccessDeclarations(feature)).not.toThrow();
143
+ });
144
+
119
145
  // 3. openToAll write handler accepting a personal-data field needs publicIntake.
120
146
  test("openToAll write handler accepting a personal-data field without publicIntake throws", () => {
121
147
  const feature = defineFeature("notes", (r) => {
@@ -1,4 +1,4 @@
1
- import type { AccessRule, FeatureDefinition, WriteHandlerDef } from "../types";
1
+ import type { AccessRule, FeatureDefinition, QueryHandlerDef, WriteHandlerDef } from "../types";
2
2
  import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
3
3
  import { getZodObjectShape } from "./zod-shape";
4
4
 
@@ -73,15 +73,16 @@ function validateOpenToAllReason(
73
73
 
74
74
  function validateEscapeHatchReason(
75
75
  feature: FeatureDefinition,
76
+ kind: HandlerKind,
76
77
  handlerName: string,
77
- escapeHatch: WriteHandlerDef["escapeHatch"],
78
+ escapeHatch: WriteHandlerDef["escapeHatch"] | QueryHandlerDef["escapeHatch"],
78
79
  ): void {
79
80
  // skip: no escapeHatch declared, or its reason is already non-empty
80
81
  if (!escapeHatch || escapeHatch.reason.trim().length > 0) return;
81
82
  throw new Error(
82
- `[Feature ${feature.name}] write handler "${handlerName}" declares ` +
83
+ `[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
83
84
  `{ escapeHatch: { reason: "" } } — the reason must be a non-empty string ` +
84
- "explaining why this handler needs db.global() write access.",
85
+ "explaining why this handler needs db.global() write access or a SYSTEM identity switch.",
85
86
  );
86
87
  }
87
88
 
@@ -127,11 +128,12 @@ function validateOpenToAllPersonalData(
127
128
  export function validateAccessDeclarations(feature: FeatureDefinition): void {
128
129
  for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
129
130
  validateOpenToAllReason(feature, "write", handlerName, handler.access);
130
- validateEscapeHatchReason(feature, handlerName, handler.escapeHatch);
131
+ validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
131
132
  validateOpenToAllPersonalData(feature, handlerName, handler);
132
133
  }
133
134
  for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
134
135
  validateOpenToAllReason(feature, "query", handlerName, handler.access);
136
+ validateEscapeHatchReason(feature, "query", handlerName, handler.escapeHatch);
135
137
  validatePublicIntakeOnlyOnWrite(feature, "query", handlerName, handler.access);
136
138
  }
137
139
  for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
@@ -185,6 +185,7 @@ export function buildEntityHandlerMethods<TName extends string>(
185
185
  outputSchema?: ZodType;
186
186
  description?: string;
187
187
  agent?: AgentHandlerHints;
188
+ escapeHatch?: EscapeHatchDeclaration;
188
189
  },
189
190
  ): HandlerRef {
190
191
  if (typeof nameOrDef === "object") {
@@ -198,6 +199,7 @@ export function buildEntityHandlerMethods<TName extends string>(
198
199
  ...agentSlots(def),
199
200
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
200
201
  ...(def.outputSchema && { outputSchema: def.outputSchema }),
202
+ ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
201
203
  // Carry the definePagedQueryHandler brand through — this rebuild
202
204
  // drops any field not explicitly listed.
203
205
  ...(isPagedQueryHandler(def) && { [PAGED_QUERY_HANDLER_BRAND]: true }),
@@ -217,6 +219,7 @@ export function buildEntityHandlerMethods<TName extends string>(
217
219
  ...agentSlots(inline.options),
218
220
  ...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
219
221
  ...(inline.options.outputSchema && { outputSchema: inline.options.outputSchema }),
222
+ ...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
220
223
  };
221
224
  tryMapEntity(state, name, nameOrDef);
222
225
  return { name: nameOrDef };
@@ -1,4 +1,5 @@
1
1
  import type { EntityTableMeta } from "../db/entity-table-meta";
2
+ import { bindHookIdentitySwitchGrant } from "../pipeline/system-identity-switch";
2
3
  import { LifecycleHookTypes } from "./constants";
3
4
  import type { FeatureBuilderState } from "./feature-builder-state";
4
5
  import { resolveName } from "./handler-helpers";
@@ -6,6 +7,7 @@ import { isKebabSegment, toKebab } from "./qualified-name";
6
7
  import type {
7
8
  BootCheckFn,
8
9
  EntityProjectionExtension,
10
+ EscapeHatchDeclaration,
9
11
  HookPhase,
10
12
  LifecycleHookFn,
11
13
  LifecycleHookType,
@@ -117,8 +119,32 @@ export function buildUiExtensionsMethods<TName extends string>(
117
119
  type: LifecycleHookType | "validation",
118
120
  target: NameOrRef | readonly NameOrRef[] | { readonly allOf: NameOrRef },
119
121
  fn: LifecycleHookFn | ValidationHookFn,
120
- options?: { phase?: HookPhase },
122
+ options?: { phase?: HookPhase; escapeHatch?: EscapeHatchDeclaration },
121
123
  ): void {
124
+ if (type === "validation") {
125
+ if (options?.escapeHatch !== undefined) {
126
+ throw new Error(
127
+ `[Feature ${name}] r.hook("validation", ...) does not accept { escapeHatch } — ` +
128
+ "validation hooks receive no context to switch identity with.",
129
+ );
130
+ }
131
+ } else if (
132
+ options?.escapeHatch !== undefined &&
133
+ options.escapeHatch.reason.trim().length === 0
134
+ ) {
135
+ throw new Error(
136
+ `[Feature ${name}] r.hook("${type}", ...) declares { escapeHatch: { reason: "" } } — ` +
137
+ "the reason must be a non-empty string explaining why this hook switches identity to SYSTEM.",
138
+ );
139
+ }
140
+
141
+ // Wrapped once for both branches below; validation hooks stay unwrapped (no context).
142
+ const hookLabel = `${type} hook of feature "${name}"`;
143
+ const wrapped: LifecycleHookFn | ValidationHookFn =
144
+ type === "validation"
145
+ ? fn
146
+ : bindHookIdentitySwitchGrant(fn as LifecycleHookFn, hookLabel, options?.escapeHatch); // @cast-boundary engine-bridge
147
+
122
148
  // Entity-wide target ("all write/query handlers of this entity") —
123
149
  // replaces the old r.entityHook(type, entity, fn).
124
150
  if (
@@ -138,7 +164,7 @@ export function buildUiExtensionsMethods<TName extends string>(
138
164
  name,
139
165
  type,
140
166
  resolveName(target.allOf),
141
- fn as LifecycleHookFn,
167
+ wrapped as LifecycleHookFn, // @cast-boundary engine-bridge
142
168
  options,
143
169
  );
144
170
  // skip: entity-wide target fully handled above, nothing more to do
@@ -152,7 +178,7 @@ export function buildUiExtensionsMethods<TName extends string>(
152
178
  // — typed Dev-API (LifecycleHookFn|ValidationHookFn) → erased Map<name, fn>.
153
179
  if (type === "validation") {
154
180
  for (const n of names) {
155
- state.validationHooks[n] = fn as ValidationHookFn; // @cast-boundary engine-bridge
181
+ state.validationHooks[n] = wrapped as ValidationHookFn; // @cast-boundary engine-bridge
156
182
  }
157
183
  // skip: validation hooks have no phase, stored and done
158
184
  return;
@@ -166,7 +192,10 @@ export function buildUiExtensionsMethods<TName extends string>(
166
192
  if (!state.lifecycleHooks[type]) state.lifecycleHooks[type] = {};
167
193
  for (const n of names) {
168
194
  if (!state.lifecycleHooks[type][n]) state.lifecycleHooks[type][n] = [];
169
- state.lifecycleHooks[type][n].push({ fn: fn as LifecycleHookFn, featureName: name }); // @cast-boundary engine-bridge
195
+ state.lifecycleHooks[type][n].push({
196
+ fn: wrapped as LifecycleHookFn, // @cast-boundary engine-bridge
197
+ featureName: name,
198
+ });
170
199
  }
171
200
  // skip: pre/post-hooks without phase semantics, stored and done
172
201
  return;
@@ -181,7 +210,11 @@ export function buildUiExtensionsMethods<TName extends string>(
181
210
  const bucket = state.phasedLifecycleHooks[type];
182
211
  for (const n of names) {
183
212
  if (!bucket[n]) bucket[n] = [];
184
- bucket[n].push({ fn: fn as LifecycleHookFn, phase, featureName: name }); // @cast-boundary engine-bridge
213
+ bucket[n].push({
214
+ fn: wrapped as LifecycleHookFn, // @cast-boundary engine-bridge
215
+ phase,
216
+ featureName: name,
217
+ });
185
218
  }
186
219
  },
187
220
  searchPayloadExtension(entityRef: NameOrRef, fn: SearchPayloadContributorFn): void {