@cosmicdrift/kumiko-server-runtime 0.383.1 → 0.384.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/dist/index.d.ts CHANGED
@@ -22,4 +22,5 @@ export { parseRunMode, requireEnv, runProdApp } from "./run-prod-app.ts";
22
22
  export type { RunWorkerAppOptions, WorkerAppHandle, WorkerWireDeps, } from "./run-worker-app.ts";
23
23
  export { runWorkerApp } from "./run-worker-app.ts";
24
24
  export type { CspExtension, CspOption, SecurityHeadersOption } from "./security-headers.ts";
25
+ export { buildDefaultCsp } from "./security-headers.ts";
25
26
  export { makeSeedCreateFileRef, type SeedCreateFileRef } from "./seed-dispatcher.ts";
package/dist/index.js CHANGED
@@ -16,4 +16,5 @@ export { installFatalErrorHandlers, } from "./fatal-error-handlers.js";
16
16
  export { runBootstrap } from "./run-bootstrap.js";
17
17
  export { parseRunMode, requireEnv, runProdApp } from "./run-prod-app.js";
18
18
  export { runWorkerApp } from "./run-worker-app.js";
19
+ export { buildDefaultCsp } from "./security-headers.js";
19
20
  export { makeSeedCreateFileRef } from "./seed-dispatcher.js";
@@ -1,4 +1,4 @@
1
- import { type EmailVerificationOptions, type PasswordResetOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
1
+ import { type AccountUnlockOptions, type EmailVerificationOptions, type PasswordResetOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
2
  import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
3
3
  import { type UnsubscribeLinkConfig } from "@cosmicdrift/kumiko-bundled-features/delivery";
4
4
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
@@ -9,7 +9,7 @@ import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
9
9
  import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
10
10
  import type { Redis } from "ioredis";
11
11
  import { type BootCrypto } from "./boot/boot-crypto.ts";
12
- import type { AuthMailOptions, EmailVerificationSetup, InviteSetup, PasswordResetSetup, SignupSetup } from "./run-prod-app.ts";
12
+ import type { AccountUnlockSetup, AuthMailOptions, EmailVerificationSetup, InviteSetup, PasswordResetSetup, SignupSetup } from "./run-prod-app.ts";
13
13
  export declare function addConfigAccessorFactory<T extends {
14
14
  readonly configResolver?: ConfigResolver;
15
15
  }>(resolved: T, registry: Registry): T;
@@ -47,12 +47,14 @@ type AuthMailNormalizable = {
47
47
  readonly emailVerification?: EmailVerificationSetup;
48
48
  readonly signup?: SignupSetup;
49
49
  readonly invite?: InviteSetup;
50
+ readonly accountUnlock?: AccountUnlockSetup;
50
51
  };
51
52
  export declare function resolveAuthMail<T extends AuthMailNormalizable>(auth: T, hmacSecret: string, envSource: Record<string, string | undefined>, mailWiring?: {
52
53
  readonly mailConfigured?: boolean;
53
54
  }): T & {
54
55
  readonly passwordReset?: PasswordResetOptions;
55
56
  readonly emailVerification?: EmailVerificationOptions;
57
+ readonly accountUnlock?: AccountUnlockOptions;
56
58
  };
57
59
  export declare function buildProdSessionAuth(db: DbConnection, registry: Registry, sessionsFeature: FeatureDefinition | undefined, mfaFeature: FeatureDefinition | undefined, patFeature: FeatureDefinition | undefined): Promise<{
58
60
  readonly sessionCreator: Awaited<ReturnType<typeof resolveSessionStore>>["creator"];
@@ -189,11 +189,6 @@ function buildAuthPathAppUrl(baseUrl, path) {
189
189
  ? (locale) => `${baseUrl}${path(locale)}`
190
190
  : `${baseUrl}${path}`;
191
191
  }
192
- // accountUnlock (#1266) deliberately does NOT join this convenience block —
193
- // unlike reset/verify/signup/invite it's only meaningful paired with
194
- // `accountLockout` (kumiko-framework#1627), and apps set both explicitly
195
- // alongside each other (same shape as `passwordReset`), so `mail` alone
196
- // can't silently expose a new public endpoint an app didn't ask for.
197
192
  function backfillTokenSecrets(auth, tokenSecret) {
198
193
  return {
199
194
  ...auth,
@@ -209,6 +204,12 @@ function backfillTokenSecrets(auth, tokenSecret) {
209
204
  hmacSecret: auth.emailVerification.hmacSecret ?? tokenSecret,
210
205
  },
211
206
  }),
207
+ ...(auth.accountUnlock && {
208
+ accountUnlock: {
209
+ ...auth.accountUnlock,
210
+ hmacSecret: auth.accountUnlock.hmacSecret ?? tokenSecret,
211
+ },
212
+ }),
212
213
  };
213
214
  }
214
215
  export function resolveAuthMail(auth, hmacSecret, envSource, mailWiring = {}) {
@@ -256,6 +257,12 @@ export function resolveAuthMail(auth, hmacSecret, envSource, mailWiring = {}) {
256
257
  appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.inviteAccept),
257
258
  ...mailPresentation,
258
259
  },
260
+ // The login lockout is always on, so its escape hatch ships with the mail flows.
261
+ accountUnlock: withResolvedSecrets.accountUnlock ?? {
262
+ hmacSecret: tokenSecret,
263
+ appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.unlockAccount),
264
+ ...mailPresentation,
265
+ },
259
266
  };
260
267
  }
261
268
  export async function buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature, patFeature) {
@@ -10,6 +10,10 @@ export declare function readStaticFile(filePath: string): Promise<{
10
10
  /** Hashes for the default CSP. Only an app that keeps the default CSP and serves a
11
11
  * staticDir needs them; a missing or broken file leaves inline scripts blocked, so boot says so. */
12
12
  export declare function resolveCspScriptHashes(staticDir: string | undefined, securityHeaders: SecurityHeadersOption | undefined): Promise<readonly string[]>;
13
+ /** Hashes handed to hostDispatch: read from disk only when the default-CSP
14
+ * path above has not already done so. */
15
+ export declare function resolveHostDispatchScriptHashes(staticDir: string, securityHeaders: SecurityHeadersOption | undefined, defaultCspHashes: readonly string[]): Promise<readonly string[]>;
16
+ export declare function readBuildScriptHashes(staticDir: string): Promise<readonly string[]>;
13
17
  export declare function serveDiskFile(req: Request, pathname: string, file: {
14
18
  readonly bytes: Uint8Array;
15
19
  readonly mime: string;
@@ -28,5 +32,7 @@ export declare function buildStaticFallback(apiHandler: (req: Request, socketAdd
28
32
  hostDispatchDispatcher?: QueryDispatcher,
29
33
  /** trustedProxyHops for the systemQuery request-context builds below —
30
34
  * 1:1 with runProdApp's effective value. */
31
- trustedProxyHops?: number): (req: Request, socketAddress?: string) => Promise<Response>;
35
+ trustedProxyHops?: number,
36
+ /** Build inline-script hashes, passed through to hostDispatch's deps. */
37
+ hostDispatchScriptHashes?: readonly string[]): (req: Request, socketAddress?: string) => Promise<Response>;
32
38
  export declare function staticCachePolicy(pathname: string): CachePolicy;
@@ -53,11 +53,22 @@ export async function readStaticFile(filePath) {
53
53
  /** Hashes for the default CSP. Only an app that keeps the default CSP and serves a
54
54
  * staticDir needs them; a missing or broken file leaves inline scripts blocked, so boot says so. */
55
55
  export async function resolveCspScriptHashes(staticDir, securityHeaders) {
56
- if (staticDir === undefined || securityHeaders === false)
56
+ if (staticDir === undefined || !usesDefaultCsp(securityHeaders))
57
57
  return [];
58
+ return readBuildScriptHashes(staticDir);
59
+ }
60
+ function usesDefaultCsp(securityHeaders) {
61
+ if (securityHeaders === false)
62
+ return false;
58
63
  const csp = securityHeaders?.csp;
59
- if (csp === false || typeof csp === "string")
60
- return [];
64
+ return csp !== false && typeof csp !== "string";
65
+ }
66
+ /** Hashes handed to hostDispatch: read from disk only when the default-CSP
67
+ * path above has not already done so. */
68
+ export async function resolveHostDispatchScriptHashes(staticDir, securityHeaders, defaultCspHashes) {
69
+ return usesDefaultCsp(securityHeaders) ? defaultCspHashes : readBuildScriptHashes(staticDir);
70
+ }
71
+ export async function readBuildScriptHashes(staticDir) {
61
72
  const file = await readStaticFile(join(staticDir, CSP_HASHES_FILE));
62
73
  const result = file ? parseCspHashes(new TextDecoder().decode(file.bytes)) : undefined;
63
74
  if (result?.kind === "found")
@@ -133,7 +144,9 @@ export function buildStaticFallback(apiHandler, staticDir, hostDispatch, pageHea
133
144
  hostDispatchDispatcher,
134
145
  /** trustedProxyHops for the systemQuery request-context builds below —
135
146
  * 1:1 with runProdApp's effective value. */
136
- trustedProxyHops = 0) {
147
+ trustedProxyHops = 0,
148
+ /** Build inline-script hashes, passed through to hostDispatch's deps. */
149
+ hostDispatchScriptHashes = []) {
137
150
  const indexHtml = `${staticDir}/index.html`;
138
151
  const clientIpResolver = createClientIpResolver(trustedProxyHops, "buildStaticFallback");
139
152
  // Reads an HTML file from disk. No schema injection — createKumikoApp
@@ -231,7 +244,7 @@ trustedProxyHops = 0) {
231
244
  socketAddress,
232
245
  })(type, payload, tenantId);
233
246
  };
234
- const result = await hostDispatch({ host, path: url.pathname, search: url.search }, { systemQuery });
247
+ const result = await hostDispatch({ host, path: url.pathname, search: url.search }, { systemQuery, cspScriptHashes: hostDispatchScriptHashes });
235
248
  if (result.kind === "redirect") {
236
249
  return new Response(null, {
237
250
  status: result.status ?? 302,
@@ -60,10 +60,11 @@ export type SignupSetup = SignupOptions;
60
60
  export type InviteSetup = InviteOptions;
61
61
  /** Wrapper API for the account-unlock flow (#1266). = AccountUnlockOptions
62
62
  * (appUrl via delivery, symmetric to PasswordResetSetup). Self-service
63
- * escape hatch for accountLockout's monotonic failure counter — only
64
- * meaningful when `auth.accountLockout` is also set, but wired
65
- * independently like the other flows. */
66
- export type AccountUnlockSetup = AccountUnlockOptions;
63
+ * escape hatch for accountLockout's failure counter; the lockout is always
64
+ * on, so `auth.mail` mounts this flow by default. */
65
+ export type AccountUnlockSetup = Omit<AccountUnlockOptions, "hmacSecret"> & {
66
+ readonly hmacSecret?: string;
67
+ };
67
68
  /** Wrapper API for the account-lockout brute-force guard on the login
68
69
  * handler. = AccountLockoutOptions (maxFailedAttempts, lockoutDurationMinutes
69
70
  * — no mail/appUrl fields, it doesn't mount any routes). Symmetric to
@@ -72,7 +73,7 @@ export type AccountUnlockSetup = AccountUnlockOptions;
72
73
  * lockout it's meant to escape. */
73
74
  export type AccountLockoutSetup = AccountLockoutOptions;
74
75
  /** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
75
- * Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
76
+ * Verdrahtet alle 5 Mail-Flows aus einem env-SMTP-Transport + Standard-
76
77
  * Templates (siehe `auth.mail` + resolveAuthMail). */
77
78
  export type AuthMailOptions = {
78
79
  /** App-Basis-URL inkl. Schema (z.B. "https://app.example.com"). */
@@ -85,7 +86,7 @@ export type AuthMailOptions = {
85
86
  readonly emailVerificationMode?: "strict" | "off";
86
87
  /** Fallback-From-Adresse wenn `SMTP_FROM`-env fehlt (env gewinnt). */
87
88
  readonly from?: string;
88
- /** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
89
+ /** Overrides single auth paths (default DEFAULT_AUTH_PATHS). */
89
90
  readonly paths?: Partial<AuthPaths>;
90
91
  /** Reset/verify token secret, decoupled from JWT_SECRET rotation.
91
92
  * Falls back to resolveAuthMail's call-site secret when unset. */
@@ -99,26 +100,22 @@ export type RunProdAppAuthOptions = {
99
100
  readonly admin?: SeedAdminOptions;
100
101
  /** Optional override of the login error → HTTP status map. */
101
102
  readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
102
- /** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
103
- * emailVerification, signup, invite) aus `auth.mail.baseUrl` + Standard-
104
- * Pfaden. Alle vier mailen via delivery (ctx.notify) — ersetzt das per-App
105
- * hand-gerollte `send*Email`-Callback-Wiring.
103
+ /** Wires all five mail flows (passwordReset, emailVerification, signup,
104
+ * invite, accountUnlock) from `auth.mail.baseUrl` plus the default paths,
105
+ * mailing via delivery (ctx.notify).
106
106
  *
107
- * Null-Transport-Guard: ohne `SMTP_HOST`-env wird KEIN Mail-Flow
108
- * verdrahtet (Routes blieben sonst 500). Eine App die einen einzelnen
109
- * Flow custom braucht, setzt `passwordReset`/`signup`/… explizit —
110
- * der explizite Block gewinnt über den mail-Default.
107
+ * Without `SMTP_HOST` no flow is wired, since the routes would otherwise
108
+ * answer 500. An explicit `passwordReset`/`signup`/… block wins over the
109
+ * mail default for that flow.
111
110
  *
112
- * Bewusste Entscheidung: `mail` mountet ALLE 4 Flows inkl. signup +
113
- * invite (Self-Registration on). Wer nur reset+verify will, lässt `mail`
114
- * weg und setzt die gewünschten Flows explizit — kein impliziter
115
- * Self-Signup. */
111
+ * Deliberately mounts signup and invite too (self-registration on); apps
112
+ * that want only reset and verify leave `mail` unset and wire those flows
113
+ * explicitly, so there is no implicit self-signup. */
116
114
  readonly mail?: AuthMailOptions;
117
115
  /** Password-reset flow. When set, /api/auth/request-password-reset +
118
- * /api/auth/reset-password are mounted as public routes UND der
119
- * request/confirm-Handler im auth-email-password-Feature wird
120
- * registriert (sonst dispatchen die Routes ins Leere → 500).
121
- * Überschreibt den `mail`-Default für genau diesen Flow. */
116
+ * /api/auth/reset-password are mounted as public routes and the
117
+ * request/confirm handlers are registered (otherwise the routes answer
118
+ * 500). Overrides the `mail` default for this flow. */
122
119
  readonly passwordReset?: PasswordResetSetup;
123
120
  /** Email-verification flow. Symmetric to passwordReset. */
124
121
  readonly emailVerification?: EmailVerificationSetup;
@@ -258,6 +255,8 @@ export type HostDispatchFn = (req: {
258
255
  * tenant for a subdomain instead of needing its own db handle. */
259
256
  deps: {
260
257
  readonly systemQuery: PageHeadSystemQuery;
258
+ /** Raw `sha256-…` hashes of the build's inline scripts (csp-hashes.json), for a per-host CSP without 'unsafe-inline'. */
259
+ readonly cspScriptHashes: readonly string[];
261
260
  }) => HostDispatchResult | Promise<HostDispatchResult>;
262
261
  export type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery };
263
262
  export type RunProdAppOptions = {
@@ -81,7 +81,7 @@ import { resolveRateLimit } from "./rate-limit-defaults.js";
81
81
  import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
82
82
  import { resolveUnsubscribeWiring } from "./resolve-unsubscribe-wiring.js";
83
83
  import { addConfigAccessorFactory, buildBootExtraContext, buildProdSessionAuth, loggerFromExtraContext, resolveAuthMail, wireProdPatAutoRevoke, } from "./run-prod-app-boot-context.js";
84
- import { buildStaticFallback, resolveCspScriptHashes } from "./run-prod-app-static-files.js";
84
+ import { buildStaticFallback, resolveCspScriptHashes, resolveHostDispatchScriptHashes, } from "./run-prod-app-static-files.js";
85
85
  import { withSecurityHeaders } from "./security-headers.js";
86
86
  import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
87
87
  import { assertSessionBootInvariants } from "./session-boot-gate.js";
@@ -775,10 +775,13 @@ export async function runProdApp(options) {
775
775
  // owns the rest. Tests use this directly; listen() wraps it in
776
776
  // Bun.serve.
777
777
  const cspScriptHashes = await resolveCspScriptHashes(options.staticDir, options.securityHeaders);
778
+ const hostDispatchScriptHashes = options.hostDispatch !== undefined && options.staticDir !== undefined
779
+ ? await resolveHostDispatchScriptHashes(options.staticDir, options.securityHeaders, cspScriptHashes)
780
+ : [];
778
781
  const fetchHandler = withSecurityHeaders(bootOptionWiring.wrapFetch(options.staticDir
779
782
  ? buildStaticFallback(entrypoint.app.fetch.bind(entrypoint.app), options.staticDir, options.hostDispatch, options.resolvePageHead
780
783
  ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
781
- : undefined, entrypoint.dispatcher, trustedProxyHops ?? 0)
784
+ : undefined, entrypoint.dispatcher, trustedProxyHops ?? 0, hostDispatchScriptHashes)
782
785
  : // No staticDir (split-deploy / API-only container) → app.fetch's
783
786
  // response goes straight to the client, bypassing buildStaticFallback
784
787
  // (and with it tryHonoFirst) entirely. Must strip the router-miss
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.383.1",
3
+ "version": "0.384.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 tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -88,18 +88,18 @@
88
88
  }
89
89
  },
90
90
  "dependencies": {
91
- "@cosmicdrift/kumiko-bundled-features": "0.383.1",
92
- "@cosmicdrift/kumiko-framework": "0.383.1",
93
- "@cosmicdrift/kumiko-renderer": "0.383.1",
94
- "@cosmicdrift/kumiko-renderer-mail-html": "0.383.1",
95
- "@cosmicdrift/kumiko-types": "0.383.1",
91
+ "@cosmicdrift/kumiko-bundled-features": "0.384.0",
92
+ "@cosmicdrift/kumiko-framework": "0.384.0",
93
+ "@cosmicdrift/kumiko-renderer": "0.384.0",
94
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.384.0",
95
+ "@cosmicdrift/kumiko-types": "0.384.0",
96
96
  "bun-types": "^1.3.13",
97
97
  "ioredis": "^5.10.1",
98
98
  "temporal-polyfill": "^0.3.2",
99
99
  "zod": "^4.4.3"
100
100
  },
101
101
  "devDependencies": {
102
- "@cosmicdrift/kumiko-testing": "0.383.1"
102
+ "@cosmicdrift/kumiko-testing": "0.384.0"
103
103
  },
104
104
  "publishConfig": {
105
105
  "registry": "https://registry.npmjs.org",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.384.0",
4
+ "type": "improvement",
5
+ "title": "hostDispatch pages get the build's CSP script hashes",
6
+ "detail": "The deps passed to hostDispatch now carry cspScriptHashes (the inline-script hashes from csp-hashes.json). Build the page CSP with buildDefaultCsp(deps.cspScriptHashes), now exported, so per-host pages keep script-src without unsafe-inline. runDevApp passes an empty list because dev serves no per-host CSP."
7
+ },
2
8
  {
3
9
  "version": "0.383.0",
4
10
  "type": "improvement",