@cosmicdrift/kumiko-server-runtime 0.340.0 → 0.341.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/README.md CHANGED
@@ -12,4 +12,22 @@ bun add @cosmicdrift/kumiko-server-runtime
12
12
  import { runProdApp } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
13
13
  ```
14
14
 
15
+ ## Bundled read-only assets
16
+
17
+ Files your own build ships (fonts, templates) are declared in `package.json` next to `kumiko.clientEntries`:
18
+
19
+ ```json
20
+ "kumiko": { "assets": [{ "name": "inter-bold.ttf", "source": "packages/site-kit/fonts/inter-bold.ttf" }] }
21
+ ```
22
+
23
+ `source` is relative to the app's `package.json` and must stay inside it; `name` matches `/^[A-Za-z0-9][A-Za-z0-9._-]*$/`. `buildProdBundle` copies each asset to `dist/kumiko-bundled-assets/<name>`, a folder the static file server never serves. Read them without `node:fs`:
24
+
25
+ ```ts
26
+ import { readBundledAsset } from "@cosmicdrift/kumiko-server-runtime";
27
+
28
+ const bytes = await readBundledAsset("inter-bold.ttf"); // dist copy in prod, declared source in dev
29
+ ```
30
+
31
+ `resolveBundledAsset(name)` returns the absolute path for libraries that only take a path.
32
+
15
33
  Documentation: https://kumiko.rocks
@@ -0,0 +1,36 @@
1
+ import type { AuthEmailPasswordOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
+ import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { type FeatureDefinition, type Registry } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { type WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
5
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
6
+ import { Redis } from "ioredis";
7
+ import type { RunWorkerAppOptions } from "../run-worker-app.js";
8
+ export type WorkerBootCoreOptions = Omit<RunWorkerAppOptions, "wireComponents">;
9
+ export type WorkerBootProfile = {
10
+ /** Log prefix and requireEnv context, e.g. "runWorkerApp". */
11
+ readonly processName: string;
12
+ /** Resolved auth block for composeFeatures; implies includeBundled. */
13
+ readonly authOptions?: AuthEmailPasswordOptions;
14
+ /** One-shot process: its job queue is not drained after exit, so ctx.notify
15
+ * sends queued channels inline. */
16
+ readonly deliverQueuedInline?: boolean;
17
+ };
18
+ export type BootedWorkerProcess = {
19
+ readonly kind: "booted";
20
+ readonly db: DbConnection;
21
+ readonly redis: Redis;
22
+ readonly registry: Registry;
23
+ readonly features: readonly FeatureDefinition[];
24
+ readonly envSource: Record<string, string | undefined>;
25
+ readonly entrypoint: WorkerEntrypoint;
26
+ /** ctx.secrets as wired into the entrypoint (auto-wired when the secrets
27
+ * feature is mounted and a master key exists, or from `extraContext`). */
28
+ readonly secrets?: SecretsContext;
29
+ readonly close: () => Promise<void>;
30
+ };
31
+ export type WorkerBootResult = {
32
+ readonly kind: "dry-run";
33
+ readonly envSource: Record<string, string | undefined>;
34
+ } | BootedWorkerProcess;
35
+ export declare function resolveWorkerEnvSource(options: Pick<WorkerBootCoreOptions, "envSource" | "kmsSlots">, processName: string): Promise<Record<string, string | undefined>>;
36
+ export declare function bootWorkerProcess(options: WorkerBootCoreOptions, envSource: Record<string, string | undefined>, profile: WorkerBootProfile): Promise<WorkerBootResult>;
@@ -0,0 +1,183 @@
1
+ // Shared boot core of the HTTP-less processes (runWorkerApp, runBootstrap):
2
+ // env fail-fast, Temporal polyfill, composeFeatures/registry, PII invariants,
3
+ // dry-run exit, KMS health gate, connections, schema-drift gate, boot-crypto
4
+ // and extraContext. Both processes must write rows the API can read, so they
5
+ // share every step that decides ciphers, blind indexes and config encryption.
6
+ import { loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
7
+ import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
8
+ import { configureEntityFieldEncryption, createDbConnection, } from "@cosmicdrift/kumiko-framework/db";
9
+ import { createRegistry, findTierResolverUsage, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { createWorkerEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
11
+ import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
12
+ import { createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
13
+ import { createRedisCacheSyncBus, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
14
+ import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
15
+ import { Redis } from "ioredis";
16
+ import { composeFeatures } from "../compose-features.js";
17
+ import { assertPiiBootInvariants } from "../pii-boot-gate.js";
18
+ import { requireEnv } from "../run-prod-app.js";
19
+ import { addConfigAccessorFactory, buildBootExtraContext } from "../run-prod-app-boot-context.js";
20
+ import { resolveBootCrypto } from "./boot-crypto.js";
21
+ import { jobRunLoggerCallbacks } from "./job-run-logger.js";
22
+ import { assertWorkerMetricsOptions } from "./worker-metrics-server.js";
23
+ function isSecretsContext(value) {
24
+ if (typeof value !== "object" || value === null)
25
+ return false;
26
+ return ["get", "has", "set", "delete"].every((method) => typeof value[method] === "function");
27
+ }
28
+ export async function resolveWorkerEnvSource(options, processName) {
29
+ const rawEnvSource = options.envSource ?? process.env;
30
+ return options.kmsSlots
31
+ ? await resolvePlatformKeks(rawEnvSource, {
32
+ slots: options.kmsSlots,
33
+ logPrefix: `[${processName}]`,
34
+ })
35
+ : rawEnvSource;
36
+ }
37
+ async function buildTierEffectiveFeatures(features, db, registry, cacheSync) {
38
+ const tierResolverUsage = findTierResolverUsage(features);
39
+ if (!tierResolverUsage)
40
+ return undefined;
41
+ const plugin = tierResolverUsage.options;
42
+ return plugin.build({ db, registry, cacheSync });
43
+ }
44
+ async function assertWorkerSchemaCurrent(db, migrationsDir, processName) {
45
+ const dir = migrationsDir ?? "./kumiko/migrations";
46
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint
47
+ console.log(`[${processName}] checking schema drift (${dir})…`);
48
+ try {
49
+ await assertKumikoSchemaCurrent(db, dir);
50
+ }
51
+ catch (err) {
52
+ if (err instanceof SchemaDriftError) {
53
+ // biome-ignore lint/suspicious/noConsole: terminal error message
54
+ console.error(`\n[${processName}] BOOT ABORTED — ${err.message}\n`);
55
+ }
56
+ throw err;
57
+ }
58
+ }
59
+ export async function bootWorkerProcess(options, envSource, profile) {
60
+ const { processName } = profile;
61
+ // Polyfill before anything else (fw#1725): without it every job fails with
62
+ // "Temporal is not defined" in a retry loop, with no boot-time signal.
63
+ const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
64
+ await ensureTemporalPolyfill();
65
+ const databaseUrl = requireEnv("DATABASE_URL", envSource, processName);
66
+ const redisUrl = requireEnv("REDIS_URL", envSource, processName);
67
+ const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
68
+ // MUST be composed with the same `includeBundled` as the API process.
69
+ const includeBundled = !!options.includeBundled || profile.authOptions !== undefined;
70
+ const features = composeFeatures(options.features, {
71
+ includeBundled,
72
+ ...(profile.authOptions && { authOptions: profile.authOptions }),
73
+ });
74
+ validateBoot(features, { env: envSource, ...options.validateBootOptions });
75
+ warnIfNonUtcServerTimeZone();
76
+ assertWorkerMetricsOptions(options.metrics, options.observability, processName);
77
+ assertPiiBootInvariants(features, {
78
+ kms: options.kms,
79
+ blindIndexKey: options.blindIndexKey,
80
+ allowPlaintextPii: options.allowPlaintextPii,
81
+ mode: "prod",
82
+ });
83
+ const registry = createRegistry(features);
84
+ if (envSource["KUMIKO_DRY_RUN_ENV"] === "boot") {
85
+ // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
86
+ console.log(`[${processName}] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`);
87
+ if (options.envSource === undefined) {
88
+ process.exit(0);
89
+ }
90
+ return { kind: "dry-run", envSource };
91
+ }
92
+ // Before the connections, so an abort leaks nothing.
93
+ if (options.kms) {
94
+ const kmsHealth = await options.kms.health();
95
+ if (!kmsHealth.ok) {
96
+ throw new Error(`[${processName}] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
97
+ }
98
+ }
99
+ const { db, close: closeDb } = createDbConnection(databaseUrl);
100
+ const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
101
+ const cacheSync = createRedisCacheSyncBus({
102
+ redisUrl,
103
+ clientOptions: redisClientOptionsFromEnv(envSource),
104
+ });
105
+ const resolvedEffectiveFeatures = options.effectiveFeatures ??
106
+ (await buildTierEffectiveFeatures(features, db, registry, cacheSync));
107
+ if (options.migrations !== false) {
108
+ try {
109
+ await assertWorkerSchemaCurrent(db, options.migrations?.dir, processName);
110
+ }
111
+ catch (err) {
112
+ await closeDb();
113
+ redis.disconnect();
114
+ await cacheSync.close();
115
+ throw err;
116
+ }
117
+ }
118
+ const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
119
+ const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
120
+ const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
121
+ // Same crypto wiring as the API (fw#1725), otherwise this process writes
122
+ // rows the API can no longer read (different cipher, missing blind index).
123
+ const deps = { db, redis, registry };
124
+ const resolvedExtraContext = typeof options.extraContext === "function"
125
+ ? options.extraContext(deps)
126
+ : (options.extraContext ?? {});
127
+ const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
128
+ configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
129
+ configurePiiSubjectKms(options.kms);
130
+ configureBlindIndexKey(options.blindIndexKey);
131
+ const autoExtraContext = buildBootExtraContext({
132
+ db,
133
+ features,
134
+ envSource,
135
+ registry,
136
+ hasAuth: includeBundled,
137
+ crypto: bootCrypto,
138
+ ...(options.kms && { kms: options.kms }),
139
+ ...(profile.deliverQueuedInline === true && { deliverQueuedInline: true }),
140
+ });
141
+ const extraContext = addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry);
142
+ const jobLogger = jobRunLoggerCallbacks(registry, db);
143
+ const entrypoint = createWorkerEntrypoint({
144
+ registry,
145
+ context: { db, redis, entityCache, registry, ...extraContext },
146
+ jwtSecret: jwtSecretOrKeyring,
147
+ dispatcherOptions: {
148
+ idempotency,
149
+ cacheSync,
150
+ ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
151
+ },
152
+ eventDedup,
153
+ ...(options.observability && { observability: options.observability }),
154
+ ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
155
+ redisUrl,
156
+ ...jobLogger,
157
+ ...(options.jobs?.queueNamePrefix !== undefined && {
158
+ queueNamePrefix: options.jobs.queueNamePrefix,
159
+ }),
160
+ ...(options.jobs?.getActiveTenantIds !== undefined && {
161
+ getActiveTenantIds: options.jobs.getActiveTenantIds,
162
+ }),
163
+ ...(options.eventDispatcher && { eventDispatcher: options.eventDispatcher }),
164
+ });
165
+ entrypoint.lifecycle.registerShutdownHook("workerCacheSync", async () => {
166
+ await cacheSync.close();
167
+ });
168
+ return {
169
+ kind: "booted",
170
+ db,
171
+ redis,
172
+ registry,
173
+ features,
174
+ envSource,
175
+ entrypoint,
176
+ ...(isSecretsContext(extraContext["secrets"]) && { secrets: extraContext["secrets"] }),
177
+ close: async () => {
178
+ await entrypoint.stop();
179
+ await closeDb();
180
+ redis.disconnect();
181
+ },
182
+ };
183
+ }
@@ -0,0 +1,14 @@
1
+ import { type MetricsRouteOptions } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
3
+ import type { ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
4
+ export type WorkerMetricsOptions = MetricsRouteOptions & {
5
+ /** Port of the scrape server. 0 picks a free port (tests). */
6
+ readonly port: number;
7
+ };
8
+ export type WorkerMetricsServer = {
9
+ readonly port: number;
10
+ };
11
+ /** Fails the boot (also under KUMIKO_DRY_RUN_ENV=boot) on a metrics config
12
+ * that would otherwise only surface as a 503 on the first scrape. */
13
+ export declare function assertWorkerMetricsOptions(metrics: WorkerMetricsOptions | undefined, observability: ObservabilityProvider | undefined, processName: string): void;
14
+ export declare function startWorkerMetricsServer(metrics: WorkerMetricsOptions, entrypoint: WorkerEntrypoint): WorkerMetricsServer;
@@ -0,0 +1,25 @@
1
+ // /metrics for HTTP-less worker pods: the worker entrypoint has no Hono app,
2
+ // so scraping needs its own tiny server on a dedicated port.
3
+ import { createMetricsApp, isPrometheusMeter, } from "@cosmicdrift/kumiko-framework/api";
4
+ /** Fails the boot (also under KUMIKO_DRY_RUN_ENV=boot) on a metrics config
5
+ * that would otherwise only surface as a 503 on the first scrape. */
6
+ export function assertWorkerMetricsOptions(metrics, observability, processName) {
7
+ // skip: metrics not configured, nothing to validate
8
+ if (!metrics)
9
+ return;
10
+ if (!Number.isInteger(metrics.port) || metrics.port < 0 || metrics.port > 65535) {
11
+ throw new Error(`[${processName}] metrics.port must be an integer between 0 and 65535, got ${metrics.port}`);
12
+ }
13
+ if (!observability || !isPrometheusMeter(observability.meter)) {
14
+ throw new Error(`[${processName}] metrics is set but observability has no PrometheusMeter — pass observability: { ...createNoopProvider(), meter: createPrometheusMeter() }`);
15
+ }
16
+ }
17
+ export function startWorkerMetricsServer(metrics, entrypoint) {
18
+ const app = createMetricsApp(entrypoint.observability.meter, metrics);
19
+ const server = Bun.serve({ port: metrics.port, fetch: app.fetch });
20
+ entrypoint.lifecycle.registerShutdownHook("metrics-server", async () => {
21
+ await server.stop();
22
+ });
23
+ // Bun.serve with a bound TCP port always reports one.
24
+ return { port: server.port ?? metrics.port };
25
+ }
@@ -36,6 +36,7 @@
36
36
  // <chunk>-<hash>.js ← split chunks
37
37
  // styles-<hash>.css ← Tailwind output
38
38
  // <asset>-<hash>.<ext> ← imported file-loader assets
39
+ // kumiko-bundled-assets/ ← package.json "kumiko.assets", never served over HTTP
39
40
  // manifest.json ← logical → hashed-URL mapping
40
41
  // <public/* 1:1> ← favicon.ico, robots.txt, og-image.png, …
41
42
  //
@@ -53,6 +54,7 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
53
54
  import { isPlainObject, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
54
55
  import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
55
56
  import { Temporal } from "temporal-polyfill";
57
+ import { BUNDLED_ASSETS_DIST_DIR, readBundledAssetDeclarations, resolveBundledAssetSource, } from "./bundled-assets.js";
56
58
  import { RENDERER_WEB_FONT_FILE_PATTERN, RENDERER_WEB_FONTS_DIST_DIR, resolveRendererWebFontsDir, } from "./renderer-web-fonts.js";
57
59
  import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli.js";
58
60
  // Bun-Runtime-Check als module-level Konstante: alle Build-Schritte
@@ -105,6 +107,8 @@ export async function buildProdBundle(options = {}) {
105
107
  const stylesheet = resolveStylesheetEntry(cwd, firstClientSource, options.stylesheet);
106
108
  const publicDir = resolve(cwd, "public");
107
109
  const hasPublicDir = existsSync(publicDir);
110
+ // Validated before the clean step so a bad declaration cannot leave a wiped dist/.
111
+ const bundledAssets = readBundledAssetDeclarations(cwd);
108
112
  if (clientEntries.length === 0 && !hasPublicDir) {
109
113
  throw new Error(`[kumiko build] nothing to build in ${cwd} — expected one of: ` +
110
114
  `package.json "kumiko.clientEntry"/"kumiko.clientEntries", src/client.tsx, public/`);
@@ -143,6 +147,9 @@ export async function buildProdBundle(options = {}) {
143
147
  if (hasPublicDir) {
144
148
  await copyPublicFolder(publicDir, outDir, templateBasenames);
145
149
  }
150
+ // 5a. Declared read-only assets (package.json "kumiko.assets"), copied after
151
+ // public/ so a same-named public file cannot shadow them.
152
+ await copyBundledAssets(cwd, outDir, bundledAssets);
146
153
  // 5b. Build-Identität: Hash über die content-gehashten Asset-URLs +
147
154
  // lesbarer Zeitstempel. Wird in index.html gebacken und als
148
155
  // build-info.json geschrieben (Update-Awareness, default an).
@@ -483,6 +490,20 @@ async function copyRendererWebFonts(cwd, outDir) {
483
490
  }
484
491
  }
485
492
  }
493
+ // Fails the build on a missing source: a runtime readBundledAsset() miss in
494
+ // prod would otherwise only surface on the first request that needs the file.
495
+ async function copyBundledAssets(cwd, outDir, declarations) {
496
+ const target = join(outDir, BUNDLED_ASSETS_DIST_DIR);
497
+ for (const declaration of declarations) {
498
+ const source = resolveBundledAssetSource(cwd, declaration);
499
+ if (!existsSync(source) || !statSync(source).isFile()) {
500
+ throw new Error(`[kumiko build] package.json "kumiko.assets" "${declaration.name}": source file not found ` +
501
+ `at ${source} (declared as "${declaration.source}").`);
502
+ }
503
+ await mkdir(target, { recursive: true });
504
+ await cp(source, join(target, declaration.name));
505
+ }
506
+ }
486
507
  // @internal — exported nur für Unit-Tests.
487
508
  export function discoverHtmlTemplate(cwd) {
488
509
  for (const candidate of ["index.html", "public/index.html"]) {
@@ -0,0 +1,25 @@
1
+ /** dist-relative folder holding the copied assets. The static file server
2
+ * refuses to serve it (see run-prod-app-static-files.ts) — assets are
3
+ * server-side inputs, not public URLs. */
4
+ export declare const BUNDLED_ASSETS_DIST_DIR = "kumiko-bundled-assets";
5
+ /** Allowlist for asset names; also rules out path segments. */
6
+ export declare const BUNDLED_ASSET_NAME_PATTERN: RegExp;
7
+ export type BundledAssetDeclaration = {
8
+ readonly name: string;
9
+ /** Relative to the directory of the declaring package.json. */
10
+ readonly source: string;
11
+ };
12
+ export type BundledAssetOptions = {
13
+ /** App root (the dir holding dist/ and package.json). Default: process.cwd(). */
14
+ readonly cwd?: string;
15
+ };
16
+ export declare function isValidBundledAssetName(name: string): boolean;
17
+ /** Absolute source path of a declaration, contained in `packageDir`. */
18
+ export declare function resolveBundledAssetSource(packageDir: string, declaration: BundledAssetDeclaration): string;
19
+ /** Reads and validates package.json `kumiko.assets`. Missing file or key → [];
20
+ * anything malformed throws, so a typo cannot silently drop a declared asset. */
21
+ export declare function readBundledAssetDeclarations(packageDir: string): readonly BundledAssetDeclaration[];
22
+ /** Absolute path of a declared asset: the built copy in prod, the declared
23
+ * source in dev. Throws for invalid, undeclared or missing assets. */
24
+ export declare function resolveBundledAsset(name: string, opts?: BundledAssetOptions): string;
25
+ export declare function readBundledAsset(name: string, opts?: BundledAssetOptions): Promise<Uint8Array>;
@@ -0,0 +1,117 @@
1
+ // Read-only files an app's own build ships (fonts, templates). Declared in
2
+ // package.json `kumiko.assets: [{ name, source }]`; buildProdBundle copies
3
+ // them into dist/<BUNDLED_ASSETS_DIST_DIR>/, readBundledAsset() finds them in
4
+ // prod (the copy) and in dev (the declared source) behind one name. The one
5
+ // guarded place for this lookup — apps must not wire node:fs for it.
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { readFile } from "node:fs/promises";
8
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
9
+ import { isPlainObject, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
10
+ /** dist-relative folder holding the copied assets. The static file server
11
+ * refuses to serve it (see run-prod-app-static-files.ts) — assets are
12
+ * server-side inputs, not public URLs. */
13
+ export const BUNDLED_ASSETS_DIST_DIR = "kumiko-bundled-assets";
14
+ /** Allowlist for asset names; also rules out path segments. */
15
+ export const BUNDLED_ASSET_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
16
+ export function isValidBundledAssetName(name) {
17
+ return BUNDLED_ASSET_NAME_PATTERN.test(name) && !name.includes("..");
18
+ }
19
+ function assertValidBundledAssetName(name) {
20
+ if (!isValidBundledAssetName(name)) {
21
+ throw new Error(`[kumiko assets] invalid asset name "${name}" — must match ${BUNDLED_ASSET_NAME_PATTERN} ` +
22
+ `and contain no "..".`);
23
+ }
24
+ }
25
+ // Same two-line defense as the storage layer's resolveContainedPath.
26
+ function resolveContained(baseDir, relative, label) {
27
+ const base = resolve(baseDir);
28
+ if (isAbsolute(relative)) {
29
+ throw new Error(`[kumiko assets] ${label} "${relative}" must be relative to the package dir.`);
30
+ }
31
+ const filePath = resolve(join(base, relative));
32
+ if (filePath !== base && !filePath.startsWith(base + sep)) {
33
+ throw new Error(`[kumiko assets] ${label} "${relative}" escapes the package dir ${base}.`);
34
+ }
35
+ return filePath;
36
+ }
37
+ /** Absolute source path of a declaration, contained in `packageDir`. */
38
+ export function resolveBundledAssetSource(packageDir, declaration) {
39
+ return resolveContained(packageDir, declaration.source, `kumiko.assets["${declaration.name}"].source`);
40
+ }
41
+ /** Reads and validates package.json `kumiko.assets`. Missing file or key → [];
42
+ * anything malformed throws, so a typo cannot silently drop a declared asset. */
43
+ export function readBundledAssetDeclarations(packageDir) {
44
+ const pkgJsonPath = resolve(packageDir, "package.json");
45
+ if (!existsSync(pkgJsonPath))
46
+ return [];
47
+ const parsed = parseJsonOrThrow(readFileSync(pkgJsonPath, "utf8"), pkgJsonPath);
48
+ if (!isPlainObject(parsed))
49
+ return [];
50
+ const kumiko = parsed["kumiko"];
51
+ if (!isPlainObject(kumiko) || !("assets" in kumiko))
52
+ return [];
53
+ const value = kumiko["assets"];
54
+ if (!Array.isArray(value)) {
55
+ throw new Error(`[kumiko assets] package.json "kumiko.assets" must be an array.`);
56
+ }
57
+ const seen = new Set();
58
+ return value.map((item, index) => {
59
+ const declaration = parseDeclaration(item, index);
60
+ if (seen.has(declaration.name)) {
61
+ throw new Error(`[kumiko assets] duplicate "kumiko.assets[].name" "${declaration.name}" — names must be unique.`);
62
+ }
63
+ seen.add(declaration.name);
64
+ resolveBundledAssetSource(packageDir, declaration);
65
+ return declaration;
66
+ });
67
+ }
68
+ function parseDeclaration(item, index) {
69
+ if (!isPlainObject(item)) {
70
+ throw new Error(`[kumiko assets] package.json "kumiko.assets[${index}]" must be an object.`);
71
+ }
72
+ const name = item["name"];
73
+ const source = item["source"];
74
+ if (typeof name !== "string") {
75
+ throw new Error(`[kumiko assets] package.json "kumiko.assets[${index}].name" must be a string.`);
76
+ }
77
+ assertValidBundledAssetName(name);
78
+ if (typeof source !== "string" || source.length === 0) {
79
+ throw new Error(`[kumiko assets] package.json "kumiko.assets[${index}].source" must be a non-empty string.`);
80
+ }
81
+ return { name, source };
82
+ }
83
+ function findNearestPackageDir(from) {
84
+ let dir = resolve(from);
85
+ for (;;) {
86
+ if (existsSync(join(dir, "package.json")))
87
+ return dir;
88
+ const parent = dirname(dir);
89
+ if (parent === dir)
90
+ return undefined;
91
+ dir = parent;
92
+ }
93
+ }
94
+ /** Absolute path of a declared asset: the built copy in prod, the declared
95
+ * source in dev. Throws for invalid, undeclared or missing assets. */
96
+ export function resolveBundledAsset(name, opts = {}) {
97
+ assertValidBundledAssetName(name);
98
+ const cwd = resolve(opts.cwd ?? process.cwd());
99
+ const built = resolveContained(join(cwd, "dist", BUNDLED_ASSETS_DIST_DIR), name, "asset name");
100
+ if (existsSync(built))
101
+ return built;
102
+ const packageDir = findNearestPackageDir(cwd);
103
+ const declaration = packageDir
104
+ ? readBundledAssetDeclarations(packageDir).find((d) => d.name === name)
105
+ : undefined;
106
+ if (packageDir === undefined || declaration === undefined) {
107
+ throw new Error(`[kumiko assets] "${name}" is not a built asset and not declared in package.json "kumiko.assets".`);
108
+ }
109
+ const source = resolveBundledAssetSource(packageDir, declaration);
110
+ if (!existsSync(source)) {
111
+ throw new Error(`[kumiko assets] source of "${name}" not found: ${source}`);
112
+ }
113
+ return source;
114
+ }
115
+ export async function readBundledAsset(name, opts = {}) {
116
+ return readFile(resolveBundledAsset(name, opts));
117
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
+ export { type BundledAssetDeclaration, type BundledAssetOptions, readBundledAsset, resolveBundledAsset, } from "./bundled-assets.js";
1
2
  export { type ComposeFeaturesOptions, composeFeatures } from "./compose-features.js";
3
+ export type { RunBootstrapOptions } from "./run-bootstrap.js";
4
+ export { runBootstrap } from "./run-bootstrap.js";
2
5
  export type { AccountUnlockSetup, EmailVerificationSetup, InviteSetup, PageHeadMeta, PageHeadResolver, PageHeadSystemQuery, PasswordResetSetup, ProdAppHandle, ProdSeedFn, RunProdAppAuthOptions, RunProdAppOptions, SignupSetup, } from "./run-prod-app.js";
3
6
  export { requireEnv, runProdApp } from "./run-prod-app.js";
4
7
  export type { RunWorkerAppOptions, WorkerAppHandle, WorkerWireDeps, } from "./run-worker-app.js";
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // runDevApp (kumiko-dev-server), aber ohne Dev-/Scaffold-/Codegen-
3
3
  // Tooling (ts-morph) als Dependency — Prod-Apps ziehen so kein
4
4
  // Dev-Tooling mehr in ihre node_modules.
5
+ export { readBundledAsset, resolveBundledAsset, } from "./bundled-assets.js";
5
6
  export { composeFeatures } from "./compose-features.js";
7
+ export { runBootstrap } from "./run-bootstrap.js";
6
8
  export { requireEnv, runProdApp } from "./run-prod-app.js";
7
9
  export { runWorkerApp } from "./run-worker-app.js";
@@ -0,0 +1,13 @@
1
+ import { type BootstrapPlan, type BootstrapReport } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
+ import { type RunProdAppAuthOptions } from "./run-prod-app.js";
3
+ import type { RunWorkerAppOptions } from "./run-worker-app.js";
4
+ export type RunBootstrapOptions = Omit<RunWorkerAppOptions, "wireComponents" | "includeBundled" | "jobs" | "eventDispatcher" | "metrics"> & BootstrapPlan & {
5
+ /** The same auth block the app passes to runProdApp (`admin` is ignored).
6
+ * Needs a mounted invite flow: `auth.invite`, or `auth.mail` with
7
+ * SMTP_HOST set — invitations are the only way bootstrap grants access. */
8
+ readonly auth: Omit<RunProdAppAuthOptions, "admin">;
9
+ };
10
+ /** Returns the report, or undefined in KUMIKO_DRY_RUN_ENV=boot mode. Without
11
+ * an injected `envSource` (a real process) it exits the process when done,
12
+ * so a k8s Job never hangs on a leftover socket. */
13
+ export declare function runBootstrap(options: RunBootstrapOptions): Promise<BootstrapReport | undefined>;
@@ -0,0 +1,71 @@
1
+ // runBootstrap — one-shot, passwordless first-run provisioning for prod:
2
+ // creates the listed tenants (seed hook only on creation) and invites the
3
+ // listed users; SystemAdmins receive their global role only when they accept
4
+ // the invitation. Idempotent: a second run with the same plan sends nothing,
5
+ // an invitation that expired unused is re-sent, accepted or cancelled ones
6
+ // stay untouched.
7
+ //
8
+ // Boots like runWorkerApp (same env, crypto and schema gate) but composes the
9
+ // auth feature with the app's invite flow, dispatches its writes as the
10
+ // system user, and stops again. Convention: `bin/bootstrap.ts` →
11
+ // `dist-server/bootstrap.js` (see buildServerBundle).
12
+ import { bootstrapTenants, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
13
+ import { bootWorkerProcess, resolveWorkerEnvSource } from "./boot/worker-boot-core.js";
14
+ import { buildComposeAuthOptions } from "./compose-features.js";
15
+ import { requireEnv } from "./run-prod-app.js";
16
+ import { resolveAuthMail } from "./run-prod-app-boot-context.js";
17
+ const PROCESS_NAME = "runBootstrap";
18
+ function maskEmailForLog(email) {
19
+ const at = email.indexOf("@");
20
+ return at <= 0 ? "***" : `${email[0]}***${email.slice(at)}`;
21
+ }
22
+ function logBootstrapReport(report) {
23
+ for (const tenant of report.tenants) {
24
+ // biome-ignore lint/suspicious/noConsole: the report IS the deliverable of a one-shot process
25
+ console.log(`[${PROCESS_NAME}] tenant ${tenant.id}: ${tenant.outcome}${tenant.seeded ? " (seeded)" : ""}${tenant.configApplied.length > 0 ? ` (config: ${tenant.configApplied.join(", ")})` : ""}`);
26
+ }
27
+ for (const invite of report.invites) {
28
+ const globalRoles = invite.globalRoles.length > 0 ? ` +${invite.globalRoles.join(",")}` : "";
29
+ // biome-ignore lint/suspicious/noConsole: the report IS the deliverable of a one-shot process
30
+ console.log(`[${PROCESS_NAME}] invite ${maskEmailForLog(invite.email)} → tenant ${invite.tenantId}${globalRoles}: ${invite.outcome}`);
31
+ }
32
+ }
33
+ /** Returns the report, or undefined in KUMIKO_DRY_RUN_ENV=boot mode. Without
34
+ * an injected `envSource` (a real process) it exits the process when done,
35
+ * so a k8s Job never hangs on a leftover socket. */
36
+ export async function runBootstrap(options) {
37
+ const envSource = await resolveWorkerEnvSource(options, PROCESS_NAME);
38
+ const jwtSecret = requireEnv("JWT_SECRET", envSource, PROCESS_NAME);
39
+ const effectiveAuth = resolveAuthMail(options.auth, jwtSecret, envSource);
40
+ const authOptions = buildComposeAuthOptions(effectiveAuth);
41
+ if (!authOptions?.invite) {
42
+ throw new Error(`[${PROCESS_NAME}] no invite flow mounted — set auth.invite, or auth.mail together with the SMTP_HOST env var`);
43
+ }
44
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
45
+ console.log(`[${PROCESS_NAME}] booting…`);
46
+ const boot = await bootWorkerProcess(options, envSource, {
47
+ processName: PROCESS_NAME,
48
+ authOptions,
49
+ deliverQueuedInline: true,
50
+ });
51
+ if (boot.kind === "dry-run")
52
+ return undefined;
53
+ let report;
54
+ try {
55
+ report = await bootstrapTenants({ db: boot.db, redis: boot.redis, dispatcher: boot.entrypoint.dispatcher }, {
56
+ tenants: options.tenants,
57
+ ...(options.systemAdmins && { systemAdmins: options.systemAdmins }),
58
+ ...(options.seed && { seed: options.seed }),
59
+ });
60
+ }
61
+ finally {
62
+ await boot.close();
63
+ }
64
+ logBootstrapReport(report);
65
+ // biome-ignore lint/suspicious/noConsole: boot-time progress hint
66
+ console.log(`[${PROCESS_NAME}] done.`);
67
+ if (options.envSource === undefined) {
68
+ process.exit(0);
69
+ }
70
+ return report;
71
+ }
@@ -20,6 +20,9 @@ export declare function buildBootExtraContext(opts: {
20
20
  readonly masterKey?: MasterKeyProvider;
21
21
  readonly sseBroker?: SseBroker;
22
22
  readonly kms?: KmsAdapter;
23
+ /** One-shot process (runBootstrap): nothing drains its job queue once it
24
+ * exits, so queued channels must send inline instead of enqueueing. */
25
+ readonly deliverQueuedInline?: boolean;
23
26
  }): Record<string, unknown>;
24
27
  /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
25
28
  * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
@@ -41,27 +41,40 @@ export function addConfigAccessorFactory(resolved, registry) {
41
41
  // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
42
42
  // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
43
43
  // and every notification silently skips. sseBroker optional (email/push don't
44
- // need it, in-app SSE does); no jobRunner → queued channels send inline.
44
+ // need it, in-app SSE does). Queued channels go through the delivery jobs of
45
+ // the calling context's job runner (3rd factory arg); without one they send
46
+ // inline, with `secrets` for chat-channel credentials.
45
47
  function buildDeliveryNotifyFactory(opts) {
46
48
  const deliveryService = createDeliveryService({
47
49
  db: opts.db,
48
50
  registry: opts.registry,
49
51
  channels: collectChannels(opts.registry),
52
+ ...(opts.secrets && { secrets: opts.secrets }),
50
53
  ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
51
54
  ...(opts.escapeHatchAuditSink && { escapeHatchAuditSink: opts.escapeHatchAuditSink }),
52
55
  });
53
- return (user, tenantId) => (notificationType, options) => deliveryService.notify(notificationType, options, user, tenantId);
56
+ return (user, tenantId, jobDispatcher) => (notificationType, options) => deliveryService.notify(notificationType, options, user, tenantId, opts.deliverQueuedInline === true ? undefined : jobDispatcher);
54
57
  }
55
58
  function resolveEscapeHatchAuditSink(features, db) {
56
59
  const hasAuditFeature = features.some((f) => f.name === AUDIT_FEATURE);
57
60
  return hasAuditFeature ? createEscapeHatchAuditSink({ db }) : undefined;
58
61
  }
62
+ function resolveBootSecrets(db, features, crypto, registry) {
63
+ const hasSecretsFeature = features.some((f) => f.name === SECRETS_FEATURE_NAME);
64
+ if (!hasSecretsFeature || !crypto.masterKeyProvider)
65
+ return undefined;
66
+ return createSecretsContext({
67
+ db,
68
+ masterKeyProvider: crypto.masterKeyProvider,
69
+ registry,
70
+ dekCache: crypto.dekCache,
71
+ });
72
+ }
59
73
  export function buildBootExtraContext(opts) {
60
74
  const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
61
- const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
62
- const wireSecrets = hasSecretsFeature && crypto.masterKeyProvider !== undefined;
63
75
  const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
64
76
  const escapeHatchAuditSink = resolveEscapeHatchAuditSink(opts.features, opts.db);
77
+ const secrets = resolveBootSecrets(opts.db, opts.features, crypto, opts.registry);
65
78
  return {
66
79
  templateResolver: createTemplateResolverApi(opts.db),
67
80
  ...(opts.kms && { kms: opts.kms }),
@@ -70,8 +83,10 @@ export function buildBootExtraContext(opts) {
70
83
  _notifyFactory: buildDeliveryNotifyFactory({
71
84
  db: opts.db,
72
85
  registry: opts.registry,
86
+ ...(secrets && { secrets }),
73
87
  ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
74
88
  ...(escapeHatchAuditSink && { escapeHatchAuditSink }),
89
+ deliverQueuedInline: opts.deliverQueuedInline,
75
90
  }),
76
91
  }),
77
92
  // Top-level provider so feature jobs (secrets rotate, config reencrypt)
@@ -81,14 +96,7 @@ export function buildBootExtraContext(opts) {
81
96
  // whenever a master key exists — NOT gated on the secrets feature,
82
97
  // config encryption must work without mounting ctx.secrets.
83
98
  ...(crypto.configCipher && { configEncryption: crypto.configCipher }),
84
- ...(wireSecrets &&
85
- crypto.masterKeyProvider && {
86
- secrets: createSecretsContext({
87
- db: opts.db,
88
- masterKeyProvider: crypto.masterKeyProvider,
89
- dekCache: crypto.dekCache,
90
- }),
91
- }),
99
+ ...(secrets && { secrets }),
92
100
  ...(opts.hasAuth && {
93
101
  configResolver: createConfigResolver({
94
102
  appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
@@ -1,7 +1,9 @@
1
+ import { resolve, sep } from "node:path";
1
2
  import { buildRequestContextDataFromRequest, cachedResponse, computeStrongEtag, computeWeakEtag, createClientIpResolver, requestContext, } from "@cosmicdrift/kumiko-framework/api";
2
3
  import { createAnonymousUser } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-headless/apex";
4
5
  import { ASSETS_DIR } from "./build-prod-bundle.js";
6
+ import { BUNDLED_ASSETS_DIST_DIR } from "./bundled-assets.js";
5
7
  import { stripNoRouteMatchHeader, tryHonoFirst } from "./try-hono-first.js";
6
8
  // Static-asset + SPA-fallback serving for runProdApp's HTTP handler. Split
7
9
  // out of run-prod-app.ts (#1005, Welle 2) — mechanical relocation, these
@@ -45,6 +47,14 @@ export async function readStaticFile(filePath) {
45
47
  throw err;
46
48
  }
47
49
  }
50
+ // Bundled assets are server-side inputs (readBundledAsset), never public URLs.
51
+ // Compared on the resolved path, not the URL prefix: "//kumiko-bundled-assets/x"
52
+ // or a case variant on a case-insensitive disk would slip past a prefix check.
53
+ function isInsideBundledAssetsDir(staticDir, filePath) {
54
+ const bundledDir = resolve(staticDir, BUNDLED_ASSETS_DIST_DIR).toLowerCase();
55
+ const target = resolve(filePath).toLowerCase();
56
+ return target === bundledDir || target.startsWith(bundledDir + sep);
57
+ }
48
58
  export function serveDiskFile(req, pathname, file) {
49
59
  return cachedResponse(req, {
50
60
  // @cast-boundary bun-types — Response BodyInit narrowing
@@ -247,7 +257,9 @@ trustedProxyHops = 0) {
247
257
  if (!isIndexRequest) {
248
258
  const relPath = url.pathname.slice(1);
249
259
  const filePath = `${staticDir}/${relPath}`;
250
- const file = await readStaticFile(filePath);
260
+ const file = isInsideBundledAssetsDir(staticDir, filePath)
261
+ ? undefined
262
+ : await readStaticFile(filePath);
251
263
  if (file) {
252
264
  return serveDiskFile(req, url.pathname, file);
253
265
  }
@@ -78,9 +78,11 @@ export type AuthMailOptions = {
78
78
  readonly hmacSecret?: string;
79
79
  };
80
80
  export type RunProdAppAuthOptions = {
81
- /** Initial admin user. Seeded once (idempotent — re-boots check first
82
- * whether the email is already in the users table). */
83
- readonly admin: SeedAdminOptions;
81
+ /** Initial admin user with a password, seeded once (idempotent — re-boots
82
+ * check first whether the email is already in the users table). Optional:
83
+ * passwordless setups provision their first admins via `runBootstrap`
84
+ * (invitation, global role granted on accept) instead. */
85
+ readonly admin?: SeedAdminOptions;
84
86
  /** Optional override of the login error → HTTP status map. */
85
87
  readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
86
88
  /** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
@@ -329,17 +331,18 @@ export type RunProdAppOptions = {
329
331
  * ist eine Begründung für den Audit-Trail (z.B. "kms rollout pending,
330
332
  * infra#188"). Ohne Flag und ohne `kms` bricht der Boot ab. */
331
333
  readonly allowPlaintextPii?: string;
332
- /** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
333
- * fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
334
- * (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
335
- * worker-Lane-Crons (z.B. der Daten-Export `run-export-jobs`, default
336
- * `runIn:"worker"`) und r.multiStreamProjection ohne separaten Worker.
334
+ /** Deploy topology. Default `true` (single container): this process runs
335
+ * HTTP, both job lanes (api + worker) and the event dispatcher (MSP apply)
336
+ * inline via `createAllInOneEntrypoint`. Worker-lane crons (e.g. the data
337
+ * export `run-export-jobs`, default `runIn:"worker"`), the delivery jobs
338
+ * `delivery.render`/`delivery.send` (worker lane; mail, push and chat
339
+ * delivery) and r.multiStreamProjection then run without a separate worker.
337
340
  *
338
- * `false` NUR mit einem dezidierten Worker-Deployment setzen: dann fährt
339
- * dieser Prozess API-only (`createApiEntrypoint`), und worker-Lane-Jobs
340
- * + MSPs werden NICHT mehr lokal angewandt — der Worker muss sie
341
- * übernehmen, sonst bleiben Export-Jobs pending und die Read-Side leer
342
- * (2026-06-11-Incident-Klasse). */
341
+ * Set `false` ONLY with a dedicated worker deployment: this process then
342
+ * runs API-only (`createApiEntrypoint`) and no longer applies worker-lane
343
+ * jobs or MSPs locally. The worker has to take them over, otherwise export
344
+ * jobs and queued delivery attempts (email/push/chat) stay pending and the
345
+ * read side stays empty (2026-06-11 incident class). */
343
346
  readonly runSingleInstance?: boolean;
344
347
  /** Job-Block. Wenn das Feature `r.job(...)` registriert, wird er
345
348
  * automatisch verdrahtet (siehe runSingleInstance). */
@@ -57,6 +57,7 @@ import { renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
57
57
  import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
58
58
  import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
59
59
  import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
60
+ import { createRedisCacheSyncBus, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
60
61
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
61
62
  import { Redis } from "ioredis";
62
63
  import { applyBootSeeds } from "./boot/apply-boot-seeds.js";
@@ -315,12 +316,18 @@ export async function runProdApp(options) {
315
316
  // effectiveFeatures, build the resolver here (db + registry are
316
317
  // available) before the dispatcher is constructed. App-Author sees
317
318
  // nothing — `createTierEngineFeature(opts)` mounts + framework auto-wires.
319
+ // One bus per process: the tier resolver, the dispatcher and the file-provider
320
+ // resolver all subscribe to it (invalidation across the API pods).
321
+ const cacheSync = createRedisCacheSyncBus({
322
+ redisUrl,
323
+ clientOptions: redisClientOptionsFromEnv(envSource),
324
+ });
318
325
  let resolvedEffectiveFeatures = options.effectiveFeatures;
319
326
  if (resolvedEffectiveFeatures === undefined) {
320
327
  const tierResolverUsage = findTierResolverUsage(features);
321
328
  if (tierResolverUsage) {
322
329
  const plugin = tierResolverUsage.options;
323
- resolvedEffectiveFeatures = await plugin.build({ db, registry });
330
+ resolvedEffectiveFeatures = await plugin.build({ db, registry, cacheSync });
324
331
  }
325
332
  }
326
333
  // 5. Schema-Drift-Gate (drizzle-frei, kumiko/migrations). `kumiko schema
@@ -467,6 +474,7 @@ export async function runProdApp(options) {
467
474
  ...(instanceId && { instanceId }),
468
475
  dispatcherOptions: {
469
476
  idempotency,
477
+ cacheSync,
470
478
  ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
471
479
  },
472
480
  eventDedup,
@@ -600,6 +608,9 @@ export async function runProdApp(options) {
600
608
  // (which already registered the eventDispatcher/jobRunner hooks above),
601
609
  // so it drains LIFO before those, matching the close-before-teardown
602
610
  // order fw#2630 established for buildServer's own hook.
611
+ entrypoint.lifecycle.registerShutdownHook("prodAppCacheSync", async () => {
612
+ await cacheSync.close();
613
+ });
603
614
  if (ownedRedisSseBroker) {
604
615
  const broker = ownedRedisSseBroker;
605
616
  entrypoint.lifecycle.registerShutdownHook("prodAppRedisSseBroker", async () => {
@@ -624,7 +635,7 @@ export async function runProdApp(options) {
624
635
  // "first boot" via flag, every seed-step checks its own
625
636
  // preconditions. Config-seeds rely on a deterministic
626
637
  // aggregate-id so re-boot becomes a version_conflict skip.
627
- if (effectiveAuth) {
638
+ if (effectiveAuth?.admin) {
628
639
  await seedAdminGuarded(db, effectiveAuth.admin);
629
640
  }
630
641
  await applyBootSeeds({
@@ -1,12 +1,13 @@
1
- import { type ServerOptions } from "@cosmicdrift/kumiko-framework/api";
2
- import { type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
3
- import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
- import { type EffectiveFeaturesResolver, type FeatureDefinition, type Registry, type ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
5
- import { type WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
1
+ import type { ServerOptions } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import type { EffectiveFeaturesResolver, FeatureDefinition, Registry, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
5
+ import type { WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
6
6
  import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
7
7
  import type { ObservabilityOptions, ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
8
- import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
9
- import { Redis } from "ioredis";
8
+ import type { MasterKeyProvider, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
9
+ import type { Redis } from "ioredis";
10
+ import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.js";
10
11
  import { type SystemWireDeps } from "./extra-routes-deps.js";
11
12
  export type WorkerContextOption = Record<string, unknown> | ((deps: WorkerDeps) => Record<string, unknown>);
12
13
  export type WorkerDeps = {
@@ -21,6 +22,11 @@ export type WorkerDeps = {
21
22
  * `lifecycle` for `registerShutdownHook`. */
22
23
  export type WorkerWireDeps = SystemWireDeps & {
23
24
  readonly lifecycle: WorkerEntrypoint["lifecycle"];
25
+ /** The worker's ctx.secrets — what `createInboundMailSupervisor` needs as
26
+ * `providerCtx.secrets` (the IMAP provider reads credentials through it,
27
+ * with `_userId` set to a system identity for the audit trail). Undefined
28
+ * when the secrets feature is not mounted or no master key is available. */
29
+ readonly secrets?: SecretsContext;
24
30
  };
25
31
  export type RunWorkerAppOptions = {
26
32
  /** App-specific features — same array as in the API/all-in-one process,
@@ -82,11 +88,17 @@ export type RunWorkerAppOptions = {
82
88
  readonly kmsSlots?: readonly string[];
83
89
  readonly observability?: ObservabilityProvider;
84
90
  readonly observabilityOptions?: ObservabilityOptions;
91
+ /** Serves `/metrics` on its own port (the worker has no HTTP app). Needs a
92
+ * PrometheusMeter-based `observability`; both are validated at boot,
93
+ * including KUMIKO_DRY_RUN_ENV=boot. Same `token`/`path` as runProdApp. */
94
+ readonly metrics?: WorkerMetricsOptions;
85
95
  };
86
96
  export type WorkerAppHandle = {
87
97
  /** In KUMIKO_DRY_RUN_ENV=boot mode with an injected envSource (test
88
98
  * path), no boot ran — this slot is an undefined-cast, do not access. */
89
99
  readonly entrypoint: WorkerEntrypoint;
90
100
  readonly stop: () => Promise<void>;
101
+ /** Set when `metrics` was configured. */
102
+ readonly metricsServer?: WorkerMetricsServer;
91
103
  };
92
104
  export declare function runWorkerApp(options: RunWorkerAppOptions): Promise<WorkerAppHandle>;
@@ -14,23 +14,10 @@
14
14
  //
15
15
  // Container/Coolify sets the same env vars as runProdApp:
16
16
  // DATABASE_URL, REDIS_URL, JWT_SECRET — PORT is not needed.
17
- import { loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
18
- import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
19
- import { configureEntityFieldEncryption, createDbConnection, } from "@cosmicdrift/kumiko-framework/db";
20
- import { createRegistry, findTierResolverUsage, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
21
- import { createWorkerEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
22
- import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
23
- import { createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
24
- import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
25
- import { Redis } from "ioredis";
26
- import { resolveBootCrypto } from "./boot/boot-crypto.js";
27
- import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
28
17
  import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
29
- import { composeFeatures } from "./compose-features.js";
18
+ import { bootWorkerProcess, resolveWorkerEnvSource } from "./boot/worker-boot-core.js";
19
+ import { startWorkerMetricsServer, } from "./boot/worker-metrics-server.js";
30
20
  import { makeDispatchSystemWrite } from "./extra-routes-deps.js";
31
- import { assertPiiBootInvariants } from "./pii-boot-gate.js";
32
- import { requireEnv } from "./run-prod-app.js";
33
- import { addConfigAccessorFactory, buildBootExtraContext } from "./run-prod-app-boot-context.js";
34
21
  function makeBootModeHandle() {
35
22
  return {
36
23
  // @cast-boundary boot-mode: no entrypoint exists because no boot ran —
@@ -40,142 +27,34 @@ function makeBootModeHandle() {
40
27
  };
41
28
  }
42
29
  export async function runWorkerApp(options) {
43
- const rawEnvSource = options.envSource ?? process.env;
44
- const envSource = options.kmsSlots
45
- ? await resolvePlatformKeks(rawEnvSource, {
46
- slots: options.kmsSlots,
47
- logPrefix: "[runWorkerApp]",
48
- })
49
- : rawEnvSource;
50
- // 1. Polyfill before anything else — exactly the bug fw#1725 reports:
51
- // without it, every job in the worker fails with "Temporal is not
52
- // defined" in a retry loop, with no boot-time signal.
53
- const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
54
- await ensureTemporalPolyfill();
55
- // 2. Env vars: fail-fast, same requireEnv as runProdApp. JWT_SECRET is
56
- // validated by loadJwtSecretOrKeyring itself (throws when neither
57
- // JWT_SECRET nor JWT_SECRET_V<n> is set).
58
- const databaseUrl = requireEnv("DATABASE_URL", envSource, "runWorkerApp");
59
- const redisUrl = requireEnv("REDIS_URL", envSource, "runWorkerApp");
60
- const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
30
+ const envSource = await resolveWorkerEnvSource(options, "runWorkerApp");
61
31
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
62
32
  console.log("[runWorkerApp] booting Kumiko worker…");
63
- // 3. Feature registry — identical to runProdApp, MUST be built with the
64
- // same `includeBundled` as the API process.
65
- const features = composeFeatures(options.features, {
66
- includeBundled: !!options.includeBundled,
67
- });
68
- validateBoot(features, { env: envSource, ...options.validateBootOptions });
69
- warnIfNonUtcServerTimeZone();
70
- assertPiiBootInvariants(features, {
71
- kms: options.kms,
72
- blindIndexKey: options.blindIndexKey,
73
- allowPlaintextPii: options.allowPlaintextPii,
74
- mode: "prod",
75
- });
76
- const registry = createRegistry(features);
77
- // Boot-mode exit (parity with runProdApp's C1): validators ran and the
78
- // registry is built, no DB/Redis client was constructed. Makes the boot
79
- // testable without real Postgres/Redis infra.
80
- if (envSource["KUMIKO_DRY_RUN_ENV"] === "boot") {
81
- // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
82
- console.log(`[runWorkerApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`);
83
- if (options.envSource === undefined) {
84
- process.exit(0);
85
- }
33
+ const boot = await bootWorkerProcess(options, envSource, { processName: "runWorkerApp" });
34
+ if (boot.kind === "dry-run")
86
35
  return makeBootModeHandle();
36
+ const { db, redis, registry, entrypoint } = boot;
37
+ // Metrics first: a port clash must fail the boot before start() begins
38
+ // consuming jobs/events. boot.close() runs the "metrics-server" shutdown
39
+ // hook, so a failing start() does not leak the listener.
40
+ let metricsServer;
41
+ try {
42
+ metricsServer = options.metrics
43
+ ? startWorkerMetricsServer(options.metrics, entrypoint)
44
+ : undefined;
45
+ await entrypoint.start();
87
46
  }
88
- // 4. KMS health gate — runs BEFORE the connections, so an abort leaks
89
- // nothing (identical to runProdApp).
90
- if (options.kms) {
91
- const kmsHealth = await options.kms.health();
92
- if (!kmsHealth.ok) {
93
- throw new Error(`[runWorkerApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
94
- }
47
+ catch (error) {
48
+ // The boot failure is the actionable error; a close failure on a
49
+ // half-started worker must not replace it.
50
+ await boot.close().catch(() => { });
51
+ throw error;
95
52
  }
96
- const { db, close: closeDb } = createDbConnection(databaseUrl);
97
- const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
98
- let resolvedEffectiveFeatures = options.effectiveFeatures;
99
- if (resolvedEffectiveFeatures === undefined) {
100
- const tierResolverUsage = findTierResolverUsage(features);
101
- if (tierResolverUsage) {
102
- const plugin = tierResolverUsage.options;
103
- resolvedEffectiveFeatures = await plugin.build({ db, registry });
104
- }
105
- }
106
- // 5. Schema-drift gate — identical to runProdApp.
107
- if (options.migrations !== false) {
108
- const migrationsDir = options.migrations?.dir ?? "./kumiko/migrations";
109
- // biome-ignore lint/suspicious/noConsole: boot-time progress hint
110
- console.log(`[runWorkerApp] checking schema drift (${migrationsDir})…`);
111
- try {
112
- await assertKumikoSchemaCurrent(db, migrationsDir);
113
- }
114
- catch (err) {
115
- if (err instanceof SchemaDriftError) {
116
- // biome-ignore lint/suspicious/noConsole: terminal error message
117
- console.error(`\n[runWorkerApp] BOOT ABORTED — ${err.message}\n`);
118
- }
119
- throw err;
120
- }
121
- }
122
- // 6. Pipeline pieces — same defaults as runProdApp.
123
- const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
124
- const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
125
- const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
126
- // 7. Boot-crypto + extraContext — the core of fw#1725: KMS wiring,
127
- // blind-index, config-encryption must be wired in the worker exactly
128
- // like in the API, otherwise the worker writes rows the API can no
129
- // longer read (different cipher, missing blind index).
130
- const deps = { db, redis, registry };
131
- const resolvedExtraContext = typeof options.extraContext === "function"
132
- ? options.extraContext(deps)
133
- : (options.extraContext ?? {});
134
- const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
135
- configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
136
- configurePiiSubjectKms(options.kms);
137
- configureBlindIndexKey(options.blindIndexKey);
138
- const autoExtraContext = buildBootExtraContext({
139
- db,
140
- features,
141
- envSource,
142
- registry,
143
- hasAuth: !!options.includeBundled,
144
- crypto: bootCrypto,
145
- ...(options.kms && { kms: options.kms }),
146
- });
147
- const extraContext = addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry);
148
- const jobLogger = jobRunLoggerCallbacks(registry, db);
149
- const entrypoint = createWorkerEntrypoint({
150
- registry,
151
- context: { db, redis, entityCache, registry, ...extraContext },
152
- jwtSecret: jwtSecretOrKeyring,
153
- dispatcherOptions: {
154
- idempotency,
155
- ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
156
- },
157
- eventDedup,
158
- ...(options.observability && { observability: options.observability }),
159
- ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
160
- redisUrl,
161
- ...jobLogger,
162
- ...(options.jobs?.queueNamePrefix !== undefined && {
163
- queueNamePrefix: options.jobs.queueNamePrefix,
164
- }),
165
- ...(options.jobs?.getActiveTenantIds !== undefined && {
166
- getActiveTenantIds: options.jobs.getActiveTenantIds,
167
- }),
168
- ...(options.eventDispatcher && { eventDispatcher: options.eventDispatcher }),
169
- });
170
53
  const handle = {
171
54
  entrypoint,
172
- stop: async () => {
173
- await entrypoint.stop();
174
- await closeDb();
175
- redis.disconnect();
176
- },
55
+ stop: boot.close,
56
+ ...(metricsServer && { metricsServer }),
177
57
  };
178
- await entrypoint.start();
179
58
  startPiiEventBackfillOnBoot({
180
59
  db,
181
60
  registry,
@@ -189,6 +68,7 @@ export async function runWorkerApp(options) {
189
68
  registry,
190
69
  dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
191
70
  lifecycle: entrypoint.lifecycle,
71
+ ...(boot.secrets && { secrets: boot.secrets }),
192
72
  });
193
73
  }
194
74
  let shuttingDown = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.340.0",
3
+ "version": "0.341.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -80,10 +80,10 @@
80
80
  }
81
81
  },
82
82
  "dependencies": {
83
- "@cosmicdrift/kumiko-bundled-features": "0.340.0",
84
- "@cosmicdrift/kumiko-framework": "0.340.0",
85
- "@cosmicdrift/kumiko-headless": "0.340.0",
86
- "@cosmicdrift/kumiko-renderer-web": "0.340.0",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.341.0",
84
+ "@cosmicdrift/kumiko-framework": "0.341.0",
85
+ "@cosmicdrift/kumiko-headless": "0.341.0",
86
+ "@cosmicdrift/kumiko-renderer-web": "0.341.0",
87
87
  "bun-types": "^1.3.13",
88
88
  "ioredis": "^5.10.1",
89
89
  "temporal-polyfill": "^0.3.2"
package/src/changes.json CHANGED
@@ -1,4 +1,27 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "improvement",
5
+ "title": "runBootstrap() for one-shot passwordless prod provisioning; runProdApp auth.admin is optional",
6
+ "detail": "Creates tenants (seed hook only on creation) and mails invitations; reruns send nothing, expired unused invitations are re-sent. Convention bin/bootstrap.ts -> dist-server/bootstrap.js."
7
+ },
8
+ {
9
+ "version": "0.341.0",
10
+ "type": "improvement",
11
+ "title": "runWorkerApp: secrets in wireComponents and a metrics port",
12
+ "detail": "WorkerWireDeps carries ctx.secrets (for createInboundMailSupervisor), and the new metrics option serves /metrics on its own port; KUMIKO_DRY_RUN_ENV=boot validates it."
13
+ },
14
+ {
15
+ "version": "0.341.0",
16
+ "type": "fix",
17
+ "title": "Worker metrics server starts before entrypoint.start() and is closed if boot fails"
18
+ },
19
+ {
20
+ "version": "0.341.0",
21
+ "type": "improvement",
22
+ "title": "Worker picks up tenant timezone changes made on API pods",
23
+ "detail": "The worker boot now joins the cache sync bus, so a timezone write on an API pod invalidates the worker's cached tenant timezone instead of leaving jobs on the old value until the TTL expires."
24
+ },
2
25
  {
3
26
  "version": "0.339.0",
4
27
  "type": "fix",