@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.
@@ -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)) {
@@ -101,6 +101,23 @@ function validateNoWidgetRequiredField(
101
101
  );
102
102
  }
103
103
 
104
+ export function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[] {
105
+ return "pick" in params ? params.pick : Object.keys(params.map);
106
+ }
107
+
108
+ // Mirrors the renderer's navigate: an explicit entityId, or a same-entity
109
+ // entityEdit target (row["id"] auto-fill), opens UPDATE mode, which ignores params.
110
+ export function readsNavigateParamsAsFormPrefill(
111
+ target: ScreenDefinition,
112
+ explicitEntityId: string | undefined,
113
+ sourceScreenEntity: string | undefined,
114
+ ): boolean {
115
+ if (target.type === "actionForm" || target.type === "secretMint") return true;
116
+ if (target.type !== "entityEdit") return false;
117
+ if (explicitEntityId !== undefined) return false;
118
+ return sourceScreenEntity === undefined || target.entity !== sourceScreenEntity;
119
+ }
120
+
104
121
  // Tier 2.7e navigate rowAction → target-screen params validity. Shared by
105
122
  // entityList and projectionList (framework#1708) — projectionList has no
106
123
  // `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
@@ -142,8 +159,7 @@ function validateRowActionNavigateParams(
142
159
  `target dashboard so it has somewhere to read the value from.`,
143
160
  );
144
161
  }
145
- const extractedKeys =
146
- "pick" in action.params ? action.params.pick : Object.keys(action.params.map);
162
+ const extractedKeys = rowFieldExtractorKeys(action.params);
147
163
  if (!extractedKeys.includes(filter.id)) {
148
164
  throw new Error(
149
165
  `[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
@@ -156,16 +172,8 @@ function validateRowActionNavigateParams(
156
172
  return;
157
173
  }
158
174
 
159
- const isEntityEditUpdate =
160
- target.screen.type === "entityEdit" &&
161
- (action.entityId !== undefined ||
162
- (screenEntity !== undefined && target.screen.entity === screenEntity));
163
- if (
164
- (target.screen.type !== "actionForm" &&
165
- target.screen.type !== "secretMint" &&
166
- target.screen.type !== "entityEdit") ||
167
- isEntityEditUpdate
168
- ) {
175
+ if (!readsNavigateParamsAsFormPrefill(target.screen, action.entityId, screenEntity)) {
176
+ const isEntityEditUpdate = target.screen.type === "entityEdit";
169
177
  const reason = isEntityEditUpdate
170
178
  ? `resolves to UPDATE mode (${
171
179
  action.entityId !== undefined
@@ -417,9 +425,7 @@ function validateDrawerTargetAction(
417
425
  // the typo would only show up as an empty field at click time.
418
426
  // skip: no params extractor — nothing to check against the target's fields.
419
427
  if (action.params === undefined) return;
420
- const prefilledFields =
421
- "pick" in action.params ? action.params.pick : Object.keys(action.params.map);
422
- for (const fieldName of prefilledFields) {
428
+ for (const fieldName of rowFieldExtractorKeys(action.params)) {
423
429
  if (Object.hasOwn(target.fields, fieldName)) continue;
424
430
  throw new Error(
425
431
  `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
@@ -0,0 +1,159 @@
1
+ import { qualifyEntityName } from "../qualified-name";
2
+ import type { FeatureDefinition } from "../types";
3
+ import type {
4
+ ProjectionDetailScreenDefinition,
5
+ RowFieldExtractor,
6
+ ScreenDefinition,
7
+ } from "../types/screen";
8
+ import {
9
+ collectScreensByShortId,
10
+ readsNavigateParamsAsFormPrefill,
11
+ rowFieldExtractorKeys,
12
+ } from "./screens";
13
+
14
+ type NavigateParamsSource = {
15
+ readonly screen?: string;
16
+ readonly entity?: string;
17
+ readonly entityId?: string;
18
+ readonly params: RowFieldExtractor;
19
+ readonly sourceScreenEntity: string | undefined;
20
+ };
21
+
22
+ type NavigateActionLike = {
23
+ readonly kind?: string;
24
+ readonly screen?: string;
25
+ readonly entity?: string;
26
+ readonly entityId?: string;
27
+ readonly params?: RowFieldExtractor;
28
+ };
29
+ type NavigateActionWithParams = NavigateActionLike & { readonly params: RowFieldExtractor };
30
+ type ScopedNavigateAction = {
31
+ readonly action: NavigateActionLike;
32
+ readonly sourceScreenEntity: string | undefined;
33
+ };
34
+
35
+ function isNavigateKind(action: NavigateActionLike): boolean {
36
+ return action.kind === undefined || action.kind === "navigate";
37
+ }
38
+ function isNavigateWithParams(action: NavigateActionLike): action is NavigateActionWithParams {
39
+ return isNavigateKind(action) && action.params !== undefined;
40
+ }
41
+ function scopeActions(
42
+ actions: readonly NavigateActionLike[] | undefined,
43
+ sourceScreenEntity: string | undefined,
44
+ ): ScopedNavigateAction[] {
45
+ return (actions ?? []).map((action) => ({ action, sourceScreenEntity }));
46
+ }
47
+ function projectionDetailNavigateActions(
48
+ screen: ProjectionDetailScreenDefinition,
49
+ ): ScopedNavigateAction[] {
50
+ return [
51
+ ...scopeActions(screen.actions, screen.detailFor),
52
+ ...screen.layout.sections.flatMap((section) =>
53
+ section.kind === "relatedList" ? scopeActions(section.rowActions, undefined) : [],
54
+ ),
55
+ ...(screen.metrics ?? []).flatMap((metric) =>
56
+ typeof metric === "string" || metric.navigate === undefined
57
+ ? []
58
+ : scopeActions([metric.navigate], undefined),
59
+ ),
60
+ ];
61
+ }
62
+ function navigateActionsOf(screen: ScreenDefinition): ScopedNavigateAction[] {
63
+ switch (screen.type) {
64
+ case "entityList":
65
+ return scopeActions(screen.rowActions, screen.entity);
66
+ case "projectionList":
67
+ return scopeActions(screen.rowActions, undefined);
68
+ case "entityEdit":
69
+ return scopeActions(screen.actions, screen.entity);
70
+ case "projectionDetail":
71
+ return projectionDetailNavigateActions(screen);
72
+ default:
73
+ return [];
74
+ }
75
+ }
76
+ function toNavigateParamsSource(
77
+ action: NavigateActionWithParams,
78
+ sourceScreenEntity: string | undefined,
79
+ ): NavigateParamsSource {
80
+ return {
81
+ ...(action.screen !== undefined && { screen: action.screen }),
82
+ ...(action.entity !== undefined && { entity: action.entity }),
83
+ ...(action.entityId !== undefined && { entityId: action.entityId }),
84
+ params: action.params,
85
+ sourceScreenEntity,
86
+ };
87
+ }
88
+
89
+ // Every declarative navigate that writes `params` into the target's URL —
90
+ // the same sources validateScreens pairs with validateRowActionNavigateParams,
91
+ // plus projectionDetail metrics (runMetricNavigate), which reuse that shape.
92
+ function navigateParamsSources(screen: ScreenDefinition): NavigateParamsSource[] {
93
+ return navigateActionsOf(screen).flatMap(({ action, sourceScreenEntity }) =>
94
+ isNavigateWithParams(action) ? [toNavigateParamsSource(action, sourceScreenEntity)] : [],
95
+ );
96
+ }
97
+
98
+ // Per target screen QN, the union of field names any navigate `params` may
99
+ // prefill from the URL. A form screen absent from the map accepts no URL prefill.
100
+ export function collectUrlPrefillFieldsByScreenQn(
101
+ features: readonly FeatureDefinition[],
102
+ ): Map<string, Set<string>> {
103
+ const screensByShortId = collectScreensByShortId(features);
104
+ const detailForByEntity = new Map<
105
+ string,
106
+ { readonly featureName: string; readonly shortId: string; readonly screen: ScreenDefinition }
107
+ >();
108
+ for (const feature of features) {
109
+ for (const [shortId, screen] of Object.entries(feature.screens)) {
110
+ if (screen.detailFor !== undefined && !detailForByEntity.has(screen.detailFor)) {
111
+ detailForByEntity.set(screen.detailFor, { featureName: feature.name, shortId, screen });
112
+ }
113
+ }
114
+ }
115
+
116
+ const result = new Map<string, Set<string>>();
117
+ for (const feature of features) {
118
+ for (const screen of Object.values(feature.screens)) {
119
+ for (const source of navigateParamsSources(screen)) {
120
+ const target = resolveTarget(source, screensByShortId, detailForByEntity);
121
+ if (target === undefined) continue;
122
+ const explicitEntityId =
123
+ source.entity !== undefined ? (source.entityId ?? "id") : source.entityId;
124
+ if (
125
+ !readsNavigateParamsAsFormPrefill(
126
+ target.screen,
127
+ explicitEntityId,
128
+ source.sourceScreenEntity,
129
+ )
130
+ ) {
131
+ continue;
132
+ }
133
+ const qn = qualifyEntityName(target.featureName, "screen", target.shortId);
134
+ const fields = result.get(qn) ?? new Set<string>();
135
+ for (const key of rowFieldExtractorKeys(source.params)) fields.add(key);
136
+ result.set(qn, fields);
137
+ }
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+
143
+ // Runtime resolution: a bare screen id matches the first feature registering
144
+ // it (create-app's router); an entity target opens its detailFor screen with an id.
145
+ function resolveTarget(
146
+ source: NavigateParamsSource,
147
+ screensByShortId: ReturnType<typeof collectScreensByShortId>,
148
+ detailForByEntity: ReadonlyMap<
149
+ string,
150
+ { readonly featureName: string; readonly shortId: string; readonly screen: ScreenDefinition }
151
+ >,
152
+ ):
153
+ | { readonly featureName: string; readonly shortId: string; readonly screen: ScreenDefinition }
154
+ | undefined {
155
+ if (source.entity !== undefined) return detailForByEntity.get(source.entity);
156
+ if (source.screen === undefined) return undefined;
157
+ const match = screensByShortId.get(source.screen)?.[0];
158
+ return match === undefined ? undefined : { ...match, shortId: source.screen };
159
+ }
@@ -36,11 +36,13 @@ import type {
36
36
  ScreenDefinition,
37
37
  WorkspaceSchema,
38
38
  } from "../ui-types";
39
+ import { collectUrlPrefillFieldsByScreenQn } from "./boot-validator/url-prefill-fields";
39
40
  import {
40
41
  buildConfigFeatureSchema,
41
42
  type ConfigFeatureSchema,
42
43
  SETTINGS_HUB_FEATURE,
43
44
  } from "./build-config-feature-schema";
45
+ import { qualifyEntityName } from "./qualified-name";
44
46
  import type { Registry } from "./types/feature";
45
47
  import type { ClientDerivedFieldDef, DerivedFieldDef, FieldDefinition } from "./types/fields";
46
48
 
@@ -57,6 +59,9 @@ export type BuildAppSchemaOptions = {
57
59
 
58
60
  export function buildAppSchema(registry: Registry, options: BuildAppSchemaOptions = {}): AppSchema {
59
61
  const features: FeatureSchema[] = [];
62
+ const urlPrefillFieldsByScreenQn = collectUrlPrefillFieldsByScreenQn([
63
+ ...registry.features.values(),
64
+ ]);
60
65
  for (const [featureName, feature] of registry.features) {
61
66
  const navs = Object.values(feature.navs);
62
67
  // The nav entry alone doesn't say which kind a collection lists, so the
@@ -71,7 +76,7 @@ export function buildAppSchema(registry: Registry, options: BuildAppSchemaOption
71
76
  const featureSchema: FeatureSchema = {
72
77
  featureName,
73
78
  entities: projectEntities(feature.entities ?? {}),
74
- screens: projectScreens(feature.screens, registry),
79
+ screens: projectScreens(featureName, feature.screens, registry, urlPrefillFieldsByScreenQn),
75
80
  ...(navs.length > 0 && { navs }),
76
81
  ...(contentCollections.length > 0 && { contentCollections }),
77
82
  // #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
@@ -308,12 +313,25 @@ export function findNonJsonSafePath(value: unknown, path: string): string | null
308
313
  // default (screen.searchable ?? derived); the boot-validator (3a) rejects
309
314
  // one that contradicts the schema.
310
315
  function projectScreens(
316
+ featureName: string,
311
317
  screens: Readonly<Record<string, ScreenDefinition>>,
312
318
  registry: Registry,
319
+ urlPrefillFieldsByScreenQn: ReadonlyMap<string, ReadonlySet<string>>,
313
320
  ): ScreenDefinition[] {
314
- return Object.values(screens).map((screen) =>
315
- screen.type === "projectionList" ? projectProjectionListScreen(screen, registry) : screen,
316
- );
321
+ return Object.entries(screens).map(([shortId, screen]) => {
322
+ if (screen.type === "projectionList") return projectProjectionListScreen(screen, registry);
323
+ if (
324
+ screen.type === "actionForm" ||
325
+ screen.type === "secretMint" ||
326
+ screen.type === "entityEdit"
327
+ ) {
328
+ const declared = urlPrefillFieldsByScreenQn.get(
329
+ qualifyEntityName(featureName, "screen", shortId),
330
+ );
331
+ return { ...screen, urlPrefillFields: declared === undefined ? [] : [...declared].sort() };
332
+ }
333
+ return screen;
334
+ });
317
335
  }
318
336
 
319
337
  function projectProjectionListScreen(
@@ -390,6 +408,9 @@ function projectEntity(entity: EntityDefinition): EntityDefinition {
390
408
  derivedFields: derivedOut as unknown as Record<string, DerivedFieldDef>,
391
409
  }),
392
410
  ...(typeof entity.table === "string" && { table: entity.table }),
411
+ ...(typeof entity.defaultCurrency === "string" && {
412
+ defaultCurrency: entity.defaultCurrency,
413
+ }),
393
414
  };
394
415
  }
395
416
 
@@ -457,6 +478,25 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
457
478
  if (typeof def["locale"] === "string") out["locale"] = def["locale"];
458
479
  // image: which camera a mobile capture opens (fw#2497).
459
480
  if (typeof def["capture"] === "string") out["capture"] = def["capture"];
481
+ // number: display-only suffix, static or read from a sibling field of the row.
482
+ const unit = def["unit"];
483
+ if (typeof unit === "string" || (isPlainObject(unit) && typeof unit["field"] === "string"))
484
+ out["unit"] = unit;
485
+ // text: "password" masks the input and blocks URL prefill in the renderer.
486
+ if (typeof def["format"] === "string") out["format"] = def["format"];
487
+ // Write-response redaction stays server-side; the renderer needs the flag to
488
+ // refuse any prefill (URL or handoff) into the field.
489
+ if (def["sensitive"] === true) out["sensitive"] = true;
490
+ // timestamp: the edit view-model derives its wall-clock input mode from this.
491
+ if (typeof def["locatedBy"] === "string") out["locatedBy"] = def["locatedBy"];
492
+ // file/image/images: upload picker constraints; the first variant key picks
493
+ // the preview variant.
494
+ if (Array.isArray(def["accept"]) && isJsonSafeValue(def["accept"])) out["accept"] = def["accept"];
495
+ if (typeof def["maxSize"] === "string") out["maxSize"] = def["maxSize"];
496
+ if (isPlainObject(def["variants"]) && isJsonSafeValue(def["variants"]))
497
+ out["variants"] = def["variants"];
498
+ // decimal, incl. embedded sub-fields: rounding of derived embedded-list cells.
499
+ if (typeof def["scale"] === "number") out["scale"] = def["scale"];
460
500
  // embedded lists: row-count bounds, computed cells, totals row, and the
461
501
  // sibling-money-field totals check (fw#2497).
462
502
  if (typeof def["minItems"] === "number") out["minItems"] = def["minItems"];
@@ -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 {
@@ -1,4 +1,5 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii";
2
+ import { bindHookIdentitySwitchGrant } from "../pipeline/system-identity-switch";
2
3
  import { resolveName } from "./handler-helpers";
3
4
  import type {
4
5
  RegistryState,
@@ -14,7 +15,16 @@ import {
14
15
  SOFT_DELETE_GRACE_DAYS_KEY,
15
16
  softDeleteGraceDaysConfig,
16
17
  } from "./soft-delete-cleanup";
17
- import type { EventPiiFields, EventUpcastFn, FeatureDefinition, ReferenceFieldDef } from "./types";
18
+ import type {
19
+ EventPiiFields,
20
+ EventUpcastFn,
21
+ FeatureDefinition,
22
+ PostDeleteHookFn,
23
+ PostSaveHookFn,
24
+ PreDeleteHookFn,
25
+ PreSaveHookFn,
26
+ ReferenceFieldDef,
27
+ } from "./types";
18
28
  import { HookPhases } from "./types";
19
29
 
20
30
  function allHandlerQns(state: RegistryState): ReadonlySet<string> {
@@ -115,29 +125,46 @@ export function applyExtensionUsages(state: RegistryState): void {
115
125
  // to also be gated, store the registering-feature on
116
126
  // RegistrarExtensionRegistration and use that here.
117
127
  const extOwner = "*";
128
+ // Extensions have no declaration site for escapeHatch, so SYSTEM is always denied
129
+ // (system-identity-switch.ts) — bindHookIdentitySwitchGrant(..., undefined) below.
118
130
  if (ext.hooks) {
119
131
  if (ext.hooks.postSave) {
132
+ const wrapped = bindHookIdentitySwitchGrant(
133
+ ext.hooks.postSave,
134
+ `postSave hook of extension "${usage.extensionName}"`,
135
+ undefined,
136
+ ) as PostSaveHookFn; // @cast-boundary engine-bridge
120
137
  const existing = state.entityPostSaveHooks.get(usage.entityName) ?? [];
121
138
  existing.push({
122
- fn: ext.hooks.postSave,
139
+ fn: wrapped,
123
140
  phase: HookPhases.afterCommit,
124
141
  featureName: extOwner,
125
142
  });
126
143
  state.entityPostSaveHooks.set(usage.entityName, existing);
127
144
  }
128
145
  if (ext.hooks.preDelete) {
146
+ const wrapped = bindHookIdentitySwitchGrant(
147
+ ext.hooks.preDelete,
148
+ `preDelete hook of extension "${usage.extensionName}"`,
149
+ undefined,
150
+ ) as PreDeleteHookFn; // @cast-boundary engine-bridge
129
151
  const existing = state.entityPreDeleteHooks.get(usage.entityName) ?? [];
130
152
  existing.push({
131
- fn: ext.hooks.preDelete,
153
+ fn: wrapped,
132
154
  phase: HookPhases.afterCommit,
133
155
  featureName: extOwner,
134
156
  });
135
157
  state.entityPreDeleteHooks.set(usage.entityName, existing);
136
158
  }
137
159
  if (ext.hooks.postDelete) {
160
+ const wrapped = bindHookIdentitySwitchGrant(
161
+ ext.hooks.postDelete,
162
+ `postDelete hook of extension "${usage.extensionName}"`,
163
+ undefined,
164
+ ) as PostDeleteHookFn; // @cast-boundary engine-bridge
138
165
  const existing = state.entityPostDeleteHooks.get(usage.entityName) ?? [];
139
166
  existing.push({
140
- fn: ext.hooks.postDelete,
167
+ fn: wrapped,
141
168
  phase: HookPhases.afterCommit,
142
169
  featureName: extOwner,
143
170
  });
@@ -145,11 +172,15 @@ export function applyExtensionUsages(state: RegistryState): void {
145
172
  }
146
173
  // preSave on extensions: store as handler hook for all CRUD handlers of this entity
147
174
  if (ext.hooks.preSave) {
148
- // Find all write handlers that belong to this entity via state.handlerEntityMap
175
+ const wrapped = bindHookIdentitySwitchGrant(
176
+ ext.hooks.preSave,
177
+ `preSave hook of extension "${usage.extensionName}"`,
178
+ undefined,
179
+ ) as PreSaveHookFn; // @cast-boundary engine-bridge
149
180
  for (const qualifiedHandler of state.writeHandlerMap.keys()) {
150
181
  if (state.handlerEntityMap.get(qualifiedHandler) === usage.entityName) {
151
182
  const existing = state.preSaveHooks.get(qualifiedHandler) ?? [];
152
- existing.push({ fn: ext.hooks.preSave, featureName: extOwner });
183
+ existing.push({ fn: wrapped, featureName: extOwner });
153
184
  state.preSaveHooks.set(qualifiedHandler, existing);
154
185
  }
155
186
  }
@@ -84,6 +84,19 @@ feature_disabled:
84
84
  Aktiviere das Feature via Feature-Toggle oder Routing-Config (siehe
85
85
  [Feature-Toggles](/en/feature-reference/feature-toggles/)).
86
86
 
87
+ system_identity_switch_denied:
88
+ endUser: |
89
+ Diese Aktion ist nicht erlaubt.
90
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
91
+ developer: |
92
+ `AccessDeniedError`: ein Handler oder Hook hat `ctx.queryAs`/`ctx.writeAs`
93
+ mit einer SYSTEM-Identität aufgerufen, ohne `r.systemScope()`-Handler, Job
94
+ zu sein oder `escapeHatch: { reason }` zu deklarieren.
95
+
96
+ Deklariere `escapeHatch: { reason: "..." }` am Write- oder Query-Handler
97
+ (oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
98
+ oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
99
+
87
100
  agent.tool_not_allowed:
88
101
  endUser: |
89
102
  Dieses Tool ist im aktuellen Modus nicht verfügbar.