@cosmicdrift/kumiko-server-runtime 1.0.0 → 2.0.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__/boot-probe-fixture.ts +63 -0
- package/src/__tests__/build-prod-bundle.inprocess.test.ts +164 -0
- package/src/__tests__/compose-features.test.ts +33 -0
- package/src/__tests__/run-prod-app-dry-run.test.ts +135 -0
- package/src/__tests__/run-prod-app-env-source.test.ts +5 -44
- package/src/__tests__/run-prod-app-static-files.test.ts +171 -0
- package/src/__tests__/run-prod-app.integration.test.ts +40 -6
- package/src/__tests__/session-boot-gate.test.ts +8 -40
- package/src/__tests__/session-wiring.test.ts +8 -43
- package/src/compose-features.ts +23 -11
- package/src/run-prod-app-boot-context.ts +16 -25
- package/src/run-prod-app-static-files.ts +8 -1
- package/src/run-prod-app.ts +91 -33
- package/src/session-boot-gate.ts +9 -18
- package/src/session-wiring.ts +7 -19
|
@@ -263,6 +263,31 @@ describe("runProdApp", () => {
|
|
|
263
263
|
expect(res.status).toBe(200);
|
|
264
264
|
});
|
|
265
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(
|
|
284
|
+
warnings.some(
|
|
285
|
+
(line) =>
|
|
286
|
+
line.includes('KUMIKO_DRY_RUN_ENV="not-a-real-mode"') && line.includes("unrecognized"),
|
|
287
|
+
),
|
|
288
|
+
).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
266
291
|
test("second boot against the same DB is idempotent — no crash, no duplicate tables", async () => {
|
|
267
292
|
await boot();
|
|
268
293
|
// First boot left tables in place. Restart on the same DB —
|
|
@@ -803,7 +828,12 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
803
828
|
test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
|
|
804
829
|
await expect(
|
|
805
830
|
boot(undefined, {
|
|
806
|
-
|
|
831
|
+
features: [
|
|
832
|
+
authFoundationFeature,
|
|
833
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
834
|
+
createSessionsFeature(),
|
|
835
|
+
],
|
|
836
|
+
auth: { admin: ADMIN, cookieDomain: "example.eu" },
|
|
807
837
|
allowPlaintextPii: "test: origin-guard focus, not crypto",
|
|
808
838
|
}),
|
|
809
839
|
).rejects.toThrow(/allowedOrigins is empty/);
|
|
@@ -811,16 +841,20 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
811
841
|
|
|
812
842
|
test("cookieDomain + allowedOrigins clears the guard — allowlist reaches buildServer", async () => {
|
|
813
843
|
// Without the forwarding fix this would ALSO throw /allowedOrigins is empty/.
|
|
814
|
-
//
|
|
815
|
-
//
|
|
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.
|
|
816
846
|
let bootError: unknown;
|
|
817
847
|
try {
|
|
818
848
|
const handle = await boot(undefined, {
|
|
849
|
+
features: [
|
|
850
|
+
authFoundationFeature,
|
|
851
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
852
|
+
createSessionsFeature(),
|
|
853
|
+
],
|
|
819
854
|
auth: {
|
|
820
855
|
admin: ADMIN,
|
|
821
856
|
cookieDomain: "example.eu",
|
|
822
857
|
allowedOrigins: ["https://app.example.eu"],
|
|
823
|
-
sessions: false,
|
|
824
858
|
},
|
|
825
859
|
});
|
|
826
860
|
expect(handle).toBeDefined();
|
|
@@ -841,7 +875,7 @@ describe("runProdApp — session boot gate (#1262/#1275)", () => {
|
|
|
841
875
|
memberships: [],
|
|
842
876
|
};
|
|
843
877
|
|
|
844
|
-
test("auth mounted, sessions feature missing
|
|
878
|
+
test("auth mounted, sessions feature missing → aborts boot", async () => {
|
|
845
879
|
await expect(
|
|
846
880
|
boot(undefined, {
|
|
847
881
|
auth: {
|
|
@@ -851,7 +885,7 @@ describe("runProdApp — session boot gate (#1262/#1275)", () => {
|
|
|
851
885
|
},
|
|
852
886
|
allowPlaintextPii: "test: session-gate focus, not crypto",
|
|
853
887
|
}),
|
|
854
|
-
).rejects.toThrow(/BOOT ABORTED.*
|
|
888
|
+
).rejects.toThrow(/BOOT ABORTED.*sessionStore/);
|
|
855
889
|
});
|
|
856
890
|
|
|
857
891
|
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
|
});
|
package/src/compose-features.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
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,
|
|
16
18
|
type AuthEmailPasswordOptions,
|
|
17
19
|
type AuthMailLocale,
|
|
18
20
|
createAuthEmailPasswordFeature,
|
|
21
|
+
createAuthSelfRegistrationToggleFeature,
|
|
19
22
|
type EmailVerificationOptions,
|
|
20
23
|
type InviteOptions,
|
|
21
24
|
type PasswordResetOptions,
|
|
@@ -32,7 +35,8 @@ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
32
35
|
|
|
33
36
|
export type ComposeFeaturesOptions = {
|
|
34
37
|
/** When true, prepends config + user + tenant + auth-email-password
|
|
35
|
-
*
|
|
38
|
+
* (+ auth-self-registration when authOptions.signup is set) before the
|
|
39
|
+
* app features. Mirror of "auth-mode" in run{Dev,Prod}App. */
|
|
36
40
|
readonly includeBundled: boolean;
|
|
37
41
|
/** Optional auth-feature-options durchgereicht an
|
|
38
42
|
* createAuthEmailPasswordFeature. Wenn passwordReset / emailVerification
|
|
@@ -75,6 +79,14 @@ export function composeFeatures(
|
|
|
75
79
|
createUserFeature(),
|
|
76
80
|
createTenantFeature(),
|
|
77
81
|
createAuthEmailPasswordFeature(authOptions ?? {}),
|
|
82
|
+
// signup-request/signup-confirm are registered whenever authOptions.signup
|
|
83
|
+
// is set (see above), but the handler itself no-ops unless the companion
|
|
84
|
+
// toggle feature is mounted (ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE))
|
|
85
|
+
// — without this, apps using the includeBundled convenience path get
|
|
86
|
+
// self-signup silently broken (always-200 anti-enumeration contract masks
|
|
87
|
+
// it as success). Mount it alongside signup, default ON, matching the
|
|
88
|
+
// "on unless an operator flips it off at runtime" contract.
|
|
89
|
+
...(authOptions?.signup !== undefined ? [createAuthSelfRegistrationToggleFeature()] : []),
|
|
78
90
|
];
|
|
79
91
|
const bundledNames = new Set(bundled.map((f) => f.name));
|
|
80
92
|
const filteredApp: FeatureDefinition[] = [];
|
|
@@ -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
|
}
|
|
@@ -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
|
|
|
@@ -46,7 +55,9 @@ import {
|
|
|
46
55
|
seedAdmin,
|
|
47
56
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
48
57
|
import {
|
|
58
|
+
EXT_SESSION_STORE,
|
|
49
59
|
EXT_TOKEN_VERIFIER,
|
|
60
|
+
resolveAnonymousAccessFromRegistry,
|
|
50
61
|
resolveTokenVerifier,
|
|
51
62
|
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
52
63
|
import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
@@ -140,11 +151,7 @@ import {
|
|
|
140
151
|
import { buildStaticFallback } from "./run-prod-app-static-files";
|
|
141
152
|
import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
|
|
142
153
|
import { assertSessionBootInvariants } from "./session-boot-gate";
|
|
143
|
-
import {
|
|
144
|
-
type ProdSessionsOption,
|
|
145
|
-
resolveProdSessionsConfig,
|
|
146
|
-
shouldWireProdSessions,
|
|
147
|
-
} from "./session-wiring";
|
|
154
|
+
import { shouldWireProdSessions } from "./session-wiring";
|
|
148
155
|
|
|
149
156
|
export { buildBunServeOptions } from "./bun-serve-options";
|
|
150
157
|
export {
|
|
@@ -287,14 +294,6 @@ export type RunProdAppAuthOptions = {
|
|
|
287
294
|
readonly admin: SeedAdminOptions;
|
|
288
295
|
/** Optional override of the login error → HTTP status map. */
|
|
289
296
|
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
297
|
/** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
|
|
299
298
|
* emailVerification, signup, invite) aus `auth.mail.baseUrl` + Standard-
|
|
300
299
|
* Pfaden. Alle vier mailen via delivery (ctx.notify) — ersetzt das per-App
|
|
@@ -345,6 +344,13 @@ export type RunProdAppAuthOptions = {
|
|
|
345
344
|
* — accept the wide-cookie CSRF risk explicitly instead of setting
|
|
346
345
|
* `allowedOrigins`. */
|
|
347
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;
|
|
348
354
|
};
|
|
349
355
|
|
|
350
356
|
/** Hook for app-specific seeding — runs after the admin (when auth is
|
|
@@ -368,10 +374,8 @@ export type RunProdAppDeps = {
|
|
|
368
374
|
};
|
|
369
375
|
|
|
370
376
|
export type AnonymousAccessOption =
|
|
371
|
-
| import("@cosmicdrift/kumiko-framework/api").
|
|
372
|
-
| ((
|
|
373
|
-
deps: RunProdAppDeps,
|
|
374
|
-
) => import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]);
|
|
377
|
+
| import("@cosmicdrift/kumiko-framework/api").AnonymousAccessConfig
|
|
378
|
+
| ((deps: RunProdAppDeps) => import("@cosmicdrift/kumiko-framework/api").AnonymousAccessConfig);
|
|
375
379
|
|
|
376
380
|
export type ExtraContextOption =
|
|
377
381
|
| Record<string, unknown>
|
|
@@ -621,6 +625,22 @@ export type ProdAppHandle = {
|
|
|
621
625
|
readonly stop: () => Promise<void>;
|
|
622
626
|
};
|
|
623
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
|
+
|
|
624
644
|
export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
|
|
625
645
|
// 0. Env-Schema validation + dry-run modes. Runs FIRST so:
|
|
626
646
|
// - operators can introspect env-requirements without a real boot
|
|
@@ -691,8 +711,36 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
691
711
|
// rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
|
|
692
712
|
const jwtSecret = requireEnv("JWT_SECRET", envSource);
|
|
693
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
|
+
}
|
|
694
722
|
const jwtIssuer = readEnv("JWT_ISSUER", envSource);
|
|
695
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
|
+
})();
|
|
696
744
|
const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
|
|
697
745
|
|
|
698
746
|
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
@@ -726,12 +774,11 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
726
774
|
mode: "prod",
|
|
727
775
|
});
|
|
728
776
|
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
777
|
+
const registry = createRegistry(features);
|
|
729
778
|
assertSessionBootInvariants({
|
|
730
779
|
hasAuth: Boolean(effectiveAuth),
|
|
731
|
-
|
|
732
|
-
sessionsOption: effectiveAuth?.sessions,
|
|
780
|
+
sessionStoreProviderMounted: registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
|
|
733
781
|
});
|
|
734
|
-
const registry = createRegistry(features);
|
|
735
782
|
|
|
736
783
|
// C1 boot-mode exit: validators ran + registry built; no DB/Redis client
|
|
737
784
|
// is constructed at all in this branch (the eager `new Redis(...)` below
|
|
@@ -861,10 +908,15 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
861
908
|
{ ...autoExtraContext, ...resolvedExtraContext },
|
|
862
909
|
registry,
|
|
863
910
|
);
|
|
864
|
-
const
|
|
911
|
+
const baseAnonymousAccess =
|
|
865
912
|
typeof options.anonymousAccess === "function"
|
|
866
913
|
? options.anonymousAccess(deps)
|
|
867
914
|
: options.anonymousAccess;
|
|
915
|
+
// #1374: tenantResolver / tenantExists come from auth-foundation providers.
|
|
916
|
+
const resolvedAnonymousAccess = await resolveAnonymousAccessFromRegistry(baseAnonymousAccess, {
|
|
917
|
+
db,
|
|
918
|
+
registry,
|
|
919
|
+
});
|
|
868
920
|
|
|
869
921
|
// Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
|
|
870
922
|
// also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
|
|
@@ -874,22 +926,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
874
926
|
// AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
|
|
875
927
|
// sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
|
|
876
928
|
// ü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).
|
|
929
|
+
// Secure-by-default (#1372): sessionStore provider → resolveSessionStore.
|
|
881
930
|
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
882
931
|
const sessionAuthFragment = shouldWireProdSessions(
|
|
883
932
|
Boolean(effectiveAuth),
|
|
884
|
-
|
|
885
|
-
effectiveAuth?.sessions,
|
|
933
|
+
registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
|
|
886
934
|
)
|
|
887
|
-
? buildProdSessionAuth(
|
|
888
|
-
db,
|
|
889
|
-
resolveProdSessionsConfig(effectiveAuth?.sessions),
|
|
890
|
-
sessionsFeature,
|
|
891
|
-
mfaFeature,
|
|
892
|
-
)
|
|
935
|
+
? await buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature)
|
|
893
936
|
: undefined;
|
|
894
937
|
|
|
895
938
|
// Token-verifier opt-in: any provider feature (personal-access-tokens, a
|
|
@@ -970,17 +1013,32 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
970
1013
|
...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
|
|
971
1014
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
972
1015
|
}),
|
|
1016
|
+
...(trustedProxyHops !== undefined && { trustedProxyHops }),
|
|
973
1017
|
...sessionAuthFragment,
|
|
974
1018
|
...patAuthFragment,
|
|
975
1019
|
...tenantLifecycleAuthFragment,
|
|
976
1020
|
...(mfaFeature && {
|
|
977
1021
|
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
1022
|
+
mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
|
|
1023
|
+
mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
|
|
978
1024
|
mfaVerifyRateLimit: createRedisLoginRateLimiter(
|
|
979
1025
|
redis,
|
|
980
1026
|
undefined,
|
|
981
1027
|
undefined,
|
|
982
1028
|
"mfa-verify",
|
|
983
1029
|
),
|
|
1030
|
+
mfaPreauthEnableStartRateLimit: createRedisLoginRateLimiter(
|
|
1031
|
+
redis,
|
|
1032
|
+
undefined,
|
|
1033
|
+
undefined,
|
|
1034
|
+
"mfa-preauth-start",
|
|
1035
|
+
),
|
|
1036
|
+
mfaPreauthConfirmRateLimit: createRedisLoginRateLimiter(
|
|
1037
|
+
redis,
|
|
1038
|
+
undefined,
|
|
1039
|
+
undefined,
|
|
1040
|
+
"mfa-preauth-confirm",
|
|
1041
|
+
),
|
|
984
1042
|
}),
|
|
985
1043
|
...(effectiveAuth.passwordReset && {
|
|
986
1044
|
passwordReset: {
|
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
|
}
|