@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 +3 -3
- package/src/changes.json +26 -0
- package/src/engine/__tests__/build-app-schema-url-prefill.test.ts +153 -0
- package/src/engine/__tests__/build-app-schema.test.ts +90 -1
- 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/boot-validator/screens.ts +21 -15
- package/src/engine/boot-validator/url-prefill-fields.ts +159 -0
- package/src/engine/build-app-schema.ts +44 -4
- 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
|
@@ -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)) {
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
315
|
-
screen.type === "projectionList"
|
|
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
|
-
|
|
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 {
|
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
183
|
+
existing.push({ fn: wrapped, featureName: extOwner });
|
|
153
184
|
state.preSaveHooks.set(qualifiedHandler, existing);
|
|
154
185
|
}
|
|
155
186
|
}
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -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.
|