bosia 0.4.3 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.4.3",
3
+ "version": "0.4.6",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
package/src/cli/add.ts CHANGED
@@ -27,9 +27,11 @@ interface ComponentMeta {
27
27
  npmDeps: Record<string, string>;
28
28
  }
29
29
 
30
- interface RegistryIndex {
30
+ export interface RegistryIndex {
31
31
  components: string[];
32
32
  features: string[];
33
+ blocks?: string[];
34
+ themes?: string[];
33
35
  }
34
36
 
35
37
  // Track already-installed components within a session to avoid re-running deps
@@ -193,7 +195,7 @@ export function cn(...inputs: ClassValue[]) {
193
195
  }
194
196
  `;
195
197
 
196
- function ensureUtils() {
198
+ export function ensureUtils() {
197
199
  const utilsPath = join(process.cwd(), "src", "lib", "utils.ts");
198
200
  if (!existsSync(utilsPath)) {
199
201
  mkdirSync(dirname(utilsPath), { recursive: true });
@@ -0,0 +1,94 @@
1
+ import { join, dirname } from "path";
2
+ import { mkdirSync, writeFileSync, existsSync } from "fs";
3
+ import * as p from "@clack/prompts";
4
+ import {
5
+ resolveLocalRegistryOrExit,
6
+ readRegistryJSON,
7
+ readRegistryFile,
8
+ bunAdd,
9
+ } from "./registry.ts";
10
+ import { addComponent, initAddRegistry, ensureUtils } from "./add.ts";
11
+ import { mergeFontImports } from "./fonts.ts";
12
+
13
+ // ─── bun x bosia@latest add block <category>/<name> ──────
14
+ // Installs a composed block into src/lib/blocks/<path>/.
15
+ // Recursively installs primitive component dependencies and
16
+ // optional Google Fonts @imports into app.css.
17
+
18
+ interface BlockMeta {
19
+ name: string;
20
+ description: string;
21
+ category: string;
22
+ themes?: string[];
23
+ dependencies: string[]; // primitive component names
24
+ files: string[];
25
+ fonts?: Record<string, string>; // family → @import URL
26
+ npmDeps: Record<string, string>;
27
+ }
28
+
29
+ export async function runAddBlock(name: string | undefined, flags: string[] = []) {
30
+ if (!name || !name.includes("/")) {
31
+ console.error(
32
+ "❌ Please provide a block path.\n Usage: bun x bosia@latest add block <category>/<name> [--local]",
33
+ );
34
+ process.exit(1);
35
+ }
36
+
37
+ const local = flags.includes("--local");
38
+ const registryRoot = local ? resolveLocalRegistryOrExit() : null;
39
+ if (local) console.log(`⬡ Using local registry: ${registryRoot}\n`);
40
+
41
+ await initAddRegistry(registryRoot);
42
+ ensureUtils();
43
+
44
+ console.log(`⬡ Installing block: ${name}\n`);
45
+
46
+ const meta = await readRegistryJSON<BlockMeta>(registryRoot, "blocks", name, "meta.json");
47
+
48
+ // 1. Install primitive dependencies first
49
+ for (const dep of meta.dependencies ?? []) {
50
+ await addComponent(dep, false);
51
+ }
52
+
53
+ // 2. Copy block files to src/lib/blocks/<path>/
54
+ const cwd = process.cwd();
55
+ const destDir = join(cwd, "src", "lib", "blocks", name);
56
+
57
+ if (existsSync(destDir)) {
58
+ const replace = await p.confirm({
59
+ message: `Block "${name}" already exists at src/lib/blocks/${name}/. Replace it?`,
60
+ });
61
+ if (p.isCancel(replace) || !replace) {
62
+ console.log(` ⏭️ Skipped ${name}`);
63
+ return;
64
+ }
65
+ }
66
+
67
+ mkdirSync(destDir, { recursive: true });
68
+
69
+ for (const file of meta.files) {
70
+ const content = await readRegistryFile(registryRoot, "blocks", name, file);
71
+ const dest = join(destDir, file);
72
+ if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
73
+ writeFileSync(dest, content, "utf-8");
74
+ console.log(` ✍️ src/lib/blocks/${name}/${file}`);
75
+ }
76
+
77
+ // 3. Merge font @imports into app.css (idempotent)
78
+ if (meta.fonts && Object.keys(meta.fonts).length > 0) {
79
+ const cssPath = join(cwd, "src", "app.css");
80
+ if (existsSync(cssPath)) {
81
+ const added = mergeFontImports(cssPath, meta.fonts);
82
+ if (added.length > 0) {
83
+ console.log(` 🔤 Added fonts to app.css: ${added.join(", ")}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ // 4. npm deps
89
+ if (meta.npmDeps && Object.keys(meta.npmDeps).length > 0) {
90
+ await bunAdd(cwd, meta.npmDeps);
91
+ }
92
+
93
+ console.log(`\n✅ ${name} installed at src/lib/blocks/${name}/`);
94
+ }
@@ -0,0 +1,61 @@
1
+ import { readFileSync, writeFileSync } from "fs";
2
+
3
+ // ─── Font @import management for app.css ──────────────────
4
+ // Merges Google-Fonts (or any) @import lines into app.css idempotently.
5
+ // Each font URL is bracketed with a marker comment so we can detect and skip
6
+ // duplicates without parsing real CSS.
7
+
8
+ const MARK_PREFIX = "/* bosia-font:";
9
+
10
+ export interface FontEntry {
11
+ [family: string]: string; // family → @import URL
12
+ }
13
+
14
+ /**
15
+ * Inserts `@import url("…");` lines for each font that is not already present.
16
+ * Returns the list of family names that were newly added (empty if no-op).
17
+ */
18
+ export function mergeFontImports(cssPath: string, fonts: FontEntry): string[] {
19
+ const existing = readFileSync(cssPath, "utf-8");
20
+ const added: string[] = [];
21
+ const lines: string[] = [];
22
+
23
+ for (const [family, url] of Object.entries(fonts)) {
24
+ const marker = `${MARK_PREFIX} ${family} */`;
25
+ if (existing.includes(marker)) continue;
26
+ lines.push(`${marker}\n@import url("${url}");`);
27
+ added.push(family);
28
+ }
29
+
30
+ if (lines.length === 0) return [];
31
+
32
+ // Prepend after any opening comment block; simplest: prepend to top.
33
+ const next = lines.join("\n") + "\n" + existing;
34
+ writeFileSync(cssPath, next, "utf-8");
35
+ return added;
36
+ }
37
+
38
+ /**
39
+ * Remove font @imports inserted by Bosia by family name. Used when switching themes.
40
+ */
41
+ export function removeFontImports(cssPath: string, families: string[]): string[] {
42
+ const existing = readFileSync(cssPath, "utf-8");
43
+ let next = existing;
44
+ const removed: string[] = [];
45
+
46
+ for (const family of families) {
47
+ const marker = `${MARK_PREFIX} ${family} */`;
48
+ const re = new RegExp(`${escapeRegExp(marker)}\\n@import url\\("[^"]+"\\);\\n?`, "g");
49
+ if (re.test(next)) {
50
+ next = next.replace(re, "");
51
+ removed.push(family);
52
+ }
53
+ }
54
+
55
+ if (removed.length > 0) writeFileSync(cssPath, next, "utf-8");
56
+ return removed;
57
+ }
58
+
59
+ function escapeRegExp(s: string): string {
60
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ }
package/src/cli/index.ts CHANGED
@@ -37,10 +37,19 @@ async function main() {
37
37
  break;
38
38
  }
39
39
  case "add": {
40
- const { runAdd } = await import("./add.ts");
41
- const addName = args.find((a) => !a.startsWith("--"));
42
- const addFlags = args.filter((a) => a.startsWith("--"));
43
- await runAdd(addName, addFlags);
40
+ const positional = args.filter((a) => !a.startsWith("--"));
41
+ const flags = args.filter((a) => a.startsWith("--"));
42
+ const sub = positional[0];
43
+ if (sub === "block") {
44
+ const { runAddBlock } = await import("./block.ts");
45
+ await runAddBlock(positional[1], flags);
46
+ } else if (sub === "theme") {
47
+ const { runAddTheme } = await import("./theme.ts");
48
+ await runAddTheme(positional[1], flags);
49
+ } else {
50
+ const { runAdd } = await import("./add.ts");
51
+ await runAdd(sub, flags);
52
+ }
44
53
  break;
45
54
  }
46
55
  case "feat": {
@@ -63,8 +72,10 @@ Commands:
63
72
  build Build for production
64
73
  start Run the production server
65
74
  test [args] Run tests with bun test (auto-loads .env.test, sets BOSIA_ENV=test)
66
- add <component> Add a UI component from the registry
67
- feat <feature> Add a feature scaffold from the registry [--local]
75
+ add <component> Add a UI component from the registry
76
+ add block <cat>/<name> Add a composed block from the registry
77
+ add theme <name> Add a theme (tokens.css) from the registry
78
+ feat <feature> Add a feature scaffold from the registry [--local]
68
79
 
69
80
  Examples:
70
81
  bun x bosia@latest create my-app
@@ -77,6 +88,8 @@ Examples:
77
88
  bun x bosia test --coverage
78
89
  bun x bosia@latest add button → src/lib/components/ui/button/
79
90
  bun x bosia@latest add shop/cart → src/lib/components/shop/cart/
91
+ bun x bosia@latest add block cards/feature-editorial
92
+ bun x bosia@latest add theme editorial
80
93
  bun x bosia@latest feat login
81
94
  `);
82
95
  break;
@@ -0,0 +1,88 @@
1
+ import { join } from "path";
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
+ import { resolveLocalRegistryOrExit, readRegistryJSON, readRegistryFile } from "./registry.ts";
4
+ import { mergeFontImports } from "./fonts.ts";
5
+
6
+ // ─── bun x bosia@latest add theme <name> ─────────────────
7
+ // Installs a theme tokens.css to src/lib/themes/<name>.css and
8
+ // rewrites the active theme @import in src/app.css. One theme
9
+ // active at a time (v1 assumption).
10
+
11
+ interface ThemeMeta {
12
+ name: string;
13
+ description: string;
14
+ files: string[];
15
+ fonts?: Record<string, string>;
16
+ npmDeps?: Record<string, string>;
17
+ }
18
+
19
+ const THEME_IMPORT_RE = /^@import\s+["']\.\/lib\/themes\/[^"']+["'];?\s*$/m;
20
+ const THEME_MARKER = "/* bosia-theme */";
21
+
22
+ export async function runAddTheme(name: string | undefined, flags: string[] = []) {
23
+ if (!name) {
24
+ console.error(
25
+ "❌ Please provide a theme name.\n Usage: bun x bosia@latest add theme <name> [--local]",
26
+ );
27
+ process.exit(1);
28
+ }
29
+
30
+ const local = flags.includes("--local");
31
+ const registryRoot = local ? resolveLocalRegistryOrExit() : null;
32
+ if (local) console.log(`⬡ Using local registry: ${registryRoot}\n`);
33
+
34
+ console.log(`⬡ Installing theme: ${name}\n`);
35
+
36
+ const meta = await readRegistryJSON<ThemeMeta>(registryRoot, "themes", name, "meta.json");
37
+
38
+ const cwd = process.cwd();
39
+ const themesDir = join(cwd, "src", "lib", "themes");
40
+ mkdirSync(themesDir, { recursive: true });
41
+
42
+ // Copy tokens.css (and any other files) to src/lib/themes/<name>.css
43
+ // Convention: first file is the tokens file, written as <name>.css.
44
+ const tokensFile = meta.files[0] ?? "tokens.css";
45
+ const content = await readRegistryFile(registryRoot, "themes", name, tokensFile);
46
+ const tokensDest = join(themesDir, `${name}.css`);
47
+ writeFileSync(tokensDest, content, "utf-8");
48
+ console.log(` ✍️ src/lib/themes/${name}.css`);
49
+
50
+ // Patch app.css: swap any existing ./lib/themes/*.css import for this one
51
+ const appCssPath = join(cwd, "src", "app.css");
52
+ if (existsSync(appCssPath)) {
53
+ patchAppCssThemeImport(appCssPath, name);
54
+ console.log(` 🎨 app.css → @import "./lib/themes/${name}.css"`);
55
+ } else {
56
+ console.warn(` ⚠️ src/app.css not found — theme import not wired automatically.`);
57
+ }
58
+
59
+ // Font @imports
60
+ if (meta.fonts && Object.keys(meta.fonts).length > 0 && existsSync(appCssPath)) {
61
+ const added = mergeFontImports(appCssPath, meta.fonts);
62
+ if (added.length > 0) console.log(` 🔤 Added fonts: ${added.join(", ")}`);
63
+ }
64
+
65
+ console.log(`\n✅ ${name} theme installed.`);
66
+ }
67
+
68
+ function patchAppCssThemeImport(appCssPath: string, themeName: string) {
69
+ const src = readFileSync(appCssPath, "utf-8");
70
+ const newImport = `${THEME_MARKER}\n@import "./lib/themes/${themeName}.css";`;
71
+
72
+ let next: string;
73
+ if (THEME_IMPORT_RE.test(src)) {
74
+ next = src.replace(THEME_IMPORT_RE, `@import "./lib/themes/${themeName}.css";`);
75
+ if (!next.includes(THEME_MARKER)) {
76
+ next = next.replace(`@import "./lib/themes/${themeName}.css";`, newImport);
77
+ }
78
+ } else {
79
+ // Insert after the tailwindcss @import line so @theme {} is processed correctly.
80
+ const tw = /^(@import\s+["']tailwindcss["'];\s*\n)/m;
81
+ if (tw.test(src)) {
82
+ next = src.replace(tw, `$1${newImport}\n`);
83
+ } else {
84
+ next = `${newImport}\n${src}`;
85
+ }
86
+ }
87
+ writeFileSync(appCssPath, next, "utf-8");
88
+ }
package/src/core/cors.ts CHANGED
@@ -13,8 +13,24 @@ export interface CorsConfig {
13
13
  maxAge?: number;
14
14
  }
15
15
 
16
- const DEFAULT_METHODS = "GET, HEAD, PUT, PATCH, POST, DELETE";
17
- const DEFAULT_HEADERS = "Content-Type, Authorization";
16
+ const DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"];
17
+ const DEFAULT_HEADERS = ["Content-Type", "Authorization"];
18
+
19
+ function parseHeaderList(value: string): string[] {
20
+ return value
21
+ .split(",")
22
+ .map((s) => s.trim())
23
+ .filter(Boolean);
24
+ }
25
+
26
+ /**
27
+ * Headers applied to *every* response when CORS is configured, regardless of
28
+ * whether the request Origin is allowed. Keeps caches/CDNs from serving a
29
+ * response with `Access-Control-Allow-Origin: X` to a different origin Y.
30
+ */
31
+ export function applyCorsVary(headers: Headers): void {
32
+ headers.set("Vary", "Origin");
33
+ }
18
34
 
19
35
  /**
20
36
  * Returns CORS response headers if the request Origin is in the allowed list.
@@ -48,22 +64,52 @@ export function getCorsHeaders(
48
64
 
49
65
  /**
50
66
  * Handles OPTIONS preflight requests.
51
- * Returns a 204 response with CORS headers, or null if the origin is not allowed.
67
+ *
68
+ * - Returns `null` if the request's Origin is missing or not allowed — the
69
+ * caller treats this as "not a CORS preflight we serve", avoiding leaking
70
+ * policy details to unknown origins.
71
+ * - Returns a 403 (carrying `Access-Control-Allow-Origin` + `Vary: Origin`)
72
+ * when the requested method or any requested header falls outside the
73
+ * configured allow-list. A 403 surfaces a clearer "not allowed by CORS
74
+ * policy" message in the browser than letting the OPTIONS request fall
75
+ * through to the default handler.
76
+ * - Otherwise returns a 204 with the standard preflight headers.
52
77
  */
53
78
  export function handlePreflight(request: Request, config: CorsConfig): Response | null {
54
79
  const base = getCorsHeaders(request, config);
55
80
  if (!base) return null;
56
81
 
82
+ const allowedMethods = config.allowedMethods ?? DEFAULT_METHODS;
83
+ const allowedHeaders = config.allowedHeaders ?? DEFAULT_HEADERS;
84
+
85
+ const requestedMethod = request.headers.get("access-control-request-method");
86
+ if (requestedMethod) {
87
+ const upper = requestedMethod.toUpperCase();
88
+ const allowedUpper = allowedMethods.map((m) => m.toUpperCase());
89
+ if (!allowedUpper.includes(upper)) {
90
+ return rejectPreflight(base, `Method ${requestedMethod} not allowed by CORS policy`);
91
+ }
92
+ }
93
+
94
+ const requestedHeadersRaw = request.headers.get("access-control-request-headers");
95
+ if (requestedHeadersRaw) {
96
+ const requested = parseHeaderList(requestedHeadersRaw).map((h) => h.toLowerCase());
97
+ const allowedLower = allowedHeaders.map((h) => h.toLowerCase());
98
+ const disallowed = requested.find((h) => !allowedLower.includes(h));
99
+ if (disallowed) {
100
+ return rejectPreflight(base, `Header ${disallowed} not allowed by CORS policy`);
101
+ }
102
+ }
103
+
57
104
  const headers = new Headers(base as HeadersInit);
58
- headers.set(
59
- "Access-Control-Allow-Methods",
60
- config.allowedMethods?.join(", ") ?? DEFAULT_METHODS,
61
- );
62
- headers.set(
63
- "Access-Control-Allow-Headers",
64
- config.allowedHeaders?.join(", ") ?? DEFAULT_HEADERS,
65
- );
105
+ headers.set("Access-Control-Allow-Methods", allowedMethods.join(", "));
106
+ headers.set("Access-Control-Allow-Headers", allowedHeaders.join(", "));
66
107
  headers.set("Access-Control-Max-Age", String(config.maxAge ?? 86400));
67
108
 
68
109
  return new Response(null, { status: 204, headers });
69
110
  }
111
+
112
+ function rejectPreflight(base: Record<string, string>, reason: string): Response {
113
+ const headers = new Headers(base as HeadersInit);
114
+ return new Response(reason, { status: 403, headers });
115
+ }
@@ -0,0 +1,47 @@
1
+ // ─── CSP Nonce ───────────────────────────────────────────
2
+ // Per-request cryptographic nonce. Embedded as `nonce="..."` on every
3
+ // framework-emitted <script> tag so that user code (or operators) can
4
+ // configure a `Content-Security-Policy` header with `script-src 'nonce-…'`
5
+ // and lock down inline-script execution without breaking framework
6
+ // hydration.
7
+ //
8
+ // 16 random bytes → base64 (22 chars after stripping `=` padding) gives
9
+ // the 128 bits of entropy recommended by the CSP spec.
10
+
11
+ const NONCE_BYTES = 16;
12
+
13
+ export function generateNonce(): string {
14
+ const bytes = new Uint8Array(NONCE_BYTES);
15
+ crypto.getRandomValues(bytes);
16
+ let binary = "";
17
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
18
+ return btoa(binary).replace(/=+$/, "");
19
+ }
20
+
21
+ /** Returns ` nonce="…"` (with leading space) when `nonce` is non-empty, otherwise `""`. */
22
+ export function nonceAttr(nonce: string | undefined): string {
23
+ return nonce ? ` nonce="${nonce}"` : "";
24
+ }
25
+
26
+ // ─── Optional CSP Header ─────────────────────────────────
27
+ // Opt-in via `CSP_DIRECTIVES` env. The literal `{nonce}` placeholder in
28
+ // the configured value is replaced with the per-request nonce on each
29
+ // response. Empty/unset env → no `Content-Security-Policy` header.
30
+ //
31
+ // Example:
32
+ // CSP_DIRECTIVES="default-src 'self'; script-src 'self' 'nonce-{nonce}'"
33
+
34
+ export const CSP_DIRECTIVES_TEMPLATE: string | null = process.env.CSP_DIRECTIVES?.trim() || null;
35
+
36
+ /**
37
+ * `true` when an operator has opted into CSP via `CSP_DIRECTIVES`. The
38
+ * framework gates *all* nonce-related wire output on this flag — without a
39
+ * matching `Content-Security-Policy` header the nonce attribute is just dead
40
+ * bytes, so we omit it.
41
+ */
42
+ export const CSP_ENABLED: boolean = CSP_DIRECTIVES_TEMPLATE !== null;
43
+
44
+ export function buildCspHeader(nonce: string): string | null {
45
+ if (!CSP_DIRECTIVES_TEMPLATE) return null;
46
+ return CSP_DIRECTIVES_TEMPLATE.replace(/\{nonce\}/g, nonce);
47
+ }
package/src/core/csrf.ts CHANGED
@@ -31,12 +31,15 @@ export function checkCsrf(
31
31
  if (SAFE_METHODS.has(request.method.toUpperCase())) return null;
32
32
 
33
33
  // Derive the expected origin.
34
- // In dev, the browser hits the proxy on DEV_PORT (e.g. localhost:9000)
35
- // while the Elysia server sees url.origin as localhost:9001.
36
- // X-Forwarded-Host / Host headers reflect the actual host the client used.
37
- const forwardedHost = request.headers.get("x-forwarded-host");
34
+ // `X-Forwarded-*` headers are only trusted when `TRUST_PROXY=true`, since a
35
+ // directly-exposed server would otherwise let a client spoof its own origin
36
+ // via attacker-controlled forwarded headers. Behind a real reverse proxy
37
+ // (nginx, Caddy, Cloudflare) the operator opts in by setting the env.
38
+ const trustProxy = process.env.TRUST_PROXY === "true";
39
+ const forwardedHost = trustProxy ? request.headers.get("x-forwarded-host") : null;
38
40
  const host = forwardedHost ?? request.headers.get("host");
39
- const protocol = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
41
+ const forwardedProto = trustProxy ? request.headers.get("x-forwarded-proto") : null;
42
+ const protocol = forwardedProto ?? url.protocol.replace(":", "");
40
43
  const expectedOrigin = host ? `${protocol}://${host}` : url.origin;
41
44
 
42
45
  const allowedOrigins = new Set([expectedOrigin, ...(config.allowedOrigins ?? [])]);
package/src/core/dev.ts CHANGED
@@ -94,6 +94,10 @@ async function startAppServer() {
94
94
  PORT: String(APP_PORT),
95
95
  // Allow externalized deps (elysia, etc.) to resolve from bosia's node_modules
96
96
  NODE_PATH: BOSIA_NODE_PATH,
97
+ // Dev proxy injects X-Forwarded-Host/Proto reflecting the public DEV_PORT, so CSRF
98
+ // origin checks must honour them. Safe in dev because the proxy controls these
99
+ // headers — no untrusted client can spoof them.
100
+ TRUST_PROXY: "true",
97
101
  },
98
102
  });
99
103
 
@@ -204,16 +208,24 @@ const devServer = Bun.serve({
204
208
  );
205
209
  }
206
210
 
207
- // Proxy everything else to the app server
211
+ // Proxy everything else to the app server. Inject X-Forwarded-Host/Proto so
212
+ // the app's CSRF origin check (gated behind TRUST_PROXY=true, also set in the
213
+ // app env above) reconstructs the public-facing origin from the dev proxy
214
+ // rather than the inner-app's host (localhost:APP_PORT).
208
215
  try {
216
+ const reqUrl = new URL(req.url);
209
217
  const target = new URL(req.url);
210
218
  target.hostname = "localhost";
211
219
  target.port = String(APP_PORT);
212
220
 
221
+ const forwardedHeaders = new Headers(req.headers);
222
+ forwardedHeaders.set("x-forwarded-host", reqUrl.host);
223
+ forwardedHeaders.set("x-forwarded-proto", reqUrl.protocol.replace(":", ""));
224
+
213
225
  return await fetch(
214
226
  new Request(target.toString(), {
215
227
  method: req.method,
216
- headers: req.headers,
228
+ headers: forwardedHeaders,
217
229
  body: req.body,
218
230
  redirect: "manual",
219
231
  }),
@@ -29,11 +29,10 @@ export class Redirect {
29
29
  const DANGEROUS_SCHEMES = /^(javascript|data|vbscript):/i;
30
30
 
31
31
  function validateRedirectLocation(location: string, options?: RedirectOptions): void {
32
- if (options?.allowExternal) return;
33
-
34
32
  const trimmed = location.trim();
35
33
 
36
- // Reject dangerous schemes
34
+ // Dangerous schemes are rejected even when `allowExternal: true` —
35
+ // `javascript:` / `data:` / `vbscript:` are never legitimate redirect targets.
37
36
  if (DANGEROUS_SCHEMES.test(trimmed)) {
38
37
  throw new Error(
39
38
  `redirect(): dangerous scheme in URL "${location}". ` +
@@ -41,6 +40,8 @@ function validateRedirectLocation(location: string, options?: RedirectOptions):
41
40
  );
42
41
  }
43
42
 
43
+ if (options?.allowExternal) return;
44
+
44
45
  // Reject protocol-relative URLs (//evil.com)
45
46
  if (trimmed.startsWith("//")) {
46
47
  throw new Error(
package/src/core/hooks.ts CHANGED
@@ -34,7 +34,16 @@ export interface Cookies {
34
34
  export type RequestEvent = {
35
35
  request: Request;
36
36
  url: URL;
37
- locals: Record<string, any>;
37
+ /**
38
+ * Per-request scratch object for user hooks/load functions.
39
+ *
40
+ * `locals.nonce` is populated by the framework with a fresh per-request
41
+ * cryptographic nonce (base64, 128 bits of entropy) and is safe to embed
42
+ * as `nonce="${event.locals.nonce}"` on user-authored inline scripts when
43
+ * the operator enables a `Content-Security-Policy` via the
44
+ * `CSP_DIRECTIVES` env var.
45
+ */
46
+ locals: Record<string, any> & { nonce?: string };
38
47
  params: Record<string, string>;
39
48
  cookies: Cookies;
40
49
  };
package/src/core/html.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { getDeclaredEnvKeys } from "./env.ts";
3
+ import { nonceAttr } from "./csp.ts";
3
4
 
4
5
  // ─── Dist Manifest ───────────────────────────────────────
5
6
  // Maps hashed filenames → script/link tags.
@@ -76,6 +77,7 @@ export function buildHtml(
76
77
  formData: any = null,
77
78
  lang?: string,
78
79
  ssr = true,
80
+ nonce?: string,
79
81
  ): string {
80
82
  const cssLinks = (distManifest.css ?? [])
81
83
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
@@ -83,10 +85,11 @@ export function buildHtml(
83
85
 
84
86
  const fallbackTitle = head.includes("<title>") ? "" : "<title>Bosia App</title>";
85
87
 
88
+ const n = nonceAttr(nonce);
86
89
  const publicEnv = getPublicDynamicEnv();
87
90
  const envScript =
88
91
  Object.keys(publicEnv).length > 0
89
- ? `\n <script>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
92
+ ? `\n <script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
90
93
  : "";
91
94
 
92
95
  const formScript =
@@ -94,9 +97,9 @@ export function buildHtml(
94
97
  const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
95
98
 
96
99
  const scripts = csr
97
- ? `${envScript}\n <script>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
100
+ ? `${envScript}\n <script${n}>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
98
101
  : isDev
99
- ? `\n <script>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
102
+ ? `\n <script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
100
103
  : "";
101
104
 
102
105
  return `<!DOCTYPE html>
@@ -109,7 +112,7 @@ export function buildHtml(
109
112
  ${head}
110
113
  ${cssLinks}
111
114
  <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">
112
- <script>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>
115
+ <script${n}>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>
113
116
  </head>
114
117
  <body>
115
118
  <div id="app">${body}</div>${scripts}
@@ -121,27 +124,23 @@ export function buildHtml(
121
124
 
122
125
  import type { Metadata } from "./hooks.ts";
123
126
 
124
- const _shellOpenCache = new Map<string, string>();
125
-
126
127
  /** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
127
- export function buildHtmlShellOpen(lang?: string): string {
128
+ export function buildHtmlShellOpen(lang?: string, nonce?: string): string {
128
129
  const key = safeLang(lang);
129
- const cached = _shellOpenCache.get(key);
130
- if (cached) return cached;
130
+ const n = nonceAttr(nonce);
131
131
  const cssLinks = (distManifest.css ?? [])
132
132
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
133
133
  .join("\n ");
134
- const result =
134
+ return (
135
135
  `<!DOCTYPE html>\n<html lang="${key}">\n<head>\n` +
136
136
  ` <meta charset="UTF-8">\n` +
137
137
  ` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
138
138
  ` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n` +
139
139
  ` ${cssLinks}\n` +
140
140
  ` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
141
- ` <script>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
142
- ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`;
143
- _shellOpenCache.set(key, result);
144
- return result;
141
+ ` <script${n}>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
142
+ ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
143
+ );
145
144
  }
146
145
 
147
146
  const SPINNER =
@@ -208,25 +207,27 @@ export function buildHtmlTail(
208
207
  formData: any = null,
209
208
  ssr = true,
210
209
  bodyEndExtras?: string[],
210
+ nonce?: string,
211
211
  ): string {
212
- let out = `<script>document.getElementById('__bs__').remove()</script>`;
212
+ const n = nonceAttr(nonce);
213
+ let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
213
214
  out += `\n<div id="app">${body}</div>`;
214
215
  if (head)
215
- out += `\n<script>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
216
+ out += `\n<script${n}>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
216
217
  if (csr) {
217
218
  const publicEnv = getPublicDynamicEnv();
218
219
  if (Object.keys(publicEnv).length > 0) {
219
- out += `\n<script>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
220
+ out += `\n<script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
220
221
  }
221
222
  const formInject =
222
223
  formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
223
224
  const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
224
225
  out +=
225
- `\n<script>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
226
+ `\n<script${n}>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
226
227
  `window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formInject}</script>`;
227
- out += `\n<script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
228
+ out += `\n<script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
228
229
  } else if (isDev) {
229
- out += `\n<script>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
230
+ out += `\n<script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
230
231
  }
231
232
  if (bodyEndExtras?.length) {
232
233
  for (const fragment of bodyEndExtras) {
@@ -126,6 +126,19 @@ export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
126
126
  path: "tailwindcss",
127
127
  namespace: "bosia-empty-css",
128
128
  }));
129
+ // app.css is processed by Tailwind CLI into public/bosia-tw.css and
130
+ // loaded via <link> tag in HTML. User layouts often `import "../app.css"`
131
+ // for IDE/Tailwind tooling — bundle as JS no-op so Bun doesn't emit a
132
+ // CSS chunk per dynamic-imported route (identical content → output
133
+ // collision under splitting:true).
134
+ build.onResolve({ filter: /(?:^|.*\/)app\.css$/ }, () => ({
135
+ path: "app.css",
136
+ namespace: "bosia-empty-app-css",
137
+ }));
138
+ build.onLoad({ filter: /.*/, namespace: "bosia-empty-app-css" }, () => ({
139
+ contents: "",
140
+ loader: "js",
141
+ }));
129
142
  build.onLoad({ filter: /.*/, namespace: "bosia-empty-css" }, () => ({
130
143
  contents: "",
131
144
  loader: "css",
@@ -44,6 +44,17 @@ interface PrerenderTarget {
44
44
  export function substituteParams(pattern: string, entry: Record<string, string>): string {
45
45
  let resolved = pattern;
46
46
  for (const [key, value] of Object.entries(entry)) {
47
+ // `..` and `\` are never legitimate in a route segment — they let a build
48
+ // emit prerendered HTML outside the intended output tree. Forward slashes
49
+ // are only allowed for catch-all (`[...key]`) segments, which by design
50
+ // expand to multiple path parts. Validate accordingly.
51
+ const isRest = pattern.includes(`[...${key}]`);
52
+ if (/\\|\.\./.test(value) || (!isRest && value.includes("/"))) {
53
+ throw new Error(
54
+ `Prerender entries(): unsafe value "${value}" for [${key}] — ` +
55
+ `path traversal characters are not allowed in dynamic segment values.`,
56
+ );
57
+ }
47
58
  resolved = resolved.replace(`[...${key}]`, value);
48
59
  resolved = resolved.replace(`[${key}]`, value);
49
60
  }
@@ -4,6 +4,7 @@ import { findMatch } from "./matcher.ts";
4
4
  import { serverRoutes, errorPage } from "bosia:routes";
5
5
  import type { RouteMatch } from "./types.ts";
6
6
  import type { Cookies } from "./hooks.ts";
7
+ import { CSP_ENABLED } from "./csp.ts";
7
8
  import { HttpError, Redirect } from "./errors.ts";
8
9
  import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
9
10
  import App from "./client/App.svelte";
@@ -296,6 +297,7 @@ export async function renderSSRStream(
296
297
  if (!match) return null;
297
298
 
298
299
  const { route, params } = match;
300
+ const nonce = CSP_ENABLED && typeof locals.nonce === "string" ? locals.nonce : undefined;
299
301
 
300
302
  // ── Pre-stream phase: resolve metadata before committing to a 200 ──
301
303
  // Errors here return a proper error response with correct status code.
@@ -307,7 +309,17 @@ export async function renderSSRStream(
307
309
  return Response.redirect(err.location, err.status);
308
310
  }
309
311
  if (err instanceof HttpError) {
310
- return renderErrorPage(err.status, err.message, url, req, route);
312
+ return renderErrorPage(
313
+ err.status,
314
+ err.message,
315
+ url,
316
+ req,
317
+ route,
318
+ undefined,
319
+ undefined,
320
+ undefined,
321
+ nonce,
322
+ );
311
323
  }
312
324
  if (isDev) console.error("Metadata load error:", err);
313
325
  else console.error("Metadata load error:", (err as Error).message ?? err);
@@ -344,14 +356,36 @@ export async function renderSSRStream(
344
356
  e.errorDepth,
345
357
  e.errorOrigin,
346
358
  e.partialLayoutData,
359
+ nonce,
347
360
  );
348
361
  }
349
362
  if (isDev) console.error("SSR load error:", err);
350
363
  else console.error("SSR load error:", (err as Error).message ?? err);
351
- return renderErrorPage(500, "Internal Server Error", url, req, route);
364
+ return renderErrorPage(
365
+ 500,
366
+ "Internal Server Error",
367
+ url,
368
+ req,
369
+ route,
370
+ undefined,
371
+ undefined,
372
+ undefined,
373
+ nonce,
374
+ );
352
375
  }
353
376
 
354
- if (!data) return renderErrorPage(404, "Not Found", url, req);
377
+ if (!data)
378
+ return renderErrorPage(
379
+ 404,
380
+ "Not Found",
381
+ url,
382
+ req,
383
+ undefined,
384
+ undefined,
385
+ undefined,
386
+ undefined,
387
+ nonce,
388
+ );
355
389
 
356
390
  const enc = new TextEncoder();
357
391
  const renderCtx: RenderContext = {
@@ -374,9 +408,19 @@ export async function renderSSRStream(
374
408
  );
375
409
  }
376
410
  const html =
377
- buildHtmlShellOpen(metadata?.lang) +
411
+ buildHtmlShellOpen(metadata?.lang, nonce) +
378
412
  buildMetadataChunk(metadata, headExtras) +
379
- buildHtmlTail("", "", data.pageData, data.layoutData, true, null, false, bodyEndExtras);
413
+ buildHtmlTail(
414
+ "",
415
+ "",
416
+ data.pageData,
417
+ data.layoutData,
418
+ true,
419
+ null,
420
+ false,
421
+ bodyEndExtras,
422
+ nonce,
423
+ );
380
424
  return new Response(html, {
381
425
  headers: { "Content-Type": "text/html; charset=utf-8" },
382
426
  });
@@ -408,12 +452,13 @@ export async function renderSSRStream(
408
452
  route.layoutModules.length,
409
453
  "page",
410
454
  data.layoutData,
455
+ nonce,
411
456
  );
412
457
  }
413
458
 
414
459
  // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
415
460
  const chunks: Uint8Array[] = [
416
- enc.encode(buildHtmlShellOpen(metadata?.lang)),
461
+ enc.encode(buildHtmlShellOpen(metadata?.lang, nonce)),
417
462
  enc.encode(buildMetadataChunk(metadata, headExtras)),
418
463
  enc.encode(
419
464
  buildHtmlTail(
@@ -425,6 +470,7 @@ export async function renderSSRStream(
425
470
  null,
426
471
  true,
427
472
  bodyEndExtras,
473
+ nonce,
428
474
  ),
429
475
  ),
430
476
  ];
@@ -473,8 +519,20 @@ export async function renderPageWithFormData(
473
519
  status: number,
474
520
  match?: RouteMatch<(typeof serverRoutes)[number]> | null,
475
521
  ): Promise<Response> {
522
+ const nonce = CSP_ENABLED && typeof locals.nonce === "string" ? locals.nonce : undefined;
476
523
  match ??= findMatch(serverRoutes, url.pathname);
477
- if (!match) return renderErrorPage(404, "Not Found", url, req);
524
+ if (!match)
525
+ return renderErrorPage(
526
+ 404,
527
+ "Not Found",
528
+ url,
529
+ req,
530
+ undefined,
531
+ undefined,
532
+ undefined,
533
+ undefined,
534
+ nonce,
535
+ );
478
536
 
479
537
  const { route } = match;
480
538
 
@@ -485,7 +543,18 @@ export async function renderPageWithFormData(
485
543
  Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
486
544
  ]);
487
545
 
488
- if (!data) return renderErrorPage(404, "Not Found", url, req);
546
+ if (!data)
547
+ return renderErrorPage(
548
+ 404,
549
+ "Not Found",
550
+ url,
551
+ req,
552
+ undefined,
553
+ undefined,
554
+ undefined,
555
+ undefined,
556
+ nonce,
557
+ );
489
558
 
490
559
  if (!data.ssr) {
491
560
  if (!data.csr && isDev) {
@@ -502,6 +571,7 @@ export async function renderPageWithFormData(
502
571
  formData,
503
572
  undefined,
504
573
  false,
574
+ nonce,
505
575
  );
506
576
  return compress(html, "text/html; charset=utf-8", req, status);
507
577
  }
@@ -517,7 +587,17 @@ export async function renderPageWithFormData(
517
587
  },
518
588
  });
519
589
 
520
- const html = buildHtml(body, head, data.pageData, data.layoutData, data.csr, formData);
590
+ const html = buildHtml(
591
+ body,
592
+ head,
593
+ data.pageData,
594
+ data.layoutData,
595
+ data.csr,
596
+ formData,
597
+ undefined,
598
+ true,
599
+ nonce,
600
+ );
521
601
  return compress(html, "text/html; charset=utf-8", req, status);
522
602
  }
523
603
 
@@ -536,7 +616,11 @@ export async function renderErrorPage(
536
616
  errorDepth?: number,
537
617
  errorOrigin?: ErrorOrigin,
538
618
  partialLayoutData?: Record<string, any>[],
619
+ nonce?: string,
539
620
  ): Promise<Response> {
621
+ // Strip the nonce from emitted scripts when CSP is off — the attribute
622
+ // is dead bytes without a matching policy header.
623
+ if (!CSP_ENABLED) nonce = undefined;
540
624
  // 1. Nested boundary
541
625
  if (route && errorDepth !== undefined && route.errorPages?.length) {
542
626
  const origin = errorOrigin ?? "page";
@@ -567,7 +651,17 @@ export async function renderErrorPage(
567
651
  },
568
652
  });
569
653
  // csr=false: no client hydration on the error page itself.
570
- const html = buildHtml(body, head, { status, message }, layoutData, false);
654
+ const html = buildHtml(
655
+ body,
656
+ head,
657
+ { status, message },
658
+ layoutData,
659
+ false,
660
+ null,
661
+ undefined,
662
+ true,
663
+ nonce,
664
+ );
571
665
  return compress(html, "text/html; charset=utf-8", req, status);
572
666
  } catch (err) {
573
667
  if (isDev) console.error("Nested error page render failed:", err);
@@ -591,7 +685,17 @@ export async function renderErrorPage(
591
685
  const { body, head } = render(mod.default, {
592
686
  props: { error: { status, message } },
593
687
  });
594
- const html = buildHtml(body, head, { status, message }, [], false);
688
+ const html = buildHtml(
689
+ body,
690
+ head,
691
+ { status, message },
692
+ [],
693
+ false,
694
+ null,
695
+ undefined,
696
+ true,
697
+ nonce,
698
+ );
595
699
  return compress(html, "text/html; charset=utf-8", req, status);
596
700
  } catch (err) {
597
701
  if (isDev) console.error("Error page render failed:", err);
@@ -0,0 +1,14 @@
1
+ import { join, resolve as resolvePath } from "path";
2
+
3
+ /**
4
+ * Resolve `untrusted` relative to `base` and verify the result stays inside
5
+ * `base`. Returns the absolute resolved path on success, or `null` when the
6
+ * resolved location escapes the base directory (traversal, absolute path
7
+ * pointing elsewhere, etc.). Use this on every untrusted path segment before
8
+ * touching the filesystem.
9
+ */
10
+ export function safePath(base: string, untrusted: string): string | null {
11
+ const root = resolvePath(base);
12
+ const full = resolvePath(join(base, untrusted));
13
+ return full.startsWith(root + "/") || full === root ? full : null;
14
+ }
@@ -1,7 +1,7 @@
1
1
  import { Elysia } from "elysia";
2
2
 
3
3
  import { existsSync, readFileSync } from "fs";
4
- import { join, resolve as resolvePath } from "path";
4
+ import { join } from "path";
5
5
 
6
6
  import { findMatch, compileRoutes, canonicalPathname } from "./matcher.ts";
7
7
  import { apiRoutes, serverRoutes } from "bosia:routes";
@@ -14,10 +14,12 @@ compileRoutes(serverRoutes);
14
14
  import type { Handle, RequestEvent } from "./hooks.ts";
15
15
  import { HttpError, Redirect, ActionFailure } from "./errors.ts";
16
16
  import { CookieJar } from "./cookies.ts";
17
+ import { safePath } from "./safePath.ts";
17
18
  import { checkCsrf } from "./csrf.ts";
18
19
  import type { CsrfConfig } from "./csrf.ts";
19
- import { getCorsHeaders, handlePreflight } from "./cors.ts";
20
+ import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
20
21
  import type { CorsConfig } from "./cors.ts";
22
+ import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
21
23
  import { isDev, compress, isStaticPath } from "./html.ts";
22
24
  import { dedup, dedupKey } from "./dedup.ts";
23
25
  import {
@@ -93,6 +95,12 @@ if (_corsAllowedOrigins?.length) {
93
95
  console.log(`🌐 CORS allowed origins: ${_corsAllowedOrigins.join(", ")}`);
94
96
  }
95
97
 
98
+ // ─── CSP Config ──────────────────────────────────────────
99
+
100
+ if (CSP_DIRECTIVES_TEMPLATE) {
101
+ console.log(`🔒 CSP: opt-in header active`);
102
+ }
103
+
96
104
  // ─── Core Request Resolver ────────────────────────────────
97
105
  // This is the inner handler that hooks wrap around.
98
106
 
@@ -104,13 +112,6 @@ function isValidRoutePath(path: string, origin: string): boolean {
104
112
  }
105
113
  }
106
114
 
107
- /** Resolve a file path and verify it stays within the allowed base directory. Returns null if traversal detected. */
108
- function safePath(base: string, untrusted: string): string | null {
109
- const root = resolvePath(base);
110
- const full = resolvePath(join(base, untrusted));
111
- return full.startsWith(root + "/") || full === root ? full : null;
112
- }
113
-
114
115
  /** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
115
116
  function parseActionName(url: URL): string {
116
117
  for (const key of url.searchParams.keys()) {
@@ -359,6 +360,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
359
360
  `Action "${actionName}" not found`,
360
361
  url,
361
362
  request,
363
+ undefined,
364
+ undefined,
365
+ undefined,
366
+ undefined,
367
+ locals.nonce,
362
368
  );
363
369
  }
364
370
 
@@ -387,7 +393,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
387
393
  { status: err.status },
388
394
  );
389
395
  }
390
- return renderErrorPage(err.status, err.message, url, request);
396
+ return renderErrorPage(
397
+ err.status,
398
+ err.message,
399
+ url,
400
+ request,
401
+ undefined,
402
+ undefined,
403
+ undefined,
404
+ undefined,
405
+ locals.nonce,
406
+ );
391
407
  }
392
408
  throw err;
393
409
  }
@@ -482,7 +498,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
482
498
 
483
499
  // SSR pages (+page.svelte) — streaming by default
484
500
  const streamResponse = await renderSSRStream(url, locals, request, cookies, pageMatch);
485
- if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
501
+ if (!streamResponse)
502
+ return renderErrorPage(
503
+ 404,
504
+ "Not Found",
505
+ url,
506
+ request,
507
+ undefined,
508
+ undefined,
509
+ undefined,
510
+ undefined,
511
+ locals.nonce,
512
+ );
486
513
  return streamResponse;
487
514
  }
488
515
 
@@ -518,13 +545,26 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
518
545
  }
519
546
 
520
547
  const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
521
- const event: RequestEvent = { request, url, locals: {}, params: {}, cookies: cookieJar };
548
+ const nonce = CSP_ENABLED ? generateNonce() : "";
549
+ const event: RequestEvent = {
550
+ request,
551
+ url,
552
+ locals: { nonce },
553
+ params: {},
554
+ cookies: cookieJar,
555
+ };
522
556
  const response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
523
557
 
524
558
  const headers = new Headers(response.headers);
525
559
  for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
526
- // Apply CORS headers for allowed origins
560
+ const cspHeader = buildCspHeader(nonce);
561
+ if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
562
+ // Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
563
+ // CORS is configured — even on responses to non-allowed origins — so
564
+ // downstream caches (CDNs, browser HTTP cache) key on the Origin header
565
+ // instead of serving an Access-Control-Allow-Origin response across origins.
527
566
  if (CORS_CONFIG) {
567
+ applyCorsVary(headers);
528
568
  const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
529
569
  if (corsHeaders) {
530
570
  for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);