@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
package/src/db/dialect.ts CHANGED
@@ -337,10 +337,12 @@ function makeIndex(name: string, unique: boolean): IndexBuilder {
337
337
  };
338
338
  }
339
339
 
340
+ // @wrapper-known semantic-alias
340
341
  export function index(name: string): IndexBuilder {
341
342
  return makeIndex(name, false);
342
343
  }
343
344
 
345
+ // @wrapper-known semantic-alias
344
346
  export function uniqueIndex(name: string): IndexBuilder {
345
347
  return makeIndex(name, true);
346
348
  }
@@ -428,7 +430,7 @@ export function table<TCols extends ColumnMap>(
428
430
  const handle: ColumnHandle = {
429
431
  name: final.sqlName,
430
432
  pgType: final.pgType,
431
- getSQLType: () => pgTypeToSqlType(final.pgType),
433
+ getSQLType: () => pgTypeToSqlType(final.pgType), // @wrapper-known semantic-alias
432
434
  };
433
435
  handles[field] = handle;
434
436
  const meta: ColumnMeta = {
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { readFileSync, writeFileSync } from "node:fs";
19
19
 
20
+ import { compareByCodepoint } from "../utils";
20
21
  import type { ColumnMeta, EntityTableMeta, IndexMeta } from "./entity-table-meta";
21
22
  import { renderTableDdl } from "./render-ddl";
22
23
 
@@ -56,8 +57,11 @@ export type SchemaDiff = {
56
57
 
57
58
  export function snapshotFromMetas(metas: readonly EntityTableMeta[]): Snapshot {
58
59
  // Stable ordering by tableName so the snapshot.json diff in PRs is
59
- // meaningful (table-add isn't a noisy re-sort of everything).
60
- const sorted = [...metas].sort((a, b) => a.tableName.localeCompare(b.tableName));
60
+ // meaningful (table-add isn't a noisy re-sort of everything). Codepoint, not
61
+ // localeCompare: the snapshot is serialized to byte-exact JSON and the table
62
+ // order carries into the generated migration SQL — locale-dependent ordering
63
+ // would drift between macOS-dev and Linux-CI (#367).
64
+ const sorted = [...metas].sort((a, b) => compareByCodepoint(a.tableName, b.tableName));
61
65
  return {
62
66
  version: SNAPSHOT_VERSION,
63
67
  generatedAt: new Date().toISOString(),
@@ -112,6 +112,7 @@ export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
112
112
  }
113
113
 
114
114
  /** Head event id for lag metrics — alias for selectEventsHighWaterMark. */
115
+ // @wrapper-known semantic-alias
115
116
  export async function selectEventsHeadId(db: AnyDb): Promise<bigint> {
116
117
  return selectEventsHighWaterMark(db);
117
118
  }
@@ -1521,6 +1521,26 @@ describe("boot-validator", () => {
1521
1521
  ).toThrow(/Config-Key "shop:config:typo-here" ist in keiner Feature-Registry deklariert/);
1522
1522
  });
1523
1523
 
1524
+ test("camelCase-Quelle (multi-word) → kanonische kebab-QN matched, kein Throw", () => {
1525
+ // Regression: r.config registriert den camelCase-Objekt-Key
1526
+ // (brandingTitle), die echte QN ist aber `shop:config:branding-title`
1527
+ // (define-feature toKebab't beides). Ein configEdit-Screen, der die
1528
+ // kanonische kebab-QN referenziert, darf NICHT als "unbekannt" failen —
1529
+ // ohne qualifyEntityName im Validator stand dort die rohe camelCase-QN.
1530
+ const feature = defineFeature("shop", (r) => {
1531
+ r.config({ keys: { brandingTitle: createTenantConfig("text", { default: "" }) } });
1532
+ r.screen({
1533
+ id: "settings",
1534
+ type: "configEdit",
1535
+ scope: "tenant",
1536
+ configKeys: { title: "shop:config:branding-title" },
1537
+ fields: { title: { type: "text" } } as never,
1538
+ layout: { sections: [{ title: "Basics", fields: ["title"] }] } as never,
1539
+ });
1540
+ });
1541
+ expect(() => validateBoot([feature])).not.toThrow();
1542
+ });
1543
+
1524
1544
  test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
1525
1545
  // synthesizeConfigEditScreen reicht die layout 1:1 an RenderEdit weiter —
1526
1546
  // eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
@@ -2115,3 +2135,26 @@ describe("boot-validator", () => {
2115
2135
  });
2116
2136
  });
2117
2137
  });
2138
+
2139
+ describe("boot-validator — config key backing × scope", () => {
2140
+ test("rejects backing:secrets on a non-system scope (secrets do not cascade)", () => {
2141
+ const feature = defineFeature("billing", (r) => {
2142
+ r.config({ keys: { apiKey: createTenantConfig("text", { backing: "secrets" }) } });
2143
+ });
2144
+ expect(() => validateBoot([feature])).toThrow(/backing="secrets".*requires scope="system"/i);
2145
+ });
2146
+
2147
+ test("allows backing:secrets when system-scoped (dispatch is wired)", () => {
2148
+ const feature = defineFeature("billing", (r) => {
2149
+ r.config({ keys: { apiKey: createSystemConfig("text", { backing: "secrets" }) } });
2150
+ });
2151
+ expect(() => validateBoot([feature])).not.toThrow();
2152
+ });
2153
+
2154
+ test("a config key without secrets backing boots fine", () => {
2155
+ const feature = defineFeature("billing", (r) => {
2156
+ r.config({ keys: { apiKey: createSystemConfig("text") } });
2157
+ });
2158
+ expect(() => validateBoot([feature])).not.toThrow();
2159
+ });
2160
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
3
+ import { SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE } from "../build-config-feature-schema";
4
+ import { createSystemConfig, createTenantConfig } from "../config-helpers";
5
+ import { defineFeature } from "../define-feature";
6
+ import { createRegistry } from "../registry";
7
+
8
+ // A feature that opts a config key into the Settings-Hub via `mask`.
9
+ const billing = defineFeature("billing", (r) => {
10
+ r.config({
11
+ keys: {
12
+ stripeKey: createTenantConfig("text", { mask: { title: "billing.stripe-key" } }),
13
+ platformFee: createSystemConfig("number", { mask: { title: "billing.platform-fee" } }),
14
+ },
15
+ });
16
+ });
17
+
18
+ // A config key WITHOUT mask — internal plumbing, must not surface a hub.
19
+ const plain = defineFeature("plain", (r) => {
20
+ r.config({ keys: { secret: createSystemConfig("text", {}) } });
21
+ });
22
+
23
+ function configFeature(app: ReturnType<typeof buildAppSchema>) {
24
+ return app.features.filter((f) => f.featureName === SETTINGS_HUB_FEATURE);
25
+ }
26
+
27
+ describe("buildAppSchema — Settings-Hub wiring", () => {
28
+ test("app WITHOUT workspaces: hub screens/navs appear, app stays in no-filter mode (no flip)", () => {
29
+ const app = buildAppSchema(createRegistry([billing]));
30
+
31
+ // The decisive non-flip assertion: a workspace-less app must NOT gain
32
+ // workspaces — else the renderer flips into filter-mode and drops navs.
33
+ expect(app.workspaces).toBeUndefined();
34
+
35
+ const config = configFeature(app);
36
+ expect(config).toHaveLength(1); // exactly one FeatureSchema, no duplicate
37
+ const hub = config[0];
38
+ // billing has a tenant + a system masked key → two configEdit screens
39
+ expect(hub?.screens.map((s) => s.id).sort()).toEqual(["billing-system", "billing-tenant"]);
40
+ // audience parents (system, tenant) + the two children
41
+ expect(hub?.navs?.map((n) => n.id).sort()).toEqual([
42
+ "audience-system",
43
+ "audience-tenant",
44
+ "billing-system",
45
+ "billing-tenant",
46
+ ]);
47
+ });
48
+
49
+ test("app WITH workspaces: original workspaces kept + synthetic settings workspace appended", () => {
50
+ const shell = defineFeature("shell", (r) => {
51
+ r.screen({ id: "home", type: "entityList", entity: "thing", columns: ["label"] });
52
+ r.entity("thing", { fields: { label: { type: "text" } } });
53
+ r.nav({ id: "home", label: "Home", screen: "home" });
54
+ r.workspace({ id: "main", label: "Main", nav: ["shell:nav:home"] });
55
+ });
56
+
57
+ const app = buildAppSchema(createRegistry([shell, billing]));
58
+
59
+ expect(app.workspaces).toBeDefined();
60
+ const ids = app.workspaces?.map((w) => w.definition.id).sort();
61
+ expect(ids).toEqual(["main", SETTINGS_HUB_WORKSPACE]);
62
+
63
+ const settings = app.workspaces?.find((w) => w.definition.id === SETTINGS_HUB_WORKSPACE);
64
+ // navMembers are qualified QNs under the config namespace
65
+ expect(settings?.navMembers).toContain("config:nav:audience-tenant");
66
+ expect(settings?.navMembers).toContain("config:nav:billing-tenant");
67
+ // and the original workspace's members are untouched
68
+ const main = app.workspaces?.find((w) => w.definition.id === "main");
69
+ expect(main?.navMembers).toEqual(["shell:nav:home"]);
70
+ });
71
+
72
+ test("find-or-create: merges into an existing `config` feature instead of duplicating it", () => {
73
+ // Stand-in for the config bundled-feature: a real feature named "config"
74
+ // that already owns a screen/nav. The hub must fold INTO it.
75
+ const configBundled = defineFeature(SETTINGS_HUB_FEATURE, (r) => {
76
+ r.entity("config-value", { fields: { value: { type: "text" } } });
77
+ r.screen({ id: "existing", type: "entityList", entity: "config-value", columns: ["value"] });
78
+ r.nav({ id: "existing", label: "Existing", screen: "existing" });
79
+ });
80
+
81
+ const app = buildAppSchema(createRegistry([configBundled, billing]));
82
+
83
+ const config = configFeature(app);
84
+ expect(config).toHaveLength(1); // NOT two FeatureSchemas with name "config"
85
+ const hub = config[0];
86
+ // the pre-existing screen survives alongside the generated hub screens
87
+ expect(hub?.screens.map((s) => s.id)).toContain("existing");
88
+ expect(hub?.screens.map((s) => s.id)).toContain("billing-tenant");
89
+ expect(hub?.navs?.map((n) => n.id)).toContain("existing");
90
+ expect(hub?.navs?.map((n) => n.id)).toContain("audience-tenant");
91
+ });
92
+
93
+ test("non-breaking: config keys WITHOUT mask leave the schema hub-free", () => {
94
+ const app = buildAppSchema(createRegistry([plain]));
95
+ expect(app.workspaces).toBeUndefined();
96
+ const config = configFeature(app);
97
+ // the plain feature isn't named "config", so no config FeatureSchema is
98
+ // synthesized at all — the app is byte-identical to the pre-hub world.
99
+ expect(config).toHaveLength(0);
100
+ });
101
+
102
+ test("generated hub output stays JSON-safe (factory fields are pure literals)", () => {
103
+ const app = buildAppSchema(shellWith(billing));
104
+ expect(findNonJsonSafePath(app, "app")).toBeNull();
105
+ });
106
+ });
107
+
108
+ // Helper: an app that has a workspace AND the masked billing keys, so the
109
+ // JSON-safety walk covers the synthetic workspace + configEdit screens too.
110
+ function shellWith(masked: ReturnType<typeof defineFeature>) {
111
+ const shell = defineFeature("shell", (r) => {
112
+ r.entity("thing", { fields: { label: { type: "text" } } });
113
+ r.screen({ id: "home", type: "entityList", entity: "thing", columns: ["label"] });
114
+ r.nav({ id: "home", label: "Home", screen: "home" });
115
+ r.workspace({ id: "main", label: "Main", nav: ["shell:nav:home"] });
116
+ });
117
+ return createRegistry([shell, masked]);
118
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildConfigFeatureSchema } from "../build-config-feature-schema";
3
+ import { createSystemConfig, createTenantConfig, createUserConfig } from "../config-helpers";
4
+ import { defineFeature } from "../define-feature";
5
+ import { createRegistry } from "../registry";
6
+ import type { NavDefinition } from "../types/nav";
7
+ import type { ConfigEditScreenDefinition, ScreenDefinition } from "../types/screen";
8
+
9
+ // Two features declaring masked keys across all three scopes, plus two
10
+ // exclusion probes: an UNMASKED key (internal plumbing) and a computed+masked
11
+ // key (no row to set). Built through the real createRegistry so the qualified
12
+ // names come from the real qn(toKebab(...)) path, not a hand-written stub.
13
+ const billing = defineFeature("billing", (r) => {
14
+ r.config({
15
+ keys: {
16
+ stripeKey: createTenantConfig("text", { mask: { title: "billing.stripe-key", order: 1 } }),
17
+ currency: createTenantConfig("select", {
18
+ options: ["eur", "usd"],
19
+ mask: { title: "billing.currency", order: 2 },
20
+ }),
21
+ internalFlag: createTenantConfig("boolean", {}), // no mask → excluded
22
+ platformFee: createSystemConfig("number", { mask: { title: "billing.platform-fee" } }),
23
+ derived: createTenantConfig("number", {
24
+ computed: async () => 5,
25
+ mask: { title: "billing.derived" }, // masked BUT computed → excluded
26
+ }),
27
+ },
28
+ });
29
+ });
30
+
31
+ const notify = defineFeature("notify", (r) => {
32
+ r.config({
33
+ keys: {
34
+ fromAddress: createTenantConfig("text", { mask: { title: "notify.from" } }),
35
+ digest: createUserConfig("boolean", { mask: { title: "notify.digest" } }),
36
+ },
37
+ });
38
+ });
39
+
40
+ const schema = buildConfigFeatureSchema(createRegistry([billing, notify]));
41
+
42
+ function navById(id: string): NavDefinition | undefined {
43
+ return schema.navs.find((n) => n.id === id);
44
+ }
45
+ function configScreen(id: string): ConfigEditScreenDefinition {
46
+ const s: ScreenDefinition | undefined = schema.screens.find((x) => x.id === id);
47
+ if (!s || s.type !== "configEdit") throw new Error(`no configEdit screen "${id}"`);
48
+ return s;
49
+ }
50
+
51
+ describe("buildConfigFeatureSchema — structure", () => {
52
+ test("emits one audience parent per present scope + one child per (feature × scope)", () => {
53
+ // scopes present: tenant (stripe/currency/from), system (platform-fee), user (digest)
54
+ expect(navById("audience-system")).toBeDefined();
55
+ expect(navById("audience-tenant")).toBeDefined();
56
+ expect(navById("audience-user")).toBeDefined();
57
+
58
+ // children: billing-tenant, notify-tenant, billing-system, notify-user
59
+ expect(schema.screens.map((s) => s.id).sort()).toEqual([
60
+ "billing-system",
61
+ "billing-tenant",
62
+ "notify-tenant",
63
+ "notify-user",
64
+ ]);
65
+ expect(schema.navs).toHaveLength(7); // 3 audiences + 4 children
66
+ });
67
+
68
+ test("audience parents are grouping nodes (no screen) ordered system<tenant<user", () => {
69
+ const sys = navById("audience-system");
70
+ const ten = navById("audience-tenant");
71
+ const usr = navById("audience-user");
72
+ expect(sys?.screen).toBeUndefined();
73
+ expect(sys?.parent).toBeUndefined();
74
+ expect(ten?.label).toBe("config.settings.tenant");
75
+ expect((sys?.order ?? 0) < (ten?.order ?? 0) && (ten?.order ?? 0) < (usr?.order ?? 0)).toBe(
76
+ true,
77
+ );
78
+ });
79
+
80
+ test("child nav points to its screen under the right audience parent", () => {
81
+ const child = navById("billing-tenant");
82
+ expect(child?.parent).toBe("audience-tenant");
83
+ expect(child?.screen).toBe("billing-tenant");
84
+ expect(child?.label).toBe("billing.settings");
85
+ });
86
+
87
+ test("screen carries qualified configKeys, derived field types, and mask.title as fieldLabels", () => {
88
+ const s = configScreen("billing-tenant");
89
+ expect(s.scope).toBe("tenant");
90
+ expect(s.configKeys).toEqual({
91
+ "stripe-key": "billing:config:stripe-key",
92
+ currency: "billing:config:currency",
93
+ });
94
+ expect(s.fields["stripe-key"]?.type).toBe("text");
95
+ expect(s.fields["currency"]?.type).toBe("select");
96
+ expect((s.fields["currency"] as { options?: readonly string[] }).options).toEqual([
97
+ "eur",
98
+ "usd",
99
+ ]);
100
+ // mask.title flows to fieldLabels — the per-field label override the
101
+ // Settings-Hub relies on (no __config-edit__ convention duplication).
102
+ expect(s.fieldLabels).toEqual({
103
+ "stripe-key": "billing.stripe-key",
104
+ currency: "billing.currency",
105
+ });
106
+ });
107
+
108
+ test("fields are ordered by mask.order; section title is the feature group key", () => {
109
+ const s = configScreen("billing-tenant");
110
+ expect(s.layout.sections).toHaveLength(1);
111
+ const section = s.layout.sections[0];
112
+ expect(section && "title" in section ? section.title : undefined).toBe("billing.settings");
113
+ expect(section && "fields" in section ? section.fields : undefined).toEqual([
114
+ "stripe-key",
115
+ "currency",
116
+ ]);
117
+ });
118
+
119
+ test("excludes unmasked keys and computed keys", () => {
120
+ const s = configScreen("billing-tenant");
121
+ // internal-flag has no mask, derived is computed → neither appears anywhere
122
+ expect(Object.keys(s.configKeys)).not.toContain("internal-flag");
123
+ expect(Object.keys(s.configKeys)).not.toContain("derived");
124
+ const allConfigKeyValues = schema.screens.flatMap((x) =>
125
+ x.type === "configEdit" ? Object.values(x.configKeys) : [],
126
+ );
127
+ expect(allConfigKeyValues).not.toContain("billing:config:internal-flag");
128
+ expect(allConfigKeyValues).not.toContain("billing:config:derived");
129
+ });
130
+
131
+ test("access: union of write (edit) roles; an `all`-writable group collapses to openToAll", () => {
132
+ // billing tenant keys are createTenantConfig → write = admin roles
133
+ const billingTenant = configScreen("billing-tenant");
134
+ expect(billingTenant.access).toEqual({ roles: ["TenantAdmin", "Admin", "SystemAdmin"] });
135
+ // digest is createUserConfig → write access.all (["all"]) → openToAll
136
+ expect(configScreen("notify-user").access).toEqual({ openToAll: true });
137
+ });
138
+
139
+ test("returns empty (no workspace) when no key opts into the hub via mask", () => {
140
+ const plain = defineFeature("plain", (r) => {
141
+ r.config({ keys: { secret: createSystemConfig("text", {}) } });
142
+ });
143
+ const empty = buildConfigFeatureSchema(createRegistry([plain]));
144
+ expect(empty.screens).toHaveLength(0);
145
+ expect(empty.navs).toHaveLength(0);
146
+ expect(empty.workspace).toBeUndefined();
147
+ });
148
+
149
+ test("emits a settings workspace with qualified navMembers (config:nav:*) over every generated nav", () => {
150
+ const ws = schema.workspace;
151
+ expect(ws).toBeDefined();
152
+ expect(ws?.definition.id).toBe("settings");
153
+ expect(ws?.definition.label).toBe("config.settings.title");
154
+ expect(ws?.definition.default).toBeUndefined(); // never the login default
155
+ // every generated nav (parents + children) qualified under the config namespace
156
+ expect(ws?.navMembers).toEqual(schema.navs.map((n) => `config:nav:${n.id}`).sort());
157
+ });
158
+
159
+ test("workspace access is the union of write roles across all hub keys", () => {
160
+ // billing/notify tenant keys → admin write; notify-user digest → write all.
161
+ // Any `all`-writable key collapses the whole switcher entry to openToAll.
162
+ expect(schema.workspace?.definition.access).toEqual({ openToAll: true });
163
+ });
164
+
165
+ test("workspace access stays role-gated when no hub key is world-writable", () => {
166
+ const adminOnly = defineFeature("adminonly", (r) => {
167
+ r.config({ keys: { apiKey: createTenantConfig("text", { mask: { title: "a.key" } }) } });
168
+ });
169
+ const out = buildConfigFeatureSchema(createRegistry([adminOnly]));
170
+ expect(out.workspace?.definition.access).toEqual({
171
+ roles: ["TenantAdmin", "Admin", "SystemAdmin"],
172
+ });
173
+ });
174
+ });
@@ -99,6 +99,63 @@ describe("createUserConfig", () => {
99
99
  });
100
100
  });
101
101
 
102
+ describe("config helpers — provisioning metadata (env / inheritedToTenant / backing)", () => {
103
+ test("env name is carried; absent → field absent", () => {
104
+ const bridged = createSystemConfig("text", { env: "STRIPE_SECRET_KEY" });
105
+ expect(bridged.env).toBe("STRIPE_SECRET_KEY");
106
+ expect(createSystemConfig("text").env).toBeUndefined();
107
+ });
108
+
109
+ test("inheritedToTenant:false attaches; default/true is omitted", () => {
110
+ const hidden = createSystemConfig("text", { inheritedToTenant: false });
111
+ expect(hidden.inheritedToTenant).toBe(false);
112
+
113
+ expect(createSystemConfig("text").inheritedToTenant).toBeUndefined();
114
+ expect(
115
+ createSystemConfig("text", { inheritedToTenant: true }).inheritedToTenant,
116
+ ).toBeUndefined();
117
+ });
118
+
119
+ test("backing:secrets is carried; default config is omitted", () => {
120
+ const secrets = createSystemConfig("text", { backing: "secrets" });
121
+ expect(secrets.backing).toBe("secrets");
122
+ expect(createSystemConfig("text").backing).toBeUndefined();
123
+ expect(createSystemConfig("text", { backing: "config" }).backing).toBeUndefined();
124
+ });
125
+
126
+ test("provisioning fields live on every scope factory — no factory switch to gain them", () => {
127
+ // The whole point of folding them in: a tenant or user key gains an env
128
+ // binding (or redaction) by adding a field, never by switching factory.
129
+ expect(createTenantConfig("text", { env: "TENANT_VAR" }).env).toBe("TENANT_VAR");
130
+ expect(createUserConfig("boolean", { inheritedToTenant: false }).inheritedToTenant).toBe(false);
131
+ });
132
+
133
+ test("Stripe-shape: system + masked + hidden-from-tenant + env + secrets in one call", () => {
134
+ const key = createSystemConfig("text", {
135
+ env: "STRIPE_SECRET_KEY",
136
+ encrypted: true,
137
+ inheritedToTenant: false,
138
+ backing: "secrets",
139
+ required: true,
140
+ });
141
+ expect(key).toMatchObject({
142
+ type: "text",
143
+ scope: "system",
144
+ encrypted: true,
145
+ inheritedToTenant: false,
146
+ env: "STRIPE_SECRET_KEY",
147
+ backing: "secrets",
148
+ required: true,
149
+ });
150
+ });
151
+
152
+ test("@ts-expect-error: backing only accepts the ConfigBacking union", () => {
153
+ // @ts-expect-error — "vault" is not a ConfigBacking ("config" | "secrets")
154
+ const key = createSystemConfig("text", { backing: "vault" });
155
+ expect(key.type).toBe("text");
156
+ });
157
+ });
158
+
102
159
  // expectTypeOf + @ts-expect-error are the real assertions — they fail the
103
160
  // build if the helper generic widens. The expect() lines exist so the
104
161
  // fake-test guard sees runtime asserts and lint stays quiet on unused vars.
@@ -136,6 +136,30 @@ export function validateConfigKeyAllowPerRequest(feature: FeatureDefinition): vo
136
136
  }
137
137
  }
138
138
 
139
+ // --- Config key storage backing × scope matrix ---
140
+
141
+ export function validateConfigKeyBacking(feature: FeatureDefinition): void {
142
+ for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
143
+ if (keyDef.backing !== "secrets") continue;
144
+
145
+ // secrets storage is flat per (tenant, key) with no system→tenant cascade,
146
+ // so a tenant- or user-scoped secret could never inherit a system default.
147
+ // Permanent rule: secrets-backed keys must be system-scoped.
148
+ if (keyDef.scope !== "system") {
149
+ throw new Error(
150
+ `[Feature ${feature.name}] Config key "${keyName}" has backing="secrets" but scope="${keyDef.scope}" — secrets storage is flat per (tenant,key) and does not cascade; backing="secrets" requires scope="system"`,
151
+ );
152
+ }
153
+
154
+ // system-scoped backing="secrets" is wired end-to-end: reads dispatch
155
+ // through the resolver's secretsReader, writes through config:write:set/
156
+ // :reset into the secrets store (SYSTEM_TENANT_ID), masked in the query
157
+ // handlers. The runtime contract is that the app provides
158
+ // `extraContext.secrets` (+ a MasterKeyProvider) — a backing="secrets"
159
+ // read/write without it throws loud at request time, not silently.
160
+ }
161
+ }
162
+
139
163
  // --- Config key cross-feature reference validation ---
140
164
 
141
165
  export function validateConfigReads(
@@ -1,8 +1,10 @@
1
+ import { QnTypes, qualifyEntityName } from "../qualified-name";
1
2
  import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
2
3
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
3
4
  import {
4
5
  validateCircularDeps,
5
6
  validateConfigKeyAllowPerRequest,
7
+ validateConfigKeyBacking,
6
8
  validateConfigKeyBounds,
7
9
  validateConfigKeyComputed,
8
10
  validateConfigKeyRequired,
@@ -57,14 +59,18 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
57
59
 
58
60
  // Collect all config keys across features (for cross-feature reference validation)
59
61
  const allConfigKeys = new Set<string>();
60
- // Qualified config-key set für ConfigEditScreen-Validation. Format
61
- // wie in registry.ts: `<feature>:config:<short>`. allConfigKeys oben
62
- // nutzt das ältere `feature.short`-Format für validateConfigReads.
62
+ // Qualified config-key set für ConfigEditScreen-Validation. MUSS via
63
+ // qualifyEntityName kanonisiert werden (toKebab auf Feature + Key) — exakt
64
+ // wie define-feature/registry den QN bildet. Der rohe f.configKeys-Key ist
65
+ // der camelCase-Objekt-Key (`brandingTitle`), die echte QN aber
66
+ // `…:config:branding-title`; ohne toKebab failt jeder configEdit-Screen mit
67
+ // multi-word Config-Key fälschlich. allConfigKeys oben nutzt das ältere
68
+ // `feature.short`-Format für validateConfigReads.
63
69
  const allConfigKeyQns = new Set<string>();
64
70
  for (const f of features) {
65
71
  for (const key of Object.keys(f.configKeys)) {
66
72
  allConfigKeys.add(`${f.name}.${key}`);
67
- allConfigKeyQns.add(`${f.name}:config:${key}`);
73
+ allConfigKeyQns.add(qualifyEntityName(f.name, QnTypes.config, key));
68
74
  }
69
75
  }
70
76
 
@@ -136,6 +142,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
136
142
  validateConfigKeyRequired(feature);
137
143
  validateConfigKeyComputed(feature);
138
144
  validateConfigKeyAllowPerRequest(feature);
145
+ validateConfigKeyBacking(feature);
139
146
  validateOwnershipRules(feature, allClaimKeys, knownRoles);
140
147
  validateMultiStreamProjections(feature);
141
148
  validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, allConfigKeyQns);
@@ -26,6 +26,11 @@
26
26
  // `effectiveFeatures` Argument annehmen und über alle iterations filtern.
27
27
 
28
28
  import type { AppSchema, EntityDefinition, FeatureSchema, WorkspaceSchema } from "../ui-types";
29
+ import {
30
+ buildConfigFeatureSchema,
31
+ type ConfigFeatureSchema,
32
+ SETTINGS_HUB_FEATURE,
33
+ } from "./build-config-feature-schema";
29
34
  import type { Registry } from "./types/feature";
30
35
  import type { FieldDefinition } from "./types/fields";
31
36
 
@@ -59,6 +64,21 @@ export function buildAppSchema(registry: Registry): AppSchema {
59
64
  }
60
65
  }
61
66
 
67
+ // Self-Populating Settings-Hub: aus den deklarierten Config-Keys mit `mask`
68
+ // werden Screens/Navs (+ eine Workspace) abgeleitet und hier eingehängt —
69
+ // kein manuelles r.screen/r.nav am App-Author.
70
+ const appHadWorkspaces = workspaces.length > 0;
71
+ const generated = buildConfigFeatureSchema(registry);
72
+ if (generated.screens.length > 0) {
73
+ mergeSettingsHubIntoConfigFeature(features, generated);
74
+ // Flip-Schutz: die Workspace NUR anhängen wenn die App schon Workspaces
75
+ // nutzt. Bei einer workspace-losen App bleibt app.workspaces undefined →
76
+ // der Renderer zeigt alle Navs ungefiltert, die Hub-Navs inklusive.
77
+ if (appHadWorkspaces && generated.workspace !== undefined) {
78
+ workspaces.push(generated.workspace);
79
+ }
80
+ }
81
+
62
82
  const schema = {
63
83
  features,
64
84
  ...(workspaces.length > 0 && { workspaces }),
@@ -81,6 +101,31 @@ export function buildAppSchema(registry: Registry): AppSchema {
81
101
  return schema;
82
102
  }
83
103
 
104
+ // Hängt die generierten Hub-Screens/Navs an die config-FeatureSchema (qualified
105
+ // dann als config:screen:* / config:nav:*). Existiert sie noch nicht (config
106
+ // bundled-feature nicht gemountet), wird sie angelegt. find-or-create statt
107
+ // fixem Push verhindert eine zweite FeatureSchema mit demselben featureName.
108
+ function mergeSettingsHubIntoConfigFeature(
109
+ features: FeatureSchema[],
110
+ generated: ConfigFeatureSchema,
111
+ ): void {
112
+ const existing = features.find((f) => f.featureName === SETTINGS_HUB_FEATURE);
113
+ if (existing === undefined) {
114
+ features.push({
115
+ featureName: SETTINGS_HUB_FEATURE,
116
+ entities: {},
117
+ screens: generated.screens,
118
+ navs: generated.navs,
119
+ });
120
+ } else {
121
+ features[features.indexOf(existing)] = {
122
+ ...existing,
123
+ screens: [...existing.screens, ...generated.screens],
124
+ navs: [...(existing.navs ?? []), ...generated.navs],
125
+ };
126
+ }
127
+ }
128
+
84
129
  // PlatformComponent slots ({ react, native }) legitimately hold component
85
130
  // functions — JSON.stringify drops them at inject-time and the client
86
131
  // re-resolves by name, so the walker treats them as opaque.