* feat: Add GitHub skill sync
* fix: Address GitHub skill sync CI
* fix: Harden GitHub skill sync review paths
* fix: Prevent overlapping skill sync runs
* fix: Address GitHub skill sync review findings
* fix: Satisfy Git ref lint rule
* fix: Address GitHub sync review follow-ups
* fix: Match skill frontmatter closing fence
* fix: Address GitHub sync review cycle
* fix: Address GitHub sync review follow-ups
* fix: Harden GitHub skill sync worker
* fix: Format GitHub sync rollback log
* fix: Address GitHub sync review feedback
* fix: Format skill import parse handling
* fix: Coerce scalar skill frontmatter and correct scheduler timer clear
- parse: coerce numeric/boolean name and description scalars to strings instead of dropping them to empty (restores pre-refactor behavior; preserves absent-vs-empty distinction for the when-to-use fallback)
- scheduler: clear the setTimeout handle with clearTimeout rather than clearInterval
- test: cover non-string scalar frontmatter coercion
* fix: Tolerate trailing whitespace after SKILL.md opening frontmatter fence
extractFrontmatterBlock required the opening fence to be exactly '---\n', so an opener with trailing spaces/tabs (e.g. '--- \n') silently dropped all frontmatter even though the closing-fence regex already tolerates it. Match the opener with /^---[ \t]*\n/ for symmetry. Addresses Codex P3 (parse.ts:24).
* feat: Run GitHub skill sync under a per-source tenant context
Under TENANT_ISOLATION_STRICT, the sync ran with no async tenant context, so the tenant-isolation mongoose hooks threw on every Skill/SkillFile/AclEntry operation; in non-strict mode synced skills were written tenant-less and never matched tenant-scoped reads. Add an optional per-source tenantId to the skillSync config; when set, each source sync runs inside tenantStorage.run({ tenantId }) so skills, files, and public ACL grants are created and listed within that tenant, and the skill row is stamped with the tenantId for correct dedup. Sources without tenantId keep the prior single-tenant behavior. Avoids runAsSystem. Addresses Codex P2 (sync.js:70).
Lock/status/credential bookkeeping stays outside the tenant context (those collections are intentionally global).
* test: Restore dropped tenant-context coverage for GitHub skill sync
The prior commit shipped the getTenantId import in github.spec.ts without the tenant tests that use it (lost in an interrupted edit), which failed the eslint --max-warnings=0 CI job on an unused import. Restore both github.spec.ts tenant tests (tenant-scoped run stamps tenantId and executes inside the tenant ALS context; no-tenant run stays ambient) and the two config-schemas tenant tests (accepts tenantId, rejects __SYSTEM__).
* test: Restore dropped github.spec tenant-context tests
The previous commit's github.spec.ts edit did not apply (anchor mismatch), so the getTenantId import remained unused and failed eslint --max-warnings=0. Add the two tenant tests that use it: a tenant-scoped run stamps tenantId and executes inside the tenant ALS context, and a no-tenant run stays ambient.
* feat: Scope synced skill author to tenant and harden tenant-context sync
Addresses the latest Codex review on the per-source tenant change:
- makeSourceAuthorId now folds tenantId into the synthetic author hash so the
same source mirrored into different tenants gets distinct author ids (clearer
audits, no cross-tenant author collisions). Single-tenant author ids stay
stable (suffix omitted when tenantId is absent).
- syncSourceInTenantContext uses an async callback per the tenant-context
contract so the ALS store propagates across awaited Mongoose calls.
- Tests: same-source/different-tenant yields distinct authors; mirror cleanup
is scoped to the source and deletes only its absent-upstream skills.
* fix: Repair tsc error and guard external edits in github skill sync
- Fix TS2352 in github.spec mirror-cleanup test: build the existing-skill mock via makeSkill with authorName instead of an under-typed 'as CreateSkillInput' cast (this was the failing TypeScript CI check on f00ce3c5a).
- 808: commitExistingRemoteSkillAfterFileSync re-reads to clear our own file-sync version bumps, but now compares refreshed content against the pre-sync snapshot (body/name/description/always-apply) and throws SKILL_CONFLICT on a concurrent external edit instead of overwriting it.
* docs: Note skillSync source tenantId is effectively immutable
Changing/adding/removing a source's tenantId orphans previously mirrored skills in the old tenant (a tenant-scoped sync cannot clean another tenant's data without runAsSystem, which is intentionally avoided).
* fix: Key GitHub skill upstream identity on source id and path only
Addresses Codex finding (github.ts:217): makeUpstreamId previously included owner/repo, so repointing a source to a renamed or replacement repository (same source id) changed the upstreamId, made findSkillBySourceIdentity miss the existing mirror, and then collided on the (name, author, tenantId) uniqueness constraint — leaving the source stuck failing. Identity now keys on the stable source id + root path only. The feature is unreleased, so there is no stored-id migration. Updated spec upstreamId fixtures to the new format; the existing ref-independent identity test now also covers repo moves.
* fix: Scope GitHub skill mirror deletion to the source tenant
Addresses Codex P1 (github.ts:1047/1057): an ambient source (no tenantId) runs listSkillsBySource without tenant context, which under non-strict isolation returns github-synced skills across all tenants. The mirror-deletion pass then treated other tenants' skills as absent-upstream and could delete them. Filter existingSyncedSkills to rows whose tenantId matches the source's configured tenantId (absent = its own ambient bucket) before deleting, so a sync never removes another tenant's mirrored skills. Covered by a test where an ambient run leaves a tenant-b-owned skill untouched.
* fix: Apply tenant-scoped mirror deletion implementation
The prior commit (75ccfa3fc) added the test but the source change to github.ts was lost in an interrupted edit, leaving a failing test with no implementation. This adds the actual guard: the mirror-deletion pass skips skills whose tenantId does not match the source's configured tenantId (absent = ambient bucket), so an ambient source whose listSkillsBySource returns cross-tenant rows under non-strict isolation cannot delete another tenant's mirrored skills.
* fix: Resolve global access role outside tenant context for synced skill grants
Addresses Codex P2 (github.ts:1166): default access roles (incl. skill_viewer) are seeded globally with no tenantId under runAsSystem, but a tenant-scoped sync wraps ensurePublicViewer in the source's tenant context. The PermissionService grantPermission resolved the role via a tenant-isolated AccessRole query, so the global role did not match and tenant-scoped syncs failed with 'Role skill_viewer not found'. The sync adapter now resolves the role inside runAsSystem (matching the global seed) and writes the ACL entry in the active tenant context, so the AclEntry is tenant-scoped (visible to tenant users) while the role lookup still succeeds. Covered by service tests for the resolve-vs-write split and the missing-role failure.
* fix: Strip placeholder frontmatter booleans and check skill conflict before file sync
- 1083 (github.ts:759): toCleanFrontmatter now drops a non-boolean always-apply (e.g. the 'always-apply:' / 'always-apply: # TODO' placeholder, which js-yaml yields as null). The boolean is already captured in the dedicated alwaysApply field; persisting null left ambiguous frontmatter on the synced skill.
- 1080 (github.ts:1057): for an existing mirrored skill, check for an external content edit (via getSkillById + hasExternalSkillEdit) BEFORE syncSkillFiles mutates the bundled files, so a concurrently edited skill fails fast with SKILL_CONFLICT without partial file rewrites. The post-file-sync check still guards edits that land during the file sync window.
Tests: placeholder always-apply is dropped from synced frontmatter; concurrent-edit conflict leaves files unmutated (no upsert/delete).
* fix: Harden GitHub skill sync review paths
* fix: Reuse moved GitHub skill mirrors
* fix: Scope GitHub sync identity conflicts
* test: Fix GitHub sync conflict mock typing
* fix: Support nested env-backed skill sync
* fix: Keep skill sync config base-only
* fix: Scope GitHub skill identity lookup by tenant
* fix: Harden GitHub skill sync admin gates
* fix: Guard existing skill sync permission grants
* feat: Trigger skill sync from resolved config
* fix: Scope resolved skill sync by tenant
* test: Allow manual skill sync status tenant scoping
* refactor: Extract skill sync trigger orchestrator
* test: Complete orchestrator status fixture
* chore: Bump data provider version
* fix: Restrict skill sync server credentials
* test: Complete admin skill sync status fixtures
* fix: tighten skill sync trigger safeguards
* fix: preserve alwaysApply skill sync alias
* chore: sort skill sync imports
* fix: preserve skill sync request scope
* fix: harden skill sync review edges
* refactor: move skill sync admin access to api package
* fix: add skill sync declaration return types
* fix: satisfy skill sync type checks
* fix: resolve codex skill sync review findings
* fix: harden skill sync review edges
* fix: resolve codex skill sync edge findings
* fix: satisfy API declaration build after rebase
Adds an opt-in showOnLanding flag to model specs. When set, the chat
landing shows the spec's label and description in place of the
time-of-day greeting; specs without the flag are unaffected, so existing
deployments see no behavior change. HTML-valued descriptions (inline
icons + markup) render sanitized via the shared config-HTML sanitizer
with a new media tag/attribute allowlist, both on the landing and in
model selector items. Excludes e2e specs from the typed client lint
block so staged e2e files no longer fail pre-commit with 'file not
found in project'.
The as-any casts existed only to reach the protected Keyv cache and
private localSnapshotExpiry members. TypeScript's element-access escape
hatch provides the same access fully typed, so the casts and their
eslint-disable directives are unnecessary. The directives also reported
as unused under configs that relax no-explicit-any for test files.
* 🔐 fix: Resolve template vars and respect custom Authorization on model fetch
The custom-endpoint model fetch path in `fetchModels` had two bugs that
silently broke per-user authentication on `GET /v1/models`:
1. Template variables in the configured `headers:` block were not
substituted on the OpenAI-compatible branch. Only the Ollama branch ran
`resolveHeaders`, so placeholders like `{{LIBRECHAT_OPENID_ID_TOKEN}}`
were forwarded as literal strings on every other endpoint.
2. After spreading the (unresolved) headers into the request, the code
unconditionally executed
`options.headers.Authorization = \`Bearer ${apiKey}\`` and clobbered any
`Authorization` the operator had set in `headers:`.
Combined, these meant a config like
```yaml
endpoints:
custom:
- name: "MyProxy"
apiKey: "${MY_API_KEY}"
headers:
authorization: "Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}"
```
sent `Authorization: Bearer ${MY_API_KEY}` on `/v1/models` instead of the
user's resolved JWT — even with `OPENID_REUSE_TOKENS=true` set. Auth-aware
proxies (e.g. LiteLLM with team-based JWT auth) therefore could not return
a per-user filtered model list.
This change runs `headers` through `resolveHeaders` (mirroring the Ollama
branch) and only falls back to the apiKey-based default when the resolved
headers do not already supply an `Authorization` (case-insensitive). All
other endpoints behave unchanged: when no `Authorization` is configured,
the existing `Bearer ${apiKey}` default still applies.
Tests added:
- Template variables in custom headers are resolved on the OpenAI path.
- A config-supplied `Authorization` overrides the apiKey default.
- The override check is case-insensitive (`authorization` works too).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 🔐 fix: Address review — import order, P1 token leak guard, P2 token-config path
- Fix sort-imports drift in `models.ts` and `custom/initialize.ts`.
- P1: in `loadConfigModels` (`config/models.ts`), do not forward
`endpointHeaders` to `fetchModels` when `baseURLIsUserProvided`.
Configured templates such as `Authorization: Bearer
{{LIBRECHAT_OPENID_ID_TOKEN}}` would otherwise resolve and be sent to a
destination the user controls — leaking the user's identity token.
Header overrides remain in place when only the apiKey is user-provided
(admin-trusted base URL).
- P2: in `initializeCustom` (`custom/initialize.ts`), the token-config
fetch path now forwards `headers` and `userObject` to `fetchModels`
(mirroring the auth-aware behaviour), with the same `userProvidesURL`
guard. Additionally, when `endpointConfig.headers` is set the model
cache is skipped to avoid a per-user filtered response leaking across
users; token-config caching was already user-keyed when key/URL are
user-provided.
Tests added:
- `config/models.spec.ts` (new): verifies the P1 guard — headers are
dropped when the base URL is user-provided, and forwarded when only the
apiKey is user-provided.
- `custom/initialize.spec.ts`: three cases for the P2 path covering header
forwarding to admin-trusted base URLs, header drop on user-provided
base URLs, and absence of `skipCache` when no headers are configured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 🔐 fix: Scope model + token-config caches when user-bound headers are forwarded
Two follow-up fixes from the second review pass:
P1.1 (`fetchModels` / `models.ts`): the MODEL_QUERIES cache is keyed by
baseURL+apiKey only. When callers forward headers containing template
variables that resolve against the current user (e.g. `Authorization:
Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}`), one user's filtered list could be
served to the next request that happens to share the same baseURL+apiKey.
`shouldCache` now skips the cache whenever both `headers` and `userObject`
are supplied — that's the unambiguous signal the response is being
resolved against a specific user identity. Existing callers that pass
neither (fetchOpenAIModels, fetchAnthropicModels) keep their cache.
P1.2 (`initializeCustom` / `custom/initialize.ts`): the surrounding
tokenConfigCache uses `tokenKey === endpoint` when key+URL are
admin-configured. With user-bound headers forwarded, the first user's
token config could be cached for the shared endpoint and served to other
users until TTL. `tokenKey` is now also user-scoped when
`endpointConfig.headers` will be forwarded (i.e. base URL is
admin-trusted, so the security guard leaves headers in place).
Also removed the explicit `skipCache: !!endpointConfig.headers` from the
fetchModels call in initializeCustom — the new fetchModels-level rule
covers it uniformly across both call sites.
Tests added:
- models.spec.ts: cache skipped on `headers + userObject`; cache used
when only one of them is supplied (existing callers unaffected).
- initialize.spec.ts: `tokenKey` is `${endpoint}:${userId}` when headers
will be forwarded, and `endpoint` (unscoped) when no headers are
configured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 🔐 fix: Include header fingerprint in in-request model fetch coalescing key
`loadConfigModels` coalesces concurrent fetches for endpoints that share
the same admin-trusted `${BASE_URL}__${API_KEY}` via `fetchPromisesMap`.
With per-endpoint `headers:` overrides — including templates that resolve
against the current user — that key is too coarse: two custom endpoints
sharing a proxy URL/key but configuring different headers (e.g. distinct
`X-Tenant` values, or different static `Authorization` strings) would
share a single fetch promise, and the first endpoint's filtered response
would be returned for the second endpoint within the same request.
Fix: include a stable SHA-256 fingerprint of the configured headers in
the coalescing key. Endpoints that genuinely share `baseURL + apiKey +
headers` still share one fetch (preserves the existing optimisation);
endpoints that differ in headers each get their own fetch.
Test added in `config/models.spec.ts`:
- Two endpoints sharing baseURL+apiKey but with different headers result
in two `fetchModels` calls, each carrying the right headers.
- Two endpoints sharing baseURL+apiKey AND identical headers still
coalesce into a single `fetchModels` call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 🤫 fix: Silent MCP OAuth Refresh on Mid-Session 401
Avoids the hourly interactive re-auth prompt when an MCP server
(e.g. Azure Entra ID) returns 401 mid-session by attempting a refresh
token exchange first, and only falling back to the interactive OAuth
flow when no refresh token is stored or the refresh server rejects it.
Resolves#13364.
* fix: Use distinct flow type for silent token refresh to avoid cache hit
Addresses the Codex review on PR #13369: `attemptSilentTokenRefresh` was
reusing the `'mcp_get_tokens'` flow type, so
`FlowStateManager.createFlowWithHandler` would short-circuit and return
the same tokens cached by an earlier `getOAuthTokens` call — the very
tokens the server just rejected — without executing the forced-refresh
handler.
Switch silent refresh to the distinct `'mcp_force_refresh_tokens'` flow
type so coalescing still works but stale `mcp_get_tokens` cache entries
are not reused. After a successful refresh, invalidate the
`mcp_get_tokens` flow cache so the next `getOAuthTokens` call reads the
freshly persisted tokens from storage rather than the stale cached
value.
Add a regression test that simulates the real
`FlowStateManager.createFlowWithHandler` cache-hit behavior for
`mcp_get_tokens` and verifies the silent refresh handler still runs and
returns the freshly refreshed tokens.
* fix: Address Codex round-2 review on silent MCP OAuth refresh
Three follow-up findings from Codex on PR #13369:
1. The new `mcp_force_refresh_tokens` flow type was itself cached by
`FlowStateManager.createFlowWithHandler`, so a subsequent 401 within
the refreshed token's `expires_at` could re-serve the just-rejected
token without ever re-running the refresh handler.
2. The factory's `oauthRequired` listener was removed immediately after
the initial `attemptToConnect` succeeded, so a real mid-session 401
emitted by `MCPConnection.connectClient` during transport recovery
had no listener — the OAuth handled-promise would simply time out
instead of triggering the silent refresh.
3. Routing the silent refresh through a distinct flow type broke
coalescing with the `mcp_get_tokens` lock used by `getOAuthTokens`,
letting two paths concurrently redeem the same stored refresh token.
For providers that rotate refresh tokens (e.g. Azure Entra) the
second redemption is rejected, kicking the user back into interactive
OAuth despite a successful refresh elsewhere.
Resolution:
- Drop `FlowStateManager` from the silent-refresh path entirely. Replace
with a process-local `inflightSilentRefreshes` Map keyed by
`userId:serverName` that holds only the in-flight Promise (no cached
result), so every fresh 401 after settlement triggers a fresh
redemption while concurrent 401s for the same user/server still share
one redemption.
- Stop calling `cleanupOAuthHandlers()` on successful initial connect,
keeping the OAuth handler attached for the connection's lifetime so
mid-session 401s actually reach `attemptSilentTokenRefresh`.
- Add a regression test reproducing the stale-cache scenario by faking
the `mcp_get_tokens` cache hit and asserting silent refresh still runs
against storage and returns the fresh tokens.
- Add a coalescing test asserting two concurrent oauthRequired events
for the same user/server result in a single `forceRefreshTokens` call.
- Clear `inflightSilentRefreshes` in `beforeEach` to prevent
cross-test leakage; switch the silent-refresh test mocks to
`mockResolvedValueOnce` / `mockImplementationOnce` so leftover mock
state cannot leak into later test cases.
Acknowledged remaining gap: the silent refresh still races
`getOAuthTokens`'s `mcp_get_tokens` flow when both run concurrently
(narrow window when an existing connection's local `expires_at` is
still valid but the server invalidated the token, and a new connection
is being created in parallel). The race is self-healing on the next
401 and documented inline.
* fix: Address Codex round-3 review on silent MCP OAuth refresh
Three more findings from Codex on PR #13369:
1. The in-flight silent-refresh promise was unbounded. If
`forceRefreshTokens()` ever hung (slow provider, dropped TCP), the
`inflightSilentRefreshes` lock stayed occupied forever and every
later 401 for the same user/server joined the stuck promise instead
of starting a fresh attempt or falling back to interactive OAuth.
2. The interactive-OAuth fallback didn't invalidate the
`mcp_get_tokens` flow cache after persisting fresh tokens. For
providers that don't issue refresh tokens (so silent refresh
returns null), the old cache could still feed stale access tokens
to the next `getOAuthTokens` call until its TTL expired — causing
an immediate reconnect with the same just-rejected token.
3. When silent refresh failed, the handler fell through to
`handleOAuthRequired()` whose recent-completion fast path can
reuse a COMPLETED `mcp_oauth` flow within `PENDING_STALE_MS`. Those
cached tokens are exactly the ones the server just rejected, so
the connection would keep adopting them and looping on 401s until
the cache aged out.
Resolution:
- Wrap `runSilentRefresh()` with a 60-second `withTimeout` (well under
`connectClient`'s 120s OAuth timeout). On timeout the `.catch`
resolves to null and the `finally` clears the in-flight entry, so
the next 401 starts fresh and falls through to interactive OAuth.
- Extract two helpers — `invalidateGetTokensFlow` and
`invalidateCompletedOAuthFlow` — and call them from the right
branches: clear `mcp_get_tokens` after silent-refresh success AND
after interactive-OAuth `storeTokens`; clear the COMPLETED
`mcp_oauth` state (plus its CSRF mapping) before falling through to
interactive OAuth so the fast-reuse path can't re-serve the
rejected tokens.
- Add three regression tests: hung refresh release-the-lock under
fake timers, completed-OAuth cache invalidation pre-fallback, and
`mcp_get_tokens` invalidation after interactive token store.
* fix: Address Codex round-4 review on silent MCP OAuth refresh
Three more findings from Codex on PR #13369:
1. (P1) The silent-refresh in-flight lock keyed only by
`userId:serverName`. In multi-tenant setups where two tenants share a
userId (e.g. username-based IDs) and the same MCP server name, a
concurrent mid-session 401 from tenant B would join tenant A's
in-flight refresh and adopt tenant A's freshly minted tokens onto a
tenant-B connection — a cross-tenant credential leak.
2. (P2) `invalidateGetTokensFlow` deleted the `mcp_get_tokens` flow
state regardless of its status. When another connection was
currently in `getOAuthTokens()` (PENDING flow) and joiners were
monitoring it, the unconditional delete made those waiters see
"Flow state not found" and unnecessarily fall back to interactive
OAuth — even though fresh tokens were already being written.
3. (P2) The 60s `withTimeout` wrapping `runSilentRefresh()` only races
the promise; it does not cancel the underlying `forceRefreshTokens`
/ refresh-token HTTP request. If the request returned after a
subsequent interactive OAuth had stored newer tokens, the late
completion would `storeTokens` over the newer state. This requires
a provider that doesn't rotate refresh tokens AND a refresh slower
than 60s AND a successful interactive OAuth in that window — narrow
but real.
Resolution:
- Capture `getTenantId()` into a new `factory.tenantId` field at
factory construction time (before the OAuth handler closes over it
outside the original request's async context) and include it in the
silent-refresh lock key as `tenantId:userId:serverName`.
- `invalidateGetTokensFlow` now calls `getFlowState` first and only
deletes when `status === 'COMPLETED'`. PENDING lookups are left
alone so concurrent `getOAuthTokens` waiters via `monitorFlow` can
still settle.
- For (3), document the race as a known limitation inline. Fully
closing it requires threading an `AbortSignal` through
`MCPTokenStorage.forceRefreshTokens` and the OAuth refresh handler
to skip the late `storeTokens` after timeout — out of scope for this
PR's surgical change.
- Add `getTenantId` to the `MCPOAuthConnectionEvents` test's
`@librechat/data-schemas` mock so the factory constructor doesn't
blow up under that suite.
- Add three regression tests: per-tenant lock isolation, PENDING-state
preservation under `invalidateGetTokensFlow`, and (reused) the
existing interactive-store invalidation test now driven through
`getFlowState` returning the COMPLETED state.
* fix: Address silent MCP OAuth refresh review
Restore captured tenant context around token storage and OAuth fallback paths so mid-session callbacks do not lose tenant scope.
Thread AbortSignal through forced refresh and OAuth token requests, cap silent refresh by the connection OAuth timeout, and prevent timed-out refreshes from writing stale credentials after fallback.
Complete pending mcp_get_tokens flows with fresh tokens, add missing FlowState createdAt test fixtures, and cover the new tenant/abort/cache behaviors.
* fix: Tighten tenant-scoped MCP token refresh
Cap silent refresh by both the factory connect timeout and the connection OAuth wait timeout so fallback OAuth wins before the outer connect attempt expires.
Tenant-scope mcp_get_tokens flow ids for both token lookup and refresh invalidation, preventing cross-tenant flow completion or cache deletion when tenants share user ids and server names.
Add regression tests for the omitted initTimeout budget and tenant-prefixed token flow locks.
* fix: Reserve MCP OAuth fallback budget
* fix: Harden MCP OAuth refresh races
* fix: Keep MCP OAuth fallback route-compatible
* test: Add SDK MCP OAuth refresh repro
* fix: Address MCP OAuth refresh review findings
* fix: Address MCP OAuth tenant review findings
* fix: Close MCP OAuth route tenant gaps
* fix: Preserve MCP OAuth refresh flow guards
* fix: Avoid reprocessing MCP OAuth reauth config
* fix: Release timed-out MCP refresh locks
* fix: Release MCP OAuth request callbacks
* fix: Tenant-scope remaining MCP OAuth flow lookups
* ci: Sort imports in MCP OAuth test suites
* 🗜️ ci: Cache Dependencies and Builds in Cache Integration Tests
Port the node_modules and package-dist caching pattern from
backend-review.yml to cache-integration-tests.yml, which ran a full
npm ci (~72s) and rebuilt data-provider, data-schemas, and api on
every run. Cache keys are identical to backend-review.yml so the two
workflows share entries. Drops setup-node's npm tarball cache,
superseded by the node_modules restore, matching backend-review.yml.
* 🗜️ ci: Exercise Warm-Cache Path
* 🛡️ feat: Reject chat messages matching configured credential patterns
Adds an opt-in `messagePiiFilter` middleware mounted on the agent
chat route ahead of `moderateText`. When the configured patterns
match the user's input the request is refused with 400, so the
credential never reaches OpenAI moderation, the model, or MongoDB.
Three starter patterns ship by default and operators can subset
them or add their own regex via `customPatterns` in librechat.yaml.
* 🧪 test: Memoize compiled patterns + add middleware spec
Memoize the compiled pattern array via a WeakMap keyed by the
messagePiiFilter config object so repeat requests against the same
config skip the per-request RegExp construction. Cache entries are
released automatically when the config object itself rotates.
Adds packages/api/src/middleware/messagePiiFilter.spec.ts covering
the default-starter rejections, the starterPatterns subset and
empty-array semantics, customPatterns matching layered on top of and
in place of the starters, the no-config and empty-text pass-through
paths, and a memoization regression check.
* 🛡️ fix: Skip invalid customPattern regexes instead of crashing the request
Admin DB overrides for `messagePiiFilter.customPatterns` reach
`req.config` via `mergeConfigOverrides`, which deep-merges raw
override values without re-running `configSchema`. A typo'd regex
like `(` would slip past the YAML-load validation and throw inside
`new RegExp(...)` during `compile()`, returning 500 for every chat
request until the operator rolled the override back.
Wrapped the per-pattern compile in a try/catch that logs the
invalid pattern id + reason and skips it, so other valid patterns
(starters and other custom entries) keep filtering. Added a
regression test alongside the existing spec.
* 🛡️ feat: Extend PII filter to OpenAI-compatible and Responses agent APIs
The chat-route middleware operates on `req.body.text`, but the remote
agent API endpoints (`/api/agents/v1/chat/completions`,
`/api/agents/v1/responses`) accept the same prompt content as a
`messages` array or an `input` field. A caller using their API key
could send a credential-shaped value through either route and bypass
the configured PII filter even though they share the same agent and
model backbone the middleware is meant to guard.
Factored out `findPiiMatchInMessages`, a tolerant walker that handles
both `content: string` and `content: ContentPart[]` user-message
shapes against the same compiled, cached pattern list. Wired it into
the OpenAI-compat controller after agent lookup and into the
Responses controller right after `convertToInternalMessages`. Each
returns the endpoint's native 400 error shape
(`sendErrorResponse` / `sendResponsesErrorResponse`) with the
`message_pii_filter_block` code when a user message matches.
* 🩹 test: Add findPiiMatchInMessages to OpenAI + Responses controller mocks
The OpenAI-compat and Responses controller specs mock `@librechat/api`
with a hand-listed object. The new `findPiiMatchInMessages` export
wired into both controllers in 3ea35af9a was missing from those
mocks, so the production lookup returned undefined and the controllers
threw at request time under jest. Added the missing entries (default
mock: returns null so the handlers fall through to the existing happy
paths). All 278 agents-controller tests pass locally.
* 🧹 refactor: Namespace messagePiiFilter under messageFilter.pii + fix import order
Renames the yaml field `messagePiiFilter` to `messageFilter.pii`, the
module to `messageFilterPii`, the factory to `createMessageFilterPii`,
the type to `MessageFilterPiiConfig`, and the error code to
`message_filter_pii_block`. The wrapper `messageFilter` namespace
gives future safety filters (e.g. `messageFilter.toxicity`) a place
to plug in without restructuring the config later. The
`findPiiMatchInMessages` helper kept its name because it already
describes what it does at the value level.
Also fixes import order Danny flagged on the OpenAI-compatible and
Responses controllers: `findPiiMatchInMessages` was appended at the
bottom of two `require('@librechat/api')` destructures rather than
placed in the length-sorted slot the house style expects.
* 🧹 chore: Length-sort the general require destructure in responses.js
Reorders the general sub-group inside the `require('@librechat/api')`
destructure shortest to longest so the whole block conforms to the
length-sort rule the file's `// Responses API` sub-group already
follows. Pure reorder, no other changes.
* 🧹 chore: Length-sort the defaultConfig block in AppService
Reorders the `defaultConfig` keys in `packages/data-schemas/src/app/service.ts`
shortest-line to longest-line, with the explicit-value entries
(`mcpConfig`, `fileStrategies`, `cloudfront`) trailing the shorthand
ones. Pure reorder, no behavior change.
* 🪟 ci: Shard Windows Frontend Unit Tests
Mirror the 4-way jest sharding the Ubuntu frontend test job already
uses onto the Windows job, which currently runs the whole client suite
in a single 20-minute job. Also drops the `--verbose` flag, which npm
consumed itself (it preceded `--`) and only raised npm's own log level.
* 🪟 ci: Trigger Frontend Tests on Workflow Changes
* 🧹 fix: Close Leaked Redis Clients in Cache Integration Tests
Importing `redisClients` constructs and connects BOTH `ioredisClient`
and `keyvRedisClient` as module side effects, but most cache/mcp
integration specs disconnected at most one of them — and specs that
re-import the module per test via `jest.resetModules()` leaked a fresh
pair of connected clients (sockets + ping timers) for every test.
On runners where jest resolves to a single worker (2-core machines with
`maxWorkers: '50%'`), the suite runs in-band and the leaked handles keep
the main process alive after all tests pass — the run hangs until the
CI job timeout. On larger runners jest recovers only by force-exiting
the leaked worker ("A worker process has failed to exit gracefully...").
- add a `closeRedisClients()` test helper that settles the connect
promise and closes both clients of a `redisClients` module instance
- call it from every cache/mcp integration spec that creates clients,
mirroring what LeaderElection.cache_integration.spec.ts already does
- remove the rethrow in the `keyvRedisClientReady.catch(...)` logging
handler — rethrowing inside `.catch` creates a new, never-observed
rejected promise, turning any failed initial connect into a
guaranteed unhandled rejection; callers awaiting
`keyvRedisClientReady` still observe the original rejection
All four `test:cache-integration` stages now pass AND exit cleanly with
`--maxWorkers=1` against both single-node and cluster Redis, with no
force-exit warning in worker mode.
* 🧹 chore: Treat testRedisOperations as Assertion in expect-expect Rule
* 🗂️ chore: Sort Imports per Repo Convention
* 🛰️ feat: Add GPT-5.5 + Frontier OpenAI Models, Drop Deprecated Defaults
* 🛰️ fix: Address Codex Review on OpenAI Model Refresh
- Replace nonexistent gpt-5.5-chat-latest with the actual chat-latest
alias; register its context window, output cap, pricing, and cache
rates, and pin explicit rates for legacy gpt-5.x-chat-latest aliases
so the new chat-latest key cannot out-match their cheaper pricing
- Add long-context premium tiers (>272K input) for gpt-5.5 and gpt-5.4
- Disable streaming for pro reasoning models (o1-pro, gpt-5.x-pro),
which OpenAI does not support, with spec coverage
* 🛰️ fix: Address Codex Round-2 Review and CI Spec Failure
- Allow chat-latest through the official OpenAI fetched-model filter
- Export isProReasoningModel and drop unsupported sampling parameters
for versioned pro models (gpt-5.4-pro, gpt-5.5-pro), which the
versioned-model exemption previously let through
- Honor the pro-model streaming disable in both agent chat-completions
routes, which decide SSE from model_parameters before llmConfig exists
- Update models.spec default-list assertions for the refreshed defaults
and cover chat-latest filter retention
* 🛰️ fix: Address Codex Round-3 Review
- Convert max_tokens for chat-latest, which the gpt-[5-9] guard missed
- Drop snake_case sampling params (top_p, logit_bias, penalties) in the
reasoning-model exclusion list so addParams-sourced values are removed
- Add createOpenAIAggregatorHandlers and wire them into the agent
chat-completions service's non-streaming branch, which previously ran
with no handlers and always returned an empty aggregated response
* 🛰️ ci: Fix Import Order Drift and Controller Spec Mock
- Sort type import first in service.spec.ts per import-order convention
- Register isProReasoningModel in the openai controller spec's
@librechat/api mock factory, whose enumerated exports left the new
helper undefined and broke the non-streaming flow under test
* 🛰️ chore: Trim Scope to Model Catalog Changes
Revert the OpenAI endpoint and agent handler changes (pro-model
streaming, sampling exclusions, non-streaming aggregation) — that
surface is moving out of LibreChat into the agents SDK and belongs
in its own change. Keep the model list, token windows, pricing, and
the fetched-model filter for chat-latest.
* 🛰️ fix: Correct GPT-5.4 Context Windows and Pro Long-Context Pricing
- Set gpt-5.4 and gpt-5.4-pro context to the documented 1,050,000
window — 272K is the long-context pricing breakpoint, not the cap,
and using it truncated prompts before they could reach that tier
- Add gpt-5.4-pro long-context premium rates ($60/$270 above 272K)
per its model page; gpt-5.5-pro documents no long-context tier
* 🛰️ fix: Add gpt-5.4-nano and gpt-5.5-pro Long-Context Pricing
- Register gpt-5.4-nano ($0.20/$1.25, cached $0.02, 400K context) in
the model list, pricing, cache, and token maps — the longest-match
fallback billed it at gpt-5.4's $2.50/$15
- Add gpt-5.5-pro long-context premium rates ($60/$270 above 272K);
the pricing table lists the tier even though the model page omits it
* fix: Resolve MCP Runtime User Placeholders
* fix: Harden MCP Runtime Placeholder Connections
* fix: Update MCP Source Tag Test Expectations
* fix: Complete MCP Runtime Placeholder Reinit
* fix: Harden MCP Request Scoped Runtime Configs
* fix: Align MCP OAuth Tests With Domain Policy
* fix: Harden MCP Runtime Resolution Edges
* fix: Avoid MCP Runtime Reprocessing Pitfalls
* fix: Reuse MCP Request Scoped Tool Discovery
* fix: Validate MCP Body Runtime Fields
* 🛡️ refactor: Harden runtime placeholder edges from review
- Warn at inspection when a trusted server URL contains runtime
placeholders but no domain allowlist restricts the resolved target
- Document the three resolution sites that must stay in sync so the
validated config always matches the connected one
- Note the per-call connect cost of ephemeral GRAPH/BODY connections
- Drop the no-op removeUserConnection in callTool's ephemeral cleanup;
ephemeral connections are never stored, and removing the entry could
orphan a still-connected cached connection after a config change
* 🪪 fix: Cover oauth_headers, Graph URL gating, and request-scoped reconnects
Address Codex review:
- Resolve runtime placeholders in oauth_headers (processMCPEnv + Graph
pre-pass) and include the field in placeholder detection, so OAuth
discovery/token requests no longer send literals; consolidate the
detection field lists into one helper
- Defer the early domain gate when the URL still carries a Graph
placeholder (resolved async later); the authoritative
assertResolvedRuntimeConfigAllowed check still enforces policy
- Bypass the 10s reconnect throttle for request-scoped servers, which
re-fetch tool definitions on every message by design
* ⏳ fix: Extend and decouple MCP OAuth flow timeouts
The OAuth auth button disappeared after 2 minutes (the internal OAuth
handling timeout) while the flow state lived for 3 minutes, leaving users
who didn't click immediately stuck in an unrecoverable re-auth loop. The
handling timeouts also reused the connection/init timeout, so a short
initTimeout would shrink the OAuth window further.
- Add MCP_OAUTH_HANDLING_TIMEOUT (10m) and MCP_OAUTH_FLOW_TTL (15m) to mcpConfig
- Decouple the reactive/proactive OAuth waits from initTimeout/connectionTimeout
- Use OAUTH_FLOW_TTL for the FlowStateManager TTL and the UI status window
- Ensure the flow TTL outlives the handling timeout, fixing the
"Flow state not found" race
- Remove dead FLOW_TTL constant and document new env vars
Fixes#13615
* ⏳ fix: Coordinate OAuth pending window with handling timeout
Address Codex review: the extended OAuth wait was still capped by other
timeouts that were not updated.
- Align PENDING_STALE_MS (button validity + pending-flow reuse window)
with MCP_OAUTH_HANDLING_TIMEOUT so a flow stays reusable for the full
wait instead of 2 minutes (Finding 3)
- Clamp MCP_OAUTH_FLOW_TTL to never fall below the handling timeout so a
callback near the deadline still finds its flow state (Finding 2)
- Floor attemptToConnect's timeout to the handling window for OAuth
servers so the reactive in-connect OAuth wait is not killed by the
30s connection timeout (Finding 1)
- Update flow staleness tests to reference the threshold symbolically
* ⏳ fix: Align OAuth window across status, action flows, and client polling
Address Codex round 2: extending the server wait exposed three more
windows that were still capped or now over-extended.
- checkOAuthFlowStatus reports a PENDING flow as active only within the
usable PENDING_STALE_MS window, not the longer Keyv retention TTL, so
the connect button reappears instead of a stuck 'connecting' state
- Give Action (custom tool) OAuth its own FlowStateManager on the prior
3-minute TTL so the longer MCP OAuth TTL can't leave an action tool
call waiting up to 15 minutes
- Extend the MCP server-card client polling to the 10-minute handling
window so a user who completes OAuth after 3 minutes is still picked up
* 🧪 test: Make stale-flow CSRF test track PENDING_STALE_MS
The CSRF-fallback stale-flow test hardcoded a 3-minute age, which is now
within the 10-minute PENDING_STALE_MS window and was wrongly treated as
active. Derive the age from PENDING_STALE_MS so it tracks the constant.
* ⏳ fix: Add grace buffers and surface OAuth timeout to the client
Address Codex round 3 (near-deadline edges):
- Clamp MCP_OAUTH_FLOW_TTL to handling timeout + 60s grace (not equality),
so flow state outlives the wait instead of expiring at the same instant
- Extend attemptToConnect's OAuth floor by a 60s grace so a user who
authorizes near the deadline still gets the post-OAuth reconnect
- Surface OAUTH_HANDLING_TIMEOUT on the connection-status response and
have the client poll for the configured window instead of a hardcoded
10 minutes, so a tuned server deadline isn't capped on the client
* ⏳ fix: Refresh client OAuth timeout from the first status refetch
If the connection-status cache is empty when polling starts, the client
captured the 10-minute fallback and never picked up a tuned oauthTimeout.
Re-read it after each refetch so a longer configured deadline is honored
even on a cold cache.
* 📝 refactor: Type oauthTimeout on MCPConnectionStatusResponse
Declare the oauthTimeout field on the shared response type in
data-provider instead of an ad-hoc inline cast in the client hook, and
replace the pre-existing 'as any' on the status query read with the
typed getQueryData. Type-level only; no runtime change.
When a skill is primed fresh this turn (manual $-popover or always-apply) AND
also appears in history as a `skill` tool_call, its SKILL.md body was injected
twice — once by injectSkillPrimes and once reconstructed by formatAgentMessages.
- add `collectFreshSkillPrimeNames` helper (packages/api) — union of manual +
always-apply prime names
- client.js: pass the set as `skipSkillBodyNames` to formatAgentMessages for
both the initialMessages and memoryMessages paths so the body reconstructs
once. Names not primed this turn still reconstruct (sticky manual re-prime).
Requires `@librechat/agents` with `skipSkillBodyNames` support; the published
dist silently ignores the unknown option until upgraded.
* 📖 feat: Add Claude Fable 5 Support
Claude Fable 5 (`claude-fable-5`) is Anthropic's most capable widely
released model (GA 2026-06-09). Its naming drops the opus/sonnet/haiku
tier, so LibreChat's name-parsing helpers miss it; this teaches them the
Mythos-class family (Fable / Mythos) and registers the model.
- Add `parseMythosClassVersion` and route Fable/Mythos through
`supportsAdaptiveThinking`, `omitsThinkingByDefault`,
`omitsSamplingParameters`, and `supportsContext1m`
- Extend the Bedrock detection regexes (beta headers + adaptive-thinking
branch) and `checkPromptCacheSupport` to match `claude-(fable|mythos)`
- Return 128K max output for Fable/Mythos in `maxOutputTokens.reset`/`set`
- Register `claude-fable-5` in shared Anthropic + Bedrock model lists,
1M context / 128K output token maps, and $10/$50 pricing with 12.5/1
cache rates (`claude-mythos-5` added to token + pricing maps only,
since it is limited-availability)
- Update `.env.example` and the Vertex `librechat.example.yaml` examples
- Add parallel tests across tokens, Anthropic llm config, the Bedrock
parser, and tx pricing
* 🧹 refactor: Centralize Mythos-class detection; address review feedback
- Add `isMythosClassModel` + `MYTHOS_CLASS_FAMILIES` in schemas.ts as the
single source of truth for the Fable/Mythos family; route every gate
(adaptive thinking, omit-thinking, omit-sampling, 1M context, prompt cache,
128K max-output reset/set) through it. A future sibling class is now a
one-line edit.
- [Codex P2] Exclude Mythos-class from getBedrockAnthropicBetaHeaders: Fable/
Mythos ship 128K output + fine-grained tool streaming by default, and the
legacy output-128k-2025-02-19 beta is 3.7-Sonnet-only on Bedrock and risks
request rejection. They still get adaptive thinking + effort.
- [Copilot] Add Mythos 5 test parity (name variations, cache rates, pinned
$10/$50) in tx.spec; add Mythos context/max-output/name-match in tokens.spec;
fix the stale claude-3-7-sonnet-only comment in bedrock.ts.
- Add isMythosClassModel unit tests covering all declared families.
* 📝 docs: Clarify Mythos-class Bedrock requirements; correct beta-omit rationale
Verified live against Bedrock (acct 951834775723, us-west-2):
- anthropic.claude-fable-5 IS a real Bedrock catalog model, INFERENCE_PROFILE-only
exactly like the existing anthropic.claude-opus-4-7/4-8 and claude-sonnet-4-6
default entries (refutes the "invalid model id" review claim).
- Mythos-class also requires opting into Anthropic data sharing (Bedrock Data
Retention API) before invocation.
Changes:
- .env.example: note that Mythos-class (Fable/Mythos) is inference-profile-only on
Bedrock and needs the data-sharing opt-in.
- bedrock.ts: reword the beta-omit comment to the verified rationale — output-128k /
fine-grained-tool-streaming are built-in/no-op for the 4.7+ generation, so omitting
them is lossless (dropped the unverified "Bedrock may reject" wording).
* 🔄 refactor: Reorganize imports in schemas.ts and tx.spec.ts
- Moved `TFeedback` and `Tools` imports to the top of `schemas.ts` for better readability.
- Adjusted import order in `tx.spec.ts` to maintain consistency and improve clarity.
* 🧰 fix: Flatten union schemas for Gemini/Vertex MCP tool compatibility
`@langchain/google-common`'s `zod_to_gemini_parameters` throws "Gemini cannot
handle union types" on any genuine `anyOf`/`oneOf` (e.g. discriminated unions),
so MCP tools shipping union-typed schemas crash on the Google endpoint while
working fine on OpenAI/Claude.
Add `flattenJsonSchemaUnions` (packages/api) to collapse unions to their first
non-null member and multi-entry `type` arrays to a single nullable type, and
apply it in `createToolInstance`'s existing `isGoogle` branch so only the
Google/Vertex path is affected. Lossy by design, mirroring the existing
empty-object fallback.
Closes#13612
* 🩹 fix: Address Codex review — preserve fields, strip null enums, cover definitions path
- Preserve parent-level `properties`/`required` when collapsing a union: merge the
chosen branch into the parent instead of overwriting, so args declared outside the
union (e.g. always-required fields) still reach Gemini.
- Drop the `null` member from `enum` when a union/type-array makes a field nullable,
keeping Gemini's required homogeneous-enum invariant.
- Propagate the Google-flattened schema to the definitions/deferred-tool path:
thread `provider` into `loadToolDefinitions` and flatten there, and store the
flattened schema on `mcpJsonSchema` so `extractMCPToolDefinition` no longer emits
raw unions on Google/Vertex.
* 🎨 style: Sort imports in tools/definitions per import-order check
* ♊ feat: Broaden union flatten into a full Gemini schema sanitizer
The union flatten alone wasn't enough — real GitHub MCP tools on Gemini also 400
with `Invalid value ... (TYPE_STRING), true`, because Gemini's function-calling
Schema (https://ai.google.dev/api/caching#Schema) accepts only a restricted JSON
Schema subset, and `enum` is `Type.STRING`-only.
Rename `flattenJsonSchemaUnions` → `sanitizeGeminiSchema` and broaden it (one pass,
Gemini-gated) to cover the documented subset:
- Keep only string `enum` values; drop the keyword for non-string types (fixes the
reported boolean-enum 400, incl. boolean `const` normalized to `enum: [true]`).
- `const` → single-value string enum, or drop if non-string.
- Merge `allOf` intersections; fold `exclusiveMinimum`/`exclusiveMaximum` into
`minimum`/`maximum`.
- Strip unsupported keywords: `additionalProperties`, `default`, `$schema`, `$id`.
- (Existing) collapse `anyOf`/`oneOf`, multi-entry `type` arrays, nullable.
Grounded in Google's Schema docs rather than reverse-engineered from 400s. Verified
end-to-end against the real `@langchain/google-common` converter. Complements
danny-avila/agents#232 (langchain bump), which defers schema flattening to LibreChat.
* 🩹 fix: Gate enum retention on the effective (collapsed) type
Codex review: a mixed-type enum like `type: ['integer','string'], enum: [1,'auto']`
collapsed the type to `integer` but still kept the string value `'auto'`, yielding
`{type:'integer', enum:['auto']}` — a non-string type with an enum, which Gemini
rejects. Keep `enum` only when the effective collapsed type is string (or unset),
and stamp `type: 'string'` on a surviving typeless enum (e.g. a string `const`
discriminator) so it satisfies Gemini's Type.STRING enum requirement.
Keep the Google Gen AI SDK aligned with the latest 2.x release. Updates the
declared range in both backend manifests (api, packages/api) and regenerates
the lockfile to resolve @google/genai to 2.8.0.
No application code changes: the sole consumer
(api/app/clients/tools/structured/GeminiImageGen.js) uses the stable
`GoogleGenAI` constructor and `models.generateContent` API, and the upstream
changelog records no breaking changes to those between 2.0 and 2.8.
Closes#13551
* 👷 ci: Add API runtime smoke (boot the production image) to docker-smoke
The docker-smoke workflow only built the `client-package-build` stage and
never booted the runtime, so it couldn't catch the class of regression that
recently took production down: the api tsdown bundle externalizes runtime
deps that, after `npm ci --omit=dev`, were missing from the image
(`Cannot find module 'get-stream'`).
- Add an `api-runtime-smoke` job that builds the real production image
(final `api-build` stage, `npm ci --omit=dev`), then:
1. loads the @librechat/api bundle's full require graph in the pruned
image (deterministic, no DB) — fails on any missing/ESM-incompatible
runtime dependency.
2. boots the actual entrypoint and asserts no module-load crash (the
server loads its require graph before connecting to Mongo, so this
surfaces without a database).
- Expand triggers to include `packages/api/**`, `packages/data-schemas/**`,
and `api/package.json` (previously a packages/api change only triggered
this via a root lockfile change, and even then only built the client stage).
- Add gha build cache + concurrency cancellation to bound CI cost.
* 👷 ci: Address Codex review — boot smoke against real Mongo + crash detection
- Boot the production image against a real MongoDB container with the env
the server needs, so the *entire* require graph loads. `api/db/connect.js`
throws at module scope without `MONGO_URI` and is imported before
models/services/routes, so the previous no-env boot exercised almost none
of the legacy API graph. (Codex finding 2)
- Gate on `/health` returning 200 AND the container staying alive, failing on
any container exit. A non-module startup crash (ReferenceError, SyntaxError,
bad config) now fails the smoke instead of slipping past a missing-module
grep. (Codex finding 3)
- Expand trigger from `api/package.json` to `api/**`, since the image copies
the whole `api/` tree and runs `node server/index.js`. (Codex finding 1)
* 👷 ci: Address Codex round 2 — poll /readyz + cover all image inputs
- Poll /readyz instead of /health. /health returns 200 at app.listen, but
initializeMCPs() and checkMigrations() run *after* listen and process.exit(1)
on failure; /readyz only returns 200 once serverReady is set after those
complete. So post-listen startup crashes now fail the smoke too. (finding A)
- Expand triggers to every source tree copied into the production image:
client/**, config/**, skill/** (the final stage copies client/dist, config,
and skill). (finding B)
The tsdown migration (#13595) externalizes all third-party imports
(Rollup inlined them), so several modules the api source imports must be
present at runtime. Six were not, causing production (`npm ci --omit=dev`)
to crash on boot with `Cannot find module 'get-stream'` (then the next).
Fixed following the package's existing convention — packages/api declares
runtime libs as `peerDependencies`, and the `/api` app provides them as
real `dependencies` (how express/mongoose/sharp already resolve):
- `api/package.json` (the prod app, the provider): add the 3 that were
missing — `get-stream`, `jszip`, `mongodb`. (`dedent`/`lodash`/`nanoid`
were already provided by /api.)
- `packages/api/package.json`: add all 6 to `peerDependencies` (the
contract) and to `devDependencies` (workspace build/tests), matching
the existing `mammoth`/`pdfjs-dist`/`sanitize-html` dev+peer pattern.
`jszip`/`mongodb` move out of dev-only (were pruned in production).
Pinned to CJS-compatible majors (get-stream@6, nanoid@3). Verified the
built bundle has zero undeclared externals and the 3 newly-provided deps
are production (non-dev) in the lockfile, so they survive `--omit=dev`.