@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.
Files changed (55) hide show
  1. package/package.json +1 -1
  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 +23 -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 +26 -0
  23. package/src/engine/boot-validator/index.ts +2 -0
  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 +25 -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 +1 -7
  30. package/src/engine/index.ts +8 -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 +35 -0
  37. package/src/engine/types/index.ts +2 -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/migrations/__tests__/pending-rebuilds.integration.test.ts +62 -1
  47. package/src/migrations/index.ts +1 -0
  48. package/src/migrations/pending-rebuilds.ts +48 -9
  49. package/src/pipeline/dispatcher.ts +5 -5
  50. package/src/pipeline/lifecycle-pipeline.ts +4 -4
  51. package/src/stack/table-helpers.ts +1 -0
  52. package/src/time/tz-context.ts +3 -3
  53. package/src/utils/compare.ts +10 -0
  54. package/src/utils/ids.ts +1 -0
  55. package/src/utils/index.ts +1 -0
@@ -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
  }
@@ -2115,3 +2115,26 @@ describe("boot-validator", () => {
2115
2115
  });
2116
2116
  });
2117
2117
  });
2118
+
2119
+ describe("boot-validator — config key backing × scope", () => {
2120
+ test("rejects backing:secrets on a non-system scope (secrets do not cascade)", () => {
2121
+ const feature = defineFeature("billing", (r) => {
2122
+ r.config({ keys: { apiKey: createTenantConfig("text", { backing: "secrets" }) } });
2123
+ });
2124
+ expect(() => validateBoot([feature])).toThrow(/backing="secrets".*requires scope="system"/i);
2125
+ });
2126
+
2127
+ test("rejects backing:secrets even system-scoped until the dispatch is wired (framework#333)", () => {
2128
+ const feature = defineFeature("billing", (r) => {
2129
+ r.config({ keys: { apiKey: createSystemConfig("text", { backing: "secrets" }) } });
2130
+ });
2131
+ expect(() => validateBoot([feature])).toThrow(/not yet wired.*333/i);
2132
+ });
2133
+
2134
+ test("a config key without secrets backing boots fine", () => {
2135
+ const feature = defineFeature("billing", (r) => {
2136
+ r.config({ keys: { apiKey: createSystemConfig("text") } });
2137
+ });
2138
+ expect(() => validateBoot([feature])).not.toThrow();
2139
+ });
2140
+ });
@@ -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,32 @@ 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
+ // The secrets read/write dispatch is not yet wired (framework#333). Until
155
+ // it lands, the value would silently persist in the config-values
156
+ // projection instead of the secrets store — losing the envelope-encryption,
157
+ // key-rotation and audit-on-read that backing="secrets" promises. Reject
158
+ // loudly rather than degrade to config-encrypted behind the declaration.
159
+ throw new Error(
160
+ `[Feature ${feature.name}] Config key "${keyName}" declares backing="secrets", but the secrets read/write dispatch is not yet wired (framework#333). Until then remove backing (the value would store as config-encrypted) or gate the feature behind #333.`,
161
+ );
162
+ }
163
+ }
164
+
139
165
  // --- Config key cross-feature reference validation ---
140
166
 
141
167
  export function validateConfigReads(
@@ -3,6 +3,7 @@ import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext"
3
3
  import {
4
4
  validateCircularDeps,
5
5
  validateConfigKeyAllowPerRequest,
6
+ validateConfigKeyBacking,
6
7
  validateConfigKeyBounds,
7
8
  validateConfigKeyComputed,
8
9
  validateConfigKeyRequired,
@@ -136,6 +137,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
136
137
  validateConfigKeyRequired(feature);
137
138
  validateConfigKeyComputed(feature);
138
139
  validateConfigKeyAllowPerRequest(feature);
140
+ validateConfigKeyBacking(feature);
139
141
  validateOwnershipRules(feature, allClaimKeys, knownRoles);
140
142
  validateMultiStreamProjections(feature);
141
143
  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.