@cosmicdrift/kumiko-server-runtime 0.329.0 → 0.330.2

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.
Files changed (90) hide show
  1. package/README.md +15 -0
  2. package/dist/boot/apply-boot-seeds.d.ts +8 -0
  3. package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
  4. package/dist/boot/boot-crypto.d.ts +9 -0
  5. package/dist/boot/boot-crypto.js +39 -0
  6. package/dist/boot/job-run-logger.d.ts +19 -0
  7. package/dist/boot/job-run-logger.js +44 -0
  8. package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
  9. package/dist/boot/pii-event-backfill-on-boot.js +25 -0
  10. package/dist/build-prod-bundle.d.ts +103 -0
  11. package/dist/build-prod-bundle.js +751 -0
  12. package/dist/bun-serve-options.d.ts +32 -0
  13. package/dist/bun-serve-options.js +49 -0
  14. package/dist/compose-features.d.ts +31 -0
  15. package/dist/compose-features.js +124 -0
  16. package/dist/extra-routes-deps.d.ts +30 -0
  17. package/dist/extra-routes-deps.js +4 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +7 -0
  20. package/dist/pii-boot-gate.d.ts +13 -0
  21. package/dist/pii-boot-gate.js +46 -0
  22. package/dist/renderer-web-fonts.d.ts +9 -0
  23. package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
  24. package/dist/resolve-tailwind-cli.d.ts +14 -0
  25. package/dist/resolve-tailwind-cli.js +36 -0
  26. package/dist/run-prod-app-boot-context.d.ts +43 -0
  27. package/dist/run-prod-app-boot-context.js +198 -0
  28. package/dist/run-prod-app-static-files.d.ts +32 -0
  29. package/dist/run-prod-app-static-files.js +280 -0
  30. package/dist/run-prod-app.d.ts +471 -0
  31. package/dist/run-prod-app.js +741 -0
  32. package/dist/run-worker-app.d.ts +92 -0
  33. package/dist/run-worker-app.js +220 -0
  34. package/dist/security-headers.d.ts +20 -0
  35. package/dist/security-headers.js +49 -0
  36. package/dist/session-boot-gate.d.ts +11 -0
  37. package/dist/session-boot-gate.js +22 -0
  38. package/dist/session-wiring.d.ts +11 -0
  39. package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
  40. package/dist/try-hono-first.d.ts +24 -0
  41. package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
  42. package/package.json +102 -36
  43. package/src/changes.json +6 -0
  44. package/src/__tests__/boot-extra-context.test.ts +0 -163
  45. package/src/__tests__/boot-probe-fixture.ts +0 -64
  46. package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
  47. package/src/__tests__/build-prod-bundle.test.ts +0 -724
  48. package/src/__tests__/cache-headers.test.ts +0 -83
  49. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  50. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -609
  51. package/src/__tests__/compose-features.test.ts +0 -206
  52. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  53. package/src/__tests__/pii-boot-gate.test.ts +0 -112
  54. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  55. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  56. package/src/__tests__/renderer-web-theme-plugin.integration.test.ts +0 -77
  57. package/src/__tests__/require-env.test.ts +0 -34
  58. package/src/__tests__/resolve-auth-mail.test.ts +0 -144
  59. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  60. package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
  61. package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
  62. package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
  63. package/src/__tests__/run-prod-app-spec.test.ts +0 -167
  64. package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
  65. package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
  66. package/src/__tests__/run-worker-app-boot.test.ts +0 -183
  67. package/src/__tests__/run-worker-app.integration.test.ts +0 -218
  68. package/src/__tests__/security-headers.test.ts +0 -88
  69. package/src/__tests__/session-boot-gate.test.ts +0 -70
  70. package/src/__tests__/session-wiring.test.ts +0 -16
  71. package/src/__tests__/try-hono-first.test.ts +0 -108
  72. package/src/__tests__/websocket-route.integration.test.ts +0 -695
  73. package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
  74. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  75. package/src/boot/boot-crypto.ts +0 -67
  76. package/src/boot/job-run-logger.ts +0 -63
  77. package/src/boot/pii-event-backfill-on-boot.ts +0 -43
  78. package/src/build-prod-bundle.ts +0 -996
  79. package/src/bun-serve-options.ts +0 -110
  80. package/src/compose-features.ts +0 -197
  81. package/src/extra-routes-deps.ts +0 -38
  82. package/src/index.ts +0 -27
  83. package/src/pii-boot-gate.ts +0 -80
  84. package/src/resolve-tailwind-cli.ts +0 -45
  85. package/src/run-prod-app-boot-context.ts +0 -319
  86. package/src/run-prod-app-static-files.ts +0 -359
  87. package/src/run-prod-app.ts +0 -1420
  88. package/src/run-worker-app.ts +0 -366
  89. package/src/security-headers.ts +0 -67
  90. 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
- export function shouldWireProdSessions(
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: Response): Response {
61
- response.headers.delete(NO_ROUTE_MATCH_HEADER_NAME);
62
- return response;
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
- app: HonoLikeApp,
76
- req: Request,
77
- socketAddress?: string,
78
- ): Promise<HonoFirstResult> {
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
  }