@cosmicdrift/kumiko-dev-server 0.105.1 → 0.108.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__/compose-features-wiring.integration.test.ts +9 -9
- package/src/__tests__/compose-features.test.ts +6 -1
- package/src/__tests__/config-resolver-default.integration.test.ts +35 -1
- package/src/__tests__/require-env.test.ts +29 -0
- package/src/__tests__/run-dev-app.integration.test.ts +75 -1
- package/src/__tests__/scaffold-app-feature.test.ts +2 -2
- package/src/__tests__/scaffold-app.test.ts +137 -81
- package/src/__tests__/session-wiring.test.ts +14 -0
- package/src/__tests__/walkthrough.integration.test.ts +13 -19
- package/src/run-dev-app.ts +34 -4
- package/src/run-prod-app.ts +33 -14
- package/src/scaffold-app.ts +297 -20
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// scaffoldApp unit-tests (DX-1.0).
|
|
1
|
+
// scaffoldApp unit-tests (DX-1.0 + #352 deploy/schema scaffold).
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
4
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
@@ -6,6 +6,29 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { scaffoldApp } from "../scaffold-app";
|
|
8
8
|
|
|
9
|
+
const SCAFFOLD_FILES = [
|
|
10
|
+
"package.json",
|
|
11
|
+
"tsconfig.json",
|
|
12
|
+
"biome.json",
|
|
13
|
+
"bunfig.toml",
|
|
14
|
+
"bunfig.ci.toml",
|
|
15
|
+
"src/run-config.ts",
|
|
16
|
+
"kumiko/schema.ts",
|
|
17
|
+
"bin/main.ts",
|
|
18
|
+
"bin/dev.ts",
|
|
19
|
+
"bin/kumiko.ts",
|
|
20
|
+
"src/client.tsx",
|
|
21
|
+
"src/styles.css",
|
|
22
|
+
".env.example",
|
|
23
|
+
"docker-compose.yml",
|
|
24
|
+
"kumiko/migrations/0001_init.sql",
|
|
25
|
+
"kumiko/migrations/.snapshot.json",
|
|
26
|
+
"deploy/Dockerfile",
|
|
27
|
+
"deploy/Dockerfile.dockerignore",
|
|
28
|
+
"deploy/migrate-step.sh",
|
|
29
|
+
"README.md",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
9
32
|
describe("scaffoldApp", () => {
|
|
10
33
|
let tmp: string;
|
|
11
34
|
beforeEach(() => {
|
|
@@ -15,31 +38,21 @@ describe("scaffoldApp", () => {
|
|
|
15
38
|
rmSync(tmp, { recursive: true, force: true });
|
|
16
39
|
});
|
|
17
40
|
|
|
18
|
-
test("scaffolds the expected files into <cwd>/<name>", () => {
|
|
41
|
+
test("scaffolds the expected files into <cwd>/<name>", async () => {
|
|
19
42
|
const dest = join(tmp, "my-shop");
|
|
20
|
-
const result = scaffoldApp({ name: "my-shop", destination: dest });
|
|
43
|
+
const result = await scaffoldApp({ name: "my-shop", destination: dest });
|
|
21
44
|
|
|
22
45
|
expect(result.appName).toBe("my-shop");
|
|
23
46
|
expect(result.destination).toBe(dest);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"tsconfig.json",
|
|
27
|
-
"src/run-config.ts",
|
|
28
|
-
"bin/main.ts",
|
|
29
|
-
"bin/dev.ts",
|
|
30
|
-
"src/client.tsx",
|
|
31
|
-
".env.example",
|
|
32
|
-
"docker-compose.yml",
|
|
33
|
-
"README.md",
|
|
34
|
-
]);
|
|
35
|
-
for (const f of result.files) {
|
|
47
|
+
for (const f of SCAFFOLD_FILES) {
|
|
48
|
+
expect(result.files).toContain(f);
|
|
36
49
|
expect(existsSync(join(dest, f))).toBe(true);
|
|
37
50
|
}
|
|
38
51
|
});
|
|
39
52
|
|
|
40
|
-
test("package.json has @cosmicdrift/* deps with version pin", () => {
|
|
53
|
+
test("package.json has @cosmicdrift/* deps with version pin", async () => {
|
|
41
54
|
const dest = join(tmp, "my-shop");
|
|
42
|
-
scaffoldApp({ name: "my-shop", destination: dest, frameworkVersion: "^0.13.0" });
|
|
55
|
+
await scaffoldApp({ name: "my-shop", destination: dest, frameworkVersion: "^0.13.0" });
|
|
43
56
|
|
|
44
57
|
const pkg = JSON.parse(readFileSync(join(dest, "package.json"), "utf-8")) as {
|
|
45
58
|
name: string;
|
|
@@ -53,70 +66,82 @@ describe("scaffoldApp", () => {
|
|
|
53
66
|
expect(pkg.dependencies["@cosmicdrift/kumiko-renderer-web"]).toBe("^0.13.0");
|
|
54
67
|
expect(pkg.scripts["boot"]).toContain("KUMIKO_DRY_RUN_ENV=boot");
|
|
55
68
|
expect(pkg.scripts["dev"]).toBe("bun --watch bin/dev.ts");
|
|
69
|
+
expect(pkg.scripts["build"]).toBe("bun kumiko-build");
|
|
70
|
+
expect(pkg.scripts["start"]).toBe("bun run bin/main.ts");
|
|
71
|
+
expect(pkg.scripts["lint"]).toBe("biome check .");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("init migration includes auth-mode tables (read_users)", async () => {
|
|
75
|
+
const dest = join(tmp, "my-shop");
|
|
76
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
77
|
+
|
|
78
|
+
const sql = readFileSync(join(dest, "kumiko/migrations/0001_init.sql"), "utf-8");
|
|
79
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS "read_users"');
|
|
56
80
|
});
|
|
57
81
|
|
|
58
|
-
test("
|
|
82
|
+
test("kumiko/schema.ts + bin/kumiko.ts wire HAS_AUTH single-source", async () => {
|
|
59
83
|
const dest = join(tmp, "my-shop");
|
|
60
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
84
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
85
|
+
|
|
86
|
+
const schema = readFileSync(join(dest, "kumiko/schema.ts"), "utf-8");
|
|
87
|
+
expect(schema).toContain("APP_FEATURES, HAS_AUTH");
|
|
88
|
+
expect(schema).toContain("collectTableMetas");
|
|
89
|
+
|
|
90
|
+
const kumikoBin = readFileSync(join(dest, "bin/kumiko.ts"), "utf-8");
|
|
91
|
+
expect(kumikoBin).toContain("runSchemaCli");
|
|
92
|
+
expect(kumikoBin).toContain("includeBundled: HAS_AUTH");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("bin/dev.ts contains runDevApp + welcomeBanner + admin login + clientEntry", async () => {
|
|
96
|
+
const dest = join(tmp, "my-shop");
|
|
97
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
61
98
|
|
|
62
99
|
const dev = readFileSync(join(dest, "bin/dev.ts"), "utf-8");
|
|
63
100
|
expect(dev).toContain("runDevApp");
|
|
64
101
|
expect(dev).toContain("welcomeBanner: true");
|
|
65
102
|
expect(dev).toContain("admin@my-shop.local");
|
|
66
103
|
expect(dev).toContain(`password: "changeme"`);
|
|
67
|
-
// Without clientEntry the default HTML still <script src="/client.js">'s,
|
|
68
|
-
// and the dev-server 404s on it — every fresh scaffold rendered blank.
|
|
69
104
|
expect(dev).toContain(`clientEntry: "./src/client.tsx"`);
|
|
70
105
|
});
|
|
71
106
|
|
|
72
|
-
test("src/client.tsx bundles createKumikoApp + emailPasswordClient + DefaultAppShell", () => {
|
|
107
|
+
test("src/client.tsx bundles createKumikoApp + emailPasswordClient + DefaultAppShell", async () => {
|
|
73
108
|
const dest = join(tmp, "my-shop");
|
|
74
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
109
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
75
110
|
|
|
76
111
|
const client = readFileSync(join(dest, "src/client.tsx"), "utf-8");
|
|
77
112
|
expect(client).toContain("createKumikoApp");
|
|
78
113
|
expect(client).toContain("emailPasswordClient");
|
|
79
|
-
// Without shell:DefaultAppShell post-login renders only the active
|
|
80
|
-
// screen (no sidebar / topbar) — the e2e hero-demo proved this dead-
|
|
81
|
-
// ends on "Screen not found" because routing has no layout chrome.
|
|
82
114
|
expect(client).toContain("DefaultAppShell");
|
|
83
115
|
expect(client).toContain("shell: DefaultAppShell");
|
|
84
116
|
expect(client).toContain('from "@cosmicdrift/kumiko-renderer-web"');
|
|
85
117
|
expect(client).toContain('from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web"');
|
|
86
118
|
});
|
|
87
119
|
|
|
88
|
-
test(".env.example carries KUMIKO_DEV_DB_NAME default so reboots are persistent", () => {
|
|
120
|
+
test(".env.example carries KUMIKO_DEV_DB_NAME default so reboots are persistent", async () => {
|
|
89
121
|
const dest = join(tmp, "my-shop");
|
|
90
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
122
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
91
123
|
|
|
92
124
|
const env = readFileSync(join(dest, ".env.example"), "utf-8");
|
|
93
125
|
expect(env).toContain("KUMIKO_DEV_DB_NAME=my_shop_dev");
|
|
94
126
|
});
|
|
95
127
|
|
|
96
|
-
test(".env.example lists both TEST_DATABASE_URL (bun dev) + DATABASE_URL (prod)", () => {
|
|
128
|
+
test(".env.example lists both TEST_DATABASE_URL (bun dev) + DATABASE_URL (prod)", async () => {
|
|
97
129
|
const dest = join(tmp, "my-shop");
|
|
98
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
130
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
99
131
|
|
|
100
132
|
const env = readFileSync(join(dest, ".env.example"), "utf-8");
|
|
101
|
-
// runDevApp → setupTestStack needs TEST_DATABASE_URL; previously the
|
|
102
|
-
// template only listed DATABASE_URL, so every fresh `bun dev` crashed
|
|
103
|
-
// with "Missing required env var: TEST_DATABASE_URL".
|
|
104
133
|
expect(env).toContain("TEST_DATABASE_URL=");
|
|
105
134
|
expect(env).toContain("DATABASE_URL=");
|
|
106
135
|
});
|
|
107
136
|
|
|
108
|
-
test("docker-compose.yml ports + credentials match the .env.example *_URL defaults", () => {
|
|
137
|
+
test("docker-compose.yml ports + credentials match the .env.example *_URL defaults", async () => {
|
|
109
138
|
const dest = join(tmp, "my-shop");
|
|
110
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
139
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
111
140
|
|
|
112
141
|
const compose = readFileSync(join(dest, "docker-compose.yml"), "utf-8");
|
|
113
142
|
const env = readFileSync(join(dest, ".env.example"), "utf-8");
|
|
114
|
-
// README tells the user to run `docker compose up -d`; that only works if
|
|
115
|
-
// the compose service matches what the generated .env points its *_URLs at.
|
|
116
143
|
expect(env).toContain("127.0.0.1:5432");
|
|
117
144
|
expect(env).toContain("127.0.0.1:6379");
|
|
118
|
-
// Ports bind to loopback only — weak dev creds / auth-less Redis must not
|
|
119
|
-
// be reachable from the LAN (security review of the scaffold).
|
|
120
145
|
expect(compose).toContain('"127.0.0.1:5432:5432"');
|
|
121
146
|
expect(compose).toContain('"127.0.0.1:6379:6379"');
|
|
122
147
|
expect(compose).not.toContain('"5432:5432"');
|
|
@@ -126,9 +151,9 @@ describe("scaffoldApp", () => {
|
|
|
126
151
|
expect(compose).toContain("image: redis:");
|
|
127
152
|
});
|
|
128
153
|
|
|
129
|
-
test("README lists the mounted features dynamically", () => {
|
|
154
|
+
test("README lists the mounted features dynamically", async () => {
|
|
130
155
|
const dest = join(tmp, "my-shop");
|
|
131
|
-
scaffoldApp({
|
|
156
|
+
await scaffoldApp({
|
|
132
157
|
name: "my-shop",
|
|
133
158
|
destination: dest,
|
|
134
159
|
features: [
|
|
@@ -153,78 +178,75 @@ describe("scaffoldApp", () => {
|
|
|
153
178
|
expect(readme).toContain("- `delivery`");
|
|
154
179
|
});
|
|
155
180
|
|
|
156
|
-
test("bin/main.ts contains runProdApp + auth.admin stub", () => {
|
|
181
|
+
test("bin/main.ts contains runProdApp + auth.admin stub + staticDir", async () => {
|
|
157
182
|
const dest = join(tmp, "my-shop");
|
|
158
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
183
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
159
184
|
|
|
160
185
|
const main = readFileSync(join(dest, "bin/main.ts"), "utf-8");
|
|
161
186
|
expect(main).toContain("runProdApp");
|
|
187
|
+
expect(main).toContain('staticDir: "./dist"');
|
|
162
188
|
expect(main).toContain("auth: {");
|
|
163
189
|
expect(main).toContain("admin@my-shop.local");
|
|
164
190
|
expect(main).toContain('tenantKey: "my-shop"');
|
|
165
|
-
// Tenant-ID is a valid UUID-v4 format (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx).
|
|
166
191
|
expect(main).toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/);
|
|
167
192
|
});
|
|
168
193
|
|
|
169
|
-
test("bin/main.ts composes the auth-mode feature set into envSchema (JWT_SECRET boot-gate)", () => {
|
|
194
|
+
test("bin/main.ts composes the auth-mode feature set into envSchema (JWT_SECRET boot-gate)", async () => {
|
|
170
195
|
const dest = join(tmp, "my-shop");
|
|
171
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
196
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
172
197
|
|
|
173
198
|
const main = readFileSync(join(dest, "bin/main.ts"), "utf-8");
|
|
174
|
-
|
|
175
|
-
// auth-mode — otherwise auth-email-password's JWT_SECRET (min-32) is
|
|
176
|
-
// absent from the boot-gate and a too-short secret slips through.
|
|
177
|
-
expect(main).toContain("composeFeatures(APP_FEATURES, { includeBundled: true })");
|
|
199
|
+
expect(main).toContain("composeFeatures(APP_FEATURES, { includeBundled: HAS_AUTH })");
|
|
178
200
|
expect(main).toContain(
|
|
179
201
|
"composeEnvSchema({ core: frameworkCoreEnvSchema, features: bootFeatures })",
|
|
180
202
|
);
|
|
181
|
-
expect(main).toContain("composeFeatures");
|
|
182
203
|
});
|
|
183
204
|
|
|
184
|
-
test("src/run-config.ts mounts secrets + sessions
|
|
205
|
+
test("src/run-config.ts mounts secrets + sessions + HAS_AUTH", async () => {
|
|
185
206
|
const dest = join(tmp, "my-shop");
|
|
186
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
207
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
187
208
|
|
|
188
209
|
const runConfig = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
|
|
189
210
|
expect(runConfig).toContain("createSecretsFeature()");
|
|
190
211
|
expect(runConfig).toContain("createSessionsFeature()");
|
|
191
212
|
expect(runConfig).toContain("export const APP_FEATURES");
|
|
213
|
+
expect(runConfig).toContain("export const HAS_AUTH");
|
|
192
214
|
});
|
|
193
215
|
|
|
194
|
-
test("rejects non-kebab-case names", () => {
|
|
195
|
-
expect(
|
|
196
|
-
expect(
|
|
197
|
-
expect(
|
|
216
|
+
test("rejects non-kebab-case names", async () => {
|
|
217
|
+
await expect(scaffoldApp({ name: "MyShop", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
218
|
+
await expect(scaffoldApp({ name: "my_shop", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
219
|
+
await expect(scaffoldApp({ name: "0shop", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
198
220
|
});
|
|
199
221
|
|
|
200
|
-
test("rejects trailing- and double-hyphen names (invalid package segment)", () => {
|
|
201
|
-
expect(
|
|
202
|
-
expect(
|
|
222
|
+
test("rejects trailing- and double-hyphen names (invalid package segment)", async () => {
|
|
223
|
+
await expect(scaffoldApp({ name: "my-shop-", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
224
|
+
await expect(scaffoldApp({ name: "my--shop", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
203
225
|
});
|
|
204
226
|
|
|
205
|
-
test("resolves a relative destination against the supplied cwd, not process.cwd()", () => {
|
|
206
|
-
|
|
207
|
-
// displayed path matches the actual write location.
|
|
208
|
-
const result = scaffoldApp({ name: "shop", destination: "apps/shop", cwd: tmp });
|
|
227
|
+
test("resolves a relative destination against the supplied cwd, not process.cwd()", async () => {
|
|
228
|
+
const result = await scaffoldApp({ name: "shop", destination: "apps/shop", cwd: tmp });
|
|
209
229
|
expect(result.destination).toBe(join(tmp, "apps/shop"));
|
|
210
230
|
expect(existsSync(join(tmp, "apps/shop", "package.json"))).toBe(true);
|
|
211
231
|
});
|
|
212
232
|
|
|
213
|
-
test("resolves the name-default destination against the supplied cwd", () => {
|
|
214
|
-
const result = scaffoldApp({ name: "shop", cwd: tmp });
|
|
233
|
+
test("resolves the name-default destination against the supplied cwd", async () => {
|
|
234
|
+
const result = await scaffoldApp({ name: "shop", cwd: tmp });
|
|
215
235
|
expect(result.destination).toBe(join(tmp, "shop"));
|
|
216
236
|
expect(existsSync(join(tmp, "shop", "package.json"))).toBe(true);
|
|
217
237
|
});
|
|
218
238
|
|
|
219
|
-
test("refuses to overwrite existing destination", () => {
|
|
239
|
+
test("refuses to overwrite existing destination", async () => {
|
|
220
240
|
const dest = join(tmp, "existing");
|
|
221
|
-
scaffoldApp({ name: "existing", destination: dest });
|
|
222
|
-
expect(
|
|
241
|
+
await scaffoldApp({ name: "existing", destination: dest });
|
|
242
|
+
await expect(scaffoldApp({ name: "existing", destination: dest })).rejects.toThrow(
|
|
243
|
+
/already exists/,
|
|
244
|
+
);
|
|
223
245
|
});
|
|
224
246
|
|
|
225
|
-
test("features-param: custom selection lands in run-config.ts imports + APP_FEATURES", () => {
|
|
247
|
+
test("features-param: custom selection lands in run-config.ts imports + APP_FEATURES", async () => {
|
|
226
248
|
const dest = join(tmp, "custom-features");
|
|
227
|
-
scaffoldApp({
|
|
249
|
+
await scaffoldApp({
|
|
228
250
|
name: "custom-features",
|
|
229
251
|
destination: dest,
|
|
230
252
|
features: [
|
|
@@ -251,23 +273,20 @@ describe("scaffoldApp", () => {
|
|
|
251
273
|
expect(cfg).not.toContain("createSessionsFeature");
|
|
252
274
|
});
|
|
253
275
|
|
|
254
|
-
test("features-param: empty array falls back to foundation (backwards-compat)", () => {
|
|
276
|
+
test("features-param: empty array falls back to foundation (backwards-compat)", async () => {
|
|
255
277
|
const dest = join(tmp, "empty-features");
|
|
256
|
-
scaffoldApp({ name: "empty-features", destination: dest, features: [] });
|
|
278
|
+
await scaffoldApp({ name: "empty-features", destination: dest, features: [] });
|
|
257
279
|
const cfg = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
|
|
258
280
|
expect(cfg).toContain("createSecretsFeature()");
|
|
259
281
|
expect(cfg).toContain("createSessionsFeature()");
|
|
260
282
|
});
|
|
261
283
|
|
|
262
|
-
test("features-param: composeFeatures auto-mounted
|
|
284
|
+
test("features-param: composeFeatures auto-mounted names are filtered out", async () => {
|
|
263
285
|
const dest = join(tmp, "filtered");
|
|
264
|
-
scaffoldApp({
|
|
286
|
+
await scaffoldApp({
|
|
265
287
|
name: "filtered",
|
|
266
288
|
destination: dest,
|
|
267
289
|
features: [
|
|
268
|
-
// Picker's recommended set includes the 4 auto-mounted bundled
|
|
269
|
-
// features; without the filter they'd land in APP_FEATURES and
|
|
270
|
-
// composeFeatures would log 4 dedupe warnings on every boot.
|
|
271
290
|
{
|
|
272
291
|
name: "config",
|
|
273
292
|
importPath: "@cosmicdrift/kumiko-bundled-features/config",
|
|
@@ -308,11 +327,48 @@ describe("scaffoldApp", () => {
|
|
|
308
327
|
expect(cfg).toContain("createDeliveryFeature()");
|
|
309
328
|
});
|
|
310
329
|
|
|
311
|
-
test("
|
|
330
|
+
test("features-param: ONLY auto-mounted names → empty effective set, no foundation fallback", async () => {
|
|
331
|
+
const dest = join(tmp, "only-auto-mounted");
|
|
332
|
+
await scaffoldApp({
|
|
333
|
+
name: "only-auto-mounted",
|
|
334
|
+
destination: dest,
|
|
335
|
+
features: [
|
|
336
|
+
{
|
|
337
|
+
name: "config",
|
|
338
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/config",
|
|
339
|
+
exportName: "createConfigFeature",
|
|
340
|
+
callExpression: "createConfigFeature()",
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
name: "user",
|
|
344
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/user",
|
|
345
|
+
exportName: "createUserFeature",
|
|
346
|
+
callExpression: "createUserFeature()",
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
name: "tenant",
|
|
350
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/tenant",
|
|
351
|
+
exportName: "createTenantFeature",
|
|
352
|
+
callExpression: "createTenantFeature()",
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: "auth-email-password",
|
|
356
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/auth-email-password",
|
|
357
|
+
exportName: "createAuthEmailPasswordFeature",
|
|
358
|
+
callExpression: "createAuthEmailPasswordFeature()",
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
});
|
|
362
|
+
const cfg = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
|
|
363
|
+
expect(cfg).not.toContain("createSecretsFeature");
|
|
364
|
+
expect(cfg).not.toContain("createSessionsFeature");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("deterministic tenantId for same name (reproducible boots)", async () => {
|
|
312
368
|
const a = join(tmp, "a");
|
|
313
369
|
const b = join(tmp, "b");
|
|
314
|
-
scaffoldApp({ name: "stable", destination: a });
|
|
315
|
-
scaffoldApp({ name: "stable", destination: b });
|
|
370
|
+
await scaffoldApp({ name: "stable", destination: a });
|
|
371
|
+
await scaffoldApp({ name: "stable", destination: b });
|
|
316
372
|
const mainA = readFileSync(join(a, "bin/main.ts"), "utf-8");
|
|
317
373
|
const mainB = readFileSync(join(b, "bin/main.ts"), "utf-8");
|
|
318
374
|
const uuidA = mainA.match(
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createSessionsFeature,
|
|
4
|
+
SESSIONS_FEATURE,
|
|
5
|
+
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
2
6
|
import { resolveProdSessionsConfig, shouldWireProdSessions } from "../session-wiring";
|
|
3
7
|
|
|
4
8
|
describe("shouldWireProdSessions — secure-by-default with opt-out (KF-1)", () => {
|
|
@@ -25,6 +29,16 @@ describe("shouldWireProdSessions — secure-by-default with opt-out (KF-1)", ()
|
|
|
25
29
|
});
|
|
26
30
|
});
|
|
27
31
|
|
|
32
|
+
describe("SESSIONS_FEATURE constant matches the real feature name", () => {
|
|
33
|
+
it("createSessionsFeature()'s name equals SESSIONS_FEATURE", () => {
|
|
34
|
+
// shouldWireProdSessions's own arm only tests the pure boolean helper —
|
|
35
|
+
// the actual run-prod-app.ts integration seam
|
|
36
|
+
// (`features.some((f) => f.name === SESSIONS_FEATURE)`) drifts silently
|
|
37
|
+
// if the feature is ever renamed without updating this constant.
|
|
38
|
+
expect(createSessionsFeature().name).toBe(SESSIONS_FEATURE);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
28
42
|
describe("resolveProdSessionsConfig", () => {
|
|
29
43
|
it("passes a config object through", () => {
|
|
30
44
|
expect(resolveProdSessionsConfig({ expiresInMs: 5000 })).toEqual({ expiresInMs: 5000 });
|
|
@@ -32,23 +32,17 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
32
32
|
rmSync(tmp, { recursive: true, force: true });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
test("Step 1 (kumiko new app) — produces walkthrough's scaffold files", () => {
|
|
36
|
-
const result = scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
37
|
-
expect(result.files).
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"bin/dev.ts",
|
|
43
|
-
"src/client.tsx",
|
|
44
|
-
".env.example",
|
|
45
|
-
"docker-compose.yml",
|
|
46
|
-
"README.md",
|
|
47
|
-
]);
|
|
35
|
+
test("Step 1 (kumiko new app) — produces walkthrough's scaffold files", async () => {
|
|
36
|
+
const result = await scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
37
|
+
expect(result.files).toContain("package.json");
|
|
38
|
+
expect(result.files).toContain("kumiko/schema.ts");
|
|
39
|
+
expect(result.files).toContain("bin/kumiko.ts");
|
|
40
|
+
expect(result.files).toContain("deploy/Dockerfile");
|
|
41
|
+
expect(result.files).toContain("kumiko/migrations/0001_init.sql");
|
|
48
42
|
});
|
|
49
43
|
|
|
50
|
-
test("Step 2 (kumiko add feature) — auto-mounts + walkthrough diff matches", () => {
|
|
51
|
-
scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
44
|
+
test("Step 2 (kumiko add feature) — auto-mounts + walkthrough diff matches", async () => {
|
|
45
|
+
await scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
52
46
|
const result = scaffoldAppFeature({ name: "notes", appRoot });
|
|
53
47
|
expect(result.autoMounted).toBe(true);
|
|
54
48
|
|
|
@@ -63,8 +57,8 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
63
57
|
expect(runConfig).toContain("createSessionsFeature()");
|
|
64
58
|
});
|
|
65
59
|
|
|
66
|
-
test("Step 3 (boot validation) — scaffolded run-config matches walkthrough's APP_FEATURES claim", () => {
|
|
67
|
-
scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
60
|
+
test("Step 3 (boot validation) — scaffolded run-config matches walkthrough's APP_FEATURES claim", async () => {
|
|
61
|
+
await scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
68
62
|
scaffoldAppFeature({ name: "notes", appRoot });
|
|
69
63
|
|
|
70
64
|
// Text-assert: scaffolded run-config.ts contains exactly the 3 features
|
|
@@ -108,8 +102,8 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
108
102
|
expect(registry.features.size).toBe(7);
|
|
109
103
|
});
|
|
110
104
|
|
|
111
|
-
test("bin/main.ts contains the auth.admin stub the walkthrough relies on", () => {
|
|
112
|
-
scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
105
|
+
test("bin/main.ts contains the auth.admin stub the walkthrough relies on", async () => {
|
|
106
|
+
await scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
113
107
|
const main = readFileSync(join(appRoot, "bin/main.ts"), "utf-8");
|
|
114
108
|
// composeFeatures(includeBundled:true)-trigger is `auth: { admin: { … } }`.
|
|
115
109
|
// Walkthrough explicitly says this is what auto-mounts the 4 bundled features.
|
package/src/run-dev-app.ts
CHANGED
|
@@ -22,12 +22,19 @@ import {
|
|
|
22
22
|
buildEnvConfigOverrides,
|
|
23
23
|
createConfigResolver,
|
|
24
24
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
25
|
+
import {
|
|
26
|
+
createPatResolver,
|
|
27
|
+
PAT_FEATURE,
|
|
28
|
+
patRateLimitFromFeature,
|
|
29
|
+
patScopesFromFeature,
|
|
30
|
+
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
25
31
|
import {
|
|
26
32
|
createSessionCallbacks,
|
|
27
33
|
type SessionCallbacks,
|
|
28
34
|
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
29
35
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
30
|
-
import type { SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
|
|
36
|
+
import type { PatResolver, SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
|
|
37
|
+
import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
|
|
31
38
|
import {
|
|
32
39
|
collectWriteHandlerQns,
|
|
33
40
|
createRegistry,
|
|
@@ -226,9 +233,8 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
226
233
|
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
227
234
|
});
|
|
228
235
|
|
|
229
|
-
//
|
|
230
|
-
// FILE_STORAGE_PROVIDER
|
|
231
|
-
// nicht über die env-Bridge. Setzen bevor validateBoot greift.
|
|
236
|
+
// An explicitly wired file provider (options.files) satisfies the
|
|
237
|
+
// FILE_STORAGE_PROVIDER boot gate — set it before validateBoot runs.
|
|
232
238
|
if (options.files !== undefined && process.env["FILE_STORAGE_PROVIDER"] === undefined) {
|
|
233
239
|
process.env["FILE_STORAGE_PROVIDER"] = "configured";
|
|
234
240
|
}
|
|
@@ -339,6 +345,26 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
339
345
|
}
|
|
340
346
|
return sessionCallbacks;
|
|
341
347
|
};
|
|
348
|
+
// PAT opt-in: same late-bound holder pattern — the resolver needs the real
|
|
349
|
+
// db (only concrete after setupTestStack). Wired when the feature is mounted;
|
|
350
|
+
// scopes come from the feature's exports (single source with its handlers).
|
|
351
|
+
let patResolver: PatResolver | undefined;
|
|
352
|
+
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
353
|
+
const patAuthFragment = patFeature
|
|
354
|
+
? {
|
|
355
|
+
patResolver: (rawToken: string) => {
|
|
356
|
+
if (!patResolver) {
|
|
357
|
+
throw new Error("[runDevApp] pat-resolver accessed before onAfterSetup");
|
|
358
|
+
}
|
|
359
|
+
return patResolver(rawToken);
|
|
360
|
+
},
|
|
361
|
+
patRateLimiter: (() => {
|
|
362
|
+
const rl = patRateLimitFromFeature(patFeature);
|
|
363
|
+
return createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs);
|
|
364
|
+
})(),
|
|
365
|
+
}
|
|
366
|
+
: {};
|
|
367
|
+
|
|
342
368
|
const sessionAuthFragment =
|
|
343
369
|
effectiveAuth?.sessions !== undefined
|
|
344
370
|
? {
|
|
@@ -392,6 +418,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
392
418
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
393
419
|
}),
|
|
394
420
|
...sessionAuthFragment,
|
|
421
|
+
...patAuthFragment,
|
|
395
422
|
...(effectiveAuth.passwordReset && {
|
|
396
423
|
passwordReset: {
|
|
397
424
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
@@ -437,6 +464,9 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
437
464
|
...(expiresInMs !== undefined && { expiresInMs }),
|
|
438
465
|
});
|
|
439
466
|
}
|
|
467
|
+
if (patFeature) {
|
|
468
|
+
patResolver = createPatResolver({ db: stack.db, scopes: patScopesFromFeature(patFeature) });
|
|
469
|
+
}
|
|
440
470
|
if (effectiveAuth) {
|
|
441
471
|
await seedAdmin(stack.db, effectiveAuth.admin);
|
|
442
472
|
}
|
package/src/run-prod-app.ts
CHANGED
|
@@ -50,6 +50,12 @@ import {
|
|
|
50
50
|
createDeliveryService,
|
|
51
51
|
DELIVERY_FEATURE,
|
|
52
52
|
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
53
|
+
import {
|
|
54
|
+
createPatResolver,
|
|
55
|
+
PAT_FEATURE,
|
|
56
|
+
patRateLimitFromFeature,
|
|
57
|
+
patScopesFromFeature,
|
|
58
|
+
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
53
59
|
import {
|
|
54
60
|
createSecretsContext,
|
|
55
61
|
SECRETS_FEATURE_NAME,
|
|
@@ -66,6 +72,7 @@ import {
|
|
|
66
72
|
cachedResponse,
|
|
67
73
|
computeStrongEtag,
|
|
68
74
|
computeWeakEtag,
|
|
75
|
+
createInMemoryLoginRateLimiter,
|
|
69
76
|
createSseBroker,
|
|
70
77
|
type SseBroker,
|
|
71
78
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
@@ -84,7 +91,6 @@ import {
|
|
|
84
91
|
findTierResolverUsage,
|
|
85
92
|
type NotifyFactory,
|
|
86
93
|
type Registry,
|
|
87
|
-
type TenantId,
|
|
88
94
|
type TierResolverPlugin,
|
|
89
95
|
validateAppCustomScreenWriteQns,
|
|
90
96
|
validateBoot,
|
|
@@ -170,10 +176,11 @@ export function requireEnv(
|
|
|
170
176
|
): string {
|
|
171
177
|
const value = src[name];
|
|
172
178
|
if (value === undefined || value === "") {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
const advice =
|
|
180
|
+
context === "runProdApp"
|
|
181
|
+
? "Set it in your container env / .env.production / Coolify secrets."
|
|
182
|
+
: "Set it in your .env / shell before running the dev server.";
|
|
183
|
+
throw new Error(`${context}: required env var "${name}" is missing or empty. ${advice}`);
|
|
177
184
|
}
|
|
178
185
|
return value;
|
|
179
186
|
}
|
|
@@ -523,7 +530,7 @@ export type RunProdAppOptions = {
|
|
|
523
530
|
* liche features aktiv via globalFeatureToggleRuntime. Pattern:
|
|
524
531
|
* createLateBoundHolder + post-boot runtime.initialize in einem
|
|
525
532
|
* seed-fn (db ist erst nach migrations + features ready). */
|
|
526
|
-
readonly effectiveFeatures?:
|
|
533
|
+
readonly effectiveFeatures?: EffectiveFeaturesResolver;
|
|
527
534
|
/** Composed Zod-schema for env-validation (from `composeEnvSchema({
|
|
528
535
|
* features, extend })` in @cosmicdrift/kumiko-framework/env). When set:
|
|
529
536
|
* - `process.env` is parsed against it BEFORE any boot work; missing
|
|
@@ -570,14 +577,7 @@ export type ProdAppHandle = {
|
|
|
570
577
|
readonly stop: () => Promise<void>;
|
|
571
578
|
};
|
|
572
579
|
|
|
573
|
-
//
|
|
574
|
-
// ConfigAccessor when `_configAccessorFactory` is on the AppContext
|
|
575
|
-
// (pipeline/dispatcher.ts). Without it `ctx.config` stays undefined and any
|
|
576
|
-
// handler reading it — e.g. createFileProviderForTenant for the GDPR export
|
|
577
|
-
// download — throws "ctx.config is missing". Built from the EFFECTIVE resolver
|
|
578
|
-
// so an app-supplied configResolver override (its appOverrides) is the one
|
|
579
|
-
// ctx.config reads. Shared with runDevApp (mergeConfigResolverDefault) for
|
|
580
|
-
// dev/prod parity.
|
|
580
|
+
// Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
|
|
581
581
|
export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
|
|
582
582
|
resolved: T,
|
|
583
583
|
registry: Registry,
|
|
@@ -957,6 +957,24 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
957
957
|
? buildProdSessionAuth(db, resolveProdSessionsConfig(effectiveAuth?.sessions))
|
|
958
958
|
: undefined;
|
|
959
959
|
|
|
960
|
+
// PAT opt-in: if the personal-access-tokens feature is mounted, wire its
|
|
961
|
+
// resolver (bearer PATs → SessionUser, before jwt.verify). Scopes come from
|
|
962
|
+
// the feature's exports — the same declaration its handlers use.
|
|
963
|
+
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
964
|
+
let patAuthFragment:
|
|
965
|
+
| {
|
|
966
|
+
patResolver: ReturnType<typeof createPatResolver>;
|
|
967
|
+
patRateLimiter: ReturnType<typeof createInMemoryLoginRateLimiter>;
|
|
968
|
+
}
|
|
969
|
+
| undefined;
|
|
970
|
+
if (effectiveAuth && patFeature) {
|
|
971
|
+
const rl = patRateLimitFromFeature(patFeature);
|
|
972
|
+
patAuthFragment = {
|
|
973
|
+
patResolver: createPatResolver({ db, scopes: patScopesFromFeature(patFeature) }),
|
|
974
|
+
patRateLimiter: createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs),
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
960
978
|
const baseEntrypointOptions = {
|
|
961
979
|
registry,
|
|
962
980
|
context: {
|
|
@@ -994,6 +1012,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
994
1012
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
995
1013
|
}),
|
|
996
1014
|
...sessionAuthFragment,
|
|
1015
|
+
...patAuthFragment,
|
|
997
1016
|
...(effectiveAuth.passwordReset && {
|
|
998
1017
|
passwordReset: {
|
|
999
1018
|
requestHandler: AuthHandlers.requestPasswordReset,
|