@cosmicdrift/kumiko-dev-server 0.100.0 → 0.102.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.102.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
53
|
+
"@cosmicdrift/kumiko-bundled-features": "0.102.0",
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.102.0",
|
|
55
55
|
"ts-morph": "^28.0.0"
|
|
56
56
|
},
|
|
57
57
|
"publishConfig": {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
3
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
6
|
+
import { buildBootExtraContext } from "../run-prod-app";
|
|
7
|
+
|
|
8
|
+
// Pins runProdApp's framework-default-provider autowire (buildBootExtraContext):
|
|
9
|
+
// textContent unconditional, secrets feature-gated, KEK-env trap avoided, and
|
|
10
|
+
// the money-horse regression (no secrets feature → no forced KEK → no throw).
|
|
11
|
+
|
|
12
|
+
// db is never touched at construction time — createTextContentApi /
|
|
13
|
+
// createSecretsContext only store the handle and query lazily. A bare stub
|
|
14
|
+
// keeps this a fast unit test (no Postgres) instead of a full boot.
|
|
15
|
+
const fakeDb = {} as unknown as DbConnection;
|
|
16
|
+
const registry = createRegistry([]);
|
|
17
|
+
const KEK = Buffer.alloc(32, 7).toString("base64");
|
|
18
|
+
|
|
19
|
+
const otherFeature = defineFeature("widgets", () => {});
|
|
20
|
+
|
|
21
|
+
describe("buildBootExtraContext — framework-default provider autowire", () => {
|
|
22
|
+
test("textContent is wired unconditionally (no secrets feature, no auth)", () => {
|
|
23
|
+
const ctx = buildBootExtraContext({
|
|
24
|
+
db: fakeDb,
|
|
25
|
+
features: [otherFeature],
|
|
26
|
+
envSource: {},
|
|
27
|
+
registry,
|
|
28
|
+
hasAuth: false,
|
|
29
|
+
});
|
|
30
|
+
expect(typeof (ctx["textContent"] as { getBlock?: unknown }).getBlock).toBe("function");
|
|
31
|
+
expect(ctx["secrets"]).toBeUndefined();
|
|
32
|
+
expect(ctx["configResolver"]).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("secrets feature mounted + valid KEK env → ctx.secrets wired", () => {
|
|
36
|
+
const ctx = buildBootExtraContext({
|
|
37
|
+
db: fakeDb,
|
|
38
|
+
features: [createSecretsFeature()],
|
|
39
|
+
envSource: {
|
|
40
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: KEK,
|
|
41
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
42
|
+
},
|
|
43
|
+
registry,
|
|
44
|
+
hasAuth: false,
|
|
45
|
+
});
|
|
46
|
+
const secrets = ctx["secrets"] as {
|
|
47
|
+
get?: unknown;
|
|
48
|
+
set?: unknown;
|
|
49
|
+
has?: unknown;
|
|
50
|
+
delete?: unknown;
|
|
51
|
+
};
|
|
52
|
+
expect(typeof secrets.get).toBe("function");
|
|
53
|
+
expect(typeof secrets.set).toBe("function");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("secrets feature mounted, only V1 set (no CURRENT_VERSION) → wired (default '1')", () => {
|
|
57
|
+
const ctx = buildBootExtraContext({
|
|
58
|
+
db: fakeDb,
|
|
59
|
+
features: [createSecretsFeature()],
|
|
60
|
+
envSource: { KUMIKO_SECRETS_MASTER_KEY_V1: KEK },
|
|
61
|
+
registry,
|
|
62
|
+
hasAuth: false,
|
|
63
|
+
});
|
|
64
|
+
expect(ctx["secrets"]).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("secrets feature mounted but NO KEK + no override → skipped, no throw (dev DEV_KEY path)", () => {
|
|
68
|
+
// dev wires its own DEV_KEY secrets explicitly; the auto-wire must not
|
|
69
|
+
// eagerly construct an env-provider and crash the boot when no env KEK
|
|
70
|
+
// exists. ctx.secrets stays unset so the app's explicit wiring wins.
|
|
71
|
+
let ctx: Record<string, unknown> | undefined;
|
|
72
|
+
expect(() => {
|
|
73
|
+
ctx = buildBootExtraContext({
|
|
74
|
+
db: fakeDb,
|
|
75
|
+
features: [createSecretsFeature()],
|
|
76
|
+
envSource: {},
|
|
77
|
+
registry,
|
|
78
|
+
hasAuth: false,
|
|
79
|
+
});
|
|
80
|
+
}).not.toThrow();
|
|
81
|
+
expect(ctx?.["secrets"]).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("no secrets feature → no KEK ever read, boots green (money-horse regression)", () => {
|
|
85
|
+
const ctx = buildBootExtraContext({
|
|
86
|
+
db: fakeDb,
|
|
87
|
+
features: [otherFeature],
|
|
88
|
+
envSource: {}, // no KEK — must NOT matter when secrets isn't mounted
|
|
89
|
+
registry,
|
|
90
|
+
hasAuth: false,
|
|
91
|
+
});
|
|
92
|
+
expect(ctx["secrets"]).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("masterKey override is used verbatim — no env-provider built", () => {
|
|
96
|
+
const override: MasterKeyProvider = {
|
|
97
|
+
wrapDek: async () => ({ encryptedDek: Buffer.alloc(0), kekVersion: 1 }),
|
|
98
|
+
unwrapDek: async () => Buffer.alloc(0),
|
|
99
|
+
currentVersion: () => 1,
|
|
100
|
+
isAvailable: async () => true,
|
|
101
|
+
};
|
|
102
|
+
// envSource is empty: if the override weren't honoured, createEnvMasterKeyProvider
|
|
103
|
+
// would throw "no KEK". It doesn't → the override path is taken.
|
|
104
|
+
const ctx = buildBootExtraContext({
|
|
105
|
+
db: fakeDb,
|
|
106
|
+
features: [createSecretsFeature()],
|
|
107
|
+
envSource: {},
|
|
108
|
+
registry,
|
|
109
|
+
hasAuth: false,
|
|
110
|
+
masterKey: override,
|
|
111
|
+
});
|
|
112
|
+
expect(ctx["secrets"]).toBeDefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type RunProdAppAuthOptions, resolveAuthMail } from "../run-prod-app";
|
|
3
|
+
|
|
4
|
+
// Pins the auth.mail convenience (resolveAuthMail): one mail block expands
|
|
5
|
+
// into the four explicit flow setups built from DEFAULT_AUTH_PATHS, the
|
|
6
|
+
// null-transport guard (no SMTP_HOST → flows stay unwired), and explicit
|
|
7
|
+
// per-flow setups winning over the mail default.
|
|
8
|
+
|
|
9
|
+
const admin: RunProdAppAuthOptions["admin"] = {
|
|
10
|
+
email: "admin@example.com",
|
|
11
|
+
password: "pw-long-enough",
|
|
12
|
+
displayName: "Admin",
|
|
13
|
+
memberships: [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const withMail: RunProdAppAuthOptions = {
|
|
17
|
+
admin,
|
|
18
|
+
mail: { baseUrl: "https://app.example.com", appName: "Test" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("resolveAuthMail", () => {
|
|
22
|
+
test("no mail block → auth returned unchanged", () => {
|
|
23
|
+
const noMail: RunProdAppAuthOptions = { admin };
|
|
24
|
+
const out = resolveAuthMail(noMail, "secret", { SMTP_HOST: "localhost" });
|
|
25
|
+
expect(out.passwordReset).toBeUndefined();
|
|
26
|
+
expect(out.signup).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("mail + SMTP_HOST → all four flows wired from DEFAULT_AUTH_PATHS", () => {
|
|
30
|
+
const out = resolveAuthMail(withMail, "secret", { SMTP_HOST: "localhost" });
|
|
31
|
+
expect(out.passwordReset?.appResetUrl).toBe("https://app.example.com/reset-password");
|
|
32
|
+
expect(out.emailVerification?.appVerifyUrl).toBe("https://app.example.com/verify-email");
|
|
33
|
+
expect(out.signup?.appActivationUrl).toBe("https://app.example.com/signup/complete");
|
|
34
|
+
expect(out.invite?.appAcceptUrl).toBe("https://app.example.com/invite/accept");
|
|
35
|
+
expect(typeof out.passwordReset?.sendResetEmail).toBe("function");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("mail but NO SMTP_HOST → null-transport guard, flows stay unwired", () => {
|
|
39
|
+
const out = resolveAuthMail(withMail, "secret", {});
|
|
40
|
+
expect(out.passwordReset).toBeUndefined();
|
|
41
|
+
expect(out.signup).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("explicit per-flow setup wins over the mail default", () => {
|
|
45
|
+
const explicit: RunProdAppAuthOptions = {
|
|
46
|
+
...withMail,
|
|
47
|
+
passwordReset: {
|
|
48
|
+
hmacSecret: "h",
|
|
49
|
+
appResetUrl: "https://custom.example.com/pw",
|
|
50
|
+
sendResetEmail: async () => {},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const out = resolveAuthMail(explicit, "secret", { SMTP_HOST: "localhost" });
|
|
54
|
+
expect(out.passwordReset?.appResetUrl).toBe("https://custom.example.com/pw");
|
|
55
|
+
// other flows still come from the mail default
|
|
56
|
+
expect(out.signup?.appActivationUrl).toBe("https://app.example.com/signup/complete");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("paths override only affects the named path", () => {
|
|
60
|
+
const pathsOverride: RunProdAppAuthOptions = {
|
|
61
|
+
admin,
|
|
62
|
+
mail: { baseUrl: "https://app.example.com", paths: { resetPassword: "/pw" } },
|
|
63
|
+
};
|
|
64
|
+
const out = resolveAuthMail(pathsOverride, "secret", { SMTP_HOST: "localhost" });
|
|
65
|
+
expect(out.passwordReset?.appResetUrl).toBe("https://app.example.com/pw");
|
|
66
|
+
expect(out.emailVerification?.appVerifyUrl).toBe("https://app.example.com/verify-email");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { resolveProdSessionsConfig, shouldWireProdSessions } from "../session-wiring";
|
|
3
|
+
|
|
4
|
+
describe("shouldWireProdSessions — secure-by-default with opt-out (KF-1)", () => {
|
|
5
|
+
it("wires sessions when the feature is mounted, even without an explicit config", () => {
|
|
6
|
+
// The publicstatus bug: sessions feature mounted + auth set, but no auth.sessions —
|
|
7
|
+
// previously left stateless (no revocation). Now it wires automatically.
|
|
8
|
+
expect(shouldWireProdSessions(true, true, undefined)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("wires sessions when a config object is given", () => {
|
|
12
|
+
expect(shouldWireProdSessions(true, true, { expiresInMs: 1000 })).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("does not wire when sessions: false (explicit opt-out)", () => {
|
|
16
|
+
expect(shouldWireProdSessions(true, true, false)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("does not wire when the sessions feature is not mounted", () => {
|
|
20
|
+
expect(shouldWireProdSessions(true, false, undefined)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not wire when the app has no auth at all", () => {
|
|
24
|
+
expect(shouldWireProdSessions(false, true, undefined)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("resolveProdSessionsConfig", () => {
|
|
29
|
+
it("passes a config object through", () => {
|
|
30
|
+
expect(resolveProdSessionsConfig({ expiresInMs: 5000 })).toEqual({ expiresInMs: 5000 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("collapses false / undefined to defaults", () => {
|
|
34
|
+
expect(resolveProdSessionsConfig(undefined)).toEqual({});
|
|
35
|
+
expect(resolveProdSessionsConfig(false)).toEqual({});
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/run-dev-app.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
validateAppCustomScreenWriteQns,
|
|
42
42
|
validateBoot,
|
|
43
43
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
44
|
+
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
44
45
|
import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
45
46
|
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
46
47
|
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
@@ -58,6 +59,7 @@ import { renderWelcomeBanner } from "./welcome-banner";
|
|
|
58
59
|
// brauchen. PasswordResetSetup / EmailVerificationSetup leben in
|
|
59
60
|
// run-prod-app.ts (single source of truth) — hier nur durchgereicht.
|
|
60
61
|
export type {
|
|
62
|
+
AuthMailOptions,
|
|
61
63
|
EmailVerificationSetup,
|
|
62
64
|
InviteSetup,
|
|
63
65
|
PasswordResetSetup,
|
|
@@ -65,12 +67,13 @@ export type {
|
|
|
65
67
|
} from "./run-prod-app";
|
|
66
68
|
|
|
67
69
|
import type {
|
|
70
|
+
AuthMailOptions,
|
|
68
71
|
EmailVerificationSetup,
|
|
69
72
|
InviteSetup,
|
|
70
73
|
PasswordResetSetup,
|
|
71
74
|
SignupSetup,
|
|
72
75
|
} from "./run-prod-app";
|
|
73
|
-
import { addConfigAccessorFactory } from "./run-prod-app";
|
|
76
|
+
import { addConfigAccessorFactory, buildBootExtraContext, resolveAuthMail } from "./run-prod-app";
|
|
74
77
|
|
|
75
78
|
export type RunDevAppAuthOptions = {
|
|
76
79
|
/** Admin user to seed at boot. Idempotent — re-runs in persistent-DB
|
|
@@ -90,6 +93,12 @@ export type RunDevAppAuthOptions = {
|
|
|
90
93
|
readonly sessions?: {
|
|
91
94
|
readonly expiresInMs?: number;
|
|
92
95
|
};
|
|
96
|
+
/** Auth-Mail-Convenience — symmetrisch zu RunProdAppAuthOptions.mail.
|
|
97
|
+
* Verdrahtet alle 4 Mail-Flows aus env-SMTP + Standard-Templates;
|
|
98
|
+
* hmacSecret = `JWT_SECRET`-env (Dev-Fallback wenn ungesetzt). Ohne
|
|
99
|
+
* `SMTP_HOST`-env bleiben die Flows unverdrahtet (Null-Transport-Guard).
|
|
100
|
+
* Explizite Flow-Setups gewinnen über den mail-Default. */
|
|
101
|
+
readonly mail?: AuthMailOptions;
|
|
93
102
|
/** Password-reset flow. Wenn gesetzt werden /api/auth/request-password-
|
|
94
103
|
* reset + /api/auth/reset-password als Public-Routes gemounted UND
|
|
95
104
|
* der request/confirm-Handler im auth-email-password-Feature wird
|
|
@@ -154,9 +163,14 @@ export type RunDevAppOptions = {
|
|
|
154
163
|
/** Eigene Seed-Funktionen, laufen nach dem Admin (wenn auth) in
|
|
155
164
|
* Array-Reihenfolge. */
|
|
156
165
|
readonly seeds?: readonly SeedFn[];
|
|
157
|
-
/** Extra-AppContext-Keys.
|
|
158
|
-
*
|
|
166
|
+
/** Extra-AppContext-Keys. `textContent` (immer) + `secrets` (wenn das
|
|
167
|
+
* secrets-Feature gemountet ist) + `configResolver` (auth-mode) werden
|
|
168
|
+
* automatisch ergänzt — App-Werte gewinnen. Symmetrisch zu runProdApp. */
|
|
159
169
|
readonly extraContext?: CreateKumikoServerOptions["extraContext"];
|
|
170
|
+
/** MasterKeyProvider für die auto-verdrahtete `ctx.secrets`. Default:
|
|
171
|
+
* `createEnvMasterKeyProvider`. Override für KMS-Backends. Nur relevant
|
|
172
|
+
* wenn das secrets-Feature gemountet ist. Symmetrisch zu runProdApp. */
|
|
173
|
+
readonly masterKey?: MasterKeyProvider;
|
|
160
174
|
/** Env-Quelle für die ENV→config-app-override-Brücke (Keys mit `env:`
|
|
161
175
|
* bekommen ihren env-Wert als app-override-Default — symmetrisch zu
|
|
162
176
|
* runProdApp). Default `process.env`. Injizierbar als Test-Seam, damit
|
|
@@ -194,9 +208,21 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
194
208
|
// source of truth — auch runProdApp und der per-app drizzle-Schema-
|
|
195
209
|
// Generator nutzen denselben Helper, damit Migration und Runtime nie
|
|
196
210
|
// auseinanderdriften können).
|
|
197
|
-
const
|
|
211
|
+
const envSource = options.envSource ?? process.env;
|
|
212
|
+
// auth.mail → normalisiert in die expliziten Flow-Felder (symmetrisch zu
|
|
213
|
+
// runProdApp). hmacSecret = JWT_SECRET-env mit Dev-Fallback (der Dev-Server
|
|
214
|
+
// mintet JWTs selbst; der Reset-Token-HMAC teilt denselben Key wie in den
|
|
215
|
+
// Apps). Ab hier IMMER effectiveAuth statt options.auth.
|
|
216
|
+
const effectiveAuth = options.auth
|
|
217
|
+
? resolveAuthMail(
|
|
218
|
+
options.auth,
|
|
219
|
+
envSource["JWT_SECRET"] ?? "kumiko-dev-auth-mail-hmac-fallback-secret",
|
|
220
|
+
envSource,
|
|
221
|
+
)
|
|
222
|
+
: undefined;
|
|
223
|
+
const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
|
|
198
224
|
const features = composeFeatures(options.features, {
|
|
199
|
-
includeBundled: !!
|
|
225
|
+
includeBundled: !!effectiveAuth,
|
|
200
226
|
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
201
227
|
});
|
|
202
228
|
|
|
@@ -277,13 +303,26 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
277
303
|
// throwaway-Registry hier extrahiert nur die config-Keys, weil der
|
|
278
304
|
// configResolver vor dem Server-Boot konstruiert wird (stack.registry
|
|
279
305
|
// gibt's erst onAfterSetup) — createKumikoServer baut intern seine eigene.
|
|
280
|
-
const
|
|
281
|
-
? mergeConfigResolverDefault(
|
|
282
|
-
options.extraContext,
|
|
283
|
-
createRegistry(features),
|
|
284
|
-
options.envSource ?? process.env,
|
|
285
|
-
)
|
|
306
|
+
const cfgExtra = effectiveAuth
|
|
307
|
+
? mergeConfigResolverDefault(options.extraContext, createRegistry(features), envSource)
|
|
286
308
|
: options.extraContext;
|
|
309
|
+
// Auto-wire textContent (immer) + secrets (feature-gated), symmetrisch zu
|
|
310
|
+
// runProdApp. Anders als prod existiert die db hier erst im Factory-deps
|
|
311
|
+
// (createKumikoServer baut den Stack), darum als Factory: buildBootExtraContext
|
|
312
|
+
// mit hasAuth:false (configResolver kommt schon aus cfgExtra), App-Werte
|
|
313
|
+
// (cfgExtra) gewinnen über die Boot-Defaults.
|
|
314
|
+
const extraContext: CreateKumikoServerOptions["extraContext"] = (deps) => {
|
|
315
|
+
const boot = buildBootExtraContext({
|
|
316
|
+
db: deps.db,
|
|
317
|
+
features,
|
|
318
|
+
envSource,
|
|
319
|
+
registry: deps.registry,
|
|
320
|
+
hasAuth: false,
|
|
321
|
+
...(options.masterKey && { masterKey: options.masterKey }),
|
|
322
|
+
});
|
|
323
|
+
const base = typeof cfgExtra === "function" ? cfgExtra(deps) : (cfgExtra ?? {});
|
|
324
|
+
return { ...boot, ...base };
|
|
325
|
+
};
|
|
287
326
|
|
|
288
327
|
// Sessions opt-in: Holder lebt im closure, `createSessionCallbacks`
|
|
289
328
|
// kennt erst nach setupTestStack die echte db-connection. Inline
|
|
@@ -300,7 +339,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
300
339
|
return sessionCallbacks;
|
|
301
340
|
};
|
|
302
341
|
const sessionAuthFragment =
|
|
303
|
-
|
|
342
|
+
effectiveAuth?.sessions !== undefined
|
|
304
343
|
? {
|
|
305
344
|
sessionCreator: (user: SessionUser, meta: SessionMetadata) =>
|
|
306
345
|
requireSessions().sessionCreator(user, meta),
|
|
@@ -334,55 +373,55 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
334
373
|
...(finalEffectiveFeatures !== undefined && {
|
|
335
374
|
effectiveFeatures: finalEffectiveFeatures,
|
|
336
375
|
}),
|
|
337
|
-
...(
|
|
376
|
+
...(effectiveAuth && {
|
|
338
377
|
auth: {
|
|
339
378
|
membershipQuery: TenantQueries.memberships,
|
|
340
379
|
loginHandler: AuthHandlers.login,
|
|
341
|
-
loginErrorStatusMap:
|
|
380
|
+
loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
|
|
342
381
|
[AuthErrors.invalidCredentials]: 401,
|
|
343
382
|
[AuthErrors.noMembership]: 403,
|
|
344
383
|
},
|
|
345
|
-
...(
|
|
346
|
-
cookieDomain:
|
|
384
|
+
...(effectiveAuth.cookieDomain !== undefined && {
|
|
385
|
+
cookieDomain: effectiveAuth.cookieDomain,
|
|
347
386
|
}),
|
|
348
|
-
...(
|
|
349
|
-
allowedOrigins:
|
|
387
|
+
...(effectiveAuth.allowedOrigins !== undefined && {
|
|
388
|
+
allowedOrigins: effectiveAuth.allowedOrigins,
|
|
350
389
|
}),
|
|
351
|
-
...(
|
|
352
|
-
unsafeSkipOriginCheck:
|
|
390
|
+
...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
|
|
391
|
+
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
353
392
|
}),
|
|
354
393
|
...sessionAuthFragment,
|
|
355
|
-
...(
|
|
394
|
+
...(effectiveAuth.passwordReset && {
|
|
356
395
|
passwordReset: {
|
|
357
396
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
358
397
|
confirmHandler: AuthHandlers.resetPassword,
|
|
359
|
-
sendResetEmail:
|
|
360
|
-
appResetUrl:
|
|
398
|
+
sendResetEmail: effectiveAuth.passwordReset.sendResetEmail,
|
|
399
|
+
appResetUrl: effectiveAuth.passwordReset.appResetUrl,
|
|
361
400
|
},
|
|
362
401
|
}),
|
|
363
|
-
...(
|
|
402
|
+
...(effectiveAuth.emailVerification && {
|
|
364
403
|
emailVerification: {
|
|
365
404
|
requestHandler: AuthHandlers.requestEmailVerification,
|
|
366
405
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
367
|
-
sendVerificationEmail:
|
|
368
|
-
appVerifyUrl:
|
|
406
|
+
sendVerificationEmail: effectiveAuth.emailVerification.sendVerificationEmail,
|
|
407
|
+
appVerifyUrl: effectiveAuth.emailVerification.appVerifyUrl,
|
|
369
408
|
},
|
|
370
409
|
}),
|
|
371
|
-
...(
|
|
410
|
+
...(effectiveAuth.signup && {
|
|
372
411
|
signup: {
|
|
373
412
|
requestHandler: AuthHandlers.signupRequest,
|
|
374
413
|
confirmHandler: AuthHandlers.signupConfirm,
|
|
375
|
-
sendActivationEmail:
|
|
376
|
-
appActivationUrl:
|
|
414
|
+
sendActivationEmail: effectiveAuth.signup.sendActivationEmail,
|
|
415
|
+
appActivationUrl: effectiveAuth.signup.appActivationUrl,
|
|
377
416
|
},
|
|
378
417
|
}),
|
|
379
|
-
...(
|
|
418
|
+
...(effectiveAuth.invite && {
|
|
380
419
|
invite: {
|
|
381
420
|
acceptHandler: AuthHandlers.inviteAccept,
|
|
382
421
|
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
383
422
|
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
384
|
-
sendInviteEmail:
|
|
385
|
-
appAcceptUrl:
|
|
423
|
+
sendInviteEmail: effectiveAuth.invite.sendInviteEmail,
|
|
424
|
+
appAcceptUrl: effectiveAuth.invite.appAcceptUrl,
|
|
386
425
|
},
|
|
387
426
|
}),
|
|
388
427
|
},
|
|
@@ -398,15 +437,15 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
398
437
|
registry: stack.registry,
|
|
399
438
|
});
|
|
400
439
|
}
|
|
401
|
-
if (
|
|
402
|
-
const expiresInMs =
|
|
440
|
+
if (effectiveAuth?.sessions !== undefined) {
|
|
441
|
+
const expiresInMs = effectiveAuth.sessions.expiresInMs;
|
|
403
442
|
sessionCallbacks = createSessionCallbacks({
|
|
404
443
|
db: stack.db,
|
|
405
444
|
...(expiresInMs !== undefined && { expiresInMs }),
|
|
406
445
|
});
|
|
407
446
|
}
|
|
408
|
-
if (
|
|
409
|
-
await seedAdmin(stack.db,
|
|
447
|
+
if (effectiveAuth) {
|
|
448
|
+
await seedAdmin(stack.db, effectiveAuth.admin);
|
|
410
449
|
}
|
|
411
450
|
// Apply r.config({ seeds }) declared by any registered feature.
|
|
412
451
|
// Runs before user-supplied seed callbacks so those can read /
|
|
@@ -424,10 +463,10 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
424
463
|
const port = handle.server?.port ?? options.port ?? 3000;
|
|
425
464
|
const banner = renderWelcomeBanner({
|
|
426
465
|
url: `http://localhost:${port}`,
|
|
427
|
-
...(
|
|
466
|
+
...(effectiveAuth?.admin && {
|
|
428
467
|
admin: {
|
|
429
|
-
email:
|
|
430
|
-
password:
|
|
468
|
+
email: effectiveAuth.admin.email,
|
|
469
|
+
password: effectiveAuth.admin.password,
|
|
431
470
|
},
|
|
432
471
|
}),
|
|
433
472
|
...(overrides.featuresDir !== undefined && { featuresDir: overrides.featuresDir }),
|
package/src/run-prod-app.ts
CHANGED
|
@@ -27,8 +27,13 @@
|
|
|
27
27
|
import {
|
|
28
28
|
AuthErrors,
|
|
29
29
|
AuthHandlers,
|
|
30
|
+
type AuthMailerConfig,
|
|
31
|
+
type AuthMailLocale,
|
|
32
|
+
type AuthPaths,
|
|
33
|
+
createAuthMailerConfig,
|
|
30
34
|
type EmailVerificationOptions,
|
|
31
35
|
type InviteOptions,
|
|
36
|
+
makeAuthPaths,
|
|
32
37
|
type PasswordResetOptions,
|
|
33
38
|
type SignupOptions,
|
|
34
39
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
@@ -36,13 +41,22 @@ import {
|
|
|
36
41
|
type SeedAdminOptions,
|
|
37
42
|
seedAdmin,
|
|
38
43
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
44
|
+
import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
39
45
|
import {
|
|
40
46
|
buildEnvConfigOverrides,
|
|
41
47
|
createConfigAccessorFactory,
|
|
42
48
|
createConfigResolver,
|
|
43
49
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
44
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
createSecretsContext,
|
|
52
|
+
SECRETS_FEATURE_NAME,
|
|
53
|
+
} from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
54
|
+
import {
|
|
55
|
+
createSessionCallbacks,
|
|
56
|
+
SESSIONS_FEATURE,
|
|
57
|
+
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
45
58
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
59
|
+
import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
46
60
|
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
47
61
|
import {
|
|
48
62
|
type CachePolicy,
|
|
@@ -52,7 +66,11 @@ import {
|
|
|
52
66
|
createSseBroker,
|
|
53
67
|
type SseBroker,
|
|
54
68
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
55
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
createDbConnection,
|
|
71
|
+
type DbConnection,
|
|
72
|
+
type DbRunner,
|
|
73
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
56
74
|
import {
|
|
57
75
|
buildAppSchema,
|
|
58
76
|
type ConfigResolver,
|
|
@@ -94,6 +112,10 @@ import {
|
|
|
94
112
|
createEventDedup,
|
|
95
113
|
createIdempotencyGuard,
|
|
96
114
|
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
115
|
+
import {
|
|
116
|
+
createEnvMasterKeyProvider,
|
|
117
|
+
type MasterKeyProvider,
|
|
118
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
97
119
|
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
98
120
|
import Redis from "ioredis";
|
|
99
121
|
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
@@ -101,6 +123,12 @@ import { ASSETS_DIR } from "./build-prod-bundle";
|
|
|
101
123
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
|
|
102
124
|
import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
|
|
103
125
|
import { injectSchema } from "./inject-schema";
|
|
126
|
+
import {
|
|
127
|
+
type ProdSessionsConfig,
|
|
128
|
+
type ProdSessionsOption,
|
|
129
|
+
resolveProdSessionsConfig,
|
|
130
|
+
shouldWireProdSessions,
|
|
131
|
+
} from "./session-wiring";
|
|
104
132
|
import { tryHonoFirst } from "./try-hono-first";
|
|
105
133
|
|
|
106
134
|
/**
|
|
@@ -258,6 +286,24 @@ export type InviteSetup = InviteOptions & {
|
|
|
258
286
|
readonly appAcceptUrl: string;
|
|
259
287
|
};
|
|
260
288
|
|
|
289
|
+
/** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
|
|
290
|
+
* Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
|
|
291
|
+
* Templates (siehe `auth.mail` + resolveAuthMail). */
|
|
292
|
+
export type AuthMailOptions = {
|
|
293
|
+
/** App-Basis-URL inkl. Schema (z.B. "https://app.example.com"). */
|
|
294
|
+
readonly baseUrl: string;
|
|
295
|
+
/** App-Name für Mail-Subject + Body. Default "Account". */
|
|
296
|
+
readonly appName?: string;
|
|
297
|
+
/** Locale für die Mail-Templates. Default "de". */
|
|
298
|
+
readonly locale?: AuthMailLocale;
|
|
299
|
+
/** "strict" blockt Login bis emailVerified; "off" mountet ohne Gate. */
|
|
300
|
+
readonly emailVerificationMode?: "strict" | "off";
|
|
301
|
+
/** Fallback-From-Adresse wenn `SMTP_FROM`-env fehlt (env gewinnt). */
|
|
302
|
+
readonly from?: string;
|
|
303
|
+
/** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
|
|
304
|
+
readonly paths?: Partial<AuthPaths>;
|
|
305
|
+
};
|
|
306
|
+
|
|
261
307
|
export type RunProdAppAuthOptions = {
|
|
262
308
|
/** Initial admin user. Seeded once (idempotent — re-boots check first
|
|
263
309
|
* whether the email is already in the users table). */
|
|
@@ -271,13 +317,27 @@ export type RunProdAppAuthOptions = {
|
|
|
271
317
|
*
|
|
272
318
|
* Standardverhalten ohne diese Option: stateless JWTs ohne sid
|
|
273
319
|
* (legacy-Verhalten, Kartenhaus existing-Apps unangefasst). */
|
|
274
|
-
readonly sessions?:
|
|
275
|
-
|
|
276
|
-
|
|
320
|
+
readonly sessions?: ProdSessionsOption;
|
|
321
|
+
/** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
|
|
322
|
+
* emailVerification, signup, invite) aus einem SMTP-Transport-aus-env +
|
|
323
|
+
* Standard-Templates. Ersetzt das per-App hand-gerollte
|
|
324
|
+
* `createSmtpTransportFromEnv` + `createAuthMailerConfig`-Wrapper-Trio.
|
|
325
|
+
*
|
|
326
|
+
* Null-Transport-Guard: ohne `SMTP_HOST`-env wird KEIN Mail-Flow
|
|
327
|
+
* verdrahtet (Routes blieben sonst 500). Eine App die einen einzelnen
|
|
328
|
+
* Flow custom braucht, setzt `passwordReset`/`signup`/… explizit —
|
|
329
|
+
* der explizite Block gewinnt über den mail-Default.
|
|
330
|
+
*
|
|
331
|
+
* Bewusste Entscheidung: `mail` mountet ALLE 4 Flows inkl. signup +
|
|
332
|
+
* invite (Self-Registration on). Wer nur reset+verify will, lässt `mail`
|
|
333
|
+
* weg und setzt die gewünschten Flows explizit — kein impliziter
|
|
334
|
+
* Self-Signup. */
|
|
335
|
+
readonly mail?: AuthMailOptions;
|
|
277
336
|
/** Password-reset flow. When set, /api/auth/request-password-reset +
|
|
278
337
|
* /api/auth/reset-password are mounted as public routes UND der
|
|
279
338
|
* request/confirm-Handler im auth-email-password-Feature wird
|
|
280
|
-
* registriert (sonst dispatchen die Routes ins Leere → 500).
|
|
339
|
+
* registriert (sonst dispatchen die Routes ins Leere → 500).
|
|
340
|
+
* Überschreibt den `mail`-Default für genau diesen Flow. */
|
|
281
341
|
readonly passwordReset?: PasswordResetSetup;
|
|
282
342
|
/** Email-verification flow. Symmetric to passwordReset. */
|
|
283
343
|
readonly emailVerification?: EmailVerificationSetup;
|
|
@@ -421,13 +481,22 @@ export type RunProdAppOptions = {
|
|
|
421
481
|
* Setups die ihren eigenen Schema-Check fahren (z.B. bring-your-own-
|
|
422
482
|
* ORM). Standard-Apps lassen das default. */
|
|
423
483
|
readonly migrations?: { readonly dir: string } | false;
|
|
424
|
-
/** Extra AppContext keys.
|
|
425
|
-
*
|
|
484
|
+
/** Extra AppContext keys. Framework-Defaults werden zur Boot-Zeit
|
|
485
|
+
* unter den Factory-Werten ergänzt, App-Werte gewinnen immer:
|
|
486
|
+
* - `textContent` (createTextContentApi(db)) — immer
|
|
487
|
+
* - `secrets` (createSecretsContext) — nur wenn das `secrets`-Feature
|
|
488
|
+
* gemountet ist (sonst kein KEK-env-Zwang für Apps ohne secrets)
|
|
489
|
+
* - `configResolver` — im Auth-Mode (env-config-overrides)
|
|
490
|
+
* Akzeptiert einen statischen Object ODER eine Factory
|
|
426
491
|
* `({db, redis, registry}) => Record<string, unknown>` — gleiches
|
|
427
|
-
* Pattern wie `anonymousAccess`.
|
|
428
|
-
*
|
|
429
|
-
* werden gemerged (Factory-Werte überschreiben). */
|
|
492
|
+
* Pattern wie `anonymousAccess`. Eine App die einen dieser Keys selbst
|
|
493
|
+
* setzt (z.B. eigener configResolver) überschreibt den Default. */
|
|
430
494
|
readonly extraContext?: ExtraContextOption;
|
|
495
|
+
/** MasterKeyProvider für die auto-verdrahtete `ctx.secrets`. Default:
|
|
496
|
+
* `createEnvMasterKeyProvider` (KEK aus `KUMIKO_SECRETS_MASTER_KEY_V<n>`).
|
|
497
|
+
* Override für KMS-Backends (AWS/GCP/Azure) statt env-KEK. Nur relevant
|
|
498
|
+
* wenn das `secrets`-Feature gemountet ist. */
|
|
499
|
+
readonly masterKey?: MasterKeyProvider;
|
|
431
500
|
/** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
|
|
432
501
|
* fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
|
|
433
502
|
* (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
|
|
@@ -547,6 +616,109 @@ export function addConfigAccessorFactory<T extends { readonly configResolver?: C
|
|
|
547
616
|
};
|
|
548
617
|
}
|
|
549
618
|
|
|
619
|
+
// Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
|
|
620
|
+
// der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
|
|
621
|
+
// Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
|
|
622
|
+
// Ausnahme. textContent ist unbedingt (createTextContentApi wirft nie, baut
|
|
623
|
+
// nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
|
|
624
|
+
// das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
|
|
625
|
+
// (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
|
|
626
|
+
// createEnvMasterKeyProvider nicht wirft. Das deckt zwei Fälle: (a) App ohne
|
|
627
|
+
// secrets → kein KEK-Zwang; (b) dev mit App-eigenem DEV-KEK in extraContext
|
|
628
|
+
// (kein env-KEK) → kein Boot-Crash, die App-explizite secrets-Wiring gewinnt.
|
|
629
|
+
// Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
|
|
630
|
+
// beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
|
|
631
|
+
// ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
|
|
632
|
+
// Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
|
|
633
|
+
// passiert beim Caller (App gewinnt).
|
|
634
|
+
const MASTER_KEK_VAR = /^KUMIKO_SECRETS_MASTER_KEY_V\d+$/;
|
|
635
|
+
function envHasMasterKek(env: Record<string, string | undefined>): boolean {
|
|
636
|
+
return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export function buildBootExtraContext(opts: {
|
|
640
|
+
readonly db: DbConnection;
|
|
641
|
+
readonly features: readonly FeatureDefinition[];
|
|
642
|
+
readonly envSource: Record<string, string | undefined>;
|
|
643
|
+
readonly registry: Registry;
|
|
644
|
+
readonly hasAuth: boolean;
|
|
645
|
+
readonly masterKey?: MasterKeyProvider;
|
|
646
|
+
}): Record<string, unknown> {
|
|
647
|
+
const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
|
|
648
|
+
const wireSecrets =
|
|
649
|
+
hasSecretsFeature && (opts.masterKey !== undefined || envHasMasterKek(opts.envSource));
|
|
650
|
+
return {
|
|
651
|
+
textContent: createTextContentApi(opts.db),
|
|
652
|
+
...(wireSecrets && {
|
|
653
|
+
secrets: createSecretsContext({
|
|
654
|
+
db: opts.db,
|
|
655
|
+
masterKeyProvider:
|
|
656
|
+
opts.masterKey ??
|
|
657
|
+
createEnvMasterKeyProvider({
|
|
658
|
+
// CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
|
|
659
|
+
// ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
|
|
660
|
+
env: {
|
|
661
|
+
...opts.envSource,
|
|
662
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
|
|
663
|
+
opts.envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
|
|
664
|
+
},
|
|
665
|
+
}),
|
|
666
|
+
}),
|
|
667
|
+
}),
|
|
668
|
+
...(opts.hasAuth && {
|
|
669
|
+
configResolver: createConfigResolver({
|
|
670
|
+
appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
|
|
671
|
+
}),
|
|
672
|
+
}),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// auth.mail-Convenience → normalisiert in die expliziten passwordReset/
|
|
677
|
+
// emailVerification/signup/invite-Felder, BEVOR buildComposeAuthOptions
|
|
678
|
+
// (Feature-Side: hmacSecret/mode) und das auth-routes-Fragment sie lesen —
|
|
679
|
+
// so speist EIN mail-Block beide Pfade. App-explizite Flows gewinnen über
|
|
680
|
+
// den Default. Null-Transport-Guard: ohne SMTP_HOST-env bleibt alles
|
|
681
|
+
// unverdrahtet (sonst lieferten die reset/verify-Routes 500).
|
|
682
|
+
/** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
|
|
683
|
+
* App-Auth-Typen (prod + dev) erfüllen das strukturell. */
|
|
684
|
+
type AuthMailNormalizable = {
|
|
685
|
+
readonly mail?: AuthMailOptions;
|
|
686
|
+
readonly passwordReset?: PasswordResetSetup;
|
|
687
|
+
readonly emailVerification?: EmailVerificationSetup;
|
|
688
|
+
readonly signup?: SignupSetup;
|
|
689
|
+
readonly invite?: InviteSetup;
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
export function resolveAuthMail<T extends AuthMailNormalizable>(
|
|
693
|
+
auth: T,
|
|
694
|
+
hmacSecret: string,
|
|
695
|
+
envSource: Record<string, string | undefined>,
|
|
696
|
+
): T {
|
|
697
|
+
if (!auth.mail) return auth;
|
|
698
|
+
const mailSender = createSmtpTransportFromEnv(envSource, {
|
|
699
|
+
fallbackFrom: auth.mail.from ?? "noreply@localhost",
|
|
700
|
+
});
|
|
701
|
+
if (!mailSender) return auth;
|
|
702
|
+
const mc: AuthMailerConfig = createAuthMailerConfig({
|
|
703
|
+
mailSender,
|
|
704
|
+
hmacSecret,
|
|
705
|
+
baseUrl: auth.mail.baseUrl,
|
|
706
|
+
paths: makeAuthPaths(auth.mail.paths),
|
|
707
|
+
...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
|
|
708
|
+
...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
|
|
709
|
+
...(auth.mail.emailVerificationMode !== undefined && {
|
|
710
|
+
emailVerificationMode: auth.mail.emailVerificationMode,
|
|
711
|
+
}),
|
|
712
|
+
});
|
|
713
|
+
return {
|
|
714
|
+
...auth,
|
|
715
|
+
passwordReset: auth.passwordReset ?? mc.passwordReset,
|
|
716
|
+
emailVerification: auth.emailVerification ?? mc.emailVerification,
|
|
717
|
+
signup: auth.signup ?? mc.signup,
|
|
718
|
+
invite: auth.invite ?? mc.invite,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
550
722
|
export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
|
|
551
723
|
// 0. Env-Schema validation + dry-run modes. Runs FIRST so:
|
|
552
724
|
// - operators can introspect env-requirements without a real boot
|
|
@@ -618,14 +790,21 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
618
790
|
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
619
791
|
console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
|
|
620
792
|
|
|
793
|
+
// auth.mail → expandiert in die expliziten Flow-Felder, bevor sie sowohl
|
|
794
|
+
// die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
|
|
795
|
+
// unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
|
|
796
|
+
const effectiveAuth = options.auth
|
|
797
|
+
? resolveAuthMail(options.auth, jwtSecret, envSource)
|
|
798
|
+
: undefined;
|
|
799
|
+
|
|
621
800
|
// 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
|
|
622
801
|
// password via composeFeatures — same source-of-truth as runDevApp
|
|
623
802
|
// AND the per-app drizzle-Schema-Generator, so Migration und Runtime
|
|
624
803
|
// sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
|
|
625
804
|
// can validate wiring and exit without opening a Postgres/Redis socket.
|
|
626
|
-
const composeAuthOptions = buildComposeAuthOptions(
|
|
805
|
+
const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
|
|
627
806
|
const features = composeFeatures(options.features, {
|
|
628
|
-
includeBundled: !!
|
|
807
|
+
includeBundled: !!effectiveAuth,
|
|
629
808
|
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
630
809
|
});
|
|
631
810
|
|
|
@@ -719,15 +898,19 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
719
898
|
typeof options.extraContext === "function"
|
|
720
899
|
? options.extraContext(deps)
|
|
721
900
|
: (options.extraContext ?? {});
|
|
901
|
+
|
|
902
|
+
// Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
|
|
903
|
+
// gewinnen immer (z.B. money-horse's eigener configResolver).
|
|
904
|
+
const autoExtraContext = buildBootExtraContext({
|
|
905
|
+
db,
|
|
906
|
+
features,
|
|
907
|
+
envSource,
|
|
908
|
+
registry,
|
|
909
|
+
hasAuth: !!effectiveAuth,
|
|
910
|
+
...(options.masterKey && { masterKey: options.masterKey }),
|
|
911
|
+
});
|
|
722
912
|
const extraContext = addConfigAccessorFactory(
|
|
723
|
-
|
|
724
|
-
? {
|
|
725
|
-
configResolver: createConfigResolver({
|
|
726
|
-
appOverrides: buildEnvConfigOverrides(registry, envSource),
|
|
727
|
-
}),
|
|
728
|
-
...resolvedExtraContext,
|
|
729
|
-
}
|
|
730
|
-
: resolvedExtraContext,
|
|
913
|
+
{ ...autoExtraContext, ...resolvedExtraContext },
|
|
731
914
|
registry,
|
|
732
915
|
);
|
|
733
916
|
const resolvedAnonymousAccess =
|
|
@@ -743,8 +926,16 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
743
926
|
// AuthRoutesConfig-Surface — der wird vom sessions-Feature selbst über
|
|
744
927
|
// die `autoRevokeOnPasswordChange`-Option konsumiert, nicht über die
|
|
745
928
|
// auth-routes.
|
|
746
|
-
|
|
747
|
-
|
|
929
|
+
// Secure-by-default: if the sessions feature is mounted, server-side revocation +
|
|
930
|
+
// sessionStrictMode are wired automatically; `auth.sessions` only overrides the config,
|
|
931
|
+
// and `auth.sessions: false` is the explicit opt-out (back to stateless JWTs).
|
|
932
|
+
const sessionsFeatureMounted = features.some((f) => f.name === SESSIONS_FEATURE);
|
|
933
|
+
const sessionAuthFragment = shouldWireProdSessions(
|
|
934
|
+
Boolean(effectiveAuth),
|
|
935
|
+
sessionsFeatureMounted,
|
|
936
|
+
effectiveAuth?.sessions,
|
|
937
|
+
)
|
|
938
|
+
? buildProdSessionAuth(db, resolveProdSessionsConfig(effectiveAuth?.sessions))
|
|
748
939
|
: undefined;
|
|
749
940
|
|
|
750
941
|
const baseEntrypointOptions = {
|
|
@@ -765,56 +956,56 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
765
956
|
...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
|
|
766
957
|
},
|
|
767
958
|
eventDedup,
|
|
768
|
-
...(
|
|
959
|
+
...(effectiveAuth && {
|
|
769
960
|
auth: {
|
|
770
961
|
membershipQuery: TenantQueries.memberships,
|
|
771
962
|
userQuery: UserQueries.findForAuth,
|
|
772
963
|
loginHandler: AuthHandlers.login,
|
|
773
|
-
loginErrorStatusMap:
|
|
964
|
+
loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
|
|
774
965
|
[AuthErrors.invalidCredentials]: 401,
|
|
775
966
|
[AuthErrors.noMembership]: 403,
|
|
776
967
|
},
|
|
777
|
-
...(
|
|
778
|
-
cookieDomain:
|
|
968
|
+
...(effectiveAuth.cookieDomain !== undefined && {
|
|
969
|
+
cookieDomain: effectiveAuth.cookieDomain,
|
|
779
970
|
}),
|
|
780
|
-
...(
|
|
781
|
-
allowedOrigins:
|
|
971
|
+
...(effectiveAuth.allowedOrigins !== undefined && {
|
|
972
|
+
allowedOrigins: effectiveAuth.allowedOrigins,
|
|
782
973
|
}),
|
|
783
|
-
...(
|
|
784
|
-
unsafeSkipOriginCheck:
|
|
974
|
+
...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
|
|
975
|
+
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
785
976
|
}),
|
|
786
977
|
...sessionAuthFragment,
|
|
787
|
-
...(
|
|
978
|
+
...(effectiveAuth.passwordReset && {
|
|
788
979
|
passwordReset: {
|
|
789
980
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
790
981
|
confirmHandler: AuthHandlers.resetPassword,
|
|
791
|
-
sendResetEmail:
|
|
792
|
-
appResetUrl:
|
|
982
|
+
sendResetEmail: effectiveAuth.passwordReset.sendResetEmail,
|
|
983
|
+
appResetUrl: effectiveAuth.passwordReset.appResetUrl,
|
|
793
984
|
},
|
|
794
985
|
}),
|
|
795
|
-
...(
|
|
986
|
+
...(effectiveAuth.emailVerification && {
|
|
796
987
|
emailVerification: {
|
|
797
988
|
requestHandler: AuthHandlers.requestEmailVerification,
|
|
798
989
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
799
|
-
sendVerificationEmail:
|
|
800
|
-
appVerifyUrl:
|
|
990
|
+
sendVerificationEmail: effectiveAuth.emailVerification.sendVerificationEmail,
|
|
991
|
+
appVerifyUrl: effectiveAuth.emailVerification.appVerifyUrl,
|
|
801
992
|
},
|
|
802
993
|
}),
|
|
803
|
-
...(
|
|
994
|
+
...(effectiveAuth.signup && {
|
|
804
995
|
signup: {
|
|
805
996
|
requestHandler: AuthHandlers.signupRequest,
|
|
806
997
|
confirmHandler: AuthHandlers.signupConfirm,
|
|
807
|
-
sendActivationEmail:
|
|
808
|
-
appActivationUrl:
|
|
998
|
+
sendActivationEmail: effectiveAuth.signup.sendActivationEmail,
|
|
999
|
+
appActivationUrl: effectiveAuth.signup.appActivationUrl,
|
|
809
1000
|
},
|
|
810
1001
|
}),
|
|
811
|
-
...(
|
|
1002
|
+
...(effectiveAuth.invite && {
|
|
812
1003
|
invite: {
|
|
813
1004
|
acceptHandler: AuthHandlers.inviteAccept,
|
|
814
1005
|
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
815
1006
|
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
816
|
-
sendInviteEmail:
|
|
817
|
-
appAcceptUrl:
|
|
1007
|
+
sendInviteEmail: effectiveAuth.invite.sendInviteEmail,
|
|
1008
|
+
appAcceptUrl: effectiveAuth.invite.appAcceptUrl,
|
|
818
1009
|
},
|
|
819
1010
|
}),
|
|
820
1011
|
},
|
|
@@ -880,8 +1071,8 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
880
1071
|
// "first boot" via flag, every seed-step checks its own
|
|
881
1072
|
// preconditions. Config-seeds rely on a deterministic
|
|
882
1073
|
// aggregate-id so re-boot becomes a version_conflict skip.
|
|
883
|
-
if (
|
|
884
|
-
await seedAdmin(db,
|
|
1074
|
+
if (effectiveAuth) {
|
|
1075
|
+
await seedAdmin(db, effectiveAuth.admin);
|
|
885
1076
|
}
|
|
886
1077
|
await applyBootSeeds({ registry, db });
|
|
887
1078
|
for (const seed of options.seeds ?? []) {
|
|
@@ -1270,7 +1461,7 @@ export function staticCachePolicy(pathname: string): CachePolicy {
|
|
|
1270
1461
|
|
|
1271
1462
|
function buildProdSessionAuth(
|
|
1272
1463
|
db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
|
|
1273
|
-
opts:
|
|
1464
|
+
opts: ProdSessionsConfig,
|
|
1274
1465
|
): {
|
|
1275
1466
|
readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
|
|
1276
1467
|
readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runProdApp session-wiring decision (extracted pure so it is testable without a
|
|
3
|
+
* full prod boot).
|
|
4
|
+
*
|
|
5
|
+
* Secure-by-default: mounting the `sessions` feature turns server-side session
|
|
6
|
+
* revocation + sessionStrictMode ON automatically — there is no separate opt-in. The
|
|
7
|
+
* `auth.sessions` option only overrides the config, and `auth.sessions: false` is the
|
|
8
|
+
* explicit opt-out (back to stateless JWTs).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type ProdSessionsConfig = { readonly expiresInMs?: number };
|
|
12
|
+
|
|
13
|
+
/** Config object to override defaults, or `false` to disable session wiring entirely. */
|
|
14
|
+
export type ProdSessionsOption = ProdSessionsConfig | false;
|
|
15
|
+
|
|
16
|
+
export function shouldWireProdSessions(
|
|
17
|
+
hasAuth: boolean,
|
|
18
|
+
sessionsFeatureMounted: boolean,
|
|
19
|
+
sessionsOption: ProdSessionsOption | undefined,
|
|
20
|
+
): boolean {
|
|
21
|
+
return hasAuth && sessionsFeatureMounted && sessionsOption !== false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The config passed to buildProdSessionAuth — `false`/absent collapse to defaults. */
|
|
25
|
+
export function resolveProdSessionsConfig(
|
|
26
|
+
sessionsOption: ProdSessionsOption | undefined,
|
|
27
|
+
): ProdSessionsConfig {
|
|
28
|
+
return sessionsOption || {};
|
|
29
|
+
}
|