@cosmicdrift/kumiko-framework 0.48.1 → 0.51.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.
Files changed (56) hide show
  1. package/package.json +2 -2
  2. package/src/api/__tests__/origin-middleware.integration.test.ts +132 -0
  3. package/src/api/__tests__/origin-middleware.test.ts +191 -0
  4. package/src/api/anonymous-cookie.ts +1 -0
  5. package/src/api/api-constants.ts +9 -0
  6. package/src/api/auth-middleware.ts +2 -0
  7. package/src/api/auth-routes.ts +29 -4
  8. package/src/api/csrf-middleware.ts +1 -4
  9. package/src/api/origin-middleware.ts +98 -0
  10. package/src/api/server.ts +20 -0
  11. package/src/bun-db/__tests__/_helpers.ts +1 -0
  12. package/src/bun-db/query.ts +1 -0
  13. package/src/db/__tests__/migrate-generator.test.ts +11 -0
  14. package/src/db/collect-table-metas.ts +2 -1
  15. package/src/db/dialect.ts +3 -1
  16. package/src/db/migrate-generator.ts +6 -2
  17. package/src/db/queries/event-store.ts +1 -0
  18. package/src/engine/__tests__/boot-validator.test.ts +43 -0
  19. package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +118 -0
  20. package/src/engine/__tests__/build-config-feature-schema.test.ts +174 -0
  21. package/src/engine/__tests__/config-helpers.test.ts +57 -0
  22. package/src/engine/boot-validator/config-deps.ts +24 -0
  23. package/src/engine/boot-validator/index.ts +11 -4
  24. package/src/engine/build-app-schema.ts +45 -0
  25. package/src/engine/build-config-feature-schema.ts +228 -0
  26. package/src/engine/config-helpers.ts +31 -0
  27. package/src/engine/define-handler.ts +1 -0
  28. package/src/engine/entity-handlers.ts +7 -1
  29. package/src/engine/feature-manifest.ts +6 -7
  30. package/src/engine/index.ts +9 -0
  31. package/src/engine/pipeline.ts +1 -0
  32. package/src/engine/qualified-name.ts +1 -0
  33. package/src/engine/state-machine.ts +1 -0
  34. package/src/engine/steps/compute.ts +1 -1
  35. package/src/engine/steps/return.ts +1 -0
  36. package/src/engine/types/config.ts +65 -1
  37. package/src/engine/types/index.ts +3 -0
  38. package/src/engine/types/screen.ts +13 -0
  39. package/src/env/index.ts +1 -0
  40. package/src/errors/write-error-info.ts +2 -0
  41. package/src/es-ops/__tests__/context.integration.test.ts +33 -24
  42. package/src/es-ops/__tests__/runner.integration.test.ts +130 -86
  43. package/src/es-ops/context.ts +6 -4
  44. package/src/event-store/event-store.ts +3 -0
  45. package/src/files/file-handle.ts +1 -1
  46. package/src/jobs/job-runner.ts +4 -0
  47. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +84 -1
  48. package/src/migrations/index.ts +5 -0
  49. package/src/migrations/pending-rebuilds.ts +84 -10
  50. package/src/pipeline/dispatcher.ts +15 -7
  51. package/src/pipeline/lifecycle-pipeline.ts +4 -4
  52. package/src/stack/table-helpers.ts +1 -0
  53. package/src/time/tz-context.ts +3 -3
  54. package/src/utils/compare.ts +10 -0
  55. package/src/utils/ids.ts +1 -0
  56. package/src/utils/index.ts +1 -0
@@ -0,0 +1,228 @@
1
+ // Self-Populating Settings-Hub (config-provisioning Phase 2).
2
+ //
3
+ // Leitet aus den im Registry deklarierten Config-Keys automatisch die
4
+ // Settings-UI ab: pro Audience (scope) einen Parent-Nav, pro (Feature ×
5
+ // scope) einen configEdit-Screen + Child-Nav darunter. Kein manuelles
6
+ // r.screen/r.nav am App-Author.
7
+ //
8
+ // Sichtbar wird nur ein Key MIT `mask` (siehe ConfigKeyDefinition): mask ist
9
+ // die per-Key-Intent „user-facing Einstellung" und trägt zugleich das Label
10
+ // (mask.title, ein i18n-Key). Keys ohne mask sind internes Plumbing
11
+ // (ENV-provisioniert/computed) und erscheinen nicht.
12
+ //
13
+ // Die erzeugten Screens/Navs werden von buildAppSchema in die FeatureSchema
14
+ // des config-Features (featureName "config") gemerged — der Renderer
15
+ // qualifiziert die kurzen ids/refs mit "config". Daher hier durchweg KURZE
16
+ // ids/parent/screen-Refs (buildNavRegistrySliceForApp qualifiziert selbst).
17
+
18
+ import type { WorkspaceSchema } from "../ui-types";
19
+ import type { ConfigScope } from "./constants";
20
+ import {
21
+ createBooleanField,
22
+ createNumberField,
23
+ createSelectField,
24
+ createTextField,
25
+ } from "./factories";
26
+ import type { ConfigKeyDefinition } from "./types/config";
27
+ import type { Registry } from "./types/feature";
28
+ import type { FieldDefinition } from "./types/fields";
29
+ import type { AccessRule } from "./types/handlers";
30
+ import type { NavDefinition } from "./types/nav";
31
+ import type {
32
+ ConfigEditScreenDefinition,
33
+ EditFieldsSection,
34
+ ScreenDefinition,
35
+ } from "./types/screen";
36
+
37
+ // Namespace, unter dem buildAppSchema die generierten Screens/Navs einhängt
38
+ // (find-or-create FeatureSchema). MUSS gleich CONFIG_FEATURE aus dem config
39
+ // bundled-feature sein — framework kann das const nicht importieren (Richtung
40
+ // bundled-features → framework), darum hier gepinnt + Pin-Test bundled-seitig.
41
+ export const SETTINGS_HUB_FEATURE = "config";
42
+ // Eigene Workspace nur für workspace-mode-Apps (siehe buildAppSchema): Settings
43
+ // erscheinen als eigener Switcher-Eintrag, statt die kuratierten App-Workspaces
44
+ // zu verschmutzen. Apps ohne Workspaces zeigen die Navs über den no-filter-Pfad.
45
+ export const SETTINGS_HUB_WORKSPACE = "settings";
46
+
47
+ export type ConfigFeatureSchema = {
48
+ readonly screens: readonly ScreenDefinition[];
49
+ readonly navs: readonly NavDefinition[];
50
+ // Fertige Settings-Workspace mit qualifizierten navMembers. Nur present
51
+ // wenn mind. ein Key opt-in via mask hat; buildAppSchema hängt sie NUR an
52
+ // wenn die App bereits Workspaces nutzt (sonst kippt eine workspace-lose
53
+ // App in den Filter-Modus und verliert alle übrigen Navs).
54
+ readonly workspace?: WorkspaceSchema;
55
+ };
56
+
57
+ // Audience-Reihenfolge im Sidebar: Plattform vor Tenant vor Benutzer.
58
+ const SCOPE_ORDER: Record<ConfigScope, number> = { system: 10, tenant: 20, user: 30 };
59
+
60
+ type MaskedKey = {
61
+ readonly qn: string;
62
+ readonly feature: string;
63
+ readonly shortKey: string;
64
+ readonly def: ConfigKeyDefinition;
65
+ };
66
+
67
+ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema {
68
+ const masked = collectMaskedKeys(registry);
69
+ if (masked.length === 0) return { screens: [], navs: [] };
70
+
71
+ const screens: ScreenDefinition[] = [];
72
+ const navs: NavDefinition[] = [];
73
+
74
+ for (const scope of scopesPresent(masked)) {
75
+ const scopeKeys = masked.filter((k) => k.def.scope === scope);
76
+
77
+ // Audience-Parent: Gruppierungs-Knoten ohne Screen.
78
+ navs.push({
79
+ id: `audience-${scope}`,
80
+ label: `config.settings.${scope}`,
81
+ order: SCOPE_ORDER[scope],
82
+ access: unionEditAccess(scopeKeys.map((k) => k.def)),
83
+ });
84
+
85
+ for (const feature of featuresPresent(scopeKeys)) {
86
+ const group = scopeKeys.filter((k) => k.feature === feature);
87
+ const ordered = sortByMaskOrder(group);
88
+ const shortId = `${feature}-${scope}`;
89
+
90
+ screens.push(buildScreen(shortId, scope, feature, ordered));
91
+ navs.push({
92
+ id: shortId,
93
+ label: `${feature}.settings`,
94
+ parent: `audience-${scope}`,
95
+ screen: shortId,
96
+ order: minMaskOrder(group),
97
+ access: unionEditAccess(group.map((k) => k.def)),
98
+ });
99
+ }
100
+ }
101
+
102
+ return { screens, navs, workspace: buildSettingsWorkspace(navs, masked) };
103
+ }
104
+
105
+ // navMembers tragen die QUALIFIZIERTEN Nav-QNs (siehe build-app-schema.test:
106
+ // admin.navMembers === ["orders:nav:list", ...]). Die generierten Navs leben
107
+ // unter SETTINGS_HUB_FEATURE, also `config:nav:<shortId>`. Sortiert = stabile
108
+ // Landing-Screen-Wahl (firstNavScreenId iteriert navMembers der Reihe nach).
109
+ function buildSettingsWorkspace(
110
+ navs: readonly NavDefinition[],
111
+ masked: readonly MaskedKey[],
112
+ ): WorkspaceSchema {
113
+ const navMembers = navs.map((n) => `${SETTINGS_HUB_FEATURE}:nav:${n.id}`).sort();
114
+ return {
115
+ definition: {
116
+ id: SETTINGS_HUB_WORKSPACE,
117
+ label: "config.settings.title",
118
+ icon: "settings",
119
+ order: 1000,
120
+ // Union der Schreib-Rollen aller Hub-Keys — sonst sieht ein
121
+ // unprivilegierter User einen leeren "Settings"-Switcher-Eintrag.
122
+ access: unionEditAccess(masked.map((k) => k.def)),
123
+ },
124
+ navMembers,
125
+ };
126
+ }
127
+
128
+ function buildScreen(
129
+ shortId: string,
130
+ scope: ConfigScope,
131
+ feature: string,
132
+ keys: readonly MaskedKey[],
133
+ ): ConfigEditScreenDefinition {
134
+ const configKeys: Record<string, string> = {};
135
+ const fields: Record<string, FieldDefinition> = {};
136
+ const fieldLabels: Record<string, string> = {};
137
+ for (const k of keys) {
138
+ configKeys[k.shortKey] = k.qn;
139
+ fields[k.shortKey] = deriveField(k.def);
140
+ // mask is the visibility gate, so collectMaskedKeys guarantees it here.
141
+ if (k.def.mask) fieldLabels[k.shortKey] = k.def.mask.title;
142
+ }
143
+ const section: EditFieldsSection = {
144
+ title: `${feature}.settings`,
145
+ fields: keys.map((k) => k.shortKey),
146
+ };
147
+ return {
148
+ id: shortId,
149
+ type: "configEdit",
150
+ scope,
151
+ configKeys,
152
+ fields,
153
+ fieldLabels,
154
+ layout: { sections: [section] },
155
+ access: unionEditAccess(keys.map((k) => k.def)),
156
+ };
157
+ }
158
+
159
+ function deriveField(def: ConfigKeyDefinition): FieldDefinition {
160
+ switch (def.type) {
161
+ case "number":
162
+ return createNumberField();
163
+ case "boolean":
164
+ return createBooleanField();
165
+ case "select":
166
+ return def.options !== undefined && def.options.length > 0
167
+ ? createSelectField({ options: def.options })
168
+ : createTextField();
169
+ default:
170
+ return createTextField();
171
+ }
172
+ }
173
+
174
+ function collectMaskedKeys(registry: Registry): MaskedKey[] {
175
+ const out: MaskedKey[] = [];
176
+ for (const [qn, def] of registry.getAllConfigKeys()) {
177
+ // computed keys derive their value — there is no row to set, so a
178
+ // configEdit screen could not write them. Skip even when masked.
179
+ if (def.mask === undefined || def.computed !== undefined) continue;
180
+ const sep = qn.indexOf(":config:");
181
+ if (sep === -1) continue;
182
+ out.push({
183
+ qn,
184
+ feature: qn.slice(0, sep),
185
+ shortKey: qn.slice(sep + ":config:".length),
186
+ def,
187
+ });
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function scopesPresent(keys: readonly MaskedKey[]): ConfigScope[] {
193
+ const set = new Set<ConfigScope>(keys.map((k) => k.def.scope));
194
+ return [...set].sort((a, b) => (SCOPE_ORDER[a] ?? 0) - (SCOPE_ORDER[b] ?? 0));
195
+ }
196
+
197
+ function featuresPresent(keys: readonly MaskedKey[]): string[] {
198
+ return [...new Set(keys.map((k) => k.feature))].sort();
199
+ }
200
+
201
+ function sortByMaskOrder(keys: readonly MaskedKey[]): MaskedKey[] {
202
+ return [...keys].sort(
203
+ (a, b) => maskOrder(a) - maskOrder(b) || a.shortKey.localeCompare(b.shortKey),
204
+ );
205
+ }
206
+
207
+ function maskOrder(k: MaskedKey): number {
208
+ return k.def.mask?.order ?? 0;
209
+ }
210
+
211
+ function minMaskOrder(keys: readonly MaskedKey[]): number {
212
+ return Math.min(...keys.map(maskOrder));
213
+ }
214
+
215
+ // Der Hub ist zum Editieren — wer mindestens einen Key der Gruppe SCHREIBEN
216
+ // darf, sieht den Settings-Eintrag (write, nicht read). Das hält system-scope
217
+ // (write default `["system"]`) human-hidden bis der Autor write: SystemAdmin
218
+ // opt-int, und zeigt user-scope (write `all`) jedem. `all` lässt sich in
219
+ // AccessRule nur als openToAll ausdrücken; der Write bleibt server-seitig
220
+ // per Key gegated.
221
+ function unionEditAccess(defs: readonly ConfigKeyDefinition[]): AccessRule {
222
+ const roles = new Set<string>();
223
+ for (const def of defs) {
224
+ for (const role of def.access.write) roles.add(role);
225
+ }
226
+ if (roles.has("all")) return { openToAll: true };
227
+ return { roles: [...roles] };
228
+ }
@@ -1,9 +1,11 @@
1
1
  import type { ConfigScope } from "./constants";
2
2
  import type {
3
+ ConfigBacking,
3
4
  ConfigBounds,
4
5
  ConfigComputedFn,
5
6
  ConfigKeyDefinition,
6
7
  ConfigKeyType,
8
+ ConfigMask,
7
9
  ConfigSeedDef,
8
10
  ConfigValue,
9
11
  CreateSeedOptions,
@@ -43,6 +45,18 @@ export const access = {
43
45
  // `allowPerRequest` is conditional against `text`: text keys can never
44
46
  // opt in to per-request overrides (XSS/SQL/Shell risk). For other types
45
47
  // it's a plain boolean opt-in consumed by resolveConfigOrParam.
48
+ //
49
+ // Provisioning metadata (optional, available on every scope so a key never
50
+ // switches factory to gain it):
51
+ // `env` binds an ENV var as the boot-time default (the
52
+ // ENV→app-override bridge reads keyDef.env).
53
+ // `inheritedToTenant` default true; false redacts the inherited system
54
+ // value from tenant-side reads (e.g. SMTP creds).
55
+ // `backing` storage backing; "secrets" routes the key through the
56
+ // secrets store. backing×scope rules are enforced at
57
+ // boot, not by this type (secrets don't cascade).
58
+ // `mask` marks the key as a user-facing setting → the
59
+ // Settings-Hub derives its screen+nav. Absent = internal.
46
60
  type ConfigKeyOptions<T extends ConfigKeyType> = {
47
61
  write?: readonly string[];
48
62
  read?: readonly string[];
@@ -50,9 +64,18 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
50
64
  encrypted?: boolean;
51
65
  options?: readonly string[]; // for select type
52
66
  bounds?: T extends "number" ? ConfigBounds : never;
67
+ // Regex enforced at write (set.write) — only meaningful for text keys
68
+ // (never for the other type-tags). Use anchored + length-bounded patterns:
69
+ // the value is tenant-supplied, so an unbounded backtracking regex is a
70
+ // ReDoS vector. See ConfigKeyDefinition.pattern.
71
+ pattern?: T extends "text" ? { regex: string; flags?: string } : never;
53
72
  computed?: ConfigComputedFn<T>;
54
73
  allowPerRequest?: T extends "text" ? never : boolean;
55
74
  required?: boolean;
75
+ env?: string;
76
+ inheritedToTenant?: boolean;
77
+ backing?: ConfigBacking;
78
+ mask?: ConfigMask;
56
79
  };
57
80
 
58
81
  // --- Scope Defaults ---
@@ -82,9 +105,14 @@ function createConfigKey<T extends ConfigKeyType>(
82
105
  ...(opts.encrypted ? { encrypted: true } : {}),
83
106
  ...(opts.options ? { options: opts.options } : {}),
84
107
  bounds: opts.bounds as ConfigBounds | undefined, // @cast-boundary schema-walk
108
+ ...(opts.pattern ? { pattern: opts.pattern } : {}),
85
109
  computed: opts.computed,
86
110
  ...(opts.allowPerRequest === true ? { allowPerRequest: true } : {}),
87
111
  ...(opts.required === true ? { required: true } : {}),
112
+ ...(opts.env ? { env: opts.env } : {}),
113
+ ...(opts.inheritedToTenant === false ? { inheritedToTenant: false } : {}),
114
+ ...(opts.backing === "secrets" ? { backing: "secrets" } : {}),
115
+ ...(opts.mask ? { mask: opts.mask } : {}),
88
116
  };
89
117
  }
90
118
 
@@ -92,6 +120,7 @@ function createConfigKey<T extends ConfigKeyType>(
92
120
  // Generic on the type-tag so `r.config({keys})` can propagate it into the
93
121
  // returned `ConfigKeyHandle<T>` — that's what narrows `ctx.config(handle)`.
94
122
 
123
+ // @wrapper-known semantic-alias
95
124
  export function createTenantConfig<T extends ConfigKeyType>(
96
125
  type: T,
97
126
  opts?: ConfigKeyOptions<T>,
@@ -99,6 +128,7 @@ export function createTenantConfig<T extends ConfigKeyType>(
99
128
  return createConfigKey("tenant", type, opts);
100
129
  }
101
130
 
131
+ // @wrapper-known semantic-alias
102
132
  export function createSystemConfig<T extends ConfigKeyType>(
103
133
  type: T,
104
134
  opts?: ConfigKeyOptions<T>,
@@ -106,6 +136,7 @@ export function createSystemConfig<T extends ConfigKeyType>(
106
136
  return createConfigKey("system", type, opts);
107
137
  }
108
138
 
139
+ // @wrapper-known semantic-alias
109
140
  export function createUserConfig<T extends ConfigKeyType>(
110
141
  type: T,
111
142
  opts?: ConfigKeyOptions<T>,
@@ -122,6 +122,7 @@ export function defineWriteHandler<
122
122
 
123
123
  if ("perform" in def && def.perform !== undefined) {
124
124
  const performDef = def.perform;
125
+ // @wrapper-known semantic-alias
125
126
  const compiledHandler = async (
126
127
  event: WriteEvent<z.infer<TSchema>>,
127
128
  ctx: HandlerContext<TMap>,
@@ -261,6 +261,7 @@ export function defineEntityQueryHandler(
261
261
 
262
262
  type EntityHandlerOptions = { readonly access?: AccessRule };
263
263
 
264
+ // @wrapper-known semantic-alias
264
265
  export function defineEntityCreateHandler(
265
266
  entityName: string,
266
267
  entity: EntityDefinition,
@@ -269,6 +270,7 @@ export function defineEntityCreateHandler(
269
270
  return defineEntityWriteHandler(`${entityName}:create`, entity, options);
270
271
  }
271
272
 
273
+ // @wrapper-known semantic-alias
272
274
  export function defineEntityUpdateHandler(
273
275
  entityName: string,
274
276
  entity: EntityDefinition,
@@ -277,6 +279,7 @@ export function defineEntityUpdateHandler(
277
279
  return defineEntityWriteHandler(`${entityName}:update`, entity, options);
278
280
  }
279
281
 
282
+ // @wrapper-known semantic-alias
280
283
  export function defineEntityDeleteHandler(
281
284
  entityName: string,
282
285
  entity: EntityDefinition,
@@ -285,6 +288,7 @@ export function defineEntityDeleteHandler(
285
288
  return defineEntityWriteHandler(`${entityName}:delete`, entity, options);
286
289
  }
287
290
 
291
+ // @wrapper-known semantic-alias
288
292
  export function defineEntityRestoreHandler(
289
293
  entityName: string,
290
294
  entity: EntityDefinition,
@@ -293,6 +297,7 @@ export function defineEntityRestoreHandler(
293
297
  return defineEntityWriteHandler(`${entityName}:restore`, entity, options);
294
298
  }
295
299
 
300
+ // @wrapper-known semantic-alias
296
301
  export function defineEntityListHandler(
297
302
  entityName: string,
298
303
  entity: EntityDefinition,
@@ -301,6 +306,7 @@ export function defineEntityListHandler(
301
306
  return defineEntityQueryHandler(`${entityName}:list`, entity, options);
302
307
  }
303
308
 
309
+ // @wrapper-known semantic-alias
304
310
  export function defineEntityDetailHandler(
305
311
  entityName: string,
306
312
  entity: EntityDefinition,
@@ -359,7 +365,7 @@ export function defineProjectionQueryHandler(
359
365
  ctx.queryProjection(
360
366
  projectionQualifiedName,
361
367
  options?.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
362
- ),
368
+ ), // @wrapper-known semantic-alias
363
369
  ...(options?.access && { access: options.access }),
364
370
  };
365
371
  }
@@ -6,6 +6,7 @@
6
6
  // AST-Parser kann die imperativen Factory-Helper der bundled features nicht
7
7
  // lesen.
8
8
 
9
+ import { compareByCodepoint } from "../utils";
9
10
  import type { Registry } from "./types/feature";
10
11
 
11
12
  export type ManifestConfigKey = {
@@ -18,6 +19,10 @@ export type ManifestConfigKey = {
18
19
  readonly computed: boolean;
19
20
  readonly options: readonly string[] | null;
20
21
  readonly bounds: { readonly min?: number; readonly max?: number } | null;
22
+ // Serializable write-time validator for type="text" keys (hex/https/length).
23
+ // Carried into the manifest as ConfigKeyDefinition.pattern's JSDoc promises
24
+ // (feature-manifest, docgen) — was previously dropped by this serializer.
25
+ readonly pattern: { readonly regex: string; readonly flags?: string } | null;
21
26
  readonly writeRoles: readonly string[];
22
27
  readonly readRoles: readonly string[];
23
28
  };
@@ -59,13 +64,6 @@ export type FeatureManifest = {
59
64
 
60
65
  const CONFIG_SEGMENT = ":config:";
61
66
 
62
- // Codepoint order, not localeCompare: the manifest is serialized to byte-exact
63
- // JSON, and localeCompare's ordering depends on the runner's ICU locale → drift
64
- // between macOS-dev and Linux-CI (#330).
65
- function compareByCodepoint(a: string, b: string): number {
66
- return a < b ? -1 : a > b ? 1 : 0;
67
- }
68
-
69
67
  export type BuildManifestOptions = {
70
68
  /** Herkunfts-Beschreibung fürs Manifest (landet 1:1 im JSON). */
71
69
  readonly source: string;
@@ -102,6 +100,7 @@ export function buildManifestFromRegistry(
102
100
  computed: def.computed !== undefined,
103
101
  options: def.options ?? null,
104
102
  bounds: def.bounds ?? null,
103
+ pattern: def.pattern ?? null,
105
104
  writeRoles: def.access.write,
106
105
  readRoles: def.access.read,
107
106
  });
@@ -3,6 +3,12 @@
3
3
  export { hasAccess } from "./access";
4
4
  export { validateBoot } from "./boot-validator";
5
5
  export { buildAppSchema } from "./build-app-schema";
6
+ export type { ConfigFeatureSchema } from "./build-config-feature-schema";
7
+ export {
8
+ buildConfigFeatureSchema,
9
+ SETTINGS_HUB_FEATURE,
10
+ SETTINGS_HUB_WORKSPACE,
11
+ } from "./build-config-feature-schema";
6
12
  export { buildTarget } from "./build-target";
7
13
  export {
8
14
  access,
@@ -215,6 +221,7 @@ export type {
215
221
  ConcurrencyMode,
216
222
  ConfigAccessor,
217
223
  ConfigAccessorFactory,
224
+ ConfigBacking,
218
225
  ConfigCascade,
219
226
  ConfigCascadeLevel,
220
227
  ConfigDefinition,
@@ -223,8 +230,10 @@ export type {
223
230
  ConfigKeyDefinition,
224
231
  ConfigKeyHandle,
225
232
  ConfigKeyType,
233
+ ConfigMask,
226
234
  ConfigResolver,
227
235
  ConfigScope,
236
+ ConfigSecretsReader,
228
237
  ConfigSeedDef,
229
238
  ConfigStoredRow,
230
239
  ConfigStoredRowWithSource,
@@ -80,6 +80,7 @@ export function pipeline<TPayload = unknown, TData = unknown>(
80
80
 
81
81
  // Internal: invoked by run-pipeline.ts to materialise the step list.
82
82
  // Not exported from the engine barrel — pipeline-internal plumbing.
83
+ // @wrapper-known semantic-alias
83
84
  export function buildPipelineSteps<TPayload>(
84
85
  pipelineDef: PipelineDef<TPayload>,
85
86
  event: WriteEvent<TPayload>,
@@ -92,6 +92,7 @@ export function isValidQn(value: string): boolean {
92
92
  // True if `name` is a valid QN segment (lowercase letters, digits, dashes;
93
93
  // starts with a letter). Same rule as `QN_SEGMENT` — kept public so feature
94
94
  // registration can reject bad names at the source instead of at registry-boot.
95
+ // @wrapper-known semantic-alias
95
96
  export function isKebabSegment(name: string): boolean {
96
97
  return QN_SEGMENT.test(name);
97
98
  }
@@ -61,6 +61,7 @@ export function defineTransitions<const TMap extends Record<string, readonly str
61
61
  * nutzen. Beides ist erlaubt; die Method-Form auf dem Graph ist die
62
62
  * idiomatische API für neuen Code.
63
63
  */
64
+ // @wrapper-known semantic-alias
64
65
  export function guardTransition<TStates extends string>(
65
66
  transitions: TransitionGraph<TStates>,
66
67
  from: TStates,
@@ -27,7 +27,7 @@ defineStep<ComputeStepArgs, unknown>({
27
27
  kind: "compute",
28
28
  defaultFailureStrategy: "throw",
29
29
  resultKey: (args) => args.name,
30
- run: (args, ctx) => args.fn(ctx),
30
+ run: (args, ctx) => args.fn(ctx), // @wrapper-known semantic-alias
31
31
  });
32
32
 
33
33
  export function buildComputeStep<TResult>(
@@ -23,6 +23,7 @@ defineStep<ReturnStepArgs, WriteResult<unknown>>({
23
23
  kind: "return",
24
24
  defaultFailureStrategy: "throw",
25
25
  resultKey: () => RETURN_RESULT_KEY,
26
+ // @wrapper-known semantic-alias
26
27
  run: (args, ctx: PipelineCtx) => resolveRequired(args.resolver, ctx),
27
28
  });
28
29
 
@@ -66,6 +66,23 @@ export type ConfigComputedFn<T extends ConfigKeyType = ConfigKeyType> = (
66
66
  ctx: ConfigComputedContext,
67
67
  ) => Promise<ConfigValue<T>>;
68
68
 
69
+ // Storage-Backing eines provisionierten Config-Keys. "config" (Default) =
70
+ // config_values-Projektion mit voller Cascade (user→tenant→system→app→default).
71
+ // "secrets" = read_tenant_secrets (flach pro (tenant,key), AES-GCM-Envelope mit
72
+ // Rotation/Audit, KEINE Cascade) — nur sinnvoll für scope:system ohne
73
+ // Tenant-Override. Die backing×scope-Matrix erzwingt der boot-validator.
74
+ export type ConfigBacking = "config" | "secrets";
75
+
76
+ // Minimal read surface the config resolver needs to dispatch a
77
+ // backing="secrets" key to the secrets store, without coupling the engine
78
+ // types to the full SecretsContext. The app's `ctx.secrets` (a SecretsContext)
79
+ // is structurally assignable. Threaded per-call (not at resolver construction)
80
+ // because the resolver is framework-auto-created while `ctx.secrets` is
81
+ // app-provided — only the request context sees both.
82
+ export type ConfigSecretsReader = {
83
+ get(tenantId: TenantId, key: string): Promise<{ readonly reveal: () => string } | undefined>;
84
+ };
85
+
69
86
  export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
70
87
  readonly type: T;
71
88
  readonly default?: ConfigValue<T>;
@@ -74,6 +91,15 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
74
91
  readonly encrypted?: boolean;
75
92
  readonly options?: readonly string[];
76
93
  readonly bounds?: ConfigBounds;
94
+ // Per-key string-pattern validation for type="text". The value must match
95
+ // the regex at write time — set.write hard-rejects a mismatch with
96
+ // ValidationError("invalid_format"), same posture as bounds. Stored as a
97
+ // serializable {regex, flags} pair (not a RegExp/predicate) so it survives
98
+ // JSON like `bounds`/`options` (feature-manifest, docgen) and compiles per
99
+ // write via new RegExp. Keep patterns anchored + length-bounded: the value
100
+ // is tenant-supplied (untrusted), an unbounded catastrophic-backtracking
101
+ // regex applied to it would be a ReDoS vector.
102
+ readonly pattern?: { readonly regex: string; readonly flags?: string };
77
103
  readonly computed?: ConfigComputedFn<T>;
78
104
  // Per-Request opt-in. Default false — resolveConfigOrParam wirft für
79
105
  // Keys ohne diese Marke, auch wenn der Caller paramValue übergibt. Das
@@ -91,6 +117,34 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
91
117
  // config:query:readiness; keep in sync with the feature's requireNonEmpty
92
118
  // calls in its build-fn.
93
119
  readonly required?: boolean;
120
+
121
+ // --- Provisioning-Metadata (optional auf createTenant/System/UserConfig) ---
122
+ // ENV-Var-Name, dessen Wert beim Boot als app-override-Default dieses Keys
123
+ // gebrückt wird. Reiner Fallback — überschreibt keinen gesetzten Row.
124
+ readonly env?: string;
125
+ // false → der geerbte system-row-Wert wird für Tenant-Admins redigiert
126
+ // (cascade.query + values.query): der Tenant sieht weder den Wert noch dass
127
+ // er gesetzt ist, nur den eigenen Override. Greift quell-basiert auf jeden
128
+ // Wert aus der system-row — nicht scope-gebunden; typischer Fall ist ein
129
+ // scope:tenant Key, dessen Plattform-Default in der system-row liegt (SMTP-
130
+ // Creds). Default true = transparente Cascade.
131
+ readonly inheritedToTenant?: boolean;
132
+ // "config" (Default, volle Cascade) oder "secrets" (flach pro (tenant,key)).
133
+ readonly backing?: ConfigBacking;
134
+ // Markiert den Key als user-facing Einstellung: der Self-Populating
135
+ // Settings-Hub leitet daraus automatisch Screen+Nav-Eintrag ab (kein
136
+ // manuelles r.screen/r.nav). Fehlt `mask`, gilt der Key als internes
137
+ // Plumbing (ENV-provisioniert/computed) und erscheint NICHT im Hub.
138
+ readonly mask?: ConfigMask;
139
+ };
140
+
141
+ // Label-Träger für den Settings-Hub. `title` ist ein i18n-Key (kein Literal —
142
+ // Guard), `icon` ein Icon-Registry-Key für den Nav-Eintrag, `order` die
143
+ // Sortier-Gewichtung innerhalb seiner Audience-Gruppe.
144
+ export type ConfigMask = {
145
+ readonly title: string;
146
+ readonly icon?: string;
147
+ readonly order?: number;
94
148
  };
95
149
 
96
150
  export type ConfigDefinition = {
@@ -110,6 +164,10 @@ export type ConfigAccessor = {
110
164
  export type ConfigAccessorFactory = (deps: {
111
165
  readonly user: { readonly id: string; readonly tenantId: TenantId };
112
166
  readonly db: DbConnection | TenantDb;
167
+ // Present when the app wired `extraContext.secrets`. Lets the internal
168
+ // `ctx.config.get` read a backing="secrets" key transparently from the
169
+ // secrets store; absent → a backing="secrets" read throws loud.
170
+ readonly secrets?: ConfigSecretsReader;
113
171
  }) => ConfigAccessor;
114
172
 
115
173
  // Row shape returned by ConfigResolver.getAll — just enough for the
@@ -183,6 +241,7 @@ export type ConfigResolver = {
183
241
  tenantId: TenantId,
184
242
  userId: string,
185
243
  db: DbConnection | TenantDb,
244
+ secretsReader?: ConfigSecretsReader,
186
245
  ): Promise<string | number | boolean | undefined>;
187
246
 
188
247
  // Same cascade as get() but also reports which layer produced the value.
@@ -196,6 +255,7 @@ export type ConfigResolver = {
196
255
  tenantId: TenantId,
197
256
  userId: string,
198
257
  db: DbConnection | TenantDb,
258
+ secretsReader?: ConfigSecretsReader,
199
259
  ): Promise<ConfigValueWithSource>;
200
260
 
201
261
  getAll(
@@ -224,17 +284,21 @@ export type ConfigResolver = {
224
284
  tenantId: TenantId,
225
285
  userId: string,
226
286
  db: DbConnection | TenantDb,
287
+ secretsReader?: ConfigSecretsReader,
227
288
  ): Promise<ConfigCascade>;
228
289
 
229
290
  // Batch variant: resolves cascades for N keys in one DB round-trip.
230
291
  // keyDefs must contain definitions for every key in the keys array.
231
- // Returns a map of qualifiedKey → ConfigCascade.
292
+ // Returns a map of qualifiedKey → ConfigCascade. backing="secrets" keys
293
+ // resolve their system rung from the secrets store via secretsReader
294
+ // (one read each — they are system-only and rare).
232
295
  getCascadeBatch(
233
296
  keys: readonly string[],
234
297
  keyDefs: ReadonlyMap<string, ConfigKeyDefinition>,
235
298
  tenantId: TenantId,
236
299
  userId: string,
237
300
  db: DbConnection | TenantDb,
301
+ secretsReader?: ConfigSecretsReader,
238
302
  ): Promise<ReadonlyMap<string, ConfigCascade>>;
239
303
  };
240
304
 
@@ -12,6 +12,7 @@ export type {
12
12
  export type {
13
13
  ConfigAccessor,
14
14
  ConfigAccessorFactory,
15
+ ConfigBacking,
15
16
  ConfigBounds,
16
17
  ConfigCascade,
17
18
  ConfigCascadeLevel,
@@ -22,7 +23,9 @@ export type {
22
23
  ConfigKeyDefinition,
23
24
  ConfigKeyHandle,
24
25
  ConfigKeyType,
26
+ ConfigMask,
25
27
  ConfigResolver,
28
+ ConfigSecretsReader,
26
29
  ConfigSeedDef,
27
30
  ConfigStoredRow,
28
31
  ConfigStoredRowWithSource,
@@ -344,6 +344,13 @@ export type EntityEditScreenDefinition = {
344
344
  * (History-/Audit-Erhalt): unterdrückt den Löschen-Button im
345
345
  * Update-Form. */
346
346
  readonly allowDelete?: boolean;
347
+ /** Optionaler per-Field-Label-i18n-Key (Field-Name → Key), überschreibt
348
+ * die Default-Konvention `<feature>:entity:<entity>:field:<name>`.
349
+ * Primär für configEdit: dessen Pseudo-Entity `__config-edit__` hat
350
+ * keinen natürlichen Field-Namespace — der Settings-Hub injiziert hier
351
+ * das `mask.title`-Label des Config-Keys. Fehlt ein Eintrag, gilt die
352
+ * Konvention. */
353
+ readonly fieldLabels?: Readonly<Record<string, string>>;
347
354
  readonly slots?: ScreenSlots;
348
355
  readonly access?: AccessRule;
349
356
  };
@@ -470,6 +477,12 @@ export type ConfigEditScreenDefinition = {
470
477
  /** Layout: Sections mit Field-Refs. Identisch zu entityEdit/
471
478
  * actionForm. */
472
479
  readonly layout: EditLayout;
480
+ /** Optionaler per-Field-Label-i18n-Key (Field-Name → Key). Der
481
+ * Settings-Hub setzt hier `mask.title` des jeweiligen Config-Keys,
482
+ * damit das am Key deklarierte Label am generierten Feld erscheint —
483
+ * ohne es unter der `__config-edit__`-Konvention zu duplizieren.
484
+ * Fehlt ein Eintrag, gilt die Konvention. */
485
+ readonly fieldLabels?: Readonly<Record<string, string>>;
473
486
  /** i18n-key für den Submit-Button. Default: "kumiko.actions.save". */
474
487
  readonly submitLabel?: string;
475
488
  readonly slots?: ScreenSlots;
package/src/env/index.ts CHANGED
@@ -106,6 +106,7 @@ export function getDefaultValue(field: z.ZodType): unknown {
106
106
  return undefined;
107
107
  }
108
108
 
109
+ // @wrapper-known semantic-alias
109
110
  export function getFieldDescription(field: z.ZodType): string | undefined {
110
111
  return zodDescription(field);
111
112
  }