@cosmicdrift/kumiko-framework 0.234.0 → 0.235.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/unprocessable-error-details-reason-codemod.test.ts +205 -0
- package/src/api/server.ts +3 -0
- package/src/changes.json +8 -0
- package/src/db/__tests__/list-row-meta-drift.test.ts +15 -0
- package/src/db/queries/event-consumer.ts +20 -5
- package/src/engine/__tests__/boot-validator.test.ts +114 -0
- package/src/engine/__tests__/build-app-schema.test.ts +48 -1
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +140 -0
- package/src/engine/boot-validator/entity-list-screens.ts +13 -1
- package/src/engine/boot-validator/i18n-keys.ts +13 -5
- package/src/engine/boot-validator/screens.ts +25 -1
- package/src/engine/build-app-schema.ts +14 -0
- package/src/engine/build-config-feature-schema.ts +216 -44
- package/src/engine/index.ts +2 -0
- package/src/engine/registry-validate.ts +16 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/index.ts +2 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/required-surface-keys.ts +11 -0
- package/src/i18n/settings-hub-keys.ts +28 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +127 -0
- package/src/jobs/job-runner.ts +37 -2
- package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +176 -0
- package/src/pipeline/event-dispatcher-delivery.ts +1 -1
- package/src/pipeline/event-dispatcher.ts +6 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -4
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +273 -0
- package/src/testing/e2e-generator.ts +3 -0
- package/src/ui-types/index.ts +4 -0
- package/src/ui-types/list-row-meta.ts +25 -0
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
|
|
1
2
|
import { normalizeListColumn } from "../screen-helpers";
|
|
2
3
|
import type { EntityListScreenDefinition, FeatureDefinition } from "../types";
|
|
3
4
|
|
|
4
5
|
/** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
|
|
5
6
|
export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
|
|
6
7
|
|
|
8
|
+
// Same set computeListViewModel (headless) can actually render — NOT
|
|
9
|
+
// db/table-builder's rowMetaFieldNames(softDelete), which also carries
|
|
10
|
+
// isDeleted/deletedAt/deletedById. Accepting those here would let a
|
|
11
|
+
// softDelete column pass the boot gate and then throw at render-time
|
|
12
|
+
// because the renderer's LIST_ROW_META_COLUMNS doesn't know them.
|
|
13
|
+
const LIST_ROW_META_COLUMN_NAMES = new Set(Object.keys(LIST_ROW_META_COLUMNS));
|
|
14
|
+
|
|
7
15
|
function hasFilterableFields(feature: FeatureDefinition, entityName: string): boolean {
|
|
8
16
|
const entities = feature.entities;
|
|
9
17
|
if (!entities) return false;
|
|
@@ -71,7 +79,11 @@ function validateOneEntityListScreen(
|
|
|
71
79
|
if (normalized.label === undefined) {
|
|
72
80
|
const entity = feature.entities?.[screen.entity];
|
|
73
81
|
const isDerived = entity?.derivedFields?.[normalized.field] !== undefined;
|
|
74
|
-
|
|
82
|
+
// Row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) is a
|
|
83
|
+
// real base-table column without being a declared entity field — e.g.
|
|
84
|
+
// a SystemAdmin cross-tenant list exposing tenantId.
|
|
85
|
+
const isRowMeta = LIST_ROW_META_COLUMN_NAMES.has(normalized.field);
|
|
86
|
+
if (!entity?.fields[normalized.field] && !isDerived && !isRowMeta) {
|
|
75
87
|
throw new Error(`${prefix}: unknown column field "${normalized.field}"`);
|
|
76
88
|
}
|
|
77
89
|
}
|
|
@@ -6,13 +6,15 @@ import {
|
|
|
6
6
|
requiredKeysFromScreen,
|
|
7
7
|
requiredKeysFromWorkspace,
|
|
8
8
|
} from "../../i18n/required-surface-keys";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildConfigFeatureSchema,
|
|
11
|
+
type ConfigFeatureSchema,
|
|
12
|
+
SETTINGS_HUB_FEATURE,
|
|
13
|
+
} from "../build-config-feature-schema";
|
|
10
14
|
import { createRegistry } from "../registry";
|
|
11
15
|
import type { FeatureDefinition } from "../types";
|
|
12
|
-
import type { Registry } from "../types/feature";
|
|
13
16
|
|
|
14
|
-
function requiredKeysFromGeneratedConfigHub(
|
|
15
|
-
const schema = buildConfigFeatureSchema(registry);
|
|
17
|
+
function requiredKeysFromGeneratedConfigHub(schema: ConfigFeatureSchema): readonly string[] {
|
|
16
18
|
if (schema.navs.length === 0) return [];
|
|
17
19
|
const out = new Set<string>();
|
|
18
20
|
|
|
@@ -33,6 +35,7 @@ function requiredKeysFromGeneratedConfigHub(registry: Registry): readonly string
|
|
|
33
35
|
for (const key of requiredKeysFromWorkspace(schema.workspace.definition)) out.add(key);
|
|
34
36
|
}
|
|
35
37
|
out.add("config.settings.title");
|
|
38
|
+
if (schema.screens.some((s) => s.type === "secretsEdit")) out.add("config.secrets.title");
|
|
36
39
|
for (const scope of ["system", "tenant", "user"] as const) {
|
|
37
40
|
out.add(`config.settings.${scope}`);
|
|
38
41
|
}
|
|
@@ -62,6 +65,11 @@ function hasDefinedTranslation(defined: Set<string>, key: string): boolean {
|
|
|
62
65
|
export function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]): void {
|
|
63
66
|
const defined = buildEffectiveTranslationKeys(features);
|
|
64
67
|
const registry = createRegistry(features);
|
|
68
|
+
const generatedConfigHub = buildConfigFeatureSchema(registry);
|
|
69
|
+
// The secrets screen's labels/hints come from the `r.secret()` declaration
|
|
70
|
+
// itself, not from r.translations — without this, every app with a
|
|
71
|
+
// declared secret would fail boot on its own generated keys.
|
|
72
|
+
for (const key of Object.keys(generatedConfigHub.translations ?? {})) defined.add(key);
|
|
65
73
|
|
|
66
74
|
for (const feature of features) {
|
|
67
75
|
if (!featureHasI18nSurface(feature)) continue;
|
|
@@ -76,7 +84,7 @@ export function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]):
|
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
for (const key of requiredKeysFromGeneratedConfigHub(
|
|
87
|
+
for (const key of requiredKeysFromGeneratedConfigHub(generatedConfigHub)) {
|
|
80
88
|
if (!hasDefinedTranslation(defined, key)) {
|
|
81
89
|
throw new Error(`[i18n] Settings-Hub: required translation key missing: "${key}"`);
|
|
82
90
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
9
|
+
import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
|
|
9
10
|
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
|
|
10
11
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
11
12
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
@@ -26,6 +27,15 @@ import type {
|
|
|
26
27
|
ToolbarAction,
|
|
27
28
|
} from "../types/screen";
|
|
28
29
|
|
|
30
|
+
// entityList columns accept exactly the row-meta columns the renderer
|
|
31
|
+
// (computeListViewModel) knows how to type — LIST_ROW_META_COLUMNS, NOT the
|
|
32
|
+
// wider rowMetaFieldNames(softDelete) used below for rowAction payload/visible
|
|
33
|
+
// field refs. Those two checks guard different things: a screen column must
|
|
34
|
+
// be renderable, a payload/visible field reference only needs to exist on the
|
|
35
|
+
// row. Using the wider set here would let a softDelete column like
|
|
36
|
+
// "deletedAt" pass the boot gate and then throw at render-time instead.
|
|
37
|
+
const LIST_ROW_META_COLUMN_NAMES = new Set(Object.keys(LIST_ROW_META_COLUMNS));
|
|
38
|
+
|
|
29
39
|
// entityList and projectionList both allow a rowAction to double as the
|
|
30
40
|
// row-body click target (rowClick: true, fw#1708/#2164) — at most one per
|
|
31
41
|
// screen, or the renderer can't tell which one should fire.
|
|
@@ -991,6 +1001,11 @@ export function validateScreens(
|
|
|
991
1001
|
continue;
|
|
992
1002
|
}
|
|
993
1003
|
|
|
1004
|
+
// secretsEdit is generator-only (buildConfigFeatureSchema) — never
|
|
1005
|
+
// authored via r.screen, so feature.screens never actually carries one.
|
|
1006
|
+
// Kept here only so this loop's type narrowing stays exhaustive.
|
|
1007
|
+
if (screen.type === "secretsEdit") continue;
|
|
1008
|
+
|
|
994
1009
|
// entityList / entityEdit: entity-refs are feature-local.
|
|
995
1010
|
const entityDef = feature.entities?.[screen.entity];
|
|
996
1011
|
if (!entityDef) {
|
|
@@ -1031,14 +1046,23 @@ export function validateScreens(
|
|
|
1031
1046
|
}
|
|
1032
1047
|
for (const col of screen.columns) {
|
|
1033
1048
|
const normalized = normalizeListColumn(col);
|
|
1049
|
+
// A row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) is a
|
|
1050
|
+
// real DB column without being a declared entity field — e.g. a
|
|
1051
|
+
// SystemAdmin cross-tenant list exposing tenantId. Accept it like a
|
|
1052
|
+
// regular field name — but only the columns computeListViewModel can
|
|
1053
|
+
// actually render (LIST_ROW_META_COLUMN_NAMES), not the wider
|
|
1054
|
+
// softDelete-aware rowMeta set used below for payload/visible refs.
|
|
1055
|
+
//
|
|
1034
1056
|
// A virtual presentational column (drawn by a columnRenderer component
|
|
1035
1057
|
// from the row, e.g. tag chips) needs BOTH a label AND a renderer —
|
|
1036
1058
|
// renderer is what actually makes it "virtual" (label alone still
|
|
1037
1059
|
// needs list.ts's virtual-branch to have something to draw; without
|
|
1038
1060
|
// a renderer the column would render nothing). A column with neither
|
|
1039
|
-
// matching a real field NOR a renderer is a typo worth
|
|
1061
|
+
// matching a real field/row-meta column NOR a renderer is a typo worth
|
|
1062
|
+
// failing the boot.
|
|
1040
1063
|
if (
|
|
1041
1064
|
!columnFieldNames.has(normalized.field) &&
|
|
1065
|
+
!LIST_ROW_META_COLUMN_NAMES.has(normalized.field) &&
|
|
1042
1066
|
!(normalized.label !== undefined && normalized.renderer !== undefined)
|
|
1043
1067
|
) {
|
|
1044
1068
|
throw new Error(
|
|
@@ -160,12 +160,16 @@ function mergeSettingsHubIntoConfigFeature(
|
|
|
160
160
|
entities: {},
|
|
161
161
|
screens: generated.screens,
|
|
162
162
|
navs: generated.navs,
|
|
163
|
+
...(generated.translations !== undefined && { translations: generated.translations }),
|
|
163
164
|
});
|
|
164
165
|
} else {
|
|
165
166
|
features[features.indexOf(existing)] = {
|
|
166
167
|
...existing,
|
|
167
168
|
screens: [...existing.screens, ...generated.screens],
|
|
168
169
|
navs: [...(existing.navs ?? []), ...generated.navs],
|
|
170
|
+
...(generated.translations !== undefined && {
|
|
171
|
+
translations: { ...existing.translations, ...generated.translations },
|
|
172
|
+
}),
|
|
169
173
|
};
|
|
170
174
|
}
|
|
171
175
|
}
|
|
@@ -462,6 +466,16 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
|
|
|
462
466
|
if (Array.isArray(def["totals"]) && isJsonSafeValue(def["totals"])) out["totals"] = def["totals"];
|
|
463
467
|
if (isPlainObject(def["totalsMatch"]) && isJsonSafeValue(def["totalsMatch"]))
|
|
464
468
|
out["totalsMatch"] = def["totalsMatch"];
|
|
469
|
+
// embedded lists: the sub-field map itself — without it the client-side
|
|
470
|
+
// edit view-model iterates `undefined` and the screen crashes (fw#2507).
|
|
471
|
+
// Recursive per sub-field, NOT a blanket isJsonSafeValue gate: a single
|
|
472
|
+
// sub-field carrying a validator fn would otherwise drop the whole map,
|
|
473
|
+
// and a blanket copy would ship server internals.
|
|
474
|
+
if (isPlainObject(def["schema"])) {
|
|
475
|
+
out["schema"] = Object.fromEntries(
|
|
476
|
+
Object.entries(def["schema"]).map(([k, v]) => [k, projectField(v as FieldDefinition)]), // @cast-boundary schema-walk
|
|
477
|
+
);
|
|
478
|
+
}
|
|
465
479
|
return out as FieldDefinition; // @cast-boundary schema-walk
|
|
466
480
|
}
|
|
467
481
|
|
|
@@ -24,8 +24,8 @@ import {
|
|
|
24
24
|
createTextField,
|
|
25
25
|
} from "./factories";
|
|
26
26
|
import { isKebabSegment } from "./qualified-name";
|
|
27
|
-
import type { ConfigKeyDefinition } from "./types/config";
|
|
28
|
-
import type { Registry } from "./types/feature";
|
|
27
|
+
import type { ConfigKeyDefinition, TranslationKeys } from "./types/config";
|
|
28
|
+
import type { Registry, SecretKeyDefinition } from "./types/feature";
|
|
29
29
|
import type { FieldDefinition } from "./types/fields";
|
|
30
30
|
import type { AccessRule } from "./types/handlers";
|
|
31
31
|
import type { NavDefinition, NavIconKey } from "./types/nav";
|
|
@@ -33,6 +33,8 @@ import type {
|
|
|
33
33
|
ConfigEditScreenDefinition,
|
|
34
34
|
EditFieldsSection,
|
|
35
35
|
ScreenDefinition,
|
|
36
|
+
SecretsEditScreenDefinition,
|
|
37
|
+
SecretsEditSection,
|
|
36
38
|
} from "./types/screen";
|
|
37
39
|
|
|
38
40
|
// Namespace, unter dem buildAppSchema die generierten Screens/Navs einhängt
|
|
@@ -53,6 +55,9 @@ export type ConfigFeatureSchema = {
|
|
|
53
55
|
// wenn die App bereits Workspaces nutzt (sonst kippt eine workspace-lose
|
|
54
56
|
// App in den Filter-Modus und verliert alle übrigen Navs).
|
|
55
57
|
readonly workspace?: WorkspaceSchema;
|
|
58
|
+
// Generated i18n keys for the secrets screen (label/hint text pulled from
|
|
59
|
+
// the `r.secret()` declaration itself, not authored via r.translations).
|
|
60
|
+
readonly translations?: TranslationKeys;
|
|
56
61
|
};
|
|
57
62
|
|
|
58
63
|
// Audience-Reihenfolge im Sidebar: Plattform vor Tenant vor Benutzer.
|
|
@@ -105,64 +110,231 @@ type MaskedKey = {
|
|
|
105
110
|
|
|
106
111
|
type ScopedKey = { readonly key: MaskedKey; readonly roles: readonly string[] };
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
type ScopeResult = {
|
|
114
|
+
readonly screens: readonly ScreenDefinition[];
|
|
115
|
+
readonly navs: readonly NavDefinition[];
|
|
116
|
+
readonly translations?: TranslationKeys;
|
|
117
|
+
};
|
|
111
118
|
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
// Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
|
|
120
|
+
// (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
|
|
121
|
+
function collectDeclaredTranslationKeys(registry: Registry): Set<string> {
|
|
114
122
|
const declaredTranslationKeys = new Set<string>();
|
|
115
123
|
for (const f of registry.features.values()) {
|
|
116
124
|
for (const key of Object.keys(f.translations ?? {})) declaredTranslationKeys.add(key);
|
|
117
125
|
}
|
|
126
|
+
return declaredTranslationKeys;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Per-feature configEdit screen + child-nav, one pair per feature present in
|
|
130
|
+
// `visible` at this scope.
|
|
131
|
+
function buildFeatureScreensAndNavs(
|
|
132
|
+
scope: ConfigScope,
|
|
133
|
+
visible: readonly ScopedKey[],
|
|
134
|
+
declaredTranslationKeys: ReadonlySet<string>,
|
|
135
|
+
): { screens: ScreenDefinition[]; navs: NavDefinition[] } {
|
|
136
|
+
const screens: ScreenDefinition[] = [];
|
|
137
|
+
const navs: NavDefinition[] = [];
|
|
138
|
+
for (const feature of featuresPresent(visible.map((v) => v.key))) {
|
|
139
|
+
const group = visible.filter((v) => v.key.feature === feature);
|
|
140
|
+
const ordered = sortByMaskOrder(group.map((v) => v.key));
|
|
141
|
+
const access = rolesToAccess(group.flatMap((v) => v.roles));
|
|
142
|
+
const shortId = `${feature}-${scope}`;
|
|
143
|
+
|
|
144
|
+
screens.push(buildScreen(shortId, scope, feature, ordered, access, declaredTranslationKeys));
|
|
145
|
+
// A tenant/user-home key with an elevated write role (SystemAdmin on a
|
|
146
|
+
// tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
|
|
147
|
+
// audience navs (cascade-default screen + home screen) — both would
|
|
148
|
+
// otherwise carry the identical `${feature}.settings` label. Opt-in
|
|
149
|
+
// scoped override (`${feature}.settings.${scope}`) disambiguates only
|
|
150
|
+
// where a feature actually declares one; every single-scope feature
|
|
151
|
+
// keeps the plain key unchanged.
|
|
152
|
+
const scopedLabel = `${feature}.settings.${scope}`;
|
|
153
|
+
navs.push({
|
|
154
|
+
id: shortId,
|
|
155
|
+
label: declaredTranslationKeys.has(scopedLabel) ? scopedLabel : `${feature}.settings`,
|
|
156
|
+
parent: audienceNavShortId(scope),
|
|
157
|
+
screen: shortId,
|
|
158
|
+
icon: ordered[0]?.def.mask?.icon ?? "settings",
|
|
159
|
+
order: minMaskOrder(ordered),
|
|
160
|
+
access,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return { screens, navs };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Everything generated for one audience scope: the audience-parent nav, the
|
|
167
|
+
// per-feature configEdit screens+navs, and (tenant-scope only, when secrets
|
|
168
|
+
// are enabled) the secrets screen. Null when nothing is visible at this scope.
|
|
169
|
+
function buildScopeResult(
|
|
170
|
+
scope: ConfigScope,
|
|
171
|
+
masked: readonly MaskedKey[],
|
|
172
|
+
secretsEnabled: boolean,
|
|
173
|
+
secretsWriteHandler: ReturnType<Registry["getWriteHandler"]>,
|
|
174
|
+
declaredSecrets: readonly DeclaredSecret[],
|
|
175
|
+
declaredTranslationKeys: ReadonlySet<string>,
|
|
176
|
+
): ScopeResult | null {
|
|
177
|
+
const visible = scopedKeysAt(masked, scope);
|
|
178
|
+
// Secrets attach to the tenant-audience nav regardless of whether any
|
|
179
|
+
// config key is visible there — they alone must be enough to open it.
|
|
180
|
+
const includeSecrets = secretsEnabled && scope === "tenant";
|
|
181
|
+
if (visible.length === 0 && !includeSecrets) return null;
|
|
182
|
+
|
|
183
|
+
const configAccess = rolesToAccess(visible.flatMap((v) => v.roles));
|
|
184
|
+
const secretsAccess = secretsWriteHandler?.access;
|
|
185
|
+
const screens: ScreenDefinition[] = [];
|
|
186
|
+
const navs: NavDefinition[] = [];
|
|
187
|
+
// Audience-Parent: Gruppierungs-Knoten ohne Screen.
|
|
188
|
+
navs.push({
|
|
189
|
+
id: audienceNavShortId(scope),
|
|
190
|
+
label: `config.settings.${scope}`,
|
|
191
|
+
icon: SCOPE_ICON[scope],
|
|
192
|
+
order: SCOPE_ORDER[scope],
|
|
193
|
+
access: includeSecrets ? unionAccessRules([configAccess, secretsAccess]) : configAccess,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const perFeature = buildFeatureScreensAndNavs(scope, visible, declaredTranslationKeys);
|
|
197
|
+
screens.push(...perFeature.screens);
|
|
198
|
+
navs.push(...perFeature.navs);
|
|
199
|
+
|
|
200
|
+
let translations: TranslationKeys | undefined;
|
|
201
|
+
if (includeSecrets) {
|
|
202
|
+
const generated = buildSecretsScreen(declaredSecrets, secretsAccess, declaredTranslationKeys);
|
|
203
|
+
screens.push(generated.screen);
|
|
204
|
+
navs.push(generated.nav);
|
|
205
|
+
translations = generated.translations;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { screens, navs, ...(translations !== undefined && { translations }) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema {
|
|
212
|
+
const masked = collectMaskedKeys(registry);
|
|
213
|
+
const declaredSecrets = collectDeclaredSecrets(registry);
|
|
214
|
+
// The `secrets` feature must actually be mounted (its set-handler
|
|
215
|
+
// registered) for a declared `r.secret()` to have anywhere to write to.
|
|
216
|
+
const secretsWriteHandler = registry.getWriteHandler("secrets:write:set");
|
|
217
|
+
const secretsEnabled = secretsWriteHandler !== undefined && declaredSecrets.length > 0;
|
|
218
|
+
if (masked.length === 0 && !secretsEnabled) return { screens: [], navs: [] };
|
|
219
|
+
|
|
220
|
+
const declaredTranslationKeys = collectDeclaredTranslationKeys(registry);
|
|
118
221
|
|
|
119
222
|
const screens: ScreenDefinition[] = [];
|
|
120
223
|
const navs: NavDefinition[] = [];
|
|
224
|
+
let translations: TranslationKeys | undefined;
|
|
121
225
|
|
|
122
226
|
for (const scope of SCOPES_BROAD_TO_DEEP) {
|
|
123
|
-
const
|
|
124
|
-
|
|
227
|
+
const result = buildScopeResult(
|
|
228
|
+
scope,
|
|
229
|
+
masked,
|
|
230
|
+
secretsEnabled,
|
|
231
|
+
secretsWriteHandler,
|
|
232
|
+
declaredSecrets,
|
|
233
|
+
declaredTranslationKeys,
|
|
234
|
+
);
|
|
235
|
+
if (result === null) continue;
|
|
236
|
+
screens.push(...result.screens);
|
|
237
|
+
navs.push(...result.navs);
|
|
238
|
+
if (result.translations !== undefined) translations = result.translations;
|
|
239
|
+
}
|
|
125
240
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
241
|
+
// Every masked key machine-only and no secrets → no human hub, and no
|
|
242
|
+
// (empty) settings switcher.
|
|
243
|
+
if (navs.length === 0) return { screens, navs };
|
|
244
|
+
return {
|
|
245
|
+
screens,
|
|
246
|
+
navs,
|
|
247
|
+
workspace: buildSettingsWorkspace(navs),
|
|
248
|
+
...(translations !== undefined && { translations }),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
type DeclaredSecret = {
|
|
253
|
+
readonly qn: string;
|
|
254
|
+
readonly feature: string;
|
|
255
|
+
readonly shortKey: string;
|
|
256
|
+
readonly def: SecretKeyDefinition;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function collectDeclaredSecrets(registry: Registry): DeclaredSecret[] {
|
|
260
|
+
const out: DeclaredSecret[] = [];
|
|
261
|
+
for (const [qn, def] of registry.getAllSecretKeys()) {
|
|
262
|
+
const sep = qn.indexOf(":secret:");
|
|
263
|
+
if (sep === -1) continue;
|
|
264
|
+
out.push({
|
|
265
|
+
qn,
|
|
266
|
+
feature: qn.slice(0, sep),
|
|
267
|
+
shortKey: qn.slice(sep + ":secret:".length),
|
|
268
|
+
def,
|
|
133
269
|
});
|
|
270
|
+
}
|
|
271
|
+
return out.sort(
|
|
272
|
+
(a, b) => a.feature.localeCompare(b.feature) || a.shortKey.localeCompare(b.shortKey),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// One screen for ALL declared secrets (not one per feature — a per-feature
|
|
277
|
+
// nav would collide with the same feature's configEdit nav under
|
|
278
|
+
// audience-tenant). `access` mirrors the secrets `set`-handler's own access
|
|
279
|
+
// rule verbatim so a screen never permits more than the handler itself does.
|
|
280
|
+
function buildSecretsScreen(
|
|
281
|
+
secrets: readonly DeclaredSecret[],
|
|
282
|
+
access: AccessRule | undefined,
|
|
283
|
+
declaredTranslationKeys: ReadonlySet<string>,
|
|
284
|
+
): { screen: SecretsEditScreenDefinition; nav: NavDefinition; translations: TranslationKeys } {
|
|
285
|
+
const secretKeys: Record<string, string> = {};
|
|
286
|
+
const fieldLabels: Record<string, string> = {};
|
|
287
|
+
const fieldHints: Record<string, string> = {};
|
|
288
|
+
const requiredFields: string[] = [];
|
|
289
|
+
// Mutable outer record — TranslationKeys' Readonly<Record<...>> index
|
|
290
|
+
// signature only permits reading, so the top-level assignments below
|
|
291
|
+
// need a writable local type; each entry is still a fresh, never-mutated object.
|
|
292
|
+
const translations: Record<string, Readonly<Record<string, string>>> = {};
|
|
293
|
+
const sections: SecretsEditSection[] = [];
|
|
134
294
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
navs.push({
|
|
151
|
-
id: shortId,
|
|
152
|
-
label: declaredTranslationKeys.has(scopedLabel) ? scopedLabel : `${feature}.settings`,
|
|
153
|
-
parent: audienceNavShortId(scope),
|
|
154
|
-
screen: shortId,
|
|
155
|
-
icon: ordered[0]?.def.mask?.icon ?? "settings",
|
|
156
|
-
order: minMaskOrder(ordered),
|
|
157
|
-
access,
|
|
158
|
-
});
|
|
295
|
+
for (const feature of [...new Set(secrets.map((s) => s.feature))]) {
|
|
296
|
+
const fieldIds: string[] = [];
|
|
297
|
+
for (const s of secrets.filter((v) => v.feature === feature)) {
|
|
298
|
+
const fieldId = `${s.feature}-${s.shortKey}`;
|
|
299
|
+
fieldIds.push(fieldId);
|
|
300
|
+
secretKeys[fieldId] = s.qn;
|
|
301
|
+
const labelKey = `config.secret.${s.feature}.${s.shortKey}.label`;
|
|
302
|
+
fieldLabels[fieldId] = labelKey;
|
|
303
|
+
translations[labelKey] = { ...s.def.label };
|
|
304
|
+
if (s.def.hint !== undefined) {
|
|
305
|
+
const hintKey = `config.secret.${s.feature}.${s.shortKey}.hint`;
|
|
306
|
+
fieldHints[fieldId] = hintKey;
|
|
307
|
+
translations[hintKey] = { ...s.def.hint };
|
|
308
|
+
}
|
|
309
|
+
if (s.def.required === true) requiredFields.push(fieldId);
|
|
159
310
|
}
|
|
311
|
+
const titleKey = `${feature}.settings`;
|
|
312
|
+
sections.push({
|
|
313
|
+
...(declaredTranslationKeys.has(titleKey) && { title: titleKey }),
|
|
314
|
+
fields: fieldIds,
|
|
315
|
+
});
|
|
160
316
|
}
|
|
161
317
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
318
|
+
const screen: SecretsEditScreenDefinition = {
|
|
319
|
+
id: "secrets",
|
|
320
|
+
type: "secretsEdit",
|
|
321
|
+
secretKeys,
|
|
322
|
+
fieldLabels,
|
|
323
|
+
...(Object.keys(fieldHints).length > 0 && { fieldHints }),
|
|
324
|
+
...(requiredFields.length > 0 && { requiredFields }),
|
|
325
|
+
sections,
|
|
326
|
+
...(access !== undefined && { access }),
|
|
327
|
+
};
|
|
328
|
+
const nav: NavDefinition = {
|
|
329
|
+
id: "secrets",
|
|
330
|
+
label: "config.secrets.title",
|
|
331
|
+
parent: audienceNavShortId("tenant"),
|
|
332
|
+
screen: "secrets",
|
|
333
|
+
icon: "key",
|
|
334
|
+
order: 900,
|
|
335
|
+
...(access !== undefined && { access }),
|
|
336
|
+
};
|
|
337
|
+
return { screen, nav, translations };
|
|
166
338
|
}
|
|
167
339
|
|
|
168
340
|
// Keys visible at `scope`, paired with their effective write roles AT that
|
package/src/engine/index.ts
CHANGED
|
@@ -587,6 +587,22 @@ export function validateJobTriggers(state: RegistryState): void {
|
|
|
587
587
|
}
|
|
588
588
|
}
|
|
589
589
|
|
|
590
|
+
export function validateBootGates(state: RegistryState): void {
|
|
591
|
+
// Boot gates run inline in JobRunner.start(), which has neither the
|
|
592
|
+
// per-tenant fan-out (that re-enqueues) nor the sequential lock's
|
|
593
|
+
// re-enqueue-and-return path. Both would make the gate pass silently
|
|
594
|
+
// without ever running the handler.
|
|
595
|
+
for (const [jobName, jobDef] of state.jobMap) {
|
|
596
|
+
if (!jobDef.bootGate) continue;
|
|
597
|
+
if (jobDef.perTenant) {
|
|
598
|
+
throw new Error(`Job "${jobName}" cannot combine bootGate with perTenant`);
|
|
599
|
+
}
|
|
600
|
+
if (jobDef.concurrency === "sequential") {
|
|
601
|
+
throw new Error(`Job "${jobName}" cannot combine bootGate with concurrency "sequential"`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
590
606
|
export function validateExtensionUsageTargets(state: RegistryState): void {
|
|
591
607
|
// Validate: extension usages must reference existing extensions
|
|
592
608
|
for (const usage of state.extensionUsages) {
|
package/src/engine/registry.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
populateHandlerEntityMappings,
|
|
28
28
|
publishEventPiiCatalog,
|
|
29
29
|
resolveNotificationTriggersAndRegisterHooks,
|
|
30
|
+
validateBootGates,
|
|
30
31
|
validateEntityHookTargets,
|
|
31
32
|
validateEventMigrationVersions,
|
|
32
33
|
validateExtensionSelectors,
|
|
@@ -79,6 +80,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
79
80
|
validateLifecycleHookTargets(state);
|
|
80
81
|
validateEntityHookTargets(state, features);
|
|
81
82
|
validateJobTriggers(state);
|
|
83
|
+
validateBootGates(state);
|
|
82
84
|
validateExtensionUsageTargets(state);
|
|
83
85
|
computeHasRateLimitedHandler(state);
|
|
84
86
|
publishEventPiiCatalog(state);
|
package/src/i18n/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ProjectionListScreenDefinition,
|
|
14
14
|
RowAction,
|
|
15
15
|
ScreenDefinition,
|
|
16
|
+
SecretsEditScreenDefinition,
|
|
16
17
|
ToolbarAction,
|
|
17
18
|
WorkspaceDefinition,
|
|
18
19
|
} from "../engine/types";
|
|
@@ -223,6 +224,16 @@ export function requiredKeysFromScreen(
|
|
|
223
224
|
}
|
|
224
225
|
break;
|
|
225
226
|
}
|
|
227
|
+
case "secretsEdit": {
|
|
228
|
+
const secretsEdit = screen as SecretsEditScreenDefinition;
|
|
229
|
+
for (const key of Object.values(secretsEdit.fieldLabels))
|
|
230
|
+
pushKey(out, key, treatDotFormAsKey);
|
|
231
|
+
for (const key of Object.values(secretsEdit.fieldHints ?? {})) {
|
|
232
|
+
pushKey(out, key, treatDotFormAsKey);
|
|
233
|
+
}
|
|
234
|
+
for (const section of secretsEdit.sections) pushKey(out, section.title, treatDotFormAsKey);
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
226
237
|
case "projectionDetail": {
|
|
227
238
|
const detail = screen as ProjectionDetailScreenDefinition;
|
|
228
239
|
for (const section of detail.layout.sections) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TranslationKeys } from "../engine/types/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settings-Hub chrome keys (parent-nav labels, secrets-screen title/strings)
|
|
5
|
+
* needed by ANY app that mounts a foundation feature producing a hub nav —
|
|
6
|
+
* `secrets` is auto-mounted (see FOUNDATION_FEATURES in
|
|
7
|
+
* @cosmicdrift/kumiko-dev-server scaffold-app.ts) while `config` stays
|
|
8
|
+
* optional, so these keys must be shippable without `config` present.
|
|
9
|
+
* Both `config` and `secrets` ship this bundle via r.translations — see
|
|
10
|
+
* @cosmicdrift/kumiko-bundled-features config/i18n.ts (CONFIG_FEATURE_I18N)
|
|
11
|
+
* and secrets/feature.ts. Owned here (not in bundled-features) because the
|
|
12
|
+
* Settings-Hub generator that requires these keys
|
|
13
|
+
* (build-config-feature-schema.ts) lives in this package.
|
|
14
|
+
*/
|
|
15
|
+
export const SETTINGS_HUB_I18N: TranslationKeys = {
|
|
16
|
+
"config.settings.title": { en: "Settings" },
|
|
17
|
+
"config.settings.system": { en: "Platform" },
|
|
18
|
+
"config.settings.tenant": { en: "Tenant" },
|
|
19
|
+
"config.settings.user": { en: "Personal" },
|
|
20
|
+
"config.secrets.title": { en: "Secrets" },
|
|
21
|
+
"config.secrets.notSet": { en: "Not set" },
|
|
22
|
+
"config.secrets.set": { en: "Set" },
|
|
23
|
+
"config.secrets.required": { en: "Required" },
|
|
24
|
+
"config.secrets.placeholder": { en: "Enter a value" },
|
|
25
|
+
"config.secrets.replacePlaceholder": { en: "Enter a new value to replace" },
|
|
26
|
+
"config.secrets.delete": { en: "Delete" },
|
|
27
|
+
"screen:secrets.title": { en: "Secrets" },
|
|
28
|
+
};
|