@cosmicdrift/kumiko-server-runtime 0.386.0 → 0.387.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.
@@ -9,28 +9,27 @@ import { CSP_HASHES_FILE, parseCspHashes } from "./csp-hashes.js";
9
9
  import { buildRequestBoundSystemQuery, } from "./request-bound-system-query.js";
10
10
  import { stripNoRouteMatchHeader, tryHonoFirst } from "./try-hono-first.js";
11
11
  // Static-asset + SPA-fallback serving for runProdApp's HTTP handler. Split
12
- // out of run-prod-app.ts (#1005, Welle 2) — mechanical relocation, these
12
+ // out of run-prod-app.ts — mechanical relocation, these
13
13
  // functions are self-contained (no closure over runProdApp's local boot
14
14
  // state), only params.
15
- // Static-fallback: try the Hono app first, fall back to a file in
16
- // staticDir if Hono returns 404. Keeps /api/* on the dispatcher and
17
- // everything else (HTML, JS, CSS, images) on the disk.
15
+ // Static fallback: try the Hono app first and serve a file from staticDir only
16
+ // when Hono returns 404, so /api/* stays on the dispatcher while HTML,
17
+ // JavaScript, CSS, and images come from disk.
18
18
  //
19
- // Cache-Header-Strategie:
19
+ // Cache-header strategy:
20
20
  // /assets/* → public, max-age=31536000, immutable
21
- // (gehashte Filenames vom Build, sicher cachebar)
21
+ // (build-generated hashed filenames are safe to cache)
22
22
  // /index.html → no-cache, must-revalidate
23
- // (HTML-Shell, must reload on deploy)
23
+ // (the HTML shell must reload after deployment)
24
24
  // /manifest.json, /sw.js → no-cache
25
- // (Update-Detection-Mechanismen, müssen frisch sein)
26
- // alles andere → kein expliziter Header
27
- // (Browser-Default, public/-Files wie favicon)
28
- // File-reader für den static-fallback. Nutzt node:fs/promises statt
29
- // Bun.file damit der Pfad in vitest+node integration-tests laufen kann
30
- // (Bun.file ist Bun-only). Performance-cost ist marginal: die Disk-
31
- // Files in einem prod-staticDir sind 1-200 KB, full-buffer-Read ist
32
- // ein paar Mikrosekunden. Streaming via Bun.file wäre nur relevant ab
33
- // ~1 MB.
25
+ // (update-detection mechanisms must stay fresh)
26
+ // everything else → no explicit header
27
+ // (browser default, including public/ files such as favicon)
28
+ //
29
+ // Use node:fs/promises instead of Bun.file so this path also runs in vitest+node
30
+ // integration tests; Bun.file is Bun-only. Full-buffer reads are acceptable because
31
+ // production static files are typically 1–200 KB; streaming would matter only above
32
+ // roughly 1 MB.
34
33
  export async function readStaticFile(filePath) {
35
34
  try {
36
35
  const { readFile, stat } = await import("node:fs/promises");
@@ -99,10 +98,9 @@ export function serveDiskFile(req, pathname, file) {
99
98
  lastModified: new Date(file.mtimeMs),
100
99
  });
101
100
  }
102
- // Minimal-Mime-Map — deckt die Files ab die kumiko-build und typische
103
- // public/-Inhalte produzieren. Bun.file leitet das aus dem Suffix ab,
104
- // im node-Pfad müssen wir es selbst tun. Default: octet-stream (Browser
105
- // fragt bei unbekanntem MIME nach).
101
+ // Covers files produced by kumiko-build and typical public/ contents. Bun.file
102
+ // infers the type from the suffix, but the node path must do so explicitly;
103
+ // unknown types use octet-stream so the browser can handle them safely.
106
104
  export function mimeTypeFor(filePath) {
107
105
  const ext = filePath.toLowerCase().split(".").pop() ?? "";
108
106
  switch (ext) {
@@ -190,9 +188,9 @@ hostDispatchScriptHashes = []) {
190
188
  // inject into) → the original html object is returned untouched.
191
189
  //
192
190
  // The resolve+timeout+inject itself runs through headless's
193
- // resolveAndInjectPageHead — the single call site runDevApp also uses
194
- // (kumiko-framework#3026), so only the systemQuery/dispatcher wiring
195
- // below (Request-bound, prod-specific) and the etag recompute are local.
191
+ // resolveAndInjectPageHead, the same call site runDevApp uses, so only the
192
+ // systemQuery/dispatcher wiring below (Request-bound, prod-specific) and the
193
+ // etag recompute are local.
196
194
  async function applyPageHead(req, html, socketAddress) {
197
195
  if (!pageHead)
198
196
  return html;
@@ -224,10 +222,9 @@ hostDispatchScriptHashes = []) {
224
222
  etag: computeStrongEtag(encoded),
225
223
  };
226
224
  }
227
- // hostDispatch konsultieren wenn gesetzt UND der Request auf den
228
- // HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
229
- // resolved Response (redirect/404/html) oder null wenn der Default-
230
- // Pfad weiterlaufen soll.
225
+ // Consult hostDispatch when the request reaches the HTML fallback (the root or
226
+ // an SPA route). It can return a resolved response (redirect, 404, or HTML),
227
+ // or null to continue with the default path.
231
228
  async function tryHostDispatch(req, socketAddress) {
232
229
  if (!hostDispatch)
233
230
  return null;
@@ -261,8 +258,8 @@ hostDispatchScriptHashes = []) {
261
258
  async function serveHostDispatchFile(req, file, csp, socketAddress, status) {
262
259
  const html = await readHtmlFile(`${staticDir}/${file}`);
263
260
  if (!html) {
264
- // Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
265
- // Liefer 500 statt silent-404 damit der Bug schnell auffällt.
261
+ // Configuration error: hostDispatch references a nonexistent file. Return 500
262
+ // instead of silently returning 404 so the problem is visible immediately.
266
263
  return new Response(`hostDispatch: file not found: ${file}`, { status: 500 });
267
264
  }
268
265
  // Per-host body (hostDispatch picks the file by host) → Vary: Host,
@@ -282,26 +279,24 @@ hostDispatchScriptHashes = []) {
282
279
  if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
283
280
  return stripNoRouteMatchHeader(await apiHandler(req, socketAddress));
284
281
  }
285
- // Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
286
- // /sitemap.xml) UND r.httpRoute-Features (z.B. /legal/*) müssen vor
287
- // dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
288
- // unbekannte Pfade als index.html. Shared mit dev-server's
289
- // createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
282
+ // Try Hono before disk lookup so extraRoutes (for example /feed.xml and
283
+ // /sitemap.xml) and r.httpRoute features (for example /legal/*) are handled
284
+ // before unknown paths fall through to index.html. Keep this consistent with
285
+ // dev-server's createKumikoServer.handleFetch so both paths have identical semantics.
290
286
  const honoTry = await tryHonoFirst({ fetch: apiHandler }, req, socketAddress);
291
287
  if (honoTry.matched) {
292
288
  return honoTry.response;
293
289
  }
294
290
  const honoRes = honoTry.response;
295
- // Disk-/SPA-Fallback ist GET/HEAD-only. Ein non-GET ohne Hono-Match
296
- // (z.B. POST auf einen falsch konfigurierten Webhook-Pfad) muss den
297
- // Hono-404 durchreichen — 200 index.html würde dem Provider
298
- // "delivered" signalisieren und Events gingen still verloren (#259).
291
+ // The disk/SPA fallback accepts only GET and HEAD. A non-GET without a Hono
292
+ // match (for example, POST to a misconfigured webhook path) must preserve the
293
+ // Hono 404: returning index.html with 200 could make the provider report the
294
+ // event as delivered even though it was dropped.
299
295
  if (req.method !== "GET" && req.method !== "HEAD") {
300
296
  return honoRes;
301
297
  }
302
- // Disk-Datei (Asset oder konkrete File). Asset-Pfade laufen
303
- // host-unabhängig — die Bundles in /assets/* werden vom client
304
- // aktiv geladen, kein Server-side Routing nötig.
298
+ // Asset paths are host-independent because the client actively loads the
299
+ // bundles in /assets/*; they do not require server-side routing.
305
300
  const isIndexRequest = url.pathname === "/" || url.pathname === "/index.html";
306
301
  if (!isIndexRequest) {
307
302
  const relPath = url.pathname.slice(1);
@@ -324,18 +319,17 @@ hostDispatchScriptHashes = []) {
324
319
  const withHead = await applyPageHead(req, index, socketAddress);
325
320
  return serveHtmlFile(req, "/index.html", withHead, pageHead && { vary: PAGE_HEAD_VARY });
326
321
  }
327
- // Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
328
- // ursprünglichen 404 von Hono durch (statt einen neuen Roundtrip).
322
+ // If Hono found no route and neither a disk file nor index.html exists, preserve
323
+ // its original 404 instead of issuing another request.
329
324
  return honoRes;
330
325
  };
331
326
  }
332
327
  // The resolved page head depends on host and preferred locale; ?lang= is part of the URL and
333
328
  // already keys the cache.
334
329
  const PAGE_HEAD_VARY = "Host, Accept-Language";
335
- // Map URL-Pfad → Cache-Policy. Hashed-Asset-Pfade (/assets/*) sind
336
- // unveränderlich, der Rest bleibt revalidate/no-cache damit Updates ohne
337
- // Hard-Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
338
- // runProdApp.
330
+ // Map URL paths to cache policies. Hashed asset paths (/assets/*) are immutable;
331
+ // other paths use revalidation or no-cache so updates take effect without a hard
332
+ // reload. Exported for unit tests; consumers access it through runProdApp.
339
333
  export function staticCachePolicy(pathname) {
340
334
  if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
341
335
  return { kind: "immutable" };
@@ -345,9 +339,8 @@ export function staticCachePolicy(pathname) {
345
339
  }
346
340
  if (pathname === "/manifest.json" ||
347
341
  pathname === "/sw.js" ||
348
- // ponytail: build-info.json ist statisch — kein /api/version-Endpoint
349
- // nötig, der Disk-Fallback serviert sie. no-cache, sonst pollt der
350
- // UpdateChecker eine veraltete id.
342
+ // ponytail: build-info.json is static, so no /api/version endpoint is needed; the disk
343
+ // fallback serves it. Use no-cache so UpdateChecker does not poll a stale id.
351
344
  pathname === "/build-info.json") {
352
345
  return { kind: "no-cache" };
353
346
  }
@@ -48,17 +48,17 @@ export type PasswordResetSetup = Omit<PasswordResetOptions, "hmacSecret"> & {
48
48
  export type EmailVerificationSetup = Omit<EmailVerificationOptions, "hmacSecret"> & {
49
49
  readonly hmacSecret?: string;
50
50
  };
51
- /** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
52
- * Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
53
- * verify gibt's KEIN hmacSecret — Signup-Tokens sind opaque random in
54
- * Redis, nicht HMAC-signed. */
51
+ /** Wrapper API for Magic-Link self-signup. = SignupOptions (appUrl; mail
52
+ * is sent through delivery/ctx.notify like reset/verify). Unlike reset/verify,
53
+ * there is no hmacSecret: signup tokens are opaque random values in Redis,
54
+ * not HMAC-signed. */
55
55
  export type SignupSetup = SignupOptions;
56
- /** Wrapper-API für Tenant-Invite Magic-Link. = InviteOptions (appUrl, die
57
- * Invite-Mail geht via delivery wie reset/verify/signup). Drei accept-
58
- * Branches im framework; handler-names sind hardcoded in run-prod-app aus
59
- * AuthHandlers (analog signup). */
56
+ /** Wrapper API for tenant-invite Magic-Link. = InviteOptions (appUrl; the
57
+ * invite mail is sent through delivery like reset/verify/signup). The
58
+ * framework has three accept branches; handler names are fixed in
59
+ * run-prod-app through AuthHandlers, as with signup. */
60
60
  export type InviteSetup = InviteOptions;
61
- /** Wrapper API for the account-unlock flow (#1266). = AccountUnlockOptions
61
+ /** Wrapper API for the account-unlock flow. = AccountUnlockOptions
62
62
  * (appUrl via delivery, symmetric to PasswordResetSetup). Self-service
63
63
  * escape hatch for accountLockout's failure counter; the lockout is always
64
64
  * on, so `auth.mail` mounts this flow by default. */
@@ -68,23 +68,25 @@ export type AccountUnlockSetup = Omit<AccountUnlockOptions, "hmacSecret"> & {
68
68
  /** Wrapper API for the account-lockout brute-force guard on the login
69
69
  * handler. = AccountLockoutOptions (maxFailedAttempts, lockoutDurationMinutes
70
70
  * — no mail/appUrl fields, it doesn't mount any routes). Symmetric to
71
- * RunDevAppAuthOptions.accountLockout (kumiko-framework#1627) — before this,
71
+ * RunDevAppAuthOptions.accountLockout — before this,
72
72
  * `accountUnlock` existed on both wrappers with no way to ever set the
73
73
  * lockout it's meant to escape. */
74
74
  export type AccountLockoutSetup = AccountLockoutOptions;
75
- /** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
76
- * Verdrahtet alle 5 Mail-Flows aus einem env-SMTP-Transport + Standard-
77
- * Templates (siehe `auth.mail` + resolveAuthMail). */
75
+ /** Auth-mail convenience options shared by runProdApp and runDevApp.
76
+ * Wires all five mail flows from an env-based SMTP transport and default
77
+ * templates (see `auth.mail` and resolveAuthMail). */
78
78
  export type AuthMailOptions = {
79
79
  /** App-Basis-URL inkl. Schema (z.B. "https://app.example.com"). */
80
80
  readonly baseUrl: string;
81
- /** App-Name für Mail-Subject + Body. Default "Account". */
81
+ /** App name used in the mail subject and body. Default: "Account". */
82
82
  readonly appName?: string;
83
- /** Locale für die Mail-Templates. Default "de". */
83
+ /** Locale used by the mail templates. Default: "de". */
84
84
  readonly locale?: AuthMailLocale;
85
- /** "strict" blockt Login bis emailVerified; "off" mountet ohne Gate. */
85
+ /** "strict" blocks login until emailVerified; "off" mounts the flow without
86
+ * that gate. */
86
87
  readonly emailVerificationMode?: "strict" | "off";
87
- /** Fallback-From-Adresse wenn `SMTP_FROM`-env fehlt (env gewinnt). */
88
+ /** Fallback From address when the `SMTP_FROM` env var is absent; the env var
89
+ * takes precedence. */
88
90
  readonly from?: string;
89
91
  /** Overrides single auth paths (default DEFAULT_AUTH_PATHS). */
90
92
  readonly paths?: Partial<AuthPaths>;
@@ -119,16 +121,15 @@ export type RunProdAppAuthOptions = {
119
121
  readonly passwordReset?: PasswordResetSetup;
120
122
  /** Email-verification flow. Symmetric to passwordReset. */
121
123
  readonly emailVerification?: EmailVerificationSetup;
122
- /** Self-Signup flow (Magic-Link). When set, /api/auth/signup-request +
123
- * /api/auth/signup-confirm are mounted; signup-confirm mintet JWT +
124
- * Cookies wie ein erfolgreicher login (Auto-Login direkt nach
125
- * Activation). */
124
+ /** Self-signup flow (Magic-Link). When set, /api/auth/signup-request and
125
+ * /api/auth/signup-confirm are mounted; signup-confirm mints a JWT and
126
+ * cookies like a successful login (automatic login after activation). */
126
127
  readonly signup?: SignupSetup;
127
128
  /** Tenant-Invite flow (Magic-Link). When set, /api/auth/invite-accept,
128
129
  * /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
129
130
  * are mounted. */
130
131
  readonly invite?: InviteSetup;
131
- /** Account-unlock flow (#1266). When set, /api/auth/request-account-unlock
132
+ /** Account-unlock flow. When set, /api/auth/request-account-unlock
132
133
  * + /api/auth/confirm-account-unlock are mounted. Self-service escape
133
134
  * hatch for accountLockout's monotonic failure-counter — confirming
134
135
  * clears the Redis lockout state, no entity write. */
@@ -163,13 +164,12 @@ export type RunProdAppAuthOptions = {
163
164
  * `mail.baseUrl`, then of the APP_BASE_URL env. */
164
165
  readonly publicOrigin?: string;
165
166
  /** Number of trusted reverse-proxy hops between the client and this
166
- * process for client-IP derivation (see AuthRoutesConfig.trustedProxyHops,
167
- * kumiko-framework#1539) — closes the X-Forwarded-For spoofing hole on
168
- * the auth rate-limiters. Falls back to the `KUMIKO_TRUSTED_PROXY_HOPS`
169
- * env var when unset; both unset means 0 hops, the safe socket-only
170
- * default (only the socket address counts, XFF ignored). Deprecated in
171
- * favor of the top-level `trustedProxyHops` option, which wins over
172
- * this and the env var. */
167
+ * process for client-IP derivation (see AuthRoutesConfig.trustedProxyHops)
168
+ * — closes the X-Forwarded-For spoofing hole on the auth rate-limiters.
169
+ * Falls back to the `KUMIKO_TRUSTED_PROXY_HOPS` env var when unset; both
170
+ * unset means 0 hops, the safe socket-only default (only the socket address
171
+ * counts, XFF ignored). Deprecated in favor of the top-level
172
+ * `trustedProxyHops` option, which wins over this and the env var. */
173
173
  readonly trustedProxyHops?: number;
174
174
  /** Server-computed post-auth redirect (see AuthRoutesConfig.postAuthLanding).
175
175
  * Result lands in the login/signup/invite response as `landingPath`; an
@@ -192,12 +192,10 @@ export type ProdSeedFn = (deps: {
192
192
  masterKeyProvider?: import("@cosmicdrift/kumiko-framework/secrets").MasterKeyProvider;
193
193
  configEncryption?: import("@cosmicdrift/kumiko-framework/secrets").EnvelopeCipher;
194
194
  }) => Promise<void>;
195
- /** Boot-Time-Deps die `extraContext` + `anonymousAccess` Factories als
196
- * Argument bekommen. Closure dann in der returned Config (z.B. ein
197
- * TenantResolver der gegen `db` queriet, oder ein extraContext-Provider
198
- * der direkt SSE-Events publishen will). Single-source: identisch zu
199
- * setupTestStack's extraContext-Factory-Shape damit Test/Prod gleich
200
- * aussehen. */
195
+ /** Dependencies passed to the `extraContext` and `anonymousAccess` factories.
196
+ * The returned config can close over them, for example a TenantResolver that
197
+ * queries `db` or an extra-context provider that publishes SSE events. The
198
+ * shape matches setupTestStack so test and production wiring remain aligned. */
201
199
  export type RunProdAppDeps = {
202
200
  readonly db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
203
201
  readonly redis: Redis;
@@ -282,20 +280,16 @@ export type RunProdAppOptions = {
282
280
  * @example
283
281
  * seeds: { demoTenant: { key: "demo", name: "Demo" }, sysadminFromEnv: true, run: [seedDemoData] } */
284
282
  readonly seeds?: readonly ProdSeedFn[] | ProdSeedsConfig;
285
- /** Pfad zum seeds-Directory für ES-Operations / Seed-Migrations
286
- * (file-basiert wie drizzle-migrate). Wenn gesetzt + KUMIKO_SKIP_ES_OPS
287
- * != "1": runProdApp scannt das Verzeichnis nach `<id>.ts` Files,
288
- * diff vs kumiko_es_operations-Table, läuft pending in Tx.
289
- * Plan: kumiko-platform/docs/plans/features/es-ops.md */
283
+ /** Path to the seeds directory for ES operations and seed migrations
284
+ * (file-based like drizzle-migrate). When set and KUMIKO_SKIP_ES_OPS != "1",
285
+ * runProdApp scans it for `<id>.ts` files, diffs them against the
286
+ * kumiko_es_operations table, and runs pending operations in a transaction. */
290
287
  readonly seedsDir?: string;
291
- /** Anonymous-access for public endpoints (same shape as runDevApp).
292
- * Akzeptiert entweder einen statischen Config-Object ODER eine
293
- * Factory `({db, redis, registry}) => Config` — die Factory wird
294
- * einmal zur Boot-Zeit aufgerufen, NACHDEM db/redis/registry konstruiert
295
- * sind. Der Caller closure'd typischerweise db/redis/registry in den
296
- * TenantResolver damit z.B. ein Subdomain → Tenant-Lookup gegen die
297
- * DB möglich ist (siehe samples/showcases/publicstatus für das
298
- * Multi-Tenant-Pattern). */
288
+ /** Anonymous access for public endpoints (same shape as runDevApp).
289
+ * Accepts either a static config object or a factory
290
+ * `({db, redis, registry}) => Config`, called once at boot after
291
+ * db/redis/registry have been constructed. The factory can close over these
292
+ * dependencies so a TenantResolver can look up a subdomain in the database. */
299
293
  readonly anonymousAccess?: AnonymousAccessOption;
300
294
  /** Static-file root for HTML / assets. Served on the catch-all route
301
295
  * for any path that doesn't match an /api/ handler. Use this for the
@@ -350,25 +344,24 @@ export type RunProdAppOptions = {
350
344
  * (AWS/GCP/Azure); with it set, `KUMIKO_SECRETS_MASTER_KEY_V1` is not
351
345
  * required in the env. */
352
346
  readonly masterKey?: MasterKeyProvider;
353
- /** Subject-Key-Adapter für Crypto-Shredding (DSGVO Art. 17). Wenn gesetzt,
354
- * steht er Feature-Code als `ctx.kms` zur Verfügung und der Boot prüft
355
- * seine health() — die App startet nicht gegen ein unerreichbares KMS
356
- * (Silent-Start würde jeden PII-Read mit 503 beantworten). Kein Default:
357
- * ohne Adapter bleibt Crypto-Shredding aus. */
347
+ /** Subject-key adapter for crypto-shredding (GDPR Art. 17). When set, it is
348
+ * exposed to feature code as `ctx.kms`, and boot checks its health so the app
349
+ * cannot start against an unreachable KMS. Without an adapter,
350
+ * crypto-shredding remains disabled. */
358
351
  readonly kms?: KmsAdapter;
359
- /** 32-Byte-Key (base64) für Blind-Index-HMACs auf `lookupable`-Feldern
360
- * (env: KUMIKO_BLIND_INDEX_KEY, `openssl rand -base64 32`). Bewusst
361
- * getrennt vom PLATFORM_KEK und nicht Teil des KmsAdapter-Contracts.
362
- * Pflicht sobald `kms` gesetzt ist UND ein Feature lookupable-Felder
363
- * deklariert — sonst bricht jeder Equality-Lookup auf Ciphertext
364
- * (Login by email!) und der Boot failt hart. */
352
+ /** 32-byte base64 key for blind-index HMACs on `lookupable` fields
353
+ * (env: KUMIKO_BLIND_INDEX_KEY, `openssl rand -base64 32`). It is separate
354
+ * from PLATFORM_KEK and not part of the KmsAdapter contract. It is required
355
+ * when `kms` is set and a feature declares lookupable fields; otherwise
356
+ * equality lookups on ciphertext, such as login by email, fail and boot
357
+ * aborts. */
365
358
  readonly blindIndexKey?: string;
366
- /** Explizites Opt-out aus dem harten PII-Boot-Gate: Features deklarieren
367
- * pii/userOwned/tenantOwned-Felder, aber es ist (noch) kein `kms`
368
- * provisioniert — die Felder liegen dann als KLARTEXT in Rows und
369
- * Events und DSGVO-Erasure via Crypto-Shredding ist unmöglich. Der Wert
370
- * ist eine Begründung für den Audit-Trail (z.B. "kms rollout pending,
371
- * infra#188"). Ohne Flag und ohne `kms` bricht der Boot ab. */
359
+ /** Explicit opt-out from the strict PII boot gate. Features may declare
360
+ * pii/userOwned/tenantOwned fields without a provisioned `kms`; those fields
361
+ * remain plaintext in rows and events, and GDPR erasure through
362
+ * crypto-shredding is unavailable. The value records the reason for the
363
+ * audit trail (for example, "kms rollout pending"). Without this flag and
364
+ * without `kms`, boot aborts. */
372
365
  readonly allowPlaintextPii?: string;
373
366
  /** Short form over `kms` / `blindIndexKey` / `allowPlaintextPii` that wires
374
367
  * the subject-key KMS from the env; not combinable with them. Unset keeps
@@ -422,8 +415,8 @@ export type RunProdAppOptions = {
422
415
  /** With `runSingleInstance: false`: the worker mounts the same mail / fileStorage features
423
416
  * (its own boot options or by hand), so the boot warning about them stays silent. */
424
417
  readonly workerMountsFeatures?: boolean;
425
- /** Job-Block. Wenn das Feature `r.job(...)` registriert, wird er
426
- * automatisch verdrahtet (siehe runSingleInstance). */
418
+ /** Job settings. Jobs a feature registers with `r.job(...)` are wired
419
+ * automatically (see runSingleInstance). */
427
420
  readonly jobs?: {
428
421
  /** BullMQ-Queue-Prefix (default "kumiko"). */
429
422
  readonly queueNamePrefix?: string;
@@ -433,25 +426,20 @@ export type RunProdAppOptions = {
433
426
  * the app manages tenants outside that feature. */
434
427
  readonly getActiveTenantIds?: JobRunnerOptions["getActiveTenantIds"];
435
428
  };
436
- /** Event-Dispatcher (MSP-Anwendung) im API-Process. Default AN —
437
- * runProdApp ist das Single-Container-Deployment, es gibt keinen
438
- * Worker-Process der multiStreamProjections anwenden könnte. Bis
439
- * 2026-06-11 fehlte der Dispatcher hier komplett: jede MSP-basierte
440
- * Read-Projektion (z.B. custom-fields jsonb) blieb in Prod leer,
441
- * kumiko_event_consumers blieb ohne Rows. `disabled: true` nur für
442
- * Setups mit dezidiertem Worker-Process. */
429
+ /** Event dispatcher (MSP application) in the API process. Enabled by default
430
+ * because the single-container deployment has no separate worker process to
431
+ * apply multiStreamProjections. Set `disabled: true` only when a dedicated
432
+ * worker process takes over the dispatcher. */
443
433
  readonly eventDispatcher?: {
444
434
  readonly disabled?: boolean;
445
- /** Poll-Intervall des Dispatcher-Loops (default siehe
446
- * createEventDispatcher). LISTEN/NOTIFY-Wiring kommt mit einem
447
- * späteren pgClient-Pass-through. */
435
+ /** Dispatcher-loop polling interval (default: see createEventDispatcher).
436
+ * LISTEN/NOTIFY wiring comes with a later pgClient pass-through. */
448
437
  readonly pollIntervalMs?: number;
449
438
  };
450
439
  /** Declarative HTTP-routes outside the /api/write|query|batch pipeline —
451
440
  * each entry declares its access tier (`entry`: "anonymous" | "user" |
452
441
  * "signature"), buildServer wires the matching guard + deps. Mounted
453
- * inside buildServer, right after the r.httpRoute loop, before seeds
454
- * (kumiko-framework#3050). */
442
+ * inside buildServer, right after the r.httpRoute loop, before seeds. */
455
443
  readonly extraRoutes?: readonly ExtraRouteDefinition[];
456
444
  /** Framework-managed unsubscribe links. Mounts `createUnsubscribeRoutes({ secret })` and makes
457
445
  * delivery sign `data.unsubscribeUrl` (path `DELIVERY_UNSUBSCRIBE_PATH`) for every email
@@ -508,13 +496,13 @@ export type RunProdAppOptions = {
508
496
  * Tests use this to feed crafted env-maps without polluting the
509
497
  * global. */
510
498
  readonly envSource?: Record<string, string | undefined>;
511
- /** Observability-Provider (Tracer + Meter). Default: Noop (kein Overhead,
512
- * kein `/metrics`-Endpoint). Setze `createPrometheusMeter()`-basierten
513
- * Provider + `metrics` um `/metrics` real zu exposen (publicstatus#91). */
499
+ /** Observability provider (tracer and meter). Default: Noop, with no overhead
500
+ * or `/metrics` endpoint. Supply a provider based on
501
+ * `createPrometheusMeter()` together with `metrics` to expose `/metrics`. */
514
502
  readonly observability?: ObservabilityProvider;
515
- /** Aktiviert den `/metrics`-Endpoint (Open-Metrics-Format). Ohne einen
516
- * PrometheusMeter-basierten `observability`-Provider antwortet `/metrics`
517
- * mit 503 + Misconfiguration-Hinweis statt echter Metriken. */
503
+ /** Enables the `/metrics` endpoint in OpenMetrics format. Without a
504
+ * PrometheusMeter-based `observability` provider, `/metrics` returns 503
505
+ * with a misconfiguration message instead of real metrics. */
518
506
  readonly metrics?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["metrics"];
519
507
  /** L1 (global per-IP limit) + L2 (auth endpoints) rate-limit middleware, passed
520
508
  * to `buildServer`'s `ServerOptions.rateLimit`. Without a value L1 allows 600/60 s