Compare commits

...
Author SHA1 Message Date
hanzo-dev c9fd1cfc07 ci(data-schemas): isolate native-sqlite specs one-file-per-process; v0.9.16
The 15 specs that instantiate the native better-sqlite3 driver corrupt each
other under a shared jest worker: jest gives each spec file its own JS module
realm while the native addon is process-cached per worker, so cross-file state
breaks sibling in-memory databases (driver-agnostic — reproduces with mainstream
better-sqlite3; a compound json_extract UNIQUE stops firing). Prod is unaffected
(one realm, one shared handle for the process lifetime).

test:ci now runs test/ci.mjs: the normal coverage pass for everything except the
native-driver set (via testPathIgnorePatterns), then each of those 15 specs in
its own jest process, exiting non-zero on any failure. NOT --runInBand /
maxWorkers=1 (a single shared worker still corrupts). No test weakening, no skips.

Bump chat 0.9.15 -> 0.9.16 (patch).
2026-07-04 15:51:44 -07:00
hanzo-dev 5feca644e2 fix(store/sqlite): close shared handle on rekey + test teardown (real leak)
sharedSqliteHandle() reassigned sharedHandle on a collection-key change without
closing the prior connection — a latent native-handle leak on any rekey. Close
it before replacing, and export closeSharedSqliteHandle() so storeRegistry.spec
(rekeys twice) and tenantIsolation.coverage.spec tear the handle down instead of
leaking it past the file.

NOTE: this fixes the real leak the review identified, but does NOT resolve the
cross-file store-spec flake. That flake is a separate, deeper issue (see report):
driver-agnostic (reproduces with mainstream better-sqlite3 too), isolated to the
store's create path, timing/heap-sensitive (any probe masks it), and NOT fixed by
closing handles/GC/statement-cache/wrapper-pinning. Every store suite passes in
its own process; production (single long-lived handle, one module realm) is
unaffected.
2026-07-04 15:35:37 -07:00
hanzo-dev 72a5c71fe3 fix(store/sqlite): swap node:sqlite→better-sqlite3-multiple-ciphers (Node 20) + honor tenant sentinel in debug log
node:sqlite (DatabaseSync) is a Node 22+ builtin; prod runs Node 20 (Alpine),
so require('node:sqlite') threw ERR_UNKNOWN_BUILTIN_MODULE, cascading through
createModels → applySqliteOverrides → openDatabase and breaking the built dist
("createModels is not a function"). Swap to better-sqlite3-multiple-ciphers
12.11.1 (engines include 20.x, synchronous drop-in, SQLCipher AES-256 at rest —
the Node embodiment of the hanzoai/sqlite contract).

- data-schemas dep + root pnpm.onlyBuiltDependencies allowlist (native addon);
  rollup externalizes the driver.
- openDatabase(): lazy require of the driver, identical WAL/synchronous/
  busy_timeout/foreign_keys pragmas. Wire the CHAT_SQLITE_KEY encryption seam
  (SQLCipher cipher/legacy/key pragmas applied before any page-touching
  statement; 64-hex validated, fail-closed; key/path never logged). KMS/CEK
  derivation is Milestone 2.
- DocModel: retype db to the driver's Database instance; binding/return parity
  already correct (booleans→1/0, dates→ISO, TEXT reads only). engine untouched
  (27/27 held).
- parsers: restore the structured-logging-context impl dropped in an upstream
  merge (spec #13110 landed without its parsers.ts) — appendRequestContext for
  non-debug lines and drop the __SYSTEM__ tenant sentinel from debug traversal.
- models: register the SystemGrant model in createModels (schema/model/methods/
  SQLite-spec + coverage guard all existed; only the registration was dangling).

Scrub all node:sqlite/DatabaseSync references from src (comments + spec names).
2026-07-04 14:40:03 -07:00
hanzo-devandz 31bd2144cf fix(store/sqlite): guard engine setPath/deletePath against prototype pollution
The SQLite store's update path feeds attacker-influenced keys ($set/$unset/
$inc/$push/$addToSet — conversation import, saveConvo, agent metadata) straight
into setPath, which walked cur['__proto__'] / cur['constructor'] and could mutate
a shared prototype (global prototype pollution). Reject any dotted path whose
segments include __proto__/prototype/constructor (and the single-segment own-key
case a JSON.parse'd body produces). Legitimate nested writes are unaffected.

engine.spec: +5 guard cases (dotted $set, constructor.prototype, JSON-sourced
own __proto__, $setOnInsert/$inc, legit nested still writes). 27/27 green on
Node 20 (pure-JS engine, no node:sqlite dependency).
2026-07-04 14:14:56 -07:00
zeekayandClaude Fable 5 8360c518b6 fix(backfill): resolve @librechat/data-schemas via workspace path fallback
The pnpm workspace does not hoist a bare @librechat/data-schemas symlink to
the app root where the backfill runs (node config/backfill-sqlite.js), so a
bare require throws MODULE_NOT_FOUND in the pod. Fall back to the workspace
path (packages/data-schemas). Verified: require('/app/packages/data-schemas')
resolves in the chat pod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:15:39 -07:00
zeekayandClaude Fable 5 bd938fc6c6 feat(store): dual-write mirror + auth/billing specs to drop chat-docdb (0.9.15)
The Mongo→SQLite cutover, completed so chat-docdb can be DELETED with zero
data loss and an instant revert at every step.

- DualWriteModel: wraps a primary (served) + mirror model; reads/props fall
  through to primary, the 10 write methods run on primary then replicate the
  affected docs to the mirror KEYED BY THE PRIMARY'S _id (upsert if present,
  delete if gone). Symmetric — same code mirrors mongoose->sqlite (pre-flip)
  and sqlite->mongoose (post-flip escape hatch). Mirror failures are logged,
  never thrown, so the served path can't break; gaps are caught by the
  pre-flip count reconcile + the idempotent backfill.
- DocModel.upsertRaw: exact by-_id upsert (verbatim, no timestamp stamping) —
  the shared primitive for the mirror AND the backfill, so live mirroring and
  the one-shot copy converge on one keyspace without duplicating.
- Seam: applySqliteOverrides now honors TWO flags — CHAT_STORE_SQLITE (served)
  + CHAT_STORE_DUALWRITE (mirrored) — yielding the four cutover states. One
  shared node:sqlite connection per process (was per-createModels-call).
- Route User/Session/Token through the handle (methods/index.ts) and add the
  auth+billing CollectionSpecs (User/Session/Token/Balance/Transaction). These
  are the collections actually populated in chat-docdb outside the 24 already
  wired; User is the hot-path record every request loads and every conversation
  references by _id, so it MUST move for a lossless Mongo delete.
- connectDb() is now Mongo-optional: unset MONGO_URI => SQLite-only mode, skip
  the connection (final state, chat-docdb gone) with bufferCommands off so a
  stray mongoose query fails fast instead of hanging.
- Dockerfile{,.multi,.static}: node:20/22-alpine -> ghcr.io/hanzoai/nodejs
  :v24.18.0 (Node 24; node:sqlite built in, better-sqlite3 compiles).
- config/backfill-sqlite.js: one-shot Mongo->SQLite copy + count reconcile.

Tests: DualWriteModel.spec (10) green — mirror-by-primary-_id both directions,
read passthrough, bulkWrite, idempotent backfill, all 29 specs build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:04:11 -07:00
b6a82bcf8f fix(agents): raise cloud-agent run timeout 30s→180s (long-run 502) (#58)
* fix(agents): raise cloud-agent run timeout 30s→180s (env CLOUD_AGENT_TIMEOUT)

A cloud agent run is a real chat completion — a zen5-mini answer routinely
takes ~25-30s (measured 28s), larger models/prompts longer. The hardcoded 30s
client timeout aborted long runs mid-flight, surfacing in the UI as a 502 even
though the cloud run finished and was recorded. Bump the default to 180s and
make it env-configurable (CLOUD_AGENT_TIMEOUT), matching the existing
CLOUD_AGENT_MAX_CONCURRENT knob. List/get are fast; this headroom only affects
/run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(agents): lock cloud-agent run timeout contract (180s default + env override)

Adds coverage the timeout bump lacked: asserts the 180s default (the fix for
the 30s -> in-UI 502), that an explicit constructor timeout wins, and that
CLOUD_AGENT_TIMEOUT overrides the module-load default (isolated re-require).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 05:14:04 -07:00
zeekayandClaude Opus 4.8 650ad06b15 ci: ignore all three transitive-pinned otel packages in depcheck
The unpinned depcheck (workflow installs latest each run) shifts which
@opentelemetry/* packages it reports: it flagged only @opentelemetry/core
before, now also exporter-trace-otlp-http and sdk-trace-base. All three are
transitive deps of @opentelemetry/sdk-node (imported/driven via NodeSDK in
packages/api/src/telemetry/sdk.ts), pinned top-level for otel version
alignment, none imported by name. Ignore the complete set so ROOT_UNUSED is
empty regardless of depcheck's version-dependent otel detection. Only
@opentelemetry/api is imported directly and stays checked.

Verified locally: with the three ignored, depcheck's root output contains only
@opentelemetry/api, which the workflow's used-in-code list subtracts -> empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 04:47:57 -07:00
zeekayandClaude Opus 4.8 52bcd7de06 ci: silence otel-core depcheck false-positive + post PR comments via github-script
detect-unused-packages and detect-unused-i18next-strings each hard-fail on the
comment step because the ARC runners have no `gh` CLI (`gh api` -> exit 127), and
detect-unused-packages additionally flags @opentelemetry/core.

- Add .depcheckrc.yml ignoring @opentelemetry/core: it is a transitive
  requirement of the otel stack in use (sdk-node / sdk-trace-base /
  exporter-trace-otlp-http), intentionally version-pinned at the top level and
  never imported by name, so depcheck reports a false positive. Verified locally:
  with the ignore, depcheck no longer lists it (it was the sole ROOT_UNUSED item).
- Move both workflows' "post comment" step from `gh api` to
  actions/github-script@v7 (Octokit + built-in token; jobs already grant
  pull-requests: write). No dependency on a gh binary that isn't on the runner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 04:33:19 -07:00
zeekayandClaude Opus 4.8 495340a3b7 i18n: remove orphaned en key com_agents_cloud_section
Genuinely unused: defined only in en/translation.json, referenced nowhere in
code (the sibling com_agents_cloud_* keys are all used; this section-header
string never shipped a consumer). Its presence was the sole finding of the
detect-unused-i18next-strings workflow, which hard-fails on any unused key.
Removing it makes that check pass without weakening it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 04:26:23 -07:00
zeekayandClaude Opus 4.8 864c8a7bc7 ci: fix workspace-build OOM heap + drop dead LiteLLM lint workflow
Two pre-existing, repo-wide CI failures that reddened main and every PR:

1. test.yml / e2e.yml OOM'd building packages/api (rollup peaks ~5 GiB RSS;
   Node's default ~2 GiB old-space heap => "heap out of memory", exit 134).
   Add the same NODE_OPTIONS heap knob backend-review.yml/frontend-review.yml
   already use (6144, well under the 8 GiB runner limit). Verified: build:api
   completes in ~54s at 6144 (peak RSS 4.98 GiB); it OOMs at 3072.

2. test-linting.yml ("LiteLLM Linting") lints a litellm/ Python dir that does
   not exist in this repo and runs `poetry install` with no pyproject.toml, so
   it always failed at "Install dependencies". Pure upstream LibreChat/LiteLLM
   residue — the real JS lint is eslint-ci.yml ("Run ESLint Linting"), which
   passes. Remove the dead workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 04:18:13 -07:00
zeekayandClaude Fable 5 1bcdcfc1e1 chore(chat): runtime image node:20 -> node:22-alpine (0.9.13)
Node >= 22.5 ships `node:sqlite` (DatabaseSync), which the SQLite document
store requires once CHAT_STORE_SQLITE / CHAT_STORE_DUALWRITE is enabled.
Dockerfile.static was already node:22-alpine; this brings the runtime image
to parity. The store lazy-requires node:sqlite (stores/sqlite/index.ts), so
with the flag unset the runtime boots unchanged — this is a pure Node bump,
no SQLite enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 01:10:29 -07:00
72700de2f0 fix(mcp): full connection/transport port (proxy + response-size caps + WS SSRF) + wire suite into CI (#61)
* fix(mcp): restore drifted ~/auth SSRF/allowedAddresses helpers

The tests + callers (hardenedFetch, MCPOAuthHandler, MCP connection) already
expected the port-scoped allowedAddresses SSRF surface (LibreChat #12933/#13022),
but domain.ts/agent.ts had been reduced to the pre-#12933 versions while their
specs were trimmed to match — a tests-without-impl drift babel hid at test time.

Restore the upstream pair (matched impl + spec):
- domain.ts: isAddressAllowed; 3-arg resolveHostnameSSRF/isSSRFTarget (allowed
  host:port exemption); isOAuthUrlAllowed; validateEndpointURL; port-scoped
  isMCPDomainAllowed fail-closed on unparseable allowlisted URLs.
- agent.ts: createSSRFSafeUndiciConnect(allowedAddresses, port) + allowedAddresses
  connect-time exemption (fixes the dead 2-arg call in hardenedFetch.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): restore circuit-breaker config + OAuth reconnection cooldown

mcpConfig gains the CB_* connect/disconnect circuit-breaker knobs and
OAuthReconnectionTracker regains its progressive cooldown (5m/10m/20m/30m capped)
that reconnection-storm.test.ts asserts. Both were trimmed while their tests
stayed at the upstream version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): finish connection.ts transport port — proxy, response caps, WS SSRF

Completes the half-ported MCP connection/transport layer (LibreChat
#13076/#13219/#13224/#13274). The test suite (MCPConnectionSSRF,
MCPConnectionAgentLifecycle, reconnection-storm) was byte-identical to upstream
but the implementation stopped at the redirect-SSRF guard, leaving 22
tests-without-impl in MCPConnectionSSRF alone.

- Proxy support: serverConfig.proxy + PROXY/HTTP(S)_PROXY env with full NO_PROXY
  semantics (wildcard, CIDR, IP-range, IPv6, host-suffix, port scoping); per-URL
  ProxyAgent/Agent dispatcher selection tracked in this.agents; recomputed across
  redirects; proxied-target SSRF preflight (IP literal -> resolveHostnameSSRF,
  hostname -> allowedAddresses exemption or reject).
- Response-size caps: guardStreamableHTTPResponses opt-in wraps the body stream
  with MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES + MCP_STREAMABLE_HTTP_MAX_LINE_BYTES,
  emitting a JSON-RPC error SSE frame instead of unbounded buffering.
- WS SSRF: resolveHostnameSSRF(host, allowedAddresses, port) now runs regardless
  of useSSRFProtection (allowlist deployments), closing DNS-rebind to private IPs.
- Connect-lifecycle circuit breaker + agent cleanup (closeAgents) on disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): accept proxy + sseReadTimeout in MCP server config schema

Adds ProxyUrlSchema (http/https/socks, env-var resolved) to SSE/streamable-http
options and sseReadTimeout to the base schema so connection.ts's proxy/idle-read
support is reachable from real config. proxy is admin-only: z.never() in the
UI/API user-input schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(chat): gate MCP connection/transport suite in the primary test workflow

Root cause of the drift: test.yml (the Hanzo-active CI) only ran 'test:api'
(the api/ Express dir) and 'test:client' — packages/api tests never ran here, so
the connection.ts port could drift from its byte-identical tests unnoticed.

Add packages/api 'test:transport' (MCPConnection*, MCPRedirectSSRFGuard,
reconnection-storm, MCPManager, auth domain/agent SSRF specs, hardenedFetch,
OAuthReconnection*) and run it in test.yml. A future half-port of the transport
layer now fails CI loudly. No .skip's exist in these suites; nothing un-skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:44:05 -07:00
zeekayandClaude Opus 4.8 32acfb52e3 fix(chat): stream #titleConvo so the Hanzo gateway response parses (real auto-title fix)
Root cause of new chats never titling: the title path invokes the model
NON-streaming (stream:false). The Hanzo Cloud gateway returns a valid
{choices:[{message}]} body, but the pinned langchain ChatOpenAI parses that
non-streaming body to ZERO generations, so invoke() throws
'Cannot read properties of undefined (reading \'message\')' and no title is
saved. Every agent RUN already streams (SSE), which parses the same gateway
correctly — that is why generation works but titles did not.

Force clientOptions.streaming = true in #titleConvo (after the omitTitleOptions
filter, which strips 'streaming'). Verified against the live gateway with Dave's
per-user key: streaming=true -> clean title; streaming=false -> the crash.

Pairs with the zen5-flash repoint (fast, non-reasoning): streaming fixes the
parse for all models; zen5-flash keeps the call under the 45s title timeout
(zen3-nano/qwen3-8b took ~60s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:38:02 -07:00
zeekayandClaude Opus 4.8 3cb0488797 fix(chat): title/summary model zen3-nano -> zen5-flash (fast, non-reasoning)
New chats didn't reliably auto-title: zen3-nano maps to the reasoning model
qwen3-8b (live upstream), which took 16-31s per title — routinely near the
45s #titleConvo timeout. The other 6 families pointed titleModel at
third-party names (llama-3.1-8b, qwen3-coder-flash, ...) that are NOT in the
live gateway catalog, so they returned a 200 error-envelope.

Repoint every family's titleModel (and the Hanzo summaryModel) to zen5-flash:
a fast, NON-reasoning, in-catalog model (live upstream deepseek-4-flash) that
returns a clean single-line title in ~1-2s. Picked by direct api.hanzo.ai/v1
probes: zen5-flash ~1.5s clean; zen3-nano 16-31s; zen5-mini 10-20s; zen5 /
zen3-vl >40s; zen5-nano not servable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:43:57 -07:00
zeekayandClaude Opus 4.8 43cc8a73bb fix(chat): make #titleConvo envelope-safe (parity with agent runs)
The Hanzo Cloud gateway answers some failures with HTTP 200 + a JSON
error-envelope ({status:"error", msg}) that has no `choices`. On the
title path the OpenAI client parsed that 200 to `undefined` and
#titleConvo threw `Cannot read properties of undefined (reading 'message')`,
so new conversations never got a title.

Apply the same wrapHanzoGatewayFetch rewrite agent runs use, now explicitly
on the title client's fetch, so the envelope becomes a clean 402 the outer
try/catch skips gracefully. Export the wrapper from @hanzochat/api; the
re-wrap is idempotent (a second pass sees a 402, not a 200 envelope) and
response-only, so per-user hk- billing and normal completions are untouched.

Adds an idempotency unit test (7/7 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:43:49 -07:00
zeekayandClaude Opus 4.8 e4c75ffdd1 docs(chat): title-route limitation note (zen3-nano) — config source of truth
The #titleConvo path no longer 404s (zen5-nano was off-catalog), but full
auto-title still needs a follow-up: zen3-nano reasons past the 45s title timeout,
and off-title-path models (e.g. llama-3.1-8b) return a 200 error-envelope the
title client mis-parses. Needs the gateway-envelope rewrite on #titleConvo + a
fast non-reasoning title model. librechat.yaml is the source the chat-config
ConfigMap is generated from (not used at runtime; CONFIG_PATH=/app/chat.yaml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:12:29 -07:00
zeekayandClaude Opus 4.8 8486223c89 chore(chat): 0.9.10 — ensō icon fix folded into the design-maven pass
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:25:02 -07:00
zeekayandClaude Opus 4.8 fa01f11732 fix(chat): ensō for the Zen family in UnknownIcon (the real icon path)
Custom endpoints resolve their icon via getIconKey -> 'unknown' -> UnknownIcon
(by endpoint NAME), not icons[custom] — so the earlier icons[custom] remap was
inert (reverted). The Hanzo house endpoint had no asset/iconURL match and fell
through to the generic lucide "bot". Now:
- 'hanzo'/'zen' -> ZenLogoIcon (ensō), matching the assistant message avatar,
  on the welcome screen + model pill + picker menu.
- qwen/google-gemma/openai-gpt-oss -> real provider marks by name (the
  KnownEndpoints enum lacks those keys). DeepSeek/Mistral logos unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:24:54 -07:00
zeekayandClaude Opus 4.8 6e56f51429 chore(chat): 0.9.9 — design-maven polish pass (true-black, ensō, monochrome)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:10:46 -07:00
zeekayandClaude Opus 4.8 e27fb77e5b feat(chat): polish welcome heading + code blocks
- Welcome heading: tracking-tight for crisp large-display type (Linear/Vercel).
- Code blocks: hairline-bordered true-black surface (rounded-lg, border-medium,
  #0a0a0a) with a subtle bottom-bordered header instead of the heavier grey bar;
  reads calm + monochrome now that code renders in Geist Mono.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:10:00 -07:00
zeekayandClaude Opus 4.8 d2ded972bc feat(chat): monochrome brand — ensō everywhere, neutral avatars, kill leaks
- Zen ensō (円相) replaces the generic lucide "bot" for the custom Zen endpoint:
  welcome screen, model pill and menu icon now match the assistant avatar.
- Monochrome avatars: collapse the colorful DiceBear palette to a neutral grey
  ramp (the green "GU" guest chip is gone), and neutralize the periwinkle
  no-seed fallback in Icon/Avatar.
- Kill the stray "(" glyph: the right control-panel NavToggle handle was floating
  mid-edge at 0.25 opacity; now invisible until hover (header controls remain).
- Model-picker menu separator used cool border-slate-*; now neutral border-light.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:03:20 -07:00
zeekayandClaude Opus 4.8 5e9a043837 feat(chat): true-black hard-default theme + crisp type + auto-title fix
- Hard-default to dark (true-black) unless the user explicitly picks light:
  ThemeProvider getInitialTheme + index.html no-flash script now default dark,
  and the loading canvas is true #000 (was blue-tinted #070b13). theme-color #000.
- Crisp type: font-synthesis:none + antialiased. Basel ships 400/500 only, so
  faux-bold was blurring headings; hierarchy now comes from size + the real
  Medium face. Code now renders Geist Mono (was forced to Consolas via !important).
- Auto-title: repoint titleModel/summaryModel zen5-nano -> zen3-nano (zen5-nano
  isn't in the live /v1/models catalog, so #titleConvo 404'd and new chats never
  auto-titled). zen3-nano is the known-good guest/test model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:03:10 -07:00
zeekayandClaude Opus 4.8 d31f7546bd fix(chat): seed GUEST role default so guest generation stops crashing (0.9.8)
Root cause: commit 46b636c8c0 (server-side anonymous guest chat) added
GUEST to the SystemRoles enum and stamped guest JWTs with role=GUEST, but
never added a roleDefaults[GUEST] entry nor seeded the role. Every guest
generation ran checkAccess -> getRoleByName('GUEST'), which missed the DB,
fell into the self-heal branch `if (!role && SystemRoles['GUEST'])`
(truthy) and called `new Role(roleDefaults['GUEST']).save()` ===
`new Role(undefined)` -> "Role validation failed: name: Path `name` is
required" -> the whole generation threw. Since the logged-out landing IS
the guest composer (0.9.3), nearly every visitor hit this.

Fix (three orthogonal parts):
- data-provider/roles.ts: add the missing roleDefaults[GUEST] (named,
  mirrors USER's minimal grant; guest scope is enforced by
  enforceGuestScope middleware, not by these permissions).
- data-schemas initializeRoles: seed GUEST at boot alongside ADMIN/USER so
  it is deterministic, not lazily created.
- models/Role.js: gate the self-heal create on roleDefaults[roleName]
  (possessing the canonical defaults) instead of SystemRoles[roleName]
  (mere enum membership). roleDefaults entries always carry a name, so a
  nameless create is now structurally impossible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:59:51 -07:00
zeekayandClaude Opus 4.8 7f0bdb3145 feat(build): "Build an app" affordance — hanzo.app handoff + inline build shell (0.9.7)
Uniform "build an app" entry across chat -> app -> console. One pure module
(utils/buildApp.ts) is the single source of the hanzo.app builder wire
(hanzo.app/dev?prompt=) and the `/build [prompt]` command grammar; every
surface funnels through it (DRY).

Phase 1 (ships): the hanzo.app handoff, reachable three ways —
- `/build [prompt]` slash command, intercepted in ChatForm.onSubmit
- "Build this as an app" action on assistant messages (HoverButtons)
- the inline preview pane's "Open in App" CTA
All open https://hanzo.app/dev?prompt=<encoded> in a new tab (noopener).

Phase 2 (scaffold): inline build mode. A `buildMode` recoil flag toggled by the
composer "Build an app" button (BuildAppButton) makes ChatView render a
stripped-down split — chat thread on the left + a side preview pane
(BuildApp/BuildPreviewPane) on the right. The pane is a placeholder whose CTA is
the Phase 1 handoff, seeded live from the composer text. A `/build` route
deep-links into build mode (seeds the composer from ?prompt=/?q=). When buildMode
is off, ChatView renders byte-identical to before (zero regression to normal chat
or the guest landing). Phase 3 (real inline codegen/preview) is documented inline.

9 unit tests for buildApp (url + command grammar). Guest flow untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:18:15 -07:00
zeekayandClaude Opus 4.8 523c38b371 fix(store): lazy-require node:sqlite so the server boots on Node 20 (0.9.6)
The SQLite DocModel store statically `import`s `DatabaseSync` from
`node:sqlite` at the top of `stores/sqlite/{index,DocModel}.ts`. That module
is re-exported by the data-schemas package index, which the API server loads
at boot — so the eager `require('node:sqlite')` in the bundled dist runs
unconditionally. `node:sqlite` only exists on Node >= 22.5; the runtime image
is node:20-alpine, so boot dies with
`ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite`
(crashloop) — independent of the store's inert-by-default flag.

The store is only ever exercised when `CHAT_STORE_SQLITE` is set (a CSV of
collections), via `createSqliteHandle` -> `openDatabase`. Make the import
lazy: `import type` for the erased type references, and a single
function-scoped `require('node:sqlite')` inside `openDatabase`. Module load
no longer touches the builtin, so the server boots on Node 20 with the store
inert; when the flag is set (on a Node >= 22.5 runtime) it works verbatim.

Proven: rebuilt dist has no module-scope require; loading dist/index.cjs with
`node:sqlite` blocked (simulated Node 20) succeeds, and openDatabase() still
defers to the builtin only when actually opening a database.

Unblocks deploying any main-based image (incl. the 0.9.5 guest-chat fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:13:28 -07:00
zeekayandClaude Opus 4.8 de8a33d8a3 fix(chat): guest can send + never bounce to /login (0.9.5)
Two anonymous-guest client bugs, both proven with live Playwright.

BUG A — guest can't SEND ("Unknown endpoint: Hanzo"):
`new QueryClient()` was built in App's render body, so every re-render
minted a fresh EMPTY client. A guest's expected 401s (mcp/servers,
files/config, keys?name=Hanzo, …) fire the queryCache `onError` →
`setError` → App re-render → the provider swaps in an empty client. The
lazy chat-form's `useChatFunctions` then reads `getQueryData([endpoints])`
off an endpoints-less client, so the custom `Hanzo` endpoint resolves with
`endpointType === undefined` and `parseCompactConvo` throws
"Unknown endpoint: Hanzo" — the completion POST never happens. Backend is
correct: guest token → /v1/chat/endpoints={Hanzo:{type:custom}},
/v1/chat/models={Hanzo:[zen3-nano]}. Fix: stabilize the client with
`useState(() => new QueryClient(...))` — one client, one cache, every
consumer (incl. the lazy chunk) shares the populated store.

BUG B — intermittent first-load /login redirect:
The axios 401 interceptor hard-`window.location.href`'d to /login whenever
a one-shot refresh yielded no token. On a cold visit the guest bearer is
still in flight (`isGuestSession()` false because there's no bearer yet),
so an early expected 401 bounced the visitor to /login, racing the
guest-acquire. Fix: only hard-redirect when a real *session* bearer is
actually present (`currentBearer() != null && !isGuestSession()`); an
anonymous cold-start with no bearer is left to routing/the login gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:47:29 -07:00
zeekayandClaude Opus 4.8 2f2378ab21 fix(chat): keep guests on chat surface — don't hard-redirect 401s to /login
The axios 401 interceptor hard-redirected to /login whenever a one-shot
refresh yielded no token. A guest (anonymous preview) session carries a
{guest:true} JWT that is valid ONLY on the chat-completion route; every
other endpoint (/api/mcp/servers, /api/files/config, ...) answers 401 by
design. Those expected 401s bounced the guest to /login, wiping the guest
session in an infinite loop, so the landing never rendered the composer.

Derive guest-ness from the active bearer (isGuestSession) and skip the
hard redirect for guests — real users with a truly-expired session still
redirect. Fixes anonymous-guest landing on hanzo.chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:33:31 -07:00
120c39a197 feat(image): text-to-image via the Hanzo gateway, metered per-user
Enables 'generate an image of X' to render a real image inline through the
DALLE3 agent tool pointed at the Hanzo gateway (/v1/images/generations):
- DALLE3.js: model is configurable (DALLE3_MODEL, e.g. zen3-image); DALL-E-3-only
  knobs (quality/style) are sent ONLY for a dall-e model so the Hanzo image
  backend never sees a param it would reject. Agent path already fetches the
  result server-side and returns it inline (upstream host never reaches client).
- handleTools.js: dalle is now a custom constructor that injects the signed-in
  user's PER-USER hk- key (resolveHanzoCloudKey) so image generation is metered
  to them — mirroring the chat per-user key path. Guests keep the shared key;
  an authed user whose key can't be resolved FAILS CLOSED (no shared-org spend).

Deploy also sets env DALLE_REVERSE_PROXY=https://api.hanzo.ai/v1/images/generations
and DALLE3_MODEL=zen3-image. Zen-brand model id only; upstream never surfaced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:51:13 -07:00
8d2549322a feat(store): SQLite DocModel migration (29 domains) + read-only commerce + identity-a — inert by default (CHAT_STORE_SQLITE unset = pure mongoose) (#60)
* feat(data-schemas): pure Mongo-shaped query/update engine for SQLite store

Correctness core of the mongoose->SQLite migration seam: pure, I/O-free
matchesFilter / applyUpdate / projectDoc / sortDocs implementing the exact
Mongo operator subset the chat data methods use ($eq $ne $in $nin $gt/$gte
$lt/$lte $exists $regex $not $and $or $nor; $set $unset $setOnInsert $inc
$push $pull $addToSet). Date-aware comparison, mongo null/absent semantics.
21 unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): SQLite DocModel + handle backing the Model API on node:sqlite

DocModel presents the Mongoose Model-API subset the chat data methods use
(findOne/find/findOneAndUpdate/updateOne/updateMany/deleteMany/deleteOne/
countDocuments/distinct/create/insertMany/bulkWrite + chainable QueryBuilder
with select/sort/limit/skip/lean/deleteMany). Docs stored as JSON via node:sqlite
(stdlib, zero new dep); JSON1 expression indexes on unique/anchor fields; exact
mongo semantics delegated to the pure engine; date rehydration on read; no
mongoose, no tenant middleware (Conversation/Message are not tenant-plugged
upstream). createSqliteHandle() returns a mongoose-shaped handle the unchanged
method factories run against. 7 adapter tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): prove conversations+messages fully off mongoose on SQLite

- Decouple message.ts + conversation.ts from the mongoose package: factories now
  take a structural DataHandle ({models}) satisfied by BOTH mongoose and the
  SQLite handle. Only type-only imports from 'mongoose' remain; zero runtime
  mongoose in the data path for this domain.
- engine: type-aware operand coercion so cursor pagination (String(Date) operand
  vs Date field) compares chronologically, mirroring mongoose schema casting.
- convoMessage.sqlite.spec: 14 tests running the REAL createMessageMethods /
  createConversationMethods against createSqliteHandle — save/upsert, get,
  update, delete, deleteMessagesSince, cursor pagination, bulk, archived +
  retention-visibility filtering, getConvosQueried, cross-collection deleteConvos,
  deleteNullOrEmptyConversations, searchConversation. All green.

Isolates one unrelated pre-existing fork gap via jest mock: this tree's
librechat-data-provider never synced the RetentionMode enum (upstream #13049),
so message.ts/conversation.ts reference an undefined export on the mongoose path
too. Documented for separate fix.

42/42 store tests green (engine 21 + DocModel 7 + contract 14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): registry-aware createModels + store exports (the seam)

createModels now applies per-domain backend selection via CHAT_STORE_SQLITE
(CSV of collection names -> SQLite DocModel). Unset default = pure mongoose,
live path unchanged; only collections with a CollectionSpec are overridable
(fails closed otherwise). Exports createSqliteHandle/DocModel/CollectionSpec/
DataHandle from the package index. node:sqlite + node:crypto added to rollup
externals. Package builds clean (rollup, EXIT=0); 45/45 store+registry tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(data-schemas): drop local build artifacts + shared-deps symlink from branch

Revert dist/* to base (CI rebuilds bundles deterministically — no local builds)
and untrack the packages/data-schemas/node_modules dev symlink. Source-only branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Batch 2 — Preset, ConversationTag, SharedLink on SQLite

Migrate three self-contained, non-tenant-plugged chat-document domains onto the
DocModel store behind the same seam. Decouple their factories to DataHandle;
createMethods now passes a registry-aware handle to createShareMethods so the
CHAT_STORE_SQLITE flag flips Share (pattern-2) in the live path too.

DocModel extensions (all reusable for later batches):
- compound unique indexes (ConversationTag {tag,user})
- ObjectId-ref casting on write + cross-collection .populate() (SharedLink.messages -> Message)
- findByIdAndUpdate / findOneAndDelete; findOneAndUpdate is now a chainable
  QueryBuilder (mutate mode) so .lean()/.select()/.populate() chain after a write
- schema defaults on insert (SharedLink.isPublic:true)
engine: mixed-update semantics (top-level fields fold into $set) + $pullAll.

Contract specs run the REAL createPreset/ConversationTag/Share methods against
createSqliteHandle. 60/60 store+contract+registry tests green; rollup build EXIT=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Batch 3 — Project on SQLite + realtime-vs-storage finding

Project migrated (pattern-1, registry-aware): add DocModel.findById; CollectionSpec
with array defaults; contract spec mirrors api/models/Project.js exact ops
(getProjectByName upsert, $addToSet $each, $pull $in, updateMany $pull).

Prompt/PromptGroup deferred with rationale: they construct mongoose.Types.ObjectId,
use an aggregate $lookup/$unwind pipeline + populate + manual tenant/ACL
(accessibleIds: ObjectId[]) — need an aggregate primitive, not the mechanical
recipe. Categories read-path is already mongoose-free (getCategories is static).

REALTIME finding (verified codebase-wide): chat has ZERO Mongo change-streams /
tailable cursors / .watch(). Its realtime is SSE token streaming (sendEvent /
agent GenerationJobManager) tied to the generation request — application layer,
DB-independent. So NO migrated domain needs a DB-subscription replacement; plain
node:sqlite is correct for all. Hanzo Base realtime is reserved for future
DB-driven push (multi-device live sync, presence, collab sessions) — none today.
Documented at the storage-decision source (collections.ts).

63/63 store+contract+registry green; rollup build EXIT=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Batch 4 — File, Key, PluginAuth, Banner on SQLite

Migrate four non-tenant storage domains onto the DocModel store. Decouple their
factories to DataHandle; createMethods passes the registry-aware handle to
File/Key/PluginAuth (pattern-2); Banner is pattern-1 (~/db/models).

- Key: encrypted roundtrip proven (updateUserKey encrypt -> getUserKey decrypt)
  against SQLite; CREDS_KEY/IV set via jest setupFiles (runs before module load).
- PluginAuth: replaced a redundant `new Model().save()` else-branch with
  Model.create (equivalent on mongoose, part of the shared Model API — DocModel
  supports it). No behavior change.
- Realtime directive recorded: Conversation+Message are the Base-realtime cutover
  targets ("Base for realtime, SQLite for storage"); all other migrated domains
  are pure storage. Empirically chat has zero Mongo change-streams (realtime=SSE),
  so the SQLite store is correct in the interim; Base swap is backend-only.

MCPServer deferred (constructs mongoose.Types.ObjectId + _id ObjectId cursor).
70/70 store+contract+registry tests green; rollup build EXIT=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Batch 5 — tenant-aware DocModel variant + Config on SQLite

Add tenant isolation to the store, mirroring the mongoose applyTenantIsolation
plugin, gated by CollectionSpec.tenantIsolated (unlocks Config/Skill/SkillFile/
SystemGrant):
- scopeFilter: every read/write filter scoped to getTenantId() (SYSTEM bypasses;
  no-tenant + TENANT_ISOLATION_STRICT=true fails closed) — wired at the single
  candidates() chokepoint.
- stampTenant: inserts stamped with tenantId (create/insertMany/upsert).
- sanitizeTenantUpdate: update payloads cannot mutate tenantId (throws cross-tenant;
  strips from $set/$setOnInsert/$unset/top-level) — wired into
  mutateOne/updateOne/updateMany/bulkWrite.

Config migrated (compound unique {principalType,principalId,tenantId}); decouple
createConfigMethods to DataHandle; QueryBuilder.session() no-op (single connection).
Fix: anchorWhere binds booleans as 1/0 (node:sqlite rejects JS booleans; json_extract
returns 1/0) — hardens every boolean-filtered collection.

batch5 contract spec proves cross-tenant isolation with REAL createConfigMethods:
per-tenant stamping, identical principals isolated across tenants, tenant-scoped
list/find, no cross-tenant delete. 74/74 green; rollup build EXIT=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Batch 6 — SystemGrant on SQLite + ObjectId read-coercion

SystemGrant migrated (tenant-plugged, same recipe as Config): CollectionSpec with
compound unique {principalType,principalId,capability,tenantId} + tenantIsolated;
add DocModel.exists(); decouple createSystemGrantMethods to DataHandle.

Add ObjectId read-coercion to the engine (coerceId): filters carrying real
mongoose ObjectId operands (SystemGrant.normalizePrincipalId casts USER ids to
Types.ObjectId) now compare against the hex strings the store persists (docs
stringify ObjectIds to hex; _ids are ObjectId-hex). Applied in comparable /
valueEquals / anchorWhere. This is the shared primitive that unblocks the
ObjectId-coupled domains (Skill/SkillFile, MCPServer, Prompt/PromptGroup).

Contract spec runs REAL createSystemGrantMethods with USER principals
(ObjectId path exercised end-to-end): grant/has(exists)/revoke, idempotent upsert,
platform-vs-tenant isolation. 77/77 green; rollup build EXIT=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Batch 7a — ObjectId shim + MCPServer, Skill, SkillFile

Add handle.Types.ObjectId shim (thin: store _ids are already ObjectId-hex,
coerceId resolves comparison/storage): a 24-hex ObjectId class with toHexString/
toString/toJSON/equals/isValid, exposed as SqliteHandle.Types and typed on
DataHandle.Types. createMethods' dbHandle now carries mongoose.Types (real
ObjectIds coerce too).

- MCPServer (pattern-2): full real-method spec — create / findByServerName /
  findByObjectId (findById + ObjectId operand) / byAuthor / update / delete +
  unique serverName. Decouple models + Types to handle; wired via dbHandle.
- Skill/SkillFile (tenant-plugged): decouple models + Types to handle; storage
  contract proven — compound unique, ACL _id-in-accessibleIds ObjectId filtering
  via coercion, SkillFile upsert. Their 800-line ACL/validation method layer
  rides on these ops (full harness = follow-up).

81/81 green across 10 suites; rollup build EXIT=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Batch 7b — aggregate primitive + Prompt, PromptGroup

Add DocModel.aggregate supporting the bounded stage set the chat methods use
($match / $lookup / $unwind / $sort / $limit / $project), run in JS over
candidate docs; $lookup resolves the mongo collection name to a sibling model
(prompts -> Prompt) and joins via the shared engine. Fix deepCoerceIds: coerce
ObjectId-like values to hex BEFORE structuredClone in create/insertOne (clone
was stripping the shim's methods, serializing productionId as an object husk).

Prompt / PromptGroup migrated (pattern-1): decouple models + Types to handle;
CollectionSpecs with productionId/prompts refs. Contract spec proves the
aggregate primitive directly AND the REAL getPromptGroup end-to-end (casts _id
-> ObjectId, $lookup productionId -> Prompt, $unwind preserveNullAndEmptyArrays).

All storage-tier domains are now on the DocModel. 85/85 green across 11 suites;
rollup build EXIT=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Skill/SkillFile full-method harness + findOneAndUpdate(new:false) fix

Close the Skill/SkillFile proof gap: real createSkillMethods with stub ACL deps
(PermissionService injected) — createSkill (validation + uniqueness), getSkillById,
getSkillByName(accessibleIds ObjectId ACL), updateSkill optimistic version bump,
deleteSkill (+ removeAllPermissions), and SkillFile upsert (new-vs-replace) /
getByPath / list. Skill + SkillFile now fully proven, not just storage-proven.

Fix mutateOne: findOneAndUpdate(new:false, upsert:true) now returns null on an
insert (no pre-image) and the old doc on update — mongoose semantics that
upsertSkillFile's atomic new-vs-replace fileCount detection depends on. Locked
with a direct DocModel.spec test. All prior findOneAndUpdate upserts use new:true
(unaffected).

89/89 green across 12 suites; rollup build EXIT=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Batch 8a — 7 chat-native domains on SQLite

Migrate MemoryEntry, ToolCall, Assistant, Action, AccessRole, Role, AgentApiKey
onto the DocModel store (mechanical: no external subsystem owns them — cloud
/v1/agents is additive, there is no /v1/memory, and app-domain authz has no IAM
equivalent). Decouple all 7 factories to DataHandle; createMethods passes the
registry-aware handle to Role/Memory/AgentApiKey/AccessRole (pattern-2).

Adapt role.initializeRoles off the `new Role().save()` mongoose-document pattern
to the shared Model API (findOne/create/updateOne) — equivalent on both backends.

Contract spec runs the REAL methods: memory set/create(dup-throws)/list/delete,
toolcall create/get/byConvo/delete, assistant/action upsert+get+delete, accessRole
create/find/list/delete, role initializeRoles seeds + listRoles, agentApiKey
create/validate(hash)/list. 96/96 SQLite-spec tests green; rollup build EXIT=0.
(Pre-existing mongoose specs fail identically on base — fork gaps, out of scope.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(data-schemas): Batch 8b — Agent, AgentCategory, AclEntry, Group on SQLite

Migrate the ObjectId/aggregate/ACL chat-native domains. Decouple factories to
DataHandle (models + Types); wire AgentCategory/AclEntry/UserGroup through the
registry handle. Agent uses the ObjectId shim (version tracking, _id cursor);
AgentCategory uses the aggregate primitive; AclEntry uses bitwise permission
queries; Group links members by id.

Store primitives added/fixed (all reusable):
- aggregate $group (with $sum/$first/$last/$max/$min/$push/$addToSet) — powers
  AgentCategory.getCategoriesWithCounts.
- bitwise operators $bitsAllSet/$bitsAnySet/$bitsAllClear/$bitsAnyClear — powers
  AclEntry.hasPermission.
- applyUpdate deepCoerceIds the upsert seed (ObjectId shims survived to storage
  as hex, fixing findOneAndUpdate-upsert with ObjectId filter fields).
- array-safe index anchor: json_each(...) EXISTS replaces json_extract= so
  {field: value} over an ARRAY field (Mongo array-contains, e.g. Group.memberIds,
  tags, projectIds) hits the index instead of being excluded by the prefilter.

Contract spec runs REAL methods: agent create/get/update/delete, category $group
counts, acl grant/has(bitwise)/revoke, group create/find/addMember/getUserGroups.
100/100 SQLite-spec tests green across 14 suites; rollup build EXIT=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(billing): chat→commerce debit wiring behind COMMERCE_WRITES (default OFF)

Step 2 of the money migration. Default OFF = current local-Mongo billing path
runs byte-for-byte; the Commerce-first fail-closed READ gate is unchanged.

CommerceClient (the previously-dead write methods, now correct):
- recordUsage now debits the billing SUBJECT (billingSubject(owner,email)) — not
  the Mongo user id — with amountMicros (lossless micro-USD; commerce rounds to
  nearest cent + records exact micros) + requestId (stable per-spend idempotency
  key → no double-debit) + totalTokens/provider.
- _flushUsageQueue now passes X-Hanzo-Org per entry (was omitted → debits hit the
  wrong tenant and never netted the balance the gate reads). Fixed.
- add deposit() for credits (POST /v1/billing/deposit).

Wiring (flag-gated, additive, fail-open):
- commerceWrites.js: COMMERCE_WRITES gate + recordCommerceDebit (never throws
  into the spend path; local Mongo authoritative until cutover).
- createTransaction: after the local debit, ALSO record to commerce when the
  flag is ON and the request threaded `subject` (tokenValue is micro-USD;
  transaction _id is the idempotency key). Inert until subject is threaded + the
  flag flipped.

Tests (executed, PASS): CommerceClient.spec — recordUsage builds the right body
(subject/amountMicros/requestId/totalTokens) + X-Hanzo-Org header, no _namespace
leak; the flag gate is OFF by default.

REMAINING (gated): thread `subject` into txMetadata at the spendTokens call
sites; wire credits (deposit/grantStarter); Step-3 live-verify; then retire local
writes behind the flag. Local Balance/Transaction writes are NOT retired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(billing): revert chat→commerce debit — the gateway is the single debit authority

The COMMERCE_WRITES chat-debit hook (bf6302a72c, today) is a double-debit
footgun and is reverted. It contradicts the established, LIVE architecture:

  - hanzoCloudKey.ts (module doc): every IAM user has one hk- Cloud key; "the
    cloud gateway (api.hanzo.ai) debits that key's org commerce balance and
    returns 402 when the org runs out. Forwarding the right per-user key ===
    correct per-user billing automatically." initialize.ts forwards that key on
    every authed request (baseURL api.hanzo.ai/v1).
  - packages/api usage.ts (NOTE, 2026-06-27): a prior chat→commerce write in
    recordCollectedUsage was a SECOND debit to a mis-keyed account; it was
    REMOVED. "Chat must NOT also record usage to Commerce."
  - prod CR universe/.../crs/chat.yaml: HANZO_PER_USER_KEY=true, balance gate
    off, and in its own words "chat records NO usage to commerce (that write was
    removed); the single debit is still cloud's, per the user's hk- key."

Two writers to one ledger for one spend = double charge. The gateway is the ONE
debit authority for AI spend across every product (chat/code/agents/API); a
per-client debit path is the wrong DRY seam. So chat stays a READER of Commerce.

Removed (the whole unused chat→commerce WRITE surface):
  - api/models/commerceWrites.js (the COMMERCE_WRITES gate + recordCommerceDebit)
  - the recordCommerceDebit call + import in Transaction.js (now a comment
    stating the single-debit invariant)
  - CommerceClient.recordUsage (the debit) + its now-dead _usageQueue /
    _flushUsageQueue machinery + interval; deposit/grantStarter (unused credit
    helpers — the real first-chat grant is resolveHanzoCloudKey's direct POST
    /v1/billing/grant-starter in packages/api, the correct layer)
  - CommerceClient.spec.js (only tested the removed surface)

Kept (CommerceClient is now purely read-only, one responsibility):
  checkBalance (fail-closed money gate), getTierConfig, isModelAllowed,
  getCreditBreakdown — all live (balanceMethods.js, Balance.js).

Verify: node --check clean on both edited files; zero dangling references to the
removed symbols repo-wide; balanceMethods.spec mocks only the kept READ surface.

Money domain off-Mongo status after this: Commerce (via the gateway) is the
balance/debit authority — no Mongo Balance/Transaction WRITE is authoritative in
prod (gate off). The local Transaction doc remains an in-app usage log only;
retiring/relocating it to the SQLite store is tracked with the cutover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(identity): User is a thin IAM projection — drop the local password credential (step a)

Identity migration step (a): IAM (hanzo.id) owns identity; chat's User doc is a
thin OIDC-keyed projection (provider='openid', openidId=userinfo.sub, org from
userinfo), never a second credential store.

Confirmed no OIDC path writes a password: openidStrategy.js (508-523) and
process.js createSocialUser build the User with NO password; createUser
(packages/data-schemas) writes no default; the schema field was select:false.
The ONLY writers were two local-email flows, both gated OFF in prod
(ALLOW_REGISTRATION=false, ALLOW_EMAIL_LOGIN=false).

Changes (source only — Docker rebuilds packages/data-schemas dist via
`pnpm run frontend`):
  - schema/user.ts: remove the `password` field. The User projection carries no
    local secret. (IUser keeps `password?: string` — always undefined — so the
    disabled local strategy / comparePassword still compile; the full
    local-strategy teardown lands with the cutover.)
  - AuthService.registerUser: stop writing password (local signup stores no
    credential).
  - AuthService.resetPassword: reject — "Password reset is managed by Hanzo IAM
    (hanzo.id)." Nothing to reset locally.

Login-safety: the authed OIDC flow (openidStrategy) references `password` ZERO
times — provably untouched by this change. comparePassword/localStrategy run
only when local login is enabled (off in prod), so removing the field is inert
for the live path.

Verify: data-schemas builds clean; store-registry + convo/message + user-schema
specs green (17/17 targeted); full suite identical to clean tree
(1210 pass / 250 pre-existing mongo-env fails / 100 skip — my change adds zero
failures). LIVE Dave-login verification is the deploy-time gate in the cutover
runbook — this branch is not deployed, so prod is untouched (default path stays
pure-mongoose + password field until the cutover deploy).

STOP before step (b): OPENID_REUSE_TOKENS is the documented login-breaker
(chat.yaml: =true → /api/auth/refresh 403 at hanzo.id → login dead). Do NOT flip
until the IAM refresh-token fix lands and is live-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:33:53 -07:00
zandGitHub c3257d2238 Merge pull request #59 from hanzoai/feat/kill-api-prefix
refactor(chat): kill /api/ prefix → /v1/chat/* (#42 flagship)
2026-07-03 13:58:15 -07:00
hanzo-dev 87b581388d fix(chat): migrate regex-form /api survivors (telemetry matchers + OAuth actionId parse)
Two regex-form OUR-route references the string-based sweep couldn't reach:

- api metrics.ts PATH_NORMALIZATIONS: matcher regexes still /^\\/api\\/...
  while replacements were /v1/chat/... — a half-migration. Real request
  paths (/v1/chat/*) never matched -> high-cardinality routes (stream/status/
  files/messages/convos/agents/tags/tools/sessions with IDs) unnormalized ->
  Prometheus label cardinality blowup. Matchers -> /^\\/v1\\/chat\\/.
  (Aligns with metrics.spec's already-migrated /v1/chat/X/#id expectations.)
- client ToolCall.tsx: regex extracting actionId from the Action OAuth
  redirect_uri matched /api/actions/:id/oauth/callback; callback is now
  /v1/chat/actions/... -> actionId parse failed -> Action OAuth UI broken.

OUR-route /api now ZERO in all forms (string, template, regex, cookie-path).
2026-07-03 13:55:43 -07:00
hanzo-dev 2abc855b9c fix(chat): migrate no-trailing-slash /api survivors the sweep missed
Blue's transform anchored on '/api/' (trailing slash) and missed 4 places
where the path ends exactly at '/api':

- client MarkdownComponents.tsx: chat-message file links built as
  ${origin}/api/files/... -> BROKEN (files now at /v1/chat/files).
  Fixed base -> /v1/chat. [user-facing regression: file downloads]
- data-provider getDomainServerBaseUrl(): same ${origin}/api -> /v1/chat.
- api oauth/csrf.ts OAUTH_SESSION_COOKIE_PATH '/api' -> '/v1/chat': the
  OAuth session cookie (24h CSRF fallback) was never sent to the relocated
  /v1/chat/{actions,mcp} callbacks. [vector-6 miss]
- api metrics.ts path normalization '=== /api' -> '=== /v1/chat' (matches
  sibling /images,/avatars pattern).

OUR-route /api/ now ZERO across mounts, fetches, base-URLs, cookie paths,
redirect_uris.
2026-07-03 13:55:43 -07:00
hanzo-dev fdbb17da6c fix(chat): revert over-swept third-party paths + fix resources API URL
Red-team review of the /api/ -> /v1/chat/* migration found the sweep
over-reached into paths that are NOT the chat mount prefix:

- client/src/utils/resources.ts: REMOTE_AGENT 'copy API endpoint' URL was
  mangled /api/v1/responses -> /v1/chat/v1/responses (double-prefix, no
  route serves it). Canonicalize to /v1/responses (OpenAI-compat Responses
  API). User-facing.
- data-provider actions.spec + openapiSpecs (scholar-ai.net, swapi.dev) +
  api mcp.spec wss template: third-party API path fixtures wrongly swept to
  /v1/chat/*. Reverted to /api/* (they represent EXTERNAL APIs, not chat
  routes). Fixes 5 failing createURL/executor tests.
- Stale doc comments: useFavorites (/v1/user -> /v1/chat/user), tokens.ts
  typedef ref.

OUR-route /api/ remains ZERO. Login/OAuth/SSE/CSRF paths unaffected.
2026-07-03 13:55:43 -07:00
hanzo-dev ec32864a00 refactor(chat): eliminate /api/ prefix -> /v1/chat/* (namespaced, no-collision)
App REST surface moves from /api/* to /v1/chat/* in lockstep across server
mounts, client URL builders, SSE stream paths, OAuth redirect_uris, telemetry
route-grouping, RUM proxy, and the nginx bridge.

Namespace /v1/chat/* (not flat /v1/) to avoid colliding with the OpenAI-compat
inference surface (/v1/chat/completions, /v1/models -> router_backend) and to
match the canonical chat/openapi.yaml. Social OIDC login (/oauth/*) and /health
are unchanged (login-safe).

Levers:
- server: api/server/index.js 26 app.use('/v1/chat/*') mounts (+ experimental.js)
- client: packages/data-provider/src/api-endpoints.ts (75 builders) + config.ts
Lockstep: checkBan matcher, Files/Code download path, actions/mcp CSRF cookie
paths, ActionService + mcp/oauth handler redirect_uris, admin OpenID callback,
telemetry middleware/sdk/stream, rum proxy path, vite dev proxy + PWA denylist,
robots.txt, .env.example, pr-preview health-path (/api/health -> /health).

Third-party APIs chat CALLS are untouched (Ollama, Wolfram, Discord, GitHub
Enterprise, Mistral, OpenRouter, DataDog). Live-surface /api/: 683 -> 0.
Also untracks runtime log artifacts (api/logs, client/junit.xml; already gitignored).
2026-07-03 13:55:43 -07:00
zeekayandClaude Opus 4.8 3e83bc6779 feat(guest): logged-out landing IS the chat composer + airtight per-IP quota (0.9.3)
ChatGPT-style anonymous preview: when ALLOW_GUEST_CHAT is on, a logged-out
visitor renders the real chat view (composer + starter cards + model picker)
and can send a message as a guest on the free Zen model, WITHOUT logging in.
Sign-in is only prompted after the free per-IP quota (402 GUEST_LIMIT).

Client (the missing wiring — server guest path was already complete):
- ChatRoute now renders ChatView for canChat = isAuthenticated || isGuest;
  /api/models + /api/endpoints queries run for guests (guest-scoped config),
  and the roles gate treats a guest as loaded (no agent access). Previously
  ChatRoute hard-returned null for !isAuthenticated, so a guest got the shell
  but no composer.
- useAuthRedirect now surfaces isGuest.

Abuse control (airtight, server-enforced):
- guestMessageLimiter + guestTokenLimiter now key on the REAL client IP via
  utils/guestClientIp (Cloudflare CF-Connecting-IP, falls back to req.ip), NOT
  the guest token — clearing cookies / incognito / minting a fresh token can't
  reset the count. Shared Redis limiterCache holds it across replicas.
- guestLimiters env parsed as positive ints.

Prod runs GUEST_MESSAGE_MAX=2. Guests always use the shared capped HANZO_API_KEY
(per-user hk- billing is skipped for guest principals).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:59:48 -07:00
zeekayandClaude Opus 4.8 39e5a71c7e chore(chat): release 0.9.2 — design unification (Basel Grotesk + Geist Mono, PanelLeft sidebar, dark tokens)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:55:06 -07:00
1eb2caecec fix(mcp): close redirect-based SSRF on MCP request path (backport LibreChat #12931) (#57)
* fix(mcp): block private IPs when the connector resolves all addresses

undici's connect-time DNS lookup calls the SSRF-safe wrapper with
{ all: true }, so dns.lookup returns a LookupAddress[] rather than a
single string. The previous guard only inspected `typeof address ===
'string'`, silently failing open on the array shape — a hostname
resolving to a private/reserved IP would pass unchecked.

Normalize both shapes to a flat address list and reject if ANY resolved
address is private, mirroring upstream LibreChat's getBlockedLookupAddress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mcp): re-check SSRF on every redirect hop, strip cross-origin creds

An MCP server URL is server-controlled. Our customFetch issued the
request with undici's default redirect:'follow', so a public MCP server
(which passes the add-time isMCPDomainAllowed check) could 301/302/307/
308-redirect the connection to an internal IP literal (169.254.169.254
cloud metadata, 127.0.0.1, in-cluster 10.x/172.16-31/192.168 services).
undici skips its connect-time DNS lookup for IP literals, so the
redirect hop reached the internal target unguarded — a live SSRF on the
multi-tenant chat surface (proven: a 301 to 127.0.0.1/latest/meta-data/
was followed).

Harden createFetchFunction to follow redirects manually (redirect:
'manual'):
- Only 307/308 are followed, up to MAX_REDIRECTS=5; 301/302/303 are
  returned unfollowed (the MCP SDK rejects a bare 3xx).
- Every hop's target is re-validated with isSSRFTarget (catches IP
  literals the connect lookup skips) + resolveHostnameSSRF (catches
  hostnames resolving to private IPs); a blocked hop is not followed.
- Cross-origin hops strip credential headers (Authorization, cookie,
  mcp-session-id, plus runtime/config secret header keys) so a bearer
  token / session id never leaks to a redirect target's origin.
- Cross-origin hops pin an SSRF-safe connect dispatcher for the rest of
  the chain, closing the allowlist-mode DNS-rebinding gap.

Mirrors upstream LibreChat MCP redirect SSRF hardening (PR #12931).
Redirect targets get no allowlist exemption by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(mcp): prove redirect SSRF guard rejects internal-IP targets

End-to-end test using the REAL isSSRFTarget/resolveHostnameSSRF
classifiers (the sibling MCPConnectionSSRF suite mocks ~/auth). Stands
up loopback HTTP servers and asserts a 302/307/308 redirect to an
internal IP literal is never followed — the redirect is issued
(entryHit) but the internal metadata endpoint is never reached
(internalHit stays false). Also pins the classifier: 169.254.169.254,
127.0.0.1, RFC1918, localhost, ::1 are SSRF targets; a public hostname
is not.

Reverting the connection.ts guard makes all three redirect cases fail
(internal endpoint reached), confirming the test exercises the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:54:19 -07:00
zandGitHub d44146f1fc Merge pull request #56 from hanzoai/design/unify-basel-geist-mono
design: unify Basel Grotesk + Geist Mono, sidebar icon, panels, dark tokens
2026-07-03 08:46:21 -07:00
278 changed files with 15567 additions and 4240 deletions
+16
View File
@@ -0,0 +1,16 @@
# depcheck configuration.
#
# ignores: dependencies that are present on purpose but never imported directly,
# so depcheck's static scan reports them as a false positive.
#
# The three @opentelemetry/* entries below are all transitive dependencies of
# @opentelemetry/sdk-node, which we DO import and drive (NodeSDK in
# packages/api/src/telemetry/sdk.ts). They are pinned at the top level to keep
# every otel package on one aligned version (mismatched otel versions break at
# runtime). None is imported by name, so depcheck flags them — expected, not
# dead. Do NOT remove them (that would unpin the otel version set). Only
# @opentelemetry/api is imported directly and stays checked.
ignores:
- "@opentelemetry/core"
- "@opentelemetry/exporter-trace-otlp-http"
- "@opentelemetry/sdk-trace-base"
+1 -1
View File
@@ -119,7 +119,7 @@ HANZO_API_KEY=
# Canonical Hanzo Cloud agents (/v1/agents). Lets signed-in users run their own
# cloud agents from chat via `/agent <name>` or the @mention picker. The chat
# backend proxies /api/agents/cloud/* to this host, forwarding the user's
# backend proxies /v1/chat/agents/cloud/* to this host, forwarding the user's
# hanzo.id token server-side (never to the browser); cloud scopes to their org.
# Optional: if unset, derived from OPENAI_BASE_URL (its host, minus /v1).
# HANZO_CLOUD_URL=https://api.hanzo.ai
+3
View File
@@ -21,6 +21,9 @@ jobs:
runs-on: hanzo-build-linux-amd64
env:
CI: true
# pnpm run frontend builds packages/api (rollup, ~5 GiB peak) + the client;
# the default ~2 GiB heap OOMs. Match the review workflows' heap knob.
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
NODE_ENV: CI
SEARCH: false
MONGO_URI: mongodb://localhost:27017/librechat-test
+23 -22
View File
@@ -119,30 +119,31 @@ jobs:
echo "unused_keys=[]" >> $GITHUB_ENV
fi
# Post via github-script (Octokit + the built-in token) rather than the gh
# CLI: gh is not installed on the self-hosted ARC runners, so `gh api` here
# exits 127. The job already grants `pull-requests: write`.
- name: Post verified comment on PR
if: env.unused_keys != '[]'
run: |
PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
# Format the unused keys list as checkboxes for easy manual checking.
FILTERED_KEYS=$(echo "$unused_keys" | jq -r '.[]' | grep -v '^\s*$' | sed 's/^/- [ ] `/;s/$/`/' )
COMMENT_BODY=$(cat <<EOF
### 🚨 Unused i18next Keys Detected
The following translation keys are defined in \`translation.json\` but are **not used** in the codebase:
$FILTERED_KEYS
⚠️ **Please remove these unused keys to keep the translation files clean.**
EOF
)
gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
-f body="$COMMENT_BODY" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@v7
with:
script: |
const keys = JSON.parse(process.env.unused_keys || '[]').filter(Boolean);
const list = keys.map((k) => `- [ ] \`${k}\``).join('\n');
const body = [
'### 🚨 Unused i18next Keys Detected',
'',
'The following translation keys are defined in `translation.json` but are **not used** in the codebase:',
'',
list,
'',
'⚠️ **Please remove these unused keys to keep the translation files clean.**',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
- name: Fail workflow if unused keys found
if: env.unused_keys != '[]'
+1 -1
View File
@@ -16,6 +16,6 @@ jobs:
service: chat
image: ghcr.io/hanzoai/chat
port: "3080"
health-path: /api/health
health-path: /health
e2e-dir: e2e
secrets: inherit
-57
View File
@@ -1,57 +0,0 @@
name: LiteLLM Linting
on:
pull_request:
branches: [ main ]
jobs:
lint:
runs-on: hanzo-build-linux-amd64
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install Poetry
uses: snok/install-poetry@v1
- name: Install dependencies
run: |
pip install openai==1.81.0
poetry install --with dev
pip install openai==1.81.0
- name: Run Black formatting
run: |
cd litellm
poetry run black .
cd ..
- name: Run Ruff linting
run: |
cd litellm
poetry run ruff check .
cd ..
- name: Run MyPy type checking
run: |
cd litellm
poetry run mypy . --ignore-missing-imports
cd ..
- name: Check for circular imports
run: |
cd litellm
poetry run python ../tests/documentation_tests/test_circular_imports.py
cd ..
- name: Check import safety
run: |
poetry run python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
+16
View File
@@ -10,6 +10,12 @@ jobs:
test:
runs-on: hanzo-build-linux-amd64
# The packages/api rollup build peaks ~5 GiB RSS and OOMs at Node's default
# ~2 GiB old-space heap. Match the heap the review workflows already use
# (well under the 8 GiB runner limit). One knob, same expression everywhere.
env:
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
services:
mongodb:
image: mongo:7
@@ -66,5 +72,15 @@ jobs:
MEILI_HOST: http://localhost:7700
MEILI_MASTER_KEY: test_master_key
# Gate against tests-without-implementation drift in the MCP connection/
# transport layer (proxy, response-size caps, WS SSRF, allowedAddresses).
# These suites live in packages/api and were never run by this workflow —
# which is how the connection.ts port drifted from its byte-identical tests.
# A future half-port now fails here loudly.
- name: Run MCP connection/transport tests
run: cd packages/api && pnpm run test:transport
env:
NODE_ENV: test
- name: Run client tests
run: pnpm run test:client
+27 -27
View File
@@ -248,35 +248,35 @@ jobs:
cd ..
fi
# Post via github-script (Octokit + the built-in token) rather than the gh
# CLI: gh is not installed on the self-hosted ARC runners, so `gh api` here
# exits 127. The job already grants `pull-requests: write`.
- name: Post comment on PR if unused dependencies are found
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
run: |
PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
ROOT_LIST=$(echo "$ROOT_UNUSED" | awk '{print "- `" $0 "`"}')
CLIENT_LIST=$(echo "$CLIENT_UNUSED" | awk '{print "- `" $0 "`"}')
API_LIST=$(echo "$API_UNUSED" | awk '{print "- `" $0 "`"}')
COMMENT_BODY=$(cat <<EOF
### 🚨 Unused NPM Packages Detected
The following **unused dependencies** were found:
$(if [[ ! -z "$ROOT_UNUSED" ]]; then echo "#### 📂 Root \`package.json\`"; echo ""; echo "$ROOT_LIST"; echo ""; fi)
$(if [[ ! -z "$CLIENT_UNUSED" ]]; then echo "#### 📂 Client \`client/package.json\`"; echo ""; echo "$CLIENT_LIST"; echo ""; fi)
$(if [[ ! -z "$API_UNUSED" ]]; then echo "#### 📂 API \`api/package.json\`"; echo ""; echo "$API_LIST"; echo ""; fi)
⚠️ **Please remove these unused dependencies to keep your project clean.**
EOF
)
gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
-f body="$COMMENT_BODY" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@v7
with:
script: |
const section = (title, val) => {
const items = (val || '').split('\n').map((s) => s.trim()).filter(Boolean);
if (!items.length) return '';
return `#### 📂 ${title}\n\n${items.map((i) => `- \`${i}\``).join('\n')}\n`;
};
const body = [
'### 🚨 Unused NPM Packages Detected',
'',
'The following **unused dependencies** were found:',
'',
section('Root `package.json`', process.env.ROOT_UNUSED),
section('Client `client/package.json`', process.env.CLIENT_UNUSED),
section('API `api/package.json`', process.env.API_UNUSED),
'⚠️ **Please remove these unused dependencies to keep your project clean.**',
].filter(Boolean).join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
- name: Fail workflow if unused dependencies found
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
+4 -2
View File
@@ -1,7 +1,9 @@
# v0.8.3-rc1
# Base node image
FROM node:20-alpine AS node
# Base node image — Hanzo Node 24 (Alpine) base: node:sqlite (DatabaseSync) is
# built in and native better-sqlite3 compiles (build-base + python3 + g++ baked
# in), so the CHAT_STORE_SQLITE document store runs. Node 20 lacked node:sqlite.
FROM ghcr.io/hanzoai/nodejs:v24.18.0 AS node
# Install jemalloc
RUN apk add --no-cache jemalloc
+1 -1
View File
@@ -5,7 +5,7 @@
ARG NODE_MAX_OLD_SPACE_SIZE=6144
# Base for all builds
FROM node:20-alpine AS base-min
FROM ghcr.io/hanzoai/nodejs:v24.18.0 AS base-min
# Install jemalloc
RUN apk add --no-cache jemalloc
# Set environment variable to use jemalloc
+1 -1
View File
@@ -6,7 +6,7 @@
# Build: docker build -f Dockerfile.static -t ghcr.io/hanzoai/chat:latest .
# Run: docker run -p 3080:3080 ghcr.io/hanzoai/chat:latest
FROM node:22-alpine
FROM ghcr.io/hanzoai/nodejs:v24.18.0
RUN npm install -g serve@14
+37 -16
View File
@@ -92,27 +92,48 @@ CREDS_KEY= CREDS_IV= # Credential encryption
## Guest Chat (anonymous preview)
Off by default (`ALLOW_GUEST_CHAT=false`). When enabled, unauthenticated
visitors get a per-IP free quota (`GUEST_MESSAGE_MAX`, default 3) on the free
Off by default (`ALLOW_GUEST_CHAT=false`). When enabled, the landing IS the chat
composer (ChatGPT-style): an unauthenticated visitor renders the real chat view —
composer, starter cards, model picker — WITHOUT logging in, scoped to the free
Zen model (`GUEST_MODEL`, default `zen3-nano`) via the `Hanzo` custom endpoint
(`api.hanzo.ai`). Exhausting the quota returns `402 {type:'GUEST_LIMIT'}` and
the client opens the existing OpenID/hanzo.id login.
(`api.hanzo.ai`). Prod uses `GUEST_MESSAGE_MAX=2`. Exhausting the quota returns
`402 {type:'GUEST_LIMIT'}` and the client opens the existing OpenID/hanzo.id login.
Client render path (guest === chat, not a marketing gate):
- `AuthContext` auto-acquires a guest token when `startupConfig.allowGuestChat`
is true (`silentRefresh` fallback + a dedicated effect closing the config race);
sets `isGuest=true`, `isAuthenticated=false`. `useAuthRedirect` keeps guests on
the chat surface (only truly anonymous, non-guest, non-guest-enabled users go to
`/login`). `Root` shows the chat shell for `isAuthenticated || isGuest`.
- `ChatRoute` renders `ChatView` for `canChat = isAuthenticated || isGuest`; the
`/v1/chat/models` + `/v1/chat/endpoints` queries run for guests (both routes use
`requireGuestOrJwtAuth` and return the guest-scoped single-model config), and the
roles gate treats a guest as loaded (guests have no agent access). Files:
`client/src/routes/{ChatRoute,useAuthRedirect}.tsx`, `hooks/useGuestAuth.ts`,
`hooks/AuthContext.tsx`, `components/Auth/GuestLimitDialog.tsx`.
Security model (fail-closed, server-enforced):
- `POST /api/auth/guest` issues a short-lived guest JWT (`{guest:true}`,
per-token random id) signed with `JWT_SECRET`. Rate-limited per IP.
- `POST /v1/chat/auth/guest` issues a short-lived guest JWT (`{guest:true}`,
per-token random id) signed with `JWT_SECRET`. Rate-limited per IP
(`guestTokenLimiter`, `GUEST_TOKEN_MAX`/`GUEST_TOKEN_WINDOW`) so tokens can't be
spam-minted.
- `requireGuestOrJwtAuth` (chat-completion route ONLY) accepts guest tokens;
the standard `jwt` strategy rejects them everywhere else (no DB user), so
every other route stays closed. `enforceGuestScope` pins endpoint+model and
strips agents/tools/files/spec/preset. `guestMessageLimiter` (reuses the
Redis `limiterCache`) enforces the per-IP quota.
strips agents/tools/files/spec/preset. Guests always use the shared, capped
`HANZO_API_KEY` (per-user `hk-` billing is skipped for `guest` principals).
- `guestMessageLimiter` enforces the quota against the REAL client IP
(`utils/guestClientIp` → Cloudflare `CF-Connecting-IP`, falls back to `req.ip`),
NOT the token — clearing cookies / incognito / minting a fresh token does NOT
reset it. Backed by the shared Redis `limiterCache` so it holds across replicas.
`USE_REDIS=true` is MANDATORY (a memory store would let a visitor round-robin
pods to multiply their quota).
- Key files: `api/server/services/guestConfig.js`,
`api/server/controllers/auth/GuestController.js`,
`api/server/middleware/{requireGuestOrJwtAuth,enforceGuestScope}.js`,
`api/server/middleware/limiters/{guestLimiters,guestMessageLimiter}.js`,
router wiring in `api/server/routes/agents/index.js`. Client:
`client/src/hooks/useGuestAuth.ts`, `AuthContext.tsx`,
`components/Auth/GuestLimitDialog.tsx`.
`api/server/utils/guestClientIp.js`,
router wiring in `api/server/routes/agents/index.js`.
- Env: `ALLOW_GUEST_CHAT`, `GUEST_MESSAGE_MAX`, `GUEST_ENDPOINT`, `GUEST_MODEL`,
`GUEST_TOKEN_EXPIRY`, `GUEST_TOKEN_MAX`, `GUEST_TOKEN_WINDOW`. Requires
`HANZO_API_KEY` (the free publishable gateway key) and `USE_REDIS` for the
@@ -126,7 +147,7 @@ agent builder, which is untouched.
- Two surfaces, ONE run path: the `/agent <name> [prompt]` slash command and the
@mention picker (cloud agents appear as a `cloudAgent` type). Both funnel
through `useRunCloudAgent``POST /api/agents/cloud/:name/run`. The @mention /
through `useRunCloudAgent``POST /v1/chat/agents/cloud/:name/run`. The @mention /
`/agent` picker arms `/agent <name> ` in the composer; submit is intercepted in
`ChatForm` (`parseAgentCommand`) and dispatched to the run path.
- Server proxy + auth (token never reaches the browser): the chat backend reads
@@ -158,7 +179,7 @@ agent builder, which is untouched.
`session.openidTokens.refreshToken` is written ONLY in REUSE mode (where
`refreshController`/`logoutController` read it). That keeps login, refresh AND
logout on the local-JWT path byte-identical to a non-OpenID login; that flag
SOLELY gates whether `/api/auth/refresh` performs the OIDC refresh-grant.
SOLELY gates whether `/v1/chat/auth/refresh` performs the OIDC refresh-grant.
The ~1h id_token is used while valid; durable refresh (hanzo.id/Casdoor OIDC
refresh or an RFC-8693 token-exchange from the chat session) is a tracked
FOLLOW-UP — the login-breaking refresh-grant is NOT enabled here.
@@ -176,7 +197,7 @@ agent builder, which is untouched.
`client/src/components/Chat/Input/AgentsCommand.tsx`, and the @mention wiring in
`client/src/hooks/Input/useMentions.ts` + `Mention.tsx`.
- Env: `HANZO_CLOUD_URL` (optional; falls back to the `OPENAI_BASE_URL` host).
- Convergence path (later): chat's LibreChat-legacy `/api/agents` CRUD should
- Convergence path (later): chat's LibreChat-legacy `/v1/chat/agents` CRUD should
converge onto cloud `/v1/agents`; this step only ADDS cloud-agent RUN.
## Unified cloud architecture (2026-07) — investigate-before-ripping map
@@ -188,7 +209,7 @@ Verified by full call-graph + route-table trace; do NOT rip blind.
### What already routes through the Go backend `api.hanzo.ai/v1` (no shadow LLM)
- **Chat completions**: client `useSSE``POST /api/agents/chat/Hanzo` (all
- **Chat completions**: client `useSSE``POST /v1/chat/agents/chat/Hanzo` (all
chat, incl. plain-model, goes through the agents framework) → custom-endpoint
resolver (`packages/api/src/endpoints/custom/initialize.ts`) reads
`HANZO_API_KEY` + literal `baseURL https://api.hanzo.ai/v1` from the loaded
@@ -198,7 +219,7 @@ Verified by full call-graph + route-table trace; do NOT rip blind.
- **Code interpreter** → `LIBRECHAT_CODE_BASEURL` = cloud `/v1/exec`.
- **Web search** → `webSearch` block (searxng+firecrawl contracts) = cloud
`/v1/websearch`.
- **Cloud agents** → `POST /api/agents/cloud/:name/run` server-proxies to cloud
- **Cloud agents** → `POST /v1/chat/agents/cloud/:name/run` server-proxies to cloud
`/v1/agents` with the user's hanzo.id bearer (see "Cloud Agents" section).
- **Model list**: curated **zen-only** (`fetch:false`) in the loaded config —
NO raw upstream names (brand policy). Authoritative prod list lives in the
+6 -6
View File
@@ -49,7 +49,7 @@ Artifacts are for substantial, self-contained content that users might modify or
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
- HTML: "text/html"
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
- Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags.
@@ -63,7 +63,7 @@ Artifacts are for substantial, self-contained content that users might modify or
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
@@ -162,7 +162,7 @@ Artifacts are for substantial, self-contained content that users might modify or
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
- HTML: "text/html"
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
- SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
@@ -186,7 +186,7 @@ Artifacts are for substantial, self-contained content that users might modify or
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
- When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
@@ -367,7 +367,7 @@ Artifacts are for substantial, self-contained content that users might modify or
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
- HTML: "text/html"
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
- SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
@@ -391,7 +391,7 @@ Artifacts are for substantial, self-contained content that users might modify or
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
- When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
+18 -8
View File
@@ -143,16 +143,26 @@ class DALLE3 extends Tool {
throw new Error('Missing required field: prompt');
}
// Model is configurable (DALLE3_MODEL) so this tool drives the Hanzo image
// family (e.g. zen3-image) through the same OpenAI /images/generations shape.
// `quality` and `style` are DALL-E-3-only knobs — send them ONLY for a dall-e
// model so a non-DALL-E backend never sees a parameter it may reject.
const model = process.env.DALLE3_MODEL || 'dall-e-3';
const isDallE = model.startsWith('dall-e');
const genParams = {
model,
size,
prompt: this.replaceUnwantedChars(prompt),
n: 1,
};
if (isDallE) {
genParams.quality = quality;
genParams.style = style;
}
let resp;
try {
resp = await this.openai.images.generate({
model: 'dall-e-3',
quality,
style,
size,
prompt: this.replaceUnwantedChars(prompt),
n: 1,
});
resp = await this.openai.images.generate(genParams);
} catch (error) {
logger.error('[DALL-E-3] Problem generating the image:', error);
return this
+26 -1
View File
@@ -12,6 +12,8 @@ const {
loadWebSearchAuth,
buildImageToolContext,
buildWebSearchContext,
resolveHanzoCloudKey,
isHanzoPerUserKeyEnabled,
} = require('@hanzochat/api');
const { getMCPServersRegistry } = require('~/config');
const {
@@ -232,7 +234,30 @@ const loadTools = async ({
const requestedTools = {};
if (functions === true) {
toolConstructors.dalle = DALLE3;
// dalle (image generation) drives the Hanzo image family via a PER-USER hk-
// key so generation is metered to the signed-in user — mirroring the chat
// per-user key path in custom/initialize.ts. A guest keeps the shared env
// key; an authenticated user whose key cannot be resolved FAILS CLOSED
// (throws) rather than silently billing the shared org. (Custom constructors
// take precedence over the generic toolConstructors path in the loop below.)
customConstructors.dalle = async () => {
const authFields = getAuthFields('dalle');
const authValues = await loadAuthValues({ userId: user, authFields });
const billingUser = options.req?.user;
const isAuthenticatedUser = Boolean(
billingUser && !billingUser.guest && billingUser.email,
);
if (isHanzoPerUserKeyEnabled() && isAuthenticatedUser) {
const perUserKey = await resolveHanzoCloudKey(billingUser);
if (!perUserKey) {
throw new Error(
'Your Hanzo Cloud account is not linked for billing yet. Please sign out and back in, then claim your starter credit at https://billing.hanzo.ai',
);
}
authValues.DALLE3_API_KEY = perUserKey;
}
return new DALLE3({ ...imageGenOptions, ...authValues, userId: user });
};
}
/** @type {ImageGenOptions} */
+15 -3
View File
@@ -5,9 +5,11 @@ const { logger } = require('@librechat/data-schemas');
const mongoose = require('mongoose');
const MONGO_URI = process.env.MONGO_URI;
if (!MONGO_URI) {
throw new Error('Please define the MONGO_URI environment variable');
}
// MONGO_URI is intentionally optional: in SQLite-only mode (every collection
// served from CHAT_STORE_SQLITE, chat-docdb deleted) there is no Mongo to
// connect to. connectDb() below handles the unset case by skipping the
// connection rather than failing boot. When MONGO_URI IS set (default and the
// dual-write migration window) the connection is required as before.
/** The maximum number of connections in the connection pool. */
const maxPoolSize = parseInt(process.env.MONGO_MAX_POOL_SIZE) || undefined;
/** The minimum number of connections in the connection pool. */
@@ -45,6 +47,16 @@ mongoose.connection.on('error', (err) => {
});
async function connectDb() {
if (!MONGO_URI) {
// SQLite-only mode: skip Mongo entirely. Disable command buffering so any
// stray mongoose query (e.g. Meili indexSync, which still references
// mongoose.models directly) fails fast and is swallowed by its caller
// instead of hanging forever on a pool that will never connect.
mongoose.set('bufferCommands', false);
logger.info('[connectDb] MONGO_URI unset — SQLite-only mode, skipping MongoDB connection');
return null;
}
if (cached.conn && cached.conn?._readyState === 1) {
return cached.conn;
}
@@ -1,15 +0,0 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/Users/z/work/hanzo/chat/api/logs/.124163c776d287793643ac0e08ac1d35d67ad894-audit.json",
"files": [
{
"date": 1771557162318,
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2026-02-19.log",
"hash": "a54018af15e4552979b0e91a2379ef40b95019fe56fe70074bb13f91952d908f"
}
],
"hashType": "sha256"
}
@@ -1,15 +0,0 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/Users/z/work/hanzo/chat/api/logs/.35252977fe148e605b7b92f19cd71e590a6f0a53-audit.json",
"files": [
{
"date": 1771557162297,
"name": "/Users/z/work/hanzo/chat/api/logs/error-2026-02-19.log",
"hash": "63688154da6c0a28cba1e30ddeef3eb867682460096d9b007dcfd2ddc25202b0"
}
],
"hashType": "sha256"
}
@@ -1,45 +0,0 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/Users/z/work/hanzo/chat/api/logs/.5a07238e142f72f47174c381d68b979f0f4a60b3-audit.json",
"files": [
{
"date": 1750474453457,
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-20.log",
"hash": "696c5dfed20dbc87cd7113adb058ad5df3e53b93b9fc24840b7e236724ac4823"
},
{
"date": 1750715218180,
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-23.log",
"hash": "c802b606c51329d1d65fe6c877dff02200e4d57e6565de3697ce4d8938bd8366"
},
{
"date": 1750883760400,
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-25.log",
"hash": "202106c1ae63bd10f256f9249f6f32e20e495b9ca7f5ab7844f6dff28716805a"
},
{
"date": 1750950599470,
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-26.log",
"hash": "e5f73d742f92938cef296a5c97cf8bb7f4e3db0198b8bc53bb0eee3ddf0f1e84"
},
{
"date": 1751049961804,
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-27.log",
"hash": "15a471f383c1ea303252a3b35f88e44cc4bd1905666617dc9afc4b415d3ddac9"
},
{
"date": 1751124937592,
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-28.log",
"hash": "77a7dd41fff34914a5ce25239c9fd77fafb8c3617a7a118550af69fbcc577643"
},
{
"date": 1751599940774,
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-07-03.log",
"hash": "c86e337d49f3edad24952270eb6bebba4588201f1e14eab31b2482ee0c68be00"
}
],
"hashType": "sha256"
}
@@ -1,30 +0,0 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/Users/z/work/hanzo/chat/api/logs/.b5a17c43715e8a0a84a729c6012dc1ba16b1828b-audit.json",
"files": [
{
"date": 1750954055845,
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2025-06-26.log",
"hash": "980f8267840afde6278855f4218760f010a3fb215aa86ef5bb69dc08bb4b1b50"
},
{
"date": 1751049961800,
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2025-06-27.log",
"hash": "cebe79495cabf77d359dbcd3168502d3a5c4d8993e1bed0663fa40d850ccf6d5"
},
{
"date": 1751124937590,
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2025-06-28.log",
"hash": "bb29a81af67a7fce02315648194ef210ef2ad3c89e7083220d9a63103dc762cc"
},
{
"date": 1751599940771,
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2025-07-03.log",
"hash": "9b454a63526db0eb56a27269fd38a86ac3d35dba85338ae89c91ea9895d467cb"
}
],
"hashType": "sha256"
}
-8
View File
@@ -1,8 +0,0 @@
2025-06-25T20:37:18.651Z info: [Optional] Redis not initialized.
2025-06-25T20:37:20.168Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-25T20:39:50.405Z info: [Optional] Redis not initialized.
2025-06-25T20:39:51.070Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-25T20:40:07.114Z info: [Optional] Redis not initialized.
2025-06-25T20:40:07.770Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-25T20:40:53.234Z info: [Optional] Redis not initialized.
2025-06-25T20:40:53.905Z info: [Optional] IoRedis not initialized for rate limiters.
-115
View File
@@ -1,115 +0,0 @@
2025-06-26T15:10:34.209Z info: [Optional] Redis not initialized.
2025-06-26T15:10:35.166Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T16:21:35.171Z info: [Optional] Redis not initialized.
2025-06-26T16:21:36.574Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T16:22:06.626Z error: connect ECONNREFUSED 127.0.0.1:27017
2025-06-26T16:23:03.561Z info: [Optional] Redis not initialized.
2025-06-26T16:23:04.255Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T16:23:04.300Z info: Connected to MongoDB
2025-06-26T16:23:04.301Z info: [indexSync] Starting index synchronization check...
2025-06-26T16:23:04.420Z error: Invalid custom config file at /Users/z/work/hanzo/chat/chat.yaml:
{
"issues": [
{
"code": "unrecognized_keys",
"keys": [
"ag... [truncated]
2025-06-26T16:23:04.420Z debug: Web search serperApiKey: Using environment variable SERPER_API_KEY (not set in environment, user provided value)
2025-06-26T16:23:04.420Z debug: Web search firecrawlApiKey: Using environment variable FIRECRAWL_API_KEY (not set in environment, user provided value)
2025-06-26T16:23:04.420Z debug: Web search firecrawlApiUrl: Using environment variable FIRECRAWL_API_URL (not set in environment, user provided value)
2025-06-26T16:23:04.421Z debug: Web search jinaApiKey: Using environment variable JINA_API_KEY (not set in environment, user provided value)
2025-06-26T16:23:04.421Z debug: Web search cohereApiKey: Using environment variable COHERE_API_KEY (not set in environment, user provided value)
2025-06-26T16:23:04.421Z warn: Default value for CREDS_KEY is being used.
2025-06-26T16:23:04.421Z warn: Default value for CREDS_IV is being used.
2025-06-26T16:23:04.421Z warn: Default value for JWT_SECRET is being used.
2025-06-26T16:23:04.421Z warn: Default value for JWT_REFRESH_SECRET is being used.
2025-06-26T16:23:04.421Z info: Please replace any default secret values.
2025-06-26T16:23:04.421Z info:
For your convenience, use this tool to generate your own secret values:
https://www.hanzo.ai/toolkit/creds_generator
2025-06-26T16:23:04.421Z warn: RAG API is either not running or not reachable at undefined, you may experience errors with file uploads.
2025-06-26T16:23:04.429Z info: No changes needed for 'USER' role permissions
2025-06-26T16:23:04.431Z info: No changes needed for 'ADMIN' role permissions
2025-06-26T16:23:04.431Z info: Turnstile is DISABLED (no siteKey provided).
2025-06-26T16:24:16.068Z info: [Optional] Redis not initialized.
2025-06-26T16:24:17.688Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T16:24:17.756Z info: Connected to MongoDB
2025-06-26T16:24:17.757Z info: [indexSync] Starting index synchronization check...
2025-06-26T16:24:17.811Z error: Invalid custom config file at /Users/z/work/hanzo/chat/chat.yaml:
{
"issues": [
{
"code": "unrecognized_keys",
"keys": [
"ag... [truncated]
2025-06-26T16:24:17.812Z debug: Web search serperApiKey: Using environment variable SERPER_API_KEY (not set in environment, user provided value)
2025-06-26T16:24:17.812Z debug: Web search firecrawlApiKey: Using environment variable FIRECRAWL_API_KEY (not set in environment, user provided value)
2025-06-26T16:24:17.812Z debug: Web search firecrawlApiUrl: Using environment variable FIRECRAWL_API_URL (not set in environment, user provided value)
2025-06-26T16:24:17.812Z debug: Web search jinaApiKey: Using environment variable JINA_API_KEY (not set in environment, user provided value)
2025-06-26T16:24:17.812Z debug: Web search cohereApiKey: Using environment variable COHERE_API_KEY (not set in environment, user provided value)
2025-06-26T16:24:17.812Z warn: Default value for CREDS_KEY is being used.
2025-06-26T16:24:17.812Z warn: Default value for CREDS_IV is being used.
2025-06-26T16:24:17.812Z warn: Default value for JWT_SECRET is being used.
2025-06-26T16:24:17.812Z warn: Default value for JWT_REFRESH_SECRET is being used.
2025-06-26T16:24:17.812Z info: Please replace any default secret values.
2025-06-26T16:24:17.812Z info:
For your convenience, use this tool to generate your own secret values:
https://www.hanzo.ai/toolkit/creds_generator
2025-06-26T16:24:17.812Z warn: RAG API is either not running or not reachable at undefined, you may experience errors with file uploads.
2025-06-26T16:24:17.823Z info: No changes needed for 'USER' role permissions
2025-06-26T16:24:17.825Z info: No changes needed for 'ADMIN' role permissions
2025-06-26T16:24:17.825Z info: Turnstile is DISABLED (no siteKey provided).
2025-06-26T16:24:17.832Z info: Server listening at http://localhost:3080
2025-06-26T16:24:18.010Z debug: [MEILI_SYNC:meili-index-sync] Creating initial flow state
2025-06-26T16:24:18.020Z info: [indexSync] Messages are fully synced: 0/0
2025-06-26T16:24:18.023Z info: [indexSync] Conversations are fully synced: 0/0
2025-06-26T16:24:18.024Z debug: [indexSync] No sync was needed
2025-06-26T16:24:37.105Z error: invalid signature
2025-06-26T17:16:21.631Z info: Cleaning up FlowStateManager intervals...
2025-06-26T17:28:31.428Z info: [Optional] Redis not initialized.
2025-06-26T19:16:07.812Z info: [Optional] Redis not initialized.
2025-06-26T19:16:09.408Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T19:16:39.465Z error: connect ECONNREFUSED 127.0.0.1:27017
2025-06-26T19:28:57.619Z info: [Optional] Redis not initialized.
2025-06-26T19:28:58.798Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:18:29.425Z info: [Optional] Redis not initialized.
2025-06-26T21:18:30.678Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:19:00.734Z error: getaddrinfo ENOTFOUND mongodb
2025-06-26T21:20:04.085Z info: [Optional] Redis not initialized.
2025-06-26T21:20:04.788Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:20:34.832Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
2025-06-26T21:35:21.338Z info: [Optional] Redis not initialized.
2025-06-26T21:35:22.746Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:35:52.803Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
2025-06-26T21:39:16.497Z info: [Optional] Redis not initialized.
2025-06-26T21:39:17.655Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:39:47.696Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
2025-06-26T21:43:04.982Z info: [Optional] Redis not initialized.
2025-06-26T21:43:06.587Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:43:07.208Z info: [Optional] Redis not initialized.
2025-06-26T21:43:07.990Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:43:09.790Z info: [Optional] Redis not initialized.
2025-06-26T21:43:10.572Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:43:12.993Z info: [Optional] Redis not initialized.
2025-06-26T21:43:14.084Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:43:16.627Z info: [Optional] Redis not initialized.
2025-06-26T21:43:17.383Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:43:19.449Z info: [Optional] Redis not initialized.
2025-06-26T21:43:20.212Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:43:34.812Z info: [Optional] Redis not initialized.
2025-06-26T21:43:36.179Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-26T21:43:50.258Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
2025-06-26T21:44:17.565Z info: [Optional] Redis not initialized.
2025-06-26T21:44:18.322Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-27T03:16:07.463Z info: [Optional] Redis not initialized.
2025-06-27T03:16:08.973Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-27T03:16:17.650Z debug: [indexSync] Clearing sync timeouts before exiting...
2025-06-27T03:19:09.423Z info: [Optional] Redis not initialized.
2025-06-27T03:19:10.741Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-27T03:19:15.742Z debug: [indexSync] Clearing sync timeouts before exiting...
-3
View File
@@ -1,3 +0,0 @@
2025-06-27T18:46:02.321Z info: [Optional] Redis not initialized.
2025-06-27T18:46:03.818Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-27T18:46:23.237Z debug: [indexSync] Clearing sync timeouts before exiting...
-3
View File
@@ -1,3 +0,0 @@
2025-06-28T15:35:37.951Z info: [Optional] Redis not initialized.
2025-06-28T15:35:39.502Z info: [Optional] IoRedis not initialized for rate limiters.
2025-06-28T15:36:09.559Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
-3
View File
@@ -1,3 +0,0 @@
2025-07-04T03:32:21.282Z info: [Optional] Redis not initialized.
2025-07-04T03:32:22.739Z info: [Optional] IoRedis not initialized for rate limiters.
2025-07-04T03:32:40.543Z debug: [indexSync] Clearing sync timeouts before exiting...
-4
View File
@@ -1,4 +0,0 @@
{"level":"error","message":"[mongoMeili] Error checking index convos: fetch failed","name":"MeiliSearchCommunicationError","stack":"MeiliSearchCommunicationError: fetch failed\n at node:internal/deps/undici/undici:13510:13\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)"}
{"level":"error","message":"[mongoMeili] Error checking index messages: fetch failed","name":"MeiliSearchCommunicationError","stack":"MeiliSearchCommunicationError: fetch failed\n at node:internal/deps/undici/undici:13510:13\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)"}
{"level":"error","message":"[mongoMeili] Error checking index convos: fetch failed","name":"MeiliSearchCommunicationError","stack":"MeiliSearchCommunicationError: fetch failed\n at node:internal/deps/undici/undici:13510:13\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)"}
{"level":"error","message":"[mongoMeili] Error checking index messages: fetch failed","name":"MeiliSearchCommunicationError","stack":"MeiliSearchCommunicationError: fetch failed\n at node:internal/deps/undici/undici:13510:13\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)"}
-4
View File
@@ -1,4 +0,0 @@
{"level":"debug","message":"[mongoMeili] Index convos already exists"}
{"level":"debug","message":"[mongoMeili] Index convos already exists"}
{"level":"debug","message":"[mongoMeili] Index messages already exists"}
{"level":"debug","message":"[mongoMeili] Index messages already exists"}
-4
View File
@@ -1,4 +0,0 @@
{"level":"debug","message":"[mongoMeili] Index messages already exists"}
{"level":"debug","message":"[mongoMeili] Index convos already exists"}
{"level":"debug","message":"[mongoMeili] Index messages already exists"}
{"level":"debug","message":"[mongoMeili] Index convos already exists"}
+6 -6
View File
@@ -2189,13 +2189,13 @@ describe('models/Agent', () => {
const actions = [
{
action_id: '123',
metadata: { version: '1.0', endpoints: ['GET /api/test'], schema: { type: 'object' } },
metadata: { version: '1.0', endpoints: ['GET /v1/chat/test'], schema: { type: 'object' } },
},
{
action_id: '456',
metadata: {
version: '2.0',
endpoints: ['POST /api/example'],
endpoints: ['POST /v1/chat/example'],
schema: { type: 'string' },
},
},
@@ -2212,10 +2212,10 @@ describe('models/Agent', () => {
test('should generate different hashes for different action metadata', async () => {
const actionIds = ['test.com_action_123'];
const actions1 = [
{ action_id: '123', metadata: { version: '1.0', endpoints: ['GET /api/test'] } },
{ action_id: '123', metadata: { version: '1.0', endpoints: ['GET /v1/chat/test'] } },
];
const actions2 = [
{ action_id: '123', metadata: { version: '2.0', endpoints: ['GET /api/test'] } },
{ action_id: '123', metadata: { version: '2.0', endpoints: ['GET /v1/chat/test'] } },
];
const hash1 = await generateActionMetadataHash(actionIds, actions1);
@@ -2284,8 +2284,8 @@ describe('models/Agent', () => {
},
},
endpoints: [
{ path: '/api/test', method: 'GET', params: ['id'] },
{ path: '/api/create', method: 'POST', body: true },
{ path: '/v1/chat/test', method: 'GET', params: ['id'] },
{ path: '/v1/chat/create', method: 'POST', body: true },
],
},
},
+7 -2
View File
@@ -1,6 +1,5 @@
const {
CacheKeys,
SystemRoles,
roleDefaults,
permissionsSchema,
removeNullishValues,
@@ -31,7 +30,13 @@ const getRoleByName = async function (roleName, fieldsToSelect = null) {
}
let role = await query.lean().exec();
if (!role && SystemRoles[roleName]) {
// Only self-heal a missing system role when we actually hold its canonical
// defaults. Keying on SystemRoles[roleName] (enum membership) alone let a
// role that exists in the enum but lacks a roleDefaults entry (e.g. GUEST)
// fall into `new Role(undefined).save()` -> "name is required" -> every
// generation for that role died. roleDefaults[roleName] always carries a
// name, so this guard makes the nameless create structurally impossible.
if (!role && roleDefaults[roleName]) {
role = await new Role(roleDefaults[roleName]).save();
await cache.set(roleName, role);
return role.toObject();
+8
View File
@@ -214,6 +214,14 @@ async function createTransaction(_txData) {
incrementValue,
});
// NB: chat records NO debit to Commerce here. The single debit for an AI spend
// is the cloud gateway's (api.hanzo.ai): every authenticated request forwards
// the user's own hk- key and the gateway debits that key's Commerce balance
// (per-user, fail-closed, 402 when empty). A second debit from chat would
// double-charge — the incident that removed the write in recordCollectedUsage
// (packages/api usage.ts). This local Transaction/Balance is the in-app usage
// ledger only; in prod the balance gate is off (chat.yaml balance.enabled=false).
return {
rate: transaction.rate,
user: transaction.user.toString(),
@@ -45,7 +45,7 @@ const validateResourceType = (resourceType) => {
/**
* Bulk update permissions for a resource (grant, update, remove)
* @route PUT /api/{resourceType}/{resourceId}/permissions
* @route PUT /v1/chat/{resourceType}/{resourceId}/permissions
* @param {Object} req - Express request object
* @param {Object} req.params - Route parameters
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
@@ -178,7 +178,7 @@ const updateResourcePermissions = async (req, res) => {
/**
* Get principals with their permission roles for a resource (UI-friendly format)
* Uses efficient aggregation pipeline to join User/Group data in single query
* @route GET /api/permissions/{resourceType}/{resourceId}
* @route GET /v1/chat/permissions/{resourceType}/{resourceId}
*/
const getResourcePermissions = async (req, res) => {
try {
@@ -311,7 +311,7 @@ const getResourcePermissions = async (req, res) => {
/**
* Get available roles for a resource type
* @route GET /api/{resourceType}/roles
* @route GET /v1/chat/{resourceType}/roles
*/
const getResourceRoles = async (req, res) => {
try {
@@ -339,7 +339,7 @@ const getResourceRoles = async (req, res) => {
/**
* Get user's effective permission bitmask for a resource
* @route GET /api/{resourceType}/{resourceId}/effective
* @route GET /v1/chat/{resourceType}/{resourceId}/effective
*/
const getUserEffectivePermissions = async (req, res) => {
try {
@@ -370,7 +370,7 @@ const getUserEffectivePermissions = async (req, res) => {
/**
* Search for users and groups to grant permissions
* Supports hybrid local database + Entra ID search when configured
* @route GET /api/permissions/search-principals
* @route GET /v1/chat/permissions/search-principals
*/
const searchPrincipals = async (req, res) => {
try {
@@ -487,7 +487,7 @@ const searchPrincipals = async (req, res) => {
/**
* Get user's effective permissions for all accessible resources of a type
* @route GET /api/permissions/{resourceType}/effective/all
* @route GET /v1/chat/permissions/{resourceType}/effective/all
*/
const getAllEffectivePermissions = async (req, res) => {
try {
@@ -60,7 +60,7 @@ describe('guest bootstrap controllers', () => {
process.env = originalEnv;
});
describe('GET /api/endpoints', () => {
describe('GET /v1/chat/endpoints', () => {
it('returns ONLY the guest endpoint, no DB/config read', async () => {
const req = guestReq();
const res = mockRes();
@@ -80,7 +80,7 @@ describe('guest bootstrap controllers', () => {
});
});
describe('GET /api/models', () => {
describe('GET /v1/chat/models', () => {
it('returns ONLY the single guest model under the guest endpoint', async () => {
const req = guestReq();
const res = mockRes();
+28 -1
View File
@@ -15,6 +15,7 @@ const {
getBalanceConfig,
getProviderConfig,
omitTitleOptions,
wrapHanzoGatewayFetch,
memoryInstructions,
applyContextToAgent,
createTokenCounter,
@@ -940,7 +941,7 @@ class AgentClient extends BaseClient {
}
/** Skip token spending if aborted - the abort handler (abortMiddleware.js) handles it
This prevents double-spending when user aborts via `/api/agents/chat/abort` */
This prevents double-spending when user aborts via `/v1/chat/agents/chat/abort` */
const wasAborted = abortController?.signal?.aborted;
if (!wasAborted) {
await this.recordCollectedUsage({
@@ -1069,6 +1070,22 @@ class AgentClient extends BaseClient {
clientOptions.configuration = options.configOptions;
}
// Envelope-safety for the title path (parity with agent runs). The Hanzo Cloud
// gateway answers some failures with HTTP 200 + a JSON error-envelope
// ({status:"error", msg}) that carries no `choices`. Left raw, the OpenAI client
// parses the 200 to `undefined` and #titleConvo crashes on
// `reading 'message'` — so no title is ever written. Wrap the title client's
// fetch exactly as `initializeCustom` does for agent runs, so the envelope
// becomes a clean, catchable error the outer try/catch skips gracefully.
// Response-only + idempotent (a re-wrap sees a 402, not a 200 envelope), so
// per-user hk- billing and normal completions are untouched.
if (
clientOptions.configuration &&
/(?:^|\.)hanzo\.ai(?::|\/|$)/i.test(clientOptions.configuration.baseURL ?? '')
) {
clientOptions.configuration.fetch = wrapHanzoGatewayFetch(clientOptions.configuration.fetch);
}
if (clientOptions.maxTokens != null) {
delete clientOptions.maxTokens;
}
@@ -1085,6 +1102,16 @@ class AgentClient extends BaseClient {
),
);
// Force the title generation onto the STREAMING (SSE) path. The Hanzo Cloud
// gateway's non-streaming (`stream:false`) chat.completion body — though a
// valid `{choices:[{message}]}` — is parsed to ZERO generations by the pinned
// langchain ChatOpenAI, so `invoke()` then throws
// `Cannot read properties of undefined (reading 'message')` and NO title is
// ever written. The streaming path (which every agent RUN already uses, hence
// generation works) parses the same gateway correctly. `streaming` is stripped
// by `omitTitleOptions` above, so it must be (re)set here, after the filter.
clientOptions.streaming = true;
if (
provider === Providers.GOOGLE &&
(endpointConfig?.titleMethod === TitleMethod.FUNCTIONS ||
+2 -2
View File
@@ -64,7 +64,7 @@ function createOAuthHandler(redirectUri = domains.client) {
if (isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
/**
* REUSE path: the OIDC tokenset drives BOTH the app auth token and the
* refresh grant (`/api/auth/refresh` performs an OIDC refresh). Unchanged.
* refresh grant (`/v1/chat/auth/refresh` performs an OIDC refresh). Unchanged.
* setOpenIDAuthTokens already persists the id_token to the session.
*/
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
@@ -74,7 +74,7 @@ function createOAuthHandler(redirectUri = domains.client) {
* Decoupled path (the live default, REUSE disabled). Keep refresh on the
* local-JWT path so login/refresh cookies stay byte-identical, but ALSO
* persist the id_token server-side so downstream on-behalf-of cloud calls
* (POST /api/agents/cloud/:name/run -> cloud /v1/agents) can run as this
* (POST /v1/chat/agents/cloud/:name/run -> cloud /v1/agents) can run as this
* hanzo.id principal. OPENID_REUSE_TOKENS still SOLELY gates the OIDC
* refresh-grant; it no longer gates id_token persistence.
*/
+4 -4
View File
@@ -172,7 +172,7 @@ const getMCPTools = async (req, res) => {
};
/**
* Get all MCP servers with permissions
* @route GET /api/mcp/servers
* @route GET /v1/chat/mcp/servers
*/
const getMCPServersList = async (req, res) => {
try {
@@ -193,7 +193,7 @@ const getMCPServersList = async (req, res) => {
/**
* Create MCP server
* @route POST /api/mcp/servers
* @route POST /v1/chat/mcp/servers
*/
const createMCPServerController = async (req, res) => {
try {
@@ -252,7 +252,7 @@ const getMCPServerById = async (req, res) => {
/**
* Update MCP server
* @route PATCH /api/mcp/servers/:serverName
* @route PATCH /v1/chat/mcp/servers/:serverName
*/
const updateMCPServerController = async (req, res) => {
try {
@@ -287,7 +287,7 @@ const updateMCPServerController = async (req, res) => {
/**
* Delete MCP server
* @route DELETE /api/mcp/servers/:serverName
* @route DELETE /v1/chat/mcp/servers/:serverName
*/
const deleteMCPServerController = async (req, res) => {
try {
+26 -26
View File
@@ -296,33 +296,33 @@ if (cluster.isMaster) {
/** Routes */
app.use('/oauth', routes.oauth);
app.use('/api/auth', routes.auth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/api-keys', routes.apiKeys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/presets', routes.presets);
app.use('/api/prompts', routes.prompts);
app.use('/api/categories', routes.categories);
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models);
app.use('/api/plugins', routes.plugins);
app.use('/api/config', routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/v1/chat/auth', routes.auth);
app.use('/v1/chat/actions', routes.actions);
app.use('/v1/chat/keys', routes.keys);
app.use('/v1/chat/api-keys', routes.apiKeys);
app.use('/v1/chat/user', routes.user);
app.use('/v1/chat/search', routes.search);
app.use('/v1/chat/messages', routes.messages);
app.use('/v1/chat/convos', routes.convos);
app.use('/v1/chat/presets', routes.presets);
app.use('/v1/chat/prompts', routes.prompts);
app.use('/v1/chat/categories', routes.categories);
app.use('/v1/chat/endpoints', routes.endpoints);
app.use('/v1/chat/balance', routes.balance);
app.use('/v1/chat/models', routes.models);
app.use('/v1/chat/plugins', routes.plugins);
app.use('/v1/chat/config', routes.config);
app.use('/v1/chat/assistants', routes.assistants);
app.use('/v1/chat/files', await routes.files.initialize());
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
app.use('/api/share', routes.share);
app.use('/api/roles', routes.roles);
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
app.use('/api/memories', routes.memories);
app.use('/api/permissions', routes.accessPermissions);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
app.use('/v1/chat/share', routes.share);
app.use('/v1/chat/roles', routes.roles);
app.use('/v1/chat/agents', routes.agents);
app.use('/v1/chat/banner', routes.banner);
app.use('/v1/chat/memories', routes.memories);
app.use('/v1/chat/permissions', routes.accessPermissions);
app.use('/v1/chat/tags', routes.tags);
app.use('/v1/chat/mcp', routes.mcp);
/** Error handler */
app.use(ErrorController);
+26 -26
View File
@@ -174,34 +174,34 @@ const startServer = async () => {
app.use('/oauth', routes.oauth);
/* API Endpoints */
app.use('/api/auth', routes.auth);
app.use('/api/admin', routes.adminAuth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/api-keys', routes.apiKeys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/presets', routes.presets);
app.use('/api/prompts', routes.prompts);
app.use('/api/categories', routes.categories);
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models);
app.use('/api/config', routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/v1/chat/auth', routes.auth);
app.use('/v1/chat/admin', routes.adminAuth);
app.use('/v1/chat/actions', routes.actions);
app.use('/v1/chat/keys', routes.keys);
app.use('/v1/chat/api-keys', routes.apiKeys);
app.use('/v1/chat/user', routes.user);
app.use('/v1/chat/search', routes.search);
app.use('/v1/chat/messages', routes.messages);
app.use('/v1/chat/convos', routes.convos);
app.use('/v1/chat/presets', routes.presets);
app.use('/v1/chat/prompts', routes.prompts);
app.use('/v1/chat/categories', routes.categories);
app.use('/v1/chat/endpoints', routes.endpoints);
app.use('/v1/chat/balance', routes.balance);
app.use('/v1/chat/models', routes.models);
app.use('/v1/chat/config', routes.config);
app.use('/v1/chat/assistants', routes.assistants);
app.use('/v1/chat/files', await routes.files.initialize());
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
app.use('/api/share', routes.share);
app.use('/api/roles', routes.roles);
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
app.use('/api/memories', routes.memories);
app.use('/api/permissions', routes.accessPermissions);
app.use('/v1/chat/share', routes.share);
app.use('/v1/chat/roles', routes.roles);
app.use('/v1/chat/agents', routes.agents);
app.use('/v1/chat/banner', routes.banner);
app.use('/v1/chat/memories', routes.memories);
app.use('/v1/chat/permissions', routes.accessPermissions);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
app.use('/v1/chat/tags', routes.tags);
app.use('/v1/chat/mcp', routes.mcp);
app.use(ErrorController);
+1 -1
View File
@@ -111,7 +111,7 @@ describe('Server Configuration', () => {
});
try {
const response = await request(app).post('/api/auth/login').send({
const response = await request(app).post('/v1/chat/auth/login').send({
email: 'test@example.com',
password: 'password123',
});
@@ -22,6 +22,8 @@ jest.mock('librechat-data-provider', () => ({
jest.mock('~/server/utils', () => ({
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
guestClientIp: (req) =>
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
}));
jest.mock('../requireJwtAuth', () =>
@@ -70,7 +72,7 @@ const buildRouter = () => {
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/api/agents', buildRouter());
app.use('/v1/chat/agents', buildRouter());
return app;
};
@@ -94,7 +96,7 @@ describe('guest chat middleware chain', () => {
it('lets a guest reach the completion route, pinned to the free endpoint/model', async () => {
const res = await request(buildApp())
.post('/api/agents/chat')
.post('/v1/chat/agents/chat')
.set('Authorization', `Bearer ${guestToken()}`)
.set('x-test-ip', '10.1.0.1')
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
@@ -107,7 +109,7 @@ describe('guest chat middleware chain', () => {
const ip = '10.1.0.2';
const send = () =>
request(app)
.post('/api/agents/chat')
.post('/v1/chat/agents/chat')
.set('Authorization', `Bearer ${guestToken()}`)
.set('x-test-ip', ip)
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
@@ -121,7 +123,7 @@ describe('guest chat middleware chain', () => {
it('routes reserved /chat/abort to the JWT-only handler, not the guest router', async () => {
const res = await request(buildApp())
.post('/api/agents/chat/abort')
.post('/v1/chat/agents/chat/abort')
.set('Authorization', `Bearer ${guestToken()}`)
.set('x-test-ip', '10.1.0.3')
.send({ streamId: 'x' });
@@ -46,7 +46,7 @@ const buildEndpointOption = require('./buildEndpointOption');
const createReq = (body, config = {}) => ({
body,
config,
baseUrl: '/api/chat',
baseUrl: '/v1/chat/chat',
});
const createRes = () => ({
+1 -1
View File
@@ -26,7 +26,7 @@ const banResponse = async (req, res) => {
const { baseUrl, originalUrl } = req;
if (!ua.browser.name) {
return res.status(403).json({ message });
} else if (baseUrl === '/api/agents' && originalUrl.startsWith('/api/agents/chat')) {
} else if (baseUrl === '/v1/chat/agents' && originalUrl.startsWith('/v1/chat/agents/chat')) {
return await denyRequest(req, res, { type: ViolationTypes.BAN });
}
@@ -4,7 +4,7 @@ const { ViolationTypes } = require('librechat-data-provider');
const logViolation = require('~/cache/logViolation');
/**
* Per-user rate limiter for the cloud-agents proxy (`/api/agents/cloud/*`). A run
* Per-user rate limiter for the cloud-agents proxy (`/v1/chat/agents/cloud/*`). A run
* is a real, billable cloud chat completion that holds an upstream socket for up
* to 30s, yet it bypasses the message limiters that guard the sibling completion
* path. This caps a signed-in user to CLOUD_AGENT_USER_MAX requests per window so
@@ -1,8 +1,14 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { removePorts } = require('~/server/utils');
const { guestClientIp } = require('~/server/utils');
const { GUEST_TOKEN_WINDOW = 60, GUEST_TOKEN_MAX = 20 } = process.env;
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const GUEST_TOKEN_WINDOW = parsePositiveInt(process.env.GUEST_TOKEN_WINDOW, 60);
const GUEST_TOKEN_MAX = parsePositiveInt(process.env.GUEST_TOKEN_MAX, 20);
const windowMs = GUEST_TOKEN_WINDOW * 60 * 1000;
const max = GUEST_TOKEN_MAX;
@@ -16,12 +22,19 @@ const handler = (req, res) => {
/**
* Per-IP rate limiter for guest token issuance.
*
* Caps how many guest tokens a single client IP can mint per window so nobody can
* spam-mint tokens (a DoS / quota-probe vector). Keyed on the REAL client IP
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`) and backed by the shared
* Redis `limiterCache` so the cap holds across replicas. Note this is defense in
* depth only: even unlimited tokens cannot multiply the message quota, which is
* itself keyed per-IP (see `guestMessageLimiter`).
*/
const guestTokenLimiter = rateLimit({
windowMs,
max,
handler,
keyGenerator: removePorts,
keyGenerator: guestClientIp,
store: limiterCache('guest_token_limiter'),
});
@@ -1,6 +1,6 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { removePorts } = require('~/server/utils');
const { guestClientIp } = require('~/server/utils');
const { getGuestConfig } = require('~/server/services/guestConfig');
const GUEST_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000;
@@ -16,7 +16,11 @@ const handler = (req, res) => {
* Per-IP quota limiter for anonymous guest chat completions.
*
* Reuses the shared Redis-backed `limiterCache` infrastructure (same store as the
* message limiters). It only counts requests made by an authenticated guest
* message limiters), so the count is shared across replicas — a guest cannot
* multiply their quota by round-robining pods. The key is the REAL client IP
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`), NOT the guest token, so
* clearing cookies, opening incognito, or minting a fresh guest token does not
* reset the count. It only counts requests made by an authenticated guest
* principal; logged-in users skip the counter entirely. On exhaustion it returns
* a `402 { type: 'GUEST_LIMIT' }` signal the client maps to the login gate.
*/
@@ -24,7 +28,7 @@ const guestMessageLimiter = rateLimit({
windowMs: GUEST_LIMIT_WINDOW_MS,
max: () => getGuestConfig().messageMax,
handler,
keyGenerator: removePorts,
keyGenerator: guestClientIp,
skip: (req) => req.user?.guest !== true,
store: limiterCache('guest_message_limiter'),
});
@@ -7,6 +7,8 @@ jest.mock('@hanzochat/api', () => ({
jest.mock('~/server/utils', () => ({
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
guestClientIp: (req) =>
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
}));
jest.mock('~/server/services/guestConfig', () => ({
+11 -11
View File
@@ -36,7 +36,7 @@ function createApp(user) {
next();
});
}
app.use('/api/config', configRoute);
app.use('/v1/chat/config', configRoute);
return app;
}
@@ -70,7 +70,7 @@ afterEach(() => {
delete process.env.RUM_ENVIRONMENT;
});
describe('GET /api/config RUM config', () => {
describe('GET /v1/chat/config RUM config', () => {
it('includes public-token RUM config when enabled with valid env', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';
@@ -82,7 +82,7 @@ describe('GET /api/config RUM config', () => {
process.env.RUM_ENVIRONMENT = 'test';
const app = createApp(null);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.body.rum).toEqual({
provider: 'hyperdx',
@@ -107,7 +107,7 @@ describe('GET /api/config RUM config', () => {
process.env.RUM_PUBLIC_TOKEN = 'public-token';
const app = createApp(null);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.body).not.toHaveProperty('rum');
});
@@ -119,12 +119,12 @@ describe('GET /api/config RUM config', () => {
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.body.rum).toEqual({
provider: 'hyperdx',
enabled: true,
url: '/api/rum',
url: '/v1/chat/rum',
serviceName: 'librechat-web',
authMode: 'proxy',
consoleCapture: false,
@@ -139,7 +139,7 @@ describe('GET /api/config RUM config', () => {
process.env.RUM_AUTH_MODE = 'proxy';
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.body).not.toHaveProperty('rum');
});
@@ -151,7 +151,7 @@ describe('GET /api/config RUM config', () => {
process.env.RUM_PUBLIC_TOKEN = 'public-token';
const app = createApp(null);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.body).not.toHaveProperty('rum');
});
@@ -163,7 +163,7 @@ describe('GET /api/config RUM config', () => {
process.env.RUM_PUBLIC_TOKEN = 'public-token';
const app = createApp(null);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.body.rum?.url).toBe('http://[::1]:4318');
});
@@ -175,7 +175,7 @@ describe('GET /api/config RUM config', () => {
process.env.RUM_AUTH_MODE = 'userJwt';
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.body).not.toHaveProperty('rum');
});
@@ -187,7 +187,7 @@ describe('GET /api/config RUM config', () => {
process.env.RUM_AUTH_MODE = 'userJwt';
const app = createApp(null);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.body).not.toHaveProperty('rum');
});
+1 -1
View File
@@ -5,7 +5,7 @@ const configRoute = require('../config');
// file deepcode ignore UseCsurfForExpress/test: test
const app = express();
app.disable('x-powered-by');
app.use('/api/config', configRoute);
app.use('/v1/chat/config', configRoute);
afterEach(() => {
delete process.env.APP_TITLE;
@@ -39,7 +39,7 @@ jest.mock('multer', () => require(MOCKS).multerLib());
jest.mock('~/server/services/Endpoints/azureAssistants', () => require(MOCKS).assistantEndpoint());
jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assistantEndpoint());
describe('POST /api/convos/duplicate - Rate Limiting', () => {
describe('POST /v1/chat/convos/duplicate - Rate Limiting', () => {
let app;
let duplicateConversation;
const savedEnv = {};
@@ -73,7 +73,7 @@ describe('POST /api/convos/duplicate - Rate Limiting', () => {
req.user = { id: 'rate-limit-test-user' };
next();
});
app.use('/api/convos', convosRouter);
app.use('/v1/chat/convos', convosRouter);
});
duplicateConversation.mockResolvedValue({
@@ -95,13 +95,13 @@ describe('POST /api/convos/duplicate - Rate Limiting', () => {
for (let i = 0; i < userMax; i++) {
const res = await request(app)
.post('/api/convos/duplicate')
.post('/v1/chat/convos/duplicate')
.send({ conversationId: 'conv-123' });
expect(res.status).toBe(201);
}
const res = await request(app)
.post('/api/convos/duplicate')
.post('/v1/chat/convos/duplicate')
.send({ conversationId: 'conv-123' });
expect(res.status).toBe(429);
expect(res.body.message).toMatch(/too many/i);
@@ -122,13 +122,13 @@ describe('POST /api/convos/duplicate - Rate Limiting', () => {
for (let i = 0; i < ipMax; i++) {
const res = await request(app)
.post('/api/convos/duplicate')
.post('/v1/chat/convos/duplicate')
.send({ conversationId: 'conv-123' });
expect(res.status).toBe(201);
}
const res = await request(app)
.post('/api/convos/duplicate')
.post('/v1/chat/convos/duplicate')
.send({ conversationId: 'conv-123' });
expect(res.status).toBe(429);
expect(res.body.message).toMatch(/too many/i);
+31 -31
View File
@@ -124,7 +124,7 @@ describe('Convos Routes', () => {
next();
});
app.use('/api/convos', convosRouter);
app.use('/v1/chat/convos', convosRouter);
});
beforeEach(() => {
@@ -145,7 +145,7 @@ describe('Convos Routes', () => {
deletedCount: 3,
});
const response = await request(app).delete('/api/convos/all');
const response = await request(app).delete('/v1/chat/convos/all');
expect(response.status).toBe(201);
expect(response.body).toEqual(mockDbResponse);
@@ -176,7 +176,7 @@ describe('Convos Routes', () => {
deletedCount: 0,
});
const response = await request(app).delete('/api/convos/all');
const response = await request(app).delete('/v1/chat/convos/all');
expect(response.status).toBe(201);
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
@@ -186,7 +186,7 @@ describe('Convos Routes', () => {
const errorMessage = 'Database connection error';
deleteConvos.mockRejectedValue(new Error(errorMessage));
const response = await request(app).delete('/api/convos/all');
const response = await request(app).delete('/v1/chat/convos/all');
expect(response.status).toBe(500);
expect(response.text).toBe('Error clearing conversations');
@@ -200,7 +200,7 @@ describe('Convos Routes', () => {
deleteConvos.mockResolvedValue({ deletedCount: 5 });
deleteToolCalls.mockRejectedValue(new Error('Tool calls deletion failed'));
const response = await request(app).delete('/api/convos/all');
const response = await request(app).delete('/v1/chat/convos/all');
expect(response.status).toBe(500);
expect(response.text).toBe('Error clearing conversations');
@@ -211,7 +211,7 @@ describe('Convos Routes', () => {
deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
deleteAllSharedLinks.mockRejectedValue(new Error('Shared links deletion failed'));
const response = await request(app).delete('/api/convos/all');
const response = await request(app).delete('/v1/chat/convos/all');
expect(response.status).toBe(500);
expect(response.text).toBe('Error clearing conversations');
@@ -223,7 +223,7 @@ describe('Convos Routes', () => {
deleteToolCalls.mockResolvedValue({ deletedCount: 5 });
deleteAllSharedLinks.mockResolvedValue({ deletedCount: 2 });
let response = await request(app).delete('/api/convos/all');
let response = await request(app).delete('/v1/chat/convos/all');
expect(response.status).toBe(201);
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
@@ -237,13 +237,13 @@ describe('Convos Routes', () => {
req.user = { id: 'test-user-456' };
next();
});
app2.use('/api/convos', require('../convos'));
app2.use('/v1/chat/convos', require('../convos'));
deleteConvos.mockResolvedValue({ deletedCount: 7 });
deleteToolCalls.mockResolvedValue({ deletedCount: 12 });
deleteAllSharedLinks.mockResolvedValue({ deletedCount: 4 });
response = await request(app2).delete('/api/convos/all');
response = await request(app2).delete('/v1/chat/convos/all');
expect(response.status).toBe(201);
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-456');
@@ -267,7 +267,7 @@ describe('Convos Routes', () => {
return Promise.resolve({ deletedCount: 3 });
});
await request(app).delete('/api/convos/all');
await request(app).delete('/v1/chat/convos/all');
/** Verify all three functions were called */
expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteAllSharedLinks']);
@@ -286,7 +286,7 @@ describe('Convos Routes', () => {
deleteToolCalls.mockResolvedValue(mockToolCallsDeleted);
deleteAllSharedLinks.mockResolvedValue(mockSharedLinksDeleted);
const response = await request(app).delete('/api/convos/all');
const response = await request(app).delete('/v1/chat/convos/all');
expect(response.status).toBe(201);
@@ -314,7 +314,7 @@ describe('Convos Routes', () => {
});
const response = await request(app)
.delete('/api/convos')
.delete('/v1/chat/convos')
.send({
arg: {
conversationId: mockConversationId,
@@ -341,7 +341,7 @@ describe('Convos Routes', () => {
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
const response = await request(app)
.delete('/api/convos')
.delete('/v1/chat/convos')
.send({
arg: {
source: 'button',
@@ -363,7 +363,7 @@ describe('Convos Routes', () => {
});
const response = await request(app)
.delete('/api/convos')
.delete('/v1/chat/convos')
.send({
arg: {
conversationId: mockConversationId,
@@ -375,7 +375,7 @@ describe('Convos Routes', () => {
});
it('should return 400 when no parameters provided', async () => {
const response = await request(app).delete('/api/convos').send({
const response = await request(app).delete('/v1/chat/convos').send({
arg: {},
});
@@ -386,7 +386,7 @@ describe('Convos Routes', () => {
});
it('should return 400 when request body is empty (DoS prevention)', async () => {
const response = await request(app).delete('/api/convos').send({});
const response = await request(app).delete('/v1/chat/convos').send({});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'no parameters provided' });
@@ -394,7 +394,7 @@ describe('Convos Routes', () => {
});
it('should return 400 when arg is null (DoS prevention)', async () => {
const response = await request(app).delete('/api/convos').send({ arg: null });
const response = await request(app).delete('/v1/chat/convos').send({ arg: null });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'no parameters provided' });
@@ -402,7 +402,7 @@ describe('Convos Routes', () => {
});
it('should return 400 when arg is undefined (DoS prevention)', async () => {
const response = await request(app).delete('/api/convos').send({ arg: undefined });
const response = await request(app).delete('/v1/chat/convos').send({ arg: undefined });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'no parameters provided' });
@@ -411,7 +411,7 @@ describe('Convos Routes', () => {
it('should return 400 when request body is null (DoS prevention)', async () => {
const response = await request(app)
.delete('/api/convos')
.delete('/v1/chat/convos')
.set('Content-Type', 'application/json')
.send('null');
@@ -427,7 +427,7 @@ describe('Convos Routes', () => {
deleteConvoSharedLink.mockRejectedValue(new Error('Failed to delete shared links'));
const response = await request(app)
.delete('/api/convos')
.delete('/v1/chat/convos')
.send({
arg: {
conversationId: mockConversationId,
@@ -458,7 +458,7 @@ describe('Convos Routes', () => {
});
await request(app)
.delete('/api/convos')
.delete('/v1/chat/convos')
.send({
arg: {
conversationId: mockConversationId,
@@ -479,7 +479,7 @@ describe('Convos Routes', () => {
});
const response = await request(app)
.delete('/api/convos')
.delete('/v1/chat/convos')
.send({
arg: {
conversationId: mockConversationId,
@@ -509,7 +509,7 @@ describe('Convos Routes', () => {
saveConvo.mockResolvedValue(mockArchivedConvo);
const response = await request(app)
.post('/api/convos/archive')
.post('/v1/chat/convos/archive')
.send({
arg: {
conversationId: mockConversationId,
@@ -522,7 +522,7 @@ describe('Convos Routes', () => {
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ user: { id: 'test-user-123' } }),
{ conversationId: mockConversationId, isArchived: true },
{ context: `POST /api/convos/archive ${mockConversationId}` },
{ context: `POST /v1/chat/convos/archive ${mockConversationId}` },
);
});
@@ -538,7 +538,7 @@ describe('Convos Routes', () => {
saveConvo.mockResolvedValue(mockUnarchivedConvo);
const response = await request(app)
.post('/api/convos/archive')
.post('/v1/chat/convos/archive')
.send({
arg: {
conversationId: mockConversationId,
@@ -551,13 +551,13 @@ describe('Convos Routes', () => {
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ user: { id: 'test-user-123' } }),
{ conversationId: mockConversationId, isArchived: false },
{ context: `POST /api/convos/archive ${mockConversationId}` },
{ context: `POST /v1/chat/convos/archive ${mockConversationId}` },
);
});
it('should return 400 when conversationId is missing', async () => {
const response = await request(app)
.post('/api/convos/archive')
.post('/v1/chat/convos/archive')
.send({
arg: {
isArchived: true,
@@ -571,7 +571,7 @@ describe('Convos Routes', () => {
it('should return 400 when isArchived is not a boolean', async () => {
const response = await request(app)
.post('/api/convos/archive')
.post('/v1/chat/convos/archive')
.send({
arg: {
conversationId: 'conv-123',
@@ -586,7 +586,7 @@ describe('Convos Routes', () => {
it('should return 400 when isArchived is undefined', async () => {
const response = await request(app)
.post('/api/convos/archive')
.post('/v1/chat/convos/archive')
.send({
arg: {
conversationId: 'conv-123',
@@ -603,7 +603,7 @@ describe('Convos Routes', () => {
saveConvo.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/api/convos/archive')
.post('/v1/chat/convos/archive')
.send({
arg: {
conversationId: mockConversationId,
@@ -619,7 +619,7 @@ describe('Convos Routes', () => {
});
it('should handle empty arg object', async () => {
const response = await request(app).post('/api/convos/archive').send({
const response = await request(app).post('/v1/chat/convos/archive').send({
arg: {},
});
+9 -9
View File
@@ -81,7 +81,7 @@ function createApp(user) {
router.get('/:principalType/:principalId', handlers.getPrincipalGrants);
router.post('/', handlers.assignGrant);
router.delete('/:principalType/:principalId/:capability', handlers.revokeGrant);
app.use('/api/admin/grants', router);
app.use('/v1/chat/admin/grants', router);
return app;
}
@@ -96,7 +96,7 @@ describe('Admin Grants Routes — Integration', () => {
it('GET / returns seeded admin grants', async () => {
const app = createApp(adminUser);
const res = await request(app).get('/api/admin/grants').expect(200);
const res = await request(app).get('/v1/chat/admin/grants').expect(200);
expect(res.body).toHaveProperty('grants');
expect(res.body).toHaveProperty('total');
@@ -107,7 +107,7 @@ describe('Admin Grants Routes — Integration', () => {
it('GET /effective returns capabilities for admin', async () => {
const app = createApp(adminUser);
const res = await request(app).get('/api/admin/grants/effective').expect(200);
const res = await request(app).get('/v1/chat/admin/grants/effective').expect(200);
expect(res.body).toHaveProperty('capabilities');
expect(res.body.capabilities).toContain('access:admin');
@@ -119,7 +119,7 @@ describe('Admin Grants Routes — Integration', () => {
// Assign
const assignRes = await request(app)
.post('/api/admin/grants')
.post('/v1/chat/admin/grants')
.send({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.USER,
@@ -135,19 +135,19 @@ describe('Admin Grants Routes — Integration', () => {
// Verify via GET
const getRes = await request(app)
.get(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
.get(`/v1/chat/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
.expect(200);
expect(getRes.body.grants.some((g) => g.capability === 'read:users')).toBe(true);
// Revoke
await request(app)
.delete(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}/read:users`)
.delete(`/v1/chat/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}/read:users`)
.expect(200);
// Verify revoked
const afterRes = await request(app)
.get(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
.get(`/v1/chat/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
.expect(200);
expect(afterRes.body.grants.some((g) => g.capability === 'read:users')).toBe(false);
@@ -157,7 +157,7 @@ describe('Admin Grants Routes — Integration', () => {
const app = createApp(adminUser);
const res = await request(app)
.post('/api/admin/grants')
.post('/v1/chat/admin/grants')
.send({
principalType: PrincipalType.ROLE,
principalId: 'nonexistent-role',
@@ -172,7 +172,7 @@ describe('Admin Grants Routes — Integration', () => {
const app = createApp(undefined);
const res = await request(app)
.post('/api/admin/grants')
.post('/v1/chat/admin/grants')
.send({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.USER,
@@ -5,7 +5,7 @@
*
* Proves:
* - a guest token on a JWT-only route returns a clean 401 (NOT 500/CastError),
* - /api/endpoints, /api/user, /api/convos return guest-scoped data,
* - /v1/chat/endpoints, /v1/chat/user, /v1/chat/convos return guest-scoped data,
* - a non-bootstrap protected route still 401s for a guest.
*/
@@ -52,19 +52,19 @@ const buildApp = () => {
passport.use(jwtLogin());
// Bootstrap (guest-aware) routes.
app.get('/api/endpoints', requireGuestOrJwtAuth, (req, res) => {
app.get('/v1/chat/endpoints', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
}
return res.send(JSON.stringify({ openAI: {}, Hanzo: {} }));
});
app.get('/api/user', requireGuestOrJwtAuth, (req, res) => {
app.get('/v1/chat/user', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.status(200).send(buildGuestUser(req.user));
}
return res.status(200).send(req.user);
});
app.get('/api/convos', requireGuestOrJwtAuth, (req, res) => {
app.get('/v1/chat/convos', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.status(200).json({ conversations: [], nextCursor: null });
}
@@ -72,7 +72,7 @@ const buildApp = () => {
});
// Non-bootstrap protected route (JWT-only): must reject guests.
app.get('/api/prompts', requireJwtAuth, (req, res) => res.status(200).json({ ok: true }));
app.get('/v1/chat/prompts', requireJwtAuth, (req, res) => res.status(200).json({ ok: true }));
return app;
};
@@ -91,9 +91,9 @@ describe('guest bootstrap auth chain (integration)', () => {
afterEach(() => jest.clearAllMocks());
it('GET /api/endpoints → 200 with ONLY the guest endpoint', async () => {
it('GET /v1/chat/endpoints → 200 with ONLY the guest endpoint', async () => {
const res = await request(app)
.get('/api/endpoints')
.get('/v1/chat/endpoints')
.set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
const body = JSON.parse(res.text);
@@ -101,36 +101,36 @@ describe('guest bootstrap auth chain (integration)', () => {
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /api/user → 200 ephemeral guest principal (no email, no DB lookup)', async () => {
const res = await request(app).get('/api/user').set('Authorization', `Bearer ${guestToken}`);
it('GET /v1/chat/user → 200 ephemeral guest principal (no email, no DB lookup)', async () => {
const res = await request(app).get('/v1/chat/user').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: 'guest_abc-123', role: 'GUEST', guest: true });
expect(res.body).not.toHaveProperty('email');
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /api/convos → 200 empty list for a guest', async () => {
const res = await request(app).get('/api/convos').set('Authorization', `Bearer ${guestToken}`);
it('GET /v1/chat/convos → 200 empty list for a guest', async () => {
const res = await request(app).get('/v1/chat/convos').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ conversations: [], nextCursor: null });
});
it('GET /api/prompts (JWT-only) → clean 401 for a guest, NEVER 500/CastError', async () => {
const res = await request(app).get('/api/prompts').set('Authorization', `Bearer ${guestToken}`);
it('GET /v1/chat/prompts (JWT-only) → clean 401 for a guest, NEVER 500/CastError', async () => {
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(401);
// The guest id must never reach getUserById (that is what caused the CastError → 500).
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /api/prompts → clean 401 for a real-but-missing user (no 500)', async () => {
it('GET /v1/chat/prompts → clean 401 for a real-but-missing user (no 500)', async () => {
const realToken = jwt.sign({ id: '64b2f0c0c0c0c0c0c0c0c0c0' }, JWT_SECRET);
const res = await request(app).get('/api/prompts').set('Authorization', `Bearer ${realToken}`);
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${realToken}`);
expect(res.status).toBe(401);
expect(getUserById).toHaveBeenCalledTimes(1);
});
it('GET /api/prompts → 401 with no token (fail closed)', async () => {
const res = await request(app).get('/api/prompts');
it('GET /v1/chat/prompts → 401 with no token (fail closed)', async () => {
const res = await request(app).get('/v1/chat/prompts');
expect(res.status).toBe(401);
});
});
+10 -10
View File
@@ -28,7 +28,7 @@ describe('Keys Routes', () => {
next();
});
app.use('/api/keys', keysRouter);
app.use('/v1/chat/keys', keysRouter);
});
beforeEach(() => {
@@ -40,7 +40,7 @@ describe('Keys Routes', () => {
updateUserKey.mockResolvedValue({});
const response = await request(app)
.put('/api/keys')
.put('/v1/chat/keys')
.send({ name: 'openAI', value: 'sk-test-key-123', expiresAt: '2026-12-31' });
expect(response.status).toBe(201);
@@ -56,7 +56,7 @@ describe('Keys Routes', () => {
it('should not allow userId override via request body (IDOR prevention)', async () => {
updateUserKey.mockResolvedValue({});
const response = await request(app).put('/api/keys').send({
const response = await request(app).put('/v1/chat/keys').send({
userId: 'attacker-injected-id',
name: 'openAI',
value: 'sk-attacker-key',
@@ -74,7 +74,7 @@ describe('Keys Routes', () => {
it('should ignore extraneous fields from request body', async () => {
updateUserKey.mockResolvedValue({});
const response = await request(app).put('/api/keys').send({
const response = await request(app).put('/v1/chat/keys').send({
name: 'openAI',
value: 'sk-test-key',
expiresAt: '2026-12-31',
@@ -96,7 +96,7 @@ describe('Keys Routes', () => {
updateUserKey.mockResolvedValue({});
const response = await request(app)
.put('/api/keys')
.put('/v1/chat/keys')
.send({ name: 'anthropic', value: 'sk-ant-key' });
expect(response.status).toBe(201);
@@ -110,7 +110,7 @@ describe('Keys Routes', () => {
it('should return 400 when request body is null', async () => {
const response = await request(app)
.put('/api/keys')
.put('/v1/chat/keys')
.set('Content-Type', 'application/json')
.send('null');
@@ -123,7 +123,7 @@ describe('Keys Routes', () => {
it('should delete a user key by name', async () => {
deleteUserKey.mockResolvedValue({});
const response = await request(app).delete('/api/keys/openAI');
const response = await request(app).delete('/v1/chat/keys/openAI');
expect(response.status).toBe(204);
expect(deleteUserKey).toHaveBeenCalledWith({
@@ -138,7 +138,7 @@ describe('Keys Routes', () => {
it('should delete all keys when all=true', async () => {
deleteUserKey.mockResolvedValue({});
const response = await request(app).delete('/api/keys?all=true');
const response = await request(app).delete('/v1/chat/keys?all=true');
expect(response.status).toBe(204);
expect(deleteUserKey).toHaveBeenCalledWith({
@@ -148,7 +148,7 @@ describe('Keys Routes', () => {
});
it('should return 400 when all query param is not true', async () => {
const response = await request(app).delete('/api/keys');
const response = await request(app).delete('/v1/chat/keys');
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Specify either all=true to delete.' });
@@ -161,7 +161,7 @@ describe('Keys Routes', () => {
const mockExpiry = { expiresAt: '2026-12-31' };
getUserKeyExpiry.mockResolvedValue(mockExpiry);
const response = await request(app).get('/api/keys?name=openAI');
const response = await request(app).get('/v1/chat/keys?name=openAI');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockExpiry);
+4 -4
View File
@@ -12,7 +12,7 @@ jest.mock('@hanzochat/api', () => ({
const app = express();
// Mock the route handler
app.get('/api/config', (req, res) => {
app.get('/v1/chat/config', (req, res) => {
const ldapConfig = getLdapConfig();
res.json({ ldap: ldapConfig });
});
@@ -26,7 +26,7 @@ describe('LDAP Config Tests', () => {
getLdapConfig.mockReturnValue({ enabled: true, username: true });
isEnabled.mockReturnValue(true);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.statusCode).toBe(200);
expect(response.body.ldap).toEqual({
@@ -39,7 +39,7 @@ describe('LDAP Config Tests', () => {
getLdapConfig.mockReturnValue({ enabled: true });
isEnabled.mockReturnValue(false);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.statusCode).toBe(200);
expect(response.body.ldap).toEqual({
@@ -50,7 +50,7 @@ describe('LDAP Config Tests', () => {
it('should not return LDAP config when LDAP is not enabled', async () => {
getLdapConfig.mockReturnValue(undefined);
const response = await request(app).get('/api/config');
const response = await request(app).get('/v1/chat/config');
expect(response.statusCode).toBe(200);
expect(response.body.ldap).toBeUndefined();
+82 -82
View File
@@ -147,7 +147,7 @@ describe('MCP Routes', () => {
next();
});
app.use('/api/mcp', mcpRouter);
app.use('/v1/chat/mcp', mcpRouter);
});
afterAll(async () => {
@@ -182,7 +182,7 @@ describe('MCP Routes', () => {
flowId: 'test-user-id:test-server',
});
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
userId: 'test-user-id',
flowId: 'test-user-id:test-server',
});
@@ -199,7 +199,7 @@ describe('MCP Routes', () => {
});
it('should return 403 when userId does not match authenticated user', async () => {
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
userId: 'different-user-id',
flowId: 'test-user-id:test-server',
});
@@ -216,7 +216,7 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
userId: 'test-user-id',
flowId: 'non-existent-flow-id',
});
@@ -237,7 +237,7 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
userId: 'test-user-id',
flowId: 'test-user-id:test-server',
});
@@ -254,7 +254,7 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
userId: 'test-user-id',
flowId: 'test-user-id:test-server',
});
@@ -274,7 +274,7 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
userId: 'test-user-id',
flowId: 'test-user-id:test-server',
});
@@ -289,7 +289,7 @@ describe('MCP Routes', () => {
const { getLogStores } = require('~/cache');
it('should redirect to error page when OAuth error is received', async () => {
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/callback').query({
error: 'access_denied',
state: 'test-user-id:test-server',
});
@@ -300,7 +300,7 @@ describe('MCP Routes', () => {
});
it('should redirect to error page when code is missing', async () => {
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/callback').query({
state: 'test-user-id:test-server',
});
const basePath = getBasePath();
@@ -310,7 +310,7 @@ describe('MCP Routes', () => {
});
it('should redirect to error page when state is missing', async () => {
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
});
const basePath = getBasePath();
@@ -320,7 +320,7 @@ describe('MCP Routes', () => {
});
it('should redirect to error page when CSRF cookie is missing', async () => {
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-user-id:test-server',
});
@@ -335,7 +335,7 @@ describe('MCP Routes', () => {
it('should redirect to error page when CSRF cookie does not match state', async () => {
const csrfToken = generateTestCsrfToken('different-flow-id');
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.get('/v1/chat/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
@@ -355,7 +355,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.get('/v1/chat/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
@@ -419,7 +419,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.get('/v1/chat/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
@@ -464,7 +464,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.get('/v1/chat/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
@@ -506,7 +506,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.get('/v1/chat/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
@@ -558,7 +558,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.get('/v1/chat/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
@@ -606,7 +606,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.get('/v1/chat/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
@@ -671,7 +671,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.get('/v1/chat/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
@@ -718,7 +718,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.get('/v1/chat/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({
code: 'test-auth-code',
@@ -751,7 +751,7 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:flow-123');
const response = await request(app).get('/v1/chat/mcp/oauth/tokens/test-user-id:flow-123');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -769,16 +769,16 @@ describe('MCP Routes', () => {
req.user = null;
next();
});
unauthApp.use('/api/mcp', mcpRouter);
unauthApp.use('/v1/chat/mcp', mcpRouter);
const response = await request(unauthApp).get('/api/mcp/oauth/tokens/test-flow-id');
const response = await request(unauthApp).get('/v1/chat/mcp/oauth/tokens/test-flow-id');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'User not authenticated' });
});
it('should return 403 when user tries to access flow they do not own', async () => {
const response = await request(app).get('/api/mcp/oauth/tokens/other-user-id:flow-123');
const response = await request(app).get('/v1/chat/mcp/oauth/tokens/other-user-id:flow-123');
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Access denied' });
@@ -793,7 +793,7 @@ describe('MCP Routes', () => {
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get(
'/api/mcp/oauth/tokens/test-user-id:non-existent-flow',
'/v1/chat/mcp/oauth/tokens/test-user-id:non-existent-flow',
);
expect(response.status).toBe(404);
@@ -811,7 +811,7 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:pending-flow');
const response = await request(app).get('/v1/chat/mcp/oauth/tokens/test-user-id:pending-flow');
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Flow not completed' });
@@ -822,7 +822,7 @@ describe('MCP Routes', () => {
throw new Error('Database connection failed');
});
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:error-flow');
const response = await request(app).get('/v1/chat/mcp/oauth/tokens/test-user-id:error-flow');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to get tokens' });
@@ -843,7 +843,7 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/oauth/status/test-user-id:test-server');
const response = await request(app).get('/v1/chat/mcp/oauth/status/test-user-id:test-server');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -855,7 +855,7 @@ describe('MCP Routes', () => {
});
it('should return 403 when flowId does not match authenticated user', async () => {
const response = await request(app).get('/api/mcp/oauth/status/other-user-id:test-server');
const response = await request(app).get('/v1/chat/mcp/oauth/status/other-user-id:test-server');
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Access denied' });
@@ -869,7 +869,7 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/oauth/status/test-user-id:non-existent');
const response = await request(app).get('/v1/chat/mcp/oauth/status/test-user-id:non-existent');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Flow not found' });
@@ -883,7 +883,7 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/oauth/status/test-user-id:error-server');
const response = await request(app).get('/v1/chat/mcp/oauth/status/test-user-id:error-server');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to get flow status' });
@@ -906,7 +906,7 @@ describe('MCP Routes', () => {
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
const response = await request(app).post('/v1/chat/mcp/oauth/cancel/test-server');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -930,7 +930,7 @@ describe('MCP Routes', () => {
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
const response = await request(app).post('/v1/chat/mcp/oauth/cancel/test-server');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -949,7 +949,7 @@ describe('MCP Routes', () => {
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
const response = await request(app).post('/v1/chat/mcp/oauth/cancel/test-server');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to cancel OAuth flow' });
@@ -962,9 +962,9 @@ describe('MCP Routes', () => {
req.user = null;
next();
});
unauthApp.use('/api/mcp', mcpRouter);
unauthApp.use('/v1/chat/mcp', mcpRouter);
const response = await request(unauthApp).post('/api/mcp/oauth/cancel/test-server');
const response = await request(unauthApp).post('/v1/chat/mcp/oauth/cancel/test-server');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'User not authenticated' });
@@ -984,7 +984,7 @@ describe('MCP Routes', () => {
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
const response = await request(app).post('/api/mcp/non-existent-server/reinitialize');
const response = await request(app).post('/v1/chat/mcp/non-existent-server/reinitialize');
expect(response.status).toBe(404);
expect(response.body).toEqual({
@@ -1018,7 +1018,7 @@ describe('MCP Routes', () => {
oauthUrl: 'https://oauth.example.com/auth',
});
const response = await request(app).post('/api/mcp/oauth-server/reinitialize');
const response = await request(app).post('/v1/chat/mcp/oauth-server/reinitialize');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -1043,7 +1043,7 @@ describe('MCP Routes', () => {
require('~/cache').getLogStores.mockReturnValue({});
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue(null);
const response = await request(app).post('/api/mcp/error-server/reinitialize');
const response = await request(app).post('/v1/chat/mcp/error-server/reinitialize');
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -1061,7 +1061,7 @@ describe('MCP Routes', () => {
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).post('/api/mcp/test-server/reinitialize');
const response = await request(app).post('/v1/chat/mcp/test-server/reinitialize');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Internal server error' });
@@ -1074,9 +1074,9 @@ describe('MCP Routes', () => {
req.user = null;
next();
});
unauthApp.use('/api/mcp', mcpRouter);
unauthApp.use('/v1/chat/mcp', mcpRouter);
const response = await request(unauthApp).post('/api/mcp/test-server/reinitialize');
const response = await request(unauthApp).post('/v1/chat/mcp/test-server/reinitialize');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'User not authenticated' });
@@ -1116,7 +1116,7 @@ describe('MCP Routes', () => {
oauthUrl: null,
});
const response = await request(app).post('/api/mcp/test-server/reinitialize');
const response = await request(app).post('/v1/chat/mcp/test-server/reinitialize');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -1174,7 +1174,7 @@ describe('MCP Routes', () => {
oauthUrl: null,
});
const response = await request(app).post('/api/mcp/test-server/reinitialize');
const response = await request(app).post('/v1/chat/mcp/test-server/reinitialize');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
@@ -1212,7 +1212,7 @@ describe('MCP Routes', () => {
requiresOAuth: true,
});
const response = await request(app).get('/api/mcp/connection/status');
const response = await request(app).get('/v1/chat/mcp/connection/status');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -1236,7 +1236,7 @@ describe('MCP Routes', () => {
it('should return 404 when MCP config is not found', async () => {
getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
const response = await request(app).get('/api/mcp/connection/status');
const response = await request(app).get('/v1/chat/mcp/connection/status');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'MCP config not found' });
@@ -1245,7 +1245,7 @@ describe('MCP Routes', () => {
it('should return 500 when connection status check fails', async () => {
getMCPSetupData.mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/api/mcp/connection/status');
const response = await request(app).get('/v1/chat/mcp/connection/status');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to get connection status' });
@@ -1258,9 +1258,9 @@ describe('MCP Routes', () => {
req.user = null;
next();
});
unauthApp.use('/api/mcp', mcpRouter);
unauthApp.use('/v1/chat/mcp', mcpRouter);
const response = await request(unauthApp).get('/api/mcp/connection/status');
const response = await request(unauthApp).get('/v1/chat/mcp/connection/status');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'User not authenticated' });
@@ -1287,7 +1287,7 @@ describe('MCP Routes', () => {
requiresOAuth: true,
});
const response = await request(app).get('/api/mcp/connection/status/oauth-server');
const response = await request(app).get('/v1/chat/mcp/connection/status/oauth-server');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -1308,7 +1308,7 @@ describe('MCP Routes', () => {
oauthServers: [],
});
const response = await request(app).get('/api/mcp/connection/status/non-existent-server');
const response = await request(app).get('/v1/chat/mcp/connection/status/non-existent-server');
expect(response.status).toBe(404);
expect(response.body).toEqual({
@@ -1319,7 +1319,7 @@ describe('MCP Routes', () => {
it('should return 404 when MCP config is not found', async () => {
getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
const response = await request(app).get('/api/mcp/connection/status/test-server');
const response = await request(app).get('/v1/chat/mcp/connection/status/test-server');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'MCP config not found' });
@@ -1328,7 +1328,7 @@ describe('MCP Routes', () => {
it('should return 500 when connection status check fails', async () => {
getMCPSetupData.mockRejectedValue(new Error('Database connection failed'));
const response = await request(app).get('/api/mcp/connection/status/test-server');
const response = await request(app).get('/v1/chat/mcp/connection/status/test-server');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to get connection status' });
@@ -1341,9 +1341,9 @@ describe('MCP Routes', () => {
req.user = null;
next();
});
unauthApp.use('/api/mcp', mcpRouter);
unauthApp.use('/v1/chat/mcp', mcpRouter);
const response = await request(unauthApp).get('/api/mcp/connection/status/test-server');
const response = await request(unauthApp).get('/v1/chat/mcp/connection/status/test-server');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'User not authenticated' });
@@ -1366,7 +1366,7 @@ describe('MCP Routes', () => {
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
const response = await request(app).get('/api/mcp/test-server/auth-values');
const response = await request(app).get('/v1/chat/mcp/test-server/auth-values');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -1387,7 +1387,7 @@ describe('MCP Routes', () => {
mockRegistryInstance.getServerConfig.mockResolvedValue(null);
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
const response = await request(app).get('/v1/chat/mcp/non-existent-server/auth-values');
expect(response.status).toBe(404);
expect(response.body).toEqual({
@@ -1406,7 +1406,7 @@ describe('MCP Routes', () => {
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/api/mcp/test-server/auth-values');
const response = await request(app).get('/v1/chat/mcp/test-server/auth-values');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -1426,7 +1426,7 @@ describe('MCP Routes', () => {
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/test-server/auth-values');
const response = await request(app).get('/v1/chat/mcp/test-server/auth-values');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to check auth value flags' });
@@ -1440,7 +1440,7 @@ describe('MCP Routes', () => {
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/test-server/auth-values');
const response = await request(app).get('/v1/chat/mcp/test-server/auth-values');
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -1453,9 +1453,9 @@ describe('MCP Routes', () => {
it('should return 401 when user is not authenticated in auth-values endpoint', async () => {
const appWithoutAuth = express();
appWithoutAuth.use(express.json());
appWithoutAuth.use('/api/mcp', mcpRouter);
appWithoutAuth.use('/v1/chat/mcp', mcpRouter);
const response = await request(appWithoutAuth).get('/api/mcp/test-server/auth-values');
const response = await request(appWithoutAuth).get('/v1/chat/mcp/test-server/auth-values');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'User not authenticated' });
@@ -1502,7 +1502,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
.get(`/v1/chat/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.expect(302);
@@ -1556,7 +1556,7 @@ describe('MCP Routes', () => {
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
.get(`/v1/chat/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.expect(302);
@@ -1583,7 +1583,7 @@ describe('MCP Routes', () => {
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockServerConfigs);
const response = await request(app).get('/api/mcp/servers');
const response = await request(app).get('/v1/chat/mcp/servers');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockServerConfigs);
@@ -1593,7 +1593,7 @@ describe('MCP Routes', () => {
it('should return empty object when no servers are configured', async () => {
mockRegistryInstance.getAllServerConfigs.mockResolvedValue({});
const response = await request(app).get('/api/mcp/servers');
const response = await request(app).get('/v1/chat/mcp/servers');
expect(response.status).toBe(200);
expect(response.body).toEqual({});
@@ -1606,9 +1606,9 @@ describe('MCP Routes', () => {
req.user = null;
next();
});
unauthApp.use('/api/mcp', mcpRouter);
unauthApp.use('/v1/chat/mcp', mcpRouter);
const response = await request(unauthApp).get('/api/mcp/servers');
const response = await request(unauthApp).get('/v1/chat/mcp/servers');
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Unauthorized' });
@@ -1617,7 +1617,7 @@ describe('MCP Routes', () => {
it('should return 500 when server config retrieval fails', async () => {
mockRegistryInstance.getAllServerConfigs.mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/api/mcp/servers');
const response = await request(app).get('/v1/chat/mcp/servers');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
@@ -1638,7 +1638,7 @@ describe('MCP Routes', () => {
config: validConfig,
});
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: validConfig });
expect(response.status).toBe(201);
expect(response.body).toEqual({
@@ -1664,7 +1664,7 @@ describe('MCP Routes', () => {
title: 'Test Stdio Server',
};
const response = await request(app).post('/api/mcp/servers').send({ config: stdioConfig });
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: stdioConfig });
// Stdio transport is not allowed via API - only admins can configure it via YAML
expect(response.status).toBe(400);
@@ -1678,7 +1678,7 @@ describe('MCP Routes', () => {
title: 'Invalid Server',
};
const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: invalidConfig });
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
@@ -1692,7 +1692,7 @@ describe('MCP Routes', () => {
title: 'Invalid Protocol Server',
};
const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: invalidConfig });
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
@@ -1707,7 +1707,7 @@ describe('MCP Routes', () => {
mockRegistryInstance.addServer.mockRejectedValue(new Error('Database connection failed'));
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: validConfig });
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Database connection failed' });
@@ -1724,7 +1724,7 @@ describe('MCP Routes', () => {
mockRegistryInstance.getServerConfig.mockResolvedValue(mockConfig);
const response = await request(app).get('/api/mcp/servers/test-server');
const response = await request(app).get('/v1/chat/mcp/servers/test-server');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockConfig);
@@ -1737,7 +1737,7 @@ describe('MCP Routes', () => {
it('should return 404 when server not found', async () => {
mockRegistryInstance.getServerConfig.mockResolvedValue(null);
const response = await request(app).get('/api/mcp/servers/non-existent-server');
const response = await request(app).get('/v1/chat/mcp/servers/non-existent-server');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'MCP server not found' });
@@ -1746,7 +1746,7 @@ describe('MCP Routes', () => {
it('should return 500 when registry throws error', async () => {
mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/api/mcp/servers/error-server');
const response = await request(app).get('/v1/chat/mcp/servers/error-server');
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Database error' });
@@ -1765,7 +1765,7 @@ describe('MCP Routes', () => {
mockRegistryInstance.updateServer.mockResolvedValue(updatedConfig);
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.patch('/v1/chat/mcp/servers/test-server')
.send({ config: updatedConfig });
expect(response.status).toBe(200);
@@ -1789,7 +1789,7 @@ describe('MCP Routes', () => {
};
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.patch('/v1/chat/mcp/servers/test-server')
.send({ config: invalidConfig });
expect(response.status).toBe(400);
@@ -1807,7 +1807,7 @@ describe('MCP Routes', () => {
mockRegistryInstance.updateServer.mockRejectedValue(new Error('Update failed'));
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.patch('/v1/chat/mcp/servers/test-server')
.send({ config: validConfig });
expect(response.status).toBe(500);
@@ -1819,7 +1819,7 @@ describe('MCP Routes', () => {
it('should delete server successfully', async () => {
mockRegistryInstance.removeServer.mockResolvedValue(undefined);
const response = await request(app).delete('/api/mcp/servers/test-server');
const response = await request(app).delete('/v1/chat/mcp/servers/test-server');
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'MCP server deleted successfully' });
@@ -1833,7 +1833,7 @@ describe('MCP Routes', () => {
it('should return 500 when registry throws error', async () => {
mockRegistryInstance.removeServer.mockRejectedValue(new Error('Deletion failed'));
const response = await request(app).delete('/api/mcp/servers/error-server');
const response = await request(app).delete('/v1/chat/mcp/servers/error-server');
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Deletion failed' });
@@ -155,7 +155,7 @@ describe('DELETE /:conversationId/:messageId route handler', () => {
req.user = { id: authenticatedUserId };
next();
});
app.use('/api/messages', messagesRouter);
app.use('/v1/chat/messages', messagesRouter);
});
beforeEach(() => {
@@ -165,7 +165,7 @@ describe('DELETE /:conversationId/:messageId route handler', () => {
it('should pass user and conversationId in the deleteMessages filter', async () => {
deleteMessages.mockResolvedValue({ deletedCount: 1 });
await request(app).delete('/api/messages/convo-1/msg-1');
await request(app).delete('/v1/chat/messages/convo-1/msg-1');
expect(deleteMessages).toHaveBeenCalledTimes(1);
expect(deleteMessages).toHaveBeenCalledWith({
@@ -178,7 +178,7 @@ describe('DELETE /:conversationId/:messageId route handler', () => {
it('should return 204 on successful deletion', async () => {
deleteMessages.mockResolvedValue({ deletedCount: 1 });
const response = await request(app).delete('/api/messages/convo-1/msg-owned');
const response = await request(app).delete('/v1/chat/messages/convo-1/msg-owned');
expect(response.status).toBe(204);
expect(deleteMessages).toHaveBeenCalledWith({
@@ -191,7 +191,7 @@ describe('DELETE /:conversationId/:messageId route handler', () => {
it('should return 500 when deleteMessages throws', async () => {
deleteMessages.mockRejectedValue(new Error('DB failure'));
const response = await request(app).delete('/api/messages/convo-1/msg-1');
const response = await request(app).delete('/v1/chat/messages/convo-1/msg-1');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Internal server error' });
@@ -213,7 +213,7 @@ describe('message route conversation ownership filters', () => {
req.user = { id: authenticatedUserId };
next();
});
app.use('/api/messages', messagesRouter);
app.use('/v1/chat/messages', messagesRouter);
});
beforeEach(() => {
@@ -233,7 +233,7 @@ describe('message route conversation ownership filters', () => {
saveMessage.mockResolvedValue(savedMessage);
saveConvo.mockResolvedValue({ conversationId: urlConversationId });
const response = await request(app).post(`/api/messages/${urlConversationId}`).send({
const response = await request(app).post(`/v1/chat/messages/${urlConversationId}`).send({
messageId: savedMessage.messageId,
conversationId: bodyConversationId,
text: savedMessage.text,
@@ -248,20 +248,20 @@ describe('message route conversation ownership filters', () => {
text: savedMessage.text,
user: authenticatedUserId,
}),
{ context: 'POST /api/messages/:conversationId' },
{ context: 'POST /v1/chat/messages/:conversationId' },
);
expect(saveMessage.mock.calls[0][1].conversationId).not.toBe(bodyConversationId);
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ userId: authenticatedUserId }),
savedMessage,
{ context: 'POST /api/messages/:conversationId' },
{ context: 'POST /v1/chat/messages/:conversationId' },
);
});
it('should filter conversation message reads by authenticated user', async () => {
getMessages.mockResolvedValue([{ messageId: 'message-1', conversationId: 'convo-1' }]);
const response = await request(app).get('/api/messages/convo-1');
const response = await request(app).get('/v1/chat/messages/convo-1');
expect(response.status).toBe(200);
expect(getMessages).toHaveBeenCalledWith(
@@ -273,7 +273,7 @@ describe('message route conversation ownership filters', () => {
it('should filter single message reads by authenticated user', async () => {
getMessages.mockResolvedValue([{ messageId: 'message-1', conversationId: 'convo-1' }]);
const response = await request(app).get('/api/messages/convo-1/message-1');
const response = await request(app).get('/v1/chat/messages/convo-1/message-1');
expect(response.status).toBe(200);
expect(getMessages).toHaveBeenCalledWith(
+13 -13
View File
@@ -28,7 +28,7 @@ function createApp(user) {
req.user = user;
next();
});
app.use('/api/roles', rolesRouter);
app.use('/v1/chat/roles', rolesRouter);
return app;
}
@@ -48,12 +48,12 @@ beforeEach(() => {
mockGetRoleByName.mockResolvedValue(null);
});
describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
describe('GET /v1/chat/roles/:roleName — isOwnRole authorization', () => {
it('allows a custom role user to fetch their own role', async () => {
mockGetRoleByName.mockResolvedValue(staffRole);
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/STAFF');
const res = await request(app).get('/v1/chat/roles/STAFF');
expect(res.status).toBe(200);
expect(res.body.name).toBe('STAFF');
@@ -63,7 +63,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
it('returns 403 when a custom role user requests a different custom role', async () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/MANAGER');
const res = await request(app).get('/v1/chat/roles/MANAGER');
expect(res.status).toBe(403);
expect(mockGetRoleByName).not.toHaveBeenCalled();
@@ -72,7 +72,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
it('returns 403 when a custom role user requests ADMIN', async () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/ADMIN');
const res = await request(app).get('/v1/chat/roles/ADMIN');
expect(res.status).toBe(403);
expect(mockGetRoleByName).not.toHaveBeenCalled();
@@ -82,7 +82,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
mockGetRoleByName.mockResolvedValue(userRole);
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
const res = await request(app).get(`/v1/chat/roles/${SystemRoles.USER}`);
expect(res.status).toBe(200);
});
@@ -90,7 +90,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
it('returns 403 when USER requests the ADMIN role', async () => {
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
const res = await request(app).get(`/v1/chat/roles/${SystemRoles.ADMIN}`);
expect(res.status).toBe(403);
});
@@ -100,7 +100,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
mockGetRoleByName.mockResolvedValue(adminRole);
const app = createApp({ id: 'u1', role: SystemRoles.ADMIN });
const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
const res = await request(app).get(`/v1/chat/roles/${SystemRoles.ADMIN}`);
expect(res.status).toBe(200);
});
@@ -110,7 +110,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
mockGetRoleByName.mockResolvedValue(staffRole);
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get('/api/roles/STAFF');
const res = await request(app).get('/v1/chat/roles/STAFF');
expect(res.status).toBe(200);
expect(res.body.name).toBe('STAFF');
@@ -120,7 +120,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
mockGetRoleByName.mockResolvedValue(null);
const app = createApp({ id: 'u1', role: 'GHOST' });
const res = await request(app).get('/api/roles/GHOST');
const res = await request(app).get('/v1/chat/roles/GHOST');
expect(res.status).toBe(404);
});
@@ -129,7 +129,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
mockGetRoleByName.mockRejectedValue(new Error('db error'));
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
const res = await request(app).get(`/v1/chat/roles/${SystemRoles.USER}`);
expect(res.status).toBe(500);
});
@@ -137,7 +137,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
it('returns 403 for prototype property names like constructor (no prototype pollution)', async () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/constructor');
const res = await request(app).get('/v1/chat/roles/constructor');
expect(res.status).toBe(403);
expect(mockGetRoleByName).not.toHaveBeenCalled();
@@ -148,7 +148,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
mockGetRoleByName.mockResolvedValue(staffRole);
const res = await request(app).get('/api/roles/STAFF');
const res = await request(app).get('/v1/chat/roles/STAFF');
expect(res.status).toBe(200);
});
+12 -12
View File
@@ -64,7 +64,7 @@ const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => {
req.config = { interfaceConfig: { retentionMode } };
next();
});
app.use('/api/share', shareRouter);
app.use('/v1/chat/share', shareRouter);
return app;
};
@@ -78,7 +78,7 @@ describe('share routes retention', () => {
createSharedLink.mockResolvedValue({ shareId: 'share-123' });
const response = await request(buildApp())
.post('/api/share/convo-123')
.post('/v1/chat/share/convo-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(200);
@@ -113,7 +113,7 @@ describe('share routes retention', () => {
createSharedLink.mockResolvedValue({ shareId: 'share-123' });
const response = await request(buildApp())
.post('/api/share/convo-123')
.post('/v1/chat/share/convo-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(404);
@@ -125,7 +125,7 @@ describe('share routes retention', () => {
createSharedLink.mockResolvedValue({ shareId: 'share-123' });
const response = await request(buildApp({ retentionMode: RetentionMode.ALL }))
.post('/api/share/convo-123')
.post('/v1/chat/share/convo-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(404);
@@ -137,7 +137,7 @@ describe('share routes retention', () => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
expect(response.status).toBe(200);
expect(mongoose.models.SharedLink.findOne).toHaveBeenCalledWith(
@@ -169,7 +169,7 @@ describe('share routes retention', () => {
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
expect(response.status).toBe(404);
expect(updateSharedLink).not.toHaveBeenCalled();
@@ -181,7 +181,7 @@ describe('share routes retention', () => {
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
const response = await request(buildApp({ retentionMode: RetentionMode.ALL })).patch(
'/api/share/share-123',
'/v1/chat/share/share-123',
);
expect(response.status).toBe(404);
@@ -197,7 +197,7 @@ describe('share routes retention', () => {
mockGetSharedLinkExpiration.mockResolvedValue(null);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null);
@@ -208,7 +208,7 @@ describe('share routes retention', () => {
mockGetSharedLinkExpiration.mockResolvedValue(undefined);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, undefined);
@@ -223,7 +223,7 @@ describe('share routes retention', () => {
});
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
expect(response.status).toBe(200);
expect(logger.error).toHaveBeenCalledWith(
@@ -239,7 +239,7 @@ describe('share routes retention', () => {
updateSharedLink.mockResolvedValue({ shareId: 'share-456', targetMessageId: 'msg-456' });
const response = await request(buildApp())
.patch('/api/share/share-123')
.patch('/v1/chat/share/share-123')
.send({ targetMessageId: 'msg-456' });
expect(response.status).toBe(200);
@@ -253,7 +253,7 @@ describe('share routes retention', () => {
it('rejects non-string target message updates', async () => {
const response = await request(buildApp())
.patch('/api/share/share-123')
.patch('/v1/chat/share/share-123')
.send({ targetMessageId: 123 });
expect(response.status).toBe(400);
+7 -7
View File
@@ -22,17 +22,17 @@ router.use(uaParser);
/**
* Generic routes for resource permissions
* Pattern: /api/permissions/{resourceType}/{resourceId}
* Pattern: /v1/chat/permissions/{resourceType}/{resourceId}
*/
/**
* GET /api/permissions/search-principals
* GET /v1/chat/permissions/search-principals
* Search for users and groups to grant permissions
*/
router.get('/search-principals', checkPeoplePickerAccess, searchPrincipals);
/**
* GET /api/permissions/{resourceType}/roles
* GET /v1/chat/permissions/{resourceType}/roles
* Get available roles for a resource type
*/
router.get('/:resourceType/roles', getResourceRoles);
@@ -84,7 +84,7 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) =
};
/**
* GET /api/permissions/{resourceType}/{resourceId}
* GET /v1/chat/permissions/{resourceType}/{resourceId}
* Get all permissions for a specific resource
* SECURITY: Requires SHARE permission to view resource permissions
*/
@@ -95,7 +95,7 @@ router.get(
);
/**
* PUT /api/permissions/{resourceType}/{resourceId}
* PUT /v1/chat/permissions/{resourceType}/{resourceId}
* Bulk update permissions for a specific resource
* SECURITY: Requires SHARE permission to modify resource permissions
* SECURITY: Requires SHARE_PUBLIC permission to enable public sharing
@@ -108,13 +108,13 @@ router.put(
);
/**
* GET /api/permissions/{resourceType}/effective/all
* GET /v1/chat/permissions/{resourceType}/effective/all
* Get user's effective permissions for all accessible resources of a type
*/
router.get('/:resourceType/effective/all', getAllEffectivePermissions);
/**
* GET /api/permissions/{resourceType}/{resourceId}/effective
* GET /v1/chat/permissions/{resourceType}/{resourceId}/effective
* Get user's effective permissions for a specific resource
*/
router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
@@ -126,7 +126,7 @@ describe('Access permissions share policy', () => {
req.user = { id: 'skill-owner', role: SystemRoles.USER };
next();
});
app.use('/api/permissions', accessPermissionsRouter);
app.use('/v1/chat/permissions', accessPermissionsRouter);
});
it.each(sharePolicyCases)(
@@ -142,7 +142,7 @@ describe('Access permissions share policy', () => {
});
const response = await request(app)
.put(`/api/permissions/${resourceType}/${resourceId}`)
.put(`/v1/chat/permissions/${resourceType}/${resourceId}`)
.send({ updated: [createUpdatedPrincipal(accessRoleId)], public: false });
expect(response.status).toBe(403);
@@ -166,7 +166,7 @@ describe('Access permissions share policy', () => {
});
const response = await request(app)
.put(`/api/permissions/${ResourceType.SKILL}/${resourceId}`)
.put(`/v1/chat/permissions/${ResourceType.SKILL}/${resourceId}`)
.send({ updated: [createUpdatedPrincipal(AccessRoleIds.SKILL_VIEWER)], public: false });
expect(response.status).toBe(200);
@@ -178,7 +178,7 @@ describe('Access permissions share policy', () => {
hasCapability.mockResolvedValue(true);
const response = await request(app)
.put(`/api/permissions/${ResourceType.SKILL}/${resourceId}`)
.put(`/v1/chat/permissions/${ResourceType.SKILL}/${resourceId}`)
.send({ updated: [createUpdatedPrincipal(AccessRoleIds.SKILL_VIEWER)], public: false });
expect(response.status).toBe(200);
@@ -198,7 +198,7 @@ describe('Access permissions share policy', () => {
});
const response = await request(app)
.put(`/api/permissions/${ResourceType.SKILL}/${resourceId}`)
.put(`/v1/chat/permissions/${ResourceType.SKILL}/${resourceId}`)
.send({ public: true, publicAccessRoleId: AccessRoleIds.SKILL_VIEWER });
expect(response.status).toBe(403);
+1 -1
View File
@@ -19,7 +19,7 @@ const { getLogStores } = require('~/cache');
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET;
const OAUTH_CSRF_COOKIE_PATH = '/api/actions';
const OAUTH_CSRF_COOKIE_PATH = '/v1/chat/actions';
/**
* Sets a CSRF cookie binding the action OAuth flow to the current browser session.
+1 -1
View File
@@ -80,7 +80,7 @@ const EXCHANGE_CODE_PATTERN = /^[a-f0-9]{64}$/i;
* This endpoint is called server-to-server by the admin panel.
* The code is one-time-use and expires in 30 seconds.
*
* POST /api/admin/oauth/exchange
* POST /v1/chat/admin/oauth/exchange
* Body: { code: string }
* Response: { token: string, refreshToken: string, user: object }
*/
+4 -4
View File
@@ -134,7 +134,7 @@ describe('admin auth OpenID refresh route', () => {
app = express();
app.use(express.json());
app.use('/api/admin', adminAuthRouter);
app.use('/v1/chat/admin', adminAuthRouter);
isEnabled.mockReturnValue(true);
getOpenIdConfig.mockReturnValue(openIdConfig);
@@ -187,7 +187,7 @@ describe('admin auth OpenID refresh route', () => {
Object.assign(process.env, env);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.post('/v1/chat/admin/oauth/refresh')
.send({ refresh_token: 'incoming-refresh-token' });
expect(response.status).toBe(200);
@@ -211,7 +211,7 @@ describe('admin auth OpenID refresh route', () => {
});
const response = await request(app)
.post('/api/admin/oauth/refresh')
.post('/v1/chat/admin/oauth/refresh')
.send({ refresh_token: 'incoming-refresh-token' });
expect(response.status).toBe(401);
@@ -227,7 +227,7 @@ describe('admin auth OpenID refresh route', () => {
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
await request(app)
.post('/api/admin/oauth/refresh')
.post('/v1/chat/admin/oauth/refresh')
.send({ refresh_token: 'incoming-refresh-token' });
expect(logger.debug).toHaveBeenCalledWith('[admin/oauth/refresh] OpenID refresh params', {
@@ -69,7 +69,7 @@ describe('Agent Abort Endpoint', () => {
beforeAll(() => {
app = express();
app.use(express.json());
app.use('/api/agents', agentRoutes);
app.use('/v1/chat/agents', agentRoutes);
});
beforeEach(() => {
@@ -86,7 +86,7 @@ describe('Agent Abort Endpoint', () => {
});
const response = await request(app)
.post('/api/agents/chat/abort')
.post('/v1/chat/agents/chat/abort')
.send({ conversationId: jobStreamId });
expect(response.status).toBe(403);
@@ -112,7 +112,7 @@ describe('Agent Abort Endpoint', () => {
});
const response = await request(app)
.post('/api/agents/chat/abort')
.post('/v1/chat/agents/chat/abort')
.send({ conversationId: jobStreamId });
expect(response.status).toBe(200);
@@ -135,7 +135,7 @@ describe('Agent Abort Endpoint', () => {
});
const response = await request(app)
.post('/api/agents/chat/abort')
.post('/v1/chat/agents/chat/abort')
.send({ conversationId: jobStreamId });
expect(response.status).toBe(200);
@@ -163,7 +163,7 @@ describe('Agent Abort Endpoint', () => {
});
const response = await request(app)
.post('/api/agents/chat/abort')
.post('/v1/chat/agents/chat/abort')
.send({ conversationId: jobStreamId });
expect(response.status).toBe(200);
@@ -189,7 +189,7 @@ describe('Agent Abort Endpoint', () => {
});
const response = await request(app)
.post('/api/agents/chat/abort')
.post('/v1/chat/agents/chat/abort')
.send({ conversationId: jobStreamId });
expect(response.status).toBe(200);
@@ -224,7 +224,7 @@ describe('Agent Abort Endpoint', () => {
mockSaveMessage.mockResolvedValue();
const response = await request(app)
.post('/api/agents/chat/abort')
.post('/v1/chat/agents/chat/abort')
.send({ conversationId: jobStreamId });
expect(response.status).toBe(200);
@@ -271,7 +271,7 @@ describe('Agent Abort Endpoint', () => {
mockSaveMessage.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/api/agents/chat/abort')
.post('/v1/chat/agents/chat/abort')
.send({ conversationId: jobStreamId });
// Should still return success even if save fails
@@ -289,7 +289,7 @@ describe('Agent Abort Endpoint', () => {
mockGenerationJobManager.getActiveJobIdsForUser.mockResolvedValue([]);
const response = await request(app)
.post('/api/agents/chat/abort')
.post('/v1/chat/agents/chat/abort')
.send({ conversationId: 'non-existent-job' });
expect(response.status).toBe(404);
@@ -63,7 +63,7 @@ function buildApp(session, cookie) {
}
next();
});
app.use('/api/agents/cloud', cloudRouter);
app.use('/v1/chat/agents/cloud', cloudRouter);
return app;
}
@@ -79,14 +79,14 @@ describe('cloud agents proxy route', () => {
describe('token handling (no leak, fail-secure)', () => {
it('401s when the openid principal has no hanzo.id session token', async () => {
const res = await request(buildApp({})).get('/api/agents/cloud');
const res = await request(buildApp({})).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('forwards the session id_token (never the browser) to cloud', async () => {
mockClient.list.mockResolvedValue({ agents: [{ name: 'researcher' }] });
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
const res = await request(buildApp(withToken)).get('/v1/chat/agents/cloud');
expect(res.status).toBe(200);
expect(mockClient.list).toHaveBeenCalledWith(VALID_ID);
expect(res.body).toEqual({ agents: [{ name: 'researcher' }], enabled: true });
@@ -97,7 +97,7 @@ describe('cloud agents proxy route', () => {
// hanzo.id issues JWT access tokens too; the fallback stays principal-bound.
const accJwt = mintIdToken();
const app = buildApp({ openidTokens: { accessToken: accJwt } });
await request(app).get('/api/agents/cloud');
await request(app).get('/v1/chat/agents/cloud');
expect(mockClient.list).toHaveBeenCalledWith(accJwt);
});
@@ -106,7 +106,7 @@ describe('cloud agents proxy route', () => {
// forward. (This is the path a selective `openid_access_token` cookie
// injection would take; the binding requirement closes it.)
const app = buildApp({ openidTokens: { accessToken: 'OPAQUE_NO_BINDING' } });
const res = await request(app).get('/api/agents/cloud');
const res = await request(app).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
@@ -114,7 +114,7 @@ describe('cloud agents proxy route', () => {
it('401s (never forwards) an access_token JWT that names a different principal', async () => {
const foreignAcc = mintIdToken({ sub: 'sub-someone-else' });
const app = buildApp({ openidTokens: { accessToken: foreignAcc } });
const res = await request(app).get('/api/agents/cloud');
const res = await request(app).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
@@ -122,7 +122,7 @@ describe('cloud agents proxy route', () => {
it('reads the httpOnly openid_id_token cookie when the session is empty', async () => {
mockClient.list.mockResolvedValue({ agents: [] });
const app = buildApp({}, `openid_id_token=${VALID_ID}`);
await request(app).get('/api/agents/cloud');
await request(app).get('/v1/chat/agents/cloud');
expect(mockClient.list).toHaveBeenCalledWith(VALID_ID);
});
});
@@ -131,7 +131,7 @@ describe('cloud agents proxy route', () => {
it('honest 401 when the id_token is past its own exp (no forward)', async () => {
const expired = mintIdToken({ exp: Math.floor(Date.now() / 1000) - 60 });
const app = buildApp({ openidTokens: { idToken: expired } });
const res = await request(app).get('/api/agents/cloud');
const res = await request(app).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
@@ -139,14 +139,14 @@ describe('cloud agents proxy route', () => {
it('honest 401 when the id_token names a different principal (sub mismatch)', async () => {
const foreign = mintIdToken({ sub: 'sub-someone-else' });
const app = buildApp({ openidTokens: { idToken: foreign } });
const res = await request(app).get('/api/agents/cloud');
const res = await request(app).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('honest 401 when the id_token is not a decodable JWT', async () => {
const app = buildApp({ openidTokens: { idToken: 'not-a-jwt' } });
const res = await request(app).get('/api/agents/cloud');
const res = await request(app).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
@@ -158,14 +158,14 @@ describe('cloud agents proxy route', () => {
// Forwarding those tokens would run as the wrong principal — deny at the
// identity layer (req.user.provider), independent of any cookie.
mockPrincipal = LOCAL_USER;
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
const res = await request(buildApp(withToken)).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('401s for a principal that carries no provider', async () => {
mockPrincipal = { id: 'u_unknown' };
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
const res = await request(buildApp(withToken)).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
@@ -175,7 +175,7 @@ describe('cloud agents proxy route', () => {
// asserted, so NO token is forwarded — even one whose sub would have matched
// a normal user. Fail-secure closes the null-binding gap.
mockPrincipal = { id: 'u_openid_no_sub', provider: 'openid' };
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
const res = await request(buildApp(withToken)).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
@@ -186,7 +186,7 @@ describe('cloud agents proxy route', () => {
// binding rejects it — the forwarded principal can only ever be req.user.
const foreign = mintIdToken({ sub: 'victim-sub-xyz' });
const app = buildApp({}, `openid_id_token=${foreign}`);
const res = await request(app).get('/api/agents/cloud');
const res = await request(app).get('/v1/chat/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
@@ -195,7 +195,7 @@ describe('cloud agents proxy route', () => {
describe('disabled deployment', () => {
it('returns an empty, disabled list when cloud agents are not configured', async () => {
getCloudAgentsClient.mockReturnValue(null);
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
const res = await request(buildApp(withToken)).get('/v1/chat/agents/cloud');
expect(res.status).toBe(200);
expect(res.body).toEqual({ agents: [], enabled: false });
});
@@ -205,7 +205,7 @@ describe('cloud agents proxy route', () => {
it('forwards {input} and returns the RunResult', async () => {
mockClient.run.mockResolvedValue({ id: 'run_1', status: 'ok', output: 'done' });
const res = await request(buildApp(withToken))
.post('/api/agents/cloud/researcher/run')
.post('/v1/chat/agents/cloud/researcher/run')
.send({ input: 'summarize' });
expect(res.status).toBe(200);
expect(mockClient.run).toHaveBeenCalledWith(VALID_ID, 'researcher', 'summarize');
@@ -219,7 +219,7 @@ describe('cloud agents proxy route', () => {
});
mockClient.run.mockRejectedValue(err);
const res = await request(buildApp(withToken))
.post('/api/agents/cloud/researcher/run')
.post('/v1/chat/agents/cloud/researcher/run')
.send({ input: 'x' });
expect(res.status).toBe(502);
expect(res.body).toEqual({ status: 'error', error: 'model down' });
@@ -229,7 +229,7 @@ describe('cloud agents proxy route', () => {
const err = Object.assign(new Error('invalid agent name'), { status: 400 });
mockClient.run.mockRejectedValue(err);
const res = await request(buildApp(withToken))
.post('/api/agents/cloud/researcher/run')
.post('/v1/chat/agents/cloud/researcher/run')
.send({ input: 'x' });
// simulate the client rejecting after the boundary let a valid name through
expect(res.status).toBe(400);
@@ -250,7 +250,7 @@ describe('cloud agents proxy route', () => {
for (const name of smuggles) {
it(`rejects "${name}" with 400 and never calls the client`, async () => {
const res = await request(buildApp(withToken))
.post(`/api/agents/cloud/${name}/run`)
.post(`/v1/chat/agents/cloud/${name}/run`)
.send({ input: 'x' });
expect(res.status).toBe(400);
expect(mockClient.run).not.toHaveBeenCalled();
@@ -258,7 +258,7 @@ describe('cloud agents proxy route', () => {
}
it('rejects a bad name on GET /:name too', async () => {
const res = await request(buildApp(withToken)).get('/api/agents/cloud/..%2Fetc');
const res = await request(buildApp(withToken)).get('/v1/chat/agents/cloud/..%2Fetc');
expect(res.status).toBe(400);
expect(mockClient.get).not.toHaveBeenCalled();
});
@@ -411,7 +411,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
// Mount the responses routes
const responsesRoutes = require('~/server/routes/agents/responses');
app.use('/api/agents/v1/responses', responsesRoutes);
app.use('/v1/chat/agents/v1/responses', responsesRoutes);
// Create test user
testUser = await User.create({
@@ -531,7 +531,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
describe('basic-response', () => {
it('should return a valid ResponseResource for a simple text request', async () => {
const response = await authRequest()
.post('/api/agents/v1/responses')
.post('/v1/chat/agents/v1/responses')
.send({
model: testAgent.id,
input: [
@@ -570,7 +570,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
describe('streaming-response', () => {
it('should return valid SSE streaming events', async () => {
const response = await authRequest()
.post('/api/agents/v1/responses')
.post('/v1/chat/agents/v1/responses')
.send({
model: testAgent.id,
input: [
@@ -642,7 +642,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
it('should emit valid event types per Open Responses spec', async () => {
const response = await authRequest()
.post('/api/agents/v1/responses')
.post('/v1/chat/agents/v1/responses')
.send({
model: testAgent.id,
input: [
@@ -679,7 +679,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
it('should include logprobs array in output_text events', async () => {
const response = await authRequest()
.post('/api/agents/v1/responses')
.post('/v1/chat/agents/v1/responses')
.send({
model: testAgent.id,
input: [
@@ -736,7 +736,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
// gets merged into the system prompt, or we test with a simple user message
// that instructs the behavior.
const response = await authRequest()
.post('/api/agents/v1/responses')
.post('/v1/chat/agents/v1/responses')
.send({
model: testAgent.id,
input: [
@@ -762,7 +762,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
describe('multi-turn', () => {
it('should handle multi-turn conversation history', async () => {
const response = await authRequest()
.post('/api/agents/v1/responses')
.post('/v1/chat/agents/v1/responses')
.send({
model: testAgent.id,
input: [
@@ -802,7 +802,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
describe('string-input', () => {
it('should accept simple string input', async () => {
const response = await authRequest().post('/api/agents/v1/responses').send({
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
model: testAgent.id,
input: 'Hello!',
});
@@ -822,7 +822,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
describe('Extended Thinking', () => {
it('should return reasoning output when thinking is enabled', async () => {
const response = await authRequest()
.post('/api/agents/v1/responses')
.post('/v1/chat/agents/v1/responses')
.send({
model: thinkingAgent.id,
input: [
@@ -863,7 +863,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
it('should stream reasoning events when thinking is enabled', async () => {
const response = await authRequest()
.post('/api/agents/v1/responses')
.post('/v1/chat/agents/v1/responses')
.send({
model: thinkingAgent.id,
input: [
@@ -938,7 +938,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
describe('Schema Validation', () => {
it('should include all required fields in response', async () => {
const response = await authRequest().post('/api/agents/v1/responses').send({
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
model: testAgent.id,
input: 'Test',
});
@@ -984,7 +984,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
});
it('should have valid message item structure', async () => {
const response = await authRequest().post('/api/agents/v1/responses').send({
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
model: testAgent.id,
input: 'Hello',
});
@@ -1034,7 +1034,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
describe('Response Storage', () => {
it('should store response when store: true and retrieve it', async () => {
// Create a stored response
const createResponse = await authRequest().post('/api/agents/v1/responses').send({
const createResponse = await authRequest().post('/v1/chat/agents/v1/responses').send({
model: testAgent.id,
input: 'Remember this: The answer is 42.',
store: true,
@@ -1050,7 +1050,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
await new Promise((resolve) => setTimeout(resolve, 500));
// Retrieve the stored response
const getResponseResult = await authRequest().get(`/api/agents/v1/responses/${responseId}`);
const getResponseResult = await authRequest().get(`/v1/chat/agents/v1/responses/${responseId}`);
// Note: The response might be stored under conversationId, not responseId
// If we get 404, that's expected behavior for now since we store by conversationId
@@ -1062,7 +1062,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
});
it('should return 404 for non-existent response', async () => {
const response = await authRequest().get('/api/agents/v1/responses/resp_nonexistent123');
const response = await authRequest().get('/v1/chat/agents/v1/responses/resp_nonexistent123');
expect(response.status).toBe(404);
expect(response.body.error).toBeDefined();
@@ -1075,7 +1075,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
describe('Error Handling', () => {
it('should return error for missing model', async () => {
const response = await authRequest().post('/api/agents/v1/responses').send({
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
input: 'Hello',
});
@@ -1084,7 +1084,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
});
it('should return error for missing input', async () => {
const response = await authRequest().post('/api/agents/v1/responses').send({
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
model: testAgent.id,
});
@@ -1093,7 +1093,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
});
it('should return error for non-existent agent', async () => {
const response = await authRequest().post('/api/agents/v1/responses').send({
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
model: 'agent_nonexistent123456789',
input: 'Hello',
});
@@ -1109,7 +1109,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
describe('GET /v1/responses/models', () => {
it('should list available agents as models', async () => {
const response = await authRequest().get('/api/agents/v1/responses/models');
const response = await authRequest().get('/v1/chat/agents/v1/responses/models');
expect(response.status).toBe(200);
expect(response.body.object).toBe('list');
+7 -7
View File
@@ -7,11 +7,11 @@ const { getCloudAgentsClient, AGENT_NAME_RE } = require('~/server/services/Cloud
/**
* Cloud agents router lets a signed-in chat user RUN their own canonical Hanzo
* Cloud agents (`/v1/agents`) from the chat thread. Mounted at `/api/agents/cloud`.
* Cloud agents (`/v1/agents`) from the chat thread. Mounted at `/v1/chat/agents/cloud`.
*
* GET /api/agents/cloud list the caller's cloud agents
* GET /api/agents/cloud/:name one agent's detail + recent runs
* POST /api/agents/cloud/:name/run run the agent {input} -> RunResult
* GET /v1/chat/agents/cloud list the caller's cloud agents
* GET /v1/chat/agents/cloud/:name one agent's detail + recent runs
* POST /v1/chat/agents/cloud/:name/run run the agent {input} -> RunResult
*
* Auth: `requireJwtAuth` gates every route (guests are rejected). The chat
* backend then forwards the user's hanzo.id id_token to cloud as a Bearer;
@@ -144,7 +144,7 @@ function sendCloudError(res, err, action) {
return res.status(status).json({ error: err.message || 'cloud agents request failed' });
}
/** GET /api/agents/cloud — list the caller's cloud agents. */
/** GET /v1/chat/agents/cloud — list the caller's cloud agents. */
router.get('/', async (req, res) => {
const client = getCloudAgentsClient();
if (!client) {
@@ -162,7 +162,7 @@ router.get('/', async (req, res) => {
}
});
/** GET /api/agents/cloud/:name — one agent's detail + recent runs. */
/** GET /v1/chat/agents/cloud/:name — one agent's detail + recent runs. */
router.get('/:name', async (req, res) => {
const client = getCloudAgentsClient();
if (!client) {
@@ -180,7 +180,7 @@ router.get('/:name', async (req, res) => {
}
});
/** POST /api/agents/cloud/:name/run — run the agent, return its RunResult. */
/** POST /v1/chat/agents/cloud/:name/run — run the agent, return its RunResult. */
router.post('/:name/run', async (req, res) => {
const client = getCloudAgentsClient();
if (!client) {
+3 -3
View File
@@ -25,7 +25,7 @@ const router = express.Router();
/**
* Open Responses API routes (API key authentication handled in route file)
* Mounted at /agents/v1/responses (full path: /api/agents/v1/responses)
* Mounted at /agents/v1/responses (full path: /v1/chat/agents/v1/responses)
* NOTE: Must be mounted BEFORE /v1 to avoid being caught by the less specific route
* @see https://openresponses.org/specification
*/
@@ -33,7 +33,7 @@ router.use('/v1/responses', responses);
/**
* OpenAI-compatible API routes (API key authentication handled in route file)
* Mounted at /agents/v1 (full path: /api/agents/v1/chat/completions)
* Mounted at /agents/v1 (full path: /v1/chat/agents/v1/chat/completions)
*/
router.use('/v1', openai);
@@ -103,7 +103,7 @@ router.use(uaParser);
* Canonical Hanzo Cloud agents (`/v1/agents`). Mounted BEFORE the legacy `/`
* (v1) router so `/cloud/*` is not shadowed by v1's `GET /:id`. The cloud router
* carries its own `requireJwtAuth` and forwards the user's hanzo.id bearer to
* cloud server-side (see cloud.js). Legacy `/api/agents` CRUD stays untouched.
* cloud server-side (see cloud.js). Legacy `/v1/chat/agents` CRUD stays untouched.
*/
router.use('/cloud', cloud);
+6 -6
View File
@@ -79,18 +79,18 @@ jest.mock('~/server/middleware', () => {
const authRouter = require('./auth');
describe('POST /api/auth/cloudfront/refresh', () => {
describe('POST /v1/chat/auth/cloudfront/refresh', () => {
let app;
beforeEach(() => {
jest.clearAllMocks();
app = express();
app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/v1/chat/auth', authRouter);
});
it('requires authentication', async () => {
await request(app).post('/api/auth/cloudfront/refresh').expect(401);
await request(app).post('/v1/chat/auth/cloudfront/refresh').expect(401);
expect(mockForceRefreshCloudFrontAuthCookies).not.toHaveBeenCalled();
});
@@ -104,7 +104,7 @@ describe('POST /api/auth/cloudfront/refresh', () => {
});
const response = await request(app)
.post('/api/auth/cloudfront/refresh')
.post('/v1/chat/auth/cloudfront/refresh')
.set('Authorization', 'Bearer ok')
.expect(404);
@@ -121,7 +121,7 @@ describe('POST /api/auth/cloudfront/refresh', () => {
});
const response = await request(app)
.post('/api/auth/cloudfront/refresh')
.post('/v1/chat/auth/cloudfront/refresh')
.set('Authorization', 'Bearer ok')
.expect(200);
@@ -139,7 +139,7 @@ describe('POST /api/auth/cloudfront/refresh', () => {
it('reuses the auth middleware refresh result instead of minting cookies twice', async () => {
const response = await request(app)
.post('/api/auth/cloudfront/refresh')
.post('/v1/chat/auth/cloudfront/refresh')
.set('Authorization', 'Bearer ok')
.set('x-cloudfront-warmed', 'true')
.expect(200);
+2 -2
View File
@@ -186,7 +186,7 @@ router.post('/archive', validateConvoAccess, async (req, res) => {
const dbResponse = await saveConvo(
req,
{ conversationId, isArchived },
{ context: `POST /api/convos/archive ${conversationId}` },
{ context: `POST /v1/chat/convos/archive ${conversationId}` },
);
res.status(200).json(dbResponse);
} catch (error) {
@@ -226,7 +226,7 @@ router.post('/update', validateConvoAccess, async (req, res) => {
const dbResponse = await saveConvo(
req,
{ conversationId, title: sanitizedTitle },
{ context: `POST /api/convos/update ${conversationId}` },
{ context: `POST /v1/chat/convos/update ${conversationId}` },
);
res.status(201).json(dbResponse);
} catch (error) {
+8 -8
View File
@@ -106,7 +106,7 @@ describe('file upload routes restore strict isolation context after multer', ()
beforeAll(async () => {
const { initialize } = require('./index');
app = express();
app.use('/api/files', await initialize());
app.use('/v1/chat/files', await initialize());
});
beforeEach(() => {
@@ -123,12 +123,12 @@ describe('file upload routes restore strict isolation context after multer', ()
});
it.each([
['files', '/api/files'],
['images', '/api/files/images'],
['avatar', '/api/files/images/avatar'],
['agent-avatar', '/api/files/images/agents/agent-1/avatar'],
['assistant-avatar', '/api/files/images/assistants/asst-1/avatar'],
['speech-stt', '/api/files/speech/stt'],
['files', '/v1/chat/files'],
['images', '/v1/chat/files/images'],
['avatar', '/v1/chat/files/images/avatar'],
['agent-avatar', '/v1/chat/files/images/agents/agent-1/avatar'],
['assistant-avatar', '/v1/chat/files/images/assistants/asst-1/avatar'],
['speech-stt', '/v1/chat/files/speech/stt'],
])('restores context for %s upload', async (route, url) => {
const res = await request(app).post(url);
@@ -142,7 +142,7 @@ describe('file upload routes restore strict isolation context after multer', ()
role: 'USER',
};
const res = await request(app).post('/api/files');
const res = await request(app).post('/v1/chat/files');
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/Tenant context required/);
+2 -2
View File
@@ -23,7 +23,7 @@ describe('Multer Configuration', () => {
mockReq = {
user: { id: 'test-user-123' },
body: {},
originalUrl: '/api/files/upload',
originalUrl: '/v1/chat/files/upload',
config: {
paths: {
uploads: tempDir,
@@ -256,7 +256,7 @@ describe('Multer Configuration', () => {
});
it('should handle audio files for speech-to-text endpoint with real config', async () => {
mockReq.originalUrl = '/api/speech/stt';
mockReq.originalUrl = '/v1/chat/speech/stt';
const multerInstance = await createMulterInstance();
expect(multerInstance).toBeDefined();
+6 -6
View File
@@ -47,7 +47,7 @@ const { getLogStores } = require('~/cache');
const router = Router();
const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
const OAUTH_CSRF_COOKIE_PATH = '/v1/chat/mcp';
/**
* Get all MCP tools available to the user
@@ -677,7 +677,7 @@ const checkMCPCreate = generateCheckAccess({
/**
* Get list of accessible MCP servers
* @route GET /api/mcp/servers
* @route GET /v1/chat/mcp/servers
* @param {Object} req.query - Query parameters for pagination and search
* @param {number} [req.query.limit] - Number of results per page
* @param {string} [req.query.after] - Pagination cursor
@@ -688,7 +688,7 @@ router.get('/servers', requireJwtAuth, checkMCPUsePermissions, getMCPServersList
/**
* Create a new MCP server
* @route POST /api/mcp/servers
* @route POST /v1/chat/mcp/servers
* @param {MCPServerCreateParams} req.body - The MCP server creation parameters.
* @returns {MCPServer} 201 - Success response - application/json
*/
@@ -696,7 +696,7 @@ router.post('/servers', requireJwtAuth, checkMCPCreate, createMCPServerControlle
/**
* Get single MCP server by ID
* @route GET /api/mcp/servers/:serverName
* @route GET /v1/chat/mcp/servers/:serverName
* @param {string} req.params.serverName - MCP server identifier.
* @returns {MCPServer} 200 - Success response - application/json
*/
@@ -713,7 +713,7 @@ router.get(
/**
* Update MCP server
* @route PATCH /api/mcp/servers/:serverName
* @route PATCH /v1/chat/mcp/servers/:serverName
* @param {string} req.params.serverName - MCP server identifier.
* @param {MCPServerUpdateParams} req.body - The MCP server update parameters.
* @returns {MCPServer} 200 - Success response - application/json
@@ -731,7 +731,7 @@ router.patch(
/**
* Delete MCP server
* @route DELETE /api/mcp/servers/:serverName
* @route DELETE /v1/chat/mcp/servers/:serverName
* @param {string} req.params.serverName - MCP server identifier.
* @returns {Object} 200 - Success response - application/json
*/
+4 -4
View File
@@ -188,7 +188,7 @@ router.post('/branch', async (req, res) => {
};
const savedMessage = await saveMessage(req, newMessage, {
context: 'POST /api/messages/branch',
context: 'POST /v1/chat/messages/branch',
});
if (!savedMessage) {
@@ -265,7 +265,7 @@ router.post('/artifact/:messageId', async (req, res) => {
content: message.content,
user: req.user.id,
},
{ context: 'POST /api/messages/artifact/:messageId' },
{ context: 'POST /v1/chat/messages/artifact/:messageId' },
);
res.status(200).json({
@@ -297,12 +297,12 @@ router.post('/:conversationId', validateMessageReq, async (req, res) => {
const savedMessage = await saveMessage(
req,
{ ...message, user: req.user.id },
{ context: 'POST /api/messages/:conversationId' },
{ context: 'POST /v1/chat/messages/:conversationId' },
);
if (!savedMessage) {
return res.status(400).json({ error: 'Message not saved' });
}
await saveConvo(req, savedMessage, { context: 'POST /api/messages/:conversationId' });
await saveConvo(req, savedMessage, { context: 'POST /v1/chat/messages/:conversationId' });
res.status(201).json(savedMessage);
} catch (error) {
logger.error('Error saving message:', error);
+36 -36
View File
@@ -83,7 +83,7 @@ beforeAll(async () => {
// Import routes after middleware is set up
promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
app.use('/v1/chat/prompts', promptRoutes);
});
afterEach(() => {
@@ -180,13 +180,13 @@ describe('Prompt Routes - ACL Permissions', () => {
// Simple test to verify route is loaded
it('should have routes loaded', async () => {
// This should at least not crash
const response = await request(app).get('/api/prompts/test-404');
const response = await request(app).get('/v1/chat/prompts/test-404');
// We expect a 401 or 404, not 500
expect(response.status).not.toBe(500);
});
describe('POST /api/prompts - Create Prompt', () => {
describe('POST /v1/chat/prompts - Create Prompt', () => {
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
@@ -204,7 +204,7 @@ describe('Prompt Routes - ACL Permissions', () => {
},
};
const response = await request(app).post('/api/prompts').send(promptData);
const response = await request(app).post('/v1/chat/prompts').send(promptData);
expect(response.status).toBe(200);
expect(response.body.prompt).toBeDefined();
@@ -234,7 +234,7 @@ describe('Prompt Routes - ACL Permissions', () => {
},
};
const response = await request(app).post('/api/prompts').send(promptData).expect(200);
const response = await request(app).post('/v1/chat/prompts').send(promptData).expect(200);
expect(response.body.prompt).toBeDefined();
expect(response.body.group).toBeDefined();
@@ -252,7 +252,7 @@ describe('Prompt Routes - ACL Permissions', () => {
});
});
describe('GET /api/prompts/:promptId - Get Prompt', () => {
describe('GET /v1/chat/prompts/:promptId - Get Prompt', () => {
let testPrompt;
let testGroup;
@@ -293,7 +293,7 @@ describe('Prompt Routes - ACL Permissions', () => {
grantedBy: testUsers.owner._id,
});
const response = await request(app).get(`/api/prompts/${testPrompt._id}`);
const response = await request(app).get(`/v1/chat/prompts/${testPrompt._id}`);
expect(response.status).toBe(200);
expect(response.body._id).toBe(testPrompt._id.toString());
expect(response.body.prompt).toBe(testPrompt.prompt);
@@ -303,7 +303,7 @@ describe('Prompt Routes - ACL Permissions', () => {
// Change the user to one without access
setTestUser(app, testUsers.noAccess);
const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(403);
const response = await request(app).get(`/v1/chat/prompts/${testPrompt._id}`).expect(403);
// Verify error response
expect(response.body.error).toBe('Forbidden');
@@ -314,13 +314,13 @@ describe('Prompt Routes - ACL Permissions', () => {
// Set admin user
setTestUser(app, testUsers.admin);
const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200);
const response = await request(app).get(`/v1/chat/prompts/${testPrompt._id}`).expect(200);
expect(response.body._id).toBe(testPrompt._id.toString());
});
});
describe('DELETE /api/prompts/:promptId - Delete Prompt', () => {
describe('DELETE /v1/chat/prompts/:promptId - Delete Prompt', () => {
let testPrompt;
let testGroup;
@@ -366,7 +366,7 @@ describe('Prompt Routes - ACL Permissions', () => {
it('should delete prompt when user has delete permissions', async () => {
const response = await request(app)
.delete(`/api/prompts/${testPrompt._id}`)
.delete(`/v1/chat/prompts/${testPrompt._id}`)
.query({ groupId: testGroup._id.toString() })
.expect(200);
@@ -408,7 +408,7 @@ describe('Prompt Routes - ACL Permissions', () => {
setTestUser(app, testUsers.viewer);
await request(app)
.delete(`/api/prompts/${authorPrompt._id}`)
.delete(`/v1/chat/prompts/${authorPrompt._id}`)
.query({ groupId: testGroup._id.toString() })
.expect(403);
@@ -418,7 +418,7 @@ describe('Prompt Routes - ACL Permissions', () => {
});
});
describe('PATCH /api/prompts/:promptId/tags/production - Make Production', () => {
describe('PATCH /v1/chat/prompts/:promptId/tags/production - Make Production', () => {
let testPrompt;
let testGroup;
@@ -462,7 +462,7 @@ describe('Prompt Routes - ACL Permissions', () => {
setTestUser(app, testUsers.owner);
const response = await request(app)
.patch(`/api/prompts/${testPrompt._id}/tags/production`)
.patch(`/v1/chat/prompts/${testPrompt._id}/tags/production`)
.expect(200);
expect(response.body.message).toBe('Prompt production made successfully');
@@ -486,7 +486,7 @@ describe('Prompt Routes - ACL Permissions', () => {
// Set viewer user
setTestUser(app, testUsers.viewer);
await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403);
await request(app).patch(`/v1/chat/prompts/${testPrompt._id}/tags/production`).expect(403);
// Verify prompt hasn't changed
const unchangedGroup = await PromptGroup.findById(testGroup._id);
@@ -538,13 +538,13 @@ describe('Prompt Routes - ACL Permissions', () => {
// Change user to someone without explicit permissions
setTestUser(app, testUsers.noAccess);
const response = await request(app).get(`/api/prompts/${publicPrompt._id}`).expect(200);
const response = await request(app).get(`/v1/chat/prompts/${publicPrompt._id}`).expect(200);
expect(response.body._id).toBe(publicPrompt._id.toString());
});
});
describe('PATCH /api/prompts/groups/:groupId - Update Prompt Group Security', () => {
describe('PATCH /v1/chat/prompts/groups/:groupId - Update Prompt Group Security', () => {
let testGroup;
beforeEach(async () => {
@@ -581,7 +581,7 @@ describe('Prompt Routes - ACL Permissions', () => {
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
.send(updateData)
.expect(200);
@@ -597,7 +597,7 @@ describe('Prompt Routes - ACL Permissions', () => {
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
@@ -613,7 +613,7 @@ describe('Prompt Routes - ACL Permissions', () => {
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
@@ -629,7 +629,7 @@ describe('Prompt Routes - ACL Permissions', () => {
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
@@ -645,7 +645,7 @@ describe('Prompt Routes - ACL Permissions', () => {
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
@@ -661,7 +661,7 @@ describe('Prompt Routes - ACL Permissions', () => {
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
@@ -676,7 +676,7 @@ describe('Prompt Routes - ACL Permissions', () => {
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
@@ -696,7 +696,7 @@ describe('Prompt Routes - ACL Permissions', () => {
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
@@ -741,7 +741,7 @@ describe('Prompt Routes - ACL Permissions', () => {
it('should correctly indicate hasMore when there are more pages', async () => {
const response = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '10' })
.expect(200);
@@ -755,7 +755,7 @@ describe('Prompt Routes - ACL Permissions', () => {
it('should correctly indicate no more pages on the last page', async () => {
// First get the cursor for page 2
const firstPage = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '10' })
.expect(200);
@@ -764,7 +764,7 @@ describe('Prompt Routes - ACL Permissions', () => {
// Now fetch the second page using the cursor
const response = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '10', cursor: firstPage.body.after })
.expect(200);
@@ -775,7 +775,7 @@ describe('Prompt Routes - ACL Permissions', () => {
it('should support cursor-based pagination', async () => {
// First page
const firstPage = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '5' })
.expect(200);
@@ -785,7 +785,7 @@ describe('Prompt Routes - ACL Permissions', () => {
// Second page using cursor
const secondPage = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '5', cursor: firstPage.body.after })
.expect(200);
@@ -848,7 +848,7 @@ describe('Prompt Routes - ACL Permissions', () => {
// Test pagination with category filter
const firstPage = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '5', category: 'test-cat-1' })
.expect(200);
@@ -858,7 +858,7 @@ describe('Prompt Routes - ACL Permissions', () => {
expect(firstPage.body.after).toBeTruthy();
const secondPage = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '5', cursor: firstPage.body.after, category: 'test-cat-1' })
.expect(200);
@@ -916,7 +916,7 @@ describe('Prompt Routes - ACL Permissions', () => {
// Test pagination with name filter
const firstPage = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '10', name: 'Search' })
.expect(200);
@@ -926,7 +926,7 @@ describe('Prompt Routes - ACL Permissions', () => {
expect(firstPage.body.after).toBeTruthy();
const secondPage = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '10', cursor: firstPage.body.after, name: 'Search' })
.expect(200);
@@ -984,7 +984,7 @@ describe('Prompt Routes - ACL Permissions', () => {
// Test pagination with both filters
const response = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '5', name: 'API', category: 'api-category' })
.expect(200);
@@ -999,7 +999,7 @@ describe('Prompt Routes - ACL Permissions', () => {
// Page 2
const page2 = await request(app)
.get('/api/prompts/groups')
.get('/v1/chat/prompts/groups')
.query({ limit: '5', cursor: response.body.after, name: 'API', category: 'api-category' })
.expect(200);
+8 -8
View File
@@ -103,7 +103,7 @@ const createPermissionUpdateHandler = (permissionKey) => {
};
/**
* GET /api/roles/:roleName
* GET /v1/chat/roles/:roleName
* Get a specific role by name
*/
router.get('/:roleName', async (req, res) => {
@@ -131,43 +131,43 @@ router.get('/:roleName', async (req, res) => {
});
/**
* PUT /api/roles/:roleName/prompts
* PUT /v1/chat/roles/:roleName/prompts
* Update prompt permissions for a specific role
*/
router.put('/:roleName/prompts', checkAdmin, createPermissionUpdateHandler('prompts'));
/**
* PUT /api/roles/:roleName/agents
* PUT /v1/chat/roles/:roleName/agents
* Update agent permissions for a specific role
*/
router.put('/:roleName/agents', checkAdmin, createPermissionUpdateHandler('agents'));
/**
* PUT /api/roles/:roleName/memories
* PUT /v1/chat/roles/:roleName/memories
* Update memory permissions for a specific role
*/
router.put('/:roleName/memories', checkAdmin, createPermissionUpdateHandler('memories'));
/**
* PUT /api/roles/:roleName/people-picker
* PUT /v1/chat/roles/:roleName/people-picker
* Update people picker permissions for a specific role
*/
router.put('/:roleName/people-picker', checkAdmin, createPermissionUpdateHandler('people-picker'));
/**
* PUT /api/roles/:roleName/mcp-servers
* PUT /v1/chat/roles/:roleName/mcp-servers
* Update MCP servers permissions for a specific role
*/
router.put('/:roleName/mcp-servers', checkAdmin, createPermissionUpdateHandler('mcp-servers'));
/**
* PUT /api/roles/:roleName/marketplace
* PUT /v1/chat/roles/:roleName/marketplace
* Update marketplace permissions for a specific role
*/
router.put('/:roleName/marketplace', checkAdmin, createPermissionUpdateHandler('marketplace'));
/**
* PUT /api/roles/:roleName/remote-agents
* PUT /v1/chat/roles/:roleName/remote-agents
* Update remote agents (API) permissions for a specific role
*/
router.put('/:roleName/remote-agents', checkAdmin, createPermissionUpdateHandler('remote-agents'));
+7 -7
View File
@@ -141,7 +141,7 @@ beforeAll(async () => {
});
currentTestUser = testUsers.owner;
app.use('/api/skills', require('./skills'));
app.use('/v1/chat/skills', require('./skills'));
});
afterEach(async () => {
@@ -202,7 +202,7 @@ async function setupTestData() {
async function createSkillAsOwner(overrides = {}) {
return request(app)
.post('/api/skills')
.post('/v1/chat/skills')
.send({
name: 'strict-file-skill',
description: 'A strict tenant skill used in multipart route tests.',
@@ -227,7 +227,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
zip.file('scripts/run.sh', 'echo strict');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const res = await request(app).post('/api/skills/import').attach('file', buffer, {
const res = await request(app).post('/v1/chat/skills/import').attach('file', buffer, {
filename: 'strict-import.skill',
contentType: 'application/zip',
});
@@ -274,7 +274,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
});
const res = await request(app)
.post('/api/skills/import')
.post('/v1/chat/skills/import')
.attach('file', Buffer.from('# Request Tenant Markdown'), {
filename: 'request-tenant.md',
contentType: 'text/markdown',
@@ -301,7 +301,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
});
const res = await request(app)
.post('/api/skills/import')
.post('/v1/chat/skills/import')
.attach('file', Buffer.from('# No Tenant'), {
filename: 'no-tenant.md',
contentType: 'text/markdown',
@@ -316,7 +316,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
expect(created.status).toBe(201);
const res = await request(app)
.post(`/api/skills/${created.body._id}/files`)
.post(`/v1/chat/skills/${created.body._id}/files`)
.field('relativePath', 'scripts/manual.sh')
.attach('file', Buffer.from('echo manual'), {
filename: 'manual.sh',
@@ -351,7 +351,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
});
const res = await request(app)
.post(`/api/skills/${created.body._id}/files`)
.post(`/v1/chat/skills/${created.body._id}/files`)
.field('relativePath', 'scripts/request-tenant.sh')
.attach('file', Buffer.from('echo request tenant'), {
filename: 'request-tenant.sh',
+36 -36
View File
@@ -143,7 +143,7 @@ beforeAll(async () => {
currentTestUser = testUsers.owner;
skillRoutes = require('./skills');
app.use('/api/skills', skillRoutes);
app.use('/v1/chat/skills', skillRoutes);
});
afterEach(async () => {
@@ -223,7 +223,7 @@ async function createSkillAsOwner(overrides = {}) {
// Description is deliberately kept above the 20-char short-description
// warning threshold so existing tests don't trip the coaching warning.
const res = await request(app)
.post('/api/skills')
.post('/v1/chat/skills')
.send({
name: 'demo-skill',
description: 'A small demo skill used in routing integration tests.',
@@ -240,7 +240,7 @@ describe('Skill routes', () => {
});
afterEach(() => errSpy.mockRestore());
describe('POST /api/skills', () => {
describe('POST /v1/chat/skills', () => {
it('creates a skill and grants SKILL_OWNER ACL', async () => {
const res = await createSkillAsOwner();
expect(res.status).toBe(201);
@@ -306,7 +306,7 @@ describe('Skill routes', () => {
});
it('rejects missing description with 400', async () => {
const res = await request(app).post('/api/skills').send({ name: 'x-skill', body: '' });
const res = await request(app).post('/v1/chat/skills').send({ name: 'x-skill', body: '' });
expect(res.status).toBe(400);
});
@@ -324,7 +324,7 @@ describe('Skill routes', () => {
});
});
describe('POST /api/skills/import', () => {
describe('POST /v1/chat/skills/import', () => {
it('enforces fileConfig.skills.fileSizeLimit before import handling', async () => {
mockFileConfig = {
skills: {
@@ -333,7 +333,7 @@ describe('Skill routes', () => {
};
const res = await request(app)
.post('/api/skills/import')
.post('/v1/chat/skills/import')
.attach('file', Buffer.alloc(2 * 1024 * 1024), {
filename: 'too-large.skill',
contentType: 'application/zip',
@@ -368,7 +368,7 @@ describe('Skill routes', () => {
zip.file('scripts/imported-script.sh', 'echo imported');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const res = await request(app).post('/api/skills/import').attach('file', buffer, {
const res = await request(app).post('/v1/chat/skills/import').attach('file', buffer, {
filename: 'imported-skill.skill',
contentType: 'application/zip',
});
@@ -395,7 +395,7 @@ describe('Skill routes', () => {
});
});
describe('GET /api/skills', () => {
describe('GET /v1/chat/skills', () => {
it('returns only skills the caller can access', async () => {
const mine = await createSkillAsOwner({ name: 'mine-skill' });
expect(mine.status).toBe(201);
@@ -407,36 +407,36 @@ describe('Skill routes', () => {
// users see their own skill only.
setTestUser(testUsers.owner);
const res = await request(app).get('/api/skills');
const res = await request(app).get('/v1/chat/skills');
expect(res.status).toBe(200);
expect(res.body.skills.length).toBe(1);
expect(res.body.skills[0].name).toBe('mine-skill');
});
});
describe('GET /api/skills/:id', () => {
describe('GET /v1/chat/skills/:id', () => {
it('returns 403 when the user has no access', async () => {
const created = await createSkillAsOwner();
expect(created.status).toBe(201);
setTestUser(testUsers.noAccess);
const res = await request(app).get(`/api/skills/${created.body._id}`);
const res = await request(app).get(`/v1/chat/skills/${created.body._id}`);
expect(res.status).toBe(403);
});
it('returns the skill to the owner with isPublic flag', async () => {
const created = await createSkillAsOwner();
const res = await request(app).get(`/api/skills/${created.body._id}`);
const res = await request(app).get(`/v1/chat/skills/${created.body._id}`);
expect(res.status).toBe(200);
expect(res.body.name).toBe('demo-skill');
expect(res.body.isPublic).toBe(false);
});
});
describe('PATCH /api/skills/:id (optimistic concurrency)', () => {
describe('PATCH /v1/chat/skills/:id (optimistic concurrency)', () => {
it('updates with correct expectedVersion and bumps version', async () => {
const created = await createSkillAsOwner();
const res = await request(app)
.patch(`/api/skills/${created.body._id}`)
.patch(`/v1/chat/skills/${created.body._id}`)
.send({ expectedVersion: 1, description: 'Updated description' });
expect(res.status).toBe(200);
expect(res.body.version).toBe(2);
@@ -446,12 +446,12 @@ describe('Skill routes', () => {
it('returns 409 on stale expectedVersion', async () => {
const created = await createSkillAsOwner();
const first = await request(app)
.patch(`/api/skills/${created.body._id}`)
.patch(`/v1/chat/skills/${created.body._id}`)
.send({ expectedVersion: 1, description: 'First' });
expect(first.status).toBe(200);
const stale = await request(app)
.patch(`/api/skills/${created.body._id}`)
.patch(`/v1/chat/skills/${created.body._id}`)
.send({ expectedVersion: 1, description: 'Stale' });
expect(stale.status).toBe(409);
expect(stale.body.error).toBe('skill_version_conflict');
@@ -461,7 +461,7 @@ describe('Skill routes', () => {
it('rejects updates without expectedVersion', async () => {
const created = await createSkillAsOwner();
const res = await request(app)
.patch(`/api/skills/${created.body._id}`)
.patch(`/v1/chat/skills/${created.body._id}`)
.send({ description: 'no version' });
expect(res.status).toBe(400);
});
@@ -470,16 +470,16 @@ describe('Skill routes', () => {
const created = await createSkillAsOwner();
setTestUser(testUsers.noAccess);
const res = await request(app)
.patch(`/api/skills/${created.body._id}`)
.patch(`/v1/chat/skills/${created.body._id}`)
.send({ expectedVersion: 1, description: 'nope' });
expect(res.status).toBe(403);
});
});
describe('DELETE /api/skills/:id', () => {
describe('DELETE /v1/chat/skills/:id', () => {
it('deletes and cascades ACL entries', async () => {
const created = await createSkillAsOwner();
const res = await request(app).delete(`/api/skills/${created.body._id}`);
const res = await request(app).delete(`/v1/chat/skills/${created.body._id}`);
expect(res.status).toBe(200);
expect(res.body.deleted).toBe(true);
@@ -493,33 +493,33 @@ describe('Skill routes', () => {
it('returns 403 for a non-owner', async () => {
const created = await createSkillAsOwner();
setTestUser(testUsers.noAccess);
const res = await request(app).delete(`/api/skills/${created.body._id}`);
const res = await request(app).delete(`/v1/chat/skills/${created.body._id}`);
expect(res.status).toBe(403);
});
});
describe('GET /api/skills/:id/files', () => {
describe('GET /v1/chat/skills/:id/files', () => {
it('returns an empty list for a skill with no files', async () => {
const created = await createSkillAsOwner();
const res = await request(app).get(`/api/skills/${created.body._id}/files`);
const res = await request(app).get(`/v1/chat/skills/${created.body._id}/files`);
expect(res.status).toBe(200);
expect(res.body.files).toEqual([]);
});
});
describe('POST /api/skills/:id/files (live)', () => {
describe('POST /v1/chat/skills/:id/files (live)', () => {
it('returns 400 when no file is provided', async () => {
const created = await createSkillAsOwner();
const res = await request(app).post(`/api/skills/${created.body._id}/files`);
const res = await request(app).post(`/v1/chat/skills/${created.body._id}/files`);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no file/i);
});
});
describe('GET /api/skills/:id/files/:relativePath', () => {
describe('GET /v1/chat/skills/:id/files/:relativePath', () => {
it('returns SKILL.md content from skill body', async () => {
const created = await createSkillAsOwner();
const res = await request(app).get(`/api/skills/${created.body._id}/files/SKILL.md`);
const res = await request(app).get(`/v1/chat/skills/${created.body._id}/files/SKILL.md`);
expect(res.status).toBe(200);
expect(res.body.mimeType).toBe('text/markdown');
expect(res.body.isBinary).toBe(false);
@@ -530,13 +530,13 @@ describe('Skill routes', () => {
it('returns 404 for a nonexistent file', async () => {
const created = await createSkillAsOwner();
const res = await request(app).get(
`/api/skills/${created.body._id}/files/scripts%2Fmissing.sh`,
`/v1/chat/skills/${created.body._id}/files/scripts%2Fmissing.sh`,
);
expect(res.status).toBe(404);
});
});
describe('DELETE /api/skills/:id/files/:relativePath', () => {
describe('DELETE /v1/chat/skills/:id/files/:relativePath', () => {
const { upsertSkillFile } = require('~/models');
it('deletes an existing skill file, bumps skill version, and returns 200', async () => {
@@ -553,12 +553,12 @@ describe('Skill routes', () => {
author: testUsers.owner._id,
});
const beforeSkill = await request(app).get(`/api/skills/${created.body._id}`);
const beforeSkill = await request(app).get(`/v1/chat/skills/${created.body._id}`);
expect(beforeSkill.body.fileCount).toBe(1);
expect(beforeSkill.body.version).toBe(2);
const res = await request(app).delete(
`/api/skills/${created.body._id}/files/scripts%2Fparse.sh`,
`/v1/chat/skills/${created.body._id}/files/scripts%2Fparse.sh`,
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
@@ -567,7 +567,7 @@ describe('Skill routes', () => {
deleted: true,
});
const afterSkill = await request(app).get(`/api/skills/${created.body._id}`);
const afterSkill = await request(app).get(`/v1/chat/skills/${created.body._id}`);
expect(afterSkill.body.fileCount).toBe(0);
expect(afterSkill.body.version).toBe(3);
});
@@ -575,7 +575,7 @@ describe('Skill routes', () => {
it('returns 404 when the file does not exist', async () => {
const created = await createSkillAsOwner();
const res = await request(app).delete(
`/api/skills/${created.body._id}/files/scripts%2Fmissing.sh`,
`/v1/chat/skills/${created.body._id}/files/scripts%2Fmissing.sh`,
);
expect(res.status).toBe(404);
});
@@ -584,7 +584,7 @@ describe('Skill routes', () => {
const created = await createSkillAsOwner();
setTestUser(testUsers.noAccess);
const res = await request(app).delete(
`/api/skills/${created.body._id}/files/scripts%2Fparse.sh`,
`/v1/chat/skills/${created.body._id}/files/scripts%2Fparse.sh`,
);
expect(res.status).toBe(403);
});
@@ -604,12 +604,12 @@ describe('Skill routes', () => {
setTestUser(testUsers.editor);
const res = await request(app)
.patch(`/api/skills/${created.body._id}`)
.patch(`/v1/chat/skills/${created.body._id}`)
.send({ expectedVersion: 1, description: 'Edited by editor' });
expect(res.status).toBe(200);
// Editor should NOT be able to delete
const del = await request(app).delete(`/api/skills/${created.body._id}`);
const del = await request(app).delete(`/v1/chat/skills/${created.body._id}`);
expect(del.status).toBe(403);
});
});
+2 -2
View File
@@ -176,7 +176,7 @@ async function createActionTool({
const stateToken = jwt.sign(statePayload, JWT_SECRET, { expiresIn: '10m' });
try {
const redirectUri = `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`;
const redirectUri = `${process.env.DOMAIN_CLIENT}/v1/chat/actions/${action_id}/oauth/callback`;
const params = new URLSearchParams({
client_id: metadata.oauth_client_id,
scope: metadata.auth.scope,
@@ -222,7 +222,7 @@ async function createActionTool({
state: stateToken,
userId: userId,
client_url: metadata.auth.client_url,
redirect_uri: `${process.env.DOMAIN_SERVER}/api/actions/${action_id}/oauth/callback`,
redirect_uri: `${process.env.DOMAIN_SERVER}/v1/chat/actions/${action_id}/oauth/callback`,
token_exchange_method: metadata.auth.token_exchange_method,
/** Encrypted values */
encrypted_oauth_client_id: encrypted.oauth_client_id,
+9 -40
View File
@@ -214,7 +214,9 @@ const registerUser = async (user, additionalData = {}) => {
//determine if this is the first registered user (not counting anonymous_user)
const isFirstRegisteredUser = (await countUsers()) === 0;
const salt = bcrypt.genSaltSync(10);
// No local password credential is stored — identity is owned by Hanzo IAM.
// (This local path is gated off in prod via ALLOW_REGISTRATION=false; the
// full local-strategy teardown lands with the identity cutover.)
const newUserData = {
provider: provider ?? 'local',
email,
@@ -222,7 +224,6 @@ const registerUser = async (user, additionalData = {}) => {
name,
avatar: null,
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
password: bcrypt.hashSync(password, salt),
...additionalData,
};
@@ -327,43 +328,11 @@ const requestPasswordReset = async (req) => {
* @param {String} password
* @returns
*/
const resetPassword = async (userId, token, password) => {
let passwordResetToken = await findToken(
{
userId,
},
{ sort: { createdAt: -1 } },
);
if (!passwordResetToken) {
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, passwordResetToken.token);
if (!isValid) {
return new Error('Invalid or expired password reset token');
}
const hash = bcrypt.hashSync(password, 10);
const user = await updateUser(userId, { password: hash });
if (checkEmailConfig()) {
await sendEmail({
email: user.email,
subject: 'Password Reset Successfully',
payload: {
appName: process.env.APP_TITLE || 'Hanzo Chat',
name: user.name || user.username || user.email,
year: new Date().getFullYear(),
},
template: 'passwordReset.handlebars',
});
}
await deleteTokens({ token: passwordResetToken.token });
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' };
const resetPassword = async () => {
// Chat stores no local password credential. Password reset is owned by Hanzo
// IAM (hanzo.id); there is nothing to reset here. Prod already disables this
// route (ALLOW_EMAIL_LOGIN=false) — this is the service-level guarantee.
return new Error('Password reset is managed by Hanzo IAM (hanzo.id).');
};
/**
@@ -421,7 +390,7 @@ const setAuthTokens = async (userId, res, _session = null) => {
*
* This is DELIBERATELY decoupled from the token-refresh strategy. It runs on
* EVERY OpenID login regardless of `OPENID_REUSE_TOKENS`; that flag alone still
* governs whether `/api/auth/refresh` performs an OIDC refresh-grant. It writes
* governs whether `/v1/chat/auth/refresh` performs an OIDC refresh-grant. It writes
* only server-side session state (no `token_provider`, `refreshToken`, or other
* auth cookie), so it cannot alter the browser-facing login/refresh cookies a
* REUSE-disabled login stays byte-identical.
+13 -3
View File
@@ -50,15 +50,25 @@ const MAX_RESPONSE = 4 * 1024 * 1024;
*/
const MAX_CONCURRENT = Number(process.env.CLOUD_AGENT_MAX_CONCURRENT) || 50;
/**
* HTTP timeout (ms) for a cloud call. A run is a real chat completion a
* zen5-mini answer routinely takes ~25-30s and larger models/prompts longer so
* the old 30s default aborted mid-run, surfacing as an in-UI 502 even though the
* cloud run finished and recorded. 180s gives real runs headroom; override with
* CLOUD_AGENT_TIMEOUT. (List/get are fast; the ceiling only matters for /run.)
*/
const DEFAULT_TIMEOUT = Number(process.env.CLOUD_AGENT_TIMEOUT) || 180000;
class CloudAgentsClient {
/**
* @param {Object} opts
* @param {string} opts.endpoint - Cloud base URL (e.g. https://api.hanzo.ai)
* @param {number} [opts.timeout] - HTTP timeout in ms (default 30000; a run is
* a real chat completion so it needs more headroom than a metadata read)
* @param {number} [opts.timeout] - HTTP timeout in ms (default DEFAULT_TIMEOUT,
* 180s; a run is a real chat completion so it needs far more headroom than a
* metadata read 30s aborted long runs mid-flight)
* @param {number} [opts.maxConcurrent] - process-wide in-flight ceiling
*/
constructor({ endpoint, timeout = 30000, maxConcurrent = MAX_CONCURRENT }) {
constructor({ endpoint, timeout = DEFAULT_TIMEOUT, maxConcurrent = MAX_CONCURRENT }) {
this.endpoint = endpoint.replace(/\/+$/, '');
this.timeout = timeout;
this.maxConcurrent = maxConcurrent;
@@ -210,6 +210,36 @@ describe('CloudAgentsClient', () => {
});
});
describe('run timeout (headroom for long completions)', () => {
it('defaults to 180s so a long run is not aborted mid-flight (the 30s -> 502 bug)', () => {
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
expect(client.timeout).toBe(180000);
});
it('honors an explicit constructor timeout', () => {
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai', timeout: 5000 });
expect(client.timeout).toBe(5000);
});
it('is overridable via CLOUD_AGENT_TIMEOUT (mirrors CLOUD_AGENT_MAX_CONCURRENT)', () => {
const saved = process.env.CLOUD_AGENT_TIMEOUT;
process.env.CLOUD_AGENT_TIMEOUT = '90000';
// DEFAULT_TIMEOUT is read at module load, so re-require in isolation.
jest.resetModules();
const { CloudAgentsClient: Fresh } = require('./CloudAgentsClient');
try {
expect(new Fresh({ endpoint: 'https://api.hanzo.ai' }).timeout).toBe(90000);
} finally {
if (saved === undefined) {
delete process.env.CLOUD_AGENT_TIMEOUT;
} else {
process.env.CLOUD_AGENT_TIMEOUT = saved;
}
jest.resetModules();
}
});
});
describe('getCloudAgentsClient (env wiring)', () => {
const saved = {};
beforeEach(() => {
+8 -94
View File
@@ -1,11 +1,15 @@
const { logger } = require('@librechat/data-schemas');
/**
* CommerceClient provides a cached, fail-open interface to Hanzo Commerce
* billing APIs. Pattern follows cloud-api's filter_balance.go:
* CommerceClient is chat's READ-ONLY window into Hanzo Commerce (balance, tier,
* credit breakdown). It NEVER writes: the single debit for an AI spend is the
* cloud gateway's (api.hanzo.ai debits the forwarded per-user hk- key), and the
* only credit (the first-chat starter grant) is issued by resolveHanzoCloudKey
* (packages/api) at the request boundary. Two writers to one ledger is the
* double-debit anti-pattern so chat stays a reader. Pattern follows cloud-api's
* filter_balance.go:
* - 30s TTL balance/tier cache with async refresh
* - Fire-and-forget usage recording queue
* - All errors fail-open (local MongoDB is authoritative fallback)
* - Reads fail CLOSED (the money gate); tier/breakdown fail open
*
* @example
* const client = new CommerceClient({
@@ -33,11 +37,6 @@ class CommerceClient {
// Tier cache: userId -> { data, fetchedAt, refreshing }
this._tierCache = new Map();
// Usage recording queue
this._usageQueue = [];
this._usageFlushing = false;
this._usageFlushInterval = setInterval(() => this._flushUsageQueue(), 5000);
// Cache cleanup every 5 minutes
this._cleanupInterval = setInterval(() => this._cleanupCaches(), 300000);
}
@@ -134,64 +133,6 @@ class CommerceClient {
return { allowed, tier: tier.name, allowedModels: tier.allowedModels };
}
/**
* Enqueue usage recording (fire-and-forget). Commerce calls BurnCredits
* internally to burn trial grants first, then paid.
*
* @param {Object} usage
* @param {string} usage.userId
* @param {string} usage.model
* @param {number} usage.promptTokens
* @param {number} usage.completionTokens
* @param {number} usage.amountCents - Cost in cents
*/
recordUsage({ userId, model, promptTokens, completionTokens, amountCents }) {
this._usageQueue.push({
user: userId,
model,
promptTokens: promptTokens || 0,
completionTokens: completionTokens || 0,
amount: amountCents || 0,
currency: 'usd',
status: 'completed',
});
// Flush immediately if queue is large
if (this._usageQueue.length >= 50) {
this._flushUsageQueue().catch(() => {});
}
}
/**
* Ensure a subject has the one-time $5 starter credit, idempotently.
*
* This posts to /v1/billing/grant-starter, which creates a real Deposit
* transaction (tag "starter-credit", $5, 30-day expiry) so it nets into
* GET /v1/billing/balance, the account the gateway gate reads and debits.
* (NOT a credit-grant record: those live in a separate ledger the balance
* endpoint does not read, so they would never unblock the gate.)
*
* Idempotent + race-safe in Commerce (tag-deduped inside a transaction): safe
* to call on every first chat; duplicate/concurrent calls never double-grant.
*
* @param {string} subject - Commerce billing subject (e.g. "hanzo/alice@gmail.com")
* @returns {Promise<{granted: boolean}|null>} or null on failure
*/
async grantStarter(subject) {
try {
const resp = await this._request(
'POST',
'/v1/billing/grant-starter',
{ user: subject, trigger: 'chat_first_use' },
this._namespaceOf(subject),
);
return resp;
} catch (err) {
logger.error('[CommerceClient] Failed to ensure starter credit', err);
return null;
}
}
/**
* Get credit balance breakdown by tag (trial vs purchased).
*
@@ -268,30 +209,6 @@ class CommerceClient {
}
}
async _flushUsageQueue() {
if (this._usageFlushing || this._usageQueue.length === 0) {
return;
}
this._usageFlushing = true;
const batch = this._usageQueue.splice(0, 100);
for (const usage of batch) {
try {
await this._request('POST', '/v1/billing/usage', usage);
} catch (err) {
logger.warn('[CommerceClient] Usage recording failed', {
user: usage.user,
model: usage.model,
error: err.message,
});
// Don't retry — usage is also tracked locally in MongoDB
}
}
this._usageFlushing = false;
}
/**
* @param {string} method
* @param {string} path
@@ -351,10 +268,7 @@ class CommerceClient {
}
destroy() {
clearInterval(this._usageFlushInterval);
clearInterval(this._cleanupInterval);
// Flush remaining usage
this._flushUsageQueue().catch(() => {});
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ const createDownloadFallback = ({
const basePath = getBasePath();
return {
filename: name,
filepath: `${basePath}/api/files/code/download/${session_id}/${id}`,
filepath: `${basePath}/v1/chat/files/code/download/${session_id}/${id}`,
expiresAt,
conversationId,
toolCallId,
@@ -286,7 +286,7 @@ describe('Code Process', () => {
const result = await processCodeOutput(baseParams);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('exceeds size limit'));
expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123');
expect(result.filepath).toContain('/v1/chat/files/code/download/session-123/file-id-123');
expect(result.expiresAt).toBeDefined();
// Should not call createFile for oversized files (fallback path)
expect(createFile).not.toHaveBeenCalled();
@@ -307,7 +307,7 @@ describe('Code Process', () => {
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('saveBuffer not available'),
);
expect(result.filepath).toContain('/api/files/code/download/');
expect(result.filepath).toContain('/v1/chat/files/code/download/');
expect(result.filename).toBe('test-file.txt');
});
@@ -316,7 +316,7 @@ describe('Code Process', () => {
const result = await processCodeOutput(baseParams);
expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123');
expect(result.filepath).toContain('/v1/chat/files/code/download/session-123/file-id-123');
expect(result.conversationId).toBe('conv-123');
expect(result.messageId).toBe('msg-123');
expect(result.toolCallId).toBe('tool-call-123');
+1 -1
View File
@@ -55,7 +55,7 @@ const buildGuestPrincipal = (id) => ({
});
/**
* Builds the guest-scoped `/api/user` response: the ephemeral principal only.
* Builds the guest-scoped `/v1/chat/user` response: the ephemeral principal only.
* Mirrors the safe-field shape the client expects (no password/totp/email/db id).
*
* @param {{ id: string }} principal
+30
View File
@@ -0,0 +1,30 @@
const removePorts = require('./removePorts');
/**
* Resolves the real client IP for per-IP guest rate limiting.
*
* hanzo.chat is served behind Cloudflare the DO LB the ingress. With that
* many hops, Express `req.ip` (via `trust proxy`) is not reliably the visitor's
* address, which would let anonymous users share/reset their free-message bucket
* (or collapse everyone into one bucket). Cloudflare always sets
* `CF-Connecting-IP` to the true originating client and unlike a
* client-supplied `X-Forwarded-For` entry a browser cannot forge it through
* the CF edge. Prefer it; fall back to the trust-proxy-resolved `req.ip` when the
* request did not transit Cloudflare (e.g. in-cluster/local).
*
* The returned string is the SOLE identity the guest quota keys on, so it must be
* stable across guest tokens, cookie clears, and incognito sessions from the same
* network origin.
*
* @param {import('express').Request} req
* @returns {string}
*/
const guestClientIp = (req) => {
const cf = req.headers?.['cf-connecting-ip'];
if (typeof cf === 'string' && cf.trim()) {
return cf.trim();
}
return removePorts(req);
};
module.exports = guestClientIp;
+2
View File
@@ -1,4 +1,5 @@
const removePorts = require('./removePorts');
const guestClientIp = require('./guestClientIp');
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const queue = require('./queue');
@@ -7,6 +8,7 @@ const files = require('./files');
module.exports = {
...handleText,
removePorts,
guestClientIp,
sendEmail,
...files,
...queue,
+1 -1
View File
@@ -674,7 +674,7 @@ const setupOpenIdAdmin = (openidConfig) => {
scope: process.env.OPENID_SCOPE,
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
callbackURL: process.env.DOMAIN_SERVER + '/api/admin/oauth/openid/callback',
callbackURL: process.env.DOMAIN_SERVER + '/v1/chat/admin/oauth/openid/callback',
},
createOpenIDCallback(true),
);
+1 -1
View File
@@ -61,7 +61,7 @@ const createReq = (overrides = {}) => ({
headers: { 'user-agent': 'Mozilla/5.0' },
body: {},
baseUrl: '/api',
originalUrl: '/api/test',
originalUrl: '/v1/chat/test',
...overrides,
});
+9 -15
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<base href="/" />
<meta name="theme-color" content="#171717" />
<meta name="theme-color" content="#000000" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -24,21 +24,15 @@
}
</style>
<script>
const theme = localStorage.getItem('color-theme');
// Hard default: true-black dark, unless the user explicitly picked light or system-light.
// Applied on <html> synchronously here to avoid any light flash before React hydrates.
const stored = localStorage.getItem('color-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = stored === 'light' ? false : stored === 'system' ? prefersDark : true;
document.documentElement.classList.add(isDark ? 'dark' : 'light');
const loadingContainerStyle = document.createElement('style');
let backgroundColor;
if (theme === 'dark') {
backgroundColor = '#070b13';
} else if (theme === 'light') {
backgroundColor = '#ffffff';
} else if (theme === 'system') {
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
backgroundColor = prefersDarkScheme ? '#070b13' : '#ffffff';
} else {
backgroundColor = '#ffffff';
}
const backgroundColor = isDark ? '#000000' : '#ffffff';
loadingContainerStyle.innerHTML = `
#loading-container {
display: flex;
-763
View File
@@ -1,763 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="jest tests" tests="337" failures="0" errors="0" time="9.042">
<testsuite name="i18next translation tests" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:01" time="0.697" tests="6">
<testcase classname="i18next translation tests should return the correct translation for a valid key in English" name="i18next translation tests should return the correct translation for a valid key in English" time="0.005">
</testcase>
<testcase classname="i18next translation tests should return the correct translation for a valid key in French" name="i18next translation tests should return the correct translation for a valid key in French" time="0.001">
</testcase>
<testcase classname="i18next translation tests should return the correct translation for a valid key in Spanish" name="i18next translation tests should return the correct translation for a valid key in Spanish" time="0">
</testcase>
<testcase classname="i18next translation tests should fallback to English for an invalid language code" name="i18next translation tests should fallback to English for an invalid language code" time="0.001">
</testcase>
<testcase classname="i18next translation tests should return the key itself for an invalid key" name="i18next translation tests should return the key itself for an invalid key" time="0.001">
</testcase>
<testcase classname="i18next translation tests should correctly format placeholders in the translation" name="i18next translation tests should correctly format placeholders in the translation" time="0">
</testcase>
</testsuite>
<testsuite name="AgentFooter" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:01" time="1.005" tests="8">
<testcase classname="AgentFooter Main Functionality renders with standard components based on default state" name="AgentFooter Main Functionality renders with standard components based on default state" time="0.026">
</testcase>
<testcase classname="AgentFooter Main Functionality handles loading states for createMutation" name="AgentFooter Main Functionality handles loading states for createMutation" time="0.066">
</testcase>
<testcase classname="AgentFooter Main Functionality handles loading states for updateMutation" name="AgentFooter Main Functionality handles loading states for updateMutation" time="0.002">
</testcase>
<testcase classname="AgentFooter Conditional Rendering adjusts UI based on activePanel state" name="AgentFooter Conditional Rendering adjusts UI based on activePanel state" time="0.002">
</testcase>
<testcase classname="AgentFooter Conditional Rendering adjusts UI based on agent ID existence" name="AgentFooter Conditional Rendering adjusts UI based on agent ID existence" time="0.002">
</testcase>
<testcase classname="AgentFooter Conditional Rendering adjusts UI based on user role" name="AgentFooter Conditional Rendering adjusts UI based on user role" time="0.004">
</testcase>
<testcase classname="AgentFooter Conditional Rendering adjusts UI based on permissions" name="AgentFooter Conditional Rendering adjusts UI based on permissions" time="0.001">
</testcase>
<testcase classname="AgentFooter Edge Cases handles null agent data" name="AgentFooter Edge Cases handles null agent data" time="0.001">
</testcase>
</testsuite>
<testsuite name="Button" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:01" time="1.081" tests="2">
<testcase classname="Button renders with the correct type and children" name="Button renders with the correct type and children" time="0.023">
</testcase>
<testcase classname="Button calls onClick when clicked" name="Button calls onClick when clicked" time="0.006">
</testcase>
</testsuite>
<testsuite name="useCopyToClipboard" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:02" time="0.121" tests="15">
<testcase classname="useCopyToClipboard Basic functionality should copy plain text without citations" name="useCopyToClipboard Basic functionality should copy plain text without citations" time="0.006">
</testcase>
<testcase classname="useCopyToClipboard Basic functionality should handle content array with text types" name="useCopyToClipboard Basic functionality should handle content array with text types" time="0.002">
</testcase>
<testcase classname="useCopyToClipboard Basic functionality should reset isCopied after timeout" name="useCopyToClipboard Basic functionality should reset isCopied after timeout" time="0.001">
</testcase>
<testcase classname="useCopyToClipboard Citation formatting should format standalone search citations" name="useCopyToClipboard Citation formatting should format standalone search citations" time="0.002">
</testcase>
<testcase classname="useCopyToClipboard Citation formatting should format news citations with correct mapping" name="useCopyToClipboard Citation formatting should format news citations with correct mapping" time="0.002">
</testcase>
<testcase classname="useCopyToClipboard Citation formatting should handle highlighted text with citations" name="useCopyToClipboard Citation formatting should handle highlighted text with citations" time="0.001">
</testcase>
<testcase classname="useCopyToClipboard Citation formatting should handle composite citations" name="useCopyToClipboard Citation formatting should handle composite citations" time="0.001">
</testcase>
<testcase classname="useCopyToClipboard Citation deduplication should use same number for duplicate URLs" name="useCopyToClipboard Citation deduplication should use same number for duplicate URLs" time="0.001">
</testcase>
<testcase classname="useCopyToClipboard Citation deduplication should handle multiple citations of the same source" name="useCopyToClipboard Citation deduplication should handle multiple citations of the same source" time="0.002">
</testcase>
<testcase classname="useCopyToClipboard Edge cases should handle missing search results gracefully" name="useCopyToClipboard Edge cases should handle missing search results gracefully" time="0">
</testcase>
<testcase classname="useCopyToClipboard Edge cases should handle invalid citation indices" name="useCopyToClipboard Edge cases should handle invalid citation indices" time="0">
</testcase>
<testcase classname="useCopyToClipboard Edge cases should handle citations without links" name="useCopyToClipboard Edge cases should handle citations without links" time="0.001">
</testcase>
<testcase classname="useCopyToClipboard Edge cases should clean up orphaned citation lists at the end" name="useCopyToClipboard Edge cases should clean up orphaned citation lists at the end" time="0">
</testcase>
<testcase classname="useCopyToClipboard All citation types should handle all citation types correctly" name="useCopyToClipboard All citation types should handle all citation types correctly" time="0.001">
</testcase>
<testcase classname="useCopyToClipboard Complex scenarios should handle mixed highlighted text and composite citations" name="useCopyToClipboard Complex scenarios should handle mixed highlighted text and composite citations" time="0">
</testcase>
</testsuite>
<testsuite name="Conversation Utilities" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:02" time="0.549" tests="36">
<testcase classname="Conversation Utilities groupConversationsByDate groups conversations by date correctly" name="Conversation Utilities groupConversationsByDate groups conversations by date correctly" time="0.002">
</testcase>
<testcase classname="Conversation Utilities groupConversationsByDate skips conversations with duplicate conversationIds" name="Conversation Utilities groupConversationsByDate skips conversations with duplicate conversationIds" time="0.001">
</testcase>
<testcase classname="Conversation Utilities groupConversationsByDate sorts conversations by month correctly" name="Conversation Utilities groupConversationsByDate sorts conversations by month correctly" time="0.001">
</testcase>
<testcase classname="Conversation Utilities groupConversationsByDate handles conversations from multiple years correctly" name="Conversation Utilities groupConversationsByDate handles conversations from multiple years correctly" time="0">
</testcase>
<testcase classname="Conversation Utilities groupConversationsByDate handles conversations from the same month correctly" name="Conversation Utilities groupConversationsByDate handles conversations from the same month correctly" time="0">
</testcase>
<testcase classname="Conversation Utilities groupConversationsByDate handles conversations with null or undefined updatedAt correctly" name="Conversation Utilities groupConversationsByDate handles conversations with null or undefined updatedAt correctly" time="0">
</testcase>
<testcase classname="Conversation Utilities groupConversationsByDate correctly groups and sorts conversations for every month of the year" name="Conversation Utilities groupConversationsByDate correctly groups and sorts conversations for every month of the year" time="0.001">
</testcase>
<testcase classname="Conversation Utilities normalizeConversationData normalizes the number of items on each page after data removal" name="Conversation Utilities normalizeConversationData normalizes the number of items on each page after data removal" time="0.001">
</testcase>
<testcase classname="Conversation Utilities normalizeConversationData normalizes the number of items on each page after data addition" name="Conversation Utilities normalizeConversationData normalizes the number of items on each page after data addition" time="0.001">
</testcase>
<testcase classname="Conversation Utilities normalizeConversationData returns empty data when there is no data" name="Conversation Utilities normalizeConversationData returns empty data when there is no data" time="0">
</testcase>
<testcase classname="Conversation Utilities normalizeConversationData does not normalize data when not needed" name="Conversation Utilities normalizeConversationData does not normalize data when not needed" time="0">
</testcase>
<testcase classname="Conversation Utilities normalizeConversationData deletes pages that have no data as a result of normalization" name="Conversation Utilities normalizeConversationData deletes pages that have no data as a result of normalization" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers findConversationInInfinite finds a conversation by id in InfiniteData" name="Conversation Utilities InfiniteData helpers findConversationInInfinite finds a conversation by id in InfiniteData" time="0.001">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers findConversationInInfinite returns undefined if conversation not found" name="Conversation Utilities InfiniteData helpers findConversationInInfinite returns undefined if conversation not found" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers findConversationInInfinite returns undefined if data is undefined" name="Conversation Utilities InfiniteData helpers findConversationInInfinite returns undefined if data is undefined" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers updateInfiniteConvoPage updates a conversation in InfiniteData" name="Conversation Utilities InfiniteData helpers updateInfiniteConvoPage updates a conversation in InfiniteData" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers updateInfiniteConvoPage returns original data if conversation not found" name="Conversation Utilities InfiniteData helpers updateInfiniteConvoPage returns original data if conversation not found" time="0.001">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers updateInfiniteConvoPage returns undefined if data is undefined" name="Conversation Utilities InfiniteData helpers updateInfiniteConvoPage returns undefined if data is undefined" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers addConversationToInfinitePages adds a conversation to the first page" name="Conversation Utilities InfiniteData helpers addConversationToInfinitePages adds a conversation to the first page" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers addConversationToInfinitePages creates new InfiniteData if data is undefined" name="Conversation Utilities InfiniteData helpers addConversationToInfinitePages creates new InfiniteData if data is undefined" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers removeConvoFromInfinitePages removes a conversation by id" name="Conversation Utilities InfiniteData helpers removeConvoFromInfinitePages removes a conversation by id" time="0.008">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers removeConvoFromInfinitePages removes empty pages after deletion" name="Conversation Utilities InfiniteData helpers removeConvoFromInfinitePages removes empty pages after deletion" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers removeConvoFromInfinitePages returns original data if data is undefined" name="Conversation Utilities InfiniteData helpers removeConvoFromInfinitePages returns original data if data is undefined" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers updateConvoFieldsInfinite updates fields and bumps to front if keepPosition is false" name="Conversation Utilities InfiniteData helpers updateConvoFieldsInfinite updates fields and bumps to front if keepPosition is false" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers updateConvoFieldsInfinite updates fields and keeps position if keepPosition is true" name="Conversation Utilities InfiniteData helpers updateConvoFieldsInfinite updates fields and keeps position if keepPosition is true" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers updateConvoFieldsInfinite returns original data if conversation not found" name="Conversation Utilities InfiniteData helpers updateConvoFieldsInfinite returns original data if conversation not found" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers updateConvoFieldsInfinite returns original data if data is undefined" name="Conversation Utilities InfiniteData helpers updateConvoFieldsInfinite returns original data if data is undefined" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers storeEndpointSettings stores model for endpoint" name="Conversation Utilities InfiniteData helpers storeEndpointSettings stores model for endpoint" time="0.001">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers storeEndpointSettings stores secondaryModel for gptPlugins endpoint" name="Conversation Utilities InfiniteData helpers storeEndpointSettings stores secondaryModel for gptPlugins endpoint" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers storeEndpointSettings does nothing if conversation is null" name="Conversation Utilities InfiniteData helpers storeEndpointSettings does nothing if conversation is null" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers storeEndpointSettings does nothing if endpoint is missing" name="Conversation Utilities InfiniteData helpers storeEndpointSettings does nothing if endpoint is missing" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers QueryClient helpers addConvoToAllQueries adds new on top if not present" name="Conversation Utilities InfiniteData helpers QueryClient helpers addConvoToAllQueries adds new on top if not present" time="0.001">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers QueryClient helpers addConvoToAllQueries does not duplicate" name="Conversation Utilities InfiniteData helpers QueryClient helpers addConvoToAllQueries does not duplicate" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers QueryClient helpers updateConvoInAllQueries updates correct convo" name="Conversation Utilities InfiniteData helpers QueryClient helpers updateConvoInAllQueries updates correct convo" time="0">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers QueryClient helpers removeConvoFromAllQueries deletes conversation" name="Conversation Utilities InfiniteData helpers QueryClient helpers removeConvoFromAllQueries deletes conversation" time="0.001">
</testcase>
<testcase classname="Conversation Utilities InfiniteData helpers QueryClient helpers addConversationToAllConversationsQueries works with multiple pages" name="Conversation Utilities InfiniteData helpers QueryClient helpers addConversationToAllConversationsQueries works with multiple pages" time="0">
</testcase>
</testsuite>
<testsuite name="cleanupPreset" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.074" tests="14">
<testcase classname="cleanupPreset chatGptLabel migration should migrate chatGptLabel to modelLabel when only chatGptLabel exists" name="cleanupPreset chatGptLabel migration should migrate chatGptLabel to modelLabel when only chatGptLabel exists" time="0.001">
</testcase>
<testcase classname="cleanupPreset chatGptLabel migration should prioritize modelLabel over chatGptLabel when both exist" name="cleanupPreset chatGptLabel migration should prioritize modelLabel over chatGptLabel when both exist" time="0.006">
</testcase>
<testcase classname="cleanupPreset chatGptLabel migration should keep modelLabel when only modelLabel exists" name="cleanupPreset chatGptLabel migration should keep modelLabel when only modelLabel exists" time="0">
</testcase>
<testcase classname="cleanupPreset chatGptLabel migration should handle preset without either label" name="cleanupPreset chatGptLabel migration should handle preset without either label" time="0">
</testcase>
<testcase classname="cleanupPreset chatGptLabel migration should handle empty chatGptLabel" name="cleanupPreset chatGptLabel migration should handle empty chatGptLabel" time="0.001">
</testcase>
<testcase classname="cleanupPreset chatGptLabel migration should not migrate empty string chatGptLabel when modelLabel exists" name="cleanupPreset chatGptLabel migration should not migrate empty string chatGptLabel when modelLabel exists" time="0">
</testcase>
<testcase classname="cleanupPreset presetOverride handling should apply presetOverride and then handle label migration" name="cleanupPreset presetOverride handling should apply presetOverride and then handle label migration" time="0">
</testcase>
<testcase classname="cleanupPreset presetOverride handling should handle label migration in presetOverride" name="cleanupPreset presetOverride handling should handle label migration in presetOverride" time="0">
</testcase>
<testcase classname="cleanupPreset error handling should handle undefined preset" name="cleanupPreset error handling should handle undefined preset" time="0">
</testcase>
<testcase classname="cleanupPreset error handling should handle preset with null endpoint" name="cleanupPreset error handling should handle preset with null endpoint" time="0.001">
</testcase>
<testcase classname="cleanupPreset error handling should handle preset with empty string endpoint" name="cleanupPreset error handling should handle preset with empty string endpoint" time="0">
</testcase>
<testcase classname="cleanupPreset normal preset properties should preserve all other preset properties" name="cleanupPreset normal preset properties should preserve all other preset properties" time="0">
</testcase>
<testcase classname="cleanupPreset normal preset properties should generate default title when title is missing" name="cleanupPreset normal preset properties should generate default title when title is missing" time="0">
</testcase>
<testcase classname="cleanupPreset normal preset properties should handle null presetId" name="cleanupPreset normal preset properties should handle null presetId" time="0.001">
</testcase>
</testsuite>
<testsuite name="VersionItem" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:02" time="0.898" tests="10">
<testcase classname="VersionItem renders version number and timestamp" name="VersionItem renders version number and timestamp" time="0.05">
</testcase>
<testcase classname="VersionItem active version badge and no restore button when active" name="VersionItem active version badge and no restore button when active" time="0.05">
</testcase>
<testcase classname="VersionItem restore button and no active badge when not active" name="VersionItem restore button and no active badge when not active" time="0.002">
</testcase>
<testcase classname="VersionItem restore confirmation flow - confirmed" name="VersionItem restore confirmation flow - confirmed" time="0.032">
</testcase>
<testcase classname="VersionItem restore confirmation flow - canceled" name="VersionItem restore confirmation flow - canceled" time="0.003">
</testcase>
<testcase classname="VersionItem handles invalid timestamp" name="VersionItem handles invalid timestamp" time="0.003">
</testcase>
<testcase classname="VersionItem handles missing timestamps" name="VersionItem handles missing timestamps" time="0.009">
</testcase>
<testcase classname="VersionItem prefers updatedAt over createdAt when both exist" name="VersionItem prefers updatedAt over createdAt when both exist" time="0.002">
</testcase>
<testcase classname="VersionItem falls back to createdAt when updatedAt is missing" name="VersionItem falls back to createdAt when updatedAt is missing" time="0.002">
</testcase>
<testcase classname="VersionItem handles empty version object" name="VersionItem handles empty version object" time="0.001">
</testcase>
</testsuite>
<testsuite name="useHealthCheck" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.12" tests="14">
<testcase classname="useHealthCheck when not authenticated should not start health check" name="useHealthCheck when not authenticated should not start health check" time="0.002">
</testcase>
<testcase classname="useHealthCheck when authenticated should start health check after delay" name="useHealthCheck when authenticated should start health check after delay" time="0.002">
</testcase>
<testcase classname="useHealthCheck when authenticated should set up 10-minute interval" name="useHealthCheck when authenticated should set up 10-minute interval" time="0.003">
</testcase>
<testcase classname="useHealthCheck when authenticated should run health check continuously every 10 minutes" name="useHealthCheck when authenticated should run health check continuously every 10 minutes" time="0.002">
</testcase>
<testcase classname="useHealthCheck when authenticated should add window focus event listener" name="useHealthCheck when authenticated should add window focus event listener" time="0.001">
</testcase>
<testcase classname="useHealthCheck when authenticated should handle window focus correctly when no previous check" name="useHealthCheck when authenticated should handle window focus correctly when no previous check" time="0.001">
</testcase>
<testcase classname="useHealthCheck when authenticated should handle window focus correctly when check is recent" name="useHealthCheck when authenticated should handle window focus correctly when check is recent" time="0.001">
</testcase>
<testcase classname="useHealthCheck when authenticated should handle window focus correctly when check is old" name="useHealthCheck when authenticated should handle window focus correctly when check is old" time="0.001">
</testcase>
<testcase classname="useHealthCheck when authenticated should prevent multiple initializations" name="useHealthCheck when authenticated should prevent multiple initializations" time="0.001">
</testcase>
<testcase classname="useHealthCheck when authenticated should handle API errors gracefully" name="useHealthCheck when authenticated should handle API errors gracefully" time="0.001">
</testcase>
<testcase classname="useHealthCheck cleanup should clear intervals on unmount" name="useHealthCheck cleanup should clear intervals on unmount" time="0.001">
</testcase>
<testcase classname="useHealthCheck cleanup should remove event listeners on unmount" name="useHealthCheck cleanup should remove event listeners on unmount" time="0">
</testcase>
<testcase classname="useHealthCheck cleanup should clear timeout on unmount before initialization" name="useHealthCheck cleanup should clear timeout on unmount before initialization" time="0.001">
</testcase>
<testcase classname="useHealthCheck authentication state changes should start health check when authentication becomes true" name="useHealthCheck authentication state changes should start health check when authentication becomes true" time="0.001">
</testcase>
</testsuite>
<testsuite name="getEndpointField" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.068" tests="11">
<testcase classname="getEndpointField returns undefined if endpointsConfig is undefined" name="getEndpointField returns undefined if endpointsConfig is undefined" time="0">
</testcase>
<testcase classname="getEndpointField returns undefined if endpoint is null" name="getEndpointField returns undefined if endpoint is null" time="0">
</testcase>
<testcase classname="getEndpointField returns undefined if endpoint is undefined" name="getEndpointField returns undefined if endpoint is undefined" time="0">
</testcase>
<testcase classname="getEndpointField returns the correct value for a valid endpoint and property" name="getEndpointField returns the correct value for a valid endpoint and property" time="0.001">
</testcase>
<testcase classname="getEndpointField returns undefined for a valid endpoint but an invalid property" name="getEndpointField returns undefined for a valid endpoint but an invalid property" time="0">
</testcase>
<testcase classname="getEndpointField returns the correct value for a non-enum endpoint and valid property" name="getEndpointField returns the correct value for a non-enum endpoint and valid property" time="0">
</testcase>
<testcase classname="getEndpointField returns undefined for a non-enum endpoint with an invalid property" name="getEndpointField returns undefined for a non-enum endpoint with an invalid property" time="0">
</testcase>
<testcase classname="getEndpointsFilter returns an empty object if endpointsConfig is undefined" name="getEndpointsFilter returns an empty object if endpointsConfig is undefined" time="0">
</testcase>
<testcase classname="getEndpointsFilter returns a filter object based on endpointsConfig" name="getEndpointsFilter returns a filter object based on endpointsConfig" time="0">
</testcase>
<testcase classname="getAvailableEndpoints returns available endpoints based on filter and config" name="getAvailableEndpoints returns available endpoints based on filter and config" time="0">
</testcase>
<testcase classname="mapEndpoints returns sorted available endpoints" name="mapEndpoints returns sorted available endpoints" time="0.001">
</testcase>
</testsuite>
<testsuite name="SplitText" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.264" tests="1">
<testcase classname="SplitText renders emojis correctly" name="SplitText renders emojis correctly" time="0.04">
</testcase>
</testsuite>
<testsuite name="useQueryParams" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:02" time="1.12" tests="6">
<testcase classname="useQueryParams should process query parameters on initial render" name="useQueryParams should process query parameters on initial render" time="0.121">
</testcase>
<testcase classname="useQueryParams should auto-submit message when submit=true and no settings to apply" name="useQueryParams should auto-submit message when submit=true and no settings to apply" time="0.011">
</testcase>
<testcase classname="useQueryParams should defer submission when settings need to be applied first" name="useQueryParams should defer submission when settings need to be applied first" time="0.153">
</testcase>
<testcase classname="useQueryParams should submit after timeout if settings never get applied" name="useQueryParams should submit after timeout if settings never get applied" time="0.003">
</testcase>
<testcase classname="useQueryParams should mark as submitted when no submit parameter is present" name="useQueryParams should mark as submitted when no submit parameter is present" time="0.005">
</testcase>
<testcase classname="useQueryParams should handle empty query parameters" name="useQueryParams should handle empty query parameters" time="0.002">
</testcase>
</testsuite>
<testsuite name="VersionContent" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.081" tests="3">
<testcase classname="VersionContent renders different UI states correctly" name="VersionContent renders different UI states correctly" time="0.006">
</testcase>
<testcase classname="VersionContent restore functionality works correctly" name="VersionContent restore functionality works correctly" time="0.002">
</testcase>
<testcase classname="VersionContent handles edge cases in data" name="VersionContent handles edge cases in data" time="0.003">
</testcase>
</testsuite>
<testsuite name="presets utils" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.179" tests="27">
<testcase classname="presets utils getPresetTitle with modelLabel should use modelLabel as the label" name="presets utils getPresetTitle with modelLabel should use modelLabel as the label" time="0.001">
</testcase>
<testcase classname="presets utils getPresetTitle with modelLabel should prioritize modelLabel over deprecated chatGptLabel" name="presets utils getPresetTitle with modelLabel should prioritize modelLabel over deprecated chatGptLabel" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle with modelLabel should handle title that includes the label" name="presets utils getPresetTitle with modelLabel should handle title that includes the label" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle with modelLabel should handle case-insensitive title matching" name="presets utils getPresetTitle with modelLabel should handle case-insensitive title matching" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle with modelLabel should use label as title when label includes the title" name="presets utils getPresetTitle with modelLabel should use label as title when label includes the title" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle without modelLabel should work without modelLabel" name="presets utils getPresetTitle without modelLabel should work without modelLabel" time="0.001">
</testcase>
<testcase classname="presets utils getPresetTitle without modelLabel should handle empty modelLabel" name="presets utils getPresetTitle without modelLabel should handle empty modelLabel" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle without modelLabel should handle null modelLabel" name="presets utils getPresetTitle without modelLabel should handle null modelLabel" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle title variations should handle missing title" name="presets utils getPresetTitle title variations should handle missing title" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle title variations should handle empty title" name="presets utils getPresetTitle title variations should handle empty title" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle title variations should handle &quot;New Chat&quot; title" name="presets utils getPresetTitle title variations should handle &quot;New Chat&quot; title" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle title variations should handle title with whitespace" name="presets utils getPresetTitle title variations should handle title with whitespace" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle mention mode should return mention format with all components" name="presets utils getPresetTitle mention mode should return mention format with all components" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle mention mode should handle mention format with object tools" name="presets utils getPresetTitle mention mode should handle mention format with object tools" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle mention mode should handle mention format with minimal data" name="presets utils getPresetTitle mention mode should handle mention format with minimal data" time="0.001">
</testcase>
<testcase classname="presets utils getPresetTitle mention mode should handle mention format with only modelLabel" name="presets utils getPresetTitle mention mode should handle mention format with only modelLabel" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle mention mode should handle mention format with only promptPrefix" name="presets utils getPresetTitle mention mode should handle mention format with only promptPrefix" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle edge cases should handle missing model" name="presets utils getPresetTitle edge cases should handle missing model" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle edge cases should handle undefined model" name="presets utils getPresetTitle edge cases should handle undefined model" time="0">
</testcase>
<testcase classname="presets utils getPresetTitle edge cases should trim the final result" name="presets utils getPresetTitle edge cases should trim the final result" time="0">
</testcase>
<testcase classname="presets utils removeUnavailableTools should remove unavailable tools from string array" name="presets utils removeUnavailableTools should remove unavailable tools from string array" time="0">
</testcase>
<testcase classname="presets utils removeUnavailableTools should remove unavailable tools from object array" name="presets utils removeUnavailableTools should remove unavailable tools from object array" time="0">
</testcase>
<testcase classname="presets utils removeUnavailableTools should handle preset without tools" name="presets utils removeUnavailableTools should handle preset without tools" time="0.001">
</testcase>
<testcase classname="presets utils removeUnavailableTools should handle preset with empty tools array" name="presets utils removeUnavailableTools should handle preset with empty tools array" time="0">
</testcase>
<testcase classname="presets utils removeUnavailableTools should remove all tools when none are available" name="presets utils removeUnavailableTools should remove all tools when none are available" time="0">
</testcase>
<testcase classname="presets utils removeUnavailableTools should preserve all other preset properties" name="presets utils removeUnavailableTools should preserve all other preset properties" time="0">
</testcase>
<testcase classname="presets utils removeUnavailableTools should not mutate the original preset" name="presets utils removeUnavailableTools should not mutate the original preset" time="0">
</testcase>
</testsuite>
<testsuite name="Store Atoms Validation" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:02" time="0.56" tests="4">
<testcase classname="Store Atoms Validation should have all required atoms defined" name="Store Atoms Validation should have all required atoms defined" time="0.005">
</testcase>
<testcase classname="Store Atoms Validation should not have any undefined atoms when destructuring" name="Store Atoms Validation should not have any undefined atoms when destructuring" time="0.001">
</testcase>
<testcase classname="Store Atoms Validation should have all user atoms defined" name="Store Atoms Validation should have all user atoms defined" time="0">
</testcase>
<testcase classname="Store Atoms Validation should verify all atoms are properly typed" name="Store Atoms Validation should verify all atoms are properly typed" time="0">
</testcase>
</testsuite>
<testsuite name="useFocusChatEffect" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:02" time="1.226" tests="17">
<testcase classname="useFocusChatEffect Basic functionality should focus textarea when location.state.focusChat is true" name="useFocusChatEffect Basic functionality should focus textarea when location.state.focusChat is true" time="0.014">
</testcase>
<testcase classname="useFocusChatEffect Basic functionality should not focus textarea when location.state.focusChat is false" name="useFocusChatEffect Basic functionality should not focus textarea when location.state.focusChat is false" time="0.002">
</testcase>
<testcase classname="useFocusChatEffect Basic functionality should not focus textarea when textAreaRef.current is null" name="useFocusChatEffect Basic functionality should not focus textarea when textAreaRef.current is null" time="0.001">
</testcase>
<testcase classname="useFocusChatEffect Basic functionality should not focus textarea on touchscreen devices" name="useFocusChatEffect Basic functionality should not focus textarea on touchscreen devices" time="0.001">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should use window.location.search instead of location.search" name="useFocusChatEffect URL parameter handling should use window.location.search instead of location.search" time="0.007">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should prioritize window.location.search with agent_id parameter" name="useFocusChatEffect URL parameter handling should prioritize window.location.search with agent_id parameter" time="0">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should use empty path when window.location.search is empty" name="useFocusChatEffect URL parameter handling should use empty path when window.location.search is empty" time="0.001">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should use window.location.search when React Router search is empty" name="useFocusChatEffect URL parameter handling should use window.location.search when React Router search is empty" time="0.001">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should use window.location.search even when both have agent_id but with different values" name="useFocusChatEffect URL parameter handling should use window.location.search even when both have agent_id but with different values" time="0.001">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should handle URL parameters with special characters correctly" name="useFocusChatEffect URL parameter handling should handle URL parameters with special characters correctly" time="0.001">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should handle multiple URL parameters correctly" name="useFocusChatEffect URL parameter handling should handle multiple URL parameters correctly" time="0">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should pass through malformed URL parameters unchanged" name="useFocusChatEffect URL parameter handling should pass through malformed URL parameters unchanged" time="0.001">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should handle navigation immediately after URL parameter changes" name="useFocusChatEffect URL parameter handling should handle navigation immediately after URL parameter changes" time="0.001">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should handle undefined or null search params gracefully" name="useFocusChatEffect URL parameter handling should handle undefined or null search params gracefully" time="0">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should handle navigation when location.state is null" name="useFocusChatEffect URL parameter handling should handle navigation when location.state is null" time="0.001">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should handle navigation when location.state.focusChat is undefined" name="useFocusChatEffect URL parameter handling should handle navigation when location.state.focusChat is undefined" time="0">
</testcase>
<testcase classname="useFocusChatEffect URL parameter handling should handle navigation when both search params are empty" name="useFocusChatEffect URL parameter handling should handle navigation when both search params are empty" time="0.001">
</testcase>
</testsuite>
<testsuite name="getMemories" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.094" tests="1">
<testcase classname="getMemories should fetch memories from /api/memories" name="getMemories should fetch memories from /api/memories" time="0.004">
</testcase>
</testsuite>
<testsuite name="preprocessLaTeX" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.076" tests="33">
<testcase classname="preprocessLaTeX returns the same string if no LaTeX patterns are found" name="preprocessLaTeX returns the same string if no LaTeX patterns are found" time="0.001">
</testcase>
<testcase classname="preprocessLaTeX returns the same string if no dollar signs are present" name="preprocessLaTeX returns the same string if no dollar signs are present" time="0">
</testcase>
<testcase classname="preprocessLaTeX preserves valid inline LaTeX delimiters \(...\)" name="preprocessLaTeX preserves valid inline LaTeX delimiters \(...\)" time="0">
</testcase>
<testcase classname="preprocessLaTeX preserves valid block LaTeX delimiters \[...\]" name="preprocessLaTeX preserves valid block LaTeX delimiters \[...\]" time="0">
</testcase>
<testcase classname="preprocessLaTeX preserves valid double dollar delimiters" name="preprocessLaTeX preserves valid double dollar delimiters" time="0">
</testcase>
<testcase classname="preprocessLaTeX converts single dollar delimiters to double dollars" name="preprocessLaTeX converts single dollar delimiters to double dollars" time="0.001">
</testcase>
<testcase classname="preprocessLaTeX converts multiple single dollar expressions" name="preprocessLaTeX converts multiple single dollar expressions" time="0">
</testcase>
<testcase classname="preprocessLaTeX escapes currency dollar signs" name="preprocessLaTeX escapes currency dollar signs" time="0">
</testcase>
<testcase classname="preprocessLaTeX escapes currency with spaces" name="preprocessLaTeX escapes currency with spaces" time="0">
</testcase>
<testcase classname="preprocessLaTeX escapes currency with commas" name="preprocessLaTeX escapes currency with commas" time="0">
</testcase>
<testcase classname="preprocessLaTeX escapes currency with decimals" name="preprocessLaTeX escapes currency with decimals" time="0.001">
</testcase>
<testcase classname="preprocessLaTeX converts LaTeX expressions while escaping currency" name="preprocessLaTeX converts LaTeX expressions while escaping currency" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles Goldbach Conjecture example" name="preprocessLaTeX handles Goldbach Conjecture example" time="0">
</testcase>
<testcase classname="preprocessLaTeX does not escape already escaped dollar signs" name="preprocessLaTeX does not escape already escaped dollar signs" time="0">
</testcase>
<testcase classname="preprocessLaTeX does not convert already escaped single dollars" name="preprocessLaTeX does not convert already escaped single dollars" time="0">
</testcase>
<testcase classname="preprocessLaTeX escapes mhchem commands" name="preprocessLaTeX escapes mhchem commands" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles empty string" name="preprocessLaTeX handles empty string" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles complex mixed content" name="preprocessLaTeX handles complex mixed content" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles multiple equations with currency" name="preprocessLaTeX handles multiple equations with currency" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles inline code blocks" name="preprocessLaTeX handles inline code blocks" time="0.001">
</testcase>
<testcase classname="preprocessLaTeX handles multiline code blocks" name="preprocessLaTeX handles multiline code blocks" time="0">
</testcase>
<testcase classname="preprocessLaTeX preserves LaTeX expressions with special characters" name="preprocessLaTeX preserves LaTeX expressions with special characters" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles complex physics equations" name="preprocessLaTeX handles complex physics equations" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles financial calculations with currency" name="preprocessLaTeX handles financial calculations with currency" time="0">
</testcase>
<testcase classname="preprocessLaTeX does not convert partial or malformed expressions" name="preprocessLaTeX does not convert partial or malformed expressions" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles nested parentheses in LaTeX" name="preprocessLaTeX handles nested parentheses in LaTeX" time="0">
</testcase>
<testcase classname="preprocessLaTeX preserves spacing in equations" name="preprocessLaTeX preserves spacing in equations" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles LaTeX with newlines inside should not be converted" name="preprocessLaTeX handles LaTeX with newlines inside should not be converted" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles multiple dollar signs in text" name="preprocessLaTeX handles multiple dollar signs in text" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles complex LaTeX with currency in same expression" name="preprocessLaTeX handles complex LaTeX with currency in same expression" time="0">
</testcase>
<testcase classname="preprocessLaTeX preserves already escaped dollars in LaTeX" name="preprocessLaTeX preserves already escaped dollars in LaTeX" time="0.001">
</testcase>
<testcase classname="preprocessLaTeX handles adjacent LaTeX and currency" name="preprocessLaTeX handles adjacent LaTeX and currency" time="0">
</testcase>
<testcase classname="preprocessLaTeX handles LaTeX with special characters and currency" name="preprocessLaTeX handles LaTeX with special characters and currency" time="0">
</testcase>
</testsuite>
<testsuite name="isActiveVersion" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.074" tests="26">
<testcase classname="isActiveVersion returns true for the first version in versions array when currentAgent is null" name="isActiveVersion returns true for the first version in versions array when currentAgent is null" time="0.001">
</testcase>
<testcase classname="isActiveVersion returns true when all fields match exactly" name="isActiveVersion returns true when all fields match exactly" time="0">
</testcase>
<testcase classname="isActiveVersion returns false when names do not match" name="isActiveVersion returns false when names do not match" time="0.001">
</testcase>
<testcase classname="isActiveVersion returns false when descriptions do not match" name="isActiveVersion returns false when descriptions do not match" time="0">
</testcase>
<testcase classname="isActiveVersion returns false when instructions do not match" name="isActiveVersion returns false when instructions do not match" time="0">
</testcase>
<testcase classname="isActiveVersion returns false when artifacts do not match" name="isActiveVersion returns false when artifacts do not match" time="0">
</testcase>
<testcase classname="isActiveVersion matches tools regardless of order" name="isActiveVersion matches tools regardless of order" time="0.001">
</testcase>
<testcase classname="isActiveVersion returns false when tools arrays have different lengths" name="isActiveVersion returns false when tools arrays have different lengths" time="0">
</testcase>
<testcase classname="isActiveVersion returns false when tools do not match" name="isActiveVersion returns false when tools do not match" time="0">
</testcase>
<testcase classname="isActiveVersion matches capabilities regardless of order" name="isActiveVersion matches capabilities regardless of order" time="0">
</testcase>
<testcase classname="isActiveVersion returns false when capabilities arrays have different lengths" name="isActiveVersion returns false when capabilities arrays have different lengths" time="0">
</testcase>
<testcase classname="isActiveVersion returns false when capabilities do not match" name="isActiveVersion returns false when capabilities do not match" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles missing tools arrays" name="isActiveVersion edge cases handles missing tools arrays" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles when version has tools but agent does not" name="isActiveVersion edge cases handles when version has tools but agent does not" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles when agent has tools but version does not" name="isActiveVersion edge cases handles when agent has tools but version does not" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles missing capabilities arrays" name="isActiveVersion edge cases handles missing capabilities arrays" time="0.001">
</testcase>
<testcase classname="isActiveVersion edge cases handles when version has capabilities but agent does not" name="isActiveVersion edge cases handles when version has capabilities but agent does not" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles when agent has capabilities but version does not" name="isActiveVersion edge cases handles when agent has capabilities but version does not" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles null values in fields" name="isActiveVersion edge cases handles null values in fields" time="0.001">
</testcase>
<testcase classname="isActiveVersion edge cases handles empty versions array" name="isActiveVersion edge cases handles empty versions array" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles empty arrays for tools" name="isActiveVersion edge cases handles empty arrays for tools" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles empty arrays for capabilities" name="isActiveVersion edge cases handles empty arrays for capabilities" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles missing artifacts field" name="isActiveVersion edge cases handles missing artifacts field" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles when version has artifacts but agent does not" name="isActiveVersion edge cases handles when version has artifacts but agent does not" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles when agent has artifacts but version does not" name="isActiveVersion edge cases handles when agent has artifacts but version does not" time="0">
</testcase>
<testcase classname="isActiveVersion edge cases handles empty string for artifacts" name="isActiveVersion edge cases handles empty string for artifacts" time="0">
</testcase>
</testsuite>
<testsuite name="ConversationTag Utilities" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.077" tests="6">
<testcase classname="ConversationTag Utilities updateConversationTag updates the first tag correctly" name="ConversationTag Utilities updateConversationTag updates the first tag correctly" time="0.001">
</testcase>
<testcase classname="ConversationTag Utilities updates the third tag correctly" name="ConversationTag Utilities updates the third tag correctly" time="0.001">
</testcase>
<testcase classname="ConversationTag Utilities updates the order of other tags if the order of the tags is moving up" name="ConversationTag Utilities updates the order of other tags if the order of the tags is moving up" time="0">
</testcase>
<testcase classname="ConversationTag Utilities updates the order of other tags if the order of the tags is moving down" name="ConversationTag Utilities updates the order of other tags if the order of the tags is moving down" time="0.001">
</testcase>
<testcase classname="ConversationTag Utilities updates the order of other tags if new tag is added" name="ConversationTag Utilities updates the order of other tags if new tag is added" time="0.003">
</testcase>
<testcase classname="ConversationTag Utilities returns a new array for new tag if no tags exist" name="ConversationTag Utilities returns a new array for new tag if no tags exist" time="0">
</testcase>
</testsuite>
<testsuite name="createChatSearchParams" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.097" tests="27">
<testcase classname="createChatSearchParams conversation inputs handles basic conversation properties" name="createChatSearchParams conversation inputs handles basic conversation properties" time="0.001">
</testcase>
<testcase classname="createChatSearchParams conversation inputs applies only the endpoint property when other conversation fields are absent" name="createChatSearchParams conversation inputs applies only the endpoint property when other conversation fields are absent" time="0.001">
</testcase>
<testcase classname="createChatSearchParams conversation inputs applies only the model property when other conversation fields are absent" name="createChatSearchParams conversation inputs applies only the model property when other conversation fields are absent" time="0">
</testcase>
<testcase classname="createChatSearchParams conversation inputs includes assistant_id when endpoint is assistants" name="createChatSearchParams conversation inputs includes assistant_id when endpoint is assistants" time="0.001">
</testcase>
<testcase classname="createChatSearchParams conversation inputs includes agent_id when endpoint is agents" name="createChatSearchParams conversation inputs includes agent_id when endpoint is agents" time="0.002">
</testcase>
<testcase classname="createChatSearchParams conversation inputs excludes all parameters except assistant_id when endpoint is assistants" name="createChatSearchParams conversation inputs excludes all parameters except assistant_id when endpoint is assistants" time="0.001">
</testcase>
<testcase classname="createChatSearchParams conversation inputs excludes all parameters except agent_id when endpoint is agents" name="createChatSearchParams conversation inputs excludes all parameters except agent_id when endpoint is agents" time="0">
</testcase>
<testcase classname="createChatSearchParams conversation inputs returns empty params when agent endpoint has no agent_id" name="createChatSearchParams conversation inputs returns empty params when agent endpoint has no agent_id" time="0">
</testcase>
<testcase classname="createChatSearchParams conversation inputs returns empty params when assistants endpoint has no assistant_id" name="createChatSearchParams conversation inputs returns empty params when assistants endpoint has no assistant_id" time="0.001">
</testcase>
<testcase classname="createChatSearchParams conversation inputs ignores agent_id when it matches EPHEMERAL_AGENT_ID" name="createChatSearchParams conversation inputs ignores agent_id when it matches EPHEMERAL_AGENT_ID" time="0">
</testcase>
<testcase classname="createChatSearchParams conversation inputs handles stop arrays correctly by joining with commas" name="createChatSearchParams conversation inputs handles stop arrays correctly by joining with commas" time="0.005">
</testcase>
<testcase classname="createChatSearchParams conversation inputs filters out non-supported array properties" name="createChatSearchParams conversation inputs filters out non-supported array properties" time="0.001">
</testcase>
<testcase classname="createChatSearchParams conversation inputs includes empty arrays in output params" name="createChatSearchParams conversation inputs includes empty arrays in output params" time="0">
</testcase>
<testcase classname="createChatSearchParams conversation inputs handles non-stop arrays correctly in paramMap" name="createChatSearchParams conversation inputs handles non-stop arrays correctly in paramMap" time="0">
</testcase>
<testcase classname="createChatSearchParams conversation inputs includes empty non-stop arrays as serialized empty arrays" name="createChatSearchParams conversation inputs includes empty non-stop arrays as serialized empty arrays" time="0">
</testcase>
<testcase classname="createChatSearchParams conversation inputs excludes parameters with null or undefined values from the output" name="createChatSearchParams conversation inputs excludes parameters with null or undefined values from the output" time="0.001">
</testcase>
<testcase classname="createChatSearchParams conversation inputs handles float parameter values correctly" name="createChatSearchParams conversation inputs handles float parameter values correctly" time="0">
</testcase>
<testcase classname="createChatSearchParams conversation inputs handles integer parameter values correctly" name="createChatSearchParams conversation inputs handles integer parameter values correctly" time="0">
</testcase>
<testcase classname="createChatSearchParams preset inputs handles preset objects correctly" name="createChatSearchParams preset inputs handles preset objects correctly" time="0">
</testcase>
<testcase classname="createChatSearchParams preset inputs returns only spec param when spec property is present" name="createChatSearchParams preset inputs returns only spec param when spec property is present" time="0.001">
</testcase>
<testcase classname="createChatSearchParams record inputs includes allowed parameters from Record inputs" name="createChatSearchParams record inputs includes allowed parameters from Record inputs" time="0">
</testcase>
<testcase classname="createChatSearchParams record inputs excludes disallowed parameters from Record inputs" name="createChatSearchParams record inputs excludes disallowed parameters from Record inputs" time="0">
</testcase>
<testcase classname="createChatSearchParams record inputs includes valid values from Record inputs" name="createChatSearchParams record inputs includes valid values from Record inputs" time="0">
</testcase>
<testcase classname="createChatSearchParams record inputs excludes null or undefined values from Record inputs" name="createChatSearchParams record inputs excludes null or undefined values from Record inputs" time="0">
</testcase>
<testcase classname="createChatSearchParams record inputs handles generic object without endpoint or model properties" name="createChatSearchParams record inputs handles generic object without endpoint or model properties" time="0">
</testcase>
<testcase classname="createChatSearchParams edge cases returns an empty URLSearchParams instance when input is null" name="createChatSearchParams edge cases returns an empty URLSearchParams instance when input is null" time="0">
</testcase>
<testcase classname="createChatSearchParams edge cases returns an empty URLSearchParams instance for an empty object input" name="createChatSearchParams edge cases returns an empty URLSearchParams instance for an empty object input" time="0">
</testcase>
</testsuite>
<testsuite name="imageResize utility" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="0.074" tests="6">
<testcase classname="imageResize utility supportsClientResize should return true when all required APIs are available" name="imageResize utility supportsClientResize should return true when all required APIs are available" time="0.001">
</testcase>
<testcase classname="imageResize utility supportsClientResize should return false when HTMLCanvasElement is not available" name="imageResize utility supportsClientResize should return false when HTMLCanvasElement is not available" time="0">
</testcase>
<testcase classname="imageResize utility shouldResizeImage should return true for large image files" name="imageResize utility shouldResizeImage should return true for large image files" time="0">
</testcase>
<testcase classname="imageResize utility shouldResizeImage should return false for small image files" name="imageResize utility shouldResizeImage should return false for small image files" time="0.001">
</testcase>
<testcase classname="imageResize utility shouldResizeImage should return false for non-image files" name="imageResize utility shouldResizeImage should return false for non-image files" time="0">
</testcase>
<testcase classname="imageResize utility shouldResizeImage should return false for GIF files" name="imageResize utility shouldResizeImage should return false for GIF files" time="0">
</testcase>
</testsuite>
<testsuite name="Prompts Atoms Runtime Safety" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:02" time="1.384" tests="4">
<testcase classname="Prompts Atoms Runtime Safety should handle undefined atoms with fallback pattern" name="Prompts Atoms Runtime Safety should handle undefined atoms with fallback pattern" time="0.01">
</testcase>
<testcase classname="Prompts Atoms Runtime Safety should use alwaysMakeProd atom with fallback" name="Prompts Atoms Runtime Safety should use alwaysMakeProd atom with fallback" time="0.009">
</testcase>
<testcase classname="Prompts Atoms Runtime Safety should use promptsEditorMode atom with fallback" name="Prompts Atoms Runtime Safety should use promptsEditorMode atom with fallback" time="0.002">
</testcase>
<testcase classname="Prompts Atoms Runtime Safety should not throw error when using fallback pattern" name="Prompts Atoms Runtime Safety should not throw error when using fallback pattern" time="0.008">
</testcase>
</testsuite>
<testsuite name="VersionPanel" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:02" time="1.477" tests="4">
<testcase classname="VersionPanel renders panel UI and handles navigation" name="VersionPanel renders panel UI and handles navigation" time="0.058">
</testcase>
<testcase classname="VersionPanel VersionContent receives correct props" name="VersionPanel VersionContent receives correct props" time="0.016">
</testcase>
<testcase classname="VersionPanel handles data state variations" name="VersionPanel handles data state variations" time="0.019">
</testcase>
<testcase classname="VersionPanel memoizes agent data correctly" name="VersionPanel memoizes agent data correctly" time="0.002">
</testcase>
</testsuite>
<testsuite name="ApiKeyDialog" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:01" time="2.041" tests="8">
<testcase classname="ApiKeyDialog shows all dropdowns and both reranker fields when no config is set" name="ApiKeyDialog shows all dropdowns and both reranker fields when no config is set" time="0.221">
</testcase>
<testcase classname="ApiKeyDialog shows static text for provider and only provider input if provider is set" name="ApiKeyDialog shows static text for provider and only provider input if provider is set" time="0.051">
</testcase>
<testcase classname="ApiKeyDialog shows only Jina reranker field if rerankerType is set to jina" name="ApiKeyDialog shows only Jina reranker field if rerankerType is set to jina" time="0.036">
</testcase>
<testcase classname="ApiKeyDialog shows only Cohere reranker field if rerankerType is set to cohere" name="ApiKeyDialog shows only Cohere reranker field if rerankerType is set to cohere" time="0.033">
</testcase>
<testcase classname="ApiKeyDialog shows documentation link for the visible reranker" name="ApiKeyDialog shows documentation link for the visible reranker" time="0.057">
</testcase>
<testcase classname="ApiKeyDialog does not render provider section if SYSTEM_DEFINED" name="ApiKeyDialog does not render provider section if SYSTEM_DEFINED" time="0.039">
</testcase>
<testcase classname="ApiKeyDialog does not render scraper section if SYSTEM_DEFINED" name="ApiKeyDialog does not render scraper section if SYSTEM_DEFINED" time="0.039">
</testcase>
<testcase classname="ApiKeyDialog does not render reranker section if SYSTEM_DEFINED" name="ApiKeyDialog does not render reranker section if SYSTEM_DEFINED" time="0.04">
</testcase>
</testsuite>
<testsuite name="PluginStoreItem" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="1.839" tests="3">
<testcase classname="PluginStoreItem renders the plugin name and description" name="PluginStoreItem renders the plugin name and description" time="0.212">
</testcase>
<testcase classname="PluginStoreItem calls onInstall when the install button is clicked" name="PluginStoreItem calls onInstall when the install button is clicked" time="0.057">
</testcase>
<testcase classname="PluginStoreItem calls onUninstall when the uninstall button is clicked" name="PluginStoreItem calls onUninstall when the uninstall button is clicked" time="0.019">
</testcase>
</testsuite>
<testsuite name="Regenerate" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="1.67" tests="2">
<testcase classname="Regenerate should render the Regenerate button" name="Regenerate should render the Regenerate button" time="0.244">
</testcase>
<testcase classname="Regenerate should call onClick when the button is clicked" name="Regenerate should call onClick when the button is clicked" time="0.01">
</testcase>
</testsuite>
<testsuite name="Stop" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:04" time="1.394" tests="2">
<testcase classname="Stop should render the Stop button" name="Stop should render the Stop button" time="0.19">
</testcase>
<testcase classname="Stop should call onClick when the button is clicked" name="Stop should call onClick when the button is clicked" time="0.011">
</testcase>
</testsuite>
<testsuite name="PluginPagination" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="1.854" tests="5">
<testcase classname="PluginPagination should render the previous button as enabled when not on the first page" name="PluginPagination should render the previous button as enabled when not on the first page" time="0.249">
</testcase>
<testcase classname="PluginPagination should call onChangePage with the previous page number when the previous button is clicked" name="PluginPagination should call onChangePage with the previous page number when the previous button is clicked" time="0.051">
</testcase>
<testcase classname="PluginPagination should call onChangePage with the next page number when the next button is clicked" name="PluginPagination should call onChangePage with the next page number when the next button is clicked" time="0.033">
</testcase>
<testcase classname="PluginPagination should render the page numbers" name="PluginPagination should render the page numbers" time="0.02">
</testcase>
<testcase classname="PluginPagination should call onChangePage with the correct page number when a page number button is clicked" name="PluginPagination should call onChangePage with the correct page number when a page number button is clicked" time="0.024">
</testcase>
</testsuite>
<testsuite name="DialogTemplate" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="2.251" tests="3">
<testcase classname="DialogTemplate renders correctly with all props" name="DialogTemplate renders correctly with all props" time="0.103">
</testcase>
<testcase classname="DialogTemplate renders correctly without optional props" name="DialogTemplate renders correctly without optional props" time="0.001">
</testcase>
<testcase classname="DialogTemplate calls selectHandler when the select button is clicked" name="DialogTemplate calls selectHandler when the select button is clicked" time="0.037">
</testcase>
</testsuite>
<testsuite name="ConversationModeSwitch" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="2.48" tests="2">
<testcase classname="ConversationModeSwitch renders correctly" name="ConversationModeSwitch renders correctly" time="0.104">
</testcase>
<testcase classname="ConversationModeSwitch calls onCheckedChange when the switch is toggled" name="ConversationModeSwitch calls onCheckedChange when the switch is toggled" time="0.014">
</testcase>
</testsuite>
<testsuite name="ThemeSelector" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="2.778" tests="2">
<testcase classname="ThemeSelector renders correctly" name="ThemeSelector renders correctly" time="0.383">
</testcase>
<testcase classname="ThemeSelector calls onChange when the select value changes" name="ThemeSelector calls onChange when the select value changes" time="0.14">
</testcase>
</testsuite>
<testsuite name="CloudBrowserVoicesSwitch" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:05" time="0.615" tests="2">
<testcase classname="CloudBrowserVoicesSwitch renders correctly" name="CloudBrowserVoicesSwitch renders correctly" time="0.128">
</testcase>
<testcase classname="CloudBrowserVoicesSwitch calls onCheckedChange when the switch is toggled" name="CloudBrowserVoicesSwitch calls onCheckedChange when the switch is toggled" time="0.016">
</testcase>
</testsuite>
<testsuite name="LangSelector" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="2.891" tests="2">
<testcase classname="LangSelector renders correctly" name="LangSelector renders correctly" time="0.423">
</testcase>
<testcase classname="LangSelector calls onChange when the select value changes" name="LangSelector calls onChange when the select value changes" time="0.267">
</testcase>
</testsuite>
<testsuite name="PluginAuthForm" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:03" time="3.112" tests="2">
<testcase classname="PluginAuthForm renders the form with the correct fields" name="PluginAuthForm renders the form with the correct fields" time="0.325">
</testcase>
<testcase classname="PluginAuthForm calls the onSubmit function with the form data when submitted" name="PluginAuthForm calls the onSubmit function with the form data when submitted" time="0.266">
</testcase>
</testsuite>
<testsuite name="SpeechToTextSwitch" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:05" time="0.8" tests="2">
<testcase classname="SpeechToTextSwitch renders correctly" name="SpeechToTextSwitch renders correctly" time="0.34">
</testcase>
<testcase classname="SpeechToTextSwitch calls onCheckedChange when the switch is toggled" name="SpeechToTextSwitch calls onCheckedChange when the switch is toggled" time="0.014">
</testcase>
</testsuite>
<testsuite name="TextToSpeechSwitch" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:05" time="1.191" tests="2">
<testcase classname="TextToSpeechSwitch renders correctly" name="TextToSpeechSwitch renders correctly" time="0.133">
</testcase>
<testcase classname="TextToSpeechSwitch calls onCheckedChange when the switch is toggled" name="TextToSpeechSwitch calls onCheckedChange when the switch is toggled" time="0.015">
</testcase>
</testsuite>
<testsuite name="AutoTranscribeAudioSwitch" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:06" time="0.542" tests="2">
<testcase classname="AutoTranscribeAudioSwitch renders correctly" name="AutoTranscribeAudioSwitch renders correctly" time="0.077">
</testcase>
<testcase classname="AutoTranscribeAudioSwitch calls onCheckedChange when the switch is toggled" name="AutoTranscribeAudioSwitch calls onCheckedChange when the switch is toggled" time="0.006">
</testcase>
</testsuite>
<testsuite name="CacheTTSSwitch" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:05" time="1.15" tests="2">
<testcase classname="CacheTTSSwitch renders correctly" name="CacheTTSSwitch renders correctly" time="0.063">
</testcase>
<testcase classname="CacheTTSSwitch calls onCheckedChange when the switch is toggled" name="CacheTTSSwitch calls onCheckedChange when the switch is toggled" time="0.026">
</testcase>
</testsuite>
<testsuite name="AutomaticPlaybackSwitch" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:05" time="1.129" tests="2">
<testcase classname="AutomaticPlaybackSwitch renders correctly" name="AutomaticPlaybackSwitch renders correctly" time="0.061">
</testcase>
<testcase classname="AutomaticPlaybackSwitch calls onCheckedChange when the switch is toggled" name="AutomaticPlaybackSwitch calls onCheckedChange when the switch is toggled" time="0.015">
</testcase>
</testsuite>
<testsuite name="undefined" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:06" time="0.792" tests="3">
<testcase classname=" renders login form" name=" renders login form" time="0.054">
</testcase>
<testcase classname=" submits login form" name=" submits login form" time="0.093">
</testcase>
<testcase classname=" displays validation error messages" name=" displays validation error messages" time="0.05">
</testcase>
</testsuite>
<testsuite name="undefined" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:06" time="0.845" tests="3">
<testcase classname=" renders login form" name=" renders login form" time="0.119">
</testcase>
<testcase classname=" calls loginUser.mutate on login" name=" calls loginUser.mutate on login" time="0.095">
</testcase>
<testcase classname=" Navigates to / on successful login" name=" Navigates to / on successful login" time="0.078">
</testcase>
</testsuite>
<testsuite name="undefined" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:06" time="0.86" tests="4">
<testcase classname=" renders plugin store dialog with plugins from the available plugins query and shows install/uninstall buttons based on user plugins" name=" renders plugin store dialog with plugins from the available plugins query and shows install/uninstall buttons based on user plugins" time="0.146">
</testcase>
<testcase classname=" Displays the plugin auth form when installing a plugin with auth" name=" Displays the plugin auth form when installing a plugin with auth" time="0.109">
</testcase>
<testcase classname=" allows the user to navigate between pages" name=" allows the user to navigate between pages" time="0.091">
</testcase>
<testcase classname=" allows the user to search for plugins" name=" allows the user to search for plugins" time="0.024">
</testcase>
</testsuite>
<testsuite name="undefined" errors="0" failures="0" skipped="0" timestamp="2025-07-04T03:30:06" time="0.846" tests="3">
<testcase classname=" renders registration form" name=" renders registration form" time="0.137">
</testcase>
<testcase classname=" shows validation error messages" name=" shows validation error messages" time="0.143">
</testcase>
<testcase classname=" shows error message when registration fails" name=" shows error message when registration fails" time="0.188">
</testcase>
</testsuite>
</testsuites>
+1 -1
View File
@@ -23,7 +23,7 @@ server {
# The default limits for image uploads as of 11/22/23 is 20MB/file, and 25MB/request
client_max_body_size 25M;
location /api/ {
location /v1/chat/ {
proxy_pass http://api:3080$request_uri;
}
+1 -1
View File
@@ -1,3 +1,3 @@
User-agent: *
Disallow: /api/
Disallow: /v1/chat/
Allow: /
+29 -20
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect } from 'react';
import { lazy, Suspense, useState, useEffect } from 'react';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
import { RouterProvider } from 'react-router-dom';
@@ -23,25 +23,34 @@ const ReactQueryDevtools = import.meta.env.DEV
const App = () => {
const { setError } = useApiErrorBoundary();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Always attempt network requests, even when navigator.onLine is false
// This is needed because localhost is reachable without WiFi
networkMode: 'always',
},
mutations: {
networkMode: 'always',
},
},
queryCache: new QueryCache({
onError: (error) => {
if (error?.response?.status === 401) {
setError(error);
}
},
}),
});
// Stabilize the QueryClient for the app's lifetime. Creating it in the render
// body mints a fresh, empty client on every re-render; a guest's expected 401s
// (mcp/servers, files/config, ) fire `setError`, re-rendering App and swapping
// in an empty client so the lazy chat-form's `getQueryData([endpoints])`
// resolves to a client with no `Hanzo` endpoint and submit throws
// "Unknown endpoint: Hanzo". One client, one cache, every consumer.
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// Always attempt network requests, even when navigator.onLine is false
// This is needed because localhost is reachable without WiFi
networkMode: 'always',
},
mutations: {
networkMode: 'always',
},
},
queryCache: new QueryCache({
onError: (error) => {
if (error?.response?.status === 401) {
setError(error);
}
},
}),
}),
);
useEffect(() => {
initializeFontSize();
@@ -0,0 +1,72 @@
import { memo, useCallback } from 'react';
import { useWatch } from 'react-hook-form';
import { X, ExternalLink, AppWindow } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useChatFormContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { openAppBuilder } from '~/utils';
import store from '~/store';
/**
* SCAFFOLD (Phase 2) for the inline "build an app" experience: the side preview
* pane that will eventually render the generated app. For now it is a placeholder
* whose primary CTA is the "Open in App" handoff to the full hanzo.app builder,
* seeded live from the composer text.
*
* Phase 3 (real inline codegen/preview) replaces the placeholder with a live
* sandbox iframe. It needs: (1) a codegen/session endpoint the chat thread drives
* (hanzo.app `/dev` codegen behind `/v1/`); (2) a sandbox preview URL to load into
* the iframe; (3) a build-state channel (SSE) streaming file/preview updates.
* Until then this hands off to hanzo.app so the experience stays uniform.
*/
function BuildPreviewPane() {
const localize = useLocalize();
const setBuildMode = useSetRecoilState(store.buildMode);
const methods = useChatFormContext();
const text = useWatch({ control: methods.control, name: 'text' });
const openInApp = useCallback(() => openAppBuilder(text), [text]);
const close = useCallback(() => setBuildMode(false), [setBuildMode]);
return (
<aside className="hidden h-full w-full max-w-[45%] flex-col border-l border-border-light bg-surface-primary md:flex">
<div className="flex items-center justify-between border-b border-border-light px-4 py-2.5">
<div className="flex items-center gap-2 text-sm font-medium text-text-primary">
<AppWindow className="icon-md" aria-hidden="true" />
{localize('com_ui_build_app_preview')}
</div>
<button
type="button"
onClick={close}
title={localize('com_ui_close')}
aria-label={localize('com_ui_close')}
className="rounded-lg p-1.5 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
>
<X className="icon-md" aria-hidden="true" />
</button>
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl border border-border-medium bg-surface-secondary">
<AppWindow className="h-7 w-7 text-text-secondary" aria-hidden="true" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_build_app_preview_placeholder')}
</p>
<p className="mx-auto max-w-xs text-xs text-text-secondary">
{localize('com_ui_build_app_preview_hint')}
</p>
</div>
<button
type="button"
onClick={openInApp}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-submit px-4 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-surface-submit-hover hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<ExternalLink className="icon-sm" aria-hidden="true" />
{localize('com_ui_build_app_open_in_app')}
</button>
</div>
</aside>
);
}
export default memo(BuildPreviewPane);
+44 -25
View File
@@ -10,6 +10,7 @@ import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } fr
import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import BuildPreviewPane from '~/components/BuildApp/BuildPreviewPane';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
@@ -33,6 +34,7 @@ function ChatView({ index = 0 }: { index?: number }) {
const { conversationId } = useParams();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
const buildMode = useRecoilValue(store.buildMode);
const fileMap = useFileMapContext();
@@ -76,36 +78,53 @@ function ChatView({ index = 0 }: { index?: number }) {
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
}
const chatColumn = (
<div
className={cn(
'relative flex h-full flex-col',
buildMode ? 'min-w-0 flex-1' : 'w-full',
)}
>
{!isLoading && <Header />}
<>
<div
className={cn(
'flex flex-col',
isLandingPage
? 'flex-1 items-center justify-end sm:justify-center'
: 'h-full overflow-y-auto',
)}
>
{content}
<div
className={cn(
'w-full',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
<ChatForm index={index} />
{isLandingPage ? <ConversationStarters /> : <Footer />}
</div>
</div>
{isLandingPage && <Footer />}
</>
</div>
);
return (
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation>
<div className="relative flex h-full w-full flex-col">
{!isLoading && <Header />}
<>
<div
className={cn(
'flex flex-col',
isLandingPage
? 'flex-1 items-center justify-end sm:justify-center'
: 'h-full overflow-y-auto',
)}
>
{content}
<div
className={cn(
'w-full',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
<ChatForm index={index} />
{isLandingPage ? <ConversationStarters /> : <Footer />}
</div>
</div>
{isLandingPage && <Footer />}
</>
</div>
{/* Inline "build an app" mode: chat thread + side preview pane (scaffold). */}
{buildMode ? (
<div className="flex h-full w-full flex-row">
{chatColumn}
<BuildPreviewPane />
</div>
) : (
chatColumn
)}
</Presentation>
</AddedChatContext.Provider>
</ChatContext.Provider>
@@ -0,0 +1,40 @@
import { memo, useCallback } from 'react';
import { AppWindow } from 'lucide-react';
import { useRecoilState } from 'recoil';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
/**
* Composer affordance that enters the inline "build an app" mode (split chat +
* side preview). From the preview pane the user hands off to the full hanzo.app
* builder ("Open in App"). Styled to match the composer's rounded badge chrome
* so it reads as one system across chat -> app -> console.
*/
function BuildAppButton() {
const localize = useLocalize();
const [isBuildMode, setBuildMode] = useRecoilState(store.buildMode);
const toggle = useCallback(() => setBuildMode((prev) => !prev), [setBuildMode]);
return (
<button
type="button"
onClick={toggle}
aria-pressed={isBuildMode}
title={localize('com_ui_build_app')}
className={cn(
'group inline-flex h-9 items-center justify-center gap-1.5 whitespace-nowrap',
'rounded-full border border-border-medium px-3 text-sm font-medium',
'bg-transparent text-text-primary shadow-sm transition-all',
'hover:bg-surface-hover hover:shadow-md active:shadow-inner',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isBuildMode && 'border-border-heavy bg-surface-hover',
)}
>
<AppWindow className="icon-md" aria-hidden="true" />
<span className="hidden sm:inline">{localize('com_ui_build_app')}</span>
</button>
);
}
export default memo(BuildAppButton);
+12 -2
View File
@@ -23,8 +23,9 @@ import { useRunCloudAgent } from '~/hooks/Agents';
import { mainTextareaId, BadgeItem } from '~/common';
import AttachFileChat from './Files/AttachFileChat';
import FileFormChat from './Files/FileFormChat';
import { cn, removeFocusRings, parseAgentCommand } from '~/utils';
import { cn, removeFocusRings, parseAgentCommand, parseBuildCommand, openAppBuilder } from '~/utils';
import TextareaHeader from './TextareaHeader';
import BuildAppButton from './BuildAppButton';
import PromptsCommand from './PromptsCommand';
import AgentsCommand from './AgentsCommand';
import AudioRecorder from './AudioRecorder';
@@ -141,7 +142,15 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
*/
const onSubmit = useCallback(
(data?: { text: string }) => {
const command = parseAgentCommand(data?.text ?? '');
const text = data?.text ?? '';
/** `/build [prompt]` hands off to the hanzo.app builder (new tab). */
const buildPrompt = parseBuildCommand(text);
if (buildPrompt !== null) {
methods.reset();
openAppBuilder(buildPrompt);
return;
}
const command = parseAgentCommand(text);
if (command) {
methods.reset();
setShowAgentsPopover(false);
@@ -343,6 +352,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
<AttachFileChat conversation={conversation} disableInputs={disableInputs} />
</div>
<BuildAppButton />
<BadgeRow
showEphemeralBadges={
!!endpoint && !isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)
@@ -93,7 +93,7 @@ export default function StreamAudio({ index = 0 }) {
}
logger.log('Fetching audio...', navigator.userAgent);
const response = await fetch('/api/files/speech/tts', {
const response = await fetch('/v1/chat/files/speech/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ messageId: latestMessage?.messageId, runId: activeRunId, voice }),

Some files were not shown because too many files have changed in this diff Show More