@cosmicdrift/kumiko-server-runtime 0.328.1 → 0.330.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 +15 -0
- package/dist/boot/apply-boot-seeds.d.ts +8 -0
- package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
- package/dist/boot/boot-crypto.d.ts +9 -0
- package/dist/boot/boot-crypto.js +39 -0
- package/dist/boot/job-run-logger.d.ts +19 -0
- package/dist/boot/job-run-logger.js +44 -0
- package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
- package/dist/boot/pii-event-backfill-on-boot.js +25 -0
- package/dist/build-prod-bundle.d.ts +103 -0
- package/dist/build-prod-bundle.js +751 -0
- package/dist/bun-serve-options.d.ts +32 -0
- package/dist/bun-serve-options.js +49 -0
- package/dist/compose-features.d.ts +31 -0
- package/dist/compose-features.js +124 -0
- package/dist/extra-routes-deps.d.ts +30 -0
- package/dist/extra-routes-deps.js +4 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/pii-boot-gate.d.ts +13 -0
- package/dist/pii-boot-gate.js +46 -0
- package/dist/renderer-web-fonts.d.ts +9 -0
- package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
- package/dist/resolve-tailwind-cli.d.ts +14 -0
- package/dist/resolve-tailwind-cli.js +36 -0
- package/dist/run-prod-app-boot-context.d.ts +43 -0
- package/dist/run-prod-app-boot-context.js +198 -0
- package/dist/run-prod-app-static-files.d.ts +32 -0
- package/dist/run-prod-app-static-files.js +280 -0
- package/dist/run-prod-app.d.ts +471 -0
- package/dist/run-prod-app.js +741 -0
- package/dist/run-worker-app.d.ts +92 -0
- package/dist/run-worker-app.js +220 -0
- package/dist/security-headers.d.ts +20 -0
- package/dist/security-headers.js +49 -0
- package/dist/session-boot-gate.d.ts +11 -0
- package/dist/session-boot-gate.js +22 -0
- package/dist/session-wiring.d.ts +11 -0
- package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
- package/dist/try-hono-first.d.ts +24 -0
- package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
- package/package.json +102 -36
- package/src/changes.json +6 -0
- package/src/__tests__/boot-extra-context.test.ts +0 -163
- package/src/__tests__/boot-probe-fixture.ts +0 -64
- package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
- package/src/__tests__/build-prod-bundle.test.ts +0 -724
- package/src/__tests__/cache-headers.test.ts +0 -83
- package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
- package/src/__tests__/compose-features-wiring.integration.test.ts +0 -609
- package/src/__tests__/compose-features.test.ts +0 -206
- package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
- package/src/__tests__/pii-boot-gate.test.ts +0 -112
- package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
- package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
- package/src/__tests__/renderer-web-theme-plugin.integration.test.ts +0 -77
- package/src/__tests__/require-env.test.ts +0 -34
- package/src/__tests__/resolve-auth-mail.test.ts +0 -144
- package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
- package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
- package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
- package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
- package/src/__tests__/run-prod-app-spec.test.ts +0 -167
- package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
- package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
- package/src/__tests__/run-worker-app-boot.test.ts +0 -183
- package/src/__tests__/run-worker-app.integration.test.ts +0 -218
- package/src/__tests__/security-headers.test.ts +0 -88
- package/src/__tests__/session-boot-gate.test.ts +0 -70
- package/src/__tests__/session-wiring.test.ts +0 -16
- package/src/__tests__/try-hono-first.test.ts +0 -108
- package/src/__tests__/websocket-route.integration.test.ts +0 -580
- package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
- package/src/boot/__tests__/job-run-logger.test.ts +0 -26
- package/src/boot/boot-crypto.ts +0 -67
- package/src/boot/job-run-logger.ts +0 -63
- package/src/boot/pii-event-backfill-on-boot.ts +0 -43
- package/src/build-prod-bundle.ts +0 -996
- package/src/bun-serve-options.ts +0 -110
- package/src/compose-features.ts +0 -197
- package/src/extra-routes-deps.ts +0 -38
- package/src/index.ts +0 -27
- package/src/pii-boot-gate.ts +0 -80
- package/src/resolve-tailwind-cli.ts +0 -45
- package/src/run-prod-app-boot-context.ts +0 -319
- package/src/run-prod-app-static-files.ts +0 -359
- package/src/run-prod-app.ts +0 -1420
- package/src/run-worker-app.ts +0 -366
- package/src/security-headers.ts +0 -67
- package/src/session-boot-gate.ts +0 -34
|
@@ -0,0 +1,92 @@
|
|
|
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";
|
|
6
|
+
import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
|
|
7
|
+
import type { ObservabilityOptions, ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
|
|
8
|
+
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
9
|
+
import { Redis } from "ioredis";
|
|
10
|
+
import { type SystemWireDeps } from "./extra-routes-deps.js";
|
|
11
|
+
export type WorkerContextOption = Record<string, unknown> | ((deps: WorkerDeps) => Record<string, unknown>);
|
|
12
|
+
export type WorkerDeps = {
|
|
13
|
+
readonly db: DbConnection;
|
|
14
|
+
readonly redis: Redis;
|
|
15
|
+
readonly registry: Registry;
|
|
16
|
+
};
|
|
17
|
+
/** Deps for the `wireComponents` hook — app-wired co-running components
|
|
18
|
+
* (an analysis runner, an IMAP supervisor, ...) that need the system-
|
|
19
|
+
* write dispatcher and register their own shutdown hooks on the worker
|
|
20
|
+
* lifecycle. Same shape as the `wire` hook's SystemWireDeps, plus
|
|
21
|
+
* `lifecycle` for `registerShutdownHook`. */
|
|
22
|
+
export type WorkerWireDeps = SystemWireDeps & {
|
|
23
|
+
readonly lifecycle: WorkerEntrypoint["lifecycle"];
|
|
24
|
+
};
|
|
25
|
+
export type RunWorkerAppOptions = {
|
|
26
|
+
/** App-specific features — same array as in the API/all-in-one process,
|
|
27
|
+
* so the registry + schema stay identical across processes. */
|
|
28
|
+
readonly features: readonly FeatureDefinition[];
|
|
29
|
+
/** Opt-in boot-validator warnings — see ValidateBootOptions. */
|
|
30
|
+
readonly validateBootOptions?: ValidateBootOptions;
|
|
31
|
+
/** Mount the auto-mixed config/user/tenant/auth-email-password features —
|
|
32
|
+
* MUST match the API process's `includeBundled` value, otherwise API
|
|
33
|
+
* and worker run with a diverging registry topology. Also controls
|
|
34
|
+
* whether buildBootExtraContext auto-wires `configResolver` (same
|
|
35
|
+
* auth-mode gate as runProdApp). */
|
|
36
|
+
readonly includeBundled?: boolean;
|
|
37
|
+
/** Path to kumiko/migrations for the boot gate. See RunProdAppOptions
|
|
38
|
+
* ["migrations"] — identical semantics. */
|
|
39
|
+
readonly migrations?: {
|
|
40
|
+
readonly dir: string;
|
|
41
|
+
} | false;
|
|
42
|
+
/** Extra AppContext keys — same factory-union pattern as
|
|
43
|
+
* RunProdAppOptions["extraContext"], without sseBroker (the worker
|
|
44
|
+
* has none — see entrypoint/index.ts's documented SSE limitation). */
|
|
45
|
+
readonly extraContext?: WorkerContextOption;
|
|
46
|
+
/** MasterKeyProvider for ctx.secrets. Default: env-KEK (see
|
|
47
|
+
* RunProdAppOptions["masterKey"]). */
|
|
48
|
+
readonly masterKey?: MasterKeyProvider;
|
|
49
|
+
/** Subject-key adapter for crypto-shredding — boot checks health()
|
|
50
|
+
* before any connection (see RunProdAppOptions["kms"]). */
|
|
51
|
+
readonly kms?: KmsAdapter;
|
|
52
|
+
/** Blind-index key for lookupable fields (see
|
|
53
|
+
* RunProdAppOptions["blindIndexKey"]). */
|
|
54
|
+
readonly blindIndexKey?: string;
|
|
55
|
+
/** Explicit opt-out from the PII boot gate (see
|
|
56
|
+
* RunProdAppOptions["allowPlaintextPii"]). */
|
|
57
|
+
readonly allowPlaintextPii?: string;
|
|
58
|
+
readonly jobs?: {
|
|
59
|
+
readonly queueNamePrefix?: string;
|
|
60
|
+
/** Source of active tenant ids for `perTenant: true` jobs (see
|
|
61
|
+
* RunProdAppOptions["jobs"]["getActiveTenantIds"]) — identical
|
|
62
|
+
* semantics, framework falls back to the tenant feature's own
|
|
63
|
+
* active-tenant-ids query when omitted. */
|
|
64
|
+
readonly getActiveTenantIds?: JobRunnerOptions["getActiveTenantIds"];
|
|
65
|
+
};
|
|
66
|
+
/** Tuning knobs for the event-dispatcher loop (pollIntervalMs, pgClient
|
|
67
|
+
* for LISTEN/NOTIFY). */
|
|
68
|
+
readonly eventDispatcher?: ServerOptions["eventDispatcher"];
|
|
69
|
+
/** Hook for app-wired co-running components that need the system-write
|
|
70
|
+
* dispatcher (e.g. an analysis runner, an IMAP supervisor). Runs AFTER
|
|
71
|
+
* `entrypoint.start()` — the hook itself is responsible for starting
|
|
72
|
+
* its component and registering a shutdown hook on `lifecycle`. */
|
|
73
|
+
readonly wireComponents?: (deps: WorkerWireDeps) => Promise<void> | void;
|
|
74
|
+
/** Feature-toggle resolver (see RunProdAppOptions["effectiveFeatures"]). */
|
|
75
|
+
readonly effectiveFeatures?: EffectiveFeaturesResolver;
|
|
76
|
+
/** Override `process.env` for env-validation (see
|
|
77
|
+
* RunProdAppOptions["envSource"]). */
|
|
78
|
+
readonly envSource?: Record<string, string | undefined>;
|
|
79
|
+
/** Env names to decrypt from their `_CIPHERTEXT` twin at boot, typically
|
|
80
|
+
* `kmsSlotsOf(composedEnv.schema)`. The worker parses no env schema, so it
|
|
81
|
+
* cannot derive them; without this it reads the env as is. */
|
|
82
|
+
readonly kmsSlots?: readonly string[];
|
|
83
|
+
readonly observability?: ObservabilityProvider;
|
|
84
|
+
readonly observabilityOptions?: ObservabilityOptions;
|
|
85
|
+
};
|
|
86
|
+
export type WorkerAppHandle = {
|
|
87
|
+
/** In KUMIKO_DRY_RUN_ENV=boot mode with an injected envSource (test
|
|
88
|
+
* path), no boot ran — this slot is an undefined-cast, do not access. */
|
|
89
|
+
readonly entrypoint: WorkerEntrypoint;
|
|
90
|
+
readonly stop: () => Promise<void>;
|
|
91
|
+
};
|
|
92
|
+
export declare function runWorkerApp(options: RunWorkerAppOptions): Promise<WorkerAppHandle>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// runWorkerApp — production-grade bootstrap wrapper for a dedicated
|
|
2
|
+
// Kumiko worker process. Symmetric to runProdApp, but without HTTP: no
|
|
3
|
+
// Hono app, no auth routes, no SSE broker, no seeds. Shares the boot core
|
|
4
|
+
// with runProdApp (env fail-fast, Temporal polyfill, composeFeatures/
|
|
5
|
+
// registry, PII invariants, KMS health gate, connections, schema-drift
|
|
6
|
+
// gate, boot-crypto, extraContext) — see fw#1725: before this function,
|
|
7
|
+
// every app deploying a worker rebuilt this boot by hand (solon#42), and
|
|
8
|
+
// every deviation was silent: without `ensureTemporalPolyfill`, every job
|
|
9
|
+
// in the worker fails with "Temporal is not defined" in a retry loop,
|
|
10
|
+
// with no boot-time signal that anything is wrong.
|
|
11
|
+
//
|
|
12
|
+
// App-author writes:
|
|
13
|
+
// await runWorkerApp({ features, wireComponents: async (deps) => {...} });
|
|
14
|
+
//
|
|
15
|
+
// Container/Coolify sets the same env vars as runProdApp:
|
|
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
|
+
import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
|
|
29
|
+
import { composeFeatures } from "./compose-features.js";
|
|
30
|
+
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
|
+
function makeBootModeHandle() {
|
|
35
|
+
return {
|
|
36
|
+
// @cast-boundary boot-mode: no entrypoint exists because no boot ran —
|
|
37
|
+
// callers on this path never read this slot.
|
|
38
|
+
entrypoint: undefined,
|
|
39
|
+
stop: async () => { },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
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);
|
|
61
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
62
|
+
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
|
+
}
|
|
86
|
+
return makeBootModeHandle();
|
|
87
|
+
}
|
|
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
|
+
}
|
|
95
|
+
}
|
|
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
|
+
const handle = {
|
|
171
|
+
entrypoint,
|
|
172
|
+
stop: async () => {
|
|
173
|
+
await entrypoint.stop();
|
|
174
|
+
await closeDb();
|
|
175
|
+
redis.disconnect();
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
await entrypoint.start();
|
|
179
|
+
startPiiEventBackfillOnBoot({
|
|
180
|
+
db,
|
|
181
|
+
registry,
|
|
182
|
+
lifecycle: entrypoint.lifecycle,
|
|
183
|
+
envSource,
|
|
184
|
+
});
|
|
185
|
+
if (options.wireComponents) {
|
|
186
|
+
await options.wireComponents({
|
|
187
|
+
db,
|
|
188
|
+
redis,
|
|
189
|
+
registry,
|
|
190
|
+
dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
|
|
191
|
+
lifecycle: entrypoint.lifecycle,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
let shuttingDown = false;
|
|
195
|
+
const shutdown = async (signal) => {
|
|
196
|
+
// skip: shutdown already in progress, avoid double-drain
|
|
197
|
+
if (shuttingDown)
|
|
198
|
+
return;
|
|
199
|
+
shuttingDown = true;
|
|
200
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
201
|
+
console.log(`[runWorkerApp] ${signal} received — draining…`);
|
|
202
|
+
try {
|
|
203
|
+
await handle.stop();
|
|
204
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
205
|
+
console.log("[runWorkerApp] graceful shutdown complete.");
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
// biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
|
|
209
|
+
console.error("[runWorkerApp] error during shutdown:", e);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
216
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
217
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
218
|
+
console.log("[runWorkerApp] ready — worker running.");
|
|
219
|
+
return handle;
|
|
220
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type SecurityHeadersOption = false | {
|
|
2
|
+
/** `Strict-Transport-Security` value, or `false` to omit.
|
|
3
|
+
* Default: `max-age=31536000; includeSubDomains`. */
|
|
4
|
+
readonly hsts?: string | false;
|
|
5
|
+
/** `X-Frame-Options` value, or `false` to omit. Default: `DENY`.
|
|
6
|
+
* Apps that must be embeddable (iframe widgets) set `false` and
|
|
7
|
+
* scope framing via a `csp` frame-ancestors directive instead. */
|
|
8
|
+
readonly frameOptions?: string | false;
|
|
9
|
+
/** `false` omits `X-Content-Type-Options: nosniff`. */
|
|
10
|
+
readonly contentTypeOptions?: false;
|
|
11
|
+
/** `Referrer-Policy` value, or `false` to omit.
|
|
12
|
+
* Default: `strict-origin-when-cross-origin`. */
|
|
13
|
+
readonly referrerPolicy?: string | false;
|
|
14
|
+
/** `Content-Security-Policy` default for ALL responses. No built-in
|
|
15
|
+
* default — a wrong CSP breaks app assets, so it stays opt-in.
|
|
16
|
+
* A per-host CSP from `hostDispatch` wins over this value. */
|
|
17
|
+
readonly csp?: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function resolveSecurityHeaders(option: SecurityHeadersOption | undefined): ReadonlyArray<readonly [string, string]>;
|
|
20
|
+
export declare function withSecurityHeaders<TServeArg = string>(handler: (req: Request, serveArg?: TServeArg) => Response | Promise<Response>, option: SecurityHeadersOption | undefined): (req: Request, serveArg?: TServeArg) => Response | Promise<Response>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const DEFAULT_HSTS = "max-age=31536000; includeSubDomains";
|
|
2
|
+
const DEFAULT_FRAME_OPTIONS = "DENY";
|
|
3
|
+
const DEFAULT_REFERRER_POLICY = "strict-origin-when-cross-origin";
|
|
4
|
+
export function resolveSecurityHeaders(option) {
|
|
5
|
+
if (option === false)
|
|
6
|
+
return [];
|
|
7
|
+
const opt = option ?? {};
|
|
8
|
+
const headers = [];
|
|
9
|
+
const hsts = opt.hsts ?? DEFAULT_HSTS;
|
|
10
|
+
if (hsts !== false)
|
|
11
|
+
headers.push(["strict-transport-security", hsts]);
|
|
12
|
+
const frameOptions = opt.frameOptions ?? DEFAULT_FRAME_OPTIONS;
|
|
13
|
+
if (frameOptions !== false)
|
|
14
|
+
headers.push(["x-frame-options", frameOptions]);
|
|
15
|
+
if (opt.contentTypeOptions !== false)
|
|
16
|
+
headers.push(["x-content-type-options", "nosniff"]);
|
|
17
|
+
const referrerPolicy = opt.referrerPolicy ?? DEFAULT_REFERRER_POLICY;
|
|
18
|
+
if (referrerPolicy !== false)
|
|
19
|
+
headers.push(["referrer-policy", referrerPolicy]);
|
|
20
|
+
if (opt.csp)
|
|
21
|
+
headers.push(["content-security-policy", opt.csp]);
|
|
22
|
+
return headers;
|
|
23
|
+
}
|
|
24
|
+
// Sets each header only when absent so per-response values (e.g. the
|
|
25
|
+
// per-host CSP from hostDispatch) always win over the runtime default.
|
|
26
|
+
export function withSecurityHeaders(handler, option) {
|
|
27
|
+
const defaults = resolveSecurityHeaders(option);
|
|
28
|
+
if (defaults.length === 0)
|
|
29
|
+
return handler;
|
|
30
|
+
return async (req, serveArg) => {
|
|
31
|
+
const res = await handler(req, serveArg);
|
|
32
|
+
try {
|
|
33
|
+
for (const [name, value] of defaults) {
|
|
34
|
+
if (!res.headers.has(name))
|
|
35
|
+
res.headers.set(name, value);
|
|
36
|
+
}
|
|
37
|
+
return res;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Immutable-headers Response (e.g. proxied fetch) — re-wrap.
|
|
41
|
+
const headers = new Headers(res.headers);
|
|
42
|
+
for (const [name, value] of defaults) {
|
|
43
|
+
if (!headers.has(name))
|
|
44
|
+
headers.set(name, value);
|
|
45
|
+
}
|
|
46
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type SessionBootGateOptions = {
|
|
2
|
+
readonly hasAuth: boolean;
|
|
3
|
+
readonly sessionStoreProviderMounted: boolean;
|
|
4
|
+
/** prod fails hard on a missing sessionStore provider (stateless JWTs would
|
|
5
|
+
* silently skip server-side revocation and any session-list UI stays
|
|
6
|
+
* empty); dev only warns — a forgotten sessions mount in a local sample
|
|
7
|
+
* app shouldn't crash the dev-server boot, but it must not stay silent
|
|
8
|
+
* either (#2027 — dev previously had no gate here at all). */
|
|
9
|
+
readonly mode: "prod" | "dev";
|
|
10
|
+
};
|
|
11
|
+
export declare function assertSessionBootInvariants(opts: SessionBootGateOptions): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Catch a forgotten sessions mount at boot instead of silently degrading
|
|
2
|
+
// into stateless JWTs (#1372). Mount createSessionsFeature() for revocable
|
|
3
|
+
// sessions; there is no auth.sessions opt-out anymore.
|
|
4
|
+
export function assertSessionBootInvariants(opts) {
|
|
5
|
+
// skip: no auth mounted — nothing to gate.
|
|
6
|
+
if (!opts.hasAuth)
|
|
7
|
+
return;
|
|
8
|
+
// skip: sessionStore provider is wired (sessions feature).
|
|
9
|
+
if (opts.sessionStoreProviderMounted)
|
|
10
|
+
return;
|
|
11
|
+
const tag = opts.mode === "prod" ? "runProdApp" : "runDevApp";
|
|
12
|
+
const message = "auth is mounted but no sessionStore provider is registered. JWTs would be stateless " +
|
|
13
|
+
"(no server-side revocation) and any session-list screen stays empty. Mount " +
|
|
14
|
+
"createSessionsFeature() (@cosmicdrift/kumiko-bundled-features/sessions) alongside auth-foundation.";
|
|
15
|
+
if (opts.mode === "dev") {
|
|
16
|
+
// biome-ignore lint/suspicious/noConsole: boot-time ops warning, no logger configured this early
|
|
17
|
+
console.warn(`[${tag}] ${message}`);
|
|
18
|
+
// skip: dev mode, warning already logged above — never abort dev boot.
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`[${tag}] BOOT ABORTED — ${message}`);
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runProdApp session-wiring decision (extracted pure so it is testable without a
|
|
3
|
+
* full prod boot).
|
|
4
|
+
*
|
|
5
|
+
* Secure-by-default (#1372): mounting a sessionStore provider (sessions feature)
|
|
6
|
+
* turns server-side session revocation ON automatically. There is no
|
|
7
|
+
* `auth.sessions` opt-in/opt-out — mount sessions for revocable JWTs, omit it
|
|
8
|
+
* for intentional stateless JWTs (boot-gate warns / aborts unless acknowledged
|
|
9
|
+
* via a future flag if needed; today auth without sessions fails the gate).
|
|
10
|
+
*/
|
|
11
|
+
export declare function shouldWireProdSessions(hasAuth: boolean, sessionStoreProviderMounted: boolean): boolean;
|
|
@@ -8,10 +8,6 @@
|
|
|
8
8
|
* for intentional stateless JWTs (boot-gate warns / aborts unless acknowledged
|
|
9
9
|
* via a future flag if needed; today auth without sessions fails the gate).
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
hasAuth: boolean,
|
|
14
|
-
sessionStoreProviderMounted: boolean,
|
|
15
|
-
): boolean {
|
|
16
|
-
return hasAuth && sessionStoreProviderMounted;
|
|
11
|
+
export function shouldWireProdSessions(hasAuth, sessionStoreProviderMounted) {
|
|
12
|
+
return hasAuth && sessionStoreProviderMounted;
|
|
17
13
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type HonoLikeApp = {
|
|
2
|
+
readonly fetch: (req: Request, socketAddress?: string) => Response | Promise<Response>;
|
|
3
|
+
};
|
|
4
|
+
export type HonoFirstResult = {
|
|
5
|
+
/** True when Hono has a matching route (no router-miss).
|
|
6
|
+
* Caller returns the response directly.
|
|
7
|
+
* False when no route matches (router-miss, detected via 404 +
|
|
8
|
+
* NO_ROUTE_MATCH_HEADER_NAME). Caller does the SPA/static fallback;
|
|
9
|
+
* the response still carries the 404 as a final fallback in case the
|
|
10
|
+
* SPA path doesn't deliver anything either. */
|
|
11
|
+
readonly matched: boolean;
|
|
12
|
+
readonly response: Response;
|
|
13
|
+
};
|
|
14
|
+
export declare function stripNoRouteMatchHeader(response: Response): Response;
|
|
15
|
+
/**
|
|
16
|
+
* Hono-first try: app.fetch FIRST. If matched (no router-miss), the caller
|
|
17
|
+
* returns the response directly. If not matched, the caller falls back to
|
|
18
|
+
* its own SPA/static fallback — the response (404) stays available as a
|
|
19
|
+
* last-resort safety net.
|
|
20
|
+
*
|
|
21
|
+
* req.clone() because downstream needs to read the request body again
|
|
22
|
+
* (future-proofing for POST/PUT/PATCH — only GET routes today).
|
|
23
|
+
*/
|
|
24
|
+
export declare function tryHonoFirst(app: HonoLikeApp, req: Request, socketAddress?: string): Promise<HonoFirstResult>;
|
|
@@ -27,41 +27,17 @@
|
|
|
27
27
|
//
|
|
28
28
|
// req.clone() because downstream needs to read the request body again
|
|
29
29
|
// (future-proofing for POST/PUT/PATCH — only GET routes today).
|
|
30
|
-
|
|
31
30
|
import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
32
|
-
|
|
33
|
-
export type HonoLikeApp = {
|
|
34
|
-
// Hono.app.fetch is `(req, env?) => Response | Promise<Response>` (sync if
|
|
35
|
-
// all handlers are sync, otherwise a Promise). createApiEntrypoint's
|
|
36
|
-
// apiHandler matches the same shape. The union accepts both — we await
|
|
37
|
-
// below, which works for either case. `socketAddress` rides in as `env`
|
|
38
|
-
// so requestIdMiddleware/rate-limit can read it via `c.env` without Hono
|
|
39
|
-
// ever seeing a raw Bun `Server`.
|
|
40
|
-
readonly fetch: (req: Request, socketAddress?: string) => Response | Promise<Response>;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export type HonoFirstResult = {
|
|
44
|
-
/** True when Hono has a matching route (no router-miss).
|
|
45
|
-
* Caller returns the response directly.
|
|
46
|
-
* False when no route matches (router-miss, detected via 404 +
|
|
47
|
-
* NO_ROUTE_MATCH_HEADER_NAME). Caller does the SPA/static fallback;
|
|
48
|
-
* the response still carries the 404 as a final fallback in case the
|
|
49
|
-
* SPA path doesn't deliver anything either. */
|
|
50
|
-
readonly matched: boolean;
|
|
51
|
-
readonly response: Response;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
31
|
// Removes the internal router-miss marker from a response before it leaves
|
|
55
32
|
// the process. Needed at EVERY site that potentially passes app.fetch()'s
|
|
56
33
|
// response through to a real client — including the passthrough paths that
|
|
57
34
|
// never call tryHonoFirst at all (e.g. /api/* in runProdApp, dotted-paths/
|
|
58
35
|
// non-GET in the dev server). No no-op check needed: Headers.delete() on a
|
|
59
36
|
// missing key is harmless.
|
|
60
|
-
export function stripNoRouteMatchHeader(response
|
|
61
|
-
|
|
62
|
-
|
|
37
|
+
export function stripNoRouteMatchHeader(response) {
|
|
38
|
+
response.headers.delete(NO_ROUTE_MATCH_HEADER_NAME);
|
|
39
|
+
return response;
|
|
63
40
|
}
|
|
64
|
-
|
|
65
41
|
/**
|
|
66
42
|
* Hono-first try: app.fetch FIRST. If matched (no router-miss), the caller
|
|
67
43
|
* returns the response directly. If not matched, the caller falls back to
|
|
@@ -71,13 +47,9 @@ export function stripNoRouteMatchHeader(response: Response): Response {
|
|
|
71
47
|
* req.clone() because downstream needs to read the request body again
|
|
72
48
|
* (future-proofing for POST/PUT/PATCH — only GET routes today).
|
|
73
49
|
*/
|
|
74
|
-
export async function tryHonoFirst(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const response = await app.fetch(req.clone(), socketAddress);
|
|
80
|
-
const isRouterMiss = response.status === 404 && response.headers.has(NO_ROUTE_MATCH_HEADER_NAME);
|
|
81
|
-
stripNoRouteMatchHeader(response);
|
|
82
|
-
return { matched: !isRouterMiss, response };
|
|
50
|
+
export async function tryHonoFirst(app, req, socketAddress) {
|
|
51
|
+
const response = await app.fetch(req.clone(), socketAddress);
|
|
52
|
+
const isRouterMiss = response.status === 404 && response.headers.has(NO_ROUTE_MATCH_HEADER_NAME);
|
|
53
|
+
stripNoRouteMatchHeader(response);
|
|
54
|
+
return { matched: !isRouterMiss, response };
|
|
83
55
|
}
|