@cosmicdrift/kumiko-server-runtime 0.157.2 → 0.159.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__/run-prod-app.integration.test.ts +110 -1
- package/src/__tests__/security-headers.test.ts +88 -0
- package/src/__tests__/session-boot-gate.test.ts +54 -0
- package/src/boot/boot-crypto.ts +5 -20
- package/src/compose-features.ts +13 -1
- package/src/index.ts +2 -0
- package/src/run-prod-app-boot-context.ts +7 -2
- package/src/run-prod-app.ts +97 -27
- package/src/security-headers.ts +67 -0
- package/src/session-boot-gate.ts +29 -0
- package/src/session-wiring.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.159.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.159.1",
|
|
76
|
+
"@cosmicdrift/kumiko-framework": "0.159.1",
|
|
77
77
|
"temporal-polyfill": "^0.3.2"
|
|
78
78
|
},
|
|
79
79
|
"publishConfig": {
|
|
@@ -13,6 +13,13 @@ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
|
13
13
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
17
|
+
import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
18
|
+
import {
|
|
19
|
+
createSessionsFeature,
|
|
20
|
+
userSessionEntity,
|
|
21
|
+
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
22
|
+
import { userEntity } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
16
23
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
24
|
import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
18
25
|
import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
@@ -26,6 +33,10 @@ import {
|
|
|
26
33
|
createArchivedStreamsTable,
|
|
27
34
|
createEventsTable,
|
|
28
35
|
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
36
|
+
import {
|
|
37
|
+
createNoopProvider,
|
|
38
|
+
createPrometheusMeter,
|
|
39
|
+
} from "@cosmicdrift/kumiko-framework/observability";
|
|
29
40
|
import {
|
|
30
41
|
createEventConsumerStateTable,
|
|
31
42
|
createProjectionStateTable,
|
|
@@ -196,6 +207,8 @@ async function migrateTestDb(): Promise<void> {
|
|
|
196
207
|
await createProjectionStateTable(db);
|
|
197
208
|
await createEventConsumerStateTable(db);
|
|
198
209
|
await unsafeEnsureEntityTable(db, widgetEntity, "widget");
|
|
210
|
+
await unsafeEnsureEntityTable(db, userEntity, "user");
|
|
211
|
+
await unsafeEnsureEntityTable(db, userSessionEntity, "user-session");
|
|
199
212
|
await asRawClient(db).unsafe(
|
|
200
213
|
`CREATE TABLE IF NOT EXISTS prod_probe_pings (
|
|
201
214
|
id BIGSERIAL PRIMARY KEY,
|
|
@@ -250,6 +263,26 @@ describe("runProdApp", () => {
|
|
|
250
263
|
expect(res.status).toBe(200);
|
|
251
264
|
});
|
|
252
265
|
|
|
266
|
+
test("unrecognized KUMIKO_DRY_RUN_ENV value warns and falls through to a normal boot", async () => {
|
|
267
|
+
const originalWarn = console.warn;
|
|
268
|
+
const warnings: string[] = [];
|
|
269
|
+
console.warn = (...args: unknown[]) => {
|
|
270
|
+
warnings.push(args.map(String).join(" "));
|
|
271
|
+
};
|
|
272
|
+
const original = process.env["KUMIKO_DRY_RUN_ENV"];
|
|
273
|
+
process.env["KUMIKO_DRY_RUN_ENV"] = "not-a-real-mode";
|
|
274
|
+
try {
|
|
275
|
+
const handle = await boot();
|
|
276
|
+
const res = await handle.entrypoint.app.fetch(new Request("http://test/health"));
|
|
277
|
+
expect(res.status).toBe(200);
|
|
278
|
+
} finally {
|
|
279
|
+
console.warn = originalWarn;
|
|
280
|
+
if (original === undefined) delete process.env["KUMIKO_DRY_RUN_ENV"];
|
|
281
|
+
else process.env["KUMIKO_DRY_RUN_ENV"] = original;
|
|
282
|
+
}
|
|
283
|
+
expect(warnings.some((line) => line.includes("unrecognized"))).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
|
|
253
286
|
test("second boot against the same DB is idempotent — no crash, no duplicate tables", async () => {
|
|
254
287
|
await boot();
|
|
255
288
|
// First boot left tables in place. Restart on the same DB —
|
|
@@ -790,7 +823,7 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
790
823
|
test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
|
|
791
824
|
await expect(
|
|
792
825
|
boot(undefined, {
|
|
793
|
-
auth: { admin: ADMIN, cookieDomain: "example.eu" },
|
|
826
|
+
auth: { admin: ADMIN, cookieDomain: "example.eu", sessions: false },
|
|
794
827
|
allowPlaintextPii: "test: origin-guard focus, not crypto",
|
|
795
828
|
}),
|
|
796
829
|
).rejects.toThrow(/allowedOrigins is empty/);
|
|
@@ -807,6 +840,7 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
807
840
|
admin: ADMIN,
|
|
808
841
|
cookieDomain: "example.eu",
|
|
809
842
|
allowedOrigins: ["https://app.example.eu"],
|
|
843
|
+
sessions: false,
|
|
810
844
|
},
|
|
811
845
|
});
|
|
812
846
|
expect(handle).toBeDefined();
|
|
@@ -819,6 +853,48 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
819
853
|
});
|
|
820
854
|
});
|
|
821
855
|
|
|
856
|
+
describe("runProdApp — session boot gate (#1262/#1275)", () => {
|
|
857
|
+
const ADMIN = {
|
|
858
|
+
email: "session-gate@example.eu",
|
|
859
|
+
password: "test-pw-strong-1234",
|
|
860
|
+
displayName: "Admin",
|
|
861
|
+
memberships: [],
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
test("auth mounted, sessions feature missing, no opt-out → aborts boot", async () => {
|
|
865
|
+
await expect(
|
|
866
|
+
boot(undefined, {
|
|
867
|
+
auth: {
|
|
868
|
+
admin: ADMIN,
|
|
869
|
+
cookieDomain: "example.eu",
|
|
870
|
+
allowedOrigins: ["https://app.example.eu"],
|
|
871
|
+
},
|
|
872
|
+
allowPlaintextPii: "test: session-gate focus, not crypto",
|
|
873
|
+
}),
|
|
874
|
+
).rejects.toThrow(/BOOT ABORTED.*sessions.*stateless/s);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("auth mounted, sessions feature mounted → boots cleanly (the happy path the gate guards)", async () => {
|
|
878
|
+
const handle = await boot(undefined, {
|
|
879
|
+
// "user" is auto-mounted via includeBundled whenever auth.admin is set.
|
|
880
|
+
// sessions requires auth-foundation, which needs a tokenVerifier
|
|
881
|
+
// provider — PAT.
|
|
882
|
+
features: [
|
|
883
|
+
authFoundationFeature,
|
|
884
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
885
|
+
createSessionsFeature(),
|
|
886
|
+
],
|
|
887
|
+
auth: {
|
|
888
|
+
admin: ADMIN,
|
|
889
|
+
cookieDomain: "example.eu",
|
|
890
|
+
allowedOrigins: ["https://app.example.eu"],
|
|
891
|
+
},
|
|
892
|
+
allowPlaintextPii: "test: session-gate focus, not crypto",
|
|
893
|
+
});
|
|
894
|
+
expect(handle).toBeDefined();
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
822
898
|
describe("runProdApp job-lane wiring (runSingleInstance)", () => {
|
|
823
899
|
// Red-then-green for the export bug: on createApiEntrypoint (old default) the
|
|
824
900
|
// worker-lane cron was never registered. createAllInOneEntrypoint (new
|
|
@@ -932,3 +1008,36 @@ describe("hard PII boot gate (#818 step 2)", () => {
|
|
|
932
1008
|
expect(handle).toBeDefined();
|
|
933
1009
|
});
|
|
934
1010
|
});
|
|
1011
|
+
|
|
1012
|
+
// Regression for fw#1352: runProdApp wires the metrics route through two
|
|
1013
|
+
// independently forwarded options (observability, metrics). Wrong nesting
|
|
1014
|
+
// or a renamed key in ApiEntrypointOptions would silently no-op instead of
|
|
1015
|
+
// erroring the boot, and /metrics would stay 404 or empty.
|
|
1016
|
+
describe("runProdApp — /metrics endpoint (fw#1352)", () => {
|
|
1017
|
+
test("observability (PrometheusMeter) + metrics.token wired → GET /metrics mit Bearer liefert OpenMetrics-Body", async () => {
|
|
1018
|
+
const meter = createPrometheusMeter();
|
|
1019
|
+
meter.registerMetric({ name: "kumiko_probe_total", type: "counter" });
|
|
1020
|
+
meter.counter("kumiko_probe_total").inc(2);
|
|
1021
|
+
|
|
1022
|
+
const handle = await boot(undefined, {
|
|
1023
|
+
observability: { ...createNoopProvider(), meter },
|
|
1024
|
+
metrics: { token: "t" },
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
const res = await handle.entrypoint.app.fetch(
|
|
1028
|
+
new Request("http://test/metrics", { headers: { Authorization: "Bearer t" } }),
|
|
1029
|
+
);
|
|
1030
|
+
expect(res.status).toBe(200);
|
|
1031
|
+
expect(res.headers.get("Content-Type")).toMatch(/openmetrics-text/);
|
|
1032
|
+
const body = await res.text();
|
|
1033
|
+
expect(body).toContain("kumiko_probe_total 2");
|
|
1034
|
+
expect(body).toMatch(/# EOF\n$/);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("ohne observability und ohne metrics-Option → /metrics ist keine Route (404)", async () => {
|
|
1038
|
+
const handle = await boot();
|
|
1039
|
+
|
|
1040
|
+
const res = await handle.entrypoint.app.fetch(new Request("http://test/metrics"));
|
|
1041
|
+
expect(res.status).toBe(404);
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { resolveSecurityHeaders, withSecurityHeaders } from "../security-headers";
|
|
3
|
+
|
|
4
|
+
const okHandler = (_req: Request) => new Response("ok");
|
|
5
|
+
const req = new Request("http://localhost/");
|
|
6
|
+
|
|
7
|
+
describe("resolveSecurityHeaders", () => {
|
|
8
|
+
it("returns the four defaults without csp when unconfigured", () => {
|
|
9
|
+
const headers = new Map(resolveSecurityHeaders(undefined));
|
|
10
|
+
expect(headers.get("strict-transport-security")).toBe("max-age=31536000; includeSubDomains");
|
|
11
|
+
expect(headers.get("x-frame-options")).toBe("DENY");
|
|
12
|
+
expect(headers.get("x-content-type-options")).toBe("nosniff");
|
|
13
|
+
expect(headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
|
14
|
+
expect(headers.has("content-security-policy")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns nothing when disabled", () => {
|
|
18
|
+
expect(resolveSecurityHeaders(false)).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("applies per-header overrides and opt-outs", () => {
|
|
22
|
+
const headers = new Map(
|
|
23
|
+
resolveSecurityHeaders({
|
|
24
|
+
hsts: "max-age=60",
|
|
25
|
+
frameOptions: false,
|
|
26
|
+
referrerPolicy: "no-referrer",
|
|
27
|
+
csp: "default-src 'self'",
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
expect(headers.get("strict-transport-security")).toBe("max-age=60");
|
|
31
|
+
expect(headers.has("x-frame-options")).toBe(false);
|
|
32
|
+
expect(headers.get("x-content-type-options")).toBe("nosniff");
|
|
33
|
+
expect(headers.get("referrer-policy")).toBe("no-referrer");
|
|
34
|
+
expect(headers.get("content-security-policy")).toBe("default-src 'self'");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("withSecurityHeaders", () => {
|
|
39
|
+
it("sets defaults on every response", async () => {
|
|
40
|
+
const res = await withSecurityHeaders(okHandler, undefined)(req);
|
|
41
|
+
expect(res.headers.get("strict-transport-security")).toBe(
|
|
42
|
+
"max-age=31536000; includeSubDomains",
|
|
43
|
+
);
|
|
44
|
+
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
|
45
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
46
|
+
expect(res.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
|
47
|
+
expect(await res.text()).toBe("ok");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns the handler unwrapped when disabled", () => {
|
|
51
|
+
expect(withSecurityHeaders(okHandler, false)).toBe(okHandler);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("never overrides a header the response already set", async () => {
|
|
55
|
+
const handler = (_req: Request) =>
|
|
56
|
+
new Response("ok", {
|
|
57
|
+
headers: {
|
|
58
|
+
"content-security-policy": "default-src 'none'",
|
|
59
|
+
"x-frame-options": "SAMEORIGIN",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const res = await withSecurityHeaders(handler, { csp: "default-src 'self'" })(req);
|
|
63
|
+
expect(res.headers.get("content-security-policy")).toBe("default-src 'none'");
|
|
64
|
+
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
|
65
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("re-wraps responses with immutable headers", async () => {
|
|
69
|
+
const immutable = new Response("ok", { status: 201, statusText: "Created" });
|
|
70
|
+
Object.defineProperty(immutable, "headers", {
|
|
71
|
+
value: new Proxy(immutable.headers, {
|
|
72
|
+
get(target, prop) {
|
|
73
|
+
if (prop === "set") {
|
|
74
|
+
return () => {
|
|
75
|
+
throw new TypeError("immutable");
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const value = Reflect.get(target, prop);
|
|
79
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
const res = await withSecurityHeaders(() => immutable, undefined)(req);
|
|
84
|
+
expect(res.status).toBe(201);
|
|
85
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
86
|
+
expect(await res.text()).toBe("ok");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { assertSessionBootInvariants } from "../session-boot-gate";
|
|
3
|
+
|
|
4
|
+
describe("assertSessionBootInvariants", () => {
|
|
5
|
+
test("no auth mounted → nothing to gate", () => {
|
|
6
|
+
expect(() =>
|
|
7
|
+
assertSessionBootInvariants({
|
|
8
|
+
hasAuth: false,
|
|
9
|
+
sessionsFeatureMounted: false,
|
|
10
|
+
sessionsOption: undefined,
|
|
11
|
+
}),
|
|
12
|
+
).not.toThrow();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("auth mounted, sessions feature missing, no opt-out → aborts boot", () => {
|
|
16
|
+
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
|
+
}),
|
|
42
|
+
).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("auth mounted, sessions feature wired AND an expiresInMs override → boots", () => {
|
|
46
|
+
expect(() =>
|
|
47
|
+
assertSessionBootInvariants({
|
|
48
|
+
hasAuth: true,
|
|
49
|
+
sessionsFeatureMounted: true,
|
|
50
|
+
sessionsOption: { expiresInMs: 60_000 },
|
|
51
|
+
}),
|
|
52
|
+
).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/boot/boot-crypto.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// buildBootExtraContext + applyBootSeeds so resolver, set-handler and
|
|
6
6
|
// seeds share the same cipher instance (and DEK cache).
|
|
7
7
|
|
|
8
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
9
8
|
import {
|
|
10
9
|
createDekCache,
|
|
11
10
|
createEnvelopeCipher,
|
|
@@ -27,12 +26,10 @@ export function envHasMasterKek(env: Record<string, string | undefined>): boolea
|
|
|
27
26
|
export type BootCrypto = {
|
|
28
27
|
readonly masterKeyProvider?: MasterKeyProvider;
|
|
29
28
|
// Cipher for encrypted config keys. Present exactly when a master key is
|
|
30
|
-
// available.
|
|
31
|
-
// until the config re-encrypt job migrated them.
|
|
29
|
+
// available.
|
|
32
30
|
readonly configCipher?: EnvelopeCipher;
|
|
33
|
-
// Cipher for `encrypted: true` entity fields — same master key
|
|
34
|
-
//
|
|
35
|
-
// to configCipher when no ENCRYPTION_KEY is set.
|
|
31
|
+
// Cipher for `encrypted: true` entity fields — same master key and
|
|
32
|
+
// instance as configCipher (kept as a separate field for API stability).
|
|
36
33
|
readonly entityFieldCipher?: EnvelopeCipher;
|
|
37
34
|
readonly dekCache: DekCache;
|
|
38
35
|
};
|
|
@@ -56,22 +53,10 @@ export function resolveBootCrypto(
|
|
|
56
53
|
: undefined);
|
|
57
54
|
|
|
58
55
|
const dekCache = createDekCache();
|
|
59
|
-
const legacyConfigKey = envSource["CONFIG_ENCRYPTION_KEY"];
|
|
60
56
|
const configCipher = masterKeyProvider
|
|
61
|
-
? createEnvelopeCipher(masterKeyProvider, {
|
|
62
|
-
dekCache,
|
|
63
|
-
...(legacyConfigKey ? { legacy: createEncryptionProvider(legacyConfigKey) } : {}),
|
|
64
|
-
})
|
|
57
|
+
? createEnvelopeCipher(masterKeyProvider, { dekCache })
|
|
65
58
|
: undefined;
|
|
66
|
-
|
|
67
|
-
const legacyEntityKey = envSource["ENCRYPTION_KEY"];
|
|
68
|
-
const entityFieldCipher =
|
|
69
|
-
masterKeyProvider && legacyEntityKey
|
|
70
|
-
? createEnvelopeCipher(masterKeyProvider, {
|
|
71
|
-
dekCache,
|
|
72
|
-
legacy: createEncryptionProvider(legacyEntityKey),
|
|
73
|
-
})
|
|
74
|
-
: configCipher;
|
|
59
|
+
const entityFieldCipher = configCipher;
|
|
75
60
|
|
|
76
61
|
return {
|
|
77
62
|
...(masterKeyProvider && { masterKeyProvider }),
|
package/src/compose-features.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// auf Frühere referenzieren (z.B. authClaims-Hooks an user/tenant).
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
+
type AccountUnlockOptions,
|
|
15
16
|
type AuthEmailPasswordOptions,
|
|
16
17
|
type AuthMailLocale,
|
|
17
18
|
createAuthEmailPasswordFeature,
|
|
@@ -102,6 +103,7 @@ export type AuthOptionsCarrier = {
|
|
|
102
103
|
readonly emailVerification?: EmailVerificationOptions;
|
|
103
104
|
readonly signup?: SignupOptions;
|
|
104
105
|
readonly invite?: InviteOptions;
|
|
106
|
+
readonly accountUnlock?: AccountUnlockOptions;
|
|
105
107
|
};
|
|
106
108
|
|
|
107
109
|
/** Baut den authOptions-Block für composeFeatures aus einem
|
|
@@ -158,7 +160,17 @@ export function buildComposeAuthOptions(
|
|
|
158
160
|
if (auth.invite) {
|
|
159
161
|
opts.invite = pickMailFields(auth.invite);
|
|
160
162
|
}
|
|
161
|
-
|
|
163
|
+
if (auth.accountUnlock) {
|
|
164
|
+
opts.accountUnlock = {
|
|
165
|
+
hmacSecret: auth.accountUnlock.hmacSecret,
|
|
166
|
+
...pickMailFields(auth.accountUnlock),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return opts.passwordReset ||
|
|
170
|
+
opts.emailVerification ||
|
|
171
|
+
opts.signup ||
|
|
172
|
+
opts.invite ||
|
|
173
|
+
opts.accountUnlock
|
|
162
174
|
? opts
|
|
163
175
|
: undefined;
|
|
164
176
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Dev-Tooling mehr in ihre node_modules.
|
|
5
5
|
export { type ComposeFeaturesOptions, composeFeatures } from "./compose-features";
|
|
6
6
|
export type {
|
|
7
|
+
AccountUnlockSetup,
|
|
7
8
|
EmailVerificationSetup,
|
|
8
9
|
InviteSetup,
|
|
9
10
|
PasswordResetSetup,
|
|
@@ -14,3 +15,4 @@ export type {
|
|
|
14
15
|
SignupSetup,
|
|
15
16
|
} from "./run-prod-app";
|
|
16
17
|
export { runProdApp } from "./run-prod-app";
|
|
18
|
+
export type { SecurityHeadersOption } from "./security-headers";
|
|
@@ -160,6 +160,13 @@ type AuthMailNormalizable = {
|
|
|
160
160
|
readonly invite?: InviteSetup;
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
+
// accountUnlock (#1266) deliberately does NOT join this convenience block —
|
|
164
|
+
// unlike reset/verify/signup/invite it's only meaningful paired with
|
|
165
|
+
// `accountLockout`, which itself isn't wired through RunProdAppAuthOptions
|
|
166
|
+
// today. Apps that mount `accountLockout` set `auth.accountUnlock` alongside
|
|
167
|
+
// it explicitly (same shape as `passwordReset`), so `mail` alone can't
|
|
168
|
+
// silently expose a new public endpoint an app didn't ask for.
|
|
169
|
+
|
|
163
170
|
export function resolveAuthMail<T extends AuthMailNormalizable>(
|
|
164
171
|
auth: T,
|
|
165
172
|
hmacSecret: string,
|
|
@@ -216,7 +223,6 @@ export function buildProdSessionAuth(
|
|
|
216
223
|
readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
|
|
217
224
|
readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
|
|
218
225
|
readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
|
|
219
|
-
readonly sessionStrictMode: true;
|
|
220
226
|
} {
|
|
221
227
|
const cbs = createSessionCallbacks({
|
|
222
228
|
db,
|
|
@@ -236,6 +242,5 @@ export function buildProdSessionAuth(
|
|
|
236
242
|
sessionCreator: cbs.sessionCreator,
|
|
237
243
|
sessionRevoker: cbs.sessionRevoker,
|
|
238
244
|
sessionChecker: cbs.sessionChecker,
|
|
239
|
-
sessionStrictMode: true,
|
|
240
245
|
};
|
|
241
246
|
}
|
package/src/run-prod-app.ts
CHANGED
|
@@ -20,11 +20,18 @@
|
|
|
20
20
|
// Container/Coolify setzt:
|
|
21
21
|
// DATABASE_URL=postgresql://...
|
|
22
22
|
// REDIS_URL=redis://...
|
|
23
|
-
// JWT_SECRET=<random-32+>
|
|
23
|
+
// JWT_SECRET=<random-32+> (always required — also signs the password-
|
|
24
|
+
// reset/email-verification HMAC tokens, a separate non-rotating family)
|
|
25
|
+
// — additionally, for zero-downtime rotation of the session-JWT
|
|
26
|
+
// signing key specifically:
|
|
27
|
+
// JWT_SECRET_V1=<random-32+> (repeat _V2, _V3, ... per rotation)
|
|
28
|
+
// JWT_SECRET_CURRENT_VERSION=1 (which V<n> signs new tokens; the others
|
|
29
|
+
// still verify in-flight tokens until they expire)
|
|
24
30
|
// PORT=3000
|
|
25
31
|
// KUMIKO_INSTANCE_ID=<stable per replica>
|
|
26
32
|
|
|
27
33
|
import {
|
|
34
|
+
type AccountUnlockOptions,
|
|
28
35
|
AuthErrors,
|
|
29
36
|
AuthHandlers,
|
|
30
37
|
type AuthMailLocale,
|
|
@@ -38,12 +45,14 @@ import {
|
|
|
38
45
|
type SeedAdminOptions,
|
|
39
46
|
seedAdmin,
|
|
40
47
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
48
|
+
import {
|
|
49
|
+
EXT_TOKEN_VERIFIER,
|
|
50
|
+
resolveTokenVerifier,
|
|
51
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
41
52
|
import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
42
53
|
import {
|
|
43
|
-
createPatResolver,
|
|
44
54
|
PAT_FEATURE,
|
|
45
55
|
patRateLimitFromFeature,
|
|
46
|
-
patScopesFromFeature,
|
|
47
56
|
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
48
57
|
import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
49
58
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
@@ -53,9 +62,12 @@ import {
|
|
|
53
62
|
} from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
|
|
54
63
|
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
55
64
|
import {
|
|
56
|
-
|
|
65
|
+
createRedisLoginRateLimiter,
|
|
57
66
|
createSseBroker,
|
|
67
|
+
type LoginRateLimiter,
|
|
68
|
+
loadJwtSecretOrKeyring,
|
|
58
69
|
type SseBroker,
|
|
70
|
+
type TokenVerifier,
|
|
59
71
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
60
72
|
import {
|
|
61
73
|
configureBlindIndexKey,
|
|
@@ -126,6 +138,8 @@ import {
|
|
|
126
138
|
resolveAuthMail,
|
|
127
139
|
} from "./run-prod-app-boot-context";
|
|
128
140
|
import { buildStaticFallback } from "./run-prod-app-static-files";
|
|
141
|
+
import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
|
|
142
|
+
import { assertSessionBootInvariants } from "./session-boot-gate";
|
|
129
143
|
import {
|
|
130
144
|
type ProdSessionsOption,
|
|
131
145
|
resolveProdSessionsConfig,
|
|
@@ -242,6 +256,13 @@ export type SignupSetup = SignupOptions;
|
|
|
242
256
|
* AuthHandlers (analog signup). */
|
|
243
257
|
export type InviteSetup = InviteOptions;
|
|
244
258
|
|
|
259
|
+
/** Wrapper API for the account-unlock flow (#1266). = AccountUnlockOptions
|
|
260
|
+
* (appUrl via delivery, symmetric to PasswordResetSetup). Self-service
|
|
261
|
+
* escape hatch for accountLockout's monotonic failure counter — only
|
|
262
|
+
* meaningful when `auth.accountLockout` is also set, but wired
|
|
263
|
+
* independently like the other flows. */
|
|
264
|
+
export type AccountUnlockSetup = AccountUnlockOptions;
|
|
265
|
+
|
|
245
266
|
/** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
|
|
246
267
|
* Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
|
|
247
268
|
* Templates (siehe `auth.mail` + resolveAuthMail). */
|
|
@@ -269,7 +290,7 @@ export type RunProdAppAuthOptions = {
|
|
|
269
290
|
/** Opt-in: revocable server-side sessions. Caller MUSS
|
|
270
291
|
* `createSessionsFeature()` zu `features` adden — runProdApp wired
|
|
271
292
|
* hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
|
|
272
|
-
* echte db-connection
|
|
293
|
+
* echte db-connection (sidless JWTs werden dann abgelehnt).
|
|
273
294
|
*
|
|
274
295
|
* Standardverhalten ohne diese Option: stateless JWTs ohne sid
|
|
275
296
|
* (legacy-Verhalten, Kartenhaus existing-Apps unangefasst). */
|
|
@@ -306,6 +327,11 @@ export type RunProdAppAuthOptions = {
|
|
|
306
327
|
* /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
|
|
307
328
|
* are mounted. */
|
|
308
329
|
readonly invite?: InviteSetup;
|
|
330
|
+
/** Account-unlock flow (#1266). When set, /api/auth/request-account-unlock
|
|
331
|
+
* + /api/auth/confirm-account-unlock are mounted. Self-service escape
|
|
332
|
+
* hatch for accountLockout's monotonic failure-counter — confirming
|
|
333
|
+
* clears the Redis lockout state, no entity write. */
|
|
334
|
+
readonly accountUnlock?: AccountUnlockSetup;
|
|
309
335
|
/** Domain attribute for both auth cookies (see
|
|
310
336
|
* AuthRoutesConfig.cookieDomain). Set to the registrable parent
|
|
311
337
|
* domain when login and app live on different subdomains. */
|
|
@@ -569,6 +595,12 @@ export type RunProdAppOptions = {
|
|
|
569
595
|
* dieses Feld wird kein L1/L2 verdrahtet (L3 Handler-`rateLimit:`
|
|
570
596
|
* funktioniert unabhängig davon bereits). */
|
|
571
597
|
readonly rateLimit?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["rateLimit"];
|
|
598
|
+
/** Default security headers on every response (HSTS, X-Frame-Options,
|
|
599
|
+
* X-Content-Type-Options, Referrer-Policy; CSP opt-in). Headers already
|
|
600
|
+
* set by a response (e.g. hostDispatch's per-host CSP) are never
|
|
601
|
+
* overridden. `false` disables the whole block; per-header overrides
|
|
602
|
+
* via the object form — see SecurityHeadersOption. */
|
|
603
|
+
readonly securityHeaders?: SecurityHeadersOption;
|
|
572
604
|
};
|
|
573
605
|
|
|
574
606
|
export type ProdAppHandle = {
|
|
@@ -652,7 +684,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
652
684
|
// configured.
|
|
653
685
|
const databaseUrl = requireEnv("DATABASE_URL", envSource);
|
|
654
686
|
const redisUrl = requireEnv("REDIS_URL", envSource);
|
|
687
|
+
// JWT_SECRET stays mandatory (also the resolveAuthMail hmacSecret
|
|
688
|
+
// fallback below — a separate, non-rotating HMAC token family, not the
|
|
689
|
+
// session JWT). jwtSecretOrKeyring is the OPTIONAL upgrade: set
|
|
690
|
+
// JWT_SECRET_V<n> + JWT_SECRET_CURRENT_VERSION for zero-downtime
|
|
691
|
+
// rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
|
|
655
692
|
const jwtSecret = requireEnv("JWT_SECRET", envSource);
|
|
693
|
+
const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
|
|
656
694
|
const jwtIssuer = readEnv("JWT_ISSUER", envSource);
|
|
657
695
|
const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
|
|
658
696
|
const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
|
|
@@ -687,6 +725,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
687
725
|
allowPlaintextPii: options.allowPlaintextPii,
|
|
688
726
|
mode: "prod",
|
|
689
727
|
});
|
|
728
|
+
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
729
|
+
assertSessionBootInvariants({
|
|
730
|
+
hasAuth: Boolean(effectiveAuth),
|
|
731
|
+
sessionsFeatureMounted: sessionsFeature !== undefined,
|
|
732
|
+
sessionsOption: effectiveAuth?.sessions,
|
|
733
|
+
});
|
|
690
734
|
const registry = createRegistry(features);
|
|
691
735
|
|
|
692
736
|
// C1 boot-mode exit: validators ran + registry built; no DB/Redis client
|
|
@@ -824,17 +868,16 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
824
868
|
|
|
825
869
|
// Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
|
|
826
870
|
// also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
|
|
827
|
-
//
|
|
828
|
-
//
|
|
871
|
+
// Ein JWT ohne sid wird abgelehnt, sobald ein sessionChecker verdrahtet ist —
|
|
872
|
+
// Prod-Sessions können nicht stillschweigend umgangen werden. sessionMassRevoker
|
|
829
873
|
// (4. callback aus createSessionCallbacks) ist nicht Teil der
|
|
830
874
|
// AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
|
|
831
875
|
// sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
|
|
832
876
|
// über die auth-routes.
|
|
833
877
|
// Secure-by-default: if the sessions feature is mounted, server-side revocation +
|
|
834
|
-
//
|
|
878
|
+
// auto-revoke-on-password-change are wired automatically;
|
|
835
879
|
// `auth.sessions` only overrides the config, and `auth.sessions: false` is the
|
|
836
880
|
// explicit opt-out (back to stateless JWTs).
|
|
837
|
-
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
838
881
|
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
839
882
|
const sessionAuthFragment = shouldWireProdSessions(
|
|
840
883
|
Boolean(effectiveAuth),
|
|
@@ -849,21 +892,25 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
849
892
|
)
|
|
850
893
|
: undefined;
|
|
851
894
|
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
895
|
+
// Token-verifier opt-in: any provider feature (personal-access-tokens, a
|
|
896
|
+
// future auth-provider-jwt, ...) self-registers via
|
|
897
|
+
// r.useExtension(EXT_TOKEN_VERIFIER, ...) — wire one generic resolver
|
|
898
|
+
// whenever at least one is mounted, resolved by shape at request-time.
|
|
899
|
+
// PAT keeps its own per-token rate limiter (patRateLimiter), unrelated to
|
|
900
|
+
// verification.
|
|
855
901
|
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
902
|
+
const hasTokenVerifierProviders = registry.getExtensionUsages(EXT_TOKEN_VERIFIER).length > 0;
|
|
856
903
|
let patAuthFragment:
|
|
857
904
|
| {
|
|
858
|
-
|
|
859
|
-
patRateLimiter:
|
|
905
|
+
tokenVerifier: TokenVerifier;
|
|
906
|
+
patRateLimiter: LoginRateLimiter;
|
|
860
907
|
}
|
|
861
908
|
| undefined;
|
|
862
|
-
if (effectiveAuth && patFeature) {
|
|
909
|
+
if (effectiveAuth && patFeature && hasTokenVerifierProviders) {
|
|
863
910
|
const rl = patRateLimitFromFeature(patFeature);
|
|
864
911
|
patAuthFragment = {
|
|
865
|
-
|
|
866
|
-
patRateLimiter:
|
|
912
|
+
tokenVerifier: (rawToken) => resolveTokenVerifier({ db, registry }, rawToken),
|
|
913
|
+
patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
|
|
867
914
|
};
|
|
868
915
|
}
|
|
869
916
|
|
|
@@ -887,7 +934,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
887
934
|
...extraContext,
|
|
888
935
|
},
|
|
889
936
|
sseBroker,
|
|
890
|
-
jwtSecret,
|
|
937
|
+
jwtSecret: jwtSecretOrKeyring,
|
|
891
938
|
...(jwtIssuer && { jwtIssuer }),
|
|
892
939
|
...(instanceId && { instanceId }),
|
|
893
940
|
dispatcherOptions: {
|
|
@@ -908,6 +955,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
908
955
|
[AuthErrors.invalidCredentials]: 401,
|
|
909
956
|
[AuthErrors.noMembership]: 403,
|
|
910
957
|
},
|
|
958
|
+
// Redis-backed, not the in-memory default createAuthRoutes falls
|
|
959
|
+
// back to — an in-process limiter only rate-limits within a single
|
|
960
|
+
// replica, so a multi-instance prod deployment would silently give
|
|
961
|
+
// each replica its own bucket (#1262/#1274). Redis is required infra
|
|
962
|
+
// here already (REDIS_URL), so this is free.
|
|
963
|
+
loginRateLimit: createRedisLoginRateLimiter(redis),
|
|
911
964
|
...(effectiveAuth.cookieDomain !== undefined && {
|
|
912
965
|
cookieDomain: effectiveAuth.cookieDomain,
|
|
913
966
|
}),
|
|
@@ -920,7 +973,15 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
920
973
|
...sessionAuthFragment,
|
|
921
974
|
...patAuthFragment,
|
|
922
975
|
...tenantLifecycleAuthFragment,
|
|
923
|
-
...(mfaFeature && {
|
|
976
|
+
...(mfaFeature && {
|
|
977
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
978
|
+
mfaVerifyRateLimit: createRedisLoginRateLimiter(
|
|
979
|
+
redis,
|
|
980
|
+
undefined,
|
|
981
|
+
undefined,
|
|
982
|
+
"mfa-verify",
|
|
983
|
+
),
|
|
984
|
+
}),
|
|
924
985
|
...(effectiveAuth.passwordReset && {
|
|
925
986
|
passwordReset: {
|
|
926
987
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
@@ -933,6 +994,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
933
994
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
934
995
|
},
|
|
935
996
|
}),
|
|
997
|
+
...(effectiveAuth.accountUnlock && {
|
|
998
|
+
accountUnlock: {
|
|
999
|
+
requestHandler: AuthHandlers.requestAccountUnlock,
|
|
1000
|
+
confirmHandler: AuthHandlers.confirmAccountUnlock,
|
|
1001
|
+
},
|
|
1002
|
+
}),
|
|
936
1003
|
...(effectiveAuth.signup && {
|
|
937
1004
|
signup: {
|
|
938
1005
|
requestHandler: AuthHandlers.signupRequest,
|
|
@@ -1066,14 +1133,17 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1066
1133
|
// wired via a wrapper so Hono owns /api/* + extraRoutes and disk
|
|
1067
1134
|
// owns the rest. Tests use this directly; listen() wraps it in
|
|
1068
1135
|
// Bun.serve.
|
|
1069
|
-
const fetchHandler =
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1136
|
+
const fetchHandler = withSecurityHeaders(
|
|
1137
|
+
options.staticDir
|
|
1138
|
+
? buildStaticFallback(
|
|
1139
|
+
entrypoint.app.fetch.bind(entrypoint.app),
|
|
1140
|
+
options.staticDir,
|
|
1141
|
+
appSchemaJson,
|
|
1142
|
+
options.hostDispatch,
|
|
1143
|
+
)
|
|
1144
|
+
: entrypoint.app.fetch.bind(entrypoint.app),
|
|
1145
|
+
options.securityHeaders,
|
|
1146
|
+
);
|
|
1077
1147
|
|
|
1078
1148
|
// 11. Mark lifecycle ready — health/ready flips to 200 after this.
|
|
1079
1149
|
entrypoint.lifecycle.markReady();
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type SecurityHeadersOption =
|
|
2
|
+
| false
|
|
3
|
+
| {
|
|
4
|
+
/** `Strict-Transport-Security` value, or `false` to omit.
|
|
5
|
+
* Default: `max-age=31536000; includeSubDomains`. */
|
|
6
|
+
readonly hsts?: string | false;
|
|
7
|
+
/** `X-Frame-Options` value, or `false` to omit. Default: `DENY`.
|
|
8
|
+
* Apps that must be embeddable (iframe widgets) set `false` and
|
|
9
|
+
* scope framing via a `csp` frame-ancestors directive instead. */
|
|
10
|
+
readonly frameOptions?: string | false;
|
|
11
|
+
/** `false` omits `X-Content-Type-Options: nosniff`. */
|
|
12
|
+
readonly contentTypeOptions?: false;
|
|
13
|
+
/** `Referrer-Policy` value, or `false` to omit.
|
|
14
|
+
* Default: `strict-origin-when-cross-origin`. */
|
|
15
|
+
readonly referrerPolicy?: string | false;
|
|
16
|
+
/** `Content-Security-Policy` default for ALL responses. No built-in
|
|
17
|
+
* default — a wrong CSP breaks app assets, so it stays opt-in.
|
|
18
|
+
* A per-host CSP from `hostDispatch` wins over this value. */
|
|
19
|
+
readonly csp?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_HSTS = "max-age=31536000; includeSubDomains";
|
|
23
|
+
const DEFAULT_FRAME_OPTIONS = "DENY";
|
|
24
|
+
const DEFAULT_REFERRER_POLICY = "strict-origin-when-cross-origin";
|
|
25
|
+
|
|
26
|
+
export function resolveSecurityHeaders(
|
|
27
|
+
option: SecurityHeadersOption | undefined,
|
|
28
|
+
): ReadonlyArray<readonly [string, string]> {
|
|
29
|
+
if (option === false) return [];
|
|
30
|
+
const opt = option ?? {};
|
|
31
|
+
const headers: Array<readonly [string, string]> = [];
|
|
32
|
+
const hsts = opt.hsts ?? DEFAULT_HSTS;
|
|
33
|
+
if (hsts !== false) headers.push(["strict-transport-security", hsts]);
|
|
34
|
+
const frameOptions = opt.frameOptions ?? DEFAULT_FRAME_OPTIONS;
|
|
35
|
+
if (frameOptions !== false) headers.push(["x-frame-options", frameOptions]);
|
|
36
|
+
if (opt.contentTypeOptions !== false) headers.push(["x-content-type-options", "nosniff"]);
|
|
37
|
+
const referrerPolicy = opt.referrerPolicy ?? DEFAULT_REFERRER_POLICY;
|
|
38
|
+
if (referrerPolicy !== false) headers.push(["referrer-policy", referrerPolicy]);
|
|
39
|
+
if (opt.csp) headers.push(["content-security-policy", opt.csp]);
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Sets each header only when absent so per-response values (e.g. the
|
|
44
|
+
// per-host CSP from hostDispatch) always win over the runtime default.
|
|
45
|
+
export function withSecurityHeaders(
|
|
46
|
+
handler: (req: Request) => Response | Promise<Response>,
|
|
47
|
+
option: SecurityHeadersOption | undefined,
|
|
48
|
+
): (req: Request) => Response | Promise<Response> {
|
|
49
|
+
const defaults = resolveSecurityHeaders(option);
|
|
50
|
+
if (defaults.length === 0) return handler;
|
|
51
|
+
return async (req: Request): Promise<Response> => {
|
|
52
|
+
const res = await handler(req);
|
|
53
|
+
try {
|
|
54
|
+
for (const [name, value] of defaults) {
|
|
55
|
+
if (!res.headers.has(name)) res.headers.set(name, value);
|
|
56
|
+
}
|
|
57
|
+
return res;
|
|
58
|
+
} catch {
|
|
59
|
+
// Immutable-headers Response (e.g. proxied fetch) — re-wrap.
|
|
60
|
+
const headers = new Headers(res.headers);
|
|
61
|
+
for (const [name, value] of defaults) {
|
|
62
|
+
if (!headers.has(name)) headers.set(name, value);
|
|
63
|
+
}
|
|
64
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ProdSessionsOption } from "./session-wiring";
|
|
2
|
+
|
|
3
|
+
export type SessionBootGateOptions = {
|
|
4
|
+
readonly hasAuth: boolean;
|
|
5
|
+
readonly sessionsFeatureMounted: boolean;
|
|
6
|
+
readonly sessionsOption: ProdSessionsOption | undefined;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Mirrors pii-boot-gate.ts: catch a forgotten wiring at boot instead of
|
|
10
|
+
// letting it degrade silently into stateless JWTs (no server-side
|
|
11
|
+
// revocation, valid for the full 24h token TTL). `auth.sessions: false` is
|
|
12
|
+
// already the sanctioned opt-out (see session-wiring.ts) — reusing it here
|
|
13
|
+
// instead of inventing a second acknowledgment param.
|
|
14
|
+
export function assertSessionBootInvariants(opts: SessionBootGateOptions): void {
|
|
15
|
+
// skip: no auth mounted — nothing to gate.
|
|
16
|
+
if (!opts.hasAuth) return;
|
|
17
|
+
// skip: explicit opt-out, operator acknowledged stateless JWTs.
|
|
18
|
+
if (opts.sessionsOption === false) return;
|
|
19
|
+
// skip: sessions feature is wired.
|
|
20
|
+
if (opts.sessionsFeatureMounted) return;
|
|
21
|
+
|
|
22
|
+
throw new Error(
|
|
23
|
+
"[runProdApp] BOOT ABORTED — auth is mounted but the `sessions` feature is not. " +
|
|
24
|
+
"JWTs would be stateless (no server-side revocation, valid until the 24h expiry) " +
|
|
25
|
+
"with no warning. Mount createSessionsFeature() " +
|
|
26
|
+
"(@cosmicdrift/kumiko-bundled-features/sessions) for revocable sessions, or pass " +
|
|
27
|
+
"{ auth: { sessions: false } } to acknowledge stateless JWTs are intentional.",
|
|
28
|
+
);
|
|
29
|
+
}
|
package/src/session-wiring.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* full prod boot).
|
|
4
4
|
*
|
|
5
5
|
* Secure-by-default: mounting the `sessions` feature turns server-side session
|
|
6
|
-
* revocation
|
|
6
|
+
* revocation ON automatically — there is no separate opt-in. The
|
|
7
7
|
* `auth.sessions` option only overrides the config, and `auth.sessions: false` is the
|
|
8
8
|
* explicit opt-out (back to stateless JWTs).
|
|
9
9
|
*/
|