@cosmicdrift/kumiko-framework 0.234.0 → 0.235.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 +3 -3
- package/src/__tests__/unprocessable-error-details-reason-codemod.test.ts +205 -0
- package/src/api/server.ts +3 -0
- package/src/changes.json +8 -0
- package/src/db/queries/event-consumer.ts +20 -5
- package/src/engine/__tests__/build-app-schema.test.ts +48 -1
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +140 -0
- package/src/engine/boot-validator/i18n-keys.ts +13 -5
- package/src/engine/boot-validator/screens.ts +5 -0
- package/src/engine/build-app-schema.ts +14 -0
- package/src/engine/build-config-feature-schema.ts +216 -44
- package/src/engine/index.ts +2 -0
- package/src/engine/registry-validate.ts +16 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/index.ts +2 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/required-surface-keys.ts +11 -0
- package/src/i18n/settings-hub-keys.ts +28 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +127 -0
- package/src/jobs/job-runner.ts +37 -2
- package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +176 -0
- package/src/pipeline/event-dispatcher-delivery.ts +1 -1
- package/src/pipeline/event-dispatcher.ts +6 -0
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +273 -0
- package/src/testing/e2e-generator.ts +3 -0
- package/src/ui-types/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.235.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.235.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.235.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const SCRIPT_PATH = join(
|
|
7
|
+
import.meta.dir,
|
|
8
|
+
"../scripts/codemod/unprocessable-error-details-reason.ts",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
function makeFixtureDir(files: Record<string, string>): {
|
|
12
|
+
readonly dir: string;
|
|
13
|
+
readonly cleanup: () => void;
|
|
14
|
+
} {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), "unprocessable-error-codemod-"));
|
|
16
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
17
|
+
const full = join(dir, relPath);
|
|
18
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
19
|
+
writeFileSync(full, content, "utf-8");
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
dir,
|
|
23
|
+
cleanup: () => {
|
|
24
|
+
try {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore — best-effort
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function runCodemod(targetDir: string, extraArgs: readonly string[] = []): Promise<string> {
|
|
34
|
+
const proc = Bun.spawn({
|
|
35
|
+
cmd: ["bun", SCRIPT_PATH, targetDir, ...extraArgs],
|
|
36
|
+
stdout: "pipe",
|
|
37
|
+
stderr: "pipe",
|
|
38
|
+
});
|
|
39
|
+
const [stdout, stderr] = await Promise.all([
|
|
40
|
+
new Response(proc.stdout).text(),
|
|
41
|
+
new Response(proc.stderr).text(),
|
|
42
|
+
]);
|
|
43
|
+
await proc.exited;
|
|
44
|
+
return `${stdout}${stderr}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function read(dir: string, relPath: string): string {
|
|
48
|
+
return readFileSync(join(dir, relPath), "utf-8");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("unprocessable-error-details-reason codemod", () => {
|
|
52
|
+
let cleanup: (() => void) | undefined;
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
cleanup?.();
|
|
56
|
+
cleanup = undefined;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("removes reason from the middle of a details object literal", async () => {
|
|
60
|
+
const fixture = makeFixtureDir({
|
|
61
|
+
"guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
62
|
+
|
|
63
|
+
export function guard(spec: { field: string; code: string }, current: number, limit: number) {
|
|
64
|
+
throw new UnprocessableError(spec.code, {
|
|
65
|
+
i18nKey: "errors.cap",
|
|
66
|
+
details: { field: spec.field, reason: spec.code, current, limit },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
`,
|
|
70
|
+
});
|
|
71
|
+
cleanup = fixture.cleanup;
|
|
72
|
+
|
|
73
|
+
await runCodemod(fixture.dir);
|
|
74
|
+
|
|
75
|
+
const out = read(fixture.dir, "guard.ts");
|
|
76
|
+
expect(out).toContain("details: { field: spec.field, current, limit }");
|
|
77
|
+
expect(out).not.toContain("reason: spec.code");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("removes reason from the start of a details object literal", async () => {
|
|
81
|
+
const fixture = makeFixtureDir({
|
|
82
|
+
"guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
83
|
+
|
|
84
|
+
export function guard(error: { reason: string; actual: number; max: number }) {
|
|
85
|
+
throw new UnprocessableError("cap", {
|
|
86
|
+
details: { reason: error.reason, actual: error.actual, max: error.max },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
`,
|
|
90
|
+
});
|
|
91
|
+
cleanup = fixture.cleanup;
|
|
92
|
+
|
|
93
|
+
await runCodemod(fixture.dir);
|
|
94
|
+
|
|
95
|
+
const out = read(fixture.dir, "guard.ts");
|
|
96
|
+
expect(out).toContain("details: { actual: error.actual, max: error.max }");
|
|
97
|
+
expect(out).not.toContain("reason: error.reason");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("removes reason when it is the only property, leaving a valid empty object literal", async () => {
|
|
101
|
+
const fixture = makeFixtureDir({
|
|
102
|
+
"guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
103
|
+
|
|
104
|
+
export function guard() {
|
|
105
|
+
throw new UnprocessableError("apex", {
|
|
106
|
+
details: { reason: "no_tenant_context_on_apex" },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
`,
|
|
110
|
+
});
|
|
111
|
+
cleanup = fixture.cleanup;
|
|
112
|
+
|
|
113
|
+
await runCodemod(fixture.dir);
|
|
114
|
+
|
|
115
|
+
const out = read(fixture.dir, "guard.ts");
|
|
116
|
+
expect(out).not.toContain("reason:");
|
|
117
|
+
expect(out).toMatch(/details:\s*\{\s*\}/);
|
|
118
|
+
|
|
119
|
+
// The result must still be syntactically valid TypeScript — TS2307
|
|
120
|
+
// (module not found) is expected and irrelevant here: there is no real
|
|
121
|
+
// node_modules in an in-memory project. Anything else is a real error.
|
|
122
|
+
const { Project } = await import("ts-morph");
|
|
123
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: true });
|
|
124
|
+
const sourceFile = project.createSourceFile("guard.ts", out);
|
|
125
|
+
const diagnostics = sourceFile.getPreEmitDiagnostics().filter((d) => d.getCode() !== 2307);
|
|
126
|
+
expect(diagnostics.length).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("follows a renamed import (UnprocessableError as X)", async () => {
|
|
130
|
+
const fixture = makeFixtureDir({
|
|
131
|
+
"guard.ts": `import { UnprocessableError as MyError } from "@cosmicdrift/kumiko-framework/errors";
|
|
132
|
+
|
|
133
|
+
export function guard() {
|
|
134
|
+
throw new MyError("x", { details: { reason: "y", other: 1 } });
|
|
135
|
+
}
|
|
136
|
+
`,
|
|
137
|
+
});
|
|
138
|
+
cleanup = fixture.cleanup;
|
|
139
|
+
|
|
140
|
+
const output = await runCodemod(fixture.dir);
|
|
141
|
+
|
|
142
|
+
const out = read(fixture.dir, "guard.ts");
|
|
143
|
+
expect(out).toContain("details: { other: 1 }");
|
|
144
|
+
expect(out).not.toContain("reason:");
|
|
145
|
+
expect(output).toContain("removed 1 redundant");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("does not touch a locally defined class with the same name", async () => {
|
|
149
|
+
const fixture = makeFixtureDir({
|
|
150
|
+
"guard.ts": `class UnprocessableError extends Error {}
|
|
151
|
+
|
|
152
|
+
export function guard() {
|
|
153
|
+
throw new UnprocessableError("local", { details: { reason: "y" } } as never);
|
|
154
|
+
}
|
|
155
|
+
`,
|
|
156
|
+
});
|
|
157
|
+
cleanup = fixture.cleanup;
|
|
158
|
+
|
|
159
|
+
const before = read(fixture.dir, "guard.ts");
|
|
160
|
+
const output = await runCodemod(fixture.dir);
|
|
161
|
+
const after = read(fixture.dir, "guard.ts");
|
|
162
|
+
|
|
163
|
+
expect(after).toBe(before);
|
|
164
|
+
expect(output).toContain("locally defined class");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("skips a details object literal containing a spread", async () => {
|
|
168
|
+
const fixture = makeFixtureDir({
|
|
169
|
+
"guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
170
|
+
|
|
171
|
+
export function guard(rest: Record<string, unknown>) {
|
|
172
|
+
throw new UnprocessableError("x", { details: { ...rest, reason: "y" } });
|
|
173
|
+
}
|
|
174
|
+
`,
|
|
175
|
+
});
|
|
176
|
+
cleanup = fixture.cleanup;
|
|
177
|
+
|
|
178
|
+
const before = read(fixture.dir, "guard.ts");
|
|
179
|
+
const output = await runCodemod(fixture.dir);
|
|
180
|
+
const after = read(fixture.dir, "guard.ts");
|
|
181
|
+
|
|
182
|
+
expect(after).toBe(before);
|
|
183
|
+
expect(output).toContain("spread");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("--dry-run writes nothing", async () => {
|
|
187
|
+
const fixture = makeFixtureDir({
|
|
188
|
+
"guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
189
|
+
|
|
190
|
+
export function guard() {
|
|
191
|
+
throw new UnprocessableError("x", { details: { reason: "y", other: 1 } });
|
|
192
|
+
}
|
|
193
|
+
`,
|
|
194
|
+
});
|
|
195
|
+
cleanup = fixture.cleanup;
|
|
196
|
+
|
|
197
|
+
const before = read(fixture.dir, "guard.ts");
|
|
198
|
+
const output = await runCodemod(fixture.dir, ["--dry-run"]);
|
|
199
|
+
const after = read(fixture.dir, "guard.ts");
|
|
200
|
+
|
|
201
|
+
expect(after).toBe(before);
|
|
202
|
+
expect(output).toContain("dry-run");
|
|
203
|
+
expect(output).toContain("removed 1 redundant");
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/api/server.ts
CHANGED
|
@@ -506,6 +506,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
506
506
|
// Default (shared) is applied inside event-dispatcher, so omitting when
|
|
507
507
|
// the MSP didn't declare one keeps the existing behaviour.
|
|
508
508
|
...(msp.delivery && { delivery: msp.delivery }),
|
|
509
|
+
// Carry the MSP's declared cursor-seed through to the consumer. Default
|
|
510
|
+
// (beginning) is applied inside insertConsumerIfAbsent.
|
|
511
|
+
...(msp.startFrom && { startFrom: msp.startFrom }),
|
|
509
512
|
handler: async (event, ctx) => {
|
|
510
513
|
const applyFn = msp.apply[event.type];
|
|
511
514
|
// skip: this MSP doesn't care about this event type — fast path,
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.235.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "UnprocessableOpts.details can no longer carry its own `reason` key (fw#2460).",
|
|
6
|
+
"detail": "UnprocessableError builds its `details` as `{ ...opts?.details, reason }`, so `reason` was always owned by the ctor's first positional argument — but nothing stopped a caller from also putting `reason` inside `opts.details`, where it was silently overwritten. `UnprocessableOpts.details` is now typed `Readonly<Record<string, unknown>> & { readonly reason?: never }`, so that redundant key is now a compile-time error (TS2322) instead of a silent no-op. This was shipped in #2460 without a changeset or changelog entry, which is what this entry retroactively fixes.",
|
|
7
|
+
"migration": "Remove `reason` from any `details: { ... }` object literal passed to `new UnprocessableError(reason, { details: { ...} })` — the value is unchanged, it now flows only through the first positional argument. Run the codemod, or delete the property by hand where it isn't statically removable (the codemod reports those sites with file:line and a reason).",
|
|
8
|
+
"codemod": "scripts/codemod/unprocessable-error-details-reason.ts"
|
|
9
|
+
},
|
|
2
10
|
{
|
|
3
11
|
"version": "0.209.1",
|
|
4
12
|
"type": "breaking",
|
|
@@ -6,16 +6,31 @@ export async function lockEventConsumersShareMode(db: AnyDb): Promise<void> {
|
|
|
6
6
|
await asRawClient(db).unsafe(`LOCK TABLE "kumiko_event_consumers" IN SHARE MODE`);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// startFrom "now" seeds the FIRST-registration cursor at the current
|
|
10
|
+
// MAX(events.id) instead of the column default 0 — mounting a consumer into
|
|
11
|
+
// an existing app then skips the historical log instead of replaying it.
|
|
12
|
+
// ON CONFLICT DO NOTHING keeps both branches safe for an existing row: the
|
|
13
|
+
// subquery only ever affects the row this statement inserts.
|
|
9
14
|
export async function insertConsumerIfAbsent(
|
|
10
15
|
db: AnyDb,
|
|
11
16
|
name: string,
|
|
12
17
|
instanceId: string,
|
|
18
|
+
startFrom: "beginning" | "now" = "beginning",
|
|
13
19
|
): Promise<void> {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
if (startFrom === "now") {
|
|
21
|
+
await asRawClient(db).unsafe(
|
|
22
|
+
`INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status", "last_processed_event_id")
|
|
23
|
+
VALUES ($1, $2, 'idle', COALESCE((SELECT MAX("id") FROM "kumiko_events"), 0))
|
|
24
|
+
ON CONFLICT ("name", "instance_id") DO NOTHING`,
|
|
25
|
+
[name, instanceId],
|
|
26
|
+
);
|
|
27
|
+
} else {
|
|
28
|
+
await asRawClient(db).unsafe(
|
|
29
|
+
`INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status") VALUES ($1, $2, 'idle')
|
|
30
|
+
ON CONFLICT ("name", "instance_id") DO NOTHING`,
|
|
31
|
+
[name, instanceId],
|
|
32
|
+
);
|
|
33
|
+
}
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
export async function selectConsumerForUpdateSkipLocked(
|
|
@@ -589,8 +589,9 @@ describe("buildAppSchema", () => {
|
|
|
589
589
|
type: "embedded",
|
|
590
590
|
multiple: true,
|
|
591
591
|
schema: {
|
|
592
|
-
qty: { type: "number" },
|
|
592
|
+
qty: { type: "number", required: true },
|
|
593
593
|
amount: { type: "money" },
|
|
594
|
+
status: { type: "select", options: ["open", "closed"] },
|
|
594
595
|
},
|
|
595
596
|
minItems: 1,
|
|
596
597
|
maxItems: 20,
|
|
@@ -618,6 +619,14 @@ describe("buildAppSchema", () => {
|
|
|
618
619
|
});
|
|
619
620
|
expect(fields["lines"]?.["totals"]).toEqual(["amount"]);
|
|
620
621
|
expect(fields["lines"]?.["totalsMatch"]).toEqual({ amount: "invoiceTotal" });
|
|
622
|
+
// fw#2507: `schema` itself — the embedded list's sub-field map — must
|
|
623
|
+
// survive too, or computeEditViewModel iterates `undefined` and the
|
|
624
|
+
// browser crashes on any entityEdit screen with an embedded list.
|
|
625
|
+
const schema = fields["lines"]?.["schema"] as Record<string, Record<string, unknown>>;
|
|
626
|
+
expect(Object.keys(schema)).toEqual(["qty", "amount", "status"]);
|
|
627
|
+
expect(schema["qty"]).toEqual({ type: "number", required: true });
|
|
628
|
+
expect(schema["amount"]).toEqual({ type: "money" });
|
|
629
|
+
expect(schema["status"]).toEqual({ type: "select", options: ["open", "closed"] });
|
|
621
630
|
});
|
|
622
631
|
|
|
623
632
|
test("embedded: derived/totalsMatch mit Function eine Ebene tief bleiben blockiert (fw#2497)", () => {
|
|
@@ -655,6 +664,44 @@ describe("buildAppSchema", () => {
|
|
|
655
664
|
expect(fields["multi"]?.["multiline"]).toBeUndefined();
|
|
656
665
|
});
|
|
657
666
|
|
|
667
|
+
test("embedded: schema übersteht die Projection, ein Function-Wert eines Sub-Fields wird aber gedroppt (fw#2507)", () => {
|
|
668
|
+
// Regression: `schema` — the embedded list's sub-field map — was never
|
|
669
|
+
// forwarded at all, crashing computeEditViewModel client-side
|
|
670
|
+
// (Object.entries(undefined)). It's projected recursively per sub-field
|
|
671
|
+
// (through projectField again), not via a blanket copy: a sub-field
|
|
672
|
+
// carrying a non-JSON-safe value (e.g. a validator/renderer function)
|
|
673
|
+
// must lose only that value, not the whole schema map.
|
|
674
|
+
const entity = {
|
|
675
|
+
fields: {
|
|
676
|
+
lines: {
|
|
677
|
+
type: "embedded",
|
|
678
|
+
multiple: true,
|
|
679
|
+
schema: {
|
|
680
|
+
qty: { type: "number", required: true, default: () => 1 },
|
|
681
|
+
amount: { type: "money" },
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
} as unknown as EntityDefinition;
|
|
686
|
+
|
|
687
|
+
const f = defineFeature("ent", (r) => {
|
|
688
|
+
r.entity("thing", entity);
|
|
689
|
+
});
|
|
690
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
691
|
+
const fields = (
|
|
692
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
693
|
+
fields: Record<string, Record<string, unknown>>;
|
|
694
|
+
}
|
|
695
|
+
).fields;
|
|
696
|
+
|
|
697
|
+
const schema = fields["lines"]?.["schema"] as Record<string, Record<string, unknown>>;
|
|
698
|
+
expect(schema).toBeDefined();
|
|
699
|
+
expect(schema["qty"]?.["type"]).toBe("number");
|
|
700
|
+
expect(schema["qty"]?.["required"]).toBe(true);
|
|
701
|
+
expect(schema["qty"]?.["default"]).toBeUndefined();
|
|
702
|
+
expect(schema["amount"]).toEqual({ type: "money" });
|
|
703
|
+
});
|
|
704
|
+
|
|
658
705
|
test("default: JSON-safe Arrays/Objects überleben die Projection, Nicht-JSON-Werte bleiben blockiert (fw#2497)", () => {
|
|
659
706
|
// isJsonSafeValue() now recurses into arrays/plain objects instead of
|
|
660
707
|
// only accepting string/number/boolean/null — but the defense-in-depth
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { buildConfigFeatureSchema } from "../build-config-feature-schema";
|
|
4
|
+
import { defineFeature } from "../define-feature";
|
|
5
|
+
import { createRegistry } from "../registry";
|
|
6
|
+
import type { NavDefinition } from "../types/nav";
|
|
7
|
+
import type { ScreenDefinition, SecretsEditScreenDefinition } from "../types/screen";
|
|
8
|
+
|
|
9
|
+
// Mirrors the real "secrets" bundled feature just enough to exercise the
|
|
10
|
+
// generator: a single writeHandler "set" is all buildConfigFeatureSchema
|
|
11
|
+
// checks for (registry.getWriteHandler("secrets:write:set")) to decide the
|
|
12
|
+
// secrets screen is "mounted" — the plaintext-storage internals are
|
|
13
|
+
// irrelevant to derivation and stay out of this test.
|
|
14
|
+
function secretsFeature(roles: readonly string[] = ["TenantAdmin", "Admin", "SystemAdmin"]) {
|
|
15
|
+
return defineFeature("secrets", (r) => {
|
|
16
|
+
r.writeHandler(
|
|
17
|
+
"set",
|
|
18
|
+
z.object({ key: z.string(), value: z.string() }),
|
|
19
|
+
async () => ({ isSuccess: true, data: null }),
|
|
20
|
+
{ access: { roles: [...roles] } },
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function secretsScreen(
|
|
26
|
+
schema: ReturnType<typeof buildConfigFeatureSchema>,
|
|
27
|
+
): SecretsEditScreenDefinition {
|
|
28
|
+
const s: ScreenDefinition | undefined = schema.screens.find((x) => x.id === "secrets");
|
|
29
|
+
if (s?.type !== "secretsEdit") throw new Error('no "secretsEdit" screen "secrets"');
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function navById(
|
|
34
|
+
schema: ReturnType<typeof buildConfigFeatureSchema>,
|
|
35
|
+
id: string,
|
|
36
|
+
): NavDefinition | undefined {
|
|
37
|
+
return schema.navs.find((n) => n.id === id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("buildConfigFeatureSchema — secrets derivation", () => {
|
|
41
|
+
test("mounted secrets feature + declared r.secret() yields exactly one secretsEdit screen", () => {
|
|
42
|
+
const stripe = defineFeature("stripe", (r) => {
|
|
43
|
+
r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant", required: true });
|
|
44
|
+
r.secret("webhookSecret", {
|
|
45
|
+
label: { en: "Stripe Webhook Secret" },
|
|
46
|
+
hint: { en: "Found in the Stripe dashboard" },
|
|
47
|
+
scope: "tenant",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(), stripe]));
|
|
51
|
+
|
|
52
|
+
expect(schema.screens.filter((s) => s.type === "secretsEdit")).toHaveLength(1);
|
|
53
|
+
const screen = secretsScreen(schema);
|
|
54
|
+
expect(screen.secretKeys).toEqual({
|
|
55
|
+
"stripe-api-key": "stripe:secret:api-key",
|
|
56
|
+
"stripe-webhook-secret": "stripe:secret:webhook-secret",
|
|
57
|
+
});
|
|
58
|
+
expect(screen.fieldLabels).toEqual({
|
|
59
|
+
"stripe-api-key": "config.secret.stripe.api-key.label",
|
|
60
|
+
"stripe-webhook-secret": "config.secret.stripe.webhook-secret.label",
|
|
61
|
+
});
|
|
62
|
+
expect(screen.fieldHints).toEqual({
|
|
63
|
+
"stripe-webhook-secret": "config.secret.stripe.webhook-secret.hint",
|
|
64
|
+
});
|
|
65
|
+
expect(screen.requiredFields).toEqual(["stripe-api-key"]);
|
|
66
|
+
expect(schema.translations).toEqual({
|
|
67
|
+
"config.secret.stripe.api-key.label": { en: "Stripe API Key" },
|
|
68
|
+
"config.secret.stripe.webhook-secret.label": { en: "Stripe Webhook Secret" },
|
|
69
|
+
"config.secret.stripe.webhook-secret.hint": { en: "Found in the Stripe dashboard" },
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("sections group by declaring feature; title only when '<feature>.settings' is a declared translation", () => {
|
|
74
|
+
const stripe = defineFeature("stripe", (r) => {
|
|
75
|
+
r.translations({ keys: { "stripe.settings": { en: "Stripe" } } });
|
|
76
|
+
r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
|
|
77
|
+
});
|
|
78
|
+
const mailer = defineFeature("mailer", (r) => {
|
|
79
|
+
// no r.translations() → no "mailer.settings" key declared anywhere
|
|
80
|
+
r.secret("smtpPassword", { label: { en: "SMTP Password" }, scope: "tenant" });
|
|
81
|
+
});
|
|
82
|
+
const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(), stripe, mailer]));
|
|
83
|
+
const screen = secretsScreen(schema);
|
|
84
|
+
|
|
85
|
+
expect(screen.sections).toEqual([
|
|
86
|
+
{ fields: ["mailer-smtp-password"] },
|
|
87
|
+
{ title: "stripe.settings", fields: ["stripe-api-key"] },
|
|
88
|
+
]);
|
|
89
|
+
expect(screen.sections.some((s) => "title" in s && s.title === "mailer.settings")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("audience-tenant nav + settings workspace arise from secrets alone (no masked config key)", () => {
|
|
93
|
+
const stripe = defineFeature("stripe", (r) => {
|
|
94
|
+
r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
|
|
95
|
+
});
|
|
96
|
+
const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(), stripe]));
|
|
97
|
+
|
|
98
|
+
expect(navById(schema, "audience-tenant")).toBeDefined();
|
|
99
|
+
expect(navById(schema, "secrets")?.parent).toBe("audience-tenant");
|
|
100
|
+
expect(navById(schema, "secrets")?.screen).toBe("secrets");
|
|
101
|
+
expect(schema.workspace).toBeDefined();
|
|
102
|
+
expect(schema.workspace?.navMembers).toContain("config:nav:secrets");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("screen and nav access mirror secrets:write:set's access rule, including custom roles", () => {
|
|
106
|
+
const stripe = defineFeature("stripe", (r) => {
|
|
107
|
+
r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
|
|
108
|
+
});
|
|
109
|
+
const customRoles = ["BillingOps"];
|
|
110
|
+
const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(customRoles), stripe]));
|
|
111
|
+
|
|
112
|
+
expect(secretsScreen(schema).access).toEqual({ roles: customRoles });
|
|
113
|
+
expect(navById(schema, "secrets")?.access).toEqual({ roles: customRoles });
|
|
114
|
+
expect(navById(schema, "audience-tenant")?.access).toEqual({ roles: customRoles });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("declared secrets without a mounted secrets feature yield no secretsEdit screen and no secrets nav", () => {
|
|
118
|
+
const stripe = defineFeature("stripe", (r) => {
|
|
119
|
+
r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
|
|
120
|
+
});
|
|
121
|
+
const schema = buildConfigFeatureSchema(createRegistry([stripe]));
|
|
122
|
+
|
|
123
|
+
expect(schema.screens).toHaveLength(0);
|
|
124
|
+
expect(schema.navs).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("generated screen is JSON-safe (no leaked SecretKeyDefinition.redact function)", () => {
|
|
128
|
+
const stripe = defineFeature("stripe", (r) => {
|
|
129
|
+
r.secret("apiKey", {
|
|
130
|
+
label: { en: "Stripe API Key" },
|
|
131
|
+
scope: "tenant",
|
|
132
|
+
redact: (plaintext) => `${plaintext.slice(0, 3)}...`,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(), stripe]));
|
|
136
|
+
const screen = secretsScreen(schema);
|
|
137
|
+
|
|
138
|
+
expect(JSON.parse(JSON.stringify(screen))).toEqual(screen);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -6,13 +6,15 @@ import {
|
|
|
6
6
|
requiredKeysFromScreen,
|
|
7
7
|
requiredKeysFromWorkspace,
|
|
8
8
|
} from "../../i18n/required-surface-keys";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildConfigFeatureSchema,
|
|
11
|
+
type ConfigFeatureSchema,
|
|
12
|
+
SETTINGS_HUB_FEATURE,
|
|
13
|
+
} from "../build-config-feature-schema";
|
|
10
14
|
import { createRegistry } from "../registry";
|
|
11
15
|
import type { FeatureDefinition } from "../types";
|
|
12
|
-
import type { Registry } from "../types/feature";
|
|
13
16
|
|
|
14
|
-
function requiredKeysFromGeneratedConfigHub(
|
|
15
|
-
const schema = buildConfigFeatureSchema(registry);
|
|
17
|
+
function requiredKeysFromGeneratedConfigHub(schema: ConfigFeatureSchema): readonly string[] {
|
|
16
18
|
if (schema.navs.length === 0) return [];
|
|
17
19
|
const out = new Set<string>();
|
|
18
20
|
|
|
@@ -33,6 +35,7 @@ function requiredKeysFromGeneratedConfigHub(registry: Registry): readonly string
|
|
|
33
35
|
for (const key of requiredKeysFromWorkspace(schema.workspace.definition)) out.add(key);
|
|
34
36
|
}
|
|
35
37
|
out.add("config.settings.title");
|
|
38
|
+
if (schema.screens.some((s) => s.type === "secretsEdit")) out.add("config.secrets.title");
|
|
36
39
|
for (const scope of ["system", "tenant", "user"] as const) {
|
|
37
40
|
out.add(`config.settings.${scope}`);
|
|
38
41
|
}
|
|
@@ -62,6 +65,11 @@ function hasDefinedTranslation(defined: Set<string>, key: string): boolean {
|
|
|
62
65
|
export function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]): void {
|
|
63
66
|
const defined = buildEffectiveTranslationKeys(features);
|
|
64
67
|
const registry = createRegistry(features);
|
|
68
|
+
const generatedConfigHub = buildConfigFeatureSchema(registry);
|
|
69
|
+
// The secrets screen's labels/hints come from the `r.secret()` declaration
|
|
70
|
+
// itself, not from r.translations — without this, every app with a
|
|
71
|
+
// declared secret would fail boot on its own generated keys.
|
|
72
|
+
for (const key of Object.keys(generatedConfigHub.translations ?? {})) defined.add(key);
|
|
65
73
|
|
|
66
74
|
for (const feature of features) {
|
|
67
75
|
if (!featureHasI18nSurface(feature)) continue;
|
|
@@ -76,7 +84,7 @@ export function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]):
|
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
for (const key of requiredKeysFromGeneratedConfigHub(
|
|
87
|
+
for (const key of requiredKeysFromGeneratedConfigHub(generatedConfigHub)) {
|
|
80
88
|
if (!hasDefinedTranslation(defined, key)) {
|
|
81
89
|
throw new Error(`[i18n] Settings-Hub: required translation key missing: "${key}"`);
|
|
82
90
|
}
|
|
@@ -991,6 +991,11 @@ export function validateScreens(
|
|
|
991
991
|
continue;
|
|
992
992
|
}
|
|
993
993
|
|
|
994
|
+
// secretsEdit is generator-only (buildConfigFeatureSchema) — never
|
|
995
|
+
// authored via r.screen, so feature.screens never actually carries one.
|
|
996
|
+
// Kept here only so this loop's type narrowing stays exhaustive.
|
|
997
|
+
if (screen.type === "secretsEdit") continue;
|
|
998
|
+
|
|
994
999
|
// entityList / entityEdit: entity-refs are feature-local.
|
|
995
1000
|
const entityDef = feature.entities?.[screen.entity];
|
|
996
1001
|
if (!entityDef) {
|
|
@@ -160,12 +160,16 @@ function mergeSettingsHubIntoConfigFeature(
|
|
|
160
160
|
entities: {},
|
|
161
161
|
screens: generated.screens,
|
|
162
162
|
navs: generated.navs,
|
|
163
|
+
...(generated.translations !== undefined && { translations: generated.translations }),
|
|
163
164
|
});
|
|
164
165
|
} else {
|
|
165
166
|
features[features.indexOf(existing)] = {
|
|
166
167
|
...existing,
|
|
167
168
|
screens: [...existing.screens, ...generated.screens],
|
|
168
169
|
navs: [...(existing.navs ?? []), ...generated.navs],
|
|
170
|
+
...(generated.translations !== undefined && {
|
|
171
|
+
translations: { ...existing.translations, ...generated.translations },
|
|
172
|
+
}),
|
|
169
173
|
};
|
|
170
174
|
}
|
|
171
175
|
}
|
|
@@ -462,6 +466,16 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
|
|
|
462
466
|
if (Array.isArray(def["totals"]) && isJsonSafeValue(def["totals"])) out["totals"] = def["totals"];
|
|
463
467
|
if (isPlainObject(def["totalsMatch"]) && isJsonSafeValue(def["totalsMatch"]))
|
|
464
468
|
out["totalsMatch"] = def["totalsMatch"];
|
|
469
|
+
// embedded lists: the sub-field map itself — without it the client-side
|
|
470
|
+
// edit view-model iterates `undefined` and the screen crashes (fw#2507).
|
|
471
|
+
// Recursive per sub-field, NOT a blanket isJsonSafeValue gate: a single
|
|
472
|
+
// sub-field carrying a validator fn would otherwise drop the whole map,
|
|
473
|
+
// and a blanket copy would ship server internals.
|
|
474
|
+
if (isPlainObject(def["schema"])) {
|
|
475
|
+
out["schema"] = Object.fromEntries(
|
|
476
|
+
Object.entries(def["schema"]).map(([k, v]) => [k, projectField(v as FieldDefinition)]), // @cast-boundary schema-walk
|
|
477
|
+
);
|
|
478
|
+
}
|
|
465
479
|
return out as FieldDefinition; // @cast-boundary schema-walk
|
|
466
480
|
}
|
|
467
481
|
|