@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 +18 -0
- package/dist/boot/worker-boot-core.d.ts +36 -0
- package/dist/boot/worker-boot-core.js +183 -0
- package/dist/boot/worker-metrics-server.d.ts +14 -0
- package/dist/boot/worker-metrics-server.js +25 -0
- package/dist/build-prod-bundle.js +21 -0
- package/dist/bundled-assets.d.ts +25 -0
- package/dist/bundled-assets.js +117 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/run-bootstrap.d.ts +13 -0
- package/dist/run-bootstrap.js +71 -0
- package/dist/run-prod-app-boot-context.d.ts +3 -0
- package/dist/run-prod-app-boot-context.js +20 -12
- package/dist/run-prod-app-static-files.js +13 -1
- package/dist/run-prod-app.d.ts +16 -13
- package/dist/run-prod-app.js +13 -2
- package/dist/run-worker-app.d.ts +19 -7
- package/dist/run-worker-app.js +23 -143
- package/package.json +5 -5
- package/src/changes.json +23 -0
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)
|
|
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
|
-
...(
|
|
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 =
|
|
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
|
}
|
package/dist/run-prod-app.d.ts
CHANGED
|
@@ -78,9 +78,11 @@ export type AuthMailOptions = {
|
|
|
78
78
|
readonly hmacSecret?: string;
|
|
79
79
|
};
|
|
80
80
|
export type RunProdAppAuthOptions = {
|
|
81
|
-
/** Initial admin user
|
|
82
|
-
* whether the email is already in the users table).
|
|
83
|
-
|
|
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
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
* `
|
|
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`
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
* (2026-06-11
|
|
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). */
|
package/dist/run-prod-app.js
CHANGED
|
@@ -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({
|
package/dist/run-worker-app.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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>;
|
package/dist/run-worker-app.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
|
173
|
-
|
|
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.
|
|
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.
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
85
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
86
|
-
"@cosmicdrift/kumiko-renderer-web": "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",
|