Skip to content

Commit e7ced59

Browse files
🪵 feat: Audit Logs (#52)
* ✨ feat: Audit log UI for SystemGrants changes Wire the audit-log tab into the grants page, switch the server function from a stub to a real /api/admin/audit-log call with filter query params, generate the CSV client-side from already-fetched entries, and add unit coverage for the audit log utilities. * 🔒 fix: Harden audit log feature — CSV injection, a11y, click-ui, server validation Defang CSV formula injection (CWE-1236) with leading-quote escape for cells beginning with =/+/-/@/tab/CR, switch to CRLF line endings, prepend UTF-8 BOM, and emit localized headers via a new auditLogToCsv(entries, localize) signature. Migrate the audit log tab UI to click-ui: ButtonGroup for the action filter, DatePicker for date inputs, Button for export, Badge with state="success"/"danger"/"neutral" for action and principal-type pills (fixes the failing 4.5:1 contrast on the prior badge-success class). Fix the focus-loss bug where the search input unmounted on every keystroke: drop the isLoading early-return, debounce search at 300ms, render LoadingState inline within the table body, and handle the isError case explicitly. Wire useAnnouncement + ScreenReaderAnnouncer so filter changes announce the result count to assistive tech; give SearchInput a proper aria-label; rename the entry-count plural keys to the i18next v25 _zero/_one/_other suffix convention; harden the CSV blob download for Safari/Firefox via appendChild plus a deferred URL.revokeObjectURL. Server-side: add a requireAnyCapability defense-in-depth guard, tighten the Zod schema with ISO date validation and a 200-char cap on search, parse the response body via Zod, bump staleTime to 60s, and add placeholderData: keepPreviousData so filter changes don't flash empty. * ✨ feat: Audit log — pagination, faceted filters, server CSV, side drawer, CSP, click-ui Server: paginated getAuditLogPageFn with cursor/limit + multi-action + facet params (actorId, targetPrincipalType, targetPrincipalId, capability), Zod-parsed response schema, auditLogInfiniteQueryOptions factory for useInfiniteQuery, exportAuditLogServerFn that proxies the backend CSV endpoint, all behind the same triple-capability defense-in-depth guard. UI: new AuditLogDetailDrawer (click-ui Flyout) renders the full entry with copyable IDs and before/after diff highlighted via Badge state. Local AuditLogEntryWithDiff type carries optional before/after arrays until the data-schemas package upstreams the fields. Parser: parseAuditSearch handles actor: / target: / capability: / created:>YYYY-MM-DD qualifiers with quoted multi-word values, falling back to free text for unknown keys. diffGrantState reports added/removed/unchanged sets. Click-ui migration: GrantTableRow and EditCapabilitiesDialog now use Badge state for status pills and the principal-type chip; deleted unused badge-success and badge-danger CSS classes from styles.css. GrantManagementTab keeps its raw table for now since click-ui Table does not support per-row tabIndex/role/onKeyDown/ref (documented inline). Security: Content-Security-Policy plus X-Content-Type-Options, Referrer-Policy, X-Frame-Options on every HTML response, with HSTS gated on production. Inline filter action wrapped in an array to match the new multi-action server schema (batch B will replace this filter UI entirely). * ✨ feat: Audit log infinite pagination, faceted filters, structured search, permalinks Replace useQuery with useInfiniteQuery against auditLogInfiniteQueryOptions so audit log pages on demand via cursor pagination — both a manual Load more button and an IntersectionObserver sentinel auto-load when the bottom row scrolls into view. The legacy single-shot getAuditLogFn and auditLogQueryOptions are gone. Multi-select action facet via click-ui CheckboxMultiSelect plus four faceted text/select filters (actor ID, target ID, target principal type, capability) collapsed behind a "More filters" disclosure with debounced inputs. Structured search runs the live input through parseAuditSearch on every debounce tick, extracts actor: / target: / capability: / created:>YYYY-MM-DD qualifiers, and renders each one as a dismissible Badge chip; clicking a chip regex-strips the corresponding token from the input. Qualifiers override the manual facet inputs when both are present. Row click and Enter/Space activation set ?entryId= on the route via TanStack Router; the matching entry opens in the AuditLogDetailDrawer with copy-permalink and Esc-to-close semantics. validateSearch on /_app/grants is extended so the param survives tab switches. Dual-mode CSV export: client-side auditLogToCsv for ≤500 loaded entries, server-side exportAuditLogServerFn for larger result sets or when more pages remain. Filter changes announce the result count via ScreenReaderAnnouncer, and Load More announces page-loaded count for assistive tech. * 🧹 chore: Audit log polish — UX, offset pagination, Radix Dialog drawer, dead-code purge Replace the cursor-based useInfiniteQuery with offset-based useQuery + placeholderData: keepPreviousData and the shared numbered Pagination component, matching the GroupsTab pattern; debounced filter setters reset the page in the same callback so search and pagination stay in sync. Drop the qualifier-parser and the disclosure-collapsed More-filters block; the four facet fields (Actor, Target, Target type, Capability) sit always-visible and partial-match against denormalized name fields on the backend. Top search box is plain regex-substring across actor, target, and capability. Replace click-ui Flyout with @radix-ui/react-dialog directly for the side panel so enter and exit animations actually play, driven by data-state keyframes added to styles.css. Every ID-like field in the drawer gets a CopyableMono button with per-button copied feedback. Each DatePicker renders a single tab stop and the shared danger-styled Clear button resets both date inputs together. Delete the unused AuditLogRow.tsx, the parseAuditSearch parser plus its types and tests, the dead ACTION_FILTER_LABELS and AUDIT_ACTION_FILTERS exports, the diffGrantState helper, and the locale keys left over from the load-more / qualifier-chip iteration. Net 383 lines deleted. * 🔒 fix: CSP report-only fallback to unblock SSR hydration TanStack Start's SSR injects an inline `<script type="module">import("...")</script>` into the root HTML to boot the client. The previous enforced policy of `script-src 'self'` (no nonce, no `'unsafe-inline'`) would cause browsers to refuse that inline script in production, breaking hydration before any UI rendered. Local `bun run dev` never exercises `server.ts`, so the regression hid in plain sight. Threading a per-request nonce through TanStack Start's manifest is non-trivial. As an interim, the policy now ships as `Content-Security-Policy-Report-Only` so violations still surface in browser devtools and reporting endpoints without blocking hydration. Set `ADMIN_PANEL_CSP_ENFORCE=true` to flip back to enforcement once the nonce wiring lands. * 🛠️ fix: Stabilize `localize` reference across renders `useLocalize` returned a fresh closure on every render, so any effect that listed it in its deps array re-fired every render. In `AuditLogTab` that was the screen-reader announce effect, causing assistive tech to be spammed every time React reconciled the component. Wrapping the closure in `useCallback` keyed on `translate` keeps the function identity stable across renders while still picking up language changes. * 🔒 fix: Close CSV formula-injection prefix gaps The previous prefix regex `^[=+\-@\t\r]` missed payloads that lead with whitespace before the formula trigger (e.g. ` =SUM(...)`), payloads that start with `\n` or `|` (the latter is Excel's DDE invocation marker), and Unicode decoy characters such as NBSP and BOM that spreadsheets render as zero-width but JavaScript's `\s` does not always cover symmetrically. The defang now treats a value as dangerous if either its first character is a trigger or if the first character after stripping space/NBSP/BOM is a trigger; stripping the entire `\s` class would falsely accept payloads led by `\r` / `\n` / `\t`, which are themselves triggers. Local-day date helpers (`isoDateToDate`, `dateToIsoDate`, `localDayBoundaryIso`) also moved here so the timezone fix in `AuditLogTab` can be unit tested in isolation; new cases cover round-trips, rolled-over input rejection, and both start/end boundaries. * ⚡ perf: De-duplicate effective-capabilities fetch per audit-log call Each `getAuditLogPageFn` / `exportAuditLogServerFn` invocation previously did two round-trips: `requireAnyCapability` would call `getEffectiveCapabilitiesFn`, then the handler would call the audit-log endpoint. Pagination doubled the backend traffic of the whole tab. Handlers now fetch capabilities once via a new `guardAuditLogAccess` helper and run `checkAnyCapability` against the in-memory list. `checkAnyCapability` is exposed so future server functions can adopt the same pattern; `requireAnyCapability` is implemented in terms of it to keep behaviour identical for unchanged callers. Adds `getAuditLogEntryFn` and `auditLogEntryQueryOptions` so the UI can deep-link to entries that aren't on the current page. The endpoint returns `{ entry: null }` for 404 so callers can render an explicit "not found" state without crashing. * 🧹 chore: Extract `useDebouncedFilter` hook Four near-identical debounced-text-filter handlers in `AuditLogTab` collapsed into a single hook that owns the controlled value, the debounced commit value, and timer cleanup. The optional `onCommit` callback fires once per quiescent settle so callers can reset pagination or log analytics without re-rolling their own ref/`setTimeout` plumbing. * 🛠️ fix: Audit log deep-links, exports, clipboard, and TZ correctness Drawer permalinks no longer silently fail for entries off the current page. When `?entryId=` points at a row that isn't in `pageEntries`, the tab falls back to `getAuditLogEntryFn` via React Query and renders the drawer from either the on-page row or the fetched record. A new not-found state in `AuditLogDetailDrawer` surfaces the case where the id is gone instead of leaving the drawer empty. CSV export now always hits the backend. The previous client/server split truncated CSVs whenever a result set had between 51 and 500 matching rows: the client path serialized at most one page (`AUDIT_LOG_PAGE_SIZE = 50`) but the threshold for switching to the server endpoint was 500. Pulling the client path keeps `auditLogToCsv` (and its tests) as the contract the server is expected to honor, and removes the now-unused `com_audit_export_client` translation key. Clipboard writes for the permalink button and the inline copyable cells now await the promise and only flip to the "Copied!" affordance on success. Permission-denied, HTTP-origin, and `navigator.clipboard === undefined` paths all surface via the existing `ScreenReaderAnnouncer` with a new `com_a11y_copy_failed` key. The permalink itself is now built from `window.location.origin` + the canonical `/grants?tab=audit-log&entryId=…` shape so copied links don't carry the current filter state. Filter pages now use `useDebouncedFilter` instead of four ad-hoc handlers. `DatePickerCell`'s `useEffect` no longer re-runs every render; the comment captures *why* the workaround exists so future readers don't strip it. Date filters now anchor at local-day boundaries (`localDayBoundaryIso`) instead of mixing UTC midnight with local-time picker values, fixing off-by-one filter results for any non-UTC user. `pageEntries` is memoized to avoid being a fresh array each render. Dead `com_audit_filter_*` translation keys from the qualifier-parser cleanup are removed. * ⚡ perf: Drop BFF audit-log capability guard The LibreChat backend already enforces ACCESS_ADMIN on every /api/admin/audit-log route, and any future tightening (e.g. a dedicated READ_AUDIT_LOG capability) belongs there. The BFF-layer guard was running an extra /effective round-trip on every page request without buying real protection, since the backend would reject the same callers we did. It was also inconsistent with GrantManagementTab, which sits on the same page and already calls getAllGrantsFn with no BFF guard. Removes guardAuditLogAccess, AUDIT_LOG_REQUIRED_CAPS, and the three call sites in getAuditLogPageFn / getAuditLogEntryFn / exportAuditLogServerFn. The checkAnyCapability helper extracted in eef26ce stays — it's still used by requireAnyCapability and is useful on its own. * 🛡️ feat: Gate audit-log tab on `READ_AUDIT_LOG` capability The audit-log tab was visible to anyone who could reach the Grants page (i.e. anyone with `ACCESS_ADMIN`). With the LibreChat backend now requiring `READ_AUDIT_LOG` on `/api/admin/audit-log`, users without that grant will hit a 403 if they click the tab — surfacing the right backend policy but a bad UX. The tab trigger, panel slot, and body render are all gated on `hasCapability('read:audit_log')` via the existing `useCapabilities` hook, which reads from the cached effective-capabilities lookup the sidebar already uses. A stale `?tab=audit-log` URL on a session that lost the cap silently falls back to management rather than rendering an empty page. `READ_AUDIT_LOG_CAPABILITY` lives in `@/constants` as a forward-compat string constant until the `@librechat/data-schemas` dependency bumps to a version that exports it from `SystemCapabilities`. * 🛠️ chore: Drop READ_AUDIT_LOG shim, use SystemCapabilities directly LC PR #13087 adds READ_AUDIT_LOG to @librechat/data-schemas, so the local forward-compat constant is just a string-literal detour. Removed READ_AUDIT_LOG_CAPABILITY and updated GrantsPage to read the cap from SystemCapabilities.READ_AUDIT_LOG. This commit will not typecheck against the currently-published data-schemas 0.0.48 (the cap does not exist there). That is the intended state until LC merges, data-schemas re-publishes, and this PR's package.json pin is bumped as a final commit. Until then, local verification via `bun link` against the LC checkout exercises the full path. Also adds the picker labels: com_cap_read_audit_log and com_cap_desc_read_audit_log so the System category renders the toggle and tooltip correctly once data-schemas publishes with the cap in CAPABILITY_CATEGORIES. * 📦 chore: Bump @librechat/data-schemas to ^0.0.52 This PR depends on the READ_AUDIT_LOG capability added in danny-avila/LibreChat#13087. Pinning ahead of publication: the install will fail until the LC PR merges and data-schemas is republished, at which point bun install populates the lockfile and CI goes green. * 🎨 chore: sort imports in src/server/auth.ts after rebase Post-rebase fixup so house-style import ordering applies to a file main edited but the rebase didn't re-run sort-imports against. * 📦 chore: bump librechat-data-provider to ^0.8.503 The npm-published @librechat/data-schemas@0.0.52 was built against a librechat-data-provider that already exports RetentionMode, so the previous ^0.8.502 pin (which resolves to 0.8.502, before RetentionMode landed) breaks the dev server boot with: [MISSING_EXPORT] "RetentionMode" is not exported by node_modules/librechat-data-provider/dist/index.es.js Bumping to ^0.8.503 (now on npm) restores the missing export and aligns the admin panel with the version data-schemas@0.0.52 was built against. * 🧹 chore: drop client-side audit-log CSV path CSV export now flows entirely through exportAuditLogServerFn against the LibreChat backend, so the in-bundle helpers auditLogToCsv, escapeCsvCell, hasFormulaPrefix, CSV_COLUMNS, and the _CsvColumnsExhaustive compile-time check are dead code with no production callers. The corresponding describe('auditLogToCsv', ...) block in auditLogUtils.test.ts and the eight orphaned com_audit_csv_col_* locale keys go with them. Formula-injection defang is unchanged in behavior because the equivalent guarantee now lives on the backend CSV writer in packages/api with its own regression coverage. * ♿ fix: announce filter match total to screen readers, not page slice The post-filter live-region announcement was being passed pageEntries.length, which is capped at AUDIT_LOG_PAGE_SIZE (50). For a filter matching 200 rows, the announcement read "50 audit log entries match the current filters" while the visible bottom-of-page counter correctly read "200 entries" off the API total. Swap the announcement source to use total and update the effect's dependency array to track total instead of pageEntries.length. * ♿ fix: gate audit-log a11y announcements + surface export failures The post-filter announcement effect kept isFetching in its dependency array, so every pagination click re-fired the same X-entries-match message to screen readers even though no filter had changed. Tracking a filterSignature in a ref now short-circuits the announcement unless the debounced filter inputs themselves have actually changed, leaving page navigation silent. handleExport had a try/finally with no catch, and the caller invokes it via () => void handleExport(), so a rejection from exportAuditLogServerFn went unhandled and the user saw the loading state vanish with no download and no error. A catch branch now announces a new com_a11y_audit_export_failed locale key, mirroring how copy paths use com_a11y_copy_failed when the clipboard write fails. * ♿ fix: re-apply DatePickerCell tabIndex patch after Clear remount The wrapper's mount-only effect (empty deps) only ran once, so after the Clear button bumps dateResetNonce and the inner DatePicker remounts via its key prop, the freshly created input element retains its default tabIndex and the double-tab-stop the wrapper exists to prevent reappears. DatePickerCell now takes a resetKey prop that callers pass the same dateResetNonce they use to remount the inner DatePicker. Including it in the effect dep array re-runs the patch each time the inner input is replaced, keeping the keyboard tab order stable across clears. * 🌐 i18n: add 11 missing com_config_field_* keys after data-provider bump librechat-data-provider 0.8.503 adds new fields to the config schemas (retentionMode, guardrailConfig/Identifier/Version, isTemporary, hideBadgeRow, forward_audience_on_refresh, proxy, buildInfo, trace, streamProcessingMode) which the localization coverage spec then flags as missing the expected com_config_field_<fieldName> entries. Restoring those keys keeps every schema field covered by an English label so the config-field locale assertion stays green. * 🛡️ feat: BFF-layer READ_AUDIT_LOG guard + forward-compat shim Reintroduces READ_AUDIT_LOG_CAPABILITY in src/constants/capabilities.ts because the npm-published @librechat/data-schemas@0.0.52 does not yet export SystemCapabilities.READ_AUDIT_LOG; that constant lands in the sibling LC PR which publishes 0.0.53 once it merges to main. The shim value is byte-identical to the upstream constant, so the only follow-up after publish is a one-line import swap. Adds defense-in-depth requireCapability(READ_AUDIT_LOG_CAPABILITY) calls to getAuditLogPageFn, getAuditLogEntryFn, and exportAuditLogServerFn so an admin without READ_AUDIT_LOG is rejected at the BFF rather than relying on the LibreChat backend to return 403 — same pattern the config server functions use for sensitive mutations. The previous comment claiming the backend only enforces ACCESS_ADMIN was stale; LC PR #13087 already chains requireAdminAccess + requireAuditLogRead on the audit-log router, and the BFF guard keeps the policy consistent regardless of merge order between the two PRs. * ♿ fix: audit-log export race + date input labels + drawer close animation Three related polish fixes flagged by review bots: Export was reading the debounced filter snapshot, so a user who typed a search term and clicked Export inside the 300ms window got a CSV covering the previous (broader) result set. handleExport now rebuilds the wire filters from the immediate input values via a shared buildFilters helper, so the export matches exactly what the inputs show. The page query still uses the debounced values so typing does not refetch on every keystroke. The DatePicker controls were rendered with sibling <span> labels and no input id, regressing the WCAG check in e2e/grants.spec.ts that expects #audit-date-from / #audit-date-to with associated <label htmlFor>. DatePickerCell now takes an inputId prop, the wrapper effect stamps it onto the click-ui-rendered input alongside the tabIndex tweak, and both visible labels are real <label> elements bound by htmlFor. The not-found drawer skipped its Radix exit animation because the component returned null once notFound and entry both went falsy on close. Mirroring the existing latestEntry latch, a latestNotFound state keeps the not-found dialog mounted with open=false long enough for data-state="closed" keyframes to play out. * ♿ fix: audit-log filter page sync, drawer copy errors, stale not-found state Three closely-related polish fixes: Filter changes that bypassed the debouncer (action toggles, date pickers, date clear, target type) were updating the query filters in the same render while the deferred useEffect that reset currentPage to 1 ran on the next tick. That window produced one fetch with new filters at an old page offset, occasionally rendering a false empty state until the second fetch landed. Each filter callsite now calls resetToFirstPage() inline alongside its state setter so the query key changes once with currentPage=1. CopyableMono already exposed an onCopyFailed callback for clipboard errors, but the drawer never wired it to any of its five copy controls (timestamp, actor ID, target ID, capability, entry ID). A clipboard rejection from any of those was therefore silent. Added an onCopyFailed prop to AuditLogDetailDrawer and threaded the existing announce(com_a11y_copy_failed) pattern through from AuditLogTab, matching the permalink-copy treatment. The latestNotFound latch added for the close-animation fix did not clear latestEntry, so opening a missing-entry permalink after a valid entry left the drawer rendering stale content under a not-found URL. The mount-tracking effect now clears latestEntry whenever notFound flips true so the not-found branch (gated on !latestEntry) takes precedence. * 🛡️ feat: surface READ_AUDIT_LOG in the grants editing UI The forward-compat shim added READ_AUDIT_LOG_CAPABILITY so the audit tab could check the capability while pinned to data-schemas 0.0.52, but the grants CapabilityPanel renders rows from CAPABILITY_CATEGORIES, and the upstream 0.0.52 array does not yet list READ_AUDIT_LOG under the System category. Without that row no admin could grant or revoke the capability from the UI, so only seed-time holders could ever reach the new tab. Wrap the upstream CAPABILITY_CATEGORIES so the System category includes READ_AUDIT_LOG_CAPABILITY whenever it is missing. Once the dep moves to 0.0.53+ the upstream array already contains the entry and the dedupe pass turns the wrapper into a no-op, so it stays safe to keep around until the shim itself is dropped. * ♿ fix: collapse stale counts on audit-log fetch error + open audit tab on entryId When the paginated audit-log query fails, the table swaps in an error EmptyState but the surrounding shell kept rendering the pagination controls and entry-count footer derived from data still held by keepPreviousData (the last successful page). Users saw "showing 47 of 482 entries" with working pagination next to a "failed to load" message — mixed signals. The footer and Pagination now read from displayTotal/displayTotalPages, both collapsed to zero/one whenever isError is true. The page-clamp effect still references the live totalPages so the user's currentPage is preserved across transient failures. The screen-reader announcement effect now also short-circuits on isError so it does not say "X entries match the current filters" while the table is showing a failure message. The filter signature is left untracked on error so a retry that succeeds will announce. Deep-link permalinks of the form /grants?entryId=abc landed with no tab parameter, so the route defaulted to management and AuditLogTab never mounted — the drawer the PR description promises to open would not appear. The route now falls back to the audit-log tab whenever an entryId is present; GrantsPage already redirects to management for users without READ_AUDIT_LOG so the new default does not strand anyone. * ♿ fix: gate audit-log export on displayTotal + keep drawer open during deep-link fetch The export button still gated on raw `total`, which keepPreviousData lets stay non-zero across a failed refetch. Footer and pagination already use displayTotal, so the Export button could remain enabled while the table is showing the error EmptyState and "0 entries." Switched the disabled gate to displayTotal so all error-state surfaces agree. A cold load to /grants?entryId=abc — or any case where the deep-linked row is not on the current page — left the drawer closed while the single-entry fetch ran, then flicked it open when the fetch resolved. The drawer now stays mounted whenever an entryId is in the URL, with a new `loading` prop driving a LoadingState shell inside the same panel chrome until the entry either loads or is confirmed not-found. Closing the drawer still removes entryId from the URL, so the new open=!!entryId gate exits cleanly. * ♿ fix: clear drawer latches when entryId switches mid-fetch The drawer holds latestEntry / latestNotFound across renders so Radix can play its exit animation against the last good content. When the URL entryId switches to a different row whose fetch is in flight, the parent passes entry=null with loading=true — but the prior effect only updated latches on truthy entry or notFound, so latestEntry stayed at the previous row and the loading shell (gated on !latestEntry) never rendered. The user saw the previous entry's content under the new entryId until the fetch resolved. Extended the latch effect so loading=true with both entry and notFound falsy clears both latches, letting the loading shell render against the new entryId. Close-after-fetch transitions still work because the loading flag drops to false before the close keyframes. * ♿ fix: hide audit-log count footer on fetch error The displayTotal-on-error path stopped the Pagination from disagreeing with the EmptyState, but the aria-live count footer kept rendering "No entries" against the same zeroed total, contradicting the "failed to load" error the table was showing. Screen-reader users heard "No entries" while sighted users saw a failure message. Conditionally rendering the footer only when isError is false removes the mixed-signal announcement; the visible table already carries the error state so there is no information loss. * ♿ fix: drop stale entryOnPage on list error + surface deep-link fetch errors Two error-path follow-ups to the displayTotal work: entryOnPage was reading directly from pageEntries, which keepPreviousData keeps populated across a failed refetch. With the list error EmptyState showing in the table, the detail drawer was still resolving the deep-linked id against that stale slice and rendering the old row. Gated entryOnPage on !isError so the single-entry fetch (independent of the list query) becomes the only data source while the list is in a failure state. Non-404 fetch failures for a deep-linked entryId left selectedEntry null and entryNotFound false (the latter requires isSuccess plus entry===null for a positive 404 result). Drawer rendered with open=true but no content branch, returning null while entryId sat in the URL with no panel and no close affordance. Added a loadError prop that derives from entryFetch.isError, mirrored the latestNotFound latch for animation parity, and a new error branch in the drawer that renders the same panel chrome with a com_audit_detail_load_error message + close buttons. The user can now dismiss the drawer and clear the bad URL. * 🌐 fix: send canonical actorQuery/targetQuery audit-log filters The LibreChat backend treats actorId/targetPrincipalId as deprecated aliases for actorQuery/targetQuery (substring matches on actorName / targetName), logging a warning per request and slated for removal in a future release. Renamed the BFF schema fields and the buildFilters emit-path to the canonical names so the proxy stays off the deprecation path and survives the alias removal. UI state and labels keep their internal id-flavored names because the wire format is the only thing the backend sees. * ✨ feat: Migrate audit-log UI to the data-schemas 0.0.54 contract The LibreChat audit-log backend (PR #13087) is now merged and published as @librechat/data-schemas@0.0.54, which reshapes the audit surface into a general-purpose event log. Update the UI to consume it. - Bump @librechat/data-schemas ^0.0.52 → ^0.0.54 and librechat-data-provider ^0.8.503 → ^0.8.506 (0.0.54 imports BASE_ONLY_CONFIG_SECTIONS from it). - BFF (server/capabilities.ts): rebuild the audit filter + entry + page zod schemas to the new shape — namespaced actions (grant.assigned/grant.removed), structured actor/target/integrity, metadata/context, category/outcome/severity /actorType/targetType facets, cursor, and nextCursor. Enums sourced from the data-schemas constant arrays so they can't drift. - AuditLogTab + AuditLogDetailDrawer: read the new nested fields (actor.name, target.name/type/id, metadata.capability via auditCapability helper), send targetType (was targetPrincipalType), and use the namespaced action values. - Collateral from the version bump (unrelated to audit, required to compile): role.ts adds the new SHARED_LINKS permission type; CapabilityPanel casts the capability key; config.ts normalizes parseImportedYaml's appConfig union for the changed AppService return type. tsc + eslint clean; auditLogUtils tests updated and green. * 🛠️ fix: Address Codex review on the audit-log UI migration - R1 (P1): stop importing runtime values from the @librechat/data-schemas barrel in client-reachable code. server/capabilities.ts is reached by AuditLogTab via @/server, and the main barrel pulls Node-only modules (AGENTS.md). Move the audit enum literals to a client-safe @/constants/audit module (type-checked against the package via erased type-only imports + satisfies); capabilities.ts now sources them from @/constants. - R2 (P2): surface the new SHARED_LINKS permission type in the role editor. Adding it to PERMISSION_TYPE_SCHEMA defaulted/persisted it but RolePermissionsPanel's hard-coded PERMISSION_TYPE_ORDER omitted it, so admins couldn't grant/revoke it. Added it to the order plus com_perm_type/desc_SHARED_LINKS labels. tsc + eslint clean; audit utils tests green. * 🛠️ fix: Address Codex round-2 on the audit-log UI - R6 (P2, security): constrain the single-entry fetch id to the backend ObjectId shape. A crafted ?entryId=export.csv was proxied to a sibling audit-log sub-route; the BFF validator now rejects non-ObjectId ids and the client query only fires for a well-formed id. - R4 (P2): surface the MCP_SERVERS CONFIGURE_OBO permission (new in the bumped data-provider) in PERMISSION_TYPE_SCHEMA + a com_perm_CONFIGURE_OBO label, so admins can grant it and "select all" no longer drops it. - R5 (P3): clear the audit-entry deep link (entryId) when switching away from the audit-log tab, so it doesn't silently reopen or linger in Management URLs. tsc + eslint clean; audit utils tests green. * 🛠️ fix: Address Codex round-3 on the audit-log UI - R7 (P1): scope audit queries to category=grant. The LibreChat audit endpoint is now a general-purpose event log, but this UI/parser only handles grant rows (adminAuditLogEntrySchema.action is grant.assigned/removed only). Force category=['grant'] in both the page and export server fns so a non-grant event can't reach the strict parser and error the whole tab. - R9 (P3): preserve the audit-log tab when closing a bare ?entryId= permalink — the close path now keeps tab=audit-log so users aren't bounced to Management. - R8 (P2): align the Grants e2e selectors with the actual export button name ("Export all matching", was searching for "export as csv"). tsc + eslint clean; audit utils tests green. * 🛠️ fix: Address Codex round-4 on the audit-log UI - R11 (P2): declare @radix-ui/react-dialog as a direct dependency (it was only an overrides pin + transitive via click-ui). AuditLogDetailDrawer imports it at runtime, so pnpm strict / prod installs need it declared. - R12 (P3): keep the entry-id validity guard in AuditLogTab. The useQuery override was replacing the option's enabled, dropping the only client-side check that blocks a malformed ?entryId= from reaching the server fn. Extracted a shared isAuditEntryId helper in @/constants/audit (client-safe) used by both the query option and the component override (and the BFF reuses it too). tsc + eslint clean; audit utils tests green. * 🩹 fix: Stream audit-log CSV export instead of buffering it (Codex R3) The export server function read the backend's streamed CSV fully into memory and returned it inside a JSON server-function payload, defeating the backend's streaming/backpressure and risking BFF memory / server-fn payload limits on large exports (capped at MAX_AUDIT_EXPORT_ROWS = 100k). This TanStack Start version has no file-based server routes (no createServerFileRoute) and dev/e2e run on vite, so a Bun route in server.ts wouldn't be portable. Instead, exportAuditLogServerFn now returns a `Response` that pipes the backend's `export.csv` body straight through (a server fn that returns a Response is passed through verbatim). The BFF no longer buffers the whole file or wraps it in JSON, preserving backend streaming/backpressure; auth + grant-scoping are unchanged (requireCapability + category=grant). The client turns the streamed Response into a Blob download. Tests: new src/server/auditLogExport.test.ts covers the authorized streaming path (text/csv attachment, grant-scoped backend call, body piped through) and the unauthorized rejection. tsc + eslint clean. * 🛠️ fix: Address Codex round-6 on the audit-log UI - R13 (P2): treat a malformed ?entryId= as not-found. The isAuditEntryId guard disables the fetch, leaving entryNotFound/loading/loadError all false, so the drawer latched a previously-opened entry under the bad permalink. entryNotFound is now true for an invalid id, so the drawer clears instead of showing stale forensic details. - R14 (P2): restore the principal-type badge hook on EditCapabilitiesDialog. The badge refactor dropped principalConfig.badgeClass, leaving the .badge-role/ group/user CSS dead and breaking the e2e badge assertion; re-applied it via the click-ui Badge className. tsc + eslint clean. * fix(deps): pin librechat-data-provider to 0.8.505 (CI dayjs ESM bug) librechat-data-provider@0.8.506's ESM build (dist/index.mjs) imports `dayjs/plugin/utc` / `dayjs/plugin/timezone` without a file extension. dayjs@1.11 ships no `exports` map, so under strict Node ESM (vitest's externalized deps) that bare specifier fails to resolve (ERR_MODULE_NOT_FOUND), taking down 9 test suites that transitively import data-provider. 0.8.505 imports only the dayjs root, so it's unaffected — and it still exports every symbol @librechat/data-schemas @0.0.54 re-imports (BASE_ONLY_CONFIG_SECTIONS, skillSyncConfigSchema, RetentionMode, PrincipalModel, …), so the audit-log types are intact. Pinned exactly (not ^) because ^0.8.505 would re-resolve to the broken 0.8.506. Dropped com_config_field_pinned/_promptCacheTtl (0.8.506-only config fields). Matches main's data-provider version. Verified: tsc clean, eslint --max-warnings 0 clean, vitest 727/727 natively (no resolver workaround). * chore(deps): move librechat-data-provider to ^0.8.507 0.8.507 fixes the ESM dayjs-plugin resolution bug that forced the exact 0.8.505 pin (LibreChat #13851). Back to a caret range now that the broken 0.8.506 is below the floor. Re-adds com_config_field_pinned / _promptCacheTtl (config fields present in 0.8.50x but not 0.8.505). Verified: tsc clean, eslint --max-warnings 0 clean, vitest 727/727 natively (no dayjs resolver workaround needed). * fix(audit-log): preserve base path in copied permalinks handleCopyPermalink built `${origin}/grants?...`, dropping a configured VITE_BASE_PATH (e.g. /adminpanel) so links 404 on subpath deployments. Extract a pure buildEntryPermalink(id, origin, basePath) helper that prefixes the normalized base path (matching the __root.tsx favicon precedent) and unit-test it. Addresses Codex P2. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
1 parent 6bde5c3 commit e7ced59

28 files changed

Lines changed: 2099 additions & 273 deletions

bun.lock

Lines changed: 25 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/grants.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ test.describe('Grants page - Audit Log tab', () => {
148148
});
149149

150150
test('export button is present', async ({ page }) => {
151-
const exportBtn = page.getByRole('button', { name: /export as csv/i });
151+
const exportBtn = page.getByRole('button', { name: /export all matching/i });
152152
await expect(exportBtn).toBeVisible();
153153
});
154154

@@ -180,7 +180,7 @@ test.describe('Grants page - Tab switching', () => {
180180
await page.waitForTimeout(300);
181181

182182
await expect(page).toHaveURL(/tab=audit-log/);
183-
await expect(page.getByRole('button', { name: /export as csv/i })).toBeVisible();
183+
await expect(page.getByRole('button', { name: /export all matching/i })).toBeVisible();
184184

185185
const mgmtTab = page.getByRole('tab', { name: /management/i });
186186
await mgmtTab.click();

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
},
2323
"dependencies": {
2424
"@clickhouse/click-ui": "0.2.0-rc.4",
25-
"@librechat/data-schemas": "^0.0.53",
25+
"@librechat/data-schemas": "^0.0.54",
26+
"@radix-ui/react-dialog": "1.1.15",
2627
"@tailwindcss/vite": "^4.1.18",
2728
"@tanstack/react-devtools": "0.10.0",
2829
"@tanstack/react-query": "5.95.2",
@@ -41,7 +42,7 @@
4142
"i18next-browser-languagedetector": "^8.2.1",
4243
"input-otp": "^1.4.2",
4344
"js-yaml": "^4.1.1",
44-
"librechat-data-provider": "^0.8.505",
45+
"librechat-data-provider": "^0.8.507",
4546
"lucide-react": "^0.545.0",
4647
"prom-client": "^15.1.3",
4748
"react": "^19.2.0",

server.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,43 @@ function getCacheHeaders(filePath: string): Record<string, string> {
4141
return {};
4242
}
4343

44+
// 'unsafe-inline' in style-src is required because Tailwind 4 + click-ui inject inline styles at runtime.
45+
// TanStack Start's SSR injects an inline `<script type="module">import("/_build/...")</script>` to
46+
// boot the client. Without a nonce or 'unsafe-inline' for script-src, browsers will block hydration.
47+
// Threading a per-request nonce through TanStack Start's manifest is non-trivial; until that wiring
48+
// lands we ship the policy as report-only so it surfaces violations in dev tooling without breaking
49+
// hydration in prod. Set ADMIN_PANEL_CSP_ENFORCE=true to switch back to enforcement (only safe once
50+
// the nonce path is in place).
51+
const CSP_VALUE = [
52+
"default-src 'self'",
53+
"script-src 'self'",
54+
"style-src 'self' 'unsafe-inline'",
55+
"img-src 'self' data: blob:",
56+
"font-src 'self' data:",
57+
"connect-src 'self'",
58+
"object-src 'none'",
59+
"frame-ancestors 'none'",
60+
"base-uri 'self'",
61+
"form-action 'self'",
62+
].join('; ');
63+
64+
const CSP_ENFORCE = process.env.ADMIN_PANEL_CSP_ENFORCE === 'true';
65+
const CSP_HEADER_NAME = CSP_ENFORCE
66+
? 'Content-Security-Policy'
67+
: 'Content-Security-Policy-Report-Only';
68+
69+
function applySecurityHeaders(headers: Headers): void {
70+
const contentType = headers.get('Content-Type') ?? '';
71+
if (!contentType.toLowerCase().startsWith('text/html')) return;
72+
headers.set(CSP_HEADER_NAME, CSP_VALUE);
73+
headers.set('X-Content-Type-Options', 'nosniff');
74+
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
75+
headers.set('X-Frame-Options', 'DENY');
76+
if (process.env.NODE_ENV === 'production') {
77+
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
78+
}
79+
}
80+
4481
type Handler = { default: { fetch: (req: Request) => Promise<Response> } };
4582

4683
const { default: handler } = (await import(SERVER_ENTRY.href)) as Handler;
@@ -66,11 +103,11 @@ async function buildStaticRoutes(): Promise<Record<string, (req: Request) => Pro
66103
const cache = getCacheHeaders(path);
67104
const routePath = `${BASE_PATH}/${path}`;
68105
routes[routePath] = (req) =>
69-
withHttpMetrics(
70-
req,
71-
routePath,
72-
() => new Response(file, { headers: { 'Content-Type': file.type, ...cache } }),
73-
);
106+
withHttpMetrics(req, routePath, () => {
107+
const res = new Response(file, { headers: { 'Content-Type': file.type, ...cache } });
108+
applySecurityHeaders(res.headers);
109+
return res;
110+
});
74111
}
75112
return routes;
76113
}
@@ -92,6 +129,7 @@ const server = Bun.serve({
92129
for (const [k, v] of Object.entries(NO_CACHE)) {
93130
patched.headers.set(k, v);
94131
}
132+
applySecurityHeaders(patched.headers);
95133
return patched;
96134
},
97135
},

0 commit comments

Comments
 (0)