@bractjs/bractjs 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/bin/cli.ts +18 -1
  2. package/package.json +3 -2
  3. package/src/__tests__/codegen-write.test.ts +67 -0
  4. package/src/__tests__/codegen.test.ts +29 -2
  5. package/src/__tests__/compile-safety.test.ts +4 -0
  6. package/src/__tests__/csp.test.ts +10 -0
  7. package/src/__tests__/define-actions.test.ts +69 -0
  8. package/src/__tests__/env.test.ts +18 -0
  9. package/src/__tests__/fetcher-store.test.ts +67 -0
  10. package/src/__tests__/fixtures/app/root.tsx +7 -2
  11. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  12. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  13. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
  16. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  17. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  18. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  19. package/src/__tests__/form-data-helpers.test.ts +43 -0
  20. package/src/__tests__/headers.test.ts +111 -0
  21. package/src/__tests__/integration.test.ts +90 -0
  22. package/src/__tests__/layout-registry.test.ts +7 -3
  23. package/src/__tests__/loader.test.ts +32 -1
  24. package/src/__tests__/matcher.test.ts +29 -0
  25. package/src/__tests__/module-registry.test.ts +2 -3
  26. package/src/__tests__/nav-utils.test.ts +46 -0
  27. package/src/__tests__/prerender.test.ts +102 -0
  28. package/src/__tests__/programmatic-api.test.ts +20 -1
  29. package/src/__tests__/revalidation.test.ts +65 -0
  30. package/src/__tests__/route-lint.test.ts +79 -0
  31. package/src/__tests__/route-middleware.test.ts +84 -0
  32. package/src/__tests__/route-table.test.ts +33 -0
  33. package/src/__tests__/safe-validate.test.ts +96 -0
  34. package/src/__tests__/scanner.test.ts +46 -1
  35. package/src/__tests__/scroll-restoration.test.ts +66 -0
  36. package/src/__tests__/search-serializer.test.ts +42 -0
  37. package/src/__tests__/search-validation.test.ts +125 -0
  38. package/src/__tests__/security-fixes.test.ts +201 -0
  39. package/src/__tests__/security.test.ts +110 -1
  40. package/src/__tests__/selective-ssr.test.ts +85 -0
  41. package/src/__tests__/spa-mode.test.ts +77 -0
  42. package/src/__tests__/typed-routing.test.ts +51 -1
  43. package/src/__tests__/use-matches.test.ts +54 -0
  44. package/src/build/bundler.ts +33 -0
  45. package/src/build/prerender.ts +88 -0
  46. package/src/build/route-lint.ts +49 -0
  47. package/src/client/ClientRouter.tsx +339 -47
  48. package/src/client/cache.ts +8 -0
  49. package/src/client/components/Await.tsx +9 -2
  50. package/src/client/components/Form.tsx +23 -34
  51. package/src/client/components/Link.tsx +80 -9
  52. package/src/client/components/Outlet.tsx +8 -2
  53. package/src/client/components/ScrollRestoration.tsx +125 -0
  54. package/src/client/entry.tsx +39 -2
  55. package/src/client/fetcher-store.ts +61 -0
  56. package/src/client/form-utils.ts +3 -0
  57. package/src/client/hooks/useActionData.ts +7 -3
  58. package/src/client/hooks/useFetcher.ts +116 -33
  59. package/src/client/hooks/useFetchers.ts +23 -0
  60. package/src/client/hooks/useLoaderData.ts +8 -4
  61. package/src/client/hooks/useLocation.ts +27 -0
  62. package/src/client/hooks/useMatches.ts +32 -0
  63. package/src/client/hooks/useNavigate.ts +11 -6
  64. package/src/client/hooks/useRevalidator.ts +26 -0
  65. package/src/client/hooks/useSearch.ts +73 -0
  66. package/src/client/hooks/useSearchParams.ts +7 -2
  67. package/src/client/nav-utils.ts +26 -0
  68. package/src/client/prefetch.ts +110 -15
  69. package/src/client/registry.ts +24 -0
  70. package/src/client/revalidation.ts +25 -0
  71. package/src/client/router.tsx +34 -1
  72. package/src/client/rpc.ts +11 -1
  73. package/src/client/scroll-restoration.ts +48 -0
  74. package/src/client/search-serializer.ts +40 -0
  75. package/src/client/types.ts +6 -0
  76. package/src/codegen/module-registry.ts +13 -21
  77. package/src/codegen/route-codegen.ts +148 -10
  78. package/src/config/load.ts +22 -0
  79. package/src/dev/hmr-client.ts +3 -1
  80. package/src/dev/route-table.ts +27 -0
  81. package/src/dev/server.ts +106 -8
  82. package/src/dev/watcher.ts +25 -3
  83. package/src/index.ts +38 -6
  84. package/src/server/action-handler.ts +3 -13
  85. package/src/server/action-registry.ts +35 -0
  86. package/src/server/adapter.ts +16 -0
  87. package/src/server/api-route.ts +47 -0
  88. package/src/server/csp.ts +19 -4
  89. package/src/server/csrf.ts +36 -3
  90. package/src/server/env.ts +26 -5
  91. package/src/server/headers.ts +49 -0
  92. package/src/server/layout.ts +43 -20
  93. package/src/server/loader.ts +14 -8
  94. package/src/server/matcher.ts +29 -2
  95. package/src/server/matches.ts +50 -0
  96. package/src/server/middleware.ts +66 -0
  97. package/src/server/proto-guard.ts +56 -0
  98. package/src/server/render.ts +51 -18
  99. package/src/server/request-handler.ts +111 -29
  100. package/src/server/scanner.ts +45 -3
  101. package/src/server/search.ts +47 -0
  102. package/src/server/serve.ts +116 -4
  103. package/src/server/session.ts +12 -1
  104. package/src/server/spa.ts +62 -0
  105. package/src/server/stream-handler.ts +10 -1
  106. package/src/server/validate.ts +89 -14
  107. package/src/shared/context.ts +7 -0
  108. package/src/shared/define-actions.ts +39 -0
  109. package/src/shared/form-data.ts +34 -0
  110. package/src/shared/route-types.ts +191 -2
  111. package/templates/new-app/app/root.tsx +2 -1
  112. package/templates/new-app/bractjs.config.ts +7 -12
  113. package/types/config.d.ts +24 -0
  114. package/types/index.d.ts +182 -9
  115. package/types/route.d.ts +138 -3
  116. package/LICENSE +0 -21
  117. package/README.md +0 -1125
package/src/index.ts CHANGED
@@ -4,9 +4,13 @@ export { buildFetchHandler } from "./server/serve.ts";
4
4
  export { defineContext } from "./server/context.ts";
5
5
  export type { ContextFactory } from "./server/context.ts";
6
6
  export { route } from "./server/api-route.ts";
7
- export type { ApiRouteDefinition, AppApiRoutes } from "./server/api-route.ts";
8
- export { validate } from "./server/validate.ts";
9
- export type { FieldErrors, ValidationError } from "./server/validate.ts";
7
+ export type { ApiRouteDefinition, ApiRouteOptions, AppApiRoutes } from "./server/api-route.ts";
8
+ export { validate, safeValidate, isValidationResponse, readValidationError } from "./server/validate.ts";
9
+ export type { FieldErrors, ValidationError, SafeValidateResult } from "./server/validate.ts";
10
+ export { hasForbiddenKey, nullProtoFromEntries } from "./server/proto-guard.ts";
11
+ export { formText, formValues } from "./shared/form-data.ts";
12
+ export { defineActions } from "./shared/define-actions.ts";
13
+ export { validateSearch, searchParamsToObject } from "./server/search.ts";
10
14
  export type { BractAdapter } from "./server/adapter.ts";
11
15
  export { BunAdapter } from "./server/adapter.ts";
12
16
 
@@ -68,8 +72,19 @@ export type {
68
72
  LoaderFunction,
69
73
  ActionFunction,
70
74
  MetaFunction,
75
+ HeadersFunction,
76
+ HeadersArgs,
77
+ RouteMiddlewareFunction,
78
+ ClientLoaderFunction,
79
+ ClientActionFunction,
80
+ RouteMatch,
71
81
  RouteModule,
72
82
  RouteDefinition,
83
+ RouterLocation,
84
+ ShouldRevalidateArgs,
85
+ ShouldRevalidateFunction,
86
+ LoaderData,
87
+ ActionData,
73
88
  } from "./shared/route-types.ts";
74
89
  export type { RouteFile, Segment } from "./server/scanner.ts";
75
90
 
@@ -79,8 +94,8 @@ export { BractJSContext, BractJSProvider, useBractJSContext } from "./shared/con
79
94
  export type { BractJSContextValue, RouteManifest } from "./shared/context.ts";
80
95
 
81
96
  // Middleware
82
- export { pipeline, MiddlewarePipeline } from "./server/middleware.ts";
83
- export type { MiddlewareFn, MiddlewareContext } from "./server/middleware.ts";
97
+ export { pipeline, MiddlewarePipeline, runRouteMiddleware, collectRouteMiddleware } from "./server/middleware.ts";
98
+ export type { MiddlewareFn, MiddlewareContext, RouteMiddleware } from "./server/middleware.ts";
84
99
  export { requestLogger } from "./middleware/requestLogger.ts";
85
100
  export { cors } from "./middleware/cors.ts";
86
101
  export type { CorsOptions } from "./middleware/cors.ts";
@@ -102,17 +117,29 @@ export { Form } from "./client/components/Form.tsx";
102
117
  export { Await } from "./client/components/Await.tsx";
103
118
  export { Image } from "./client/components/Image.tsx";
104
119
  export type { ImageProps, ImageFormat, ImageFit } from "./client/components/Image.tsx";
120
+ export { ScrollRestoration } from "./client/components/ScrollRestoration.tsx";
121
+ export type { ScrollRestorationProps } from "./client/components/ScrollRestoration.tsx";
105
122
 
106
123
  // Client hooks
107
124
  export { useLoaderData } from "./client/hooks/useLoaderData.ts";
108
125
  export { useActionData } from "./client/hooks/useActionData.ts";
126
+ export { useLocation } from "./client/hooks/useLocation.ts";
109
127
  export { useParams } from "./client/hooks/useParams.ts";
128
+ export { useMatches } from "./client/hooks/useMatches.ts";
110
129
  export { useNavigation } from "./client/hooks/useNavigation.ts";
111
130
  export { useNavigate } from "./client/hooks/useNavigate.ts";
112
131
  export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
113
132
  export { useFetcher } from "./client/hooks/useFetcher.ts";
133
+ export type { FetcherResult, FetcherFormProps, UseFetcherOptions } from "./client/hooks/useFetcher.ts";
134
+ export { useFetchers } from "./client/hooks/useFetchers.ts";
135
+ export type { FetcherEntry, FetcherState } from "./client/fetcher-store.ts";
136
+ export { useRevalidator } from "./client/hooks/useRevalidator.ts";
137
+ export type { Revalidator } from "./client/hooks/useRevalidator.ts";
114
138
  export { useSearchParams } from "./client/hooks/useSearchParams.ts";
115
139
  export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
140
+ export { useSearch, useSetSearch } from "./client/hooks/useSearch.ts";
141
+ export type { SetSearchFn, SetSearchOptions } from "./client/hooks/useSearch.ts";
142
+ export { serializeSearch } from "./client/search-serializer.ts";
116
143
  export { useBlocker } from "./client/hooks/useBlocker.ts";
117
144
  export { useLocale } from "./client/hooks/useLocale.ts";
118
145
  export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
@@ -127,6 +154,8 @@ export type {
127
154
  RegisteredRoutes,
128
155
  ParamsFor,
129
156
  SearchFor,
157
+ SearchOutputFor,
158
+ InferSchemaOutput,
130
159
  RouteSearchParamsMap,
131
160
  RouteContextMap,
132
161
  } from "./client/registry.ts";
@@ -141,4 +170,7 @@ export { createDevServer } from "./dev/server.ts";
141
170
  export type { DevServerOptions, DevServer } from "./dev/server.ts";
142
171
  export { runBuild } from "./build/bundler.ts";
143
172
  export type { BuildConfig } from "./build/bundler.ts";
144
- export { loadUserConfig } from "./config/load.ts";
173
+ export { loadUserConfig, defineConfig } from "./config/load.ts";
174
+ export { runPrerender } from "./build/prerender.ts";
175
+ export type { PrerenderOptions, PrerenderResult } from "./build/prerender.ts";
176
+ export { renderSpaShell } from "./server/spa.ts";
@@ -1,29 +1,19 @@
1
1
  import { resolveAction } from "./action-registry.ts";
2
2
  import { json } from "./response.ts";
3
- import { isAllowedMutation } from "./csrf.ts";
3
+ import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
4
+ import { hasForbiddenKey } from "./proto-guard.ts";
4
5
 
5
- const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
6
6
  // Cap action JSON bodies. Anything over this looks like an abuse attempt;
7
7
  // FormData uploads (large files) take the multipart branch and bypass this.
8
8
  const MAX_JSON_BODY_BYTES = 1_048_576; // 1 MiB
9
9
 
10
- // Deep scan: nested objects can carry __proto__ pollution vectors too.
11
- function hasForbiddenKey(value: unknown, depth = 0): boolean {
12
- if (depth > 20 || !value || typeof value !== "object") return false;
13
- for (const key of Object.keys(value as Record<string, unknown>)) {
14
- if (FORBIDDEN_KEYS.has(key)) return true;
15
- if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
16
- }
17
- return false;
18
- }
19
-
20
10
  export async function handleActionRequest(request: Request): Promise<Response | null> {
21
11
  const url = new URL(request.url);
22
12
  // SECURITY(medium): exact-match prevents URL confusion (e.g. "/_actionfoo"
23
13
  // would otherwise also reach this handler).
24
14
  if (url.pathname !== "/_action") return null;
25
15
  if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
26
- if (!isAllowedMutation(request)) return new Response("Forbidden", { status: 403 });
16
+ if (!isAllowedMutation(request)) return csrfForbiddenResponse();
27
17
 
28
18
  const id = url.searchParams.get("id");
29
19
  if (!id) return new Response("Bad Request: missing action id", { status: 400 });
@@ -6,6 +6,37 @@ import { join, relative, resolve, isAbsolute } from "node:path";
6
6
  const SERVER_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
7
7
  const registry = new Map<string, (...args: unknown[]) => Promise<unknown>>();
8
8
 
9
+ // SECURITY(high): exporting a function from a `"use server"` module publishes
10
+ // it as an unauthenticated RPC endpoint reachable via POST /_action and
11
+ // GET /_stream. For files under `routes/`, these reserved exports are framework
12
+ // lifecycle hooks / components — NOT intended as callable actions — so we never
13
+ // register them. Without this filter a route's `loader`/`action`/`default`
14
+ // could be invoked directly over the wire, bypassing search/param validation
15
+ // and (for /_stream) with zero arguments. Authors who genuinely want an action
16
+ // must export it under a different name.
17
+ const RESERVED_ROUTE_EXPORTS = new Set([
18
+ "default",
19
+ "loader",
20
+ "action",
21
+ "meta",
22
+ "beforeLoad",
23
+ "context",
24
+ "ErrorBoundary",
25
+ "Fallback",
26
+ "config",
27
+ "searchSchema",
28
+ "ssr",
29
+ ]);
30
+
31
+ function isRouteFile(rel: string): boolean {
32
+ return rel.startsWith("routes/") || rel.startsWith("routes\\");
33
+ }
34
+
35
+ function shouldRegisterExport(name: string, fromRouteFile: boolean): boolean {
36
+ if (fromRouteFile && RESERVED_ROUTE_EXPORTS.has(name)) return false;
37
+ return true;
38
+ }
39
+
9
40
  /**
10
41
  * Hash key for an action — must use the same string the client-side proxy
11
42
  * plugin hashes (`pathKey + "#" + name`). Mismatch → `/_action?id=...` 404.
@@ -61,8 +92,10 @@ export async function loadServerActions(appDir: string): Promise<void> {
61
92
  continue;
62
93
  }
63
94
 
95
+ const fromRouteFile = isRouteFile(rel);
64
96
  for (const [name, val] of Object.entries(mod)) {
65
97
  if (typeof val !== "function") continue;
98
+ if (!shouldRegisterExport(name, fromRouteFile)) continue;
66
99
  const id = await computeId(pathKeyForAction(filePath, appDir), name);
67
100
  registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
68
101
  }
@@ -82,8 +115,10 @@ export async function loadServerActionsFromRegistry(
82
115
  entries: Array<{ relPath: string; mod: Record<string, unknown> }>,
83
116
  ): Promise<void> {
84
117
  for (const { relPath, mod } of entries) {
118
+ const fromRouteFile = isRouteFile(relPath);
85
119
  for (const [name, val] of Object.entries(mod)) {
86
120
  if (typeof val !== "function") continue;
121
+ if (!shouldRegisterExport(name, fromRouteFile)) continue;
87
122
  const id = await computeId(relPath, name);
88
123
  registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
89
124
  }
@@ -22,9 +22,24 @@ export interface BractAdapter {
22
22
  * Default adapter — wraps `Bun.serve()`.
23
23
  * Created internally by `createServer()` when no adapter is provided.
24
24
  */
25
+ // SECURITY(medium): hard ceiling on request body size at the server boundary,
26
+ // independent of any Content-Length the client advertises. The per-route and
27
+ // /_action handlers apply their own (smaller) caps and double-check the decoded
28
+ // size, but this is the single backstop every code path inherits — it bounds
29
+ // memory even for paths that don't pre-check (e.g. an app's own /api handler
30
+ // that reads request.formData() directly). Sits above the 10 MiB route-form
31
+ // cap so legitimate uploads still pass; raise it via the `maxRequestBodySize`
32
+ // config for apps with a dedicated large-upload endpoint.
33
+ const DEFAULT_MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024; // 16 MiB
34
+
25
35
  export class BunAdapter implements BractAdapter {
26
36
  private server: ReturnType<typeof Bun.serve> | null = null;
27
37
  private handler: ((request: Request) => Promise<Response>) | null = null;
38
+ private maxRequestBodySize: number;
39
+
40
+ constructor(maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_BYTES) {
41
+ this.maxRequestBodySize = maxRequestBodySize;
42
+ }
28
43
 
29
44
  setHandler(handler: (request: Request) => Promise<Response>): void {
30
45
  this.handler = handler;
@@ -40,6 +55,7 @@ export class BunAdapter implements BractAdapter {
40
55
  const handler = this.handler;
41
56
  this.server = Bun.serve({
42
57
  port,
58
+ maxRequestBodySize: this.maxRequestBodySize,
43
59
  fetch: handler,
44
60
  error(err: Error) {
45
61
  console.error("[bractjs] unhandled server error:", err);
@@ -1,4 +1,6 @@
1
1
  import { isExplicitDev } from "./env.ts";
2
+ import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
3
+ import { hasForbiddenKey } from "./proto-guard.ts";
2
4
 
3
5
  // ── Types ──────────────────────────────────────────────────────────────────
4
6
 
@@ -8,6 +10,27 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
8
10
  // cannot exhaust memory. Same 1 MiB ceiling used by /_action JSON.
9
11
  const MAX_BODY_BYTES = 1_048_576;
10
12
 
13
+ // SECURITY(high): the same state-changing methods the route-action / _action
14
+ // paths CSRF-gate. A typed API route using one of these is cross-site
15
+ // forgeable (cookies ride along; a form-encoded body is CORS-"simple" and
16
+ // skips preflight) unless the caller proves same-origin. We require that proof
17
+ // by default — see the gate in handleApiRequest.
18
+ const MUTATING_METHODS = new Set<HttpMethod>(["POST", "PUT", "PATCH", "DELETE"]);
19
+
20
+ export interface ApiRouteOptions {
21
+ /**
22
+ * Cross-site-request-forgery protection for this route. Default `true` for
23
+ * mutating methods (POST/PUT/PATCH/DELETE): the request must be same-origin
24
+ * (proven via `Sec-Fetch-Site`, the `X-BractJS-Action` header, or a matching
25
+ * `Origin`), exactly like server actions. Set `false` ONLY for endpoints
26
+ * that are safe to call cross-site — i.e. they do NOT rely on ambient
27
+ * credentials (session cookies / Basic auth) and are intentionally public
28
+ * (webhooks, token-authenticated APIs, public read/write services).
29
+ * Only GET is exempt from the gate; DELETE is treated as mutating.
30
+ */
31
+ csrf?: boolean;
32
+ }
33
+
11
34
  export interface ApiRouteDefinition<
12
35
  TMethod extends HttpMethod,
13
36
  TPath extends string,
@@ -17,6 +40,8 @@ export interface ApiRouteDefinition<
17
40
  method: TMethod;
18
41
  path: TPath;
19
42
  handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>;
43
+ /** Resolved CSRF setting (defaults applied at registration). */
44
+ csrf: boolean;
20
45
  _types: { input: TInput; output: TOutput };
21
46
  }
22
47
 
@@ -29,6 +54,11 @@ const routeRegistry: ApiRouteDefinition<HttpMethod, string, any, any>[] = [];
29
54
  *
30
55
  * Usage in app/api/users.ts:
31
56
  * export const getUsers = bract.route("GET", "/api/users", async () => db.users.findAll());
57
+ *
58
+ * Mutating routes (POST/PUT/PATCH/DELETE) are CSRF-protected by default — the
59
+ * request must be same-origin. Pass `{ csrf: false }` for a deliberately public,
60
+ * credential-free endpoint (e.g. a webhook):
61
+ * bract.route("POST", "/api/webhook", handler, { csrf: false });
32
62
  */
33
63
  export function route<
34
64
  TMethod extends HttpMethod,
@@ -39,11 +69,14 @@ export function route<
39
69
  method: TMethod,
40
70
  path: TPath,
41
71
  handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>,
72
+ options?: ApiRouteOptions,
42
73
  ): ApiRouteDefinition<TMethod, TPath, TInput, TOutput> {
43
74
  const def: ApiRouteDefinition<TMethod, TPath, TInput, TOutput> = {
44
75
  method,
45
76
  path,
46
77
  handler,
78
+ // Default ON. Opt out only for endpoints that don't trust ambient creds.
79
+ csrf: options?.csrf ?? true,
47
80
  _types: {} as { input: TInput; output: TOutput },
48
81
  };
49
82
  routeRegistry.push(def);
@@ -62,6 +95,14 @@ export async function handleApiRequest(request: Request): Promise<Response | nul
62
95
  if (def.method !== request.method) continue;
63
96
  if (!pathMatches(def.path, url.pathname)) continue;
64
97
 
98
+ // SECURITY(high): CSRF gate for mutating methods. Same check the route
99
+ // action / _action / _stream paths use, so an authenticated user's cookies
100
+ // can't be used to forge a cross-site write to an /api route. Routes that
101
+ // opt out (`csrf: false`) are responsible for not trusting ambient creds.
102
+ if (def.csrf && MUTATING_METHODS.has(def.method) && !isAllowedMutation(request)) {
103
+ return csrfForbiddenResponse();
104
+ }
105
+
65
106
  let input: unknown = undefined;
66
107
  if (request.method !== "GET" && request.method !== "DELETE") {
67
108
  // Trust an advertised Content-Length up front so oversized payloads
@@ -86,6 +127,12 @@ export async function handleApiRequest(request: Request): Promise<Response | nul
86
127
  } catch {
87
128
  return new Response("Bad Request: invalid JSON", { status: 400 });
88
129
  }
130
+ // SECURITY(high): reject prototype-pollution keys before the parsed
131
+ // body reaches a handler that might merge it into another object.
132
+ // Parity with the /_action JSON path.
133
+ if (hasForbiddenKey(input)) {
134
+ return new Response("Bad Request: forbidden keys", { status: 400 });
135
+ }
89
136
  } else if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
90
137
  input = await request.formData();
91
138
  }
package/src/server/csp.ts CHANGED
@@ -21,6 +21,15 @@ export interface CspOptions {
21
21
  * Useful for staging a policy before turning it on. Default: false.
22
22
  */
23
23
  reportOnly?: boolean;
24
+ /**
25
+ * Drop `'unsafe-inline'` from the default `style-src`. The baseline policy
26
+ * allows inline styles for ergonomics (React inline styles, CSS-in-JS), which
27
+ * leaves inline-style injection (CSS exfiltration / UI redress) possible.
28
+ * Set `strict: true` for `style-src 'self'` only — you must then serve all
29
+ * styles from same-origin stylesheets (or override `style-src` yourself with
30
+ * a nonce/hash via `directives`). Default: false.
31
+ */
32
+ strict?: boolean;
24
33
  }
25
34
 
26
35
  /**
@@ -66,14 +75,20 @@ export function csp(options: CspOptions = {}): MiddlewareFn {
66
75
 
67
76
  const directives: Record<string, string | null> = {
68
77
  "default-src": "'self'",
69
- // 'strict-dynamic' lets the nonced bootstrap script load the chunks it
70
- // imports without each chunk needing its own nonce. Falls back to 'self'
71
- // in browsers that don't support it.
78
+ // 'strict-dynamic': trust flows through the nonce a nonced script may
79
+ // load the chunks it imports without each chunk carrying its own nonce.
80
+ // NOTE: in browsers that support 'strict-dynamic', the 'self' and any
81
+ // host/allowlist expressions in script-src are IGNORED; only the nonce
82
+ // (and scripts it transitively loads) are trusted. 'self' is kept solely
83
+ // as a fallback for older browsers that don't implement 'strict-dynamic'.
72
84
  "script-src": `'self' 'nonce-${nonce}' 'strict-dynamic'`,
73
- "style-src": "'self' 'unsafe-inline'",
85
+ "style-src": options.strict ? "'self'" : "'self' 'unsafe-inline'",
74
86
  "img-src": "'self' data: blob:",
75
87
  "connect-src": "'self'",
76
88
  "base-uri": "'self'",
89
+ // Restrict where <form> can submit so an injected form can't exfiltrate
90
+ // to an attacker origin even if it slips past other controls.
91
+ "form-action": "'self'",
77
92
  "frame-ancestors": "'self'",
78
93
  "object-src": "'none'",
79
94
  ...(options.directives ?? {}),
@@ -23,12 +23,45 @@
23
23
  * none of these headers and are rejected by default — they must set
24
24
  * `X-BractJS-Action` or a same-origin `Origin` to mutate.
25
25
  */
26
+ // This gate protects server actions (/_action), streaming actions (/_stream),
27
+ // route mutations, AND typed /api routes (see api-route.ts) — every
28
+ // state-changing, cookie-trusting surface in the framework.
29
+ //
26
30
  // SECURITY(medium): X-BractJS-Action acts as a CSRF token by relying on CORS
27
31
  // preflight blocking custom headers cross-origin. This is safe only while the
28
32
  // server does NOT emit a permissive Access-Control-Allow-Headers listing this
29
- // header. If CORS policy is ever loosened, Sec-Fetch-Site (1) remains as the
30
- // browser-enforced backstop, and apps that loosen CORS should add a
31
- // cryptographic double-submit token.
33
+ // header. The built-in cors() (middleware/cors.ts) deliberately omits it; if
34
+ // you ship your OWN CORS layer and expose this header cross-origin, you defeat
35
+ // CSRF everywhere — add a cryptographic double-submit token in that case.
36
+ // Sec-Fetch-Site (1) remains a browser-enforced backstop, but note it is NOT
37
+ // sent by every client/proxy: behind a header-stripping proxy the gate falls
38
+ // back to the same-origin Origin check, which cors() does not weaken.
39
+ import { isExplicitDev } from "./env.ts";
40
+
41
+ /**
42
+ * The developer-facing explanation of a CSRF rejection. In dev it spells out
43
+ * the accepted signals and the usual fix; in prod it stays terse so the 403
44
+ * leaks nothing. Used for the plain route/action 403 bodies — the stream
45
+ * handler embeds {@link csrfHint} in its SSE error event instead.
46
+ */
47
+ export function csrfHint(): string {
48
+ return (
49
+ "Blocked a cross-site or unattributed mutation (CSRF protection). " +
50
+ "Same-origin browser requests are allowed automatically; a manual fetch() " +
51
+ 'must send the header `X-BractJS-Action: 1` (BractJS\'s <Form> and ' +
52
+ "useFetcher do this for you)."
53
+ );
54
+ }
55
+
56
+ /** A 403 Response for a rejected mutation: explanatory in dev, terse in prod. */
57
+ export function csrfForbiddenResponse(): Response {
58
+ if (isExplicitDev()) {
59
+ console.warn("[bractjs] 403 (CSRF): " + csrfHint());
60
+ return new Response("Forbidden — " + csrfHint(), { status: 403 });
61
+ }
62
+ return new Response("Forbidden", { status: 403 });
63
+ }
64
+
32
65
  export function isAllowedMutation(request: Request): boolean {
33
66
  // (1) Browser-enforced signal. If present, it vetoes cross-origin requests
34
67
  // regardless of what the Origin/custom headers claim.
package/src/server/env.ts CHANGED
@@ -20,6 +20,19 @@ export function isDevRuntime(): boolean {
20
20
  return _runtimeMode === "dev";
21
21
  }
22
22
 
23
+ /**
24
+ * The dev HMR WebSocket port, set by createDevServer and read when rendering
25
+ * the dev bootstrap so the injected HMR client connects to the right port
26
+ * (the config's `hmrPort`, not a hardcoded 3001). 0 = default.
27
+ */
28
+ let _devHmrPort = 0;
29
+ export function setDevHmrPort(port: number): void {
30
+ _devHmrPort = port;
31
+ }
32
+ export function getDevHmrPort(): number {
33
+ return _devHmrPort;
34
+ }
35
+
23
36
  /**
24
37
  * Strict "is development?" check used to gate sensitive output (error
25
38
  * messages, stack traces) that would otherwise leak in production.
@@ -50,12 +63,20 @@ const LS = String.fromCharCode(0x2028);
50
63
  const PS = String.fromCharCode(0x2029);
51
64
 
52
65
  export function safeStringify(data: unknown): string {
53
- const seen = new WeakSet();
54
- const json = JSON.stringify(data, (_key, value) => {
55
- if (typeof value === "object" && value !== null) {
56
- if (seen.has(value)) return "[Circular]";
57
- seen.add(value);
66
+ // Cycle detection must track ANCESTORS, not every visited node — a WeakSet
67
+ // of all seen objects flags legitimate shared references as "[Circular]"
68
+ // (e.g. a loader echoing `args.search` while the payload also carries
69
+ // `search` at the top level). MDN's replacer pattern: `this` is the holder
70
+ // object, so popping the stack until the top is the holder leaves exactly
71
+ // the current ancestor chain.
72
+ const ancestors: object[] = [];
73
+ const json = JSON.stringify(data, function (_key, value: unknown) {
74
+ if (typeof value !== "object" || value === null) return value;
75
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
76
+ ancestors.pop();
58
77
  }
78
+ if (ancestors.includes(value)) return "[Circular]";
79
+ ancestors.push(value);
59
80
  return value;
60
81
  });
61
82
  // Escape HTML-sensitive chars + JS LineTerminators (U+2028 / U+2029) so this
@@ -0,0 +1,49 @@
1
+ import type { LayoutChain } from "./layout.ts";
2
+ import type { LoaderResults } from "./loader.ts";
3
+ import type { HeadersFunction } from "../shared/route-types.ts";
4
+
5
+ type Params = Record<string, string>;
6
+
7
+ /**
8
+ * Walk the route module chain (root → layouts → route) calling each module's
9
+ * optional `headers()` export, threading the accumulated `Headers` through as
10
+ * `parentHeaders`. Each call's returned `HeadersInit` is merged on top, so the
11
+ * innermost route wins per key (RR7 semantics).
12
+ *
13
+ * Returns `null` when no module in the chain exports `headers` — callers keep
14
+ * their existing default headers untouched in that case.
15
+ */
16
+ export function resolveHeaders(
17
+ chain: LayoutChain,
18
+ loaderData: LoaderResults,
19
+ params: Params,
20
+ request: Request,
21
+ ): Headers | null {
22
+ const links: Array<{ fn: HeadersFunction; data: unknown }> = [];
23
+
24
+ if (chain.root.headers) links.push({ fn: chain.root.headers, data: loaderData.root });
25
+ chain.layouts.forEach((mod, i) => {
26
+ if (mod.headers) links.push({ fn: mod.headers, data: loaderData.layouts[i] ?? null });
27
+ });
28
+ if (chain.route.headers) links.push({ fn: chain.route.headers, data: loaderData.route });
29
+
30
+ if (links.length === 0) return null;
31
+
32
+ const merged = new Headers();
33
+ for (const { fn, data } of links) {
34
+ const produced = new Headers(fn({ loaderData: data, params, request, parentHeaders: merged }));
35
+ // `set` (not `append`) so an inner route overrides an ancestor's value for
36
+ // the same key rather than accumulating duplicates.
37
+ produced.forEach((value, key) => merged.set(key, value));
38
+ }
39
+ return merged;
40
+ }
41
+
42
+ /**
43
+ * Copy resolved route headers onto a base headers object, overriding any
44
+ * same-key defaults. Mutates and returns `base`. No-op when `resolved` is null.
45
+ */
46
+ export function applyRouteHeaders(base: Headers, resolved: Headers | null): Headers {
47
+ if (resolved) resolved.forEach((value, key) => base.set(key, value));
48
+ return base;
49
+ }
@@ -1,5 +1,5 @@
1
1
  import { join, resolve } from "node:path";
2
- import type { RouteFile } from "./scanner.ts";
2
+ import { layoutDirsFromFilePath, type RouteFile } from "./scanner.ts";
3
3
  import type { RouteModule } from "../shared/route-types.ts";
4
4
 
5
5
  // ── Types ──────────────────────────────────────────────────────────────────
@@ -8,6 +8,12 @@ export interface LayoutChain {
8
8
  root: RouteModule;
9
9
  layouts: RouteModule[];
10
10
  route: RouteModule;
11
+ /**
12
+ * appDir-relative source paths for each module in the chain, for error
13
+ * messages ("loader error in routes/blog/[id].tsx"). Optional so hand-built
14
+ * chains in tests don't have to supply it.
15
+ */
16
+ files?: { root?: string; layouts: string[]; route?: string };
11
17
  }
12
18
 
13
19
  export interface ResolvedRoute extends RouteFile {
@@ -22,21 +28,6 @@ export interface ResolvedRoute extends RouteFile {
22
28
  */
23
29
  export type ModuleRegistry = Record<string, RouteModule | Record<string, unknown>>;
24
30
 
25
- // ── Helpers ────────────────────────────────────────────────────────────────
26
-
27
- /** Derive the ancestor directory segments from a route's urlPattern. */
28
- function layoutDirs(urlPattern: string): string[] {
29
- if (urlPattern === "") return [];
30
- const segments = urlPattern.split("/");
31
- // For "blog/[id]" → check "routes/blog/layout.tsx" only (not the leaf)
32
- segments.pop();
33
- const dirs: string[] = [];
34
- for (let i = 1; i <= segments.length; i++) {
35
- dirs.push(segments.slice(0, i).join("/"));
36
- }
37
- return dirs;
38
- }
39
-
40
31
  // ── resolveLayoutChain ─────────────────────────────────────────────────────
41
32
 
42
33
  export async function resolveLayoutChain(
@@ -52,8 +43,9 @@ export async function resolveLayoutChain(
52
43
  layoutFiles.push(rootPath);
53
44
  }
54
45
 
55
- // Intermediate layout.tsx files, outermost → innermost
56
- for (const dir of layoutDirs(routeFile.urlPattern)) {
46
+ // Intermediate layout.tsx files, outermost → innermost. Derived from the
47
+ // file path so route-group folders ((marketing)/…) contribute their layout.
48
+ for (const dir of layoutDirsFromFilePath(routeFile.filePath)) {
57
49
  const layoutPath = resolve(join(appDir, "routes", dir, "layout.tsx"));
58
50
  if (await Bun.file(layoutPath).exists()) {
59
51
  layoutFiles.push(layoutPath);
@@ -78,7 +70,7 @@ export function resolveLayoutChainFromRegistry(
78
70
  if (registry["root.tsx"]) layoutFiles.push("root.tsx");
79
71
  else if (registry["root.ts"]) layoutFiles.push("root.ts");
80
72
 
81
- for (const dir of layoutDirs(routeFile.urlPattern)) {
73
+ for (const dir of layoutDirsFromFilePath(routeFile.filePath)) {
82
74
  const tsxKey = `routes/${dir}/layout.tsx`;
83
75
  const tsKey = `routes/${dir}/layout.ts`;
84
76
  if (registry[tsxKey]) layoutFiles.push(tsxKey);
@@ -96,12 +88,24 @@ export async function importRouteModule(filePath: string): Promise<RouteModule>
96
88
  loader: mod.loader,
97
89
  action: mod.action,
98
90
  meta: mod.meta,
91
+ headers: mod.headers,
92
+ // SECURITY(high): like beforeLoad, route middleware can be an auth gate —
93
+ // project it or every `middleware` export becomes a silent no-op.
94
+ middleware: mod.middleware,
99
95
  // SECURITY(high): beforeLoad is the auth/redirect gate and `context` is the
100
96
  // per-route context factory. Both MUST be projected here — dropping them
101
97
  // turns every beforeLoad() export into a silent no-op, bypassing auth on
102
98
  // full-page GET, POST actions, and the /_data soft-nav endpoint alike.
103
99
  beforeLoad: mod.beforeLoad,
104
100
  context: mod.context,
101
+ // searchSchema gates loader input — dropping it silently skips search
102
+ // validation, so loaders would see raw strings where they expect coerced data.
103
+ searchSchema: mod.searchSchema,
104
+ // Selective-SSR surface: dropping `ssr` would silently restore full SSR
105
+ // (running loaders the route opted out of); dropping `Fallback` would
106
+ // SSR an empty outlet and guarantee a hydration mismatch.
107
+ ssr: mod.ssr,
108
+ Fallback: mod.Fallback,
105
109
  handle: mod.handle,
106
110
  ErrorBoundary: mod.ErrorBoundary,
107
111
  default: mod.default,
@@ -120,10 +124,18 @@ function pickRouteModule(mod: Record<string, unknown> | RouteModule | undefined)
120
124
  loader: m.loader as RouteModule["loader"],
121
125
  action: m.action as RouteModule["action"],
122
126
  meta: m.meta as RouteModule["meta"],
127
+ headers: m.headers as RouteModule["headers"],
128
+ // SECURITY(high): keep middleware (auth gate) — see importRouteModule.
129
+ middleware: m.middleware as RouteModule["middleware"],
123
130
  // SECURITY(high): keep beforeLoad + context in the projection — see the
124
131
  // note in importRouteModule. The compiled-binary path goes through here.
125
132
  beforeLoad: m.beforeLoad as RouteModule["beforeLoad"],
126
133
  context: m.context as unknown,
134
+ // Keep searchSchema too — see importRouteModule. Missing it here would
135
+ // skip search validation only in compiled binaries, the worst kind of skew.
136
+ searchSchema: m.searchSchema,
137
+ ssr: m.ssr as RouteModule["ssr"],
138
+ Fallback: m.Fallback as RouteModule["Fallback"],
127
139
  handle: m.handle as RouteModule["handle"],
128
140
  ErrorBoundary: m.ErrorBoundary as RouteModule["ErrorBoundary"],
129
141
  default: m.default as RouteModule["default"],
@@ -155,7 +167,12 @@ export async function resolveRouteChain(
155
167
  const layoutMods = layoutKeys.map((k) => pickRouteModule(registry[k]));
156
168
  const routeKey = routeFile.filePath.split("\\").join("/");
157
169
  const routeMod = pickRouteModule(registry[routeKey]);
158
- return { root: rootMod, layouts: layoutMods, route: routeMod };
170
+ return {
171
+ root: rootMod,
172
+ layouts: layoutMods,
173
+ route: routeMod,
174
+ files: { root: rootKey, layouts: layoutKeys, route: routeKey },
175
+ };
159
176
  }
160
177
 
161
178
  const resolved = await resolveLayoutChain(routeFile, appDir);
@@ -167,9 +184,15 @@ export async function resolveRouteChain(
167
184
  resolve(join(appDir, routeFile.filePath))
168
185
  );
169
186
 
187
+ // Relativize the absolute layout paths back to appDir-relative for messages.
188
+ const appRoot = resolve(appDir);
189
+ const rel = (abs: string) => abs.startsWith(appRoot + "/") ? abs.slice(appRoot.length + 1) : abs;
190
+ const [rootFile, ...layoutFiles] = resolved.layoutFiles.map(rel);
191
+
170
192
  return {
171
193
  root: rootMod ?? {},
172
194
  layouts: layoutMods,
173
195
  route: routeMod,
196
+ files: { root: rootFile, layouts: layoutFiles, route: routeFile.filePath },
174
197
  };
175
198
  }