@cosmicdrift/kumiko-framework 0.48.0 → 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/__tests__/feature-manifest.test.ts +45 -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 +4 -3
- 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
|
@@ -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
|
-
|
|
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.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ConfigScopes } from "../constants";
|
|
3
|
+
import { buildManifestFromRegistry, createRegistry, defineFeature } from "../index";
|
|
4
|
+
|
|
5
|
+
const boolKey = {
|
|
6
|
+
type: "boolean",
|
|
7
|
+
scope: ConfigScopes.system,
|
|
8
|
+
access: { read: ["anonymous"], write: ["anonymous"] },
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
describe("buildManifestFromRegistry — deterministic codepoint sort (#330)", () => {
|
|
12
|
+
// `bZeta` vs `balpha`: localeCompare orders these case-insensitively
|
|
13
|
+
// (balpha < bZeta), but a codepoint sort puts uppercase 'Z' (U+005A) ahead of
|
|
14
|
+
// lowercase 'a' (U+0061) — the two comparators DISAGREE. The manifest is
|
|
15
|
+
// serialized to byte-exact JSON and must not depend on the runner's ICU
|
|
16
|
+
// locale (macOS-dev vs Linux-CI). This assertion fails the instant anyone
|
|
17
|
+
// reverts buildManifestFromRegistry to localeCompare. (Feature names skip the
|
|
18
|
+
// kebab normalization that config/secret short-names go through, so they are
|
|
19
|
+
// the one place a case-based disagreement survives into the sorted output.)
|
|
20
|
+
test("features are ordered by codepoint name, not locale", () => {
|
|
21
|
+
const registry = createRegistry([
|
|
22
|
+
defineFeature("bZeta", () => {}),
|
|
23
|
+
defineFeature("balpha", () => {}),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const manifest = buildManifestFromRegistry(registry, { source: "test" });
|
|
27
|
+
|
|
28
|
+
expect(manifest.features.map((f) => f.name)).toEqual(["bZeta", "balpha"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("config keys within a feature come out sorted by qualified name", () => {
|
|
32
|
+
const feature = defineFeature("demo", (r) => {
|
|
33
|
+
r.config({ keys: { "z-flag": boolKey, "a-flag": boolKey } });
|
|
34
|
+
});
|
|
35
|
+
const registry = createRegistry([feature]);
|
|
36
|
+
|
|
37
|
+
const manifest = buildManifestFromRegistry(registry, { source: "test" });
|
|
38
|
+
const demo = manifest.features.find((f) => f.name === "demo");
|
|
39
|
+
|
|
40
|
+
expect(demo?.configKeys.map((k) => k.qualifiedName)).toEqual([
|
|
41
|
+
"demo:config:a-flag",
|
|
42
|
+
"demo:config:z-flag",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -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.
|