@timber-js/app 0.2.0-alpha.209 → 0.2.0-alpha.210

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 (170) hide show
  1. package/dist/_chunks/{actions-BerlqoXA.js → actions-Rjk4htmA.js} +19 -25
  2. package/dist/_chunks/{actions-BerlqoXA.js.map → actions-Rjk4htmA.js.map} +1 -1
  3. package/dist/_chunks/als-registry-DaxkVjt5.js.map +1 -1
  4. package/dist/_chunks/{cache-api-CR23J_NC.js → cache-api-DGdYfNJn.js} +4 -4
  5. package/dist/_chunks/{cache-api-CR23J_NC.js.map → cache-api-DGdYfNJn.js.map} +1 -1
  6. package/dist/_chunks/{chains-CpFg56UB.js → chains-BoO51joc.js} +2 -2
  7. package/dist/_chunks/{chains-CpFg56UB.js.map → chains-BoO51joc.js.map} +1 -1
  8. package/dist/_chunks/{cli-check-C6Ev6wBO.js → cli-check-ajNY3B2e.js} +3 -3
  9. package/dist/_chunks/{cli-check-C6Ev6wBO.js.map → cli-check-ajNY3B2e.js.map} +1 -1
  10. package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js → cli-schema-sync-D2eI8jEg.js} +2 -2
  11. package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js.map → cli-schema-sync-D2eI8jEg.js.map} +1 -1
  12. package/dist/_chunks/{file-cache-Dw6BJPG7.js → codegen-Bps1sLKJ.js} +3 -29
  13. package/dist/_chunks/codegen-Bps1sLKJ.js.map +1 -0
  14. package/dist/_chunks/{convention-lint-jKTwKwPe.js → convention-lint-DLmhGsRS.js} +54 -316
  15. package/dist/_chunks/convention-lint-DLmhGsRS.js.map +1 -0
  16. package/dist/_chunks/{dev-server-C4WZdB7L.js → dev-server-v97rQH4b.js} +99 -9
  17. package/dist/_chunks/dev-server-v97rQH4b.js.map +1 -0
  18. package/dist/_chunks/{error-boundary-tA7kVfs4.js → error-boundary-DsNScGRM.js} +4 -4
  19. package/dist/_chunks/{error-boundary-tA7kVfs4.js.map → error-boundary-DsNScGRM.js.map} +1 -1
  20. package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js → json-lossy-check-CVuRs2hG.js} +2 -2
  21. package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js.map → json-lossy-check-CVuRs2hG.js.map} +1 -1
  22. package/dist/_chunks/{live-graph-Dv-JJCZw.js → live-graph-9cSnn_h9.js} +3 -3
  23. package/dist/_chunks/{live-graph-Dv-JJCZw.js.map → live-graph-9cSnn_h9.js.map} +1 -1
  24. package/dist/_chunks/{logger-DiDt5ppH.js → logger-BP0LN6vP.js} +17 -2
  25. package/dist/_chunks/{logger-DiDt5ppH.js.map → logger-BP0LN6vP.js.map} +1 -1
  26. package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -1
  27. package/dist/_chunks/navigation-root-BQfo1-kG.js.map +1 -1
  28. package/dist/_chunks/{poison-scan-CpeT6_OJ.js → poison-scan-vGV7Re0B.js} +2 -2
  29. package/dist/_chunks/{poison-scan-CpeT6_OJ.js.map → poison-scan-vGV7Re0B.js.map} +1 -1
  30. package/dist/_chunks/{scanner-Bw0oq1HB.js → scanner-DmqdxzbW.js} +392 -7
  31. package/dist/_chunks/scanner-DmqdxzbW.js.map +1 -0
  32. package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
  33. package/dist/_chunks/{sizeof-UwzwB1uM.js → sizeof-BM1409x2.js} +2 -2
  34. package/dist/_chunks/{sizeof-UwzwB1uM.js.map → sizeof-BM1409x2.js.map} +1 -1
  35. package/dist/_chunks/{status-page-marker-gaihi0KZ.js → status-page-marker-BRX9Ib-d.js} +1 -45
  36. package/dist/_chunks/status-page-marker-BRX9Ib-d.js.map +1 -0
  37. package/dist/_chunks/{walkers-BXExhzzk.js → walkers-Czu2jXFq.js} +3 -3
  38. package/dist/_chunks/{walkers-BXExhzzk.js.map → walkers-Czu2jXFq.js.map} +1 -1
  39. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  40. package/dist/analyze/crawl-entry.js +2 -2
  41. package/dist/analyze/graph-command.js +2 -2
  42. package/dist/cache/index.js +2 -2
  43. package/dist/cache/stores/memory.js +1 -1
  44. package/dist/cli.js +3 -3
  45. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  46. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  47. package/dist/client/error-boundary.js +1 -1
  48. package/dist/client/history.d.ts +0 -9
  49. package/dist/client/history.d.ts.map +1 -1
  50. package/dist/client/index.d.ts +2 -1
  51. package/dist/client/index.d.ts.map +1 -1
  52. package/dist/client/index.js +9 -3
  53. package/dist/client/index.js.map +1 -1
  54. package/dist/client/internal.js +25 -38
  55. package/dist/client/internal.js.map +1 -1
  56. package/dist/client/link.d.ts +22 -0
  57. package/dist/client/link.d.ts.map +1 -1
  58. package/dist/client/navigation-api.d.ts +14 -2
  59. package/dist/client/navigation-api.d.ts.map +1 -1
  60. package/dist/client/navigation-root.d.ts +9 -1
  61. package/dist/client/navigation-root.d.ts.map +1 -1
  62. package/dist/client/navigation-transition.d.ts +6 -1
  63. package/dist/client/navigation-transition.d.ts.map +1 -1
  64. package/dist/client/react-root.d.ts.map +1 -1
  65. package/dist/client/router-effects.d.ts +10 -3
  66. package/dist/client/router-effects.d.ts.map +1 -1
  67. package/dist/client/router-pipeline.d.ts +3 -1
  68. package/dist/client/router-pipeline.d.ts.map +1 -1
  69. package/dist/client/router-types.d.ts +65 -9
  70. package/dist/client/router-types.d.ts.map +1 -1
  71. package/dist/client/router.d.ts.map +1 -1
  72. package/dist/client/segment-cache.d.ts +0 -15
  73. package/dist/client/segment-cache.d.ts.map +1 -1
  74. package/dist/client/use-router.d.ts +13 -6
  75. package/dist/client/use-router.d.ts.map +1 -1
  76. package/dist/config-validation.d.ts +19 -2
  77. package/dist/config-validation.d.ts.map +1 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +7 -7
  80. package/dist/index.js.map +1 -1
  81. package/dist/routing/convention-lint.d.ts.map +1 -1
  82. package/dist/routing/export-detect.d.ts +19 -0
  83. package/dist/routing/export-detect.d.ts.map +1 -1
  84. package/dist/routing/index.js +3 -3
  85. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  86. package/dist/routing/scanner.d.ts.map +1 -1
  87. package/dist/routing/types.d.ts +7 -0
  88. package/dist/routing/types.d.ts.map +1 -1
  89. package/dist/server/action-handler.d.ts +1 -1
  90. package/dist/server/action-handler.d.ts.map +1 -1
  91. package/dist/server/actions.d.ts +9 -4
  92. package/dist/server/actions.d.ts.map +1 -1
  93. package/dist/server/csrf.d.ts +38 -19
  94. package/dist/server/csrf.d.ts.map +1 -1
  95. package/dist/server/error-boundary-wrapper.d.ts +8 -4
  96. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  97. package/dist/server/fallback-error.d.ts.map +1 -1
  98. package/dist/server/form-flash.d.ts +1 -1
  99. package/dist/server/index.js +2 -2
  100. package/dist/server/index.js.map +1 -1
  101. package/dist/server/internal.js +249 -16
  102. package/dist/server/internal.js.map +1 -1
  103. package/dist/server/logger.d.ts +16 -3
  104. package/dist/server/logger.d.ts.map +1 -1
  105. package/dist/server/metadata-collector.d.ts +2 -0
  106. package/dist/server/metadata-collector.d.ts.map +1 -1
  107. package/dist/server/metadata-routes.d.ts +12 -1
  108. package/dist/server/metadata-routes.d.ts.map +1 -1
  109. package/dist/server/pipeline-helpers.d.ts +29 -1
  110. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  111. package/dist/server/pipeline.d.ts +8 -1
  112. package/dist/server/pipeline.d.ts.map +1 -1
  113. package/dist/server/route-element-builder.d.ts.map +1 -1
  114. package/dist/server/route-matcher.d.ts +8 -0
  115. package/dist/server/route-matcher.d.ts.map +1 -1
  116. package/dist/server/rsc-entry/{wrap-action-dispatch.d.ts → action-dispatcher.d.ts} +18 -40
  117. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -0
  118. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  119. package/dist/server/safe-load.d.ts +5 -12
  120. package/dist/server/safe-load.d.ts.map +1 -1
  121. package/docs/api/30-api-server.mdx +1 -1
  122. package/docs/api/31-api-client.mdx +23 -20
  123. package/docs/api/34-api-config.mdx +4 -2
  124. package/package.json +1 -1
  125. package/src/client/browser-entry/action-dispatch.ts +28 -28
  126. package/src/client/browser-entry/post-hydration.ts +11 -1
  127. package/src/client/browser-entry/router-init.ts +10 -4
  128. package/src/client/history.ts +0 -22
  129. package/src/client/index.ts +2 -1
  130. package/src/client/link.tsx +30 -1
  131. package/src/client/navigation-api.ts +36 -3
  132. package/src/client/navigation-root.tsx +13 -1
  133. package/src/client/navigation-transition.ts +17 -7
  134. package/src/client/react-root.ts +11 -2
  135. package/src/client/router-effects.ts +11 -4
  136. package/src/client/router-pipeline.ts +4 -0
  137. package/src/client/router-types.ts +80 -6
  138. package/src/client/router.ts +36 -24
  139. package/src/client/segment-cache.ts +0 -65
  140. package/src/client/use-router.ts +25 -6
  141. package/src/config-validation.ts +121 -5
  142. package/src/index.ts +5 -8
  143. package/src/routing/convention-lint.ts +75 -0
  144. package/src/routing/export-detect.ts +88 -0
  145. package/src/routing/manifest-codegen.ts +6 -0
  146. package/src/routing/scanner.ts +9 -0
  147. package/src/routing/types.ts +7 -0
  148. package/src/server/action-handler.ts +14 -11
  149. package/src/server/actions.ts +37 -42
  150. package/src/server/als-registry.ts +1 -1
  151. package/src/server/csrf.ts +100 -72
  152. package/src/server/error-boundary-wrapper.ts +11 -12
  153. package/src/server/fallback-error.ts +13 -21
  154. package/src/server/form-flash.ts +1 -1
  155. package/src/server/logger.ts +19 -3
  156. package/src/server/metadata-collector.ts +4 -1
  157. package/src/server/metadata-routes.ts +23 -8
  158. package/src/server/pipeline-helpers.ts +89 -8
  159. package/src/server/pipeline.ts +27 -2
  160. package/src/server/route-element-builder.ts +1 -0
  161. package/src/server/route-matcher.ts +11 -0
  162. package/src/server/rsc-entry/{wrap-action-dispatch.ts → action-dispatcher.ts} +20 -62
  163. package/src/server/rsc-entry/index.ts +14 -27
  164. package/src/server/safe-load.ts +5 -12
  165. package/dist/_chunks/convention-lint-jKTwKwPe.js.map +0 -1
  166. package/dist/_chunks/dev-server-C4WZdB7L.js.map +0 -1
  167. package/dist/_chunks/file-cache-Dw6BJPG7.js.map +0 -1
  168. package/dist/_chunks/scanner-Bw0oq1HB.js.map +0 -1
  169. package/dist/_chunks/status-page-marker-gaihi0KZ.js.map +0 -1
  170. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +0 -1
@@ -1,19 +1,19 @@
1
1
  import { r as RenderTimeoutError, t as DEFAULT_RENDER_TIMEOUT_MS } from "../_chunks/render-timeout-DOf_BBcO.js";
2
2
  import { n as isRscContentType, r as isRscRequest } from "../_chunks/rsc-media-type-DDc7duTD.js";
3
- import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-DSDjM_hJ.js";
4
- import { r as canonicalize } from "../_chunks/canonicalize-CgHoscYO.js";
5
3
  import { n as classifyUrlSegment, r as effectiveUrlSegment } from "../_chunks/segment-classify-C539Pa2O.js";
6
4
  import { t as computeSegmentTreePaths } from "../_chunks/segment-keys-D5hu1hz4.js";
5
+ import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-DSDjM_hJ.js";
6
+ import { r as canonicalize } from "../_chunks/canonicalize-CgHoscYO.js";
7
7
  import { a as requestContextAls, r as prebuiltCaptureAls, s as timingAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-DaxkVjt5.js";
8
- import { A as getTraceId, C as isDebug, D as generateTraceId, M as runWithTraceId, N as setSpanAttribute, O as getOtelTraceId, P as withSpan, S as isRedirectSignal, _ as swallow, a as logMiddlewareShortCircuit, b as isControlFlowSignal, c as logRequestCompleted, d as logRouteSetupError, f as logSlowRequest, g as setLogger, h as logWaitUntilUnsupported, i as logMiddlewareError, j as replaceTraceId, l as logRequestReceived, m as logWaitUntilRejected, n as logCacheMiss, o as logProxyError, p as logSwrRefetchFailed, s as logRenderError, t as getLogger, u as logRouteError, x as isDenySignal } from "../_chunks/logger-DiDt5ppH.js";
8
+ import { C as isRedirectSignal, F as withSpan, M as replaceTraceId, N as runWithTraceId, O as generateTraceId, P as setSpanAttribute, S as isDenySignal, _ as setLogger, a as logMiddlewareError, c as logRenderError, d as logRouteError, f as logRouteSetupError, g as logWaitUntilUnsupported, h as logWaitUntilRejected, j as getTraceId, k as getOtelTraceId, l as logRequestCompleted, m as logSwrRefetchFailed, n as logCacheMiss, o as logMiddlewareShortCircuit, p as logSlowRequest, r as logCsrfRejected, s as logProxyError, t as getLogger, u as logRequestReceived, v as swallow, w as isDebug, x as isControlFlowSignal } from "../_chunks/logger-BP0LN6vP.js";
9
9
  import { i as collectRouteModulepreloads, n as collectRouteCss, r as collectRouteFonts } from "../_chunks/build-manifest-DWppEdLB.js";
10
10
  import { i as isRscCacheKeyShape, n as RSC_KEY_PARAM, s as rscCacheKey, t as RSC_KEY_HEADERS } from "../_chunks/rsc-cache-key-ClUiXQnK.js";
11
- import { C as setMutableCookieContext, S as setMatchedSegmentPath, T as runOutsideReactCacheScope, b as markResponseFlushed, f as getCookie, g as getHeader, h as applyRequestHeaderOverlay, i as coerce, m as getSetCookieHeaders, o as DenySignal, s as RedirectSignal, t as executeAction, v as getSearchParams, w as setSegmentParams, x as runWithRequestContext, y as getSegmentParams } from "../_chunks/actions-BerlqoXA.js";
12
- import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPayload, d as checkVersionSkew, i as storeOverlayEntry, l as setPrebuiltPayloadSource, n as lookupOverlay, o as hasPrebuiltPayloadSource, r as overlayKey, s as isParamIndependent, u as applyReloadHeaders, v as currentInvalidationEpoch, x as getWaitUntil, y as lastInvalidationEpochFor } from "../_chunks/cache-api-CR23J_NC.js";
11
+ import { C as setMutableCookieContext, S as setMatchedSegmentPath, T as runOutsideReactCacheScope, b as markResponseFlushed, f as getCookie, g as getHeader, h as applyRequestHeaderOverlay, i as coerce, m as getSetCookieHeaders, o as DenySignal, s as RedirectSignal, t as executeAction, v as getSearchParams, w as setSegmentParams, x as runWithRequestContext, y as getSegmentParams } from "../_chunks/actions-Rjk4htmA.js";
12
+ import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPayload, d as checkVersionSkew, i as storeOverlayEntry, l as setPrebuiltPayloadSource, n as lookupOverlay, o as hasPrebuiltPayloadSource, r as overlayKey, s as isParamIndependent, u as applyReloadHeaders, v as currentInvalidationEpoch, x as getWaitUntil, y as lastInvalidationEpochFor } from "../_chunks/cache-api-DGdYfNJn.js";
13
13
  import { n as createSingleflight } from "../_chunks/singleflight-BeVMMKi0.js";
14
14
  import { n as toBracketKey } from "../_chunks/resolve-schema-CBR6Lm4i.js";
15
15
  import { t as normalizeParamValue } from "../_chunks/param-value-C8TNYchQ.js";
16
- import { l as TIMBER_ERROR_HEADER, t as StatusPageMarker } from "../_chunks/status-page-marker-gaihi0KZ.js";
16
+ import { l as TIMBER_ERROR_HEADER, t as StatusPageMarker } from "../_chunks/status-page-marker-BRX9Ib-d.js";
17
17
  import { createHash } from "node:crypto";
18
18
  import { readFile } from "node:fs/promises";
19
19
  import React, { createElement, useContext } from "react";
@@ -317,16 +317,7 @@ var ModuleLoadError = class extends Error {
317
317
  * On success: returns the module object (same as `loader.load()`).
318
318
  * On failure: throws `ModuleLoadError` with file path and original cause.
319
319
  *
320
- * For error rendering paths that need fallthrough instead of throwing,
321
- * callers should catch at the call site:
322
- *
323
- * ```ts
324
- * // Throwing (default) — route-element-builder, api-handler, etc.
325
- * const mod = await loadModule(segment.page);
326
- *
327
- * // Fallthrough — error-renderer, error-boundary-wrapper
328
- * const mod = await loadModule(segment.error).catch(() => null);
329
- * ```
320
+ * Error rendering paths that need fallthrough use `loadFallbackModule()`.
330
321
  */
331
322
  async function loadModule(loader) {
332
323
  try {
@@ -347,12 +338,248 @@ function isApiRouteChain(segments) {
347
338
  const leaf = segments[segments.length - 1];
348
339
  return !!leaf?.route && !leaf.page;
349
340
  }
341
+ /**
342
+ * Whether a matched chain is a route.ts handler that opted out of the CSRF
343
+ * check with `export const csrf = false`. Keyed on the same leaf
344
+ * `isApiRouteChain` dispatches on, so the exemption can only ever apply to
345
+ * the route.ts that will actually run — never to a page, and so never to a
346
+ * server action. See design/08-forms-and-actions.md §"Per-route exemption".
347
+ */
348
+ function isCsrfExemptRoute(segments) {
349
+ return isApiRouteChain(segments) && segments[segments.length - 1]?.csrfExempt === true;
350
+ }
350
351
  var SERVE_PATH_TO_CONVENTION = /* @__PURE__ */ new Map();
351
352
  for (const [baseName, convention] of Object.entries(METADATA_ROUTE_CONVENTIONS)) if (convention.serveExtension) for (const ext of [...convention.staticExtensions, convention.serveExtension]) SERVE_PATH_TO_CONVENTION.set(`${convention.servePath}.${ext}`, baseName);
352
353
  else SERVE_PATH_TO_CONVENTION.set(convention.servePath, baseName);
353
354
  //#endregion
355
+ //#region src/shared/csrf-reject.ts
356
+ /**
357
+ * Marker header on CSRF rejections.
358
+ *
359
+ * A CSRF rejection is a 403, the same status as an access-control denial.
360
+ * The server stamps this header on every CSRF 403 so callers — the browser
361
+ * action runtime, a same-origin `fetch` in user code, a curl session — can
362
+ * tell the two apart without parsing the body. A cross-origin `fetch` cannot
363
+ * read it: the rejection returns before proxy.ts, so no CORS headers are set
364
+ * and the browser reports a network error instead. Shared so the server writer and the client
365
+ * reader cannot drift.
366
+ *
367
+ * See design/08-forms-and-actions.md §"CSRF Protection" (TIM-1459).
368
+ */
369
+ var CSRF_REJECT_HEADER = "X-Timber-Reject";
370
+ var CSRF_REJECT_VALUE = "csrf";
371
+ //#endregion
372
+ //#region src/server/csrf.ts
373
+ /**
374
+ * CSRF protection — Fetch Metadata first, Origin as the fallback.
375
+ *
376
+ * CSRF needs a victim's browser to attach cookies, and browsers mark the
377
+ * requests they send: `Sec-Fetch-Site` on HTTPS (and localhost) pages in
378
+ * every current browser, `Origin` on cross-site POSTs everywhere else. So
379
+ * the check trusts those two headers in that order, and lets a request
380
+ * carrying neither through — a webhook, curl, or a server-to-server
381
+ * `fetch`. The residual risk is browsers old enough to send neither on a
382
+ * form POST (Firefox < 70, IE11); SameSite cookies are the defense there.
383
+ * See design/08-forms-and-actions.md §"CSRF Protection".
384
+ *
385
+ * Prior art: Go's `net/http.CrossOriginProtection` (Go 1.25) and the OWASP
386
+ * CSRF cheat sheet (Fetch Metadata as a primary defense).
387
+ *
388
+ * The per-route exemption (`export const csrf = false` in route.ts) is not
389
+ * decided here: the caller owns route matching and applies it to a
390
+ * rejection. Disable globally with csrf: false (not recommended).
391
+ *
392
+ * See design/08-forms-and-actions.md §"CSRF Protection"
393
+ * See design/13-security.md §"Security Testing Checklist" #6
394
+ */
395
+ /** HTTP methods that are considered safe (no mutation). */
396
+ var SAFE_METHODS = /* @__PURE__ */ new Set([
397
+ "GET",
398
+ "HEAD",
399
+ "OPTIONS"
400
+ ]);
401
+ /**
402
+ * `Sec-Fetch-Site` values that mean the browser itself vouches for the
403
+ * request: `same-origin`, or `none` (user-initiated — typed URL, bookmark).
404
+ * Every other value, including ones no browser sends, is not same-origin.
405
+ * The header is forbidden to page scripts, so a page cannot forge it.
406
+ */
407
+ var TRUSTED_FETCH_SITES = /* @__PURE__ */ new Set(["same-origin", "none"]);
408
+ /**
409
+ * Derive the request's scheme from trusted sources.
410
+ *
411
+ * Priority:
412
+ * 1. X-Forwarded-Proto (set by reverse proxies / load balancers)
413
+ * 2. Request URL protocol
414
+ *
415
+ * Falls back to 'https' (fail closed) if neither is available.
416
+ */
417
+ function deriveRequestScheme(req) {
418
+ const forwarded = req.headers.get("X-Forwarded-Proto");
419
+ if (forwarded) {
420
+ const proto = forwarded.split(",")[0].trim().toLowerCase();
421
+ if (proto === "http" || proto === "https") return proto;
422
+ }
423
+ try {
424
+ return new URL(req.url).protocol.replace(":", "");
425
+ } catch {
426
+ return "https";
427
+ }
428
+ }
429
+ /**
430
+ * Decide whether an unsafe-method request may proceed.
431
+ *
432
+ * In order:
433
+ * 1. `Sec-Fetch-Site` is `same-origin` or `none` → allow.
434
+ * 2. `Sec-Fetch-Site` has any other value → allow only if `Origin` is in
435
+ * `allowedOrigins`.
436
+ * 3. No `Sec-Fetch-Site`, `Origin` present (a plain-HTTP page, or an
437
+ * older browser) → `Origin` must be in `allowedOrigins` or equal the
438
+ * origin derived from the scheme and `Host`.
439
+ * 4. Neither header → allow: not a browser, so no ambient cookies to abuse.
440
+ *
441
+ * Safe methods (GET, HEAD, OPTIONS) and `csrf: false` always pass.
442
+ */
443
+ function validateCsrf(req, config) {
444
+ if (SAFE_METHODS.has(req.method)) return { ok: true };
445
+ if (config.csrf === false) return { ok: true };
446
+ const fetchSite = req.headers.get("Sec-Fetch-Site");
447
+ const origin = req.headers.get("Origin");
448
+ const rejection = (reason, expected) => ({
449
+ ok: false,
450
+ status: 403,
451
+ reason,
452
+ origin,
453
+ fetchSite,
454
+ expected
455
+ });
456
+ if (fetchSite !== null) {
457
+ if (TRUSTED_FETCH_SITES.has(fetchSite)) return { ok: true };
458
+ if (origin !== null && config.allowedOrigins?.includes(origin)) return { ok: true };
459
+ return rejection("cross-origin", config.allowedOrigins ?? []);
460
+ }
461
+ if (origin === null) return { ok: true };
462
+ const allowed = config.allowedOrigins ?? [];
463
+ if (allowed.includes(origin)) return { ok: true };
464
+ const host = req.headers.get("Host");
465
+ if (!host) return rejection("missing-host", allowed);
466
+ let originOrigin;
467
+ try {
468
+ originOrigin = new URL(origin).origin;
469
+ } catch {
470
+ return rejection("invalid-origin", allowed);
471
+ }
472
+ let expectedOrigin;
473
+ try {
474
+ expectedOrigin = new URL(`${deriveRequestScheme(req)}://${host}`).origin;
475
+ } catch {
476
+ return rejection("invalid-host", allowed);
477
+ }
478
+ return originOrigin === expectedOrigin ? { ok: true } : rejection("origin-mismatch", [expectedOrigin, ...allowed]);
479
+ }
480
+ /**
481
+ * Longest request-derived value echoed into the server log. Origin, Host,
482
+ * Sec-Fetch-Site, and the path are attacker-controlled; a request can carry
483
+ * kilobytes in each, and every rejection writes a log line. Real values are
484
+ * far shorter.
485
+ */
486
+ var MAX_LOGGED_VALUE = 200;
487
+ function clip(value) {
488
+ return value.length > MAX_LOGGED_VALUE ? `${value.slice(0, MAX_LOGGED_VALUE)}…` : value;
489
+ }
490
+ /**
491
+ * Clip and quote a request-derived value for the log. JSON quoting escapes
492
+ * quotes and control characters, so a crafted header cannot close the quote
493
+ * and forge `key=value` fields or a second line.
494
+ */
495
+ function quote(value) {
496
+ return JSON.stringify(clip(value));
497
+ }
498
+ var PROXY_HINT = "If the app runs behind a proxy that rewrites Host or terminates TLS, forward the original Host and X-Forwarded-Proto, or list the public origin in allowedOrigins in timber.config.ts.";
499
+ var EXEMPTION_HINT = "If this route.ts must accept cross-site browser requests (OIDC form_post, SAML, 3-D Secure returns), add `export const csrf = false` to it.";
500
+ /**
501
+ * Browsers send `Origin: null` from opaque contexts: sandboxed iframes, a POST
502
+ * redirected across origins, file: pages. Every such context shares the one
503
+ * value — an attacker's `<iframe sandbox>` included — so config validation
504
+ * refuses it in `allowedOrigins` and the hint points at the exemption instead.
505
+ */
506
+ var OPAQUE_ORIGIN_DETAIL = `origin "null" is an opaque origin (sandboxed iframe, a POST redirected across origins, or a file: page). allowedOrigins cannot admit it: every opaque context sends "null", including an attacker's sandboxed iframe. ${EXEMPTION_HINT}`;
507
+ /** One sentence per rejection path: what was observed, and what to change. */
508
+ function describeRejection(r, host) {
509
+ const opaque = r.origin === "null";
510
+ const origin = quote(r.origin ?? "");
511
+ const expected = r.expected.map(quote).join(", ");
512
+ switch (r.reason) {
513
+ case "cross-origin": {
514
+ if (opaque) return OPAQUE_ORIGIN_DETAIL;
515
+ const from = r.origin === null ? "with no Origin header" : `from origin ${origin}`;
516
+ const allowed = r.expected.length > 0 ? ` and is not in allowedOrigins [${expected}]` : "";
517
+ return `Sec-Fetch-Site ${quote(r.fetchSite ?? "")} request ${from}: the browser reports it is not same-origin${allowed}. If this origin is legitimate, add it to allowedOrigins in timber.config.ts. ${EXEMPTION_HINT}`;
518
+ }
519
+ case "missing-host": return `origin ${origin} could not be checked: request has no Host header. ${PROXY_HINT}`;
520
+ case "invalid-host": return `origin ${origin} could not be checked: Host header ${quote(host ?? "")} is not a valid host. ${PROXY_HINT}`;
521
+ case "invalid-origin":
522
+ if (opaque) return OPAQUE_ORIGIN_DETAIL;
523
+ return `Origin header ${origin} is not a valid origin.`;
524
+ case "origin-mismatch": {
525
+ const [own, ...listed] = r.expected.map(quote);
526
+ return `origin ${origin} does not match the request origin ${own}${listed.length > 0 ? ` or allowedOrigins [${listed.join(", ")}]` : ""}. ${PROXY_HINT}`;
527
+ }
528
+ }
529
+ }
530
+ /**
531
+ * Turn a failed check into the response the client sees, and log why.
532
+ *
533
+ * The response is a 403 marked as a CSRF rejection (header + plain-text
534
+ * body) so it cannot be mistaken for an access-control 403. It never
535
+ * carries the observed or expected origins — `allowedOrigins` is server
536
+ * configuration, and a Host-derived origin can be an internal hostname
537
+ * behind a proxy. The server log gets the full diagnosis.
538
+ */
539
+ function csrfRejectionResponse(req, rejection) {
540
+ logCsrfRejected(describeRejection(rejection, req.headers.get("Host")), {
541
+ method: req.method,
542
+ path: clip(new URL(req.url).pathname),
543
+ reason: rejection.reason
544
+ });
545
+ return new Response("Forbidden: CSRF origin check failed\n", {
546
+ status: rejection.status,
547
+ headers: {
548
+ "Content-Type": "text/plain; charset=utf-8",
549
+ [CSRF_REJECT_HEADER]: CSRF_REJECT_VALUE
550
+ }
551
+ });
552
+ }
553
+ //#endregion
354
554
  //#region src/server/pipeline-helpers.ts
355
555
  /**
556
+ * The pipeline-boundary CSRF gate: the 403 to answer with, or null to go on.
557
+ *
558
+ * Runs on every request after canonicalization and before proxy.ts, so it
559
+ * covers route.ts handlers and server actions alike, and no user code has
560
+ * run when it decides. The per-route exemption is looked up only for a
561
+ * request the check would reject — the common accepted request never pays
562
+ * for a match. The lookup uses the pipeline's own matcher on the canonical
563
+ * path, the same match `handleRequest` dispatches on, and reads a flag the
564
+ * scanner took from route.ts source, so deciding it loads no module.
565
+ *
566
+ * A matcher that throws leaves the request unexempted: fail closed.
567
+ *
568
+ * See design/08-forms-and-actions.md §"CSRF Protection".
569
+ */
570
+ function csrfGate(req, canonicalPath, csrf, matchRoute) {
571
+ const result = validateCsrf(req, csrf);
572
+ if (result.ok) return null;
573
+ let exempt = false;
574
+ try {
575
+ const match = matchRoute(canonicalPath);
576
+ exempt = match !== null && isCsrfExemptRoute(match.segments);
577
+ } catch (err) {
578
+ swallow(err, "csrf gate: route match for exemption lookup threw");
579
+ }
580
+ return exempt ? null : csrfRejectionResponse(req, result);
581
+ }
582
+ /**
356
583
  * Build a proxy resolver closure from the declared source. Called exactly
357
584
  * once at `createPipeline` setup time, so the hot path sees only the branch
358
585
  * that corresponds to this pipeline's configured variant.
@@ -1784,6 +2011,7 @@ function createPipeline(config) {
1784
2011
  const canonResult = canonicalize(url.pathname, stripTrailingSlash);
1785
2012
  if (!canonResult.ok) {
1786
2013
  if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${url.pathname} — responding with ${canonResult.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
2014
+ await setSpanAttribute("http.response.status_code", canonResult.status);
1787
2015
  return new Response(null, { status: canonResult.status });
1788
2016
  }
1789
2017
  const canonicalPath = canonResult.pathname;
@@ -1793,6 +2021,11 @@ function createPipeline(config) {
1793
2021
  canonicalUrl.pathname = canonicalPath;
1794
2022
  canonicalReq = new Request(canonicalUrl.toString(), req);
1795
2023
  }
2024
+ const csrfRejection = csrfGate(canonicalReq, canonicalPath, config.csrf ?? {}, config.matchRoute);
2025
+ if (csrfRejection) {
2026
+ await setSpanAttribute("http.response.status_code", csrfRejection.status);
2027
+ return csrfRejection;
2028
+ }
1796
2029
  const innerHandler = async () => {
1797
2030
  if (config.dispatchAction) {
1798
2031
  const actionResult = await config.dispatchAction(canonicalReq, canonicalPath, pipelineFn);