@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
@@ -0,0 +1,180 @@
1
+ /**
2
+ * timber-cache-transform — Vite plugin that injects stable callsite IDs
3
+ * into `cache()` calls imported from `@timber-js/app/cache`.
4
+ *
5
+ * Without this plugin, cache keys use a process-local counter (fnIdCounter)
6
+ * which is unstable across cold starts in serverless/multi-instance
7
+ * deployments. With a shared CacheHandler (Redis/KV), colliding counters
8
+ * cause cache key poisoning — serving one route's cached data to another.
9
+ *
10
+ * The plugin transforms:
11
+ * cache(fn, opts) → cache(fn, opts, "stable-id")
12
+ *
13
+ * where stable-id = fnv1a(relativeModulePath + ":cache:" + callIndex).
14
+ *
15
+ * See TIM-997, design/06-caching.md §"Cache Key Security".
16
+ */
17
+
18
+ import { relative } from 'node:path';
19
+ import MagicString from 'magic-string';
20
+ import { parseAst } from 'vite';
21
+ import type { Plugin } from 'vite';
22
+ import type { PluginContext } from '../plugin-context.js';
23
+ import { fnv1aHash } from '../cache/fast-hash.js';
24
+
25
+ const CACHE_MODULE_SOURCES = new Set(['@timber-js/app/cache', 'timber/cache']);
26
+
27
+ interface PositionedNode {
28
+ type: string;
29
+ start: number;
30
+ end: number;
31
+ [key: string]: unknown;
32
+ }
33
+
34
+ interface IdentifierNode extends PositionedNode {
35
+ type: 'Identifier';
36
+ name: string;
37
+ }
38
+
39
+ interface ImportDeclarationNode extends PositionedNode {
40
+ type: 'ImportDeclaration';
41
+ source: { value: string };
42
+ specifiers: Array<
43
+ PositionedNode & {
44
+ type: string;
45
+ local: IdentifierNode;
46
+ imported?: IdentifierNode;
47
+ }
48
+ >;
49
+ }
50
+
51
+ interface CallExpressionNode extends PositionedNode {
52
+ type: 'CallExpression';
53
+ callee: PositionedNode;
54
+ arguments: PositionedNode[];
55
+ }
56
+
57
+ interface ProgramNode extends PositionedNode {
58
+ type: 'Program';
59
+ body: PositionedNode[];
60
+ }
61
+
62
+ /**
63
+ * Find the local binding name(s) for `cache` imported from the cache module.
64
+ */
65
+ function findCacheBindings(program: ProgramNode): Set<string> {
66
+ const bindings = new Set<string>();
67
+ for (const stmt of program.body) {
68
+ if (stmt.type !== 'ImportDeclaration') continue;
69
+ const decl = stmt as unknown as ImportDeclarationNode;
70
+ if (!CACHE_MODULE_SOURCES.has(decl.source.value)) continue;
71
+ for (const spec of decl.specifiers) {
72
+ if (spec.type !== 'ImportSpecifier') continue;
73
+ const importedName = spec.imported?.name ?? spec.local.name;
74
+ if (importedName === 'cache') {
75
+ bindings.add(spec.local.name);
76
+ }
77
+ }
78
+ }
79
+ return bindings;
80
+ }
81
+
82
+ /**
83
+ * Recursively walk AST nodes to find cache() call expressions.
84
+ *
85
+ * cache() is typically called at module scope, but we also handle calls
86
+ * inside functions/blocks for completeness.
87
+ */
88
+ function findCacheCallSites(
89
+ node: PositionedNode,
90
+ cacheBindings: Set<string>,
91
+ results: CallExpressionNode[]
92
+ ): void {
93
+ if (node.type === 'CallExpression') {
94
+ const call = node as unknown as CallExpressionNode;
95
+ if (
96
+ call.callee.type === 'Identifier' &&
97
+ cacheBindings.has((call.callee as IdentifierNode).name) &&
98
+ call.arguments.length === 2
99
+ ) {
100
+ results.push(call);
101
+ }
102
+ }
103
+ for (const key of Object.keys(node)) {
104
+ if (key === 'type' || key === 'start' || key === 'end') continue;
105
+ const val = node[key];
106
+ if (val && typeof val === 'object') {
107
+ if (Array.isArray(val)) {
108
+ for (const item of val) {
109
+ if (item && typeof item === 'object' && typeof item.type === 'string') {
110
+ findCacheCallSites(item as PositionedNode, cacheBindings, results);
111
+ }
112
+ }
113
+ } else if (typeof (val as PositionedNode).type === 'string') {
114
+ findCacheCallSites(val as PositionedNode, cacheBindings, results);
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Generate a stable callsite ID from a module path and call index.
122
+ *
123
+ * The ID is an FNV-1a hash of the relative module path + call index,
124
+ * providing a deterministic identifier that is:
125
+ * - Stable across cold starts of the same build
126
+ * - Unique across different modules
127
+ * - Unique across multiple cache() calls within the same module
128
+ */
129
+ function generateStableId(relativeModulePath: string, callIndex: number): string {
130
+ return fnv1aHash(`${relativeModulePath}:cache:${callIndex}`);
131
+ }
132
+
133
+ export function timberCacheTransform(ctx: PluginContext): Plugin {
134
+ return {
135
+ name: 'timber-cache-transform',
136
+
137
+ transform(code, id) {
138
+ if (!code.includes('@timber-js/app/cache') && !code.includes('timber/cache')) return;
139
+ // Skip node_modules (don't transform third-party code)
140
+ if (id.includes('node_modules')) return;
141
+
142
+ let program: ProgramNode;
143
+ try {
144
+ program = parseAst(code, { lang: 'tsx' }) as unknown as ProgramNode;
145
+ } catch {
146
+ return;
147
+ }
148
+
149
+ const cacheBindings = findCacheBindings(program);
150
+ if (cacheBindings.size === 0) return;
151
+
152
+ const callSites: CallExpressionNode[] = [];
153
+ findCacheCallSites(program, cacheBindings, callSites);
154
+ if (callSites.length === 0) return;
155
+
156
+ // Sort by position in file for deterministic call indices
157
+ callSites.sort((a, b) => a.start - b.start);
158
+
159
+ const root = ctx.root || process.cwd();
160
+ const relPath = relative(root, id);
161
+ const s = new MagicString(code);
162
+
163
+ for (let i = 0; i < callSites.length; i++) {
164
+ const call = callSites[i];
165
+ const stableId = generateStableId(relPath, i);
166
+ // Insert the stable ID as the 3rd argument just before the closing ')'
167
+ const insertPos = call.end - 1;
168
+ s.appendLeft(insertPos, `, "${stableId}"`);
169
+ }
170
+
171
+ return {
172
+ code: s.toString(),
173
+ map: s.generateMap({ hires: 'boundary' }),
174
+ };
175
+ },
176
+ };
177
+ }
178
+
179
+ // Exported for testing
180
+ export { generateStableId };
@@ -45,15 +45,15 @@ export function buildResolvedPattern(route: RouteEntry): string | null {
45
45
  const parts = route.urlPath.split('/');
46
46
  const templateParts = parts.map((part) => {
47
47
  if (part.startsWith('[[...') && part.endsWith(']]')) {
48
- // Optional catch-all — matches any trailing path
48
+ // eslint-disable-next-line no-template-curly-in-string -- codegen output
49
49
  return '${string}';
50
50
  }
51
51
  if (part.startsWith('[...') && part.endsWith(']')) {
52
- // Catch-all — matches one or more segments
52
+ // eslint-disable-next-line no-template-curly-in-string -- codegen output
53
53
  return '${string}';
54
54
  }
55
55
  if (part.startsWith('[') && part.endsWith(']')) {
56
- // Dynamic segment
56
+ // eslint-disable-next-line no-template-curly-in-string -- codegen output
57
57
  return '${string}';
58
58
  }
59
59
  return part;
@@ -118,8 +118,10 @@ export function formatLinkCatchAllOverloads(): string[] {
118
118
 
119
119
  // ExternalHref inlined here rather than referenced as an exported
120
120
  // alias so the generated .d.ts stands alone without source imports.
121
+ /* eslint-disable no-template-curly-in-string -- codegen output */
121
122
  const externalHref =
122
123
  '`http://${string}` | `https://${string}` | `mailto:${string}` | `tel:${string}` | `ftp://${string}` | `//${string}` | `#${string}` | `?${string}`';
124
+ /* eslint-enable no-template-curly-in-string */
123
125
 
124
126
  lines.push(' // Typed Link overloads — catch-all (block 2 / emitted second)');
125
127
  lines.push(' interface LinkFunction {');
@@ -109,8 +109,9 @@ export function isCodec(value: unknown): value is Codec<unknown> {
109
109
  export function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
110
110
  return {
111
111
  parse(value: string | string[] | undefined): T {
112
- // For array inputs, take the last value (consistent with URLSearchParams.get())
113
- const input = Array.isArray(value) ? value[value.length - 1] : value;
112
+ // For array inputs (duplicate query keys), use the first value.
113
+ // Browsers and URLSearchParams.get() return the first occurrence.
114
+ const input = Array.isArray(value) ? value[0] : value;
114
115
 
115
116
  // Try parsing the raw value
116
117
  const result = validateSync(schema, input);
@@ -510,26 +510,38 @@ function logValidationFailure(errors: ValidationErrors): void {
510
510
  * Returns validation errors keyed by field name, or null if all files are ok.
511
511
  */
512
512
  function validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {
513
- const errors: ValidationErrors = {};
514
513
  const limitKb = Math.round(limit / 1024);
515
514
  const limitLabel =
516
515
  limit >= 1024 * 1024 ? `${Math.round(limit / (1024 * 1024))}MB` : `${limitKb}KB`;
517
516
 
518
- for (const [key, value] of Object.entries(input)) {
519
- if (value instanceof File && value.size > limit) {
520
- errors[key] = [
521
- `File "${value.name}" (${formatSize(value.size)}) exceeds the ${limitLabel} limit`,
522
- ];
523
- } else if (Array.isArray(value)) {
524
- const oversized = value.filter((item) => item instanceof File && item.size > limit);
525
- if (oversized.length > 0) {
526
- errors[key] = oversized.map(
527
- (f: File) => `File "${f.name}" (${formatSize(f.size)}) exceeds the ${limitLabel} limit`
528
- );
517
+ const errors: ValidationErrors = {};
518
+
519
+ function walk(obj: Record<string, unknown>, prefix: string): void {
520
+ for (const [key, value] of Object.entries(obj)) {
521
+ const path = prefix ? `${prefix}.${key}` : key;
522
+ if (value instanceof File && value.size > limit) {
523
+ errors[path] = [
524
+ `File "${value.name}" (${formatSize(value.size)}) exceeds the ${limitLabel} limit`,
525
+ ];
526
+ } else if (Array.isArray(value)) {
527
+ for (let i = 0; i < value.length; i++) {
528
+ const item = value[i];
529
+ const itemPath = `${path}[${i}]`;
530
+ if (item instanceof File && item.size > limit) {
531
+ (errors[itemPath] ??= []).push(
532
+ `File "${item.name}" (${formatSize(item.size)}) exceeds the ${limitLabel} limit`
533
+ );
534
+ } else if (typeof item === 'object' && item !== null && !(item instanceof File)) {
535
+ walk(item as Record<string, unknown>, itemPath);
536
+ }
537
+ }
538
+ } else if (typeof value === 'object' && value !== null && !(value instanceof File)) {
539
+ walk(value as Record<string, unknown>, path);
529
540
  }
530
541
  }
531
542
  }
532
543
 
544
+ walk(input, '');
533
545
  return Object.keys(errors).length > 0 ? errors : null;
534
546
  }
535
547
 
@@ -545,7 +557,13 @@ function stripFiles(value: unknown): Record<string, unknown> | undefined {
545
557
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
546
558
  if (v instanceof File) continue;
547
559
  if (Array.isArray(v)) {
548
- result[k] = v.filter((item) => !(item instanceof File));
560
+ result[k] = v
561
+ .filter((item) => !(item instanceof File))
562
+ .map((item) =>
563
+ typeof item === 'object' && item !== null && !(item instanceof File)
564
+ ? (stripFiles(item) ?? {})
565
+ : item
566
+ );
549
567
  } else if (typeof v === 'object' && v !== null && !(v instanceof File)) {
550
568
  result[k] = stripFiles(v) ?? {};
551
569
  } else {
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { getCacheHandler } from '../cache/handler-store';
18
- import { RedirectSignal } from './primitives';
18
+ import { DenySignal, RedirectSignal } from './primitives';
19
19
  import { withSpan } from './tracing';
20
20
  import { revalidationAls, type RevalidationState } from './als-registry.js';
21
21
 
@@ -178,11 +178,28 @@ export async function executeAction(
178
178
  revalidation = await config.renderer(path);
179
179
  } catch (renderError) {
180
180
  if (renderError instanceof RedirectSignal) {
181
- // Revalidation triggered a redirect (e.g., session expired)
181
+ // Revalidation triggered a redirect (e.g., session expired,
182
+ // middleware on the target path called `redirect()`).
182
183
  redirectTo = renderError.location;
183
184
  redirectStatus = renderError.status;
185
+ } else if (renderError instanceof DenySignal) {
186
+ // Target path's middleware/access denied this user.
187
+ //
188
+ // Dropping the revalidation here is the security boundary added
189
+ // in TIM-996: without this branch, a DenySignal would fall into
190
+ // the `console.error` path AND the element tree would have
191
+ // already been built and serialized — leaking a privileged RSC
192
+ // payload to the action submitter. By the time the signal
193
+ // surfaces, the renderer has thrown BEFORE building the tree,
194
+ // so we simply drop `revalidation` and ship the action result
195
+ // alone. The action itself still succeeded.
196
+ console.error(
197
+ `[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`
198
+ );
184
199
  } else {
185
- // Log but don't fail the action — revalidation is best-effort
200
+ // Log but don't fail the action — revalidation is best-effort.
201
+ // Includes input-validation rejections from the renderer
202
+ // (e.g., revalidatePath('//evil.com/admin')).
186
203
  console.error('[timber] revalidatePath render failed:', renderError);
187
204
  }
188
205
  }
@@ -53,6 +53,9 @@ export function parseBodySize(size: string): number {
53
53
  }
54
54
  }
55
55
 
56
+ /** Strict integer pattern: one or more digits, nothing else. */
57
+ const STRICT_INTEGER_RE = /^\d+$/;
58
+
56
59
  /** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
57
60
  export function enforceBodyLimits(
58
61
  req: Request,
@@ -66,11 +69,16 @@ export function enforceBodyLimits(
66
69
  return { ok: false, status: 411 };
67
70
  }
68
71
 
69
- const bodySize = Number.parseInt(contentLength, 10);
70
- if (Number.isNaN(bodySize)) {
72
+ // Reject malformed values: multiple values ("100, 999999"), negative numbers,
73
+ // floats ("100.5"), or non-numeric strings. parseInt would silently parse
74
+ // the first number from "100, 999999" as 100, letting the real body through.
75
+ const trimmed = contentLength.trim();
76
+ if (!STRICT_INTEGER_RE.test(trimmed)) {
71
77
  return { ok: false, status: 411 };
72
78
  }
73
79
 
80
+ const bodySize = Number.parseInt(trimmed, 10);
81
+
74
82
  const limit = resolveLimit(kind, config);
75
83
  return bodySize <= limit ? { ok: true } : { ok: false, status: 413 };
76
84
  }
@@ -14,7 +14,11 @@
14
14
 
15
15
  import { requestContextAls, type RequestContextStore } from './als-registry.js';
16
16
  import { isDebug } from './debug.js';
17
- import { assertValidCookieName, assertValidCookieValue } from '../cookies/validation.js';
17
+ import {
18
+ assertValidCookieName,
19
+ assertValidCookieValue,
20
+ assertValidCookieOptions,
21
+ } from '../cookies/validation.js';
18
22
  import {
19
23
  parseCookieHeader,
20
24
  parseSetCookie,
@@ -27,7 +31,7 @@ import {
27
31
  // `getCookies()`. The pure shared module lives in `cookies/validation.ts`
28
32
  // so the client `useCookie` hook can use the same checks without pulling
29
33
  // in the server ALS code.
30
- export { assertValidCookieName, assertValidCookieValue };
34
+ export { assertValidCookieName, assertValidCookieValue, assertValidCookieOptions };
31
35
 
32
36
  // ─── Public API ───────────────────────────────────────────────────────────
33
37
 
@@ -137,6 +141,7 @@ export function getCookieJar(): RequestCookies {
137
141
  const { raw: _raw, ...attributeOptions } = options ?? {};
138
142
  void _raw;
139
143
  const opts = { ...DEFAULT_COOKIE_OPTIONS, ...attributeOptions };
144
+ assertValidCookieOptions(opts);
140
145
  store.cookieJar.set(name, { name, value: wireValue, options: opts });
141
146
  // Read-your-own-writes: store the DECODED logical value so that
142
147
  // subsequent `cookies().get(name)` in the same request returns
@@ -33,7 +33,7 @@ export function parseCookieHeader(header: string): Map<string, string> {
33
33
  if (eqIndex === -1) continue;
34
34
  const name = pair.slice(0, eqIndex).trim();
35
35
  const value = pair.slice(eqIndex + 1).trim();
36
- if (name) {
36
+ if (name && !map.has(name)) {
37
37
  map.set(name, safeDecodeCookieValue(value));
38
38
  }
39
39
  }
@@ -28,12 +28,36 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
28
28
  // ─── Implementation ───────────────────────────────────────────────────────
29
29
 
30
30
  /**
31
- * Validate the Origin header against the request's Host.
31
+ * Derive the request's scheme from trusted sources.
32
+ *
33
+ * Priority:
34
+ * 1. X-Forwarded-Proto (set by reverse proxies / load balancers)
35
+ * 2. Request URL protocol
36
+ *
37
+ * Falls back to 'https' (fail closed) if neither is available.
38
+ */
39
+ function deriveRequestScheme(req: Request): string {
40
+ const forwarded = req.headers.get('X-Forwarded-Proto');
41
+ if (forwarded) {
42
+ const proto = forwarded.split(',')[0].trim().toLowerCase();
43
+ if (proto === 'http' || proto === 'https') return proto;
44
+ }
45
+
46
+ try {
47
+ return new URL(req.url).protocol.replace(':', '');
48
+ } catch {
49
+ return 'https';
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Validate the Origin header against the request's full origin
55
+ * (scheme + host + port).
32
56
  *
33
57
  * For mutation methods (POST, PUT, PATCH, DELETE):
34
58
  * - If `csrf: false`, skip validation.
35
59
  * - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
36
- * - Otherwise, Origin's host must match the request's Host header.
60
+ * - Otherwise, Origin must match the derived request origin.
37
61
  *
38
62
  * Safe methods (GET, HEAD, OPTIONS) always pass.
39
63
  */
@@ -67,13 +91,22 @@ export function validateCsrf(req: Request, config: CsrfConfig): CsrfResult {
67
91
  return { ok: false, status: 403 };
68
92
  }
69
93
 
70
- // Extract hostname from Origin URL and compare to Host header
71
- let originHost: string;
94
+ // Compare full origins (scheme + host + port) using URL.origin for
95
+ // canonical port normalization (e.g. https://x:443 → https://x).
96
+ let originOrigin: string;
97
+ try {
98
+ originOrigin = new URL(origin).origin;
99
+ } catch {
100
+ return { ok: false, status: 403 };
101
+ }
102
+
103
+ const scheme = deriveRequestScheme(req);
104
+ let expectedOrigin: string;
72
105
  try {
73
- originHost = new URL(origin).host;
106
+ expectedOrigin = new URL(`${scheme}://${host}`).origin;
74
107
  } catch {
75
108
  return { ok: false, status: 403 };
76
109
  }
77
110
 
78
- return originHost === host ? { ok: true } : { ok: false, status: 403 };
111
+ return originOrigin === expectedOrigin ? { ok: true } : { ok: false, status: 403 };
79
112
  }
@@ -309,7 +309,7 @@ export class FlightInjectorCore {
309
309
  errorSignal?: AbortSignal
310
310
  ) {
311
311
  this.rscReader = rscStream.getReader();
312
- this.timeoutMs = renderTimeoutMs ?? 30_000;
312
+ this.timeoutMs = (renderTimeoutMs ?? 30_000) || 120_000;
313
313
  this.errorSignal = errorSignal;
314
314
  this.machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
315
315
  initial: { phase: 'init' },
@@ -373,10 +373,11 @@ export class FlightInjectorCore {
373
373
  // Guard each read with a timeout so a permanently hung RSC stream
374
374
  // eventually aborts. See design/02 §"Streaming Constraints".
375
375
  const readPromise = this.rscReader.read();
376
- const { done, value } =
377
- this.timeoutMs > 0
378
- ? await withTimeout(readPromise, this.timeoutMs, 'RSC stream read timed out')
379
- : await readPromise;
376
+ const { done, value } = await withTimeout(
377
+ readPromise,
378
+ this.timeoutMs,
379
+ 'RSC stream read timed out'
380
+ );
380
381
 
381
382
  if (done) {
382
383
  this.machine.send({ type: 'PULL_DONE' });
@@ -201,16 +201,19 @@ export function createInlinedRscStream(
201
201
  ): ReadableStream<Uint8Array> {
202
202
  const rscReader = rscStream.getReader();
203
203
  const decoder = new TextDecoder('utf-8', { fatal: true });
204
- const timeoutMs = renderTimeoutMs ?? 30_000;
204
+ // When renderTimeoutMs is 0 ("disabled"), enforce a safety ceiling
205
+ // so a stalled RSC stream can't hang the connection forever.
206
+ const timeoutMs = (renderTimeoutMs ?? 30_000) || 120_000;
205
207
 
206
208
  return new ReadableStream<Uint8Array>({
207
209
  async pull(controller) {
208
210
  try {
209
211
  const readPromise = rscReader.read();
210
- const { done, value } =
211
- timeoutMs > 0
212
- ? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
213
- : await readPromise;
212
+ const { done, value } = await withTimeout(
213
+ readPromise,
214
+ timeoutMs,
215
+ 'RSC stream read timed out'
216
+ );
214
217
  if (done) {
215
218
  controller.close();
216
219
  return;
@@ -338,17 +338,26 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
338
338
  // separately for the same URL. Without this, a shared cache may
339
339
  // serve an RSC Flight payload to a browser expecting HTML (or
340
340
  // vice versa). See GHSA-wfc6-r584-vfw7, design/13-security.md.
341
+ //
342
+ // Vary: X-Timber-URL — intercepting route responses depend on
343
+ // the source URL, so caches must store them separately.
344
+ const varyTokens = ['Accept'];
345
+ if (config.interceptionRewrites?.length) {
346
+ varyTokens.push('X-Timber-URL');
347
+ }
341
348
  const existingVary = result.headers.get('Vary');
342
- if (!existingVary) {
343
- result.headers.set('Vary', 'Accept');
344
- } else {
345
- const tokens = existingVary
346
- .toLowerCase()
347
- .split(',')
348
- .map((t) => t.trim());
349
- if (!tokens.includes('accept')) {
350
- result.headers.set('Vary', `${existingVary}, Accept`);
351
- }
349
+ const existingTokens = existingVary
350
+ ? existingVary
351
+ .toLowerCase()
352
+ .split(',')
353
+ .map((t) => t.trim())
354
+ : [];
355
+ const newTokens = varyTokens.filter((t) => !existingTokens.includes(t.toLowerCase()));
356
+ if (newTokens.length > 0) {
357
+ result.headers.set(
358
+ 'Vary',
359
+ existingVary ? `${existingVary}, ${newTokens.join(', ')}` : newTokens.join(', ')
360
+ );
352
361
  }
353
362
 
354
363
  // Append Server-Timing header based on configured mode.
@@ -240,6 +240,9 @@ export class RedirectSignal extends Error {
240
240
  /** Pattern matching absolute URLs: http(s):// or protocol-relative // */
241
241
  const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
242
242
 
243
+ // eslint-disable-next-line no-control-regex -- must match all C0 controls to prevent bypass
244
+ const REDIRECT_CONTROL_CHARS_RE = /[\x00-\x1f]/g;
245
+
243
246
  /**
244
247
  * Options for redirect() — alternative to passing a bare status code.
245
248
  */
@@ -300,7 +303,14 @@ export function redirect(path: string, statusOrOptions?: number | RedirectOption
300
303
  if (status < 300 || status > 399) {
301
304
  throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
302
305
  }
303
- if (ABSOLUTE_URL_RE.test(path)) {
306
+ // Strip C0 control characters and trim whitespace before checking — browsers
307
+ // do this during URL resolution, so " \thttp://evil.com" resolves as absolute.
308
+ // Normalize for validation only: strip C0 controls, trim, then replace backslashes
309
+ // with forward slashes. Browsers normalize \ → / during URL resolution, so
310
+ // "/\evil.com" becomes "//evil.com" (protocol-relative open redirect).
311
+ const cleaned = path.replace(REDIRECT_CONTROL_CHARS_RE, '').trimStart().replaceAll('\\', '/');
312
+
313
+ if (ABSOLUTE_URL_RE.test(cleaned)) {
304
314
  throw new Error(
305
315
  `redirect() only accepts relative URLs. Got absolute URL: "${path}". ` +
306
316
  'Use redirectExternal(url, allowList) for external redirects.'
@@ -67,10 +67,12 @@ function replaceOutermostSegmentOutlet(
67
67
  const props = element.props as Record<string, unknown>;
68
68
  if (element.type === AccessGate && props.children) {
69
69
  const inner = replaceOutermostSegmentOutlet(props.children as React.ReactElement, replacement);
70
+ /* eslint-disable react/no-children-prop -- createElement API */
70
71
  return createElement(element.type as React.FunctionComponent<Record<string, unknown>>, {
71
72
  ...props,
72
73
  children: inner,
73
74
  });
75
+ /* eslint-enable react/no-children-prop */
74
76
  }
75
77
  return element;
76
78
  }
@@ -152,10 +152,11 @@ function getLayoutsForErrorFile(
152
152
  */
153
153
  function toSerializableError(error: unknown): SerializableError {
154
154
  const err = error instanceof Error ? error : new Error(String(error));
155
+ const devMode = isDevMode();
155
156
  return {
156
- message: err.message,
157
- name: err.name,
158
- ...(isDevMode() && err.stack ? { stack: err.stack } : {}),
157
+ message: devMode ? err.message : 'An unexpected error occurred.',
158
+ name: devMode ? err.name : 'Error',
159
+ ...(devMode && err.stack ? { stack: err.stack } : {}),
159
160
  };
160
161
  }
161
162