@timber-js/app 0.2.0-alpha.87 → 0.2.0-alpha.88

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 (55) hide show
  1. package/LICENSE +8 -0
  2. package/dist/client/index.d.ts +44 -1
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/client/link.d.ts +7 -44
  6. package/dist/client/link.d.ts.map +1 -1
  7. package/dist/config-types.d.ts +39 -0
  8. package/dist/config-types.d.ts.map +1 -1
  9. package/dist/fonts/bundle.d.ts +48 -0
  10. package/dist/fonts/bundle.d.ts.map +1 -0
  11. package/dist/fonts/dev-middleware.d.ts +22 -0
  12. package/dist/fonts/dev-middleware.d.ts.map +1 -0
  13. package/dist/fonts/pipeline.d.ts +138 -0
  14. package/dist/fonts/pipeline.d.ts.map +1 -0
  15. package/dist/fonts/transform.d.ts +72 -0
  16. package/dist/fonts/transform.d.ts.map +1 -0
  17. package/dist/fonts/types.d.ts +45 -1
  18. package/dist/fonts/types.d.ts.map +1 -1
  19. package/dist/fonts/virtual-modules.d.ts +59 -0
  20. package/dist/fonts/virtual-modules.d.ts.map +1 -0
  21. package/dist/index.js +742 -573
  22. package/dist/index.js.map +1 -1
  23. package/dist/plugins/entries.d.ts.map +1 -1
  24. package/dist/plugins/fonts.d.ts +16 -83
  25. package/dist/plugins/fonts.d.ts.map +1 -1
  26. package/dist/server/action-client.d.ts +8 -0
  27. package/dist/server/action-client.d.ts.map +1 -1
  28. package/dist/server/action-handler.d.ts +7 -0
  29. package/dist/server/action-handler.d.ts.map +1 -1
  30. package/dist/server/index.js +158 -2
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/route-matcher.d.ts +7 -0
  33. package/dist/server/route-matcher.d.ts.map +1 -1
  34. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  35. package/dist/server/sensitive-fields.d.ts +74 -0
  36. package/dist/server/sensitive-fields.d.ts.map +1 -0
  37. package/package.json +6 -7
  38. package/src/cli.ts +0 -0
  39. package/src/client/index.ts +77 -1
  40. package/src/client/link.tsx +15 -65
  41. package/src/config-types.ts +39 -0
  42. package/src/fonts/bundle.ts +142 -0
  43. package/src/fonts/dev-middleware.ts +74 -0
  44. package/src/fonts/pipeline.ts +275 -0
  45. package/src/fonts/transform.ts +353 -0
  46. package/src/fonts/types.ts +50 -1
  47. package/src/fonts/virtual-modules.ts +159 -0
  48. package/src/plugins/entries.ts +37 -0
  49. package/src/plugins/fonts.ts +102 -704
  50. package/src/plugins/routing.ts +6 -5
  51. package/src/server/action-client.ts +34 -4
  52. package/src/server/action-handler.ts +32 -2
  53. package/src/server/route-matcher.ts +7 -0
  54. package/src/server/rsc-entry/index.ts +19 -3
  55. package/src/server/sensitive-fields.ts +230 -0
@@ -172,7 +172,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
172
172
  rescan();
173
173
  }
174
174
 
175
- return generateManifestModule(ctx.routeTree!);
175
+ return generateManifestModule(ctx.routeTree!, ctx.root);
176
176
  },
177
177
 
178
178
  /**
@@ -201,7 +201,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
201
201
  devServer.watcher.add(ctx.appDir);
202
202
 
203
203
  /** Snapshot of the last generated manifest, used to detect structural changes. */
204
- let lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : '';
204
+ let lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
205
205
 
206
206
  /**
207
207
  * Handle a route-significant file being added or removed.
@@ -212,7 +212,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
212
212
  if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
213
213
 
214
214
  rescan();
215
- lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : '';
215
+ lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
216
216
  invalidateManifest(devServer);
217
217
  };
218
218
 
@@ -229,7 +229,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
229
229
  if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
230
230
 
231
231
  rescan();
232
- const newManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : '';
232
+ const newManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
233
233
  if (newManifest !== lastManifest) {
234
234
  lastManifest = newManifest;
235
235
  invalidateManifest(devServer);
@@ -275,7 +275,7 @@ function invalidateManifest(server: ViteDevServer): void {
275
275
  * The output is a default-exported object containing the serialized route tree.
276
276
  * All file references use absolute paths (required for virtual modules).
277
277
  */
278
- function generateManifestModule(tree: RouteTree): string {
278
+ function generateManifestModule(tree: RouteTree, viteRoot: string): string {
279
279
  const imports: string[] = [];
280
280
  let importIndex = 0;
281
281
 
@@ -477,6 +477,7 @@ function generateManifestModule(tree: RouteTree): string {
477
477
  ...imports,
478
478
  '',
479
479
  'const manifest = {',
480
+ ` viteRoot: ${JSON.stringify(viteRoot)},`,
480
481
  proxyLine,
481
482
  globalErrorLine,
482
483
  rewritesLine,
@@ -131,6 +131,13 @@ interface ActionClientConfig<TCtx> {
131
131
  middleware?: ActionMiddleware<TCtx> | ActionMiddleware<Record<string, unknown>>[];
132
132
  /** Max file size in bytes. Files exceeding this are rejected with validation errors. */
133
133
  fileSizeLimit?: number;
134
+ /**
135
+ * Override the sensitive-field deny-list for this action client.
136
+ * See `SensitiveFieldsOption` in `./sensitive-fields.ts`. Per-action config
137
+ * takes precedence over the global `forms.stripSensitiveFields` option in
138
+ * `timber.config.ts`. See design/08-forms-and-actions.md and TIM-816.
139
+ */
140
+ stripSensitiveFields?: SensitiveFieldsOption;
134
141
  }
135
142
 
136
143
  /** Intermediate builder returned by createActionClient(). */
@@ -217,6 +224,12 @@ import { parseFormData } from './form-data.js';
217
224
  import { formatSize } from '../utils/format.js';
218
225
  import { isDebug, isDevMode } from './debug.js';
219
226
  import { RedirectSignal, DenySignal } from './primitives.js';
227
+ import {
228
+ stripSensitiveFields,
229
+ resolveSensitivePredicate,
230
+ getGlobalSensitiveFieldsConfig,
231
+ type SensitiveFieldsOption,
232
+ } from './sensitive-fields.js';
220
233
 
221
234
  /**
222
235
  * Extract validation errors from a schema error.
@@ -340,6 +353,25 @@ export function createActionClient<TCtx = Record<string, never>>(
340
353
  rawInput = args[0];
341
354
  }
342
355
 
356
+ // Resolve the sensitive-field stripping predicate once per invocation.
357
+ // Precedence: per-action (config.stripSensitiveFields) > global
358
+ // (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
359
+ // See TIM-816.
360
+ const sensitivePredicate = resolveSensitivePredicate(
361
+ config.stripSensitiveFields,
362
+ getGlobalSensitiveFieldsConfig()
363
+ );
364
+
365
+ // Capture a "safe-to-echo" snapshot of the raw input once. Files are
366
+ // stripped (can't serialize, shouldn't echo back) and sensitive fields
367
+ // (passwords, tokens, CVV, etc.) are removed before they would land
368
+ // in the RSC payload → client form `defaultValue` → DOM.
369
+ const buildSubmittedValues = (): Record<string, unknown> | undefined => {
370
+ const withoutFiles = stripFiles(rawInput);
371
+ if (withoutFiles === undefined) return undefined;
372
+ return stripSensitiveFields(withoutFiles, sensitivePredicate);
373
+ };
374
+
343
375
  // Validate file sizes before schema validation.
344
376
  if (config.fileSizeLimit !== undefined && rawInput && typeof rawInput === 'object') {
345
377
  const fileSizeErrors = validateFileSizes(
@@ -347,14 +379,12 @@ export function createActionClient<TCtx = Record<string, never>>(
347
379
  config.fileSizeLimit
348
380
  );
349
381
  if (fileSizeErrors) {
350
- const submittedValues = stripFiles(rawInput);
351
- return { validationErrors: fileSizeErrors, submittedValues };
382
+ return { validationErrors: fileSizeErrors, submittedValues: buildSubmittedValues() };
352
383
  }
353
384
  }
354
385
 
355
386
  // Capture submitted values for repopulation on validation failure.
356
- // Exclude File objects (can't serialize, shouldn't echo back).
357
- const submittedValues = schema ? stripFiles(rawInput) : undefined;
387
+ const submittedValues = schema ? buildSubmittedValues() : undefined;
358
388
 
359
389
  // Validate with schema if provided
360
390
  let input: TInput;
@@ -30,6 +30,12 @@ import {
30
30
  import { handleActionError } from './action-client.js';
31
31
  import { enforceBodyLimits, enforceFieldLimit, type BodyLimitsConfig } from './body-limits.js';
32
32
  import { parseFormData } from './form-data.js';
33
+ import {
34
+ stripSensitiveFields,
35
+ resolveSensitivePredicate,
36
+ getGlobalSensitiveFieldsConfig,
37
+ type SensitiveFieldsOption,
38
+ } from './sensitive-fields.js';
33
39
  import type { FormFlashData } from './form-flash.js';
34
40
  import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
35
41
  import { logActionError } from './logger.js';
@@ -44,6 +50,12 @@ export interface ActionDispatchConfig {
44
50
  revalidateRenderer?: RevalidateRenderer;
45
51
  /** Body size limits (from timber.config.ts). */
46
52
  bodyLimits?: BodyLimitsConfig;
53
+ /**
54
+ * Override the sensitive-field deny-list for the no-JS form POST path.
55
+ * Defaults to the global `forms.stripSensitiveFields` from `timber.config.ts`.
56
+ * See `SensitiveFieldsOption` in `./sensitive-fields.ts` and TIM-816.
57
+ */
58
+ sensitiveFields?: SensitiveFieldsOption;
47
59
  }
48
60
 
49
61
  // ─── Constants ────────────────────────────────────────────────────────────
@@ -295,8 +307,14 @@ async function handleFormAction(
295
307
  }
296
308
 
297
309
  // Capture submitted values for re-render on validation failure.
298
- // Parse before decodeAction consumes the FormData.
299
- const submittedValues = parseFormData(formData);
310
+ // Parse before decodeAction consumes the FormData, then strip sensitive
311
+ // fields (passwords, tokens, CVV, etc.) so they are never rendered back
312
+ // into the HTML as `defaultValue` attributes. See TIM-816.
313
+ const sensitivePredicate = resolveSensitivePredicate(
314
+ config.sensitiveFields,
315
+ getGlobalSensitiveFieldsConfig()
316
+ );
317
+ const submittedValues = stripSensitiveFields(parseFormData(formData), sensitivePredicate);
300
318
 
301
319
  // decodeAction resolves the action function from the form data's hidden fields.
302
320
  // It returns a bound function with the form data already applied.
@@ -338,5 +356,17 @@ async function handleFormAction(
338
356
  // This handles both success ({ data }) and validation failure
339
357
  // ({ validationErrors, submittedValues }) — the form is the single source of truth.
340
358
  const actionResult = result.actionResult as FormFlashData;
359
+
360
+ // Defense-in-depth: strip sensitive fields from `actionResult.submittedValues`
361
+ // even if the action already built it. `createActionClient` strips internally,
362
+ // but raw `'use server'` functions that manually return `{ submittedValues }`
363
+ // are not covered by the inner strip. See TIM-816.
364
+ if (actionResult && actionResult.submittedValues) {
365
+ actionResult.submittedValues = stripSensitiveFields(
366
+ actionResult.submittedValues,
367
+ sensitivePredicate
368
+ );
369
+ }
370
+
341
371
  return { rerender: actionResult };
342
372
  }
@@ -68,6 +68,13 @@ export interface ManifestSegmentNode {
68
68
  /** The manifest shape from virtual:timber-route-manifest. */
69
69
  export interface ManifestRoot {
70
70
  root: ManifestSegmentNode;
71
+ /**
72
+ * Absolute path to the Vite project root, captured at build/load time.
73
+ * Used by dev-only features (e.g., dev error page frame classification)
74
+ * that need a correct project root even when CWD differs (e.g., monorepo
75
+ * custom root). See TIM-807 / TIM-808.
76
+ */
77
+ viteRoot: string;
71
78
  proxy?: ManifestFile;
72
79
  /**
73
80
  * Global error page: app/global-error.{tsx,ts,jsx,js}
@@ -192,6 +192,18 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
192
192
  setDebugFromConfig(true);
193
193
  }
194
194
 
195
+ // Wire the global sensitive-field stripping config so `createActionClient`
196
+ // instances in user code pick up `forms.stripSensitiveFields` from
197
+ // `timber.config.ts` without needing to plumb config through each action.
198
+ // See TIM-816 and design/08-forms-and-actions.md §"Validation errors".
199
+ const formsConfig = (runtimeConfig as Record<string, unknown>).forms as
200
+ | { stripSensitiveFields?: import('../sensitive-fields.js').SensitiveFieldsOption }
201
+ | undefined;
202
+ if (formsConfig) {
203
+ const { setGlobalSensitiveFieldsConfig } = await import('../sensitive-fields.js');
204
+ setGlobalSensitiveFieldsConfig(formsConfig.stripSensitiveFields);
205
+ }
206
+
195
207
  // Two separate flags for two different security levels:
196
208
  //
197
209
  // isDev (isDevMode) — gates client-visible behavior: dev error pages with
@@ -324,9 +336,12 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
324
336
  manifest.globalError,
325
337
  // Project root for dev error page frame classification.
326
338
  // Not in runtimeConfig (TIM-787: leaked to client bundles).
327
- // manifest.root is the resolved Vite root — correct even when
328
- // CWD differs from project root (e.g., monorepo custom root) (TIM-807).
329
- isDev ? manifest.root : undefined
339
+ // manifest.viteRoot is the resolved Vite root string — correct even
340
+ // when CWD differs from project root (e.g., monorepo custom root)
341
+ // (TIM-807). Passing manifest.root (segment tree object) here would
342
+ // stringify to "[object Object]" and misclassify app frames as
343
+ // internal, dropping source locations from the dev error page (TIM-808).
344
+ isDev ? manifest.viteRoot : undefined
330
345
  ),
331
346
  renderDenyFallback: async (deny, req, responseHeaders) => {
332
347
  // Render the deny page (403.tsx, 404.tsx, etc.) for DenySignals
@@ -389,6 +404,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
389
404
  bodyLimits: {
390
405
  limits: (runtimeConfig as Record<string, unknown>).limits as BodyLimitsConfig['limits'],
391
406
  },
407
+ sensitiveFields: formsConfig?.stripSensitiveFields,
392
408
  revalidateRenderer: async (path: string) => {
393
409
  // Build the React element tree for the route at `path`.
394
410
  // Returns the element tree (not serialized) so the action handler can
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Sensitive field stripping — removes password/token/CVV-style fields
3
+ * from form values before they are echoed back to the client as
4
+ * `submittedValues` for form repopulation.
5
+ *
6
+ * Applied to both action paths:
7
+ * - With-JS action path: `createActionClient()` in `action-client.ts`
8
+ * - No-JS form POST path: `handleFormAction()` in `action-handler.ts`
9
+ *
10
+ * Why: on a validation failure, timber echoes submitted form values back so
11
+ * the user doesn't have to re-type everything. Without filtering, plaintext
12
+ * passwords / credit-card numbers / TOTP codes would travel through the RSC
13
+ * stream (with-JS) or land in the HTML as `defaultValue` attributes (no-JS)
14
+ * — ending up in browser history, proxy logs, disk caches, and the
15
+ * back-forward cache.
16
+ *
17
+ * Safe by default: the built-in deny-list is applied unconditionally unless
18
+ * the user explicitly opts out via `forms.stripSensitiveFields: false` in
19
+ * `timber.config.ts` or per-action via `createActionClient({ stripSensitiveFields: false })`.
20
+ *
21
+ * See design/08-forms-and-actions.md §"Validation errors"
22
+ * See design/13-security.md §"Sensitive field stripping"
23
+ * See TIM-816
24
+ */
25
+
26
+ import { isDebug } from './debug.js';
27
+
28
+ // ─── Public types ────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * How to strip sensitive fields from `submittedValues`.
32
+ *
33
+ * - `true` / `undefined` — use the built-in deny-list (default, safe).
34
+ * - `false` — do not strip anything (dev convenience; never do this in prod).
35
+ * - `string[]` — additional field names to strip, merged with the built-in list.
36
+ * - `(name) => boolean` — custom predicate, fully replaces the built-in list.
37
+ * Return `true` to strip, `false` to keep. The `name` argument is the raw
38
+ * (un-normalized) field name as it appeared in the submitted form.
39
+ */
40
+ export type SensitiveFieldsOption = boolean | readonly string[] | ((name: string) => boolean);
41
+
42
+ // ─── Built-in deny-list ──────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Substring patterns matched against the normalized field name.
46
+ * Normalization = lowercase + strip `_` and `-`.
47
+ *
48
+ * Any field whose normalized name *contains* one of these strings is
49
+ * considered sensitive. Entries like `currentPassword`, `passwordConfirmation`,
50
+ * and `user.password` all match via the `password` substring.
51
+ */
52
+ const BUILTIN_SUBSTRING_PATTERNS: readonly string[] = [
53
+ 'password',
54
+ 'passwd',
55
+ 'pwd',
56
+ 'secret',
57
+ 'apikey',
58
+ 'accesstoken',
59
+ 'refreshtoken',
60
+ 'cvv',
61
+ 'cvc',
62
+ 'cardnumber',
63
+ 'cardcvc',
64
+ 'ssn',
65
+ 'socialsecuritynumber',
66
+ 'otp',
67
+ 'totp',
68
+ 'mfacode',
69
+ 'twofactorcode',
70
+ 'privatekey',
71
+ ];
72
+
73
+ /**
74
+ * Exact matches against the normalized field name. These are field names that
75
+ * are too short or too common to substring-match safely. e.g. `token` alone
76
+ * would match `csrfToken`, which is not sensitive — so `token` is exact-only,
77
+ * while legitimate token fields are covered by `accesstoken` / `refreshtoken`.
78
+ */
79
+ const BUILTIN_EXACT_PATTERNS: readonly string[] = ['token'];
80
+
81
+ /**
82
+ * Normalize a field name for deny-list comparison.
83
+ * Lowercases the string and strips `_` and `-` so camelCase, snake_case, and
84
+ * kebab-case variants all compare equal (`api_key` / `apiKey` / `api-key` →
85
+ * `apikey`).
86
+ */
87
+ function normalize(name: string): string {
88
+ let out = '';
89
+ for (let i = 0; i < name.length; i++) {
90
+ const ch = name.charCodeAt(i);
91
+ if (ch === 0x5f /* _ */ || ch === 0x2d /* - */) continue;
92
+ // A-Z → a-z
93
+ if (ch >= 0x41 && ch <= 0x5a) {
94
+ out += String.fromCharCode(ch + 32);
95
+ } else {
96
+ out += name[i];
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Check whether a name matches the built-in deny-list (with optional extras).
104
+ * Extras are merged into the substring pattern list after normalization.
105
+ */
106
+ function isBuiltinSensitive(name: string, extras?: readonly string[]): boolean {
107
+ const normalized = normalize(name);
108
+ if (BUILTIN_EXACT_PATTERNS.includes(normalized)) return true;
109
+ for (const pattern of BUILTIN_SUBSTRING_PATTERNS) {
110
+ if (normalized.includes(pattern)) return true;
111
+ }
112
+ if (extras && extras.length > 0) {
113
+ for (const extra of extras) {
114
+ const normExtra = normalize(extra);
115
+ if (normExtra.length === 0) continue;
116
+ if (normalized.includes(normExtra)) return true;
117
+ }
118
+ }
119
+ return false;
120
+ }
121
+
122
+ // ─── Predicate resolution ────────────────────────────────────────────────
123
+
124
+ /**
125
+ * A resolved predicate: `null` means "don't strip anything" (the option was
126
+ * explicitly `false`). Otherwise a function from raw field name → boolean.
127
+ */
128
+ export type ResolvedSensitivePredicate = ((name: string) => boolean) | null;
129
+
130
+ /**
131
+ * Resolve a `SensitiveFieldsOption` into a concrete predicate.
132
+ * Precedence: per-action > global > built-in default.
133
+ *
134
+ * - Per-action `undefined` → fall back to global.
135
+ * - Global `undefined` → use built-in list.
136
+ * - Either level set to `false` → disable stripping entirely (returns `null`).
137
+ * - `true` → built-in list.
138
+ * - `string[]` → built-in ∪ extras.
139
+ * - function → custom, replaces the built-in list entirely.
140
+ */
141
+ export function resolveSensitivePredicate(
142
+ perAction: SensitiveFieldsOption | undefined,
143
+ global: SensitiveFieldsOption | undefined
144
+ ): ResolvedSensitivePredicate {
145
+ const chosen = perAction !== undefined ? perAction : global;
146
+
147
+ if (chosen === false) return null;
148
+ if (chosen === undefined || chosen === true) {
149
+ return (name) => isBuiltinSensitive(name);
150
+ }
151
+ if (typeof chosen === 'function') {
152
+ return chosen;
153
+ }
154
+ // Array of extra names merged with the built-in list.
155
+ const extras = chosen;
156
+ return (name) => isBuiltinSensitive(name, extras);
157
+ }
158
+
159
+ // ─── Module-level global config ──────────────────────────────────────────
160
+
161
+ let globalConfig: SensitiveFieldsOption | undefined;
162
+
163
+ /**
164
+ * Set the global `forms.stripSensitiveFields` config from `timber.config.ts`.
165
+ * Called once at startup from `rsc-entry`.
166
+ */
167
+ export function setGlobalSensitiveFieldsConfig(option: SensitiveFieldsOption | undefined): void {
168
+ globalConfig = option;
169
+ }
170
+
171
+ /** Read the global `forms.stripSensitiveFields` config. */
172
+ export function getGlobalSensitiveFieldsConfig(): SensitiveFieldsOption | undefined {
173
+ return globalConfig;
174
+ }
175
+
176
+ // ─── Stripping ───────────────────────────────────────────────────────────
177
+
178
+ // One warning per field name per process — prevents log spam when a form is
179
+ // submitted many times in dev mode.
180
+ const warnedFields = new Set<string>();
181
+
182
+ function warnStripped(name: string): void {
183
+ if (!isDebug()) return;
184
+ if (warnedFields.has(name)) return;
185
+ warnedFields.add(name);
186
+ console.warn(
187
+ `[timber] stripped sensitive field "${name}" from submittedValues. ` +
188
+ `Override via forms.stripSensitiveFields in timber.config.ts.`
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Walk an object (recursively) and return a copy with every key matching
194
+ * `predicate` removed. Nested objects like `{ user: { password: '...' } }`
195
+ * are handled — `user.password` is stripped while other `user.*` fields remain.
196
+ *
197
+ * - Arrays are walked element-wise (object entries inside arrays are cleaned).
198
+ * - Non-plain values (strings, numbers, Files, Dates, etc.) are returned as-is.
199
+ * - When a stripped key is encountered, it is omitted from the result entirely
200
+ * — we do NOT set it to an empty string, because that would overwrite a
201
+ * valid `defaultValue` the form author might have set.
202
+ */
203
+ export function stripSensitiveFields<T>(value: T, predicate: ResolvedSensitivePredicate): T {
204
+ // Null predicate = stripping disabled entirely.
205
+ if (predicate === null) return value;
206
+ if (value === null || value === undefined) return value;
207
+ if (typeof value !== 'object') return value;
208
+ if (value instanceof File || value instanceof Date) return value;
209
+
210
+ if (Array.isArray(value)) {
211
+ return value.map((item) => stripSensitiveFields(item, predicate)) as unknown as T;
212
+ }
213
+
214
+ const result: Record<string, unknown> = {};
215
+ for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
216
+ if (predicate(key)) {
217
+ warnStripped(key);
218
+ continue;
219
+ }
220
+ result[key] = stripSensitiveFields(nested, predicate);
221
+ }
222
+ return result as unknown as T;
223
+ }
224
+
225
+ // ─── Test helpers ────────────────────────────────────────────────────────
226
+
227
+ /** Reset the "warned once" cache. Exposed for tests. */
228
+ export function __resetSensitiveFieldsWarnings(): void {
229
+ warnedFields.clear();
230
+ }