@cosmicdrift/kumiko-server-runtime 0.164.0 → 0.165.1
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__/boot-probe-fixture.ts +63 -0
- package/src/__tests__/build-prod-bundle.inprocess.test.ts +2 -4
- package/src/__tests__/compose-features.test.ts +15 -0
- package/src/__tests__/run-prod-app-dry-run.test.ts +22 -45
- package/src/__tests__/run-prod-app-env-source.test.ts +5 -44
- package/src/__tests__/run-prod-app-static-files.test.ts +24 -1
- package/src/__tests__/run-prod-app.integration.test.ts +8 -3
- package/src/compose-features.ts +14 -11
- package/src/run-prod-app-static-files.ts +8 -1
- package/src/run-prod-app.ts +67 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.165.1",
|
|
4
4
|
"description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
76
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
75
|
+
"@cosmicdrift/kumiko-bundled-features": "0.165.1",
|
|
76
|
+
"@cosmicdrift/kumiko-framework": "0.165.1",
|
|
77
77
|
"temporal-polyfill": "^0.3.2"
|
|
78
78
|
},
|
|
79
79
|
"publishConfig": {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Shared fixture for run-prod-app-{dry-run,env-source}.test.ts — both boot
|
|
2
|
+
// runProdApp against a minimal probe feature with process.env cleared so the
|
|
3
|
+
// test fully controls config via envSource. Table/feature name stay
|
|
4
|
+
// parametrized (never collapsed to one shared fixture): a table clash
|
|
5
|
+
// between the two test files' entities would surface as flaky cross-file
|
|
6
|
+
// DB state, not a compile error.
|
|
7
|
+
import { afterEach, beforeEach } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
createBooleanField,
|
|
10
|
+
createEntity,
|
|
11
|
+
createTextField,
|
|
12
|
+
defineFeature,
|
|
13
|
+
type FeatureDefinition,
|
|
14
|
+
type FeatureRegistrar,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
export function makeProbeFeature(opts: {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly table: string;
|
|
21
|
+
readonly extraSetup?: (r: FeatureRegistrar<string>) => void;
|
|
22
|
+
}): FeatureDefinition {
|
|
23
|
+
const probeEntity = createEntity({
|
|
24
|
+
fields: {
|
|
25
|
+
name: createTextField({ required: true }),
|
|
26
|
+
active: createBooleanField({ default: true }),
|
|
27
|
+
},
|
|
28
|
+
table: opts.table,
|
|
29
|
+
});
|
|
30
|
+
return defineFeature(opts.name, (r) => {
|
|
31
|
+
r.entity("widget", probeEntity);
|
|
32
|
+
opts.extraSetup?.(r);
|
|
33
|
+
r.queryHandler({
|
|
34
|
+
name: "ping",
|
|
35
|
+
schema: z.object({}),
|
|
36
|
+
access: { roles: ["anonymous"] },
|
|
37
|
+
handler: async () => ({ pong: true }),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// DATABASE_URL/REDIS_URL/JWT_SECRET are required (their read throws
|
|
43
|
+
// pre-#1441-fix boot bugs); PORT is non-throwing, cleared only so ambient
|
|
44
|
+
// PORT can't mask an "envSource wins" assertion.
|
|
45
|
+
export const CLEARED_BOOT_VARS = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET", "PORT"] as const;
|
|
46
|
+
|
|
47
|
+
// Registers beforeEach/afterEach for the current describe block — call this
|
|
48
|
+
// at the top of a `describe(...)` body, same as calling beforeEach directly.
|
|
49
|
+
export function withClearedBootEnv(): void {
|
|
50
|
+
const saved: Record<string, string | undefined> = {};
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
for (const k of CLEARED_BOOT_VARS) {
|
|
53
|
+
saved[k] = process.env[k];
|
|
54
|
+
delete process.env[k];
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
for (const k of CLEARED_BOOT_VARS) {
|
|
59
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
60
|
+
else process.env[k] = saved[k];
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -5,12 +5,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
|
-
import {
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
10
9
|
import { buildProdBundle } from "../build-prod-bundle";
|
|
11
10
|
|
|
12
|
-
const
|
|
13
|
-
const REPO_ROOT = resolve(__dirname, "../../../..");
|
|
11
|
+
const REPO_ROOT = resolve(import.meta.dir, "../../../..");
|
|
14
12
|
|
|
15
13
|
describe("buildProdBundle in-process (Bun.build)", () => {
|
|
16
14
|
let tmp = "";
|
|
@@ -87,6 +87,11 @@ describe("composeFeatures", () => {
|
|
|
87
87
|
});
|
|
88
88
|
const toggle = features.find((f) => f.name === "auth-self-registration");
|
|
89
89
|
expect(toggle).toBeDefined();
|
|
90
|
+
// The security-relevant part isn't that the feature is mounted, it's
|
|
91
|
+
// that self-signup defaults ON — a flip to `default: false` upstream
|
|
92
|
+
// would silently no-op signup (always-200 anti-enumeration masks it)
|
|
93
|
+
// while this assertion alone (toBeDefined) stayed green.
|
|
94
|
+
expect(toggle?.toggleableDefault).toBe(true);
|
|
90
95
|
});
|
|
91
96
|
|
|
92
97
|
test("no authOptions.signup → auth-self-registration NOT mounted", () => {
|
|
@@ -94,6 +99,16 @@ describe("composeFeatures", () => {
|
|
|
94
99
|
expect(features.map((f) => f.name)).not.toContain("auth-self-registration");
|
|
95
100
|
});
|
|
96
101
|
|
|
102
|
+
test("authOptions.signup + app also mounts its own auth-self-registration stub → deduped to exactly one", () => {
|
|
103
|
+
const pickerSelfRegDupe = defineFeature("auth-self-registration", () => {});
|
|
104
|
+
const features = composeFeatures([noopFeature, pickerSelfRegDupe], {
|
|
105
|
+
includeBundled: true,
|
|
106
|
+
authOptions: { signup: { appUrl: "https://app/signup/complete" } },
|
|
107
|
+
});
|
|
108
|
+
const names = features.map((f) => f.name).filter((n) => n === "auth-self-registration");
|
|
109
|
+
expect(names).toEqual(["auth-self-registration"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
97
112
|
test("OHNE authOptions → KEINE reset/verify-handlers (anti-default-deploy-bug)", () => {
|
|
98
113
|
// Genau der Bug der vom Review-Agent gefangen wurde: composeFeatures
|
|
99
114
|
// ohne authOptions registriert die handler nicht. Wenn jemand das
|
|
@@ -1,53 +1,21 @@
|
|
|
1
1
|
// Dry-run + bootErrorReporter paths — no DB/Redis. envSource avoids process.exit(0).
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
createBooleanField,
|
|
6
|
-
createEntity,
|
|
7
|
-
createTextField,
|
|
8
|
-
defineFeature,
|
|
9
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
10
4
|
import { composeEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
|
|
11
5
|
import { z } from "zod";
|
|
12
6
|
import { runProdApp } from "../run-prod-app";
|
|
7
|
+
import { makeProbeFeature, withClearedBootEnv } from "./boot-probe-fixture";
|
|
13
8
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
name: createTextField({ required: true }),
|
|
17
|
-
active: createBooleanField({ default: true }),
|
|
18
|
-
},
|
|
9
|
+
const probeFeature = makeProbeFeature({
|
|
10
|
+
name: "dry-run-probe",
|
|
19
11
|
table: "dry_run_probe",
|
|
12
|
+
extraSetup: (r) => {
|
|
13
|
+
r.envSchema(z.object({ DRY_RUN_PROBE: z.string().optional().describe("probe var") }));
|
|
14
|
+
},
|
|
20
15
|
});
|
|
21
16
|
|
|
22
|
-
const probeFeature = defineFeature("dry-run-probe", (r) => {
|
|
23
|
-
r.entity("widget", probeEntity);
|
|
24
|
-
r.envSchema(z.object({ DRY_RUN_PROBE: z.string().optional().describe("probe var") }));
|
|
25
|
-
r.queryHandler({
|
|
26
|
-
name: "ping",
|
|
27
|
-
schema: z.object({}),
|
|
28
|
-
access: { roles: ["anonymous"] },
|
|
29
|
-
handler: async () => ({ pong: true }),
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
const CLEARED = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET", "PORT"] as const;
|
|
34
|
-
|
|
35
17
|
describe("runProdApp dry-run / bootErrorReporter", () => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
for (const k of CLEARED) {
|
|
40
|
-
saved[k] = process.env[k];
|
|
41
|
-
delete process.env[k];
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
afterEach(() => {
|
|
46
|
-
for (const k of CLEARED) {
|
|
47
|
-
if (saved[k] === undefined) delete process.env[k];
|
|
48
|
-
else process.env[k] = saved[k];
|
|
49
|
-
}
|
|
50
|
-
});
|
|
18
|
+
withClearedBootEnv();
|
|
51
19
|
|
|
52
20
|
test("KUMIKO_DRY_RUN_ENV=human + envSource → render + dry-run handle (no exit)", async () => {
|
|
53
21
|
const logs: string[] = [];
|
|
@@ -69,7 +37,7 @@ describe("runProdApp dry-run / bootErrorReporter", () => {
|
|
|
69
37
|
console.log = originalLog;
|
|
70
38
|
}
|
|
71
39
|
|
|
72
|
-
expect(logs.some((l) => l.includes("DRY_RUN_PROBE")
|
|
40
|
+
expect(logs.some((l) => l.includes("DRY_RUN_PROBE"))).toBe(true);
|
|
73
41
|
const res = await handle!.fetch(new Request("http://test/"));
|
|
74
42
|
expect(res.status).toBe(503);
|
|
75
43
|
expect(await res.text()).toBe("dry-run");
|
|
@@ -95,9 +63,14 @@ describe("runProdApp dry-run / bootErrorReporter", () => {
|
|
|
95
63
|
} finally {
|
|
96
64
|
console.log = originalLog;
|
|
97
65
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
66
|
+
const jsonLine = logs.find((l) => l.trimStart().startsWith("{"));
|
|
67
|
+
if (!jsonLine) throw new Error(`No JSON line in logs: ${JSON.stringify(logs)}`);
|
|
68
|
+
const parsed = JSON.parse(jsonLine) as {
|
|
69
|
+
optional: Array<{ name: string; feature: string }>;
|
|
70
|
+
};
|
|
71
|
+
expect(parsed.optional).toContainEqual(
|
|
72
|
+
expect.objectContaining({ name: "DRY_RUN_PROBE", feature: "dry-run-probe" }),
|
|
73
|
+
);
|
|
101
74
|
});
|
|
102
75
|
|
|
103
76
|
test("unrecognized KUMIKO_DRY_RUN_ENV warns then hits envSchema parse", async () => {
|
|
@@ -126,7 +99,11 @@ describe("runProdApp dry-run / bootErrorReporter", () => {
|
|
|
126
99
|
} finally {
|
|
127
100
|
console.warn = originalWarn;
|
|
128
101
|
}
|
|
129
|
-
expect(
|
|
102
|
+
expect(
|
|
103
|
+
warnings.some(
|
|
104
|
+
(w) => w.includes('KUMIKO_DRY_RUN_ENV="not-a-real-mode"') && w.includes("unrecognized"),
|
|
105
|
+
),
|
|
106
|
+
).toBe(true);
|
|
130
107
|
});
|
|
131
108
|
|
|
132
109
|
test("bootErrorReporter receives KumikoBootError instead of process.exit", async () => {
|
|
@@ -6,40 +6,15 @@
|
|
|
6
6
|
// required-var test would throw "required env var DATABASE_URL is missing" and
|
|
7
7
|
// the PORT test would bind the default instead of the injected port.
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
createBooleanField,
|
|
12
|
-
createEntity,
|
|
13
|
-
createTextField,
|
|
14
|
-
defineFeature,
|
|
15
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
-
import { z } from "zod";
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
17
10
|
import { runProdApp } from "../run-prod-app";
|
|
11
|
+
import { makeProbeFeature, withClearedBootEnv } from "./boot-probe-fixture";
|
|
18
12
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
name: createTextField({ required: true }),
|
|
22
|
-
active: createBooleanField({ default: true }),
|
|
23
|
-
},
|
|
13
|
+
const probeFeature = makeProbeFeature({
|
|
14
|
+
name: "env-source-probe",
|
|
24
15
|
table: "env_source_probe",
|
|
25
16
|
});
|
|
26
17
|
|
|
27
|
-
const probeFeature = defineFeature("env-source-probe", (r) => {
|
|
28
|
-
r.entity("widget", probeEntity);
|
|
29
|
-
r.queryHandler({
|
|
30
|
-
name: "ping",
|
|
31
|
-
schema: z.object({}),
|
|
32
|
-
access: { roles: ["anonymous"] },
|
|
33
|
-
handler: async () => ({ pong: true }),
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
// Cleared from process.env so the test fully controls config via envSource.
|
|
38
|
-
// DATABASE_URL/REDIS_URL/JWT_SECRET are required (their read throws pre-fix);
|
|
39
|
-
// PORT is non-throwing, cleared only so ambient PORT can't mask the second
|
|
40
|
-
// test's "PORT comes from envSource" assertion.
|
|
41
|
-
const CLEARED_VARS = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET", "PORT"] as const;
|
|
42
|
-
|
|
43
18
|
const DUMMY_ENV = {
|
|
44
19
|
KUMIKO_DRY_RUN_ENV: "boot",
|
|
45
20
|
DATABASE_URL: "postgres://smoke:smoke@127.0.0.1:1/smoke",
|
|
@@ -48,21 +23,7 @@ const DUMMY_ENV = {
|
|
|
48
23
|
} as const;
|
|
49
24
|
|
|
50
25
|
describe("runProdApp boot-mode env-source", () => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
for (const k of CLEARED_VARS) {
|
|
55
|
-
saved[k] = process.env[k];
|
|
56
|
-
delete process.env[k];
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
afterEach(() => {
|
|
61
|
-
for (const k of CLEARED_VARS) {
|
|
62
|
-
if (saved[k] === undefined) delete process.env[k];
|
|
63
|
-
else process.env[k] = saved[k];
|
|
64
|
-
}
|
|
65
|
-
});
|
|
26
|
+
withClearedBootEnv();
|
|
66
27
|
|
|
67
28
|
test("boots from injected envSource even when process.env lacks the required vars", async () => {
|
|
68
29
|
const logs: string[] = [];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Unit coverage for mimeTypeFor + hostDispatch edge in buildStaticFallback.
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import {
|
|
@@ -57,6 +57,20 @@ describe("readStaticFile / serveDiskFile", () => {
|
|
|
57
57
|
expect(await readStaticFile(join(tmp, "missing.css"))).toBeUndefined();
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
test("readStaticFile on a directory → undefined (EISDIR), not a throw", async () => {
|
|
61
|
+
const dirPath = join(tmp, "sub");
|
|
62
|
+
await mkdir(dirPath);
|
|
63
|
+
expect(await readStaticFile(dirPath)).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("readStaticFile through a file used as a directory segment → undefined (ENOTDIR), not a throw (#1504)", async () => {
|
|
67
|
+
const path = join(tmp, "index.html");
|
|
68
|
+
await writeFile(path, "<html/>");
|
|
69
|
+
// GET /index.html/x — "index.html" is a file, so treating it as a
|
|
70
|
+
// directory segment must fall through to the SPA fallback, not 500.
|
|
71
|
+
expect(await readStaticFile(join(path, "x"))).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
60
74
|
test("serveDiskFile sets content-type from mime", async () => {
|
|
61
75
|
const path = join(tmp, "a.svg");
|
|
62
76
|
await writeFile(path, "<svg/>");
|
|
@@ -126,6 +140,15 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
126
140
|
expect(await res.text()).toBe("PNGDATA");
|
|
127
141
|
});
|
|
128
142
|
|
|
143
|
+
test("a request for a directory copied verbatim from public/ falls back to index.html instead of 500ing", async () => {
|
|
144
|
+
await mkdir(join(tmp, "sub"));
|
|
145
|
+
await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
|
|
146
|
+
const handler = buildStaticFallback(() => new Response("404", { status: 404 }), tmp, "{}");
|
|
147
|
+
const res = await handler(new Request("http://t/sub"));
|
|
148
|
+
expect(res.status).toBe(200);
|
|
149
|
+
expect(await res.text()).toContain("spa-shell");
|
|
150
|
+
});
|
|
151
|
+
|
|
129
152
|
test("hostDispatch html with CSP + Vary: Host", async () => {
|
|
130
153
|
await writeFile(join(tmp, "tenant.html"), "<!doctype html><html><body>ok</body></html>");
|
|
131
154
|
const handler = buildStaticFallback(
|
|
@@ -280,7 +280,12 @@ describe("runProdApp", () => {
|
|
|
280
280
|
if (original === undefined) delete process.env["KUMIKO_DRY_RUN_ENV"];
|
|
281
281
|
else process.env["KUMIKO_DRY_RUN_ENV"] = original;
|
|
282
282
|
}
|
|
283
|
-
expect(
|
|
283
|
+
expect(
|
|
284
|
+
warnings.some(
|
|
285
|
+
(line) =>
|
|
286
|
+
line.includes('KUMIKO_DRY_RUN_ENV="not-a-real-mode"') && line.includes("unrecognized"),
|
|
287
|
+
),
|
|
288
|
+
).toBe(true);
|
|
284
289
|
});
|
|
285
290
|
|
|
286
291
|
test("second boot against the same DB is idempotent — no crash, no duplicate tables", async () => {
|
|
@@ -836,8 +841,8 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
836
841
|
|
|
837
842
|
test("cookieDomain + allowedOrigins clears the guard — allowlist reaches buildServer", async () => {
|
|
838
843
|
// Without the forwarding fix this would ALSO throw /allowedOrigins is empty/.
|
|
839
|
-
//
|
|
840
|
-
//
|
|
844
|
+
// auth tables are migrated for this file (migrateTestDb() above); this test
|
|
845
|
+
// isolates only that a later boot failure is never the origin-guard message.
|
|
841
846
|
let bootError: unknown;
|
|
842
847
|
try {
|
|
843
848
|
const handle = await boot(undefined, {
|
package/src/compose-features.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
// composeFeatures — single source of truth
|
|
2
|
-
//
|
|
1
|
+
// composeFeatures — single source of truth for the feature list that
|
|
2
|
+
// boot AND the schema generator see.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Both runDevApp and runProdApp mix in the same bundled features in
|
|
5
|
+
// auth-mode (config + user + tenant + auth-email-password, plus
|
|
6
|
+
// auth-self-registration when authOptions.signup is set). So the
|
|
7
|
+
// drizzle schema generator sees the exact same feature list per app as
|
|
8
|
+
// the runtime, the composition lives here — both bootstrap wrappers AND
|
|
9
|
+
// each app's drizzle/generate.ts call it.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
// auth-email-password
|
|
12
|
-
//
|
|
11
|
+
// Order: infrastructure features (config/user/tenant) first, then
|
|
12
|
+
// auth-email-password (+ auth-self-registration when authOptions.signup
|
|
13
|
+
// is set), then the app features. Later features may reference earlier
|
|
14
|
+
// ones (e.g. authClaims hooks on user/tenant).
|
|
13
15
|
|
|
14
16
|
import {
|
|
15
17
|
type AccountUnlockOptions,
|
|
@@ -33,7 +35,8 @@ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
33
35
|
|
|
34
36
|
export type ComposeFeaturesOptions = {
|
|
35
37
|
/** When true, prepends config + user + tenant + auth-email-password
|
|
36
|
-
*
|
|
38
|
+
* (+ auth-self-registration when authOptions.signup is set) before the
|
|
39
|
+
* app features. Mirror of "auth-mode" in run{Dev,Prod}App. */
|
|
37
40
|
readonly includeBundled: boolean;
|
|
38
41
|
/** Optional auth-feature-options durchgereicht an
|
|
39
42
|
* createAuthEmailPasswordFeature. Wenn passwordReset / emailVerification
|
|
@@ -43,7 +43,14 @@ export async function readStaticFile(
|
|
|
43
43
|
const [bytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
|
|
44
44
|
return { bytes, mime: mimeTypeFor(filePath), mtimeMs: fileStat.mtimeMs };
|
|
45
45
|
} catch (err) {
|
|
46
|
-
|
|
46
|
+
const code = (err as { code?: string }).code;
|
|
47
|
+
// ENOENT: no such path. EISDIR: readFile() on a directory (e.g. GET
|
|
48
|
+
// /assets where "assets" is a subfolder copied verbatim from public/).
|
|
49
|
+
// ENOTDIR: a path segment that isn't a directory is used as one (e.g.
|
|
50
|
+
// GET /index.html/x — "index.html" is a file, not a directory) — all
|
|
51
|
+
// three mean "not a servable file", so fall through to the SPA
|
|
52
|
+
// fallback instead of a 500.
|
|
53
|
+
if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR") return undefined;
|
|
47
54
|
throw err;
|
|
48
55
|
}
|
|
49
56
|
}
|
package/src/run-prod-app.ts
CHANGED
|
@@ -27,6 +27,15 @@
|
|
|
27
27
|
// JWT_SECRET_V1=<random-32+> (repeat _V2, _V3, ... per rotation)
|
|
28
28
|
// JWT_SECRET_CURRENT_VERSION=1 (which V<n> signs new tokens; the others
|
|
29
29
|
// still verify in-flight tokens until they expire)
|
|
30
|
+
// Adopting rotation for the first time (no JWT_SECRET_V<n> set yet): the
|
|
31
|
+
// plain JWT_SECRET above stays set and is carried into the keyring as a
|
|
32
|
+
// verify-only legacy key (loadJwtSecretOrKeyring), so sessions signed
|
|
33
|
+
// before the cutover keep verifying — no mass-logout at adoption. This
|
|
34
|
+
// legacy key never expires on its own (boot logs a warning while it's
|
|
35
|
+
// present) — once max token TTL has elapsed since cutover, unset
|
|
36
|
+
// JWT_SECRET to retire it. Check first whether an app relies on the
|
|
37
|
+
// auth.mail convenience default for passwordReset/emailVerification
|
|
38
|
+
// hmacSecret (falls back to this same JWT_SECRET) before unsetting it.
|
|
30
39
|
// PORT=3000
|
|
31
40
|
// KUMIKO_INSTANCE_ID=<stable per replica>
|
|
32
41
|
|
|
@@ -335,6 +344,13 @@ export type RunProdAppAuthOptions = {
|
|
|
335
344
|
* — accept the wide-cookie CSRF risk explicitly instead of setting
|
|
336
345
|
* `allowedOrigins`. */
|
|
337
346
|
readonly unsafeSkipOriginCheck?: boolean;
|
|
347
|
+
/** Number of trusted reverse-proxy hops between the client and this
|
|
348
|
+
* process for client-IP derivation (see AuthRoutesConfig.trustedProxyHops,
|
|
349
|
+
* kumiko-framework#1539) — closes the X-Forwarded-For spoofing hole on
|
|
350
|
+
* the auth rate-limiters. Falls back to the `KUMIKO_TRUSTED_PROXY_HOPS`
|
|
351
|
+
* env var when unset; both unset means the pre-#1539 spoofable default
|
|
352
|
+
* (0 hops). Set this to your real ingress hop count (typically 1). */
|
|
353
|
+
readonly trustedProxyHops?: number;
|
|
338
354
|
};
|
|
339
355
|
|
|
340
356
|
/** Hook for app-specific seeding — runs after the admin (when auth is
|
|
@@ -609,6 +625,22 @@ export type ProdAppHandle = {
|
|
|
609
625
|
readonly stop: () => Promise<void>;
|
|
610
626
|
};
|
|
611
627
|
|
|
628
|
+
let warnedLegacyJwtSecret = false;
|
|
629
|
+
function warnLegacyJwtSecretOnce(): void {
|
|
630
|
+
// skip: already warned once this process — avoid log spam on every boot path.
|
|
631
|
+
if (warnedLegacyJwtSecret) return;
|
|
632
|
+
warnedLegacyJwtSecret = true;
|
|
633
|
+
// biome-ignore lint/suspicious/noConsole: boot-time ops hint, no logger configured this early
|
|
634
|
+
console.warn(
|
|
635
|
+
"[runProdApp] JWT keyring carries a legacy (pre-rotation) verify-only key — " +
|
|
636
|
+
"it never expires on its own. JWT_SECRET remains a required env (hmacSecret " +
|
|
637
|
+
"fallback + boot requireEnv). To stop verifying with the pre-rotation secret: " +
|
|
638
|
+
"set auth.mail.hmacSecret explicitly, then rotate JWT_SECRET to a fresh value " +
|
|
639
|
+
"that is no longer present in any in-flight token (see resolveAuthMail / " +
|
|
640
|
+
"loadJwtSecretOrKeyring).",
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
612
644
|
export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
|
|
613
645
|
// 0. Env-Schema validation + dry-run modes. Runs FIRST so:
|
|
614
646
|
// - operators can introspect env-requirements without a real boot
|
|
@@ -679,8 +711,36 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
679
711
|
// rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
|
|
680
712
|
const jwtSecret = requireEnv("JWT_SECRET", envSource);
|
|
681
713
|
const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
|
|
714
|
+
if (typeof jwtSecretOrKeyring === "object" && Object.hasOwn(jwtSecretOrKeyring.keys, "legacy")) {
|
|
715
|
+
// Once per process — JWT_SECRET stays a required env (requireEnv above +
|
|
716
|
+
// resolveAuthMail hmacSecret fallback). Retiring the *legacy verify key*
|
|
717
|
+
// means setting auth.mail.hmacSecret explicitly and rotating JWT_SECRET
|
|
718
|
+
// to a fresh value that is no longer in any in-flight token; unsetting
|
|
719
|
+
// JWT_SECRET entirely hard-crashes boot.
|
|
720
|
+
warnLegacyJwtSecretOnce();
|
|
721
|
+
}
|
|
682
722
|
const jwtIssuer = readEnv("JWT_ISSUER", envSource);
|
|
683
723
|
const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
|
|
724
|
+
// kumiko-framework#1539 — options.auth.trustedProxyHops wins; falls back
|
|
725
|
+
// to the env var so ops can close the XFF-spoofing hole per-deployment
|
|
726
|
+
// without a code change (mirrors instanceId's env-first pattern above).
|
|
727
|
+
// Fail loud on a garbage env value rather than silently coercing to NaN:
|
|
728
|
+
// clientIpOf treats NaN like "always short chain" and returns "unknown"
|
|
729
|
+
// for every request, which collapses mfa-verify/preauth-confirm's
|
|
730
|
+
// pure-IP-keyed rate limiter into one shared bucket for the whole
|
|
731
|
+
// deployment — a self-inflicted DoS, worse than staying on the default.
|
|
732
|
+
const trustedProxyHopsFromEnv = readEnv("KUMIKO_TRUSTED_PROXY_HOPS", envSource);
|
|
733
|
+
const trustedProxyHops = ((): number | undefined => {
|
|
734
|
+
if (options.auth?.trustedProxyHops !== undefined) return options.auth.trustedProxyHops;
|
|
735
|
+
if (trustedProxyHopsFromEnv === undefined) return undefined;
|
|
736
|
+
const parsed = Number.parseInt(trustedProxyHopsFromEnv, 10);
|
|
737
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
`runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "${trustedProxyHopsFromEnv}".`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
return parsed;
|
|
743
|
+
})();
|
|
684
744
|
const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
|
|
685
745
|
|
|
686
746
|
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
@@ -953,6 +1013,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
953
1013
|
...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
|
|
954
1014
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
955
1015
|
}),
|
|
1016
|
+
...(trustedProxyHops !== undefined && { trustedProxyHops }),
|
|
956
1017
|
...sessionAuthFragment,
|
|
957
1018
|
...patAuthFragment,
|
|
958
1019
|
...tenantLifecycleAuthFragment,
|
|
@@ -966,6 +1027,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
966
1027
|
undefined,
|
|
967
1028
|
"mfa-verify",
|
|
968
1029
|
),
|
|
1030
|
+
mfaPreauthEnableStartRateLimit: createRedisLoginRateLimiter(
|
|
1031
|
+
redis,
|
|
1032
|
+
undefined,
|
|
1033
|
+
undefined,
|
|
1034
|
+
"mfa-preauth-start",
|
|
1035
|
+
),
|
|
969
1036
|
mfaPreauthConfirmRateLimit: createRedisLoginRateLimiter(
|
|
970
1037
|
redis,
|
|
971
1038
|
undefined,
|