@cosmicdrift/kumiko-framework 0.262.0 → 0.264.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.262.0",
3
+ "version": "0.264.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.262.0",
201
+ "@cosmicdrift/kumiko-types": "0.264.0",
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.262.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.264.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
package/src/changes.json CHANGED
@@ -1,4 +1,30 @@
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
+ },
9
+ {
10
+ "version": "0.263.0",
11
+ "type": "breaking",
12
+ "title": "Forms are prefilled from URL query parameters only for fields a declarative `navigate` action declares as `params` (fw#2852).",
13
+ "detail": "`buildAppSchema` now derives `urlPrefillFields` per actionForm/secretMint/entityEdit-create screen from every navigate `params` that targets it — entityList/projectionList rowActions, projectionDetail/entityEdit actions, relatedList rowActions, and projectionDetail metrics; a form nothing targets takes nothing from the URL. Previously any query parameter matching a field name was taken, so a crafted link could seed e.g. an IBAN or e-mail field. Declared params keep working. `sensitive` and `format: \"password\"` fields are still never prefilled — not from the URL, an allowlist, or a handoff.",
14
+ "migration": "Code that prefills a form via `nav.navigate` + `nav.setSearchParams` without a declared `params` (known: publicstatus `custom-field-list`, phronexsis `driver-map-node-panel`, kumiko-enterprise ai-agent `openForm` client tool / `agentPrefill=1`) must switch to a declared `navigate` action with `params`, or — for programmatic prefill — to `useNavigateWithInitialValues()` from `@cosmicdrift/kumiko-renderer`. No codemod."
15
+ },
16
+ {
17
+ "version": "0.263.0",
18
+ "type": "improvement",
19
+ "title": "New `useNavigateWithInitialValues()` hands a form screen initial values in memory instead of the query string (fw#2852).",
20
+ "detail": "`useNavigateWithInitialValues()`, exported from `@cosmicdrift/kumiko-renderer`, returns `(target: ScreenTarget, initialValues: Readonly<Record<string, unknown>>) => void`, which navigates to a form screen and carries the initial values in memory rather than as URL search params — for programmatic prefill outside a declared `navigate` `params` action. `sensitive` and `format: \"password\"` fields are still never prefilled, same as URL and allowlist prefill."
21
+ },
22
+ {
23
+ "version": "0.263.0",
24
+ "type": "fix",
25
+ "title": "`projectField`/`projectEntity` now forward field metadata the client schema previously dropped (fw#2852).",
26
+ "detail": "`projectField`/`projectEntity` (`packages/framework/src/engine/build-app-schema.ts`) now forward number `unit`, entity `defaultCurrency`, text `format`, timestamp `locatedBy`, file/image `accept`/`maxSize`/`variants`, decimal `scale` (incl. embedded sub-fields), and `sensitive` into the client schema; previously they were lost on the way, so the unit suffix did not render, money fields fell back to \"EUR\", password masking and the never-prefill-sensitive rule were inactive client-side."
27
+ },
2
28
  {
3
29
  "version": "0.262.0",
4
30
  "type": "improvement",
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildAppSchema } from "../build-app-schema";
3
+ import { defineFeature } from "../define-feature";
4
+ import { createRegistry } from "../registry";
5
+ import type { EntityDefinition } from "../types/fields";
6
+ import type { ScreenDefinition } from "../types/screen";
7
+
8
+ const leaseEntity = {
9
+ fields: { name: { type: "text" }, iban: { type: "text" } },
10
+ } as unknown as EntityDefinition;
11
+ const itemEntity = {
12
+ fields: { leaseId: { type: "text" }, note: { type: "text" } },
13
+ } as unknown as EntityDefinition;
14
+
15
+ const leasesFeature = defineFeature("leases", (r) => {
16
+ r.entity("lease", leaseEntity);
17
+ r.entity("item", itemEntity);
18
+ r.screen({
19
+ id: "lease-list",
20
+ type: "entityList",
21
+ entity: "lease",
22
+ columns: ["name"],
23
+ rowActions: [
24
+ {
25
+ kind: "navigate",
26
+ id: "add-item",
27
+ label: "add",
28
+ screen: "item-create",
29
+ params: { map: { leaseId: "id" } },
30
+ },
31
+ {
32
+ kind: "navigate",
33
+ id: "pay",
34
+ label: "pay",
35
+ screen: "record-payment",
36
+ params: { pick: ["name"] },
37
+ },
38
+ {
39
+ kind: "navigate",
40
+ id: "edit",
41
+ label: "edit",
42
+ screen: "lease-edit",
43
+ params: { pick: ["iban"] },
44
+ },
45
+ {
46
+ kind: "drawer",
47
+ id: "note",
48
+ label: "note",
49
+ screen: "drawer-only-form",
50
+ params: { pick: ["note"] },
51
+ },
52
+ ],
53
+ });
54
+ r.screen({
55
+ id: "lease-edit",
56
+ type: "entityEdit",
57
+ entity: "lease",
58
+ layout: { sections: [{ title: "x", fields: ["name", "iban"] }] },
59
+ actions: [
60
+ {
61
+ kind: "navigate",
62
+ id: "pay-party",
63
+ label: "pay",
64
+ screen: "record-payment",
65
+ params: { map: { partyId: "id" } },
66
+ },
67
+ ],
68
+ });
69
+ r.screen({
70
+ id: "lease-detail",
71
+ type: "projectionDetail",
72
+ query: "leases:query:lease:detail",
73
+ layout: { sections: [] },
74
+ metrics: [
75
+ {
76
+ field: "openItems",
77
+ label: "items",
78
+ navigate: { screen: "item-create", params: { map: { note: "summary" } } },
79
+ },
80
+ ],
81
+ });
82
+ r.screen({
83
+ id: "item-create",
84
+ type: "entityEdit",
85
+ entity: "item",
86
+ layout: { sections: [{ title: "x", fields: ["leaseId", "note"] }] },
87
+ });
88
+ r.screen({
89
+ id: "record-payment",
90
+ type: "actionForm",
91
+ handler: "leases:write:payment:record",
92
+ fields: { name: { type: "text" }, partyId: { type: "text" }, iban: { type: "text" } },
93
+ layout: { sections: [{ title: "x", fields: ["name", "partyId", "iban"] }] },
94
+ urlPrefillFields: ["iban"],
95
+ } as ScreenDefinition);
96
+ r.screen({
97
+ id: "drawer-only-form",
98
+ type: "actionForm",
99
+ handler: "leases:write:item:note",
100
+ fields: { note: { type: "text" } },
101
+ layout: { sections: [{ title: "x", fields: ["note"] }] },
102
+ });
103
+ });
104
+
105
+ const billingFeature = defineFeature("billing", (r) => {
106
+ r.entity("invoice", { fields: { payee: { type: "text" } } } as unknown as EntityDefinition);
107
+ r.screen({
108
+ id: "invoice-list",
109
+ type: "entityList",
110
+ entity: "invoice",
111
+ columns: ["payee"],
112
+ rowActions: [
113
+ {
114
+ kind: "navigate",
115
+ id: "pay",
116
+ label: "pay",
117
+ screen: "record-payment",
118
+ params: { map: { name: "payee" } },
119
+ },
120
+ ],
121
+ });
122
+ });
123
+
124
+ function urlPrefillFieldsOf(featureName: string, screenId: string): unknown {
125
+ const app = buildAppSchema(createRegistry([leasesFeature, billingFeature]));
126
+ const screen = app.features
127
+ .find((f) => f.featureName === featureName)
128
+ ?.screens.find((s) => s.id.endsWith(`:${screenId}`) || s.id === screenId);
129
+ if (screen === undefined) throw new Error(`screen ${featureName}/${screenId} not projected`);
130
+ return "urlPrefillFields" in screen ? screen.urlPrefillFields : undefined;
131
+ }
132
+
133
+ describe("buildAppSchema — urlPrefillFields derived from navigate params", () => {
134
+ test("a cross-entity entityEdit-create target gets exactly the declared params keys", () => {
135
+ expect(urlPrefillFieldsOf("leases", "item-create")).toEqual(["leaseId", "note"]);
136
+ });
137
+
138
+ test("keys from several sources and features are unioned; an authored value is overwritten", () => {
139
+ expect(urlPrefillFieldsOf("leases", "record-payment")).toEqual(["name", "partyId"]);
140
+ });
141
+
142
+ test("a form no navigate params targets gets an empty list — drawer params do not count", () => {
143
+ expect(urlPrefillFieldsOf("leases", "drawer-only-form")).toEqual([]);
144
+ });
145
+
146
+ test("a same-entity entityEdit target opens in update mode and gets no URL prefill", () => {
147
+ expect(urlPrefillFieldsOf("leases", "lease-edit")).toEqual([]);
148
+ });
149
+
150
+ test("non-form screens carry no urlPrefillFields", () => {
151
+ expect(urlPrefillFieldsOf("leases", "lease-list")).toBeUndefined();
152
+ });
153
+ });
@@ -420,10 +420,10 @@ describe("buildAppSchema", () => {
420
420
  "default", // array-valued for multiSelect — projectField's
421
421
  // isJsonSafeValue() (fw#2497) now recurses into arrays/plain
422
422
  // objects instead of only string/number/boolean/null.
423
+ "sensitive", // write-response redaction server-side AND the renderer's prefill block
423
424
  ] as const;
424
425
 
425
426
  const SERVER_ONLY_KEYS = [
426
- "sensitive", // controls write-response redaction, never rendered
427
427
  "access", // server-side authz check, not a renderer concern
428
428
  "pii", // PII classification, drives crypto/storage — not client-relevant
429
429
  "userOwned", // same: subject-key annotation, server/crypto-only
@@ -582,6 +582,95 @@ describe("buildAppSchema", () => {
582
582
  expect(fields["avatar"]?.["capture"]).toBeUndefined();
583
583
  });
584
584
 
585
+ test("number unit (static and sibling-field form) and entity defaultCurrency survive the projection", () => {
586
+ // Regression: the edit view-model reads `unit` and `entity.defaultCurrency`,
587
+ // but the client schema never carried them — the suffix never rendered and
588
+ // money fields always fell back to "EUR".
589
+ const entity = {
590
+ defaultCurrency: "USD",
591
+ fields: {
592
+ distance: { type: "number", unit: "mi" },
593
+ mileage: { type: "number", unit: { field: "mileageUnit" } },
594
+ mileageUnit: { type: "text" },
595
+ count: { type: "number" },
596
+ bogus: { type: "number", unit: { field: () => "km" } },
597
+ },
598
+ } as unknown as EntityDefinition;
599
+
600
+ const f = defineFeature("ent", (r) => {
601
+ r.entity("thing", entity);
602
+ });
603
+ const app = buildAppSchema(createRegistry([f]));
604
+ const projected = app.features[0]!.entities["thing"] as unknown as {
605
+ defaultCurrency?: string;
606
+ fields: Record<string, Record<string, unknown>>;
607
+ };
608
+
609
+ expect(projected.defaultCurrency).toBe("USD");
610
+ expect(projected.fields["distance"]?.["unit"]).toBe("mi");
611
+ expect(projected.fields["mileage"]?.["unit"]).toEqual({ field: "mileageUnit" });
612
+ expect(projected.fields["count"]?.["unit"]).toBeUndefined();
613
+ expect(projected.fields["bogus"]?.["unit"]).toBeUndefined();
614
+ expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
615
+ });
616
+
617
+ test("entity without defaultCurrency omits the key", () => {
618
+ const f = defineFeature("ent", (r) => {
619
+ r.entity("thing", { fields: { label: { type: "text" } } } as unknown as EntityDefinition);
620
+ });
621
+ const app = buildAppSchema(createRegistry([f]));
622
+ expect("defaultCurrency" in app.features[0]!.entities["thing"]!).toBe(false);
623
+ });
624
+
625
+ test("text format, timestamp locatedBy, file accept/maxSize, image variants and decimal scale survive the projection", () => {
626
+ // Regression: all of these are read by the edit view-model (password
627
+ // masking, wall-clock input, upload constraints, preview variant, derived
628
+ // embedded-cell rounding) but were missing from the client schema.
629
+ const entity = {
630
+ fields: {
631
+ secret: { type: "text", format: "password" },
632
+ pickupAt: { type: "timestamp", locatedBy: "pickupTz" },
633
+ pickupTz: { type: "tz" },
634
+ contract: { type: "file", accept: ["application/pdf"], maxSize: "5mb" },
635
+ photo: {
636
+ type: "image",
637
+ accept: ["image/*"],
638
+ maxSize: "10mb",
639
+ variants: { thumb: { fit: "cover", size: { width: 120, height: 120 } } },
640
+ },
641
+ positions: {
642
+ type: "embedded",
643
+ multiple: true,
644
+ schema: { price: { type: "decimal", scale: 2 } },
645
+ },
646
+ },
647
+ } as unknown as EntityDefinition;
648
+
649
+ const f = defineFeature("ent", (r) => {
650
+ r.entity("thing", entity);
651
+ });
652
+ const app = buildAppSchema(createRegistry([f]));
653
+ const fields = (
654
+ app.features[0]!.entities["thing"] as unknown as {
655
+ fields: Record<string, Record<string, unknown>>;
656
+ }
657
+ ).fields;
658
+
659
+ expect(fields["secret"]?.["format"]).toBe("password");
660
+ expect(fields["pickupAt"]?.["locatedBy"]).toBe("pickupTz");
661
+ expect(fields["contract"]?.["accept"]).toEqual(["application/pdf"]);
662
+ expect(fields["contract"]?.["maxSize"]).toBe("5mb");
663
+ expect(fields["photo"]?.["accept"]).toEqual(["image/*"]);
664
+ expect(fields["photo"]?.["variants"]).toEqual({
665
+ thumb: { fit: "cover", size: { width: 120, height: 120 } },
666
+ });
667
+ const positionsSchema = fields["positions"]?.["schema"] as Record<
668
+ string,
669
+ Record<string, unknown>
670
+ >;
671
+ expect(positionsSchema["price"]?.["scale"]).toBe(2);
672
+ });
673
+
585
674
  test("embedded: minItems/maxItems/derived/totals/totalsMatch überleben die Projection (fw#2497)", () => {
586
675
  // Regression: `totals` carries "Renderer metadata: numeric sub-field
587
676
  // names to sum in a totals row" in its own doc-comment but was never
@@ -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
  });