@cosmicdrift/kumiko-framework 0.332.0 → 0.334.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/dist/db/eagerload.js +2 -1
- package/dist/engine/boot-validator/config-deps.js +13 -0
- package/dist/engine/boot-validator/pii-retention.js +39 -2
- package/dist/engine/boot-validator/screens.js +38 -6
- package/dist/engine/build-app-schema.js +3 -0
- package/dist/engine/build-config-feature-schema.js +98 -21
- package/dist/engine/config-helpers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +7 -2
- package/dist/engine/extension-names.d.ts +12 -1
- package/dist/engine/extension-names.js +7 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round5.d.ts +1 -0
- package/dist/engine/feature-ast/extractors/round5.js +5 -0
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-entity-handlers.js +22 -0
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +40 -0
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -3
- package/dist/engine/role-assignment.d.ts +11 -1
- package/dist/engine/role-assignment.js +64 -3
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +11 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/i18n/required-surface-keys.js +24 -3
- package/dist/i18n/settings-hub-keys.js +18 -4
- package/dist/pipeline/dispatch-batch.js +8 -3
- package/dist/pipeline/dispatch-write.js +5 -2
- package/dist/pipeline/dispatcher-utils.js +4 -1
- package/dist/pipeline/write-result-masking.d.ts +6 -0
- package/dist/pipeline/write-result-masking.js +36 -0
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/ui-types/index.d.ts +2 -2
- package/package.json +4 -4
- package/src/changes.json +12 -0
package/dist/db/eagerload.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { requestContext } from "../api/request-context.js";
|
|
26
26
|
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
|
|
27
27
|
import { selectMany } from "../db/query.js";
|
|
28
|
+
import { maskWriteOnlyFields } from "../engine/field-access.js";
|
|
28
29
|
import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
|
|
29
30
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
30
31
|
import { buildEntityTable } from "./table-builder.js";
|
|
@@ -114,7 +115,7 @@ async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, piiFields
|
|
|
114
115
|
}
|
|
115
116
|
const id = decrypted["id"];
|
|
116
117
|
if (typeof id === "string")
|
|
117
|
-
map.set(id, decrypted);
|
|
118
|
+
map.set(id, maskWriteOnlyFields(refEntity, decrypted));
|
|
118
119
|
}
|
|
119
120
|
return map;
|
|
120
121
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
|
|
1
2
|
import { isEncryptedAtRest } from "../config-helpers.js";
|
|
2
3
|
// --- Toggleable-dependency warnings ---
|
|
3
4
|
//
|
|
@@ -123,6 +124,18 @@ export function validateConfigKeyOptionsQuery(feature) {
|
|
|
123
124
|
if (keyDef.allowPerRequest) {
|
|
124
125
|
throw new Error(`${prefix} has optionsQuery AND allowPerRequest=true — a per-request value would bypass the option set unchecked`);
|
|
125
126
|
}
|
|
127
|
+
validateOptionsQueryKeyRefs(feature, keyName, keyDef);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function validateOptionsQueryKeyRefs(feature, keyName, keyDef) {
|
|
131
|
+
const prefix = `[Feature ${feature.name}] Config key "${keyName}"`;
|
|
132
|
+
for (const ref of optionsQueryFieldRefs(keyDef.optionsQueryPayload)) {
|
|
133
|
+
if (ref === keyName) {
|
|
134
|
+
throw new Error(`${prefix} optionsQueryPayload references itself ({ field: "${ref}" })`);
|
|
135
|
+
}
|
|
136
|
+
if (feature.configKeys[ref] === undefined) {
|
|
137
|
+
throw new Error(`${prefix} optionsQueryPayload references unknown config key "${ref}" of feature "${feature.name}"`);
|
|
138
|
+
}
|
|
126
139
|
}
|
|
127
140
|
}
|
|
128
141
|
// --- Config key storage backing × scope matrix ---
|
|
@@ -26,6 +26,42 @@ function ownerFieldNamesReferencedByPersonalOf(fieldsByName) {
|
|
|
26
26
|
}
|
|
27
27
|
return ownerFieldNames;
|
|
28
28
|
}
|
|
29
|
+
const WRITE_ONLY_FORBIDDEN_FLAGS = [
|
|
30
|
+
"default",
|
|
31
|
+
"searchable",
|
|
32
|
+
"sortable",
|
|
33
|
+
"filterable",
|
|
34
|
+
"lookupable",
|
|
35
|
+
];
|
|
36
|
+
function validateWriteOnlyField(featureName, entityName, fieldName, field) {
|
|
37
|
+
const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
|
|
38
|
+
if (field.type === "embedded") {
|
|
39
|
+
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
40
|
+
if (subField.writeOnly === true) {
|
|
41
|
+
// @cast-boundary schema-walk
|
|
42
|
+
throw new Error(`${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// skip: embedded fields carry no writeOnly of their own; sub-fields are checked above
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const flags = field; // @cast-boundary schema-walk
|
|
49
|
+
// skip: only writeOnly fields have constraints to validate
|
|
50
|
+
if (flags.writeOnly !== true)
|
|
51
|
+
return;
|
|
52
|
+
if (field.type !== "text") {
|
|
53
|
+
throw new Error(`${where} declares { writeOnly: true } but has type "${field.type}" — writeOnly only applies to text fields.`);
|
|
54
|
+
}
|
|
55
|
+
if (field.sensitive !== true) {
|
|
56
|
+
throw new Error(`${where} declares { writeOnly: true } without { sensitive: true } — add find: "secret" (ciphertext at rest, stripped from event echoes).`);
|
|
57
|
+
}
|
|
58
|
+
for (const flag of WRITE_ONLY_FORBIDDEN_FLAGS) {
|
|
59
|
+
const value = flags[flag];
|
|
60
|
+
if (value !== undefined && value !== false) {
|
|
61
|
+
throw new Error(`${where} combines { writeOnly: true } with { ${flag} } — a value that is never returned cannot be defaulted, searched, sorted, filtered or looked up.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
29
65
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
30
66
|
//
|
|
31
67
|
// Drei Klassen von Checks:
|
|
@@ -92,19 +128,20 @@ export function validatePiiAndRetention(feature) {
|
|
|
92
128
|
sensitiveFlags.encrypted !== true) {
|
|
93
129
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`);
|
|
94
130
|
}
|
|
131
|
+
validateWriteOnlyField(feature.name, entityName, fieldName, field);
|
|
95
132
|
// Sorting reads the projection column — that stays ciphertext, so
|
|
96
133
|
// sortable + subject annotation stays a boot-fail. searchable has
|
|
97
134
|
// been allowed since #1610: the search consumer decrypts into the
|
|
98
135
|
// derived index and forget purges those docs (see
|
|
99
136
|
// createSearchEventConsumer). sensitive + searchable stays forbidden
|
|
100
|
-
// (
|
|
137
|
+
// (never searchable).
|
|
101
138
|
{
|
|
102
139
|
const flags = field; // @cast-boundary schema-walk
|
|
103
140
|
if (annotCount > 0 && flags.sortable === true) {
|
|
104
141
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`);
|
|
105
142
|
}
|
|
106
143
|
if (flags.sensitive === true && flags.searchable === true) {
|
|
107
|
-
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means
|
|
144
|
+
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`);
|
|
108
145
|
}
|
|
109
146
|
}
|
|
110
147
|
if (annot.userOwned) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// into validateColumnRendererForm, splitting them would create a
|
|
5
5
|
// same-folder require cycle.
|
|
6
6
|
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
7
|
-
import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
7
|
+
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
9
9
|
import { rowMetaFieldNames } from "../../db/table-builder.js";
|
|
10
10
|
import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
|
|
@@ -471,13 +471,35 @@ function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpt
|
|
|
471
471
|
if (ftype === "money") {
|
|
472
472
|
validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
|
|
473
473
|
}
|
|
474
|
-
validateFormSelectOptions(featureName, screenId, context, fname, fdef);
|
|
474
|
+
validateFormSelectOptions(featureName, screenId, context, fname, fdef, fieldNames);
|
|
475
|
+
rejectWriteOnlyFormField(featureName, screenId, context, fname, fdef);
|
|
475
476
|
}
|
|
476
477
|
return fieldNames;
|
|
477
478
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
479
|
+
function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNames) {
|
|
480
|
+
for (const ref of optionsQueryFieldRefs(payload)) {
|
|
481
|
+
if (ref === fieldName) {
|
|
482
|
+
throw new Error(`${where} optionsQueryPayload references itself ({ field: "${ref}" })`);
|
|
483
|
+
}
|
|
484
|
+
if (!siblingFieldNames.has(ref)) {
|
|
485
|
+
throw new Error(`${where} optionsQueryPayload references unknown field "${ref}" — it must be another field of the same form`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Inline forms have no stored value to hide or keep: writeOnly there would
|
|
490
|
+
// render a "set / keep" affordance with nothing behind it.
|
|
491
|
+
function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
|
|
492
|
+
if (fdef.writeOnly === true) {
|
|
493
|
+
// @cast-boundary schema-walk
|
|
494
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fieldName}" declares writeOnly — ` +
|
|
495
|
+
`writeOnly only applies to entity fields (nothing to hide on a form that stores no value). ` +
|
|
496
|
+
`Use format: "password" for a masked input.`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Static options XOR optionsQuery on inline-form select fields, and `{ field }`
|
|
500
|
+
// payload refs must name another field of the same form. The QN existence check
|
|
501
|
+
// lives in query-refs.ts with the other query refs.
|
|
502
|
+
function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
|
|
481
503
|
if (fdef.type === "select") {
|
|
482
504
|
const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
|
|
483
505
|
if (fdef.optionsQuery !== undefined) {
|
|
@@ -487,6 +509,7 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
|
|
|
487
509
|
if (fdef.options.length > 0) {
|
|
488
510
|
throw new Error(`${where} declares both options and optionsQuery — pick one`);
|
|
489
511
|
}
|
|
512
|
+
validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
|
|
490
513
|
}
|
|
491
514
|
else if (fdef.optionsQueryPayload !== undefined) {
|
|
492
515
|
throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
|
|
@@ -1048,6 +1071,14 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1048
1071
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
1049
1072
|
`(writeForm) has zero fields — drop the section or add fields to it.`);
|
|
1050
1073
|
}
|
|
1074
|
+
// A ref to a fieldDef the form never renders would never resolve.
|
|
1075
|
+
const writeFormFieldNames = new Set(
|
|
1076
|
+
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
1077
|
+
section.fields.map((f) => normalizeEditField(f).field));
|
|
1078
|
+
for (const [defName, fdef] of Object.entries(section.fieldDefs)) {
|
|
1079
|
+
validateFormSelectOptions(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef, writeFormFieldNames);
|
|
1080
|
+
rejectWriteOnlyFormField(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef);
|
|
1081
|
+
}
|
|
1051
1082
|
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
1052
1083
|
for (const f of section.fields) {
|
|
1053
1084
|
const fieldName = normalizeEditField(f).field;
|
|
@@ -1128,7 +1159,8 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1128
1159
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
|
|
1129
1160
|
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
|
|
1130
1161
|
}
|
|
1131
|
-
validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef);
|
|
1162
|
+
validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef, fieldNames);
|
|
1163
|
+
rejectWriteOnlyFormField(feature.name, screenId, "configEdit", fname, fdef);
|
|
1132
1164
|
}
|
|
1133
1165
|
if (screen.layout.sections.length === 0) {
|
|
1134
1166
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
|
|
@@ -439,6 +439,9 @@ function projectField(fieldDef) {
|
|
|
439
439
|
// refuse any prefill (URL or handoff) into the field.
|
|
440
440
|
if (def["sensitive"] === true)
|
|
441
441
|
out["sensitive"] = true;
|
|
442
|
+
// Without it the renderer shows a plain text input for a field whose value never leaves the server.
|
|
443
|
+
if (def["writeOnly"] === true)
|
|
444
|
+
out["writeOnly"] = true;
|
|
442
445
|
// timestamp: the edit view-model derives its wall-clock input mode from this.
|
|
443
446
|
if (typeof def["locatedBy"] === "string")
|
|
444
447
|
out["locatedBy"] = def["locatedBy"];
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// des config-Features (featureName "config") gemerged — der Renderer
|
|
15
15
|
// qualifiziert die kurzen ids/refs mit "config". Daher hier durchweg KURZE
|
|
16
16
|
// ids/parent/screen-Refs (buildNavRegistrySliceForApp qualifiziert selbst).
|
|
17
|
+
import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
|
|
18
|
+
import { CONFIG_EDIT_ENTITY, fieldLabelKey } from "../i18n/required-surface-keys.js";
|
|
17
19
|
import { EXTENSION_SELECTOR_HINT_KEY, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
|
|
18
20
|
import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
|
|
19
21
|
import { isKebabSegment, toKebab } from "./qualified-name.js";
|
|
@@ -35,6 +37,14 @@ const SCOPE_ICON = {
|
|
|
35
37
|
user: "user",
|
|
36
38
|
};
|
|
37
39
|
const SCOPES_BROAD_TO_DEEP = ["system", "tenant", "user"];
|
|
40
|
+
const audienceDescriptionKey = (scope) => `config.settings.audience.${scope}`;
|
|
41
|
+
const PROVIDER_PANEL_TITLE_ENTRY = {
|
|
42
|
+
en: "Provider",
|
|
43
|
+
de: "Anbieter",
|
|
44
|
+
es: "Proveedor",
|
|
45
|
+
};
|
|
46
|
+
const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
|
|
47
|
+
const SAVE_PROVIDER_LABEL_KEY = "config.settings.saveProvider";
|
|
38
48
|
const audienceNavShortId = (scope) => `audience-${scope}`;
|
|
39
49
|
// Generated post-boot, never via r.nav() — the boot validator exempts exactly these QNs (an app references one to place the settings group inline).
|
|
40
50
|
export const SETTINGS_HUB_AUDIENCE_NAV_QNS = SCOPES_BROAD_TO_DEEP.map((scope) => `${SETTINGS_HUB_FEATURE}:nav:${audienceNavShortId(scope)}`);
|
|
@@ -212,7 +222,11 @@ function assertUniquePanelIds(ownerGroup, panels) {
|
|
|
212
222
|
function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
|
|
213
223
|
const selectionScreenId = `${ownerGroup}-tenant-selection`;
|
|
214
224
|
const selectionScreen = {
|
|
215
|
-
...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub
|
|
225
|
+
...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
|
|
226
|
+
description: EXTENSION_SELECTOR_HINT_KEY,
|
|
227
|
+
sectionTitle: PROVIDER_SECTION_TITLE_KEY,
|
|
228
|
+
submitLabel: SAVE_PROVIDER_LABEL_KEY,
|
|
229
|
+
}),
|
|
216
230
|
dormant: true,
|
|
217
231
|
};
|
|
218
232
|
const screens = [selectionScreen];
|
|
@@ -220,10 +234,8 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
220
234
|
const secretsPanels = [];
|
|
221
235
|
const accessRules = [ownerAccess];
|
|
222
236
|
const translations = {};
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (titleValues !== undefined)
|
|
226
|
-
translations[`screen:${selectionScreenId}.title`] = titleValues;
|
|
237
|
+
translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
|
|
238
|
+
Object.assign(translations, providerOptionTranslations(selectionScreen, plugins, hub));
|
|
227
239
|
for (const plugin of plugins) {
|
|
228
240
|
const visibleWhen = {
|
|
229
241
|
query: SELECTED_EXTENSIONS_QUERY,
|
|
@@ -234,7 +246,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
234
246
|
const pluginAccess = rolesToAccess(plugin.scopedKeys.flatMap((v) => v.roles));
|
|
235
247
|
const ordered = sortByMaskOrder(plugin.scopedKeys.map((v) => v.key));
|
|
236
248
|
screens.push({
|
|
237
|
-
...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub),
|
|
249
|
+
...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub, { description: null }),
|
|
238
250
|
dormant: true,
|
|
239
251
|
});
|
|
240
252
|
accessRules.push(pluginAccess);
|
|
@@ -243,6 +255,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
243
255
|
id: `${plugin.feature}-config`,
|
|
244
256
|
screen: pluginTenantScreenId(plugin),
|
|
245
257
|
visibleWhen,
|
|
258
|
+
chromeless: true,
|
|
246
259
|
});
|
|
247
260
|
}
|
|
248
261
|
if (plugin.secrets.length > 0) {
|
|
@@ -256,6 +269,7 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
256
269
|
id: `${plugin.feature}-secrets`,
|
|
257
270
|
screen: secretsScreenId,
|
|
258
271
|
visibleWhen,
|
|
272
|
+
chromeless: true,
|
|
259
273
|
});
|
|
260
274
|
}
|
|
261
275
|
}
|
|
@@ -263,9 +277,9 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
263
277
|
const dashboard = {
|
|
264
278
|
id: `${ownerGroup}-tenant`,
|
|
265
279
|
type: "dashboard",
|
|
266
|
-
|
|
280
|
+
showUpdatedAt: false,
|
|
267
281
|
panels: assertUniquePanelIds(ownerGroup, [
|
|
268
|
-
{ kind: "screen", id: "selection", screen: selectionScreenId },
|
|
282
|
+
{ kind: "screen", id: "selection", screen: selectionScreenId, chromeless: true },
|
|
269
283
|
...ownerPanels,
|
|
270
284
|
...configPanels,
|
|
271
285
|
...secretsPanels,
|
|
@@ -479,10 +493,12 @@ function buildSettingsWorkspace(navs) {
|
|
|
479
493
|
navMembers,
|
|
480
494
|
};
|
|
481
495
|
}
|
|
482
|
-
function buildScreen(shortId, scope, feature, keys, access, hub) {
|
|
496
|
+
function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
|
|
483
497
|
const configKeys = {};
|
|
484
498
|
const fields = {};
|
|
485
499
|
const fieldLabels = {};
|
|
500
|
+
const fieldDescriptions = {};
|
|
501
|
+
const requiredFields = [];
|
|
486
502
|
// Field id collapses to the plain shortKey when the key stays in its own
|
|
487
503
|
// feature's group (100% of today's apps — byte-identical output). Only a
|
|
488
504
|
// cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
|
|
@@ -497,18 +513,16 @@ function buildScreen(shortId, scope, feature, keys, access, hub) {
|
|
|
497
513
|
}
|
|
498
514
|
seenFieldIds.set(id, k.qn);
|
|
499
515
|
configKeys[id] = k.qn;
|
|
500
|
-
fields[id] = deriveField(k
|
|
516
|
+
fields[id] = deriveField(k, hub.selectorOptions.get(k.qn), keys, fieldId);
|
|
501
517
|
// mask is the visibility gate, so collectMaskedKeys guarantees it here.
|
|
502
518
|
if (k.def.mask)
|
|
503
519
|
fieldLabels[id] = k.def.mask.title;
|
|
520
|
+
if (k.def.mask?.description !== undefined)
|
|
521
|
+
fieldDescriptions[id] = k.def.mask.description;
|
|
522
|
+
if (k.def.required === true)
|
|
523
|
+
requiredFields.push(id);
|
|
504
524
|
}
|
|
505
|
-
|
|
506
|
-
const descriptionKey = `${feature}.settings.description`;
|
|
507
|
-
const section = {
|
|
508
|
-
title: `${feature}.settings`,
|
|
509
|
-
...(hub.declaredTranslationKeys.has(descriptionKey) && { description: descriptionKey }),
|
|
510
|
-
fields: keys.map(fieldId),
|
|
511
|
-
};
|
|
525
|
+
const { section, screenDescription } = buildSettingsSection(feature, scope, keys.map(fieldId), hub, options);
|
|
512
526
|
return {
|
|
513
527
|
id: shortId,
|
|
514
528
|
type: "configEdit",
|
|
@@ -516,16 +530,79 @@ function buildScreen(shortId, scope, feature, keys, access, hub) {
|
|
|
516
530
|
configKeys,
|
|
517
531
|
fields,
|
|
518
532
|
fieldLabels,
|
|
519
|
-
|
|
533
|
+
...(Object.keys(fieldDescriptions).length > 0 && { fieldDescriptions }),
|
|
534
|
+
...(requiredFields.length > 0 && { requiredFields }),
|
|
535
|
+
...(screenDescription !== undefined && { description: screenDescription }),
|
|
536
|
+
...(options.submitLabel !== undefined && { submitLabel: options.submitLabel }),
|
|
537
|
+
layout: { sections: [section], width: "full", variant: "settings-list" },
|
|
520
538
|
access,
|
|
521
539
|
};
|
|
522
540
|
}
|
|
523
|
-
|
|
541
|
+
// `{ field: <shortKey> }` names another key of the owner feature; on the form it
|
|
542
|
+
// has to point at that key's generated field id, so it must sit on the same mask.
|
|
543
|
+
function rewriteOptionsQueryFieldRefs(key, payload, screenKeys, fieldIdOf) {
|
|
544
|
+
return Object.fromEntries(Object.entries(payload).map(([name, value]) => {
|
|
545
|
+
if (!isOptionsQueryFieldRef(value))
|
|
546
|
+
return [name, value];
|
|
547
|
+
const target = screenKeys.find((k) => k.ownerFeature === key.ownerFeature && k.shortKey === toKebab(value.field));
|
|
548
|
+
if (target === undefined) {
|
|
549
|
+
throw new Error(`[Settings-Hub] config key "${key.qn}" optionsQueryPayload "${name}" references key "${value.field}", which is not on the same generated settings screen (group "${key.feature}") — give both keys the same group, scope and a mask.`);
|
|
550
|
+
}
|
|
551
|
+
return [name, { field: fieldIdOf(target) }];
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
function buildSettingsSection(feature, scope, fieldIds, hub, options) {
|
|
555
|
+
// translate() echoes an undeclared key, so an ungated description would render raw.
|
|
556
|
+
const descriptionKey = `${feature}.settings.description`;
|
|
557
|
+
const featureDescription = hub.declaredTranslationKeys.has(descriptionKey)
|
|
558
|
+
? descriptionKey
|
|
559
|
+
: undefined;
|
|
560
|
+
const audienceDescription = options.description === null
|
|
561
|
+
? undefined
|
|
562
|
+
: (options.description ?? audienceDescriptionKey(scope));
|
|
563
|
+
// The section's header column holds one sentence: the feature's own, else the
|
|
564
|
+
// audience one. When both exist the audience sentence stays on the screen.
|
|
565
|
+
const sectionDescription = featureDescription ?? audienceDescription;
|
|
566
|
+
const screenDescription = featureDescription !== undefined ? audienceDescription : undefined;
|
|
567
|
+
// A feature whose page title equals its `<feature>.settings` label would have
|
|
568
|
+
// the section heading dropped as a duplicate; `.settings.section` lets it
|
|
569
|
+
// declare a distinct heading.
|
|
570
|
+
const sectionKey = `${feature}.settings.section`;
|
|
571
|
+
const section = {
|
|
572
|
+
title: options.sectionTitle ??
|
|
573
|
+
(hub.declaredTranslationKeys.has(sectionKey) ? sectionKey : `${feature}.settings`),
|
|
574
|
+
...(sectionDescription !== undefined && { description: sectionDescription }),
|
|
575
|
+
fields: fieldIds,
|
|
576
|
+
};
|
|
577
|
+
return { section, screenDescription };
|
|
578
|
+
}
|
|
579
|
+
// Option labels of the provider select: each plugin feature's `<feature>.settings`
|
|
580
|
+
// copy, under the option-label key the configEdit renderer already derives.
|
|
581
|
+
function providerOptionTranslations(selectionScreen, plugins, hub) {
|
|
582
|
+
const out = {};
|
|
583
|
+
for (const [fieldId, qn] of Object.entries(selectionScreen.configKeys)) {
|
|
584
|
+
if (!hub.selectorOptions.has(qn))
|
|
585
|
+
continue;
|
|
586
|
+
for (const plugin of plugins) {
|
|
587
|
+
const label = hub.translationsByKey.get(`${plugin.feature}.settings`);
|
|
588
|
+
if (label === undefined)
|
|
589
|
+
continue;
|
|
590
|
+
const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${plugin.pluginId}`;
|
|
591
|
+
out[key] = label;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return out;
|
|
595
|
+
}
|
|
596
|
+
function deriveField(key, selectorPluginIds, screenKeys, fieldIdOf) {
|
|
597
|
+
const def = key.def;
|
|
524
598
|
if (selectorPluginIds !== undefined)
|
|
525
599
|
return createSelectField({ options: selectorPluginIds });
|
|
526
600
|
switch (def.type) {
|
|
527
601
|
case "number":
|
|
528
|
-
return createNumberField(
|
|
602
|
+
return createNumberField({
|
|
603
|
+
...(def.bounds?.min !== undefined && { min: def.bounds.min }),
|
|
604
|
+
...(def.bounds?.max !== undefined && { max: def.bounds.max }),
|
|
605
|
+
});
|
|
529
606
|
case "boolean":
|
|
530
607
|
return createBooleanField();
|
|
531
608
|
case "select":
|
|
@@ -534,7 +611,7 @@ function deriveField(def, selectorPluginIds) {
|
|
|
534
611
|
options: [],
|
|
535
612
|
optionsQuery: def.optionsQuery,
|
|
536
613
|
...(def.optionsQueryPayload !== undefined && {
|
|
537
|
-
optionsQueryPayload: def.optionsQueryPayload,
|
|
614
|
+
optionsQueryPayload: rewriteOptionsQueryFieldRefs(key, def.optionsQueryPayload, screenKeys, fieldIdOf),
|
|
538
615
|
}),
|
|
539
616
|
});
|
|
540
617
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions } from "./types/index.js";
|
|
1
|
+
import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, OptionsQueryPayload } from "./types/index.js";
|
|
2
2
|
export declare function isEncryptedAtRest(def: Pick<ConfigKeyDefinition, "encrypted" | "backing">): boolean;
|
|
3
3
|
export declare const access: {
|
|
4
4
|
readonly all: readonly string[];
|
|
@@ -19,7 +19,7 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
|
|
|
19
19
|
piiEncrypted?: T extends "text" ? boolean : never;
|
|
20
20
|
options?: readonly string[];
|
|
21
21
|
optionsQuery?: T extends "select" ? string : never;
|
|
22
|
-
optionsQueryPayload?: T extends "select" ?
|
|
22
|
+
optionsQueryPayload?: T extends "select" ? OptionsQueryPayload : never;
|
|
23
23
|
bounds?: T extends "number" ? ConfigBounds : never;
|
|
24
24
|
pattern?: T extends "text" ? {
|
|
25
25
|
regex: string;
|
|
@@ -7,6 +7,7 @@ import { acknowledgeConventionCrossTenant } from "../db/tenant-db.js";
|
|
|
7
7
|
import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
|
|
8
8
|
import { assertUnreachable } from "../utils/index.js";
|
|
9
9
|
import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
|
|
10
|
+
import { dropEmptyWriteOnlyValues } from "./field-access.js";
|
|
10
11
|
import { instructionFieldNames } from "./instruction-fields.js";
|
|
11
12
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
|
|
12
13
|
// Convention-based handler factories for event-sourced aggregates.
|
|
@@ -233,7 +234,7 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
233
234
|
// present that identity, so their payload.id is dropped exactly as
|
|
234
235
|
// before this field became part of the schema.
|
|
235
236
|
const { id: _callerId, ...payloadWithoutId } = event.payload;
|
|
236
|
-
const payload = isSystemIdentity(event.user) ? event.payload : payloadWithoutId;
|
|
237
|
+
const payload = dropEmptyWriteOnlyValues(entity, isSystemIdentity(event.user) ? event.payload : payloadWithoutId);
|
|
237
238
|
return executor.create(payload, event.user, dbFor(ctx), {
|
|
238
239
|
preSave: runPreSave &&
|
|
239
240
|
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
@@ -249,7 +250,11 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
249
250
|
handler = async (event, ctx) => {
|
|
250
251
|
const { runPreSave } = ctx;
|
|
251
252
|
const db = dbFor(ctx);
|
|
252
|
-
const
|
|
253
|
+
const submitted = event.payload; // @cast-boundary engine-payload
|
|
254
|
+
const payload = {
|
|
255
|
+
...submitted,
|
|
256
|
+
changes: dropEmptyWriteOnlyValues(entity, submitted.changes),
|
|
257
|
+
};
|
|
253
258
|
const user = await actingUserFor(event.user, payload.id, db);
|
|
254
259
|
// skipUnchanged (#464): API-driven updates diff against the stored
|
|
255
260
|
// row so a resubmitted-but-identical field doesn't force a fresh
|
|
@@ -5,6 +5,7 @@ import type { PrincipalStatusPlugin, TenantLifecycleStatusPlugin } from "./activ
|
|
|
5
5
|
import type { TenantDataExtensionHooks } from "./extensions/tenant-data.js";
|
|
6
6
|
import type { TenantResourceExtensionHooks } from "./extensions/tenant-resource.js";
|
|
7
7
|
import type { UserDataExtensionOptions } from "./extensions/user-data.js";
|
|
8
|
+
import type { AssignableFromRole } from "./role-assignment.js";
|
|
8
9
|
import { TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin } from "./tier-resolver-extension.js";
|
|
9
10
|
/**
|
|
10
11
|
* `userData` — User-Daten-Rights-Hooks (DSGVO Art. 15 + 17 + 20).
|
|
@@ -137,13 +138,20 @@ export declare const EXT_PRINCIPAL_STATUS: "principalStatus";
|
|
|
137
138
|
* the bundled `tenant-lifecycle` feature; absent means no tenant is ever "in teardown".
|
|
138
139
|
*/
|
|
139
140
|
export declare const EXT_TENANT_LIFECYCLE_STATUS: "tenantLifecycleStatus";
|
|
141
|
+
/**
|
|
142
|
+
* `assignableRole` — an app declares a membership role as assignable:
|
|
143
|
+
* `r.useExtension(EXT_ASSIGNABLE_ROLE, "<RoleName>", { assignableFrom? })`
|
|
144
|
+
* (`assignableFrom` defaults to "Admin").
|
|
145
|
+
* Provider is the bundled `tenant` feature; consumers are the membership-role guards.
|
|
146
|
+
*/
|
|
147
|
+
export declare const EXT_ASSIGNABLE_ROLE: "assignableRole";
|
|
140
148
|
export declare const TENANT_MEMBERSHIPS_QUERY: "tenant:query:memberships";
|
|
141
149
|
/**
|
|
142
150
|
* Union aller standardisierten Extension-Namen der Datenschutz-Surface.
|
|
143
151
|
* Nicht alle Extensions im System sind in dieser Liste — andere
|
|
144
152
|
* Features koennen weiterhin eigene Extension-Namen registrieren.
|
|
145
153
|
*/
|
|
146
|
-
export type KumikoExtensionName = typeof EXT_USER_DATA | typeof EXT_TENANT_DATA | typeof EXT_STORAGE_PROVIDER | typeof EXT_SEARCH_ADAPTER | typeof EXT_EXTERNAL_RESOURCE | typeof EXT_INFRA_RESOURCE | typeof EXT_FILE_PROVIDER | typeof EXT_DERIVATIVE_RENDERER | typeof EXT_DERIVATIVE_PUBLIC_PREDICATE | typeof EXT_DERIVATIVE_OVERLAY_RESOLVER | typeof EXT_PRINCIPAL_STATUS | typeof EXT_TENANT_LIFECYCLE_STATUS;
|
|
154
|
+
export type KumikoExtensionName = typeof EXT_USER_DATA | typeof EXT_TENANT_DATA | typeof EXT_STORAGE_PROVIDER | typeof EXT_SEARCH_ADAPTER | typeof EXT_EXTERNAL_RESOURCE | typeof EXT_INFRA_RESOURCE | typeof EXT_FILE_PROVIDER | typeof EXT_DERIVATIVE_RENDERER | typeof EXT_DERIVATIVE_PUBLIC_PREDICATE | typeof EXT_DERIVATIVE_OVERLAY_RESOLVER | typeof EXT_PRINCIPAL_STATUS | typeof EXT_TENANT_LIFECYCLE_STATUS | typeof EXT_ASSIGNABLE_ROLE;
|
|
147
155
|
/** The four `destroyTenant(tenantId, ctx)`-only resource-cleanup extension points. */
|
|
148
156
|
export type TenantResourceExtensionName = typeof EXT_STORAGE_PROVIDER | typeof EXT_SEARCH_ADAPTER | typeof EXT_EXTERNAL_RESOURCE | typeof EXT_INFRA_RESOURCE;
|
|
149
157
|
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
@@ -155,6 +163,9 @@ declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
|
155
163
|
[EXT_DERIVATIVE_OVERLAY_RESOLVER]: OverlayResolverPlugin;
|
|
156
164
|
[EXT_PRINCIPAL_STATUS]: PrincipalStatusPlugin;
|
|
157
165
|
[EXT_TENANT_LIFECYCLE_STATUS]: TenantLifecycleStatusPlugin;
|
|
166
|
+
[EXT_ASSIGNABLE_ROLE]: {
|
|
167
|
+
readonly assignableFrom?: AssignableFromRole;
|
|
168
|
+
};
|
|
158
169
|
[EXT_STORAGE_PROVIDER]: TenantResourceExtensionHooks;
|
|
159
170
|
[EXT_SEARCH_ADAPTER]: TenantResourceExtensionHooks;
|
|
160
171
|
[EXT_EXTERNAL_RESOURCE]: TenantResourceExtensionHooks;
|
|
@@ -167,6 +167,13 @@ export const EXT_PRINCIPAL_STATUS = "principalStatus";
|
|
|
167
167
|
* the bundled `tenant-lifecycle` feature; absent means no tenant is ever "in teardown".
|
|
168
168
|
*/
|
|
169
169
|
export const EXT_TENANT_LIFECYCLE_STATUS = "tenantLifecycleStatus";
|
|
170
|
+
/**
|
|
171
|
+
* `assignableRole` — an app declares a membership role as assignable:
|
|
172
|
+
* `r.useExtension(EXT_ASSIGNABLE_ROLE, "<RoleName>", { assignableFrom? })`
|
|
173
|
+
* (`assignableFrom` defaults to "Admin").
|
|
174
|
+
* Provider is the bundled `tenant` feature; consumers are the membership-role guards.
|
|
175
|
+
*/
|
|
176
|
+
export const EXT_ASSIGNABLE_ROLE = "assignableRole";
|
|
170
177
|
// Default membership-list query handler name. Accepted coupling, same
|
|
171
178
|
// pattern as FILE_PROVIDER_CONFIG_KEY — the `tenant` feature registers a handler under this literal.
|
|
172
179
|
export const TENANT_MEMBERSHIPS_QUERY = "tenant:query:memberships";
|
|
@@ -8,7 +8,7 @@ export { findImportBindingForLocalName, resolveModuleFile, } from "./resolve-imp
|
|
|
8
8
|
export { extractDescribe, extractOptionalRequires, extractReadsConfig, extractRequires, extractSystemScope, extractToggleable, extractUiHints, } from "./round1.js";
|
|
9
9
|
export { extractEntity, extractNav, extractRelation, extractWorkspace, } from "./round2.js";
|
|
10
10
|
export { extractClaimKey, extractConfig, extractMetric, extractReferenceData, extractSecret, extractTranslations, extractUseExtension, isClaimKeyType, type NamedOptionsResult, readNamedOptions, } from "./round3.js";
|
|
11
|
-
export { extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
|
|
11
|
+
export { extractBootCheck, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
|
|
12
12
|
export { extractTreeActions } from "./round6.js";
|
|
13
13
|
export type { ExtractOutput } from "./shared.js";
|
|
14
14
|
export { fail, findFunctionLiteral, isPlainObject, ok, readBooleanProperty, readDataLiteralNode, readNameOrRef, readNameOrRefOrList, readObjectPropertyInitializer, readPropertyKey, readStringLiteralArgs, readVarargsOrArrayProp, resolveSameFileObjectLiteral, } from "./shared.js";
|
|
@@ -8,6 +8,6 @@ export { findImportBindingForLocalName, resolveModuleFile, } from "./resolve-imp
|
|
|
8
8
|
export { extractDescribe, extractOptionalRequires, extractReadsConfig, extractRequires, extractSystemScope, extractToggleable, extractUiHints, } from "./round1.js";
|
|
9
9
|
export { extractEntity, extractNav, extractRelation, extractWorkspace, } from "./round2.js";
|
|
10
10
|
export { extractClaimKey, extractConfig, extractMetric, extractReferenceData, extractSecret, extractTranslations, extractUseExtension, isClaimKeyType, readNamedOptions, } from "./round3.js";
|
|
11
|
-
export { extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
|
|
11
|
+
export { extractBootCheck, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
|
|
12
12
|
export { extractTreeActions } from "./round6.js";
|
|
13
13
|
export { fail, findFunctionLiteral, isPlainObject, ok, readBooleanProperty, readDataLiteralNode, readNameOrRef, readNameOrRefOrList, readObjectPropertyInitializer, readPropertyKey, readStringLiteralArgs, readVarargsOrArrayProp, resolveSameFileObjectLiteral, } from "./shared.js";
|
|
@@ -6,3 +6,4 @@ export declare function extractExtendsRegistrar(call: CallExpression, sourceFile
|
|
|
6
6
|
export declare function extractUsesApi(call: CallExpression, sourceFile: SourceFile): ExtractOutput<UsesApiPattern>;
|
|
7
7
|
export declare function extractExposesApi(call: CallExpression, sourceFile: SourceFile): ExtractOutput<ExposesApiPattern>;
|
|
8
8
|
export declare function extractStoreTable(call: CallExpression, sourceFile: SourceFile): ExtractOutput<never>;
|
|
9
|
+
export declare function extractBootCheck(call: CallExpression, sourceFile: SourceFile): ExtractOutput<never>;
|
|
@@ -61,3 +61,8 @@ export function extractStoreTable(call, sourceFile) {
|
|
|
61
61
|
// UnknownPattern) marks it design-time-unreadable, like entity-by-identifier.
|
|
62
62
|
return fail("storeTable", sourceLocationFromNode(call, sourceFile), "storeTable meta is a factory/identifier argument, not a static literal");
|
|
63
63
|
}
|
|
64
|
+
export function extractBootCheck(call, sourceFile) {
|
|
65
|
+
// The argument is a runtime closure over the composed feature list —
|
|
66
|
+
// nothing to extract statically, so a clean ParseError like storeTable.
|
|
67
|
+
return fail("bootCheck", sourceLocationFromNode(call, sourceFile), "bootCheck takes a runtime closure, not a static literal");
|
|
68
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// Per-pattern extractors fill in iteratively (C1.5) — each round adds
|
|
22
22
|
// one extractor + a focused test.
|
|
23
23
|
import { Project, SyntaxKind } from "ts-morph";
|
|
24
|
-
import { extractAiClassify, extractAiExtract, extractAiGenerate, extractAuthClaims, extractClaimKey, extractConfig, extractDefineEvent, extractDescribe, extractEntity, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractHook, extractHttpRoute, extractJob, extractMetric, extractMultiStreamProjection, extractNav, extractNotification, extractOptionalRequires, extractProjection, extractQueryHandler, extractReadsConfig, extractReferenceData, extractRelation, extractRequires, extractScreen, extractSecret, extractStoreTable, extractStreamHandler, extractSystemScope, extractToggleable, extractTranslations, extractTreeActions, extractUiHints, extractUseExtension, extractUsesApi, extractWorkspace, extractWriteHandler, findFunctionLiteral, findImportBindingForLocalName, readObjectPropertyInitializer, resolveModuleFile, resolveSameFileObjectLiteral, } from "./extractors/index.js";
|
|
24
|
+
import { extractAiClassify, extractAiExtract, extractAiGenerate, extractAuthClaims, extractBootCheck, extractClaimKey, extractConfig, extractDefineEvent, extractDescribe, extractEntity, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractHook, extractHttpRoute, extractJob, extractMetric, extractMultiStreamProjection, extractNav, extractNotification, extractOptionalRequires, extractProjection, extractQueryHandler, extractReadsConfig, extractReferenceData, extractRelation, extractRequires, extractScreen, extractSecret, extractStoreTable, extractStreamHandler, extractSystemScope, extractToggleable, extractTranslations, extractTreeActions, extractUiHints, extractUseExtension, extractUsesApi, extractWorkspace, extractWriteHandler, findFunctionLiteral, findImportBindingForLocalName, readObjectPropertyInitializer, resolveModuleFile, resolveSameFileObjectLiteral, } from "./extractors/index.js";
|
|
25
25
|
import { sourceLocationFromNode } from "./source-location.js";
|
|
26
26
|
/**
|
|
27
27
|
* Parse the given feature file and return all recognised r.* calls
|
|
@@ -440,6 +440,8 @@ function dispatchExtractor(methodName, call, sourceFile) {
|
|
|
440
440
|
return extractExposesApi(call, sourceFile);
|
|
441
441
|
case "storeTable":
|
|
442
442
|
return extractStoreTable(call, sourceFile);
|
|
443
|
+
case "bootCheck":
|
|
444
|
+
return extractBootCheck(call, sourceFile);
|
|
443
445
|
// Round 6 — Tree-Actions pattern
|
|
444
446
|
case "treeActions":
|
|
445
447
|
return extractTreeActions(call, sourceFile);
|
|
@@ -5,6 +5,24 @@ import { registerEntityCrud } from "./entity-handlers.js";
|
|
|
5
5
|
import { resolveName } from "./handler-helpers.js";
|
|
6
6
|
import { splitNamedDefinition } from "./object-form.js";
|
|
7
7
|
const CRUD_VERBS = new Set(["create", "update", "delete"]);
|
|
8
|
+
const CRUD_REGISTERED_VERBS = new Set([...CRUD_VERBS, "restore", "list", "detail"]);
|
|
9
|
+
// A silent overwrite would drop framework behaviour that rides on the r.crud
|
|
10
|
+
// handlers (e.g. writeOnly masking), so a same-name registration fails loudly.
|
|
11
|
+
function assertHandlerNameFree(state, featureName, kind, handlerName) {
|
|
12
|
+
const registered = kind === "write" ? state.writeHandlers : state.queryHandlers;
|
|
13
|
+
// skip: a free name is the normal case
|
|
14
|
+
if (!Object.hasOwn(registered, handlerName))
|
|
15
|
+
return;
|
|
16
|
+
const [entityName, verb] = handlerName.split(":");
|
|
17
|
+
const crudShaped = entityName !== undefined &&
|
|
18
|
+
verb !== undefined &&
|
|
19
|
+
state.entities[entityName] !== undefined &&
|
|
20
|
+
CRUD_REGISTERED_VERBS.has(verb);
|
|
21
|
+
throw new Error(`Feature "${featureName}" registers the ${kind} handler "${handlerName}" twice.` +
|
|
22
|
+
(crudShaped
|
|
23
|
+
? ` r.crud("${entityName}", ...) already registers that name; use a distinct name or drop the "${verb}" verb from r.crud.`
|
|
24
|
+
: " Use a distinct name."));
|
|
25
|
+
}
|
|
8
26
|
// Map handler name to entity via colon convention.
|
|
9
27
|
// "task:create" → entity "task". Bare CRUD verbs (create/update/delete) map
|
|
10
28
|
// when feature name matches an entity or the feature owns exactly one entity.
|
|
@@ -70,6 +88,7 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
70
88
|
writeHandler(nameOrDef, schema, handler, options) {
|
|
71
89
|
if (typeof nameOrDef === "object") {
|
|
72
90
|
const def = nameOrDef;
|
|
91
|
+
assertHandlerNameFree(state, name, "write", def.name);
|
|
73
92
|
state.writeHandlers[def.name] = {
|
|
74
93
|
name: def.name,
|
|
75
94
|
schema: def.schema,
|
|
@@ -101,6 +120,7 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
101
120
|
return { name: def.name };
|
|
102
121
|
}
|
|
103
122
|
const inline = requireInlineHandlerArgs("writeHandler", schema, handler, options);
|
|
123
|
+
assertHandlerNameFree(state, name, "write", nameOrDef);
|
|
104
124
|
state.writeHandlers[nameOrDef] = {
|
|
105
125
|
name: nameOrDef,
|
|
106
126
|
schema: inline.schema,
|
|
@@ -116,6 +136,7 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
116
136
|
queryHandler(nameOrDef, schema, handler, options) {
|
|
117
137
|
if (typeof nameOrDef === "object") {
|
|
118
138
|
const def = nameOrDef;
|
|
139
|
+
assertHandlerNameFree(state, name, "query", def.name);
|
|
119
140
|
state.queryHandlers[def.name] = {
|
|
120
141
|
name: def.name,
|
|
121
142
|
schema: def.schema,
|
|
@@ -137,6 +158,7 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
137
158
|
return { name: def.name };
|
|
138
159
|
}
|
|
139
160
|
const inline = requireInlineHandlerArgs("queryHandler", schema, handler, options);
|
|
161
|
+
assertHandlerNameFree(state, name, "query", nameOrDef);
|
|
140
162
|
state.queryHandlers[nameOrDef] = {
|
|
141
163
|
name: nameOrDef,
|
|
142
164
|
schema: inline.schema,
|