@cosmicdrift/kumiko-server-runtime 0.1.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/LICENSE +57 -0
- package/package.json +87 -0
- package/src/__tests__/boot-extra-context.test.ts +140 -0
- package/src/__tests__/build-prod-bundle.test.ts +278 -0
- package/src/__tests__/cache-headers.test.ts +83 -0
- package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +308 -0
- package/src/__tests__/compose-features-wiring.integration.test.ts +382 -0
- package/src/__tests__/compose-features.test.ts +128 -0
- package/src/__tests__/config-seed-boot.integration.test.ts +158 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/pii-boot-gate.test.ts +68 -0
- package/src/__tests__/renderer-web-css-relocation.integration.test.ts +85 -0
- package/src/__tests__/renderer-web-shell-sentinel.test.ts +35 -0
- package/src/__tests__/require-env.test.ts +29 -0
- package/src/__tests__/resolve-auth-mail.test.ts +69 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +81 -0
- package/src/__tests__/run-prod-app-env-source.test.ts +157 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.test.ts +915 -0
- package/src/__tests__/session-wiring.test.ts +51 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/boot/__tests__/job-run-logger.test.ts +26 -0
- package/src/boot/apply-boot-seeds.ts +19 -0
- package/src/boot/boot-crypto.ts +82 -0
- package/src/boot/job-run-logger.ts +51 -0
- package/src/build-prod-bundle.ts +692 -0
- package/src/bun-serve-options.ts +22 -0
- package/src/compose-features.ts +164 -0
- package/src/extra-routes-deps.ts +47 -0
- package/src/index.ts +16 -0
- package/src/inject-schema.ts +30 -0
- package/src/pii-boot-gate.ts +62 -0
- package/src/resolve-tailwind-cli.ts +45 -0
- package/src/run-prod-app-boot-context.ts +241 -0
- package/src/run-prod-app-static-files.ts +273 -0
- package/src/run-prod-app.ts +1137 -0
- package/src/session-wiring.ts +29 -0
- package/src/try-hono-first.ts +46 -0
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
// runProdApp — production-grade Bootstrap-Wrapper für Kumiko-Apps.
|
|
2
|
+
//
|
|
3
|
+
// Symmetrisch zu runDevApp, aber:
|
|
4
|
+
// - DATABASE_URL / REDIS_URL / JWT_SECRET aus env (fail-fast bei Boot,
|
|
5
|
+
// keine ephemeralen Test-DBs)
|
|
6
|
+
// - Hard Schema-Drift-Gate: prüft kumiko/migrations vs. _kumiko_migrations
|
|
7
|
+
// + tableExists für jede erwartete Tabelle. KEIN Auto-CREATE TABLE im
|
|
8
|
+
// Boot — Migration ist ein CI-Step (`bun kumiko schema apply`), Boot
|
|
9
|
+
// validiert nur. Verhindert Race-Conditions bei Multi-Replica-Deploys
|
|
10
|
+
// + macht Schema-Stand reviewbar in der Pull-Request.
|
|
11
|
+
// - Idempotente Seeds: laufen nur wenn DB leer (über `isDbEmpty`-Probe
|
|
12
|
+
// pro Seed). Re-Boots nach erstem Seed sind no-op.
|
|
13
|
+
// - HTTP-Server via Bun.serve mit graceful SIGTERM/SIGINT → drain().
|
|
14
|
+
// - Auth-Routes + bundled-features auto-mix wenn `auth:` gesetzt
|
|
15
|
+
// (gleiche Logik wie runDevApp).
|
|
16
|
+
//
|
|
17
|
+
// App-Author schreibt:
|
|
18
|
+
// await runProdApp({ features, auth, anonymousAccess, seeds });
|
|
19
|
+
//
|
|
20
|
+
// Container/Coolify setzt:
|
|
21
|
+
// DATABASE_URL=postgresql://...
|
|
22
|
+
// REDIS_URL=redis://...
|
|
23
|
+
// JWT_SECRET=<random-32+>
|
|
24
|
+
// PORT=3000
|
|
25
|
+
// KUMIKO_INSTANCE_ID=<stable per replica>
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
AuthErrors,
|
|
29
|
+
AuthHandlers,
|
|
30
|
+
type AuthMailLocale,
|
|
31
|
+
type AuthPaths,
|
|
32
|
+
type EmailVerificationOptions,
|
|
33
|
+
type InviteOptions,
|
|
34
|
+
type PasswordResetOptions,
|
|
35
|
+
type SignupOptions,
|
|
36
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
37
|
+
import {
|
|
38
|
+
type SeedAdminOptions,
|
|
39
|
+
seedAdmin,
|
|
40
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
41
|
+
import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
42
|
+
import {
|
|
43
|
+
createPatResolver,
|
|
44
|
+
PAT_FEATURE,
|
|
45
|
+
patRateLimitFromFeature,
|
|
46
|
+
patScopesFromFeature,
|
|
47
|
+
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
48
|
+
import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
49
|
+
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
50
|
+
import {
|
|
51
|
+
resolveTenantLifecycleGate,
|
|
52
|
+
TENANT_LIFECYCLE_FEATURE,
|
|
53
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
|
|
54
|
+
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
55
|
+
import {
|
|
56
|
+
createInMemoryLoginRateLimiter,
|
|
57
|
+
createSseBroker,
|
|
58
|
+
type SseBroker,
|
|
59
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
60
|
+
import {
|
|
61
|
+
configureBlindIndexKey,
|
|
62
|
+
configurePiiSubjectKms,
|
|
63
|
+
type KmsAdapter,
|
|
64
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
65
|
+
import {
|
|
66
|
+
configureEntityFieldEncryption,
|
|
67
|
+
createDbConnection,
|
|
68
|
+
type DbRunner,
|
|
69
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
70
|
+
import {
|
|
71
|
+
buildAppSchema,
|
|
72
|
+
collectWriteHandlerQns,
|
|
73
|
+
createRegistry,
|
|
74
|
+
type EffectiveFeaturesResolver,
|
|
75
|
+
type FeatureDefinition,
|
|
76
|
+
findTierResolverUsage,
|
|
77
|
+
type TierResolverPlugin,
|
|
78
|
+
validateAppCustomScreenWriteQns,
|
|
79
|
+
validateBoot,
|
|
80
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
81
|
+
import {
|
|
82
|
+
type AllInOneEntrypoint,
|
|
83
|
+
type ApiEntrypoint,
|
|
84
|
+
createAllInOneEntrypoint,
|
|
85
|
+
createApiEntrypoint,
|
|
86
|
+
} from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
87
|
+
import {
|
|
88
|
+
type ComposedEnvSchema,
|
|
89
|
+
KumikoBootError,
|
|
90
|
+
parseEnv,
|
|
91
|
+
} from "@cosmicdrift/kumiko-framework/env";
|
|
92
|
+
import { type DryRunMode, renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
|
|
93
|
+
import {
|
|
94
|
+
createEsOperationsTable,
|
|
95
|
+
createSeedMigrationContext,
|
|
96
|
+
runPendingSeedMigrations,
|
|
97
|
+
} from "@cosmicdrift/kumiko-framework/es-ops";
|
|
98
|
+
import {
|
|
99
|
+
assertKumikoSchemaCurrent,
|
|
100
|
+
SchemaDriftError,
|
|
101
|
+
} from "@cosmicdrift/kumiko-framework/migrations";
|
|
102
|
+
import type {
|
|
103
|
+
ObservabilityOptions,
|
|
104
|
+
ObservabilityProvider,
|
|
105
|
+
} from "@cosmicdrift/kumiko-framework/observability";
|
|
106
|
+
import {
|
|
107
|
+
createDispatcher,
|
|
108
|
+
createEntityCache,
|
|
109
|
+
createEventDedup,
|
|
110
|
+
createIdempotencyGuard,
|
|
111
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
112
|
+
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
113
|
+
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
114
|
+
import Redis from "ioredis";
|
|
115
|
+
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
116
|
+
import { resolveBootCrypto } from "./boot/boot-crypto";
|
|
117
|
+
import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
|
|
118
|
+
import { buildBunServeOptions } from "./bun-serve-options";
|
|
119
|
+
import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
|
|
120
|
+
import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
|
|
121
|
+
import { assertPiiBootInvariants } from "./pii-boot-gate";
|
|
122
|
+
import {
|
|
123
|
+
addConfigAccessorFactory,
|
|
124
|
+
buildBootExtraContext,
|
|
125
|
+
buildProdSessionAuth,
|
|
126
|
+
resolveAuthMail,
|
|
127
|
+
} from "./run-prod-app-boot-context";
|
|
128
|
+
import { buildStaticFallback } from "./run-prod-app-static-files";
|
|
129
|
+
import {
|
|
130
|
+
type ProdSessionsOption,
|
|
131
|
+
resolveProdSessionsConfig,
|
|
132
|
+
shouldWireProdSessions,
|
|
133
|
+
} from "./session-wiring";
|
|
134
|
+
|
|
135
|
+
export { buildBunServeOptions } from "./bun-serve-options";
|
|
136
|
+
export {
|
|
137
|
+
addConfigAccessorFactory,
|
|
138
|
+
buildBootExtraContext,
|
|
139
|
+
resolveAuthMail,
|
|
140
|
+
} from "./run-prod-app-boot-context";
|
|
141
|
+
export { staticCachePolicy } from "./run-prod-app-static-files";
|
|
142
|
+
|
|
143
|
+
// Strict env-var read. Throws with a clear hint when missing — better
|
|
144
|
+
// than discovering a Postgres-connection-refused 30s into the boot.
|
|
145
|
+
// `src` defaults to process.env but is threaded from the caller's envSource
|
|
146
|
+
// so the boot-path reads the SAME env-quelle that was validated above —
|
|
147
|
+
// injected dummies in test-mode must not silently fall back to process.env.
|
|
148
|
+
export function requireEnv(
|
|
149
|
+
name: string,
|
|
150
|
+
src: Record<string, string | undefined> = process.env,
|
|
151
|
+
context = "runProdApp",
|
|
152
|
+
): string {
|
|
153
|
+
const value = src[name];
|
|
154
|
+
if (value === undefined || value === "") {
|
|
155
|
+
const advice =
|
|
156
|
+
context === "runProdApp"
|
|
157
|
+
? "Set it in your container env / .env.production / Coolify secrets."
|
|
158
|
+
: "Set it in your .env / shell before running the dev server.";
|
|
159
|
+
throw new Error(`${context}: required env var "${name}" is missing or empty. ${advice}`);
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Optional env helper — returns undefined for missing, string for set.
|
|
165
|
+
// Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
|
|
166
|
+
function readEnv(
|
|
167
|
+
name: string,
|
|
168
|
+
src: Record<string, string | undefined> = process.env,
|
|
169
|
+
): string | undefined {
|
|
170
|
+
const value = src[name];
|
|
171
|
+
return value === undefined || value === "" ? undefined : value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// `boot` is the C1 smoke-test path — validators run, no DB/Redis connect,
|
|
175
|
+
// exit after registry-build. Render-modes (human|json|pulumi|k8s|1)
|
|
176
|
+
// inspect the env-schema and exit before any feature wiring.
|
|
177
|
+
type RunMode = DryRunMode | "boot";
|
|
178
|
+
|
|
179
|
+
function parseRunMode(raw: string | undefined): RunMode | null {
|
|
180
|
+
if (!raw) return null;
|
|
181
|
+
const v = raw.toLowerCase();
|
|
182
|
+
if (v === "1" || v === "true" || v === "human") return "human";
|
|
183
|
+
if (v === "json" || v === "pulumi" || v === "k8s" || v === "boot") return v;
|
|
184
|
+
// biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
|
|
185
|
+
console.warn(
|
|
186
|
+
`[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
|
|
187
|
+
`(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`,
|
|
188
|
+
);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isRenderMode(mode: RunMode | null): mode is DryRunMode {
|
|
193
|
+
return mode !== null && mode !== "boot";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function defaultBootErrorReporter(err: KumikoBootError): never {
|
|
197
|
+
// biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
|
|
198
|
+
console.error(err.format());
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
|
|
203
|
+
// was passed (= test-mode). The handle is intentionally inert — listen()
|
|
204
|
+
// and stop() are no-ops; tests inspect the dry-run console output and
|
|
205
|
+
// move on.
|
|
206
|
+
function makeDryRunHandle(): ProdAppHandle {
|
|
207
|
+
const noop = async () => {
|
|
208
|
+
/* dry-run handle: no server was constructed */
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
// @cast-boundary dry-run-mode: no ApiEntrypoint exists because no
|
|
212
|
+
// boot ran; the handle only surfaces test/CLI inspection and the
|
|
213
|
+
// entrypoint is never reached by callers in dry-run.
|
|
214
|
+
entrypoint: undefined as unknown as ApiEntrypoint,
|
|
215
|
+
fetch: () => new Response("dry-run", { status: 503 }),
|
|
216
|
+
listen: noop,
|
|
217
|
+
stop: noop,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Wrapper-API für den Password-Reset-Flow.
|
|
222
|
+
*
|
|
223
|
+
* Seit der delivery-Migration trägt PasswordResetOptions selbst `appUrl`
|
|
224
|
+
* (+ appName/locale) und der Handler mailt via ctx.notify — kein
|
|
225
|
+
* sendResetEmail-Callback mehr. Apps geben `auth.mail` (Convenience,
|
|
226
|
+
* resolveAuthMail baut die appUrl) ODER einen expliziten Block. */
|
|
227
|
+
export type PasswordResetSetup = PasswordResetOptions;
|
|
228
|
+
|
|
229
|
+
/** Wrapper-API für den Email-Verification-Flow. Symmetrisch zu
|
|
230
|
+
* PasswordResetSetup — = EmailVerificationOptions (appUrl via delivery). */
|
|
231
|
+
export type EmailVerificationSetup = EmailVerificationOptions;
|
|
232
|
+
|
|
233
|
+
/** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
|
|
234
|
+
* Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
|
|
235
|
+
* verify gibt's KEIN hmacSecret — Signup-Tokens sind opaque random in
|
|
236
|
+
* Redis, nicht HMAC-signed. */
|
|
237
|
+
export type SignupSetup = SignupOptions;
|
|
238
|
+
|
|
239
|
+
/** Wrapper-API für Tenant-Invite Magic-Link. = InviteOptions (appUrl, die
|
|
240
|
+
* Invite-Mail geht via delivery wie reset/verify/signup). Drei accept-
|
|
241
|
+
* Branches im framework; handler-names sind hardcoded in run-prod-app aus
|
|
242
|
+
* AuthHandlers (analog signup). */
|
|
243
|
+
export type InviteSetup = InviteOptions;
|
|
244
|
+
|
|
245
|
+
/** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
|
|
246
|
+
* Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
|
|
247
|
+
* Templates (siehe `auth.mail` + resolveAuthMail). */
|
|
248
|
+
export type AuthMailOptions = {
|
|
249
|
+
/** App-Basis-URL inkl. Schema (z.B. "https://app.example.com"). */
|
|
250
|
+
readonly baseUrl: string;
|
|
251
|
+
/** App-Name für Mail-Subject + Body. Default "Account". */
|
|
252
|
+
readonly appName?: string;
|
|
253
|
+
/** Locale für die Mail-Templates. Default "de". */
|
|
254
|
+
readonly locale?: AuthMailLocale;
|
|
255
|
+
/** "strict" blockt Login bis emailVerified; "off" mountet ohne Gate. */
|
|
256
|
+
readonly emailVerificationMode?: "strict" | "off";
|
|
257
|
+
/** Fallback-From-Adresse wenn `SMTP_FROM`-env fehlt (env gewinnt). */
|
|
258
|
+
readonly from?: string;
|
|
259
|
+
/** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
|
|
260
|
+
readonly paths?: Partial<AuthPaths>;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export type RunProdAppAuthOptions = {
|
|
264
|
+
/** Initial admin user. Seeded once (idempotent — re-boots check first
|
|
265
|
+
* whether the email is already in the users table). */
|
|
266
|
+
readonly admin: SeedAdminOptions;
|
|
267
|
+
/** Optional override of the login error → HTTP status map. */
|
|
268
|
+
readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
|
|
269
|
+
/** Opt-in: revocable server-side sessions. Caller MUSS
|
|
270
|
+
* `createSessionsFeature()` zu `features` adden — runProdApp wired
|
|
271
|
+
* hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
|
|
272
|
+
* echte db-connection, plus sessionStrictMode=true.
|
|
273
|
+
*
|
|
274
|
+
* Standardverhalten ohne diese Option: stateless JWTs ohne sid
|
|
275
|
+
* (legacy-Verhalten, Kartenhaus existing-Apps unangefasst). */
|
|
276
|
+
readonly sessions?: ProdSessionsOption;
|
|
277
|
+
/** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
|
|
278
|
+
* emailVerification, signup, invite) aus `auth.mail.baseUrl` + Standard-
|
|
279
|
+
* Pfaden. Alle vier mailen via delivery (ctx.notify) — ersetzt das per-App
|
|
280
|
+
* hand-gerollte `send*Email`-Callback-Wiring.
|
|
281
|
+
*
|
|
282
|
+
* Null-Transport-Guard: ohne `SMTP_HOST`-env wird KEIN Mail-Flow
|
|
283
|
+
* verdrahtet (Routes blieben sonst 500). Eine App die einen einzelnen
|
|
284
|
+
* Flow custom braucht, setzt `passwordReset`/`signup`/… explizit —
|
|
285
|
+
* der explizite Block gewinnt über den mail-Default.
|
|
286
|
+
*
|
|
287
|
+
* Bewusste Entscheidung: `mail` mountet ALLE 4 Flows inkl. signup +
|
|
288
|
+
* invite (Self-Registration on). Wer nur reset+verify will, lässt `mail`
|
|
289
|
+
* weg und setzt die gewünschten Flows explizit — kein impliziter
|
|
290
|
+
* Self-Signup. */
|
|
291
|
+
readonly mail?: AuthMailOptions;
|
|
292
|
+
/** Password-reset flow. When set, /api/auth/request-password-reset +
|
|
293
|
+
* /api/auth/reset-password are mounted as public routes UND der
|
|
294
|
+
* request/confirm-Handler im auth-email-password-Feature wird
|
|
295
|
+
* registriert (sonst dispatchen die Routes ins Leere → 500).
|
|
296
|
+
* Überschreibt den `mail`-Default für genau diesen Flow. */
|
|
297
|
+
readonly passwordReset?: PasswordResetSetup;
|
|
298
|
+
/** Email-verification flow. Symmetric to passwordReset. */
|
|
299
|
+
readonly emailVerification?: EmailVerificationSetup;
|
|
300
|
+
/** Self-Signup flow (Magic-Link). When set, /api/auth/signup-request +
|
|
301
|
+
* /api/auth/signup-confirm are mounted; signup-confirm mintet JWT +
|
|
302
|
+
* Cookies wie ein erfolgreicher login (Auto-Login direkt nach
|
|
303
|
+
* Activation). */
|
|
304
|
+
readonly signup?: SignupSetup;
|
|
305
|
+
/** Tenant-Invite flow (Magic-Link). When set, /api/auth/invite-accept,
|
|
306
|
+
* /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
|
|
307
|
+
* are mounted. */
|
|
308
|
+
readonly invite?: InviteSetup;
|
|
309
|
+
/** Domain attribute for both auth cookies (see
|
|
310
|
+
* AuthRoutesConfig.cookieDomain). Set to the registrable parent
|
|
311
|
+
* domain when login and app live on different subdomains. */
|
|
312
|
+
readonly cookieDomain?: string;
|
|
313
|
+
/** Server-side Origin allowlist for the CSRF guard (see
|
|
314
|
+
* AuthRoutesConfig.allowedOrigins). REQUIRED once `cookieDomain` is set —
|
|
315
|
+
* buildServer fails closed otherwise. Apex + admin host, never tenant
|
|
316
|
+
* subdomains. */
|
|
317
|
+
readonly allowedOrigins?: readonly string[];
|
|
318
|
+
/** Opt out of the Origin guard (see AuthRoutesConfig.unsafeSkipOriginCheck)
|
|
319
|
+
* — accept the wide-cookie CSRF risk explicitly instead of setting
|
|
320
|
+
* `allowedOrigins`. */
|
|
321
|
+
readonly unsafeSkipOriginCheck?: boolean;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/** Hook for app-specific seeding — runs after the admin (when auth is
|
|
325
|
+
* active). Each seed is responsible for its own idempotence (seeds are
|
|
326
|
+
* expected to check "is my row already there?" before inserting). */
|
|
327
|
+
export type ProdSeedFn = (deps: {
|
|
328
|
+
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
329
|
+
}) => Promise<void>;
|
|
330
|
+
|
|
331
|
+
/** Boot-Time-Deps die `extraContext` + `anonymousAccess` Factories als
|
|
332
|
+
* Argument bekommen. Closure dann in der returned Config (z.B. ein
|
|
333
|
+
* TenantResolver der gegen `db` queriet, oder ein extraContext-Provider
|
|
334
|
+
* der direkt SSE-Events publishen will). Single-source: identisch zu
|
|
335
|
+
* setupTestStack's extraContext-Factory-Shape damit Test/Prod gleich
|
|
336
|
+
* aussehen. */
|
|
337
|
+
export type RunProdAppDeps = {
|
|
338
|
+
readonly db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
339
|
+
readonly redis: import("ioredis").default;
|
|
340
|
+
readonly registry: import("@cosmicdrift/kumiko-framework/engine").Registry;
|
|
341
|
+
readonly sseBroker: SseBroker;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export type AnonymousAccessOption =
|
|
345
|
+
| import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]
|
|
346
|
+
| ((
|
|
347
|
+
deps: RunProdAppDeps,
|
|
348
|
+
) => import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]);
|
|
349
|
+
|
|
350
|
+
export type ExtraContextOption =
|
|
351
|
+
| Record<string, unknown>
|
|
352
|
+
| ((deps: RunProdAppDeps) => Record<string, unknown>);
|
|
353
|
+
|
|
354
|
+
/** Per-Host Routing-Entscheidung für den staticDir-Fallback. Wird aus
|
|
355
|
+
* hostDispatch returned. Drei Modi:
|
|
356
|
+
* - "html": eine bestimmte HTML-Datei (relativ zu staticDir) servieren,
|
|
357
|
+
* mit optionaler Schema-Injection und CSP. Schema-Injection MUSS
|
|
358
|
+
* explizit eingeschaltet werden (default false) — Public-Domain-
|
|
359
|
+
* Antworten leaken sonst die volle Admin-UI-Schema-Topologie.
|
|
360
|
+
* - "redirect": 301/302 an die angegebene Location.
|
|
361
|
+
* - "not-found": klar abweisen (z.B. unbekannte Subdomain).
|
|
362
|
+
*
|
|
363
|
+
* Wird NUR konsultiert wenn der Pfad sonst auf den HTML-Fallback gehen
|
|
364
|
+
* würde — also für "/", "/index.html", oder SPA-Routen die weder Hono
|
|
365
|
+
* matched noch eine konkrete Disk-Datei treffen. Asset-Pfade (/assets/*)
|
|
366
|
+
* und API-Pfade laufen unabhängig vom Host. */
|
|
367
|
+
export type HostDispatchResult =
|
|
368
|
+
| {
|
|
369
|
+
readonly kind: "html";
|
|
370
|
+
readonly file: string;
|
|
371
|
+
readonly injectSchema?: boolean;
|
|
372
|
+
readonly csp?: string;
|
|
373
|
+
}
|
|
374
|
+
| { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 }
|
|
375
|
+
| { readonly kind: "not-found" };
|
|
376
|
+
|
|
377
|
+
export type HostDispatchFn = (req: {
|
|
378
|
+
readonly host: string;
|
|
379
|
+
readonly path: string;
|
|
380
|
+
/** Query-String inkl. führendem `?`, `""` wenn keiner. Redirects die
|
|
381
|
+
* den Pfad auf einen anderen Host umbiegen (z.B. Auth-Routen mit
|
|
382
|
+
* `?token=` aus alten Mail-Links) MÜSSEN ihn an `to` anhängen. */
|
|
383
|
+
readonly search: string;
|
|
384
|
+
}) => HostDispatchResult;
|
|
385
|
+
|
|
386
|
+
export type RunProdAppOptions = {
|
|
387
|
+
/** App-specific features. config/user/tenant/auth-email-password are
|
|
388
|
+
* auto-mixed when `auth:` is set — don't add them yourself. */
|
|
389
|
+
readonly features: readonly FeatureDefinition[];
|
|
390
|
+
/** Listen-Port. Default 3000 (or $PORT). */
|
|
391
|
+
readonly port?: number;
|
|
392
|
+
/** Auth-mode: standard features + routes wired, admin seeded. */
|
|
393
|
+
readonly auth?: RunProdAppAuthOptions;
|
|
394
|
+
/** Custom seed functions, run after the admin seed (when auth-mode). */
|
|
395
|
+
readonly seeds?: readonly ProdSeedFn[];
|
|
396
|
+
/** Pfad zum seeds-Directory für ES-Operations / Seed-Migrations
|
|
397
|
+
* (file-basiert wie drizzle-migrate). Wenn gesetzt + KUMIKO_SKIP_ES_OPS
|
|
398
|
+
* != "1": runProdApp scannt das Verzeichnis nach `<id>.ts` Files,
|
|
399
|
+
* diff vs kumiko_es_operations-Table, läuft pending in Tx.
|
|
400
|
+
* Plan: kumiko-platform/docs/plans/features/es-ops.md */
|
|
401
|
+
readonly seedsDir?: string;
|
|
402
|
+
/** Anonymous-access for public endpoints (same shape as runDevApp).
|
|
403
|
+
* Akzeptiert entweder einen statischen Config-Object ODER eine
|
|
404
|
+
* Factory `({db, redis, registry}) => Config` — die Factory wird
|
|
405
|
+
* einmal zur Boot-Zeit aufgerufen, NACHDEM db/redis/registry konstruiert
|
|
406
|
+
* sind. Der Caller closure'd typischerweise db/redis/registry in den
|
|
407
|
+
* TenantResolver damit z.B. ein Subdomain → Tenant-Lookup gegen die
|
|
408
|
+
* DB möglich ist (siehe samples/showcases/publicstatus für das
|
|
409
|
+
* Multi-Tenant-Pattern). */
|
|
410
|
+
readonly anonymousAccess?: AnonymousAccessOption;
|
|
411
|
+
/** Static-file root for HTML / assets. Served on the catch-all route
|
|
412
|
+
* for any path that doesn't match an /api/ handler. Use this for the
|
|
413
|
+
* public status page HTML, embed widget JS, etc. */
|
|
414
|
+
readonly staticDir?: string;
|
|
415
|
+
/** Host-aware Routing-Hook für Multi-Tenant + Multi-App-Deployments
|
|
416
|
+
* (z.B. publicstatus's `<sub>.publicstatus.eu` (Public-Page) +
|
|
417
|
+
* `admin.publicstatus.eu` (Admin-UI) + `publicstatus.eu` (Apex/
|
|
418
|
+
* Marketing) im SELBEN Container).
|
|
419
|
+
*
|
|
420
|
+
* Wird aufgerufen wenn der staticDir-Fallback einen HTML-Response
|
|
421
|
+
* generieren würde (Root oder SPA-Route). Default-Verhalten ohne
|
|
422
|
+
* hostDispatch: index.html mit Schema-Injection (Single-App).
|
|
423
|
+
*
|
|
424
|
+
* Sicherheitshinweis: Schema-Injection (`__KUMIKO_SCHEMA__`) leakt
|
|
425
|
+
* die Admin-UI-Topologie (alle Screens, Felder, Layouts) ans HTML.
|
|
426
|
+
* Public-Domain-Antworten sollen das NIEMALS — `injectSchema` ist
|
|
427
|
+
* daher default false und MUSS pro Host explizit eingeschaltet
|
|
428
|
+
* werden. CSP-Header pro Host können zusätzlich Asset-Pfade
|
|
429
|
+
* einschränken. */
|
|
430
|
+
readonly hostDispatch?: HostDispatchFn;
|
|
431
|
+
/** Pfad zu kumiko/migrations für den Boot-Gate. Default "./kumiko/
|
|
432
|
+
* migrations" relativ zum process-cwd (wo die App gestartet wird —
|
|
433
|
+
* bei Container-Deploys typischerweise der App-Workspace-Root, weil
|
|
434
|
+
* WORKDIR im Dockerfile dorthin zeigt). Boot wirft SchemaDriftError
|
|
435
|
+
* wenn Migrations pending sind oder erwartete Tabellen fehlen.
|
|
436
|
+
* Setze auf `false` um den Gate komplett zu deaktivieren — nur für
|
|
437
|
+
* Setups die ihren eigenen Schema-Check fahren (z.B. bring-your-own-
|
|
438
|
+
* ORM). Standard-Apps lassen das default. */
|
|
439
|
+
readonly migrations?: { readonly dir: string } | false;
|
|
440
|
+
/** Extra AppContext keys. Framework-Defaults werden zur Boot-Zeit
|
|
441
|
+
* unter den Factory-Werten ergänzt, App-Werte gewinnen immer:
|
|
442
|
+
* - `textContent` (createTextContentApi(db)) — immer
|
|
443
|
+
* - `secrets` (createSecretsContext) — nur wenn das `secrets`-Feature
|
|
444
|
+
* gemountet ist (sonst kein KEK-env-Zwang für Apps ohne secrets)
|
|
445
|
+
* - `configResolver` — im Auth-Mode (env-config-overrides)
|
|
446
|
+
* Akzeptiert einen statischen Object ODER eine Factory
|
|
447
|
+
* `({db, redis, registry}) => Record<string, unknown>` — gleiches
|
|
448
|
+
* Pattern wie `anonymousAccess`. Eine App die einen dieser Keys selbst
|
|
449
|
+
* setzt (z.B. eigener configResolver) überschreibt den Default. */
|
|
450
|
+
readonly extraContext?: ExtraContextOption;
|
|
451
|
+
/** MasterKeyProvider für die auto-verdrahtete `ctx.secrets`. Default:
|
|
452
|
+
* `createEnvMasterKeyProvider` (KEK aus `KUMIKO_SECRETS_MASTER_KEY_V<n>`).
|
|
453
|
+
* Override für KMS-Backends (AWS/GCP/Azure) statt env-KEK. Nur relevant
|
|
454
|
+
* wenn das `secrets`-Feature gemountet ist. */
|
|
455
|
+
readonly masterKey?: MasterKeyProvider;
|
|
456
|
+
/** Subject-Key-Adapter für Crypto-Shredding (DSGVO Art. 17). Wenn gesetzt,
|
|
457
|
+
* steht er Feature-Code als `ctx.kms` zur Verfügung und der Boot prüft
|
|
458
|
+
* seine health() — die App startet nicht gegen ein unerreichbares KMS
|
|
459
|
+
* (Silent-Start würde jeden PII-Read mit 503 beantworten). Kein Default:
|
|
460
|
+
* ohne Adapter bleibt Crypto-Shredding aus. */
|
|
461
|
+
readonly kms?: KmsAdapter;
|
|
462
|
+
/** 32-Byte-Key (base64) für Blind-Index-HMACs auf `lookupable`-Feldern
|
|
463
|
+
* (env: KUMIKO_BLIND_INDEX_KEY, `openssl rand -base64 32`). Bewusst
|
|
464
|
+
* getrennt vom PLATFORM_KEK und nicht Teil des KmsAdapter-Contracts.
|
|
465
|
+
* Pflicht sobald `kms` gesetzt ist UND ein Feature lookupable-Felder
|
|
466
|
+
* deklariert — sonst bricht jeder Equality-Lookup auf Ciphertext
|
|
467
|
+
* (Login by email!) und der Boot failt hart. */
|
|
468
|
+
readonly blindIndexKey?: string;
|
|
469
|
+
/** Explizites Opt-out aus dem harten PII-Boot-Gate: Features deklarieren
|
|
470
|
+
* pii/userOwned/tenantOwned-Felder, aber es ist (noch) kein `kms`
|
|
471
|
+
* provisioniert — die Felder liegen dann als KLARTEXT in Rows und
|
|
472
|
+
* Events und DSGVO-Erasure via Crypto-Shredding ist unmöglich. Der Wert
|
|
473
|
+
* ist eine Begründung für den Audit-Trail (z.B. "kms rollout pending,
|
|
474
|
+
* infra#188"). Ohne Flag und ohne `kms` bricht der Boot ab. */
|
|
475
|
+
readonly allowPlaintextPii?: string;
|
|
476
|
+
/** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
|
|
477
|
+
* fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
|
|
478
|
+
* (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
|
|
479
|
+
* worker-Lane-Crons (z.B. der Daten-Export `run-export-jobs`, default
|
|
480
|
+
* `runIn:"worker"`) und r.multiStreamProjection ohne separaten Worker.
|
|
481
|
+
*
|
|
482
|
+
* `false` NUR mit einem dezidierten Worker-Deployment setzen: dann fährt
|
|
483
|
+
* dieser Prozess API-only (`createApiEntrypoint`), und worker-Lane-Jobs
|
|
484
|
+
* + MSPs werden NICHT mehr lokal angewandt — der Worker muss sie
|
|
485
|
+
* übernehmen, sonst bleiben Export-Jobs pending und die Read-Side leer
|
|
486
|
+
* (2026-06-11-Incident-Klasse). */
|
|
487
|
+
readonly runSingleInstance?: boolean;
|
|
488
|
+
/** Job-Block. Wenn das Feature `r.job(...)` registriert, wird er
|
|
489
|
+
* automatisch verdrahtet (siehe runSingleInstance). */
|
|
490
|
+
readonly jobs?: {
|
|
491
|
+
/** BullMQ-Queue-Prefix (default "kumiko"). */
|
|
492
|
+
readonly queueNamePrefix?: string;
|
|
493
|
+
};
|
|
494
|
+
/** Event-Dispatcher (MSP-Anwendung) im API-Process. Default AN —
|
|
495
|
+
* runProdApp ist das Single-Container-Deployment, es gibt keinen
|
|
496
|
+
* Worker-Process der multiStreamProjections anwenden könnte. Bis
|
|
497
|
+
* 2026-06-11 fehlte der Dispatcher hier komplett: jede MSP-basierte
|
|
498
|
+
* Read-Projektion (z.B. custom-fields jsonb) blieb in Prod leer,
|
|
499
|
+
* kumiko_event_consumers blieb ohne Rows. `disabled: true` nur für
|
|
500
|
+
* Setups mit dezidiertem Worker-Process. */
|
|
501
|
+
readonly eventDispatcher?: {
|
|
502
|
+
readonly disabled?: boolean;
|
|
503
|
+
/** Poll-Intervall des Dispatcher-Loops (default siehe
|
|
504
|
+
* createEventDispatcher). LISTEN/NOTIFY-Wiring kommt mit einem
|
|
505
|
+
* späteren pgClient-Pass-through. */
|
|
506
|
+
readonly pollIntervalMs?: number;
|
|
507
|
+
};
|
|
508
|
+
/** Mount-Point für app-eigene HTTP-Routes außerhalb des Dispatcher-
|
|
509
|
+
* Systems. Aufgerufen NACH /api/* + /health, VOR der static-fallback —
|
|
510
|
+
* perfekt für GET-Endpoints die kein JSON liefern: /feed.xml,
|
|
511
|
+
* /og-image, /sitemap.xml, /robots.txt-mit-Logik. Bekommt das raw
|
|
512
|
+
* Hono-app + die Connection-Deps (db/redis) zum Querying.
|
|
513
|
+
*
|
|
514
|
+
* Naming: `deps` statt `ctx` weil im Framework `ctx` der HandlerContext
|
|
515
|
+
* mit user/tenant/registry ist — hier ist der Scope absichtlich kleiner
|
|
516
|
+
* (Routes laufen außerhalb der Auth/Tenant-Pipeline). */
|
|
517
|
+
readonly extraRoutes?: (app: import("hono").Hono, deps: ExtraRoutesSystemDeps) => void;
|
|
518
|
+
/** When true (default), Bun.serve is started before runProdApp resolves —
|
|
519
|
+
* the common case: `await runProdApp({...})` boots the server and the
|
|
520
|
+
* process stays up listening on PORT. Set to false in tests that drive
|
|
521
|
+
* the fetch-handler directly (Bun.serve isn't available under vitest +
|
|
522
|
+
* node), then call handle.listen() manually if needed. */
|
|
523
|
+
readonly autoListen?: boolean;
|
|
524
|
+
/** Feature-toggle resolver — durchgereicht an createApiEntrypoint's
|
|
525
|
+
* dispatcherOptions. Sprint-8 Tier-Composition: per-Tenant unterschied-
|
|
526
|
+
* liche features aktiv via globalFeatureToggleRuntime. Pattern:
|
|
527
|
+
* createLateBoundHolder + post-boot runtime.initialize in einem
|
|
528
|
+
* seed-fn (db ist erst nach migrations + features ready). */
|
|
529
|
+
readonly effectiveFeatures?: EffectiveFeaturesResolver;
|
|
530
|
+
/** Composed Zod-schema for env-validation (from `composeEnvSchema({
|
|
531
|
+
* features, extend })` in @cosmicdrift/kumiko-framework/env). When set:
|
|
532
|
+
* - `process.env` is parsed against it BEFORE any boot work; missing
|
|
533
|
+
* or invalid vars throw a `KumikoBootError` listing ALL problems
|
|
534
|
+
* at once (not first-fail).
|
|
535
|
+
* - `KUMIKO_DRY_RUN_ENV=human|json|pulumi|k8s` introspects the schema
|
|
536
|
+
* and prints the env-var inventory, then exits without booting.
|
|
537
|
+
*
|
|
538
|
+
* 9.1 is additive: features that still read `process.env` directly
|
|
539
|
+
* keep working. Migration to the schema is Sprint-9.2-9.5. */
|
|
540
|
+
readonly envSchema?: ComposedEnvSchema;
|
|
541
|
+
/** Prefix for `pulumi config set <prefix><CamelCase(VAR)>` in dry-run
|
|
542
|
+
* output and boot-error suggestions. Without this, suggestions use
|
|
543
|
+
* bare `camelCase(VAR)` and ops has to guess the app prefix. */
|
|
544
|
+
readonly pulumiPrefix?: string;
|
|
545
|
+
/** Handler for KumikoBootError. Default: print formatted error to
|
|
546
|
+
* stderr and `process.exit(1)` so the container restarts with a
|
|
547
|
+
* visible log line. Override in tests that drive runProdApp directly
|
|
548
|
+
* (avoid the exit). Return type is `void` rather than `never` to keep
|
|
549
|
+
* test-overrides honest — if a reporter returns, runProdApp falls
|
|
550
|
+
* through to a regular `throw err` as the safety net. */
|
|
551
|
+
readonly bootErrorReporter?: (err: KumikoBootError) => void;
|
|
552
|
+
/** Override `process.env` for env-validation. Default: `process.env`.
|
|
553
|
+
* Tests use this to feed crafted env-maps without polluting the
|
|
554
|
+
* global. */
|
|
555
|
+
readonly envSource?: Record<string, string | undefined>;
|
|
556
|
+
/** Observability-Provider (Tracer + Meter). Default: Noop (kein Overhead,
|
|
557
|
+
* kein `/metrics`-Endpoint). Setze `createPrometheusMeter()`-basierten
|
|
558
|
+
* Provider + `metrics` um `/metrics` real zu exposen (publicstatus#91). */
|
|
559
|
+
readonly observability?: ObservabilityProvider;
|
|
560
|
+
/** Konfiguriert die Auto-Instrumentation des resolvierten Observability-
|
|
561
|
+
* Providers (siehe `BaseEntrypointOptions.observabilityOptions`). */
|
|
562
|
+
readonly observabilityOptions?: ObservabilityOptions;
|
|
563
|
+
/** Aktiviert den `/metrics`-Endpoint (Open-Metrics-Format). Ohne einen
|
|
564
|
+
* PrometheusMeter-basierten `observability`-Provider antwortet `/metrics`
|
|
565
|
+
* mit 503 + Misconfiguration-Hinweis statt echter Metriken. */
|
|
566
|
+
readonly metrics?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["metrics"];
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
export type ProdAppHandle = {
|
|
570
|
+
/** The composed entrypoint — AllInOne (runSingleInstance, default) or
|
|
571
|
+
* Api-only (runSingleInstance:false). In KUMIKO_DRY_RUN_ENV mode WITH
|
|
572
|
+
* `envSource` injected (test path), no boot ran and this slot is an
|
|
573
|
+
* undefined-cast — do not access. Production dry-run hits
|
|
574
|
+
* `process.exit(0)` before returning a handle. */
|
|
575
|
+
readonly entrypoint: ApiEntrypoint | AllInOneEntrypoint;
|
|
576
|
+
/** The fetch-handler — wired into Bun.serve in production, called
|
|
577
|
+
* directly in tests. Composes Hono + static-fallback. */
|
|
578
|
+
readonly fetch: (req: Request) => Promise<Response> | Response;
|
|
579
|
+
/** Active Bun-server (only set when listen() was called — tests skip
|
|
580
|
+
* listen() because Bun.serve isn't available under vitest/node). */
|
|
581
|
+
server?: ReturnType<typeof Bun.serve>;
|
|
582
|
+
/** Bind to PORT and start serving. Production calls this; tests don't. */
|
|
583
|
+
readonly listen: (port?: number) => Promise<void>;
|
|
584
|
+
readonly stop: () => Promise<void>;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
|
|
588
|
+
// 0. Env-Schema validation + dry-run modes. Runs FIRST so:
|
|
589
|
+
// - operators can introspect env-requirements without a real boot
|
|
590
|
+
// (no DB connection needed, KUMIKO_DRY_RUN_ENV=… → render + exit)
|
|
591
|
+
// - missing/invalid env-vars produce a structured KumikoBootError
|
|
592
|
+
// with ALL problems aggregated (not first-fail), before we waste
|
|
593
|
+
// seconds on a Postgres connection that was never configured.
|
|
594
|
+
//
|
|
595
|
+
// Both code paths are no-ops when no envSchema is passed — Sprint-9
|
|
596
|
+
// migration is per-feature additive; pre-migration apps keep the
|
|
597
|
+
// legacy `requireEnv("DATABASE_URL")` checks below.
|
|
598
|
+
//
|
|
599
|
+
// Ordering invariant: this step runs BEFORE the Temporal polyfill,
|
|
600
|
+
// so env-schemas MUST use only Temporal-free Zod types. Don't author
|
|
601
|
+
// `z.iso.date()`/`Temporal.Instant` fields on env-vars — they'd crash
|
|
602
|
+
// at parse-time before the polyfill loads. Plain strings + .regex /
|
|
603
|
+
// .min / .email / .url cover every env-var shape we've actually
|
|
604
|
+
// needed in 9.1's audit (37 references, 25 distinct vars).
|
|
605
|
+
const envSource = options.envSource ?? process.env;
|
|
606
|
+
const runMode = parseRunMode(envSource["KUMIKO_DRY_RUN_ENV"]);
|
|
607
|
+
if (options.envSchema) {
|
|
608
|
+
if (isRenderMode(runMode)) {
|
|
609
|
+
// biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
|
|
610
|
+
console.log(
|
|
611
|
+
renderDryRun(options.envSchema, runMode, {
|
|
612
|
+
...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
|
|
613
|
+
sources: options.envSchema.sources,
|
|
614
|
+
}),
|
|
615
|
+
);
|
|
616
|
+
// Tests inject envSource and want a return-value, not exit. Detecting
|
|
617
|
+
// "this is a test" via envSource is brittle; instead exit when running
|
|
618
|
+
// against the real process.env (the deploy-flow), return otherwise.
|
|
619
|
+
if (options.envSource === undefined) {
|
|
620
|
+
process.exit(0);
|
|
621
|
+
}
|
|
622
|
+
return makeDryRunHandle();
|
|
623
|
+
}
|
|
624
|
+
// boot-mode AND normal-boot both run env-validation. boot-mode wants
|
|
625
|
+
// a real env-check (all required vars present + schema-valid) before
|
|
626
|
+
// it asserts feature-wiring works.
|
|
627
|
+
try {
|
|
628
|
+
parseEnv(options.envSchema.schema, envSource, {
|
|
629
|
+
sources: options.envSchema.sources,
|
|
630
|
+
...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
|
|
631
|
+
});
|
|
632
|
+
} catch (err) {
|
|
633
|
+
if (err instanceof KumikoBootError) {
|
|
634
|
+
const reporter = options.bootErrorReporter ?? defaultBootErrorReporter;
|
|
635
|
+
reporter(err);
|
|
636
|
+
}
|
|
637
|
+
throw err;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// 1. Polyfill before anything else — feature code references Temporal.
|
|
642
|
+
const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
|
|
643
|
+
await ensureTemporalPolyfill();
|
|
644
|
+
|
|
645
|
+
// 2. Env-vars: fail-fast. Better a 0s boot crash with a clear error
|
|
646
|
+
// than a 30s timeout chasing a Postgres connection that was never
|
|
647
|
+
// configured.
|
|
648
|
+
const databaseUrl = requireEnv("DATABASE_URL", envSource);
|
|
649
|
+
const redisUrl = requireEnv("REDIS_URL", envSource);
|
|
650
|
+
const jwtSecret = requireEnv("JWT_SECRET", envSource);
|
|
651
|
+
const jwtIssuer = readEnv("JWT_ISSUER", envSource);
|
|
652
|
+
const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
|
|
653
|
+
const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
|
|
654
|
+
|
|
655
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
656
|
+
console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
|
|
657
|
+
|
|
658
|
+
// auth.mail → expandiert in die expliziten Flow-Felder, bevor sie sowohl
|
|
659
|
+
// die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
|
|
660
|
+
// unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
|
|
661
|
+
const effectiveAuth = options.auth
|
|
662
|
+
? resolveAuthMail(options.auth, jwtSecret, envSource)
|
|
663
|
+
: undefined;
|
|
664
|
+
|
|
665
|
+
// 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
|
|
666
|
+
// password via composeFeatures — same source-of-truth as runDevApp
|
|
667
|
+
// AND the per-app drizzle-Schema-Generator, so Migration und Runtime
|
|
668
|
+
// sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
|
|
669
|
+
// can validate wiring and exit without opening a Postgres/Redis socket.
|
|
670
|
+
const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
|
|
671
|
+
const features = composeFeatures(options.features, {
|
|
672
|
+
includeBundled: !!effectiveAuth,
|
|
673
|
+
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
validateBoot(features);
|
|
677
|
+
warnIfNonUtcServerTimeZone();
|
|
678
|
+
validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
|
|
679
|
+
assertPiiBootInvariants(features, {
|
|
680
|
+
kms: options.kms,
|
|
681
|
+
blindIndexKey: options.blindIndexKey,
|
|
682
|
+
allowPlaintextPii: options.allowPlaintextPii,
|
|
683
|
+
mode: "prod",
|
|
684
|
+
});
|
|
685
|
+
const registry = createRegistry(features);
|
|
686
|
+
|
|
687
|
+
// C1 boot-mode exit: validators ran + registry built; no DB/Redis client
|
|
688
|
+
// is constructed at all in this branch (the eager `new Redis(...)` below
|
|
689
|
+
// would otherwise open a TCP connect just to immediately disconnect it).
|
|
690
|
+
if (runMode === "boot") {
|
|
691
|
+
// biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
|
|
692
|
+
console.log(
|
|
693
|
+
`[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
|
|
694
|
+
);
|
|
695
|
+
if (options.envSource === undefined) {
|
|
696
|
+
process.exit(0);
|
|
697
|
+
}
|
|
698
|
+
return makeDryRunHandle();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 4. Connections — Postgres + Redis. The Redis client is shared by
|
|
702
|
+
// idempotency, event-dedup, entity-cache, rate-limit; failing to
|
|
703
|
+
// construct here surfaces the misconfig immediately. `new Redis(...)`
|
|
704
|
+
// connects eagerly, so it must stay AFTER the boot-mode exit above.
|
|
705
|
+
// KMS health gate: an app configured for crypto-shredding must not boot
|
|
706
|
+
// against an unreachable key store — a silent start would answer every
|
|
707
|
+
// PII read with 503. Runs BEFORE connections so an abort leaks nothing.
|
|
708
|
+
if (options.kms) {
|
|
709
|
+
const kmsHealth = await options.kms.health();
|
|
710
|
+
if (!kmsHealth.ok) {
|
|
711
|
+
throw new Error(
|
|
712
|
+
`[runProdApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const { db, close: closeDb } = createDbConnection(databaseUrl);
|
|
718
|
+
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
719
|
+
|
|
720
|
+
// Sprint-8a Tier-Composition auto-wire: scan features for a
|
|
721
|
+
// tenantTierResolver-extension. If found AND user didn't supply own
|
|
722
|
+
// effectiveFeatures, build the resolver here (db + registry are
|
|
723
|
+
// available) before the dispatcher is constructed. App-Author sees
|
|
724
|
+
// nothing — `createTierEngineFeature(opts)` mounts + framework auto-wires.
|
|
725
|
+
let resolvedEffectiveFeatures: EffectiveFeaturesResolver | undefined = options.effectiveFeatures;
|
|
726
|
+
if (resolvedEffectiveFeatures === undefined) {
|
|
727
|
+
const tierResolverUsage = findTierResolverUsage(features);
|
|
728
|
+
if (tierResolverUsage) {
|
|
729
|
+
const plugin = tierResolverUsage.options as TierResolverPlugin;
|
|
730
|
+
resolvedEffectiveFeatures = await plugin.build({ db, registry });
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// 5. Schema-Drift-Gate (drizzle-frei, kumiko/migrations). `kumiko schema
|
|
735
|
+
// apply` läuft als Deploy-Step VOR dem Container-Rollout. Boot prüft nur:
|
|
736
|
+
// (a) Alle Migrations aus kumiko/migrations/*.sql sind in
|
|
737
|
+
// _kumiko_migrations applied (+ checksum unverändert)
|
|
738
|
+
// (b) Alle Tabellen aus kumiko/migrations/.snapshot.json existieren
|
|
739
|
+
// Drift = Boot-Error mit klarer Meldung (kein Auto-Heal — mehrere
|
|
740
|
+
// Container-Replicas würden sonst race-conditionen fahren). Opt-out via
|
|
741
|
+
// `migrations: false` für custom Schema-Setups.
|
|
742
|
+
if (options.migrations !== false) {
|
|
743
|
+
const migrationsDir = options.migrations?.dir ?? "./kumiko/migrations";
|
|
744
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint
|
|
745
|
+
console.log(`[runProdApp] checking schema drift (${migrationsDir})…`);
|
|
746
|
+
try {
|
|
747
|
+
await assertKumikoSchemaCurrent(db, migrationsDir);
|
|
748
|
+
} catch (err) {
|
|
749
|
+
if (err instanceof SchemaDriftError) {
|
|
750
|
+
// biome-ignore lint/suspicious/noConsole: terminal error message
|
|
751
|
+
console.error(`\n[runProdApp] BOOT ABORTED — ${err.message}\n`);
|
|
752
|
+
}
|
|
753
|
+
throw err;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// 6. Pipeline pieces — same default config as runDevApp's setupTestStack.
|
|
758
|
+
const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
|
|
759
|
+
const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
|
|
760
|
+
const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
|
|
761
|
+
|
|
762
|
+
// 7. Lifecycle is built by createApiEntrypoint when not supplied —
|
|
763
|
+
// we let the entrypoint own it and read it back through the handle
|
|
764
|
+
// for SIGTERM.
|
|
765
|
+
//
|
|
766
|
+
// extraContext + anonymousAccess sind factory-union: entweder direktes
|
|
767
|
+
// Object oder Function die {db, redis, registry} bekommt und das Object
|
|
768
|
+
// returned. Factory-Form gilt als bevorzugt für Cases die zur Boot-Zeit
|
|
769
|
+
// gegen die DB resolven müssen (z.B. Subdomain-Tenant-Lookup im
|
|
770
|
+
// tenantResolver) — die Factory closure'd `db` und der Resolver kann
|
|
771
|
+
// sie zur Request-Zeit aufrufen.
|
|
772
|
+
// sseBroker hier bauen (statt's createApiEntrypoint intern machen zu
|
|
773
|
+
// lassen) damit extraContext-Factories ihn schon zur Boot-Zeit closure'n
|
|
774
|
+
// können — z.B. ein extraContext-Provider der direkt SSE-Events
|
|
775
|
+
// publisht. Wir reichen denselben Broker dann an createApiEntrypoint
|
|
776
|
+
// durch (sseBroker?-option), damit der Server-internal-Broadcast und
|
|
777
|
+
// App-spezifische Publishes über genau einen Broker laufen.
|
|
778
|
+
const sseBroker = createSseBroker();
|
|
779
|
+
const deps: RunProdAppDeps = { db, redis, registry, sseBroker };
|
|
780
|
+
const resolvedExtraContext =
|
|
781
|
+
typeof options.extraContext === "function"
|
|
782
|
+
? options.extraContext(deps)
|
|
783
|
+
: (options.extraContext ?? {});
|
|
784
|
+
|
|
785
|
+
// Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
|
|
786
|
+
// gewinnen immer (z.B. money-horse's eigener configResolver).
|
|
787
|
+
const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
|
|
788
|
+
// App-wide cipher for `encrypted: true` entity fields — executors resolve
|
|
789
|
+
// it lazily, entities without encrypted fields never touch it.
|
|
790
|
+
configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
|
|
791
|
+
// Subject-key KMS for pii-annotated fields (crypto-shredding, #724).
|
|
792
|
+
// No adapter = engine off, plaintext as before — the warning keeps the gap
|
|
793
|
+
// visible until the prod-grade PgKmsAdapter (phase E) makes a hard boot
|
|
794
|
+
// gate viable (InMemory in prod would lose every DEK on restart).
|
|
795
|
+
configurePiiSubjectKms(options.kms);
|
|
796
|
+
// Blind-Index-Key (#818) — Boot-Gate: mit aktivem KMS würden lookupable-
|
|
797
|
+
// Felder als Ciphertext gespeichert, ohne Key gäbe es keinen bidx-Arm und
|
|
798
|
+
// jeder Equality-Lookup (Login by email!) liefe ins Leere. Fail-fast statt
|
|
799
|
+
// silent-broken-auth.
|
|
800
|
+
configureBlindIndexKey(options.blindIndexKey);
|
|
801
|
+
const autoExtraContext = buildBootExtraContext({
|
|
802
|
+
db,
|
|
803
|
+
features,
|
|
804
|
+
envSource,
|
|
805
|
+
registry,
|
|
806
|
+
hasAuth: !!effectiveAuth,
|
|
807
|
+
sseBroker,
|
|
808
|
+
crypto: bootCrypto,
|
|
809
|
+
...(options.kms && { kms: options.kms }),
|
|
810
|
+
});
|
|
811
|
+
const extraContext = addConfigAccessorFactory(
|
|
812
|
+
{ ...autoExtraContext, ...resolvedExtraContext },
|
|
813
|
+
registry,
|
|
814
|
+
);
|
|
815
|
+
const resolvedAnonymousAccess =
|
|
816
|
+
typeof options.anonymousAccess === "function"
|
|
817
|
+
? options.anonymousAccess(deps)
|
|
818
|
+
: options.anonymousAccess;
|
|
819
|
+
|
|
820
|
+
// Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
|
|
821
|
+
// also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
|
|
822
|
+
// sessionStrictMode=true: Prod-Sessions sollen nicht stillschweigend
|
|
823
|
+
// von einem JWT-ohne-sid umgangen werden können. sessionMassRevoker
|
|
824
|
+
// (4. callback aus createSessionCallbacks) ist nicht Teil der
|
|
825
|
+
// AuthRoutesConfig-Surface — der geht via bindAutoRevokeFromFeature ans
|
|
826
|
+
// sessions-Feature (Password-Change/-Reset revoked alle Sessions), nicht
|
|
827
|
+
// über die auth-routes.
|
|
828
|
+
// Secure-by-default: if the sessions feature is mounted, server-side revocation +
|
|
829
|
+
// sessionStrictMode + auto-revoke-on-password-change are wired automatically;
|
|
830
|
+
// `auth.sessions` only overrides the config, and `auth.sessions: false` is the
|
|
831
|
+
// explicit opt-out (back to stateless JWTs).
|
|
832
|
+
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
833
|
+
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
834
|
+
const sessionAuthFragment = shouldWireProdSessions(
|
|
835
|
+
Boolean(effectiveAuth),
|
|
836
|
+
sessionsFeature !== undefined,
|
|
837
|
+
effectiveAuth?.sessions,
|
|
838
|
+
)
|
|
839
|
+
? buildProdSessionAuth(
|
|
840
|
+
db,
|
|
841
|
+
resolveProdSessionsConfig(effectiveAuth?.sessions),
|
|
842
|
+
sessionsFeature,
|
|
843
|
+
mfaFeature,
|
|
844
|
+
)
|
|
845
|
+
: undefined;
|
|
846
|
+
|
|
847
|
+
// PAT opt-in: if the personal-access-tokens feature is mounted, wire its
|
|
848
|
+
// resolver (bearer PATs → SessionUser, before jwt.verify). Scopes come from
|
|
849
|
+
// the feature's exports — the same declaration its handlers use.
|
|
850
|
+
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
851
|
+
let patAuthFragment:
|
|
852
|
+
| {
|
|
853
|
+
patResolver: ReturnType<typeof createPatResolver>;
|
|
854
|
+
patRateLimiter: ReturnType<typeof createInMemoryLoginRateLimiter>;
|
|
855
|
+
}
|
|
856
|
+
| undefined;
|
|
857
|
+
if (effectiveAuth && patFeature) {
|
|
858
|
+
const rl = patRateLimitFromFeature(patFeature);
|
|
859
|
+
patAuthFragment = {
|
|
860
|
+
patResolver: createPatResolver({ db, scopes: patScopesFromFeature(patFeature) }),
|
|
861
|
+
patRateLimiter: createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
|
|
866
|
+
const tenantLifecycleAuthFragment = tenantLifecycleFeature
|
|
867
|
+
? {
|
|
868
|
+
resolveTenantLifecycleStatus: async (tenantId: string) => {
|
|
869
|
+
const gate = await resolveTenantLifecycleGate(db, tenantId);
|
|
870
|
+
return gate ? { status: gate.status } : null;
|
|
871
|
+
},
|
|
872
|
+
}
|
|
873
|
+
: undefined;
|
|
874
|
+
|
|
875
|
+
const baseEntrypointOptions = {
|
|
876
|
+
registry,
|
|
877
|
+
context: {
|
|
878
|
+
db,
|
|
879
|
+
redis,
|
|
880
|
+
entityCache,
|
|
881
|
+
registry,
|
|
882
|
+
...extraContext,
|
|
883
|
+
},
|
|
884
|
+
sseBroker,
|
|
885
|
+
jwtSecret,
|
|
886
|
+
...(jwtIssuer && { jwtIssuer }),
|
|
887
|
+
...(instanceId && { instanceId }),
|
|
888
|
+
dispatcherOptions: {
|
|
889
|
+
idempotency,
|
|
890
|
+
...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
|
|
891
|
+
},
|
|
892
|
+
eventDedup,
|
|
893
|
+
...(options.observability && { observability: options.observability }),
|
|
894
|
+
...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
|
|
895
|
+
...(options.metrics && { metrics: options.metrics }),
|
|
896
|
+
...(effectiveAuth && {
|
|
897
|
+
auth: {
|
|
898
|
+
membershipQuery: TenantQueries.memberships,
|
|
899
|
+
userQuery: UserQueries.findForAuth,
|
|
900
|
+
loginHandler: AuthHandlers.login,
|
|
901
|
+
loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
|
|
902
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
903
|
+
[AuthErrors.noMembership]: 403,
|
|
904
|
+
},
|
|
905
|
+
...(effectiveAuth.cookieDomain !== undefined && {
|
|
906
|
+
cookieDomain: effectiveAuth.cookieDomain,
|
|
907
|
+
}),
|
|
908
|
+
...(effectiveAuth.allowedOrigins !== undefined && {
|
|
909
|
+
allowedOrigins: effectiveAuth.allowedOrigins,
|
|
910
|
+
}),
|
|
911
|
+
...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
|
|
912
|
+
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
913
|
+
}),
|
|
914
|
+
...sessionAuthFragment,
|
|
915
|
+
...patAuthFragment,
|
|
916
|
+
...tenantLifecycleAuthFragment,
|
|
917
|
+
...(mfaFeature && { mfaVerifyHandler: AuthMfaHandlers.verify }),
|
|
918
|
+
...(effectiveAuth.passwordReset && {
|
|
919
|
+
passwordReset: {
|
|
920
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
921
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
922
|
+
},
|
|
923
|
+
}),
|
|
924
|
+
...(effectiveAuth.emailVerification && {
|
|
925
|
+
emailVerification: {
|
|
926
|
+
requestHandler: AuthHandlers.requestEmailVerification,
|
|
927
|
+
confirmHandler: AuthHandlers.verifyEmail,
|
|
928
|
+
},
|
|
929
|
+
}),
|
|
930
|
+
...(effectiveAuth.signup && {
|
|
931
|
+
signup: {
|
|
932
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
933
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
934
|
+
},
|
|
935
|
+
}),
|
|
936
|
+
...(effectiveAuth.invite && {
|
|
937
|
+
invite: {
|
|
938
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
939
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
940
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
941
|
+
},
|
|
942
|
+
}),
|
|
943
|
+
},
|
|
944
|
+
}),
|
|
945
|
+
...(resolvedAnonymousAccess && { anonymousAccess: resolvedAnonymousAccess }),
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
// Deploy-Topologie. Default (Single-Container): createAllInOneEntrypoint
|
|
949
|
+
// fährt HTTP + BEIDE Job-Lanes (zwei Runner, jeder schedult seine eigene
|
|
950
|
+
// Lane-Crons → kein Double-Fire) + Event-Dispatcher inline. So laufen
|
|
951
|
+
// worker-Lane-Crons (run-export-jobs default runIn:"worker") UND MSPs ohne
|
|
952
|
+
// separaten Worker-Process — die Asymmetrie, an der der Daten-Export hing.
|
|
953
|
+
// runSingleInstance:false → API-only; ein dezidierter Worker MUSS dann die
|
|
954
|
+
// worker-Lane + MSPs übernehmen (api-Lane-Jobs laufen weiter lokal).
|
|
955
|
+
// Default single-instance. eventDispatcher.disabled ist die Alt-Art, MSPs
|
|
956
|
+
// diesem Prozess wegzunehmen (dezidierter Worker) — als runSingleInstance:
|
|
957
|
+
// false honorieren (api-only, kein lokaler Dispatcher), damit der explizite
|
|
958
|
+
// Flag Vorrang behält aber Bestands-Caller nicht brechen.
|
|
959
|
+
const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
|
|
960
|
+
const hasJobs = registry.getAllJobs().size > 0;
|
|
961
|
+
const queueNamePrefix = options.jobs?.queueNamePrefix;
|
|
962
|
+
const jobLogger = jobRunLoggerCallbacks(registry, db);
|
|
963
|
+
const dispatcherTunables =
|
|
964
|
+
options.eventDispatcher?.pollIntervalMs !== undefined
|
|
965
|
+
? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
|
|
966
|
+
: {};
|
|
967
|
+
|
|
968
|
+
const entrypoint: ApiEntrypoint | AllInOneEntrypoint = runSingleInstance
|
|
969
|
+
? createAllInOneEntrypoint({
|
|
970
|
+
...baseEntrypointOptions,
|
|
971
|
+
redisUrl,
|
|
972
|
+
...jobLogger,
|
|
973
|
+
...(queueNamePrefix !== undefined && { queueNamePrefix }),
|
|
974
|
+
...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
|
|
975
|
+
})
|
|
976
|
+
: createApiEntrypoint({
|
|
977
|
+
...baseEntrypointOptions,
|
|
978
|
+
...(hasJobs && {
|
|
979
|
+
jobs: {
|
|
980
|
+
redisUrl,
|
|
981
|
+
runLocalJobs: true,
|
|
982
|
+
...jobLogger,
|
|
983
|
+
...(queueNamePrefix !== undefined && { queueNamePrefix }),
|
|
984
|
+
},
|
|
985
|
+
}),
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
|
|
989
|
+
// in die index.html injiziert damit createKumikoApp() im Browser
|
|
990
|
+
// `window.__KUMIKO_SCHEMA__` synchron lesen kann — gleicher Pfad
|
|
991
|
+
// wie im dev-server, damit der Client-Code keine Sonderfall-
|
|
992
|
+
// Branch zwischen dev/prod braucht. Boot-once weil Features
|
|
993
|
+
// nach dem Start nicht mehr ändern.
|
|
994
|
+
// TODO: Sobald per-Tenant- oder per-User-Schema kommt (Feature-Toggles
|
|
995
|
+
// pro Tenant, Auth-Rolle gated Screens), muss die Injection pro
|
|
996
|
+
// Request rendern — staticDir-Fallback einen render(req)-Hook bekommen
|
|
997
|
+
// statt eines fixed JSON-Strings. Heute: registry-static, also OK.
|
|
998
|
+
const appSchemaJson = JSON.stringify(buildAppSchema(registry));
|
|
999
|
+
|
|
1000
|
+
// 9. Seeds: admin first, then config-seeds from r.config({seeds}),
|
|
1001
|
+
// then app-specific. All idempotent — runProdApp doesn't gate
|
|
1002
|
+
// "first boot" via flag, every seed-step checks its own
|
|
1003
|
+
// preconditions. Config-seeds rely on a deterministic
|
|
1004
|
+
// aggregate-id so re-boot becomes a version_conflict skip.
|
|
1005
|
+
if (effectiveAuth) {
|
|
1006
|
+
await seedAdmin(db, effectiveAuth.admin);
|
|
1007
|
+
}
|
|
1008
|
+
await applyBootSeeds({
|
|
1009
|
+
registry,
|
|
1010
|
+
db,
|
|
1011
|
+
...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
|
|
1012
|
+
});
|
|
1013
|
+
for (const seed of options.seeds ?? []) {
|
|
1014
|
+
await seed({ db });
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ES-Operations / Seed-Migrations (Phase 1). Läuft NACH applyBootSeeds +
|
|
1018
|
+
// existing seeds-array — die deklarativen Seeds sind die "always-insert-
|
|
1019
|
+
// if-missing"-Schicht; seed-migrations sind die "diff-and-update"-
|
|
1020
|
+
// Schicht für Drift den existing Seeds nicht erfassen können (z.B.
|
|
1021
|
+
// Membership-Roles-Change nach initialer Seed-Erstellung).
|
|
1022
|
+
if (options.seedsDir !== undefined && envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
|
|
1023
|
+
await createEsOperationsTable(db);
|
|
1024
|
+
const seedDispatcher = createDispatcher(registry, {
|
|
1025
|
+
db,
|
|
1026
|
+
redis,
|
|
1027
|
+
entityCache,
|
|
1028
|
+
registry,
|
|
1029
|
+
...extraContext,
|
|
1030
|
+
});
|
|
1031
|
+
await runPendingSeedMigrations({
|
|
1032
|
+
db,
|
|
1033
|
+
seedsDir: options.seedsDir,
|
|
1034
|
+
appliedBy: "boot",
|
|
1035
|
+
registry, // → dry-run-validator catched handler-QN-typos vor dem write
|
|
1036
|
+
// @wrapper-known semantic-alias
|
|
1037
|
+
createContext: (dbRunner: DbRunner) =>
|
|
1038
|
+
createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
await entrypoint.start();
|
|
1043
|
+
|
|
1044
|
+
// 10. App-eigene HTTP-Routes mounten — vor dem static-fallback. Hono
|
|
1045
|
+
// matcht in Eintrags-Reihenfolge, also greifen explizite Routen
|
|
1046
|
+
// der App (z.B. /feed.xml) bevor der Static-Fallback nach Disk-
|
|
1047
|
+
// Files sucht. Eingehende /api/*-Pfade sind schon vom dispatcher
|
|
1048
|
+
// belegt; extraRoutes sollte die nicht überschreiben (kein
|
|
1049
|
+
// enforce, das ist Author-Verantwortung).
|
|
1050
|
+
if (options.extraRoutes) {
|
|
1051
|
+
options.extraRoutes(entrypoint.app, {
|
|
1052
|
+
db,
|
|
1053
|
+
redis,
|
|
1054
|
+
registry,
|
|
1055
|
+
dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// 11. Build the fetch-handler. Static-fallback for non-/api/ paths
|
|
1060
|
+
// wired via a wrapper so Hono owns /api/* + extraRoutes and disk
|
|
1061
|
+
// owns the rest. Tests use this directly; listen() wraps it in
|
|
1062
|
+
// Bun.serve.
|
|
1063
|
+
const fetchHandler = options.staticDir
|
|
1064
|
+
? buildStaticFallback(
|
|
1065
|
+
entrypoint.app.fetch.bind(entrypoint.app),
|
|
1066
|
+
options.staticDir,
|
|
1067
|
+
appSchemaJson,
|
|
1068
|
+
options.hostDispatch,
|
|
1069
|
+
)
|
|
1070
|
+
: entrypoint.app.fetch.bind(entrypoint.app);
|
|
1071
|
+
|
|
1072
|
+
// 11. Mark lifecycle ready — health/ready flips to 200 after this.
|
|
1073
|
+
entrypoint.lifecycle.markReady();
|
|
1074
|
+
|
|
1075
|
+
const handle: ProdAppHandle = {
|
|
1076
|
+
entrypoint,
|
|
1077
|
+
fetch: fetchHandler,
|
|
1078
|
+
listen: async (listenPort = port) => {
|
|
1079
|
+
// Bun.serve is the production HTTP. Tests don't call listen()
|
|
1080
|
+
// because vitest runs under Node where Bun.serve doesn't exist.
|
|
1081
|
+
// Options-Shape (inkl. idleTimeout: 0 für SSE) liegt in der
|
|
1082
|
+
// exportierten buildBunServeOptions-Funktion — siehe ihren
|
|
1083
|
+
// Header für die Begründung.
|
|
1084
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun === "undefined") {
|
|
1085
|
+
// Klare Fehlermeldung statt nackter ReferenceError. Trifft wenn
|
|
1086
|
+
// jemand listen() unter Node/vitest aufruft ohne autoListen:false
|
|
1087
|
+
// — hilft beim Debug, statt sich an "Bun is not defined" abzumühen.
|
|
1088
|
+
throw new Error(
|
|
1089
|
+
"[runProdApp] listen() requires Bun runtime (Bun.serve). " +
|
|
1090
|
+
"Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.",
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler));
|
|
1094
|
+
|
|
1095
|
+
// SIGTERM/SIGINT — graceful shutdown. Only registered when we
|
|
1096
|
+
// actually own a Bun-server, otherwise the test process picks up
|
|
1097
|
+
// signals it shouldn't respond to.
|
|
1098
|
+
let shuttingDown = false;
|
|
1099
|
+
const shutdown = async (signal: string) => {
|
|
1100
|
+
if (shuttingDown) return;
|
|
1101
|
+
shuttingDown = true;
|
|
1102
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
1103
|
+
console.log(`[runProdApp] ${signal} received — draining…`);
|
|
1104
|
+
try {
|
|
1105
|
+
await handle.stop();
|
|
1106
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
1107
|
+
console.log("[runProdApp] graceful shutdown complete.");
|
|
1108
|
+
} catch (e) {
|
|
1109
|
+
// biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
|
|
1110
|
+
console.error("[runProdApp] error during shutdown:", e);
|
|
1111
|
+
} finally {
|
|
1112
|
+
process.exit(0);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
1116
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
1117
|
+
|
|
1118
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
1119
|
+
console.log(`[runProdApp] ready on http://0.0.0.0:${listenPort}`);
|
|
1120
|
+
},
|
|
1121
|
+
stop: async () => {
|
|
1122
|
+
await entrypoint.stop();
|
|
1123
|
+
handle.server?.stop();
|
|
1124
|
+
await closeDb();
|
|
1125
|
+
redis.disconnect();
|
|
1126
|
+
},
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
// 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
|
|
1130
|
+
// false because Bun.serve isn't available under vitest/node).
|
|
1131
|
+
// Production path: `await runProdApp({...})` and the server is up.
|
|
1132
|
+
if (options.autoListen !== false) {
|
|
1133
|
+
await handle.listen();
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return handle;
|
|
1137
|
+
}
|