@cosmicdrift/kumiko-framework 0.333.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/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 +1 -1
- package/dist/engine/index.js +1 -1
- 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 +5 -0
- 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 +1 -1
- 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,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,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { EntityDefinition, SessionUser } from "./types/index.js";
|
|
2
|
+
export declare function maskWriteOnlyFields<T extends Readonly<Record<string, unknown>>>(entity: EntityDefinition, row: T): T;
|
|
3
|
+
export declare function dropEmptyWriteOnlyValues<T extends Readonly<Record<string, unknown>>>(entity: EntityDefinition, values: T): T;
|
|
2
4
|
export declare function filterReadFields(entity: EntityDefinition, data: Readonly<Record<string, unknown>>, user: SessionUser): Record<string, unknown>;
|
|
3
5
|
export declare function checkWriteFieldRoles(entity: EntityDefinition, changes: Readonly<Record<string, unknown>>, user: SessionUser): string | null;
|
|
4
6
|
export declare function checkWriteFieldOwnership(entity: EntityDefinition, submittedChanges: Readonly<Record<string, unknown>>, user: SessionUser, oldRow?: Readonly<Record<string, unknown>>, rowContext?: Readonly<Record<string, unknown>>): string | null;
|
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
import { normalizeAccessEntry, userCanReadFieldRow, userCanWriteFieldRow } from "./ownership.js";
|
|
2
|
+
function writeOnlyFieldNames(entity) {
|
|
3
|
+
const names = [];
|
|
4
|
+
for (const [name, field] of Object.entries(entity.fields)) {
|
|
5
|
+
if (field.type === "text" && field.writeOnly === true)
|
|
6
|
+
names.push(name);
|
|
7
|
+
}
|
|
8
|
+
return names;
|
|
9
|
+
}
|
|
10
|
+
function maskWriteOnlyValue(value) {
|
|
11
|
+
return value === null || value === undefined || value === "" ? null : true;
|
|
12
|
+
}
|
|
13
|
+
// writeOnly text fields never leave the server as plaintext: set -> true,
|
|
14
|
+
// empty -> null. Only keys present in the row are touched (a partial `changes`
|
|
15
|
+
// stays partial). Returns the same object when the entity has no such field.
|
|
16
|
+
export function maskWriteOnlyFields(entity, row) {
|
|
17
|
+
const names = writeOnlyFieldNames(entity);
|
|
18
|
+
if (names.length === 0)
|
|
19
|
+
return row;
|
|
20
|
+
const masked = { ...row };
|
|
21
|
+
for (const name of names) {
|
|
22
|
+
if (name in masked)
|
|
23
|
+
masked[name] = maskWriteOnlyValue(masked[name]);
|
|
24
|
+
}
|
|
25
|
+
return masked; // @cast-boundary engine-payload
|
|
26
|
+
}
|
|
27
|
+
// Write payloads: "" on a writeOnly field means "unchanged" (an untouched
|
|
28
|
+
// masked input submits it), so the key is dropped before the executor sees it.
|
|
29
|
+
export function dropEmptyWriteOnlyValues(entity, values) {
|
|
30
|
+
const names = writeOnlyFieldNames(entity).filter((name) => values[name] === "");
|
|
31
|
+
if (names.length === 0)
|
|
32
|
+
return values;
|
|
33
|
+
const kept = { ...values };
|
|
34
|
+
for (const name of names)
|
|
35
|
+
delete kept[name];
|
|
36
|
+
return kept; // @cast-boundary engine-payload
|
|
37
|
+
}
|
|
2
38
|
// Field-level read filtering. Returns a copy of `data` with fields stripped
|
|
3
39
|
// if the user's roles don't grant read access OR the ownership-rule for the
|
|
4
40
|
// matching role doesn't accept this concrete row. Fields without access
|
|
@@ -22,6 +58,10 @@ export function filterReadFields(entity, data, user) {
|
|
|
22
58
|
if (!userCanReadFieldRow(user, accessMap, data)) {
|
|
23
59
|
continue; // entire field stripped
|
|
24
60
|
}
|
|
61
|
+
if (field.type === "text" && field.writeOnly === true) {
|
|
62
|
+
result[key] = maskWriteOnlyValue(value);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
25
65
|
// For embedded fields: filter sub-fields with access restrictions.
|
|
26
66
|
// A list-embedded value is an array of rows — each row is filtered on its
|
|
27
67
|
// own, so a sub-field rule can reference the row it sits in. Filtering the
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -40,7 +40,7 @@ export type { AddEntityArgs, AddHookArgs, AddRelationArgs, AddWriteHandlerArgs,
|
|
|
40
40
|
export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, groupByCategory, PATTERN_LIBRARY, parseFeatureFile, parsePatternChanges, parseSourceFile, removePattern, renderFeatureFile, renderPattern, replacePattern, updatePattern, VERSION_HEADER, } from "./feature-ast/index.js";
|
|
41
41
|
export { type ChangelogEntry, type ChangelogType, compareVersions, type FeatureChangelog, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
|
|
42
42
|
export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, serializeManifest, } from "./feature-manifest.js";
|
|
43
|
-
export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, } from "./field-access.js";
|
|
43
|
+
export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
|
|
44
44
|
export { resolveName, withResponseData } from "./handler-helpers.js";
|
|
45
45
|
export { i18nKey } from "./i18n-key.js";
|
|
46
46
|
export { buildSessionRoles, findForbiddenMembershipRole, isForbiddenMembershipRole, stripForbiddenMembershipRoles, } from "./membership-roles.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export { createBigIntField, createBooleanField, createDateField, createDecimalFi
|
|
|
35
35
|
export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, groupByCategory, PATTERN_LIBRARY, parseFeatureFile, parsePatternChanges, parseSourceFile, removePattern, renderFeatureFile, renderPattern, replacePattern, updatePattern, VERSION_HEADER, } from "./feature-ast/index.js";
|
|
36
36
|
export { compareVersions, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
|
|
37
37
|
export { buildManifestFromRegistry, serializeManifest, } from "./feature-manifest.js";
|
|
38
|
-
export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, } from "./field-access.js";
|
|
38
|
+
export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
|
|
39
39
|
export { resolveName, withResponseData } from "./handler-helpers.js";
|
|
40
40
|
export { i18nKey } from "./i18n-key.js";
|
|
41
41
|
// findForbiddenMembershipRole/isForbiddenMembershipRole/
|
|
@@ -3,6 +3,7 @@ import type { EntityDefinition, FieldDefinition } from "./types/index.js";
|
|
|
3
3
|
export declare function isRepresentableAtScale(n: number, scale: number): boolean;
|
|
4
4
|
export declare function fieldToZod(field: FieldDefinition, currencies: readonly string[], opts?: {
|
|
5
5
|
readonly applyDefaults?: boolean;
|
|
6
|
+
readonly isUpdate?: boolean;
|
|
6
7
|
}): z.ZodTypeAny;
|
|
7
8
|
export declare function buildInsertSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[], options?: {
|
|
8
9
|
readonly allowCallerId?: boolean;
|
|
@@ -87,6 +87,13 @@ export function fieldToZod(field, currencies, opts = {}) {
|
|
|
87
87
|
let schema = z.string();
|
|
88
88
|
if (field.maxLength)
|
|
89
89
|
schema = schema.max(field.maxLength);
|
|
90
|
+
if (field.writeOnly === true) {
|
|
91
|
+
// Update: "" = unchanged (dropped before the executor), null = clear,
|
|
92
|
+
// unless required. Create: "" would leave a required field unset.
|
|
93
|
+
if (opts.isUpdate !== true)
|
|
94
|
+
return field.required ? schema.min(1) : schema;
|
|
95
|
+
return field.required ? schema : schema.nullable();
|
|
96
|
+
}
|
|
90
97
|
if (field.format === "email")
|
|
91
98
|
schema = schema.email();
|
|
92
99
|
if (field.format === "url")
|
|
@@ -399,7 +406,10 @@ export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES],
|
|
|
399
406
|
// fieldToZod still knows the default for its "" → default mapping
|
|
400
407
|
// (e.g. select) — applyDefaults: false only suppresses the schema-level
|
|
401
408
|
// `.default(...)` fallback for a genuinely omitted key.
|
|
402
|
-
shape[name] = fieldToZod(field, currencies, {
|
|
409
|
+
shape[name] = fieldToZod(field, currencies, {
|
|
410
|
+
applyDefaults: false,
|
|
411
|
+
isUpdate: true,
|
|
412
|
+
}).optional();
|
|
403
413
|
}
|
|
404
414
|
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
405
415
|
}
|
|
@@ -6,7 +6,7 @@ export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap,
|
|
|
6
6
|
export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
|
|
7
7
|
export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
|
|
8
8
|
export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
|
|
9
|
-
export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
|
|
9
|
+
export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
|
|
10
10
|
export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
|
|
11
11
|
export { isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
12
12
|
export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
|
|
@@ -9,6 +9,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
9
9
|
readonly filterable?: boolean;
|
|
10
10
|
readonly encrypted?: boolean;
|
|
11
11
|
readonly sensitive?: boolean;
|
|
12
|
+
readonly writeOnly?: boolean;
|
|
12
13
|
readonly readAsInstruction?: boolean;
|
|
13
14
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
14
15
|
readonly default?: string;
|
|
@@ -29,6 +30,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
29
30
|
readonly filterable?: boolean;
|
|
30
31
|
readonly encrypted?: boolean;
|
|
31
32
|
readonly sensitive?: boolean;
|
|
33
|
+
readonly writeOnly?: boolean;
|
|
32
34
|
readonly readAsInstruction?: boolean;
|
|
33
35
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
34
36
|
readonly default?: string;
|
|
@@ -49,6 +51,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
49
51
|
readonly filterable?: boolean;
|
|
50
52
|
readonly encrypted?: boolean;
|
|
51
53
|
readonly sensitive?: boolean;
|
|
54
|
+
readonly writeOnly?: boolean;
|
|
52
55
|
readonly readAsInstruction?: boolean;
|
|
53
56
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
54
57
|
readonly default?: string;
|
|
@@ -81,6 +84,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
81
84
|
readonly filterable?: boolean;
|
|
82
85
|
readonly encrypted?: boolean;
|
|
83
86
|
readonly sensitive?: boolean;
|
|
87
|
+
readonly writeOnly?: boolean;
|
|
84
88
|
readonly readAsInstruction?: boolean;
|
|
85
89
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
86
90
|
readonly default?: string;
|
|
@@ -101,6 +105,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
101
105
|
readonly filterable?: boolean;
|
|
102
106
|
readonly encrypted?: boolean;
|
|
103
107
|
readonly sensitive?: boolean;
|
|
108
|
+
readonly writeOnly?: boolean;
|
|
104
109
|
readonly readAsInstruction?: boolean;
|
|
105
110
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
106
111
|
readonly default?: string;
|
|
@@ -121,6 +126,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
121
126
|
readonly filterable?: boolean;
|
|
122
127
|
readonly encrypted?: boolean;
|
|
123
128
|
readonly sensitive?: boolean;
|
|
129
|
+
readonly writeOnly?: boolean;
|
|
124
130
|
readonly readAsInstruction?: boolean;
|
|
125
131
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
126
132
|
readonly default?: string;
|
|
@@ -9,6 +9,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
9
9
|
readonly filterable?: boolean;
|
|
10
10
|
readonly encrypted?: boolean;
|
|
11
11
|
readonly sensitive?: boolean;
|
|
12
|
+
readonly writeOnly?: boolean;
|
|
12
13
|
readonly readAsInstruction?: boolean;
|
|
13
14
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
14
15
|
readonly default?: string;
|
|
@@ -29,6 +30,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
29
30
|
readonly filterable?: boolean;
|
|
30
31
|
readonly encrypted?: boolean;
|
|
31
32
|
readonly sensitive?: boolean;
|
|
33
|
+
readonly writeOnly?: boolean;
|
|
32
34
|
readonly readAsInstruction?: boolean;
|
|
33
35
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
34
36
|
readonly default?: string;
|
|
@@ -49,6 +51,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
49
51
|
readonly filterable?: boolean;
|
|
50
52
|
readonly encrypted?: boolean;
|
|
51
53
|
readonly sensitive?: boolean;
|
|
54
|
+
readonly writeOnly?: boolean;
|
|
52
55
|
readonly readAsInstruction?: boolean;
|
|
53
56
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
54
57
|
readonly default?: string;
|
|
@@ -81,6 +84,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
81
84
|
readonly filterable?: boolean;
|
|
82
85
|
readonly encrypted?: boolean;
|
|
83
86
|
readonly sensitive?: boolean;
|
|
87
|
+
readonly writeOnly?: boolean;
|
|
84
88
|
readonly readAsInstruction?: boolean;
|
|
85
89
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
86
90
|
readonly default?: string;
|
|
@@ -101,6 +105,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
101
105
|
readonly filterable?: boolean;
|
|
102
106
|
readonly encrypted?: boolean;
|
|
103
107
|
readonly sensitive?: boolean;
|
|
108
|
+
readonly writeOnly?: boolean;
|
|
104
109
|
readonly readAsInstruction?: boolean;
|
|
105
110
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
106
111
|
readonly default?: string;
|
|
@@ -121,6 +126,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
121
126
|
readonly filterable?: boolean;
|
|
122
127
|
readonly encrypted?: boolean;
|
|
123
128
|
readonly sensitive?: boolean;
|
|
129
|
+
readonly writeOnly?: boolean;
|
|
124
130
|
readonly readAsInstruction?: boolean;
|
|
125
131
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
126
132
|
readonly default?: string;
|
|
@@ -293,6 +293,10 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
293
293
|
case "configEdit": {
|
|
294
294
|
const config = screen;
|
|
295
295
|
pushKey(out, config.submitLabel);
|
|
296
|
+
pushKey(out, config.description, treatDotFormAsKey);
|
|
297
|
+
for (const key of Object.values(config.fieldDescriptions ?? {})) {
|
|
298
|
+
pushKey(out, key, treatDotFormAsKey);
|
|
299
|
+
}
|
|
296
300
|
for (const fieldName of Object.keys(config.fields)) {
|
|
297
301
|
const override = config.fieldLabels?.[fieldName];
|
|
298
302
|
if (override !== undefined)
|
|
@@ -308,6 +312,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
308
312
|
if (section.kind === "relatedList")
|
|
309
313
|
continue; // rejected at boot, unreachable here
|
|
310
314
|
pushSectionTitles(out, section, treatDotFormAsKey);
|
|
315
|
+
pushKey(out, section.description, treatDotFormAsKey);
|
|
311
316
|
for (const f of sectionFieldSpecs(section)) {
|
|
312
317
|
const fieldName = editFieldName(f);
|
|
313
318
|
const override = config.fieldLabels?.[fieldName];
|
|
@@ -16,14 +16,28 @@ export const SETTINGS_HUB_I18N = {
|
|
|
16
16
|
"config.settings.tenant": { en: "Tenant" },
|
|
17
17
|
"config.settings.user": { en: "Personal" },
|
|
18
18
|
"config.settings.extensionSelectorHint": {
|
|
19
|
-
en: "
|
|
19
|
+
en: "Choose a provider and save. Its settings appear below.",
|
|
20
20
|
},
|
|
21
|
+
"config.settings.provider": { en: "Provider" },
|
|
22
|
+
"config.settings.saveProvider": { en: "Save provider" },
|
|
23
|
+
"config.settings.audience.system": {
|
|
24
|
+
en: "Applies to every tenant unless a tenant sets its own value.",
|
|
25
|
+
},
|
|
26
|
+
"config.settings.audience.tenant": {
|
|
27
|
+
en: "Applies to everyone in this tenant. Platform defaults are shown where nothing is set here.",
|
|
28
|
+
},
|
|
29
|
+
"config.settings.audience.user": { en: "Only applies to you." },
|
|
21
30
|
"config.secrets.title": { en: "Secrets" },
|
|
22
31
|
"config.secrets.notSet": { en: "Not set" },
|
|
23
32
|
"config.secrets.set": { en: "Set" },
|
|
24
|
-
"config.secrets.
|
|
33
|
+
"config.secrets.saved": { en: "Saved" },
|
|
34
|
+
"config.secrets.section": { en: "Secrets" },
|
|
35
|
+
"config.secrets.description": {
|
|
36
|
+
en: "Stored encrypted. Saved values are never shown again.",
|
|
37
|
+
},
|
|
38
|
+
"config.secrets.stored": { en: "Stored: {preview}" },
|
|
25
39
|
"config.secrets.placeholder": { en: "Enter a value" },
|
|
26
|
-
"config.secrets.replacePlaceholder": { en: "Enter a new value to replace" },
|
|
27
|
-
"config.secrets.delete": { en: "
|
|
40
|
+
"config.secrets.replacePlaceholder": { en: "Enter a new value to replace it" },
|
|
41
|
+
"config.secrets.delete": { en: "Remove" },
|
|
28
42
|
"screen:secrets.title": { en: "Secrets" },
|
|
29
43
|
};
|
|
@@ -7,6 +7,7 @@ import { resolveDbSource } from "./dispatch-shared.js";
|
|
|
7
7
|
import { executeNestedWrite } from "./dispatch-write.js";
|
|
8
8
|
import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
9
9
|
import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin.js";
|
|
10
|
+
import { maskBatchResultForClient } from "./write-result-masking.js";
|
|
10
11
|
// afterCommit hooks fire in flushAfterCommit, outside the command's scope.
|
|
11
12
|
function rewrapHooksWithOrigin(afterCommitHooks, fromIndex, origin) {
|
|
12
13
|
for (let i = fromIndex; i < afterCommitHooks.length; i++) {
|
|
@@ -51,20 +52,24 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
51
52
|
idempotencyToken = checked.token;
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
// Hooks already ran on the plaintext results; what is returned and cached
|
|
56
|
+
// for retries must not carry writeOnly values.
|
|
57
|
+
const maskForClient = (result) => maskBatchResultForClient(ctx.registry, commands, result);
|
|
54
58
|
// Cache the result under requestId so retries get the same answer. Only a
|
|
55
59
|
// provably rolled-back 5xx releases the lock instead (releaseOrFinalize).
|
|
56
60
|
const finalize = async (result) => {
|
|
61
|
+
const masked = maskForClient(result);
|
|
57
62
|
if (requestId && idempotency && idempotencyToken) {
|
|
58
|
-
await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken,
|
|
63
|
+
await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, masked);
|
|
59
64
|
}
|
|
60
|
-
return
|
|
65
|
+
return masked;
|
|
61
66
|
};
|
|
62
67
|
// Never for the no-tx fallback: without a rollback, a re-run would repeat
|
|
63
68
|
// the side effects of the commands that already ran.
|
|
64
69
|
const releaseOrFinalize = async (result, isRetryableRollback) => {
|
|
65
70
|
if (isRetryableRollback && requestId && idempotency && idempotencyToken) {
|
|
66
71
|
await idempotency.release(user.tenantId, user.id, requestId, idempotencyToken);
|
|
67
|
-
return result;
|
|
72
|
+
return maskForClient(result);
|
|
68
73
|
}
|
|
69
74
|
return finalize(result);
|
|
70
75
|
};
|
|
@@ -5,7 +5,7 @@ import { createTenantDb, hasTenantColumn } from "../db/tenant-db.js";
|
|
|
5
5
|
import { tenantDbRunner } from "../db/tenant-db-runner.js";
|
|
6
6
|
import { hasAccess } from "../engine/access.js";
|
|
7
7
|
import { ConfigScopes } from "../engine/constants.js";
|
|
8
|
-
import { checkWriteFieldOwnership, checkWriteFieldRoles } from "../engine/field-access.js";
|
|
8
|
+
import { checkWriteFieldOwnership, checkWriteFieldRoles, maskWriteOnlyFields, } from "../engine/field-access.js";
|
|
9
9
|
import { defineTransitions, guardTransition } from "../engine/state-machine.js";
|
|
10
10
|
import { HookPhases } from "../engine/types/index.js";
|
|
11
11
|
import { runValidation } from "../engine/validation.js";
|
|
@@ -262,7 +262,10 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
|
|
|
262
262
|
}
|
|
263
263
|
const subWrapper = subResult.data; // @cast-boundary engine-payload
|
|
264
264
|
const subRow = (subWrapper["data"] ?? subWrapper); // @cast-boundary engine-payload
|
|
265
|
-
|
|
265
|
+
const subEntityName = registry.getHandlerEntity(spec.subType);
|
|
266
|
+
const subEntity = subEntityName ? registry.getEntity(subEntityName) : undefined;
|
|
267
|
+
// parentRow is returned to the client; the child's own SaveContext keeps the plaintext.
|
|
268
|
+
subRows.push(subEntity ? maskWriteOnlyFields(subEntity, subRow) : subRow);
|
|
266
269
|
}
|
|
267
270
|
parentRow[spec.key] = subRows;
|
|
268
271
|
}
|
|
@@ -4,8 +4,11 @@ import { VersionConflictError as EventStoreVersionConflictError } from "../event
|
|
|
4
4
|
export function isFailedWriteResult(result) {
|
|
5
5
|
return (!!result && typeof result === "object" && "isSuccess" in result && result.isSuccess === false);
|
|
6
6
|
}
|
|
7
|
+
const LIFECYCLE_KINDS = ["save", "delete"];
|
|
7
8
|
export function isLifecycleResult(data) {
|
|
8
|
-
|
|
9
|
+
if (!data || typeof data !== "object" || !("kind" in data))
|
|
10
|
+
return false;
|
|
11
|
+
return LIFECYCLE_KINDS.some((kind) => kind === data.kind);
|
|
9
12
|
}
|
|
10
13
|
export function isWriteResultShape(result) {
|
|
11
14
|
return (!!result &&
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { WriteResult } from "../engine/types/index.js";
|
|
2
|
+
import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared.js";
|
|
3
|
+
type EntityLookup = Pick<DispatchContext["registry"], "getHandlerEntity" | "getEntity">;
|
|
4
|
+
export declare function maskWriteResultForClient(registry: EntityLookup, handlerType: string, result: WriteResult): WriteResult;
|
|
5
|
+
export declare function maskBatchResultForClient(registry: EntityLookup, commands: readonly BatchCommand[], batch: BatchResult): BatchResult;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { maskWriteOnlyFields } from "../engine/field-access.js";
|
|
2
|
+
import { isLifecycleResult } from "./dispatcher-utils.js";
|
|
3
|
+
function maskLifecycleOrRow(entity, data) {
|
|
4
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
5
|
+
return data;
|
|
6
|
+
if (!isLifecycleResult(data))
|
|
7
|
+
return maskWriteOnlyFields(entity, data); // @cast-boundary engine-payload
|
|
8
|
+
if (data.kind === "save") {
|
|
9
|
+
return {
|
|
10
|
+
...data,
|
|
11
|
+
data: maskWriteOnlyFields(entity, data.data),
|
|
12
|
+
changes: maskWriteOnlyFields(entity, data.changes),
|
|
13
|
+
previous: maskWriteOnlyFields(entity, data.previous),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return { ...data, data: maskWriteOnlyFields(entity, data.data) };
|
|
17
|
+
}
|
|
18
|
+
// Copy-on-mask: the original results still feed postSaveBatch hooks, which
|
|
19
|
+
// need the plaintext. Only what leaves the dispatcher is masked.
|
|
20
|
+
export function maskWriteResultForClient(registry, handlerType, result) {
|
|
21
|
+
if (!result.isSuccess)
|
|
22
|
+
return result;
|
|
23
|
+
const entityName = registry.getHandlerEntity(handlerType);
|
|
24
|
+
const entity = entityName ? registry.getEntity(entityName) : undefined;
|
|
25
|
+
if (!entity)
|
|
26
|
+
return result;
|
|
27
|
+
const data = maskLifecycleOrRow(entity, result.data);
|
|
28
|
+
return data === result.data ? result : { ...result, data };
|
|
29
|
+
}
|
|
30
|
+
export function maskBatchResultForClient(registry, commands, batch) {
|
|
31
|
+
const results = batch.results.map((result, index) => {
|
|
32
|
+
const command = commands[index];
|
|
33
|
+
return command ? maskWriteResultForClient(registry, command.type, result) : result;
|
|
34
|
+
});
|
|
35
|
+
return { ...batch, results };
|
|
36
|
+
}
|
|
@@ -9,6 +9,7 @@ export declare const sharedWidgetEntity: import("@cosmicdrift/kumiko-types/field
|
|
|
9
9
|
readonly filterable?: boolean;
|
|
10
10
|
readonly encrypted?: boolean;
|
|
11
11
|
readonly sensitive?: boolean;
|
|
12
|
+
readonly writeOnly?: boolean;
|
|
12
13
|
readonly readAsInstruction?: boolean;
|
|
13
14
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
14
15
|
readonly default?: string;
|
|
@@ -31,6 +32,7 @@ export declare const sharedWidgetTable: import("../db/table-builder.js").EntityT
|
|
|
31
32
|
readonly filterable?: boolean;
|
|
32
33
|
readonly encrypted?: boolean;
|
|
33
34
|
readonly sensitive?: boolean;
|
|
35
|
+
readonly writeOnly?: boolean;
|
|
34
36
|
readonly readAsInstruction?: boolean;
|
|
35
37
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
36
38
|
readonly default?: string;
|
|
@@ -53,6 +55,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
|
|
|
53
55
|
readonly filterable?: boolean;
|
|
54
56
|
readonly encrypted?: boolean;
|
|
55
57
|
readonly sensitive?: boolean;
|
|
58
|
+
readonly writeOnly?: boolean;
|
|
56
59
|
readonly readAsInstruction?: boolean;
|
|
57
60
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
58
61
|
readonly default?: string;
|
|
@@ -73,6 +76,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
|
|
|
73
76
|
readonly filterable?: boolean;
|
|
74
77
|
readonly encrypted?: boolean;
|
|
75
78
|
readonly sensitive?: boolean;
|
|
79
|
+
readonly writeOnly?: boolean;
|
|
76
80
|
readonly readAsInstruction?: boolean;
|
|
77
81
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
78
82
|
readonly default?: string;
|
|
@@ -93,6 +97,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
|
|
|
93
97
|
readonly filterable?: boolean;
|
|
94
98
|
readonly encrypted?: boolean;
|
|
95
99
|
readonly sensitive?: boolean;
|
|
100
|
+
readonly writeOnly?: boolean;
|
|
96
101
|
readonly readAsInstruction?: boolean;
|
|
97
102
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
98
103
|
readonly default?: string;
|
|
@@ -118,6 +123,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
|
|
|
118
123
|
readonly filterable?: boolean;
|
|
119
124
|
readonly encrypted?: boolean;
|
|
120
125
|
readonly sensitive?: boolean;
|
|
126
|
+
readonly writeOnly?: boolean;
|
|
121
127
|
readonly readAsInstruction?: boolean;
|
|
122
128
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
123
129
|
readonly default?: string;
|
|
@@ -138,6 +144,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
|
|
|
138
144
|
readonly filterable?: boolean;
|
|
139
145
|
readonly encrypted?: boolean;
|
|
140
146
|
readonly sensitive?: boolean;
|
|
147
|
+
readonly writeOnly?: boolean;
|
|
141
148
|
readonly readAsInstruction?: boolean;
|
|
142
149
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
143
150
|
readonly default?: string;
|
|
@@ -158,6 +165,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
|
|
|
158
165
|
readonly filterable?: boolean;
|
|
159
166
|
readonly encrypted?: boolean;
|
|
160
167
|
readonly sensitive?: boolean;
|
|
168
|
+
readonly writeOnly?: boolean;
|
|
161
169
|
readonly readAsInstruction?: boolean;
|
|
162
170
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
163
171
|
readonly default?: string;
|
|
@@ -183,6 +191,7 @@ export declare const sharedItemEntity: import("@cosmicdrift/kumiko-types/fields"
|
|
|
183
191
|
readonly filterable?: boolean;
|
|
184
192
|
readonly encrypted?: boolean;
|
|
185
193
|
readonly sensitive?: boolean;
|
|
194
|
+
readonly writeOnly?: boolean;
|
|
186
195
|
readonly readAsInstruction?: boolean;
|
|
187
196
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
188
197
|
readonly default?: string;
|
|
@@ -224,6 +233,7 @@ export declare const sharedItemTable: import("../db/table-builder.js").EntityTab
|
|
|
224
233
|
readonly filterable?: boolean;
|
|
225
234
|
readonly encrypted?: boolean;
|
|
226
235
|
readonly sensitive?: boolean;
|
|
236
|
+
readonly writeOnly?: boolean;
|
|
227
237
|
readonly readAsInstruction?: boolean;
|
|
228
238
|
readonly format?: "email" | "url" | "phone" | "password";
|
|
229
239
|
readonly default?: string;
|
package/dist/ui-types/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type { ParsedRefTarget } from "../engine/parse-ref-target.js";
|
|
|
5
5
|
export { parseRefTarget } from "../engine/parse-ref-target.js";
|
|
6
6
|
export type { FieldsOrGroupsSection } from "../engine/screen-helpers.js";
|
|
7
7
|
export { evalFieldCondition, explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
|
|
8
|
-
export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, NumberFieldDef, SelectFieldDef, SelectOptionTone, TextFieldDef, } from "../engine/types/fields.js";
|
|
8
|
+
export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, SelectFieldDef, SelectOptionTone, TextFieldDef, } from "../engine/types/fields.js";
|
|
9
9
|
export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
|
|
10
10
|
export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
|
|
11
11
|
export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.334.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -206,8 +206,8 @@
|
|
|
206
206
|
"./package.json": "./package.json"
|
|
207
207
|
},
|
|
208
208
|
"dependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
210
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-http": "0.334.0",
|
|
210
|
+
"@cosmicdrift/kumiko-types": "0.334.0",
|
|
211
211
|
"bullmq": "^5.76.7",
|
|
212
212
|
"bun-types": "^1.3.13",
|
|
213
213
|
"hono": "^4.13.1",
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
"zod": "^4.4.3"
|
|
224
224
|
},
|
|
225
225
|
"devDependencies": {
|
|
226
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
226
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.334.0",
|
|
227
227
|
"bun-types": "^1.3.13",
|
|
228
228
|
"pino-pretty": "^13.1.3"
|
|
229
229
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.334.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Settings form gaps: writeForm footer submit, option description/group, dependent optionsQueryPayload, config badge label, writeOnly secret fields",
|
|
6
|
+
"detail": "- A `writeForm` section that fills a whole tab puts its submit button into the pinned form footer.\n- `optionsQuery` rows may carry `description` (muted second line) and `group` (heading). The combobox and the radio list show both. Options with either one never render as segments.\n- `optionsQueryPayload` values may be `{ field: \"<sibling>\" }`. The select reloads when that field changes and clears a value the new rows no longer contain. A cleared config select resets the key, so the inherited value applies again. On config keys, `field` names another key of the same feature on the same settings mask. The boot validator checks the names, and `writeForm` fieldDefs now go through the select checks too.\n- The origin line and cascade level rows on `configEdit` fields show the option label instead of the raw value or id.\n- New `writeOnly: true` on entity text fields with `find: \"secret\"`. Reads return `true` (set) or `null` and never the value. On write, `\"\"` keeps the stored value and `null` clears it. The edit form shows a masked input with a \"set\" placeholder and a remove action. `maskWriteOnlyFields(entity, row)` is exported from `@cosmicdrift/kumiko-framework/engine` for custom query handlers that return executor rows."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.334.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "Duplicate write/query handler names in one feature fail at definition time",
|
|
12
|
+
"migration": "Rename a custom handler that shares its name with another handler of the same feature, including names r.crud registers (<entity>:list, <entity>:detail, <entity>:create, <entity>:update, <entity>:delete, <entity>:restore). Before, the later registration replaced the earlier one silently."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.332.0",
|
|
4
16
|
"type": "fix",
|