@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.
@@ -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
- expect(result.files).toEqual([
25
- "package.json",
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("bin/dev.ts contains runDevApp + welcomeBanner + admin login + clientEntry", () => {
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
- // envSchema must cover the same features runProdApp auto-mixes via
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 as foundation", () => {
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(() => scaffoldApp({ name: "MyShop", destination: tmp })).toThrow(/kebab-case/);
196
- expect(() => scaffoldApp({ name: "my_shop", destination: tmp })).toThrow(/kebab-case/);
197
- expect(() => scaffoldApp({ name: "0shop", destination: tmp })).toThrow(/kebab-case/);
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(() => scaffoldApp({ name: "my-shop-", destination: tmp })).toThrow(/kebab-case/);
202
- expect(() => scaffoldApp({ name: "my--shop", destination: tmp })).toThrow(/kebab-case/);
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
- // The CLI passes ctx.cwd; the scaffold must land under it so the
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(() => scaffoldApp({ name: "existing", destination: dest })).toThrow(/already exists/);
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 (config/user/tenant/auth-email-password) are filtered out — no boot-time dedupe-warn spam", () => {
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("deterministic tenantId for same name (reproducible boots)", () => {
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).toEqual([
38
- "package.json",
39
- "tsconfig.json",
40
- "src/run-config.ts",
41
- "bin/main.ts",
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.
@@ -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
- // Ein explizit gewireter File-Provider (options.files) erfüllt das
230
- // FILE_STORAGE_PROVIDER-Boot-Gateder Provider IST konfiguriert, nur
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
  }
@@ -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
- throw new Error(
174
- `${context}: required env var "${name}" is missing or empty. ` +
175
- `Set it in your container env / .env.production / Coolify secrets.`,
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?: (tenantId: TenantId) => ReadonlySet<string>;
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
- // Mint `ctx.config` per request: the dispatcher only builds a per-user
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,