@cosmicdrift/kumiko-dev-server 0.152.0 → 0.153.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.
Files changed (48) hide show
  1. package/package.json +5 -8
  2. package/src/__tests__/build-prod-bundle.integration.test.ts +1 -1
  3. package/src/__tests__/compose-stacks.test.ts +1 -1
  4. package/src/__tests__/discover-format.test.ts +1 -1
  5. package/src/__tests__/env-schema.integration.test.ts +1 -1
  6. package/src/__tests__/walkthrough.integration.test.ts +1 -1
  7. package/src/build.ts +1 -1
  8. package/src/create-kumiko-server.ts +12 -6
  9. package/src/index.ts +4 -12
  10. package/src/run-dev-app.ts +12 -8
  11. package/src/scaffold-app.ts +9 -4
  12. package/src/schema-apply.ts +4 -1
  13. package/src/schema-check-core.ts +1 -1
  14. package/src/setup-test-stack-from-features.ts +4 -1
  15. package/src/__tests__/boot-extra-context.test.ts +0 -140
  16. package/src/__tests__/build-prod-bundle.test.ts +0 -278
  17. package/src/__tests__/cache-headers.test.ts +0 -83
  18. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  19. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -382
  20. package/src/__tests__/compose-features.test.ts +0 -128
  21. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  22. package/src/__tests__/inject-schema.test.ts +0 -62
  23. package/src/__tests__/pii-boot-gate.test.ts +0 -68
  24. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  25. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  26. package/src/__tests__/require-env.test.ts +0 -29
  27. package/src/__tests__/resolve-auth-mail.test.ts +0 -69
  28. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  29. package/src/__tests__/run-prod-app-env-source.test.ts +0 -157
  30. package/src/__tests__/run-prod-app-spec.test.ts +0 -57
  31. package/src/__tests__/run-prod-app.integration.test.ts +0 -915
  32. package/src/__tests__/session-wiring.test.ts +0 -51
  33. package/src/__tests__/try-hono-first.test.ts +0 -63
  34. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  35. package/src/boot/apply-boot-seeds.ts +0 -19
  36. package/src/boot/boot-crypto.ts +0 -82
  37. package/src/boot/job-run-logger.ts +0 -51
  38. package/src/build-prod-bundle.ts +0 -692
  39. package/src/compose-features.ts +0 -164
  40. package/src/extra-routes-deps.ts +0 -47
  41. package/src/inject-schema.ts +0 -30
  42. package/src/pii-boot-gate.ts +0 -62
  43. package/src/resolve-tailwind-cli.ts +0 -45
  44. package/src/run-prod-app-boot-context.ts +0 -241
  45. package/src/run-prod-app-static-files.ts +0 -273
  46. package/src/run-prod-app.ts +0 -1158
  47. package/src/session-wiring.ts +0 -29
  48. package/src/try-hono-first.ts +0 -46
@@ -1,164 +0,0 @@
1
- // composeFeatures — single source of truth für die Feature-Liste die
2
- // Boot UND Schema-Generator sehen.
3
- //
4
- // Sowohl runDevApp als auch runProdApp mischen im auth-mode dieselben
5
- // vier Bundled-Features dazu (config + user + tenant + auth-email-pw).
6
- // Damit der drizzle-Schema-Generator pro App genau dieselbe Feature-
7
- // Liste sieht wie die Runtime, leben die Komposition hier — beide
8
- // Bootstrap-Wrapper UND der per-app drizzle/generate.ts rufen sie auf.
9
- //
10
- // Reihenfolge: Infrastruktur-Features (config/user/tenant) zuerst, dann
11
- // auth-email-password, dann die App-Features. Spätere Features dürfen
12
- // auf Frühere referenzieren (z.B. authClaims-Hooks an user/tenant).
13
-
14
- import {
15
- type AuthEmailPasswordOptions,
16
- type AuthMailLocale,
17
- createAuthEmailPasswordFeature,
18
- type EmailVerificationOptions,
19
- type InviteOptions,
20
- type PasswordResetOptions,
21
- type SignupOptions,
22
- } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
23
- import {
24
- AUTH_MFA_FEATURE,
25
- mfaStatusCheckerFromFeature,
26
- } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
27
- import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
28
- import { createTenantFeature } from "@cosmicdrift/kumiko-bundled-features/tenant";
29
- import { createUserFeature } from "@cosmicdrift/kumiko-bundled-features/user";
30
- import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
31
-
32
- export type ComposeFeaturesOptions = {
33
- /** When true, prepends config + user + tenant + auth-email-password
34
- * before the app features. Mirror of "auth-mode" in run{Dev,Prod}App. */
35
- readonly includeBundled: boolean;
36
- /** Optional auth-feature-options durchgereicht an
37
- * createAuthEmailPasswordFeature. Wenn passwordReset / emailVerification
38
- * hier gesetzt sind, registriert das Feature die request-/confirm-
39
- * Handler — sonst NICHT (500 wenn die routes via auth-routes.ts
40
- * gemounted sind aber kein Handler dispatcht). Hand-in-hand mit dem
41
- * passwordReset-Block in RunProdAppAuthOptions / RunDevAppAuthOptions. */
42
- readonly authOptions?: AuthEmailPasswordOptions;
43
- };
44
-
45
- export function composeFeatures(
46
- appFeatures: readonly FeatureDefinition[],
47
- options: ComposeFeaturesOptions,
48
- ): FeatureDefinition[] {
49
- // ponytail: includeBundled:false skips the auth-mfa auto-wiring below —
50
- // an app composing its own foundation and mounting auth-mfa itself is
51
- // responsible for threading mfaStatusCheckerFromFeature(...) into its
52
- // own createAuthEmailPasswordFeature(...) call, or login silently
53
- // bypasses MFA. Upgrade if this trips someone: warn here when appFeatures
54
- // contains AUTH_MFA_FEATURE but includeBundled is false.
55
- if (!options.includeBundled) return [...appFeatures];
56
-
57
- // Bundled foundation goes first so its instances carry the runDevApp /
58
- // runProdApp `authOptions` (passwordReset wiring etc.). App-features that
59
- // ALSO declare one of these names — e.g. the create-kumiko-app picker
60
- // hands back `createAuthEmailPasswordFeature()` because the user ticked
61
- // it — would otherwise crash createRegistry with "Duplicate feature".
62
- // Drop the app-side duplicates and warn so the user can clean run-config.
63
- //
64
- // auth-mfa is NOT part of the bundled foundation (apps opt in explicitly
65
- // via APP_FEATURES) — but if it's there, the login handler needs its
66
- // status-checker wired in at construction time, since createAuthEmail-
67
- // PasswordFeature is built right here, before the caller ever sees it.
68
- const mfaFeature = appFeatures.find((f) => f.name === AUTH_MFA_FEATURE);
69
- const authOptions = mfaFeature
70
- ? { ...options.authOptions, mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature) }
71
- : options.authOptions;
72
- const bundled = [
73
- createConfigFeature(),
74
- createUserFeature(),
75
- createTenantFeature(),
76
- createAuthEmailPasswordFeature(authOptions ?? {}),
77
- ];
78
- const bundledNames = new Set(bundled.map((f) => f.name));
79
- const filteredApp: FeatureDefinition[] = [];
80
- for (const f of appFeatures) {
81
- if (bundledNames.has(f.name)) {
82
- // biome-ignore lint/suspicious/noConsole: boot-time UX warning
83
- console.warn(
84
- `[composeFeatures] "${f.name}" already auto-mounted via includeBundled — dropping the explicit copy from APP_FEATURES. Remove it from run-config.ts to silence this warning.`,
85
- );
86
- continue;
87
- }
88
- filteredApp.push(f);
89
- }
90
- return [...bundled, ...filteredApp];
91
- }
92
-
93
- /** Shape eines beliebigen run{Prod,Dev}App-Auth-Blocks der eine
94
- * PasswordReset/EmailVerification-Konfiguration tragen kann. Die
95
- * Wrapper-API (PasswordResetSetup) extends die Feature-API
96
- * (PasswordResetOptions), darum reicht ein structural-typed
97
- * Lookup auf den auth-only-Subset. Erlaubt buildComposeAuthOptions
98
- * mit RunProd- UND RunDev-AuthOptions zu callen ohne den Helper
99
- * doppelt zu bauen. */
100
- export type AuthOptionsCarrier = {
101
- readonly passwordReset?: PasswordResetOptions;
102
- readonly emailVerification?: EmailVerificationOptions;
103
- readonly signup?: SignupOptions;
104
- readonly invite?: InviteOptions;
105
- };
106
-
107
- /** Baut den authOptions-Block für composeFeatures aus einem
108
- * Wrapper-Auth-Block. Reicht NUR die feature-side-Felder
109
- * (hmacSecret, tokenTtlMinutes, mode) durch — die mail-side
110
- * (sendResetEmail/appResetUrl) gehört in die auth-routes-config
111
- * und wird vom Wrapper separat verdrahtet.
112
- *
113
- * Returnt undefined wenn weder passwordReset noch emailVerification
114
- * gesetzt sind (composeFeatures default-deny: KEINE handler in der
115
- * Registry registriert, /api/auth/request-password-reset etc. bleiben
116
- * 401/404). */
117
- type MailFlowFields = {
118
- readonly appUrl: string;
119
- readonly tokenTtlMinutes?: number;
120
- readonly appName?: string;
121
- readonly locale?: AuthMailLocale;
122
- };
123
-
124
- // The magic-link mail fields shared by all four flows. Conditional spreads omit
125
- // undefined keys (exactOptionalPropertyTypes) and avoid the property-write that
126
- // trips noPropertyAccessFromIndexSignature on the type-aliased option shapes.
127
- // reset/verify layer hmacSecret (+ mode) on top.
128
- function pickMailFields(src: MailFlowFields): MailFlowFields {
129
- return {
130
- appUrl: src.appUrl,
131
- ...(src.tokenTtlMinutes !== undefined && { tokenTtlMinutes: src.tokenTtlMinutes }),
132
- ...(src.appName !== undefined && { appName: src.appName }),
133
- ...(src.locale !== undefined && { locale: src.locale }),
134
- };
135
- }
136
-
137
- export function buildComposeAuthOptions(
138
- auth: AuthOptionsCarrier | undefined,
139
- ): AuthEmailPasswordOptions | undefined {
140
- if (!auth) return undefined;
141
- const opts: { -readonly [K in keyof AuthEmailPasswordOptions]: AuthEmailPasswordOptions[K] } = {};
142
- if (auth.passwordReset) {
143
- opts.passwordReset = {
144
- hmacSecret: auth.passwordReset.hmacSecret,
145
- ...pickMailFields(auth.passwordReset),
146
- };
147
- }
148
- if (auth.emailVerification) {
149
- opts.emailVerification = {
150
- hmacSecret: auth.emailVerification.hmacSecret,
151
- ...pickMailFields(auth.emailVerification),
152
- ...(auth.emailVerification.mode !== undefined && { mode: auth.emailVerification.mode }),
153
- };
154
- }
155
- if (auth.signup) {
156
- opts.signup = pickMailFields(auth.signup);
157
- }
158
- if (auth.invite) {
159
- opts.invite = pickMailFields(auth.invite);
160
- }
161
- return opts.passwordReset || opts.emailVerification || opts.signup || opts.invite
162
- ? opts
163
- : undefined;
164
- }
@@ -1,47 +0,0 @@
1
- import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import {
4
- createSystemUser,
5
- type Registry,
6
- type SessionUser,
7
- type TenantId,
8
- type WriteResult,
9
- } from "@cosmicdrift/kumiko-framework/engine";
10
- import type Redis from "ioredis";
11
-
12
- /** Deps für `extraRoutes` — geteilt zwischen runProdApp (prod) und
13
- * createKumikoServer (dev), damit die beiden Pfade nicht driften.
14
- * Naming: `deps` statt `ctx` weil im Framework `ctx` der HandlerContext
15
- * mit user/tenant/registry ist — hier ist der Scope absichtlich kleiner
16
- * (Routes laufen außerhalb der Auth/Tenant-Pipeline). */
17
- export type ExtraRoutesSystemDeps = {
18
- readonly db: DbConnection;
19
- readonly redis: Redis;
20
- /** Feature-registry — z.B. für Plugin-Lookups via
21
- * `registry.getExtensionUsages("subscriptionProvider")`. */
22
- readonly registry: Registry;
23
- /** Schreibt durch den /api/*-Command-Dispatcher (gleiche Idempotency/
24
- * Job-Hooks) — aber als auto-konstruierter SystemAdmin des Ziel-
25
- * Tenants, OHNE Access-Check der Route. Privilege-Scope: SystemAdmin
26
- * ist die höchste nicht-tenant-scoped Rolle — der Call erreicht JEDEN
27
- * SystemAdmin-gegateten Handler auf jedem Tenant; das Rollen-Set ist
28
- * nicht konfigurierbar. Nur für Pfade, die ihre Authentizität selbst
29
- * beweisen (Provider-Webhook-Signaturen,
30
- * createSubscriptionWebhookHandler et al.). */
31
- readonly dispatchSystemWrite: (args: {
32
- readonly handlerQn: string;
33
- readonly payload: unknown;
34
- readonly tenantId: TenantId;
35
- }) => Promise<WriteResult>;
36
- };
37
-
38
- type SystemWriteDispatcher = {
39
- readonly write: (handlerQn: string, payload: unknown, user: SessionUser) => Promise<WriteResult>;
40
- };
41
-
42
- export function makeDispatchSystemWrite(
43
- dispatcher: SystemWriteDispatcher,
44
- ): ExtraRoutesSystemDeps["dispatchSystemWrite"] {
45
- return ({ handlerQn, payload, tenantId }) =>
46
- dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
47
- }
@@ -1,30 +0,0 @@
1
- // Injiziert das Server-aufgelöste AppSchema in das HTML-Template damit
2
- // createKumikoApp() es synchron unter `window.__KUMIKO_SCHEMA__` vorfindet.
3
- // JSON ist valides JS — direkt eingebettet, der Browser parsed das
4
- // Object-Literal nativ.
5
- //
6
- // Geteilt zwischen dev-server (Schema kommt frisch aus dem laufenden
7
- // Prozess) und prod-server (Schema wird beim Boot einmal berechnet und
8
- // in die statisch ausgelieferte index.html injiziert). Beide Pfade
9
- // nutzen dieselbe Tag-Form damit `createKumikoApp` den Lookup nicht je
10
- // nach Kontext anders machen muss.
11
- //
12
- // Idempotenz: wenn das HTML schon einen __KUMIKO_SCHEMA__-Marker hat,
13
- // wird nicht doppelt injected — verhindert dass repeated index.html-
14
- // Reads (prod) oder bereits-vorbereitete templates (custom CI-builds)
15
- // stacking-Tags produzieren.
16
-
17
- // `<` als < serialisieren: verhindert `</script>`-Breakout aus dem
18
- // RAWTEXT-Block, JSON bleibt valides JS.
19
- function scriptSafeJsonHtml(json: string): string {
20
- return json.replace(/</g, "\\u003c");
21
- }
22
-
23
- export function injectSchema(html: string, schemaJson: string): string {
24
- if (html.includes("__KUMIKO_SCHEMA__")) return html;
25
- const tag = `<script>window.__KUMIKO_SCHEMA__=${scriptSafeJsonHtml(schemaJson)};</script>`;
26
- if (html.includes('<script src="/client.js"')) {
27
- return html.replace('<script src="/client.js"', `${tag}<script src="/client.js"`);
28
- }
29
- return html.includes("</body>") ? html.replace("</body>", `${tag}</body>`) : html + tag;
30
- }
@@ -1,62 +0,0 @@
1
- import {
2
- collectLookupableFields,
3
- collectPiiSubjectFields,
4
- type KmsAdapter,
5
- } from "@cosmicdrift/kumiko-framework/crypto";
6
- import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
7
-
8
- type PiiGateOptions = {
9
- readonly kms?: KmsAdapter | undefined;
10
- readonly blindIndexKey?: string | undefined;
11
- readonly allowPlaintextPii?: string | undefined;
12
- /** prod fails hard on plaintext PII (opt-out via allowPlaintextPii);
13
- * dev only warns — local data, and an InMemory KMS against a persistent
14
- * dev DB would strand every row after a restart. */
15
- readonly mode: "prod" | "dev";
16
- };
17
-
18
- export function assertPiiBootInvariants(
19
- features: readonly FeatureDefinition[],
20
- opts: PiiGateOptions,
21
- ): void {
22
- const tag = opts.mode === "prod" ? "runProdApp" : "runDevApp";
23
-
24
- const lookupableEntities = features.flatMap((feature) =>
25
- Object.entries(feature.entities ?? {})
26
- .filter(([, entity]) => collectLookupableFields(entity).length > 0)
27
- .map(([name]) => name),
28
- );
29
- if (opts.kms && !opts.blindIndexKey && lookupableEntities.length > 0) {
30
- throw new Error(
31
- `[${tag}] BOOT ABORTED — entities [${lookupableEntities.join(", ")}] declare lookupable fields and a KMS is configured, but no blindIndexKey was passed. Equality lookups on encrypted fields would silently stop matching. Pass { blindIndexKey } (env: KUMIKO_BLIND_INDEX_KEY, generate: openssl rand -base64 32).`,
32
- );
33
- }
34
-
35
- // skip: KMS configured — PII fields are encrypted, nothing left to gate.
36
- if (opts.kms) return;
37
- const piiEntities = features.flatMap((feature) =>
38
- Object.entries(feature.entities ?? {})
39
- .filter(([, entity]) => collectPiiSubjectFields(entity).length > 0)
40
- .map(([name]) => name),
41
- );
42
- // skip: no PII-annotated entities mounted — plaintext gate is moot.
43
- if (piiEntities.length === 0) return;
44
-
45
- if (opts.mode === "dev") {
46
- // biome-ignore lint/suspicious/noConsole: boot-time security warning
47
- console.warn(
48
- `[${tag}] ${piiEntities.length} entities carry pii/userOwned/tenantOwned annotations but no \`kms\` adapter is configured — fields are stored in PLAINTEXT locally. Pass { kms: new InMemoryKmsAdapter() } (ephemeral DB) or createPgKmsAdapter(...) to exercise crypto-shredding in dev.`,
49
- );
50
- return;
51
- }
52
- if (opts.allowPlaintextPii) {
53
- // biome-ignore lint/suspicious/noConsole: boot-time security warning
54
- console.warn(
55
- `[${tag}] ${piiEntities.length} entities carry PII annotations but no \`kms\` adapter is configured — fields are stored in PLAINTEXT (allowPlaintextPii: "${opts.allowPlaintextPii}"). GDPR erasure via crypto-shredding is NOT possible until a KMS is provisioned.`,
56
- );
57
- return;
58
- }
59
- throw new Error(
60
- `[${tag}] BOOT ABORTED — entities [${piiEntities.join(", ")}] carry pii/userOwned/tenantOwned annotations but no \`kms\` adapter is configured. The fields would be stored in PLAINTEXT and GDPR erasure (crypto-shredding) could not work. Pass runProdApp({ kms: createPgKmsAdapter({ databaseUrl, platformKek }) }) — or acknowledge explicitly with { allowPlaintextPii: "<reason>" } until your KMS is provisioned.`,
61
- );
62
- }
@@ -1,45 +0,0 @@
1
- // Resolved den lokal installierten @tailwindcss/cli-Bin auf seinen
2
- // absoluten Dateipfad. Wir vermeiden `bunx`, weil das auch bei lokal
3
- // installiertem Package noch das Registry-Manifest fragt und ohne Netz
4
- // mit `FailedToOpenSocket` stirbt.
5
- //
6
- // Ausgelagert aus create-kumiko-server.ts, damit unter vitest/Node
7
- // testbar (Bun-Branch + Resolve-Fail-Branch) ohne den Server zu
8
- // booten.
9
-
10
- import { dirname, resolve } from "node:path";
11
-
12
- type BunResolver = { resolveSync: (id: string, from: string) => string };
13
-
14
- export type ResolveTailwindCliDeps = {
15
- readonly bun?: BunResolver;
16
- readonly cwd: string;
17
- };
18
-
19
- export function resolveTailwindCli(deps: ResolveTailwindCliDeps): string | undefined {
20
- if (deps.bun === undefined) return undefined;
21
- try {
22
- const pkgJsonPath = deps.bun.resolveSync("@tailwindcss/cli/package.json", deps.cwd);
23
- // bin: { tailwindcss: "./dist/index.mjs" } → absoluter Pfad
24
- return resolve(pkgJsonPath, "..", "dist", "index.mjs");
25
- } catch {
26
- return undefined;
27
- }
28
- }
29
-
30
- /** Tailwind v4 resolves `@import "tailwindcss"` relative to the entry
31
- * CSS file. Skip renderer-web fallback (or fail build) when peer deps
32
- * aren't resolvable from that directory — e.g. Bun cache copies of
33
- * `@cosmicdrift/kumiko-renderer-web` outside a hoisted node_modules tree. */
34
- export function canResolveTailwindStylesheet(
35
- entryCss: string,
36
- deps: ResolveTailwindCliDeps,
37
- ): boolean {
38
- if (deps.bun === undefined) return false;
39
- try {
40
- deps.bun.resolveSync("tailwindcss", dirname(entryCss));
41
- return true;
42
- } catch {
43
- return false;
44
- }
45
- }
@@ -1,241 +0,0 @@
1
- import { makeAuthPaths } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
- import { bindMfaRevokeAllOtherSessionsFromFeature } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
3
- import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
4
- import {
5
- buildEnvConfigOverrides,
6
- createConfigAccessorFactory,
7
- createConfigResolver,
8
- } from "@cosmicdrift/kumiko-bundled-features/config";
9
- import {
10
- collectChannels,
11
- createDeliveryService,
12
- DELIVERY_FEATURE,
13
- } from "@cosmicdrift/kumiko-bundled-features/delivery";
14
- import {
15
- createSecretsContext,
16
- SECRETS_FEATURE_NAME,
17
- } from "@cosmicdrift/kumiko-bundled-features/secrets";
18
- import {
19
- bindAutoRevokeFromFeature,
20
- createSessionCallbacks,
21
- } from "@cosmicdrift/kumiko-bundled-features/sessions";
22
- import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
23
- import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
24
- import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
25
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
26
- import type {
27
- ConfigResolver,
28
- FeatureDefinition,
29
- NotifyFactory,
30
- Registry,
31
- } from "@cosmicdrift/kumiko-framework/engine";
32
- import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
33
- import { type BootCrypto, resolveBootCrypto } from "./boot/boot-crypto";
34
- import type {
35
- AuthMailOptions,
36
- EmailVerificationSetup,
37
- InviteSetup,
38
- PasswordResetSetup,
39
- SignupSetup,
40
- } from "./run-prod-app";
41
- import type { ProdSessionsConfig } from "./session-wiring";
42
-
43
- // Boot-time context helpers for runProdApp: ctx-extra-context wiring
44
- // (textContent/delivery/secrets/config-resolver), auth-mail convenience
45
- // normalization, and prod session-auth wiring. Split out of run-prod-app.ts
46
- // (#1005, Welle 2) — mechanical relocation, these are self-contained pure
47
- // functions, no closure over runProdApp's local boot state.
48
-
49
- // Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
50
- export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
51
- resolved: T,
52
- registry: Registry,
53
- ): T {
54
- if (!resolved.configResolver) return resolved;
55
- return {
56
- ...resolved,
57
- _configAccessorFactory: createConfigAccessorFactory(registry, resolved.configResolver),
58
- };
59
- }
60
-
61
- // Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
62
- // der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
63
- // Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
64
- // Ausnahme. textContent ist unbedingt (createTextContentApi wirft nie, baut
65
- // nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
66
- // das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
67
- // (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
68
- // KEK-Detection + Provider/Cipher-Aufbau leben in boot/boot-crypto.ts
69
- // (envHasMasterKek, resolveBootCrypto) — gemeinsam mit runDevApp.
70
- // Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
71
- // beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
72
- // ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
73
- // Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
74
- // passiert beim Caller (App gewinnt).
75
-
76
- // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
77
- // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
78
- // and every notification silently skips. sseBroker optional (email/push don't
79
- // need it, in-app SSE does); no jobRunner → queued channels send inline.
80
- function buildDeliveryNotifyFactory(opts: {
81
- readonly db: DbConnection;
82
- readonly registry: Registry;
83
- readonly sseBroker?: SseBroker;
84
- }): NotifyFactory {
85
- const deliveryService = createDeliveryService({
86
- db: opts.db,
87
- registry: opts.registry,
88
- channels: collectChannels(opts.registry),
89
- ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
90
- });
91
- return (user, tenantId) => (notificationType, options) =>
92
- deliveryService.notify(notificationType, options, user, tenantId);
93
- }
94
-
95
- export function buildBootExtraContext(opts: {
96
- readonly db: DbConnection;
97
- readonly features: readonly FeatureDefinition[];
98
- readonly envSource: Record<string, string | undefined>;
99
- readonly registry: Registry;
100
- readonly hasAuth: boolean;
101
- // Resolved once per boot via resolveBootCrypto — shared by secrets,
102
- // config-resolver, config-set-handler and boot-seeds. Absent (tests
103
- // that don't care about encryption) ⇒ resolved from envSource here.
104
- readonly crypto?: BootCrypto;
105
- readonly masterKey?: MasterKeyProvider;
106
- readonly sseBroker?: SseBroker;
107
- readonly kms?: KmsAdapter;
108
- }): Record<string, unknown> {
109
- const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
110
- const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
111
- const wireSecrets = hasSecretsFeature && crypto.masterKeyProvider !== undefined;
112
- const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
113
- return {
114
- textContent: createTextContentApi(opts.db),
115
- ...(opts.kms && { kms: opts.kms }),
116
- ...(hasDeliveryFeature && {
117
- _notifyFactory: buildDeliveryNotifyFactory({
118
- db: opts.db,
119
- registry: opts.registry,
120
- ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
121
- }),
122
- }),
123
- // Top-level provider so feature jobs (secrets rotate, config reencrypt)
124
- // reach it via ctx — previously only test-stack wired it.
125
- ...(crypto.masterKeyProvider && { masterKeyProvider: crypto.masterKeyProvider }),
126
- // Encrypt/decrypt partner for `encrypted: true` config keys. Wired
127
- // whenever a master key exists — NOT gated on the secrets feature,
128
- // config encryption must work without mounting ctx.secrets.
129
- ...(crypto.configCipher && { configEncryption: crypto.configCipher }),
130
- ...(wireSecrets &&
131
- crypto.masterKeyProvider && {
132
- secrets: createSecretsContext({
133
- db: opts.db,
134
- masterKeyProvider: crypto.masterKeyProvider,
135
- dekCache: crypto.dekCache,
136
- }),
137
- }),
138
- ...(opts.hasAuth && {
139
- configResolver: createConfigResolver({
140
- appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
141
- ...(crypto.configCipher && { cipher: crypto.configCipher }),
142
- }),
143
- }),
144
- };
145
- }
146
-
147
- // auth.mail-Convenience → normalisiert in die expliziten passwordReset/
148
- // emailVerification/signup/invite-Felder, BEVOR buildComposeAuthOptions
149
- // (Feature-Side: hmacSecret/mode) und das auth-routes-Fragment sie lesen —
150
- // so speist EIN mail-Block beide Pfade. App-explizite Flows gewinnen über
151
- // den Default. Null-Transport-Guard: ohne SMTP_HOST-env bleibt alles
152
- // unverdrahtet (sonst lieferten die reset/verify-Routes 500).
153
- /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
154
- * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
155
- type AuthMailNormalizable = {
156
- readonly mail?: AuthMailOptions;
157
- readonly passwordReset?: PasswordResetSetup;
158
- readonly emailVerification?: EmailVerificationSetup;
159
- readonly signup?: SignupSetup;
160
- readonly invite?: InviteSetup;
161
- };
162
-
163
- export function resolveAuthMail<T extends AuthMailNormalizable>(
164
- auth: T,
165
- hmacSecret: string,
166
- envSource: Record<string, string | undefined>,
167
- ): T {
168
- if (!auth.mail) return auth;
169
- // SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
170
- // blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
171
- // (channel-email), nicht über diesen Transport — er ist nur der Detektor
172
- // "ist Mail konfiguriert?".
173
- if (
174
- !createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
175
- ) {
176
- return auth;
177
- }
178
- const paths = makeAuthPaths(auth.mail.paths);
179
- // appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
180
- const mailPresentation = {
181
- ...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
182
- ...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
183
- };
184
- return {
185
- ...auth,
186
- passwordReset: auth.passwordReset ?? {
187
- hmacSecret,
188
- appUrl: `${auth.mail.baseUrl}${paths.resetPassword}`,
189
- ...mailPresentation,
190
- },
191
- emailVerification: auth.emailVerification ?? {
192
- hmacSecret,
193
- appUrl: `${auth.mail.baseUrl}${paths.verifyEmail}`,
194
- ...(auth.mail.emailVerificationMode !== undefined && {
195
- mode: auth.mail.emailVerificationMode,
196
- }),
197
- ...mailPresentation,
198
- },
199
- signup: auth.signup ?? {
200
- appUrl: `${auth.mail.baseUrl}${paths.signupComplete}`,
201
- ...mailPresentation,
202
- },
203
- invite: auth.invite ?? {
204
- appUrl: `${auth.mail.baseUrl}${paths.inviteAccept}`,
205
- ...mailPresentation,
206
- },
207
- };
208
- }
209
-
210
- export function buildProdSessionAuth(
211
- db: DbConnection,
212
- opts: ProdSessionsConfig,
213
- sessionsFeature: FeatureDefinition | undefined,
214
- mfaFeature: FeatureDefinition | undefined,
215
- ): {
216
- readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
217
- readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
218
- readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
219
- readonly sessionStrictMode: true;
220
- } {
221
- const cbs = createSessionCallbacks({
222
- db,
223
- ...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
224
- });
225
- // Secure-by-default: password-change/-reset mass-revokes the user's live
226
- // sessions without the app opting in via autoRevokeOnPasswordChange.
227
- if (sessionsFeature) {
228
- bindAutoRevokeFromFeature(sessionsFeature)?.(cbs.sessionMassRevoker);
229
- }
230
- // MFA enable/disable/regenerate mass-revokes every OTHER live session
231
- // (stolen-session defense) — only wired when auth-mfa is mounted.
232
- if (mfaFeature) {
233
- bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(cbs.sessionRevokeAllOthers);
234
- }
235
- return {
236
- sessionCreator: cbs.sessionCreator,
237
- sessionRevoker: cbs.sessionRevoker,
238
- sessionChecker: cbs.sessionChecker,
239
- sessionStrictMode: true,
240
- };
241
- }