@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.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/unprocessable-error-details-reason-codemod.test.ts +205 -0
  3. package/src/api/server.ts +3 -0
  4. package/src/changes.json +8 -0
  5. package/src/db/__tests__/list-row-meta-drift.test.ts +15 -0
  6. package/src/db/queries/event-consumer.ts +20 -5
  7. package/src/engine/__tests__/boot-validator.test.ts +114 -0
  8. package/src/engine/__tests__/build-app-schema.test.ts +48 -1
  9. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +140 -0
  10. package/src/engine/boot-validator/entity-list-screens.ts +13 -1
  11. package/src/engine/boot-validator/i18n-keys.ts +13 -5
  12. package/src/engine/boot-validator/screens.ts +25 -1
  13. package/src/engine/build-app-schema.ts +14 -0
  14. package/src/engine/build-config-feature-schema.ts +216 -44
  15. package/src/engine/index.ts +2 -0
  16. package/src/engine/registry-validate.ts +16 -0
  17. package/src/engine/registry.ts +2 -0
  18. package/src/engine/types/index.ts +2 -0
  19. package/src/i18n/index.ts +1 -0
  20. package/src/i18n/required-surface-keys.ts +11 -0
  21. package/src/i18n/settings-hub-keys.ts +28 -0
  22. package/src/jobs/__tests__/jobs.integration.test.ts +127 -0
  23. package/src/jobs/job-runner.ts +37 -2
  24. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +176 -0
  25. package/src/pipeline/event-dispatcher-delivery.ts +1 -1
  26. package/src/pipeline/event-dispatcher.ts +6 -0
  27. package/src/pipeline/multi-stream-apply-context.ts +4 -4
  28. package/src/scripts/codemod/unprocessable-error-details-reason.ts +273 -0
  29. package/src/testing/e2e-generator.ts +3 -0
  30. package/src/ui-types/index.ts +4 -0
  31. 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
- if (!entity?.fields[normalized.field] && !isDerived) {
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 { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE } from "../build-config-feature-schema";
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(registry: Registry): readonly string[] {
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(registry)) {
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 failing the boot.
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
- export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema {
109
- const masked = collectMaskedKeys(registry);
110
- if (masked.length === 0) return { screens: [], navs: [] };
113
+ type ScopeResult = {
114
+ readonly screens: readonly ScreenDefinition[];
115
+ readonly navs: readonly NavDefinition[];
116
+ readonly translations?: TranslationKeys;
117
+ };
111
118
 
112
- // Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
113
- // (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
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 visible = scopedKeysAt(masked, scope);
124
- if (visible.length === 0) continue;
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
- // Audience-Parent: Gruppierungs-Knoten ohne Screen.
127
- navs.push({
128
- id: audienceNavShortId(scope),
129
- label: `config.settings.${scope}`,
130
- icon: SCOPE_ICON[scope],
131
- order: SCOPE_ORDER[scope],
132
- access: rolesToAccess(visible.flatMap((v) => v.roles)),
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
- for (const feature of featuresPresent(visible.map((v) => v.key))) {
136
- const group = visible.filter((v) => v.key.feature === feature);
137
- const ordered = sortByMaskOrder(group.map((v) => v.key));
138
- const access = rolesToAccess(group.flatMap((v) => v.roles));
139
- const shortId = `${feature}-${scope}`;
140
-
141
- screens.push(buildScreen(shortId, scope, feature, ordered, access, declaredTranslationKeys));
142
- // A tenant/user-home key with an elevated write role (SystemAdmin on a
143
- // tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
144
- // audience navs (cascade-default screen + home screen) — both would
145
- // otherwise carry the identical `${feature}.settings` label. Opt-in
146
- // scoped override (`${feature}.settings.${scope}`) disambiguates only
147
- // where a feature actually declares one; every single-scope feature
148
- // keeps the plain key unchanged.
149
- const scopedLabel = `${feature}.settings.${scope}`;
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
- // Alle masked Keys maschinen-only → kein menschlicher Hub, kein (leerer)
163
- // Settings-Switcher.
164
- if (navs.length === 0) return { screens, navs };
165
- return { screens, navs, workspace: buildSettingsWorkspace(navs) };
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
@@ -440,6 +440,8 @@ export type {
440
440
  ScreenNavSugar,
441
441
  ScreenSlots,
442
442
  SecretKeyHandle,
443
+ SecretsEditScreenDefinition,
444
+ SecretsEditSection,
443
445
  SelectFieldDef,
444
446
  SessionUser,
445
447
  StreamHandlerDef,
@@ -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) {
@@ -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);
@@ -297,6 +297,8 @@ export type {
297
297
  ScreenFilterOp,
298
298
  ScreenNavSugar,
299
299
  ScreenSlots,
300
+ SecretsEditScreenDefinition,
301
+ SecretsEditSection,
300
302
  ToolbarAction,
301
303
  UnitKey,
302
304
  } from "@cosmicdrift/kumiko-types/screen";
package/src/i18n/index.ts CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  pickAcceptLanguage,
14
14
  resolveHeaderLocale,
15
15
  } from "./request-locale";
16
+ export { SETTINGS_HUB_I18N } from "./settings-hub-keys";
16
17
 
17
18
  export type I18nOptions = {
18
19
  defaultLocale: string;
@@ -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
+ };