@timber-js/app 0.2.0-alpha.133 → 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 (177) hide show
  1. package/dist/_chunks/{actions-BNhpxrnV.js → actions-CHFblsdT.js} +62 -19
  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/cli-schema-sync-q-Ce6-iG.js.map +1 -1
  6. package/dist/_chunks/{codec-registry-DqkeAydt.js → codec-registry-G9hD5LOq.js} +1 -1
  7. package/dist/_chunks/{codec-registry-DqkeAydt.js.map → codec-registry-G9hD5LOq.js.map} +1 -1
  8. package/dist/_chunks/{define-jeHDXZGq.js → define-DXWUJb-K.js} +3 -3
  9. package/dist/_chunks/{define-jeHDXZGq.js.map → define-DXWUJb-K.js.map} +1 -1
  10. package/dist/_chunks/{define-DMq-Yjp7.js → define-Dp-qu3ql.js} +3 -3
  11. package/dist/_chunks/{define-DMq-Yjp7.js.map → define-Dp-qu3ql.js.map} +1 -1
  12. package/dist/_chunks/fast-hash-DXZ7S2D5.js +36 -0
  13. package/dist/_chunks/fast-hash-DXZ7S2D5.js.map +1 -0
  14. package/dist/_chunks/{format-BjN9HLu9.js → format-CR6v1pWE.js} +1 -1
  15. package/dist/_chunks/{format-BjN9HLu9.js.map → format-CR6v1pWE.js.map} +1 -1
  16. package/dist/_chunks/{handler-store-bIAstrxQ.js → handler-store-aQMpqPe7.js} +1 -1
  17. package/dist/_chunks/{handler-store-bIAstrxQ.js.map → handler-store-aQMpqPe7.js.map} +1 -1
  18. package/dist/_chunks/href-validation-2G-HZaQ8.js +124 -0
  19. package/dist/_chunks/href-validation-2G-HZaQ8.js.map +1 -0
  20. package/dist/_chunks/logger-CMd9P1xQ.js.map +1 -1
  21. package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -1
  22. package/dist/_chunks/{navigation-root-B5-RmsPi.js → navigation-root-BQqFQI-n.js} +3 -3
  23. package/dist/_chunks/{navigation-root-B5-RmsPi.js.map → navigation-root-BQqFQI-n.js.map} +1 -1
  24. package/dist/_chunks/{registry-BI9cgCt7.js → registry-Dp_hJL4s.js} +1 -1
  25. package/dist/_chunks/{registry-BI9cgCt7.js.map → registry-Dp_hJL4s.js.map} +1 -1
  26. package/dist/_chunks/{resolve-schema-DU2nbVOi.js → resolve-schema-D3CbcdCW.js} +2 -2
  27. package/dist/_chunks/{resolve-schema-DU2nbVOi.js.map → resolve-schema-D3CbcdCW.js.map} +1 -1
  28. package/dist/_chunks/{router-ref-B2-2kIIC.js → router-ref-B50j42zB.js} +2 -2
  29. package/dist/_chunks/{router-ref-B2-2kIIC.js.map → router-ref-B50j42zB.js.map} +1 -1
  30. package/dist/_chunks/{schema-bridge-o0SwFtXW.js → schema-bridge-Cuy8BaYu.js} +2 -2
  31. package/dist/_chunks/{schema-bridge-o0SwFtXW.js.map → schema-bridge-Cuy8BaYu.js.map} +1 -1
  32. package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -1
  33. package/dist/_chunks/{segment-context-Bg0pFbLT.js → segment-context-BnxoPSFQ.js} +1 -1
  34. package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-BnxoPSFQ.js.map} +1 -1
  35. package/dist/_chunks/{ssr-data-DVX8S7vj.js → ssr-data-DxXyoRrz.js} +1 -1
  36. package/dist/_chunks/{ssr-data-DVX8S7vj.js.map → ssr-data-DxXyoRrz.js.map} +1 -1
  37. package/dist/_chunks/{stale-reload-CCqb62Z3.js → stale-reload-DIIIVGh_.js} +1 -1
  38. package/dist/_chunks/{stale-reload-CCqb62Z3.js.map → stale-reload-DIIIVGh_.js.map} +1 -1
  39. package/dist/_chunks/tracing-DP5djRq5.js.map +1 -1
  40. package/dist/_chunks/{use-query-states-C9G6LP0F.js → use-query-states-D9SBlAfp.js} +2 -2
  41. package/dist/_chunks/{use-query-states-C9G6LP0F.js.map → use-query-states-D9SBlAfp.js.map} +1 -1
  42. package/dist/_chunks/{use-segment-params-CIeinIx2.js → use-segment-params-x2poKmk8.js} +2 -2
  43. package/dist/_chunks/use-segment-params-x2poKmk8.js.map +1 -0
  44. package/dist/_chunks/{validation-AfEFM5ze.js → validation-BoWM_qI1.js} +31 -2
  45. package/dist/_chunks/validation-BoWM_qI1.js.map +1 -0
  46. package/dist/_chunks/walkers-BbEu2_Pp.js.map +1 -1
  47. package/dist/_chunks/warnings-CAzunaGo.js.map +1 -1
  48. package/dist/adapters/cloudflare-dev.js.map +1 -1
  49. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  50. package/dist/adapters/cloudflare.js +1 -1
  51. package/dist/adapters/cloudflare.js.map +1 -1
  52. package/dist/adapters/nitro.js +1 -1
  53. package/dist/adapters/nitro.js.map +1 -1
  54. package/dist/cache/cache-api.d.ts +1 -1
  55. package/dist/cache/cache-api.d.ts.map +1 -1
  56. package/dist/cache/index.js +17 -39
  57. package/dist/cache/index.js.map +1 -1
  58. package/dist/cache/timber-cache.d.ts +1 -1
  59. package/dist/cache/timber-cache.d.ts.map +1 -1
  60. package/dist/cli.js.map +1 -1
  61. package/dist/client/error-boundary.js +2 -2
  62. package/dist/client/error-boundary.js.map +1 -1
  63. package/dist/client/index.d.ts.map +1 -1
  64. package/dist/client/index.js +14 -26
  65. package/dist/client/index.js.map +1 -1
  66. package/dist/client/internal.js +8 -8
  67. package/dist/client/internal.js.map +1 -1
  68. package/dist/client/link.d.ts +2 -4
  69. package/dist/client/link.d.ts.map +1 -1
  70. package/dist/client/navigation-context.d.ts.map +1 -1
  71. package/dist/client/use-pathname.d.ts.map +1 -1
  72. package/dist/client/use-router.d.ts.map +1 -1
  73. package/dist/client/use-search-params.d.ts.map +1 -1
  74. package/dist/codec.js +1 -1
  75. package/dist/codec.js.map +1 -1
  76. package/dist/cookies/index.js +3 -3
  77. package/dist/cookies/index.js.map +1 -1
  78. package/dist/cookies/validation.d.ts +11 -23
  79. package/dist/cookies/validation.d.ts.map +1 -1
  80. package/dist/dev-tools/error-page.d.ts.map +1 -1
  81. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +259 -3
  84. package/dist/index.js.map +1 -1
  85. package/dist/params/define-schema.d.ts.map +1 -1
  86. package/dist/params/index.js +2 -2
  87. package/dist/params/index.js.map +1 -1
  88. package/dist/plugins/cache.d.ts +31 -0
  89. package/dist/plugins/cache.d.ts.map +1 -0
  90. package/dist/routing/link-codegen.d.ts.map +1 -1
  91. package/dist/schema-bridge.d.ts.map +1 -1
  92. package/dist/search-params/index.js +3 -3
  93. package/dist/search-params/index.js.map +1 -1
  94. package/dist/segment-params/index.js +1 -1
  95. package/dist/server/actions.d.ts.map +1 -1
  96. package/dist/server/body-limits.d.ts.map +1 -1
  97. package/dist/server/cookie-context.d.ts +2 -2
  98. package/dist/server/cookie-context.d.ts.map +1 -1
  99. package/dist/server/csrf.d.ts +3 -2
  100. package/dist/server/csrf.d.ts.map +1 -1
  101. package/dist/server/deny-renderer.d.ts.map +1 -1
  102. package/dist/server/html-injector-core.d.ts.map +1 -1
  103. package/dist/server/html-injectors.d.ts.map +1 -1
  104. package/dist/server/index.js +17 -8
  105. package/dist/server/index.js.map +1 -1
  106. package/dist/server/internal.js +69 -21
  107. package/dist/server/internal.js.map +1 -1
  108. package/dist/server/pipeline-interception.d.ts.map +1 -1
  109. package/dist/server/pipeline-phases.d.ts +5 -1
  110. package/dist/server/pipeline-phases.d.ts.map +1 -1
  111. package/dist/server/pipeline.d.ts.map +1 -1
  112. package/dist/server/primitives.d.ts +5 -2
  113. package/dist/server/primitives.d.ts.map +1 -1
  114. package/dist/server/route-element-builder.d.ts.map +1 -1
  115. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  116. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  117. package/dist/server/rsc-entry/revalidate-renderer.d.ts +108 -0
  118. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -0
  119. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  120. package/dist/server/rsc-entry/ssr-renderer.d.ts +15 -0
  121. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  122. package/dist/server/sitemap-handler.d.ts.map +1 -1
  123. package/dist/server/ssr-wrappers.d.ts.map +1 -1
  124. package/dist/server/tree-builder.d.ts.map +1 -1
  125. package/dist/shared/href-validation.d.ts +38 -0
  126. package/dist/shared/href-validation.d.ts.map +1 -0
  127. package/dist/shims/font-google.js.map +1 -1
  128. package/dist/shims/font-local.js.map +1 -1
  129. package/package.json +5 -5
  130. package/src/cache/cache-api.ts +3 -2
  131. package/src/cache/timber-cache.ts +27 -4
  132. package/src/client/index.ts +1 -0
  133. package/src/client/link.tsx +13 -37
  134. package/src/client/navigation-context.ts +2 -0
  135. package/src/client/use-pathname.ts +1 -0
  136. package/src/client/use-router.ts +3 -0
  137. package/src/client/use-search-params.ts +1 -0
  138. package/src/client/use-segment-params.ts +1 -0
  139. package/src/config-validation.ts +1 -1
  140. package/src/cookies/validation.ts +58 -0
  141. package/src/dev-tools/error-page.ts +1 -0
  142. package/src/fonts/virtual-modules.ts +2 -0
  143. package/src/index.ts +5 -1
  144. package/src/params/define-schema.ts +1 -0
  145. package/src/plugins/cache.ts +180 -0
  146. package/src/routing/link-codegen.ts +5 -3
  147. package/src/schema-bridge.ts +3 -2
  148. package/src/server/action-client.ts +31 -13
  149. package/src/server/actions.ts +20 -3
  150. package/src/server/body-limits.ts +10 -2
  151. package/src/server/cookie-context.ts +7 -2
  152. package/src/server/cookie-parsing.ts +1 -1
  153. package/src/server/csrf.ts +39 -6
  154. package/src/server/deny-renderer.ts +0 -1
  155. package/src/server/html-injector-core.ts +6 -5
  156. package/src/server/html-injectors.ts +8 -5
  157. package/src/server/pipeline-interception.ts +1 -0
  158. package/src/server/pipeline-phases.ts +30 -19
  159. package/src/server/pipeline.ts +27 -1
  160. package/src/server/primitives.ts +19 -18
  161. package/src/server/route-element-builder.ts +2 -0
  162. package/src/server/rsc-entry/error-renderer.ts +4 -3
  163. package/src/server/rsc-entry/index.ts +16 -36
  164. package/src/server/rsc-entry/revalidate-renderer.ts +344 -0
  165. package/src/server/rsc-entry/rsc-payload.ts +0 -4
  166. package/src/server/rsc-entry/ssr-renderer.ts +76 -0
  167. package/src/server/sitemap-handler.ts +24 -2
  168. package/src/server/slot-resolver.ts +8 -2
  169. package/src/server/ssr-wrappers.tsx +2 -0
  170. package/src/server/tree-builder.ts +2 -0
  171. package/src/server/types.ts +2 -2
  172. package/src/shared/href-validation.ts +127 -0
  173. package/dist/_chunks/actions-BNhpxrnV.js.map +0 -1
  174. package/dist/_chunks/merge-search-params-Cm1j99HC.js +0 -41
  175. package/dist/_chunks/merge-search-params-Cm1j99HC.js.map +0 -1
  176. package/dist/_chunks/use-segment-params-CIeinIx2.js.map +0 -1
  177. package/dist/_chunks/validation-AfEFM5ze.js.map +0 -1
@@ -104,6 +104,64 @@ function isCookieOctet(code: number): boolean {
104
104
  * This validator is for the `{ raw: true }` opt-out and for
105
105
  * `setFromHeaders` forwarding.
106
106
  */
107
+ // ─── Cookie attribute validation ──────────────────────────────────────────
108
+
109
+ /**
110
+ * Characters that must not appear in path or domain cookie attributes.
111
+ * Semicolons delimit cookie attributes on the wire, so allowing `;` in
112
+ * path or domain would inject extra attributes. CR/LF enable HTTP header
113
+ * injection.
114
+ */
115
+ const ATTR_INJECTION_RE = /[;\r\n]/;
116
+
117
+ const VALID_SAME_SITE = new Set(['strict', 'lax', 'none']);
118
+
119
+ /**
120
+ * Validate cookie option attributes at runtime to prevent Set-Cookie
121
+ * header injection through unsanitized option values.
122
+ *
123
+ * TypeScript types enforce correct shapes at compile time, but at runtime
124
+ * (e.g. from untyped libraries or user-controlled config) invalid values
125
+ * could inject additional Set-Cookie attributes.
126
+ */
127
+ export function assertValidCookieOptions(options: {
128
+ sameSite?: string;
129
+ path?: string;
130
+ domain?: string;
131
+ maxAge?: number;
132
+ }): void {
133
+ if (options.sameSite !== undefined) {
134
+ const lower = options.sameSite.toLowerCase();
135
+ if (!VALID_SAME_SITE.has(lower)) {
136
+ throw new Error(
137
+ `[timber] invalid cookie sameSite value ${JSON.stringify(options.sameSite)} — ` +
138
+ `must be 'strict', 'lax', or 'none'.`
139
+ );
140
+ }
141
+ }
142
+ if (options.path !== undefined && ATTR_INJECTION_RE.test(options.path)) {
143
+ throw new Error(
144
+ `[timber] invalid cookie path ${JSON.stringify(options.path)} — ` +
145
+ `path must not contain ';', CR, or LF (Set-Cookie header injection).`
146
+ );
147
+ }
148
+ if (options.domain !== undefined && ATTR_INJECTION_RE.test(options.domain)) {
149
+ throw new Error(
150
+ `[timber] invalid cookie domain ${JSON.stringify(options.domain)} — ` +
151
+ `domain must not contain ';', CR, or LF (Set-Cookie header injection).`
152
+ );
153
+ }
154
+ if (
155
+ options.maxAge !== undefined &&
156
+ (typeof options.maxAge !== 'number' || !Number.isFinite(options.maxAge))
157
+ ) {
158
+ throw new Error(
159
+ `[timber] invalid cookie maxAge ${JSON.stringify(options.maxAge)} — ` +
160
+ `maxAge must be a finite number.`
161
+ );
162
+ }
163
+ }
164
+
107
165
  export function assertValidCookieValue(name: string, value: string): void {
108
166
  if (typeof value !== 'string') {
109
167
  throw new Error(`[timber] cookie ${JSON.stringify(name)}: cookie value must be a string.`);
@@ -53,6 +53,7 @@ function readSourceContext(
53
53
  contextLines = 3
54
54
  ): { lines: Array<{ num: number; text: string; highlight: boolean }>; startLine: number } | null {
55
55
  try {
56
+ // eslint-disable-next-line no-var-requires -- sync require needed at render time
56
57
  const { readFileSync } = require('node:fs');
57
58
  const content = readFileSync(filePath, 'utf-8');
58
59
  const allLines = content.split('\n');
@@ -131,6 +131,7 @@ export function generateGoogleVirtualModule(fonts: Iterable<ExtractedFont>): str
131
131
  '',
132
132
  'function createFontResult(family, config) {',
133
133
  ' return {',
134
+ // eslint-disable-next-line no-template-curly-in-string -- codegen output
134
135
  ' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,',
135
136
  ' style: { fontFamily: family },',
136
137
  ' variable: config?.variable,',
@@ -171,6 +172,7 @@ export function generateLocalVirtualModule(): string {
171
172
  'export default function localFont(config) {',
172
173
  ' const family = config?.family || "Local Font";',
173
174
  ' return {',
175
+ // eslint-disable-next-line no-template-curly-in-string -- codegen output
174
176
  ' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,',
175
177
  ' style: { fontFamily: family },',
176
178
  ' variable: config?.variable,',
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ import { timberReactProd } from './plugins/react-prod';
29
29
  import { timberChunks } from './plugins/chunks';
30
30
  import { clientChunkGroup } from './plugins/client-chunks';
31
31
  import { timberServerBundle } from './plugins/server-bundle';
32
+ import { timberCacheTransform } from './plugins/cache';
32
33
  import { timberCloudflareWasm } from './plugins/cloudflare-wasm';
33
34
  import { timberAdapterBuild } from './plugins/adapter-build';
34
35
  import { timberBuildReport } from './plugins/build-report';
@@ -70,7 +71,7 @@ export type { Metadata, MetadataRoute, MetadataHandler, MetadataResult } from '.
70
71
  * This interface is empty by default and populated via codegen.
71
72
  * See design/09-typescript.md §"Typed Routes".
72
73
  */
73
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type
74
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type, typescript/no-empty-interface
74
75
  export interface Routes {}
75
76
 
76
77
  /**
@@ -464,8 +465,10 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
464
465
  // Validate config and check peer dependencies at startup.
465
466
  // Errors are logged to stderr but don't block startup — Vite may
466
467
  // still work partially, and the errors guide the user to fix things.
468
+ /* eslint-disable no-var-requires -- sync require for startup validation */
467
469
  const { validateConfig, formatConfigErrors, checkPeerDependencies, formatMissingPeers } =
468
470
  require('./config-validation.js') as typeof import('./config-validation.js');
471
+ /* eslint-enable no-var-requires */
469
472
 
470
473
  const configErrors = validateConfig(ctx.config);
471
474
  if (configErrors.length > 0) {
@@ -530,6 +533,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
530
533
  timberStaticBuild(ctx),
531
534
  timberFonts(ctx),
532
535
  timberMdx(ctx),
536
+ timberCacheTransform(ctx),
533
537
  timberContent(ctx),
534
538
  timberServerBundle(), // Bundle all deps in server environments for prod
535
539
  timberCloudflareWasm(ctx), // Externalize WASM for Cloudflare Workers
@@ -57,6 +57,7 @@ export type CodecForKey<K extends string> = K extends `[[...${string}]]`
57
57
  * Registry interface augmented by codegen with known filesystem bracket keys.
58
58
  * When empty (no codegen yet), ValidDynamicSegmentKeys falls back to string.
59
59
  */
60
+ // eslint-disable-next-line typescript/no-empty-interface -- augmented by codegen
60
61
  export interface ValidDynamicSegmentKeysRegistry {}
61
62
 
62
63
  /**
@@ -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
  }
@@ -228,7 +228,6 @@ export async function renderDenyPageAsRsc(
228
228
  });
229
229
 
230
230
  responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
231
- responseHeaders.set('Vary', 'Accept');
232
231
  return new Response(rscStream, {
233
232
  status: deny.status,
234
233
  headers: responseHeaders,
@@ -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;