Skip to content

🚑 fix: Stop Configuration Page Data Loss (Wave 0) - #151

Merged
danny-avila merged 4 commits into
mainfrom
fix/config-hotfixes
Oct 5, 2026
Merged

danny-avila merged 4 commits into
mainfrom
fix/config-hotfixes

Conversation

@danny-avila

Copy link
Copy Markdown
Contributor

Summary

Stops silent data loss and instance-wide misconfiguration on the Configuration page. These issues came from a hands-on audit: 10 isolated live stacks (a real LibreChat backend, MongoDB and a production-like librechat.yaml), with every finding independently re-reproduced. This PR is "Wave 0" of a larger overhaul. It is deliberately surgical: the UX/visual rework follows in separate PRs.

Data-loss and corruption fixes

  • Enter key no longer deletes or adds things. click-ui Button/IconButton defaulted to type="submit" inside the tab <form>. Pressing Enter in any field "clicked" the first Trash/Add button: it deleted the first custom endpoint or social login, or added "" to actions.allowedDomains / modelSpecs.addedEndpoints, which blocks every domain or hides every endpoint. Fixes:
    • every click-ui button now sets an explicit htmlType;
    • the tab form ignores implicit submits;
    • a new ESLint rule click-ui/require-button-html-type enforces this.
  • Blank values are never written. Blank list rows, emptied header lists and stray []/{} used to become overrides. For example, [""] puts LibreChat into allowlist mode, and an empty MIME list rejects every upload. normalizeForSave drops empty items. An emptied override becomes a reset, and edits equal to the saved value are dropped. Whitespace is preserved verbatim (stop sequences).
  • YAML import:
    • It no longer injects every Zod default. Previously, importing balance.startBalance turned balance.enabled off for everyone.
    • It no longer full-replaces __base__.
    • It writes to the target chosen in the dialog, not whichever profile happens to be open.
    • It merges only the keys present in the YAML, through the normal validated save path, and reports skipped and unknown keys.
  • Validate before write. Resets used to be committed before validation, so a failed save still deleted data (for example, an MCP rename plus an invalid title lost the server). Now everything is pre-flighted first, and queries always refetch afterwards.
  • Dotted keys (us.anthropic.*, gpt-4.1, MIME types) are blocked with inline errors and a save/import pre-flight. LibreChat's express-mongo-sanitize silently strips them, while the panel used to report "Changes saved".
  • Honest save results. Base-only sections (filters) and YAML-only sections (cloudfront, rateLimits, turnstile; verified in LibreChat source) are now read-only with a banner and stripped from saves. Backend partial strips and "No actionable" replies are reported as warnings ("Nothing was stored…"), never as success.
  • Azure groups / Anthropic Vertex are read-only. Overrides there had no runtime effect, and saving re-sent all groups, dropping dotted model keys. Group API keys are masked, and a stored stale override gets a "Remove stale override" action.
  • Scope mode: sequential edits to a collection entry are all kept, where previously the first was silently dropped. Review BEFORE values come from the profile being edited.

Smaller fixes

  • Delete buttons are native buttons named "Delete ". Enter on a card's trash button no longer toggles the card.
  • The "Leave site?" prompt only appears with unsaved edits.
  • A stale save error no longer reappears in a reopened review dialog.
  • The review shows REVERTS TO (the librechat.yaml or base value) for resets.
  • Settings unknown to the bundled schema are listed read-only instead of disappearing.
  • Unmapped sections get readable titles.

Change Type

  • Bug fix (non-breaking change which fixes an issue)

Testing

  • Unit tests:
    • normalizeForSave (including whitespace-significant strings)
    • findUnsafeKeys
    • raw YAML import (3-key YAML → 3 leaves, no injected defaults) and import target routing
    • the write pre-flight and skip rules
    • scope sequential edits
    • secret masking
    • the new ESLint rule
  • Playwright e2e/config.spec.ts: Enter on every tab marks nothing dirty; delete-button names (axe); create dialogs still submit on Enter. These tests need a live LibreChat backend (not the mock).
  • Live: an independent verifier checked every acceptance criterion on a separate stack across three rounds, plus a regression sweep (edit/save/reload on every tab, collections, scope profile edit, import). Final round: 13/14 pass. The remaining criterion ("OpenAI/Google show a Models field") requires librechat-data-provider 0.8.527, which is not published yet.
  • npx tsc --noEmit, npx vitest run (1174 passed), npx eslint src/

Known follow-ups (next PR, low severity)

  • Import blocks the whole file if read-only Azure groups contain dotted model names, although those keys would be skipped anyway.
  • The Vertex read-only notice repeats on each child field.
  • e2e/config.spec.ts should be gated behind a live-backend flag.
  • Alias keys (interfaceConfig.*) are listed as unknown in the import report.
  • Unknown-settings values are not masked.
  • Pluralization of the profile import message.

LibreChat backend changes recommended (separate repo)

  • mongoSanitize({ allowDots: true }) for /api/admin/config*, so dotted keys can be stored.
  • Re-run azureConfigSetup/vertexConfigSetup on merged overrides, so Azure groups become editable again.
  • Keyed ops / tombstones for endpoints.custom entries.

Test Configuration:

  • Bun 1.4.0; LibreChat dev @ a4f6763 (data-provider 0.8.527) with MongoDB 8.2

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes

Implements HF-1 to HF-11 of the configuration-page plan, plus HF-12 as
limited by the product decisions (no data-provider bump; unknown keys per D8).

- HF-1 Enter key: every click-ui Button/IconButton now sets an explicit
  htmlType (new ESLint rule click-ui/require-button-html-type); the tab form
  gets a disabled hidden default submit button and blocks implicit submission
  from inputs. Enter can no longer delete an endpoint/social login or add "".
- HF-2 Delete buttons: TrashButton/EditButton are native buttons named
  "Delete <item>"/"Edit <item>"; an entry card ignores keydown from nested
  controls, so Enter on a card's trash button deletes instead of toggling.
- HF-3 Blank values: buildSavePayload normalizes every value (blank list items
  dropped, emptied lists/records become "no value"), drops edits equal to the
  saved value, and turns an emptied override into a reset. Blank allowlist
  rows, empty header lists and stray [] / {} are never written.
- HF-4 beforeunload: only prompts while there are unsaved edits.
- HF-5 YAML import: validates a copy (version optional) but imports the raw
  YAML (no Zod defaults, unknown keys kept and listed), merges through the
  normal validated save path into the target picked in the dialog (base or a
  profile; the open profile is preselected). The full-replace PUT is gone.
- HF-6 Dotted / $ keys: inline errors in key/value rows, record add-key and
  rename inputs; the review blocks the save and names each key; import and the
  server pre-flight reject them (BLOCK_UNSAFE_CONFIG_KEYS flag for BE-1).
- HF-7 Atomic saves: validateConfigChangesFn validates every save (indexed
  array entries against the element schema, process-backed MCP fields, unsafe
  keys, capabilities) before any reset is written; queries always refetch.
- HF-8 Base-only sections (filters) and YAML-only sections (cloudfront,
  rateLimits, turnstile, verified in LibreChat) render read-only with a banner,
  are stripped from saves, and a backend "No actionable" reply is reported as
  a warning instead of "Changes saved".
- HF-9 Azure groups and Anthropic Vertex are read-only with a banner, group
  API keys are masked (also in the review dialog), and a stored override gets
  a "Remove stale override" action.
- HF-10 The save error is cleared on open, cancel, discard and every edit.
- HF-11 Scope mode reads collections from the merged tree when entries have
  pending edits, so sequential edits and "Add item" rows are kept; review
  BEFORE values come from the profile being edited, and the title names it.
- HF-12 (D8) Settings unknown to the bundled schema are listed read-only per
  section and at top level on the System tab; unmapped sections get a
  readable title; test asserts every schema section has SECTION_META.

Saves and imports send at most 100 entries per PATCH (the admin API limit).
Tests: normalizeForSave, findUnsafeKeys, raw YAML import (3 keys -> 3 leaves),
pre-flight checks, import target routing, HF-11 sequential scope edits, secret
masking; Playwright spec for Enter on every tab and delete-button names (axe).
- Sanitize the aria-describedby ids of key/value and rename errors (entry
  names can contain spaces) and give each add-key input a unique error id.
- Document that the tab form's Enter guard also covers portaled create
  dialogs, so Enter commits the field being typed instead of submitting a
  half-filled dialog.
…, Reverts)

Write rules now apply to every write path:
- One shared rule, getWriteSkipReason (src/utils/writes.ts), now drives the
  review, the server pre-flight and every write path: import, base saves,
  bulk profile saves and saveFieldProfileValueFn. Imports can no longer
  store Azure groups (plaintext keys) or Anthropic Vertex overrides. A save
  at an ancestor path that carries one of these fields is rejected. Removing
  a stale override is still allowed.
- Langfuse (BASE_PRINCIPAL_CONFIG_SECTIONS) is skipped on generic writes as
  `dedicated`. mcpAppSandbox is an interim base-only section until the
  data-provider bump.
- A PATCH reply whose stored overrides lack a section that was sent is
  reported as `notStored`, so partial strips are no longer called saved.
- When a later batch fails, the error says how many entries were already
  saved.

Import:
- Import leaves are normalized like edits (normalizeForSave), so
  `allowedDomains: [""]` and blank or null values are reported as `empty`
  instead of being stored.
- AppService alias sections are merged leaf by leaf with their canonical
  section, using APP_SERVICE_KEY_ALIASES, which is shared with
  normalizeAppServiceKeys.
- A numeric `version` no longer fails validation.

Review and messages:
- Emptying a value that only librechat.yaml or the defaults set is now
  reported as `yamlValue` ("can't be cleared here") instead of "nothing
  to save".
- Resets show the stored override as BEFORE and the value the field falls
  back to as REVERTS TO. This uses librechat.yaml for base (the new
  yamlConfig from getBaseConfigFn) and the base value for a profile.
- Warning toasts have a short title and a wrapped list of paths and
  reasons. When nothing was written they say "Nothing was stored", and the
  import success banner is not shown.

Keyboard:
- The tab form's Enter guard only applies to inputs in its own DOM, so the
  portaled Create MCP server and Create endpoint dialogs submit on Enter
  again. Covered by a new e2e test.

Cleanup:
- Removed the dead normalizeImportConfig.
- ConfigRecord and PatchFieldsResponse moved to src/types.
- Write helpers are typed with t.SaveEntry instead of unknown.
- ImportTarget is renamed to WriteTarget.
- Server utils are imported through the @/utils barrel.
- Fixed the prettier issues this branch introduced.
normalizeForSave trimmed every string and list item, so saving any sibling
field silently rewrote whitespace-significant values such as stop sequences
("\n\nHuman:" -> "Human:", ["\n"] dropped). Only drop exactly-empty
strings and list items; keep everything else verbatim.
@danny-avila
danny-avila merged commit bb88594 into main Oct 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant