@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
@@ -425,6 +425,94 @@ export function getPrerenderExport(filePath: string): PrerenderExportValue | und
425
425
  }
426
426
  }
427
427
 
428
+ /**
429
+ * How a file declares the per-route CSRF exemption.
430
+ *
431
+ * - `'absent'` — no runtime export named `csrf`.
432
+ * - `'exempt'` — exactly `export const csrf = false` (wrappers like
433
+ * `false as const` allowed), declared in this file.
434
+ * - `'invalid'` — some other `csrf` export: another value, `let`, a
435
+ * destructured binding, a specifier or named re-export (`export { csrf }`,
436
+ * `export { x as "csrf" }`), a function or class. `export * from` is not
437
+ * followed: a `csrf` it carries is neither exempt nor an error.
438
+ *
439
+ * The exemption is read from source at build time, never from the loaded
440
+ * module, because the CSRF gate runs before any user code (see
441
+ * design/08-forms-and-actions.md §"Per-route exemption"). Only the literal
442
+ * form can be read that way; any other spelling is a build error rather
443
+ * than a silent "not exempt", so what the developer wrote is what runs.
444
+ */
445
+ export type CsrfExport = 'absent' | 'exempt' | 'invalid';
446
+
447
+ export function getCsrfExport(filePath: string): CsrfExport {
448
+ if (!existsSync(filePath)) return 'absent';
449
+ // Unparseable: not exempt (fail closed). The compiler reports the syntax
450
+ // error; guessing at a half-written file would misreport it.
451
+ const program = tryParse(readFileCached(filePath));
452
+ if (!program) return 'absent';
453
+
454
+ // Walk the statements directly rather than through `collectNamedExports`:
455
+ // every spelling that exports a runtime `csrf` must be seen here, and
456
+ // that helper does not see destructured declarations or string-literal
457
+ // specifier names. What cannot be seen at all — `export * from` — is
458
+ // not read, and is documented as such.
459
+ let found: CsrfExport = 'absent';
460
+ for (const stmt of program.body) {
461
+ if (stmt.type !== 'ExportNamedDeclaration' || stmt.exportKind === 'type') continue;
462
+ for (const spec of stmt.specifiers ?? []) {
463
+ if (spec.exportKind !== 'type' && specifierName(spec.exported) === 'csrf') {
464
+ return 'invalid';
465
+ }
466
+ }
467
+ const decl = stmt.declaration as (AstNode & { kind?: string }) | undefined;
468
+ if (!decl) continue;
469
+ if (decl.id?.name === 'csrf') return 'invalid'; // function or class
470
+ if (decl.type !== 'VariableDeclaration') continue;
471
+ for (const d of decl.declarations ?? []) {
472
+ const id = d.id as AstNode | undefined;
473
+ if (id?.type === 'Identifier') {
474
+ if (id.name !== 'csrf') continue;
475
+ const init = d.init && (unwrapTransparent(d.init) as AstNode & { value?: unknown });
476
+ const literalFalse = init?.type === 'Literal' && init.value === false;
477
+ if (decl.kind !== 'const' || !literalFalse) return 'invalid';
478
+ found = 'exempt';
479
+ } else if (id && patternBinds(id, 'csrf')) {
480
+ return 'invalid'; // `export const { csrf } = …`
481
+ }
482
+ }
483
+ }
484
+ return found;
485
+ }
486
+
487
+ /** An export specifier's name: `csrf` or the string literal `"csrf"`. */
488
+ function specifierName(node: { name?: string; value?: unknown } | undefined): string | undefined {
489
+ if (!node) return undefined;
490
+ return typeof node.value === 'string' ? node.value : node.name;
491
+ }
492
+
493
+ /** Whether a destructuring pattern binds `name` anywhere inside it. */
494
+ function patternBinds(node: unknown, name: string): boolean {
495
+ if (!node || typeof node !== 'object') return false;
496
+ const n = node as Record<string, unknown>;
497
+ switch (n.type) {
498
+ case 'Identifier':
499
+ return n.name === name;
500
+ case 'ObjectPattern':
501
+ return ((n.properties as unknown[]) ?? []).some((prop) => {
502
+ const p = prop as Record<string, unknown>;
503
+ return patternBinds(p.type === 'RestElement' ? p.argument : p.value, name);
504
+ });
505
+ case 'ArrayPattern':
506
+ return ((n.elements as unknown[]) ?? []).some((el) => patternBinds(el, name));
507
+ case 'RestElement':
508
+ return patternBinds(n.argument, name);
509
+ case 'AssignmentPattern':
510
+ return patternBinds(n.left, name);
511
+ default:
512
+ return false;
513
+ }
514
+ }
515
+
428
516
  /**
429
517
  * Check if a file starts with a specific directive (e.g. "use client").
430
518
  * Directives are string literal expression statements at the top of the file.
@@ -105,6 +105,12 @@ export function generateManifestModule(tree: RouteTree, viteRoot: string): strin
105
105
  }
106
106
  }
107
107
 
108
+ // Read from route.ts source at scan time; the CSRF gate consults it
109
+ // before any user module loads (design/08 §"Per-route exemption").
110
+ if (node.csrfExempt) {
111
+ parts.push(`${nextIndent}csrfExempt: true,`);
112
+ }
113
+
108
114
  // Record-shaped conventions — table-driven
109
115
  for (const key of RECORD_FILE_KEYS) {
110
116
  const record = node[key];
@@ -25,6 +25,7 @@ import { validateSlotPlacement } from './slot-placement.ts';
25
25
  import { DEFAULT_PAGE_EXTENSIONS } from './types.ts';
26
26
  import { classifyMetadataRoute, isDynamicMetadataExtension } from '../server/metadata-routes.ts';
27
27
  import { swallow } from '../server/logger.ts';
28
+ import { getCsrfExport } from './export-detect.ts';
28
29
  import { ENCODED_SEPARATOR_RE, NULL_BYTE_RE } from '../server/canonicalize.ts';
29
30
 
30
31
  /**
@@ -303,6 +304,14 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
303
304
  `A URL is either an API endpoint or a rendered page, not both.`
304
305
  );
305
306
  }
307
+
308
+ // The per-route CSRF exemption is read from source here, at scan time, so
309
+ // the manifest carries it and the request gate never loads user code to
310
+ // decide it. Any other spelling of the export is a lint error
311
+ // (convention-lint.ts `checkCsrfExports`).
312
+ if (node.route && getCsrfExport(node.route.filePath) === 'exempt') {
313
+ node.csrfExempt = true;
314
+ }
306
315
  }
307
316
 
308
317
  /**
@@ -96,6 +96,13 @@ export interface SegmentNode<TFile = RouteFile> {
96
96
  middleware?: TFile;
97
97
  access?: TFile;
98
98
  route?: TFile;
99
+ /**
100
+ * Set when this segment's route.ts declares `export const csrf = false`.
101
+ * Read from source by the scanner, so the CSRF gate can consult it before
102
+ * any user module loads. See design/08-forms-and-actions.md
103
+ * §"Per-route exemption".
104
+ */
105
+ csrfExempt?: true;
99
106
  error?: TFile;
100
107
  default?: TFile;
101
108
  /** Status-code files: 4xx.tsx, 5xx.tsx, {status}.tsx (component format) */
@@ -73,7 +73,7 @@ export interface ActionDispatchConfig {
73
73
  *
74
74
  * **Important:** This function returns true for ANY POST with a form
75
75
  * Content-Type, including non-action POSTs to route.ts API handlers.
76
- * The caller (wrap-action-dispatch.ts) MUST check the matched route type
76
+ * The caller (rsc-entry/action-dispatcher.ts) MUST check the matched route type
77
77
  * before entering the action path — route.ts matches skip action detection
78
78
  * entirely so their body is not pre-parsed. See TIM-870.
79
79
  */
@@ -148,11 +148,12 @@ export async function handleActionRequest(
148
148
 
149
149
  // CSRF validation — reject cross-origin mutation requests.
150
150
  //
151
- // Defense-in-depth: the pipeline boundary in rsc-entry/index.ts already
152
- // validates Origin on every unsafe-method request before dispatch reaches
153
- // here, so this call is a no-op on the happy path. It is intentionally
154
- // retained so that handleActionRequest remains safe to call from any
155
- // future entry point that bypasses the wrapper. See LOCAL-773.
151
+ // Defense-in-depth: the pipeline's CSRF gate (`csrfGate`) already checks
152
+ // every unsafe-method request before dispatch reaches here, so this call
153
+ // is a no-op on the happy path. It is intentionally retained so that
154
+ // handleActionRequest remains safe to call from any entry point that
155
+ // bypasses the gate — and it never applies the per-route exemption, which
156
+ // is for route.ts only. See LOCAL-773, TIM-1543.
156
157
  const csrfResult = validateCsrf(req, config.csrf);
157
158
  if (!csrfResult.ok) {
158
159
  return csrfRejectionResponse(req, csrfResult);
@@ -398,8 +399,8 @@ async function handleRscAction(
398
399
  'Content-Type': RSC_CONTENT_TYPE,
399
400
  'X-Timber-Redirect': result.redirectTo,
400
401
  };
401
- if (result.invalidatedPaths && result.invalidatedPaths.length > 0) {
402
- redirectHeaders['X-Timber-Revalidation-Path'] = result.invalidatedPaths.join(' ');
402
+ if (result.onlyOtherPagesNamed) {
403
+ redirectHeaders['X-Timber-Invalidated'] = '1';
403
404
  }
404
405
  return new Response(rscStream, { status: 200, headers: redirectHeaders });
405
406
  }
@@ -412,9 +413,11 @@ async function handleRscAction(
412
413
  'Content-Type': RSC_CONTENT_TYPE,
413
414
  };
414
415
 
415
- // TIM-1454: Tell the client which paths were invalidated (non-matching).
416
- if (result.invalidatedPaths && result.invalidatedPaths.length > 0) {
417
- headers['X-Timber-Revalidation-Path'] = result.invalidatedPaths.join(' ');
416
+ // TIM-1454: Tell the client that revalidatePath named only other pages. A
417
+ // flag, not a path list: the client evicts every cached payload either
418
+ // way (TIM-1461).
419
+ if (result.onlyOtherPagesNamed) {
420
+ headers['X-Timber-Invalidated'] = '1';
418
421
  }
419
422
 
420
423
  if (result.revalidation) {
@@ -81,11 +81,16 @@ export interface ActionHandlerResult {
81
81
  /** Revalidation result if revalidatePath was called (element tree, not yet serialized). */
82
82
  revalidation?: RevalidationResult;
83
83
  /**
84
- * Paths passed to `revalidatePath()` that don't match the current page
85
- * (TIM-1454). The client invalidates its caches for these so the next
86
- * navigation fetches fresh data.
84
+ * True when `revalidatePath()` was called only for pages other than the
85
+ * current one (TIM-1454). The client then evicts its caches and skips the
86
+ * refresh — nothing on screen was named. Whenever the current page was
87
+ * named this is false, even if other pages were named too: its re-render,
88
+ * or the refresh that replaces a dropped one, re-renders every layout it
89
+ * shares with them. Which paths were named is not reported: the client
90
+ * evicts every cached payload after any revalidation (TIM-1476), so a path
91
+ * list would carry nothing it acts on (TIM-1461).
87
92
  */
88
- invalidatedPaths?: string[];
93
+ onlyOtherPagesNamed: boolean;
89
94
  /** Redirect location if a RedirectSignal was thrown during revalidation. */
90
95
  redirectTo?: string;
91
96
  /** Redirect status code. */
@@ -166,13 +171,6 @@ export function revalidateTag(tag: string): Promise<void> {
166
171
  return Promise.resolve();
167
172
  }
168
173
 
169
- // ─── Helpers ────────────────────────────────────────────────────────────
170
-
171
- function stripFragment(path: string): string {
172
- const idx = path.indexOf('#');
173
- return idx >= 0 ? path.slice(0, idx) : path;
174
- }
175
-
176
174
  // ─── Action Handler ──────────────────────────────────────────────────────
177
175
 
178
176
  /**
@@ -251,37 +249,35 @@ export async function executeAction(
251
249
 
252
250
  // Process path revalidation — build element tree (not yet serialized).
253
251
  //
254
- // TIM-1454: Only render the path if it matches the current page. Non-matching
255
- // paths are collected as `invalidatedPaths` — the client evicts them from its
256
- // caches so the next navigation fetches fresh data.
252
+ // TIM-1454: Only a path that matches the current page is rendered. When
253
+ // every call names another page, `onlyOtherPagesNamed` tells the client to
254
+ // evict its caches instead. The matching path is looked for across every
255
+ // call, not just the first: an action that calls revalidatePath('/other')
256
+ // and then revalidatePath('/current') must still re-render the current
257
+ // page (TIM-1461).
257
258
  let revalidation: RevalidationResult | undefined;
258
- let invalidatedPaths: string[] | undefined;
259
-
260
- if (state.paths.length > 0) {
261
- const path = state.paths[0];
259
+ const { requestPathname, requestSearch } = config;
260
+ const isCurrentPage = (path: string) =>
261
+ !requestPathname || revalidationPathMatches(path, requestPathname, requestSearch);
262
+ const path = state.paths.find(isCurrentPage);
263
+ const onlyOtherPagesNamed = state.paths.length > 0 && path === undefined;
262
264
 
263
- if (
264
- config.requestPathname &&
265
- !revalidationPathMatches(path, config.requestPathname, config.requestSearch)
266
- ) {
267
- invalidatedPaths = [stripFragment(path)];
268
- } else if (config.renderer) {
269
- try {
270
- revalidation = await config.renderer(path);
271
- } catch (renderError) {
272
- if (isRedirectSignal(renderError)) {
273
- redirectTo = renderError.location;
274
- redirectStatus = renderError.status;
275
- } else if (isDenySignal(renderError)) {
276
- console.error(
277
- `[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`
278
- );
279
- } else if (renderError instanceof RevalidationDropped) {
280
- console.error(`[timber] ${renderError.message}`);
281
- } else {
282
- console.error('[timber] revalidatePath render failed:', renderError);
283
- revalidationErrors.push(renderError);
284
- }
265
+ if (path !== undefined && config.renderer) {
266
+ try {
267
+ revalidation = await config.renderer(path);
268
+ } catch (renderError) {
269
+ if (isRedirectSignal(renderError)) {
270
+ redirectTo = renderError.location;
271
+ redirectStatus = renderError.status;
272
+ } else if (isDenySignal(renderError)) {
273
+ console.error(
274
+ `[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`
275
+ );
276
+ } else if (renderError instanceof RevalidationDropped) {
277
+ console.error(`[timber] ${renderError.message}`);
278
+ } else {
279
+ console.error('[timber] revalidatePath render failed:', renderError);
280
+ revalidationErrors.push(renderError);
285
281
  }
286
282
  }
287
283
  }
@@ -289,7 +285,7 @@ export async function executeAction(
289
285
  return {
290
286
  actionResult,
291
287
  revalidation,
292
- invalidatedPaths,
288
+ onlyOtherPagesNamed,
293
289
  revalidationErrors,
294
290
  ...(redirectTo ? { redirectTo, redirectStatus } : {}),
295
291
  };
@@ -297,7 +293,6 @@ export async function executeAction(
297
293
 
298
294
  /**
299
295
  * Check if a `revalidatePath(path)` argument matches the request pathname.
300
- /**
301
296
  * Canonicalizes the revalidated path the same way the renderer does so
302
297
  * equivalent paths compare equal. Returns true when the paths match or
303
298
  * when comparison is inconclusive (validation failure → render to be safe).
@@ -238,7 +238,7 @@ export const revalidationAls = getOrCreateAls<RevalidationState>(
238
238
 
239
239
  // ─── Form Flash ───────────────────────────────────────────────────────────
240
240
  // Used by: form-flash.ts (getFormFlash())
241
- // Design doc: design/08-forms-and-actions.md §"No-JS Error Round-Trip"
241
+ // Design doc: design/08-forms-and-actions.md §"No-JS Result Round-Trip"
242
242
 
243
243
  /** @internal — import via form-flash.ts public API */
244
244
  export const formFlashAls = getOrCreateAls<import('./form-flash.ts').FormFlashData>(
@@ -1,9 +1,21 @@
1
1
  /**
2
- * CSRF protection — Origin header validation.
2
+ * CSRF protection — Fetch Metadata first, Origin as the fallback.
3
3
  *
4
- * Auto-derived from the Host header for single-origin deployments.
5
- * Configurable via allowedOrigins for multi-origin setups.
6
- * Disable with csrf: false (not recommended outside local dev).
4
+ * CSRF needs a victim's browser to attach cookies, and browsers mark the
5
+ * requests they send: `Sec-Fetch-Site` on HTTPS (and localhost) pages in
6
+ * every current browser, `Origin` on cross-site POSTs everywhere else. So
7
+ * the check trusts those two headers in that order, and lets a request
8
+ * carrying neither through — a webhook, curl, or a server-to-server
9
+ * `fetch`. The residual risk is browsers old enough to send neither on a
10
+ * form POST (Firefox < 70, IE11); SameSite cookies are the defense there.
11
+ * See design/08-forms-and-actions.md §"CSRF Protection".
12
+ *
13
+ * Prior art: Go's `net/http.CrossOriginProtection` (Go 1.25) and the OWASP
14
+ * CSRF cheat sheet (Fetch Metadata as a primary defense).
15
+ *
16
+ * The per-route exemption (`export const csrf = false` in route.ts) is not
17
+ * decided here: the caller owns route matching and applies it to a
18
+ * rejection. Disable globally with csrf: false (not recommended).
7
19
  *
8
20
  * See design/08-forms-and-actions.md §"CSRF Protection"
9
21
  * See design/13-security.md §"Security Testing Checklist" #6
@@ -15,33 +27,36 @@ import { CSRF_REJECT_HEADER, CSRF_REJECT_VALUE } from '../shared/csrf-reject.ts'
15
27
  // ─── Types ────────────────────────────────────────────────────────────────
16
28
 
17
29
  export interface CsrfConfig {
18
- /** Explicit list of allowed origins. Replaces Host-based auto-derivation. */
30
+ /**
31
+ * Origins trusted in addition to the request's own. Canonical origins
32
+ * only (`new URL(x).origin === x`); `validateConfig` enforces that.
33
+ */
19
34
  allowedOrigins?: string[];
20
35
  /** Set to false to disable CSRF validation entirely. */
21
36
  csrf?: boolean;
22
37
  }
23
38
 
24
- /** Why a request failed the Origin check — one value per rejection path. */
39
+ /** Why a request failed the check — one value per rejection path. */
25
40
  export type CsrfRejectReason =
26
- | 'missing-origin'
27
- | 'origin-not-allowed'
41
+ | 'cross-origin'
28
42
  | 'missing-host'
29
43
  | 'invalid-host'
30
44
  | 'invalid-origin'
31
45
  | 'origin-mismatch';
32
46
 
33
47
  /**
34
- * A failed Origin check. `origin` is the request's Origin header (null when
35
- * absent); `expected` is the origin(s) it was compared against — the
36
- * `allowedOrigins` list, or the one origin derived from the Host header.
37
- * `expected` is empty when the check failed before an expected origin could
38
- * be determined. Both are for the server log only — never the response.
48
+ * A failed check. `origin` is the request's Origin header and `fetchSite`
49
+ * its Sec-Fetch-Site header (null when absent); `expected` is the origin(s)
50
+ * `origin` was compared against: the `allowedOrigins` list, preceded on
51
+ * `origin-mismatch` by the origin derived from the Host header. All three are for
52
+ * the server log only — never the response.
39
53
  */
40
54
  export interface CsrfRejection {
41
55
  ok: false;
42
56
  status: 403;
43
57
  reason: CsrfRejectReason;
44
58
  origin: string | null;
59
+ fetchSite: string | null;
45
60
  expected: readonly string[];
46
61
  }
47
62
 
@@ -52,6 +67,14 @@ export type CsrfResult = { ok: true } | CsrfRejection;
52
67
  /** HTTP methods that are considered safe (no mutation). */
53
68
  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
54
69
 
70
+ /**
71
+ * `Sec-Fetch-Site` values that mean the browser itself vouches for the
72
+ * request: `same-origin`, or `none` (user-initiated — typed URL, bookmark).
73
+ * Every other value, including ones no browser sends, is not same-origin.
74
+ * The header is forbidden to page scripts, so a page cannot forge it.
75
+ */
76
+ const TRUSTED_FETCH_SITES = new Set(['same-origin', 'none']);
77
+
55
78
  // ─── Implementation ───────────────────────────────────────────────────────
56
79
 
57
80
  /**
@@ -78,46 +101,53 @@ function deriveRequestScheme(req: Request): string {
78
101
  }
79
102
 
80
103
  /**
81
- * Validate the Origin header against the request's full origin
82
- * (scheme + host + port).
104
+ * Decide whether an unsafe-method request may proceed.
83
105
  *
84
- * For mutation methods (POST, PUT, PATCH, DELETE):
85
- * - If `csrf: false`, skip validation.
86
- * - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
87
- * - Otherwise, Origin must match the derived request origin.
106
+ * In order:
107
+ * 1. `Sec-Fetch-Site` is `same-origin` or `none` → allow.
108
+ * 2. `Sec-Fetch-Site` has any other value → allow only if `Origin` is in
109
+ * `allowedOrigins`.
110
+ * 3. No `Sec-Fetch-Site`, `Origin` present (a plain-HTTP page, or an
111
+ * older browser) → `Origin` must be in `allowedOrigins` or equal the
112
+ * origin derived from the scheme and `Host`.
113
+ * 4. Neither header → allow: not a browser, so no ambient cookies to abuse.
88
114
  *
89
- * Safe methods (GET, HEAD, OPTIONS) always pass.
115
+ * Safe methods (GET, HEAD, OPTIONS) and `csrf: false` always pass.
90
116
  */
91
117
  export function validateCsrf(req: Request, config: CsrfConfig): CsrfResult {
92
- // Safe methods don't need CSRF protection
93
- if (SAFE_METHODS.has(req.method)) {
94
- return { ok: true };
95
- }
96
-
97
- // Explicitly disabled
98
- if (config.csrf === false) {
99
- return { ok: true };
100
- }
118
+ if (SAFE_METHODS.has(req.method)) return { ok: true };
119
+ if (config.csrf === false) return { ok: true };
101
120
 
121
+ const fetchSite = req.headers.get('Sec-Fetch-Site');
102
122
  const origin = req.headers.get('Origin');
123
+ const rejection = (reason: CsrfRejectReason, expected: readonly string[]): CsrfRejection => ({
124
+ ok: false,
125
+ status: 403,
126
+ reason,
127
+ origin,
128
+ fetchSite,
129
+ expected,
130
+ });
103
131
 
104
- // No Origin header on a mutation → reject
105
- if (!origin) {
106
- return reject('missing-origin', null, []);
132
+ // Steps 1–2: the browser told us where the request came from.
133
+ if (fetchSite !== null) {
134
+ if (TRUSTED_FETCH_SITES.has(fetchSite)) return { ok: true };
135
+ if (origin !== null && config.allowedOrigins?.includes(origin)) return { ok: true };
136
+ return rejection('cross-origin', config.allowedOrigins ?? []);
107
137
  }
108
138
 
109
- // If allowedOrigins is configured, use that instead of Host-based derivation
110
- if (config.allowedOrigins) {
111
- return config.allowedOrigins.includes(origin)
112
- ? { ok: true }
113
- : reject('origin-not-allowed', origin, config.allowedOrigins);
114
- }
139
+ // Step 4: no browser marker at all.
140
+ if (origin === null) return { ok: true };
141
+
142
+ // Step 3: Origin only. The request's own origin, or a listed one — the
143
+ // same two sources step 2 trusts, so a same-origin POST from a page that
144
+ // gets no Fetch Metadata (plain HTTP) never needs listing (Go's
145
+ // CrossOriginProtection makes the same union).
146
+ const allowed = config.allowedOrigins ?? [];
147
+ if (allowed.includes(origin)) return { ok: true };
115
148
 
116
- // Auto-derive from Host header
117
149
  const host = req.headers.get('Host');
118
- if (!host) {
119
- return reject('missing-host', origin, []);
120
- }
150
+ if (!host) return rejection('missing-host', allowed);
121
151
 
122
152
  // Compare full origins (scheme + host + port) using URL.origin for
123
153
  // canonical port normalization (e.g. https://x:443 → https://x).
@@ -125,36 +155,28 @@ export function validateCsrf(req: Request, config: CsrfConfig): CsrfResult {
125
155
  try {
126
156
  originOrigin = new URL(origin).origin;
127
157
  } catch {
128
- return reject('invalid-origin', origin, []);
158
+ return rejection('invalid-origin', allowed);
129
159
  }
130
160
 
131
- const scheme = deriveRequestScheme(req);
132
161
  let expectedOrigin: string;
133
162
  try {
134
- expectedOrigin = new URL(`${scheme}://${host}`).origin;
163
+ expectedOrigin = new URL(`${deriveRequestScheme(req)}://${host}`).origin;
135
164
  } catch {
136
- return reject('invalid-host', origin, []);
165
+ return rejection('invalid-host', allowed);
137
166
  }
138
167
 
139
168
  return originOrigin === expectedOrigin
140
169
  ? { ok: true }
141
- : reject('origin-mismatch', origin, [expectedOrigin]);
142
- }
143
-
144
- function reject(
145
- reason: CsrfRejectReason,
146
- origin: string | null,
147
- expected: readonly string[]
148
- ): CsrfRejection {
149
- return { ok: false, status: 403, reason, origin, expected };
170
+ : rejection('origin-mismatch', [expectedOrigin, ...allowed]);
150
171
  }
151
172
 
152
- // ─── Rejection Response// ─── Rejection Response ───────────────────────────────────────────────────
173
+ // ─── Rejection Response ───────────────────────────────────────────────────
153
174
 
154
175
  /**
155
176
  * Longest request-derived value echoed into the server log. Origin, Host,
156
- * and the path are attacker-controlled; a request can carry kilobytes in
157
- * each, and every rejection writes a log line. Real values are far shorter.
177
+ * Sec-Fetch-Site, and the path are attacker-controlled; a request can carry
178
+ * kilobytes in each, and every rejection writes a log line. Real values are
179
+ * far shorter.
158
180
  */
159
181
  const MAX_LOGGED_VALUE = 200;
160
182
 
@@ -176,16 +198,20 @@ const PROXY_HINT =
176
198
  'original Host and X-Forwarded-Proto, or list the public origin in allowedOrigins ' +
177
199
  'in timber.config.ts.';
178
200
 
201
+ const EXEMPTION_HINT =
202
+ 'If this route.ts must accept cross-site browser requests (OIDC form_post, SAML, ' +
203
+ '3-D Secure returns), add `export const csrf = false` to it.';
204
+
179
205
  /**
180
206
  * Browsers send `Origin: null` from opaque contexts: sandboxed iframes, a POST
181
207
  * redirected across origins, file: pages. Every such context shares the one
182
- * value — an attacker's `<iframe sandbox>` included — so the hint must never
183
- * suggest allowlisting it.
208
+ * value — an attacker's `<iframe sandbox>` included — so config validation
209
+ * refuses it in `allowedOrigins` and the hint points at the exemption instead.
184
210
  */
185
211
  const OPAQUE_ORIGIN_DETAIL =
186
212
  'origin "null" is an opaque origin (sandboxed iframe, a POST redirected across ' +
187
- 'origins, or a file: page). Do not add "null" to allowedOrigins: every opaque ' +
188
- "context sends it, including an attacker's sandboxed iframe.";
213
+ 'origins, or a file: page). allowedOrigins cannot admit it: every opaque context ' +
214
+ `sends "null", including an attacker's sandboxed iframe. ${EXEMPTION_HINT}`;
189
215
 
190
216
  /** One sentence per rejection path: what was observed, and what to change. */
191
217
  function describeRejection(r: CsrfRejection, host: string | null): string {
@@ -193,20 +219,19 @@ function describeRejection(r: CsrfRejection, host: string | null): string {
193
219
  const origin = quote(r.origin ?? '');
194
220
  const expected = r.expected.map(quote).join(', ');
195
221
  switch (r.reason) {
196
- case 'missing-origin':
197
- return (
198
- 'request has no Origin header. Browsers send Origin on fetch and form POSTs; ' +
199
- 'non-browser clients calling an unsafe method must set it.'
200
- );
201
222
  // The opaque-origin note replaces only the reasons where the origin
202
223
  // itself is the fault. A null-origin request that fails for another
203
224
  // reason (no Host, a bad Host) keeps that reason's diagnosis and hint.
204
- case 'origin-not-allowed':
225
+ case 'cross-origin': {
205
226
  if (opaque) return OPAQUE_ORIGIN_DETAIL;
227
+ const from = r.origin === null ? 'with no Origin header' : `from origin ${origin}`;
228
+ const allowed = r.expected.length > 0 ? ` and is not in allowedOrigins [${expected}]` : '';
206
229
  return (
207
- `origin ${origin} is not in allowedOrigins [${expected}]. ` +
208
- 'If this origin is legitimate, add it to allowedOrigins in timber.config.ts.'
230
+ `Sec-Fetch-Site ${quote(r.fetchSite ?? '')} request ${from}: the browser reports ` +
231
+ `it is not same-origin${allowed}. If this origin is legitimate, add it to ` +
232
+ `allowedOrigins in timber.config.ts. ${EXEMPTION_HINT}`
209
233
  );
234
+ }
210
235
  case 'missing-host':
211
236
  return `origin ${origin} could not be checked: request has no Host header. ${PROXY_HINT}`;
212
237
  case 'invalid-host':
@@ -214,8 +239,11 @@ function describeRejection(r: CsrfRejection, host: string | null): string {
214
239
  case 'invalid-origin':
215
240
  if (opaque) return OPAQUE_ORIGIN_DETAIL;
216
241
  return `Origin header ${origin} is not a valid origin.`;
217
- case 'origin-mismatch':
218
- return `origin ${origin} does not match the request origin ${expected}. ${PROXY_HINT}`;
242
+ case 'origin-mismatch': {
243
+ const [own, ...listed] = r.expected.map(quote);
244
+ const list = listed.length > 0 ? ` or allowedOrigins [${listed.join(', ')}]` : '';
245
+ return `origin ${origin} does not match the request origin ${own}${list}. ${PROXY_HINT}`;
246
+ }
219
247
  }
220
248
  }
221
249