@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 +3 -3
- package/src/changes.json +7 -0
- package/src/engine/__tests__/extends-registrar.test.ts +76 -5
- package/src/engine/__tests__/hook-identity-switch-grant.test.ts +105 -0
- package/src/engine/__tests__/hook-phases.test.ts +51 -11
- package/src/engine/__tests__/post-query-hook.test.ts +21 -6
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +26 -0
- package/src/engine/boot-validator/access-declarations.ts +7 -5
- package/src/engine/feature-entity-handlers.ts +3 -0
- package/src/engine/feature-ui-extensions.ts +38 -5
- package/src/engine/registry-validate.ts +37 -6
- package/src/errors/i18n/de.yaml +13 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/jobs/__tests__/job-write-as.integration.test.ts +47 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +8 -2
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +352 -0
- package/src/pipeline/__tests__/system-identity-switch.test.ts +195 -0
- package/src/pipeline/dispatch-shared.ts +28 -16
- package/src/pipeline/system-identity-switch.ts +123 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
const
|
|
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
|
-
|
|
115
|
+
await inTxOnly[0]?.(dummySaveContext, dummyAppContext);
|
|
116
|
+
expect(inTxCalls).toEqual([dummySaveContext]);
|
|
117
|
+
expect(afterCalls).toEqual([]);
|
|
118
|
+
|
|
97
119
|
expect(afterOnly).toHaveLength(1);
|
|
98
|
-
|
|
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
|
|
119
|
-
const
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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}]
|
|
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
|
-
|
|
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] =
|
|
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({
|
|
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({
|
|
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 {
|