bosia 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,11 +112,21 @@ 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;
115
+ /**
116
+ * Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
117
+ * depth i, '1' = run, '0' = skip. Missing/extra chars default to run.
118
+ */
119
+ function buildMaskFromBits(
120
+ bits: string,
121
+ layoutCount: number,
122
+ ): { page: boolean; layouts: boolean[] } {
123
+ const page = bits[0] !== "0";
124
+ const layouts: boolean[] = [];
125
+ for (let i = 0; i < layoutCount; i++) {
126
+ const c = bits[i + 1];
127
+ layouts.push(c !== "0");
128
+ }
129
+ return { page, layouts };
112
130
  }
113
131
 
114
132
  /** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
@@ -145,15 +163,41 @@ async function resolve(event: RequestEvent): Promise<Response> {
145
163
  return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
146
164
  }
147
165
  const routeUrl = new URL(routePathStr, url.origin);
166
+ let invalidatedBits: string | null = null;
148
167
  for (const [key, val] of url.searchParams.entries()) {
168
+ if (key === "_invalidated") {
169
+ invalidatedBits = val;
170
+ continue;
171
+ }
149
172
  routeUrl.searchParams.append(key, val);
150
173
  }
151
174
  // Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
152
175
  event.url = routeUrl;
153
176
  try {
154
177
  const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
178
+ // Build mask from `?_invalidated=<bits>` where char 0 = page,
179
+ // char i+1 = layout depth i, '1' = run, '0' = skip. Absent → run all.
180
+ // Mask is sized to the total layout count (matching client `layoutIds`),
181
+ // not the count of layout servers, so depths without a server loader
182
+ // still occupy a bit position and stay aligned with the client.
183
+ const mask = invalidatedBits
184
+ ? buildMaskFromBits(
185
+ invalidatedBits,
186
+ pageMatch?.route
187
+ ? ((pageMatch.route as any).layoutModules?.length ?? 0)
188
+ : 0,
189
+ )
190
+ : undefined;
155
191
  const runLoad = async () => {
156
- const data = await loadRouteData(routeUrl, locals, request, cookies);
192
+ const data = await loadRouteData(
193
+ routeUrl,
194
+ locals,
195
+ request,
196
+ cookies,
197
+ null,
198
+ pageMatch,
199
+ mask,
200
+ );
157
201
 
158
202
  let metadata = null;
159
203
  if (pageMatch) {
@@ -175,12 +219,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
175
219
  return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
176
220
  };
177
221
 
178
- // Dedup public routes by URL only. `(private)` scope routes (per-user content)
179
- // skip the cache to prevent cross-user data leaks. See dedup.ts.
222
+ // Dedup public routes by URL + mask. `(private)` scope routes (per-user
223
+ // content) skip the cache to prevent cross-user data leaks. The mask is
224
+ // part of the key so concurrent requests for the same URL with different
225
+ // invalidation patterns don't collapse onto each other. See dedup.ts.
226
+ const dedupK = invalidatedBits
227
+ ? `${dedupKey(routeUrl)}|m=${invalidatedBits}`
228
+ : dedupKey(routeUrl);
180
229
  const result =
181
230
  pageMatch?.route.scope === "private"
182
231
  ? await runLoad()
183
- : await dedup(dedupKey(routeUrl), runLoad);
232
+ : await dedup(dedupK, runLoad);
184
233
 
185
234
  const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
186
235
  const cc = cookiesWereAccessed
@@ -359,6 +408,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
359
408
  `Action "${actionName}" not found`,
360
409
  url,
361
410
  request,
411
+ undefined,
412
+ undefined,
413
+ undefined,
414
+ undefined,
415
+ locals.nonce,
362
416
  );
363
417
  }
364
418
 
@@ -387,7 +441,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
387
441
  { status: err.status },
388
442
  );
389
443
  }
390
- return renderErrorPage(err.status, err.message, url, request);
444
+ return renderErrorPage(
445
+ err.status,
446
+ err.message,
447
+ url,
448
+ request,
449
+ undefined,
450
+ undefined,
451
+ undefined,
452
+ undefined,
453
+ locals.nonce,
454
+ );
391
455
  }
392
456
  throw err;
393
457
  }
@@ -482,7 +546,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
482
546
 
483
547
  // SSR pages (+page.svelte) — streaming by default
484
548
  const streamResponse = await renderSSRStream(url, locals, request, cookies, pageMatch);
485
- if (!streamResponse) return renderErrorPage(404, "Not Found", url, request);
549
+ if (!streamResponse)
550
+ return renderErrorPage(
551
+ 404,
552
+ "Not Found",
553
+ url,
554
+ request,
555
+ undefined,
556
+ undefined,
557
+ undefined,
558
+ undefined,
559
+ locals.nonce,
560
+ );
486
561
  return streamResponse;
487
562
  }
488
563
 
@@ -518,13 +593,26 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
518
593
  }
519
594
 
520
595
  const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
521
- const event: RequestEvent = { request, url, locals: {}, params: {}, cookies: cookieJar };
596
+ const nonce = CSP_ENABLED ? generateNonce() : "";
597
+ const event: RequestEvent = {
598
+ request,
599
+ url,
600
+ locals: { nonce },
601
+ params: {},
602
+ cookies: cookieJar,
603
+ };
522
604
  const response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
523
605
 
524
606
  const headers = new Headers(response.headers);
525
607
  for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
526
- // Apply CORS headers for allowed origins
608
+ const cspHeader = buildCspHeader(nonce);
609
+ if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
610
+ // Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
611
+ // CORS is configured — even on responses to non-allowed origins — so
612
+ // downstream caches (CDNs, browser HTTP cache) key on the Origin header
613
+ // instead of serving an Access-Control-Allow-Origin response across origins.
527
614
  if (CORS_CONFIG) {
615
+ applyCorsVary(headers);
528
616
  const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
529
617
  if (corsHeaders) {
530
618
  for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
package/src/lib/client.ts CHANGED
@@ -10,3 +10,4 @@
10
10
 
11
11
  export { enhance } from "../core/client/enhance.ts";
12
12
  export type { SubmitFunction, ActionResult } from "../core/client/enhance.ts";
13
+ export { invalidate, invalidateAll } from "../core/client/navigation.ts";
package/src/lib/index.ts CHANGED
@@ -10,6 +10,7 @@ export type { HttpError, Redirect, RedirectOptions, ActionFailure } from "../cor
10
10
  export type {
11
11
  RequestEvent,
12
12
  LoadEvent,
13
+ LoaderDeps,
13
14
  MetadataEvent,
14
15
  Metadata,
15
16
  Handle,