@cosmicdrift/kumiko-framework 0.48.1 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/__tests__/origin-middleware.integration.test.ts +132 -0
- package/src/api/__tests__/origin-middleware.test.ts +191 -0
- package/src/api/anonymous-cookie.ts +1 -0
- package/src/api/api-constants.ts +9 -0
- package/src/api/auth-middleware.ts +2 -0
- package/src/api/auth-routes.ts +29 -4
- package/src/api/csrf-middleware.ts +1 -4
- package/src/api/origin-middleware.ts +98 -0
- package/src/api/server.ts +20 -0
- package/src/bun-db/__tests__/_helpers.ts +1 -0
- package/src/bun-db/query.ts +1 -0
- package/src/db/__tests__/migrate-generator.test.ts +11 -0
- package/src/db/collect-table-metas.ts +2 -1
- package/src/db/dialect.ts +3 -1
- package/src/db/migrate-generator.ts +6 -2
- package/src/db/queries/event-store.ts +1 -0
- package/src/engine/__tests__/boot-validator.test.ts +23 -0
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +118 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +174 -0
- package/src/engine/__tests__/config-helpers.test.ts +57 -0
- package/src/engine/boot-validator/config-deps.ts +26 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/build-app-schema.ts +45 -0
- package/src/engine/build-config-feature-schema.ts +228 -0
- package/src/engine/config-helpers.ts +25 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/entity-handlers.ts +7 -1
- package/src/engine/feature-manifest.ts +1 -7
- package/src/engine/index.ts +8 -0
- package/src/engine/pipeline.ts +1 -0
- package/src/engine/qualified-name.ts +1 -0
- package/src/engine/state-machine.ts +1 -0
- package/src/engine/steps/compute.ts +1 -1
- package/src/engine/steps/return.ts +1 -0
- package/src/engine/types/config.ts +35 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/screen.ts +13 -0
- package/src/env/index.ts +1 -0
- package/src/errors/write-error-info.ts +2 -0
- package/src/es-ops/__tests__/context.integration.test.ts +33 -24
- package/src/es-ops/__tests__/runner.integration.test.ts +130 -86
- package/src/es-ops/context.ts +6 -4
- package/src/event-store/event-store.ts +3 -0
- package/src/files/file-handle.ts +1 -1
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +62 -1
- package/src/migrations/index.ts +1 -0
- package/src/migrations/pending-rebuilds.ts +48 -9
- package/src/pipeline/dispatcher.ts +5 -5
- package/src/pipeline/lifecycle-pipeline.ts +4 -4
- package/src/stack/table-helpers.ts +1 -0
- package/src/time/tz-context.ts +3 -3
- package/src/utils/compare.ts +10 -0
- package/src/utils/ids.ts +1 -0
- 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[];
|
|
@@ -53,6 +67,10 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
|
|
|
53
67
|
computed?: ConfigComputedFn<T>;
|
|
54
68
|
allowPerRequest?: T extends "text" ? never : boolean;
|
|
55
69
|
required?: boolean;
|
|
70
|
+
env?: string;
|
|
71
|
+
inheritedToTenant?: boolean;
|
|
72
|
+
backing?: ConfigBacking;
|
|
73
|
+
mask?: ConfigMask;
|
|
56
74
|
};
|
|
57
75
|
|
|
58
76
|
// --- Scope Defaults ---
|
|
@@ -85,6 +103,10 @@ function createConfigKey<T extends ConfigKeyType>(
|
|
|
85
103
|
computed: opts.computed,
|
|
86
104
|
...(opts.allowPerRequest === true ? { allowPerRequest: true } : {}),
|
|
87
105
|
...(opts.required === true ? { required: true } : {}),
|
|
106
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
107
|
+
...(opts.inheritedToTenant === false ? { inheritedToTenant: false } : {}),
|
|
108
|
+
...(opts.backing === "secrets" ? { backing: "secrets" } : {}),
|
|
109
|
+
...(opts.mask ? { mask: opts.mask } : {}),
|
|
88
110
|
};
|
|
89
111
|
}
|
|
90
112
|
|
|
@@ -92,6 +114,7 @@ function createConfigKey<T extends ConfigKeyType>(
|
|
|
92
114
|
// Generic on the type-tag so `r.config({keys})` can propagate it into the
|
|
93
115
|
// returned `ConfigKeyHandle<T>` — that's what narrows `ctx.config(handle)`.
|
|
94
116
|
|
|
117
|
+
// @wrapper-known semantic-alias
|
|
95
118
|
export function createTenantConfig<T extends ConfigKeyType>(
|
|
96
119
|
type: T,
|
|
97
120
|
opts?: ConfigKeyOptions<T>,
|
|
@@ -99,6 +122,7 @@ export function createTenantConfig<T extends ConfigKeyType>(
|
|
|
99
122
|
return createConfigKey("tenant", type, opts);
|
|
100
123
|
}
|
|
101
124
|
|
|
125
|
+
// @wrapper-known semantic-alias
|
|
102
126
|
export function createSystemConfig<T extends ConfigKeyType>(
|
|
103
127
|
type: T,
|
|
104
128
|
opts?: ConfigKeyOptions<T>,
|
|
@@ -106,6 +130,7 @@ export function createSystemConfig<T extends ConfigKeyType>(
|
|
|
106
130
|
return createConfigKey("system", type, opts);
|
|
107
131
|
}
|
|
108
132
|
|
|
133
|
+
// @wrapper-known semantic-alias
|
|
109
134
|
export function createUserConfig<T extends ConfigKeyType>(
|
|
110
135
|
type: T,
|
|
111
136
|
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 = {
|
|
@@ -59,13 +60,6 @@ export type FeatureManifest = {
|
|
|
59
60
|
|
|
60
61
|
const CONFIG_SEGMENT = ":config:";
|
|
61
62
|
|
|
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
63
|
export type BuildManifestOptions = {
|
|
70
64
|
/** Herkunfts-Beschreibung fürs Manifest (landet 1:1 im JSON). */
|
|
71
65
|
readonly source: string;
|
package/src/engine/index.ts
CHANGED
|
@@ -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,6 +230,7 @@ export type {
|
|
|
223
230
|
ConfigKeyDefinition,
|
|
224
231
|
ConfigKeyHandle,
|
|
225
232
|
ConfigKeyType,
|
|
233
|
+
ConfigMask,
|
|
226
234
|
ConfigResolver,
|
|
227
235
|
ConfigScope,
|
|
228
236
|
ConfigSeedDef,
|
package/src/engine/pipeline.ts
CHANGED
|
@@ -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,13 @@ 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
|
+
|
|
69
76
|
export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
|
|
70
77
|
readonly type: T;
|
|
71
78
|
readonly default?: ConfigValue<T>;
|
|
@@ -91,6 +98,34 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
|
|
|
91
98
|
// config:query:readiness; keep in sync with the feature's requireNonEmpty
|
|
92
99
|
// calls in its build-fn.
|
|
93
100
|
readonly required?: boolean;
|
|
101
|
+
|
|
102
|
+
// --- Provisioning-Metadata (optional auf createTenant/System/UserConfig) ---
|
|
103
|
+
// ENV-Var-Name, dessen Wert beim Boot als app-override-Default dieses Keys
|
|
104
|
+
// gebrückt wird. Reiner Fallback — überschreibt keinen gesetzten Row.
|
|
105
|
+
readonly env?: string;
|
|
106
|
+
// false → der geerbte system-row-Wert wird für Tenant-Admins redigiert
|
|
107
|
+
// (cascade.query + values.query): der Tenant sieht weder den Wert noch dass
|
|
108
|
+
// er gesetzt ist, nur den eigenen Override. Greift quell-basiert auf jeden
|
|
109
|
+
// Wert aus der system-row — nicht scope-gebunden; typischer Fall ist ein
|
|
110
|
+
// scope:tenant Key, dessen Plattform-Default in der system-row liegt (SMTP-
|
|
111
|
+
// Creds). Default true = transparente Cascade.
|
|
112
|
+
readonly inheritedToTenant?: boolean;
|
|
113
|
+
// "config" (Default, volle Cascade) oder "secrets" (flach pro (tenant,key)).
|
|
114
|
+
readonly backing?: ConfigBacking;
|
|
115
|
+
// Markiert den Key als user-facing Einstellung: der Self-Populating
|
|
116
|
+
// Settings-Hub leitet daraus automatisch Screen+Nav-Eintrag ab (kein
|
|
117
|
+
// manuelles r.screen/r.nav). Fehlt `mask`, gilt der Key als internes
|
|
118
|
+
// Plumbing (ENV-provisioniert/computed) und erscheint NICHT im Hub.
|
|
119
|
+
readonly mask?: ConfigMask;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Label-Träger für den Settings-Hub. `title` ist ein i18n-Key (kein Literal —
|
|
123
|
+
// Guard), `icon` ein Icon-Registry-Key für den Nav-Eintrag, `order` die
|
|
124
|
+
// Sortier-Gewichtung innerhalb seiner Audience-Gruppe.
|
|
125
|
+
export type ConfigMask = {
|
|
126
|
+
readonly title: string;
|
|
127
|
+
readonly icon?: string;
|
|
128
|
+
readonly order?: number;
|
|
94
129
|
};
|
|
95
130
|
|
|
96
131
|
export type ConfigDefinition = {
|
|
@@ -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,6 +23,7 @@ export type {
|
|
|
22
23
|
ConfigKeyDefinition,
|
|
23
24
|
ConfigKeyHandle,
|
|
24
25
|
ConfigKeyType,
|
|
26
|
+
ConfigMask,
|
|
25
27
|
ConfigResolver,
|
|
26
28
|
ConfigSeedDef,
|
|
27
29
|
ConfigStoredRow,
|
|
@@ -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
|
@@ -37,10 +37,12 @@ export function writeFailure(err: KumikoError): WriteFailure {
|
|
|
37
37
|
// (typed 404) and "business rule violated: REASON" (typed 422 with the reason
|
|
38
38
|
// string surfaced in details.reason). Reach for the concrete classes when you
|
|
39
39
|
// need richer payload — these two cover the bulk of handler code.
|
|
40
|
+
// @wrapper-known error-helper
|
|
40
41
|
export function failNotFound(entity: string, id?: number | string): WriteFailure {
|
|
41
42
|
return writeFailure(new NotFoundError(entity, id));
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
// @wrapper-known error-helper
|
|
44
46
|
export function failUnprocessable(
|
|
45
47
|
reason: string,
|
|
46
48
|
details?: Readonly<Record<string, unknown>>,
|