@timber-js/app 0.2.0-alpha.98 → 0.2.0-alpha.99

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 (322) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/actions-CQ8Z8VGL.js +1061 -0
  3. package/dist/_chunks/actions-CQ8Z8VGL.js.map +1 -0
  4. package/dist/_chunks/build-output-helper-DXnW0qjz.js +61 -0
  5. package/dist/_chunks/build-output-helper-DXnW0qjz.js.map +1 -0
  6. package/dist/_chunks/{define-Itxvcd7F.js → define-B-Q_UMOD.js} +19 -23
  7. package/dist/_chunks/define-B-Q_UMOD.js.map +1 -0
  8. package/dist/_chunks/{define-C77ScO0m.js → define-CfBPoJb0.js} +24 -7
  9. package/dist/_chunks/define-CfBPoJb0.js.map +1 -0
  10. package/dist/_chunks/define-cookie-BjpIt4UC.js +194 -0
  11. package/dist/_chunks/define-cookie-BjpIt4UC.js.map +1 -0
  12. package/dist/_chunks/{format-CYBGxKtc.js → format-Bcn-Iv1x.js} +1 -1
  13. package/dist/_chunks/{format-CYBGxKtc.js.map → format-Bcn-Iv1x.js.map} +1 -1
  14. package/dist/_chunks/handler-store-B-lqaGyh.js +54 -0
  15. package/dist/_chunks/handler-store-B-lqaGyh.js.map +1 -0
  16. package/dist/_chunks/logger-0m8MsKdc.js +291 -0
  17. package/dist/_chunks/logger-0m8MsKdc.js.map +1 -0
  18. package/dist/_chunks/merge-search-params-BphMdht_.js +122 -0
  19. package/dist/_chunks/merge-search-params-BphMdht_.js.map +1 -0
  20. package/dist/_chunks/navigation-root-BCYczjml.js +96 -0
  21. package/dist/_chunks/navigation-root-BCYczjml.js.map +1 -0
  22. package/dist/_chunks/registry-I2ss-lvy.js +20 -0
  23. package/dist/_chunks/registry-I2ss-lvy.js.map +1 -0
  24. package/dist/_chunks/router-ref-h3-UaCQv.js +28 -0
  25. package/dist/_chunks/router-ref-h3-UaCQv.js.map +1 -0
  26. package/dist/_chunks/{schema-bridge-C3xl_vfb.js → schema-bridge-Cxu4l-7p.js} +1 -1
  27. package/dist/_chunks/{schema-bridge-C3xl_vfb.js.map → schema-bridge-Cxu4l-7p.js.map} +1 -1
  28. package/dist/_chunks/{segment-context-fHFLF1PE.js → segment-context-Dx_OizxD.js} +1 -1
  29. package/dist/_chunks/{segment-context-fHFLF1PE.js.map → segment-context-Dx_OizxD.js.map} +1 -1
  30. package/dist/_chunks/{router-ref-C8OCm7g7.js → ssr-data-B4CdH7rE.js} +2 -26
  31. package/dist/_chunks/ssr-data-B4CdH7rE.js.map +1 -0
  32. package/dist/_chunks/{stale-reload-BX5gL1r-.js → stale-reload-Bab885FO.js} +1 -1
  33. package/dist/_chunks/{stale-reload-BX5gL1r-.js.map → stale-reload-Bab885FO.js.map} +1 -1
  34. package/dist/_chunks/tracing-C8V-YGsP.js +329 -0
  35. package/dist/_chunks/tracing-C8V-YGsP.js.map +1 -0
  36. package/dist/_chunks/{use-query-states-BiV5GJgm.js → use-query-states-B2XTqxDR.js} +3 -19
  37. package/dist/_chunks/use-query-states-B2XTqxDR.js.map +1 -0
  38. package/dist/_chunks/{use-params-IOPu7E8t.js → use-segment-params-BkpKAQ7D.js} +9 -95
  39. package/dist/_chunks/use-segment-params-BkpKAQ7D.js.map +1 -0
  40. package/dist/_chunks/{walkers-VOXgavMF.js → walkers-Tg0Alwcg.js} +6 -3
  41. package/dist/_chunks/walkers-Tg0Alwcg.js.map +1 -0
  42. package/dist/_chunks/{dev-warnings-DpGRGoDi.js → warnings-Cg47l5sk.js} +3 -3
  43. package/dist/_chunks/warnings-Cg47l5sk.js.map +1 -0
  44. package/dist/adapters/build-output-helper.d.ts +28 -0
  45. package/dist/adapters/build-output-helper.d.ts.map +1 -0
  46. package/dist/adapters/cloudflare.d.ts.map +1 -1
  47. package/dist/adapters/cloudflare.js +8 -28
  48. package/dist/adapters/cloudflare.js.map +1 -1
  49. package/dist/adapters/nitro.d.ts.map +1 -1
  50. package/dist/adapters/nitro.js +8 -26
  51. package/dist/adapters/nitro.js.map +1 -1
  52. package/dist/adapters/shared.d.ts +16 -0
  53. package/dist/adapters/shared.d.ts.map +1 -0
  54. package/dist/cache/index.js +9 -2
  55. package/dist/cache/index.js.map +1 -1
  56. package/dist/cache/timber-cache.d.ts.map +1 -1
  57. package/dist/client/error-boundary.js +2 -1
  58. package/dist/client/error-boundary.js.map +1 -1
  59. package/dist/client/form.d.ts +10 -24
  60. package/dist/client/form.d.ts.map +1 -1
  61. package/dist/client/index.d.ts +1 -5
  62. package/dist/client/index.d.ts.map +1 -1
  63. package/dist/client/index.js +40 -90
  64. package/dist/client/index.js.map +1 -1
  65. package/dist/client/internal.d.ts +2 -1
  66. package/dist/client/internal.d.ts.map +1 -1
  67. package/dist/client/internal.js +81 -7
  68. package/dist/client/internal.js.map +1 -1
  69. package/dist/client/rsc-fetch.d.ts.map +1 -1
  70. package/dist/client/state.d.ts +1 -1
  71. package/dist/client/use-cookie.d.ts +8 -0
  72. package/dist/client/use-cookie.d.ts.map +1 -1
  73. package/dist/client/{use-params.d.ts → use-segment-params.d.ts} +1 -1
  74. package/dist/client/use-segment-params.d.ts.map +1 -0
  75. package/dist/codec.d.ts +1 -1
  76. package/dist/codec.d.ts.map +1 -1
  77. package/dist/codec.js +2 -2
  78. package/dist/config-types.d.ts +28 -0
  79. package/dist/config-types.d.ts.map +1 -1
  80. package/dist/cookies/define-cookie.d.ts +87 -35
  81. package/dist/cookies/define-cookie.d.ts.map +1 -1
  82. package/dist/cookies/index.d.ts +2 -1
  83. package/dist/cookies/index.d.ts.map +1 -1
  84. package/dist/cookies/index.js +48 -2
  85. package/dist/cookies/index.js.map +1 -0
  86. package/dist/cookies/json-cookie.d.ts +64 -0
  87. package/dist/cookies/json-cookie.d.ts.map +1 -0
  88. package/dist/cookies/validation.d.ts +46 -0
  89. package/dist/cookies/validation.d.ts.map +1 -0
  90. package/dist/{plugins/dev-404-page.d.ts → dev-tools/404-page.d.ts} +1 -1
  91. package/dist/dev-tools/404-page.d.ts.map +1 -0
  92. package/dist/{plugins/dev-browser-logs.d.ts → dev-tools/browser-logs.d.ts} +1 -1
  93. package/dist/dev-tools/browser-logs.d.ts.map +1 -0
  94. package/dist/{plugins/dev-error-page.d.ts → dev-tools/error-page.d.ts} +2 -2
  95. package/dist/dev-tools/error-page.d.ts.map +1 -0
  96. package/dist/{server/dev-holding-server.d.ts → dev-tools/holding-server.d.ts} +1 -1
  97. package/dist/dev-tools/holding-server.d.ts.map +1 -0
  98. package/dist/dev-tools/index.d.ts +31 -0
  99. package/dist/dev-tools/index.d.ts.map +1 -0
  100. package/dist/{server/dev-span-processor.d.ts → dev-tools/instrumentation.d.ts} +26 -6
  101. package/dist/dev-tools/instrumentation.d.ts.map +1 -0
  102. package/dist/{server/dev-logger.d.ts → dev-tools/logger.d.ts} +1 -1
  103. package/dist/dev-tools/logger.d.ts.map +1 -0
  104. package/dist/{plugins/dev-logs.d.ts → dev-tools/logs.d.ts} +1 -1
  105. package/dist/dev-tools/logs.d.ts.map +1 -0
  106. package/dist/{plugins/dev-error-overlay.d.ts → dev-tools/overlay.d.ts} +3 -12
  107. package/dist/dev-tools/overlay.d.ts.map +1 -0
  108. package/dist/dev-tools/stack-classifier.d.ts +34 -0
  109. package/dist/dev-tools/stack-classifier.d.ts.map +1 -0
  110. package/dist/{plugins/dev-terminal-error.d.ts → dev-tools/terminal.d.ts} +2 -2
  111. package/dist/dev-tools/terminal.d.ts.map +1 -0
  112. package/dist/{server/dev-warnings.d.ts → dev-tools/warnings.d.ts} +1 -1
  113. package/dist/dev-tools/warnings.d.ts.map +1 -0
  114. package/dist/index.d.ts +1 -0
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +97 -72
  117. package/dist/index.js.map +1 -1
  118. package/dist/plugin-context.d.ts +1 -1
  119. package/dist/plugin-context.d.ts.map +1 -1
  120. package/dist/plugins/adapter-build.d.ts.map +1 -1
  121. package/dist/routing/convention-lint.d.ts.map +1 -1
  122. package/dist/routing/index.js +1 -1
  123. package/dist/routing/scanner.d.ts.map +1 -1
  124. package/dist/routing/status-file-lint.d.ts.map +1 -1
  125. package/dist/search-params/define.d.ts +25 -7
  126. package/dist/search-params/define.d.ts.map +1 -1
  127. package/dist/search-params/index.js +5 -3
  128. package/dist/search-params/index.js.map +1 -1
  129. package/dist/search-params/wrappers.d.ts +2 -2
  130. package/dist/search-params/wrappers.d.ts.map +1 -1
  131. package/dist/segment-params/define.d.ts +23 -6
  132. package/dist/segment-params/define.d.ts.map +1 -1
  133. package/dist/segment-params/index.js +1 -1
  134. package/dist/server/access-gate.d.ts +4 -3
  135. package/dist/server/access-gate.d.ts.map +1 -1
  136. package/dist/server/action-handler.d.ts +15 -6
  137. package/dist/server/action-handler.d.ts.map +1 -1
  138. package/dist/server/als-registry.d.ts +5 -5
  139. package/dist/server/als-registry.d.ts.map +1 -1
  140. package/dist/server/asset-headers.d.ts +1 -15
  141. package/dist/server/asset-headers.d.ts.map +1 -1
  142. package/dist/server/cookie-context.d.ts +170 -0
  143. package/dist/server/cookie-context.d.ts.map +1 -0
  144. package/dist/server/cookie-parsing.d.ts +51 -0
  145. package/dist/server/cookie-parsing.d.ts.map +1 -0
  146. package/dist/server/deny-boundary.d.ts +90 -0
  147. package/dist/server/deny-boundary.d.ts.map +1 -0
  148. package/dist/server/deny-renderer.d.ts.map +1 -1
  149. package/dist/server/early-hints-sender.d.ts.map +1 -1
  150. package/dist/server/index.d.ts +5 -4
  151. package/dist/server/index.d.ts.map +1 -1
  152. package/dist/server/index.js +4 -149
  153. package/dist/server/index.js.map +1 -1
  154. package/dist/server/internal.d.ts +6 -4
  155. package/dist/server/internal.d.ts.map +1 -1
  156. package/dist/server/internal.js +261 -408
  157. package/dist/server/internal.js.map +1 -1
  158. package/dist/server/logger.d.ts +14 -0
  159. package/dist/server/logger.d.ts.map +1 -1
  160. package/dist/server/middleware-runner.d.ts +17 -0
  161. package/dist/server/middleware-runner.d.ts.map +1 -1
  162. package/dist/server/param-coercion.d.ts +26 -0
  163. package/dist/server/param-coercion.d.ts.map +1 -0
  164. package/dist/server/pipeline-helpers.d.ts +14 -7
  165. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  166. package/dist/server/pipeline-outcome.d.ts +49 -0
  167. package/dist/server/pipeline-outcome.d.ts.map +1 -0
  168. package/dist/server/pipeline-phases.d.ts +4 -49
  169. package/dist/server/pipeline-phases.d.ts.map +1 -1
  170. package/dist/server/pipeline.d.ts +0 -2
  171. package/dist/server/pipeline.d.ts.map +1 -1
  172. package/dist/server/request-context.d.ts +22 -159
  173. package/dist/server/request-context.d.ts.map +1 -1
  174. package/dist/server/route-element-builder.d.ts.map +1 -1
  175. package/dist/server/rsc-entry/action-middleware-runner.d.ts +66 -0
  176. package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -0
  177. package/dist/server/rsc-entry/helpers.d.ts +1 -1
  178. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  179. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  180. package/dist/server/rsc-entry/render-route.d.ts +50 -0
  181. package/dist/server/rsc-entry/render-route.d.ts.map +1 -0
  182. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +59 -14
  183. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
  184. package/dist/server/state-tree-diff.d.ts.map +1 -1
  185. package/dist/server/tracing.d.ts +1 -1
  186. package/dist/server/tracing.d.ts.map +1 -1
  187. package/dist/server/tree-builder.d.ts +45 -16
  188. package/dist/server/tree-builder.d.ts.map +1 -1
  189. package/dist/server/types.d.ts +48 -0
  190. package/dist/server/types.d.ts.map +1 -1
  191. package/dist/server/utils/escape-html.d.ts +14 -0
  192. package/dist/server/utils/escape-html.d.ts.map +1 -0
  193. package/dist/shims/headers.d.ts +2 -2
  194. package/dist/shims/headers.d.ts.map +1 -1
  195. package/dist/shims/navigation-client.d.ts +3 -1
  196. package/dist/shims/navigation-client.d.ts.map +1 -1
  197. package/dist/shims/navigation.d.ts +9 -4
  198. package/dist/shims/navigation.d.ts.map +1 -1
  199. package/package.json +6 -7
  200. package/src/adapters/build-output-helper.ts +77 -0
  201. package/src/adapters/cloudflare.ts +10 -50
  202. package/src/adapters/nitro.ts +11 -45
  203. package/src/adapters/shared.ts +40 -0
  204. package/src/cache/timber-cache.ts +3 -2
  205. package/src/cli.ts +0 -0
  206. package/src/client/form.tsx +17 -25
  207. package/src/client/index.ts +16 -9
  208. package/src/client/internal.ts +3 -2
  209. package/src/client/router.ts +1 -1
  210. package/src/client/rsc-fetch.ts +15 -0
  211. package/src/client/state.ts +2 -2
  212. package/src/client/use-cookie.ts +29 -0
  213. package/src/codec.ts +3 -7
  214. package/src/config-types.ts +28 -0
  215. package/src/cookies/define-cookie.ts +271 -78
  216. package/src/cookies/index.ts +11 -8
  217. package/src/cookies/json-cookie.ts +105 -0
  218. package/src/cookies/validation.ts +134 -0
  219. package/src/{plugins/dev-404-page.ts → dev-tools/404-page.ts} +2 -7
  220. package/src/{plugins/dev-error-page.ts → dev-tools/error-page.ts} +5 -32
  221. package/src/dev-tools/index.ts +90 -0
  222. package/src/dev-tools/instrumentation.ts +176 -0
  223. package/src/{plugins/dev-logs.ts → dev-tools/logs.ts} +2 -2
  224. package/src/{plugins/dev-error-overlay.ts → dev-tools/overlay.ts} +5 -23
  225. package/src/dev-tools/stack-classifier.ts +75 -0
  226. package/src/{plugins/dev-terminal-error.ts → dev-tools/terminal.ts} +4 -38
  227. package/src/{server/dev-warnings.ts → dev-tools/warnings.ts} +1 -1
  228. package/src/index.ts +11 -3
  229. package/src/plugin-context.ts +1 -1
  230. package/src/plugins/adapter-build.ts +3 -1
  231. package/src/plugins/dev-server.ts +3 -3
  232. package/src/plugins/shims.ts +1 -1
  233. package/src/plugins/static-build.ts +1 -1
  234. package/src/routing/convention-lint.ts +5 -4
  235. package/src/routing/scanner.ts +5 -2
  236. package/src/routing/status-file-lint.ts +4 -2
  237. package/src/search-params/define.ts +71 -15
  238. package/src/search-params/wrappers.ts +9 -2
  239. package/src/segment-params/define.ts +66 -13
  240. package/src/server/access-gate.tsx +9 -8
  241. package/src/server/action-handler.ts +28 -38
  242. package/src/server/als-registry.ts +5 -5
  243. package/src/server/asset-headers.ts +8 -34
  244. package/src/server/cookie-context.ts +468 -0
  245. package/src/server/cookie-parsing.ts +135 -0
  246. package/src/server/{deny-page-resolver.ts → deny-boundary.ts} +78 -14
  247. package/src/server/deny-renderer.ts +2 -7
  248. package/src/server/early-hints-sender.ts +3 -2
  249. package/src/server/fallback-error.ts +1 -1
  250. package/src/server/index.ts +13 -14
  251. package/src/server/internal.ts +10 -3
  252. package/src/server/logger.ts +23 -0
  253. package/src/server/middleware-runner.ts +44 -0
  254. package/src/server/param-coercion.ts +76 -0
  255. package/src/server/pipeline-helpers.ts +37 -13
  256. package/src/server/pipeline-outcome.ts +167 -0
  257. package/src/server/pipeline-phases.ts +27 -209
  258. package/src/server/pipeline.ts +2 -9
  259. package/src/server/request-context.ts +46 -451
  260. package/src/server/route-element-builder.ts +7 -3
  261. package/src/server/rsc-entry/action-middleware-runner.ts +167 -0
  262. package/src/server/rsc-entry/error-renderer.ts +1 -1
  263. package/src/server/rsc-entry/helpers.ts +2 -7
  264. package/src/server/rsc-entry/index.ts +34 -273
  265. package/src/server/rsc-entry/render-route.ts +304 -0
  266. package/src/server/rsc-entry/rsc-payload.ts +1 -1
  267. package/src/server/rsc-entry/ssr-renderer.ts +2 -2
  268. package/src/server/rsc-entry/wrap-action-dispatch.ts +316 -23
  269. package/src/server/ssr-entry.ts +1 -1
  270. package/src/server/state-tree-diff.ts +4 -1
  271. package/src/server/tracing.ts +3 -3
  272. package/src/server/tree-builder.ts +128 -52
  273. package/src/server/types.ts +52 -0
  274. package/src/server/utils/escape-html.ts +20 -0
  275. package/src/shims/headers.ts +3 -3
  276. package/src/shims/navigation-client.ts +4 -3
  277. package/src/shims/navigation.ts +9 -7
  278. package/dist/_chunks/actions-DLnUaR65.js +0 -421
  279. package/dist/_chunks/actions-DLnUaR65.js.map +0 -1
  280. package/dist/_chunks/als-registry-HS0LGUl2.js +0 -41
  281. package/dist/_chunks/als-registry-HS0LGUl2.js.map +0 -1
  282. package/dist/_chunks/debug-ECi_61pb.js +0 -108
  283. package/dist/_chunks/debug-ECi_61pb.js.map +0 -1
  284. package/dist/_chunks/define-C77ScO0m.js.map +0 -1
  285. package/dist/_chunks/define-Itxvcd7F.js.map +0 -1
  286. package/dist/_chunks/define-cookie-BowvzoP0.js +0 -94
  287. package/dist/_chunks/define-cookie-BowvzoP0.js.map +0 -1
  288. package/dist/_chunks/dev-warnings-DpGRGoDi.js.map +0 -1
  289. package/dist/_chunks/merge-search-params-Cm_KIWDX.js +0 -41
  290. package/dist/_chunks/merge-search-params-Cm_KIWDX.js.map +0 -1
  291. package/dist/_chunks/request-context-CK5tZqIP.js +0 -478
  292. package/dist/_chunks/request-context-CK5tZqIP.js.map +0 -1
  293. package/dist/_chunks/router-ref-C8OCm7g7.js.map +0 -1
  294. package/dist/_chunks/tracing-CCYbKn5n.js +0 -238
  295. package/dist/_chunks/tracing-CCYbKn5n.js.map +0 -1
  296. package/dist/_chunks/use-params-IOPu7E8t.js.map +0 -1
  297. package/dist/_chunks/use-query-states-BiV5GJgm.js.map +0 -1
  298. package/dist/_chunks/walkers-VOXgavMF.js.map +0 -1
  299. package/dist/client/use-params.d.ts.map +0 -1
  300. package/dist/plugins/dev-404-page.d.ts.map +0 -1
  301. package/dist/plugins/dev-browser-logs.d.ts.map +0 -1
  302. package/dist/plugins/dev-error-overlay.d.ts.map +0 -1
  303. package/dist/plugins/dev-error-page.d.ts.map +0 -1
  304. package/dist/plugins/dev-logs.d.ts.map +0 -1
  305. package/dist/plugins/dev-terminal-error.d.ts.map +0 -1
  306. package/dist/server/deny-page-resolver.d.ts +0 -52
  307. package/dist/server/deny-page-resolver.d.ts.map +0 -1
  308. package/dist/server/dev-fetch-instrumentation.d.ts +0 -22
  309. package/dist/server/dev-fetch-instrumentation.d.ts.map +0 -1
  310. package/dist/server/dev-holding-server.d.ts.map +0 -1
  311. package/dist/server/dev-logger.d.ts.map +0 -1
  312. package/dist/server/dev-span-processor.d.ts.map +0 -1
  313. package/dist/server/dev-warnings.d.ts.map +0 -1
  314. package/dist/server/page-deny-boundary.d.ts +0 -31
  315. package/dist/server/page-deny-boundary.d.ts.map +0 -1
  316. package/src/server/dev-fetch-instrumentation.ts +0 -96
  317. package/src/server/dev-span-processor.ts +0 -78
  318. package/src/server/page-deny-boundary.tsx +0 -56
  319. /package/src/client/{use-params.ts → use-segment-params.ts} +0 -0
  320. /package/src/{plugins/dev-browser-logs.ts → dev-tools/browser-logs.ts} +0 -0
  321. /package/src/{server/dev-holding-server.ts → dev-tools/holding-server.ts} +0 -0
  322. /package/src/{server/dev-logger.ts → dev-tools/logger.ts} +0 -0
@@ -3,28 +3,40 @@
3
3
  *
4
4
  * Extracted from rsc-entry/index.ts so the wiring can be unit-tested in
5
5
  * isolation from Vite's virtual modules. The wrapper is responsible for
6
- * three things, in order:
6
+ * four things, in order:
7
7
  *
8
8
  * 1. **Pipeline-boundary CSRF validation** — runs on EVERY unsafe-method
9
9
  * request, before any dispatch decision. This is the only line of
10
10
  * defense for `route.ts` API handlers, which never see the action
11
11
  * handler. See LOCAL-773.
12
12
  *
13
- * 2. **Server action interception** — POST requests carrying an
13
+ * 2. **Middleware-on-actions execution** — when the request is a server
14
+ * action POST and `actions.runMiddleware !== false`, the matched
15
+ * route's `middleware.ts` chain runs BEFORE the action body. This
16
+ * closes the Next.js CVE-2025-29927 class of bug, where developers
17
+ * reasonably believe `middleware.ts` runs on every request and find
18
+ * out (the hard way) that actions silently bypass it. Middleware can
19
+ * short-circuit with a `Response`, `redirect()`, or `deny()`; can
20
+ * mutate cookies; and can inject request headers visible to the
21
+ * action body via `getHeaders()`. See TIM-871.
22
+ *
23
+ * 3. **Server action interception** — POST requests carrying an
14
24
  * `x-rsc-action` header or React's `$ACTION_REF` form fields are
15
25
  * handed to `handleActionRequest`, which executes the action and
16
26
  * returns either an RSC response or a no-JS rerender signal.
17
27
  *
18
- * 3. **No-JS validation rerender** — when an action returns flash data
28
+ * 4. **No-JS validation rerender** — when an action returns flash data
19
29
  * instead of a redirect, the wrapper re-runs the page render via the
20
30
  * pipeline with the post-action cookie state and `runWithFormFlash`
21
- * so server components can read the flash. See TIM-836 / TIM-837.
31
+ * so server components can read the flash. The synthetic GET is
32
+ * marked via `markRequestBypassMiddleware` so the pipeline does not
33
+ * double-execute middleware on it. See TIM-836 / TIM-837 / TIM-871.
22
34
  *
23
35
  * Anything else falls through to `pipeline(req)` for normal route handling.
24
36
  *
25
37
  * The wrapper takes its dependencies as parameters (no module-level
26
38
  * imports of virtual modules) so tests can construct it with stub
27
- * pipelines and stub revalidate renderers.
39
+ * pipelines, stub revalidate renderers, and stub route matchers.
28
40
  */
29
41
 
30
42
  import type { FormRerender } from '../action-handler.js';
@@ -32,8 +44,20 @@ import { handleActionRequest, isActionRequest } from '../action-handler.js';
32
44
  import type { BodyLimitsConfig } from '../body-limits.js';
33
45
  import { validateCsrf, type CsrfConfig } from '../csrf.js';
34
46
  import { runWithFormFlash } from '../form-flash.js';
47
+ import { seedRequestCookies } from '../cookie-context.js';
48
+ import { markRequestBypassMiddleware } from '../middleware-runner.js';
49
+ import { DenySignal } from '../primitives.js';
50
+ import { canonicalize } from '../canonicalize.js';
51
+ import {
52
+ buildRedirectResponse,
53
+ cloneWithMutableHeaders,
54
+ fireOnRequestError,
55
+ } from '../pipeline-helpers.js';
56
+ import { logRenderError } from '../logger.js';
57
+ import type { RouteMatch, RouteMatcher } from '../pipeline.js';
35
58
  import type { SensitiveFieldsOption } from '../sensitive-fields.js';
36
59
  import type { RevalidateRenderer } from '../actions.js';
60
+ import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-middleware-runner.js';
37
61
 
38
62
  // ─── Types ────────────────────────────────────────────────────────────────
39
63
 
@@ -48,6 +72,14 @@ import type { RevalidateRenderer } from '../actions.js';
48
72
  */
49
73
  export type RevalidateRendererFactory = (req: Request) => RevalidateRenderer;
50
74
 
75
+ /** Optional renderer for fallback deny pages — mirrors `PipelineConfig.renderDenyFallback`. */
76
+ export type RenderDenyFallbackFn = (
77
+ deny: DenySignal,
78
+ req: Request,
79
+ responseHeaders: Headers,
80
+ match?: RouteMatch
81
+ ) => Response | Promise<Response>;
82
+
51
83
  /** Dependencies for the action-dispatch wrapper. */
52
84
  export interface ActionDispatchDeps {
53
85
  /** CSRF configuration (Origin allow-list, on/off switch). */
@@ -58,6 +90,42 @@ export interface ActionDispatchDeps {
58
90
  sensitiveFields?: SensitiveFieldsOption;
59
91
  /** Per-request factory that builds a `RevalidateRenderer`. */
60
92
  buildRevalidateRenderer: RevalidateRendererFactory;
93
+ /**
94
+ * Route matcher — when present, enables middleware-on-actions execution.
95
+ * Mirrors `PipelineConfig.matchRoute`. Tests that don't exercise the
96
+ * middleware path may omit this; the wrapper falls back to the legacy
97
+ * "actions skip middleware" behavior.
98
+ */
99
+ matchRoute?: RouteMatcher;
100
+ /**
101
+ * Segment-param coercer — runs the matched route's `params.ts` codecs
102
+ * so the middleware context sees typed `segmentParams`. Required when
103
+ * `matchRoute` is provided.
104
+ */
105
+ coerceSegmentParams?: CoerceSegmentParamsFn;
106
+ /**
107
+ * Renderer for fallback deny pages — used when middleware throws a
108
+ * `DenySignal` and the framework wants to render `403.tsx` / `404.tsx`
109
+ * instead of returning a bare empty Response. Optional — when omitted
110
+ * the wrapper falls back to a bare status response.
111
+ */
112
+ renderDenyFallback?: RenderDenyFallbackFn;
113
+ /**
114
+ * Whether to run `middleware.ts` on server action requests. Defaults
115
+ * to `true` (the safe default). Controlled by
116
+ * `actions.runMiddleware` in `timber.config.ts`. See TIM-871.
117
+ */
118
+ runMiddleware?: boolean;
119
+ /**
120
+ * Whether to strip trailing slashes during pathname canonicalization
121
+ * for route matching. Mirrors `PipelineConfig.stripTrailingSlash`;
122
+ * defaults to `true` when omitted, matching the page pipeline. The
123
+ * wrapper MUST canonicalize before matching so non-canonical action
124
+ * POSTs (`/admin/`, `/admin//`, encoded separators) cannot bypass the
125
+ * middleware gate by missing the route match and falling through to
126
+ * the legacy path. See TIM-871 / codex review.
127
+ */
128
+ stripTrailingSlash?: boolean;
61
129
  }
62
130
 
63
131
  // ─── Implementation ───────────────────────────────────────────────────────
@@ -66,15 +134,7 @@ export interface ActionDispatchDeps {
66
134
  * Wrap a pipeline function with CSRF validation and server-action dispatch.
67
135
  *
68
136
  * The returned handler is the framework's outermost request entry point.
69
- * Its responsibilities, in order:
70
- *
71
- * 1. CSRF (Origin/Host) validation on every unsafe-method request.
72
- * Safe methods (GET/HEAD/OPTIONS) short-circuit inside `validateCsrf`,
73
- * so this is a no-op for reads.
74
- * 2. Server-action interception for POSTs that match `isActionRequest`.
75
- * 3. No-JS validation rerender when an action returns a `FormRerender`
76
- * signal instead of a redirect.
77
- * 4. Otherwise, delegate to `pipeline(req)`.
137
+ * Its responsibilities are documented in the file header.
78
138
  *
79
139
  * The duplicate `validateCsrf` call inside `handleActionRequest` is left in
80
140
  * place as defense-in-depth (no-op on the happy path) so the action handler
@@ -85,6 +145,9 @@ export function wrapPipelineWithActionDispatch(
85
145
  pipeline: (req: Request) => Promise<Response>,
86
146
  deps: ActionDispatchDeps
87
147
  ): (req: Request) => Promise<Response> {
148
+ const middlewareEnabled = deps.runMiddleware !== false;
149
+ const stripTrailingSlash = deps.stripTrailingSlash ?? true;
150
+
88
151
  return async (req: Request): Promise<Response> => {
89
152
  // ─── 1. Pipeline-boundary CSRF validation (LOCAL-773) ─────────────
90
153
  //
@@ -104,7 +167,192 @@ export function wrapPipelineWithActionDispatch(
104
167
 
105
168
  // ─── 2. Server action interception ────────────────────────────────
106
169
  if (isActionRequest(req)) {
107
- const actionResponse = await handleActionRequest(req, {
170
+ // ─── 2a. Route-type check + canonicalize (TIM-870) ──────────────
171
+ //
172
+ // Canonicalize and match the route BEFORE any body parsing. If the
173
+ // matched route is an API route (route.ts), skip action detection
174
+ // entirely and dispatch straight to the pipeline. Server actions
175
+ // only live on page routes; POSTs to route.ts are API requests
176
+ // whose body must reach the handler untouched — not pre-parsed
177
+ // looking for $ACTION_REF fields.
178
+ //
179
+ // This also avoids allocating a full formData() parse over large
180
+ // multipart uploads (e.g. 50 MB file uploads to /api/upload) that
181
+ // would otherwise be buffered and discarded by handleFormAction.
182
+ //
183
+ // The same canonicalized match is reused for the middleware-on-
184
+ // actions path below, avoiding a redundant match.
185
+ let match: RouteMatch | null = null;
186
+
187
+ if (deps.matchRoute) {
188
+ // Canonicalize the pathname BEFORE matching, with the exact same
189
+ // rules the page pipeline uses (`canonicalize()` in
190
+ // `pipeline-phases.ts` stage 1). Without this, an attacker could
191
+ // POST to a non-canonical variant of an authenticated route —
192
+ // `/admin/` with a trailing slash, `/admin//` with a doubled
193
+ // slash, `/adm%69n` with percent-escapes, `/admin%2fnested` with
194
+ // an encoded separator — fail the match here, fall through to
195
+ // the legacy "no middleware" path, and still reach the action
196
+ // handler. The canonicalization step closes that bypass and
197
+ // guarantees the wrapper matches EXACTLY the same route the page
198
+ // pipeline would match. A canonicalize failure (encoded
199
+ // separator, null byte, malformed escape, `..` escaping root)
200
+ // returns the canonicalizer's status directly — the request is
201
+ // malformed, never dispatched. See TIM-871 (codex review).
202
+ const url = new URL(req.url);
203
+ const canonical = canonicalize(url.pathname, stripTrailingSlash);
204
+ if (!canonical.ok) {
205
+ return new Response(null, { status: canonical.status });
206
+ }
207
+ match = deps.matchRoute(canonical.pathname);
208
+
209
+ // Skip action detection for route.ts API handlers (TIM-870).
210
+ // Server actions only target page routes. A POST to a route.ts
211
+ // path is an API request — the full body should reach the route
212
+ // handler without being pre-parsed by handleFormAction.
213
+ if (match) {
214
+ const leaf = match.segments[match.segments.length - 1];
215
+ if (leaf?.route) {
216
+ return pipeline(req);
217
+ }
218
+ }
219
+ }
220
+
221
+ // ─── 2b. Middleware-on-actions (TIM-871) ────────────────────────
222
+ //
223
+ // When middleware execution is enabled and the request matches a
224
+ // known route, run the matched chain BEFORE the action handler.
225
+ // The middleware run happens inside its own `runWithRequestContext`
226
+ // scope so middleware can read/write cookies and inject request
227
+ // headers via the standard ALS APIs. Mutations are captured at the
228
+ // end of the scope and threaded into the action handler's own ALS
229
+ // scope via `seedRequestCookies` + a request rebuilt with overlay
230
+ // headers merged in.
231
+ let downstreamReq = req;
232
+ let middlewareSetCookieHeaders: string[] = [];
233
+
234
+ if (
235
+ middlewareEnabled &&
236
+ deps.coerceSegmentParams &&
237
+ match &&
238
+ match.middlewareChain.length > 0
239
+ ) {
240
+ const outcome = await runMiddlewareForAction(req, match, deps.coerceSegmentParams);
241
+
242
+ // ─── Translate middleware outcome ─────────────────────────
243
+ if (outcome.kind === 'param-coercion-error') {
244
+ // Bad segment params → 404. Matches the page pipeline.
245
+ return new Response(null, { status: 404 });
246
+ }
247
+ if (outcome.kind === 'error') {
248
+ // Unhandled middleware error → 500.
249
+ return new Response(null, { status: 500 });
250
+ }
251
+ if (outcome.kind === 'short-circuit') {
252
+ // Middleware returned a Response. Apply its Set-Cookie
253
+ // snapshot before returning. Clone unconditionally so the
254
+ // header bag is mutable.
255
+ const finalResponse = cloneWithMutableHeaders(outcome.response);
256
+ for (const value of outcome.setCookieHeaders) {
257
+ finalResponse.headers.append('Set-Cookie', value);
258
+ }
259
+ return finalResponse;
260
+ }
261
+ if (outcome.kind === 'redirect') {
262
+ // RedirectSignal from middleware → standard redirect Response.
263
+ // Use `buildRedirectResponse` so the with-JS path produces a
264
+ // 204 + X-Timber-Redirect (SPA navigation) and the no-JS path
265
+ // produces a real HTTP 30x — exactly the same translation
266
+ // the page pipeline applies in `outcomeToResponse`.
267
+ const headers = new Headers();
268
+ for (const value of outcome.setCookieHeaders) {
269
+ headers.append('Set-Cookie', value);
270
+ }
271
+ return buildRedirectResponse(outcome.signal, req, headers);
272
+ }
273
+ if (outcome.kind === 'deny') {
274
+ // DenySignal from middleware → render the matched route's
275
+ // colocated deny page if available, otherwise a bare empty
276
+ // status response. Mirrors the page pipeline's deny handling.
277
+ const headers = new Headers();
278
+ for (const value of outcome.setCookieHeaders) {
279
+ headers.append('Set-Cookie', value);
280
+ }
281
+ if (deps.renderDenyFallback) {
282
+ try {
283
+ return cloneWithMutableHeaders(
284
+ await deps.renderDenyFallback(outcome.signal, req, headers, outcome.match)
285
+ );
286
+ } catch (denyRenderError) {
287
+ // Deny page rendering failed — log before falling through to bare
288
+ // response. Without this, a crashing deny page on the action path
289
+ // produces a blank response with zero server-side signal. See TIM-876.
290
+ const url = new URL(req.url);
291
+ logRenderError({ method: req.method, path: url.pathname, error: denyRenderError });
292
+ await fireOnRequestError(denyRenderError, req, 'render');
293
+ }
294
+ }
295
+ return new Response(null, { status: outcome.signal.status, headers });
296
+ }
297
+
298
+ // ─── Continue: middleware passed ──────────────────────────
299
+ //
300
+ // Build a downstream Request that the action handler will see:
301
+ // - Original headers + middleware's request-header overlay
302
+ // merged on top, so `getHeaders()` inside the action body
303
+ // observes the injected values.
304
+ // - Cookie header dropped; the post-middleware RYW cookie
305
+ // state is threaded directly into the action handler's
306
+ // request context via `seedRequestCookies`. This avoids
307
+ // a Cookie-header round-trip that would re-parse via
308
+ // `parseCookieHeader` — the same H-3 smuggling primitive
309
+ // mitigated by TIM-868. The action's own cookie reads
310
+ // therefore observe both the original cookies and any
311
+ // middleware mutations, with no cleartext encoding step
312
+ // in between.
313
+ // - Body forwarded as-is so the action handler can decode it.
314
+ // - Method preserved (POST).
315
+ //
316
+ // We hold the middleware Set-Cookie snapshot to prepend to the
317
+ // final response below — middleware writes precede action
318
+ // writes in the response order so the browser's last-wins
319
+ // resolution lets the action override middleware on conflict.
320
+ const mergedHeaders = new Headers(req.headers);
321
+ outcome.overlay.forEach((value, key) => {
322
+ mergedHeaders.set(key, value);
323
+ });
324
+ mergedHeaders.delete('cookie');
325
+ downstreamReq = new Request(req.url, {
326
+ method: req.method,
327
+ headers: mergedHeaders,
328
+ body: req.body,
329
+ // Required by undici when constructing a Request with a
330
+ // streaming body — `req.body` is a ReadableStream and the
331
+ // fetch spec needs an explicit half-duplex declaration.
332
+ // @ts-expect-error — `duplex` is not in the standard Request init type yet.
333
+ duplex: 'half',
334
+ });
335
+ seedRequestCookies(downstreamReq, outcome.cookies);
336
+ middlewareSetCookieHeaders = outcome.setCookieHeaders;
337
+ }
338
+
339
+ // ─── 2c. Action handler dispatch ────────────────────────────────
340
+ //
341
+ // The revalidate renderer is built from the ORIGINAL request, not
342
+ // `downstreamReq`. The downstream request had its `cookie` header
343
+ // stripped (the post-middleware RYW cookie state is threaded
344
+ // through ALS via `seedRequestCookies` instead, to preserve the
345
+ // H-3 smuggling invariant from TIM-868). But the revalidate
346
+ // renderer in `rsc-entry/index.ts` forwards `req.headers` onto the
347
+ // synthetic revalidation Request it builds for the target path —
348
+ // if we passed `downstreamReq` here, that synthetic request would
349
+ // carry no cookies, and an action that calls `revalidatePath()`
350
+ // would re-render the target route with no session / tenant / auth
351
+ // context. Using `req` (the unmodified inbound request) preserves
352
+ // the original cookie header for the revalidation side channel
353
+ // while `seedRequestCookies` handles the middleware RYW state for
354
+ // the action body itself. See TIM-871 (codex review).
355
+ const actionResponse = await handleActionRequest(downstreamReq, {
108
356
  csrf: deps.csrfConfig,
109
357
  bodyLimits: { limits: deps.bodyLimits },
110
358
  sensitiveFields: deps.sensitiveFields,
@@ -117,22 +365,36 @@ export function wrapPipelineWithActionDispatch(
117
365
  const formRerender = actionResponse as FormRerender;
118
366
  // Build a synthetic GET request for the rerender pipeline:
119
367
  // - Same URL (so route matching lands on the same page)
120
- // - Cookie header replaced with the post-action RYW snapshot
121
- // so server components see the action's writes (TIM-837)
368
+ // - Cookie header DROPPED entirely. The post-action RYW state
369
+ // is threaded into the rerender request context as a parsed
370
+ // `Map<string, string>` via `seedRequestCookies` below, so
371
+ // `parseCookieHeader` is never called for this request.
372
+ // This eliminates the value-smuggling primitive that the
373
+ // previous string round-trip exposed: a `;`-laden cookie
374
+ // value would otherwise split into sibling cookies during
375
+ // re-parse and let an attacker inject `role=admin` /
376
+ // forged session cookies into the rerender response. See
377
+ // ONGOING_SECURITY.md H-3 (TIM-868) and TIM-837.
122
378
  // - Method GET because the rerender is conceptually a page
123
379
  // render, not a re-POST. The pipeline doesn't branch on
124
380
  // method for page rendering, and constructing a POST without
125
381
  // a body is awkward across Request implementations.
382
+ // - Marked via `markRequestBypassMiddleware` so the pipeline
383
+ // skips its middleware phase on this synthetic request.
384
+ // Middleware already ran once on the inbound POST (above);
385
+ // letting the pipeline run it again would double-execute
386
+ // auth, rate limiting, and request-header injection. See
387
+ // TIM-871.
126
388
  const rerenderHeaders = new Headers(req.headers);
127
- if (formRerender.cookieHeader) {
128
- rerenderHeaders.set('cookie', formRerender.cookieHeader);
129
- } else {
130
- rerenderHeaders.delete('cookie');
131
- }
389
+ rerenderHeaders.delete('cookie');
132
390
  const rerenderReq = new Request(req.url, {
133
391
  method: 'GET',
134
392
  headers: rerenderHeaders,
135
393
  });
394
+ // Seed BEFORE pipeline() runs — runWithRequestContext consumes
395
+ // the seed when it constructs the per-request store.
396
+ seedRequestCookies(rerenderReq, formRerender.cookies);
397
+ markRequestBypassMiddleware(rerenderReq);
136
398
  const response = await runWithFormFlash(formRerender.rerender, () =>
137
399
  pipeline(rerenderReq)
138
400
  );
@@ -140,12 +402,43 @@ export function wrapPipelineWithActionDispatch(
140
402
  // The pipeline above runs in its own request context with a fresh
141
403
  // cookie jar, so cookies set inside the action would otherwise be
142
404
  // silently dropped on the no-JS rerender path. See TIM-836
143
- // (LOCAL-740).
405
+ // (LOCAL-740). Middleware-set cookies are prepended first so
406
+ // browser last-wins still lets action writes override.
407
+ for (const value of middlewareSetCookieHeaders) {
408
+ response.headers.append('Set-Cookie', value);
409
+ }
144
410
  for (const value of formRerender.setCookieHeaders) {
145
411
  response.headers.append('Set-Cookie', value);
146
412
  }
147
413
  return response;
148
414
  }
415
+ // Apply middleware Set-Cookie snapshot to the action's RSC
416
+ // response. Action's own cookies were already appended inside
417
+ // `handleActionRequest` via `getSetCookieHeaders()` before the
418
+ // action ALS scope exited; we prepend middleware writes so
419
+ // browser last-wins lets action writes take precedence on
420
+ // conflicting names.
421
+ if (middlewareSetCookieHeaders.length > 0) {
422
+ // Middleware writes go FIRST in the response order, so we
423
+ // build a fresh Headers and rebuild the Response. Cloning the
424
+ // Response keeps the body stream intact.
425
+ const mergedHeaders = new Headers();
426
+ for (const value of middlewareSetCookieHeaders) {
427
+ mergedHeaders.append('Set-Cookie', value);
428
+ }
429
+ actionResponse.headers.forEach((value, key) => {
430
+ if (key.toLowerCase() === 'set-cookie') {
431
+ mergedHeaders.append('Set-Cookie', value);
432
+ } else {
433
+ mergedHeaders.set(key, value);
434
+ }
435
+ });
436
+ return new Response(actionResponse.body, {
437
+ status: actionResponse.status,
438
+ statusText: actionResponse.statusText,
439
+ headers: mergedHeaders,
440
+ });
441
+ }
149
442
  return actionResponse;
150
443
  }
151
444
  }
@@ -37,7 +37,7 @@ import { SsrStreamError } from './primitives.js';
37
37
  import { createBufferedTransformStream, injectHead, injectRscPayload } from './html-injectors.js';
38
38
  import { wrapSsrElement } from './ssr-wrappers.js';
39
39
  import { withSpan } from './tracing.js';
40
- import { setCurrentParams } from '../client/use-params.js';
40
+ import { setCurrentParams } from '../client/use-segment-params.js';
41
41
  import { registerSsrDataProvider, type SsrData } from '../client/ssr-data.js';
42
42
 
43
43
  // Pre-import Node.js stream modules at module load time — not per-request.
@@ -14,6 +14,8 @@
14
14
  * See design/13-security.md §"State tree manipulation"
15
15
  */
16
16
 
17
+ import { swallow } from './logger.js';
18
+
17
19
  /**
18
20
  * Parse the X-Timber-State-Tree header from a request.
19
21
  *
@@ -33,7 +35,8 @@ export function parseClientStateTree(req: Request): Set<string> | null {
33
35
  return null;
34
36
  }
35
37
  return new Set(parsed.segments as string[]);
36
- } catch {
38
+ } catch (err) {
39
+ swallow(err, 'malformed X-Timber-State-Tree header');
37
40
  return null;
38
41
  }
39
42
  }
@@ -105,17 +105,17 @@ export function getTraceStore(): TraceStore | undefined {
105
105
  * Only called in dev mode — zero overhead in production.
106
106
  */
107
107
  export async function initDevTracing(
108
- config: import('./dev-logger.js').DevLoggerConfig
108
+ config: import('../dev-tools/logger.js').DevLoggerConfig
109
109
  ): Promise<void> {
110
110
  const api = await getOtelApi();
111
111
  if (!api) return;
112
112
 
113
- let DevSpanProcessor: typeof import('./dev-span-processor.js').DevSpanProcessor;
113
+ let DevSpanProcessor: typeof import('../dev-tools/instrumentation.js').DevSpanProcessor;
114
114
  let BasicTracerProvider: typeof import('@opentelemetry/sdk-trace-base').BasicTracerProvider;
115
115
  let AsyncLocalStorageContextManager: typeof import('@opentelemetry/context-async-hooks').AsyncLocalStorageContextManager;
116
116
 
117
117
  try {
118
- ({ DevSpanProcessor } = await import('./dev-span-processor.js'));
118
+ ({ DevSpanProcessor } = await import('../dev-tools/instrumentation.js'));
119
119
  ({ BasicTracerProvider } = await import('@opentelemetry/sdk-trace-base'));
120
120
  ({ AsyncLocalStorageContextManager } = await import('@opentelemetry/context-async-hooks'));
121
121
  } catch (err) {