@cosmicdrift/kumiko-server-runtime 0.159.1 → 0.161.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__/build-prod-bundle.inprocess.test.ts +166 -0
- package/src/__tests__/run-prod-app-dry-run.test.ts +158 -0
- package/src/__tests__/run-prod-app-static-files.test.ts +148 -0
- package/src/__tests__/run-prod-app.integration.test.ts +13 -4
- package/src/__tests__/session-boot-gate.test.ts +8 -40
- package/src/__tests__/session-wiring.test.ts +8 -43
- package/src/run-prod-app-boot-context.ts +16 -25
- package/src/run-prod-app.ts +16 -33
- package/src/session-boot-gate.ts +9 -18
- package/src/session-wiring.ts +7 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.161.0",
|
|
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.161.0",
|
|
76
|
+
"@cosmicdrift/kumiko-framework": "0.161.0",
|
|
77
77
|
"temporal-polyfill": "^0.3.2"
|
|
78
78
|
},
|
|
79
79
|
"publishConfig": {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// In-process buildProdBundle — covers Bun.build + HTML render + build-info
|
|
2
|
+
// in the same process (CLI subprocess tests do not contribute to lcov).
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { buildProdBundle } from "../build-prod-bundle";
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const REPO_ROOT = resolve(__dirname, "../../../..");
|
|
14
|
+
|
|
15
|
+
describe("buildProdBundle in-process (Bun.build)", () => {
|
|
16
|
+
let tmp = "";
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
tmp = await mkdtemp(join(tmpdir(), "kumiko-build-inproc-"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await rm(tmp, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function writeMinimalClientApp(opts?: { html?: string; client?: string }) {
|
|
27
|
+
await mkdir(join(tmp, "src"), { recursive: true });
|
|
28
|
+
await writeFile(
|
|
29
|
+
join(tmp, "src/client.ts"),
|
|
30
|
+
opts?.client ??
|
|
31
|
+
`const root = document.getElementById("root"); if (root) root.textContent = "hi";`,
|
|
32
|
+
);
|
|
33
|
+
await mkdir(join(tmp, "public"), { recursive: true });
|
|
34
|
+
await writeFile(
|
|
35
|
+
join(tmp, "public/index.html"),
|
|
36
|
+
opts?.html ??
|
|
37
|
+
`<!doctype html><html><head></head><body><div id="root"></div><script type="module" src="/client.js"></script></body></html>`,
|
|
38
|
+
);
|
|
39
|
+
await writeFile(join(tmp, "package.json"), `{"name":"inproc-fixture","private":true}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("client.ts → hashed bundle, manifest, build-info, injected script", async () => {
|
|
43
|
+
await writeMinimalClientApp();
|
|
44
|
+
|
|
45
|
+
const result = await buildProdBundle({ cwd: tmp, stylesheet: false });
|
|
46
|
+
|
|
47
|
+
expect(result.manifest["client.js"]).toMatch(/^\/assets\/client-[a-z0-9]+\.js$/);
|
|
48
|
+
expect(result.buildInfo?.id).toMatch(/^[0-9a-f]{12}$/);
|
|
49
|
+
expect(existsSync(join(tmp, "dist/build-info.json"))).toBe(true);
|
|
50
|
+
|
|
51
|
+
const html = await readFile(join(tmp, "dist/index.html"), "utf8");
|
|
52
|
+
expect(html).toContain(`src="${result.manifest["client.js"]}"`);
|
|
53
|
+
expect(html).toContain("__KUMIKO_BUILD__");
|
|
54
|
+
|
|
55
|
+
const assetPath = join(tmp, "dist", result.manifest["client.js"] ?? "");
|
|
56
|
+
expect(existsSync(assetPath)).toBe(true);
|
|
57
|
+
expect(await readFile(assetPath, "utf8")).toContain('"hi"');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("missing index.html → error with template snippet", async () => {
|
|
61
|
+
await mkdir(join(tmp, "src"), { recursive: true });
|
|
62
|
+
await writeFile(join(tmp, "src/client.ts"), `console.log("hi");`);
|
|
63
|
+
await writeFile(join(tmp, "package.json"), `{"name":"no-html","private":true}`);
|
|
64
|
+
|
|
65
|
+
await expect(buildProdBundle({ cwd: tmp, stylesheet: false })).rejects.toThrow(
|
|
66
|
+
/kein index\.html gefunden/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("index.html without /client.js placeholder → error with snippet", async () => {
|
|
71
|
+
await writeMinimalClientApp({
|
|
72
|
+
html: `<!doctype html><html><body>no script</body></html>`,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await expect(buildProdBundle({ cwd: tmp, stylesheet: false })).rejects.toThrow(
|
|
76
|
+
/keinen Entry-Tag für \/client\.js/,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("syntax-broken client → Bun.build rejects", async () => {
|
|
81
|
+
await writeMinimalClientApp({ client: `const x = {{{` });
|
|
82
|
+
|
|
83
|
+
// Bun may throw "Bundle failed" before returning `{ success: false }`.
|
|
84
|
+
await expect(buildProdBundle({ cwd: tmp, stylesheet: false })).rejects.toThrow(
|
|
85
|
+
/Bundle failed|Bun\.build failed/,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("stylesheet override src/styles.css → hashed styles.css in manifest", async () => {
|
|
90
|
+
// Temp under REPO_ROOT so @tailwindcss/cli + tailwindcss peer resolve
|
|
91
|
+
// (same constraint as renderer-web-css-relocation.integration.test.ts).
|
|
92
|
+
const dir = await mkdtemp(join(REPO_ROOT, ".inproc-styles-"));
|
|
93
|
+
const cwd = join(dir, "app");
|
|
94
|
+
try {
|
|
95
|
+
await mkdir(join(cwd, "src"), { recursive: true });
|
|
96
|
+
await writeFile(
|
|
97
|
+
join(cwd, "src/client.ts"),
|
|
98
|
+
`const root = document.getElementById("root"); if (root) root.textContent = "hi";`,
|
|
99
|
+
);
|
|
100
|
+
await writeFile(join(cwd, "src/styles.css"), "body { margin: 0; }\n");
|
|
101
|
+
await mkdir(join(cwd, "public"), { recursive: true });
|
|
102
|
+
await writeFile(
|
|
103
|
+
join(cwd, "public/index.html"),
|
|
104
|
+
`<!doctype html><html><head><link rel="stylesheet" href="/styles.css" /></head><body><div id="root"></div><script type="module" src="/client.js"></script></body></html>`,
|
|
105
|
+
);
|
|
106
|
+
await writeFile(join(cwd, "package.json"), `{"name":"inproc-styles","private":true}`);
|
|
107
|
+
|
|
108
|
+
const result = await buildProdBundle({ cwd, stylesheet: "src/styles.css" });
|
|
109
|
+
|
|
110
|
+
expect(result.manifest["styles.css"]).toMatch(/^\/assets\/styles-[a-z0-9]+\.css$/);
|
|
111
|
+
const cssPath = join(cwd, "dist", result.manifest["styles.css"] ?? "");
|
|
112
|
+
expect(existsSync(cssPath)).toBe(true);
|
|
113
|
+
expect(await readFile(cssPath, "utf8")).toMatch(/margin:\s*0/);
|
|
114
|
+
|
|
115
|
+
const html = await readFile(join(cwd, "dist/index.html"), "utf8");
|
|
116
|
+
expect(html).toContain(`href="${result.manifest["styles.css"]}"`);
|
|
117
|
+
expect(html).not.toContain('href="/styles.css"');
|
|
118
|
+
} finally {
|
|
119
|
+
await rm(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("missing stylesheet override → tailwind rejects", async () => {
|
|
124
|
+
const dir = await mkdtemp(join(REPO_ROOT, ".inproc-styles-miss-"));
|
|
125
|
+
const cwd = join(dir, "app");
|
|
126
|
+
try {
|
|
127
|
+
await mkdir(join(cwd, "src"), { recursive: true });
|
|
128
|
+
await writeFile(join(cwd, "src/client.ts"), `console.log("hi");`);
|
|
129
|
+
await mkdir(join(cwd, "public"), { recursive: true });
|
|
130
|
+
await writeFile(
|
|
131
|
+
join(cwd, "public/index.html"),
|
|
132
|
+
`<!doctype html><html><body><div id="root"></div><script type="module" src="/client.js"></script></body></html>`,
|
|
133
|
+
);
|
|
134
|
+
await writeFile(join(cwd, "package.json"), `{"name":"inproc-styles-miss","private":true}`);
|
|
135
|
+
|
|
136
|
+
await expect(buildProdBundle({ cwd, stylesheet: "src/missing-theme.css" })).rejects.toThrow(
|
|
137
|
+
/tailwind|Bundle failed|tailwindcss/i,
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
await rm(dir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("multi-entry client-admin + client-public → two hashed bundles", async () => {
|
|
145
|
+
await mkdir(join(tmp, "src"), { recursive: true });
|
|
146
|
+
await writeFile(join(tmp, "src/client-admin.ts"), `console.log("admin");`);
|
|
147
|
+
await writeFile(join(tmp, "src/client-public.ts"), `console.log("public");`);
|
|
148
|
+
await mkdir(join(tmp, "public"), { recursive: true });
|
|
149
|
+
await writeFile(
|
|
150
|
+
join(tmp, "public/index.html"),
|
|
151
|
+
`<!doctype html><html><body><script type="module" src="/client-public.js"></script></body></html>`,
|
|
152
|
+
);
|
|
153
|
+
await writeFile(
|
|
154
|
+
join(tmp, "admin.html"),
|
|
155
|
+
`<!doctype html><html><body><script type="module" src="/client-admin.js"></script></body></html>`,
|
|
156
|
+
);
|
|
157
|
+
await writeFile(join(tmp, "package.json"), `{"name":"multi-inproc","private":true}`);
|
|
158
|
+
|
|
159
|
+
const result = await buildProdBundle({ cwd: tmp, stylesheet: false });
|
|
160
|
+
|
|
161
|
+
expect(result.manifest["client-admin.js"]).toMatch(/^\/assets\/client-admin-/);
|
|
162
|
+
expect(result.manifest["client-public.js"]).toMatch(/^\/assets\/client-public-/);
|
|
163
|
+
expect(existsSync(join(tmp, "dist/admin.html"))).toBe(true);
|
|
164
|
+
expect(existsSync(join(tmp, "dist/index.html"))).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Dry-run + bootErrorReporter paths — no DB/Redis. envSource avoids process.exit(0).
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
createBooleanField,
|
|
6
|
+
createEntity,
|
|
7
|
+
createTextField,
|
|
8
|
+
defineFeature,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { composeEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { runProdApp } from "../run-prod-app";
|
|
13
|
+
|
|
14
|
+
const probeEntity = createEntity({
|
|
15
|
+
fields: {
|
|
16
|
+
name: createTextField({ required: true }),
|
|
17
|
+
active: createBooleanField({ default: true }),
|
|
18
|
+
},
|
|
19
|
+
table: "dry_run_probe",
|
|
20
|
+
});
|
|
21
|
+
|
|
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
|
+
describe("runProdApp dry-run / bootErrorReporter", () => {
|
|
36
|
+
const saved: Record<string, string | undefined> = {};
|
|
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
|
+
});
|
|
51
|
+
|
|
52
|
+
test("KUMIKO_DRY_RUN_ENV=human + envSource → render + dry-run handle (no exit)", async () => {
|
|
53
|
+
const logs: string[] = [];
|
|
54
|
+
const originalLog = console.log;
|
|
55
|
+
console.log = (...args: unknown[]) => {
|
|
56
|
+
logs.push(args.map(String).join(" "));
|
|
57
|
+
};
|
|
58
|
+
const envSchema = composeEnvSchema({ features: [probeFeature] });
|
|
59
|
+
let handle: Awaited<ReturnType<typeof runProdApp>>;
|
|
60
|
+
try {
|
|
61
|
+
handle = await runProdApp({
|
|
62
|
+
features: [probeFeature],
|
|
63
|
+
envSchema,
|
|
64
|
+
autoListen: false,
|
|
65
|
+
migrations: false,
|
|
66
|
+
envSource: { KUMIKO_DRY_RUN_ENV: "human" },
|
|
67
|
+
});
|
|
68
|
+
} finally {
|
|
69
|
+
console.log = originalLog;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(logs.some((l) => l.includes("DRY_RUN_PROBE") || l.includes("Optional"))).toBe(true);
|
|
73
|
+
const res = await handle!.fetch(new Request("http://test/"));
|
|
74
|
+
expect(res.status).toBe(503);
|
|
75
|
+
expect(await res.text()).toBe("dry-run");
|
|
76
|
+
await handle!.stop();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("KUMIKO_DRY_RUN_ENV=json → structured dry-run output", async () => {
|
|
80
|
+
const logs: string[] = [];
|
|
81
|
+
const originalLog = console.log;
|
|
82
|
+
console.log = (...args: unknown[]) => {
|
|
83
|
+
logs.push(args.map(String).join(" "));
|
|
84
|
+
};
|
|
85
|
+
const envSchema = composeEnvSchema({ features: [probeFeature] });
|
|
86
|
+
try {
|
|
87
|
+
const handle = await runProdApp({
|
|
88
|
+
features: [probeFeature],
|
|
89
|
+
envSchema,
|
|
90
|
+
autoListen: false,
|
|
91
|
+
migrations: false,
|
|
92
|
+
envSource: { KUMIKO_DRY_RUN_ENV: "json" },
|
|
93
|
+
});
|
|
94
|
+
await handle.stop();
|
|
95
|
+
} finally {
|
|
96
|
+
console.log = originalLog;
|
|
97
|
+
}
|
|
98
|
+
const joined = logs.join("\n");
|
|
99
|
+
expect(joined).toContain("required");
|
|
100
|
+
expect(joined).toContain("optional");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("unrecognized KUMIKO_DRY_RUN_ENV warns then hits envSchema parse", async () => {
|
|
104
|
+
const warnings: string[] = [];
|
|
105
|
+
const originalWarn = console.warn;
|
|
106
|
+
console.warn = (...args: unknown[]) => {
|
|
107
|
+
warnings.push(args.map(String).join(" "));
|
|
108
|
+
};
|
|
109
|
+
const envSchema = composeEnvSchema({
|
|
110
|
+
features: [],
|
|
111
|
+
extend: z.object({ MUST_HAVE: z.string().describe("required for test") }),
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
await expect(
|
|
115
|
+
runProdApp({
|
|
116
|
+
features: [probeFeature],
|
|
117
|
+
envSchema,
|
|
118
|
+
autoListen: false,
|
|
119
|
+
migrations: false,
|
|
120
|
+
envSource: { KUMIKO_DRY_RUN_ENV: "not-a-real-mode" },
|
|
121
|
+
bootErrorReporter: (err) => {
|
|
122
|
+
throw err;
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
).rejects.toBeInstanceOf(KumikoBootError);
|
|
126
|
+
} finally {
|
|
127
|
+
console.warn = originalWarn;
|
|
128
|
+
}
|
|
129
|
+
expect(warnings.some((w) => w.includes("unrecognized"))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("bootErrorReporter receives KumikoBootError instead of process.exit", async () => {
|
|
133
|
+
const envSchema = composeEnvSchema({
|
|
134
|
+
features: [],
|
|
135
|
+
extend: z.object({ MUST_HAVE: z.string().describe("required for test") }),
|
|
136
|
+
});
|
|
137
|
+
let reported: KumikoBootError | undefined;
|
|
138
|
+
await expect(
|
|
139
|
+
runProdApp({
|
|
140
|
+
features: [probeFeature],
|
|
141
|
+
envSchema,
|
|
142
|
+
autoListen: false,
|
|
143
|
+
migrations: false,
|
|
144
|
+
envSource: {
|
|
145
|
+
// no MUST_HAVE → parseEnv throws KumikoBootError
|
|
146
|
+
DATABASE_URL: "postgres://x",
|
|
147
|
+
REDIS_URL: "redis://x",
|
|
148
|
+
},
|
|
149
|
+
bootErrorReporter: (err) => {
|
|
150
|
+
reported = err;
|
|
151
|
+
throw err;
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
).rejects.toBeInstanceOf(KumikoBootError);
|
|
155
|
+
expect(reported).toBeInstanceOf(KumikoBootError);
|
|
156
|
+
expect(reported!.errors.some((e) => e.name === "MUST_HAVE")).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Unit coverage for mimeTypeFor + hostDispatch edge in buildStaticFallback.
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
buildStaticFallback,
|
|
9
|
+
mimeTypeFor,
|
|
10
|
+
readStaticFile,
|
|
11
|
+
serveDiskFile,
|
|
12
|
+
} from "../run-prod-app-static-files";
|
|
13
|
+
|
|
14
|
+
describe("mimeTypeFor", () => {
|
|
15
|
+
const cases: ReadonlyArray<readonly [string, string]> = [
|
|
16
|
+
["x.html", "text/html; charset=utf-8"],
|
|
17
|
+
["x.js", "text/javascript; charset=utf-8"],
|
|
18
|
+
["x.mjs", "text/javascript; charset=utf-8"],
|
|
19
|
+
["x.css", "text/css; charset=utf-8"],
|
|
20
|
+
["x.json", "application/json; charset=utf-8"],
|
|
21
|
+
["x.svg", "image/svg+xml"],
|
|
22
|
+
["x.png", "image/png"],
|
|
23
|
+
["x.jpg", "image/jpeg"],
|
|
24
|
+
["x.jpeg", "image/jpeg"],
|
|
25
|
+
["x.ico", "image/x-icon"],
|
|
26
|
+
["x.txt", "text/plain; charset=utf-8"],
|
|
27
|
+
["x.xml", "application/xml; charset=utf-8"],
|
|
28
|
+
["x.webmanifest", "application/manifest+json"],
|
|
29
|
+
["x.bin", "application/octet-stream"],
|
|
30
|
+
["noext", "application/octet-stream"],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const [path, mime] of cases) {
|
|
34
|
+
test(`${path} → ${mime}`, () => {
|
|
35
|
+
expect(mimeTypeFor(path)).toBe(mime);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("readStaticFile / serveDiskFile", () => {
|
|
41
|
+
let tmp = "";
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
tmp = await mkdtemp(join(tmpdir(), "kumiko-static-"));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
await rm(tmp, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("readStaticFile returns bytes+mime; ENOENT → undefined", async () => {
|
|
52
|
+
const path = join(tmp, "a.css");
|
|
53
|
+
await writeFile(path, "body{}");
|
|
54
|
+
const file = await readStaticFile(path);
|
|
55
|
+
expect(file?.mime).toBe("text/css; charset=utf-8");
|
|
56
|
+
expect(new TextDecoder().decode(file!.bytes)).toBe("body{}");
|
|
57
|
+
expect(await readStaticFile(join(tmp, "missing.css"))).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("serveDiskFile sets content-type from mime", async () => {
|
|
61
|
+
const path = join(tmp, "a.svg");
|
|
62
|
+
await writeFile(path, "<svg/>");
|
|
63
|
+
const file = await readStaticFile(path);
|
|
64
|
+
const res = serveDiskFile(new Request("http://t/a.svg"), "/a.svg", file!);
|
|
65
|
+
expect(res.headers.get("content-type")).toBe("image/svg+xml");
|
|
66
|
+
expect(await res.text()).toBe("<svg/>");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("buildStaticFallback hostDispatch", () => {
|
|
71
|
+
let tmp = "";
|
|
72
|
+
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
tmp = await mkdtemp(join(tmpdir(), "kumiko-fallback-"));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(async () => {
|
|
78
|
+
await rm(tmp, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("hostDispatch html pointing at missing file → 500", async () => {
|
|
82
|
+
const handler = buildStaticFallback(
|
|
83
|
+
() => new Response("api-404", { status: 404 }),
|
|
84
|
+
tmp,
|
|
85
|
+
"{}",
|
|
86
|
+
() => ({ kind: "html", file: "gone.html" }),
|
|
87
|
+
);
|
|
88
|
+
const res = await handler(new Request("http://t/"));
|
|
89
|
+
expect(res.status).toBe(500);
|
|
90
|
+
expect(await res.text()).toContain("hostDispatch: file not found: gone.html");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("hostDispatch not-found → 404; redirect → 302", async () => {
|
|
94
|
+
const notFound = buildStaticFallback(
|
|
95
|
+
() => new Response("x", { status: 404 }),
|
|
96
|
+
tmp,
|
|
97
|
+
"{}",
|
|
98
|
+
() => ({ kind: "not-found" }),
|
|
99
|
+
);
|
|
100
|
+
expect((await notFound(new Request("http://t/"))).status).toBe(404);
|
|
101
|
+
|
|
102
|
+
const redirect = buildStaticFallback(
|
|
103
|
+
() => new Response("x", { status: 404 }),
|
|
104
|
+
tmp,
|
|
105
|
+
"{}",
|
|
106
|
+
() => ({ kind: "redirect", to: "https://example.com/", status: 301 }),
|
|
107
|
+
);
|
|
108
|
+
const res = await redirect(new Request("http://t/"));
|
|
109
|
+
expect(res.status).toBe(301);
|
|
110
|
+
expect(res.headers.get("location")).toBe("https://example.com/");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("/api/* always hits apiHandler", async () => {
|
|
114
|
+
const handler = buildStaticFallback(() => new Response("from-api", { status: 200 }), tmp, "{}");
|
|
115
|
+
const res = await handler(new Request("http://t/api/query", { method: "POST" }));
|
|
116
|
+
expect(res.status).toBe(200);
|
|
117
|
+
expect(await res.text()).toBe("from-api");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("serves disk asset under staticDir", async () => {
|
|
121
|
+
await writeFile(join(tmp, "logo.png"), "PNGDATA");
|
|
122
|
+
const handler = buildStaticFallback(() => new Response("404", { status: 404 }), tmp, "{}");
|
|
123
|
+
const res = await handler(new Request("http://t/logo.png"));
|
|
124
|
+
expect(res.status).toBe(200);
|
|
125
|
+
expect(res.headers.get("content-type")).toBe("image/png");
|
|
126
|
+
expect(await res.text()).toBe("PNGDATA");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("hostDispatch html with CSP + Vary: Host", async () => {
|
|
130
|
+
await writeFile(join(tmp, "tenant.html"), "<!doctype html><html><body>ok</body></html>");
|
|
131
|
+
const handler = buildStaticFallback(
|
|
132
|
+
() => new Response("404", { status: 404 }),
|
|
133
|
+
tmp,
|
|
134
|
+
'{"screens":[]}',
|
|
135
|
+
() => ({
|
|
136
|
+
kind: "html",
|
|
137
|
+
file: "tenant.html",
|
|
138
|
+
injectSchema: false,
|
|
139
|
+
csp: "default-src 'self'",
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
const res = await handler(new Request("http://t/", { headers: { host: "a.example" } }));
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
expect(res.headers.get("vary")).toBe("Host");
|
|
145
|
+
expect(res.headers.get("content-security-policy")).toBe("default-src 'self'");
|
|
146
|
+
expect(await res.text()).toContain("ok");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -823,7 +823,12 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
823
823
|
test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
|
|
824
824
|
await expect(
|
|
825
825
|
boot(undefined, {
|
|
826
|
-
|
|
826
|
+
features: [
|
|
827
|
+
authFoundationFeature,
|
|
828
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
829
|
+
createSessionsFeature(),
|
|
830
|
+
],
|
|
831
|
+
auth: { admin: ADMIN, cookieDomain: "example.eu" },
|
|
827
832
|
allowPlaintextPii: "test: origin-guard focus, not crypto",
|
|
828
833
|
}),
|
|
829
834
|
).rejects.toThrow(/allowedOrigins is empty/);
|
|
@@ -836,11 +841,15 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
836
841
|
let bootError: unknown;
|
|
837
842
|
try {
|
|
838
843
|
const handle = await boot(undefined, {
|
|
844
|
+
features: [
|
|
845
|
+
authFoundationFeature,
|
|
846
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
847
|
+
createSessionsFeature(),
|
|
848
|
+
],
|
|
839
849
|
auth: {
|
|
840
850
|
admin: ADMIN,
|
|
841
851
|
cookieDomain: "example.eu",
|
|
842
852
|
allowedOrigins: ["https://app.example.eu"],
|
|
843
|
-
sessions: false,
|
|
844
853
|
},
|
|
845
854
|
});
|
|
846
855
|
expect(handle).toBeDefined();
|
|
@@ -861,7 +870,7 @@ describe("runProdApp — session boot gate (#1262/#1275)", () => {
|
|
|
861
870
|
memberships: [],
|
|
862
871
|
};
|
|
863
872
|
|
|
864
|
-
test("auth mounted, sessions feature missing
|
|
873
|
+
test("auth mounted, sessions feature missing → aborts boot", async () => {
|
|
865
874
|
await expect(
|
|
866
875
|
boot(undefined, {
|
|
867
876
|
auth: {
|
|
@@ -871,7 +880,7 @@ describe("runProdApp — session boot gate (#1262/#1275)", () => {
|
|
|
871
880
|
},
|
|
872
881
|
allowPlaintextPii: "test: session-gate focus, not crypto",
|
|
873
882
|
}),
|
|
874
|
-
).rejects.toThrow(/BOOT ABORTED.*
|
|
883
|
+
).rejects.toThrow(/BOOT ABORTED.*sessionStore/);
|
|
875
884
|
});
|
|
876
885
|
|
|
877
886
|
test("auth mounted, sessions feature mounted → boots cleanly (the happy path the gate guards)", async () => {
|
|
@@ -1,54 +1,22 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { assertSessionBootInvariants } from "../session-boot-gate";
|
|
3
3
|
|
|
4
|
-
describe("assertSessionBootInvariants", () => {
|
|
5
|
-
test("no auth
|
|
4
|
+
describe("assertSessionBootInvariants (#1372)", () => {
|
|
5
|
+
test("no auth → no throw", () => {
|
|
6
6
|
expect(() =>
|
|
7
|
-
assertSessionBootInvariants({
|
|
8
|
-
hasAuth: false,
|
|
9
|
-
sessionsFeatureMounted: false,
|
|
10
|
-
sessionsOption: undefined,
|
|
11
|
-
}),
|
|
7
|
+
assertSessionBootInvariants({ hasAuth: false, sessionStoreProviderMounted: false }),
|
|
12
8
|
).not.toThrow();
|
|
13
9
|
});
|
|
14
10
|
|
|
15
|
-
test("auth
|
|
11
|
+
test("auth + sessionStore → no throw", () => {
|
|
16
12
|
expect(() =>
|
|
17
|
-
assertSessionBootInvariants({
|
|
18
|
-
hasAuth: true,
|
|
19
|
-
sessionsFeatureMounted: false,
|
|
20
|
-
sessionsOption: undefined,
|
|
21
|
-
}),
|
|
22
|
-
).toThrow(/BOOT ABORTED.*sessions.*stateless/s);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("auth mounted, sessions feature missing, explicit sessions:false → boots", () => {
|
|
26
|
-
expect(() =>
|
|
27
|
-
assertSessionBootInvariants({
|
|
28
|
-
hasAuth: true,
|
|
29
|
-
sessionsFeatureMounted: false,
|
|
30
|
-
sessionsOption: false,
|
|
31
|
-
}),
|
|
32
|
-
).not.toThrow();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("auth mounted, sessions feature wired → boots", () => {
|
|
36
|
-
expect(() =>
|
|
37
|
-
assertSessionBootInvariants({
|
|
38
|
-
hasAuth: true,
|
|
39
|
-
sessionsFeatureMounted: true,
|
|
40
|
-
sessionsOption: undefined,
|
|
41
|
-
}),
|
|
13
|
+
assertSessionBootInvariants({ hasAuth: true, sessionStoreProviderMounted: true }),
|
|
42
14
|
).not.toThrow();
|
|
43
15
|
});
|
|
44
16
|
|
|
45
|
-
test("auth
|
|
17
|
+
test("auth without sessionStore → throws", () => {
|
|
46
18
|
expect(() =>
|
|
47
|
-
assertSessionBootInvariants({
|
|
48
|
-
|
|
49
|
-
sessionsFeatureMounted: true,
|
|
50
|
-
sessionsOption: { expiresInMs: 60_000 },
|
|
51
|
-
}),
|
|
52
|
-
).not.toThrow();
|
|
19
|
+
assertSessionBootInvariants({ hasAuth: true, sessionStoreProviderMounted: false }),
|
|
20
|
+
).toThrow(/BOOT ABORTED/);
|
|
53
21
|
});
|
|
54
22
|
});
|
|
@@ -1,51 +1,16 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
createSessionsFeature,
|
|
4
|
-
SESSIONS_FEATURE,
|
|
5
|
-
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
6
|
-
import { resolveProdSessionsConfig, shouldWireProdSessions } from "../session-wiring";
|
|
2
|
+
import { shouldWireProdSessions } from "../session-wiring";
|
|
7
3
|
|
|
8
|
-
describe("shouldWireProdSessions — secure-by-default
|
|
9
|
-
it("wires
|
|
10
|
-
|
|
11
|
-
// previously left stateless (no revocation). Now it wires automatically.
|
|
12
|
-
expect(shouldWireProdSessions(true, true, undefined)).toBe(true);
|
|
4
|
+
describe("shouldWireProdSessions — secure-by-default (#1372)", () => {
|
|
5
|
+
it("wires when auth + sessionStore provider mounted", () => {
|
|
6
|
+
expect(shouldWireProdSessions(true, true)).toBe(true);
|
|
13
7
|
});
|
|
14
8
|
|
|
15
|
-
it("
|
|
16
|
-
expect(shouldWireProdSessions(
|
|
9
|
+
it("does not wire without auth", () => {
|
|
10
|
+
expect(shouldWireProdSessions(false, true)).toBe(false);
|
|
17
11
|
});
|
|
18
12
|
|
|
19
|
-
it("does not wire
|
|
20
|
-
expect(shouldWireProdSessions(true,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("does not wire when the sessions feature is not mounted", () => {
|
|
24
|
-
expect(shouldWireProdSessions(true, false, undefined)).toBe(false);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("does not wire when the app has no auth at all", () => {
|
|
28
|
-
expect(shouldWireProdSessions(false, true, undefined)).toBe(false);
|
|
29
|
-
});
|
|
30
|
-
});
|
|
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
|
-
|
|
42
|
-
describe("resolveProdSessionsConfig", () => {
|
|
43
|
-
it("passes a config object through", () => {
|
|
44
|
-
expect(resolveProdSessionsConfig({ expiresInMs: 5000 })).toEqual({ expiresInMs: 5000 });
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("collapses false / undefined to defaults", () => {
|
|
48
|
-
expect(resolveProdSessionsConfig(undefined)).toEqual({});
|
|
49
|
-
expect(resolveProdSessionsConfig(false)).toEqual({});
|
|
13
|
+
it("does not wire without sessionStore provider", () => {
|
|
14
|
+
expect(shouldWireProdSessions(true, false)).toBe(false);
|
|
50
15
|
});
|
|
51
16
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { makeAuthPaths } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
2
|
+
import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
2
3
|
import { bindMfaRevokeAllOtherSessionsFromFeature } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
3
4
|
import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
4
5
|
import {
|
|
@@ -15,10 +16,7 @@ import {
|
|
|
15
16
|
createSecretsContext,
|
|
16
17
|
SECRETS_FEATURE_NAME,
|
|
17
18
|
} from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
18
|
-
import {
|
|
19
|
-
bindAutoRevokeFromFeature,
|
|
20
|
-
createSessionCallbacks,
|
|
21
|
-
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
19
|
+
import { bindAutoRevokeFromFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
22
20
|
import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
23
21
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
24
22
|
import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
@@ -38,7 +36,6 @@ import type {
|
|
|
38
36
|
PasswordResetSetup,
|
|
39
37
|
SignupSetup,
|
|
40
38
|
} from "./run-prod-app";
|
|
41
|
-
import type { ProdSessionsConfig } from "./session-wiring";
|
|
42
39
|
|
|
43
40
|
// Boot-time context helpers for runProdApp: ctx-extra-context wiring
|
|
44
41
|
// (textContent/delivery/secrets/config-resolver), auth-mail convenience
|
|
@@ -214,33 +211,27 @@ export function resolveAuthMail<T extends AuthMailNormalizable>(
|
|
|
214
211
|
};
|
|
215
212
|
}
|
|
216
213
|
|
|
217
|
-
export function buildProdSessionAuth(
|
|
214
|
+
export async function buildProdSessionAuth(
|
|
218
215
|
db: DbConnection,
|
|
219
|
-
|
|
216
|
+
registry: Registry,
|
|
220
217
|
sessionsFeature: FeatureDefinition | undefined,
|
|
221
218
|
mfaFeature: FeatureDefinition | undefined,
|
|
222
|
-
): {
|
|
223
|
-
readonly sessionCreator: ReturnType<typeof
|
|
224
|
-
readonly sessionRevoker: ReturnType<typeof
|
|
225
|
-
readonly sessionChecker: ReturnType<typeof
|
|
226
|
-
} {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
|
|
230
|
-
});
|
|
231
|
-
// Secure-by-default: password-change/-reset mass-revokes the user's live
|
|
232
|
-
// sessions without the app opting in via autoRevokeOnPasswordChange.
|
|
219
|
+
): Promise<{
|
|
220
|
+
readonly sessionCreator: Awaited<ReturnType<typeof resolveSessionStore>>["creator"];
|
|
221
|
+
readonly sessionRevoker: Awaited<ReturnType<typeof resolveSessionStore>>["revoker"];
|
|
222
|
+
readonly sessionChecker: Awaited<ReturnType<typeof resolveSessionStore>>["checker"];
|
|
223
|
+
}> {
|
|
224
|
+
// Resolve the sessions feature sessionStore provider (#1372).
|
|
225
|
+
const store = await resolveSessionStore({ db, registry });
|
|
233
226
|
if (sessionsFeature) {
|
|
234
|
-
bindAutoRevokeFromFeature(sessionsFeature)?.(
|
|
227
|
+
bindAutoRevokeFromFeature(sessionsFeature)?.(store.massRevoker);
|
|
235
228
|
}
|
|
236
|
-
// MFA enable/disable/regenerate mass-revokes every OTHER live session
|
|
237
|
-
// (stolen-session defense) — only wired when auth-mfa is mounted.
|
|
238
229
|
if (mfaFeature) {
|
|
239
|
-
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(
|
|
230
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(store.revokeAllOthers);
|
|
240
231
|
}
|
|
241
232
|
return {
|
|
242
|
-
sessionCreator:
|
|
243
|
-
sessionRevoker:
|
|
244
|
-
sessionChecker:
|
|
233
|
+
sessionCreator: store.creator,
|
|
234
|
+
sessionRevoker: store.revoker,
|
|
235
|
+
sessionChecker: store.checker,
|
|
245
236
|
};
|
|
246
237
|
}
|
package/src/run-prod-app.ts
CHANGED
|
@@ -46,7 +46,9 @@ import {
|
|
|
46
46
|
seedAdmin,
|
|
47
47
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
48
48
|
import {
|
|
49
|
+
EXT_SESSION_STORE,
|
|
49
50
|
EXT_TOKEN_VERIFIER,
|
|
51
|
+
resolveAnonymousAccessFromRegistry,
|
|
50
52
|
resolveTokenVerifier,
|
|
51
53
|
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
52
54
|
import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
@@ -140,11 +142,7 @@ import {
|
|
|
140
142
|
import { buildStaticFallback } from "./run-prod-app-static-files";
|
|
141
143
|
import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
|
|
142
144
|
import { assertSessionBootInvariants } from "./session-boot-gate";
|
|
143
|
-
import {
|
|
144
|
-
type ProdSessionsOption,
|
|
145
|
-
resolveProdSessionsConfig,
|
|
146
|
-
shouldWireProdSessions,
|
|
147
|
-
} from "./session-wiring";
|
|
145
|
+
import { shouldWireProdSessions } from "./session-wiring";
|
|
148
146
|
|
|
149
147
|
export { buildBunServeOptions } from "./bun-serve-options";
|
|
150
148
|
export {
|
|
@@ -287,14 +285,6 @@ export type RunProdAppAuthOptions = {
|
|
|
287
285
|
readonly admin: SeedAdminOptions;
|
|
288
286
|
/** Optional override of the login error → HTTP status map. */
|
|
289
287
|
readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
|
|
290
|
-
/** Opt-in: revocable server-side sessions. Caller MUSS
|
|
291
|
-
* `createSessionsFeature()` zu `features` adden — runProdApp wired
|
|
292
|
-
* hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
|
|
293
|
-
* echte db-connection (sidless JWTs werden dann abgelehnt).
|
|
294
|
-
*
|
|
295
|
-
* Standardverhalten ohne diese Option: stateless JWTs ohne sid
|
|
296
|
-
* (legacy-Verhalten, Kartenhaus existing-Apps unangefasst). */
|
|
297
|
-
readonly sessions?: ProdSessionsOption;
|
|
298
288
|
/** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
|
|
299
289
|
* emailVerification, signup, invite) aus `auth.mail.baseUrl` + Standard-
|
|
300
290
|
* Pfaden. Alle vier mailen via delivery (ctx.notify) — ersetzt das per-App
|
|
@@ -368,10 +358,8 @@ export type RunProdAppDeps = {
|
|
|
368
358
|
};
|
|
369
359
|
|
|
370
360
|
export type AnonymousAccessOption =
|
|
371
|
-
| import("@cosmicdrift/kumiko-framework/api").
|
|
372
|
-
| ((
|
|
373
|
-
deps: RunProdAppDeps,
|
|
374
|
-
) => import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]);
|
|
361
|
+
| import("@cosmicdrift/kumiko-framework/api").AnonymousAccessConfig
|
|
362
|
+
| ((deps: RunProdAppDeps) => import("@cosmicdrift/kumiko-framework/api").AnonymousAccessConfig);
|
|
375
363
|
|
|
376
364
|
export type ExtraContextOption =
|
|
377
365
|
| Record<string, unknown>
|
|
@@ -726,12 +714,11 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
726
714
|
mode: "prod",
|
|
727
715
|
});
|
|
728
716
|
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
717
|
+
const registry = createRegistry(features);
|
|
729
718
|
assertSessionBootInvariants({
|
|
730
719
|
hasAuth: Boolean(effectiveAuth),
|
|
731
|
-
|
|
732
|
-
sessionsOption: effectiveAuth?.sessions,
|
|
720
|
+
sessionStoreProviderMounted: registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
|
|
733
721
|
});
|
|
734
|
-
const registry = createRegistry(features);
|
|
735
722
|
|
|
736
723
|
// C1 boot-mode exit: validators ran + registry built; no DB/Redis client
|
|
737
724
|
// is constructed at all in this branch (the eager `new Redis(...)` below
|
|
@@ -861,10 +848,15 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
861
848
|
{ ...autoExtraContext, ...resolvedExtraContext },
|
|
862
849
|
registry,
|
|
863
850
|
);
|
|
864
|
-
const
|
|
851
|
+
const baseAnonymousAccess =
|
|
865
852
|
typeof options.anonymousAccess === "function"
|
|
866
853
|
? options.anonymousAccess(deps)
|
|
867
854
|
: options.anonymousAccess;
|
|
855
|
+
// #1374: tenantResolver / tenantExists come from auth-foundation providers.
|
|
856
|
+
const resolvedAnonymousAccess = await resolveAnonymousAccessFromRegistry(baseAnonymousAccess, {
|
|
857
|
+
db,
|
|
858
|
+
registry,
|
|
859
|
+
});
|
|
868
860
|
|
|
869
861
|
// Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
|
|
870
862
|
// also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
|
|
@@ -874,22 +866,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
874
866
|
// AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
|
|
875
867
|
// sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
|
|
876
868
|
// über die auth-routes.
|
|
877
|
-
// Secure-by-default:
|
|
878
|
-
// auto-revoke-on-password-change are wired automatically;
|
|
879
|
-
// `auth.sessions` only overrides the config, and `auth.sessions: false` is the
|
|
880
|
-
// explicit opt-out (back to stateless JWTs).
|
|
869
|
+
// Secure-by-default (#1372): sessionStore provider → resolveSessionStore.
|
|
881
870
|
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
882
871
|
const sessionAuthFragment = shouldWireProdSessions(
|
|
883
872
|
Boolean(effectiveAuth),
|
|
884
|
-
|
|
885
|
-
effectiveAuth?.sessions,
|
|
873
|
+
registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
|
|
886
874
|
)
|
|
887
|
-
? buildProdSessionAuth(
|
|
888
|
-
db,
|
|
889
|
-
resolveProdSessionsConfig(effectiveAuth?.sessions),
|
|
890
|
-
sessionsFeature,
|
|
891
|
-
mfaFeature,
|
|
892
|
-
)
|
|
875
|
+
? await buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature)
|
|
893
876
|
: undefined;
|
|
894
877
|
|
|
895
878
|
// Token-verifier opt-in: any provider feature (personal-access-tokens, a
|
package/src/session-boot-gate.ts
CHANGED
|
@@ -1,29 +1,20 @@
|
|
|
1
|
-
import type { ProdSessionsOption } from "./session-wiring";
|
|
2
|
-
|
|
3
1
|
export type SessionBootGateOptions = {
|
|
4
2
|
readonly hasAuth: boolean;
|
|
5
|
-
readonly
|
|
6
|
-
readonly sessionsOption: ProdSessionsOption | undefined;
|
|
3
|
+
readonly sessionStoreProviderMounted: boolean;
|
|
7
4
|
};
|
|
8
5
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// already the sanctioned opt-out (see session-wiring.ts) — reusing it here
|
|
13
|
-
// instead of inventing a second acknowledgment param.
|
|
6
|
+
// Catch a forgotten sessions mount at boot instead of silently degrading
|
|
7
|
+
// into stateless JWTs (#1372). Mount createSessionsFeature() for revocable
|
|
8
|
+
// sessions; there is no auth.sessions opt-out anymore.
|
|
14
9
|
export function assertSessionBootInvariants(opts: SessionBootGateOptions): void {
|
|
15
10
|
// skip: no auth mounted — nothing to gate.
|
|
16
11
|
if (!opts.hasAuth) return;
|
|
17
|
-
// skip:
|
|
18
|
-
if (opts.
|
|
19
|
-
// skip: sessions feature is wired.
|
|
20
|
-
if (opts.sessionsFeatureMounted) return;
|
|
12
|
+
// skip: sessionStore provider is wired (sessions feature).
|
|
13
|
+
if (opts.sessionStoreProviderMounted) return;
|
|
21
14
|
|
|
22
15
|
throw new Error(
|
|
23
|
-
"[runProdApp] BOOT ABORTED — auth is mounted but
|
|
24
|
-
"JWTs would be stateless (no server-side revocation
|
|
25
|
-
"
|
|
26
|
-
"(@cosmicdrift/kumiko-bundled-features/sessions) for revocable sessions, or pass " +
|
|
27
|
-
"{ auth: { sessions: false } } to acknowledge stateless JWTs are intentional.",
|
|
16
|
+
"[runProdApp] BOOT ABORTED — auth is mounted but no sessionStore provider is registered. " +
|
|
17
|
+
"JWTs would be stateless (no server-side revocation). Mount createSessionsFeature() " +
|
|
18
|
+
"(@cosmicdrift/kumiko-bundled-features/sessions) alongside auth-foundation.",
|
|
28
19
|
);
|
|
29
20
|
}
|
package/src/session-wiring.ts
CHANGED
|
@@ -2,28 +2,16 @@
|
|
|
2
2
|
* runProdApp session-wiring decision (extracted pure so it is testable without a
|
|
3
3
|
* full prod boot).
|
|
4
4
|
*
|
|
5
|
-
* Secure-by-default: mounting
|
|
6
|
-
* revocation ON automatically
|
|
7
|
-
* `auth.sessions`
|
|
8
|
-
*
|
|
5
|
+
* Secure-by-default (#1372): mounting a sessionStore provider (sessions feature)
|
|
6
|
+
* turns server-side session revocation ON automatically. There is no
|
|
7
|
+
* `auth.sessions` opt-in/opt-out — mount sessions for revocable JWTs, omit it
|
|
8
|
+
* for intentional stateless JWTs (boot-gate warns / aborts unless acknowledged
|
|
9
|
+
* via a future flag if needed; today auth without sessions fails the gate).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
|
-
export type ProdSessionsConfig = { readonly expiresInMs?: number };
|
|
12
|
-
|
|
13
|
-
/** Config object to override defaults, or `false` to disable session wiring entirely. */
|
|
14
|
-
export type ProdSessionsOption = ProdSessionsConfig | false;
|
|
15
|
-
|
|
16
12
|
export function shouldWireProdSessions(
|
|
17
13
|
hasAuth: boolean,
|
|
18
|
-
|
|
19
|
-
sessionsOption: ProdSessionsOption | undefined,
|
|
14
|
+
sessionStoreProviderMounted: boolean,
|
|
20
15
|
): boolean {
|
|
21
|
-
return hasAuth &&
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** The config passed to buildProdSessionAuth — `false`/absent collapse to defaults. */
|
|
25
|
-
export function resolveProdSessionsConfig(
|
|
26
|
-
sessionsOption: ProdSessionsOption | undefined,
|
|
27
|
-
): ProdSessionsConfig {
|
|
28
|
-
return sessionsOption || {};
|
|
16
|
+
return hasAuth && sessionStoreProviderMounted;
|
|
29
17
|
}
|