@cosmicdrift/kumiko-dev-server 0.48.1 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.48.1",
3
+ "version": "0.50.0",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -0,0 +1,97 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import {
3
+ configValuesTable,
4
+ createConfigAccessorFactory,
5
+ createConfigFeature,
6
+ createConfigResolver,
7
+ } from "@cosmicdrift/kumiko-bundled-features/config";
8
+ import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
+ import {
10
+ setupTestStack,
11
+ type TestStack,
12
+ TestUsers,
13
+ unsafePushTables,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { mergeConfigResolverDefault } from "../run-dev-app";
16
+
17
+ // Pins runDevApp's ENV→config-app-override wiring: mergeConfigResolverDefault
18
+ // builds the auth-mode configResolver-default with the ENV bridge (a key with
19
+ // `env:` gets its env value as the app-override default, symmetric to
20
+ // runProdApp), the envSource is injected (never the real process.env), and a
21
+ // caller-supplied configResolver still overrides the default.
22
+
23
+ const PAGE_SIZE = "devcfg:config:page-size";
24
+
25
+ const devcfgFeature = defineFeature("devcfg", (r) => {
26
+ r.requires("config");
27
+ r.config({
28
+ keys: {
29
+ pageSize: createTenantConfig("number", {
30
+ env: "DEVCFG_PAGE_SIZE",
31
+ default: 10,
32
+ read: access.all,
33
+ write: access.all,
34
+ }),
35
+ },
36
+ });
37
+ });
38
+
39
+ // ctx=undefined → object form, configResolver is the only key (test boundary).
40
+ type ExtraObj = { configResolver: ReturnType<typeof createConfigResolver> };
41
+
42
+ let stack: TestStack;
43
+
44
+ beforeAll(async () => {
45
+ stack = await setupTestStack({
46
+ features: [createConfigFeature(), devcfgFeature],
47
+ extraContext: ({ registry }) => {
48
+ const bootResolver = createConfigResolver();
49
+ return {
50
+ configResolver: bootResolver,
51
+ _configAccessorFactory: createConfigAccessorFactory(registry, bootResolver),
52
+ };
53
+ },
54
+ });
55
+ await unsafePushTables(stack.db, { configValuesTable });
56
+ });
57
+
58
+ afterAll(async () => {
59
+ await stack.cleanup();
60
+ });
61
+
62
+ function resolverFor(envSource: Record<string, string | undefined>) {
63
+ const extra = mergeConfigResolverDefault(undefined, stack.registry, envSource) as ExtraObj;
64
+ return extra.configResolver;
65
+ }
66
+
67
+ function peekPageSize(resolver: ReturnType<typeof createConfigResolver>) {
68
+ const keyDef = stack.registry.getConfigKey(PAGE_SIZE);
69
+ if (!keyDef) throw new Error("page-size key missing from registry");
70
+ return resolver.get(
71
+ PAGE_SIZE,
72
+ keyDef,
73
+ TestUsers.systemAdmin.tenantId,
74
+ TestUsers.systemAdmin.id,
75
+ stack.db,
76
+ );
77
+ }
78
+
79
+ describe("runDevApp configResolver-default — ENV→app-override bridge", () => {
80
+ test("a key with `env:` resolves to the injected env value as app-override", async () => {
81
+ const value = await peekPageSize(resolverFor({ DEVCFG_PAGE_SIZE: "25" }));
82
+ expect(value).toBe(25); // number, coerced from env — not 10 (keyDef.default)
83
+ });
84
+
85
+ test("no env value → falls to keyDef.default (bridge is conditional, not always-on)", async () => {
86
+ const value = await peekPageSize(resolverFor({}));
87
+ expect(value).toBe(10);
88
+ });
89
+
90
+ test("a caller-supplied configResolver overrides the default", () => {
91
+ const custom = createConfigResolver();
92
+ const extra = mergeConfigResolverDefault({ configResolver: custom }, stack.registry, {
93
+ DEVCFG_PAGE_SIZE: "25",
94
+ }) as ExtraObj;
95
+ expect(extra.configResolver).toBe(custom);
96
+ });
97
+ });
@@ -0,0 +1,82 @@
1
+ // Relocation-Test für den @source-Layout-Fix (#359).
2
+ //
3
+ // Der Bug: renderer-web/src/styles.css scannte seine eigenen Shell-Klassen
4
+ // über einen MONOREPO-relativen @source (`../../renderer-web/src`). Im
5
+ // Workspace löst der via Symlink auf (grün), in einem Standalone-Consumer
6
+ // (echtes node_modules ohne Monorepo-Geschwister) findet er nichts → unstyled
7
+ // prod (15KB statt 48KB). Der Fix macht die Zeile self-relativ (`./`), weil
8
+ // das Paket `src` shippt.
9
+ //
10
+ // Am REALEN Ort der styles.css sind `./` und `../../renderer-web/src`
11
+ // identisch — ein Test dort wäre grün mit Bug UND Fix. Der Diskriminator ist
12
+ // RELOCATION: wir kopieren die echte styles.css an einen Ort, an dem der alte
13
+ // Pfad tot ist und nur `./` greift. Der Ort liegt unter dem Repo-Root, damit
14
+ // `@import "tailwindcss"` / `react-day-picker` weiter über node_modules
15
+ // auflösen. Braucht Bun (Bun.spawn im Tailwind-One-Shot) — sonst silent skip.
16
+
17
+ import { describe, expect, test } from "bun:test";
18
+ import { execFileSync } from "node:child_process";
19
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { runTailwindOnce } from "../build-prod-bundle";
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const REPO_ROOT = resolve(__dirname, "../../../..");
26
+ const RENDERER_WEB_STYLES = resolve(__dirname, "../../../renderer-web/src/styles.css");
27
+ const SELF_SOURCE = '@source "./**/*.{ts,tsx}";';
28
+ const MONOREPO_SOURCE = '@source "../../renderer-web/src/**/*.{ts,tsx}";';
29
+
30
+ function bunAvailable(): boolean {
31
+ try {
32
+ execFileSync("bun", ["--version"], { stdio: "ignore" });
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ describe("renderer-web styles.css @source relocation (#359)", () => {
40
+ test("self-relative @source scans the shell standalone; monorepo path would not", async () => {
41
+ if (!bunAvailable()) return;
42
+
43
+ // Tailwind v4 auto-scannt nur das cwd-Verzeichnis (empirisch bestätigt:
44
+ // das Verzeichnis der Input-CSS wird NICHT automatisch gescannt). Im echten
45
+ // Standalone-Consumer ist renderer-web zudem in node_modules (gitignored) →
46
+ // ebenfalls nicht auto-gescannt. Die Shell-Klassen erreicht also NUR der
47
+ // explizite @source der styles.css. Hier mirrorn wir das robust, ohne
48
+ // gitignore-/node_modules-Semantik: das Paket-`src` (mit der Shell-Probe)
49
+ // liegt AUSSERHALB des Build-cwd — nur der self-relative `@source "./**"`
50
+ // der relozierten styles.css erreicht es, der monorepo-Pfad ist tot.
51
+ // Temp unter REPO_ROOT, damit @import "tailwindcss"/react-day-picker via
52
+ // node_modules auflösen.
53
+ const dir = await mkdtemp(join(REPO_ROOT, ".reloc-rendererweb-"));
54
+ const buildCwd = join(dir, "app");
55
+ const pkgSrc = join(dir, "pkg");
56
+ try {
57
+ const realCss = await readFile(RENDERER_WEB_STYLES, "utf8");
58
+ // Guard: der Fix MUSS in der Quelle stehen, sonst testet die Relocation nichts.
59
+ expect(realCss).toContain(SELF_SOURCE);
60
+
61
+ await mkdir(buildCwd, { recursive: true });
62
+ await mkdir(join(pkgSrc, "layout"), { recursive: true });
63
+ await writeFile(
64
+ join(pkgSrc, "layout/probe.tsx"),
65
+ `export const P = () => <div className="min-h-screen" />;\n`,
66
+ );
67
+
68
+ await writeFile(join(pkgSrc, "styles.css"), realCss);
69
+ const fixed = await runTailwindOnce(join(pkgSrc, "styles.css"), buildCwd);
70
+ expect(fixed).toContain("min-h-screen");
71
+
72
+ // Diskriminator: mit dem ALTEN monorepo-relativen @source ist die Probe
73
+ // an diesem relozierten Ort unerreichbar → Sentinel fehlt. Beweist, dass
74
+ // der Fix kein No-op ist (der Test würde bei einem Revert rot).
75
+ await writeFile(join(pkgSrc, "styles.css"), realCss.replace(SELF_SOURCE, MONOREPO_SOURCE));
76
+ const buggy = await runTailwindOnce(join(pkgSrc, "styles.css"), buildCwd);
77
+ expect(buggy).not.toContain("min-h-screen");
78
+ } finally {
79
+ await rm(dir, { recursive: true, force: true });
80
+ }
81
+ });
82
+ });
@@ -0,0 +1,35 @@
1
+ // Unit-Test für den CSS-Completeness-Guard (#359): nach dem Tailwind-One-Shot
2
+ // prüft der Build, ob das Output-CSS die renderer-web-Shell-Sentinel-Klasse
3
+ // enthält — aber NUR wenn auf das gepackte renderer-web-styles.css
4
+ // zurückgefallen wurde. Fehlt sie dort, würde prod unstyled rendern → laut
5
+ // failen mit Hinweis auf src/styles.css.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { assertRendererWebShellPresent } from "../build-prod-bundle";
9
+
10
+ const fallback = {
11
+ path: "/app/node_modules/renderer-web/src/styles.css",
12
+ isRendererWebFallback: true,
13
+ };
14
+ const appOwned = { path: "/app/src/styles.css", isRendererWebFallback: false };
15
+
16
+ describe("assertRendererWebShellPresent (#359 CSS-completeness guard)", () => {
17
+ test("fallback + fehlende Shell-Sentinel-Klasse → wirft mit src/styles.css-Hinweis", () => {
18
+ expect(() => assertRendererWebShellPresent(".some-other{display:flex}", fallback)).toThrow(
19
+ /min-h-screen/,
20
+ );
21
+ expect(() => assertRendererWebShellPresent(".some-other{display:flex}", fallback)).toThrow(
22
+ /src\/styles\.css/,
23
+ );
24
+ });
25
+
26
+ test("fallback + Shell-Sentinel vorhanden → kein Throw", () => {
27
+ expect(() =>
28
+ assertRendererWebShellPresent(".min-h-screen{min-height:100vh}", fallback),
29
+ ).not.toThrow();
30
+ });
31
+
32
+ test("app-eigenes styles.css (kein Fallback) → nie asserten, auch ohne Sentinel", () => {
33
+ expect(() => assertRendererWebShellPresent("", appOwned)).not.toThrow();
34
+ });
35
+ });
@@ -0,0 +1,33 @@
1
+ // runDevApp muss denselben Boot-Validator wie runProdApp ausführen (#359):
2
+ // eine ganze Fehlerklasse (unqualifizierte nav-/handler-QNs, unauflösbare
3
+ // navigate-Targets, screen-access) passierte früher den Dev-Server still und
4
+ // crashte erst den Prod-Pod im CrashLoopBackOff. Hier: ein Feature mit einem
5
+ // rowAction-navigate auf einen nie registrierten Screen — runDevApp muss
6
+ // SYNCHRON beim Boot werfen, bevor ein Port gebunden oder der codegen-Watcher
7
+ // gestartet wird (validateBoot läuft vor watchAndRegenerate).
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { runDevApp } from "../run-dev-app";
12
+
13
+ function unresolvableNavFeature() {
14
+ return defineFeature("shop", (r) => {
15
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
16
+ r.screen({
17
+ id: "product-list",
18
+ type: "entityList",
19
+ entity: "product",
20
+ columns: ["name"],
21
+ // "ghost-screen" wird nie via r.screen registriert → validateBoot wirft.
22
+ rowActions: [{ kind: "navigate", id: "edit", label: "actions.edit", screen: "ghost-screen" }],
23
+ });
24
+ });
25
+ }
26
+
27
+ describe("runDevApp boot-validation (#359)", () => {
28
+ test("unresolvable navigate-target throws at boot — dev/prod parity, no port bound", async () => {
29
+ await expect(runDevApp({ features: [unresolvableNavFeature()] })).rejects.toThrow(
30
+ /navigate-target "ghost-screen" does not resolve/,
31
+ );
32
+ });
33
+ });
@@ -8,6 +8,7 @@ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
8
8
  // missing call site (e.g. someone deletes the line from runDevApp)
9
9
  // surfaces as a missing-helper-use in code review rather than silently
10
10
  // shipping a server that never seeds.
11
+ // @wrapper-known semantic-alias
11
12
  export async function applyBootSeeds(deps: {
12
13
  registry: Registry;
13
14
  db: DbConnection;
@@ -139,7 +139,8 @@ export async function buildProdBundle(options: BuildProdBundleOptions = {}): Pro
139
139
  // der client.tsx ein "import './foo.css'" macht — den Fall lassen
140
140
  // wir hier raus, Tailwind ist die einzige CSS-Quelle).
141
141
  if (stylesheet) {
142
- const css = await runTailwindOnce(stylesheet, cwd);
142
+ const css = await runTailwindOnce(stylesheet.path, cwd);
143
+ assertRendererWebShellPresent(css, stylesheet);
143
144
  const hash = shortHash(css);
144
145
  const filename = `styles-${hash}.css`;
145
146
  await writeFile(join(assetsDir, filename), css);
@@ -284,17 +285,32 @@ export function discoverClientEntry(cwd: string): string | undefined {
284
285
  return only?.name === "client" ? only.sourceFile : undefined;
285
286
  }
286
287
 
288
+ type ResolvedStylesheet = {
289
+ readonly path: string;
290
+ /** True nur wenn wir auf das gepackte renderer-web-styles.css zurückfallen
291
+ * (App hat clientEntry + renderer-web-Dep, aber kein eigenes src/styles.css).
292
+ * Steuert den Shell-Klassen-Sentinel im Build. */
293
+ readonly isRendererWebFallback: boolean;
294
+ };
295
+
296
+ // Äußerster Wrapper von DefaultAppShell/WorkspaceShell trägt diese Utility
297
+ // (app-layout.tsx: "flex min-h-screen …"). Echtes Tailwind-Utility (landet
298
+ // also im kompilierten Output) ohne CSS-Escaping → stabiler Substring-Sentinel.
299
+ const RENDERER_WEB_SHELL_SENTINEL = "min-h-screen";
300
+
287
301
  function resolveStylesheetEntry(
288
302
  cwd: string,
289
303
  clientEntry: string | undefined,
290
304
  override: BuildProdBundleOptions["stylesheet"],
291
- ): string | undefined {
305
+ ): ResolvedStylesheet | undefined {
292
306
  if (override === false) return undefined;
293
- if (typeof override === "string") return resolve(cwd, override);
307
+ if (typeof override === "string") {
308
+ return { path: resolve(cwd, override), isRendererWebFallback: false };
309
+ }
294
310
 
295
311
  // App-eigenes styles.css schlägt den Default.
296
312
  const local = resolve(cwd, "src/styles.css");
297
- if (existsSync(local)) return local;
313
+ if (existsSync(local)) return { path: local, isRendererWebFallback: false };
298
314
 
299
315
  // Sonst: nur wenn ein client da ist, fallback auf renderer-web/styles.css.
300
316
  // Sample-Apps und Showcases nutzen das alle — gleiche Logik wie der dev-
@@ -310,12 +326,30 @@ function resolveStylesheetEntry(
310
326
  if (!canResolveTailwindStylesheet(resolved, { bun, cwd })) {
311
327
  return undefined;
312
328
  }
313
- return resolved;
329
+ return { path: resolved, isRendererWebFallback: true };
314
330
  } catch {
315
331
  return undefined;
316
332
  }
317
333
  }
318
334
 
335
+ // Build-Zeit-Guard gegen unstyled prod (#359): fällt der Build auf renderer-
336
+ // web/styles.css zurück und fehlt im kompilierten CSS die Shell-Sentinel-
337
+ // Klasse, wurden die DefaultAppShell-Styles nicht gescannt (renderer-web
338
+ // nicht dort installiert wo Tailwind scannt, oder ein @source-Regress).
339
+ // Laut failen statt ein 15KB-Image zu liefern, das in prod nackt rendert.
340
+ // @internal — exportiert nur für Unit-Tests.
341
+ export function assertRendererWebShellPresent(css: string, stylesheet: ResolvedStylesheet): void {
342
+ if (!stylesheet.isRendererWebFallback) return;
343
+ if (css.includes(RENDERER_WEB_SHELL_SENTINEL)) return;
344
+ throw new Error(
345
+ `[kumiko build] renderer-web-Fallback-CSS ohne Shell-Klasse "${RENDERER_WEB_SHELL_SENTINEL}" — ` +
346
+ "die DefaultAppShell/WorkspaceShell-Styles fehlen, prod würde unstyled rendern. Meist ist " +
347
+ "@cosmicdrift/kumiko-renderer-web nicht dort installiert wo Tailwind seine Source scannt. " +
348
+ 'Fix: src/styles.css anlegen mit `@import "@cosmicdrift/kumiko-renderer-web/styles.css";` ' +
349
+ '(+ `@source "./**/*.{ts,tsx}";` um auch eigene Komponenten zu scannen).',
350
+ );
351
+ }
352
+
319
353
  // @internal — exported nur für Unit-Tests.
320
354
  export function discoverHtmlTemplate(cwd: string): string | undefined {
321
355
  for (const candidate of ["index.html", "public/index.html"]) {
@@ -329,7 +363,8 @@ export function discoverHtmlTemplate(cwd: string): string | undefined {
329
363
  // Build steps
330
364
  // ---------------------------------------------------------------------------
331
365
 
332
- async function runTailwindOnce(entry: string, cwd: string): Promise<string> {
366
+ // @internal exportiert nur für Unit-Tests (Relocation-Test des @source-Layouts).
367
+ export async function runTailwindOnce(entry: string, cwd: string): Promise<string> {
333
368
  if (!hasBun) {
334
369
  throw new Error(
335
370
  "[kumiko build] Tailwind one-shot requires Bun (Bun.spawn) — run via `bun run …` or `bun kumiko build`.",
@@ -267,6 +267,7 @@ export function formatServerBuildResult(
267
267
  const dim = "\x1b[2m";
268
268
  const green = "\x1b[32m";
269
269
  const reset = "\x1b[0m";
270
+ // @wrapper-known semantic-alias
270
271
  const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(2);
271
272
  const lines: string[] = [];
272
273
  lines.push("");
@@ -49,8 +49,10 @@ const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
49
49
  type BunServer = typeof Bun extends undefined ? unknown : ReturnType<typeof Bun.serve>;
50
50
 
51
51
  // biome-ignore lint/suspicious/noConsole: dev-server status logging
52
+ // @wrapper-known semantic-alias
52
53
  const logInfo = (msg: string): void => console.log(msg);
53
54
  // biome-ignore lint/suspicious/noConsole: dev-server error logging
55
+ // @wrapper-known semantic-alias
54
56
  const logError = (...args: unknown[]): void => console.error(...args);
55
57
 
56
58
  /** Multi-Entry-Mode für Apps die mehrere getrennte Bundles ausliefern
package/src/kebab.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // package-name + folder (`my-shop`, not `my-` or `my--shop`).
3
3
  const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
4
4
 
5
+ // @wrapper-known semantic-alias
5
6
  export function isKebabSegment(value: string): boolean {
6
7
  return KEBAB_RE.test(value);
7
8
  }
@@ -18,7 +18,10 @@ import {
18
18
  type SeedAdminOptions,
19
19
  seedAdmin,
20
20
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
21
- import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";
21
+ import {
22
+ buildEnvConfigOverrides,
23
+ createConfigResolver,
24
+ } from "@cosmicdrift/kumiko-bundled-features/config";
22
25
  import {
23
26
  createSessionCallbacks,
24
27
  type SessionCallbacks,
@@ -26,12 +29,15 @@ import {
26
29
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
27
30
  import type { SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
28
31
  import {
32
+ createRegistry,
29
33
  type EffectiveFeaturesResolver,
30
34
  type FeatureDefinition,
31
35
  findTierResolverUsage,
36
+ type Registry,
32
37
  type SessionUser,
33
38
  type TenantId,
34
39
  type TierResolverPlugin,
40
+ validateBoot,
35
41
  } from "@cosmicdrift/kumiko-framework/engine";
36
42
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
37
43
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
@@ -140,6 +146,11 @@ export type RunDevAppOptions = {
140
146
  /** Extra-AppContext-Keys. Im auth-mode wird `configResolver` automatisch
141
147
  * hinzugefügt — kein Override durch den Caller nötig. */
142
148
  readonly extraContext?: CreateKumikoServerOptions["extraContext"];
149
+ /** Env-Quelle für die ENV→config-app-override-Brücke (Keys mit `env:`
150
+ * bekommen ihren env-Wert als app-override-Default — symmetrisch zu
151
+ * runProdApp). Default `process.env`. Injizierbar als Test-Seam, damit
152
+ * Tests eigene env-Werte reichen statt `process.env` global zu mutieren. */
153
+ readonly envSource?: Record<string, string | undefined>;
143
154
  /** Anonymous-Access für Public-Endpoints — Requests ohne JWT laufen
144
155
  * als Pseudo-User mit Rolle `anonymous` durch, wenn der Handler die
145
156
  * Rolle in `access.roles` führt. */
@@ -158,6 +169,23 @@ export type RunDevAppOptions = {
158
169
  };
159
170
 
160
171
  export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServerHandle> {
172
+ // Auto-mix Standard-Features im auth-mode via composeFeatures (single
173
+ // source of truth — auch runProdApp und der per-app drizzle-Schema-
174
+ // Generator nutzen denselben Helper, damit Migration und Runtime nie
175
+ // auseinanderdriften können).
176
+ const composeAuthOptions = buildComposeAuthOptions(options.auth);
177
+ const features = composeFeatures(options.features, {
178
+ includeBundled: !!options.auth,
179
+ ...(composeAuthOptions && { authOptions: composeAuthOptions }),
180
+ });
181
+
182
+ // Boot-Validation als allererstes — vor fs-Watcher und Server. Dieselbe
183
+ // Fehlerklasse (unqualifizierte nav-/handler-QNs, screen-access etc.),
184
+ // die früher nur runProdApp fing und sonst erst den Prod-Pod im
185
+ // CrashLoopBackOff sterben ließ (#359). Wirft synchron, bevor ein
186
+ // Socket oder Watcher (codegen-Write) aufgeht.
187
+ validateBoot(features);
188
+
161
189
  // Codegen + File-Watcher — schreibt `<appRoot>/.kumiko/types.generated.d.ts`
162
190
  // + `define.ts` aus den r.defineEvent-Aufrufen der App, einmal beim
163
191
  // Boot UND danach bei jeder relevanten Änderung unter `<appRoot>/src/`.
@@ -170,16 +198,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
170
198
  // process-exit räumt fs.watch-handles auf).
171
199
  watchAndRegenerate({ appRoot: process.cwd() });
172
200
 
173
- // Auto-mix Standard-Features im auth-mode via composeFeatures (single
174
- // source of truth — auch runProdApp und der per-app drizzle-Schema-
175
- // Generator nutzen denselben Helper, damit Migration und Runtime nie
176
- // auseinanderdriften können).
177
- const composeAuthOptions = buildComposeAuthOptions(options.auth);
178
- const features = composeFeatures(options.features, {
179
- includeBundled: !!options.auth,
180
- ...(composeAuthOptions && { authOptions: composeAuthOptions }),
181
- });
182
-
183
201
  // Sprint-8a Tier-Composition auto-wire: scan features for a
184
202
  // tenantTierResolver-extension. If found AND user didn't supply own
185
203
  // effectiveFeatures, we wire a late-bound wrapper here and fill it
@@ -210,9 +228,17 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
210
228
  // configResolver-default fürs config-feature — im auth-mode immer
211
229
  // hinzufügen, im no-auth-mode dem Caller überlassen. Factory-form
212
230
  // wird gewrap't damit der spread auf das aufgerufene Result greift,
213
- // nicht auf die function selbst (no-op).
231
+ // nicht auf die function selbst (no-op). Die ENV→config-Brücke (Keys mit
232
+ // `env:` → app-override-Default) läuft symmetrisch zu runProdApp; die
233
+ // throwaway-Registry hier extrahiert nur die config-Keys, weil der
234
+ // configResolver vor dem Server-Boot konstruiert wird (stack.registry
235
+ // gibt's erst onAfterSetup) — createKumikoServer baut intern seine eigene.
214
236
  const extraContext = options.auth
215
- ? mergeConfigResolverDefault(options.extraContext)
237
+ ? mergeConfigResolverDefault(
238
+ options.extraContext,
239
+ createRegistry(features),
240
+ options.envSource ?? process.env,
241
+ )
216
242
  : options.extraContext;
217
243
 
218
244
  // Sessions opt-in: Holder lebt im closure, `createSessionCallbacks`
@@ -343,10 +369,20 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
343
369
  });
344
370
  }
345
371
 
346
- function mergeConfigResolverDefault(
372
+ // Exported for the wiring-contract test (config-resolver-default.integration):
373
+ // pins that the dev configResolver-default carries the ENV→app-override bridge
374
+ // and that a caller-supplied configResolver still overrides it. Not re-exported
375
+ // from the package barrel (index.ts lists only runDevApp).
376
+ export function mergeConfigResolverDefault(
347
377
  ctx: CreateKumikoServerOptions["extraContext"],
378
+ registry: Registry,
379
+ envSource: Record<string, string | undefined>,
348
380
  ): CreateKumikoServerOptions["extraContext"] {
349
- const defaults = { configResolver: createConfigResolver() };
381
+ const defaults = {
382
+ configResolver: createConfigResolver({
383
+ appOverrides: buildEnvConfigOverrides(registry, envSource),
384
+ }),
385
+ };
350
386
  if (ctx === undefined) return defaults;
351
387
  if (typeof ctx === "function") {
352
388
  return (deps) => ({ ...defaults, ...ctx(deps) });
@@ -36,7 +36,10 @@ import {
36
36
  type SeedAdminOptions,
37
37
  seedAdmin,
38
38
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
39
- import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";
39
+ import {
40
+ buildEnvConfigOverrides,
41
+ createConfigResolver,
42
+ } from "@cosmicdrift/kumiko-bundled-features/config";
40
43
  import { createSessionCallbacks } from "@cosmicdrift/kumiko-bundled-features/sessions";
41
44
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
42
45
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
@@ -668,7 +671,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
668
671
  ? options.extraContext(deps)
669
672
  : (options.extraContext ?? {});
670
673
  const extraContext = options.auth
671
- ? { configResolver: createConfigResolver(), ...resolvedExtraContext }
674
+ ? {
675
+ configResolver: createConfigResolver({
676
+ appOverrides: buildEnvConfigOverrides(registry, envSource),
677
+ }),
678
+ ...resolvedExtraContext,
679
+ }
672
680
  : resolvedExtraContext;
673
681
  const resolvedAnonymousAccess =
674
682
  typeof options.anonymousAccess === "function"
@@ -825,6 +833,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
825
833
  seedsDir: options.seedsDir,
826
834
  appliedBy: "boot",
827
835
  registry, // → dry-run-validator catched handler-QN-typos vor dem write
836
+ // @wrapper-known semantic-alias
828
837
  createContext: (dbRunner: DbRunner) =>
829
838
  createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
830
839
  });
@@ -73,6 +73,7 @@ export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
73
73
  return { destination, files, appName: options.name };
74
74
  }
75
75
 
76
+ // @wrapper-known semantic-alias
76
77
  function write(path: string, content: string): void {
77
78
  writeFileSync(path, content);
78
79
  }
@@ -334,6 +335,7 @@ function deriveTenantId(name: string): string {
334
335
  state ^= ch.charCodeAt(0);
335
336
  state = Math.imul(state, 16777619) >>> 0;
336
337
  }
338
+ // @wrapper-known semantic-alias
337
339
  const hex = (n: number, len: number): string => n.toString(16).padStart(len, "0").slice(0, len);
338
340
  const a = hex(state, 8);
339
341
  state ^= state << 13;