@timber-js/app 0.2.0-alpha.134 → 0.2.0-alpha.135

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 (147) hide show
  1. package/dist/_chunks/{actions-B8Fhsosm.js → actions-CHFblsdT.js} +13 -9
  2. package/dist/_chunks/actions-CHFblsdT.js.map +1 -0
  3. package/dist/_chunks/{build-output-helper-K7oUCRqU.js → build-output-helper-CfBfOU9m.js} +1 -1
  4. package/dist/_chunks/{build-output-helper-K7oUCRqU.js.map → build-output-helper-CfBfOU9m.js.map} +1 -1
  5. package/dist/_chunks/{codec-registry-DqkeAydt.js → codec-registry-G9hD5LOq.js} +1 -1
  6. package/dist/_chunks/{codec-registry-DqkeAydt.js.map → codec-registry-G9hD5LOq.js.map} +1 -1
  7. package/dist/_chunks/{define-jeHDXZGq.js → define-DXWUJb-K.js} +3 -3
  8. package/dist/_chunks/{define-jeHDXZGq.js.map → define-DXWUJb-K.js.map} +1 -1
  9. package/dist/_chunks/{define-DMq-Yjp7.js → define-Dp-qu3ql.js} +3 -3
  10. package/dist/_chunks/{define-DMq-Yjp7.js.map → define-Dp-qu3ql.js.map} +1 -1
  11. package/dist/_chunks/fast-hash-DXZ7S2D5.js +36 -0
  12. package/dist/_chunks/fast-hash-DXZ7S2D5.js.map +1 -0
  13. package/dist/_chunks/{format-BjN9HLu9.js → format-CR6v1pWE.js} +1 -1
  14. package/dist/_chunks/{format-BjN9HLu9.js.map → format-CR6v1pWE.js.map} +1 -1
  15. package/dist/_chunks/{handler-store-bIAstrxQ.js → handler-store-aQMpqPe7.js} +1 -1
  16. package/dist/_chunks/{handler-store-bIAstrxQ.js.map → handler-store-aQMpqPe7.js.map} +1 -1
  17. package/dist/_chunks/{href-validation-BJmD5mZh.js → href-validation-2G-HZaQ8.js} +8 -8
  18. package/dist/_chunks/href-validation-2G-HZaQ8.js.map +1 -0
  19. package/dist/_chunks/{navigation-root-B5-RmsPi.js → navigation-root-BQqFQI-n.js} +3 -3
  20. package/dist/_chunks/{navigation-root-B5-RmsPi.js.map → navigation-root-BQqFQI-n.js.map} +1 -1
  21. package/dist/_chunks/{registry-BI9cgCt7.js → registry-Dp_hJL4s.js} +1 -1
  22. package/dist/_chunks/{registry-BI9cgCt7.js.map → registry-Dp_hJL4s.js.map} +1 -1
  23. package/dist/_chunks/{resolve-schema-DU2nbVOi.js → resolve-schema-D3CbcdCW.js} +2 -2
  24. package/dist/_chunks/{resolve-schema-DU2nbVOi.js.map → resolve-schema-D3CbcdCW.js.map} +1 -1
  25. package/dist/_chunks/{router-ref-B2-2kIIC.js → router-ref-B50j42zB.js} +2 -2
  26. package/dist/_chunks/{router-ref-B2-2kIIC.js.map → router-ref-B50j42zB.js.map} +1 -1
  27. package/dist/_chunks/{schema-bridge-o0SwFtXW.js → schema-bridge-Cuy8BaYu.js} +2 -2
  28. package/dist/_chunks/{schema-bridge-o0SwFtXW.js.map → schema-bridge-Cuy8BaYu.js.map} +1 -1
  29. package/dist/_chunks/{segment-context-Bg0pFbLT.js → segment-context-BnxoPSFQ.js} +1 -1
  30. package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-BnxoPSFQ.js.map} +1 -1
  31. package/dist/_chunks/{ssr-data-DVX8S7vj.js → ssr-data-DxXyoRrz.js} +1 -1
  32. package/dist/_chunks/{ssr-data-DVX8S7vj.js.map → ssr-data-DxXyoRrz.js.map} +1 -1
  33. package/dist/_chunks/{stale-reload-CCqb62Z3.js → stale-reload-DIIIVGh_.js} +1 -1
  34. package/dist/_chunks/{stale-reload-CCqb62Z3.js.map → stale-reload-DIIIVGh_.js.map} +1 -1
  35. package/dist/_chunks/{use-query-states-C9G6LP0F.js → use-query-states-D9SBlAfp.js} +2 -2
  36. package/dist/_chunks/{use-query-states-C9G6LP0F.js.map → use-query-states-D9SBlAfp.js.map} +1 -1
  37. package/dist/_chunks/{use-segment-params-CIeinIx2.js → use-segment-params-x2poKmk8.js} +2 -2
  38. package/dist/_chunks/use-segment-params-x2poKmk8.js.map +1 -0
  39. package/dist/_chunks/{validation-AfEFM5ze.js → validation-BoWM_qI1.js} +31 -2
  40. package/dist/_chunks/validation-BoWM_qI1.js.map +1 -0
  41. package/dist/_chunks/walkers-BbEu2_Pp.js.map +1 -1
  42. package/dist/adapters/cloudflare.js +1 -1
  43. package/dist/adapters/nitro.js +1 -1
  44. package/dist/cache/cache-api.d.ts +1 -1
  45. package/dist/cache/cache-api.d.ts.map +1 -1
  46. package/dist/cache/index.js +17 -39
  47. package/dist/cache/index.js.map +1 -1
  48. package/dist/cache/timber-cache.d.ts +1 -1
  49. package/dist/cache/timber-cache.d.ts.map +1 -1
  50. package/dist/client/error-boundary.js +2 -2
  51. package/dist/client/index.d.ts.map +1 -1
  52. package/dist/client/index.js +10 -10
  53. package/dist/client/index.js.map +1 -1
  54. package/dist/client/internal.js +8 -8
  55. package/dist/client/internal.js.map +1 -1
  56. package/dist/client/link.d.ts.map +1 -1
  57. package/dist/client/navigation-context.d.ts.map +1 -1
  58. package/dist/client/use-pathname.d.ts.map +1 -1
  59. package/dist/client/use-search-params.d.ts.map +1 -1
  60. package/dist/codec.js +1 -1
  61. package/dist/cookies/index.js +3 -3
  62. package/dist/cookies/validation.d.ts +11 -23
  63. package/dist/cookies/validation.d.ts.map +1 -1
  64. package/dist/dev-tools/error-page.d.ts.map +1 -1
  65. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +106 -3
  68. package/dist/index.js.map +1 -1
  69. package/dist/params/define-schema.d.ts.map +1 -1
  70. package/dist/params/index.js +2 -2
  71. package/dist/params/index.js.map +1 -1
  72. package/dist/plugins/cache.d.ts +31 -0
  73. package/dist/plugins/cache.d.ts.map +1 -0
  74. package/dist/routing/link-codegen.d.ts.map +1 -1
  75. package/dist/schema-bridge.d.ts.map +1 -1
  76. package/dist/search-params/index.js +3 -3
  77. package/dist/segment-params/index.js +1 -1
  78. package/dist/server/actions.d.ts.map +1 -1
  79. package/dist/server/body-limits.d.ts.map +1 -1
  80. package/dist/server/cookie-context.d.ts +2 -2
  81. package/dist/server/cookie-context.d.ts.map +1 -1
  82. package/dist/server/csrf.d.ts +3 -2
  83. package/dist/server/csrf.d.ts.map +1 -1
  84. package/dist/server/html-injector-core.d.ts.map +1 -1
  85. package/dist/server/html-injectors.d.ts.map +1 -1
  86. package/dist/server/index.js +17 -8
  87. package/dist/server/index.js.map +1 -1
  88. package/dist/server/internal.js +50 -13
  89. package/dist/server/internal.js.map +1 -1
  90. package/dist/server/pipeline.d.ts.map +1 -1
  91. package/dist/server/primitives.d.ts.map +1 -1
  92. package/dist/server/route-element-builder.d.ts.map +1 -1
  93. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  94. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  95. package/dist/server/rsc-entry/revalidate-renderer.d.ts +108 -0
  96. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -0
  97. package/dist/server/rsc-entry/ssr-renderer.d.ts +15 -0
  98. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  99. package/dist/server/sitemap-handler.d.ts.map +1 -1
  100. package/dist/server/ssr-wrappers.d.ts.map +1 -1
  101. package/dist/server/tree-builder.d.ts.map +1 -1
  102. package/dist/shared/href-validation.d.ts.map +1 -1
  103. package/package.json +7 -6
  104. package/src/cache/cache-api.ts +3 -2
  105. package/src/cache/timber-cache.ts +27 -4
  106. package/src/cli.ts +0 -0
  107. package/src/client/index.ts +1 -0
  108. package/src/client/link.tsx +8 -5
  109. package/src/client/navigation-context.ts +2 -0
  110. package/src/client/use-pathname.ts +1 -0
  111. package/src/client/use-search-params.ts +1 -0
  112. package/src/client/use-segment-params.ts +1 -0
  113. package/src/config-validation.ts +1 -1
  114. package/src/cookies/validation.ts +58 -0
  115. package/src/dev-tools/error-page.ts +1 -0
  116. package/src/fonts/virtual-modules.ts +2 -0
  117. package/src/index.ts +5 -1
  118. package/src/params/define-schema.ts +1 -0
  119. package/src/plugins/cache.ts +180 -0
  120. package/src/routing/link-codegen.ts +5 -3
  121. package/src/schema-bridge.ts +3 -2
  122. package/src/server/action-client.ts +31 -13
  123. package/src/server/actions.ts +20 -3
  124. package/src/server/body-limits.ts +10 -2
  125. package/src/server/cookie-context.ts +7 -2
  126. package/src/server/cookie-parsing.ts +1 -1
  127. package/src/server/csrf.ts +39 -6
  128. package/src/server/html-injector-core.ts +6 -5
  129. package/src/server/html-injectors.ts +8 -5
  130. package/src/server/pipeline.ts +19 -10
  131. package/src/server/primitives.ts +11 -1
  132. package/src/server/route-element-builder.ts +2 -0
  133. package/src/server/rsc-entry/error-renderer.ts +4 -3
  134. package/src/server/rsc-entry/index.ts +16 -36
  135. package/src/server/rsc-entry/revalidate-renderer.ts +344 -0
  136. package/src/server/rsc-entry/ssr-renderer.ts +76 -0
  137. package/src/server/sitemap-handler.ts +24 -2
  138. package/src/server/slot-resolver.ts +8 -2
  139. package/src/server/ssr-wrappers.tsx +2 -0
  140. package/src/server/tree-builder.ts +2 -0
  141. package/src/server/types.ts +2 -2
  142. package/src/shared/href-validation.ts +12 -8
  143. package/LICENSE +0 -8
  144. package/dist/_chunks/actions-B8Fhsosm.js.map +0 -1
  145. package/dist/_chunks/href-validation-BJmD5mZh.js.map +0 -1
  146. package/dist/_chunks/use-segment-params-CIeinIx2.js.map +0 -1
  147. package/dist/_chunks/validation-AfEFM5ze.js.map +0 -1
@@ -45,11 +45,10 @@ import { buildClientScripts } from '../html-injectors.js';
45
45
  import type { InterceptionContext, PipelineConfig, RouteMatch } from '../pipeline.js';
46
46
  import { createPipeline } from '../pipeline.js';
47
47
  import { coerceSegmentParams, setGlobalSchemaCodecs } from '../param-coercion.js';
48
- import { setSegmentParams } from '../request-context.js';
49
- import { buildRouteElement } from '../route-element-builder.js';
50
48
  import type { ManifestSegmentNode } from '../route-matcher.js';
51
49
  import { createMetadataRouteMatcher, createRouteMatcher } from '../route-matcher.js';
52
50
  import { initDevTracing } from '../tracing.js';
51
+ import { createRevalidateRendererFactory } from './revalidate-renderer.js';
53
52
 
54
53
  import { renderFallbackError as renderFallback } from '../fallback-error.js';
55
54
  import { loadInstrumentation } from '../instrumentation.js';
@@ -422,40 +421,21 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
422
421
  // action POSTs) matches the page pipeline exactly. Defaults to `true`
423
422
  // both here and inside the wrapper.
424
423
  stripTrailingSlash: pipelineConfig.stripTrailingSlash,
425
- buildRevalidateRenderer: (req) => async (path: string) => {
426
- // Build the React element tree for the route at `path`.
427
- // Returns the element tree (not serialized) so the action handler can
428
- // combine it with the action result in a single renderToReadableStream call.
429
- // Forward original request headers (cookies, session IDs, etc.).
430
- const revalidateHeaders = new Headers(req.headers);
431
- revalidateHeaders.set('Accept', 'text/x-component');
432
- const revalidateReq = new Request(new URL(path, req.url), {
433
- headers: revalidateHeaders,
434
- });
435
- const revalidateMatch = matchRoute(new URL(revalidateReq.url).pathname);
436
- if (!revalidateMatch) {
437
- throw new Error(`revalidatePath('${path}') — no matching route`);
438
- }
439
- // Coerce segment params (params.ts) before building the element tree.
440
- // Without this, components receive raw strings instead of typed values.
441
- await coerceSegmentParams(revalidateMatch);
442
- // Set coerced params in ALS so getSegmentParams() works during the
443
- // revalidation render (AccessGate reads params via ALS). Without this,
444
- // AccessGate → getSegmentParams() throws because segmentParams
445
- // is never set. See TIM-667.
446
- setSegmentParams(revalidateMatch.segmentParams);
447
- const routeResult = await buildRouteElement(
448
- revalidateReq,
449
- revalidateMatch,
450
- undefined,
451
- null,
452
- (buildManifest as BuildManifest).metadataRouteHashes
453
- );
454
- return {
455
- element: routeResult.element,
456
- headElements: routeResult.headElements,
457
- };
458
- },
424
+ // Revalidation renderer — runs the target route's `middleware.ts`
425
+ // chain BEFORE building the element tree so a server action that
426
+ // revalidates a protected path honors that path's auth, tenant
427
+ // isolation, and request-header injection. Closes the silent-bypass
428
+ // bug where `revalidatePath('/admin')` from a public action would
429
+ // serialize the admin RSC tree without ever consulting admin's
430
+ // middleware. Also validates the user-supplied `path` as a local
431
+ // root-relative URL to reject `revalidatePath('//evil.com/admin')`
432
+ // and similar scheme/authority injections. See TIM-996 and
433
+ // design/08-forms-and-actions.md §"revalidatePath()".
434
+ buildRevalidateRenderer: createRevalidateRendererFactory({
435
+ matchRoute,
436
+ coerceSegmentParams,
437
+ buildManifest: buildManifest as BuildManifest,
438
+ }),
459
439
  });
460
440
 
461
441
  return { handler, pipelineConfig };
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Revalidation renderer — builds the React element tree for the target
3
+ * path requested by `revalidatePath()` from inside a server action, with
4
+ * the same security guarantees as a normal route render.
5
+ *
6
+ * Before this module existed, `revalidatePath()` matched the route, coerced
7
+ * params, and called `buildRouteElement()` directly. That bypassed the
8
+ * target route's `middleware.ts` chain entirely. An action that revalidated
9
+ * a higher-privilege or user-controlled path could leak an authenticated
10
+ * RSC tree to the action submitter even when middleware on that path
11
+ * would have denied them.
12
+ *
13
+ * The fix (TIM-996) is to run the target route's middleware chain inside
14
+ * a fresh `runWithRequestContext` scope BEFORE building the element tree,
15
+ * propagate `DenySignal` and `RedirectSignal` to `executeAction`, drop the
16
+ * revalidation when middleware returns a short-circuit `Response`, and
17
+ * lift the request-header overlay onto the action's ALS store so the
18
+ * revalidated component render sees middleware-injected headers via
19
+ * `getHeaders()`.
20
+ *
21
+ * The input `path` is also validated as a local root-relative pathname so
22
+ * `revalidatePath('//evil.com/admin')` and similar scheme/authority
23
+ * injections cannot smuggle a foreign host through `new URL(path, base)`.
24
+ *
25
+ * See design/08-forms-and-actions.md §"revalidatePath()" and
26
+ * design/13-security.md §"Server actions silently bypass middleware".
27
+ */
28
+
29
+ import type { BuildManifest } from '../build-manifest.js';
30
+ import { canonicalize } from '../canonicalize.js';
31
+ import { getCookiesForSsr, seedRequestCookies } from '../cookie-context.js';
32
+ import { logRenderError } from '../logger.js';
33
+ import type { RouteMatch, RouteMatcher } from '../pipeline.js';
34
+ import { DenySignal, RedirectSignal } from '../primitives.js';
35
+ import { applyRequestHeaderOverlay, setSegmentParams } from '../request-context.js';
36
+ import { requestContextAls } from '../als-registry.js';
37
+ import { buildRouteElement } from '../route-element-builder.js';
38
+ import type { RevalidateRenderer, RevalidationResult } from '../actions.js';
39
+
40
+ import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-middleware-runner.js';
41
+
42
+ /**
43
+ * Sentinel thrown when revalidation cannot proceed because middleware
44
+ * returned a short-circuit `Response` (a non-redirect HTTP response that
45
+ * we cannot translate back to an element tree). `executeAction` catches
46
+ * this and drops the revalidation — the action result is still delivered,
47
+ * but no `_tree` is piggybacked onto the response. The matching name
48
+ * `RevalidationDropped` is exported so callers can identify it via
49
+ * `instanceof` without leaking the message text to clients.
50
+ */
51
+ export class RevalidationDropped extends Error {
52
+ constructor(message: string) {
53
+ super(message);
54
+ this.name = 'RevalidationDropped';
55
+ }
56
+ }
57
+
58
+ /** Result of validating a `revalidatePath()` argument. */
59
+ type PathValidation =
60
+ | { ok: true; pathname: string; search: string }
61
+ | { ok: false; reason: string };
62
+
63
+ /**
64
+ * Validate a `revalidatePath()` argument.
65
+ *
66
+ * Accepts local root-relative pathnames only. Rejects anything that
67
+ * `new URL(path, base)` would resolve to a different host or scheme:
68
+ *
69
+ * - Empty / non-string input
70
+ * - Anything not starting with `/` (relative, scheme-bearing, etc.)
71
+ * - Protocol-relative `//evil.com/admin` — resolves to scheme://evil.com
72
+ * - Backslash-prefixed `/\evil.com` — some clients fold `\` into `/`
73
+ * - Control characters and null bytes
74
+ * - Paths that fail canonicalization (encoded separators, `..` escaping
75
+ * root, malformed percent-encoding)
76
+ *
77
+ * The pathname is canonicalized so the rest of the renderer matches the
78
+ * same shape the page pipeline matches.
79
+ */
80
+ export function validateRevalidatePath(path: unknown): PathValidation {
81
+ if (typeof path !== 'string' || path.length === 0) {
82
+ return { ok: false, reason: 'must be a non-empty string' };
83
+ }
84
+ if (!path.startsWith('/')) {
85
+ return { ok: false, reason: 'must start with "/" (root-relative path)' };
86
+ }
87
+ if (path.startsWith('//')) {
88
+ return { ok: false, reason: 'must not start with "//" (protocol-relative URL)' };
89
+ }
90
+ if (path.startsWith('/\\')) {
91
+ return { ok: false, reason: 'must not start with "/\\" (backslash injection)' };
92
+ }
93
+ for (let i = 0; i < path.length; i++) {
94
+ const code = path.charCodeAt(i);
95
+ if (code <= 0x1f || code === 0x7f) {
96
+ return { ok: false, reason: 'must not contain control characters' };
97
+ }
98
+ }
99
+ const hashIndex = path.indexOf('#');
100
+ // Fragments are meaningless server-side and would not be sent back to
101
+ // the client on revalidation anyway. Drop them silently before splitting
102
+ // the query so a `#` cannot smuggle additional path content past the
103
+ // canonicalizer.
104
+ const noFragment = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
105
+ const queryIndex = noFragment.indexOf('?');
106
+ const pathnameOnly = queryIndex >= 0 ? noFragment.slice(0, queryIndex) : noFragment;
107
+ const search = queryIndex >= 0 ? noFragment.slice(queryIndex) : '';
108
+ const canonical = canonicalize(pathnameOnly, true);
109
+ if (!canonical.ok) {
110
+ return { ok: false, reason: `failed URL canonicalization (status ${canonical.status})` };
111
+ }
112
+ return { ok: true, pathname: canonical.pathname, search };
113
+ }
114
+
115
+ /**
116
+ * Serialize a cookie map into a `Cookie:` request header value
117
+ * (`name1=value1; name2=value2`).
118
+ *
119
+ * Cookie values are RFC 6265 cookie-octet (no `;`, no `,`, no whitespace,
120
+ * no `"` / `\`) by construction — `assertValidCookieValue()` rejects
121
+ * anything else at write time. Names are validated similarly. Joining
122
+ * with `; ` is therefore safe and cannot be used to smuggle additional
123
+ * cookies via a malformed value.
124
+ */
125
+ function serializeCookieMap(cookies: Map<string, string>): string {
126
+ const parts: string[] = [];
127
+ cookies.forEach((value, name) => {
128
+ parts.push(`${name}=${value}`);
129
+ });
130
+ return parts.join('; ');
131
+ }
132
+
133
+ /** Dependencies for the revalidate renderer factory. */
134
+ export interface RevalidateRendererDeps {
135
+ matchRoute: RouteMatcher;
136
+ coerceSegmentParams: CoerceSegmentParamsFn;
137
+ buildManifest: BuildManifest;
138
+ }
139
+
140
+ /**
141
+ * Build a per-request `RevalidateRenderer`.
142
+ *
143
+ * The returned renderer:
144
+ * 1. Validates the requested path.
145
+ * 2. Matches the canonicalized path against the route tree.
146
+ * 3. Coerces segment params via the matched chain's `params.ts` codecs.
147
+ * 4. Runs the matched route's `middleware.ts` chain inside a fresh
148
+ * `runWithRequestContext` scope (via `runMiddlewareForAction`).
149
+ * Deny/redirect signals are re-thrown so `executeAction` can apply
150
+ * its existing redirect handling and the new deny-drop behavior.
151
+ * A short-circuit `Response` becomes `RevalidationDropped`.
152
+ * 5. Lifts the request-header overlay onto the OUTER action's ALS store
153
+ * so the revalidated component render (which happens later inside
154
+ * `renderToReadableStream` in the action's scope) observes
155
+ * middleware-injected headers via `getHeaders()`. Acceptable because
156
+ * revalidation runs after the action body has returned — the only
157
+ * remaining ALS readers are render-phase code that should see the
158
+ * same overlay a normal page render would see.
159
+ * 6. Builds and returns the route element tree, ready for the action
160
+ * handler to serialize alongside the action result.
161
+ *
162
+ * Cookies set inside the revalidation middleware are intentionally NOT
163
+ * propagated to the action response. The middleware on a *different* path
164
+ * setting cookies via the action's response is surprising semantics; the
165
+ * scoped cookie jar on the inner ALS run is discarded with the scope.
166
+ * The primary security boundary closed here is auth — `deny()` from the
167
+ * target's middleware now prevents the revalidated tree from leaking.
168
+ */
169
+ export function createRevalidateRendererFactory(
170
+ deps: RevalidateRendererDeps
171
+ ): (req: Request) => RevalidateRenderer {
172
+ return (req: Request): RevalidateRenderer => {
173
+ return async (path: string): Promise<RevalidationResult> => {
174
+ // ── 1. Validate the path ────────────────────────────────────────
175
+ const validation = validateRevalidatePath(path);
176
+ if (!validation.ok) {
177
+ // Throwing a typed error here drops the revalidation. We do not
178
+ // surface the message to the client (logRenderError sinks it).
179
+ const err = new RevalidationDropped(
180
+ `revalidatePath(${JSON.stringify(path)}) rejected: ${validation.reason}`
181
+ );
182
+ logRenderError({ method: req.method, path: new URL(req.url).pathname, error: err });
183
+ throw err;
184
+ }
185
+
186
+ // ── 2. Build the revalidation Request on the canonical pathname ─
187
+ //
188
+ // Use the canonical pathname (not the raw input) so the rebuilt
189
+ // URL cannot carry a hostile authority — `new URL('//evil.com/x',
190
+ // base)` would otherwise resolve to scheme://evil.com/x. Forward
191
+ // the original action request's headers (session IDs, accept-
192
+ // language, etc.) so middleware on the target route sees the same
193
+ // identity the action did.
194
+ //
195
+ // The `Cookie:` header is REBUILT from the action's
196
+ // read-your-own-writes cookie state rather than copied from the
197
+ // inbound request. An action that rotates the session via
198
+ // `getCookieJar().set('session', newToken)` and then calls
199
+ // `revalidatePath('/dashboard')` should have the target route's
200
+ // middleware see the NEW session — otherwise a session-rotation
201
+ // action would succeed only to have the piggybacked revalidation
202
+ // redirect to /login. `getCookieJar()` writes only update the ALS
203
+ // jar, not the immutable `Request.headers`, so without this step
204
+ // middleware reading `ctx.req.headers.get('cookie')` sees stale
205
+ // values. Codex P2.
206
+ const baseUrl = new URL(req.url);
207
+ const revalidateUrl = new URL(baseUrl.origin);
208
+ revalidateUrl.pathname = validation.pathname;
209
+ revalidateUrl.search = validation.search;
210
+ const revalidateHeaders = new Headers(req.headers);
211
+ revalidateHeaders.set('Accept', 'text/x-component');
212
+ // Snapshot the action's post-mutation cookie map (deletions
213
+ // included) and serialize it into a `Cookie:` header value the
214
+ // target middleware can read off `ctx.req.headers.get('cookie')`.
215
+ // Only attempt this inside a request context — `getCookiesForSsr`
216
+ // throws otherwise — and bail out gracefully if no context is
217
+ // active (the legacy call-site contract permitted this).
218
+ const inRequestContext = Boolean(requestContextAls.getStore());
219
+ const ryw = inRequestContext ? getCookiesForSsr() : new Map<string, string>();
220
+ if (inRequestContext) {
221
+ if (ryw.size === 0) {
222
+ revalidateHeaders.delete('cookie');
223
+ } else {
224
+ revalidateHeaders.set('cookie', serializeCookieMap(ryw));
225
+ }
226
+ }
227
+ const revalidateReq = new Request(revalidateUrl.toString(), {
228
+ headers: revalidateHeaders,
229
+ });
230
+ // Seed the parsed map directly so the child `runWithRequestContext`
231
+ // inside `runMiddlewareForAction` skips `parseCookieHeader` — the
232
+ // same H-3 smuggling primitive mitigated by TIM-868. The Cookie
233
+ // header above remains in place for code that reads
234
+ // `ctx.req.headers.get('cookie')` directly.
235
+ if (inRequestContext) {
236
+ seedRequestCookies(revalidateReq, new Map(ryw));
237
+ }
238
+
239
+ // ── 3. Match the canonical path ─────────────────────────────────
240
+ const match = deps.matchRoute(validation.pathname);
241
+ if (!match) {
242
+ throw new Error(`revalidatePath('${path}') — no matching route`);
243
+ }
244
+
245
+ // ── 4. Run middleware in a fresh ALS scope ──────────────────────
246
+ //
247
+ // We only run middleware when the matched chain has at least one
248
+ // entry. Routes with no middleware skip this step (no scope, no
249
+ // overlay) — matching the page pipeline's behavior in
250
+ // `handleRequest` where `runMiddlewarePhase` is gated on
251
+ // `middlewareChain.length > 0`.
252
+ let overlay: Headers | undefined;
253
+ if (match.middlewareChain.length > 0) {
254
+ const outcome = await runMiddlewareForAction(
255
+ revalidateReq,
256
+ match,
257
+ deps.coerceSegmentParams
258
+ );
259
+
260
+ if (outcome.kind === 'param-coercion-error') {
261
+ // Bad params on the target path — drop revalidation. The
262
+ // action's own response still goes back to the client.
263
+ throw new RevalidationDropped(
264
+ `revalidatePath('${path}') — segment param coercion failed`
265
+ );
266
+ }
267
+ if (outcome.kind === 'error') {
268
+ // Re-throw unhandled middleware errors so `executeAction`'s
269
+ // existing `console.error` fallback logs them. Not surfaced
270
+ // to the action client (revalidation is best-effort).
271
+ throw outcome.error;
272
+ }
273
+ if (outcome.kind === 'redirect') {
274
+ // Propagate the redirect — `executeAction` catches
275
+ // RedirectSignal and converts it to an action-level redirect.
276
+ // See actions.ts §"Process path revalidation".
277
+ throw outcome.signal;
278
+ }
279
+ if (outcome.kind === 'deny') {
280
+ // Propagate the deny — `executeAction` catches DenySignal
281
+ // (added in this PR) and drops the revalidation.
282
+ throw outcome.signal;
283
+ }
284
+ if (outcome.kind === 'short-circuit') {
285
+ // Middleware returned a Response directly. A redirect Response
286
+ // (`return Response.redirect('/login')` or a 3xx + Location)
287
+ // is the same outcome as throwing `redirect()` — common for
288
+ // session-expiry / feature-flag gates — and the action client
289
+ // should follow it. Translate to a `RedirectSignal` so
290
+ // `executeAction` surfaces it as the action redirect, matching
291
+ // the normal page-pipeline behavior promised in
292
+ // design/08-forms-and-actions.md §"revalidatePath()". Codex P2.
293
+ const status = outcome.response.status;
294
+ const location = outcome.response.headers.get('location');
295
+ if (status >= 300 && status < 400 && location) {
296
+ throw new RedirectSignal(location, status);
297
+ }
298
+ // Any other short-circuit Response (200/403/418/etc.) cannot
299
+ // be translated to a piggy-backed element tree. Drop the
300
+ // revalidation; the action result still ships.
301
+ throw new RevalidationDropped(
302
+ `revalidatePath('${path}') — middleware returned a short-circuit Response (status ${status})`
303
+ );
304
+ }
305
+
306
+ // `continue` — capture the overlay so we can apply it to the
307
+ // action's request-context store below. We deliberately do NOT
308
+ // forward `outcome.cookies` or `outcome.setCookieHeaders` here;
309
+ // see the module docstring for why.
310
+ overlay = outcome.overlay;
311
+ } else {
312
+ // No middleware on the target — still coerce params so segment
313
+ // params reach `setSegmentParams` typed. `runMiddlewareForAction`
314
+ // would have done this for us; do it manually on the bypass path.
315
+ await deps.coerceSegmentParams(match);
316
+ }
317
+
318
+ // ── 5. Lift state onto the action's ALS store ───────────────────
319
+ //
320
+ // Segment params are set unconditionally so `getSegmentParams()`
321
+ // inside the revalidated tree returns the target path's coerced
322
+ // params, not the action route's. The overlay is applied only
323
+ // when middleware ran and produced headers — otherwise we leave
324
+ // the action's `headers()` view untouched.
325
+ setSegmentParams(match.segmentParams);
326
+ if (overlay) {
327
+ applyRequestHeaderOverlay(overlay);
328
+ }
329
+
330
+ // ── 6. Build the route element tree ─────────────────────────────
331
+ const routeResult = await buildRouteElement(
332
+ revalidateReq,
333
+ match,
334
+ undefined,
335
+ null,
336
+ deps.buildManifest.metadataRouteHashes
337
+ );
338
+ return {
339
+ element: routeResult.element,
340
+ headElements: routeResult.headElements,
341
+ };
342
+ };
343
+ };
344
+ }
@@ -36,6 +36,8 @@ import { callSsr } from './ssr-bridge.js';
36
36
  import type { RenderSignals } from './rsc-stream.js';
37
37
  import { recordTiming } from '../server-timing.js';
38
38
  import { teeWithErrorPropagation } from '../stream-utils.js';
39
+ import { addSpanEventSync } from '../tracing.js';
40
+ import { isDevMode } from '../debug.js';
39
41
 
40
42
  /**
41
43
  * Maximum number of queueMicrotask yields during signal promotion.
@@ -46,6 +48,19 @@ import { teeWithErrorPropagation } from '../stream-utils.js';
46
48
  */
47
49
  const SIGNAL_PROMOTION_MAX_YIELDS = 5;
48
50
 
51
+ /**
52
+ * Number of additional microtask yields used by the cap-reached detection
53
+ * phase (TIM-970). After the promotion loop burns its full cap without
54
+ * capturing a signal, we yield a few more times to distinguish "no signal
55
+ * was ever coming" (state 2) from "signal arrived one microtask past the
56
+ * cap" (state 3 — the silent-miss path). These yields are telemetry-only:
57
+ * if a late signal is observed it triggers a warn/span-event but is NOT
58
+ * promoted, because raising the effective promotion cap is deferred to
59
+ * the E.2 investigation. Without this phase, every default `deferSuspenseFor=0`
60
+ * render would emit a cap-reached warning even though nothing was missed.
61
+ */
62
+ const SIGNAL_PROMOTION_DETECTION_EXTRA_YIELDS = 3;
63
+
49
64
  /**
50
65
  * Test-only observable: records how many microtask yields were consumed
51
66
  * during the last signal promotion check. Import this in tests to assert
@@ -57,6 +72,20 @@ const SIGNAL_PROMOTION_MAX_YIELDS = 5;
57
72
  */
58
73
  export const __test_only_signalPromotionDepth: { value: number } = { value: 0 };
59
74
 
75
+ /**
76
+ * Test-only observable: true when the promotion loop hit the cap without
77
+ * capturing a signal (the silent-miss path — state 3 in TIM-970). False
78
+ * when the loop captured a signal at any depth, when it was skipped via
79
+ * `_fizzAllReadyResolved`, or when no signal was ever expected at a depth
80
+ * shallower than the cap.
81
+ *
82
+ * Pairs with `__test_only_signalPromotionDepth`: depth alone cannot
83
+ * distinguish "cap hit with signal captured on yield 5" from "cap hit with
84
+ * signal arriving on yield 6+" — both read `.value === 5`. This flag closes
85
+ * that gap. See TIM-970, design/05-streaming.md §"Signal Promotion".
86
+ */
87
+ export const __test_only_signalPromotionCapHit: { value: boolean } = { value: false };
88
+
60
89
  interface SsrRenderOptions {
61
90
  req: Request;
62
91
  rscStream: ReadableStream<Uint8Array>;
@@ -261,14 +290,61 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
261
290
  // See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
262
291
  if (!signals.redirectSignal && !signals.denySignal && !signals.renderError) {
263
292
  let yieldsConsumed = 0;
293
+ let detectionConfirmedLateSignal = false;
264
294
  if (!navContext._fizzAllReadyResolved) {
295
+ // Promotion phase: bounded cap of SIGNAL_PROMOTION_MAX_YIELDS.
296
+ // Signals captured here are honored by checkCapturedSignals and
297
+ // become the response (redirect/deny/error page).
265
298
  for (let i = 0; i < SIGNAL_PROMOTION_MAX_YIELDS; i++) {
266
299
  await new Promise<void>((r) => queueMicrotask(r));
267
300
  yieldsConsumed++;
268
301
  if (signals.redirectSignal || signals.denySignal || signals.renderError) break;
269
302
  }
303
+
304
+ // Detection phase (TIM-970): if the promotion cap was hit without
305
+ // capturing a signal, yield a few more times to disambiguate state 2
306
+ // ("no signal was ever coming" — the common legitimate-200 case) from
307
+ // state 3 ("signal arrived one microtask past the cap" — the silent
308
+ // miss). Telemetry-only: if a late signal is observed it triggers
309
+ // the warning and span event but is NOT promoted. Promoting it would
310
+ // effectively raise the cap to MAX_YIELDS + DETECTION_EXTRA_YIELDS,
311
+ // which the ticket defers to the E.2 investigation. The signals are
312
+ // cleared so checkCapturedSignals does not pick them up.
313
+ if (
314
+ yieldsConsumed === SIGNAL_PROMOTION_MAX_YIELDS &&
315
+ !signals.redirectSignal &&
316
+ !signals.denySignal &&
317
+ !signals.renderError
318
+ ) {
319
+ for (let i = 0; i < SIGNAL_PROMOTION_DETECTION_EXTRA_YIELDS; i++) {
320
+ await new Promise<void>((r) => queueMicrotask(r));
321
+ if (signals.redirectSignal || signals.denySignal || signals.renderError) {
322
+ detectionConfirmedLateSignal = true;
323
+ signals.redirectSignal = null;
324
+ signals.denySignal = null;
325
+ signals.renderError = null;
326
+ break;
327
+ }
328
+ }
329
+ }
270
330
  }
271
331
  __test_only_signalPromotionDepth.value = yieldsConsumed;
332
+ __test_only_signalPromotionCapHit.value = detectionConfirmedLateSignal;
333
+
334
+ if (detectionConfirmedLateSignal) {
335
+ if (isDevMode()) {
336
+ console.warn(
337
+ `[timber] Signal promotion cap reached (${SIGNAL_PROMOTION_MAX_YIELDS} microtask yields) — a deny()/redirect()/renderError signal arrived just past the cap and was discarded. ` +
338
+ `Likely cause: a page using deny()/redirect() inside a deeply-wrapped async component (custom withSpan, decorators, etc.). ` +
339
+ `Simplify the async chain or reduce withSpan/wrapper depth. ` +
340
+ `See design/05-streaming.md §"Signal Promotion".`
341
+ );
342
+ }
343
+ addSpanEventSync('timber.ssr.signal_promotion_cap_reached', {
344
+ depth: SIGNAL_PROMOTION_MAX_YIELDS,
345
+ signal_types_checked: 'redirect,deny,renderError',
346
+ });
347
+ }
272
348
  }
273
349
 
274
350
  const promoted = checkCapturedSignals(/* skipHandledDeny */ true);
@@ -75,11 +75,33 @@ export function createAutoSitemapHandler(
75
75
  // In dev, this cache is busted by file changes (the handler is recreated
76
76
  // when the route tree changes). In production, it's generated once.
77
77
  let cachedResult: SitemapResult | null = null;
78
+ let inflight: Promise<SitemapResult> | null = null;
78
79
 
79
80
  async function getOrGenerateSitemap(): Promise<SitemapResult> {
80
81
  if (cachedResult) return cachedResult;
81
- cachedResult = await generateSitemap(root, config, moduleLoader);
82
- return cachedResult;
82
+ if (!inflight) {
83
+ inflight = generateSitemap(root, config, moduleLoader).then(
84
+ (result) => {
85
+ cachedResult = result;
86
+ inflight = null;
87
+ return result;
88
+ },
89
+ (err) => {
90
+ inflight = null;
91
+ throw err;
92
+ }
93
+ );
94
+ }
95
+ // Every caller races against its own timeout — not just the first.
96
+ // Per design/06-caching.md — singleflight maps must have a timeout
97
+ // to prevent deadlock if generateSitemap hangs.
98
+ const timeout = new Promise<never>((_, reject) =>
99
+ setTimeout(() => {
100
+ inflight = null;
101
+ reject(new Error('[timber] sitemap generation timed out'));
102
+ }, 30_000)
103
+ );
104
+ return Promise.race([inflight, timeout]);
83
105
  }
84
106
 
85
107
  return async (pathname: string): Promise<Response | null> => {
@@ -196,9 +196,14 @@ export async function resolveSlotElement(
196
196
  // ─── Element Wrapping Helpers ───────────────────────────────────────────────
197
197
 
198
198
  /**
199
- * Wrap an element with error boundaries and layouts from intermediate
200
- * slot segments (indices 1..n, skipping the slot root at index 0).
199
+ * Wrap an element with error boundaries, access gates, and layouts from
200
+ * intermediate slot segments (indices 1..n, skipping the slot root at index 0).
201
201
  * Processes innermost-first to match route-element-builder.ts ordering.
202
+ *
203
+ * Access gates are enforced for every intermediate segment that has access.ts,
204
+ * not just the slot root. Without this, nested slot children (e.g.,
205
+ * @admin/reports/access.ts) and intercepting route children with access.ts
206
+ * would bypass authorization. See TIM-999.
202
207
  */
203
208
  async function wrapWithIntermediateSegments(
204
209
  chain: ManifestSegmentNode[],
@@ -208,6 +213,7 @@ async function wrapWithIntermediateSegments(
208
213
  for (let i = chain.length - 1; i > 0; i--) {
209
214
  const seg = chain[i];
210
215
  element = await wrapSegmentWithErrorBoundaries(seg, element, h);
216
+ element = await wrapWithAccessGate(seg, element, h);
211
217
  element = await wrapWithLayout(seg, element, h);
212
218
  }
213
219
  return element;
@@ -132,8 +132,10 @@ export function wrapSsrElement(
132
132
  const withNav = createElement(SsrNavigationProvider, null, element);
133
133
 
134
134
  // 2. TimberNuqsAdapter equivalent (wraps withNuqsSsrAdapter for the actual nuqs provider)
135
+ // eslint-disable-next-line react/no-children-prop -- createElement API
135
136
  const withNuqs = createElement(SsrNuqsWrapper, { searchParams, children: withNav });
136
137
 
137
138
  // 3. Outermost: NavigationRoot equivalent (PendingNavigationProvider + TopLoader)
139
+ // eslint-disable-next-line react/no-children-prop -- createElement API
138
140
  return createElement(SsrNavigationRoot, { hasTopLoader, children: withNuqs });
139
141
  }
@@ -302,10 +302,12 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
302
302
  }
303
303
  }
304
304
 
305
+ /* eslint-disable react/no-children-prop -- createElement API */
305
306
  element = createElement(LayoutComponent, {
306
307
  ...slotProps,
307
308
  children: element,
308
309
  });
310
+ /* eslint-enable react/no-children-prop */
309
311
  }
310
312
  }
311
313
  }
@@ -34,7 +34,7 @@ export interface RouteContext {
34
34
  headers: Headers;
35
35
  }
36
36
 
37
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type
37
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type, typescript/no-empty-interface
38
38
  export interface AccessContext {}
39
39
 
40
40
  export interface Metadata {
@@ -143,7 +143,7 @@ export interface Metadata {
143
143
 
144
144
  // eslint-disable-next-line @typescript-eslint/no-namespace
145
145
  export namespace MetadataRoute {
146
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type
146
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type, typescript/no-empty-interface
147
147
  export interface Sitemap extends Array<SitemapEntry> {}
148
148
  export interface SitemapEntry {
149
149
  url: string;