@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
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Cookie parsing and serialization helpers — pure string ↔ structure
3
+ * functions with no ALS dependency. Split out of `cookie-context.ts`
4
+ * (TIM-853) so the API surface and the wire-format codecs can each be
5
+ * read on their own.
6
+ *
7
+ * The functions in this module are total over arbitrary input. They
8
+ * never throw and never call `assertValid*` (the security validators
9
+ * live in the API surface — `cookie-context.ts` invokes them at every
10
+ * jar entry point so the smuggling-primitive invariant from TIM-868
11
+ * is enforced regardless of which path produced the bytes).
12
+ */
13
+
14
+ import type { CookieEntry } from './als-registry.js';
15
+ import type { CookieOptions } from './cookie-context.js';
16
+
17
+ /**
18
+ * Parse a Cookie header string into a Map of name → value pairs.
19
+ * Follows RFC 6265 §4.2.1: cookies are semicolon-separated key=value pairs.
20
+ *
21
+ * Values are auto-decoded with `decodeURIComponent` so they round-trip
22
+ * losslessly with `getCookies().set()` (which auto-encodes). Malformed
23
+ * `%`-escapes from third-party cookies fall back to the raw byte sequence
24
+ * — the parser must be total over arbitrary inbound headers, including
25
+ * non-conforming values from other servers, browser extensions, etc.
26
+ */
27
+ export function parseCookieHeader(header: string): Map<string, string> {
28
+ const map = new Map<string, string>();
29
+ if (!header) return map;
30
+
31
+ for (const pair of header.split(';')) {
32
+ const eqIndex = pair.indexOf('=');
33
+ if (eqIndex === -1) continue;
34
+ const name = pair.slice(0, eqIndex).trim();
35
+ const value = pair.slice(eqIndex + 1).trim();
36
+ if (name) {
37
+ map.set(name, safeDecodeCookieValue(value));
38
+ }
39
+ }
40
+
41
+ return map;
42
+ }
43
+
44
+ /**
45
+ * Decode a single cookie value with `decodeURIComponent`, falling back to
46
+ * the raw byte sequence if the input contains a malformed `%`-escape.
47
+ *
48
+ * Used by both `parseCookieHeader` (incoming Cookie: header) and the
49
+ * `setRaw` forwarding path (outgoing Set-Cookie from upstream services).
50
+ * Total — never throws.
51
+ */
52
+ export function safeDecodeCookieValue(raw: string): string {
53
+ try {
54
+ return decodeURIComponent(raw);
55
+ } catch {
56
+ return raw;
57
+ }
58
+ }
59
+
60
+ /** Serialize a CookieEntry into a Set-Cookie header value. */
61
+ export function serializeCookieEntry(entry: CookieEntry): string {
62
+ const parts = [`${entry.name}=${entry.value}`];
63
+ const opts = entry.options;
64
+
65
+ if (opts.domain) parts.push(`Domain=${opts.domain}`);
66
+ if (opts.path) parts.push(`Path=${opts.path}`);
67
+ if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);
68
+ if (opts.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`);
69
+ if (opts.httpOnly) parts.push('HttpOnly');
70
+ if (opts.secure) parts.push('Secure');
71
+ if (opts.sameSite) {
72
+ parts.push(`SameSite=${opts.sameSite.charAt(0).toUpperCase()}${opts.sameSite.slice(1)}`);
73
+ }
74
+ if (opts.partitioned) parts.push('Partitioned');
75
+
76
+ return parts.join('; ');
77
+ }
78
+
79
+ /**
80
+ * Parse a raw `Set-Cookie` header string into name, value, and options.
81
+ * Handles all standard attributes: Path, Domain, Max-Age, Expires,
82
+ * SameSite, Secure, HttpOnly, Partitioned.
83
+ *
84
+ * Does NOT apply DEFAULT_COOKIE_OPTIONS — the caller decides whether
85
+ * to merge defaults (e.g. `set()` does, but `setRaw()` should preserve
86
+ * the original header's intent).
87
+ */
88
+ export function parseSetCookie(
89
+ header: string
90
+ ): { name: string; value: string; options: CookieOptions } | null {
91
+ const segments = header.split(';');
92
+ const nameValue = segments[0];
93
+ const eqIdx = nameValue.indexOf('=');
94
+ if (eqIdx <= 0) return null;
95
+
96
+ const name = nameValue.slice(0, eqIdx).trim();
97
+ const value = nameValue.slice(eqIdx + 1).trim();
98
+ const options: CookieOptions = {};
99
+
100
+ for (let i = 1; i < segments.length; i++) {
101
+ const seg = segments[i].trim();
102
+ if (!seg) continue;
103
+ const [attrName, ...rest] = seg.split('=');
104
+ const key = attrName.trim().toLowerCase();
105
+ const val = rest.join('=').trim();
106
+ switch (key) {
107
+ case 'path':
108
+ options.path = val || '/';
109
+ break;
110
+ case 'domain':
111
+ options.domain = val;
112
+ break;
113
+ case 'max-age':
114
+ options.maxAge = Number(val);
115
+ break;
116
+ case 'expires':
117
+ options.expires = new Date(val);
118
+ break;
119
+ case 'samesite':
120
+ options.sameSite = val.toLowerCase() as 'strict' | 'lax' | 'none';
121
+ break;
122
+ case 'secure':
123
+ options.secure = true;
124
+ break;
125
+ case 'httponly':
126
+ options.httpOnly = true;
127
+ break;
128
+ case 'partitioned':
129
+ options.partitioned = true;
130
+ break;
131
+ }
132
+ }
133
+
134
+ return { name, value, options };
135
+ }
@@ -1,23 +1,41 @@
1
1
  /**
2
- * Deny Page Resolverresolves status-code file components for in-tree deny handling.
2
+ * Deny boundary subsystemthe in-tree DenySignal flow.
3
3
  *
4
- * When AccessGate or PageDenyBoundary catches a DenySignal, they need to
5
- * render the matching deny page (403.tsx, 4xx.tsx, error.tsx) as a normal
6
- * element in the React tree. This module resolves the deny page chain from
7
- * the segment chain — a list of fallback components ordered by specificity.
4
+ * Three things live together here because they form a single flow:
8
5
  *
9
- * The chain is built during buildRouteElement and passed as a prop to
10
- * AccessGate and PageDenyBoundary. At catch time, the first matching
11
- * component is rendered.
6
+ * 1. **Chain construction** (`buildDenyPageChain`) walks the matched
7
+ * segment chain at element-tree build time and produces a list of
8
+ * `DenyPageEntry` records ordered by specificity (specific status →
9
+ * category catch-all → `error.tsx`).
12
10
  *
13
- * See design/10-error-handling.md §"Status-Code Files", TIM-666.
11
+ * 2. **Runtime matching** (`renderMatchingDenyPage`) — picks the first
12
+ * chain entry whose status filter matches the thrown DenySignal and
13
+ * returns a React element for the matching component. Used by
14
+ * `AccessGate` and `PageDenyBoundary` when they catch a deny.
15
+ *
16
+ * 3. **The page boundary itself** (`PageDenyBoundary`) — the async server
17
+ * component that wraps a server-component page, calls it, and catches
18
+ * `DenySignal` so the deny page renders in-tree (no throw reaches
19
+ * React Flight, single render pass).
20
+ *
21
+ * Plus the ALS helpers (`setDenyStatus` / `getDenyStatus`) the boundary
22
+ * uses to thread the matched status code back to the pipeline so the
23
+ * HTTP status reflects the deny.
24
+ *
25
+ * Folded into one module from the former `deny-page-resolver.ts` and
26
+ * `page-deny-boundary.tsx` (TIM-853) — the names were misleading and the
27
+ * three pieces only made sense together.
28
+ *
29
+ * See design/04-authorization.md, design/10-error-handling.md, TIM-666.
14
30
  */
15
31
 
16
32
  import { createElement } from 'react';
17
33
 
18
- import type { ManifestSegmentNode } from './route-matcher.js';
19
- import { loadModule } from './safe-load.js';
20
34
  import { requestContextAls } from './als-registry.js';
35
+ import { DenySignal } from './primitives.js';
36
+ import { loadModule } from './safe-load.js';
37
+ import { withSpan } from './tracing.js';
38
+ import type { ManifestSegmentNode } from './route-matcher.js';
21
39
 
22
40
  // ─── Types ────────────────────────────────────────────────────────────────
23
41
 
@@ -29,7 +47,7 @@ export interface DenyPageEntry {
29
47
  component: (...args: unknown[]) => unknown;
30
48
  }
31
49
 
32
- // ─── Resolver ─────────────────────────────────────────────────────────────
50
+ // ─── Chain Construction ──────────────────────────────────────────────────
33
51
 
34
52
  /**
35
53
  * Build the deny page fallback chain from the segment chain.
@@ -101,7 +119,7 @@ export async function buildDenyPageChain(
101
119
  return chain;
102
120
  }
103
121
 
104
- // ─── Matcher ──────────────────────────────────────────────────────────────
122
+ // ─── Runtime Matcher ──────────────────────────────────────────────────────
105
123
 
106
124
  /**
107
125
  * Find the first deny page in the chain that matches the given status code.
@@ -131,7 +149,53 @@ export function renderMatchingDenyPage(
131
149
  return null;
132
150
  }
133
151
 
134
- // ─── ALS Helper ───────────────────────────────────────────────────────────
152
+ // ─── Page Boundary ────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Async server component that wraps a page call with DenySignal catching.
156
+ *
157
+ * Calls the page component as an async function (the same thing React
158
+ * Flight does internally), awaits it, and catches DenySignal. On catch,
159
+ * renders the matching deny page in-tree. On success, returns the page's
160
+ * rendered output normally.
161
+ *
162
+ * Client component pages ('use client') are NOT wrapped — they can't call
163
+ * deny() (server-only API) and must go through createElement normally.
164
+ *
165
+ * No error reaches React Flight — the Flight stream is clean, SSR succeeds,
166
+ * and the entire request uses a single renderToReadableStream call.
167
+ */
168
+ export async function PageDenyBoundary({
169
+ Page,
170
+ route,
171
+ denyPages,
172
+ }: {
173
+ /** The page server component function. */
174
+ Page: (...args: unknown[]) => unknown;
175
+ /** Route path for OTEL tracing. */
176
+ route: string;
177
+ /** Deny page fallback chain from the segment chain. */
178
+ denyPages: DenyPageEntry[];
179
+ }): Promise<React.ReactElement> {
180
+ try {
181
+ // Call the page as an async function — same as React Flight does.
182
+ // Wrap in OTEL span for tracing (replaces the TracedPage wrapper).
183
+ const result = await withSpan('timber.page', { 'timber.route': route }, () => Page({}));
184
+ return result as React.ReactElement;
185
+ } catch (error: unknown) {
186
+ if (error instanceof DenySignal) {
187
+ const denyElement = renderMatchingDenyPage(denyPages, error.status, error.data);
188
+ if (denyElement) {
189
+ setDenyStatus(error.status);
190
+ return denyElement;
191
+ }
192
+ }
193
+ // Non-deny errors (RedirectSignal, runtime errors) propagate normally.
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ // ─── ALS Helpers ──────────────────────────────────────────────────────────
135
199
 
136
200
  /**
137
201
  * Set the deny status in the request context ALS.
@@ -22,6 +22,7 @@ import { DenySignal } from './primitives.js';
22
22
  import { logRenderError } from './logger.js';
23
23
  import { loadModule } from './safe-load.js';
24
24
  import { isDebug } from './debug.js';
25
+ import { escapeHtml } from './utils/escape-html.js';
25
26
  import { resolveMetadata, renderMetadataToElements } from './metadata.js';
26
27
  import { resolveStatusFile } from './status-code-resolver.js';
27
28
  import type { ManifestSegmentNode } from './route-matcher.js';
@@ -280,10 +281,4 @@ function bareJsonResponse(status: number, responseHeaders: Headers): Response {
280
281
  });
281
282
  }
282
283
 
283
- function escapeHtml(str: string): string {
284
- return str
285
- .replace(/&/g, '&amp;')
286
- .replace(/</g, '&lt;')
287
- .replace(/>/g, '&gt;')
288
- .replace(/"/g, '&quot;');
289
- }
284
+ // escapeHtml imported from server/utils/escape-html.ts
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { earlyHintsSenderAls } from './als-registry.js';
22
+ import { swallow } from './logger.js';
22
23
 
23
24
  /** Function that sends Link header values as a 103 Early Hints response. */
24
25
  export type EarlyHintsSenderFn = (links: string[]) => void;
@@ -47,7 +48,7 @@ export function sendEarlyHints103(links: string[]): void {
47
48
  if (!sender) return;
48
49
  try {
49
50
  sender(links);
50
- } catch {
51
- // Sending 103 is best-effort — failure never blocks the request.
51
+ } catch (err) {
52
+ swallow(err, 'early hints 103 send failed');
52
53
  }
53
54
  }
@@ -98,7 +98,7 @@ export async function renderDevErrorPage(error: unknown, projectRoot?: string):
98
98
  try {
99
99
  // Dynamic import — keeps dev-error-page.ts and its transitive deps
100
100
  // (dev-error-overlay.ts, @jridgewell/trace-mapping) out of production bundles.
101
- const { generateDevErrorPage } = await import('../plugins/dev-error-page.js');
101
+ const { generateDevErrorPage } = await import('../dev-tools/error-page.js');
102
102
  html = generateDevErrorPage(err, 'render', root);
103
103
  // Inject Vite client script so the error overlay fires when HMR connects.
104
104
  html = html.replace(
@@ -7,20 +7,18 @@
7
7
  export type { AccessContext } from './types';
8
8
  export type { MiddlewareContext } from './types';
9
9
  export type { RouteContext } from './types';
10
- export type { Metadata, MetadataRoute } from './types';
10
+ export type { Metadata, MetadataRoute, MetadataHandler, MetadataResult } from './types';
11
11
 
12
- // Request Context — ALS-backed getHeaders(), getCookies(), and getSearchParams()
12
+ // Request Context — ALS-backed accessors (all sync)
13
+ // Prefer defineSearchParams().get() and defineSegmentParams().get() for typed access.
13
14
  // Design doc: design/04-authorization.md §"AccessContext does not include cookies or headers"
14
- // Design doc: design/23-search-params.md §"Server Integration"
15
- export {
16
- getHeaders,
17
- getHeader,
18
- getCookies,
19
- getCookie,
20
- getSearchParams,
21
- getSegmentParams,
22
- } from './request-context';
23
- export type { ReadonlyHeaders, RequestCookies, CookieOptions } from './request-context';
15
+ export { getHeaders, getSegmentParams } from './request-context';
16
+ export type { ReadonlyHeaders } from './request-context';
17
+
18
+ // Cookie Context — ALS-backed getCookieJar() escape hatch
19
+ // Design doc: design/29-cookies.md
20
+ export { getCookieJar } from './cookie-context';
21
+ export type { RequestCookies, CookieOptions, SetCookieOptions } from './cookie-context';
24
22
 
25
23
  // Runtime primitives
26
24
  export {
@@ -52,8 +50,9 @@ export type {
52
50
  ValidationErrors,
53
51
  } from './action-client';
54
52
 
55
- // FormData Preprocessing
56
- export { parseFormData, coerce } from './form-data';
53
+ // FormData Preprocessing — internal only, not part of public API.
54
+ // Schema libraries (Zod z.coerce.*, Valibot v.transform(), ArkType morphs)
55
+ // handle form coercion. Kept for action-client/action-handler internals.
57
56
 
58
57
  // Form Flash (no-JS error round-trip)
59
58
  export { getFormFlash } from './form-flash';
@@ -13,12 +13,18 @@
13
13
 
14
14
  // ── Request Context internals ────────────────────────────────────────────
15
15
  export {
16
+ getSearchParams,
17
+ getSegmentParams,
18
+ getHeader,
16
19
  setSegmentParams,
17
20
  runWithRequestContext,
18
21
  setMutableCookieContext,
19
22
  markResponseFlushed,
20
- getSetCookieHeaders,
21
23
  } from './request-context.js';
24
+ export { getCookie, getSetCookieHeaders } from './cookie-context.js';
25
+
26
+ // ── Form-data coercion (internal) ────────────────────────────────────────
27
+ export { coerce } from './form-data.js';
22
28
 
23
29
  // ── Signal classes (instanceof targets for pipeline catch logic) ──────────
24
30
  export { DenySignal, RedirectSignal, RenderError } from './primitives.js';
@@ -60,6 +66,7 @@ export type {
60
66
  TreeBuilderConfig,
61
67
  TreeBuildResult,
62
68
  LoadedModule,
69
+ LoadedComponent,
63
70
  ModuleLoader,
64
71
  AccessGateProps,
65
72
  SlotAccessGateProps,
@@ -157,8 +164,8 @@ export {
157
164
  warnSlowSlotWithoutSuspense,
158
165
  setViteServer,
159
166
  WarningId,
160
- } from './dev-warnings.js';
161
- export type { DevWarningConfig } from './dev-warnings.js';
167
+ } from '../dev-tools/warnings.js';
168
+ export type { DevWarningConfig } from '../dev-tools/warnings.js';
162
169
 
163
170
  // ── Route Handler ────────────────────────────────────────────────────────
164
171
  export { handleRouteRequest, resolveAllowedMethods } from './route-handler.js';
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { getTraceStore } from './tracing.js';
12
12
  import { createDefaultLogger } from './default-logger.js';
13
+ import { isDevMode } from './debug.js';
13
14
 
14
15
  // ─── Logger Interface ─────────────────────────────────────────────────────
15
16
 
@@ -156,3 +157,25 @@ export function logSwrRefetchFailed(data: { cacheKey: string; error: unknown }):
156
157
  export function logCacheMiss(data: { cacheKey: string }): void {
157
158
  _logger.debug('timber.cache MISS', withTraceContext(data));
158
159
  }
160
+
161
+ // ─── Swallow Helper ───────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Log an intentionally swallowed error. Provides observability into catch
165
+ * blocks that are deliberately empty — the error is consumed, never rethrown.
166
+ *
167
+ * Default level: `warn` in dev (so the overlay surfaces patterns), `debug`
168
+ * in production (low noise unless TIMBER_DEBUG is set). Pass `opts.level`
169
+ * to override.
170
+ *
171
+ * **Infallible** — swallow() itself never throws, even if the logger is
172
+ * broken. A thrown swallow would turn a benign catch into a crash.
173
+ */
174
+ export function swallow(err: unknown, reason: string, opts?: { level?: 'debug' | 'warn' }): void {
175
+ try {
176
+ const level = opts?.level ?? (isDevMode() ? 'warn' : 'debug');
177
+ _logger[level](`swallowed: ${reason}`, withTraceContext({ error: err }));
178
+ } catch {
179
+ // swallow() must never throw.
180
+ }
181
+ }
@@ -57,3 +57,47 @@ export async function runMiddlewareChain(
57
57
  }
58
58
  return undefined;
59
59
  }
60
+
61
+ // ─── Per-Request Middleware Bypass ─────────────────────────────────────────
62
+
63
+ /**
64
+ * Per-request marker for synthetic re-render requests that should NOT
65
+ * re-execute `middleware.ts`. The action-dispatch wrapper runs middleware
66
+ * once on the inbound action POST; when validation fails on the no-JS
67
+ * path, it builds a synthetic GET that flows through the normal pipeline
68
+ * to render the page with `getFormFlash()` data. Without this marker, the
69
+ * pipeline would run middleware a second time on that synthetic GET.
70
+ *
71
+ * The set is keyed by the synthetic Request object itself, so the entry
72
+ * lives exactly as long as the request and is garbage-collected with it.
73
+ * Cannot be set or detected by user code — there is no header, no URL
74
+ * parameter, nothing on the wire that an attacker could spoof.
75
+ *
76
+ * See TIM-871.
77
+ *
78
+ * @internal — framework use only.
79
+ */
80
+ const middlewareBypassRequests = new WeakSet<Request>();
81
+
82
+ /**
83
+ * Mark a request so the pipeline skips its middleware phase.
84
+ *
85
+ * Used by `wrap-action-dispatch.ts` for the no-JS form-rerender path.
86
+ *
87
+ * @internal
88
+ */
89
+ export function markRequestBypassMiddleware(req: Request): void {
90
+ middlewareBypassRequests.add(req);
91
+ }
92
+
93
+ /**
94
+ * Check whether a request was marked to bypass middleware.
95
+ *
96
+ * Called by `handleRequest` in pipeline-phases.ts before invoking the
97
+ * middleware phase. Returns false for any request not explicitly marked.
98
+ *
99
+ * @internal
100
+ */
101
+ export function shouldBypassMiddleware(req: Request): boolean {
102
+ return middlewareBypassRequests.has(req);
103
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Segment param coercion — runs the matched route's `params.ts` codecs
3
+ * over the raw matcher output before middleware and rendering.
4
+ *
5
+ * Lifted out of `pipeline-phases.ts` (TIM-853) so the coercer can be
6
+ * imported directly by other entry points (the action-dispatch wrapper,
7
+ * the revalidation renderer in `rsc-entry/index.ts`) without pulling
8
+ * the entire pipeline phase module along with it.
9
+ *
10
+ * The function throws `ParamCoercionError` from `route-element-builder.ts`
11
+ * on any codec failure; the pipeline catches that and dispatches to the
12
+ * 404 page. See design/07-routing.md §"Where Coercion Runs".
13
+ */
14
+
15
+ import type { RouteMatch } from './pipeline.js';
16
+ import { sanitizeParamValue } from './pipeline-helpers.js';
17
+ import { loadModule } from './safe-load.js';
18
+ import { ParamCoercionError } from './route-element-builder.js';
19
+
20
+ /**
21
+ * Run segment param coercion on the matched route's segments.
22
+ *
23
+ * Loads params.ts modules from segments that have them, extracts the
24
+ * segmentParams definition, and coerces raw string params through codecs.
25
+ * Throws ParamCoercionError if any codec fails (→ 404).
26
+ *
27
+ * This runs BEFORE middleware, so ctx.segmentParams is already typed.
28
+ * See design/07-routing.md §"Where Coercion Runs"
29
+ */
30
+ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
31
+ // Unconditionally install a null-prototype target so the invariant
32
+ // "match.segmentParams is null-prototype" holds from the first line,
33
+ // regardless of whether any segment has a codec.
34
+ const mergeTarget: Record<string, unknown> = Object.create(null);
35
+ for (const key of Object.keys(match.segmentParams)) {
36
+ if (key !== '__proto__') {
37
+ mergeTarget[key] = match.segmentParams[key as keyof typeof match.segmentParams];
38
+ }
39
+ }
40
+ match.segmentParams = mergeTarget as RouteMatch['segmentParams'];
41
+
42
+ for (const segment of match.segments) {
43
+ // Only process segments that have a params.ts convention file
44
+ if (!segment.params) continue;
45
+
46
+ let mod: Record<string, unknown>;
47
+ try {
48
+ mod = await loadModule(segment.params);
49
+ } catch (err) {
50
+ throw new ParamCoercionError(
51
+ `Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`
52
+ );
53
+ }
54
+
55
+ const segmentParamsDef = mod.segmentParams as
56
+ | { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
57
+ | undefined;
58
+
59
+ if (!segmentParamsDef || typeof segmentParamsDef.parse !== 'function') continue;
60
+
61
+ try {
62
+ const coerced = segmentParamsDef.parse(match.segmentParams);
63
+
64
+ // Deep-sanitize codec output: every nested plain object becomes
65
+ // null-prototype with dangerous keys stripped at every depth.
66
+ // See TIM-873, design/13-security.md
67
+ for (const key of Object.keys(coerced as Record<string, unknown>)) {
68
+ if (key !== '__proto__') {
69
+ mergeTarget[key] = sanitizeParamValue((coerced as Record<string, unknown>)[key]);
70
+ }
71
+ }
72
+ } catch (err) {
73
+ throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
74
+ }
75
+ }
76
+ }
@@ -10,33 +10,57 @@
10
10
  */
11
11
 
12
12
  import type { ProxyExport } from './proxy.js';
13
- import { getSetCookieHeaders } from './request-context.js';
13
+ import { getSetCookieHeaders } from './cookie-context.js';
14
14
  import { callOnRequestError } from './instrumentation.js';
15
15
  import { getTraceId } from './tracing.js';
16
16
  import { RedirectSignal } from './primitives.js';
17
17
  import type { ProxyConfig } from './pipeline.js';
18
18
 
19
- // ─── Prototype-Pollution-Safe Merge ────────────────────────────────────────
19
+ // ─── Prototype-Pollution-Safe Sanitizer ────────────────────────────────────
20
20
 
21
- /** Keys that must never be merged via Object.assign — they pollute Object.prototype. */
22
- const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
21
+ /**
22
+ * Only __proto__ needs stripping — it has a language-level setter that
23
+ * changes the prototype chain of spread copies. constructor and prototype
24
+ * are harmless own properties on null-prototype objects.
25
+ */
26
+ const DANGEROUS_KEYS = new Set(['__proto__']);
23
27
 
24
28
  /**
25
- * Shallow merge that skips top-level prototype-polluting keys.
29
+ * Deep-walk a value returned by a segment param codec, producing a
30
+ * sanitized copy where every plain object is null-prototype and
31
+ * dangerous keys (__proto__, constructor, prototype) are stripped at
32
+ * every depth.
33
+ *
34
+ * Non-plain objects (Date, Map, class instances, etc.) are returned
35
+ * as-is — they cannot be poisoned by `{...x}` spread and may carry
36
+ * author-intended prototype methods.
37
+ *
38
+ * Arrays are walked element-wise.
26
39
  *
27
- * This is intentionally NOT a deep sanitizer. It only blocks shallow
28
- * pollution via top-level `__proto__` / `constructor` / `prototype`
29
- * keys. The deeper guarantee for segment params comes from merging
30
- * codec output into a null-prototype target inside coerceSegmentParams().
40
+ * Performance: URL params are bounded by URL length (~8 KB). Realistic
41
+ * trees are <1 KB. The recursive walk is sub-microsecond.
31
42
  *
32
- * See TIM-655, TIM-855, design/13-security.md
43
+ * See TIM-655, TIM-855, TIM-873, design/13-security.md
33
44
  */
34
- export function safeMerge(target: Record<string, unknown>, source: Record<string, unknown>): void {
35
- for (const key of Object.keys(source)) {
45
+ export function sanitizeParamValue(value: unknown): unknown {
46
+ if (value === null || typeof value !== 'object') return value;
47
+
48
+ if (Array.isArray(value)) {
49
+ return value.map(sanitizeParamValue);
50
+ }
51
+
52
+ // Only walk plain objects — anything with a custom prototype (Date, Map,
53
+ // class instances) is left untouched.
54
+ const proto = Object.getPrototypeOf(value);
55
+ if (proto !== Object.prototype && proto !== null) return value;
56
+
57
+ const out: Record<string, unknown> = Object.create(null);
58
+ for (const key of Object.keys(value as Record<string, unknown>)) {
36
59
  if (!DANGEROUS_KEYS.has(key)) {
37
- target[key] = source[key];
60
+ out[key] = sanitizeParamValue((value as Record<string, unknown>)[key]);
38
61
  }
39
62
  }
63
+ return out;
40
64
  }
41
65
 
42
66
  // ─── Proxy Resolver ────────────────────────────────────────────────────────