From e12544f06e222ad88304bf5e4a79beeaf0e7e6a2 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 2 Aug 2026 14:34:36 +0200 Subject: [PATCH 01/18] fix(security): harden trust boundaries --- deno.json | 1 + docs/api-reference/index.md | 1 + docs/api-reference/veryfront/security.md | 134 ++ .../css-optimizer/optimizer-service.ts | 1 - .../tailwind-processor/detector.test.ts | 52 +- .../tailwind-processor/detector.ts | 1 - .../tailwind-processor/processor.test.ts | 2 + .../tailwind-processor/processor.ts | 1 - src/build/production-build/templates.ts | 2 +- src/index.client.ts | 1 - src/index.ts | 1 - src/modules/server/module-batch-handler.ts | 1 - src/modules/server/module-server.ts | 1 - src/release-assets/build-executor.ts | 8 +- .../filesystem/filesystem-repository.ts | 3 - src/repositories/repositories.test.ts | 10 + src/security/README.md | 497 +++--- src/security/client/html-sanitizer.test.ts | 10 + src/security/client/html-sanitizer.ts | 8 +- src/security/csrf/helpers.test.ts | 44 +- src/security/csrf/helpers.ts | 89 +- src/security/deno-permissions.ts | 2 +- src/security/http/auth.test.ts | 402 ++++- src/security/http/auth.ts | 285 +++- src/security/http/base-handler.test.ts | 185 ++- src/security/http/base-handler.ts | 101 +- src/security/http/config.test.ts | 138 +- src/security/http/config.ts | 251 ++- src/security/http/cors/constants.test.ts | 12 + src/security/http/cors/constants.ts | 23 +- src/security/http/cors/headers.test.ts | 251 ++- src/security/http/cors/headers.ts | 117 +- src/security/http/cors/index.ts | 3 + src/security/http/cors/middleware.ts | 16 +- src/security/http/cors/preflight.test.ts | 236 ++- src/security/http/cors/preflight.ts | 195 ++- src/security/http/cors/types.ts | 19 +- src/security/http/cors/validators.test.ts | 272 +++- src/security/http/cors/validators.ts | 446 ++++-- src/security/http/csrf/csrf-handler.test.ts | 44 +- src/security/http/csrf/csrf-handler.ts | 47 +- src/security/http/index.ts | 1 - .../http/middleware/config-loader.test.ts | 70 - src/security/http/middleware/config-loader.ts | 61 - src/security/http/middleware/index.ts | 1 - .../http/response/cache-handler.test.ts | 35 +- src/security/http/response/cache-handler.ts | 108 +- src/security/http/response/fluent-methods.ts | 4 +- .../http/response/security-handler.test.ts | 166 +- .../http/response/security-handler.ts | 94 +- .../http/response/static-helpers.test.ts | 132 ++ src/security/http/response/static-helpers.ts | 76 +- src/security/http/response/types.test.ts | 37 + src/security/http/response/types.ts | 5 +- src/security/index.test.ts | 75 +- src/security/index.ts | 27 +- src/security/input-validation/errors.ts | 3 +- src/security/input-validation/handler.test.ts | 269 +++- src/security/input-validation/handler.ts | 180 ++- src/security/input-validation/index.ts | 9 +- src/security/input-validation/limits.test.ts | 427 ++++- src/security/input-validation/limits.ts | 325 +++- src/security/input-validation/parsers.test.ts | 202 ++- src/security/input-validation/parsers.ts | 178 ++- .../input-validation/sanitizers.test.ts | 291 ---- src/security/input-validation/sanitizers.ts | 48 - src/security/input-validation/types.ts | 15 +- src/security/path-validation.test.ts | 161 +- src/security/path-validation.ts | 4 +- .../path-validation/canonical.test.ts | 148 +- src/security/path-validation/canonical.ts | 70 +- src/security/path-validation/index.test.ts | 325 +++- src/security/path-validation/index.ts | 365 ++++- .../path-validation/normalization.test.ts | 13 + src/security/path-validation/normalization.ts | 16 +- src/security/path-validation/presets.test.ts | 2 +- src/security/path-validation/presets.ts | 20 +- src/security/path-validation/rules.ts | 15 +- src/security/path-validation/types.ts | 19 +- src/security/project-locality.ts | 63 + src/security/rate-limit/README.md | 330 +--- src/security/rate-limit/index.ts | 16 - src/security/rate-limit/memory-store.test.ts | 84 - src/security/rate-limit/memory-store.ts | 85 - src/security/rate-limit/middleware.test.ts | 370 ----- src/security/rate-limit/middleware.ts | 195 --- src/security/rate-limit/strategies.test.ts | 177 --- src/security/rate-limit/strategies.ts | 128 -- src/security/rate-limit/types.ts | 41 - src/security/secure-fs.test.ts | 1389 ++++++++++++++--- src/security/secure-fs.ts | 1257 +++++++++++---- src/server/handlers/dev/dashboard/api.test.ts | 2 + src/server/handlers/dev/dashboard/api.ts | 18 +- .../handlers/monitoring/metrics.handler.ts | 2 +- .../preview/markdown-preview.handler.ts | 13 +- src/server/handlers/request/css.handler.ts | 1 - .../handlers/request/snippet.handler.test.ts | 20 +- .../handlers/request/snippet.handler.ts | 13 +- src/server/handlers/response/cors.ts | 3 +- .../services/static/static-file.service.ts | 1 - src/skill/limits.ts | 66 + src/skill/operation-budget.ts | 189 +++ src/skill/path-safety.test.ts | 623 +++++++- src/skill/path-safety.ts | 614 +++++++- src/skill/string-safety.ts | 61 + 105 files changed, 9857 insertions(+), 3814 deletions(-) create mode 100644 docs/api-reference/veryfront/security.md delete mode 100644 src/security/http/middleware/config-loader.test.ts delete mode 100644 src/security/http/middleware/config-loader.ts create mode 100644 src/security/http/response/static-helpers.test.ts create mode 100644 src/security/http/response/types.test.ts delete mode 100644 src/security/input-validation/sanitizers.test.ts delete mode 100644 src/security/input-validation/sanitizers.ts create mode 100644 src/security/project-locality.ts delete mode 100644 src/security/rate-limit/index.ts delete mode 100644 src/security/rate-limit/memory-store.test.ts delete mode 100644 src/security/rate-limit/memory-store.ts delete mode 100644 src/security/rate-limit/middleware.test.ts delete mode 100644 src/security/rate-limit/middleware.ts delete mode 100644 src/security/rate-limit/strategies.test.ts delete mode 100644 src/security/rate-limit/strategies.ts delete mode 100644 src/security/rate-limit/types.ts create mode 100644 src/skill/limits.ts create mode 100644 src/skill/operation-budget.ts create mode 100644 src/skill/string-safety.ts diff --git a/deno.json b/deno.json index c12bce01cd..0ba5b1fa32 100644 --- a/deno.json +++ b/deno.json @@ -122,6 +122,7 @@ "./mcp": "./src/mcp/index.ts", "./middleware": "./src/middleware/index.ts", "./errors": "./src/errors/index.ts", + "./security": "./src/security/index.ts", "./observability": "./src/observability/index.ts", "./observability/sentry": "./src/observability/sentry.ts", "./observability/otlp-setup": "./src/observability/tracing/otlp-setup.ts", diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index ca3c9e0329..b2f9d22edc 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -40,6 +40,7 @@ order: 1 | [`veryfront/sandbox`](./veryfront/sandbox.md) | Isolated execution. | | [`veryfront/schedule`](./veryfront/schedule.md) | Source-defined schedules for Veryfront projects. | | [`veryfront/schemas`](./veryfront/schemas.md) | Validation schemas. | +| [`veryfront/security`](./veryfront/security.md) | Security layer - input validation with size limits, CORS configuration, CSP and security headers, path traversal prevention, and secure filesystem access. | | [`veryfront/server`](./veryfront/server.md) | Server runtime helpers. | | [`veryfront/skill`](./veryfront/skill.md) | Agent skills. Public API for the agent skills system. Skills are project-level capabilities defined as SKILL.md files following the agentskills.io specification. | | [`veryfront/task`](./veryfront/task.md) | Source-defined tasks for Veryfront projects. | diff --git a/docs/api-reference/veryfront/security.md b/docs/api-reference/veryfront/security.md new file mode 100644 index 0000000000..4246d7883f --- /dev/null +++ b/docs/api-reference/veryfront/security.md @@ -0,0 +1,134 @@ +--- +title: "veryfront/security" +description: "Security layer - input validation with size limits, CORS configuration, CSP and security headers, path traversal prevention, and secure filesystem access." +order: 32 +--- + +## Import + +```ts +import { + applyCORSHeaders, + applyCORSHeadersSync, + applyCsrfCookie, + applySecurityHeaders, + buildCacheControl, + cors, +} from "veryfront/security"; +``` + +## Examples + +### Apply response security headers + +```ts +import { applySecurityHeaders, generateNonce } from "veryfront/security"; + +const response = new Response("Ready"); +applySecurityHeaders(response.headers, false, generateNonce(), null); +``` + +## Exports + +### Components + +| Name | Description | Source | +|------|-------------|--------| +| `BUILD_HELPER_PERMISSIONS` | BUILD_HELPER - manifest generators, framework source prep. Only needs filesystem + env access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/deno-permissions.ts#L48) | +| `CACHE_DURATIONS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/constants.ts#L10) | +| `CommonSchemas` | Lazy-getter object that preserves the `CommonSchemas.email` call shape. Each access returns the cached `Schema` (memoized inside `defineSchema`), so chained calls like `CommonSchemas.email.parse(x)` work as before. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/schemas/common.ts#L91) | +| `CORS_MAX_AGE` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/constants.ts#L22) | +| `DEFAULT_CORS_HEADERS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/constants.ts#L9) | +| `DEFAULT_CORS_METHODS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/constants.ts#L1) | +| `DEFAULT_LIMITS` | Framework-owned request limits. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/types.ts#L21) | +| `INPUT_VALIDATION_FAILED` | HTTP request input validation failures (replaces ValidationError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L86) | +| `PathValidationError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/types.ts#L39) | +| `SECURITY_VIOLATION` | Path traversal / secure-fs violations (replaces SecurityError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L77) | +| `SERVER_PERMISSIONS` | SERVER - CLI server (dev, production, proxy, MCP, split-mode). Also used by build and test tasks that need equivalent access. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/deno-permissions.ts#L14) | +| `ValidationPresets` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/presets.ts#L45) | +| `WORKFLOW_RUN_PERMISSIONS` | WORKFLOW_RUN - `ProcessRunExecutor` (RESTRICTED). Runs user-authored code - no `--allow-run`, `--allow-ffi`, or `--allow-sys`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/deno-permissions.ts#L37) | + +### Functions + +| Name | Description | Source | +|------|-------------|--------| +| `applyCORSHeaders` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/headers.ts#L86) | +| `applyCORSHeadersSync` | Apply CORS synchronously. Promise-returning values still fail closed at runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/headers.ts#L112) | +| `applyCsrfCookie` | Set CSRF cookie on GET/HEAD responses when not already present. Uses httpOnly: false so client JS can read the cookie for double-submit. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/csrf/helpers.ts#L150) | +| `applySecurityHeaders` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L173) | +| `buildCacheControl` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/cache-handler.ts#L86) | +| `cors` | Create CORS middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/middleware.ts#L10) | +| `corsSimple` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/middleware.ts#L39) | +| `createResponseBuilder` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/builder.ts#L60) | +| `createSecureFs` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/secure-fs.ts#L1073) | +| `createValidatedHandler` | Create a validated API handler with bounded body/query validation. Bodies without a schema are preflighted through a clone, leaving the original request body available to the handler after its size is verified. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L163) | +| `createValidationError` | Create an input validation error. Convenience wrapper around INPUT_VALIDATION_FAILED.create(). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/errors.ts#L12) | +| `createValidator` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/index.ts#L421) | +| `generateCsrfToken` | Generate a CSRF token and return value + Set-Cookie header string | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/csrf/helpers.ts#L70) | +| `generateNonce` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L44) | +| `getSecurityHeader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L160) | +| `handleCORSPreflight` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/preflight.ts#L126) | +| `isPreflightRequest` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/preflight.ts#L186) | +| `isRequestBodyTooLargeError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/limits.ts#L100) | +| `parseFormData` | Parse and validate multipart or URL-encoded form data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L133) | +| `parseJsonBody` | Parse and validate a JSON request body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L67) | +| `parseQueryParams` | Parse and validate query parameters from a bounded request URL. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L189) | +| `readBodyWithLimit` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/limits.ts#L229) | +| `sanitizePathForDisplay` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/index.ts#L443) | +| `setCors` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/middleware/cors-handler.ts#L4) | +| `shouldApplyCORS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/headers.ts#L130) | +| `validateCORSConfig` | Validate CORS configuration for security issues. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/validators.ts#L418) | +| `validateCsrf` | Validate CSRF token by comparing header and cookie | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/csrf/helpers.ts#L119) | +| `validateLexicalPath` | Validate lexical path containment without consulting a filesystem. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/index.ts#L402) | +| `validateOrigin` | Validate origin against CORS configuration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/validators.ts#L392) | +| `validateOriginSync` | Synchronous origin validation. Promise-returning values still fail closed at runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/validators.ts#L408) | +| `validatePath` | Admit a path against the physical semantics of a runtime filesystem. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/index.ts#L329) | +| `validateRequestLimits` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/limits.ts#L106) | +| `wrapAdapterWithSecurity` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/secure-fs.ts#L1132) | + +### Classes + +| Name | Description | Source | +|------|-------------|--------| +| `AuthHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/auth.ts#L155) | +| `BaseHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/base-handler.ts#L45) | +| `CsrfHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/csrf/csrf-handler.ts#L55) | +| `ResponseBuilder` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/builder.ts#L9) | +| `SecureFs` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/secure-fs.ts#L643) | +| `SecurityConfigLoader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/config.ts#L238) | + +### Types + +| Name | Description | Source | +|------|-------------|--------| +| `CacheStrategy` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/types.ts#L11) | +| `CORSConfig` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/middleware/types.ts#L1) | +| `CORSHeaderOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/types.ts#L34) | +| `CORSOptions` | CORS policy accepted by asynchronous middleware and preflight APIs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/types.ts#L7) | +| `CORSPreflightOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/types.ts#L27) | +| `CORSValidationResult` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/types.ts#L21) | +| `CSPDirectives` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/middleware/types.ts#L10) | +| `CsrfConfig` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/csrf/helpers.ts#L20) | +| `CsrfTokenOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/csrf/helpers.ts#L27) | +| `HandlerHelpers` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/base-handler.ts#L19) | +| `LexicalPathValidationOptions` | Options for lexical containment checks that never inspect a filesystem. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/types.ts#L33) | +| `OriginValidator` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/types.ts#L2) | +| `ParseFormOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/types.ts#L32) | +| `ParseJsonOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/types.ts#L28) | +| `ParseQueryOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/types.ts#L36) | +| `PathValidationPolicyOptions` | Filesystem-independent policy fields shared by physical path presets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/types.ts#L18) | +| `RequestLimits` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/types.ts#L8) | +| `ResponseBuilderConfig` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/types.ts#L27) | +| `SecureFsConfig` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/secure-fs.ts#L54) | +| `SecurityConfig` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/server.ts#L8) | +| `SecurityContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/secure-fs.ts#L37) | +| `SecurityEvent` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/secure-fs.ts#L63) | +| `SyncCORSConfig` | CORS policy accepted by synchronous response-building APIs. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/types.ts#L17) | +| `SyncCORSHeaderOptions` | Header options accepted by synchronous CORS response helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/types.ts#L42) | +| `SyncOriginValidator` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/types.ts#L1) | +| `ValidatedData` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/types.ts#L40) | +| `ValidatedHandlerConfig` | Configuration for `createValidatedHandler()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L11) | +| `ValidatedHandlerFunction` | Handler signature that receives validated request data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L18) | +| `ValidationLevel` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/types.ts#L8) | +| `ValidationOptions` | Options for physical filesystem admission. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/types.ts#L28) | +| `ValidationResult` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/types.ts#L10) | diff --git a/src/build/asset-pipeline/css-optimizer/optimizer-service.ts b/src/build/asset-pipeline/css-optimizer/optimizer-service.ts index 7c065116b7..af806a6147 100644 --- a/src/build/asset-pipeline/css-optimizer/optimizer-service.ts +++ b/src/build/asset-pipeline/css-optimizer/optimizer-service.ts @@ -254,7 +254,6 @@ export class CSSOptimizerService { baseDir: this.baseDir, adapter, context: "build", - throwOnError: true, validationOptions: { followSymlinks: false }, }); this.optimizationEngine = dependencies.optimizationEngine; diff --git a/src/build/asset-pipeline/tailwind-processor/detector.test.ts b/src/build/asset-pipeline/tailwind-processor/detector.test.ts index 2fcc61df28..8fa83eb2fa 100644 --- a/src/build/asset-pipeline/tailwind-processor/detector.test.ts +++ b/src/build/asset-pipeline/tailwind-processor/detector.test.ts @@ -1,9 +1,59 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { autoDetectContentPaths } from "./detector.ts"; +import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { autoDetectContentPaths, isTailwindV4File } from "./detector.ts"; + +function createFileAdapter(): RuntimeAdapter { + return { + name: "test", + fs: { + readFile: (path: string) => Deno.readTextFile(path), + readTextFile: (path: string) => Deno.readTextFile(path), + writeFile: (path: string, content: string) => Deno.writeTextFile(path, content), + writeTextFile: (path: string, content: string) => Deno.writeTextFile(path, content), + exists: async (path: string) => { + try { + await Deno.stat(path); + return true; + } catch { + return false; + } + }, + mkdir: (path: string, options?: { recursive?: boolean }) => Deno.mkdir(path, options), + readDir: (path: string) => Deno.readDir(path), + stat: (path: string) => Deno.stat(path), + lstat: (path: string) => Deno.lstat(path), + realPath: (path: string) => Deno.realPath(path), + remove: (path: string, options?: { recursive?: boolean }) => Deno.remove(path, options), + makeTempDir: (prefix: string) => Deno.makeTempDir({ prefix }), + watch: (paths: string | string[], options?: { recursive?: boolean }) => + options?.recursive === undefined + ? Deno.watchFs(paths) + : Deno.watchFs(paths, { recursive: options.recursive }), + }, + } as unknown as RuntimeAdapter; +} describe("build/asset-pipeline/tailwind-processor/detector", () => { + it("retains the detector's intentional false result for rejected paths", async () => { + const projectDir = await Deno.makeTempDir(); + try { + const outsideFile = `${projectDir}-outside.css`; + await Deno.writeTextFile(outsideFile, '@import "tailwindcss";'); + try { + assertEquals( + await isTailwindV4File(outsideFile, projectDir, createFileAdapter()), + false, + ); + } finally { + await Deno.remove(outsideFile); + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + describe("autoDetectContentPaths", () => { it("should return four content path patterns", () => { assertEquals(autoDetectContentPaths("/project").length, 4); diff --git a/src/build/asset-pipeline/tailwind-processor/detector.ts b/src/build/asset-pipeline/tailwind-processor/detector.ts index ff3871f625..a012445e43 100644 --- a/src/build/asset-pipeline/tailwind-processor/detector.ts +++ b/src/build/asset-pipeline/tailwind-processor/detector.ts @@ -23,7 +23,6 @@ export function isTailwindV4File( baseDir: projectDir, adapter, context: "build", - throwOnError: false, }); try { diff --git a/src/build/asset-pipeline/tailwind-processor/processor.test.ts b/src/build/asset-pipeline/tailwind-processor/processor.test.ts index e85d229bcf..339c11311c 100644 --- a/src/build/asset-pipeline/tailwind-processor/processor.test.ts +++ b/src/build/asset-pipeline/tailwind-processor/processor.test.ts @@ -24,6 +24,8 @@ function createMockAdapter(_baseDir: string): RuntimeAdapter { mkdir: (path: string, opts?: { recursive?: boolean }) => Deno.mkdir(path, opts), readDir: (path: string) => Deno.readDir(path), stat: (path: string) => Deno.stat(path), + lstat: (path: string) => Deno.lstat(path), + realPath: (path: string) => Deno.realPath(path), remove: (path: string, opts?: { recursive?: boolean }) => Deno.remove(path, opts), makeTempDir: (prefix: string) => Deno.makeTempDir({ prefix }), watch: (paths: string | string[], options?: { recursive?: boolean }) => diff --git a/src/build/asset-pipeline/tailwind-processor/processor.ts b/src/build/asset-pipeline/tailwind-processor/processor.ts index 52e4b6cf8a..f392ff6a3a 100644 --- a/src/build/asset-pipeline/tailwind-processor/processor.ts +++ b/src/build/asset-pipeline/tailwind-processor/processor.ts @@ -39,7 +39,6 @@ export class TailwindProcessor { baseDir: projectDir, adapter, context: "build", - throwOnError: true, }); logger.info("Processing Tailwind CSS v4...", { inputFile, outputFile }); diff --git a/src/build/production-build/templates.ts b/src/build/production-build/templates.ts index 5dfaf5ab68..388000c8ad 100644 --- a/src/build/production-build/templates.ts +++ b/src/build/production-build/templates.ts @@ -11,7 +11,7 @@ export const CLIENT_STYLES = ".error-container {\n max-width: 600px;\n margin: 2rem auto;\n padding: 2rem;\n background: #fee;\n border: 1px solid #fcc;\n border-radius: 8px;\n color: #c00;\n}"; export const CLIENT_ROUTER_BUNDLE: string | undefined = - 'var __defProp = Object.defineProperty;\nvar __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;\nvar __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);\n\n// src/rendering/client/browser-stubs/logger.ts\nfunction noop() {\n}\nvar logger = {\n debug: noop,\n info: console.log.bind(console),\n warn: console.warn.bind(console),\n error: console.error.bind(console),\n component: () => logger\n};\nvar rendererLogger = logger;\nvar PREFETCH_MAX_SIZE_BYTES = 200 * 1024;\n\n// src/rendering/client/navigation-store.ts\nvar STORE_KEY = /* @__PURE__ */ Symbol.for("veryfront.navigation.store.v1");\nfunction getNavigationStore() {\n const holder = globalThis;\n const existing = holder[STORE_KEY];\n if (existing) return existing;\n const listeners = /* @__PURE__ */ new Set();\n let navigator = null;\n const store = {\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n getHref() {\n const loc = globalThis.location;\n return loc ? `${loc.pathname}${loc.search}${loc.hash}` : "/";\n },\n notify() {\n for (const listener of [...listeners]) {\n try {\n listener();\n } catch {\n }\n }\n },\n navigate(href, options) {\n if (navigator) return navigator(href, options);\n globalThis.location?.assign(href);\n return Promise.resolve();\n },\n setNavigator(next) {\n navigator = next;\n }\n };\n holder[STORE_KEY] = store;\n return store;\n}\n\n// src/rendering/client/router.ts\nimport ReactDOM from "react-dom/client";\n\n// src/html/managed-head-protocol.ts\nvar HEAD_PROVENANCE_ATTRIBUTE = "data-vf-head";\nvar HEAD_LEGACY_MANAGED_ATTRIBUTE = "data-veryfront-managed";\nvar HEAD_CONTENT_HASH_ATTRIBUTE = "data-vf-hash";\nvar HEAD_REACT_MANAGED_ATTRIBUTE = "data-vf-react-head";\nvar HEAD_REACT_OWNER_ATTRIBUTE = "data-vf-react-head-owner";\nvar HEAD_ROUTE_MANAGED_ATTRIBUTE = "data-vf-route-head";\nvar HEAD_SHELL_PROVENANCE_ATTRIBUTE = "data-vf-shell-head";\nvar HEAD_SSR_PAYLOAD_ATTRIBUTE = "data-vf-ssr-head";\nvar MAX_MANAGED_HEAD_ENTRIES = 128;\nvar MAX_MANAGED_HEAD_BYTES = 2 * 1024 * 1024;\nvar REACT_HEAD_ATTRIBUTE_NAMES = {\n charSet: "charset",\n className: "class",\n crossOrigin: "crossorigin",\n fetchPriority: "fetchpriority",\n htmlFor: "for",\n httpEquiv: "http-equiv",\n imageSizes: "imagesizes",\n imageSrcSet: "imagesrcset",\n noModule: "nomodule",\n referrerPolicy: "referrerpolicy"\n};\nvar SINGLETON_META_KEYS = /* @__PURE__ */ new Set([\n "description",\n "robots",\n "viewport",\n "referrer",\n "color-scheme",\n "application-name",\n "generator",\n "og:title",\n "og:description",\n "og:url",\n "og:type",\n "og:site_name",\n "og:locale",\n "twitter:card",\n "twitter:site",\n "twitter:creator",\n "twitter:title",\n "twitter:description",\n "twitter:image",\n "twitter:image:alt"\n]);\nvar SINGLETON_LINK_RELS = /* @__PURE__ */ new Set([\n "canonical",\n "manifest",\n "amphtml"\n]);\nvar SUPPORTED_MANAGED_HEAD_TAGS = /* @__PURE__ */ new Set([\n "title",\n "meta",\n "link",\n "style",\n "script"\n]);\nvar HEAD_ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][A-Za-z0-9_.:-]*$/;\nvar MAX_HEAD_PROP_ENTRIES = 128;\nvar MAX_HEAD_ATTRIBUTE_NAME_BYTES = 256;\nvar MAX_HEAD_ATTRIBUTE_VALUE_BYTES = 64 * 1024;\nvar MAX_HEAD_ATTRIBUTE_BYTES = 1024 * 1024;\nvar MAX_HEAD_CONTENT_BYTES = 1024 * 1024;\nvar headTextEncoder = new TextEncoder();\nvar BOOLEAN_HEAD_ATTRIBUTES = /* @__PURE__ */ new Set([\n "async",\n "defer",\n "disabled",\n "itemscope",\n "nomodule"\n]);\nfunction isHeadFrameworkAttribute(name) {\n switch (name.toLowerCase()) {\n case HEAD_PROVENANCE_ATTRIBUTE:\n case HEAD_LEGACY_MANAGED_ATTRIBUTE:\n case HEAD_CONTENT_HASH_ATTRIBUTE:\n case HEAD_REACT_MANAGED_ATTRIBUTE:\n case HEAD_REACT_OWNER_ATTRIBUTE:\n case HEAD_ROUTE_MANAGED_ATTRIBUTE:\n case HEAD_SHELL_PROVENANCE_ATTRIBUTE:\n case HEAD_SSR_PAYLOAD_ATTRIBUTE:\n return true;\n default:\n return false;\n }\n}\nfunction normalizeHeadIdentityValue(value) {\n const normalized = value?.trim().toLowerCase();\n return normalized || void 0;\n}\nfunction readOwnString(record, key) {\n try {\n const descriptor = Reflect.getOwnPropertyDescriptor(record, key);\n return descriptor && !descriptor.get && !descriptor.set && "value" in descriptor && typeof descriptor.value === "string" ? descriptor.value : void 0;\n } catch {\n return void 0;\n }\n}\nfunction headMetaSingletonKeyFromRecord(meta) {\n if (readOwnString(meta, "charset") !== void 0) return "meta:charset";\n const key = normalizeHeadIdentityValue(\n readOwnString(meta, "property") ?? readOwnString(meta, "name")\n );\n if (!key) return void 0;\n if (key === "theme-color") {\n return `meta:theme-color:${readOwnString(meta, "media")?.trim() ?? ""}`;\n }\n return SINGLETON_META_KEYS.has(key) ? `meta:${key}` : void 0;\n}\nfunction headLinkSingletonKeyFromRecord(link) {\n const rel = normalizeHeadIdentityValue(readOwnString(link, "rel"));\n return rel && SINGLETON_LINK_RELS.has(rel) ? `link:${rel}` : void 0;\n}\nfunction normalizeManagedHeadString(value) {\n return value.replace(/\\r\\n?/g, "\\n");\n}\nfunction inspectHeadProps(value) {\n if (typeof value !== "object" || value === null || Array.isArray(value)) return null;\n let prototype;\n let keys;\n try {\n prototype = Object.getPrototypeOf(value);\n keys = Reflect.ownKeys(value);\n } catch {\n return null;\n }\n if (prototype !== Object.prototype && prototype !== null) return null;\n const inspected = /* @__PURE__ */ new Map();\n let entries = 0;\n for (const key of keys) {\n let descriptor;\n try {\n descriptor = Reflect.getOwnPropertyDescriptor(value, key);\n } catch {\n return null;\n }\n if (!descriptor) return null;\n if (!descriptor.enumerable) continue;\n if (typeof key !== "string" || descriptor.get || descriptor.set || !("value" in descriptor)) {\n return null;\n }\n entries++;\n if (entries > MAX_HEAD_PROP_ENTRIES) return null;\n inspected.set(key, descriptor.value);\n }\n return inspected;\n}\nfunction normalizeContentPrimitive(value) {\n if (value === null || value === void 0 || typeof value === "boolean") return void 0;\n if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") {\n return null;\n }\n const content = normalizeManagedHeadString(String(value));\n return headTextEncoder.encode(content).byteLength <= MAX_HEAD_CONTENT_BYTES ? content : null;\n}\nfunction normalizeManagedHeadAttributesFromProps(tagName, props, ambientNonce, excludedKeys = /* @__PURE__ */ new Set()) {\n const attributeMap = /* @__PURE__ */ new Map();\n for (const [key, value] of props) {\n if (key === "children" || key === "dangerouslySetInnerHTML" || excludedKeys.has(key)) {\n continue;\n }\n if (/^on/i.test(key) || typeof value === "function" || typeof value === "symbol" || typeof value === "object") {\n continue;\n }\n const name = (REACT_HEAD_ATTRIBUTE_NAMES[key] ?? key).toLowerCase();\n if (isHeadFrameworkAttribute(name) || !HEAD_ATTRIBUTE_NAME_PATTERN.test(name) || headTextEncoder.encode(name).byteLength > MAX_HEAD_ATTRIBUTE_NAME_BYTES) {\n continue;\n }\n if (BOOLEAN_HEAD_ATTRIBUTES.has(name)) {\n if (value !== false && value !== void 0) attributeMap.set(name, "");\n continue;\n }\n if (typeof value === "boolean") {\n if (name.startsWith("data-") || name.startsWith("aria-")) {\n attributeMap.set(name, String(value));\n }\n continue;\n }\n if (value === void 0) continue;\n if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") {\n continue;\n }\n const normalizedValue = normalizeManagedHeadString(String(value));\n if (headTextEncoder.encode(normalizedValue).byteLength > MAX_HEAD_ATTRIBUTE_VALUE_BYTES) {\n return null;\n }\n attributeMap.set(name, normalizedValue);\n }\n if ((tagName === "script" || tagName === "style") && ambientNonce) {\n const nonce = normalizeManagedHeadString(ambientNonce);\n if (headTextEncoder.encode(nonce).byteLength > MAX_HEAD_ATTRIBUTE_VALUE_BYTES) return null;\n attributeMap.set("nonce", nonce);\n }\n if (tagName === "link" && attributeMap.get("rel")?.trim().toLowerCase() === "preload" && attributeMap.get("as")?.trim().toLowerCase() === "font" && !attributeMap.has("crossorigin")) {\n attributeMap.set("crossorigin", "anonymous");\n }\n if (attributeMap.size > MAX_HEAD_PROP_ENTRIES) return null;\n let totalBytes = 0;\n for (const [name, value] of attributeMap) {\n totalBytes += headTextEncoder.encode(name).byteLength + headTextEncoder.encode(value).byteLength;\n if (totalBytes > MAX_HEAD_ATTRIBUTE_BYTES) return null;\n }\n return [...attributeMap.entries()].sort(([left], [right]) => left.localeCompare(right));\n}\nfunction singletonKey(tagName, attributes) {\n if (tagName === "title") return "title";\n const record = Object.fromEntries(attributes);\n if (tagName === "meta") return headMetaSingletonKeyFromRecord(record);\n if (tagName === "link") return headLinkSingletonKeyFromRecord(record);\n return void 0;\n}\nfunction scriptKeys(tagName, attributes) {\n if (tagName !== "script") return [];\n const keys = [];\n const id = attributes.get("id");\n const src = attributes.get("src");\n if (id) keys.push(`script:id:${id}`);\n if (src) keys.push(`script:src:${src}`);\n return keys;\n}\nfunction declaresDocumentEncoding(attributes) {\n return attributes.has("charset") || attributes.get("http-equiv")?.trim().toLowerCase() === "content-type";\n}\nfunction createManagedHeadDescriptor(tagName, attributes, content, contentMode) {\n const attributeMap = new Map(attributes);\n return {\n tagName,\n attributes,\n ...content !== void 0 && { content },\n contentMode,\n signature: JSON.stringify([\n tagName,\n attributes,\n contentMode,\n content ?? null\n ]),\n singletonKey: singletonKey(tagName, attributeMap),\n scriptKeys: scriptKeys(tagName, attributeMap)\n };\n}\nfunction descriptorFromManagedHeadRecord(rawTagName, record, options = {}) {\n const tagName = rawTagName.toLowerCase();\n if (!SUPPORTED_MANAGED_HEAD_TAGS.has(tagName)) return null;\n const inspected = inspectHeadProps(record);\n if (!inspected) return null;\n const excludedKeys = options.contentProperty ? /* @__PURE__ */ new Set([options.contentProperty]) : /* @__PURE__ */ new Set();\n const attributes = normalizeManagedHeadAttributesFromProps(\n tagName,\n inspected,\n options.ambientNonce,\n excludedKeys\n );\n if (!attributes) return null;\n const attributeMap = new Map(attributes);\n if (tagName === "meta" && declaresDocumentEncoding(attributeMap)) return null;\n if ((tagName === "meta" || tagName === "link") && attributes.length === 0) return null;\n let content;\n if (options.contentProperty) {\n const normalized = normalizeContentPrimitive(inspected.get(options.contentProperty));\n if (normalized === null) return null;\n content = normalized;\n }\n return createManagedHeadDescriptor(tagName, attributes, content, "text");\n}\nfunction headScriptKeysIntersect(left, right) {\n if (left.length === 0 || right.length === 0) return false;\n const rightKeys = new Set(right);\n return left.some((key) => rightKeys.has(key));\n}\nfunction aggregateManagedHeadDescriptors(descriptors) {\n const aggregated = [];\n const singletonIndexes = /* @__PURE__ */ new Map();\n const scriptKeysSeen = /* @__PURE__ */ new Set();\n for (const descriptor of descriptors) {\n if (descriptor.singletonKey) {\n const index = singletonIndexes.get(descriptor.singletonKey);\n if (index !== void 0) {\n aggregated[index] = descriptor;\n continue;\n }\n singletonIndexes.set(descriptor.singletonKey, aggregated.length);\n } else if (descriptor.scriptKeys.length > 0) {\n if (descriptor.scriptKeys.some((key) => scriptKeysSeen.has(key))) continue;\n for (const key of descriptor.scriptKeys) scriptKeysSeen.add(key);\n }\n aggregated.push(descriptor);\n }\n return aggregated;\n}\nfunction managedHeadDescriptorBytes(descriptor) {\n let bytes = headTextEncoder.encode(descriptor.tagName).byteLength;\n for (const [name, value] of descriptor.attributes) {\n bytes += headTextEncoder.encode(name).byteLength;\n bytes += headTextEncoder.encode(value).byteLength;\n }\n if (descriptor.content !== void 0) {\n bytes += headTextEncoder.encode(descriptor.content).byteLength;\n }\n return bytes;\n}\nfunction assertManagedHeadDescriptorBudget(descriptors) {\n if (descriptors.length > MAX_MANAGED_HEAD_ENTRIES) {\n throw new TypeError(\n `Managed head exceeds the ${MAX_MANAGED_HEAD_ENTRIES}-entry request limit`\n );\n }\n let bytes = 0;\n for (const descriptor of descriptors) {\n bytes += managedHeadDescriptorBytes(descriptor);\n if (bytes > MAX_MANAGED_HEAD_BYTES) {\n throw new TypeError(\n `Managed head exceeds the ${MAX_MANAGED_HEAD_BYTES}-byte request limit`\n );\n }\n }\n}\nfunction managedHeadDescriptorToTransportEntry(descriptor) {\n const attributes = descriptor.attributes.filter(([name]) => name !== "nonce");\n return {\n tagName: descriptor.tagName,\n attributes: attributes.map(([name, value]) => [name, value]),\n ...descriptor.content !== void 0 && { content: descriptor.content }\n };\n}\nfunction ownTransportValue(record, key) {\n let descriptor;\n try {\n descriptor = Reflect.getOwnPropertyDescriptor(record, key);\n } catch {\n return void 0;\n }\n if (!descriptor || descriptor.get || descriptor.set || !("value" in descriptor)) {\n return void 0;\n }\n return descriptor.value;\n}\nfunction descriptorFromManagedHeadTransportEntry(entry, ambientNonce) {\n if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {\n throw new TypeError("Managed-head transport entries must be plain objects");\n }\n let prototype;\n try {\n prototype = Object.getPrototypeOf(entry);\n } catch {\n throw new TypeError("Managed-head transport entry cannot be inspected");\n }\n if (prototype !== Object.prototype && prototype !== null) {\n throw new TypeError("Managed-head transport entries must be plain objects");\n }\n const tagName = ownTransportValue(entry, "tagName");\n const rawAttributes = ownTransportValue(entry, "attributes");\n const content = ownTransportValue(entry, "content");\n if (typeof tagName !== "string" || tagName !== tagName.toLowerCase() || !Array.isArray(rawAttributes)) {\n throw new TypeError("Managed-head transport entry is not canonical");\n }\n if (rawAttributes.length > MAX_HEAD_PROP_ENTRIES) {\n throw new TypeError("Managed-head transport entry exceeds the attribute limit");\n }\n if (content !== void 0 && typeof content !== "string") {\n throw new TypeError("Managed-head transport content must be a string");\n }\n const supportsText = tagName === "title" || tagName === "script" || tagName === "style";\n if (!supportsText && content !== void 0) {\n throw new TypeError("Managed-head transport content is invalid for this tag");\n }\n const record = /* @__PURE__ */ Object.create(null);\n const inputAttributes = [];\n const names = /* @__PURE__ */ new Set();\n for (let index = 0; index < rawAttributes.length; index += 1) {\n const pair = ownTransportValue(rawAttributes, String(index));\n if (!Array.isArray(pair) || pair.length !== 2) {\n throw new TypeError("Managed-head transport attributes must be string pairs");\n }\n const name = ownTransportValue(pair, "0");\n const value = ownTransportValue(pair, "1");\n if (typeof name !== "string" || typeof value !== "string") {\n throw new TypeError("Managed-head transport attributes must be string pairs");\n }\n const normalizedName = name.toLowerCase();\n if (name !== normalizedName || normalizedName === "nonce" || names.has(normalizedName)) {\n throw new TypeError("Managed-head transport attributes are not canonical");\n }\n names.add(normalizedName);\n inputAttributes.push([normalizedName, value]);\n Object.defineProperty(record, normalizedName, {\n enumerable: true,\n value\n });\n }\n if (content !== void 0) {\n Object.defineProperty(record, "__veryfront_transport_content", {\n enumerable: true,\n value: content\n });\n }\n const descriptor = descriptorFromManagedHeadRecord(tagName, record, {\n ...supportsText && { contentProperty: "__veryfront_transport_content" },\n ...(tagName === "script" || tagName === "style") && ambientNonce ? { ambientNonce } : {}\n });\n const normalizedInput = inputAttributes.sort(([left], [right]) => left.localeCompare(right));\n const normalizedOutput = descriptor?.attributes.filter(([name]) => name !== "nonce");\n if (!descriptor || JSON.stringify(normalizedOutput) !== JSON.stringify(normalizedInput) || supportsText && (descriptor.content ?? "") !== (content ?? "")) {\n throw new TypeError("Managed-head transport entry failed validation");\n }\n return descriptor;\n}\nvar BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";\nfunction decodeBase64Url(value) {\n if (value.length % 4 === 1 || !/^[A-Za-z0-9_-]*$/.test(value)) {\n throw new TypeError("Managed-head payload is not valid base64url");\n }\n const estimatedBytes = Math.floor(value.length * 3 / 4);\n if (estimatedBytes > MAX_MANAGED_HEAD_BYTES * 2) {\n throw new TypeError("Managed-head payload exceeds its encoded size limit");\n }\n const bytes = new Uint8Array(estimatedBytes);\n let outputIndex = 0;\n let buffer = 0;\n let bits = 0;\n for (const character of value) {\n const decoded = BASE64URL_ALPHABET.indexOf(character);\n if (decoded < 0) throw new TypeError("Managed-head payload is not valid base64url");\n buffer = buffer << 6 | decoded;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n bytes[outputIndex++] = buffer >> bits & 255;\n buffer &= bits === 0 ? 0 : (1 << bits) - 1;\n }\n }\n if (bits > 0 && buffer !== 0) {\n throw new TypeError("Managed-head payload has non-canonical trailing bits");\n }\n return bytes.subarray(0, outputIndex);\n}\nfunction deserializeManagedHeadPayload(payload, ambientNonce) {\n if (typeof payload !== "string") throw new TypeError("Managed-head payload must be a string");\n let decoded;\n try {\n decoded = new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64Url(payload));\n } catch (error) {\n if (error instanceof TypeError) throw error;\n throw new TypeError("Managed-head payload is not valid UTF-8", { cause: error });\n }\n let entries;\n try {\n entries = JSON.parse(decoded);\n } catch (error) {\n throw new TypeError("Managed-head payload is not valid JSON", { cause: error });\n }\n if (!Array.isArray(entries) || entries.length > MAX_MANAGED_HEAD_ENTRIES) {\n throw new TypeError("Managed-head payload exceeds the entry limit");\n }\n const descriptors = aggregateManagedHeadDescriptors(\n entries.map((entry) => descriptorFromManagedHeadTransportEntry(entry, ambientNonce))\n );\n assertManagedHeadDescriptorBudget(descriptors);\n return descriptors;\n}\n\n// src/html/client-head-manager.ts\nvar HEAD_MANAGER_STATE_SYMBOL = /* @__PURE__ */ Symbol.for(\n "veryfront.client-head-manager.v2"\n);\nvar CROSS_PAGE_PRESERVED_SINGLETON_KEYS = /* @__PURE__ */ new Set([\n "meta:viewport",\n "link:manifest"\n]);\nfunction getClientHeadManagerState() {\n const globalState = globalThis;\n return globalState[HEAD_MANAGER_STATE_SYMBOL] ?? (globalState[HEAD_MANAGER_STATE_SYMBOL] = {\n documents: /* @__PURE__ */ new WeakMap()\n });\n}\nfunction getManagedHeadNonce(targetDocument) {\n if (typeof targetDocument.querySelector !== "function") return void 0;\n const element = targetDocument.querySelector(\n "script[nonce], style[nonce], link[nonce]"\n );\n if (!element) return void 0;\n const nonce = element.nonce || element.getAttribute("nonce") || "";\n return nonce || void 0;\n}\nfunction readElementAttributes(element) {\n const attributes = [];\n for (const attribute of element.attributes) {\n const name = attribute.name.toLowerCase();\n if (isHeadFrameworkAttribute(name)) continue;\n const nonce = name === "nonce" && "nonce" in element ? element.nonce : "";\n const value = BOOLEAN_HEAD_ATTRIBUTES.has(name) ? "" : nonce || attribute.value;\n attributes.push([name, value]);\n }\n return attributes.sort(([left], [right]) => left.localeCompare(right));\n}\nfunction elementSingletonKey(element) {\n const tagName = element.tagName.toLowerCase();\n if (tagName === "title") return "title";\n const attributes = Object.fromEntries(readElementAttributes(element));\n if (tagName === "meta") return headMetaSingletonKeyFromRecord(attributes);\n if (tagName === "link") return headLinkSingletonKeyFromRecord(attributes);\n return void 0;\n}\nfunction promoteToShellHeadBaseline(element) {\n for (const attribute of [...element.attributes]) {\n if (isHeadFrameworkAttribute(attribute.name)) {\n element.removeAttribute(attribute.name);\n }\n }\n element.setAttribute(HEAD_SHELL_PROVENANCE_ATTRIBUTE, "true");\n}\nfunction isCrossPagePreservedSingleton(element, singletonKey2 = elementSingletonKey(element)) {\n return element.parentElement !== null && singletonKey2 !== void 0 && CROSS_PAGE_PRESERVED_SINGLETON_KEYS.has(singletonKey2);\n}\nfunction isFrameworkOwnedHeadElement(element) {\n return element.getAttribute(HEAD_PROVENANCE_ATTRIBUTE) === "true" || element.getAttribute(HEAD_REACT_MANAGED_ATTRIBUTE) === "true" || element.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1" || element.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true" || element.getAttribute(HEAD_SHELL_PROVENANCE_ATTRIBUTE) === "true";\n}\nfunction retireFrameworkHeadElement(element) {\n if (isCrossPagePreservedSingleton(element)) {\n promoteToShellHeadBaseline(element);\n return;\n }\n element.remove();\n}\nfunction retireClientHeadOwnership(targetDocument) {\n const manager = getClientHeadManagerState().documents.get(targetDocument);\n if (manager) {\n manager.retire();\n return;\n }\n for (const element of [...targetDocument.head?.children ?? []]) {\n if (isFrameworkOwnedHeadElement(element)) retireFrameworkHeadElement(element);\n }\n}\n\n// src/html/client-route-head.ts\nvar ROUTE_HEAD_CONTENT_PROPERTY = "__veryfront_route_head_content";\nfunction descriptorFromHeadElement(element) {\n const record = /* @__PURE__ */ Object.create(null);\n for (const { name, value } of element.attributes) {\n if (!isHeadFrameworkAttribute(name)) record[name] = value;\n }\n const tagName = element.tagName.toLowerCase();\n const supportsText = tagName === "title" || tagName === "script" || tagName === "style";\n if (supportsText) record[ROUTE_HEAD_CONTENT_PROPERTY] = element.textContent ?? "";\n return descriptorFromManagedHeadRecord(\n tagName,\n record,\n supportsText ? { contentProperty: ROUTE_HEAD_CONTENT_PROPERTY } : void 0\n );\n}\nfunction writeRouteDescriptor(element, descriptor) {\n for (const attribute of [...element.attributes]) element.removeAttribute(attribute.name);\n for (const [name, value] of descriptor.attributes) element.setAttribute(name, value);\n element.textContent = descriptor.content ?? "";\n element.setAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE, "1");\n element.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n}\nfunction prepareClientRouteHeadEntries(entries, targetDocument = document) {\n if (entries === void 0) return [];\n if (!Array.isArray(entries) || entries.length > MAX_MANAGED_HEAD_ENTRIES) {\n throw new TypeError("Route head payload exceeds the entry limit");\n }\n const descriptors = aggregateManagedHeadDescriptors(\n entries.map(\n (entry) => descriptorFromManagedHeadTransportEntry(entry, getManagedHeadNonce(targetDocument))\n )\n );\n assertManagedHeadDescriptorBudget(descriptors);\n return descriptors;\n}\nfunction applyPreparedClientRouteHeadDescriptors(descriptors, targetDocument = document) {\n for (const descriptor of descriptors) {\n const described = [...targetDocument.head.children].flatMap((element2) => {\n const current = descriptorFromHeadElement(element2);\n return current ? [{ element: element2, descriptor: current }] : [];\n });\n if (descriptor.singletonKey) {\n const matches = described.filter(\n ({ descriptor: current }) => current.singletonKey === descriptor.singletonKey\n );\n const directive = matches.find(\n ({ element: element2 }) => element2.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1"\n );\n if (directive) {\n continue;\n }\n const reusable = matches.find(\n ({ element: element2 }) => element2.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true" || element2.getAttribute(HEAD_SHELL_PROVENANCE_ATTRIBUTE) === "true"\n );\n if (reusable) {\n writeRouteDescriptor(reusable.element, descriptor);\n continue;\n }\n }\n if (described.some(\n ({ descriptor: current }) => current.signature === descriptor.signature || headScriptKeysIntersect(current.scriptKeys, descriptor.scriptKeys)\n )) {\n continue;\n }\n const element = targetDocument.createElement(descriptor.tagName);\n writeRouteDescriptor(element, descriptor);\n targetDocument.head.appendChild(element);\n }\n}\nfunction updateRouteTitle(title, targetDocument = document) {\n if (typeof title !== "string" || !title) return;\n const titles = [...targetDocument.head.querySelectorAll("title")];\n if (titles.some((element) => element.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1")) {\n return;\n }\n let titleElement = titles.find(\n (element) => element.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true"\n );\n for (const element of titles) {\n if (element !== titleElement) element.remove();\n }\n if (!titleElement) {\n titleElement = targetDocument.createElement("title");\n titleElement.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n targetDocument.head.appendChild(titleElement);\n }\n titleElement.textContent = title;\n}\nfunction updateRouteMetaTag(targetDocument, selector, attributeName, attributeValue, content) {\n const matches = [...targetDocument.head.querySelectorAll(selector)];\n if (matches.some((element) => element.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1")) {\n return;\n }\n let metaTag = matches.find(\n (element) => element.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true"\n );\n if (!metaTag) {\n metaTag = targetDocument.createElement("meta");\n metaTag.setAttribute(attributeName, attributeValue);\n metaTag.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n targetDocument.head.appendChild(metaTag);\n }\n metaTag.setAttribute("content", content);\n}\nfunction updateRouteMetaTags(metadata, targetDocument = document) {\n if (typeof metadata.description === "string" && metadata.description) {\n updateRouteMetaTag(\n targetDocument,\n \'meta[name="description"]\',\n "name",\n "description",\n metadata.description\n );\n }\n if (typeof metadata.ogTitle === "string" && metadata.ogTitle) {\n updateRouteMetaTag(\n targetDocument,\n \'meta[property="og:title"]\',\n "property",\n "og:title",\n metadata.ogTitle\n );\n }\n}\n\n// src/routing/client/dom-utils.ts\nvar logger2 = rendererLogger.component("veryfront");\nvar PARSED_ROUTE_HEAD_CONTENT_PROPERTY = "__veryfront_parsed_route_head_content";\nfunction isInternalLink(target) {\n const href = target.getAttribute("href");\n if (!href) return false;\n if (href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("#")) return false;\n const linkTarget = target.getAttribute("target");\n if (linkTarget === "_blank" || target.hasAttribute("download")) return false;\n return true;\n}\nfunction findAnchorElement(element) {\n let current = element;\n while (current && current.tagName !== "A") {\n current = current.parentElement;\n }\n return current instanceof HTMLAnchorElement ? current : null;\n}\nfunction applyHeadDirectives(container) {\n const targetDocument = container.ownerDocument ?? document;\n const nodes = [...container.querySelectorAll(\'[data-veryfront-head="1"], vf-head\')].filter(\n (node) => typeof node.getAttribute !== "function" || node.getAttribute(HEAD_REACT_OWNER_ATTRIBUTE) !== "1"\n );\n if (!nodes.length) return;\n retireClientHeadOwnership(targetDocument);\n cleanManagedHeadTags(targetDocument);\n for (const wrapper of nodes) {\n const TemplateElement = targetDocument.defaultView?.HTMLTemplateElement ?? globalThis.HTMLTemplateElement;\n const contentSource = TemplateElement && wrapper instanceof TemplateElement ? wrapper.content : wrapper;\n processHeadWrapper(contentSource, targetDocument);\n wrapper.parentElement?.removeChild(wrapper);\n }\n}\nfunction cleanManagedHeadTags(targetDocument) {\n for (const element of targetDocument.head.querySelectorAll(\n `[${HEAD_LEGACY_MANAGED_ATTRIBUTE}="1"]`\n )) {\n element.parentElement?.removeChild(element);\n }\n}\nfunction processHeadWrapper(wrapper, targetDocument) {\n const ElementConstructor = targetDocument.defaultView?.Element ?? globalThis.Element;\n const activeNonce = getManagedHeadNonce(targetDocument);\n for (const node of wrapper.childNodes) {\n if (!ElementConstructor || !(node instanceof ElementConstructor)) continue;\n const tagName = node.tagName.toLowerCase();\n if (headSingletonKey(node) === "meta:charset") continue;\n const clone = targetDocument.createElement(tagName);\n for (const { name, value } of node.attributes) {\n if (name.toLowerCase() !== "nonce") clone.setAttribute(name, value);\n }\n if (activeNonce && (tagName === "script" || tagName === "style" || tagName === "link")) {\n clone.setAttribute("nonce", activeNonce);\n }\n if (node.textContent && !clone.hasAttribute("src")) {\n clone.textContent = node.textContent;\n }\n replaceExistingHeadSingleton(targetDocument, clone);\n clone.setAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE, "1");\n clone.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n targetDocument.head.appendChild(clone);\n }\n}\nfunction headSingletonKey(element) {\n const tagName = element.tagName.toLowerCase();\n if (tagName === "title") return "title";\n if (tagName !== "meta" && tagName !== "link") return void 0;\n const attributes = /* @__PURE__ */ Object.create(null);\n if (!element.attributes) return void 0;\n for (const { name, value } of element.attributes) attributes[name.toLowerCase()] = value;\n if (tagName === "meta" && attributes["http-equiv"]?.trim().toLowerCase() === "content-type") {\n return "meta:charset";\n }\n return tagName === "meta" ? headMetaSingletonKeyFromRecord(attributes) : headLinkSingletonKeyFromRecord(attributes);\n}\nfunction replaceExistingHeadSingleton(targetDocument, replacement) {\n const singletonKey2 = headSingletonKey(replacement);\n if (!singletonKey2 || singletonKey2 === "meta:charset") return;\n for (const existing of [...targetDocument.head?.children ?? []]) {\n if (headSingletonKey(existing) === singletonKey2) existing.remove();\n }\n}\nfunction manageFocus(container) {\n try {\n const focusElement = container.querySelector("[data-router-focus]") || container.querySelector("main") || container.querySelector("h1");\n focusElement?.focus?.({ preventScroll: true });\n } catch (error) {\n logger2.warn("focus management failed", error);\n }\n}\nfunction extractPageDataFromScript() {\n const pageDataScript = document.querySelector("script[data-veryfront-page]");\n if (!pageDataScript) return null;\n try {\n const content = pageDataScript.textContent;\n if (!content) {\n logger2.warn("Page data script has no content");\n return {};\n }\n return JSON.parse(content);\n } catch (error) {\n logger2.error("Failed to parse page data:", error);\n return null;\n }\n}\nfunction descriptorFromDocumentHeadElement(element) {\n const tagName = element.tagName.toLowerCase();\n const record = /* @__PURE__ */ Object.create(null);\n for (const { name, value } of element.attributes) {\n if (!isHeadFrameworkAttribute(name) && name.toLowerCase() !== "nonce") {\n record[name.toLowerCase()] = value;\n }\n }\n const supportsText = tagName === "title" || tagName === "script" || tagName === "style";\n if (supportsText) record[PARSED_ROUTE_HEAD_CONTENT_PROPERTY] = element.textContent ?? "";\n return descriptorFromManagedHeadRecord(tagName, record, {\n ...supportsText && { contentProperty: PARSED_ROUTE_HEAD_CONTENT_PROPERTY }\n });\n}\nfunction payloadDescriptors(root) {\n if (!root || typeof root.querySelectorAll !== "function") return [];\n const descriptors = [];\n for (const element of root.querySelectorAll(`[${HEAD_SSR_PAYLOAD_ATTRIBUTE}]`)) {\n if (element.getAttribute(HEAD_REACT_OWNER_ATTRIBUTE) !== "1") continue;\n const payload = element.getAttribute(HEAD_SSR_PAYLOAD_ATTRIBUTE);\n if (payload) descriptors.push(...deserializeManagedHeadPayload(payload));\n }\n return descriptors;\n}\nfunction snapshotClientRouteHead(targetDocument = document) {\n const descriptors = [];\n let hasStructuredPayload = false;\n const hydrationDataScript = targetDocument.getElementById("veryfront-hydration-data");\n if (hydrationDataScript?.textContent) {\n try {\n const hydrationData = JSON.parse(hydrationDataScript.textContent);\n if (typeof hydrationData.managedHeadPayload === "string") {\n descriptors.push(...deserializeManagedHeadPayload(hydrationData.managedHeadPayload));\n hasStructuredPayload = true;\n }\n } catch {\n }\n }\n const committedDescriptors = payloadDescriptors(targetDocument.getElementById("root"));\n descriptors.push(...committedDescriptors);\n const fallbackSelector = [\n ...committedDescriptors.length === 0 ? [`[${HEAD_PROVENANCE_ATTRIBUTE}="true"]`] : [],\n ...!hasStructuredPayload ? [`[${HEAD_SHELL_PROVENANCE_ATTRIBUTE}="true"]`] : []\n ].join(", ");\n if (fallbackSelector && targetDocument.head?.querySelectorAll) {\n for (const element of targetDocument.head.querySelectorAll(fallbackSelector)) {\n const descriptor = descriptorFromDocumentHeadElement(element);\n if (descriptor) descriptors.push(descriptor);\n }\n }\n const aggregated = aggregateManagedHeadDescriptors(descriptors);\n assertManagedHeadDescriptorBudget(aggregated);\n return aggregated.map(managedHeadDescriptorToTransportEntry);\n}\nfunction parsePageDataFromHTML(html) {\n const doc = new DOMParser().parseFromString(html, "text/html");\n const root = doc.getElementById("root");\n if (!root) logger2.warn("[Veryfront] No root element found in HTML");\n const content = root?.innerHTML ?? "";\n const pageDataScript = doc.querySelector("script[data-veryfront-page]");\n let pageData = {};\n if (pageDataScript) {\n try {\n const scriptContent = pageDataScript.textContent;\n if (!scriptContent) {\n logger2.warn("Page data script in HTML has no content");\n } else {\n pageData = JSON.parse(scriptContent);\n }\n } catch (error) {\n logger2.error("Failed to parse page data from HTML:", error);\n }\n }\n let dependencyPinningCacheKey;\n const hydrationDataScript = doc.getElementById("veryfront-hydration-data");\n if (hydrationDataScript?.textContent) {\n try {\n const hydrationData = JSON.parse(hydrationDataScript.textContent);\n if (typeof hydrationData.dependencyPinningCacheKey === "string") {\n dependencyPinningCacheKey = hydrationData.dependencyPinningCacheKey;\n }\n } catch (error) {\n logger2.error("Failed to parse hydration data from HTML:", error);\n }\n }\n const managedHead = snapshotClientRouteHead(doc);\n if (managedHead.some((entry) => entry.tagName === "script") || typeof root?.querySelector === "function" && root.querySelector("script")) {\n pageData = { ...pageData, requiresFullDocumentNavigation: true };\n }\n return { content, pageData, managedHead, dependencyPinningCacheKey };\n}\n\n// src/rendering/client/browser-stubs/config.ts\nvar DEFAULT_PREFETCH_DELAY_MS = 100;\nvar PAGE_TRANSITION_DELAY_MS = 150;\n\n// src/routing/client/navigation-handlers.ts\nvar logger3 = rendererLogger.component("veryfront");\nvar MAX_SCROLL_POSITIONS = 100;\nvar NavigationHandlers = class {\n constructor(prefetchDelay = DEFAULT_PREFETCH_DELAY_MS, prefetchOptions = {}) {\n __publicField(this, "prefetchQueue", /* @__PURE__ */ new Set());\n __publicField(this, "pendingTimeouts", /* @__PURE__ */ new Map());\n __publicField(this, "scrollPositions", /* @__PURE__ */ new Map());\n __publicField(this, "isPopStateNav", false);\n __publicField(this, "prefetchDelay");\n __publicField(this, "prefetchOptions");\n this.prefetchDelay = prefetchDelay;\n this.prefetchOptions = prefetchOptions;\n }\n createClickHandler(callbacks) {\n return (event) => {\n if (!(event.target instanceof HTMLElement)) return;\n const anchor = findAnchorElement(event.target);\n if (!anchor || !isInternalLink(anchor)) return;\n const href = anchor.getAttribute("href");\n if (!href) return;\n event.preventDefault();\n callbacks.onNavigate(href);\n };\n }\n createPopStateHandler(callbacks) {\n return (_event) => {\n this.isPopStateNav = true;\n const { pathname, search, hash } = globalThis.location;\n callbacks.onNavigate(`${pathname}${search}${hash}`);\n };\n }\n createMouseOverHandler(callbacks) {\n return (event) => {\n if (!(event.target instanceof HTMLElement)) return;\n if (event.target.tagName !== "A") return;\n const href = event.target.getAttribute("href");\n if (!href || href.startsWith("http") || href.startsWith("#")) return;\n if (!this.shouldPrefetchOnHover(event.target)) return;\n if (this.prefetchQueue.has(href)) return;\n this.prefetchQueue.add(href);\n const timeoutId = setTimeout(() => {\n callbacks.onPrefetch(href);\n this.prefetchQueue.delete(href);\n this.pendingTimeouts.delete(href);\n }, this.prefetchDelay);\n this.pendingTimeouts.set(href, timeoutId);\n };\n }\n shouldPrefetchOnHover(target) {\n const prefetchAttribute = target.getAttribute("data-prefetch");\n if (prefetchAttribute === "false") return false;\n if (prefetchAttribute === "true") return true;\n return Boolean(this.prefetchOptions.hover);\n }\n saveScrollPosition(path) {\n try {\n if (this.scrollPositions.size >= MAX_SCROLL_POSITIONS) {\n const oldest = this.scrollPositions.keys().next().value;\n if (oldest) this.scrollPositions.delete(oldest);\n }\n const scrollY = globalThis.scrollY;\n if (typeof scrollY !== "number") {\n logger3.debug("No valid scrollY value available");\n this.scrollPositions.set(path, 0);\n return;\n }\n this.scrollPositions.set(path, scrollY);\n } catch (error) {\n logger3.warn("failed to record scroll position", error);\n }\n }\n getScrollPosition(path) {\n const position = this.scrollPositions.get(path);\n if (position === void 0) {\n logger3.debug(`No scroll position stored for ${path}`);\n return 0;\n }\n return position;\n }\n isPopState() {\n return this.isPopStateNav;\n }\n clearPopStateFlag() {\n this.isPopStateNav = false;\n }\n clear() {\n for (const timeoutId of this.pendingTimeouts.values()) clearTimeout(timeoutId);\n this.pendingTimeouts.clear();\n this.prefetchQueue.clear();\n this.scrollPositions.clear();\n this.isPopStateNav = false;\n }\n};\n\n// src/rendering/client/browser-stubs/error-registry.ts\nfunction createBrowserError(name, fallbackMessage) {\n return {\n create(options = {}) {\n const error = new Error(options.detail ?? fallbackMessage);\n error.name = name;\n Object.assign(error, {\n status: options.status,\n context: options.context\n });\n return error;\n }\n };\n}\nvar NETWORK_ERROR = createBrowserError("NetworkError", "Network request failed");\nvar SECURITY_VIOLATION = createBrowserError("SecurityViolation", "Security violation");\n\n// src/html/html-detection.ts\nfunction isFullHTMLDocument(content) {\n const trimmed = content.trim().toLowerCase();\n return trimmed.startsWith("");\n}\n\n// src/routing/client/page-loader.ts\nvar logger4 = rendererLogger.component("veryfront");\nvar MAX_CACHE_SIZE = 50;\nvar HYDRATION_DATA_ID = "veryfront-hydration-data";\nvar DEPENDENCY_PINNING_RESPONSE_HEADER = "x-veryfront-dependency-pins";\nfunction reloadBrowserDocument(url) {\n if (typeof globalThis.location !== "undefined") {\n globalThis.location.assign(url);\n }\n}\nfunction readDependencyPinningCacheKey(doc) {\n if (!doc) return "off";\n try {\n const hydrationDataElement = doc.getElementById(HYDRATION_DATA_ID);\n if (!hydrationDataElement?.textContent) return "off";\n const hydrationData = JSON.parse(hydrationDataElement.textContent);\n return typeof hydrationData.dependencyPinningCacheKey === "string" && hydrationData.dependencyPinningCacheKey.startsWith("on:") ? hydrationData.dependencyPinningCacheKey : "off";\n } catch (error) {\n logger4.debug("Failed to read dependency snapshot from hydration data:", error);\n return "off";\n }\n}\nvar PageLoader = class {\n constructor(doc = typeof document === "undefined" ? void 0 : document, reloadDocument = reloadBrowserDocument) {\n __publicField(this, "cache", /* @__PURE__ */ new Map());\n __publicField(this, "spaCache", /* @__PURE__ */ new Map());\n __publicField(this, "pendingRequests", /* @__PURE__ */ new Map());\n __publicField(this, "pendingSpaRequests", /* @__PURE__ */ new Map());\n /**\n * A loader belongs to the dependency snapshot of the document that created it.\n * Keeping this immutable also prevents cached or in-flight route data from\n * crossing snapshot boundaries if the hydration element is later replaced.\n */\n __publicField(this, "dependencyPinningCacheKey");\n __publicField(this, "reloadDocument");\n __publicField(this, "snapshotRecoveryStarted", false);\n this.dependencyPinningCacheKey = readDependencyPinningCacheKey(doc);\n this.reloadDocument = reloadDocument;\n }\n evictIfFull(map) {\n if (map.size < MAX_CACHE_SIZE) return;\n const oldest = map.keys().next().value;\n if (oldest) map.delete(oldest);\n }\n getCached(path) {\n return this.cache.get(this.snapshotScopedPath(path));\n }\n isCached(path) {\n return this.cache.has(this.snapshotScopedPath(path));\n }\n setCache(path, data) {\n this.evictIfFull(this.cache);\n this.cache.set(this.snapshotScopedPath(path), data);\n }\n clearCache() {\n this.cache.clear();\n this.spaCache.clear();\n this.pendingRequests.clear();\n this.pendingSpaRequests.clear();\n }\n getSpaCached(path) {\n return this.spaCache.get(this.snapshotScopedPath(path));\n }\n isSpaDataCached(path) {\n return this.spaCache.has(this.snapshotScopedPath(path));\n }\n setSpaCache(path, data) {\n this.evictIfFull(this.spaCache);\n this.spaCache.set(this.snapshotScopedPath(path), data);\n }\n async fetchPageData(path, reloadOnSnapshotFailure = true) {\n try {\n return await this.tryFetchJSON(path) ?? await this.fetchAndParseHTML(path);\n } catch (error) {\n this.recoverSnapshotFailure(error, path, reloadOnSnapshotFailure);\n throw error;\n }\n }\n async tryFetchJSON(path) {\n let response;\n try {\n const navigationUrl = new URL(path, "http://veryfront.local");\n const dataPath = navigationUrl.pathname === "/" ? "/index" : navigationUrl.pathname;\n const endpoint = `/_veryfront/data${dataPath}.json${navigationUrl.search}`;\n response = await fetch(endpoint, {\n headers: this.navigationHeaders("client")\n });\n } catch (error) {\n logger4.debug(`JSON fetch failed for ${path}, falling back to HTML:`, error);\n return null;\n }\n if (response.status === 409) {\n this.failDependencySnapshot(\n path,\n `Dependency snapshot is unavailable for ${path}`\n );\n }\n if (!response.ok) return null;\n let data;\n try {\n data = await response.json();\n } catch (error) {\n logger4.debug(`JSON response was invalid for ${path}, falling back to HTML:`, error);\n return null;\n }\n this.assertDependencySnapshot(\n data.dependencyPinningCacheKey,\n path,\n "route data"\n );\n if (typeof data.html === "string" && isFullHTMLDocument(data.html)) {\n const parsed = parsePageDataFromHTML(data.html);\n this.assertDependencySnapshot(\n parsed.dependencyPinningCacheKey,\n path,\n "route data HTML body"\n );\n return {\n ...parsed.pageData,\n ...data,\n html: parsed.content,\n managedHead: parsed.managedHead\n };\n }\n return data;\n }\n async fetchAndParseHTML(path) {\n const response = await fetch(path, {\n headers: this.navigationHeaders("client")\n });\n if (response.status === 409) {\n this.failDependencySnapshot(\n path,\n `Dependency snapshot is unavailable for ${path}`\n );\n }\n if (!response.ok) {\n throw NETWORK_ERROR.create({\n detail: `Failed to fetch ${path}`,\n status: response.status,\n context: { path }\n });\n }\n this.assertDependencySnapshot(\n response.headers.get(DEPENDENCY_PINNING_RESPONSE_HEADER),\n path,\n "HTML response"\n );\n const html = await response.text();\n const {\n content,\n pageData,\n managedHead,\n dependencyPinningCacheKey\n } = parsePageDataFromHTML(html);\n this.assertDependencySnapshot(\n dependencyPinningCacheKey,\n path,\n "HTML body"\n );\n return { ...pageData, html: content, managedHead };\n }\n loadPage(path) {\n return this.loadPageWithSnapshotRecovery(path, true);\n }\n loadPageWithSnapshotRecovery(path, reloadOnSnapshotFailure) {\n const cachedData = this.getCached(path);\n if (cachedData) {\n logger4.debug(`Loading ${path} from cache`);\n return Promise.resolve(cachedData);\n }\n const pendingKey = this.snapshotScopedPath(path);\n const pending = this.pendingRequests.get(pendingKey);\n if (pending) {\n logger4.debug(`Reusing pending request for ${path}`);\n return this.withSnapshotRecovery(\n pending,\n path,\n reloadOnSnapshotFailure\n );\n }\n logger4.debug(`Creating pending request for ${path}`);\n const request = this.createPendingRequest(pendingKey, this.pendingRequests, async () => {\n const data = await this.fetchPageData(path, false);\n this.setCache(path, data);\n return data;\n });\n return this.withSnapshotRecovery(\n request,\n path,\n reloadOnSnapshotFailure\n );\n }\n async prefetch(path) {\n if (this.isCached(path)) return;\n logger4.debug(`Prefetching ${path}`);\n try {\n await this.loadPageWithSnapshotRecovery(path, false);\n } catch (error) {\n logger4.warn(\n `[Veryfront] Failed to prefetch ${path}`,\n error instanceof Error ? error : new Error(String(error))\n );\n }\n }\n async fetchSpaPageData(path, reloadOnSnapshotFailure = true) {\n try {\n const navigationUrl = new URL(path, "http://veryfront.local");\n const normalizedPath = navigationUrl.pathname === "/" ? "index" : navigationUrl.pathname.replace(/^\\//, "");\n const endpoint = `/_veryfront/page-data/${normalizedPath}.json${navigationUrl.search}`;\n logger4.debug(`Fetching SPA page data from ${endpoint}`);\n const response = await fetch(endpoint, {\n headers: this.navigationHeaders("spa")\n });\n if (response.status === 409) {\n this.failDependencySnapshot(\n path,\n `Dependency snapshot is unavailable for SPA page data ${path}`\n );\n }\n if (!response.ok) {\n throw NETWORK_ERROR.create({\n detail: `Failed to fetch SPA page data for ${path}`,\n status: response.status,\n context: { path }\n });\n }\n const data = await response.json();\n this.assertDependencySnapshot(\n data.dependencyPinningCacheKey,\n path,\n "SPA page data"\n );\n return data;\n } catch (error) {\n this.recoverSnapshotFailure(error, path, reloadOnSnapshotFailure);\n throw error;\n }\n }\n loadSpaPageData(path) {\n return this.loadSpaPageDataWithSnapshotRecovery(path, true);\n }\n loadSpaPageDataWithSnapshotRecovery(path, reloadOnSnapshotFailure) {\n const cachedData = this.getSpaCached(path);\n if (cachedData) {\n logger4.debug(`Loading SPA data for ${path} from cache`);\n return Promise.resolve(cachedData);\n }\n const pendingKey = this.snapshotScopedPath(path);\n const pending = this.pendingSpaRequests.get(pendingKey);\n if (pending) {\n logger4.debug(`Reusing pending SPA request for ${path}`);\n return this.withSnapshotRecovery(\n pending,\n path,\n reloadOnSnapshotFailure\n );\n }\n logger4.debug(`Creating pending SPA request for ${path}`);\n const request = this.createPendingRequest(pendingKey, this.pendingSpaRequests, async () => {\n const data = await this.fetchSpaPageData(path, false);\n this.setSpaCache(path, data);\n return data;\n });\n return this.withSnapshotRecovery(\n request,\n path,\n reloadOnSnapshotFailure\n );\n }\n async prefetchSpaPageData(path) {\n if (this.isSpaDataCached(path)) return;\n logger4.debug(`Prefetching SPA page data for ${path}`);\n try {\n await this.loadSpaPageDataWithSnapshotRecovery(path, false);\n } catch (error) {\n logger4.warn(\n `[Veryfront] Failed to prefetch SPA data for ${path}`,\n error instanceof Error ? error : new Error(String(error))\n );\n }\n }\n createPendingRequest(path, pendingMap, fetcher) {\n const request = (async () => {\n try {\n return await fetcher();\n } finally {\n pendingMap.delete(path);\n }\n })();\n pendingMap.set(path, request);\n return request;\n }\n snapshotScopedPath(path) {\n return this.dependencyPinningCacheKey.startsWith("on:") ? `${this.dependencyPinningCacheKey}\\0${path}` : path;\n }\n navigationHeaders(type) {\n return {\n "X-Veryfront-Navigation": type,\n ...this.dependencyPinningCacheKey.startsWith("on:") ? {\n [DEPENDENCY_PINNING_RESPONSE_HEADER]: this.dependencyPinningCacheKey\n } : {}\n };\n }\n assertDependencySnapshot(actualCacheKey, path, source) {\n const expectedCacheKey = this.dependencyPinningCacheKey.startsWith("on:") ? this.dependencyPinningCacheKey : void 0;\n const normalizedActualCacheKey = typeof actualCacheKey === "string" ? actualCacheKey : void 0;\n const matches = expectedCacheKey ? normalizedActualCacheKey === expectedCacheKey : normalizedActualCacheKey === void 0 || normalizedActualCacheKey === "off";\n if (matches) return;\n this.failDependencySnapshot(\n path,\n `Dependency snapshot mismatch in ${source} for ${path}`\n );\n }\n failDependencySnapshot(path, detail) {\n throw NETWORK_ERROR.create({\n detail,\n status: 409,\n context: { path }\n });\n }\n withSnapshotRecovery(promise, path, reloadOnSnapshotFailure) {\n return promise.catch((error) => {\n this.recoverSnapshotFailure(error, path, reloadOnSnapshotFailure);\n throw error;\n });\n }\n recoverSnapshotFailure(error, path, reloadOnSnapshotFailure) {\n if (!reloadOnSnapshotFailure || typeof error !== "object" || error === null || error.status !== 409) {\n return;\n }\n if (this.snapshotRecoveryStarted) return;\n this.snapshotRecoveryStarted = true;\n try {\n this.reloadDocument(path);\n } catch (reloadError) {\n this.snapshotRecoveryStarted = false;\n logger4.warn(\n `[Veryfront] Failed to reload after dependency snapshot conflict for ${path}`,\n reloadError instanceof Error ? reloadError : new Error(String(reloadError))\n );\n }\n }\n};\n\n// src/utils/logger/core.ts\nvar ANSI = {\n reset: "\\x1B[0m",\n dim: "\\x1B[2m",\n gray: "\\x1B[90m",\n red: "\\x1B[31m",\n green: "\\x1B[32m",\n yellow: "\\x1B[33m",\n blue: "\\x1B[34m",\n magenta: "\\x1B[35m",\n cyan: "\\x1B[36m"\n};\nvar LEVEL_COLORS = {\n debug: ANSI.gray,\n info: ANSI.green,\n warn: ANSI.yellow,\n error: ANSI.red\n};\n\n// src/utils/logger/redact.ts\nvar REDACTED = "[REDACTED]";\nvar apply = Reflect.apply;\nvar regExpExec = RegExp.prototype.exec;\nvar regExpReplace = RegExp.prototype[Symbol.replace];\nvar stringCharCodeAt = String.prototype.charCodeAt;\nvar stringSlice = String.prototype.slice;\nvar stringToLowerCase = String.prototype.toLowerCase;\nvar NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]/g;\nfunction normalizeToAlphanumeric(s) {\n const lowercase = apply(stringToLowerCase, s, []);\n return apply(regExpReplace, NON_ALPHANUMERIC_PATTERN, [lowercase, ""]);\n}\nfunction sliceString(value, start, end) {\n return end === void 0 ? apply(stringSlice, value, [start]) : apply(stringSlice, value, [start, end]);\n}\nvar SENSITIVE_KEY_PATTERNS = [\n "password",\n "passwd",\n "pwd",\n "passphrase",\n "secret",\n "clientsecret",\n "token",\n "apikey",\n "accesskey",\n "privatekey",\n "credential",\n "authorization",\n "cookie",\n "bearer",\n "jwt",\n "connectionstring",\n "signature",\n "sessionid",\n "sid",\n "otp",\n "mfa",\n "pin",\n "salt",\n "xsrf",\n "csrf"\n];\nvar SENSITIVE_KEY_CACHE_MAX_SIZE = 512;\nvar SENSITIVE_KEY_CACHE_MAX_KEY_LENGTH = 128;\nvar sensitiveKeyCache = /* @__PURE__ */ new Map();\nfunction isSensitiveKey(key) {\n const cacheable = key.length <= SENSITIVE_KEY_CACHE_MAX_KEY_LENGTH;\n if (cacheable) {\n const cached = sensitiveKeyCache.get(key);\n if (cached !== void 0) return cached;\n }\n const normalized = normalizeToAlphanumeric(key);\n const sensitive = SENSITIVE_KEY_PATTERNS.some((pattern) => normalized.includes(pattern));\n if (cacheable) {\n if (sensitiveKeyCache.size >= SENSITIVE_KEY_CACHE_MAX_SIZE) {\n const oldestKey = sensitiveKeyCache.keys().next().value;\n if (oldestKey !== void 0) sensitiveKeyCache.delete(oldestKey);\n }\n sensitiveKeyCache.set(key, sensitive);\n }\n return sensitive;\n}\nvar SENSITIVE_URL_PARAMS = [\n "access_token",\n "accesstoken",\n "refresh_token",\n "api_key",\n "apikey",\n "code",\n "token",\n "secret",\n "client_secret",\n "password",\n "passwd",\n "pwd",\n "state",\n "sig",\n "signature",\n "auth",\n "x-amz-credential",\n "x-amz-signature",\n "x-amz-security-token",\n "x-goog-credential",\n "x-goog-signature"\n];\nvar NORMALIZED_SENSITIVE_URL_PARAMS = new Set(SENSITIVE_URL_PARAMS.map(normalizeToAlphanumeric));\nvar URL_USERINFO_RE = /(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi;\nvar HORIZONTAL_WHITESPACE_URL_USERINFO_RE = /(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi;\nvar MAX_URL_PARAMETER_DECODE_PASSES = 3;\nfunction isHorizontalAssignmentBoundary(character) {\n return character === " " || character === "\t" || character === "," || character === ";" || character === "&" || character === "?" || character === "#";\n}\nfunction isAsciiLetter(character) {\n if (!character) return false;\n const code = character.charCodeAt(0);\n return code >= 65 && code <= 90 || code >= 97 && code <= 122;\n}\nfunction isAssignmentKeyStartCharacter(character) {\n return isAsciiLetter(character) || character === "_" || character === "$";\n}\nfunction isAssignmentKeyCharacter(character) {\n if (!character) return false;\n const code = character.charCodeAt(0);\n return isAssignmentKeyStartCharacter(character) || code >= 48 && code <= 57 || character === "." || character === "-";\n}\nfunction assignmentStartsAt(input, start) {\n let index = start;\n const keyQuote = input[index] === `"` || input[index] === "\'" ? input[index++] : "";\n if (!isAssignmentKeyStartCharacter(input[index])) return false;\n index++;\n while (isAssignmentKeyCharacter(input[index])) index++;\n if (keyQuote) {\n if (input[index] !== keyQuote) return false;\n index++;\n }\n while (input[index] === " " || input[index] === "\t") index++;\n return input[index] === ":" || input[index] === "=";\n}\nfunction isAssignmentBoundaryCharacter(character) {\n return character === "\\r" || character === "\\n" || character === "}" || character === "]" || isHorizontalAssignmentBoundary(character);\n}\nfunction skipAssignmentBoundaryCharacters(input, start) {\n let index = start;\n while (index < input.length && isAssignmentBoundaryCharacter(input[index])) {\n index++;\n }\n return index;\n}\nfunction assignmentValueEndsAt(input, start) {\n const boundaryEnd = skipAssignmentBoundaryCharacters(input, start);\n return boundaryEnd >= input.length || assignmentStartsAt(input, boundaryEnd);\n}\nfunction redactAssignmentValue(input, start) {\n let scanStart = start;\n let preserveValueQuote = true;\n if (input.startsWith(REDACTED, start)) {\n const markerEnd = start + REDACTED.length;\n if (assignmentValueEndsAt(input, markerEnd)) {\n return {\n end: markerEnd,\n replacement: REDACTED\n };\n }\n scanStart = markerEnd;\n preserveValueQuote = false;\n }\n const wrapperQuote = preserveValueQuote && (input[scanStart] === `"` || input[scanStart] === "\'" || input[scanStart] === "`") ? input[scanStart] : "";\n let wrapperQuoteClosed = false;\n const replacement = () => wrapperQuote ? `${wrapperQuote}${REDACTED}${wrapperQuoteClosed ? wrapperQuote : ""}` : REDACTED;\n const expectedClosings = [];\n let quote = "";\n let quoteStart = -1;\n for (let index = scanStart; index < input.length; ) {\n const character = input[index];\n if (quote) {\n if (character === "\\\\") {\n index += 2;\n continue;\n }\n if (character === quote) {\n if (quoteStart === scanStart && expectedClosings.length === 0) {\n wrapperQuoteClosed = true;\n }\n quote = "";\n quoteStart = -1;\n }\n index++;\n continue;\n }\n if (character === `"` || character === "\'" || character === "`") {\n quote = character;\n quoteStart = index;\n index++;\n continue;\n }\n if (character === "{" || character === "[") {\n expectedClosings.push(character === "{" ? "}" : "]");\n index++;\n continue;\n }\n if (expectedClosings.length > 0 && (character === "}" || character === "]")) {\n if (expectedClosings.at(-1) !== character) {\n return { end: input.length, replacement: replacement() };\n }\n expectedClosings.pop();\n index++;\n if (expectedClosings.length === 0 && assignmentValueEndsAt(input, index)) {\n return { end: index, replacement: replacement() };\n }\n continue;\n }\n if (expectedClosings.length > 0 || !isAssignmentBoundaryCharacter(character)) {\n index++;\n continue;\n }\n const boundaryStart = index;\n index = skipAssignmentBoundaryCharacters(input, index);\n if (index >= input.length || assignmentStartsAt(input, index)) {\n return { end: boundaryStart, replacement: replacement() };\n }\n }\n return { end: input.length, replacement: replacement() };\n}\nfunction redactCredentialAssignments(input, prefixPattern, keyGroup, urlParameterBoundaryGroup) {\n let cursor = 0;\n let result = "";\n for (let match = apply(regExpExec, prefixPattern, [input]); match; match = apply(regExpExec, prefixPattern, [input])) {\n const key = match[keyGroup];\n if (!isSensitiveKey(key)) continue;\n const valueStart = prefixPattern.lastIndex;\n const boundary = urlParameterBoundaryGroup === void 0 ? void 0 : match[urlParameterBoundaryGroup];\n const markerEnd = valueStart + REDACTED.length;\n if ((boundary === "?" || boundary === "&" || boundary === ";") && input.startsWith(REDACTED, valueStart) && input[markerEnd] === "#") {\n continue;\n }\n const redactedValue = redactAssignmentValue(input, valueStart);\n result += sliceString(input, cursor, match.index);\n result += match[0];\n result += redactedValue.replacement;\n cursor = redactedValue.end;\n prefixPattern.lastIndex = redactedValue.end;\n }\n return cursor === 0 ? input : result + sliceString(input, cursor);\n}\nfunction isStandaloneUrlAuthorityBeforeWhitespace(scheme, user, password) {\n const whitespaceIndex = password.search(/[ \\t]/);\n if (whitespaceIndex < 0) return false;\n const authority = `${user}:${sliceString(password, 0, whitespaceIndex)}`;\n const candidate = scheme === "//" ? `https://${authority}` : `${scheme}${authority}`;\n try {\n const url = new URL(candidate);\n return url.username.length === 0 && url.password.length === 0;\n } catch {\n return false;\n }\n}\nfunction decodeUrlParameterName(value) {\n let decoded = value;\n for (let pass = 0; pass < MAX_URL_PARAMETER_DECODE_PASSES; pass++) {\n let next;\n try {\n next = decodeURIComponent(decoded);\n } catch {\n break;\n }\n if (next === decoded) break;\n decoded = next;\n }\n return decoded;\n}\nfunction sanitizeUrlCredentials(input) {\n if (typeof input !== "string" || input.length === 0) return input;\n let out = apply(regExpReplace, URL_USERINFO_RE, [\n input,\n (_match, scheme, userinfo) => {\n const colon = userinfo.indexOf(":");\n if (colon === -1) {\n return `${scheme}${REDACTED}@`;\n }\n const user = sliceString(userinfo, 0, colon);\n return `${scheme}${user}:${REDACTED}@`;\n }\n ]);\n out = apply(regExpReplace, HORIZONTAL_WHITESPACE_URL_USERINFO_RE, [\n out,\n (match, scheme, user, password) => {\n if (isStandaloneUrlAuthorityBeforeWhitespace(scheme, user, password)) {\n return match;\n }\n return `${scheme}${user}:${REDACTED}@`;\n }\n ]);\n out = apply(regExpReplace, /([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi, [\n out,\n (match, sep, key, _val) => {\n const decodedKey = decodeUrlParameterName(key);\n const sensitive = NORMALIZED_SENSITIVE_URL_PARAMS.has(normalizeToAlphanumeric(decodedKey)) || isSensitiveKey(decodedKey);\n return sensitive ? `${sep}${key}=${REDACTED}` : match;\n }\n ]);\n out = apply(regExpReplace, /(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi, [\n out,\n (_match, boundary, prefix) => `${boundary}${prefix}${REDACTED}`\n ]);\n out = apply(regExpReplace, /\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi, [\n out,\n (_match, prefix) => `${prefix}${REDACTED}`\n ]);\n out = apply(\n regExpReplace,\n /\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,\n [\n out,\n (_match, scheme, whitespace) => `${scheme}${whitespace}${REDACTED}`\n ]\n );\n out = redactCredentialAssignments(\n out,\n /(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,\n 2\n );\n out = redactCredentialAssignments(\n out,\n /(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,\n 2,\n 1\n );\n return out;\n}\n\n// src/errors/diagnostic-policy.ts\nvar ERROR_DIAGNOSTIC_MAX_LENGTH_CHARS = 2048;\nvar ERROR_OUTPUT_MAX_LENGTH_CHARS = 64 * 1024;\nvar ERROR_DOCS_SLUG_MAX_LENGTH_CHARS = 256;\nvar ERROR_DOCS_BASE_URL = "https://veryfront.com/docs/errors/";\nvar TRUNCATION_MARKER = "...[truncated]";\nvar UNKNOWN_ERROR_SLUG = "unknown-error";\nfunction truncateDiagnosticText(value, maxLength) {\n if (value.length <= maxLength) return value;\n const prefixLength = Math.max(0, maxLength - TRUNCATION_MARKER.length);\n const prefix = takeSafePrefix(value, prefixLength);\n return `${prefix}${TRUNCATION_MARKER}`;\n}\nfunction takeSafePrefix(value, length) {\n let prefix = value.slice(0, length);\n const finalCodeUnit = prefix.charCodeAt(prefix.length - 1);\n if (finalCodeUnit >= 55296 && finalCodeUnit <= 56319) {\n prefix = prefix.slice(0, -1);\n }\n return prefix;\n}\nfunction replaceLoneSurrogates(value) {\n let result = "";\n for (let index = 0; index < value.length; index++) {\n const codeUnit = value.charCodeAt(index);\n if (codeUnit >= 55296 && codeUnit <= 56319) {\n const nextCodeUnit = value.charCodeAt(index + 1);\n if (nextCodeUnit >= 56320 && nextCodeUnit <= 57343) {\n result += value.slice(index, index + 2);\n index++;\n } else {\n result += "\\uFFFD";\n }\n continue;\n }\n result += codeUnit >= 56320 && codeUnit <= 57343 ? "\\uFFFD" : value.charAt(index);\n }\n return result;\n}\nfunction sanitizeBoundedDiagnosticText(value) {\n if (typeof value !== "string") return REDACTED;\n return truncateDiagnosticText(\n sanitizeUrlCredentials(value),\n ERROR_DIAGNOSTIC_MAX_LENGTH_CHARS\n );\n}\nfunction sanitizeBoundedErrorSlug(slug) {\n const sanitized = typeof slug === "string" ? sanitizeUrlCredentials(slug) : UNKNOWN_ERROR_SLUG;\n const bounded = truncateDiagnosticText(\n sanitized || UNKNOWN_ERROR_SLUG,\n ERROR_DOCS_SLUG_MAX_LENGTH_CHARS\n );\n const normalized = replaceLoneSurrogates(bounded);\n return normalized === "." || normalized === ".." ? UNKNOWN_ERROR_SLUG : normalized;\n}\nfunction buildErrorDocsUrl(slug) {\n const segment = encodeURIComponent(sanitizeBoundedErrorSlug(slug));\n return `${ERROR_DOCS_BASE_URL}${segment}`;\n}\n\n// src/errors/types.ts\nvar freeze = Object.freeze;\nvar getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors;\nvar numberIsFinite = Number.isFinite;\nvar VERYFRONT_ERROR_INSTANCES = /* @__PURE__ */ new WeakSet();\nvar ERROR_CATEGORIES = /* @__PURE__ */ new Set([\n "CONFIG",\n "BUILD",\n "RUNTIME",\n "ROUTE",\n "MODULE",\n "SERVER",\n "BOUNDARY",\n "DEV",\n "DEPLOY",\n "AGENT",\n "GENERAL"\n]);\nfunction defineError(definition) {\n const snapshot = { ...definition };\n const registered = {\n ...snapshot,\n create(options) {\n const message = options?.message;\n const detail = options?.detail;\n const cause = options?.cause;\n const instance = options?.instance;\n const context = options?.context;\n const status = options?.status ?? snapshot.status;\n return new VeryfrontError(message || detail || snapshot.title, {\n slug: snapshot.slug,\n category: snapshot.category,\n status,\n title: snapshot.title,\n suggestion: snapshot.suggestion,\n exitCode: snapshot.exitCode,\n detail,\n cause,\n instance,\n context\n });\n }\n };\n return freeze(registered);\n}\nvar VeryfrontError = class extends Error {\n constructor(message, options) {\n super(message);\n __publicField(this, "slug");\n __publicField(this, "category");\n __publicField(this, "status");\n __publicField(this, "title");\n __publicField(this, "suggestion");\n /** Process exit code for the CLI boundary. */\n __publicField(this, "exitCode");\n __publicField(this, "detail");\n __publicField(this, "cause");\n __publicField(this, "instance");\n __publicField(this, "context");\n VERYFRONT_ERROR_INSTANCES.add(this);\n this.name = "VeryfrontError";\n this.slug = options.slug;\n this.category = options.category;\n this.status = options.status;\n this.title = options.title;\n this.suggestion = options.suggestion;\n this.exitCode = options.exitCode;\n this.detail = options.detail;\n this.cause = options.cause;\n this.instance = options.instance;\n this.context = options.context;\n }\n /**\n * Convert to RFC 9457 Problem Details format\n */\n toRFC9457() {\n const snapshot = snapshotVeryfrontError(this);\n if (!snapshot) {\n return {\n type: buildErrorDocsUrl("unknown-error"),\n title: "Unknown/unclassified error",\n status: 500,\n category: "GENERAL"\n };\n }\n return {\n type: buildErrorDocsUrl(snapshot.slug),\n title: sanitizeBoundedDiagnosticText(snapshot.title),\n status: snapshot.status,\n detail: snapshot.detail === void 0 ? void 0 : sanitizeBoundedDiagnosticText(snapshot.detail),\n instance: snapshot.instance === void 0 ? void 0 : sanitizeBoundedDiagnosticText(snapshot.instance),\n category: snapshot.category,\n suggestion: snapshot.suggestion === void 0 ? void 0 : sanitizeBoundedDiagnosticText(snapshot.suggestion),\n cause: typeof snapshot.cause === "string" ? sanitizeBoundedDiagnosticText(snapshot.cause) : void 0\n };\n }\n /**\n * Get documentation URL for this error\n */\n getDocsUrl() {\n const snapshot = snapshotVeryfrontError(this);\n return buildErrorDocsUrl(snapshot?.slug ?? "unknown-error");\n }\n};\nfunction isVeryfrontErrorInstance(error) {\n return typeof error === "object" && error !== null && VERYFRONT_ERROR_INSTANCES.has(error);\n}\nfunction snapshotVeryfrontError(error) {\n if (!isVeryfrontErrorInstance(error)) return null;\n return snapshotKnownVeryfrontError(error);\n}\nfunction snapshotKnownVeryfrontError(error) {\n try {\n if (!isVeryfrontErrorInstance(error)) return null;\n const descriptors = getOwnPropertyDescriptors(error);\n const dataValue = (key) => {\n const descriptor = descriptors[key];\n return descriptor && "value" in descriptor ? descriptor.value : void 0;\n };\n const slug = dataValue("slug");\n const category = dataValue("category");\n const status = dataValue("status");\n const title = dataValue("title");\n const message = dataValue("message");\n const suggestion = dataValue("suggestion");\n const exitCode = dataValue("exitCode");\n const detail = dataValue("detail");\n const cause = dataValue("cause");\n const instance = dataValue("instance");\n const context = dataValue("context");\n const stack = dataValue("stack");\n if (typeof slug !== "string" || !ERROR_CATEGORIES.has(category) || typeof status !== "number" || !numberIsFinite(status) || typeof title !== "string" || typeof message !== "string" || suggestion !== void 0 && typeof suggestion !== "string" || exitCode !== void 0 && (typeof exitCode !== "number" || !numberIsFinite(exitCode)) || detail !== void 0 && typeof detail !== "string" || instance !== void 0 && typeof instance !== "string" || stack !== void 0 && typeof stack !== "string") {\n return null;\n }\n return {\n slug,\n category,\n status,\n title,\n message,\n suggestion,\n exitCode,\n detail,\n cause,\n instance,\n context,\n stack\n };\n } catch {\n return null;\n }\n}\n\n// src/errors/error-registry/general.ts\nvar UNKNOWN_ERROR = defineError({\n slug: "unknown-error",\n category: "GENERAL",\n status: 500,\n title: "Unknown/unclassified error",\n suggestion: "Check logs for more details"\n});\nvar AUTHENTICATION_REQUIRED = defineError({\n slug: "authentication-required",\n category: "GENERAL",\n status: 401,\n title: "Authentication required",\n suggestion: "Set VERYFRONT_API_TOKEN or run \'veryfront login\'"\n});\nvar PERMISSION_DENIED = defineError({\n slug: "permission-denied",\n category: "GENERAL",\n status: 403,\n title: "File/resource permission denied",\n suggestion: "Check file permissions and access rights"\n});\nvar FILE_NOT_FOUND = defineError({\n slug: "file-not-found",\n category: "GENERAL",\n status: 404,\n title: "File not found",\n suggestion: "Verify the file path exists"\n});\nvar RESOURCE_NOT_FOUND = defineError({\n slug: "resource-not-found",\n category: "GENERAL",\n status: 404,\n title: "Requested resource not found",\n suggestion: "Verify the referenced resource ID or name exists"\n});\nvar INVALID_ARGUMENT = defineError({\n slug: "invalid-argument",\n category: "GENERAL",\n status: 400,\n title: "Invalid function argument",\n suggestion: "Check argument types and values",\n exitCode: 2\n});\nvar TIMEOUT_ERROR = defineError({\n slug: "timeout-error",\n category: "GENERAL",\n status: 408,\n title: "Operation timed out",\n suggestion: "Increase timeout or optimize the operation"\n});\nvar INITIALIZATION_ERROR = defineError({\n slug: "initialization-error",\n category: "GENERAL",\n status: 500,\n title: "Initialization failed",\n suggestion: "Check initialization requirements and dependencies"\n});\nvar NOT_SUPPORTED = defineError({\n slug: "not-supported",\n category: "GENERAL",\n status: 501,\n title: "Feature not supported",\n suggestion: "Check documentation for supported features"\n});\nvar SECURITY_VIOLATION2 = defineError({\n slug: "security-violation",\n category: "GENERAL",\n status: 403,\n title: "Security violation detected",\n suggestion: "Check for path traversal or unauthorized access attempts"\n});\nvar INPUT_VALIDATION_FAILED = defineError({\n slug: "input-validation-failed",\n category: "GENERAL",\n status: 400,\n title: "Input validation failed",\n suggestion: "Check request input against validation rules"\n});\nvar PROJECT_SOURCE_EMPTY = defineError({\n slug: "project-source-empty",\n category: "GENERAL",\n status: 400,\n title: "Project source is empty",\n suggestion: "Add project files or run \'veryfront init\'"\n});\n\n// src/html/html-escape.ts\nvar MAX_ATTRIBUTE_VALUE_BYTES = 64 * 1024;\nvar MAX_TOTAL_ATTRIBUTE_BYTES = 1024 * 1024;\nvar textEncoder = new TextEncoder();\n\n// src/security/client/html-sanitizer.ts\nvar SUSPICIOUS_PATTERN_SPECS = [\n { source: String.raw`]*>[\\s\\S]*?<\\/script>`, flags: "gi", name: "inline script" },\n { source: String.raw`javascript:`, flags: "gi", name: "javascript: URL" },\n { source: String.raw`\\bon\\w+\\s*=`, flags: "gi", name: "event handler attribute" },\n { source: String.raw`data:\\s*text\\/html`, flags: "gi", name: "data: HTML URL" }\n];\nfunction createSuspiciousPatterns() {\n return SUSPICIOUS_PATTERN_SPECS.map(({ source, flags, name }) => ({\n pattern: new RegExp(source, flags),\n name\n }));\n}\nfunction isDevMode() {\n const g = globalThis;\n return g.__VERYFRONT_DEV__ === true || g.Deno?.env?.get?.("VERYFRONT_ENV") === "development";\n}\nfunction validateTrustedHtml(html, options = {}) {\n const { allowInlineScripts = false, strict = false, warn = true } = options;\n for (const { pattern, name } of createSuspiciousPatterns()) {\n if (allowInlineScripts && name === "inline script") continue;\n pattern.lastIndex = 0;\n if (!pattern.test(html)) continue;\n if (warn) console.warn(`[Security] Suspicious ${name} detected in server HTML`);\n if (strict || !isDevMode()) {\n throw SECURITY_VIOLATION.create({ detail: `Potentially unsafe HTML: ${name} detected` });\n }\n }\n return html;\n}\n\n// src/routing/client/page-transition.ts\nvar logger5 = rendererLogger.component("veryfront");\nvar PageTransition = class {\n constructor(setupViewportPrefetch) {\n __publicField(this, "setupViewportPrefetch", setupViewportPrefetch);\n __publicField(this, "pendingTransitionTimeout");\n __publicField(this, "pendingRoot");\n }\n destroy() {\n this.cancelPendingTransition();\n }\n cancelPendingTransition() {\n if (this.pendingTransitionTimeout !== void 0) {\n clearTimeout(this.pendingTransitionTimeout);\n this.pendingTransitionTimeout = void 0;\n }\n if (this.pendingRoot) {\n this.pendingRoot.style.opacity = "1";\n this.pendingRoot = void 0;\n }\n }\n updatePage(data, isPopState, scrollY) {\n this.cancelPendingTransition();\n if (data.requiresFullDocumentNavigation || data.managedHead?.some((entry) => entry.tagName === "script") || typeof data.html === "string" && / {\n this.pendingTransitionTimeout = void 0;\n this.pendingRoot = void 0;\n try {\n retireClientHeadOwnership(rootElement.ownerDocument);\n rootElement.innerHTML = trustedHtml;\n applyHeadDirectives(rootElement);\n applyPreparedClientRouteHeadDescriptors(preparedHead, rootElement.ownerDocument);\n this.updateDocumentMetadata(rootElement.ownerDocument, data, retainedTitle);\n this.setupViewportPrefetch(rootElement);\n manageFocus(rootElement);\n this.handleScroll(isPopState, scrollY);\n } catch (error) {\n logger5.error("Route transition commit failed; reloading the document", error);\n globalThis.location?.reload();\n } finally {\n rootElement.style.opacity = "1";\n }\n }, PAGE_TRANSITION_DELAY_MS);\n }\n handleScroll(isPopState, scrollY) {\n try {\n globalThis.scrollTo(0, isPopState ? scrollY : 0);\n } catch (error) {\n logger5.warn("scroll handling failed", error);\n }\n }\n showError(error) {\n const rootElement = document.getElementById("root");\n if (!rootElement) return;\n const errorDiv = document.createElement("div");\n errorDiv.className = "veryfront-error-page";\n const heading = document.createElement("h1");\n heading.textContent = "Oops! Something went wrong";\n const message = document.createElement("p");\n message.textContent = error.message;\n const button = document.createElement("button");\n button.type = "button";\n button.textContent = "Reload Page";\n button.onclick = () => globalThis.location.reload();\n errorDiv.append(heading, message, button);\n retireClientHeadOwnership(rootElement.ownerDocument);\n rootElement.innerHTML = "";\n rootElement.appendChild(errorDiv);\n }\n setLoadingState(loading) {\n const indicator = document.getElementById("veryfront-loading");\n if (indicator) indicator.style.display = loading ? "block" : "none";\n document.body.classList.toggle("veryfront-loading", loading);\n }\n};\n\n// src/routing/client/viewport-prefetch.ts\nvar logger6 = rendererLogger.component("veryfront");\nvar ViewportPrefetch = class {\n constructor(prefetchCallback, prefetchOptions = {}) {\n __publicField(this, "observer", null);\n __publicField(this, "prefetchCallback");\n __publicField(this, "prefetchOptions");\n this.prefetchCallback = prefetchCallback;\n this.prefetchOptions = prefetchOptions;\n }\n setup(root) {\n try {\n if (!("IntersectionObserver" in globalThis)) return;\n this.observer?.disconnect();\n this.createObserver();\n this.observeLinks(root);\n } catch (error) {\n logger6.debug("setupViewportPrefetch failed", error);\n }\n }\n createObserver() {\n this.observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue;\n if (!(entry.target instanceof HTMLAnchorElement)) continue;\n const href = entry.target.getAttribute("href");\n if (href) this.prefetchCallback(href);\n this.observer?.unobserve(entry.target);\n }\n },\n { rootMargin: "200px" }\n );\n }\n observeLinks(root) {\n const anchors = root.querySelectorAll(\'a[href]:not([target="_blank"])\');\n const isViewportEnabled = Boolean(this.prefetchOptions.viewport);\n for (const anchor of anchors) {\n if (!this.shouldObserveAnchor(anchor, isViewportEnabled)) continue;\n this.observer?.observe(anchor);\n }\n }\n shouldObserveAnchor(anchor, isViewportEnabled) {\n const href = anchor.getAttribute("href");\n if (!href) return false;\n if (href.startsWith("http") || href.startsWith("#")) return false;\n if (anchor.getAttribute("download")) return false;\n const prefetchAttribute = anchor.getAttribute("data-prefetch");\n if (prefetchAttribute === "false") return false;\n return prefetchAttribute === "viewport" || isViewportEnabled;\n }\n disconnect() {\n if (!this.observer) return;\n try {\n this.observer.disconnect();\n } catch (error) {\n logger6.warn("prefetchObserver.disconnect failed", error);\n } finally {\n this.observer = null;\n }\n }\n};\n\n// src/rendering/client/router.ts\nvar logger7 = rendererLogger.component("veryfront");\nfunction toHistoryMode(options) {\n if (typeof options === "boolean") return options ? "push" : "none";\n return options?.history ?? "push";\n}\nvar VeryfrontRouter = class {\n constructor(options = {}) {\n __publicField(this, "baseUrl");\n __publicField(this, "currentPath");\n __publicField(this, "root", null);\n __publicField(this, "options");\n __publicField(this, "spaMode");\n __publicField(this, "spaNavigationHandler", null);\n __publicField(this, "navigationSequence", 0);\n __publicField(this, "pageLoader");\n __publicField(this, "navigationHandlers");\n __publicField(this, "pageTransition");\n __publicField(this, "viewportPrefetch");\n __publicField(this, "handleClick");\n __publicField(this, "handlePopState");\n __publicField(this, "handleMouseOver");\n const globalOptions = this.loadGlobalOptions();\n this.options = { ...globalOptions, ...options };\n this.baseUrl = this.options.baseUrl || globalThis.location.origin;\n this.currentPath = `${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`;\n this.spaMode = this.options.spaMode ?? globalThis.__VERYFRONT_SPA_MODE__ ?? false;\n this.pageLoader = new PageLoader();\n this.navigationHandlers = new NavigationHandlers(\n this.options.prefetchDelay,\n this.options.prefetch\n );\n this.pageTransition = new PageTransition((root) => this.viewportPrefetch.setup(root));\n this.viewportPrefetch = new ViewportPrefetch(\n (path) => this.prefetch(path),\n this.options.prefetch\n );\n this.handleClick = this.navigationHandlers.createClickHandler({\n onNavigate: (url) => this.navigate(url),\n onPrefetch: (url) => this.prefetch(url)\n });\n this.handlePopState = this.navigationHandlers.createPopStateHandler({\n // The browser already updated the URL for a popstate, so don\'t touch history.\n onNavigate: (url) => this.navigate(url, { history: "none" }),\n onPrefetch: (url) => this.prefetch(url)\n });\n this.handleMouseOver = this.navigationHandlers.createMouseOverHandler({\n onNavigate: (url) => this.navigate(url),\n onPrefetch: (url) => this.prefetch(url)\n });\n getNavigationStore().setNavigator((href, options2) => this.navigate(href, options2));\n }\n registerNavigationHandler(handler) {\n logger7.debug("Registering SPA navigation handler");\n this.spaNavigationHandler = handler;\n this.spaMode = true;\n }\n /**\n * Notify React (and any other) subscribers that a navigation completed —\n * after full page loads, soft same-route changes, and popstate. Delegates to\n * the shared navigation store, the single subscription surface both bundles\n * share.\n */\n notify() {\n getNavigationStore().notify();\n }\n pathnameOf(url) {\n try {\n return new URL(url, this.baseUrl).pathname;\n } catch {\n return url.split("?")[0]?.split("#")[0] || this.currentPath;\n }\n }\n loadGlobalOptions() {\n try {\n const options = globalThis.__VERYFRONT_ROUTER_OPTS__;\n if (!options) {\n logger7.debug("No global options configured");\n return {};\n }\n return options;\n } catch (error) {\n logger7.error("Failed to read global options:", error);\n return {};\n }\n }\n init() {\n logger7.debug("Initializing client-side router");\n const rootElement = document.getElementById("root");\n if (!rootElement) {\n logger7.error("Root element not found");\n return;\n }\n const ReactDOMToUse = globalThis.ReactDOM ?? ReactDOM;\n this.root = ReactDOMToUse.createRoot(rootElement);\n document.addEventListener("click", this.handleClick);\n globalThis.addEventListener("popstate", this.handlePopState);\n document.addEventListener("mouseover", this.handleMouseOver);\n this.viewportPrefetch.setup(document);\n this.cacheCurrentPage();\n }\n cacheCurrentPage() {\n const pageData = extractPageDataFromScript();\n if (pageData) {\n const managedHead = snapshotClientRouteHead(document);\n this.pageLoader.setCache(this.currentPath, {\n ...pageData,\n managedHead,\n ...managedHead.some((entry) => entry.tagName === "script") || document.getElementById("root")?.querySelector("script") ? { requiresFullDocumentNavigation: true } : {}\n });\n }\n }\n /**\n * Navigate to a URL. `options` selects the history behaviour: `{ history:\n * "push" }` (default), `"replace"`, or `"none"` (the URL already reflects the\n * target, as after popstate). A boolean is accepted for backward\n * compatibility — `true` pushes, `false` maps to `"none"`.\n */\n async navigate(url, options) {\n logger7.debug(`Navigating to ${url} (SPA mode: ${this.spaMode})`);\n const navigationId = ++this.navigationSequence;\n this.pageTransition.cancelPendingTransition();\n this.pageTransition.setLoadingState(false);\n const history = toHistoryMode(options);\n const sameRoute = this.pathnameOf(url) === this.pathnameOf(this.currentPath);\n this.navigationHandlers.saveScrollPosition(this.currentPath);\n this.options.onStart?.(url);\n if (history === "replace") globalThis.history.replaceState({}, "", url);\n else if (history === "push") globalThis.history.pushState({}, "", url);\n if (sameRoute && !this.shouldRevalidate(url, sameRoute)) {\n if (!this.isCurrentNavigation(navigationId)) return;\n this.currentPath = url;\n this.notify();\n this.options.onComplete?.(url);\n this.options.onNavigate?.(url);\n return;\n }\n if (this.spaMode && this.spaNavigationHandler) {\n await this.loadSpaPage(url, navigationId);\n } else {\n if (await this.loadPage(url, true, navigationId)) return;\n }\n if (!this.isCurrentNavigation(navigationId)) return;\n this.notify();\n this.options.onNavigate?.(url);\n }\n isCurrentNavigation(navigationId) {\n return navigationId === this.navigationSequence;\n }\n /**\n * Whether a navigation should refetch page data. A route change always does;\n * a same-route (query/hash-only) change consults `options.shouldRevalidate`,\n * defaulting to `true` so server data is never shown stale.\n */\n shouldRevalidate(nextUrl, sameRoute) {\n const policy = this.options.shouldRevalidate;\n if (!policy) return true;\n return policy({ currentHref: this.currentPath, nextHref: nextUrl, sameRoute });\n }\n async loadSpaPage(path, navigationId) {\n logger7.debug(`Loading SPA page: ${path}`);\n try {\n const spaData = await this.pageLoader.loadSpaPageData(path);\n if (!this.isCurrentNavigation(navigationId)) return;\n await this.spaNavigationHandler?.(spaData);\n if (!this.isCurrentNavigation(navigationId)) return;\n this.currentPath = path;\n this.handleScrollAfterNavigation();\n this.options.onComplete?.(path);\n } catch (error) {\n if (!this.isCurrentNavigation(navigationId)) return;\n const normalizedError = error instanceof Error ? error : new Error(String(error));\n logger7.error(`Failed to load SPA page ${path}`, normalizedError);\n this.options.onError?.(normalizedError);\n this.pageTransition.showError(normalizedError);\n }\n }\n handleScrollAfterNavigation() {\n const isPopState = this.navigationHandlers.isPopState();\n const scrollY = this.navigationHandlers.getScrollPosition(this.currentPath);\n try {\n globalThis.scrollTo(0, isPopState ? scrollY : 0);\n } catch (error) {\n logger7.warn("scroll handling failed", error);\n }\n this.navigationHandlers.clearPopStateFlag();\n }\n /** Returns true when navigation was handed to the browser document loader. */\n async loadPage(path, updateUI = true, navigationId) {\n if (this.pageLoader.isCached(path)) {\n logger7.debug(`Loading ${path} from cache`);\n const data = this.pageLoader.getCached(path);\n if (data) {\n if (!this.isCurrentNavigation(navigationId)) return false;\n if (updateUI && data.requiresFullDocumentNavigation) {\n globalThis.location.assign(path);\n return true;\n }\n if (updateUI) this.updatePage(data, path);\n this.currentPath = path;\n this.pageTransition.setLoadingState(false);\n this.options.onComplete?.(path);\n return false;\n }\n logger7.warn(`Cache entry for ${path} was unexpectedly null, fetching fresh data`);\n }\n this.pageTransition.setLoadingState(true);\n try {\n const data = await this.pageLoader.loadPage(path);\n if (!this.isCurrentNavigation(navigationId)) return false;\n if (updateUI && data.requiresFullDocumentNavigation) {\n globalThis.location.assign(path);\n return true;\n }\n if (updateUI) this.updatePage(data, path);\n this.currentPath = path;\n this.options.onComplete?.(path);\n return false;\n } catch (error) {\n if (!this.isCurrentNavigation(navigationId)) return false;\n const normalizedError = error instanceof Error ? error : new Error(String(error));\n logger7.error(`Failed to load ${path}`, normalizedError);\n this.options.onError?.(normalizedError);\n this.pageTransition.showError(normalizedError);\n return false;\n } finally {\n if (this.isCurrentNavigation(navigationId)) this.pageTransition.setLoadingState(false);\n }\n }\n async prefetch(path) {\n if (this.spaMode) {\n await this.pageLoader.prefetchSpaPageData(path);\n return;\n }\n await this.pageLoader.prefetch(path);\n }\n updatePage(data, targetPath) {\n if (!this.root) return;\n const isPopState = this.navigationHandlers.isPopState();\n const scrollY = this.navigationHandlers.getScrollPosition(targetPath);\n this.pageTransition.updatePage(data, isPopState, scrollY);\n this.navigationHandlers.clearPopStateFlag();\n }\n destroy() {\n this.navigationSequence++;\n this.pageTransition.setLoadingState(false);\n document.removeEventListener("click", this.handleClick);\n globalThis.removeEventListener("popstate", this.handlePopState);\n document.removeEventListener("mouseover", this.handleMouseOver);\n this.viewportPrefetch.disconnect();\n this.pageLoader.clearCache();\n this.navigationHandlers.clear();\n this.pageTransition.destroy();\n }\n};\nfunction boot(options = {}) {\n if (typeof window === "undefined" || !globalThis.document) return null;\n const globalWithRouter = globalThis;\n if (globalWithRouter.veryFrontRouter) return globalWithRouter.veryFrontRouter;\n const { slug: _slug, ...routerOptions } = options;\n const router = new VeryfrontRouter(routerOptions);\n if (document.readyState === "loading") {\n document.addEventListener("DOMContentLoaded", () => router.init(), { once: true });\n } else {\n router.init();\n }\n globalWithRouter.veryFrontRouter = router;\n return router;\n}\nif (typeof window !== "undefined" && globalThis.document) {\n boot();\n}\nexport {\n VeryfrontRouter,\n boot\n};\n'; + 'var __defProp = Object.defineProperty;\nvar __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;\nvar __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);\n\n// src/rendering/client/browser-stubs/logger.ts\nfunction noop() {\n}\nvar logger = {\n debug: noop,\n info: console.log.bind(console),\n warn: console.warn.bind(console),\n error: console.error.bind(console),\n component: () => logger\n};\nvar rendererLogger = logger;\nvar PREFETCH_MAX_SIZE_BYTES = 200 * 1024;\n\n// src/rendering/client/navigation-store.ts\nvar STORE_KEY = /* @__PURE__ */ Symbol.for("veryfront.navigation.store.v1");\nfunction getNavigationStore() {\n const holder = globalThis;\n const existing = holder[STORE_KEY];\n if (existing) return existing;\n const listeners = /* @__PURE__ */ new Set();\n let navigator = null;\n const store = {\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n getHref() {\n const loc = globalThis.location;\n return loc ? `${loc.pathname}${loc.search}${loc.hash}` : "/";\n },\n notify() {\n for (const listener of [...listeners]) {\n try {\n listener();\n } catch {\n }\n }\n },\n navigate(href, options) {\n if (navigator) return navigator(href, options);\n globalThis.location?.assign(href);\n return Promise.resolve();\n },\n setNavigator(next) {\n navigator = next;\n }\n };\n holder[STORE_KEY] = store;\n return store;\n}\n\n// src/rendering/client/router.ts\nimport ReactDOM from "react-dom/client";\n\n// src/html/managed-head-protocol.ts\nvar HEAD_PROVENANCE_ATTRIBUTE = "data-vf-head";\nvar HEAD_LEGACY_MANAGED_ATTRIBUTE = "data-veryfront-managed";\nvar HEAD_CONTENT_HASH_ATTRIBUTE = "data-vf-hash";\nvar HEAD_REACT_MANAGED_ATTRIBUTE = "data-vf-react-head";\nvar HEAD_REACT_OWNER_ATTRIBUTE = "data-vf-react-head-owner";\nvar HEAD_ROUTE_MANAGED_ATTRIBUTE = "data-vf-route-head";\nvar HEAD_SHELL_PROVENANCE_ATTRIBUTE = "data-vf-shell-head";\nvar HEAD_SSR_PAYLOAD_ATTRIBUTE = "data-vf-ssr-head";\nvar MAX_MANAGED_HEAD_ENTRIES = 128;\nvar MAX_MANAGED_HEAD_BYTES = 2 * 1024 * 1024;\nvar REACT_HEAD_ATTRIBUTE_NAMES = {\n charSet: "charset",\n className: "class",\n crossOrigin: "crossorigin",\n fetchPriority: "fetchpriority",\n htmlFor: "for",\n httpEquiv: "http-equiv",\n imageSizes: "imagesizes",\n imageSrcSet: "imagesrcset",\n noModule: "nomodule",\n referrerPolicy: "referrerpolicy"\n};\nvar SINGLETON_META_KEYS = /* @__PURE__ */ new Set([\n "description",\n "robots",\n "viewport",\n "referrer",\n "color-scheme",\n "application-name",\n "generator",\n "og:title",\n "og:description",\n "og:url",\n "og:type",\n "og:site_name",\n "og:locale",\n "twitter:card",\n "twitter:site",\n "twitter:creator",\n "twitter:title",\n "twitter:description",\n "twitter:image",\n "twitter:image:alt"\n]);\nvar SINGLETON_LINK_RELS = /* @__PURE__ */ new Set([\n "canonical",\n "manifest",\n "amphtml"\n]);\nvar SUPPORTED_MANAGED_HEAD_TAGS = /* @__PURE__ */ new Set([\n "title",\n "meta",\n "link",\n "style",\n "script"\n]);\nvar HEAD_ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][A-Za-z0-9_.:-]*$/;\nvar MAX_HEAD_PROP_ENTRIES = 128;\nvar MAX_HEAD_ATTRIBUTE_NAME_BYTES = 256;\nvar MAX_HEAD_ATTRIBUTE_VALUE_BYTES = 64 * 1024;\nvar MAX_HEAD_ATTRIBUTE_BYTES = 1024 * 1024;\nvar MAX_HEAD_CONTENT_BYTES = 1024 * 1024;\nvar headTextEncoder = new TextEncoder();\nvar BOOLEAN_HEAD_ATTRIBUTES = /* @__PURE__ */ new Set([\n "async",\n "defer",\n "disabled",\n "itemscope",\n "nomodule"\n]);\nfunction isHeadFrameworkAttribute(name) {\n switch (name.toLowerCase()) {\n case HEAD_PROVENANCE_ATTRIBUTE:\n case HEAD_LEGACY_MANAGED_ATTRIBUTE:\n case HEAD_CONTENT_HASH_ATTRIBUTE:\n case HEAD_REACT_MANAGED_ATTRIBUTE:\n case HEAD_REACT_OWNER_ATTRIBUTE:\n case HEAD_ROUTE_MANAGED_ATTRIBUTE:\n case HEAD_SHELL_PROVENANCE_ATTRIBUTE:\n case HEAD_SSR_PAYLOAD_ATTRIBUTE:\n return true;\n default:\n return false;\n }\n}\nfunction normalizeHeadIdentityValue(value) {\n const normalized = value?.trim().toLowerCase();\n return normalized || void 0;\n}\nfunction readOwnString(record, key) {\n try {\n const descriptor = Reflect.getOwnPropertyDescriptor(record, key);\n return descriptor && !descriptor.get && !descriptor.set && "value" in descriptor && typeof descriptor.value === "string" ? descriptor.value : void 0;\n } catch {\n return void 0;\n }\n}\nfunction headMetaSingletonKeyFromRecord(meta) {\n if (readOwnString(meta, "charset") !== void 0) return "meta:charset";\n const key = normalizeHeadIdentityValue(\n readOwnString(meta, "property") ?? readOwnString(meta, "name")\n );\n if (!key) return void 0;\n if (key === "theme-color") {\n return `meta:theme-color:${readOwnString(meta, "media")?.trim() ?? ""}`;\n }\n return SINGLETON_META_KEYS.has(key) ? `meta:${key}` : void 0;\n}\nfunction headLinkSingletonKeyFromRecord(link) {\n const rel = normalizeHeadIdentityValue(readOwnString(link, "rel"));\n return rel && SINGLETON_LINK_RELS.has(rel) ? `link:${rel}` : void 0;\n}\nfunction normalizeManagedHeadString(value) {\n return value.replace(/\\r\\n?/g, "\\n");\n}\nfunction inspectHeadProps(value) {\n if (typeof value !== "object" || value === null || Array.isArray(value)) return null;\n let prototype;\n let keys;\n try {\n prototype = Object.getPrototypeOf(value);\n keys = Reflect.ownKeys(value);\n } catch {\n return null;\n }\n if (prototype !== Object.prototype && prototype !== null) return null;\n const inspected = /* @__PURE__ */ new Map();\n let entries = 0;\n for (const key of keys) {\n let descriptor;\n try {\n descriptor = Reflect.getOwnPropertyDescriptor(value, key);\n } catch {\n return null;\n }\n if (!descriptor) return null;\n if (!descriptor.enumerable) continue;\n if (typeof key !== "string" || descriptor.get || descriptor.set || !("value" in descriptor)) {\n return null;\n }\n entries++;\n if (entries > MAX_HEAD_PROP_ENTRIES) return null;\n inspected.set(key, descriptor.value);\n }\n return inspected;\n}\nfunction normalizeContentPrimitive(value) {\n if (value === null || value === void 0 || typeof value === "boolean") return void 0;\n if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") {\n return null;\n }\n const content = normalizeManagedHeadString(String(value));\n return headTextEncoder.encode(content).byteLength <= MAX_HEAD_CONTENT_BYTES ? content : null;\n}\nfunction normalizeManagedHeadAttributesFromProps(tagName, props, ambientNonce, excludedKeys = /* @__PURE__ */ new Set()) {\n const attributeMap = /* @__PURE__ */ new Map();\n for (const [key, value] of props) {\n if (key === "children" || key === "dangerouslySetInnerHTML" || excludedKeys.has(key)) {\n continue;\n }\n if (/^on/i.test(key) || typeof value === "function" || typeof value === "symbol" || typeof value === "object") {\n continue;\n }\n const name = (REACT_HEAD_ATTRIBUTE_NAMES[key] ?? key).toLowerCase();\n if (isHeadFrameworkAttribute(name) || !HEAD_ATTRIBUTE_NAME_PATTERN.test(name) || headTextEncoder.encode(name).byteLength > MAX_HEAD_ATTRIBUTE_NAME_BYTES) {\n continue;\n }\n if (BOOLEAN_HEAD_ATTRIBUTES.has(name)) {\n if (value !== false && value !== void 0) attributeMap.set(name, "");\n continue;\n }\n if (typeof value === "boolean") {\n if (name.startsWith("data-") || name.startsWith("aria-")) {\n attributeMap.set(name, String(value));\n }\n continue;\n }\n if (value === void 0) continue;\n if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") {\n continue;\n }\n const normalizedValue = normalizeManagedHeadString(String(value));\n if (headTextEncoder.encode(normalizedValue).byteLength > MAX_HEAD_ATTRIBUTE_VALUE_BYTES) {\n return null;\n }\n attributeMap.set(name, normalizedValue);\n }\n if ((tagName === "script" || tagName === "style") && ambientNonce) {\n const nonce = normalizeManagedHeadString(ambientNonce);\n if (headTextEncoder.encode(nonce).byteLength > MAX_HEAD_ATTRIBUTE_VALUE_BYTES) return null;\n attributeMap.set("nonce", nonce);\n }\n if (tagName === "link" && attributeMap.get("rel")?.trim().toLowerCase() === "preload" && attributeMap.get("as")?.trim().toLowerCase() === "font" && !attributeMap.has("crossorigin")) {\n attributeMap.set("crossorigin", "anonymous");\n }\n if (attributeMap.size > MAX_HEAD_PROP_ENTRIES) return null;\n let totalBytes = 0;\n for (const [name, value] of attributeMap) {\n totalBytes += headTextEncoder.encode(name).byteLength + headTextEncoder.encode(value).byteLength;\n if (totalBytes > MAX_HEAD_ATTRIBUTE_BYTES) return null;\n }\n return [...attributeMap.entries()].sort(([left], [right]) => left.localeCompare(right));\n}\nfunction singletonKey(tagName, attributes) {\n if (tagName === "title") return "title";\n const record = Object.fromEntries(attributes);\n if (tagName === "meta") return headMetaSingletonKeyFromRecord(record);\n if (tagName === "link") return headLinkSingletonKeyFromRecord(record);\n return void 0;\n}\nfunction scriptKeys(tagName, attributes) {\n if (tagName !== "script") return [];\n const keys = [];\n const id = attributes.get("id");\n const src = attributes.get("src");\n if (id) keys.push(`script:id:${id}`);\n if (src) keys.push(`script:src:${src}`);\n return keys;\n}\nfunction declaresDocumentEncoding(attributes) {\n return attributes.has("charset") || attributes.get("http-equiv")?.trim().toLowerCase() === "content-type";\n}\nfunction createManagedHeadDescriptor(tagName, attributes, content, contentMode) {\n const attributeMap = new Map(attributes);\n return {\n tagName,\n attributes,\n ...content !== void 0 && { content },\n contentMode,\n signature: JSON.stringify([\n tagName,\n attributes,\n contentMode,\n content ?? null\n ]),\n singletonKey: singletonKey(tagName, attributeMap),\n scriptKeys: scriptKeys(tagName, attributeMap)\n };\n}\nfunction descriptorFromManagedHeadRecord(rawTagName, record, options = {}) {\n const tagName = rawTagName.toLowerCase();\n if (!SUPPORTED_MANAGED_HEAD_TAGS.has(tagName)) return null;\n const inspected = inspectHeadProps(record);\n if (!inspected) return null;\n const excludedKeys = options.contentProperty ? /* @__PURE__ */ new Set([options.contentProperty]) : /* @__PURE__ */ new Set();\n const attributes = normalizeManagedHeadAttributesFromProps(\n tagName,\n inspected,\n options.ambientNonce,\n excludedKeys\n );\n if (!attributes) return null;\n const attributeMap = new Map(attributes);\n if (tagName === "meta" && declaresDocumentEncoding(attributeMap)) return null;\n if ((tagName === "meta" || tagName === "link") && attributes.length === 0) return null;\n let content;\n if (options.contentProperty) {\n const normalized = normalizeContentPrimitive(inspected.get(options.contentProperty));\n if (normalized === null) return null;\n content = normalized;\n }\n return createManagedHeadDescriptor(tagName, attributes, content, "text");\n}\nfunction headScriptKeysIntersect(left, right) {\n if (left.length === 0 || right.length === 0) return false;\n const rightKeys = new Set(right);\n return left.some((key) => rightKeys.has(key));\n}\nfunction aggregateManagedHeadDescriptors(descriptors) {\n const aggregated = [];\n const singletonIndexes = /* @__PURE__ */ new Map();\n const scriptKeysSeen = /* @__PURE__ */ new Set();\n for (const descriptor of descriptors) {\n if (descriptor.singletonKey) {\n const index = singletonIndexes.get(descriptor.singletonKey);\n if (index !== void 0) {\n aggregated[index] = descriptor;\n continue;\n }\n singletonIndexes.set(descriptor.singletonKey, aggregated.length);\n } else if (descriptor.scriptKeys.length > 0) {\n if (descriptor.scriptKeys.some((key) => scriptKeysSeen.has(key))) continue;\n for (const key of descriptor.scriptKeys) scriptKeysSeen.add(key);\n }\n aggregated.push(descriptor);\n }\n return aggregated;\n}\nfunction managedHeadDescriptorBytes(descriptor) {\n let bytes = headTextEncoder.encode(descriptor.tagName).byteLength;\n for (const [name, value] of descriptor.attributes) {\n bytes += headTextEncoder.encode(name).byteLength;\n bytes += headTextEncoder.encode(value).byteLength;\n }\n if (descriptor.content !== void 0) {\n bytes += headTextEncoder.encode(descriptor.content).byteLength;\n }\n return bytes;\n}\nfunction assertManagedHeadDescriptorBudget(descriptors) {\n if (descriptors.length > MAX_MANAGED_HEAD_ENTRIES) {\n throw new TypeError(\n `Managed head exceeds the ${MAX_MANAGED_HEAD_ENTRIES}-entry request limit`\n );\n }\n let bytes = 0;\n for (const descriptor of descriptors) {\n bytes += managedHeadDescriptorBytes(descriptor);\n if (bytes > MAX_MANAGED_HEAD_BYTES) {\n throw new TypeError(\n `Managed head exceeds the ${MAX_MANAGED_HEAD_BYTES}-byte request limit`\n );\n }\n }\n}\nfunction managedHeadDescriptorToTransportEntry(descriptor) {\n const attributes = descriptor.attributes.filter(([name]) => name !== "nonce");\n return {\n tagName: descriptor.tagName,\n attributes: attributes.map(([name, value]) => [name, value]),\n ...descriptor.content !== void 0 && { content: descriptor.content }\n };\n}\nfunction ownTransportValue(record, key) {\n let descriptor;\n try {\n descriptor = Reflect.getOwnPropertyDescriptor(record, key);\n } catch {\n return void 0;\n }\n if (!descriptor || descriptor.get || descriptor.set || !("value" in descriptor)) {\n return void 0;\n }\n return descriptor.value;\n}\nfunction descriptorFromManagedHeadTransportEntry(entry, ambientNonce) {\n if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {\n throw new TypeError("Managed-head transport entries must be plain objects");\n }\n let prototype;\n try {\n prototype = Object.getPrototypeOf(entry);\n } catch {\n throw new TypeError("Managed-head transport entry cannot be inspected");\n }\n if (prototype !== Object.prototype && prototype !== null) {\n throw new TypeError("Managed-head transport entries must be plain objects");\n }\n const tagName = ownTransportValue(entry, "tagName");\n const rawAttributes = ownTransportValue(entry, "attributes");\n const content = ownTransportValue(entry, "content");\n if (typeof tagName !== "string" || tagName !== tagName.toLowerCase() || !Array.isArray(rawAttributes)) {\n throw new TypeError("Managed-head transport entry is not canonical");\n }\n if (rawAttributes.length > MAX_HEAD_PROP_ENTRIES) {\n throw new TypeError("Managed-head transport entry exceeds the attribute limit");\n }\n if (content !== void 0 && typeof content !== "string") {\n throw new TypeError("Managed-head transport content must be a string");\n }\n const supportsText = tagName === "title" || tagName === "script" || tagName === "style";\n if (!supportsText && content !== void 0) {\n throw new TypeError("Managed-head transport content is invalid for this tag");\n }\n const record = /* @__PURE__ */ Object.create(null);\n const inputAttributes = [];\n const names = /* @__PURE__ */ new Set();\n for (let index = 0; index < rawAttributes.length; index += 1) {\n const pair = ownTransportValue(rawAttributes, String(index));\n if (!Array.isArray(pair) || pair.length !== 2) {\n throw new TypeError("Managed-head transport attributes must be string pairs");\n }\n const name = ownTransportValue(pair, "0");\n const value = ownTransportValue(pair, "1");\n if (typeof name !== "string" || typeof value !== "string") {\n throw new TypeError("Managed-head transport attributes must be string pairs");\n }\n const normalizedName = name.toLowerCase();\n if (name !== normalizedName || normalizedName === "nonce" || names.has(normalizedName)) {\n throw new TypeError("Managed-head transport attributes are not canonical");\n }\n names.add(normalizedName);\n inputAttributes.push([normalizedName, value]);\n Object.defineProperty(record, normalizedName, {\n enumerable: true,\n value\n });\n }\n if (content !== void 0) {\n Object.defineProperty(record, "__veryfront_transport_content", {\n enumerable: true,\n value: content\n });\n }\n const descriptor = descriptorFromManagedHeadRecord(tagName, record, {\n ...supportsText && { contentProperty: "__veryfront_transport_content" },\n ...(tagName === "script" || tagName === "style") && ambientNonce ? { ambientNonce } : {}\n });\n const normalizedInput = inputAttributes.sort(([left], [right]) => left.localeCompare(right));\n const normalizedOutput = descriptor?.attributes.filter(([name]) => name !== "nonce");\n if (!descriptor || JSON.stringify(normalizedOutput) !== JSON.stringify(normalizedInput) || supportsText && (descriptor.content ?? "") !== (content ?? "")) {\n throw new TypeError("Managed-head transport entry failed validation");\n }\n return descriptor;\n}\nvar BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";\nfunction decodeBase64Url(value) {\n if (value.length % 4 === 1 || !/^[A-Za-z0-9_-]*$/.test(value)) {\n throw new TypeError("Managed-head payload is not valid base64url");\n }\n const estimatedBytes = Math.floor(value.length * 3 / 4);\n if (estimatedBytes > MAX_MANAGED_HEAD_BYTES * 2) {\n throw new TypeError("Managed-head payload exceeds its encoded size limit");\n }\n const bytes = new Uint8Array(estimatedBytes);\n let outputIndex = 0;\n let buffer = 0;\n let bits = 0;\n for (const character of value) {\n const decoded = BASE64URL_ALPHABET.indexOf(character);\n if (decoded < 0) throw new TypeError("Managed-head payload is not valid base64url");\n buffer = buffer << 6 | decoded;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n bytes[outputIndex++] = buffer >> bits & 255;\n buffer &= bits === 0 ? 0 : (1 << bits) - 1;\n }\n }\n if (bits > 0 && buffer !== 0) {\n throw new TypeError("Managed-head payload has non-canonical trailing bits");\n }\n return bytes.subarray(0, outputIndex);\n}\nfunction deserializeManagedHeadPayload(payload, ambientNonce) {\n if (typeof payload !== "string") throw new TypeError("Managed-head payload must be a string");\n let decoded;\n try {\n decoded = new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64Url(payload));\n } catch (error) {\n if (error instanceof TypeError) throw error;\n throw new TypeError("Managed-head payload is not valid UTF-8", { cause: error });\n }\n let entries;\n try {\n entries = JSON.parse(decoded);\n } catch (error) {\n throw new TypeError("Managed-head payload is not valid JSON", { cause: error });\n }\n if (!Array.isArray(entries) || entries.length > MAX_MANAGED_HEAD_ENTRIES) {\n throw new TypeError("Managed-head payload exceeds the entry limit");\n }\n const descriptors = aggregateManagedHeadDescriptors(\n entries.map((entry) => descriptorFromManagedHeadTransportEntry(entry, ambientNonce))\n );\n assertManagedHeadDescriptorBudget(descriptors);\n return descriptors;\n}\n\n// src/html/client-head-manager.ts\nvar HEAD_MANAGER_STATE_SYMBOL = /* @__PURE__ */ Symbol.for(\n "veryfront.client-head-manager.v2"\n);\nvar CROSS_PAGE_PRESERVED_SINGLETON_KEYS = /* @__PURE__ */ new Set([\n "meta:viewport",\n "link:manifest"\n]);\nfunction getClientHeadManagerState() {\n const globalState = globalThis;\n return globalState[HEAD_MANAGER_STATE_SYMBOL] ?? (globalState[HEAD_MANAGER_STATE_SYMBOL] = {\n documents: /* @__PURE__ */ new WeakMap()\n });\n}\nfunction getManagedHeadNonce(targetDocument) {\n if (typeof targetDocument.querySelector !== "function") return void 0;\n const element = targetDocument.querySelector(\n "script[nonce], style[nonce], link[nonce]"\n );\n if (!element) return void 0;\n const nonce = element.nonce || element.getAttribute("nonce") || "";\n return nonce || void 0;\n}\nfunction readElementAttributes(element) {\n const attributes = [];\n for (const attribute of element.attributes) {\n const name = attribute.name.toLowerCase();\n if (isHeadFrameworkAttribute(name)) continue;\n const nonce = name === "nonce" && "nonce" in element ? element.nonce : "";\n const value = BOOLEAN_HEAD_ATTRIBUTES.has(name) ? "" : nonce || attribute.value;\n attributes.push([name, value]);\n }\n return attributes.sort(([left], [right]) => left.localeCompare(right));\n}\nfunction elementSingletonKey(element) {\n const tagName = element.tagName.toLowerCase();\n if (tagName === "title") return "title";\n const attributes = Object.fromEntries(readElementAttributes(element));\n if (tagName === "meta") return headMetaSingletonKeyFromRecord(attributes);\n if (tagName === "link") return headLinkSingletonKeyFromRecord(attributes);\n return void 0;\n}\nfunction promoteToShellHeadBaseline(element) {\n for (const attribute of [...element.attributes]) {\n if (isHeadFrameworkAttribute(attribute.name)) {\n element.removeAttribute(attribute.name);\n }\n }\n element.setAttribute(HEAD_SHELL_PROVENANCE_ATTRIBUTE, "true");\n}\nfunction isCrossPagePreservedSingleton(element, singletonKey2 = elementSingletonKey(element)) {\n return element.parentElement !== null && singletonKey2 !== void 0 && CROSS_PAGE_PRESERVED_SINGLETON_KEYS.has(singletonKey2);\n}\nfunction isFrameworkOwnedHeadElement(element) {\n return element.getAttribute(HEAD_PROVENANCE_ATTRIBUTE) === "true" || element.getAttribute(HEAD_REACT_MANAGED_ATTRIBUTE) === "true" || element.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1" || element.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true" || element.getAttribute(HEAD_SHELL_PROVENANCE_ATTRIBUTE) === "true";\n}\nfunction retireFrameworkHeadElement(element) {\n if (isCrossPagePreservedSingleton(element)) {\n promoteToShellHeadBaseline(element);\n return;\n }\n element.remove();\n}\nfunction retireClientHeadOwnership(targetDocument) {\n const manager = getClientHeadManagerState().documents.get(targetDocument);\n if (manager) {\n manager.retire();\n return;\n }\n for (const element of [...targetDocument.head?.children ?? []]) {\n if (isFrameworkOwnedHeadElement(element)) retireFrameworkHeadElement(element);\n }\n}\n\n// src/html/client-route-head.ts\nvar ROUTE_HEAD_CONTENT_PROPERTY = "__veryfront_route_head_content";\nfunction descriptorFromHeadElement(element) {\n const record = /* @__PURE__ */ Object.create(null);\n for (const { name, value } of element.attributes) {\n if (!isHeadFrameworkAttribute(name)) record[name] = value;\n }\n const tagName = element.tagName.toLowerCase();\n const supportsText = tagName === "title" || tagName === "script" || tagName === "style";\n if (supportsText) record[ROUTE_HEAD_CONTENT_PROPERTY] = element.textContent ?? "";\n return descriptorFromManagedHeadRecord(\n tagName,\n record,\n supportsText ? { contentProperty: ROUTE_HEAD_CONTENT_PROPERTY } : void 0\n );\n}\nfunction writeRouteDescriptor(element, descriptor) {\n for (const attribute of [...element.attributes]) element.removeAttribute(attribute.name);\n for (const [name, value] of descriptor.attributes) element.setAttribute(name, value);\n element.textContent = descriptor.content ?? "";\n element.setAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE, "1");\n element.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n}\nfunction prepareClientRouteHeadEntries(entries, targetDocument = document) {\n if (entries === void 0) return [];\n if (!Array.isArray(entries) || entries.length > MAX_MANAGED_HEAD_ENTRIES) {\n throw new TypeError("Route head payload exceeds the entry limit");\n }\n const descriptors = aggregateManagedHeadDescriptors(\n entries.map(\n (entry) => descriptorFromManagedHeadTransportEntry(entry, getManagedHeadNonce(targetDocument))\n )\n );\n assertManagedHeadDescriptorBudget(descriptors);\n return descriptors;\n}\nfunction applyPreparedClientRouteHeadDescriptors(descriptors, targetDocument = document) {\n for (const descriptor of descriptors) {\n const described = [...targetDocument.head.children].flatMap((element2) => {\n const current = descriptorFromHeadElement(element2);\n return current ? [{ element: element2, descriptor: current }] : [];\n });\n if (descriptor.singletonKey) {\n const matches = described.filter(\n ({ descriptor: current }) => current.singletonKey === descriptor.singletonKey\n );\n const directive = matches.find(\n ({ element: element2 }) => element2.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1"\n );\n if (directive) {\n continue;\n }\n const reusable = matches.find(\n ({ element: element2 }) => element2.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true" || element2.getAttribute(HEAD_SHELL_PROVENANCE_ATTRIBUTE) === "true"\n );\n if (reusable) {\n writeRouteDescriptor(reusable.element, descriptor);\n continue;\n }\n }\n if (described.some(\n ({ descriptor: current }) => current.signature === descriptor.signature || headScriptKeysIntersect(current.scriptKeys, descriptor.scriptKeys)\n )) {\n continue;\n }\n const element = targetDocument.createElement(descriptor.tagName);\n writeRouteDescriptor(element, descriptor);\n targetDocument.head.appendChild(element);\n }\n}\nfunction updateRouteTitle(title, targetDocument = document) {\n if (typeof title !== "string" || !title) return;\n const titles = [...targetDocument.head.querySelectorAll("title")];\n if (titles.some((element) => element.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1")) {\n return;\n }\n let titleElement = titles.find(\n (element) => element.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true"\n );\n for (const element of titles) {\n if (element !== titleElement) element.remove();\n }\n if (!titleElement) {\n titleElement = targetDocument.createElement("title");\n titleElement.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n targetDocument.head.appendChild(titleElement);\n }\n titleElement.textContent = title;\n}\nfunction updateRouteMetaTag(targetDocument, selector, attributeName, attributeValue, content) {\n const matches = [...targetDocument.head.querySelectorAll(selector)];\n if (matches.some((element) => element.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1")) {\n return;\n }\n let metaTag = matches.find(\n (element) => element.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true"\n );\n if (!metaTag) {\n metaTag = targetDocument.createElement("meta");\n metaTag.setAttribute(attributeName, attributeValue);\n metaTag.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n targetDocument.head.appendChild(metaTag);\n }\n metaTag.setAttribute("content", content);\n}\nfunction updateRouteMetaTags(metadata, targetDocument = document) {\n if (typeof metadata.description === "string" && metadata.description) {\n updateRouteMetaTag(\n targetDocument,\n \'meta[name="description"]\',\n "name",\n "description",\n metadata.description\n );\n }\n if (typeof metadata.ogTitle === "string" && metadata.ogTitle) {\n updateRouteMetaTag(\n targetDocument,\n \'meta[property="og:title"]\',\n "property",\n "og:title",\n metadata.ogTitle\n );\n }\n}\n\n// src/routing/client/dom-utils.ts\nvar logger2 = rendererLogger.component("veryfront");\nvar PARSED_ROUTE_HEAD_CONTENT_PROPERTY = "__veryfront_parsed_route_head_content";\nfunction isInternalLink(target) {\n const href = target.getAttribute("href");\n if (!href) return false;\n if (href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("#")) return false;\n const linkTarget = target.getAttribute("target");\n if (linkTarget === "_blank" || target.hasAttribute("download")) return false;\n return true;\n}\nfunction findAnchorElement(element) {\n let current = element;\n while (current && current.tagName !== "A") {\n current = current.parentElement;\n }\n return current instanceof HTMLAnchorElement ? current : null;\n}\nfunction applyHeadDirectives(container) {\n const targetDocument = container.ownerDocument ?? document;\n const nodes = [...container.querySelectorAll(\'[data-veryfront-head="1"], vf-head\')].filter(\n (node) => typeof node.getAttribute !== "function" || node.getAttribute(HEAD_REACT_OWNER_ATTRIBUTE) !== "1"\n );\n if (!nodes.length) return;\n retireClientHeadOwnership(targetDocument);\n cleanManagedHeadTags(targetDocument);\n for (const wrapper of nodes) {\n const TemplateElement = targetDocument.defaultView?.HTMLTemplateElement ?? globalThis.HTMLTemplateElement;\n const contentSource = TemplateElement && wrapper instanceof TemplateElement ? wrapper.content : wrapper;\n processHeadWrapper(contentSource, targetDocument);\n wrapper.parentElement?.removeChild(wrapper);\n }\n}\nfunction cleanManagedHeadTags(targetDocument) {\n for (const element of targetDocument.head.querySelectorAll(\n `[${HEAD_LEGACY_MANAGED_ATTRIBUTE}="1"]`\n )) {\n element.parentElement?.removeChild(element);\n }\n}\nfunction processHeadWrapper(wrapper, targetDocument) {\n const ElementConstructor = targetDocument.defaultView?.Element ?? globalThis.Element;\n const activeNonce = getManagedHeadNonce(targetDocument);\n for (const node of wrapper.childNodes) {\n if (!ElementConstructor || !(node instanceof ElementConstructor)) continue;\n const tagName = node.tagName.toLowerCase();\n if (headSingletonKey(node) === "meta:charset") continue;\n const clone = targetDocument.createElement(tagName);\n for (const { name, value } of node.attributes) {\n if (name.toLowerCase() !== "nonce") clone.setAttribute(name, value);\n }\n if (activeNonce && (tagName === "script" || tagName === "style" || tagName === "link")) {\n clone.setAttribute("nonce", activeNonce);\n }\n if (node.textContent && !clone.hasAttribute("src")) {\n clone.textContent = node.textContent;\n }\n replaceExistingHeadSingleton(targetDocument, clone);\n clone.setAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE, "1");\n clone.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n targetDocument.head.appendChild(clone);\n }\n}\nfunction headSingletonKey(element) {\n const tagName = element.tagName.toLowerCase();\n if (tagName === "title") return "title";\n if (tagName !== "meta" && tagName !== "link") return void 0;\n const attributes = /* @__PURE__ */ Object.create(null);\n if (!element.attributes) return void 0;\n for (const { name, value } of element.attributes) attributes[name.toLowerCase()] = value;\n if (tagName === "meta" && attributes["http-equiv"]?.trim().toLowerCase() === "content-type") {\n return "meta:charset";\n }\n return tagName === "meta" ? headMetaSingletonKeyFromRecord(attributes) : headLinkSingletonKeyFromRecord(attributes);\n}\nfunction replaceExistingHeadSingleton(targetDocument, replacement) {\n const singletonKey2 = headSingletonKey(replacement);\n if (!singletonKey2 || singletonKey2 === "meta:charset") return;\n for (const existing of [...targetDocument.head?.children ?? []]) {\n if (headSingletonKey(existing) === singletonKey2) existing.remove();\n }\n}\nfunction manageFocus(container) {\n try {\n const focusElement = container.querySelector("[data-router-focus]") || container.querySelector("main") || container.querySelector("h1");\n focusElement?.focus?.({ preventScroll: true });\n } catch (error) {\n logger2.warn("focus management failed", error);\n }\n}\nfunction extractPageDataFromScript() {\n const pageDataScript = document.querySelector("script[data-veryfront-page]");\n if (!pageDataScript) return null;\n try {\n const content = pageDataScript.textContent;\n if (!content) {\n logger2.warn("Page data script has no content");\n return {};\n }\n return JSON.parse(content);\n } catch (error) {\n logger2.error("Failed to parse page data:", error);\n return null;\n }\n}\nfunction descriptorFromDocumentHeadElement(element) {\n const tagName = element.tagName.toLowerCase();\n const record = /* @__PURE__ */ Object.create(null);\n for (const { name, value } of element.attributes) {\n if (!isHeadFrameworkAttribute(name) && name.toLowerCase() !== "nonce") {\n record[name.toLowerCase()] = value;\n }\n }\n const supportsText = tagName === "title" || tagName === "script" || tagName === "style";\n if (supportsText) record[PARSED_ROUTE_HEAD_CONTENT_PROPERTY] = element.textContent ?? "";\n return descriptorFromManagedHeadRecord(tagName, record, {\n ...supportsText && { contentProperty: PARSED_ROUTE_HEAD_CONTENT_PROPERTY }\n });\n}\nfunction payloadDescriptors(root) {\n if (!root || typeof root.querySelectorAll !== "function") return [];\n const descriptors = [];\n for (const element of root.querySelectorAll(`[${HEAD_SSR_PAYLOAD_ATTRIBUTE}]`)) {\n if (element.getAttribute(HEAD_REACT_OWNER_ATTRIBUTE) !== "1") continue;\n const payload = element.getAttribute(HEAD_SSR_PAYLOAD_ATTRIBUTE);\n if (payload) descriptors.push(...deserializeManagedHeadPayload(payload));\n }\n return descriptors;\n}\nfunction snapshotClientRouteHead(targetDocument = document) {\n const descriptors = [];\n let hasStructuredPayload = false;\n const hydrationDataScript = targetDocument.getElementById("veryfront-hydration-data");\n if (hydrationDataScript?.textContent) {\n try {\n const hydrationData = JSON.parse(hydrationDataScript.textContent);\n if (typeof hydrationData.managedHeadPayload === "string") {\n descriptors.push(...deserializeManagedHeadPayload(hydrationData.managedHeadPayload));\n hasStructuredPayload = true;\n }\n } catch {\n }\n }\n const committedDescriptors = payloadDescriptors(targetDocument.getElementById("root"));\n descriptors.push(...committedDescriptors);\n const fallbackSelector = [\n ...committedDescriptors.length === 0 ? [`[${HEAD_PROVENANCE_ATTRIBUTE}="true"]`] : [],\n ...!hasStructuredPayload ? [`[${HEAD_SHELL_PROVENANCE_ATTRIBUTE}="true"]`] : []\n ].join(", ");\n if (fallbackSelector && targetDocument.head?.querySelectorAll) {\n for (const element of targetDocument.head.querySelectorAll(fallbackSelector)) {\n const descriptor = descriptorFromDocumentHeadElement(element);\n if (descriptor) descriptors.push(descriptor);\n }\n }\n const aggregated = aggregateManagedHeadDescriptors(descriptors);\n assertManagedHeadDescriptorBudget(aggregated);\n return aggregated.map(managedHeadDescriptorToTransportEntry);\n}\nfunction parsePageDataFromHTML(html) {\n const doc = new DOMParser().parseFromString(html, "text/html");\n const root = doc.getElementById("root");\n if (!root) logger2.warn("[Veryfront] No root element found in HTML");\n const content = root?.innerHTML ?? "";\n const pageDataScript = doc.querySelector("script[data-veryfront-page]");\n let pageData = {};\n if (pageDataScript) {\n try {\n const scriptContent = pageDataScript.textContent;\n if (!scriptContent) {\n logger2.warn("Page data script in HTML has no content");\n } else {\n pageData = JSON.parse(scriptContent);\n }\n } catch (error) {\n logger2.error("Failed to parse page data from HTML:", error);\n }\n }\n let dependencyPinningCacheKey;\n const hydrationDataScript = doc.getElementById("veryfront-hydration-data");\n if (hydrationDataScript?.textContent) {\n try {\n const hydrationData = JSON.parse(hydrationDataScript.textContent);\n if (typeof hydrationData.dependencyPinningCacheKey === "string") {\n dependencyPinningCacheKey = hydrationData.dependencyPinningCacheKey;\n }\n } catch (error) {\n logger2.error("Failed to parse hydration data from HTML:", error);\n }\n }\n const managedHead = snapshotClientRouteHead(doc);\n if (managedHead.some((entry) => entry.tagName === "script") || typeof root?.querySelector === "function" && root.querySelector("script")) {\n pageData = { ...pageData, requiresFullDocumentNavigation: true };\n }\n return { content, pageData, managedHead, dependencyPinningCacheKey };\n}\n\n// src/rendering/client/browser-stubs/config.ts\nvar DEFAULT_PREFETCH_DELAY_MS = 100;\nvar PAGE_TRANSITION_DELAY_MS = 150;\n\n// src/routing/client/navigation-handlers.ts\nvar logger3 = rendererLogger.component("veryfront");\nvar MAX_SCROLL_POSITIONS = 100;\nvar NavigationHandlers = class {\n constructor(prefetchDelay = DEFAULT_PREFETCH_DELAY_MS, prefetchOptions = {}) {\n __publicField(this, "prefetchQueue", /* @__PURE__ */ new Set());\n __publicField(this, "pendingTimeouts", /* @__PURE__ */ new Map());\n __publicField(this, "scrollPositions", /* @__PURE__ */ new Map());\n __publicField(this, "isPopStateNav", false);\n __publicField(this, "prefetchDelay");\n __publicField(this, "prefetchOptions");\n this.prefetchDelay = prefetchDelay;\n this.prefetchOptions = prefetchOptions;\n }\n createClickHandler(callbacks) {\n return (event) => {\n if (!(event.target instanceof HTMLElement)) return;\n const anchor = findAnchorElement(event.target);\n if (!anchor || !isInternalLink(anchor)) return;\n const href = anchor.getAttribute("href");\n if (!href) return;\n event.preventDefault();\n callbacks.onNavigate(href);\n };\n }\n createPopStateHandler(callbacks) {\n return (_event) => {\n this.isPopStateNav = true;\n const { pathname, search, hash } = globalThis.location;\n callbacks.onNavigate(`${pathname}${search}${hash}`);\n };\n }\n createMouseOverHandler(callbacks) {\n return (event) => {\n if (!(event.target instanceof HTMLElement)) return;\n if (event.target.tagName !== "A") return;\n const href = event.target.getAttribute("href");\n if (!href || href.startsWith("http") || href.startsWith("#")) return;\n if (!this.shouldPrefetchOnHover(event.target)) return;\n if (this.prefetchQueue.has(href)) return;\n this.prefetchQueue.add(href);\n const timeoutId = setTimeout(() => {\n callbacks.onPrefetch(href);\n this.prefetchQueue.delete(href);\n this.pendingTimeouts.delete(href);\n }, this.prefetchDelay);\n this.pendingTimeouts.set(href, timeoutId);\n };\n }\n shouldPrefetchOnHover(target) {\n const prefetchAttribute = target.getAttribute("data-prefetch");\n if (prefetchAttribute === "false") return false;\n if (prefetchAttribute === "true") return true;\n return Boolean(this.prefetchOptions.hover);\n }\n saveScrollPosition(path) {\n try {\n if (this.scrollPositions.size >= MAX_SCROLL_POSITIONS) {\n const oldest = this.scrollPositions.keys().next().value;\n if (oldest) this.scrollPositions.delete(oldest);\n }\n const scrollY = globalThis.scrollY;\n if (typeof scrollY !== "number") {\n logger3.debug("No valid scrollY value available");\n this.scrollPositions.set(path, 0);\n return;\n }\n this.scrollPositions.set(path, scrollY);\n } catch (error) {\n logger3.warn("failed to record scroll position", error);\n }\n }\n getScrollPosition(path) {\n const position = this.scrollPositions.get(path);\n if (position === void 0) {\n logger3.debug(`No scroll position stored for ${path}`);\n return 0;\n }\n return position;\n }\n isPopState() {\n return this.isPopStateNav;\n }\n clearPopStateFlag() {\n this.isPopStateNav = false;\n }\n clear() {\n for (const timeoutId of this.pendingTimeouts.values()) clearTimeout(timeoutId);\n this.pendingTimeouts.clear();\n this.prefetchQueue.clear();\n this.scrollPositions.clear();\n this.isPopStateNav = false;\n }\n};\n\n// src/rendering/client/browser-stubs/error-registry.ts\nfunction createBrowserError(name, fallbackMessage) {\n return {\n create(options = {}) {\n const error = new Error(options.detail ?? fallbackMessage);\n error.name = name;\n Object.assign(error, {\n status: options.status,\n context: options.context\n });\n return error;\n }\n };\n}\nvar NETWORK_ERROR = createBrowserError("NetworkError", "Network request failed");\nvar SECURITY_VIOLATION = createBrowserError("SecurityViolation", "Security violation");\n\n// src/html/html-detection.ts\nfunction isFullHTMLDocument(content) {\n const trimmed = content.trim().toLowerCase();\n return trimmed.startsWith("");\n}\n\n// src/routing/client/page-loader.ts\nvar logger4 = rendererLogger.component("veryfront");\nvar MAX_CACHE_SIZE = 50;\nvar HYDRATION_DATA_ID = "veryfront-hydration-data";\nvar DEPENDENCY_PINNING_RESPONSE_HEADER = "x-veryfront-dependency-pins";\nfunction reloadBrowserDocument(url) {\n if (typeof globalThis.location !== "undefined") {\n globalThis.location.assign(url);\n }\n}\nfunction readDependencyPinningCacheKey(doc) {\n if (!doc) return "off";\n try {\n const hydrationDataElement = doc.getElementById(HYDRATION_DATA_ID);\n if (!hydrationDataElement?.textContent) return "off";\n const hydrationData = JSON.parse(hydrationDataElement.textContent);\n return typeof hydrationData.dependencyPinningCacheKey === "string" && hydrationData.dependencyPinningCacheKey.startsWith("on:") ? hydrationData.dependencyPinningCacheKey : "off";\n } catch (error) {\n logger4.debug("Failed to read dependency snapshot from hydration data:", error);\n return "off";\n }\n}\nvar PageLoader = class {\n constructor(doc = typeof document === "undefined" ? void 0 : document, reloadDocument = reloadBrowserDocument) {\n __publicField(this, "cache", /* @__PURE__ */ new Map());\n __publicField(this, "spaCache", /* @__PURE__ */ new Map());\n __publicField(this, "pendingRequests", /* @__PURE__ */ new Map());\n __publicField(this, "pendingSpaRequests", /* @__PURE__ */ new Map());\n /**\n * A loader belongs to the dependency snapshot of the document that created it.\n * Keeping this immutable also prevents cached or in-flight route data from\n * crossing snapshot boundaries if the hydration element is later replaced.\n */\n __publicField(this, "dependencyPinningCacheKey");\n __publicField(this, "reloadDocument");\n __publicField(this, "snapshotRecoveryStarted", false);\n this.dependencyPinningCacheKey = readDependencyPinningCacheKey(doc);\n this.reloadDocument = reloadDocument;\n }\n evictIfFull(map) {\n if (map.size < MAX_CACHE_SIZE) return;\n const oldest = map.keys().next().value;\n if (oldest) map.delete(oldest);\n }\n getCached(path) {\n return this.cache.get(this.snapshotScopedPath(path));\n }\n isCached(path) {\n return this.cache.has(this.snapshotScopedPath(path));\n }\n setCache(path, data) {\n this.evictIfFull(this.cache);\n this.cache.set(this.snapshotScopedPath(path), data);\n }\n clearCache() {\n this.cache.clear();\n this.spaCache.clear();\n this.pendingRequests.clear();\n this.pendingSpaRequests.clear();\n }\n getSpaCached(path) {\n return this.spaCache.get(this.snapshotScopedPath(path));\n }\n isSpaDataCached(path) {\n return this.spaCache.has(this.snapshotScopedPath(path));\n }\n setSpaCache(path, data) {\n this.evictIfFull(this.spaCache);\n this.spaCache.set(this.snapshotScopedPath(path), data);\n }\n async fetchPageData(path, reloadOnSnapshotFailure = true) {\n try {\n return await this.tryFetchJSON(path) ?? await this.fetchAndParseHTML(path);\n } catch (error) {\n this.recoverSnapshotFailure(error, path, reloadOnSnapshotFailure);\n throw error;\n }\n }\n async tryFetchJSON(path) {\n let response;\n try {\n const navigationUrl = new URL(path, "http://veryfront.local");\n const dataPath = navigationUrl.pathname === "/" ? "/index" : navigationUrl.pathname;\n const endpoint = `/_veryfront/data${dataPath}.json${navigationUrl.search}`;\n response = await fetch(endpoint, {\n headers: this.navigationHeaders("client")\n });\n } catch (error) {\n logger4.debug(`JSON fetch failed for ${path}, falling back to HTML:`, error);\n return null;\n }\n if (response.status === 409) {\n this.failDependencySnapshot(\n path,\n `Dependency snapshot is unavailable for ${path}`\n );\n }\n if (!response.ok) return null;\n let data;\n try {\n data = await response.json();\n } catch (error) {\n logger4.debug(`JSON response was invalid for ${path}, falling back to HTML:`, error);\n return null;\n }\n this.assertDependencySnapshot(\n data.dependencyPinningCacheKey,\n path,\n "route data"\n );\n if (typeof data.html === "string" && isFullHTMLDocument(data.html)) {\n const parsed = parsePageDataFromHTML(data.html);\n this.assertDependencySnapshot(\n parsed.dependencyPinningCacheKey,\n path,\n "route data HTML body"\n );\n return {\n ...parsed.pageData,\n ...data,\n html: parsed.content,\n managedHead: parsed.managedHead\n };\n }\n return data;\n }\n async fetchAndParseHTML(path) {\n const response = await fetch(path, {\n headers: this.navigationHeaders("client")\n });\n if (response.status === 409) {\n this.failDependencySnapshot(\n path,\n `Dependency snapshot is unavailable for ${path}`\n );\n }\n if (!response.ok) {\n throw NETWORK_ERROR.create({\n detail: `Failed to fetch ${path}`,\n status: response.status,\n context: { path }\n });\n }\n this.assertDependencySnapshot(\n response.headers.get(DEPENDENCY_PINNING_RESPONSE_HEADER),\n path,\n "HTML response"\n );\n const html = await response.text();\n const {\n content,\n pageData,\n managedHead,\n dependencyPinningCacheKey\n } = parsePageDataFromHTML(html);\n this.assertDependencySnapshot(\n dependencyPinningCacheKey,\n path,\n "HTML body"\n );\n return { ...pageData, html: content, managedHead };\n }\n loadPage(path) {\n return this.loadPageWithSnapshotRecovery(path, true);\n }\n loadPageWithSnapshotRecovery(path, reloadOnSnapshotFailure) {\n const cachedData = this.getCached(path);\n if (cachedData) {\n logger4.debug(`Loading ${path} from cache`);\n return Promise.resolve(cachedData);\n }\n const pendingKey = this.snapshotScopedPath(path);\n const pending = this.pendingRequests.get(pendingKey);\n if (pending) {\n logger4.debug(`Reusing pending request for ${path}`);\n return this.withSnapshotRecovery(\n pending,\n path,\n reloadOnSnapshotFailure\n );\n }\n logger4.debug(`Creating pending request for ${path}`);\n const request = this.createPendingRequest(pendingKey, this.pendingRequests, async () => {\n const data = await this.fetchPageData(path, false);\n this.setCache(path, data);\n return data;\n });\n return this.withSnapshotRecovery(\n request,\n path,\n reloadOnSnapshotFailure\n );\n }\n async prefetch(path) {\n if (this.isCached(path)) return;\n logger4.debug(`Prefetching ${path}`);\n try {\n await this.loadPageWithSnapshotRecovery(path, false);\n } catch (error) {\n logger4.warn(\n `[Veryfront] Failed to prefetch ${path}`,\n error instanceof Error ? error : new Error(String(error))\n );\n }\n }\n async fetchSpaPageData(path, reloadOnSnapshotFailure = true) {\n try {\n const navigationUrl = new URL(path, "http://veryfront.local");\n const normalizedPath = navigationUrl.pathname === "/" ? "index" : navigationUrl.pathname.replace(/^\\//, "");\n const endpoint = `/_veryfront/page-data/${normalizedPath}.json${navigationUrl.search}`;\n logger4.debug(`Fetching SPA page data from ${endpoint}`);\n const response = await fetch(endpoint, {\n headers: this.navigationHeaders("spa")\n });\n if (response.status === 409) {\n this.failDependencySnapshot(\n path,\n `Dependency snapshot is unavailable for SPA page data ${path}`\n );\n }\n if (!response.ok) {\n throw NETWORK_ERROR.create({\n detail: `Failed to fetch SPA page data for ${path}`,\n status: response.status,\n context: { path }\n });\n }\n const data = await response.json();\n this.assertDependencySnapshot(\n data.dependencyPinningCacheKey,\n path,\n "SPA page data"\n );\n return data;\n } catch (error) {\n this.recoverSnapshotFailure(error, path, reloadOnSnapshotFailure);\n throw error;\n }\n }\n loadSpaPageData(path) {\n return this.loadSpaPageDataWithSnapshotRecovery(path, true);\n }\n loadSpaPageDataWithSnapshotRecovery(path, reloadOnSnapshotFailure) {\n const cachedData = this.getSpaCached(path);\n if (cachedData) {\n logger4.debug(`Loading SPA data for ${path} from cache`);\n return Promise.resolve(cachedData);\n }\n const pendingKey = this.snapshotScopedPath(path);\n const pending = this.pendingSpaRequests.get(pendingKey);\n if (pending) {\n logger4.debug(`Reusing pending SPA request for ${path}`);\n return this.withSnapshotRecovery(\n pending,\n path,\n reloadOnSnapshotFailure\n );\n }\n logger4.debug(`Creating pending SPA request for ${path}`);\n const request = this.createPendingRequest(pendingKey, this.pendingSpaRequests, async () => {\n const data = await this.fetchSpaPageData(path, false);\n this.setSpaCache(path, data);\n return data;\n });\n return this.withSnapshotRecovery(\n request,\n path,\n reloadOnSnapshotFailure\n );\n }\n async prefetchSpaPageData(path) {\n if (this.isSpaDataCached(path)) return;\n logger4.debug(`Prefetching SPA page data for ${path}`);\n try {\n await this.loadSpaPageDataWithSnapshotRecovery(path, false);\n } catch (error) {\n logger4.warn(\n `[Veryfront] Failed to prefetch SPA data for ${path}`,\n error instanceof Error ? error : new Error(String(error))\n );\n }\n }\n createPendingRequest(path, pendingMap, fetcher) {\n const request = (async () => {\n try {\n return await fetcher();\n } finally {\n pendingMap.delete(path);\n }\n })();\n pendingMap.set(path, request);\n return request;\n }\n snapshotScopedPath(path) {\n return this.dependencyPinningCacheKey.startsWith("on:") ? `${this.dependencyPinningCacheKey}\\0${path}` : path;\n }\n navigationHeaders(type) {\n return {\n "X-Veryfront-Navigation": type,\n ...this.dependencyPinningCacheKey.startsWith("on:") ? {\n [DEPENDENCY_PINNING_RESPONSE_HEADER]: this.dependencyPinningCacheKey\n } : {}\n };\n }\n assertDependencySnapshot(actualCacheKey, path, source) {\n const expectedCacheKey = this.dependencyPinningCacheKey.startsWith("on:") ? this.dependencyPinningCacheKey : void 0;\n const normalizedActualCacheKey = typeof actualCacheKey === "string" ? actualCacheKey : void 0;\n const matches = expectedCacheKey ? normalizedActualCacheKey === expectedCacheKey : normalizedActualCacheKey === void 0 || normalizedActualCacheKey === "off";\n if (matches) return;\n this.failDependencySnapshot(\n path,\n `Dependency snapshot mismatch in ${source} for ${path}`\n );\n }\n failDependencySnapshot(path, detail) {\n throw NETWORK_ERROR.create({\n detail,\n status: 409,\n context: { path }\n });\n }\n withSnapshotRecovery(promise, path, reloadOnSnapshotFailure) {\n return promise.catch((error) => {\n this.recoverSnapshotFailure(error, path, reloadOnSnapshotFailure);\n throw error;\n });\n }\n recoverSnapshotFailure(error, path, reloadOnSnapshotFailure) {\n if (!reloadOnSnapshotFailure || typeof error !== "object" || error === null || error.status !== 409) {\n return;\n }\n if (this.snapshotRecoveryStarted) return;\n this.snapshotRecoveryStarted = true;\n try {\n this.reloadDocument(path);\n } catch (reloadError) {\n this.snapshotRecoveryStarted = false;\n logger4.warn(\n `[Veryfront] Failed to reload after dependency snapshot conflict for ${path}`,\n reloadError instanceof Error ? reloadError : new Error(String(reloadError))\n );\n }\n }\n};\n\n// src/security/client/html-sanitizer.ts\nvar SUSPICIOUS_PATTERN_SPECS = [\n { source: String.raw`]*>[\\s\\S]*?<\\/script>`, flags: "gi", name: "inline script" },\n { source: String.raw`javascript:`, flags: "gi", name: "javascript: URL" },\n { source: String.raw`\\bon\\w+\\s*=`, flags: "gi", name: "event handler attribute" },\n { source: String.raw`data:\\s*text\\/html`, flags: "gi", name: "data: HTML URL" }\n];\nfunction createSuspiciousPatterns() {\n return SUSPICIOUS_PATTERN_SPECS.map(({ source, flags, name }) => ({\n pattern: new RegExp(source, flags),\n name\n }));\n}\nfunction isDevMode() {\n const g = globalThis;\n return g.__VERYFRONT_DEV__ === true || g.Deno?.env?.get?.("VERYFRONT_ENV") === "development";\n}\nfunction validateTrustedHtml(html, options = {}) {\n const { allowInlineScripts = false, strict = false, warn = true } = options;\n for (const { pattern, name } of createSuspiciousPatterns()) {\n if (allowInlineScripts && name === "inline script") continue;\n pattern.lastIndex = 0;\n if (!pattern.test(html)) continue;\n if (warn) console.warn(`[Security] Suspicious ${name} detected in server HTML`);\n if (strict || !isDevMode()) {\n throw SECURITY_VIOLATION.create({ detail: `Potentially unsafe HTML: ${name} detected` });\n }\n }\n return html;\n}\n\n// src/routing/client/page-transition.ts\nvar logger5 = rendererLogger.component("veryfront");\nvar PageTransition = class {\n constructor(setupViewportPrefetch) {\n __publicField(this, "setupViewportPrefetch", setupViewportPrefetch);\n __publicField(this, "pendingTransitionTimeout");\n __publicField(this, "pendingRoot");\n }\n destroy() {\n this.cancelPendingTransition();\n }\n cancelPendingTransition() {\n if (this.pendingTransitionTimeout !== void 0) {\n clearTimeout(this.pendingTransitionTimeout);\n this.pendingTransitionTimeout = void 0;\n }\n if (this.pendingRoot) {\n this.pendingRoot.style.opacity = "1";\n this.pendingRoot = void 0;\n }\n }\n updatePage(data, isPopState, scrollY) {\n this.cancelPendingTransition();\n if (data.requiresFullDocumentNavigation || data.managedHead?.some((entry) => entry.tagName === "script") || typeof data.html === "string" && / {\n this.pendingTransitionTimeout = void 0;\n this.pendingRoot = void 0;\n try {\n retireClientHeadOwnership(rootElement.ownerDocument);\n rootElement.innerHTML = trustedHtml;\n applyHeadDirectives(rootElement);\n applyPreparedClientRouteHeadDescriptors(preparedHead, rootElement.ownerDocument);\n this.updateDocumentMetadata(rootElement.ownerDocument, data, retainedTitle);\n this.setupViewportPrefetch(rootElement);\n manageFocus(rootElement);\n this.handleScroll(isPopState, scrollY);\n } catch (error) {\n logger5.error("Route transition commit failed; reloading the document", error);\n globalThis.location?.reload();\n } finally {\n rootElement.style.opacity = "1";\n }\n }, PAGE_TRANSITION_DELAY_MS);\n }\n handleScroll(isPopState, scrollY) {\n try {\n globalThis.scrollTo(0, isPopState ? scrollY : 0);\n } catch (error) {\n logger5.warn("scroll handling failed", error);\n }\n }\n showError(error) {\n const rootElement = document.getElementById("root");\n if (!rootElement) return;\n const errorDiv = document.createElement("div");\n errorDiv.className = "veryfront-error-page";\n const heading = document.createElement("h1");\n heading.textContent = "Oops! Something went wrong";\n const message = document.createElement("p");\n message.textContent = error.message;\n const button = document.createElement("button");\n button.type = "button";\n button.textContent = "Reload Page";\n button.onclick = () => globalThis.location.reload();\n errorDiv.append(heading, message, button);\n retireClientHeadOwnership(rootElement.ownerDocument);\n rootElement.innerHTML = "";\n rootElement.appendChild(errorDiv);\n }\n setLoadingState(loading) {\n const indicator = document.getElementById("veryfront-loading");\n if (indicator) indicator.style.display = loading ? "block" : "none";\n document.body.classList.toggle("veryfront-loading", loading);\n }\n};\n\n// src/routing/client/viewport-prefetch.ts\nvar logger6 = rendererLogger.component("veryfront");\nvar ViewportPrefetch = class {\n constructor(prefetchCallback, prefetchOptions = {}) {\n __publicField(this, "observer", null);\n __publicField(this, "prefetchCallback");\n __publicField(this, "prefetchOptions");\n this.prefetchCallback = prefetchCallback;\n this.prefetchOptions = prefetchOptions;\n }\n setup(root) {\n try {\n if (!("IntersectionObserver" in globalThis)) return;\n this.observer?.disconnect();\n this.createObserver();\n this.observeLinks(root);\n } catch (error) {\n logger6.debug("setupViewportPrefetch failed", error);\n }\n }\n createObserver() {\n this.observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue;\n if (!(entry.target instanceof HTMLAnchorElement)) continue;\n const href = entry.target.getAttribute("href");\n if (href) this.prefetchCallback(href);\n this.observer?.unobserve(entry.target);\n }\n },\n { rootMargin: "200px" }\n );\n }\n observeLinks(root) {\n const anchors = root.querySelectorAll(\'a[href]:not([target="_blank"])\');\n const isViewportEnabled = Boolean(this.prefetchOptions.viewport);\n for (const anchor of anchors) {\n if (!this.shouldObserveAnchor(anchor, isViewportEnabled)) continue;\n this.observer?.observe(anchor);\n }\n }\n shouldObserveAnchor(anchor, isViewportEnabled) {\n const href = anchor.getAttribute("href");\n if (!href) return false;\n if (href.startsWith("http") || href.startsWith("#")) return false;\n if (anchor.getAttribute("download")) return false;\n const prefetchAttribute = anchor.getAttribute("data-prefetch");\n if (prefetchAttribute === "false") return false;\n return prefetchAttribute === "viewport" || isViewportEnabled;\n }\n disconnect() {\n if (!this.observer) return;\n try {\n this.observer.disconnect();\n } catch (error) {\n logger6.warn("prefetchObserver.disconnect failed", error);\n } finally {\n this.observer = null;\n }\n }\n};\n\n// src/rendering/client/router.ts\nvar logger7 = rendererLogger.component("veryfront");\nfunction toHistoryMode(options) {\n if (typeof options === "boolean") return options ? "push" : "none";\n return options?.history ?? "push";\n}\nvar VeryfrontRouter = class {\n constructor(options = {}) {\n __publicField(this, "baseUrl");\n __publicField(this, "currentPath");\n __publicField(this, "root", null);\n __publicField(this, "options");\n __publicField(this, "spaMode");\n __publicField(this, "spaNavigationHandler", null);\n __publicField(this, "navigationSequence", 0);\n __publicField(this, "pageLoader");\n __publicField(this, "navigationHandlers");\n __publicField(this, "pageTransition");\n __publicField(this, "viewportPrefetch");\n __publicField(this, "handleClick");\n __publicField(this, "handlePopState");\n __publicField(this, "handleMouseOver");\n const globalOptions = this.loadGlobalOptions();\n this.options = { ...globalOptions, ...options };\n this.baseUrl = this.options.baseUrl || globalThis.location.origin;\n this.currentPath = `${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`;\n this.spaMode = this.options.spaMode ?? globalThis.__VERYFRONT_SPA_MODE__ ?? false;\n this.pageLoader = new PageLoader();\n this.navigationHandlers = new NavigationHandlers(\n this.options.prefetchDelay,\n this.options.prefetch\n );\n this.pageTransition = new PageTransition((root) => this.viewportPrefetch.setup(root));\n this.viewportPrefetch = new ViewportPrefetch(\n (path) => this.prefetch(path),\n this.options.prefetch\n );\n this.handleClick = this.navigationHandlers.createClickHandler({\n onNavigate: (url) => this.navigate(url),\n onPrefetch: (url) => this.prefetch(url)\n });\n this.handlePopState = this.navigationHandlers.createPopStateHandler({\n // The browser already updated the URL for a popstate, so don\'t touch history.\n onNavigate: (url) => this.navigate(url, { history: "none" }),\n onPrefetch: (url) => this.prefetch(url)\n });\n this.handleMouseOver = this.navigationHandlers.createMouseOverHandler({\n onNavigate: (url) => this.navigate(url),\n onPrefetch: (url) => this.prefetch(url)\n });\n getNavigationStore().setNavigator((href, options2) => this.navigate(href, options2));\n }\n registerNavigationHandler(handler) {\n logger7.debug("Registering SPA navigation handler");\n this.spaNavigationHandler = handler;\n this.spaMode = true;\n }\n /**\n * Notify React (and any other) subscribers that a navigation completed —\n * after full page loads, soft same-route changes, and popstate. Delegates to\n * the shared navigation store, the single subscription surface both bundles\n * share.\n */\n notify() {\n getNavigationStore().notify();\n }\n pathnameOf(url) {\n try {\n return new URL(url, this.baseUrl).pathname;\n } catch {\n return url.split("?")[0]?.split("#")[0] || this.currentPath;\n }\n }\n loadGlobalOptions() {\n try {\n const options = globalThis.__VERYFRONT_ROUTER_OPTS__;\n if (!options) {\n logger7.debug("No global options configured");\n return {};\n }\n return options;\n } catch (error) {\n logger7.error("Failed to read global options:", error);\n return {};\n }\n }\n init() {\n logger7.debug("Initializing client-side router");\n const rootElement = document.getElementById("root");\n if (!rootElement) {\n logger7.error("Root element not found");\n return;\n }\n const ReactDOMToUse = globalThis.ReactDOM ?? ReactDOM;\n this.root = ReactDOMToUse.createRoot(rootElement);\n document.addEventListener("click", this.handleClick);\n globalThis.addEventListener("popstate", this.handlePopState);\n document.addEventListener("mouseover", this.handleMouseOver);\n this.viewportPrefetch.setup(document);\n this.cacheCurrentPage();\n }\n cacheCurrentPage() {\n const pageData = extractPageDataFromScript();\n if (pageData) {\n const managedHead = snapshotClientRouteHead(document);\n this.pageLoader.setCache(this.currentPath, {\n ...pageData,\n managedHead,\n ...managedHead.some((entry) => entry.tagName === "script") || document.getElementById("root")?.querySelector("script") ? { requiresFullDocumentNavigation: true } : {}\n });\n }\n }\n /**\n * Navigate to a URL. `options` selects the history behaviour: `{ history:\n * "push" }` (default), `"replace"`, or `"none"` (the URL already reflects the\n * target, as after popstate). A boolean is accepted for backward\n * compatibility — `true` pushes, `false` maps to `"none"`.\n */\n async navigate(url, options) {\n logger7.debug(`Navigating to ${url} (SPA mode: ${this.spaMode})`);\n const navigationId = ++this.navigationSequence;\n this.pageTransition.cancelPendingTransition();\n this.pageTransition.setLoadingState(false);\n const history = toHistoryMode(options);\n const sameRoute = this.pathnameOf(url) === this.pathnameOf(this.currentPath);\n this.navigationHandlers.saveScrollPosition(this.currentPath);\n this.options.onStart?.(url);\n if (history === "replace") globalThis.history.replaceState({}, "", url);\n else if (history === "push") globalThis.history.pushState({}, "", url);\n if (sameRoute && !this.shouldRevalidate(url, sameRoute)) {\n if (!this.isCurrentNavigation(navigationId)) return;\n this.currentPath = url;\n this.notify();\n this.options.onComplete?.(url);\n this.options.onNavigate?.(url);\n return;\n }\n if (this.spaMode && this.spaNavigationHandler) {\n await this.loadSpaPage(url, navigationId);\n } else {\n if (await this.loadPage(url, true, navigationId)) return;\n }\n if (!this.isCurrentNavigation(navigationId)) return;\n this.notify();\n this.options.onNavigate?.(url);\n }\n isCurrentNavigation(navigationId) {\n return navigationId === this.navigationSequence;\n }\n /**\n * Whether a navigation should refetch page data. A route change always does;\n * a same-route (query/hash-only) change consults `options.shouldRevalidate`,\n * defaulting to `true` so server data is never shown stale.\n */\n shouldRevalidate(nextUrl, sameRoute) {\n const policy = this.options.shouldRevalidate;\n if (!policy) return true;\n return policy({ currentHref: this.currentPath, nextHref: nextUrl, sameRoute });\n }\n async loadSpaPage(path, navigationId) {\n logger7.debug(`Loading SPA page: ${path}`);\n try {\n const spaData = await this.pageLoader.loadSpaPageData(path);\n if (!this.isCurrentNavigation(navigationId)) return;\n await this.spaNavigationHandler?.(spaData);\n if (!this.isCurrentNavigation(navigationId)) return;\n this.currentPath = path;\n this.handleScrollAfterNavigation();\n this.options.onComplete?.(path);\n } catch (error) {\n if (!this.isCurrentNavigation(navigationId)) return;\n const normalizedError = error instanceof Error ? error : new Error(String(error));\n logger7.error(`Failed to load SPA page ${path}`, normalizedError);\n this.options.onError?.(normalizedError);\n this.pageTransition.showError(normalizedError);\n }\n }\n handleScrollAfterNavigation() {\n const isPopState = this.navigationHandlers.isPopState();\n const scrollY = this.navigationHandlers.getScrollPosition(this.currentPath);\n try {\n globalThis.scrollTo(0, isPopState ? scrollY : 0);\n } catch (error) {\n logger7.warn("scroll handling failed", error);\n }\n this.navigationHandlers.clearPopStateFlag();\n }\n /** Returns true when navigation was handed to the browser document loader. */\n async loadPage(path, updateUI = true, navigationId) {\n if (this.pageLoader.isCached(path)) {\n logger7.debug(`Loading ${path} from cache`);\n const data = this.pageLoader.getCached(path);\n if (data) {\n if (!this.isCurrentNavigation(navigationId)) return false;\n if (updateUI && data.requiresFullDocumentNavigation) {\n globalThis.location.assign(path);\n return true;\n }\n if (updateUI) this.updatePage(data, path);\n this.currentPath = path;\n this.pageTransition.setLoadingState(false);\n this.options.onComplete?.(path);\n return false;\n }\n logger7.warn(`Cache entry for ${path} was unexpectedly null, fetching fresh data`);\n }\n this.pageTransition.setLoadingState(true);\n try {\n const data = await this.pageLoader.loadPage(path);\n if (!this.isCurrentNavigation(navigationId)) return false;\n if (updateUI && data.requiresFullDocumentNavigation) {\n globalThis.location.assign(path);\n return true;\n }\n if (updateUI) this.updatePage(data, path);\n this.currentPath = path;\n this.options.onComplete?.(path);\n return false;\n } catch (error) {\n if (!this.isCurrentNavigation(navigationId)) return false;\n const normalizedError = error instanceof Error ? error : new Error(String(error));\n logger7.error(`Failed to load ${path}`, normalizedError);\n this.options.onError?.(normalizedError);\n this.pageTransition.showError(normalizedError);\n return false;\n } finally {\n if (this.isCurrentNavigation(navigationId)) this.pageTransition.setLoadingState(false);\n }\n }\n async prefetch(path) {\n if (this.spaMode) {\n await this.pageLoader.prefetchSpaPageData(path);\n return;\n }\n await this.pageLoader.prefetch(path);\n }\n updatePage(data, targetPath) {\n if (!this.root) return;\n const isPopState = this.navigationHandlers.isPopState();\n const scrollY = this.navigationHandlers.getScrollPosition(targetPath);\n this.pageTransition.updatePage(data, isPopState, scrollY);\n this.navigationHandlers.clearPopStateFlag();\n }\n destroy() {\n this.navigationSequence++;\n this.pageTransition.setLoadingState(false);\n document.removeEventListener("click", this.handleClick);\n globalThis.removeEventListener("popstate", this.handlePopState);\n document.removeEventListener("mouseover", this.handleMouseOver);\n this.viewportPrefetch.disconnect();\n this.pageLoader.clearCache();\n this.navigationHandlers.clear();\n this.pageTransition.destroy();\n }\n};\nfunction boot(options = {}) {\n if (typeof window === "undefined" || !globalThis.document) return null;\n const globalWithRouter = globalThis;\n if (globalWithRouter.veryFrontRouter) return globalWithRouter.veryFrontRouter;\n const { slug: _slug, ...routerOptions } = options;\n const router = new VeryfrontRouter(routerOptions);\n if (document.readyState === "loading") {\n document.addEventListener("DOMContentLoaded", () => router.init(), { once: true });\n } else {\n router.init();\n }\n globalWithRouter.veryFrontRouter = router;\n return router;\n}\nif (typeof window !== "undefined" && globalThis.document) {\n boot();\n}\nexport {\n VeryfrontRouter,\n boot\n};\n'; export const CLIENT_PREFETCH_BUNDLE: string | undefined = 'var __defProp = Object.defineProperty;\nvar __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;\nvar __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);\n\n// src/rendering/client/browser-logger.ts\nvar ConditionalBrowserLogger = class {\n constructor(prefix, level) {\n __publicField(this, "prefix", prefix);\n __publicField(this, "level", level);\n }\n log(minLevel, fn, message, ...args) {\n if (this.level > minLevel) return;\n fn?.(message, ...args);\n }\n debug(message, ...args) {\n this.log(\n 0 /* DEBUG */,\n console.debug,\n `[${this.prefix}] DEBUG: ${message}`,\n ...args\n );\n }\n info(message, ...args) {\n this.log(1 /* INFO */, console.log, `[${this.prefix}] ${message}`, ...args);\n }\n warn(message, ...args) {\n this.log(\n 2 /* WARN */,\n console.warn,\n `[${this.prefix}] WARN: ${message}`,\n ...args\n );\n }\n error(message, ...args) {\n this.log(\n 3 /* ERROR */,\n console.error,\n `[${this.prefix}] ERROR: ${message}`,\n ...args\n );\n }\n};\nfunction getBrowserLogLevel() {\n if (typeof window === "undefined") return 2 /* WARN */;\n const g = globalThis;\n const isDevelopment = g.__VERYFRONT_DEV__ || g.__RSC_DEV__;\n if (!isDevelopment) return 2 /* WARN */;\n const isDebugEnabled2 = g.__VERYFRONT_DEBUG__ || g.__RSC_DEBUG__;\n return isDebugEnabled2 ? 0 /* DEBUG */ : 1 /* INFO */;\n}\nvar defaultLevel = getBrowserLogLevel();\nvar rscLogger = new ConditionalBrowserLogger("RSC", defaultLevel);\nvar prefetchLogger = new ConditionalBrowserLogger("PREFETCH", defaultLevel);\nvar hydrateLogger = new ConditionalBrowserLogger("HYDRATE", defaultLevel);\nvar browserLogger = new ConditionalBrowserLogger("VERYFRONT", defaultLevel);\n\n// src/rendering/client/prefetch/link-observer.ts\nfunction isAnchorElement(element) {\n return typeof HTMLAnchorElement !== "undefined" ? element instanceof HTMLAnchorElement : element.tagName === "A";\n}\nvar LinkObserver = class {\n constructor(options, prefetchedUrls) {\n __publicField(this, "options");\n __publicField(this, "intersectionObserver", null);\n __publicField(this, "mutationObserver", null);\n __publicField(this, "prefetchedUrls");\n __publicField(this, "pendingTimeouts", /* @__PURE__ */ new Map());\n __publicField(this, "elementTimeoutMap", /* @__PURE__ */ new WeakMap());\n __publicField(this, "timeoutCounter", 0);\n this.options = options;\n this.prefetchedUrls = prefetchedUrls;\n }\n init() {\n this.createIntersectionObserver();\n this.observeLinks();\n this.setupMutationObserver();\n }\n createIntersectionObserver() {\n this.intersectionObserver = new IntersectionObserver(\n (entries) => this.handleIntersection(entries),\n { rootMargin: this.options.rootMargin }\n );\n }\n handleIntersection(entries) {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue;\n if (!isAnchorElement(entry.target)) continue;\n const link = entry.target;\n if (this.timeoutCounter > 1e6) this.timeoutCounter = 0;\n const timeoutKey = this.timeoutCounter++;\n const timeoutId = setTimeout(() => {\n this.pendingTimeouts.delete(timeoutKey);\n this.elementTimeoutMap.delete(link);\n this.options.onLinkVisible(link);\n }, this.options.delay);\n this.pendingTimeouts.set(timeoutKey, timeoutId);\n this.elementTimeoutMap.set(link, timeoutKey);\n }\n }\n observeLinks() {\n this.observeAnchors(document.querySelectorAll(\'a[href^="/"], a[href^="./"]\'));\n }\n setupMutationObserver() {\n this.mutationObserver = new MutationObserver((mutations) => {\n for (const mutation of mutations) {\n if (mutation.type !== "childList") continue;\n for (const node of mutation.addedNodes) {\n if (node.nodeType !== Node.ELEMENT_NODE) continue;\n this.observeElement(node);\n }\n for (const node of mutation.removedNodes) {\n if (node.nodeType !== Node.ELEMENT_NODE) continue;\n this.clearElementTimeouts(node);\n }\n }\n });\n this.mutationObserver.observe(document.body, { childList: true, subtree: true });\n }\n clearTimeoutForElement(element) {\n const timeoutKey = this.elementTimeoutMap.get(element);\n if (timeoutKey === void 0) return;\n const timeoutId = this.pendingTimeouts.get(timeoutKey);\n if (timeoutId !== void 0) {\n clearTimeout(timeoutId);\n this.pendingTimeouts.delete(timeoutKey);\n }\n this.elementTimeoutMap.delete(element);\n }\n clearElementTimeouts(element) {\n if (isAnchorElement(element)) this.clearTimeoutForElement(element);\n for (const link of element.querySelectorAll("a")) {\n this.clearTimeoutForElement(link);\n }\n }\n observeElement(element) {\n if (isAnchorElement(element) && this.isValidLink(element)) {\n this.intersectionObserver?.observe(element);\n }\n this.observeAnchors(element.querySelectorAll(\'a[href^="/"], a[href^="./"]\'));\n }\n observeAnchors(links) {\n for (const link of links) {\n if (!isAnchorElement(link)) continue;\n if (!this.isValidLink(link)) continue;\n this.intersectionObserver?.observe(link);\n }\n }\n isValidLink(link) {\n if (link.hostname !== globalThis.location.hostname) return false;\n if (link.hasAttribute("download")) return false;\n if (link.target === "_blank") return false;\n const url = link.href;\n if (this.prefetchedUrls.has(url)) return false;\n if (url === globalThis.location.href) return false;\n if (link.hash && link.pathname === globalThis.location.pathname) return false;\n if (link.dataset.noPrefetch) return false;\n return true;\n }\n destroy() {\n for (const timeoutId of this.pendingTimeouts.values()) {\n clearTimeout(timeoutId);\n }\n this.pendingTimeouts.clear();\n this.timeoutCounter = 0;\n this.intersectionObserver?.disconnect();\n this.intersectionObserver = null;\n this.mutationObserver?.disconnect();\n this.mutationObserver = null;\n }\n};\n\n// src/rendering/client/prefetch/network-utils.ts\nvar NetworkUtils = class {\n constructor(allowedNetworks = ["4g", "wifi", "ethernet"]) {\n __publicField(this, "networkInfo");\n __publicField(this, "allowedNetworks");\n this.allowedNetworks = allowedNetworks;\n this.networkInfo = this.getNetworkConnection();\n }\n getNavigatorWithConnection() {\n if (typeof globalThis.navigator === "undefined") return null;\n return globalThis.navigator;\n }\n getNetworkConnection() {\n const nav = this.getNavigatorWithConnection();\n return nav?.connection ?? nav?.mozConnection ?? nav?.webkitConnection ?? null;\n }\n shouldPrefetch() {\n if (this.networkInfo?.saveData) return false;\n const effectiveType = this.networkInfo?.effectiveType;\n if (effectiveType != null && !this.allowedNetworks.includes(effectiveType)) return false;\n return true;\n }\n onNetworkChange(callback) {\n this.networkInfo?.addEventListener?.("change", callback);\n }\n getNetworkInfo() {\n return this.networkInfo;\n }\n};\n\n// src/utils/constants/css.ts\nvar MAX_CSS_FILE_BYTES = 16 * 1024 * 1024;\nvar MAX_CSS_TOTAL_BYTES = 64 * 1024 * 1024;\nvar MAX_CSS_OUTPUT_FILE_BYTES = 32 * 1024 * 1024;\n\n// src/utils/constants/buffers.ts\nvar DEFAULT_MAX_BODY_SIZE_BYTES = 1024 * 1024;\nvar DEFAULT_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;\nvar PREFETCH_QUEUE_MAX_SIZE_BYTES = DEFAULT_MAX_BODY_SIZE_BYTES;\nvar MAX_BUNDLE_CHUNK_SIZE_BYTES = 4096 * 1024;\n\n// src/utils/constants/limits.ts\nvar MAX_TIMER_DELAY_MS = 2147483647;\n\n// src/utils/constants/cache.ts\nvar SECONDS_PER_MINUTE = 60;\nvar MINUTES_PER_HOUR = 60;\nvar HOURS_PER_DAY = 24;\nvar MS_PER_SECOND = 1e3;\nvar MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND;\nvar MS_PER_HOUR = MINUTES_PER_HOUR * MS_PER_MINUTE;\nvar ONE_DAY_MS = HOURS_PER_DAY * MS_PER_HOUR;\nfunction getEnvString(key) {\n const g = globalThis;\n try {\n return g.Deno?.env?.get?.(key) ?? g.process?.env?.[key];\n } catch (_) {\n return void 0;\n }\n}\nvar MAX_CONFIGURED_CACHE_ENTRIES = 1e6;\nvar MAX_CONFIGURED_CACHE_SIZE_MB = 64 * 1024;\nvar MAX_CONFIGURED_CONCURRENCY = 1e4;\nvar MAX_CONFIGURED_TTL_SECONDS = 365 * HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE;\nvar BYTES_PER_MB = 1024 * 1024;\nvar MAX_CACHE_TTL_SECONDS = 2147483647;\nvar MAX_CACHE_TTL_MILLISECONDS = MAX_CACHE_TTL_SECONDS * MS_PER_SECOND;\nfunction getEnvInteger(key, fallback, { min = 1, max }) {\n const value = getEnvString(key);\n if (value == null) return fallback;\n const normalized = value.trim();\n if (!/^\\d+$/.test(normalized)) return fallback;\n const parsed = Number(normalized);\n if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) return fallback;\n return parsed;\n}\nfunction getStrictEnvInteger(key, fallback, { min = 1, max }) {\n const value = getEnvString(key);\n if (value == null) return fallback;\n const normalized = value.trim();\n if (!/^\\d+$/.test(normalized)) {\n throw new RangeError(\n `${key} must be a base-10 integer between ${min} and ${max}`\n );\n }\n const parsed = Number(normalized);\n if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {\n throw new RangeError(`${key} must be between ${min} and ${max}`);\n }\n return parsed;\n}\nfunction getEnvCacheEntries(key, fallback) {\n return getEnvInteger(key, fallback, { max: MAX_CONFIGURED_CACHE_ENTRIES });\n}\nfunction getEnvCacheSizeMb(key, fallback) {\n return getEnvInteger(key, fallback, { max: MAX_CONFIGURED_CACHE_SIZE_MB });\n}\nfunction getEnvTtlSeconds(key, fallback) {\n return getEnvInteger(key, fallback, { max: MAX_CONFIGURED_TTL_SECONDS });\n}\nvar DEFAULT_LRU_MAX_ENTRIES = getEnvCacheEntries("LRU_DEFAULT_MAX_ENTRIES", 100);\nvar COMPONENT_LOADER_MAX_ENTRIES = getEnvCacheEntries("COMPONENT_LOADER_MAX_ENTRIES", 200);\nvar COMPONENT_LOADER_TTL_MS = 10 * MS_PER_MINUTE;\nvar MDX_RENDERER_MAX_ENTRIES = getEnvCacheEntries("MDX_RENDERER_MAX_ENTRIES", 500);\nvar MDX_RENDERER_TTL_MS = 10 * MS_PER_MINUTE;\nvar RENDERER_CORE_MAX_ENTRIES = getEnvCacheEntries("RENDERER_CORE_MAX_ENTRIES", 200);\nvar RENDERER_CORE_TTL_MS = 5 * MS_PER_MINUTE;\nvar TSX_LAYOUT_MAX_ENTRIES = getEnvCacheEntries("TSX_LAYOUT_MAX_ENTRIES", 100);\nvar TSX_LAYOUT_TTL_MS = 10 * MS_PER_MINUTE;\nvar TSX_LAYOUT_PER_PROJECT_MAX_ENTRIES = getEnvCacheEntries(\n "TSX_LAYOUT_PER_PROJECT_MAX_ENTRIES",\n Math.ceil(TSX_LAYOUT_MAX_ENTRIES / 10)\n);\nvar DATA_FETCHING_MAX_ENTRIES = getStrictEnvInteger(\n "DATA_FETCHING_MAX_ENTRIES",\n 500,\n { max: MAX_CONFIGURED_CACHE_ENTRIES }\n);\nvar DATA_FETCHING_MAX_ENTRIES_PER_PROJECT = getStrictEnvInteger(\n "DATA_FETCHING_MAX_ENTRIES_PER_PROJECT",\n Math.max(1, Math.ceil(DATA_FETCHING_MAX_ENTRIES / 5)),\n { max: DATA_FETCHING_MAX_ENTRIES }\n);\nvar dataFetchingMaxSizeMb = getStrictEnvInteger(\n "DATA_FETCHING_MAX_SIZE_MB",\n 50,\n { max: MAX_CONFIGURED_CACHE_SIZE_MB }\n);\nvar DATA_FETCHING_MAX_SIZE_BYTES = dataFetchingMaxSizeMb * BYTES_PER_MB;\nvar DATA_FETCHING_MAX_SIZE_BYTES_PER_PROJECT = getStrictEnvInteger(\n "DATA_FETCHING_MAX_SIZE_MB_PER_PROJECT",\n Math.max(1, Math.ceil(dataFetchingMaxSizeMb / 5)),\n { max: dataFetchingMaxSizeMb }\n) * BYTES_PER_MB;\nvar DATA_FETCHING_TTL_MS = 10 * MS_PER_MINUTE;\nvar DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS = getStrictEnvInteger(\n "DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS",\n 512,\n { max: MAX_CONFIGURED_CONCURRENCY }\n);\nvar DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS_PER_PROJECT = getStrictEnvInteger(\n "DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS_PER_PROJECT",\n Math.min(128, DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS),\n { max: DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS }\n);\nvar MDX_CACHE_TTL_DEVELOPMENT_MS = 5 * MS_PER_MINUTE;\nvar BUNDLE_CACHE_TTL_DEVELOPMENT_MS = 5 * MS_PER_MINUTE;\nvar BUNDLE_MANIFEST_PROD_TTL_MS = 7 * ONE_DAY_MS;\nvar SERVER_ACTION_DEFAULT_TTL_SEC = MINUTES_PER_HOUR * SECONDS_PER_MINUTE;\nvar DISTRIBUTED_SSR_MODULE_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_SSR_MODULE_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_SSR_MODULE_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_SSR_MODULE_TTL_PREVIEW_SEC",\n 10 * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_TRANSFORM_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_TRANSFORM_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_TRANSFORM_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_TRANSFORM_TTL_PREVIEW_SEC",\n 10 * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_FILE_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_FILE_TTL_SEC",\n MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_FILE_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_FILE_TTL_PREVIEW_SEC",\n 5 * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_CSS_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_CSS_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_CSS_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_CSS_TTL_PREVIEW_SEC",\n 10 * SECONDS_PER_MINUTE\n);\nvar LRU_DEFAULT_MAX_ENTRIES_V2 = getEnvCacheEntries("LRU_MAX_ENTRIES", 2e3);\nvar LRU_DEFAULT_MAX_SIZE_BYTES = getEnvCacheSizeMb("LRU_MAX_SIZE_MB", 200) * BYTES_PER_MB;\nvar MEMORY_CACHE_MAX_ENTRIES = getEnvCacheEntries("MEMORY_CACHE_MAX_ENTRIES", 2e3);\nvar MEMORY_CACHE_MAX_SIZE_BYTES = getEnvCacheSizeMb("MEMORY_CACHE_MAX_SIZE_MB", 50) * BYTES_PER_MB;\nvar FILE_CACHE_MAX_ENTRIES = getEnvCacheEntries("FILE_CACHE_MAX_ENTRIES", 1e3);\nvar FILE_CACHE_MAX_SIZE_MB = getEnvCacheSizeMb("FILE_CACHE_MAX_SIZE_MB", 100);\nvar MAX_CONCURRENT_REVALIDATIONS = getEnvInteger("MAX_CONCURRENT_REVALIDATIONS", 32, {\n max: MAX_CONFIGURED_CONCURRENCY\n});\nvar MAX_CONCURRENT_HTTP_FETCHES = getEnvInteger("MAX_CONCURRENT_HTTP_FETCHES", 50, {\n max: MAX_CONFIGURED_CONCURRENCY\n});\nvar REVALIDATION_TIMEOUT_MS = getEnvInteger("REVALIDATION_TIMEOUT_MS", 15e3, {\n max: MAX_TIMER_DELAY_MS\n});\nvar REVALIDATION_PER_PROJECT_LIMIT = getEnvInteger(\n "REVALIDATION_PER_PROJECT_LIMIT",\n Math.ceil(MAX_CONCURRENT_REVALIDATIONS / 3),\n { min: 0, max: MAX_CONFIGURED_CONCURRENCY }\n);\nvar BUNDLE_MANIFEST_DISTRIBUTED_TTL_SEC = getEnvTtlSeconds(\n "BUNDLE_MANIFEST_DISTRIBUTED_TTL_SEC",\n HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n // 24 hours (86400)\n);\nvar BUNDLE_MANIFEST_LRU_MAX_ENTRIES = getEnvCacheEntries(\n "BUNDLE_MANIFEST_LRU_MAX_ENTRIES",\n 5e3\n);\nvar BUNDLE_MANIFEST_MEMORY_MAX_METADATA_SIZE_BYTES = getEnvCacheSizeMb(\n "BUNDLE_MANIFEST_MEMORY_MAX_METADATA_SIZE_MB",\n 128\n) * BYTES_PER_MB;\nvar BUNDLE_MANIFEST_MEMORY_MAX_CODE_SIZE_BYTES = getEnvCacheSizeMb(\n "BUNDLE_MANIFEST_MEMORY_MAX_CODE_SIZE_MB",\n 256\n) * BYTES_PER_MB;\nvar HTTP_MODULE_CACHE_MAX_ENTRIES = getEnvCacheEntries(\n "HTTP_MODULE_CACHE_MAX_ENTRIES",\n 2e3\n);\nvar HTTP_MODULE_DISTRIBUTED_TTL_SEC = getEnvTtlSeconds(\n "HTTP_MODULE_DISTRIBUTED_TTL_SEC",\n HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n // 24 hours (86400)\n);\nvar TRANSFORM_DISTRIBUTED_TTL_SEC = getEnvTtlSeconds(\n "TRANSFORM_DISTRIBUTED_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n // 6 hours (21600)\n);\nvar MODULE_CACHE_MAX_ENTRIES = getEnvCacheEntries("MODULE_CACHE_MAX_ENTRIES", 1e4);\nvar MODULE_CACHE_TTL_MS = getEnvInteger(\n "MODULE_CACHE_TTL_MS",\n 5 * MS_PER_MINUTE,\n // 5 minutes - short enough to pick up changes, long enough to cache\n { max: MAX_TIMER_DELAY_MS }\n);\nvar ESM_CACHE_MAX_ENTRIES = getEnvCacheEntries("ESM_CACHE_MAX_ENTRIES", 5e3);\nvar ESM_CACHE_TTL_MS = getEnvInteger(\n "ESM_CACHE_TTL_MS",\n 10 * MS_PER_MINUTE,\n // 10 minutes - external modules change less frequently\n { max: MAX_TIMER_DELAY_MS }\n);\n\n// src/utils/constants/http.ts\nvar KB_IN_BYTES = 1024;\nvar PREFETCH_MAX_SIZE_BYTES = 200 * KB_IN_BYTES;\n\n// src/utils/constants/hmr.ts\nvar HMR_MAX_MESSAGE_SIZE_BYTES = 1024 * KB_IN_BYTES;\n\n// src/utils/constants/network.ts\nvar BYTES_PER_KB = 1024;\nvar BYTES_PER_MB2 = BYTES_PER_KB * BYTES_PER_KB;\n\n// src/utils/constants/security.ts\nvar MAX_CSRF_TTL_SECONDS = Number.MAX_SAFE_INTEGER;\n\n// src/platform/compat/constants.ts\nvar DEFAULT_PORT = 3e3;\nvar LOCALHOST = Object.freeze(\n {\n IPV4: "127.0.0.1",\n IPV6: "::1",\n HOSTNAME: "localhost"\n }\n);\n\n// src/config/defaults.ts\nvar DEFAULT_TIMEOUT_MS = 5e3;\nvar SSR_TIMEOUT_MS = 1e4;\nvar SSR_MAX_BUFFERED_BYTES = 16 * 1024 * 1024;\nvar SANDBOX_TIMEOUT_MS = 5e3;\nvar DEFAULT_CACHE_MAX_SIZE = 100;\nvar DURATION_HISTOGRAM_BOUNDARIES_MS = Object.freeze(\n [\n 5,\n 10,\n 25,\n 50,\n 75,\n 100,\n 250,\n 500,\n 750,\n 1e3,\n 2500,\n 5e3,\n 7500,\n 1e4\n ]\n);\nvar SIZE_HISTOGRAM_BOUNDARIES_KB = Object.freeze(\n [\n 1,\n 5,\n 10,\n 25,\n 50,\n 100,\n 250,\n 500,\n 1e3,\n 2500,\n 5e3,\n 1e4\n ]\n);\nvar defaultConfig = Object.freeze(\n {\n server: Object.freeze({\n port: DEFAULT_PORT,\n hostname: "0.0.0.0"\n }),\n timeouts: Object.freeze({\n default: DEFAULT_TIMEOUT_MS,\n api: 3e4,\n ssr: SSR_TIMEOUT_MS,\n hmr: 3e4,\n sandbox: SANDBOX_TIMEOUT_MS\n }),\n cache: Object.freeze({\n jit: Object.freeze({\n maxSize: DEFAULT_CACHE_MAX_SIZE,\n tempDirPrefix: "vf-bundle-"\n })\n }),\n metrics: Object.freeze({\n ssrBoundaries: DURATION_HISTOGRAM_BOUNDARIES_MS\n })\n }\n);\n\n// src/utils/constants/server.ts\nvar INTERNAL_PREFIX = "/_veryfront";\nvar INTERNAL_PATH_PREFIXES = {\n /** React Server Components endpoints */\n RSC: `${INTERNAL_PREFIX}/rsc/`,\n /** File system access endpoints (base64 encoded paths) */\n FS: `${INTERNAL_PREFIX}/fs/`,\n /** Virtual module system */\n MODULES: `${INTERNAL_PREFIX}/modules/`,\n /** Generated page modules */\n PAGES: `${INTERNAL_PREFIX}/pages/`,\n /** Data JSON endpoints */\n DATA: `${INTERNAL_PREFIX}/data/`,\n /** Library modules and large vendor surfaces */\n LIB: `${INTERNAL_PREFIX}/lib/`,\n /** Chunk assets */\n CHUNKS: `${INTERNAL_PREFIX}/chunks/`,\n /** Client component modules */\n CLIENT: `${INTERNAL_PREFIX}/client/`\n};\nvar INTERNAL_ENDPOINTS = {\n // Development endpoints\n HMR_RUNTIME: `${INTERNAL_PREFIX}/hmr-runtime.js`,\n HMR: `${INTERNAL_PREFIX}/hmr.js`,\n ERROR_OVERLAY: `${INTERNAL_PREFIX}/error-overlay.js`,\n // Legacy endpoint retained for backward compatibility (no active handler).\n DEV_LOADER: `${INTERNAL_PREFIX}/dev-loader.js`,\n CLIENT_LOG: `${INTERNAL_PREFIX}/log`,\n // Production endpoints\n CLIENT_JS: `${INTERNAL_PREFIX}/client.js`,\n ROUTER_JS: `${INTERNAL_PREFIX}/router.js`,\n PREFETCH_JS: `${INTERNAL_PREFIX}/prefetch.js`,\n MANIFEST_JSON: `${INTERNAL_PREFIX}/manifest.json`,\n APP_JS: `${INTERNAL_PREFIX}/app.js`,\n // RSC endpoints\n RSC_CLIENT: `${INTERNAL_PREFIX}/rsc/client.js`,\n RSC_MANIFEST: `${INTERNAL_PREFIX}/rsc/manifest`,\n RSC_STREAM: `${INTERNAL_PREFIX}/rsc/stream`,\n RSC_PAYLOAD: `${INTERNAL_PREFIX}/rsc/payload`,\n RSC_RENDER: `${INTERNAL_PREFIX}/rsc/render`,\n RSC_PAGE: `${INTERNAL_PREFIX}/rsc/page`,\n RSC_MODULE: `${INTERNAL_PREFIX}/rsc/module`,\n RSC_DOM: `${INTERNAL_PREFIX}/rsc/dom.js`,\n // Library module endpoints\n LIB_CHAT_REACT: `${INTERNAL_PREFIX}/lib/chat/react.js`,\n LIB_CHAT_COMPONENTS: `${INTERNAL_PREFIX}/lib/chat/components.js`,\n LIB_CHAT_PRIMITIVES: `${INTERNAL_PREFIX}/lib/chat/primitives.js`\n};\nvar PROJECT_DIRS = {\n /** Base veryfront internal directory */\n ROOT: ".veryfront",\n /** Cache directory for build artifacts, transforms, etc. */\n CACHE: ".veryfront/cache",\n /** KV store directory */\n KV: ".veryfront/kv",\n /** Log files directory */\n LOGS: ".veryfront/logs",\n /** Temporary files directory */\n TMP: ".veryfront/tmp"\n};\nvar DEFAULT_CACHE_DIR = PROJECT_DIRS.CACHE;\nvar DEV_SERVER_ENDPOINTS = {\n HMR_RUNTIME: INTERNAL_ENDPOINTS.HMR_RUNTIME,\n ERROR_OVERLAY: INTERNAL_ENDPOINTS.ERROR_OVERLAY\n};\n\n// src/rendering/client/prefetch/prefetch-queue.ts\nvar DEFAULT_OPTIONS = {\n maxConcurrent: 4,\n maxSize: PREFETCH_QUEUE_MAX_SIZE_BYTES,\n timeout: 5e3\n};\nfunction isAbortError(error) {\n if (typeof error !== "object" || error === null) return false;\n if (!("name" in error)) return false;\n return error.name === "AbortError";\n}\nvar PrefetchQueue = class {\n constructor(options = {}, prefetchedUrls) {\n __publicField(this, "options");\n __publicField(this, "controllers", /* @__PURE__ */ new Map());\n __publicField(this, "prefetchedUrls");\n __publicField(this, "concurrent", 0);\n __publicField(this, "stopped", false);\n __publicField(this, "onResourcesFetched");\n this.options = { ...DEFAULT_OPTIONS, ...options };\n this.prefetchedUrls = prefetchedUrls ?? /* @__PURE__ */ new Set();\n }\n setResourceCallback(callback) {\n this.onResourcesFetched = callback;\n }\n enqueue(url) {\n void this.prefetch(url);\n }\n has(url) {\n return this.prefetchedUrls.has(url) || this.controllers.has(url);\n }\n get size() {\n return this.controllers.size;\n }\n clear() {\n this.stopAll();\n this.prefetchedUrls.clear();\n }\n start() {\n this.stopped = false;\n }\n stop() {\n this.stopped = true;\n this.stopAll();\n }\n getQueueSize() {\n return this.controllers.size;\n }\n getConcurrentCount() {\n return this.concurrent;\n }\n async prefetchLink(link) {\n if (this.stopped) return;\n const url = link.href;\n if (!url || this.controllers.has(url) || this.prefetchedUrls.has(url)) return;\n if (this.concurrent >= this.options.maxConcurrent) {\n prefetchLogger.debug?.(`Prefetch queue full, skipping ${url}`);\n return;\n }\n let parsedUrl;\n try {\n parsedUrl = new URL(url);\n } catch (_) {\n prefetchLogger.debug?.(`Invalid prefetch URL ${url}`);\n return;\n }\n const controller = new AbortController();\n this.controllers.set(url, controller);\n this.concurrent += 1;\n const timeoutId = this.options.timeout > 0 ? setTimeout(() => controller.abort(), this.options.timeout) : void 0;\n try {\n const response = await fetch(parsedUrl.toString(), {\n method: "GET",\n signal: controller.signal,\n headers: { "X-Veryfront-Prefetch": "1" }\n });\n if (!response.ok) return;\n if (this.isResponseTooLarge(response)) {\n prefetchLogger.debug?.(`Prefetch too large, skipping ${url}`);\n return;\n }\n this.prefetchedUrls.add(url);\n if (!this.onResourcesFetched) return;\n try {\n await this.onResourcesFetched(response, url);\n } catch (callbackError) {\n prefetchLogger.error?.(`Prefetch callback failed for ${url}`, callbackError);\n }\n } catch (error) {\n if (!isAbortError(error)) {\n prefetchLogger.error?.(`Failed to prefetch ${url}`, error);\n }\n } finally {\n if (timeoutId !== void 0) clearTimeout(timeoutId);\n this.controllers.delete(url);\n this.concurrent = Math.max(0, this.concurrent - 1);\n }\n }\n async prefetch(url) {\n const link = typeof document !== "undefined" ? document.createElement("a") : { href: url };\n link.href = url;\n await this.prefetchLink(link);\n }\n stopAll() {\n for (const controller of this.controllers.values()) {\n controller.abort();\n }\n this.controllers.clear();\n this.concurrent = 0;\n }\n isResponseTooLarge(response) {\n const rawLength = response.headers.get("content-length");\n if (rawLength === null) return false;\n const size = Number.parseInt(rawLength, 10);\n if (!Number.isFinite(size)) return false;\n return size > this.options.maxSize;\n }\n};\nvar prefetchQueue = new PrefetchQueue();\n\n// src/rendering/client/prefetch/resource-hints.ts\nvar ResourceHintsManager = class {\n constructor() {\n __publicField(this, "appliedHints", /* @__PURE__ */ new Set());\n }\n applyResourceHints(hints) {\n for (const hint of hints) {\n const key = `${hint.type}:${hint.href}`;\n if (this.appliedHints.has(key)) continue;\n const existing = document.querySelector(\n `link[rel="${hint.type}"][href="${hint.href}"]`\n );\n if (existing) {\n this.appliedHints.add(key);\n continue;\n }\n this.createAndAppendHint(hint);\n this.appliedHints.add(key);\n prefetchLogger.debug(`Added resource hint: ${hint.type} ${hint.href}`);\n }\n }\n createAndAppendHint(hint) {\n if (!document.head) {\n prefetchLogger.warn("document.head is not available, skipping resource hint");\n return;\n }\n const link = document.createElement("link");\n link.rel = hint.type;\n link.href = hint.href;\n if (hint.as) link.setAttribute("as", hint.as);\n if (hint.crossOrigin) link.setAttribute("crossorigin", hint.crossOrigin);\n if (hint.media) link.setAttribute("media", hint.media);\n document.head.appendChild(link);\n }\n extractResourceHints(html, prefetchedUrls) {\n try {\n const doc = new DOMParser().parseFromString(html, "text/html");\n const hints = [];\n this.extractPreloadLinks(doc, prefetchedUrls, hints);\n this.extractScripts(doc, prefetchedUrls, hints);\n this.extractStylesheets(doc, prefetchedUrls, hints);\n return hints;\n } catch (error) {\n prefetchLogger.error("Failed to parse prefetched page", error);\n return [];\n }\n }\n isValidResourceHintType(rel) {\n switch (rel) {\n case "prefetch":\n case "preload":\n case "preconnect":\n case "dns-prefetch":\n return true;\n default:\n return false;\n }\n }\n extractPreloadLinks(doc, prefetchedUrls, hints) {\n const links = doc.querySelectorAll(\n \'link[rel="preload"], link[rel="prefetch"]\'\n );\n for (const link of links) {\n const href = link.href;\n if (!href) continue;\n if (prefetchedUrls.has(href)) continue;\n if (!this.isValidResourceHintType(link.rel)) continue;\n hints.push({\n type: link.rel,\n href,\n as: link.getAttribute("as") ?? void 0\n });\n }\n }\n extractScripts(doc, prefetchedUrls, hints) {\n for (const script of doc.querySelectorAll("script[src]")) {\n const src = script.src;\n if (!src || prefetchedUrls.has(src)) continue;\n hints.push({ type: "prefetch", href: src, as: "script" });\n }\n }\n extractStylesheets(doc, prefetchedUrls, hints) {\n for (const link of doc.querySelectorAll(\'link[rel="stylesheet"]\')) {\n const href = link.href;\n if (!href || prefetchedUrls.has(href)) continue;\n hints.push({ type: "prefetch", href, as: "style" });\n }\n }\n static generateResourceHints(_route, assets) {\n const hints = [\n \'\',\n \'\',\n \'\'\n ];\n for (const asset of assets) {\n if (asset.endsWith(".js")) {\n hints.push(``);\n continue;\n }\n if (asset.endsWith(".css")) {\n hints.push(``);\n continue;\n }\n if (/\\.(woff2?|ttf|otf)$/.test(asset)) {\n hints.push(``);\n }\n }\n return hints.join("\\n");\n }\n};\n\n// src/rendering/client/browser-stubs/logger.ts\nfunction noop() {\n}\nvar logger = {\n debug: noop,\n info: console.log.bind(console),\n warn: console.warn.bind(console),\n error: console.error.bind(console),\n component: () => logger\n};\nvar PREFETCH_MAX_SIZE_BYTES2 = 200 * 1024;\nvar PREFETCH_DEFAULT_TIMEOUT_MS2 = 1e4;\nvar PREFETCH_DEFAULT_DELAY_MS2 = 200;\n\n// src/rendering/client/prefetch.ts\nvar PrefetchManager = class {\n constructor(options = {}) {\n __publicField(this, "options");\n __publicField(this, "prefetchedUrls", /* @__PURE__ */ new Set());\n __publicField(this, "networkUtils");\n __publicField(this, "linkObserver", null);\n __publicField(this, "resourceHintsManager");\n __publicField(this, "prefetchQueue");\n this.options = {\n rootMargin: options.rootMargin ?? "50px",\n delay: options.delay ?? PREFETCH_DEFAULT_DELAY_MS2,\n maxConcurrent: options.maxConcurrent ?? 2,\n allowedNetworks: options.allowedNetworks ?? ["4g", "wifi", "ethernet"],\n maxSize: options.maxSize ?? PREFETCH_MAX_SIZE_BYTES2,\n timeout: options.timeout ?? PREFETCH_DEFAULT_TIMEOUT_MS2\n };\n this.networkUtils = new NetworkUtils(this.options.allowedNetworks);\n this.resourceHintsManager = new ResourceHintsManager();\n this.prefetchQueue = new PrefetchQueue(\n {\n maxConcurrent: this.options.maxConcurrent,\n maxSize: this.options.maxSize,\n timeout: this.options.timeout\n },\n this.prefetchedUrls\n );\n this.prefetchQueue.setResourceCallback(\n (response, url) => this.prefetchPageResources(response, url)\n );\n }\n init() {\n prefetchLogger.info("Initializing prefetch manager");\n if (!this.networkUtils.shouldPrefetch()) {\n prefetchLogger.info("Prefetching disabled due to network conditions");\n return;\n }\n this.linkObserver = new LinkObserver(\n {\n rootMargin: this.options.rootMargin,\n delay: this.options.delay,\n onLinkVisible: (link) => this.prefetchQueue.prefetchLink(link)\n },\n this.prefetchedUrls\n );\n this.linkObserver.init();\n this.networkUtils.onNetworkChange(() => {\n if (!this.networkUtils.shouldPrefetch()) this.prefetchQueue.stopAll();\n });\n }\n async prefetchPageResources(response, _pageUrl) {\n const html = await response.text();\n const hints = this.resourceHintsManager.extractResourceHints(html, this.prefetchedUrls);\n this.resourceHintsManager.applyResourceHints(hints);\n }\n applyResourceHints(hints) {\n this.resourceHintsManager.applyResourceHints(hints);\n }\n async prefetch(url) {\n await this.prefetchQueue.prefetch(url);\n }\n static generateResourceHints(route, assets) {\n return ResourceHintsManager.generateResourceHints(route, assets);\n }\n destroy() {\n this.linkObserver?.destroy();\n this.prefetchQueue.stopAll();\n this.prefetchedUrls.clear();\n }\n};\nfunction initPrefetch(options) {\n const prefetchManager = new PrefetchManager(options);\n if (document.readyState === "loading") {\n document.addEventListener("DOMContentLoaded", () => prefetchManager.init(), { once: true });\n } else {\n prefetchManager.init();\n }\n globalThis.veryFrontPrefetch = prefetchManager;\n return prefetchManager;\n}\nfunction resolveAutoInitOptions() {\n const setting = globalThis.__VERYFRONT_PREFETCH__;\n if (!setting) return null;\n if (setting === true) return {};\n if (typeof setting === "object") return setting;\n return null;\n}\nfunction shouldAutoInitPrefetch(options) {\n if (!options) return false;\n if (typeof window === "undefined" || typeof document === "undefined") return false;\n const win = window;\n const doc = document;\n if (win.__veryfrontSSRStub || doc.__veryfrontSSRStub) return false;\n if (typeof IntersectionObserver === "undefined") return false;\n if (typeof MutationObserver === "undefined") return false;\n return true;\n}\nvar autoInitOptions = resolveAutoInitOptions();\nif (shouldAutoInitPrefetch(autoInitOptions)) initPrefetch(autoInitOptions);\nexport {\n PrefetchManager,\n initPrefetch\n};\n'; diff --git a/src/index.client.ts b/src/index.client.ts index b9d9359c93..6508d2b038 100644 --- a/src/index.client.ts +++ b/src/index.client.ts @@ -59,6 +59,5 @@ export { parseFormData, parseJsonBody, parseQueryParams, - sanitizeData, } from "#veryfront/security"; export type { ValidatedHandlerConfig, ValidatedHandlerFunction } from "#veryfront/security"; diff --git a/src/index.ts b/src/index.ts index be80aec827..67103ef192 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,5 @@ export { parseFormData, parseJsonBody, parseQueryParams, - sanitizeData, } from "#veryfront/security"; export type { ValidatedHandlerConfig, ValidatedHandlerFunction } from "#veryfront/security"; diff --git a/src/modules/server/module-batch-handler.ts b/src/modules/server/module-batch-handler.ts index e5d505ccbe..baa37819c7 100644 --- a/src/modules/server/module-batch-handler.ts +++ b/src/modules/server/module-batch-handler.ts @@ -276,7 +276,6 @@ export function handleModuleBatch(req: Request, options: BatchHandlerOptions): P adapter, context: "module-loading", contextOptions: { allowedImportDirs }, - throwOnError: false, }); logger.debug("Processing batch request", { diff --git a/src/modules/server/module-server.ts b/src/modules/server/module-server.ts index 446a4a3e4b..eaa9d5fca4 100644 --- a/src/modules/server/module-server.ts +++ b/src/modules/server/module-server.ts @@ -332,7 +332,6 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise adapter, context: "module-loading", contextOptions: { allowedImportDirs }, - throwOnError: false, onSecurityEvent: (event) => { if (event.type !== "validation-failed") return; logger.warn("Security validation failed", { diff --git a/src/release-assets/build-executor.ts b/src/release-assets/build-executor.ts index b5c463cb07..d72d9577a1 100644 --- a/src/release-assets/build-executor.ts +++ b/src/release-assets/build-executor.ts @@ -53,7 +53,7 @@ import { getReactUrls } from "#veryfront/transforms/esm/react-cdn.ts"; import { PLATFORM_UTILITIES } from "#veryfront/html/utils.ts"; import { extractCandidatesFromFiles } from "#veryfront/html/styles-builder/candidate-extractor.ts"; import { FRAMEWORK_CANDIDATES } from "#veryfront/server/handlers/dev/framework-candidates.generated.ts"; -import { validatePathSync } from "#veryfront/security/path-validation.ts"; +import { validateLexicalPath } from "#veryfront/security/path-validation.ts"; import { CSS_IMPORTING_SOURCE_EXTENSIONS, resolveCssImportPath, @@ -1371,9 +1371,11 @@ function portableReleaseFilePathKey(filePath: string): string { } function resolveMaterializedReleasePath(tempDir: string, filePath: string): string { - const result = validatePathSync(filePath, { + // The build owns a newly-created temporary root and materializes only regular + // file contents beneath it; no caller-controlled filesystem links are + // admitted. Lexical containment is therefore the correct boundary here. + const result = validateLexicalPath(filePath, { baseDir: tempDir, - level: "strict", allowAbsolute: false, }); const resolvedPath = result.canonicalPath ? normalize(result.canonicalPath) : null; diff --git a/src/repositories/filesystem/filesystem-repository.ts b/src/repositories/filesystem/filesystem-repository.ts index e7a2a869d6..b52e95d0dc 100644 --- a/src/repositories/filesystem/filesystem-repository.ts +++ b/src/repositories/filesystem/filesystem-repository.ts @@ -18,8 +18,6 @@ export interface SecureFsRepositoryConfig { context: RepositoryContext; /** Security context for validation (default: "internal") */ securityContext?: SecurityContext; - /** Whether to throw on validation errors (default: true) */ - throwOnError?: boolean; } /** @@ -51,7 +49,6 @@ export class SecureFsRepository implements FileSystemRepository { baseDir: config.baseDir, adapter: config.adapter, context: config.securityContext ?? "internal", - throwOnError: config.throwOnError ?? true, }); if (this.secureFs.maxWholeFileReadBytes !== undefined) { Object.defineProperty(this, "maxWholeFileReadBytes", { diff --git a/src/repositories/repositories.test.ts b/src/repositories/repositories.test.ts index 9516563f91..f5d2d5f9b4 100644 --- a/src/repositories/repositories.test.ts +++ b/src/repositories/repositories.test.ts @@ -197,6 +197,16 @@ describe("MockFileSystemRepository", () => { }); describe("SecureFsRepository", () => { + it("fails closed for paths outside its repository root", async () => { + const repository = createFileSystemRepository({ + baseDir: "/project", + adapter: createMockAdapter(), + context: createMockRepositoryContext(), + }); + + await expect(repository.readFile("../outside.txt")).rejects.toThrow(); + }); + it("publishes immutable exact and snapshot read capabilities", async () => { const adapter = createMockAdapter(); adapter.fs.files.set("/project/asset.bin", "abc"); diff --git a/src/security/README.md b/src/security/README.md index 51ec08fa09..e4c30bdb4c 100644 --- a/src/security/README.md +++ b/src/security/README.md @@ -1,343 +1,196 @@ -# Security Module +# Security module reference -## Purpose +`src/security` owns Veryfront's request-security primitives and the internal +worker boundary used to run project code. Its only published package entrypoint +is `veryfront/security`, mapped to [`index.ts`](./index.ts). -The security module provides core security primitives and policies for Veryfront applications, including CSP (Content Security Policy), CORS, authentication utilities, and input validation. +This module does not provide password hashing, JWT verification, SQL escaping, +or a public sandbox API. Authentication here is limited to the runtime's Basic +and bearer-token request gate. Public rate limiting belongs to +[`veryfront/middleware`](../middleware/README.md). -## Scope +## Published surface -### What this module does: +The root entrypoint exports the following groups: -- Content Security Policy (CSP) configuration and enforcement -- CORS (Cross-Origin Resource Sharing) policies -- CSRF (Cross-Site Request Forgery) protection -- Authentication helpers and session management -- Input validation and sanitization -- Path traversal protection -- Security headers management -- Rate limiting -- Secure filesystem operations -- Deno permission management -- Sandbox for untrusted code execution +| Area | Runtime exports | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| HTTP handlers | `BaseHandler`, `AuthHandler`, `CsrfHandler`, `SecurityConfigLoader` | +| Input boundaries | `validateRequestLimits`, `readBodyWithLimit`, `parseJsonBody`, `parseFormData`, `parseQueryParams`, `createValidatedHandler` | +| CORS | `cors`, `corsSimple`, `validateOrigin`, `validateOriginSync`, `applyCORSHeaders`, `applyCORSHeadersSync`, `handleCORSPreflight` | +| CSRF | `generateCsrfToken`, `validateCsrf`, `applyCsrfCookie` | +| Responses | `ResponseBuilder`, `createResponseBuilder`, `applySecurityHeaders`, `buildCacheControl`, `generateNonce` | +| Paths and files | `validatePath`, `validateLexicalPath`, `createValidator`, `createSecureFs`, `SecureFs`, `wrapAdapterWithSecurity` | +| Deno permissions | `BUILD_HELPER_PERMISSIONS`, `SERVER_PERMISSIONS`, `WORKFLOW_RUN_PERMISSIONS` | -### What this module does NOT do: +Types are exported beside their owning runtime contracts. The exact runtime +inventory is regression-pinned in [`index.test.ts`](./index.test.ts); adding or +removing a root export is an intentional package-surface change. -- Middleware execution (see `middleware/builtin/security/`) -- Agent sandbox execution (see `src/sandbox/`) +## Policy ownership -## Architecture +### Configuration -``` -security/ -├── http/ # HTTP security -│ ├── auth.ts # Authentication utilities -│ ├── config.ts # Security configuration -│ ├── cors.ts # CORS policies -│ └── csp.ts # Content Security Policy -├── csrf/ # CSRF protection -├── input-validation/ # Input sanitization -│ ├── validators.ts # Validation rules -│ └── sanitizers.ts # Input cleaning -├── path-validation/ # Path traversal protection -├── rate-limit/ # Rate limiting [has README] -├── sandbox/ # Sandboxed code execution -├── client/ # Client-side security utilities -├── utils/ # Security utility functions -├── secure-fs.ts # Secure filesystem operations -├── path-validation.ts # Path validation utilities -└── deno-permissions.ts # Deno permission management -``` - -## Key Exports +Project security configuration is validated by the canonical configuration +schema before `SecurityConfigLoader` derives a request-owned, frozen security +context. Production derivation enables the default CSRF policy when the project +does not specify one. A failed configuration load fails the current request and +remains retryable for a later request. -### CSP (Content Security Policy) - -- `createCSPPolicy(options)` - Generate CSP header -- `CSPBuilder` - Fluent CSP builder -- `defaultCSP` - Secure default policy +`SecurityConfigLoader` is the only runtime configuration loader. Call +`ensureLoaded()` before reading its derived values. The former +`loadSecurityConfig()` and `isValidSecurityConfig()` helpers were removed: they +duplicated schema validation and converted loader failures into an insecure +`null` configuration. ### CORS -- `createCORSPolicy(options)` - CORS configuration -- `isAllowedOrigin(origin, policy)` - Origin validation -- `CORSPreflightHandler` - Handle OPTIONS requests - -### Authentication - -- `validateAuthToken(token)` - JWT/session validation -- `hashPassword(password)` - Secure password hashing -- `comparePasswords(plain, hashed)` - Password verification - -### Input Validation - -- `validateInput(value, rules)` - Multi-rule validation -- `sanitizeHTML(html)` - XSS protection -- `escapeSQL(query)` - SQL injection protection +CORS policy owns every `Access-Control-*` response header. Runtime helpers +snapshot and validate their configuration, reject wildcard origins combined +with credentials, bound all reflected lists, and fail closed when an origin +validator throws or returns an invalid value. -## Dependencies +Use the asynchronous helpers when an origin validator can return a promise. +The synchronous helpers deliberately deny promise-returning validators. -### Internal - -- `shared/` - Utilities and constants -- `server/` - HTTP integration - -### External - -- `bcrypt` (optional) - Password hashing -- `jsonwebtoken` (optional) - JWT handling - -## Usage Examples - -### Content Security Policy - -```typescript -import { CSPBuilder } from "./security/http"; - -const csp = new CSPBuilder() - .defaultSrc(["self"]) - .scriptSrc(["self", "https://cdn.example.com"]) - .styleSrc(["self", "unsafe-inline"]) - .imgSrc(["self", "data:", "https:"]) - .build(); - -// Apply to response -response.headers.set("Content-Security-Policy", csp); -``` +### CSRF -### CORS Configuration - -```typescript -import { createCORSPolicy } from "./security/http"; - -const cors = createCORSPolicy({ - origin: ["https://app.example.com", "https://admin.example.com"], - methods: ["GET", "POST", "PUT", "DELETE"], - credentials: true, - maxAge: 86400, // 24 hours -}); - -// Check origin -if (cors.isAllowed(request.headers.get("origin"))) { - // Add CORS headers -} -``` +CSRF uses a double-submit cookie and header comparison. The default cookie is +`__Host-vf_csrf`; it is host-only, path-scoped to `/`, and always secure. +Cookie/header names and token lifetimes are bounded both in configuration and +at the public helper boundary. State-changing requests are checked unless an +exact, schema-validated exclusion applies. ### Authentication -```typescript -import { hashPassword, validateAuthToken } from "./security/http"; - -// Hash password -const hashedPassword = await hashPassword("user-password"); - -// Validate token -const payload = await validateAuthToken(token, { - secret: process.env.JWT_SECRET, - algorithms: ["HS256"], -}); - -console.log(payload.userId); -``` - -### Input Validation - -```typescript -import { sanitizeHTML, validateInput } from "./security/input-validation"; - -// Validate email -const emailResult = validateInput(userInput.email, { - type: "email", - required: true, - maxLength: 255, -}); - -if (!emailResult.valid) { - throw new Error(emailResult.errors.join(", ")); -} - -// Sanitize HTML content -const safeHTML = sanitizeHTML(userInput.bio, { - allowedTags: ["p", "br", "strong", "em"], - allowedAttributes: {}, -}); -``` - -## Security Best Practices - -### 1. CSP Configuration - -```typescript -// Production CSP - Strict -const strictCSP = new CSPBuilder() - .defaultSrc(["none"]) - .scriptSrc(["self"]) - .styleSrc(["self"]) - .imgSrc(["self"]) - .connectSrc(["self"]) - .fontSrc(["self"]) - .objectSrc(["none"]) - .mediaSrc(["self"]) - .frameSrc(["none"]) - .build(); - -// Development CSP - Relaxed for HMR -const devCSP = new CSPBuilder() - .defaultSrc(["self"]) - .scriptSrc(["self", "unsafe-eval"]) // For HMR - .connectSrc(["self", "ws:", "wss:"]) // For WebSocket - .build(); -``` - -### 2. CORS Policies - -```typescript -// Public API - Open CORS -const publicCORS = createCORSPolicy({ - origin: "*", - methods: ["GET"], - credentials: false, -}); - -// Private API - Restricted CORS -const privateCORS = createCORSPolicy({ - origin: (origin) => { - return origin?.endsWith(".example.com") ?? false; - }, - methods: ["GET", "POST", "PUT", "DELETE"], - credentials: true, - exposedHeaders: ["X-Request-ID"], -}); +`AuthHandler` accepts either one Basic credential pair or one bearer token. +Ambiguous environment configuration fails closed. Unauthorized responses are +non-cacheable and receive the resolved CORS and security policy. Credential +verification uses constant-time comparison. + +### Input validation + +Each standalone body, form, and query parser applies the same snapshotted +request limits as `createValidatedHandler`. Query parsing measures the complete +URL in UTF-8 bytes before allocating parameter collections. JSON parsing also +captures the decoded value through the framework's bounded, iterative JSON +snapshot before invoking a schema, so excessive depth, node count, or string +size cannot be delegated to a recursive validator. Composite handlers reuse +their already-validated request boundary rather than silently skipping or +repeating those checks. + +### Response headers + +`ResponseBuilder` centralizes CSP, HSTS, framing, cross-origin, referrer, cache, +and CORS response headers. Server integrations remove project-provided +policy-owned headers before applying the host policy. Project configuration can +override supported security headers, but `Access-Control-*` values must be +configured through CORS. The production default CSP admits only same-origin +resources plus narrowly required nonce, data, and blob sources; CDN, font, +media-provider, analytics, and API origins must be declared by the owning +extension or explicit project CSP. The obsolete browser XSS auditor is disabled +with `X-XSS-Protection: 0`. + +### Paths and filesystem access + +Path validation canonicalizes existing ancestors, rejects traversal and +symlink escapes, and applies context-specific rules. `SecureFs` validates a +path before delegating to the configured runtime adapter. Its trust root and +policy are immutable after construction; it exposes no raw-adapter escape +hatch. Policy records and directory allowlists are copied from own data +properties, so inherited settings, accessors, and later caller mutations cannot +change the active policy. Directory iteration and watcher installation use +asynchronous physical canonicalization. Filesystem adapters must provide +`lstat`/`realPath` for the requested symlink policy or explicitly guarantee that +their API cannot traverse symbolic links; unknown semantics fail closed. +An omitted module-import allowlist is explicitly unrestricted within the +project root, while an empty allowlist denies every project subdirectory; the +two states are never collapsed. +Callers that create a watcher must await `watcher.ready` before assuming it is +active. Binary reads require a native binary-safe adapter capability and never +fall back to text transcoding. Temporary directories are created beneath the +configured trust root. + +`validateLexicalPath` performs only string-level containment checks. It does +not accept adapter, existence, or symlink-policy options and must not be used as +filesystem admission for a local or otherwise symlink-capable backing store. +Conversely, `validatePath` always requires the runtime adapter whose filesystem +will perform the admitted operation. `ValidationPresets` are immutable policy +fragments, not standalone physical validators; combine a preset with that +adapter or use `SecureFs`, which does so at construction. + +This is a path-admission boundary, not an operating-system capability sandbox. +A hostile actor that can concurrently replace filesystem entries can still +create time-of-check/time-of-use races on adapters without descriptor-relative +filesystem operations. Production deployments must not grant project code +independent write access to the host paths being served. + +## Internal worker isolation + +[`sandbox/`](./sandbox/) is an internal runtime implementation used by Routing, +Data, and Rendering. It is not exported from `veryfront/security`. + +The worker pool provides: + +- bounded worker count and exactly one active admission per serialized worker; +- per-request deadlines and generation retirement; +- project-root reads plus immutable framework-source reads in compiled builds, + with shared caches and `DENO_DIR` excluded; +- denied Deno environment permission, with a frozen request-owned `env` record + passed through App and Pages handler contexts instead; +- denied remote module imports, including for renderer dependencies; +- prepared-module size and retained-module limits; +- bounded, normalized data-loader results before worker-to-host transfer; +- a private control port protected from project-code message forgery; +- DNS-pinned outbound networking that blocks loopback, private, link-local, + metadata, and other non-global destinations by default; and +- deterministic cleanup of workers, streams, timers, and egress brokers. + +Worker isolation is disabled unless `WORKER_ISOLATION_ENABLED` and the relevant +`WORKER_ISOLATION_API`, `WORKER_ISOLATION_DATA`, or `WORKER_ISOLATION_SSR` flag +are enabled. Defined invalid flags and pool limits are startup errors; they are +not silently replaced with defaults. + +`WORKER_ISOLATION_SSR=1` additionally requires explicit registration of +`@veryfront/ext-react-ssr`. That extension supplies a local, offline renderer +bundle through the isolated-SSR contract. Core does not import React, and there +is no host-rendering or remote-import fallback; an SSR request fails closed with +an installation hint when the extension is absent. API and data workers do not +resolve or receive the renderer contract. + +Deno Workers share the host process. Worker retirement is lifecycle hygiene, +not a hard per-worker memory or CPU boundary. A project can still create +host-process memory pressure or consume a worker thread until the host +terminates it. Strong memory, CPU, and process containment requires a +separately limited process or container. + +## Internal-only files + +- [`client/`](./client/) validates trusted HTML and serializes values for inline + scripts. +- [`http/`](./http/) contains handler, CORS, and response-policy + implementations. +- [`input-validation/`](./input-validation/) contains bounded body readers and + request parsers. +- [`path-validation/`](./path-validation/) contains canonicalization and + validation rules. +- [`sandbox/`](./sandbox/) contains the project-worker protocol and pool. +- [`rate-limit/client-key.ts`](./rate-limit/client-key.ts) is the shared client + identity helper used by public middleware rate limiting. + +The maintained rate limiter is in `src/middleware/builtin/security`; Security +contains no second implementation. + +## Verification + +Run the complete module portfolio with: + +```sh +DENO_TESTING=1 VF_DISABLE_LRU_INTERVAL=1 NODE_ENV=test \ + deno test --preload=src/schemas/_test-setup.ts --no-check --allow-all \ + --unstable-worker-options --unstable-net src/security ``` -### 3. Password Security - -```typescript -import { comparePasswords, hashPassword } from "./security/http"; - -// Registration -const user = { - email: input.email, - passwordHash: await hashPassword(input.password, { - rounds: 12, // bcrypt cost factor - }), -}; - -// Login -const valid = await comparePasswords( - input.password, - user.passwordHash, -); -``` - -### 4. Input Validation - -```typescript -// Define validation schema -const userSchema = { - email: { - type: "email", - required: true, - maxLength: 255, - }, - age: { - type: "number", - min: 18, - max: 120, - }, - bio: { - type: "string", - maxLength: 1000, - sanitize: true, - }, -}; - -// Validate and sanitize -const result = validateInput(userInput, userSchema); -if (!result.valid) { - return new Response(JSON.stringify({ errors: result.errors }), { - status: 400, - }); -} -``` - -## Security Headers - -### Recommended Headers - -```typescript -const securityHeaders = { - "Content-Security-Policy": csp, - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "X-XSS-Protection": "1; mode=block", - "Referrer-Policy": "strict-origin-when-cross-origin", - "Permissions-Policy": "geolocation=(), microphone=(), camera=()", - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", -}; -``` - -## Testing - -```bash -# Run security tests -deno task test src/security/ - -# Test CSP generation -deno task test src/security/http/csp.test.ts - -# Test input validation -deno task test src/security/input-validation/ -``` - -## Maintainer - -**Team:** Security Team -**Primary Contact:** security@example.com -**Code Owners:** See CODEOWNERS file - -## Related Modules - -- [`server/`](../server/README.md) - Request handlers with security enforcement -- [`middleware/`](../middleware/README.md) - Security middleware - -## Common Vulnerabilities - -### XSS (Cross-Site Scripting) - -**Prevention:** - -- Use `sanitizeHTML()` for user content -- Set strict CSP -- Escape output in templates - -### CSRF (Cross-Site Request Forgery) - -**Prevention:** - -- Use SameSite cookies -- Validate CORS origin -- Implement CSRF tokens - -### SQL Injection - -**Prevention:** - -- Use parameterized queries -- Never concatenate user input -- Use `escapeSQL()` as last resort - -### Authentication Issues - -**Prevention:** - -- Use secure password hashing (bcrypt, cost ≥12) -- Implement rate limiting -- Use HTTPS only -- Set secure session cookies - -## References - -- [OWASP Top 10](https://owasp.org/www-project-top-ten/) -- [CSP Reference](https://content-security-policy.com/) -- [CORS Specification](https://fetch.spec.whatwg.org/#http-cors-protocol) -- [Veryfront Security Guide](https://veryfront.com/docs/security) +Use `--trace-leaks` for the closure gate. Worker, Routing, Data, Rendering, and +Server consumer suites are also required after changes to the sandbox protocol, +permissions, CORS, response headers, or request parsing. diff --git a/src/security/client/html-sanitizer.test.ts b/src/security/client/html-sanitizer.test.ts index e2abcd1f27..40f9b68ab5 100644 --- a/src/security/client/html-sanitizer.test.ts +++ b/src/security/client/html-sanitizer.test.ts @@ -134,4 +134,14 @@ describe("jsonForInlineScript", () => { assertStringIncludes(result, "\\u0026"); assertEquals(JSON.parse(result), value); }); + + it("rejects top-level values that JSON cannot serialize", () => { + for (const value of [undefined, () => undefined, Symbol("value")]) { + assertThrows( + () => jsonForInlineScript(value), + TypeError, + "must be JSON-serializable", + ); + } + }); }); diff --git a/src/security/client/html-sanitizer.ts b/src/security/client/html-sanitizer.ts index cee4c8278b..21e27e0539 100644 --- a/src/security/client/html-sanitizer.ts +++ b/src/security/client/html-sanitizer.ts @@ -7,7 +7,7 @@ * - validateTrustedHtml() provides defense-in-depth for server HTML */ -import { escapeHtml } from "#veryfront/html/html-escape.ts"; +import { escapeHtml } from "#veryfront/utils/html-escape.ts"; import { SECURITY_VIOLATION } from "#veryfront/errors/error-registry.ts"; export { escapeHtml }; @@ -40,7 +40,11 @@ export function escapeInlineJsonText(value: string): string { } export function jsonForInlineScript(value: unknown, space?: string | number): string { - return escapeInlineJsonText(JSON.stringify(value, null, space)); + const serialized = JSON.stringify(value, null, space); + if (serialized === undefined) { + throw new TypeError("Inline script data must be JSON-serializable"); + } + return escapeInlineJsonText(serialized); } export function buildTrustedHtmlValidatorScript(): string { diff --git a/src/security/csrf/helpers.test.ts b/src/security/csrf/helpers.test.ts index 8d717e0327..0c7fbca7b9 100644 --- a/src/security/csrf/helpers.test.ts +++ b/src/security/csrf/helpers.test.ts @@ -1,6 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { assertEquals, assertNotEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertNotEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { applyCsrfCookie, generateCsrfToken, validateCsrf } from "./helpers.ts"; describe("security/csrf/helpers", () => { @@ -33,6 +33,14 @@ describe("security/csrf/helpers", () => { assertEquals(result.setCookie.includes("Secure"), false); }); + it("keeps Secure for __Secure- prefixed cookies", () => { + const result = generateCsrfToken({ + cookieName: "__Secure-my_csrf", + secure: false, + }); + assertEquals(result.setCookie.includes("Secure"), true); + }); + it("should use custom cookie name", () => { const result = generateCsrfToken({ cookieName: "my_csrf" }); assertEquals(result.setCookie.startsWith("my_csrf="), true); @@ -48,6 +56,21 @@ describe("security/csrf/helpers", () => { const b = generateCsrfToken(); assertNotEquals(a.token, b.token); }); + + it("rejects invalid cookie serialization options", () => { + for ( + const options of [ + { cookieName: "bad\r\nname" }, + { cookieName: "" }, + { ttlSec: 0 }, + { ttlSec: Number.POSITIVE_INFINITY }, + { httpOnly: "yes" as unknown as boolean }, + { secure: "no" as unknown as boolean }, + ] + ) { + assertThrows(() => generateCsrfToken(options)); + } + }); }); describe("validateCsrf", () => { @@ -124,6 +147,25 @@ describe("security/csrf/helpers", () => { }); assertEquals(validateCsrf(req), false); }); + + it("fails closed for invalid runtime names", () => { + const req = new Request("http://localhost/submit", { + method: "POST", + headers: { + cookie: "__Host-vf_csrf=token", + "x-csrf-token": "token", + }, + }); + + assertEquals( + validateCsrf(req, { cookieName: "bad\r\nname" }), + false, + ); + assertEquals( + validateCsrf(req, { headerName: "" }), + false, + ); + }); }); describe("applyCsrfCookie", () => { diff --git a/src/security/csrf/helpers.ts b/src/security/csrf/helpers.ts index 1fda775b44..af4250c3ca 100644 --- a/src/security/csrf/helpers.ts +++ b/src/security/csrf/helpers.ts @@ -9,7 +9,9 @@ import { base64urlEncodeBytes } from "#veryfront/utils/base64url.ts"; import { parseCookiesFromHeaders } from "#veryfront/utils/cookie-utils.ts"; -import { getHostEnv } from "#veryfront/platform/compat/process.ts"; +import { isProxyTopologyTrusted } from "#veryfront/platform/compat/proxy-topology.ts"; +import { HTTP_TOKEN_PATTERN } from "#veryfront/utils/cors-policy-limits.ts"; +import { MAX_CSRF_NAME_LENGTH, MAX_CSRF_TTL_SECONDS } from "#veryfront/utils/constants/security.ts"; /** Default CSRF token TTL: 24 hours (longer than session action TTL to avoid stale-form 403s). */ const CSRF_DEFAULT_TTL_SEC = 86_400; @@ -31,15 +33,59 @@ export interface CsrfTokenOptions { secure?: boolean; } +function requireCsrfName(value: unknown, label: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_CSRF_NAME_LENGTH || + !HTTP_TOKEN_PATTERN.test(value) + ) { + throw new TypeError( + `${label} must be a valid HTTP token no longer than ${MAX_CSRF_NAME_LENGTH} characters`, + ); + } + return value; +} + +function requireCsrfTtl(value: unknown): number { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < 1 || + value > MAX_CSRF_TTL_SECONDS + ) { + throw new RangeError("CSRF token ttlSec must be a positive safe integer"); + } + return value; +} + +function requireBooleanOption(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new TypeError(`${label} must be a boolean`); + } + return value; +} + /** Generate a CSRF token and return value + Set-Cookie header string */ export function generateCsrfToken(options?: CsrfTokenOptions): { token: string; setCookie: string; } { - const cookieName = options?.cookieName ?? DEFAULT_CSRF_COOKIE_NAME; - const maxAge = options?.ttlSec ?? CSRF_DEFAULT_TTL_SEC; - const httpOnly = options?.httpOnly ?? true; - const secure = cookieName.startsWith("__Host-") ? true : options?.secure ?? true; + const cookieName = requireCsrfName( + options?.cookieName ?? DEFAULT_CSRF_COOKIE_NAME, + "CSRF cookieName", + ); + const maxAge = requireCsrfTtl(options?.ttlSec ?? CSRF_DEFAULT_TTL_SEC); + const httpOnly = options?.httpOnly === undefined + ? true + : requireBooleanOption(options.httpOnly, "CSRF token httpOnly"); + const requestedSecure = options?.secure === undefined + ? true + : requireBooleanOption(options.secure, "CSRF token secure"); + const secure = cookieName.startsWith("__Host-") || + cookieName.startsWith("__Secure-") + ? true + : requestedSecure; const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); @@ -74,22 +120,27 @@ export function validateCsrf( req: Request, options?: { cookieName?: string; headerName?: string }, ): boolean { - const cookieName = options?.cookieName ?? DEFAULT_CSRF_COOKIE_NAME; - const headerName = options?.headerName ?? "x-csrf-token"; - - let cookieToken: string | undefined; try { - cookieToken = parseCookiesFromHeaders(req.headers)[cookieName]; - } catch (_) { - /* expected: malformed cookie (e.g. bad percent-encoding) → treat as missing */ + const cookieName = requireCsrfName( + options?.cookieName ?? DEFAULT_CSRF_COOKIE_NAME, + "CSRF cookieName", + ); + const headerName = requireCsrfName( + options?.headerName ?? "x-csrf-token", + "CSRF headerName", + ); + const cookieToken = parseCookiesFromHeaders(req.headers)[cookieName]; + if (!cookieToken) return false; + + const headerToken = req.headers.get(headerName) ?? ""; + if (!headerToken) return false; + + return timingSafeEqual(cookieToken, headerToken); + } catch { + // Invalid options, malformed cookies, and unreadable request headers all + // fail closed through this boolean validation contract. return false; } - if (!cookieToken) return false; - - const headerToken = req.headers.get(headerName) ?? ""; - if (!headerToken) return false; - - return timingSafeEqual(cookieToken, headerToken); } /** @@ -133,7 +184,7 @@ export function applyCsrfCookie( // deployment trusts the upstream proxy (VERYFRONT_TRUST_FORWARDED_HEADERS=1). // The forwarded header is client-spoofable otherwise, so blindly trusting it // could suppress the Secure flag on a genuinely-HTTPS deployment. - const trustProxyHeaders = getHostEnv("VERYFRONT_TRUST_FORWARDED_HEADERS") === "1"; + const trustProxyHeaders = isProxyTopologyTrusted(); const isSecure = cookieName.startsWith("__Host-") || req.url.startsWith("https://") || (trustProxyHeaders && req.headers.get("x-forwarded-proto") === "https"); diff --git a/src/security/deno-permissions.ts b/src/security/deno-permissions.ts index a071469625..9d89d7a27e 100644 --- a/src/security/deno-permissions.ts +++ b/src/security/deno-permissions.ts @@ -28,7 +28,7 @@ export const SERVER_PERMISSIONS = [ * * `--allow-env` is intentionally left unscoped here rather than pinned to a * static allowlist: the set of env vars a run legitimately needs (tenant - * context, MODE/run IDs, operator-supplied vars such as REDIS_URL) is assembled + * context, MODE/run IDs, and operator-supplied extension variables) is assembled * dynamically per execution and cannot be enumerated statically. The child uses * `clearEnv: true`, so it does not ordinarily inherit arbitrary host variables. * This profile still grants broad read, write, and network access and is only diff --git a/src/security/http/auth.test.ts b/src/security/http/auth.test.ts index 2644188b9a..995b0a3ca7 100644 --- a/src/security/http/auth.test.ts +++ b/src/security/http/auth.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { expect } from "#std/expect.ts"; +import type { HandlerContext, SecurityConfig } from "#veryfront/types"; import { AuthHandler } from "./auth.ts"; /** @@ -12,12 +13,16 @@ describe("AuthHandler realm sanitization", () => { return new AuthHandler(); } - function createCtx(realm?: unknown) { + function createCtx(realm?: unknown): HandlerContext { const basic: Record = { username: "admin", password: "secret" }; if (realm !== undefined) basic.realm = realm; return { - securityConfig: { auth: { basic } }, - adapter: { env: { get: () => "" } }, + projectDir: "/tmp/auth-test", + securityConfig: { auth: { basic } } as unknown as SecurityConfig, + cspUserHeader: null, + adapter: { + env: { get: () => "" }, + } as unknown as HandlerContext["adapter"], isLocalProject: false, }; } @@ -25,8 +30,8 @@ describe("AuthHandler realm sanitization", () => { async function getWwwAuthenticate(handler: AuthHandler, realm?: unknown): Promise { const ctx = createCtx(realm); const req = new Request("http://localhost/test"); - const result = await handler.handle(req, ctx as any); - const response = (result as any).response as Response; + const result = await handler.handle(req, ctx); + const response = result.response as Response; return response.headers.get("WWW-Authenticate") ?? ""; } @@ -74,4 +79,391 @@ describe("AuthHandler realm sanitization", () => { const header = await getWwwAuthenticate(handler, 12345); expect(header).toBe('Basic realm="12345"'); }); + + it("does not invoke conversion hooks on an invalid realm value", async () => { + const handler = createHandler(); + let conversions = 0; + const hostileRealm = { + [Symbol.toPrimitive]() { + conversions++; + throw new Error("realm conversion must not run"); + }, + }; + + const header = await getWwwAuthenticate(handler, hostileRealm); + + expect(header).toBe('Basic realm="Secure Area"'); + expect(conversions).toBe(0); + }); + + it("keeps the outer Basic challenge request-local during CORS re-entry", async () => { + const handler = createHandler(); + const outerCtx = createCtx("Outer Realm"); + if (!outerCtx.securityConfig) throw new Error("test security config is required"); + + outerCtx.securityConfig.cors = { + origin: () => { + void handler.handle( + new Request("http://localhost/inner"), + createCtx("Inner Realm"), + ); + return true; + }, + }; + + const result = await handler.handle( + new Request("http://localhost/outer", { + headers: { origin: "https://client.example" }, + }), + outerCtx, + ); + + expect(result.response?.headers.get("WWW-Authenticate")).toBe( + 'Basic realm="Outer Realm"', + ); + }); + + it("applies the resolved CORS and security policy to unauthorized responses", async () => { + const handler = createHandler(); + const ctx = createCtx(); + if (!ctx.securityConfig) throw new Error("test security config is required"); + ctx.securityConfig.cors = { + origin: "https://client.example", + credentials: true, + }; + const req = new Request("http://localhost/test", { + headers: { origin: "https://client.example" }, + }); + + const result = await handler.handle(req, ctx); + const response = result.response as Response; + + expect(response.status).toBe(401); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://client.example", + ); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("returns a Bearer challenge with the same hardened unauthorized response", async () => { + const handler = createHandler(); + const ctx: HandlerContext = { + projectDir: "/tmp/auth-test", + securityConfig: { + auth: { bearer: { token: "expected-token" } }, + cors: { origin: "https://client.example" }, + } as SecurityConfig, + cspUserHeader: null, + adapter: { + env: { get: () => "" }, + } as unknown as HandlerContext["adapter"], + isLocalProject: false, + }; + const req = new Request("http://localhost/test", { + headers: { + authorization: "Bearer wrong-token", + origin: "https://client.example", + }, + }); + + const result = await handler.handle(req, ctx); + const response = result.response as Response; + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe("Bearer"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://client.example", + ); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("fails closed when environment variables configure both auth modes", async () => { + const handler = createHandler(); + const credentials: Record = { + VERYFRONT_BASIC_USER: "admin", + VERYFRONT_BASIC_PASS: "secret", + VERYFRONT_BEARER_TOKEN: "expected-token", + }; + const ctx: HandlerContext = { + projectDir: "/tmp/auth-test", + securityConfig: null, + cspUserHeader: null, + adapter: { + env: { get: (name: string) => credentials[name] }, + } as unknown as HandlerContext["adapter"], + isLocalProject: false, + }; + + for ( + const authorization of [ + `Basic ${btoa("admin:secret")}`, + "Bearer expected-token", + ] + ) { + const result = await handler.handle( + new Request("http://localhost/test", { + headers: { authorization }, + }), + ctx, + ); + + expect(result.continue).not.toBe(true); + expect(result.response?.status).toBe(401); + expect(result.response?.headers.get("WWW-Authenticate")).toBe( + 'Basic realm="Secure Area", Bearer', + ); + } + }); + + it("fails closed for every partial, empty, or competing environment auth state", async () => { + const handler = createHandler(); + const values = [undefined, "", "configured"] as const; + const basicAuthorization = `Basic ${btoa("configured:configured")}`; + const bearerAuthorization = "Bearer configured"; + + for (const username of values) { + for (const password of values) { + for (const token of values) { + const credentials: Readonly> = { + VERYFRONT_BASIC_USER: username, + VERYFRONT_BASIC_PASS: password, + VERYFRONT_BEARER_TOKEN: token, + }; + const ctx: HandlerContext = { + projectDir: "/tmp/auth-test", + securityConfig: null, + cspUserHeader: null, + adapter: { + env: { get: (name: string) => credentials[name] }, + } as unknown as HandlerContext["adapter"], + isLocalProject: false, + }; + const authDisabled = username === undefined && + password === undefined && + token === undefined; + const validBasic = username === "configured" && + password === "configured" && + token === undefined; + const validBearer = username === undefined && + password === undefined && + token === "configured"; + + if (authDisabled) { + const result = await handler.handle( + new Request("http://localhost/test"), + ctx, + ); + expect(result.continue).toBe(true); + continue; + } + + if (validBasic || validBearer) { + const result = await handler.handle( + new Request("http://localhost/test", { + headers: { + authorization: validBasic ? basicAuthorization : bearerAuthorization, + }, + }), + ctx, + ); + expect(result.continue).toBe(true); + continue; + } + + for ( + const authorization of [ + undefined, + basicAuthorization, + bearerAuthorization, + ] + ) { + const headers = authorization === undefined ? undefined : { authorization }; + const result = await handler.handle( + new Request("http://localhost/test", { headers }), + ctx, + ); + const response = result.response as Response; + + expect(result.continue).not.toBe(true); + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Basic realm="Secure Area", Bearer', + ); + expect(await response.text()).toBe("Unauthorized"); + } + } + } + } + }); + + it("does not expose an authentication bypass through test globals", async () => { + const globals = globalThis as Record; + const hadFlag = Object.hasOwn(globals, "__vfTestEnv"); + const previousFlag = globals.__vfTestEnv; + globals.__vfTestEnv = true; + try { + const ctx: HandlerContext = { + projectDir: "/tmp/auth-test", + securityConfig: null, + cspUserHeader: null, + adapter: { + env: { + get: (name: string) => name === "VERYFRONT_BEARER_TOKEN" ? "required" : undefined, + }, + } as unknown as HandlerContext["adapter"], + isLocalProject: false, + }; + + const result = await new AuthHandler().handle( + new Request("http://localhost/test"), + ctx, + ); + + expect(result.response?.status).toBe(401); + } finally { + if (hadFlag) globals.__vfTestEnv = previousFlag; + else delete globals.__vfTestEnv; + } + }); + + it("rejects accessor-backed explicit auth without invoking accessors", async () => { + let getterCalls = 0; + const auth = Object.defineProperty({}, "bearer", { + enumerable: true, + get() { + getterCalls++; + return { token: "must-not-be-trusted" }; + }, + }); + const securityConfig = Object.defineProperty({}, "auth", { + enumerable: true, + value: auth, + }) as SecurityConfig; + const ctx: HandlerContext = { + projectDir: "/tmp/auth-test", + securityConfig, + cspUserHeader: null, + adapter: { + env: { get: () => undefined }, + } as unknown as HandlerContext["adapter"], + isLocalProject: false, + }; + + const result = await new AuthHandler().handle( + new Request("http://localhost/test", { + headers: { authorization: "Bearer must-not-be-trusted" }, + }), + ctx, + ); + + expect(result.response?.status).toBe(401); + expect(getterCalls).toBe(0); + }); + + it("rejects an accessor-backed security auth field without invoking it", async () => { + let getterCalls = 0; + const securityConfig = Object.defineProperty({}, "auth", { + enumerable: true, + get() { + getterCalls++; + return { bearer: { token: "must-not-be-trusted" } }; + }, + }) as SecurityConfig; + const ctx: HandlerContext = { + projectDir: "/tmp/auth-test", + securityConfig, + cspUserHeader: null, + adapter: { + env: { get: () => undefined }, + } as unknown as HandlerContext["adapter"], + isLocalProject: false, + }; + + const result = await new AuthHandler().handle( + new Request("http://localhost/test", { + headers: { authorization: "Bearer must-not-be-trusted" }, + }), + ctx, + ); + + expect(result.response?.status).toBe(401); + expect(getterCalls).toBe(0); + }); + + it("fails closed when explicit auth proxy inspection fails", async () => { + const auth = new Proxy({}, { + ownKeys() { + throw new Error("hostile proxy"); + }, + }); + const ctx: HandlerContext = { + projectDir: "/tmp/auth-test", + securityConfig: { auth } as unknown as SecurityConfig, + cspUserHeader: null, + adapter: { + env: { get: () => undefined }, + } as unknown as HandlerContext["adapter"], + isLocalProject: false, + }; + + const result = await new AuthHandler().handle( + new Request("http://localhost/test"), + ctx, + ); + + expect(result.response?.status).toBe(401); + }); + + it("rejects malformed or competing explicit auth config without exposing credentials", async () => { + const handler = createHandler(); + const invalidAuthConfigs: readonly unknown[] = [ + {}, + { basic: {} }, + { basic: { username: "admin" } }, + { basic: { password: "secret" } }, + { basic: { username: "", password: "secret" } }, + { basic: { username: "admin", password: "" } }, + { bearer: {} }, + { bearer: { token: "" } }, + { + basic: { username: "admin", password: "secret" }, + bearer: { token: "private-token" }, + }, + { basic: undefined }, + { unknownMode: { secret: "must-not-leak" } }, + "invalid-auth-config", + ]; + + for (const auth of invalidAuthConfigs) { + const ctx: HandlerContext = { + projectDir: "/tmp/auth-test", + securityConfig: { auth } as unknown as SecurityConfig, + cspUserHeader: null, + adapter: { + env: { get: () => undefined }, + } as unknown as HandlerContext["adapter"], + isLocalProject: false, + }; + const result = await handler.handle( + new Request("http://localhost/test", { + headers: { authorization: "Bearer private-token" }, + }), + ctx, + ); + const response = result.response as Response; + const body = await response.text(); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Basic realm="Secure Area", Bearer', + ); + expect(body).toBe("Unauthorized"); + expect(body).not.toContain("admin"); + expect(body).not.toContain("secret"); + expect(body).not.toContain("private-token"); + } + }); }); diff --git a/src/security/http/auth.ts b/src/security/http/auth.ts index de06480dfe..fdebbc6a1c 100644 --- a/src/security/http/auth.ts +++ b/src/security/http/auth.ts @@ -5,14 +5,151 @@ import type { HandlerPriority, HandlerResult, } from "#veryfront/types"; -import type { AuthConfig } from "./middleware/types.ts"; import { encodeBase64 } from "#veryfront/utils"; import { constantTimeEqual } from "../utils/constant-time.ts"; -import { isProduction } from "#veryfront/platform/environment.ts"; function sanitizeRealm(realm: unknown): string { + const type = typeof realm; + const value = type === "string" || + type === "number" || + type === "bigint" || + type === "boolean" || + type === "symbol" + ? String(realm) + : "Secure Area"; + // deno-lint-ignore no-control-regex -- intentional: strips control chars and special chars from HTTP realm header - return String(realm).replace(/[\x00-\x1f\x7f"\\]/g, ""); + return value.replace(/[\x00-\x1f\x7f"\\]/g, ""); +} + +type ResolvedAuth = + | Readonly<{ + kind: "basic"; + username: string; + password: string; + realm: string; + }> + | Readonly<{ + kind: "bearer"; + token: string; + }> + | Readonly<{ + kind: "invalid"; + }>; + +const INVALID_AUTH = Object.freeze({ kind: "invalid" } as const); +const AUTH_CONFIG_KEYS = new Set(["basic", "bearer"]); +const BASIC_AUTH_CONFIG_KEYS = new Set(["username", "password", "realm"]); +const BEARER_AUTH_CONFIG_KEYS = new Set(["token"]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function snapshotOwnDataRecord( + value: unknown, + allowedKeys: ReadonlySet, +): Readonly> | null { + if (!isRecord(value)) return null; + + try { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return null; + + const keys = Reflect.ownKeys(value); + const snapshot = Object.create(null) as Record; + for (const key of keys) { + if (typeof key !== "string" || !allowedKeys.has(key)) return null; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return null; + Object.defineProperty(snapshot, key, { + enumerable: true, + value: descriptor.value, + }); + } + return Object.freeze(snapshot); + } catch { + return null; + } +} + +type ExplicitAuth = + | Readonly<{ state: "absent" }> + | Readonly<{ state: "present"; value: unknown }> + | Readonly<{ state: "invalid" }>; + +const ABSENT_AUTH = Object.freeze({ state: "absent" } as const); +const INVALID_EXPLICIT_AUTH = Object.freeze({ state: "invalid" } as const); + +function readExplicitAuth(securityConfig: unknown): ExplicitAuth { + if (securityConfig === null || securityConfig === undefined) return ABSENT_AUTH; + if (!isRecord(securityConfig)) return INVALID_EXPLICIT_AUTH; + + try { + const descriptor = Object.getOwnPropertyDescriptor(securityConfig, "auth"); + if (descriptor) { + return "value" in descriptor + ? Object.freeze({ state: "present", value: descriptor.value }) + : INVALID_EXPLICIT_AUTH; + } + + const visited = new Set(); + let prototype = Object.getPrototypeOf(securityConfig); + for (let depth = 0; prototype !== null && depth < 64; depth++) { + if (visited.has(prototype)) return INVALID_EXPLICIT_AUTH; + visited.add(prototype); + if (Object.getOwnPropertyDescriptor(prototype, "auth")) { + return INVALID_EXPLICIT_AUTH; + } + prototype = Object.getPrototypeOf(prototype); + } + return prototype === null ? ABSENT_AUTH : INVALID_EXPLICIT_AUTH; + } catch { + return INVALID_EXPLICIT_AUTH; + } +} + +function resolveConfiguredAuth(value: unknown): ResolvedAuth { + const auth = snapshotOwnDataRecord(value, AUTH_CONFIG_KEYS); + if (!auth) return INVALID_AUTH; + + const hasBasic = Object.hasOwn(auth, "basic"); + const hasBearer = Object.hasOwn(auth, "bearer"); + if (hasBasic === hasBearer) return INVALID_AUTH; + + if (hasBasic) { + const basic = snapshotOwnDataRecord(auth.basic, BASIC_AUTH_CONFIG_KEYS); + if ( + !basic || + !Object.hasOwn(basic, "username") || + !Object.hasOwn(basic, "password") || + typeof basic.username !== "string" || + basic.username.length === 0 || + typeof basic.password !== "string" || + basic.password.length === 0 + ) { + return INVALID_AUTH; + } + + return Object.freeze({ + kind: "basic", + username: basic.username, + password: basic.password, + realm: sanitizeRealm(basic.realm || "Secure Area"), + }); + } + + const bearer = snapshotOwnDataRecord(auth.bearer, BEARER_AUTH_CONFIG_KEYS); + if ( + !bearer || + !Object.hasOwn(bearer, "token") || + typeof bearer.token !== "string" || + bearer.token.length === 0 + ) { + return INVALID_AUTH; + } + + return Object.freeze({ kind: "bearer", token: bearer.token }); } export class AuthHandler extends BaseHandler { @@ -22,86 +159,116 @@ export class AuthHandler extends BaseHandler { patterns: [], // Checks all requests }; - private basicUser: string | null = null; - private basicPass: string | null = null; - private basicRealm = "Secure Area"; - private bearerToken: string | null = null; - handle(req: Request, ctx: HandlerContext): Promise { - this.loadAuthConfig(ctx); - if (req.method.toUpperCase() === "OPTIONS") return Promise.resolve(this.continue()); - const basicResult = this.shouldUseBasic() ? this.checkBasicAuth(req) : null; - if (basicResult) return Promise.resolve(basicResult); - - const bearerResult = this.shouldUseBearer() ? this.checkBearerAuth(req) : null; - if (bearerResult) return Promise.resolve(bearerResult); + const auth = this.resolveAuth(ctx); + if (!auth) return Promise.resolve(this.continue()); - return Promise.resolve(this.continue()); + if (auth.kind === "basic") { + return Promise.resolve(this.checkBasicAuth(req, ctx, auth)); + } + if (auth.kind === "bearer") { + return Promise.resolve(this.checkBearerAuth(req, ctx, auth)); + } + return Promise.resolve(this.rejectInvalidAuth(req, ctx)); } - private loadAuthConfig(ctx: HandlerContext): void { - // Reset per-request auth state to avoid leaking config across requests. - this.basicUser = null; - this.basicPass = null; - this.basicRealm = "Secure Area"; - this.bearerToken = null; + private resolveAuth(ctx: HandlerContext): ResolvedAuth | null { + const explicitAuth = readExplicitAuth(ctx.securityConfig); + if (explicitAuth.state === "invalid") return INVALID_AUTH; + if (explicitAuth.state === "present") return resolveConfiguredAuth(explicitAuth.value); - const authConfig = ctx.securityConfig?.auth as AuthConfig | undefined; + const username: unknown = ctx.adapter.env.get("VERYFRONT_BASIC_USER"); + const password: unknown = ctx.adapter.env.get("VERYFRONT_BASIC_PASS"); + const token: unknown = ctx.adapter.env.get("VERYFRONT_BEARER_TOKEN"); + const hasUsername = username !== undefined; + const hasPassword = password !== undefined; + const hasToken = token !== undefined; - if (authConfig?.basic) { - this.basicUser = authConfig.basic.username; - this.basicPass = authConfig.basic.password; - this.basicRealm = sanitizeRealm(authConfig.basic.realm || "Secure Area"); - return; - } + if (!hasUsername && !hasPassword && !hasToken) return null; - if (authConfig?.bearer) { - this.bearerToken = authConfig.bearer.token; - return; + if ( + hasUsername && + hasPassword && + !hasToken && + typeof username === "string" && + username.length > 0 && + typeof password === "string" && + password.length > 0 + ) { + return Object.freeze({ + kind: "basic", + username, + password, + realm: "Secure Area", + }); } - // `__vfTestEnv` lets the test harness skip env-var credential loading so - // tests run without auth. It must NEVER short-circuit auth in production: - // guard it behind the environment check so a stray/injected global can't - // silently disable authentication on a live deployment. - if (!isProduction() && (globalThis as Record).__vfTestEnv === true) return; - - this.basicUser = ctx.adapter.env.get("VERYFRONT_BASIC_USER") ?? ""; - this.basicPass = ctx.adapter.env.get("VERYFRONT_BASIC_PASS") ?? ""; - this.bearerToken = ctx.adapter.env.get("VERYFRONT_BEARER_TOKEN") ?? ""; - } - - private shouldUseBasic(): boolean { - return Boolean(this.basicUser && this.basicPass); - } + if ( + !hasUsername && + !hasPassword && + hasToken && + typeof token === "string" && + token.length > 0 + ) { + return Object.freeze({ kind: "bearer", token }); + } - private shouldUseBearer(): boolean { - return Boolean(this.bearerToken); + return INVALID_AUTH; } - private checkBasicAuth(req: Request): HandlerResult | null { - const expected = `Basic ${encodeBase64(`${this.basicUser}:${this.basicPass}`)}`; + private checkBasicAuth( + req: Request, + ctx: HandlerContext, + authConfig: Extract, + ): HandlerResult { + const expected = `Basic ${encodeBase64(`${authConfig.username}:${authConfig.password}`)}`; const auth = req.headers.get("authorization") ?? ""; - if (constantTimeEqual(auth, expected)) return null; + if (constantTimeEqual(auth, expected)) return this.continue(); return this.respond( - new Response("Unauthorized", { - status: 401, - headers: { "WWW-Authenticate": `Basic realm="${this.basicRealm}"` }, - }), + this.createResponseBuilder(ctx) + .withCORS(req, ctx.securityConfig?.cors) + .withSecurity(ctx.securityConfig ?? undefined, req) + .withCache("no-store") + .withHeaders({ "WWW-Authenticate": `Basic realm="${authConfig.realm}"` }) + .text("Unauthorized", 401), ); } - private checkBearerAuth(req: Request): HandlerResult | null { + private checkBearerAuth( + req: Request, + ctx: HandlerContext, + authConfig: Extract, + ): HandlerResult { const auth = req.headers.get("authorization") ?? ""; - if (auth.startsWith("Bearer ") && constantTimeEqual(auth.slice(7), this.bearerToken ?? "")) { - return null; + if (auth.startsWith("Bearer ") && constantTimeEqual(auth.slice(7), authConfig.token)) { + return this.continue(); } - return this.respond(new Response("Unauthorized", { status: 401 })); + return this.respond( + this.createResponseBuilder(ctx) + .withCORS(req, ctx.securityConfig?.cors) + .withSecurity(ctx.securityConfig ?? undefined, req) + .withCache("no-store") + .withHeaders({ "WWW-Authenticate": "Bearer" }) + .text("Unauthorized", 401), + ); + } + + private rejectInvalidAuth(req: Request, ctx: HandlerContext): HandlerResult { + return this.respond( + this.createResponseBuilder(ctx) + .withCORS(req, ctx.securityConfig?.cors) + .withSecurity(ctx.securityConfig ?? undefined, req) + .withCache("no-store") + .withHeaders({ + "WWW-Authenticate": 'Basic realm="Secure Area", Bearer', + }) + .text("Unauthorized", 401), + ); } } diff --git a/src/security/http/base-handler.test.ts b/src/security/http/base-handler.test.ts index 950ddc5d12..a2152d5798 100644 --- a/src/security/http/base-handler.test.ts +++ b/src/security/http/base-handler.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { deleteEnv, setEnv } from "#veryfront/compat/process.ts"; import { @@ -9,6 +9,7 @@ import { } from "#veryfront/observability/tracing/api-shim.ts"; import { runWithRequestContext } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; import { metrics } from "#veryfront/metrics"; +import { VeryfrontError } from "#veryfront/errors"; import { BaseHandler } from "./base-handler.ts"; import type { HandlerContext, @@ -28,6 +29,14 @@ class TestHandler extends BaseHandler { return this.continue(); } + testShouldHandle(req: Request, ctx: HandlerContext): boolean { + return this.shouldHandle(req, ctx); + } + + testGetErrorMessage(error: unknown): string { + return this.getErrorMessage(error); + } + // Expose withProxyContext for testing testWithProxyContext( ctx: HandlerContext, @@ -52,6 +61,72 @@ function createMinimalCtx( } as unknown as HandlerContext; } +describe("BaseHandler route matching", () => { + it("treats exact: false as the documented legacy prefix form", () => { + const handler = new TestHandler(); + handler.metadata.patterns = [{ pattern: "/api", exact: false }]; + const ctx = createMinimalCtx(); + + assertEquals( + handler.testShouldHandle(new Request("http://localhost/api/items"), ctx), + true, + ); + assertEquals( + handler.testShouldHandle(new Request("http://localhost/other"), ctx), + false, + ); + }); + + it("lets an explicit prefix option override the legacy exact alias", () => { + const handler = new TestHandler(); + const ctx = createMinimalCtx(); + + handler.metadata.patterns = [{ pattern: "/api", exact: false, prefix: false }]; + assertEquals( + handler.testShouldHandle(new Request("http://localhost/api/items"), ctx), + false, + ); + assertEquals( + handler.testShouldHandle(new Request("http://localhost/api"), ctx), + true, + ); + + handler.metadata.patterns = [{ pattern: "/api", exact: true, prefix: true }]; + assertEquals( + handler.testShouldHandle(new Request("http://localhost/api/items"), ctx), + true, + ); + }); + + it("matches global and sticky regular expressions deterministically", () => { + const ctx = createMinimalCtx(); + for (const pattern of [/^\/api/g, /^\/api/y]) { + const handler = new TestHandler(); + handler.metadata.patterns = [{ pattern }]; + const request = new Request("http://localhost/api"); + + assertEquals(handler.testShouldHandle(request, ctx), true); + assertEquals(handler.testShouldHandle(request, ctx), true); + assertEquals(pattern.lastIndex, 0); + } + }); +}); + +describe("BaseHandler error boundaries", () => { + it("returns a stable fallback for unreadable thrown values", () => { + const unreadableError = new Proxy({}, { + get() { + throw new Error("error fields must not be read directly"); + }, + getPrototypeOf() { + throw new Error("error prototype must not be read directly"); + }, + }); + + assertEquals(new TestHandler().testGetErrorMessage(unreadableError), "Unknown error"); + }); +}); + describe("BaseHandler.withProxyContext", () => { afterEach(() => { try { @@ -79,26 +154,32 @@ describe("BaseHandler.withProxyContext", () => { assertEquals(called, true, "fn should run in local dev mode"); }); - it("runs fn() without proxy context when requireToken is true but no token", async () => { + it("does not misclassify a standalone cache-isolation slug as proxy context", async () => { const handler = new TestHandler(); let called = false; + const warnings: unknown[][] = []; + const originalWarn = console.warn; - await handler.testWithProxyContext( - createMinimalCtx({ projectSlug: "my-project" }), - async () => { - called = true; - return { continue: true } as HandlerResult; - }, - { requireToken: true }, - ); + console.warn = (...args: unknown[]) => warnings.push(args); + try { + await handler.testWithProxyContext( + createMinimalCtx({ projectSlug: "my-project" }), + async () => { + called = true; + return { continue: true } as HandlerResult; + }, + { requireToken: true }, + ); + } finally { + console.warn = originalWarn; + } - // fn() should still run so embedded framework modules can be served, - // but without project-scoped credentials (no setRequestToken call) assertEquals( called, true, - "fn should run without proxy context so embedded modules work", + "fn should run directly for a standalone filesystem", ); + assertEquals(warnings, []); }); it("runs fn() when requireToken is true and token is present", async () => { @@ -168,6 +249,84 @@ describe("BaseHandler.withProxyContext", () => { assertEquals(called, true, "fn should run with proxyToken"); }); + for (const mode of ["multi-project", "contextual"] as const) { + it(`rejects missing required credentials before ${mode} filesystem work`, async () => { + const handler = new TestHandler(); + let callbackCalled = false; + const fs = mode === "multi-project" + ? { + isMultiProjectMode: () => true, + runWithContext: async (_slug: string, _token: string, fn: () => Promise) => + await fn(), + } + : { + isContextualMode: () => true, + setRequestBranch() {}, + setRequestToken() {}, + }; + const ctx = createMinimalCtx({ + projectSlug: "remote-project", + adapter: { fs } as unknown as HandlerContext["adapter"], + }); + + const error = await assertRejects( + () => + handler.testWithProxyContext( + ctx, + () => { + callbackCalled = true; + return Promise.resolve("forbidden"); + }, + { requireToken: true }, + ), + VeryfrontError, + ); + + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "authentication-required"); + assertEquals(callbackCalled, false); + }); + } + + it("rejects an incomplete multi-project filesystem adapter explicitly", async () => { + const handler = new TestHandler(); + const ctx = createMinimalCtx({ + projectSlug: "my-project", + proxyToken: "vf_proxy_token", + adapter: { + fs: { + isMultiProjectMode: () => true, + }, + } as unknown as HandlerContext["adapter"], + }); + + await assertRejects( + () => handler.testWithProxyContext(ctx, () => Promise.resolve("unused")), + TypeError, + "requires a runWithContext adapter method", + ); + }); + + it("rejects incomplete contextual filesystem capabilities without legacy inference", async () => { + const handler = new TestHandler(); + const ctx = createMinimalCtx({ + projectSlug: "my-project", + proxyToken: "vf_proxy_token", + adapter: { + fs: { + isContextualMode: () => true, + setRequestToken() {}, + }, + } as unknown as HandlerContext["adapter"], + }); + + await assertRejects( + () => handler.testWithProxyContext(ctx, () => Promise.resolve("unused")), + TypeError, + "requires a setRequestBranch adapter method", + ); + }); + it("emits metrics with project and environment labels in multi-project request context", async () => { const handler = new TestHandler(); const counterCalls: unknown[] = []; diff --git a/src/security/http/base-handler.ts b/src/security/http/base-handler.ts index bae900339c..95890a0f04 100644 --- a/src/security/http/base-handler.ts +++ b/src/security/http/base-handler.ts @@ -10,7 +10,10 @@ import { runWithVerifiedCacheApiCredential } from "#veryfront/cache/verified-api import type { VerifiedControlPlaneRequestClaims } from "#veryfront/internal-agents/control-plane-auth.ts"; import { getHostEnv } from "#veryfront/platform/compat/process.ts"; import type { WebSocketUpgradeResponse } from "#veryfront/platform/adapters/base.ts"; +import { getErrorMessage as formatErrorMessage } from "#veryfront/errors/veryfront-error.ts"; +import { AUTHENTICATION_REQUIRED } from "#veryfront/errors"; import { serverLogger } from "#veryfront/utils"; +import { isExplicitlyLocalProject } from "#veryfront/security/project-locality.ts"; import { ResponseBuilder } from "./response/index.ts"; export interface HandlerHelpers { @@ -24,6 +27,21 @@ export interface HandlerHelpers { continue: () => HandlerResult; } +/** Match a request pathname against one runtime route pattern. */ +export function matchesRoutePathname(pathname: string, routePattern: RoutePattern): boolean { + const pattern = routePattern.pattern; + + if (typeof pattern === "string") { + const isPrefixMatch = routePattern.prefix ?? routePattern.exact === false; + return isPrefixMatch ? pathname.startsWith(pattern) : pathname === pattern; + } + + if (!pattern.global && !pattern.sticky) return pattern.test(pathname); + + const statelessMatcher = new RegExp(pattern.source, pattern.flags); + return statelessMatcher.test(pathname); +} + export abstract class BaseHandler implements Handler { abstract metadata: HandlerMetadata; @@ -57,25 +75,16 @@ export abstract class BaseHandler implements Handler { if (!methods.includes(method)) return false; } - const routePattern = pattern.pattern; - - if (typeof routePattern === "string") { - return pattern.prefix ? pathname.startsWith(routePattern) : pathname === routePattern; - } - - if (routePattern instanceof RegExp) return routePattern.test(pathname); - - return false; + return matchesRoutePathname(pathname, pattern); } protected createResponseBuilder( ctx: HandlerContext, nonce?: string, - _options?: Record, ): ResponseBuilder { return new ResponseBuilder({ securityConfig: ctx.securityConfig ?? undefined, - isDev: !!ctx.isLocalProject, + isDev: isExplicitlyLocalProject(ctx), cspUserHeader: ctx.cspUserHeader, adapter: ctx.adapter, nonce, @@ -88,17 +97,16 @@ export abstract class BaseHandler implements Handler { serverLogger.debug(`[${this.metadata.name}] ${message}`, extra ?? undefined); } - protected logWarn(message: string, extra?: Record, _ctx?: HandlerContext): void { + protected logWarn(message: string, extra?: Record): void { serverLogger.warn(`[${this.metadata.name}] ${message}`, extra ?? undefined); } - protected logInfo(message: string, extra?: Record, _ctx?: HandlerContext): void { + protected logInfo(message: string, extra?: Record): void { serverLogger.info(`[${this.metadata.name}] ${message}`, extra ?? undefined); } protected getErrorMessage(error: unknown): string { - if (error instanceof Error) return error.message; - return String(error); + return formatErrorMessage(error); } protected continue(): HandlerResult { @@ -109,6 +117,8 @@ export abstract class BaseHandler implements Handler { response: Response | WebSocketUpgradeResponse, metadata?: Record, ): HandlerResult { + // HandlerResult deliberately remains HTTP-only. Runtime dispatch recognizes + // the explicit non-DOM WebSocket signal before using normal Response APIs. return { response: response as Response, continue: false, metadata }; } @@ -137,6 +147,7 @@ export abstract class BaseHandler implements Handler { setRequestToken?: (t: string) => void; setRequestBranch?: (b: string | null) => void; isMultiProjectMode?: () => boolean; + isContextualMode?: () => boolean; runWithContext?: ( slug: string, token: string, @@ -151,33 +162,35 @@ export abstract class BaseHandler implements Handler { ) => Promise; }; - if (typeof fsWrapper.setRequestBranch === "function") { - try { - fsWrapper.setRequestBranch(ctx.parsedDomain?.branch ?? null); - } catch (_) { - /* expected: multi-project mode uses runWithContext for branch context */ - } - } - const requireToken = options.requireToken ?? false; + const isMultiProjectMode = fsWrapper.isMultiProjectMode?.() === true; + const isContextualMode = fsWrapper.isContextualMode?.() === true; + const requiresProjectCredential = isMultiProjectMode || isContextualMode; // No project slug → local dev mode, no proxy context needed. if (!ctx.projectSlug) return fn(); - // Token required but missing in proxy mode → run fn() without - // project-scoped credentials. This allows embedded framework modules - // (e.g. /_vf_modules/_veryfront/...) to be served from the binary - // while project-specific content will fail at the filesystem level - // (no token = no access to remote project files). - if (requireToken && !effectiveToken) { - serverLogger.warn( - `[${this.metadata.name}] No API token for proxy context — project content will be unavailable`, - { projectSlug: ctx.projectSlug }, + // A project slug is also used to isolate standalone caches; it does not by + // itself imply a credentialed proxy filesystem. Once the adapter declares + // contextual project access, however, a required credential is an actual + // admission boundary and the callback must not run without it. + if (requireToken && !effectiveToken && requiresProjectCredential) { + return Promise.reject( + AUTHENTICATION_REQUIRED.create({ + detail: "Contextual project filesystem access requires an API token", + }), ); - return fn(); } - if (fsWrapper.isMultiProjectMode?.()) { + if (isMultiProjectMode) { + if (typeof fsWrapper.runWithContext !== "function") { + return Promise.reject( + new TypeError( + "Multi-project filesystem mode requires a runWithContext adapter method", + ), + ); + } + const isProduction = (ctx.resolvedEnvironment ?? ctx.requestContext?.mode) === "production"; const branch = ctx.parsedDomain?.branch ?? null; @@ -192,7 +205,7 @@ export abstract class BaseHandler implements Handler { ctx, ); - return fsWrapper.runWithContext!( + return fsWrapper.runWithContext( ctx.projectSlug, effectiveToken, fn, @@ -206,8 +219,22 @@ export abstract class BaseHandler implements Handler { ); } - if (typeof fsWrapper.setRequestToken === "function" && effectiveToken) { - fsWrapper.setRequestToken(effectiveToken); + if (isContextualMode) { + if (typeof fsWrapper.setRequestBranch !== "function") { + return Promise.reject( + new TypeError("Contextual filesystem mode requires a setRequestBranch adapter method"), + ); + } + fsWrapper.setRequestBranch(ctx.parsedDomain?.branch ?? null); + + if (effectiveToken) { + if (typeof fsWrapper.setRequestToken !== "function") { + return Promise.reject( + new TypeError("Contextual filesystem mode requires a setRequestToken adapter method"), + ); + } + fsWrapper.setRequestToken(effectiveToken); + } } return runWithCacheBatching(fn); diff --git a/src/security/http/config.test.ts b/src/security/http/config.test.ts index 7d32c9b835..843f5495b0 100644 --- a/src/security/http/config.test.ts +++ b/src/security/http/config.test.ts @@ -1,8 +1,9 @@ import "#veryfront/schemas/_test-setup.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; -import { SecurityConfigLoader } from "./config.ts"; +import type { VeryfrontConfig } from "#veryfront/config"; +import { deriveSecurityContext, SecurityConfigLoader } from "./config.ts"; function captureConsoleLog(): { getOutput: () => string; restore: () => void } { const originalWarn = console.warn; @@ -87,7 +88,7 @@ describe("security/http/config", () => { assertEquals(loader.getSecurityHeader("COEP", "require-corp"), "require-corp"); }); - it("reset clears cached security state", async () => { + it("does not expose a reset race after publishing security state", async () => { const loader = new SecurityConfigLoader( "/project", createMockAdapter(), @@ -104,11 +105,9 @@ describe("security/http/config", () => { assertEquals(loader.getSecurityConfig()?.cors, true); assertEquals(loader.getCspUserHeader(), "default-src 'self'"); - loader.reset(); - - assertEquals(loader.getSecurityConfig(), null); - assertEquals(loader.getCspUserHeader(), null); - assertEquals(loader.getCorsConfig(), undefined); + assertEquals("reset" in loader, false); + assertEquals(loader.getSecurityConfig()?.cors, true); + assertEquals(loader.getCspUserHeader(), "default-src 'self'"); }); it("defaults CSRF protection on in production when not explicitly configured", async () => { @@ -201,14 +200,127 @@ describe("security/http/config", () => { assertEquals(getOutput().includes("Neither CORS nor CSRF protection is configured"), true); }); + it("derives a deep-frozen request-owned security context without mutating config", () => { + const originValidator = (origin: string) => origin === "https://client.example"; + const config = { + security: { + cors: { + origin: originValidator, + methods: ["GET"], + allowedHeaders: ["authorization"], + }, + csrf: { + excludePaths: ["/webhooks"], + }, + csp: { + "default-src": ["'none'"], + }, + auth: { + basic: { + username: "alice", + password: "secret", + }, + }, + }, + } as VeryfrontConfig; + + const first = deriveSecurityContext(config, { productionDefaults: true }); + const second = deriveSecurityContext(config, { productionDefaults: true }); + const sourceCors = config.security?.cors as Exclude< + NonNullable["cors"]>, + boolean + >; + const derivedCors = first.securityConfig.cors as Exclude< + NonNullable, + boolean + >; + + assertEquals(first.securityConfig === config.security, false); + assertEquals(first.securityConfig === second.securityConfig, false); + assertEquals(derivedCors === sourceCors, false); + assertEquals(derivedCors.methods === sourceCors.methods, false); + assertEquals(Object.isFrozen(first), true); + assertEquals(Object.isFrozen(first.securityConfig), true); + assertEquals(Object.isFrozen(derivedCors), true); + assertEquals(Object.isFrozen(derivedCors.methods), true); + assertEquals(derivedCors.origin === originValidator, false); + assertEquals(Object.isFrozen(derivedCors.origin), true); + assertEquals( + typeof derivedCors.origin === "function" && + derivedCors.origin("https://client.example"), + true, + ); + assertEquals( + typeof (second.securityConfig.cors as { origin?: unknown }).origin === "function" && + (second.securityConfig.cors as { origin?: unknown }).origin === derivedCors.origin, + false, + ); + assertEquals(first.cspUserHeader, "default-src 'none'"); + + sourceCors.methods?.push("POST"); + assertEquals(derivedCors.methods, ["GET"]); + }); + + it("applies production defaults without overriding explicit security choices", () => { + const production = deriveSecurityContext( + { security: { csrf: false, cors: false } }, + { productionDefaults: true }, + ); + const development = deriveSecurityContext( + { security: {} }, + { productionDefaults: false }, + ); + + assertEquals(production.securityConfig.csrf, false); + assertEquals(production.securityConfig.cors, false); + assertEquals(development.securityConfig.csrf, undefined); + assertEquals(development.securityConfig.cors, false); + }); + + it("rejects hostile configuration shapes without invoking accessors", () => { + let configGetterCalls = 0; + const config = {} as Record; + Object.defineProperty(config, "security", { + enumerable: true, + get() { + configGetterCalls++; + return { csrf: true }; + }, + }); + assertThrows(() => deriveSecurityContext(config as VeryfrontConfig), TypeError); + assertEquals(configGetterCalls, 0); + + const cyclic: Record = {}; + cyclic.self = cyclic; + assertThrows( + () => deriveSecurityContext({ security: cyclic } as VeryfrontConfig), + TypeError, + ); + + let optionGetterCalls = 0; + const options = {} as Record; + Object.defineProperty(options, "productionDefaults", { + enumerable: true, + get() { + optionGetterCalls++; + return true; + }, + }); + assertThrows( + () => deriveSecurityContext(undefined, options as never), + TypeError, + ); + assertEquals(optionGetterCalls, 0); + }); + it("rejects a failed load for the current caller and retries on the next call", async () => { let shouldFail = true; const config = new Proxy( { security: { csrf: true } }, { - get(target, property, receiver) { - if (shouldFail) throw new Error("config load failed"); - return Reflect.get(target, property, receiver); + getOwnPropertyDescriptor(target, property) { + if (property === "security" && shouldFail) throw new Error("config load failed"); + return Reflect.getOwnPropertyDescriptor(target, property); }, }, ); @@ -216,8 +328,8 @@ describe("security/http/config", () => { await assertRejects( () => loader.ensureLoaded(), - Error, - "config load failed", + TypeError, + "Invalid security configuration", ); assertEquals(loader.getSecurityConfig(), null); diff --git a/src/security/http/config.ts b/src/security/http/config.ts index 6b93994eb7..8755cd1f7c 100644 --- a/src/security/http/config.ts +++ b/src/security/http/config.ts @@ -8,6 +8,233 @@ import { isProduction } from "#veryfront/platform/environment.ts"; const logger = serverLogger.component("security-config-loader"); +export interface DerivedSecurityContext { + securityConfig: SecurityConfig; + cspUserHeader: string | null; +} + +export interface DeriveSecurityContextOptions { + /** + * Apply security defaults used by production runtimes. Defaults to the + * process environment; callers with an independently trusted runtime + * classification may override it explicitly. + */ + productionDefaults?: boolean; +} + +const MAX_SECURITY_CONFIG_DEPTH = 32; +const MAX_SECURITY_CONFIG_ENTRIES = 10_000; +const MAX_SECURITY_CONFIG_ARRAY_LENGTH = 1_024; + +interface SecuritySnapshotState { + readonly clones: WeakMap; + readonly active: WeakSet; + entries: number; +} + +function invalidSecurityConfig(): never { + throw new TypeError("Invalid security configuration"); +} + +function consumeSecurityEntry(state: SecuritySnapshotState): void { + state.entries++; + if (state.entries > MAX_SECURITY_CONFIG_ENTRIES) invalidSecurityConfig(); +} + +function cloneAndFreezeSecurityValue( + value: T, + state: SecuritySnapshotState = { + clones: new WeakMap(), + active: new WeakSet(), + entries: 0, + }, + depth = 0, +): T { + if (value === null) return value; + + if (depth > MAX_SECURITY_CONFIG_DEPTH) return invalidSecurityConfig(); + + if (typeof value === "function") { + const source = value as (...args: unknown[]) => unknown; + const cached = state.clones.get(source); + if (cached !== undefined) return cached as T; + + const wrapped = function (this: unknown, ...args: unknown[]): unknown { + return Reflect.apply(source, this, args); + }; + state.clones.set(source, wrapped); + return Object.freeze(wrapped) as T; + } + + if (typeof value === "number") { + return Number.isFinite(value) ? value : invalidSecurityConfig(); + } + if ( + typeof value === "string" || + typeof value === "boolean" || + value === undefined + ) { + return value; + } + if (typeof value !== "object") return invalidSecurityConfig(); + + const source = value as object; + if (state.active.has(source)) return invalidSecurityConfig(); + const cached = state.clones.get(source); + if (cached !== undefined) return cached as T; + + let isArray: boolean; + let prototype: object | null; + try { + isArray = Array.isArray(value); + prototype = Object.getPrototypeOf(value); + } catch { + return invalidSecurityConfig(); + } + + if (!isArray && prototype !== Object.prototype && prototype !== null) { + return invalidSecurityConfig(); + } + + state.active.add(source); + try { + if (isArray) { + let keys: (string | symbol)[]; + let length: unknown; + try { + keys = Reflect.ownKeys(source); + const lengthDescriptor = Object.getOwnPropertyDescriptor(source, "length"); + length = lengthDescriptor && "value" in lengthDescriptor + ? lengthDescriptor.value + : undefined; + } catch { + return invalidSecurityConfig(); + } + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > MAX_SECURITY_CONFIG_ARRAY_LENGTH || + keys.length !== length + 1 + ) { + return invalidSecurityConfig(); + } + + const clone = new Array(length); + state.clones.set(source, clone); + for (let index = 0; index < length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(source, String(index)); + if (!descriptor || !("value" in descriptor)) return invalidSecurityConfig(); + consumeSecurityEntry(state); + clone[index] = cloneAndFreezeSecurityValue(descriptor.value, state, depth + 1); + } + return Object.freeze(clone) as T; + } + + let keys: (string | symbol)[]; + try { + keys = Reflect.ownKeys(source); + } catch { + return invalidSecurityConfig(); + } + const clone: Record = Object.create(null); + state.clones.set(source, clone); + for (const key of keys) { + if (typeof key !== "string") return invalidSecurityConfig(); + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) { + return invalidSecurityConfig(); + } + consumeSecurityEntry(state); + Object.defineProperty(clone, key, { + configurable: false, + enumerable: true, + writable: false, + value: cloneAndFreezeSecurityValue(descriptor.value, state, depth + 1), + }); + } + return Object.freeze(clone) as T; + } finally { + state.active.delete(source); + } +} + +function readSecurityConfig(cfg: unknown): SecurityConfig | undefined { + if (cfg === undefined) return undefined; + if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg)) { + return invalidSecurityConfig(); + } + + try { + const prototype = Object.getPrototypeOf(cfg); + if (prototype !== Object.prototype && prototype !== null) return invalidSecurityConfig(); + const descriptor = Object.getOwnPropertyDescriptor(cfg, "security"); + if (!descriptor) return undefined; + if (!descriptor.enumerable || !("value" in descriptor)) return invalidSecurityConfig(); + const value = descriptor.value; + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return invalidSecurityConfig(); + } + return value as SecurityConfig; + } catch { + return invalidSecurityConfig(); + } +} + +function readProductionDefaults(options: unknown): boolean | undefined { + if (typeof options !== "object" || options === null || Array.isArray(options)) { + return invalidSecurityConfig(); + } + + try { + const prototype = Object.getPrototypeOf(options); + if (prototype !== Object.prototype && prototype !== null) return invalidSecurityConfig(); + const keys = Reflect.ownKeys(options); + if (keys.some((key) => key !== "productionDefaults")) return invalidSecurityConfig(); + const descriptor = Object.getOwnPropertyDescriptor(options, "productionDefaults"); + if (!descriptor) return undefined; + if (!descriptor.enumerable || !("value" in descriptor)) return invalidSecurityConfig(); + if (descriptor.value !== undefined && typeof descriptor.value !== "boolean") { + return invalidSecurityConfig(); + } + return descriptor.value as boolean | undefined; + } catch { + return invalidSecurityConfig(); + } +} + +/** + * Derive a request-owned security context from schema-validated project config. + * + * Config objects can be cached and shared between projects or requests. Deep + * cloning and freezing here prevents a handler from mutating that shared + * source. Function-valued origin validators are wrapped in request-owned + * frozen callables so mutable function objects are not shared across requests. + */ +export function deriveSecurityContext( + cfg?: VeryfrontConfig, + options: DeriveSecurityContextOptions = {}, +): DerivedSecurityContext { + const source = readSecurityConfig(cfg); + const snapshot = source === undefined + ? Object.freeze(Object.create(null)) as SecurityConfig + : cloneAndFreezeSecurityValue(source); + const normalized: SecurityConfig = Object.assign(Object.create(null), snapshot); + normalized.cors ??= false; + + const productionDefaults = readProductionDefaults(options) ?? isProduction(); + if (normalized.csrf === undefined && productionDefaults) { + normalized.csrf = true; + } + + const securityConfig = Object.freeze(normalized); + return Object.freeze({ + securityConfig, + cspUserHeader: serializeCSPDirectives(securityConfig.csp), + }); +} + export class SecurityConfigLoader { private securityConfig: SecurityConfig | null = null; private cspUserHeader: string | null = null; @@ -34,7 +261,10 @@ export class SecurityConfigLoader { // Fail this request closed, but allow a later request to retry after a // transient filesystem, import, or parse failure. if (Object.is(this.loadPromise, loadPromise)) this.loadPromise = null; - logger.error("Failed to load security config; will retry on next request", { error }); + // Configuration errors can contain project paths or source fragments. + // Keep process telemetry stable and non-sensitive; the error still + // rejects the current caller unchanged for the request error boundary. + logger.error("Failed to load security config; will retry on next request"); throw error; } } @@ -45,15 +275,9 @@ export class SecurityConfigLoader { } private applyConfig(cfg?: VeryfrontConfig): void { - const security: SecurityConfig = cfg?.security ? { ...cfg.security } as SecurityConfig : {}; const production = this.productionRuntime || isProduction(); - - if (security.headers) security.headers = { ...security.headers }; - - security.cors ??= false; - if (security.csrf === undefined && production) { - security.csrf = true; - } + const derived = deriveSecurityContext(cfg, { productionDefaults: production }); + const security = derived.securityConfig; if (production && !security.cors && !security.csrf) { logger.warn( @@ -64,7 +288,7 @@ export class SecurityConfigLoader { } this.securityConfig = security; - this.cspUserHeader = serializeCSPDirectives(security.csp); + this.cspUserHeader = derived.cspUserHeader; this.isLoaded = true; } @@ -92,11 +316,4 @@ export class SecurityConfigLoader { if (typeof configValue === "string") return configValue; return envValue || defaultValue; } - - reset(): void { - this.securityConfig = null; - this.cspUserHeader = null; - this.isLoaded = false; - this.loadPromise = null; - } } diff --git a/src/security/http/cors/constants.test.ts b/src/security/http/cors/constants.test.ts index 94e5541743..74d0eec54c 100644 --- a/src/security/http/cors/constants.test.ts +++ b/src/security/http/cors/constants.test.ts @@ -5,6 +5,8 @@ import { DEFAULT_HEADERS, DEFAULT_MAX_AGE, DEFAULT_METHODS, + getDefaultCORSHeaders, + getDefaultCORSMethods, HTTP_FORBIDDEN, HTTP_NO_CONTENT, } from "./constants.ts"; @@ -20,6 +22,11 @@ describe("CORS constants", () => { it("should have 6 methods", () => { assertEquals(DEFAULT_METHODS.length, 6); }); + + it("exports the immutable runtime policy", () => { + assertEquals(Object.isFrozen(DEFAULT_METHODS), true); + assertEquals(getDefaultCORSMethods(), DEFAULT_METHODS); + }); }); describe("DEFAULT_HEADERS", () => { @@ -28,6 +35,11 @@ describe("CORS constants", () => { assert(DEFAULT_HEADERS.includes(header)); } }); + + it("exports the immutable runtime policy", () => { + assertEquals(Object.isFrozen(DEFAULT_HEADERS), true); + assertEquals(getDefaultCORSHeaders(), DEFAULT_HEADERS); + }); }); describe("DEFAULT_MAX_AGE", () => { diff --git a/src/security/http/cors/constants.ts b/src/security/http/cors/constants.ts index 38c303f1b9..032795f678 100644 --- a/src/security/http/cors/constants.ts +++ b/src/security/http/cors/constants.ts @@ -1,5 +1,24 @@ -export const DEFAULT_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; -export const DEFAULT_HEADERS = ["Content-Type", "Authorization"]; +export const DEFAULT_METHODS: readonly string[] = Object.freeze([ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +]); +export const DEFAULT_HEADERS: readonly string[] = Object.freeze([ + "Content-Type", + "Authorization", +]); + +export function getDefaultCORSMethods(): readonly string[] { + return DEFAULT_METHODS; +} + +export function getDefaultCORSHeaders(): readonly string[] { + return DEFAULT_HEADERS; +} + export const DEFAULT_MAX_AGE = 86400; export const HTTP_NO_CONTENT = 204; diff --git a/src/security/http/cors/headers.test.ts b/src/security/http/cors/headers.test.ts index aa2ec6ce1b..740862e6b0 100644 --- a/src/security/http/cors/headers.test.ts +++ b/src/security/http/cors/headers.test.ts @@ -1,7 +1,8 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { shouldApplyCORS } from "./headers.ts"; +import { applyCORSHeaders, applyCORSHeadersSync, shouldApplyCORS } from "./headers.ts"; +import { MAX_CORS_TOKEN_LENGTH } from "#veryfront/utils/cors-policy-limits.ts"; describe("security/http/cors/headers", () => { describe("shouldApplyCORS", () => { @@ -33,4 +34,252 @@ describe("security/http/cors/headers", () => { assertEquals(shouldApplyCORS(req, { origin: "http://example.com" }), false); }); }); + + it("fails the entire CORS boundary closed for an oversized exposed-header policy", async () => { + const request = new Request("http://localhost/", { + headers: { origin: "https://app.example.com" }, + }); + const response = await applyCORSHeaders({ + request, + response: new Response("ok"), + config: { + origin: "https://app.example.com", + exposedHeaders: ["X".repeat(MAX_CORS_TOKEN_LENGTH + 1)], + }, + }); + + assertEquals(response?.headers.get("Access-Control-Allow-Origin"), null); + assertEquals(response?.headers.get("Access-Control-Expose-Headers"), null); + }); + + it("does not partially apply malformed or unknown CORS configuration", async () => { + const request = new Request("http://localhost/", { + headers: { origin: "https://app.example.com" }, + }); + + for ( + const config of [ + { origin: "https://app.example.com", credentials: "yes" }, + { origin: "https://app.example.com", maxAge: -1 }, + { origin: "https://app.example.com", methods: ["GET, POST"] }, + { origin: "https://app.example.com", unexpected: true }, + ] + ) { + const response = await applyCORSHeaders({ + request, + response: new Response("ok"), + config: config as never, + }); + + assertEquals(response?.headers.get("Access-Control-Allow-Origin"), null); + assertEquals(response?.headers.get("Access-Control-Allow-Credentials"), null); + assertEquals(response?.headers.get("Access-Control-Expose-Headers"), null); + } + }); + + it("rejects unsafe callback origins before writing response headers", async () => { + const request = new Request("http://localhost/", { + headers: { origin: "https://app.example.com" }, + }); + const response = await applyCORSHeaders({ + request, + response: new Response("ok"), + config: { + origin: () => "https://例.example", + }, + }); + + assertEquals(response?.headers.get("Access-Control-Allow-Origin"), null); + }); + + it("scrubs every policy-owned CORS header when an origin is denied", async () => { + const request = new Request("http://localhost/", { + headers: { origin: "https://denied.example.com" }, + }); + const headers = new Headers({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Expose-Headers": "X-Project", + "Access-Control-Allow-Methods": "DELETE", + "Access-Control-Allow-Headers": "X-Project", + "Access-Control-Max-Age": "999999", + "Access-Control-Allow-Private-Network": "true", + "Access-Control-Future-Policy": "unsafe", + }); + await applyCORSHeaders({ + request, + headers, + config: { origin: "https://allowed.example.com" }, + }); + + for ( + const name of [ + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials", + "Access-Control-Expose-Headers", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + "Access-Control-Max-Age", + "Access-Control-Allow-Private-Network", + "Access-Control-Future-Policy", + ] + ) { + assertEquals(headers.get(name), null); + } + }); + + it("mutates supplied headers to the exact allowed policy without stale values", () => { + const origin = "https://allowed.example.com"; + const request = new Request("http://localhost/", { + headers: { origin }, + }); + const headers = new Headers({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Expose-Headers": "X-Project", + "Access-Control-Allow-Methods": "DELETE", + "Access-Control-Allow-Headers": "X-Project", + "Access-Control-Max-Age": "999999", + "Access-Control-Allow-Private-Network": "true", + "Access-Control-Future-Policy": "unsafe", + }); + + applyCORSHeadersSync({ + request, + headers, + config: { origin }, + }); + + assertEquals(headers.get("Access-Control-Allow-Origin"), origin); + assertEquals(headers.get("Vary"), "Origin"); + for ( + const name of [ + "Access-Control-Allow-Credentials", + "Access-Control-Expose-Headers", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + "Access-Control-Max-Age", + "Access-Control-Allow-Private-Network", + "Access-Control-Future-Policy", + ] + ) { + assertEquals(headers.get(name), null); + } + }); + + it("returns async denials and malformed policies with the sanitized supplied headers", async () => { + const request = new Request("http://localhost/", { + headers: { origin: "https://denied.example.com" }, + }); + + for ( + const config of [ + { origin: "https://allowed.example.com" }, + { origin: "https://denied.example.com", unexpected: true }, + ] + ) { + const response = new Response("ok", { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Future-Policy": "unsafe", + "X-Response": "original", + }, + }); + const headers = new Headers({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Future-Policy": "unsafe", + "X-Authoritative": "detached", + }); + + const result = await applyCORSHeaders({ + request, + response, + headers, + config: config as never, + }); + + assertEquals(result?.headers.get("Access-Control-Allow-Origin"), null); + assertEquals(result?.headers.get("Access-Control-Allow-Credentials"), null); + assertEquals(result?.headers.get("Access-Control-Future-Policy"), null); + assertEquals(result?.headers.get("X-Authoritative"), "detached"); + assertEquals(result?.headers.get("X-Response"), null); + } + }); + + it("returns sync denials and malformed policies with the sanitized supplied headers", () => { + const request = new Request("http://localhost/", { + headers: { origin: "https://denied.example.com" }, + }); + + for ( + const config of [ + { origin: "https://allowed.example.com" }, + { origin: "https://denied.example.com", unexpected: true }, + ] + ) { + const response = new Response("ok", { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Future-Policy": "unsafe", + "X-Response": "original", + }, + }); + const headers = new Headers({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Future-Policy": "unsafe", + "X-Authoritative": "detached", + }); + + const result = applyCORSHeadersSync({ + request, + response, + headers, + config: config as never, + }); + + assertEquals(result?.headers.get("Access-Control-Allow-Origin"), null); + assertEquals(result?.headers.get("Access-Control-Allow-Credentials"), null); + assertEquals(result?.headers.get("Access-Control-Future-Policy"), null); + assertEquals(result?.headers.get("X-Authoritative"), "detached"); + assertEquals(result?.headers.get("X-Response"), null); + } + }); + + it("keeps supplied headers authoritative when an origin is allowed", async () => { + const origin = "https://allowed.example.com"; + const result = await applyCORSHeaders({ + request: new Request("http://localhost/", { headers: { origin } }), + response: new Response("ok", { headers: { "X-Response": "original" } }), + headers: new Headers({ "X-Authoritative": "detached" }), + config: { origin }, + }); + + assertEquals(result?.headers.get("Access-Control-Allow-Origin"), origin); + assertEquals(result?.headers.get("X-Authoritative"), "detached"); + assertEquals(result?.headers.get("X-Response"), null); + }); + + it("merges Vary field names case-insensitively without empty tokens", async () => { + const request = new Request("https://example.com", { + headers: { Origin: "https://app.example.com" }, + }); + + for (const initialVary of ["origin", "", "*"]) { + const headers = new Headers({ Vary: initialVary }); + await applyCORSHeaders({ + request, + headers, + config: { origin: "https://app.example.com" }, + }); + + assertEquals( + headers.get("Vary"), + initialVary === "" ? "Origin" : initialVary, + ); + } + }); }); diff --git a/src/security/http/cors/headers.ts b/src/security/http/cors/headers.ts index 7674018aed..a50e1ec36c 100644 --- a/src/security/http/cors/headers.ts +++ b/src/security/http/cors/headers.ts @@ -1,39 +1,75 @@ -import type { CORSConfig, CORSHeaderOptions, CORSValidationResult } from "./types.ts"; -import { validateOrigin, validateOriginSync } from "./validators.ts"; +import type { + CORSConfig, + CORSHeaderOptions, + CORSValidationResult, + SyncCORSHeaderOptions, +} from "./types.ts"; +import { + corsOriginForTelemetry, + normalizeCORSConfig, + type NormalizedCORSConfig, + validateNormalizedOrigin, + validateNormalizedOriginSync, +} from "./validators.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; +import { isCorsPolicyResponseHeaderName } from "#veryfront/utils/cors-policy-limits.ts"; + +export function scrubPolicyOwnedCORSHeaders(headers: Headers): boolean { + let changed = false; + for (const name of [...headers.keys()]) { + if (!isCorsPolicyResponseHeaderName(name)) continue; + headers.delete(name); + changed = true; + } + return changed; +} function applyValidatedHeaders( validation: CORSValidationResult, - options: CORSHeaderOptions, + options: CORSHeaderOptions | SyncCORSHeaderOptions, + config: NormalizedCORSConfig, ): Response | void { - const { response, headers: headersObj, config } = options; + const { response, headers: headersObj } = options; + const headers = headersObj ?? (response ? new Headers(response.headers) : new Headers()); if (!validation.allowedOrigin) { - return response; + const changed = scrubPolicyOwnedCORSHeaders(headers); + + if (!response) return; + if (!headersObj && !changed) return response; + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); } - const headers = headersObj ?? (response ? new Headers(response.headers) : new Headers()); - + scrubPolicyOwnedCORSHeaders(headers); headers.set("Access-Control-Allow-Origin", validation.allowedOrigin); if (validation.allowedOrigin !== "*") { const varyValues = headers .get("Vary") ?.split(",") - .map((v) => v.trim()) ?? []; + .map((value) => value.trim()) + .filter(Boolean) ?? []; - if (!varyValues.includes("Origin")) { + if ( + !varyValues.some((value) => value === "*" || value.toLowerCase() === "origin") + ) { headers.set("Vary", [...varyValues, "Origin"].join(", ")); } } if (validation.allowCredentials && validation.allowedOrigin !== "*") { headers.set("Access-Control-Allow-Credentials", "true"); - } + } else headers.delete("Access-Control-Allow-Credentials"); const corsConfig = typeof config === "object" ? config : null; if (corsConfig?.exposedHeaders?.length) { headers.set("Access-Control-Expose-Headers", corsConfig.exposedHeaders.join(", ")); + } else { + headers.delete("Access-Control-Expose-Headers"); } if (!response) { @@ -48,33 +84,62 @@ function applyValidatedHeaders( } export function applyCORSHeaders(options: CORSHeaderOptions): Promise { - const origin = options.request.headers.get("origin"); + const normalized = normalizeCORSConfig(options.config); + const origin = readRequestOrigin(options.request); return withSpan( "security.cors.applyHeaders", async () => { - const validation = await validateOrigin(origin, options.config); - return applyValidatedHeaders(validation, options); + if (!normalized.valid) { + return applyValidatedHeaders( + Object.freeze({ + allowedOrigin: null, + allowCredentials: false, + error: normalized.error, + }), + options, + false, + ); + } + const validation = await validateNormalizedOrigin(origin, normalized.config); + return applyValidatedHeaders(validation, options, normalized.config); }, - { "cors.origin": origin ?? "unknown" }, + { "cors.origin": corsOriginForTelemetry(origin) }, ); } -export function applyCORSHeadersSync(options: CORSHeaderOptions): Response | void { - const origin = options.request.headers.get("origin"); - const validation = validateOriginSync(origin, options.config); - return applyValidatedHeaders(validation, options); +/** Apply CORS synchronously. Promise-returning values still fail closed at runtime. */ +export function applyCORSHeadersSync(options: SyncCORSHeaderOptions): Response | void { + const normalized = normalizeCORSConfig(options.config); + const origin = readRequestOrigin(options.request); + if (!normalized.valid) { + return applyValidatedHeaders( + Object.freeze({ + allowedOrigin: null, + allowCredentials: false, + error: normalized.error, + }), + options, + false, + ); + } + const validation = validateNormalizedOriginSync(origin, normalized.config); + return applyValidatedHeaders(validation, options, normalized.config); } export function shouldApplyCORS(request: Request, config?: boolean | CORSConfig): boolean { - if (!config) { - return false; - } + const normalized = normalizeCORSConfig(config); + if (!normalized.valid || normalized.config === false) return false; + if (normalized.config === true) return true; - if (config === true) { - return true; - } + const origin = readRequestOrigin(request); + return typeof origin === "string" ? true : normalized.config.origin === "*"; +} - const origin = request.headers.get("origin"); - return origin ? true : config.origin === "*"; +function readRequestOrigin(request: Request): unknown { + try { + return request.headers.get("origin"); + } catch { + return undefined; + } } diff --git a/src/security/http/cors/index.ts b/src/security/http/cors/index.ts index 293f609f22..88dee7e2bd 100644 --- a/src/security/http/cors/index.ts +++ b/src/security/http/cors/index.ts @@ -25,6 +25,9 @@ export type { CORSPreflightOptions, CORSValidationResult, OriginValidator, + SyncCORSConfig, + SyncCORSHeaderOptions, + SyncOriginValidator, } from "./types.ts"; export { diff --git a/src/security/http/cors/middleware.ts b/src/security/http/cors/middleware.ts index 1c1fc33f26..ba6cf78019 100644 --- a/src/security/http/cors/middleware.ts +++ b/src/security/http/cors/middleware.ts @@ -2,20 +2,22 @@ import type { Context, MiddlewareHandler } from "#veryfront/middleware/core/inde import type { CORSConfig } from "./types.ts"; import { handleCORSPreflight, isPreflightRequest } from "./preflight.ts"; import { applyCORSHeaders } from "./headers.ts"; -import { validateCORSConfig } from "./validators.ts"; +import { normalizeCORSConfig } from "./validators.ts"; import { createError, toError } from "#veryfront/errors"; +import { getDefaultCORSMethods } from "./constants.ts"; /** Create CORS middleware. */ export function cors(config?: boolean | CORSConfig): MiddlewareHandler { - const validation = validateCORSConfig(config); - if (!validation.valid) { + const normalized = normalizeCORSConfig(config); + if (!normalized.valid) { throw toError( createError({ type: "config", - message: `[CORS] Invalid configuration: ${validation.error}`, + message: `[CORS] Invalid configuration: ${normalized.error}`, }), ); } + const corsConfig = normalized.config; return async ( c: Context, @@ -24,13 +26,13 @@ export function cors(config?: boolean | CORSConfig): MiddlewareHandler { const request = c.req; if (isPreflightRequest(request)) { - return handleCORSPreflight({ request, config }); + return handleCORSPreflight({ request, config: corsConfig }); } const response = await next(); if (!response) return undefined; - return (await applyCORSHeaders({ request, response, config })) ?? response; + return (await applyCORSHeaders({ request, response, config: corsConfig })) ?? response; }; } @@ -38,6 +40,6 @@ export function corsSimple(origin: string = "*"): MiddlewareHandler { return cors({ origin, credentials: false, - methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + methods: [...getDefaultCORSMethods()], }); } diff --git a/src/security/http/cors/preflight.test.ts b/src/security/http/cors/preflight.test.ts index d0d1afcdcf..49e601f1ba 100644 --- a/src/security/http/cors/preflight.test.ts +++ b/src/security/http/cors/preflight.test.ts @@ -1,9 +1,243 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { isPreflightRequest } from "./preflight.ts"; +import { DEFAULT_METHODS } from "./constants.ts"; +import { + handleCORSPreflight, + isPreflightRequest, + normalizeCORSPreflightList, +} from "./preflight.ts"; +import { MAX_CORS_TOKEN_LENGTH } from "#veryfront/utils/cors-policy-limits.ts"; describe("security/http/cors/preflight", () => { + describe("configured policy", () => { + it("keeps configured methods and headers narrower than runtime capabilities", async () => { + const request = new Request("http://localhost/", { + method: "OPTIONS", + headers: { + origin: "https://app.example.com", + "access-control-request-method": "POST", + "access-control-request-headers": "Content-Type, X-Internal", + }, + }); + + const response = await handleCORSPreflight({ + request, + config: { + origin: "https://app.example.com", + methods: ["GET"], + allowedHeaders: ["Content-Type"], + }, + allowMethods: "GET, POST", + allowHeaders: "Content-Type, X-Internal", + }); + + assertEquals(response.headers.get("Access-Control-Allow-Methods"), "GET"); + assertEquals(response.headers.get("Access-Control-Allow-Headers"), "Content-Type"); + }); + + it("uses configured header policy before request-supplied headers", async () => { + const request = new Request("http://localhost/", { + method: "OPTIONS", + headers: { + origin: "https://app.example.com", + "access-control-request-method": "GET", + "access-control-request-headers": "X-Unconfigured", + }, + }); + + const response = await handleCORSPreflight({ + request, + config: { + origin: "https://app.example.com", + allowedHeaders: ["Authorization"], + }, + }); + + assertEquals(response.headers.get("Access-Control-Allow-Headers"), "Authorization"); + }); + + it("treats explicitly undefined optional policies as absent", async () => { + const request = new Request("http://localhost/", { + method: "OPTIONS", + headers: { + origin: "https://app.example.com", + "access-control-request-method": "GET", + "access-control-request-headers": "X-Requested", + }, + }); + + const response = await handleCORSPreflight({ + request, + config: { + origin: "https://app.example.com", + methods: undefined, + allowedHeaders: undefined, + }, + }); + + assertEquals( + response.headers.get("Access-Control-Allow-Methods"), + DEFAULT_METHODS.join(", "), + ); + assertEquals(response.headers.get("Access-Control-Allow-Headers"), "X-Requested"); + }); + + it("omits allow headers when policy and runtime capabilities do not overlap", async () => { + const request = new Request("http://localhost/", { + method: "OPTIONS", + headers: { + origin: "https://app.example.com", + "access-control-request-method": "DELETE", + "access-control-request-headers": "X-Policy", + }, + }); + + const response = await handleCORSPreflight({ + request, + config: { + origin: "https://app.example.com", + methods: ["DELETE"], + allowedHeaders: ["X-Policy"], + }, + allowMethods: "GET, POST", + allowHeaders: "Content-Type", + }); + + assertEquals(response.headers.get("Access-Control-Allow-Methods"), null); + assertEquals(response.headers.get("Access-Control-Allow-Headers"), null); + }); + + it("fails closed instead of emitting an oversized configured header list", async () => { + const request = new Request("http://localhost/", { + method: "OPTIONS", + headers: { + origin: "https://app.example.com", + "access-control-request-method": "GET", + }, + }); + + const response = await handleCORSPreflight({ + request, + config: { + origin: "https://app.example.com", + allowedHeaders: ["X".repeat(MAX_CORS_TOKEN_LENGTH + 1)], + }, + }); + + assertEquals(response.status, 403); + assertEquals(response.headers.get("Access-Control-Allow-Origin"), null); + assertEquals(response.headers.get("Access-Control-Allow-Headers"), null); + assertEquals(response.headers.get("Access-Control-Max-Age"), null); + }); + + it("rejects malformed and unknown configuration without partial CORS headers", async () => { + const request = new Request("http://localhost/", { + method: "OPTIONS", + headers: { + origin: "https://app.example.com", + "access-control-request-method": "GET", + }, + }); + + for ( + const config of [ + { origin: "https://app.example.com", credentials: "true" }, + { origin: "https://app.example.com", maxAge: Number.NaN }, + { origin: "https://app.example.com", methods: ["GET, POST"] }, + { origin: "https://app.example.com", unknown: true }, + ] + ) { + const response = await handleCORSPreflight({ + request, + config: config as never, + }); + + assertEquals(response.status, 403); + assertEquals(response.headers.get("Access-Control-Allow-Origin"), null); + assertEquals(response.headers.get("Access-Control-Allow-Methods"), null); + assertEquals(response.headers.get("Access-Control-Allow-Headers"), null); + assertEquals(response.headers.get("Access-Control-Max-Age"), null); + } + }); + + it("emits max age only for allowed origins and valid safe integers", async () => { + const request = new Request("http://localhost/", { + method: "OPTIONS", + headers: { + origin: "https://denied.example.com", + "access-control-request-method": "GET", + }, + }); + + const denied = await handleCORSPreflight({ + request, + config: { + origin: "https://allowed.example.com", + maxAge: 7, + }, + }); + + assertEquals(denied.status, 403); + assertEquals(denied.headers.get("Access-Control-Max-Age"), null); + }); + + it("normalizes unknown non-string capability inputs without throwing", () => { + for (const value of [undefined, null, 42, {}, Symbol("methods")]) { + assertEquals(normalizeCORSPreflightList(value as never), null); + } + }); + + it("fails closed for hostile, malformed, and oversized capability values", () => { + const revoked = Proxy.revocable(["GET"], {}); + revoked.revoke(); + + for ( + const value of [ + revoked.proxy, + "GET\r\nX-Injected", + "M\u{100}", + "G".repeat(100_000), + ] + ) { + assertEquals(normalizeCORSPreflightList(value as never), null); + } + }); + + it("returns one fixed bounded rejection for hostile option names and proxies", async () => { + const request = new Request("http://localhost/", { + method: "OPTIONS", + headers: { + origin: "https://app.example.com", + "access-control-request-method": "GET", + }, + }); + const revokedConfig = Proxy.revocable({}, {}); + const revokedOrigins = Proxy.revocable(["https://app.example.com"], {}); + revokedConfig.revoke(); + revokedOrigins.revoke(); + + for ( + const config of [ + revokedConfig.proxy, + { origin: revokedOrigins.proxy }, + { ["unknown\r\nX-Injected: yes"]: true }, + { ["non-byte-\u{100}"]: true }, + { ["x".repeat(100_000)]: true }, + ] + ) { + const response = await handleCORSPreflight({ + request, + config: config as never, + }); + + assertEquals(response.status, 403); + assertEquals(response.headers.get("X-CORS-Error"), "CORS policy rejected"); + assertEquals(await response.text(), "CORS request rejected"); + } + }); + }); + describe("isPreflightRequest", () => { it("should return true for OPTIONS with access-control-request-method", () => { const req = new Request("http://localhost/", { diff --git a/src/security/http/cors/preflight.ts b/src/security/http/cors/preflight.ts index 17a8bd2fc4..f3682c1041 100644 --- a/src/security/http/cors/preflight.ts +++ b/src/security/http/cors/preflight.ts @@ -1,44 +1,154 @@ import type { CORSPreflightOptions } from "./types.ts"; -import { validateOrigin } from "./validators.ts"; import { - DEFAULT_HEADERS, + corsOriginForTelemetry, + normalizeCORSConfig, + type NormalizedCORSConfig, + snapshotCORSArray, + validateNormalizedOrigin, +} from "./validators.ts"; +import { DEFAULT_MAX_AGE, - DEFAULT_METHODS, + getDefaultCORSHeaders, + getDefaultCORSMethods, HTTP_FORBIDDEN, HTTP_NO_CONTENT, } from "./constants.ts"; import { serverLogger } from "#veryfront/utils"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; +import { + isBoundedCorsTokenList, + MAX_CORS_SERIALIZED_LIST_LENGTH, + MAX_CORS_TOKEN_COUNT, +} from "#veryfront/utils/cors-policy-limits.ts"; const logger = serverLogger.component("cors"); +const REJECTED_PREFLIGHT_BODY = "CORS request rejected"; +const REJECTED_PREFLIGHT_HEADER = "CORS policy rejected"; + +interface ResolvePreflightPolicyOptions { + config?: unknown; + allowMethods?: unknown; + allowHeaders?: unknown; + requestedHeaders?: unknown; +} + +interface ResolvedPreflightPolicy { + methods: string; + headers: string; +} + +export function normalizeCORSPreflightList(value: unknown): string[] | null { + try { + if (typeof value === "string") { + if (value.length > MAX_CORS_SERIALIZED_LIST_LENGTH) return null; + const normalized = [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))]; + return isBoundedCorsTokenList(normalized) ? normalized : null; + } + + const values = snapshotCORSArray(value, MAX_CORS_TOKEN_COUNT); + if (!values) return null; + if (!values.every((item) => typeof item === "string")) return null; + const normalized = [...new Set(values.map((item) => item.trim()).filter(Boolean))]; + return isBoundedCorsTokenList(normalized) ? normalized : null; + } catch { + return null; + } +} + +function intersectLists( + policy: string[], + capability: string[], + normalize: (value: string) => string, +): string[] { + const supported = new Set(capability.map(normalize)); + return policy.filter((value) => supported.has(normalize(value))); +} + +/** + * Resolve the headers advertised by a preflight response. + * + * Explicit allow lists describe runtime capabilities. Configured lists are + * policy restrictions, so when both exist the response advertises only their + * intersection. Request-supplied header names are reflected only when no + * configured or explicit policy exists. + */ +function resolveNormalizedCORSPreflightPolicy( + options: ResolvePreflightPolicyOptions, + config: NormalizedCORSConfig, +): ResolvedPreflightPolicy { + const corsConfig = typeof config === "object" ? config : undefined; + + const methodCapability = options.allowMethods === undefined + ? undefined + : normalizeCORSPreflightList(options.allowMethods) ?? []; + const configuredMethodValues = corsConfig?.methods; + const hasConfiguredMethods = configuredMethodValues !== undefined; + const configuredMethods = hasConfiguredMethods + ? normalizeCORSPreflightList(configuredMethodValues) ?? [] + : []; + const methods = hasConfiguredMethods + ? methodCapability + ? intersectLists(configuredMethods, methodCapability, (value) => value) + : configuredMethods + : methodCapability ?? [...getDefaultCORSMethods()]; + + const headerCapability = options.allowHeaders === undefined + ? undefined + : normalizeCORSPreflightList(options.allowHeaders) ?? []; + const configuredHeaderValues = corsConfig?.allowedHeaders; + const hasConfiguredHeaders = configuredHeaderValues !== undefined; + const configuredHeaders = hasConfiguredHeaders + ? normalizeCORSPreflightList(configuredHeaderValues) ?? [] + : []; + const headers = hasConfiguredHeaders + ? headerCapability + ? intersectLists(configuredHeaders, headerCapability, (value) => value.toLowerCase()) + : configuredHeaders + : headerCapability ?? + (options.requestedHeaders + ? normalizeCORSPreflightList(options.requestedHeaders) ?? [] + : [...getDefaultCORSHeaders()]); + + return { + methods: methods.join(", "), + headers: headers.join(", "), + }; +} + +export function resolveCORSPreflightPolicy( + options: ResolvePreflightPolicyOptions, +): ResolvedPreflightPolicy { + const normalized = normalizeCORSConfig(options.config); + if (!normalized.valid) return { methods: "", headers: "" }; + return resolveNormalizedCORSPreflightPolicy(options, normalized.config); +} export function handleCORSPreflight(options: CORSPreflightOptions): Promise { + const observedOrigin = readRequestHeader(options.request, "origin"); + return withSpan( "security.cors.preflight", async () => { const { request, config, allowMethods, allowHeaders } = options; + const normalized = normalizeCORSConfig(config); + if (!normalized.valid) { + return rejectedPreflight(); + } - const origin = request.headers.get("origin"); - const validation = await validateOrigin(origin, config); + const origin = readRequestHeader(request, "origin"); + const validation = await validateNormalizedOrigin(origin, normalized.config); if (!validation.allowedOrigin) { - if (!config) { + if (normalized.config === false) { return new Response(null, { status: HTTP_NO_CONTENT }); } logger.warn("Preflight rejected", { - origin, + origin: corsOriginForTelemetry(origin), error: validation.error, }); - const errorMessage = validation.error ?? "CORS policy: Origin not allowed"; - - return new Response(errorMessage, { - status: HTTP_FORBIDDEN, - headers: { - "X-CORS-Error": validation.error ?? "Origin not allowed", - }, - }); + return rejectedPreflight(); } const headers = new Headers(); @@ -48,20 +158,16 @@ export function handleCORSPreflight(options: CORSPreflightOptions): Promise boolean | string | Promise; +export type SyncOriginValidator = (origin: string) => boolean | string; +export type OriginValidator = ( + origin: string, +) => boolean | string | Promise; +/** CORS policy accepted by asynchronous middleware and preflight APIs. */ export interface CORSConfig { origin?: string | string[] | OriginValidator; credentials?: boolean; @@ -9,6 +13,11 @@ export interface CORSConfig { maxAge?: number; } +/** CORS policy accepted by synchronous response-building APIs. */ +export interface SyncCORSConfig extends Omit { + origin?: string | string[] | SyncOriginValidator; +} + export interface CORSValidationResult { allowedOrigin: string | null; allowCredentials: boolean; @@ -28,3 +37,11 @@ export interface CORSHeaderOptions { headers?: Headers; config?: boolean | CORSConfig; } + +/** Header options accepted by synchronous CORS response helpers. */ +export interface SyncCORSHeaderOptions { + request: Request; + response?: Response; + headers?: Headers; + config?: boolean | SyncCORSConfig; +} diff --git a/src/security/http/cors/validators.test.ts b/src/security/http/cors/validators.test.ts index a97ce25da9..66eea77a3d 100644 --- a/src/security/http/cors/validators.test.ts +++ b/src/security/http/cors/validators.test.ts @@ -1,7 +1,19 @@ import "#veryfront/schemas/_test-setup.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; -import { validateCORSConfig, validateOrigin, validateOriginSync } from "./validators.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { cors } from "./middleware.ts"; +import { + corsOriginForTelemetry, + validateCORSConfig, + validateOrigin, + validateOriginSync, +} from "./validators.ts"; +import { + MAX_CORS_ORIGIN_COUNT, + MAX_CORS_ORIGIN_LENGTH, + MAX_CORS_TOKEN_COUNT, + MAX_CORS_TOKEN_LENGTH, +} from "#veryfront/utils/cors-policy-limits.ts"; describe("validateOriginSync", () => { describe("no config", () => { @@ -16,6 +28,15 @@ describe("validateOriginSync", () => { assertEquals(result.allowedOrigin, null); assertEquals(result.allowCredentials, false); }); + + it("returns fresh immutable denial results", () => { + const first = validateOriginSync("https://example.com", undefined); + const second = validateOriginSync("https://example.com", undefined); + + assertEquals(first === second, false); + assertEquals(Object.isFrozen(first), true); + assertEquals(Object.isFrozen(second), true); + }); }); describe("config = true", () => { @@ -129,6 +150,28 @@ describe("validateOriginSync", () => { assertEquals(result.allowedOrigin, null); assertEquals(result.error, "Origin validation error"); }); + + it("does not invoke validators for unsafe request origins", () => { + let calls = 0; + const result = validateOriginSync("https://例.example", { + origin: () => { + calls++; + return true; + }, + }); + + assertEquals(calls, 0); + assertEquals(result.allowedOrigin, null); + assertEquals(result.error, "Invalid or oversized request origin"); + }); + + it("rejects malformed non-string request origins without throwing", () => { + for (const origin of [undefined, 42, {}, Symbol("origin")]) { + const result = validateOriginSync(origin as never, true); + assertEquals(result.allowedOrigin, null); + assertEquals(result.error, "Invalid or oversized request origin"); + } + }); }); }); @@ -179,6 +222,24 @@ describe("validateOrigin (async)", () => { assertEquals(result.allowedOrigin, null); assertEquals(result.error, "Origin validation error"); }); + + it("rejects unsafe callback-returned origins and wildcard credentials", async () => { + for ( + const [returnedOrigin, credentials] of [ + ["https://例.example", false], + [" https://example.com", false], + ["https://example.com\r\nX-Injected: yes", false], + ["*", true], + ] as const + ) { + const result = await validateOrigin("https://request.example", { + origin: () => returnedOrigin, + credentials, + }); + + assertEquals(result.allowedOrigin, null); + } + }); }); describe("validateCORSConfig", () => { @@ -243,7 +304,7 @@ describe("validateCORSConfig", () => { maxAge: -1, }); assertEquals(result.valid, false); - assertEquals(result.error, "maxAge must be a positive number"); + assertEquals(result.error, "maxAge must be a non-negative safe integer"); }); it("should accept zero maxAge", () => { @@ -261,4 +322,209 @@ describe("validateCORSConfig", () => { }); assertEquals(result.valid, true); }); + + it("should reject malformed method and header tokens", () => { + for ( + const config of [ + { origin: "https://example.com\r\nX-Injected: yes" }, + { methods: ["GET, POST"] }, + { methods: ["GET\nInjected"] }, + { allowedHeaders: ["X Invalid"] }, + { exposedHeaders: ["X-Valid\r\nInjected"] }, + ] + ) { + assertEquals(validateCORSConfig(config).valid, false); + } + + assertEquals( + validateOriginSync("a".repeat(MAX_CORS_ORIGIN_LENGTH + 1), true).allowedOrigin, + null, + ); + }); + + it("should reject oversized CORS origins, lists, tokens, and aggregate header values", () => { + const tooManyTokens = Array.from( + { length: MAX_CORS_TOKEN_COUNT + 1 }, + (_, index) => `X-${index}`, + ); + const aggregateTokens = Array.from( + { length: 17 }, + (_, index) => `${"X".repeat(MAX_CORS_TOKEN_LENGTH - 3)}${String(index).padStart(3, "0")}`, + ); + const aggregateOrigins = Array.from( + { length: Math.min(MAX_CORS_ORIGIN_COUNT, 5) }, + (_, index) => `${index}${"a".repeat(MAX_CORS_ORIGIN_LENGTH - 1)}`, + ); + + for ( + const config of [ + { origin: "a".repeat(MAX_CORS_ORIGIN_LENGTH + 1) }, + { origin: aggregateOrigins }, + { methods: ["M".repeat(MAX_CORS_TOKEN_LENGTH + 1)] }, + { allowedHeaders: tooManyTokens }, + { exposedHeaders: aggregateTokens }, + ] + ) { + assertEquals(validateCORSConfig(config).valid, false); + } + }); + + it("should reject an oversized origin returned by a validator", async () => { + const result = await validateOrigin("https://request.example", { + origin: () => "a".repeat(MAX_CORS_ORIGIN_LENGTH + 1), + }); + + assertEquals(result.allowedOrigin, null); + assertEquals(result.error, "Origin validator returned an invalid or oversized origin"); + }); + + it("should reject non-integer, non-finite, and unsafe maxAge values", () => { + for (const maxAge of [1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1]) { + assertEquals( + validateCORSConfig({ origin: "https://example.com", maxAge }).valid, + false, + ); + } + }); + + it("should reject malformed runtime option shapes without throwing", () => { + for ( + const config of [ + null, + 1, + [], + { origin: "" }, + { origin: [] }, + { origin: ["https://example.com", 1] }, + { credentials: "true" }, + { methods: "GET" }, + { allowedHeaders: "Authorization" }, + { exposedHeaders: {} }, + { origin: "https://例.example" }, + { origin: " https://example.com" }, + { maxAge: "3600" }, + { unknown: true }, + ] + ) { + assertEquals(validateCORSConfig(config as never).valid, false); + } + }); + + it("rejects accessor-backed and inherited runtime options without invoking them", () => { + let getterCalls = 0; + const accessorConfig = Object.defineProperty({}, "origin", { + enumerable: true, + get() { + getterCalls++; + return "*"; + }, + }); + const inheritedConfig = Object.create({ origin: "*" }); + + assertEquals(validateCORSConfig(accessorConfig as never).valid, false); + assertEquals(validateCORSConfig(inheritedConfig as never).valid, false); + assertEquals(getterCalls, 0); + }); + + it("fails closed for revoked configuration and list proxies", () => { + const revokedConfig = Proxy.revocable({}, {}); + const revokedOrigins = Proxy.revocable(["https://example.com"], {}); + const revokedMethods = Proxy.revocable(["GET"], {}); + revokedConfig.revoke(); + revokedOrigins.revoke(); + revokedMethods.revoke(); + + for ( + const config of [ + revokedConfig.proxy, + { origin: revokedOrigins.proxy }, + { methods: revokedMethods.proxy }, + ] + ) { + assertEquals(validateCORSConfig(config as never).valid, false); + } + }); + + it("uses a fixed bounded error for every unknown option name", () => { + for ( + const key of [ + "unknown\r\nX-Injected: yes", + "non-byte-\u{100}", + "x".repeat(100_000), + ] + ) { + const result = validateCORSConfig({ [key]: true } as never); + assertEquals(result, { + valid: false, + error: "configuration contains unknown options", + }); + } + }); + + it("should reject malformed values through the public cors factory", () => { + assertThrows( + () => cors({ methods: ["GET, POST"] }), + Error, + "Invalid configuration", + ); + assertThrows( + () => cors({ allowedHeaders: ["X Invalid"] }), + Error, + "Invalid configuration", + ); + assertThrows( + () => cors({ maxAge: Number.NaN }), + Error, + "Invalid configuration", + ); + }); +}); + +Deno.test("CORS telemetry origins are bounded before tracing", () => { + assertEquals(corsOriginForTelemetry("https://example.com"), "https://example.com"); + assertEquals(corsOriginForTelemetry(null), "null"); + assertEquals(corsOriginForTelemetry("https://例.example"), "invalid"); + assertEquals( + corsOriginForTelemetry("a".repeat(MAX_CORS_ORIGIN_LENGTH + 1)), + "invalid", + ); + assertEquals(corsOriginForTelemetry({}), "invalid"); +}); + +Deno.test("sync validation observes rejected Promises and thenables", async () => { + const validatorsUrl = new URL("./validators.ts", import.meta.url).href; + const source = ` + import { validateOriginSync } from ${JSON.stringify(validatorsUrl)}; + + const validators = [ + async () => { + throw new Error("expected Promise rejection"); + }, + () => ({ + then(_resolve, reject) { + queueMicrotask(() => reject(new Error("expected thenable rejection"))); + }, + }), + ]; + for (const origin of validators) { + const result = validateOriginSync("https://example.com", { + origin, + }); + if (result.allowedOrigin !== null) { + throw new Error("async validator was not denied"); + } + } + await new Promise((resolve) => setTimeout(resolve, 0)); + `; + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--frozen", "--config=deno.json", source], + stdout: "piped", + stderr: "piped", + }).output(); + + assertEquals( + output.success, + true, + new TextDecoder().decode(output.stderr), + ); }); diff --git a/src/security/http/cors/validators.ts b/src/security/http/cors/validators.ts index ccc1b9e546..e21856c480 100644 --- a/src/security/http/cors/validators.ts +++ b/src/security/http/cors/validators.ts @@ -1,61 +1,279 @@ -import type { CORSConfig, CORSValidationResult } from "./types.ts"; +import type { CORSConfig, CORSValidationResult, SyncCORSConfig } from "./types.ts"; import { serverLogger } from "#veryfront/utils"; import { recordCorsRejection } from "#veryfront/observability"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; +import { + isBoundedCorsOrigin, + isBoundedCorsOriginList, + isBoundedCorsTokenList, + isValidCorsMaxAge, + MAX_CORS_ORIGIN_COUNT, + MAX_CORS_TOKEN_COUNT, +} from "#veryfront/utils/cors-policy-limits.ts"; const logger = serverLogger.component("cors"); -const NO_CORS_RESULT: CORSValidationResult = { allowedOrigin: null, allowCredentials: false }; +const CORS_CONFIG_KEYS = new Set([ + "origin", + "credentials", + "methods", + "allowedHeaders", + "exposedHeaders", + "maxAge", +]); + +export type NormalizedCORSConfig = boolean | CORSConfig; + +export type CORSConfigNormalizationResult = + | { valid: true; config: NormalizedCORSConfig } + | { valid: false; error: string }; + +function corsResult( + allowedOrigin: string | null, + allowCredentials: boolean, + error?: string, +): CORSValidationResult { + const result: CORSValidationResult = { allowedOrigin, allowCredentials }; + if (error !== undefined) result.error = error; + return Object.freeze(result); +} + +function denyCors(error?: string): CORSValidationResult { + return corsResult(null, false, error); +} + +function invalidConfig(error: string): CORSConfigNormalizationResult { + return { valid: false, error }; +} + +/** + * Snapshot a short array without invoking iterators, indexed accessors, or + * proxy `get` traps. Every proxy-sensitive reflection operation is guarded so + * revoked and otherwise hostile proxies become a deterministic invalid value. + */ +export function snapshotCORSArray( + value: unknown, + maxLength: number, +): unknown[] | null { + let array: boolean; + try { + array = Array.isArray(value); + } catch { + return null; + } + if (!array) return null; + + try { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + const length = lengthDescriptor && "value" in lengthDescriptor + ? lengthDescriptor.value + : undefined; + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > maxLength + ) { + return null; + } + + const snapshot = new Array(length); + for (let index = 0; index < length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !("value" in descriptor)) return null; + snapshot[index] = descriptor.value; + } + return snapshot; + } catch { + return null; + } +} + +function snapshotTokenList( + name: "methods" | "allowedHeaders" | "exposedHeaders", + value: unknown, +): { valid: true; value: string[] | undefined } | { valid: false; error: string } { + if (value === undefined) return { valid: true, value: undefined }; + + const snapshot = snapshotCORSArray(value, MAX_CORS_TOKEN_COUNT); + if (!snapshot) return { valid: false, error: `${name} must be an array` }; + if (snapshot.length === 0) { + return { valid: false, error: `${name} array cannot be empty` }; + } + if (!isBoundedCorsTokenList(snapshot)) { + const description = name === "methods" ? "HTTP method tokens" : "HTTP header names"; + return { + valid: false, + error: `${name} must contain bounded valid ${description}`, + }; + } + + const normalized = snapshot as string[]; + Object.freeze(normalized); + return { valid: true, value: normalized }; +} + +/** + * Snapshot and validate an untrusted runtime CORS policy. + * + * The schema protects configuration loaded through the normal config path, but + * public response helpers can be called with arbitrary JavaScript values. This + * is the single runtime contract used by those boundaries. + */ +export function normalizeCORSConfig(config: unknown): CORSConfigNormalizationResult { + if (config === undefined || config === false) { + return { valid: true, config: false }; + } + if (config === true) { + return { valid: true, config: true }; + } + if (typeof config !== "object" || config === null) { + return invalidConfig("configuration must be a boolean or CORS options object"); + } + + let configIsArray: boolean; + let keys: (string | symbol)[]; + const values: Partial> = {}; + try { + configIsArray = Array.isArray(config); + if (configIsArray) { + return invalidConfig("configuration must be a boolean or CORS options object"); + } + const prototype = Object.getPrototypeOf(config); + if (prototype !== Object.prototype && prototype !== null) { + return invalidConfig("configuration must be a plain options object"); + } + keys = Reflect.ownKeys(config); + + for (const key of keys) { + if (typeof key !== "string" || !CORS_CONFIG_KEYS.has(key)) { + return invalidConfig("configuration contains unknown options"); + } + const descriptor = Object.getOwnPropertyDescriptor(config, key); + if (!descriptor || !("value" in descriptor)) { + return invalidConfig("configuration options must be own data properties"); + } + values[key as keyof CORSConfig] = descriptor.value; + } + } catch { + return invalidConfig("configuration could not be inspected safely"); + } + + const valueOf = (key: keyof CORSConfig): unknown => values[key]; + const normalized: CORSConfig = {}; + + const origin = valueOf("origin"); + if (origin !== undefined) { + if (typeof origin === "string") { + if (!isBoundedCorsOrigin(origin)) { + return invalidConfig( + "origin must be a bounded header-safe string, string array, or validator", + ); + } + normalized.origin = origin; + } else if (typeof origin === "function") { + normalized.origin = origin as NonNullable; + } else { + const snapshot = snapshotCORSArray(origin, MAX_CORS_ORIGIN_COUNT); + if (!snapshot || !isBoundedCorsOriginList(snapshot)) { + return invalidConfig( + "origin must be a bounded header-safe string, string array, or validator", + ); + } + const origins = snapshot as string[]; + Object.freeze(origins); + normalized.origin = origins; + } + } + + const credentials = valueOf("credentials"); + if (credentials !== undefined) { + if (typeof credentials !== "boolean") { + return invalidConfig("credentials must be a boolean"); + } + normalized.credentials = credentials; + } + + for (const name of ["methods", "allowedHeaders", "exposedHeaders"] as const) { + const result = snapshotTokenList(name, valueOf(name)); + if (!result.valid) return result; + if (result.value !== undefined) normalized[name] = result.value; + } + + const maxAge = valueOf("maxAge"); + if (maxAge !== undefined) { + if (!isValidCorsMaxAge(maxAge)) { + return invalidConfig("maxAge must be a non-negative safe integer"); + } + normalized.maxAge = maxAge; + } + + if (normalized.origin === "*" && normalized.credentials === true) { + return invalidConfig("Cannot use credentials with wildcard origin"); + } + + Object.freeze(normalized); + return { valid: true, config: normalized }; +} -/** Early validation checks common to sync and async paths */ function validateEarly( - requestOrigin: string | null, - config?: boolean | CORSConfig, + requestOrigin: unknown, + config: NormalizedCORSConfig, ): CORSValidationResult | null { - if (!config) return NO_CORS_RESULT; + if (config === false) return denyCors(); + + if (requestOrigin !== null && !isBoundedCorsOrigin(requestOrigin)) { + return denyCors("Invalid or oversized request origin"); + } if (config === true) { - return { allowedOrigin: requestOrigin ?? "*", allowCredentials: false }; + return corsResult(requestOrigin ?? "*", false); } - if (!config.origin) return NO_CORS_RESULT; + if (config.origin === undefined) return denyCors(); - if (!requestOrigin) { - return config.origin === "*" ? { allowedOrigin: "*", allowCredentials: false } : NO_CORS_RESULT; + if (requestOrigin === null) { + return config.origin === "*" ? corsResult("*", false) : denyCors(); } if (config.origin !== "*") return null; if (config.credentials) { logger.warn("Cannot use credentials with wildcard origin - denying"); - return { - allowedOrigin: null, - allowCredentials: false, - error: "Cannot use credentials with wildcard origin", - }; + return denyCors("Cannot use credentials with wildcard origin"); } - return { allowedOrigin: "*", allowCredentials: false }; + return corsResult("*", false); } -function validateStaticOrigin(requestOrigin: string, corsConfig: CORSConfig): CORSValidationResult { +function validateStaticOrigin( + requestOrigin: string, + corsConfig: CORSConfig, +): CORSValidationResult { const credentials = corsConfig.credentials ?? false; const { origin } = corsConfig; - if (Array.isArray(origin)) { - const allowed = origin.includes(requestOrigin); + let originIsArray = false; + try { + originIsArray = Array.isArray(origin); + } catch { + return denyCors("Invalid origin configuration"); + } + + if (originIsArray) { + const origins = origin as string[]; + const allowed = origins.includes(requestOrigin); if (!allowed) { recordCorsRejection(); - // Log at debug level - this is expected in dev when CORS config doesn't match request origin logger.debug("Origin not in allowlist", { requestOrigin }); } - return { - allowedOrigin: allowed ? requestOrigin : null, - allowCredentials: allowed && credentials, - error: allowed ? undefined : "Origin not in allowlist", - }; + return corsResult( + allowed ? requestOrigin : null, + allowed && credentials, + allowed ? undefined : "Origin not in allowlist", + ); } if (typeof origin === "string") { @@ -63,73 +281,79 @@ function validateStaticOrigin(requestOrigin: string, corsConfig: CORSConfig): CO if (!allowed) { recordCorsRejection(); - // Log at debug level - this is expected in dev when CORS config doesn't match request origin logger.debug("Origin does not match", { requestOrigin, expectedOrigin: origin }); } - return { - allowedOrigin: allowed ? requestOrigin : null, - allowCredentials: allowed && credentials, - error: allowed ? undefined : "Origin does not match", - }; + return corsResult( + allowed ? requestOrigin : null, + allowed && credentials, + allowed ? undefined : "Origin does not match", + ); } - return { allowedOrigin: null, allowCredentials: false, error: "Invalid origin configuration" }; + return denyCors("Invalid origin configuration"); } function processFunctionResult( - result: string | boolean, + result: unknown, requestOrigin: string, credentials: boolean, ): CORSValidationResult { if (typeof result === "string") { - return { allowedOrigin: result, allowCredentials: credentials }; + if (!isBoundedCorsOrigin(result) || (result === "*" && credentials)) { + return denyCors("Origin validator returned an invalid or oversized origin"); + } + return corsResult(result, credentials && result !== "*"); + } + + if (typeof result !== "boolean") { + return denyCors("Origin validator returned an invalid result"); } - const allowed = result === true; + return corsResult( + result ? requestOrigin : null, + result && credentials, + result ? undefined : "Origin rejected by validation function", + ); +} - return { - allowedOrigin: allowed ? requestOrigin : null, - allowCredentials: allowed && credentials, - error: allowed ? undefined : "Origin rejected by validation function", - }; +function isPromiseLike(value: unknown): value is PromiseLike { + if ( + value === null || + (typeof value !== "object" && typeof value !== "function") + ) { + return false; + } + return typeof Reflect.get(value, "then") === "function"; } -/** Validate origin against CORS configuration */ -export function validateOrigin( - requestOrigin: string | null, - config?: boolean | CORSConfig, +export async function validateNormalizedOrigin( + requestOrigin: unknown, + config: NormalizedCORSConfig, ): Promise { - return withSpan( - "security.cors.validateOrigin", - async (): Promise => { - const earlyResult = validateEarly(requestOrigin, config); - if (earlyResult) return earlyResult; - - const corsConfig = config as CORSConfig; - const origin = requestOrigin as string; - const credentials = corsConfig.credentials ?? false; - - if (typeof corsConfig.origin === "function") { - try { - const result = await corsConfig.origin(origin); - return processFunctionResult(result, origin, credentials); - } catch (error) { - logger.error("Origin validation function error", error); - return { allowedOrigin: null, allowCredentials: false, error: "Origin validation error" }; - } - } + const earlyResult = validateEarly(requestOrigin, config); + if (earlyResult) return earlyResult; - return validateStaticOrigin(origin, corsConfig); - }, - { "cors.origin": requestOrigin ?? "null" }, - ); + const corsConfig = config as CORSConfig; + const origin = requestOrigin as string; + const credentials = corsConfig.credentials ?? false; + + if (typeof corsConfig.origin === "function") { + try { + const result = await corsConfig.origin(origin); + return processFunctionResult(result, origin, credentials); + } catch (error) { + logger.error("Origin validation function error", error); + return denyCors("Origin validation error"); + } + } + + return validateStaticOrigin(origin, corsConfig); } -/** Synchronous origin validation (async validators not supported) */ -export function validateOriginSync( - requestOrigin: string | null, - config?: boolean | CORSConfig, +export function validateNormalizedOriginSync( + requestOrigin: unknown, + config: NormalizedCORSConfig, ): CORSValidationResult { const earlyResult = validateEarly(requestOrigin, config); if (earlyResult) return earlyResult; @@ -144,48 +368,62 @@ export function validateOriginSync( try { const result = corsConfig.origin(origin); - - if (result instanceof Promise) { + if (isPromiseLike(result)) { + // Synchronous APIs cannot use the value, but they still own the + // validator invocation. Observe a future rejection immediately so the + // fail-closed return cannot be followed by an unhandled rejection. + void Promise.resolve(result).catch(() => undefined); logger.warn("Async origin validators are not supported in synchronous contexts"); - return { - allowedOrigin: null, - allowCredentials: false, - error: "Async origin validators not supported", - }; + return denyCors("Async origin validators not supported"); } - return processFunctionResult(result, origin, credentials); } catch (error) { logger.error("Origin validation function error", error); - return { allowedOrigin: null, allowCredentials: false, error: "Origin validation error" }; + return denyCors("Origin validation error"); } } -/** Validate CORS configuration for security issues */ -export function validateCORSConfig( - config?: boolean | CORSConfig, -): { valid: boolean; error?: string } { - if (!config || config === true) return { valid: true }; - - if (config.origin === "*" && config.credentials) { - return { valid: false, error: "Cannot use credentials with wildcard origin (*)" }; - } - - if (config.methods?.length === 0) { - return { valid: false, error: "methods array cannot be empty" }; - } - - if (config.allowedHeaders?.length === 0) { - return { valid: false, error: "allowedHeaders array cannot be empty" }; - } +export function corsOriginForTelemetry(requestOrigin: unknown): string { + if (requestOrigin === null) return "null"; + return isBoundedCorsOrigin(requestOrigin) ? requestOrigin : "invalid"; +} - if (config.exposedHeaders?.length === 0) { - return { valid: false, error: "exposedHeaders array cannot be empty" }; - } +/** Validate origin against CORS configuration. */ +export function validateOrigin( + requestOrigin: unknown, + config?: boolean | CORSConfig, +): Promise { + return withSpan( + "security.cors.validateOrigin", + async (): Promise => { + const normalized = normalizeCORSConfig(config); + if (!normalized.valid) return denyCors(normalized.error); + return await validateNormalizedOrigin(requestOrigin, normalized.config); + }, + { "cors.origin": corsOriginForTelemetry(requestOrigin) }, + ); +} - if (config.maxAge !== undefined && config.maxAge < 0) { - return { valid: false, error: "maxAge must be a positive number" }; - } +/** Synchronous origin validation. Promise-returning values still fail closed at runtime. */ +export function validateOriginSync( + requestOrigin: unknown, + config?: boolean | SyncCORSConfig, +): CORSValidationResult { + const normalized = normalizeCORSConfig(config); + if (!normalized.valid) return denyCors(normalized.error); + return validateNormalizedOriginSync(requestOrigin, normalized.config); +} - return { valid: true }; +/** Validate CORS configuration for security issues. */ +export function validateCORSConfig( + config?: boolean | CORSConfig, +): { valid: boolean; error?: string } { + const normalized = normalizeCORSConfig(config); + if (normalized.valid) return { valid: true }; + return { + valid: false, + error: normalized.error === "Cannot use credentials with wildcard origin" + ? "Cannot use credentials with wildcard origin (*)" + : normalized.error, + }; } diff --git a/src/security/http/csrf/csrf-handler.test.ts b/src/security/http/csrf/csrf-handler.test.ts index 91cb9ce04c..cf37d1fd7c 100644 --- a/src/security/http/csrf/csrf-handler.test.ts +++ b/src/security/http/csrf/csrf-handler.test.ts @@ -80,11 +80,12 @@ describe("security/http/csrf/csrf-handler", () => { assertEquals(result.continue, true); }); - it("should exempt /_veryfront/log", async () => { + it("should protect /_veryfront/log", async () => { const ctx = createCtx(true); const req = new Request("http://localhost/_veryfront/log", { method: "POST" }); const result = await handler.handle(req, ctx); - assertEquals(result.continue, true); + assertEquals(result.continue, false); + assertEquals(result.response?.status, 403); }); it("should NOT exempt /_veryfront/log/subpath", async () => { @@ -95,11 +96,12 @@ describe("security/http/csrf/csrf-handler", () => { assertEquals(result.response?.status, 403); }); - it("should exempt /_veryfront/modules/ asset paths", async () => { + it("should protect unsafe methods even on internal asset paths", async () => { const ctx = createCtx(true); const req = new Request("http://localhost/_veryfront/modules/client.js", { method: "POST" }); const result = await handler.handle(req, ctx); - assertEquals(result.continue, true); + assertEquals(result.continue, false); + assertEquals(result.response?.status, 403); }); it("should NOT exempt /_veryfront/rsc/action (Server Actions need CSRF)", async () => { @@ -118,6 +120,32 @@ describe("security/http/csrf/csrf-handler", () => { assertEquals(result.response?.status, 403); }); + it("should apply the resolved CORS and security policy to rejections", async () => { + const ctx = createCtx(true); + ctx.securityConfig = { + csrf: true, + cors: { + origin: "https://client.example", + credentials: true, + }, + }; + const req = new Request("http://localhost/submit", { + method: "POST", + headers: { origin: "https://client.example" }, + }); + + const result = await handler.handle(req, ctx); + + assertEquals(result.response?.status, 403); + assertEquals( + result.response?.headers.get("Access-Control-Allow-Origin"), + "https://client.example", + ); + assertEquals(result.response?.headers.get("Access-Control-Allow-Credentials"), "true"); + assertEquals(result.response?.headers.get("X-Content-Type-Options"), "nosniff"); + assertEquals(result.response?.headers.get("Cache-Control"), "no-store"); + }); + it("should reject PUT without CSRF token", async () => { const ctx = createCtx(true); const req = new Request("http://localhost/resource", { method: "PUT" }); @@ -139,6 +167,14 @@ describe("security/http/csrf/csrf-handler", () => { assertEquals(result.response?.status, 403); }); + it("should reject custom methods without CSRF token", async () => { + const ctx = createCtx(true); + const req = new Request("http://localhost/cache", { method: "PURGE" }); + const result = await handler.handle(req, ctx); + assertEquals(result.continue, false); + assertEquals(result.response?.status, 403); + }); + it("should pass POST with valid CSRF token", async () => { const ctx = createCtx(true); const { token } = generateCsrfToken({ secure: false }); diff --git a/src/security/http/csrf/csrf-handler.ts b/src/security/http/csrf/csrf-handler.ts index b3101213e7..0719c82aa1 100644 --- a/src/security/http/csrf/csrf-handler.ts +++ b/src/security/http/csrf/csrf-handler.ts @@ -1,8 +1,9 @@ /** * CSRF Handler — validates CSRF tokens on state-changing requests. * - * Reads config from `ctx.securityConfig?.csrf`. When enabled, POST/PUT/PATCH/DELETE - * requests must include a valid CSRF token (cookie + header match). + * Reads config from `ctx.securityConfig?.csrf`. When enabled, every method + * except GET, HEAD, and OPTIONS must include a valid CSRF token (cookie + + * header match). * * ## Server Actions integration * @@ -49,27 +50,7 @@ import type { HandlerResult, } from "#veryfront/types"; -const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); - -/** Internal /_veryfront/ directory prefixes that only ever serve static assets. */ -const CSRF_EXEMPT_PREFIXES = [ - "/_veryfront/modules/", - "/_veryfront/lib/", - "/_veryfront/chunks/", -]; - -/** - * Exact internal asset paths safe to exempt from CSRF. These are GET-only - * static JS handlers (`exact: true` patterns). They were previously exempted by - * the bare `/_veryfront/preview-hmr` / `/_veryfront/studio-bridge` prefixes, - * which also matched any sibling path (e.g. `.../studio-bridge/submit`) and - * would have left a future state-changing endpoint under that prefix - * CSRF-unprotected. Matching the exact `.js` paths keeps the exemption tight. - */ -const CSRF_EXEMPT_EXACT_PATHS = new Set([ - "/_veryfront/preview-hmr.js", - "/_veryfront/studio-bridge.js", -]); +const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); export class CsrfHandler extends BaseHandler { metadata: HandlerMetadata = { @@ -86,20 +67,12 @@ export class CsrfHandler extends BaseHandler { const method = req.method.toUpperCase(); - // Safe methods never need CSRF - if (!STATE_CHANGING_METHODS.has(method)) return this.continue(); + // Unknown and extension methods fail closed. Only the explicitly safe HTTP + // methods bypass token validation. + if (CSRF_SAFE_METHODS.has(method)) return this.continue(); const { pathname } = new URL(req.url); - // Only exempt internal asset/dev paths, NOT action endpoints - if (CSRF_EXEMPT_EXACT_PATHS.has(pathname)) return this.continue(); - if (CSRF_EXEMPT_PREFIXES.some((p) => pathname.startsWith(p))) { - return this.continue(); - } - - // Internal log endpoint is safe to exempt (fire-and-forget client telemetry) - if (pathname === "/_veryfront/log") return this.continue(); - // Check exclude paths if (typeof csrfConfig === "object" && csrfConfig.excludePaths?.length) { for (const excludePath of csrfConfig.excludePaths) { @@ -115,7 +88,11 @@ export class CsrfHandler extends BaseHandler { if (!validateCsrf(req, options)) { return this.respond( - new Response("Forbidden – invalid or missing CSRF token", { status: 403 }), + this.createResponseBuilder(ctx) + .withCORS(req, ctx.securityConfig?.cors) + .withSecurity(ctx.securityConfig ?? undefined, req) + .withCache("no-store") + .text("Forbidden – invalid or missing CSRF token", 403), ); } diff --git a/src/security/http/index.ts b/src/security/http/index.ts index c96a843b78..44923d4a08 100644 --- a/src/security/http/index.ts +++ b/src/security/http/index.ts @@ -19,5 +19,4 @@ export type { CSPDirectives, SecurityConfig, } from "./middleware/index.ts"; -export { isValidSecurityConfig, loadSecurityConfig } from "./middleware/index.ts"; export { setCors } from "./middleware/index.ts"; diff --git a/src/security/http/middleware/config-loader.test.ts b/src/security/http/middleware/config-loader.test.ts deleted file mode 100644 index 91f172909f..0000000000 --- a/src/security/http/middleware/config-loader.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; -import { isValidSecurityConfig } from "./config-loader.ts"; - -describe("security/http/middleware/config-loader", () => { - describe("isValidSecurityConfig", () => { - it("should return false for null/undefined", () => { - assertEquals(isValidSecurityConfig(null), false); - assertEquals(isValidSecurityConfig(undefined), false); - }); - - it("should return false for a non-object", () => { - assertEquals(isValidSecurityConfig("string"), false); - assertEquals(isValidSecurityConfig(123), false); - assertEquals(isValidSecurityConfig(true), false); - }); - - it("should return true for an empty object", () => { - assertEquals(isValidSecurityConfig({}), true); - }); - - it("should validate csp", () => { - assertEquals( - isValidSecurityConfig({ csp: { "default-src": "'self'" } }), - true, - ); - assertEquals(isValidSecurityConfig({ csp: "invalid" }), false); - assertEquals(isValidSecurityConfig({ csp: null }), false); - }); - - it("should validate cors", () => { - assertEquals(isValidSecurityConfig({ cors: true }), true); - assertEquals(isValidSecurityConfig({ cors: false }), true); - assertEquals(isValidSecurityConfig({ cors: { origin: "*" } }), true); - - assertEquals(isValidSecurityConfig({ cors: "invalid" }), false); - assertEquals(isValidSecurityConfig({ cors: 123 }), false); - assertEquals(isValidSecurityConfig({ cors: null }), false); - }); - - it("should validate coop", () => { - assertEquals(isValidSecurityConfig({ coop: "same-origin" }), true); - assertEquals(isValidSecurityConfig({ coop: 123 }), false); - }); - - it("should validate corp", () => { - assertEquals(isValidSecurityConfig({ corp: "same-origin" }), true); - assertEquals(isValidSecurityConfig({ corp: true }), false); - }); - - it("should validate coep", () => { - assertEquals(isValidSecurityConfig({ coep: "require-corp" }), true); - assertEquals(isValidSecurityConfig({ coep: [] }), false); - }); - - it("should return true for a full valid config", () => { - assertEquals( - isValidSecurityConfig({ - cors: true, - csp: { "default-src": "'self'" }, - coop: "same-origin", - corp: "same-origin", - coep: "require-corp", - }), - true, - ); - }); - }); -}); diff --git a/src/security/http/middleware/config-loader.ts b/src/security/http/middleware/config-loader.ts deleted file mode 100644 index a246f9a5b6..0000000000 --- a/src/security/http/middleware/config-loader.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; -import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { serverLogger } from "#veryfront/utils"; -import type { SecurityConfig } from "./types.ts"; - -export function isValidSecurityConfig(config: unknown): config is SecurityConfig { - if (config == null || typeof config !== "object") return false; - - const cfg = config as Record; - - if (cfg.csp !== undefined && (cfg.csp == null || typeof cfg.csp !== "object")) return false; - - const cors = cfg.cors; - if ( - cors !== undefined && typeof cors !== "boolean" && (cors == null || typeof cors !== "object") - ) { - return false; - } - - const csrf = cfg.csrf; - if ( - csrf !== undefined && typeof csrf !== "boolean" && (csrf == null || typeof csrf !== "object") - ) { - return false; - } - - if (cfg.coop !== undefined && typeof cfg.coop !== "string") return false; - if (cfg.corp !== undefined && typeof cfg.corp !== "string") return false; - if (cfg.coep !== undefined && typeof cfg.coep !== "string") return false; - - return true; -} - -export function loadSecurityConfig( - projectDir: string, - adapter: RuntimeAdapter, -): Promise { - return withSpan( - "security.config.load", - async (): Promise => { - try { - const { getConfig } = await import("#veryfront/config"); - const cfg = await getConfig(projectDir, adapter); - const securityConfig = (cfg as Record)?.security; - - if (!securityConfig) return null; - - if (!isValidSecurityConfig(securityConfig)) { - serverLogger.warn("Invalid security configuration structure, ignoring"); - return null; - } - - return securityConfig; - } catch (error) { - serverLogger.debug("Failed to load security config", { error }); - return null; - } - }, - { "security.projectDir": projectDir }, - ); -} diff --git a/src/security/http/middleware/index.ts b/src/security/http/middleware/index.ts index 02df702d1a..81062053ce 100644 --- a/src/security/http/middleware/index.ts +++ b/src/security/http/middleware/index.ts @@ -12,5 +12,4 @@ export type { CSPDirectives, SecurityConfig, } from "./types.ts"; -export { isValidSecurityConfig, loadSecurityConfig } from "./config-loader.ts"; export { setCors } from "./cors-handler.ts"; diff --git a/src/security/http/response/cache-handler.test.ts b/src/security/http/response/cache-handler.test.ts index e6ddc068f2..c34a453382 100644 --- a/src/security/http/response/cache-handler.test.ts +++ b/src/security/http/response/cache-handler.test.ts @@ -1,6 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { buildCacheControl } from "./cache-handler.ts"; import { CACHE_DURATIONS } from "./constants.ts"; @@ -53,9 +53,8 @@ describe("security/http/response/cache-handler", () => { ); }); - it("should fallback for unknown string preset", () => { - // TypeScript wouldn't normally allow this but testing runtime behavior - assertEquals(buildCacheControl("unknown" as never), "public, max-age=0"); + it("rejects unknown string presets", () => { + assertThrows(() => buildCacheControl("unknown" as never), TypeError); }); }); @@ -105,6 +104,34 @@ describe("security/http/response/cache-handler", () => { assert(result.includes("immutable")); assert(result.includes("must-revalidate")); }); + + it("rejects malformed and accessor-backed cache options", () => { + for ( + const strategy of [ + { maxAge: -1 }, + { maxAge: 1.5 }, + { maxAge: Number.POSITIVE_INFINITY }, + { maxAge: 60, staleWhileRevalidate: -1 }, + { maxAge: 60, public: "yes" }, + { maxAge: 60, unknown: true }, + Object.create({ maxAge: 60 }), + ] + ) { + assertThrows(() => buildCacheControl(strategy as never), TypeError); + } + + let getterCalls = 0; + const strategy = {} as Record; + Object.defineProperty(strategy, "maxAge", { + enumerable: true, + get() { + getterCalls++; + return 60; + }, + }); + assertThrows(() => buildCacheControl(strategy as never), TypeError); + assertEquals(getterCalls, 0); + }); }); }); }); diff --git a/src/security/http/response/cache-handler.ts b/src/security/http/response/cache-handler.ts index 0bfcdbed9a..04bbdbf55c 100644 --- a/src/security/http/response/cache-handler.ts +++ b/src/security/http/response/cache-handler.ts @@ -1,37 +1,111 @@ import { CACHE_DURATIONS } from "./constants.ts"; import type { CacheStrategy } from "./types.ts"; -const CACHE_PRESETS: Record = { - "no-cache": "no-cache, no-store, must-revalidate", - "no-store": "no-store", - short: `public, max-age=${CACHE_DURATIONS.SHORT}`, - medium: `public, max-age=${CACHE_DURATIONS.MEDIUM}`, - long: `public, max-age=${CACHE_DURATIONS.LONG}`, - immutable: `public, max-age=${CACHE_DURATIONS.LONG}, immutable`, - // "none" prevents all caching - used in development to avoid nonce mismatches - none: "no-cache, no-store, must-revalidate", -}; +const CACHE_PRESETS = Object.freeze( + { + "no-cache": "no-cache, no-store, must-revalidate", + "no-store": "no-store", + short: `public, max-age=${CACHE_DURATIONS.SHORT}`, + medium: `public, max-age=${CACHE_DURATIONS.MEDIUM}`, + long: `public, max-age=${CACHE_DURATIONS.LONG}`, + immutable: `public, max-age=${CACHE_DURATIONS.LONG}, immutable`, + // "none" prevents all caching - used in development to avoid nonce mismatches + none: "no-cache, no-store, must-revalidate", + } satisfies Record, string>, +); + +const CACHE_OPTION_KEYS = new Set([ + "maxAge", + "public", + "immutable", + "mustRevalidate", + "staleWhileRevalidate", +]); + +interface NormalizedCacheOptions { + maxAge: number; + public?: boolean; + immutable?: boolean; + mustRevalidate?: boolean; + staleWhileRevalidate?: number; +} + +function invalidCacheStrategy(): never { + throw new TypeError("Invalid cache strategy"); +} + +function isDeltaSeconds(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function snapshotCacheOptions(strategy: unknown): NormalizedCacheOptions { + if (typeof strategy !== "object" || strategy === null) return invalidCacheStrategy(); + + const values: Record = Object.create(null); + try { + if (Array.isArray(strategy)) return invalidCacheStrategy(); + const prototype = Object.getPrototypeOf(strategy); + if (prototype !== Object.prototype && prototype !== null) return invalidCacheStrategy(); + + for (const key of Reflect.ownKeys(strategy)) { + if (typeof key !== "string" || !CACHE_OPTION_KEYS.has(key)) { + return invalidCacheStrategy(); + } + const descriptor = Object.getOwnPropertyDescriptor(strategy, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) { + return invalidCacheStrategy(); + } + values[key] = descriptor.value; + } + } catch { + return invalidCacheStrategy(); + } + + if (!isDeltaSeconds(values.maxAge)) return invalidCacheStrategy(); + for (const key of ["public", "immutable", "mustRevalidate"] as const) { + if (values[key] !== undefined && typeof values[key] !== "boolean") { + return invalidCacheStrategy(); + } + } + if ( + values.staleWhileRevalidate !== undefined && + !isDeltaSeconds(values.staleWhileRevalidate) + ) { + return invalidCacheStrategy(); + } + + return Object.freeze({ + maxAge: values.maxAge, + public: values.public as boolean | undefined, + immutable: values.immutable as boolean | undefined, + mustRevalidate: values.mustRevalidate as boolean | undefined, + staleWhileRevalidate: values.staleWhileRevalidate as number | undefined, + }); +} export function buildCacheControl(strategy: CacheStrategy): string { if (typeof strategy === "string") { - return CACHE_PRESETS[strategy] ?? "public, max-age=0"; + if (!Object.hasOwn(CACHE_PRESETS, strategy)) return invalidCacheStrategy(); + return CACHE_PRESETS[strategy as keyof typeof CACHE_PRESETS]; } + const options = snapshotCacheOptions(strategy); + const parts: string[] = [ - strategy.public !== false ? "public" : "private", - `max-age=${strategy.maxAge}`, + options.public !== false ? "public" : "private", + `max-age=${options.maxAge}`, ]; - if (strategy.immutable) { + if (options.immutable) { parts.push("immutable"); } - if (strategy.mustRevalidate) { + if (options.mustRevalidate) { parts.push("must-revalidate"); } - if (typeof strategy.staleWhileRevalidate === "number") { - parts.push(`stale-while-revalidate=${strategy.staleWhileRevalidate}`); + if (options.staleWhileRevalidate !== undefined) { + parts.push(`stale-while-revalidate=${options.staleWhileRevalidate}`); } return parts.join(", "); diff --git a/src/security/http/response/fluent-methods.ts b/src/security/http/response/fluent-methods.ts index f20761a992..dacc0b28f4 100644 --- a/src/security/http/response/fluent-methods.ts +++ b/src/security/http/response/fluent-methods.ts @@ -6,7 +6,7 @@ import { applyCORSHeaders, applyCORSHeadersSync } from "../cors/index.ts"; import { buildCacheControl } from "./cache-handler.ts"; import { applySecurityHeaders } from "./security-handler.ts"; import { applyCsrfCookie } from "../../csrf/helpers.ts"; -import type { CacheStrategy, CORSConfig, SecurityConfig } from "./types.ts"; +import type { CacheStrategy, SecurityConfig, SyncCORSConfig } from "./types.ts"; export interface FluentMethodsContext { headers: Headers; @@ -23,7 +23,7 @@ export interface FluentMethodsContext { export function withCORS( this: T, req: Request, - corsConfig?: boolean | CORSConfig, + corsConfig?: boolean | SyncCORSConfig, ): T { applyCORSHeadersSync({ request: req, diff --git a/src/security/http/response/security-handler.test.ts b/src/security/http/response/security-handler.test.ts index f6a53e3f6a..9688749d10 100644 --- a/src/security/http/response/security-handler.test.ts +++ b/src/security/http/response/security-handler.test.ts @@ -7,6 +7,7 @@ import { buildCSP, generateNonce, getSecurityHeader, + SECURITY_POLICY_RESPONSE_HEADER_NAMES, } from "./security-handler.ts"; import type { SecurityConfig } from "./types.ts"; @@ -279,60 +280,31 @@ describe("security/http/response/security-handler", () => { ); }); - it("default CSP should allow WebSocket connections for HMR", () => { - const connectSources = parseDirectiveSources(buildCSP(false, "nonce", null), "connect-src"); - assert(connectSources.includes("wss:"), "should allow wss for WebSocket"); - assert(connectSources.includes("https:"), "should allow https for fetch/XHR"); - }); - - it("default CSP should allow Google Fonts", () => { + it("default CSP admits no remote hosts or broad network schemes", () => { const csp = buildCSP(false, "nonce", null); - const styleHosts = parseDirectiveRemoteHosts(csp, "style-src"); - const fontHosts = parseDirectiveRemoteHosts(csp, "font-src"); - assertEquals( - styleHosts.filter((host) => host === "fonts.googleapis.com"), - ["fonts.googleapis.com"], - "should allow Google Fonts styles", - ); - assertEquals( - fontHosts.filter((host) => host === "fonts.gstatic.com"), - ["fonts.gstatic.com"], - "should allow Google Fonts files", - ); - }); - - it("default CSP should allow jsdelivr CDN scripts", () => { - const scriptHosts = parseDirectiveRemoteHosts(buildCSP(false, "nonce", null), "script-src"); - assertEquals( - scriptHosts.filter((host) => host === "cdn.jsdelivr.net"), - ["cdn.jsdelivr.net"], - "should allow jsdelivr for Scalar API docs, html2canvas, React UMD", - ); - }); - - it("default CSP should allow esm.sh scripts for browser ESM hydration", () => { - const scriptHosts = parseDirectiveRemoteHosts(buildCSP(false, "nonce", null), "script-src"); - assertEquals( - scriptHosts.filter((host) => host === "esm.sh"), - ["esm.sh"], - "should allow esm.sh for the pages-router/browser ESM hydration path", - ); - }); - - it("default CSP should allow veryfront CDN styles and fonts", () => { - const csp = buildCSP(false, "nonce", null); - const styleHosts = parseDirectiveRemoteHosts(csp, "style-src"); - const fontHosts = parseDirectiveRemoteHosts(csp, "font-src"); - assertEquals( - styleHosts.filter((host) => host === "cdn.veryfront.com"), - ["cdn.veryfront.com"], - "veryfront CDN in style-src", - ); - assertEquals( - fontHosts.filter((host) => host === "cdn.veryfront.com"), - ["cdn.veryfront.com"], - "veryfront CDN in font-src", - ); + for ( + const directive of [ + "default-src", + "script-src", + "style-src", + "style-src-elem", + "img-src", + "font-src", + "connect-src", + "media-src", + "worker-src", + "frame-src", + ] + ) { + assertEquals( + parseDirectiveRemoteHosts(csp, directive), + [], + `${directive} must not hardcode a remote host`, + ); + } + assertEquals(parseDirectiveSources(csp, "connect-src"), ["'self'"]); + assertEquals(parseDirectiveSources(csp, "img-src"), ["'self'", "data:"]); + assertEquals(parseDirectiveSources(csp, "font-src"), ["'self'", "data:"]); }); it("default CSP should allow same-origin frames", () => { @@ -363,7 +335,7 @@ describe("security/http/response/security-handler", () => { ); }); - it("default CSP should allow Video.js stylesheet, style elements, blob workers, and blob media", () => { + it("default CSP should allow inline style elements, blob workers, and blob media", () => { const csp = buildCSP(false, "my-nonce", null); const styleElemSources = parseDirectiveSources( csp, @@ -371,10 +343,6 @@ describe("security/http/response/security-handler", () => { ); const mediaSources = parseDirectiveSources(csp, "media-src"); const workerSources = parseDirectiveSources(csp, "worker-src"); - const remoteStyleElemHosts = parseDirectiveRemoteHosts( - csp, - "style-src-elem", - ); assert( styleElemSources.includes("'unsafe-inline'"), "style-src-elem should allow runtime-created style tags", @@ -383,11 +351,7 @@ describe("security/http/response/security-handler", () => { !styleElemSources.some((source) => source.startsWith("'nonce-")), "style-src-elem should not mix a nonce with unsafe-inline because browsers ignore unsafe-inline when nonce/hash sources are present", ); - assertEquals( - remoteStyleElemHosts, - ["cdn.veryfront.com", "fonts.googleapis.com", "vjs.zencdn.net"], - "style-src-elem should allow Google Fonts, Veryfront CDN, and the Video.js stylesheet CDN", - ); + assertEquals(styleElemSources, ["'self'", "'unsafe-inline'"]); assert( mediaSources.includes("blob:"), "media-src should allow blob media URLs generated by browser media pipelines", @@ -415,38 +379,6 @@ describe("security/http/response/security-handler", () => { ); }); - it("default CSP should place jsdelivr in script-src not style-src", () => { - const csp = buildCSP(false, "nonce", null); - const scriptHosts = parseDirectiveRemoteHosts(csp, "script-src"); - const styleHosts = parseDirectiveRemoteHosts(csp, "style-src"); - assertEquals( - scriptHosts.filter((host) => host === "cdn.jsdelivr.net"), - ["cdn.jsdelivr.net"], - "jsdelivr should be in script-src", - ); - assertEquals( - styleHosts.filter((host) => host === "cdn.jsdelivr.net"), - [], - "jsdelivr should NOT be in style-src", - ); - }); - - it("default CSP should place esm.sh in script-src not style-src", () => { - const csp = buildCSP(false, "nonce", null); - const scriptHosts = parseDirectiveRemoteHosts(csp, "script-src"); - const styleHosts = parseDirectiveRemoteHosts(csp, "style-src"); - assertEquals( - scriptHosts.filter((host) => host === "esm.sh"), - ["esm.sh"], - "esm.sh should be in script-src", - ); - assertEquals( - styleHosts.filter((host) => host === "esm.sh"), - [], - "esm.sh should NOT be in style-src", - ); - }); - it("default CSP should keep the nonce on script-src but not on style-src", () => { const csp = buildCSP(false, "unique-nonce-123", null); const scriptSources = parseDirectiveSources(csp, "script-src"); @@ -461,11 +393,6 @@ describe("security/http/response/security-handler", () => { ); }); - it("default CSP should block http: in connect-src", () => { - const connectSources = parseDirectiveSources(buildCSP(false, "nonce", null), "connect-src"); - assert(!connectSources.includes("http:"), "connect-src must not allow plain http"); - }); - it("default CSP should not include unsafe-eval", () => { const result = buildCSP(false, "n", null); assert(!result.includes("unsafe-eval"), "default CSP must not allow eval"); @@ -505,14 +432,25 @@ describe("security/http/response/security-handler", () => { }); describe("applySecurityHeaders", () => { + it("keeps the canonical policy-owned header list aligned with production output", () => { + const headers = applyHeaders({ + adapter: createMockAdapter({ VERYFRONT_COEP: "require-corp" }), + }); + + assertEquals( + [...headers.keys()].sort(), + [...SECURITY_POLICY_RESPONSE_HEADER_NAMES].sort(), + ); + }); + it("should set X-Content-Type-Options", () => { const headers = applyHeaders(); assertEquals(headers.get("X-Content-Type-Options"), "nosniff"); }); - it("should set X-XSS-Protection", () => { + it("should disable the obsolete XSS auditor", () => { const headers = applyHeaders(); - assertEquals(headers.get("X-XSS-Protection"), "1; mode=block"); + assertEquals(headers.get("X-XSS-Protection"), "0"); }); it("should set X-Frame-Options to DENY in production", () => { @@ -614,6 +552,30 @@ describe("security/http/response/security-handler", () => { assertEquals(headers.get("X-Custom-Header"), "custom-value"); }); + it("keeps Access-Control-* headers authoritative to the CORS policy layer", () => { + const config: SecurityConfig = { + headers: { + "X-Custom-Header": "custom-value", + "Access-Control-Allow-Origin": "*", + "aCcEsS-CoNtRoL-AlLoW-CrEdEnTiAlS": "true", + "Access-Control-Future-Policy": "unsafe", + }, + }; + const headers = new Headers({ + "Access-Control-Allow-Origin": "https://policy.example", + }); + + applySecurityHeaders(headers, false, "nonce", null, config); + + assertEquals(headers.get("X-Custom-Header"), "custom-value"); + assertEquals( + headers.get("Access-Control-Allow-Origin"), + "https://policy.example", + ); + assertEquals(headers.get("Access-Control-Allow-Credentials"), null); + assertEquals(headers.get("Access-Control-Future-Policy"), null); + }); + it("should allow overriding security headers via config.headers", () => { const config: SecurityConfig = { headers: { diff --git a/src/security/http/response/security-handler.ts b/src/security/http/response/security-handler.ts index f6ccf045d4..5d1aeeff2e 100644 --- a/src/security/http/response/security-handler.ts +++ b/src/security/http/response/security-handler.ts @@ -1,8 +1,40 @@ import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { recordSecurityHeaders } from "#veryfront/observability"; import { HOSTED_STUDIO_ORIGINS } from "#veryfront/security/http/studio-origin-policy.ts"; +import { isCorsPolicyResponseHeaderName } from "#veryfront/utils/cors-policy-limits.ts"; +import { serverLogger } from "#veryfront/utils/logger/logger.ts"; import type { SecurityConfig } from "./types.ts"; +const logger = serverLogger.component("security-headers"); +const warnedReservedCorsHeaderConfigs = new WeakSet(); + +/** + * Response headers whose values and omissions are owned by the centralized + * security policy. Server integrations must remove project-provided values + * before applying policy so development omissions remain authoritative. + */ +export const SECURITY_POLICY_RESPONSE_HEADER_NAMES = Object.freeze( + [ + "content-security-policy", + "cross-origin-embedder-policy", + "cross-origin-opener-policy", + "cross-origin-resource-policy", + "referrer-policy", + "strict-transport-security", + "x-content-type-options", + "x-frame-options", + "x-xss-protection", + ] as const, +); + +const SECURITY_POLICY_RESPONSE_HEADER_NAME_SET: ReadonlySet = new Set( + SECURITY_POLICY_RESPONSE_HEADER_NAMES, +); + +export function isSecurityPolicyResponseHeaderName(name: string): boolean { + return SECURITY_POLICY_RESPONSE_HEADER_NAME_SET.has(name.toLowerCase()); +} + /** HSTS max-age default: 1 year in seconds */ const HSTS_MAX_AGE_SECONDS = 31_536_000; @@ -31,30 +63,24 @@ export function generateNonce(): string { const VERYFRONT_FRAME_ANCESTORS = ["'self'", ...HOSTED_STUDIO_ORIGINS]; /** - * Build a default CSP that works for typical veryfront apps. + * Build the dependency-free core production CSP. * - * - Scripts: nonce-based + cdn.jsdelivr.net + esm.sh (Scalar API docs, - * html2canvas, legacy/browser ESM hydration) - * - Styles: - * - style-src: 'self' + 'unsafe-inline' + Google Fonts + cdn.veryfront.com - * + Video.js CDN so React style="" attributes, framework inline styles, - * and common media-player stylesheets remain compatible. Do not include a - * nonce in style directives here: browsers ignore 'unsafe-inline' when a - * nonce/hash is present, which breaks runtime-created style attributes and - * style elements. - * - style-src-elem: 'unsafe-inline' + Google Fonts + cdn.veryfront.com + - * Video.js CDN for runtime-created