@cosmicdrift/kumiko-server-runtime 0.158.2 → 1.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__/run-prod-app.integration.test.ts +90 -1
- 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 +1 -0
- package/src/run-prod-app-boot-context.ts +7 -2
- package/src/run-prod-app.ts +60 -15
- 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": "1.0.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": "1.0.0",
|
|
76
|
+
"@cosmicdrift/kumiko-framework": "1.0.0",
|
|
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,
|
|
@@ -790,7 +803,7 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
790
803
|
test("cookieDomain without allowedOrigins fails closed — guard is wired through runProdApp", async () => {
|
|
791
804
|
await expect(
|
|
792
805
|
boot(undefined, {
|
|
793
|
-
auth: { admin: ADMIN, cookieDomain: "example.eu" },
|
|
806
|
+
auth: { admin: ADMIN, cookieDomain: "example.eu", sessions: false },
|
|
794
807
|
allowPlaintextPii: "test: origin-guard focus, not crypto",
|
|
795
808
|
}),
|
|
796
809
|
).rejects.toThrow(/allowedOrigins is empty/);
|
|
@@ -807,6 +820,7 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
807
820
|
admin: ADMIN,
|
|
808
821
|
cookieDomain: "example.eu",
|
|
809
822
|
allowedOrigins: ["https://app.example.eu"],
|
|
823
|
+
sessions: false,
|
|
810
824
|
},
|
|
811
825
|
});
|
|
812
826
|
expect(handle).toBeDefined();
|
|
@@ -819,6 +833,48 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
819
833
|
});
|
|
820
834
|
});
|
|
821
835
|
|
|
836
|
+
describe("runProdApp — session boot gate (#1262/#1275)", () => {
|
|
837
|
+
const ADMIN = {
|
|
838
|
+
email: "session-gate@example.eu",
|
|
839
|
+
password: "test-pw-strong-1234",
|
|
840
|
+
displayName: "Admin",
|
|
841
|
+
memberships: [],
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
test("auth mounted, sessions feature missing, no opt-out → aborts boot", async () => {
|
|
845
|
+
await expect(
|
|
846
|
+
boot(undefined, {
|
|
847
|
+
auth: {
|
|
848
|
+
admin: ADMIN,
|
|
849
|
+
cookieDomain: "example.eu",
|
|
850
|
+
allowedOrigins: ["https://app.example.eu"],
|
|
851
|
+
},
|
|
852
|
+
allowPlaintextPii: "test: session-gate focus, not crypto",
|
|
853
|
+
}),
|
|
854
|
+
).rejects.toThrow(/BOOT ABORTED.*sessions.*stateless/s);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("auth mounted, sessions feature mounted → boots cleanly (the happy path the gate guards)", async () => {
|
|
858
|
+
const handle = await boot(undefined, {
|
|
859
|
+
// "user" is auto-mounted via includeBundled whenever auth.admin is set.
|
|
860
|
+
// sessions requires auth-foundation, which needs a tokenVerifier
|
|
861
|
+
// provider — PAT.
|
|
862
|
+
features: [
|
|
863
|
+
authFoundationFeature,
|
|
864
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
865
|
+
createSessionsFeature(),
|
|
866
|
+
],
|
|
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
|
+
expect(handle).toBeDefined();
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
822
878
|
describe("runProdApp job-lane wiring (runSingleInstance)", () => {
|
|
823
879
|
// Red-then-green for the export bug: on createApiEntrypoint (old default) the
|
|
824
880
|
// worker-lane cron was never registered. createAllInOneEntrypoint (new
|
|
@@ -932,3 +988,36 @@ describe("hard PII boot gate (#818 step 2)", () => {
|
|
|
932
988
|
expect(handle).toBeDefined();
|
|
933
989
|
});
|
|
934
990
|
});
|
|
991
|
+
|
|
992
|
+
// Regression for fw#1352: runProdApp wires the metrics route through two
|
|
993
|
+
// independently forwarded options (observability, metrics). Wrong nesting
|
|
994
|
+
// or a renamed key in ApiEntrypointOptions would silently no-op instead of
|
|
995
|
+
// erroring the boot, and /metrics would stay 404 or empty.
|
|
996
|
+
describe("runProdApp — /metrics endpoint (fw#1352)", () => {
|
|
997
|
+
test("observability (PrometheusMeter) + metrics.token wired → GET /metrics mit Bearer liefert OpenMetrics-Body", async () => {
|
|
998
|
+
const meter = createPrometheusMeter();
|
|
999
|
+
meter.registerMetric({ name: "kumiko_probe_total", type: "counter" });
|
|
1000
|
+
meter.counter("kumiko_probe_total").inc(2);
|
|
1001
|
+
|
|
1002
|
+
const handle = await boot(undefined, {
|
|
1003
|
+
observability: { ...createNoopProvider(), meter },
|
|
1004
|
+
metrics: { token: "t" },
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
const res = await handle.entrypoint.app.fetch(
|
|
1008
|
+
new Request("http://test/metrics", { headers: { Authorization: "Bearer t" } }),
|
|
1009
|
+
);
|
|
1010
|
+
expect(res.status).toBe(200);
|
|
1011
|
+
expect(res.headers.get("Content-Type")).toMatch(/openmetrics-text/);
|
|
1012
|
+
const body = await res.text();
|
|
1013
|
+
expect(body).toContain("kumiko_probe_total 2");
|
|
1014
|
+
expect(body).toMatch(/# EOF\n$/);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
test("ohne observability und ohne metrics-Option → /metrics ist keine Route (404)", async () => {
|
|
1018
|
+
const handle = await boot();
|
|
1019
|
+
|
|
1020
|
+
const res = await handle.entrypoint.app.fetch(new Request("http://test/metrics"));
|
|
1021
|
+
expect(res.status).toBe(404);
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
@@ -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
|
@@ -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";
|
|
@@ -56,7 +65,9 @@ import {
|
|
|
56
65
|
createRedisLoginRateLimiter,
|
|
57
66
|
createSseBroker,
|
|
58
67
|
type LoginRateLimiter,
|
|
68
|
+
loadJwtSecretOrKeyring,
|
|
59
69
|
type SseBroker,
|
|
70
|
+
type TokenVerifier,
|
|
60
71
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
61
72
|
import {
|
|
62
73
|
configureBlindIndexKey,
|
|
@@ -128,6 +139,7 @@ import {
|
|
|
128
139
|
} from "./run-prod-app-boot-context";
|
|
129
140
|
import { buildStaticFallback } from "./run-prod-app-static-files";
|
|
130
141
|
import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
|
|
142
|
+
import { assertSessionBootInvariants } from "./session-boot-gate";
|
|
131
143
|
import {
|
|
132
144
|
type ProdSessionsOption,
|
|
133
145
|
resolveProdSessionsConfig,
|
|
@@ -244,6 +256,13 @@ export type SignupSetup = SignupOptions;
|
|
|
244
256
|
* AuthHandlers (analog signup). */
|
|
245
257
|
export type InviteSetup = InviteOptions;
|
|
246
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
|
+
|
|
247
266
|
/** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
|
|
248
267
|
* Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
|
|
249
268
|
* Templates (siehe `auth.mail` + resolveAuthMail). */
|
|
@@ -271,7 +290,7 @@ export type RunProdAppAuthOptions = {
|
|
|
271
290
|
/** Opt-in: revocable server-side sessions. Caller MUSS
|
|
272
291
|
* `createSessionsFeature()` zu `features` adden — runProdApp wired
|
|
273
292
|
* hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
|
|
274
|
-
* echte db-connection
|
|
293
|
+
* echte db-connection (sidless JWTs werden dann abgelehnt).
|
|
275
294
|
*
|
|
276
295
|
* Standardverhalten ohne diese Option: stateless JWTs ohne sid
|
|
277
296
|
* (legacy-Verhalten, Kartenhaus existing-Apps unangefasst). */
|
|
@@ -308,6 +327,11 @@ export type RunProdAppAuthOptions = {
|
|
|
308
327
|
* /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
|
|
309
328
|
* are mounted. */
|
|
310
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;
|
|
311
335
|
/** Domain attribute for both auth cookies (see
|
|
312
336
|
* AuthRoutesConfig.cookieDomain). Set to the registrable parent
|
|
313
337
|
* domain when login and app live on different subdomains. */
|
|
@@ -660,7 +684,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
660
684
|
// configured.
|
|
661
685
|
const databaseUrl = requireEnv("DATABASE_URL", envSource);
|
|
662
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.
|
|
663
692
|
const jwtSecret = requireEnv("JWT_SECRET", envSource);
|
|
693
|
+
const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
|
|
664
694
|
const jwtIssuer = readEnv("JWT_ISSUER", envSource);
|
|
665
695
|
const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
|
|
666
696
|
const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
|
|
@@ -695,6 +725,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
695
725
|
allowPlaintextPii: options.allowPlaintextPii,
|
|
696
726
|
mode: "prod",
|
|
697
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
|
+
});
|
|
698
734
|
const registry = createRegistry(features);
|
|
699
735
|
|
|
700
736
|
// C1 boot-mode exit: validators ran + registry built; no DB/Redis client
|
|
@@ -832,17 +868,16 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
832
868
|
|
|
833
869
|
// Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
|
|
834
870
|
// also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
|
|
835
|
-
//
|
|
836
|
-
//
|
|
871
|
+
// Ein JWT ohne sid wird abgelehnt, sobald ein sessionChecker verdrahtet ist —
|
|
872
|
+
// Prod-Sessions können nicht stillschweigend umgangen werden. sessionMassRevoker
|
|
837
873
|
// (4. callback aus createSessionCallbacks) ist nicht Teil der
|
|
838
874
|
// AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
|
|
839
875
|
// sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
|
|
840
876
|
// über die auth-routes.
|
|
841
877
|
// Secure-by-default: if the sessions feature is mounted, server-side revocation +
|
|
842
|
-
//
|
|
878
|
+
// auto-revoke-on-password-change are wired automatically;
|
|
843
879
|
// `auth.sessions` only overrides the config, and `auth.sessions: false` is the
|
|
844
880
|
// explicit opt-out (back to stateless JWTs).
|
|
845
|
-
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
846
881
|
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
847
882
|
const sessionAuthFragment = shouldWireProdSessions(
|
|
848
883
|
Boolean(effectiveAuth),
|
|
@@ -857,20 +892,24 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
857
892
|
)
|
|
858
893
|
: undefined;
|
|
859
894
|
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
//
|
|
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.
|
|
863
901
|
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
902
|
+
const hasTokenVerifierProviders = registry.getExtensionUsages(EXT_TOKEN_VERIFIER).length > 0;
|
|
864
903
|
let patAuthFragment:
|
|
865
904
|
| {
|
|
866
|
-
|
|
905
|
+
tokenVerifier: TokenVerifier;
|
|
867
906
|
patRateLimiter: LoginRateLimiter;
|
|
868
907
|
}
|
|
869
908
|
| undefined;
|
|
870
|
-
if (effectiveAuth && patFeature) {
|
|
909
|
+
if (effectiveAuth && patFeature && hasTokenVerifierProviders) {
|
|
871
910
|
const rl = patRateLimitFromFeature(patFeature);
|
|
872
911
|
patAuthFragment = {
|
|
873
|
-
|
|
912
|
+
tokenVerifier: (rawToken) => resolveTokenVerifier({ db, registry }, rawToken),
|
|
874
913
|
patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
|
|
875
914
|
};
|
|
876
915
|
}
|
|
@@ -895,7 +934,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
895
934
|
...extraContext,
|
|
896
935
|
},
|
|
897
936
|
sseBroker,
|
|
898
|
-
jwtSecret,
|
|
937
|
+
jwtSecret: jwtSecretOrKeyring,
|
|
899
938
|
...(jwtIssuer && { jwtIssuer }),
|
|
900
939
|
...(instanceId && { instanceId }),
|
|
901
940
|
dispatcherOptions: {
|
|
@@ -955,6 +994,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
955
994
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
956
995
|
},
|
|
957
996
|
}),
|
|
997
|
+
...(effectiveAuth.accountUnlock && {
|
|
998
|
+
accountUnlock: {
|
|
999
|
+
requestHandler: AuthHandlers.requestAccountUnlock,
|
|
1000
|
+
confirmHandler: AuthHandlers.confirmAccountUnlock,
|
|
1001
|
+
},
|
|
1002
|
+
}),
|
|
958
1003
|
...(effectiveAuth.signup && {
|
|
959
1004
|
signup: {
|
|
960
1005
|
requestHandler: AuthHandlers.signupRequest,
|
|
@@ -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
|
*/
|