Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c737ed137d | ||
|
|
c4f48f6af5 | ||
|
|
f466b6c2ca | ||
|
|
26591fb2ba | ||
|
|
f54d01289b | ||
|
|
1a8456bbb9 | ||
|
|
0dfa7df1da | ||
|
|
44d41f6235 | ||
|
|
0becb68c80 | ||
|
|
04210dea16 | ||
|
|
4233c8760f | ||
|
|
d20ba76651 | ||
|
|
3e36ede65d | ||
|
|
18f3d9ce80 | ||
|
|
5578ef924d | ||
|
|
5e0b6038af | ||
|
|
a5a2498997 | ||
|
|
dbed3bf2fb | ||
|
|
462dbfdc84 | ||
|
|
c2aff88ca6 | ||
|
|
05396959ae | ||
|
|
da475d299b | ||
|
|
2e794d3228 | ||
|
|
9cd97355fe | ||
|
|
b70c5400a2 | ||
|
|
8a89cfed07 | ||
|
|
c3114a1258 | ||
|
|
821b3b59a2 | ||
|
|
8a6c63a869 | ||
|
|
86ecab135e | ||
|
|
196e3c3d61 | ||
|
|
46b636c8c0 | ||
|
|
0cc792d02d | ||
|
|
ca632b467f | ||
|
|
9df97c7393 | ||
|
|
879bba70dd | ||
|
|
ba982d26e1 |
@@ -463,6 +463,21 @@ ALLOW_REGISTRATION=true
|
||||
ALLOW_SOCIAL_LOGIN=false
|
||||
ALLOW_SOCIAL_REGISTRATION=false
|
||||
ALLOW_PASSWORD_RESET=false
|
||||
|
||||
#=====================================================#
|
||||
# Guest Chat (anonymous preview) #
|
||||
#=====================================================#
|
||||
# Off by default. When enabled, unauthenticated visitors get a small free
|
||||
# quota on the free Zen model routed through api.hanzo.ai. After the quota is
|
||||
# spent the UI surfaces the existing OpenID/hanzo.id login. Guests are scoped
|
||||
# server-side to the free model only and rejected from every other route.
|
||||
ALLOW_GUEST_CHAT=false
|
||||
# GUEST_MESSAGE_MAX=3 # free messages per IP before login is required
|
||||
# GUEST_ENDPOINT=Hanzo # custom endpoint name from librechat.yaml (api.hanzo.ai)
|
||||
# GUEST_MODEL=zen3-nano # free Zen model id guests are pinned to
|
||||
# GUEST_TOKEN_EXPIRY=3600000 # guest JWT lifetime in ms (default 1h)
|
||||
# GUEST_TOKEN_MAX=20 # max guest sessions issued per IP per window
|
||||
# GUEST_TOKEN_WINDOW=60 # guest-session issuance window in minutes
|
||||
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
|
||||
ALLOW_UNVERIFIED_EMAIL_LOGIN=true
|
||||
|
||||
@@ -810,31 +825,26 @@ OPENWEATHER_API_KEY=
|
||||
# Hanzo Chat Code Interpreter API #
|
||||
#====================================#
|
||||
|
||||
# https://hanzo.ai/docs/chat/code-interpreter
|
||||
# LIBRECHAT_CODE_API_KEY=your-key
|
||||
# "Run Code" routes through the Hanzo unified backend (api.hanzo.ai/v1/exec),
|
||||
# which executes in a Hanzo sandbox. execute_code POSTs {BASEURL}/exec with
|
||||
# X-API-Key. https://hanzo.ai/docs/chat/code-interpreter
|
||||
# LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
|
||||
# LIBRECHAT_CODE_API_KEY=your-key # prod: KMS chat-secrets/LIBRECHAT_CODE_API_KEY
|
||||
|
||||
#======================#
|
||||
# Web Search #
|
||||
#======================#
|
||||
|
||||
# Note: All of the following variable names can be customized.
|
||||
# Omit values to allow user to provide them.
|
||||
|
||||
# For more information on configuration values, see:
|
||||
# Web Search routes through the Hanzo unified backend ONLY — no external SaaS.
|
||||
# The searxng provider + firecrawl scraper both point at api.hanzo.ai/v1/websearch
|
||||
# (Hanzo metasearch + Hanzo Crawl). See librechat.yaml `webSearch`.
|
||||
# https://hanzo.ai/docs/chat/features/web_search
|
||||
|
||||
# Search Provider (Required)
|
||||
# SERPER_API_KEY=your_serper_api_key
|
||||
|
||||
# Scraper (Required)
|
||||
# FIRECRAWL_API_KEY=your_firecrawl_api_key
|
||||
# Optional: Custom Firecrawl API URL
|
||||
# FIRECRAWL_API_URL=your_firecrawl_api_url
|
||||
|
||||
# Reranker (Required)
|
||||
# JINA_API_KEY=your_jina_api_key
|
||||
# or
|
||||
# COHERE_API_KEY=your_cohere_api_key
|
||||
# SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
|
||||
# FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
|
||||
# WEBSEARCH_API_KEY=your-key # prod: KMS chat-secrets/WEBSEARCH_API_KEY
|
||||
#
|
||||
# Do NOT set SERPER_API_KEY / TAVILY_API_KEY / JINA_API_KEY / COHERE_API_KEY:
|
||||
# external providers are intentionally disabled (one way, unified backend).
|
||||
|
||||
#======================#
|
||||
# MCP Configuration #
|
||||
|
||||
@@ -5,9 +5,19 @@
|
||||
OPENAI_API_KEY=sk-hanzo-your-api-key-here
|
||||
OPENAI_BASE_URL=https://api.hanzo.ai/v1
|
||||
|
||||
# Runtime Execution (Code Interpreter)
|
||||
CODE_EXECUTION_ENDPOINT=https://api.hanzo.ai/v1/execute
|
||||
RUNTIME_API_KEY=${OPENAI_API_KEY}
|
||||
# Code Interpreter ("Run Code") — Hanzo unified backend.
|
||||
# LibreChat's execute_code tool POSTs {LIBRECHAT_CODE_BASEURL}/exec with header
|
||||
# X-API-Key. cloud-api serves /v1/exec (-> sandboxed executor). The key is
|
||||
# KMS-sourced (chat-secrets/LIBRECHAT_CODE_API_KEY). These are the ONLY vars
|
||||
# LibreChat reads — the old CODE_EXECUTION_ENDPOINT/RUNTIME_API_KEY were dead.
|
||||
LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
|
||||
LIBRECHAT_CODE_API_KEY=set-in-KMS-chat-secrets
|
||||
|
||||
# Web Search — Hanzo unified backend (searxng + firecrawl compat over Hanzo
|
||||
# search + crawl). See librechat.yaml `webSearch`. One shared service key.
|
||||
SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
|
||||
FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
|
||||
WEBSEARCH_API_KEY=set-in-KMS-chat-secrets
|
||||
|
||||
# ====== BRANDING ======
|
||||
APP_TITLE=Hanzo Chat
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="chat">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">chat</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">AI chat with MCP integration and multi-provider support</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -5,6 +5,7 @@ Portions of this software are licensed as follows:
|
||||
---
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LibreChat (Danny Avila)
|
||||
Copyright (c) 2023 Hanzo AI Inc
|
||||
Originally based on LibreChat by Berri AI.
|
||||
|
||||
|
||||
@@ -90,6 +90,34 @@ CREDS_KEY= CREDS_IV= # Credential encryption
|
||||
- `uv` bundled for MCP server support
|
||||
- `dompurify` must be in `client/package.json` (externalized by bundler)
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
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.
|
||||
- `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.
|
||||
- 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`.
|
||||
- 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
|
||||
shared per-IP quota across replicas.
|
||||
|
||||
## Internal Package Names
|
||||
|
||||
These are kept as-is from upstream (npm deps, not worth renaming):
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
chat
|
||||
Copyright (c) 2023 Hanzo AI Inc.
|
||||
|
||||
This product includes software from LibreChat (https://github.com/danny-avila/LibreChat), licensed under MIT:
|
||||
|
||||
Copyright (c) 2023 LibreChat (Danny Avila)
|
||||
@@ -1,3 +1,5 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="chat" width="880"></p>
|
||||
|
||||
# Hanzo AI Chat
|
||||
|
||||
AI-powered chat platform with enterprise features, using Hanzo's cloud API or local deployment.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { billingSubject } = require('@hanzochat/api');
|
||||
const { createAutoRefillTransaction } = require('./Transaction');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { getMultiplier } = require('./tx');
|
||||
@@ -22,6 +23,21 @@ function getMinBalance() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum balance in COMMERCE cents (commerce balances are in cents).
|
||||
* Derived from HANZO_MIN_BALANCE (USD). Used by the commerce-first money gate.
|
||||
*/
|
||||
function getMinBalanceCents() {
|
||||
const envVal = process.env.HANZO_MIN_BALANCE;
|
||||
if (envVal != null && envVal !== '') {
|
||||
const usd = parseFloat(envVal);
|
||||
if (!isNaN(usd) && usd > 0) {
|
||||
return Math.round(usd * 100);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isInvalidDate(date) {
|
||||
return isNaN(date);
|
||||
}
|
||||
@@ -50,10 +66,24 @@ const checkModelAccess = async function (userId, model) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple check method that calculates token cost and returns balance info.
|
||||
* Integrates with Commerce balance gate when configured (fail-open).
|
||||
* Calculates token cost and decides whether the request may proceed.
|
||||
*
|
||||
* MONEY GATE — Commerce-first and FAIL CLOSED (this is the money path; better to
|
||||
* block a user than bleed). When Commerce is configured and we can identify the
|
||||
* user's billing org (their IAM `organization`), Commerce is AUTHORITATIVE:
|
||||
* - sufficient org balance (>= HANZO_MIN_BALANCE) → allowed (decisive; we do
|
||||
* NOT fall through to the local tokenCredits gate, so startBalance:0 never
|
||||
* false-blocks a Commerce-funded user);
|
||||
* - insufficient / unreachable / no billing identity → BLOCKED.
|
||||
* The cloud gateway separately debits the user's own hk- key against this same
|
||||
* org, so this check is the pre-flight that yields a clean "claim credit" UX
|
||||
* instead of a raw gateway 402.
|
||||
*
|
||||
* When Commerce is not configured (or no billing identity), falls back to the
|
||||
* legacy local tokenCredits gate.
|
||||
*/
|
||||
const checkBalanceRecord = async function ({
|
||||
req,
|
||||
user,
|
||||
model,
|
||||
endpoint,
|
||||
@@ -65,7 +95,79 @@ const checkBalanceRecord = async function ({
|
||||
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
|
||||
const tokenCost = amount * multiplier;
|
||||
|
||||
// Retrieve the balance record
|
||||
// Guests (anonymous preview) are NOT balance-gated here. Their spend is bounded
|
||||
// two ways, neither of which is an authed user's org balance: (1) the per-IP
|
||||
// guest message limiter (GUEST_MESSAGE_MAX, default 3) and (2) the separate,
|
||||
// small-capped, NON-exempt guest key (HANZO_API_KEY) whose own org's Commerce
|
||||
// balance the cloud gateway debits and 402s when empty. Running them through the
|
||||
// Commerce/local gate (startBalance:0) would block the free tier entirely.
|
||||
if (req?.user?.guest === true) {
|
||||
return { canSpend: true, balance: 0, tokenCost };
|
||||
}
|
||||
|
||||
const commerceClient = getCommerceClient();
|
||||
const billingOrg = (req?.user?.organization ?? '').toString().trim();
|
||||
// Per-user billing subject — prefer the one resolveHanzoCloudKey stamped from
|
||||
// the authoritative IAM identity; otherwise derive it from organization + email
|
||||
// (name == email for individual signups). This keys the gate on the SAME
|
||||
// account the gateway debits (per-user for the shared "hanzo" catch-all), not
|
||||
// the shared org balance.
|
||||
const subject =
|
||||
(req?.user?.billingSubject ?? '').toString().trim() ||
|
||||
billingSubject(billingOrg, req?.user?.email);
|
||||
|
||||
// Commerce-first authoritative gate (per-subject, fail closed).
|
||||
if (commerceClient && subject) {
|
||||
let commerceBalance;
|
||||
try {
|
||||
commerceBalance = await commerceClient.checkBalance(subject);
|
||||
} catch (err) {
|
||||
logger.error('[Balance.check] Commerce unreachable — blocking (fail-closed)', {
|
||||
user,
|
||||
subject,
|
||||
error: err.message,
|
||||
});
|
||||
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_unavailable' };
|
||||
}
|
||||
|
||||
const available = commerceBalance.available || 0;
|
||||
const minCents = Math.max(getMinBalanceCents(), 1);
|
||||
if (available < minCents) {
|
||||
logger.debug('[Balance.check] Commerce balance insufficient', {
|
||||
user,
|
||||
subject,
|
||||
available,
|
||||
minCents,
|
||||
});
|
||||
return { canSpend: false, balance: available, tokenCost, reason: 'commerce_insufficient' };
|
||||
}
|
||||
|
||||
// Tier/model access (fail-open: a tier hiccup must not block a funded user).
|
||||
if (model) {
|
||||
try {
|
||||
const modelAccess = await commerceClient.isModelAllowed(subject, model);
|
||||
if (!modelAccess.allowed) {
|
||||
return {
|
||||
canSpend: false,
|
||||
balance: available,
|
||||
tokenCost,
|
||||
reason: 'model_not_allowed',
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[Balance.check] Tier check failed, allowing (balance ok)', {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Decisive: Commerce says funded → allow. Do not consult local credits.
|
||||
return { canSpend: true, balance: available, tokenCost };
|
||||
}
|
||||
|
||||
// ── Legacy local-credit gate (Commerce not configured / no billing identity) ──
|
||||
let record = await Balance.findOne({ user }).lean();
|
||||
if (!record) {
|
||||
logger.debug('[Balance.check] No balance record found for user', { user });
|
||||
@@ -76,46 +178,6 @@ const checkBalanceRecord = async function ({
|
||||
};
|
||||
}
|
||||
|
||||
// Commerce balance gate: if configured, check Commerce first (fail-open)
|
||||
const commerceClient = getCommerceClient();
|
||||
if (commerceClient && record.commerceUserId) {
|
||||
try {
|
||||
const commerceBalance = await commerceClient.checkBalance(record.commerceUserId);
|
||||
if (!commerceBalance.sufficient) {
|
||||
logger.debug('[Balance.check] Commerce balance insufficient', {
|
||||
user,
|
||||
commerceUserId: record.commerceUserId,
|
||||
available: commerceBalance.available,
|
||||
});
|
||||
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_insufficient' };
|
||||
}
|
||||
|
||||
// Check model access via Commerce tier
|
||||
if (model) {
|
||||
const modelAccess = await commerceClient.isModelAllowed(record.commerceUserId, model);
|
||||
if (!modelAccess.allowed) {
|
||||
logger.debug('[Balance.check] Model not allowed for tier', {
|
||||
user,
|
||||
model,
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
});
|
||||
return {
|
||||
canSpend: false,
|
||||
balance: record.tokenCredits,
|
||||
tokenCost,
|
||||
reason: 'model_not_allowed',
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Fail-open: Commerce error → fall through to local check
|
||||
logger.warn('[Balance.check] Commerce check failed, falling back to local', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
let balance = record.tokenCredits;
|
||||
|
||||
// Check if credits have expired
|
||||
@@ -224,7 +286,7 @@ const addIntervalToDate = (date, value, unit) => {
|
||||
* @throws {Error} Throws an error if there's an issue with the balance check.
|
||||
*/
|
||||
const checkBalance = async ({ req, res, txData }) => {
|
||||
const result = await checkBalanceRecord(txData);
|
||||
const result = await checkBalanceRecord({ ...txData, req });
|
||||
if (result.canSpend) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Money-gate decision matrix for the Commerce-first, FAIL-CLOSED balance gate
|
||||
// (per-org via service token + X-Hanzo-Org; the cloud gateway debits the user's
|
||||
// own hk- key against the SAME org). Proves both directions: unmetered AI never
|
||||
// leaks (block on any ambiguity), and funded users + capped guests are never
|
||||
// wrongly blocked.
|
||||
|
||||
const mockClient = {
|
||||
checkBalance: jest.fn(),
|
||||
isModelAllowed: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
}));
|
||||
jest.mock('~/server/services/CommerceClient', () => ({
|
||||
getCommerceClient: jest.fn(() => mockClient),
|
||||
}));
|
||||
jest.mock('~/cache', () => ({ logViolation: jest.fn().mockResolvedValue(undefined) }));
|
||||
jest.mock('./tx', () => ({ getMultiplier: () => 1 }));
|
||||
jest.mock('./Transaction', () => ({ createAutoRefillTransaction: jest.fn() }));
|
||||
jest.mock('~/db/models', () => ({ Balance: { findOne: jest.fn() } }));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
ViolationTypes: { TOKEN_BALANCE: 'token_balance' },
|
||||
}));
|
||||
|
||||
const { checkBalance } = require('./balanceMethods');
|
||||
const { getCommerceClient } = require('~/server/services/CommerceClient');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
const argsFor = (user) => ({
|
||||
req: { user },
|
||||
res: {},
|
||||
txData: { user: user?.id ?? 'u1', tokenType: 'prompt', amount: 100, model: 'zen4-mini' },
|
||||
});
|
||||
|
||||
// checkBalance resolves `true` when allowed, throws a JSON error (carrying
|
||||
// `reason`) when blocked.
|
||||
const decide = async (user = { id: 'u1', organization: 'acme' }) => {
|
||||
try {
|
||||
const ok = await checkBalance(argsFor(user));
|
||||
return { allowed: ok, reason: null };
|
||||
} catch (err) {
|
||||
let reason = null;
|
||||
try {
|
||||
reason = JSON.parse(err.message).reason;
|
||||
} catch (_) {
|
||||
reason = err.message;
|
||||
}
|
||||
return { allowed: false, reason };
|
||||
}
|
||||
};
|
||||
|
||||
describe('checkBalance — Commerce-first money gate (fail closed)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getCommerceClient.mockReturnValue(mockClient);
|
||||
mockClient.isModelAllowed.mockResolvedValue({ allowed: true, tier: 't', allowedModels: ['*'] });
|
||||
process.env.HANZO_MIN_BALANCE = '1.00'; // 100 cents minimum
|
||||
});
|
||||
|
||||
it('ALLOWS a guest WITHOUT consulting Commerce (capped by IP limiter + guest key)', async () => {
|
||||
const { allowed } = await decide({ id: 'g1', guest: true });
|
||||
expect(allowed).toBe(true);
|
||||
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
||||
expect(Balance.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS a funded org (available >= min)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(true);
|
||||
expect(reason).toBeNull();
|
||||
expect(mockClient.checkBalance).toHaveBeenCalledWith('acme');
|
||||
});
|
||||
|
||||
it('BLOCKS commerce_insufficient when balance below minimum ($0 → claim $5)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: false, available: 0 });
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_insufficient');
|
||||
});
|
||||
|
||||
it('BLOCKS commerce_insufficient when balance is a tiny non-zero below min', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 50 }); // $0.50 < $1
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_insufficient');
|
||||
});
|
||||
|
||||
it('FAILS CLOSED (commerce_unavailable) when Commerce throws — never bleeds', async () => {
|
||||
mockClient.checkBalance.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_unavailable');
|
||||
});
|
||||
|
||||
it('BLOCKS model_not_allowed for a funded org whose tier forbids the model', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
mockClient.isModelAllowed.mockResolvedValue({
|
||||
allowed: false,
|
||||
tier: 'free',
|
||||
allowedModels: ['zen3-nano'],
|
||||
});
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('model_not_allowed');
|
||||
});
|
||||
|
||||
it('ALLOWS a funded org even if the tier check itself errors (fail-open tier, balance ok)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
mockClient.isModelAllowed.mockRejectedValue(new Error('tier-check 500'));
|
||||
const { allowed } = await decide();
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('FAILS CLOSED for an authed user with NO billing org (no commerce branch, no local credits)', async () => {
|
||||
Balance.findOne.mockReturnValue({ lean: () => Promise.resolve(null) });
|
||||
const { allowed } = await decide({ id: 'u2', organization: '' });
|
||||
expect(allowed).toBe(false);
|
||||
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
const { buildGuestEndpointsConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
async function endpointController(req, res) {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
res.send(JSON.stringify(endpointsConfig));
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ const updateFavoritesController = async (req, res) => {
|
||||
|
||||
const getFavoritesController = async (req, res) => {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, 'favorites');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
|
||||
const { buildGuestModelsConfig } = require('~/server/services/guestConfig');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
@@ -39,6 +40,9 @@ async function loadModels(req) {
|
||||
|
||||
async function modelController(req, res) {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(buildGuestModelsConfig());
|
||||
}
|
||||
const modelConfig = await loadModels(req);
|
||||
res.send(modelConfig);
|
||||
} catch (error) {
|
||||
|
||||
@@ -40,12 +40,16 @@ const { invalidateCachedTools } = require('~/server/services/Config/getCachedToo
|
||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { buildGuestUser } = require('~/server/services/guestConfig');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||
const { deleteUserAgents } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).send(buildGuestUser(req.user));
|
||||
}
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
/** @type {IUser} */
|
||||
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Guest-scoped bootstrap controllers: verify that a guest principal receives
|
||||
* safe, scoped data (and never triggers a DB lookup) on the read-only endpoints
|
||||
* the chat composer hard-requires.
|
||||
*/
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
CacheKeys: { CONFIG_STORE: 'CONFIG_STORE', MODELS_CONFIG: 'MODELS_CONFIG' },
|
||||
Tools: {},
|
||||
Constants: {},
|
||||
FileSources: { local: 'local', s3: 's3' },
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
webSearchKeys: [],
|
||||
}));
|
||||
|
||||
// --- EndpointController deps ---
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getEndpointsConfig: jest.fn(),
|
||||
loadDefaultModels: jest.fn(),
|
||||
loadConfigModels: jest.fn(),
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => ({ get: jest.fn(), set: jest.fn() })) }));
|
||||
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
|
||||
const guestReq = (overrides = {}) => ({
|
||||
user: { id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('guest bootstrap controllers', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('GET /api/endpoints', () => {
|
||||
it('returns ONLY the guest endpoint, no DB/config read', async () => {
|
||||
const req = guestReq();
|
||||
const res = mockRes();
|
||||
await endpointController(req, res);
|
||||
expect(getEndpointsConfig).not.toHaveBeenCalled();
|
||||
const payload = JSON.parse(res.send.mock.calls[0][0]);
|
||||
expect(Object.keys(payload)).toEqual(['Hanzo']);
|
||||
expect(payload.Hanzo).toMatchObject({ type: 'custom', userProvide: false });
|
||||
});
|
||||
|
||||
it('falls through to the real config for non-guest users', async () => {
|
||||
getEndpointsConfig.mockResolvedValue({ Hanzo: {}, openAI: {} });
|
||||
const req = { user: { id: 'real', role: 'USER' } };
|
||||
const res = mockRes();
|
||||
await endpointController(req, res);
|
||||
expect(getEndpointsConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/models', () => {
|
||||
it('returns ONLY the single guest model under the guest endpoint', async () => {
|
||||
const req = guestReq();
|
||||
const res = mockRes();
|
||||
await modelController(req, res);
|
||||
expect(res.send).toHaveBeenCalledWith({ Hanzo: ['zen5-mini'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -891,9 +891,20 @@ class AgentClient extends BaseClient {
|
||||
config.signal = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture before running the graph. `run.processStream` (via
|
||||
* `@librechat/agents`) treats the passed `config` as owned and, during its
|
||||
* post-stream cleanup, sets `config.configurable = undefined` to break the
|
||||
* reference chain that keeps heavy graph state (base64 images/PDFs) alive.
|
||||
* Reading `config.configurable.hide_sequential_outputs` AFTER `runAgents`
|
||||
* therefore throws `Cannot read properties of undefined`, which surfaces as
|
||||
* the post-reply error banner. Hoisting the read keeps the deprecated Agent
|
||||
* Chain filter working without depending on post-run config state.
|
||||
*/
|
||||
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
|
||||
await runAgents(initialMessages);
|
||||
/** @deprecated Agent Chain */
|
||||
if (config.configurable.hide_sequential_outputs) {
|
||||
if (hideSequentialOutputs) {
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
|
||||
@@ -15,6 +15,7 @@ jest.mock('@hanzochat/api', () => ({
|
||||
checkAccess: jest.fn(),
|
||||
initializeAgent: jest.fn(),
|
||||
createMemoryProcessor: jest.fn(),
|
||||
createRun: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/Agent', () => ({
|
||||
@@ -2257,3 +2258,122 @@ describe('AgentClient - titleConvo', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentClient - chatCompletion post-reply banner regression', () => {
|
||||
/**
|
||||
* Regression for the post-reply error banner:
|
||||
* "Cannot read properties of undefined (reading 'hide_sequential_outputs')".
|
||||
*
|
||||
* `run.processStream` (from `@librechat/agents` >= 3.1.52) treats the passed
|
||||
* `config` as owned and, in its post-stream cleanup, sets
|
||||
* `config.configurable = undefined` to break the AsyncLocalStorage reference
|
||||
* chain that keeps heavy graph state (base64 images/PDFs) alive. The
|
||||
* controller must therefore NOT read `config.configurable.hide_sequential_outputs`
|
||||
* AFTER the stream completes, or it throws and pushes an ERROR content part
|
||||
* (the red banner) which also aborts the post-stream finalize (auto-titling).
|
||||
* This test reproduces that mutation and asserts no ERROR part is produced.
|
||||
*/
|
||||
const { createRun } = require('@hanzochat/api');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
let processStreamMock;
|
||||
|
||||
const makeClient = (agentOverrides = {}) => {
|
||||
const agent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
provider: EModelEndpoint.openAI,
|
||||
model_parameters: { model: 'gpt-4' },
|
||||
...agentOverrides,
|
||||
};
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {},
|
||||
config: { endpoints: {} },
|
||||
};
|
||||
const client = new AgentClient({
|
||||
req,
|
||||
res: {},
|
||||
agent,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
contentParts: [],
|
||||
collectedUsage: [],
|
||||
artifactPromises: [],
|
||||
endpointTokenConfig: {},
|
||||
});
|
||||
client.conversationId = 'convo-123';
|
||||
client.responseMessageId = 'response-123';
|
||||
client.parentMessageId = '00000000-0000-0000-0000-000000000000';
|
||||
client.user = 'user-123';
|
||||
// Avoid DB / memory side effects unrelated to this regression.
|
||||
client.recordCollectedUsage = jest.fn().mockResolvedValue();
|
||||
client.runMemory = jest.fn().mockResolvedValue(undefined);
|
||||
client.awaitMemoryWithTimeout = jest.fn().mockResolvedValue(undefined);
|
||||
return client;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Faithfully mimic `@librechat/agents` Run.processStream cleanup: it nulls
|
||||
// the caller's `config.configurable` after the stream finishes.
|
||||
processStreamMock = jest.fn(async (_inputs, config) => {
|
||||
config.configurable = undefined;
|
||||
return [];
|
||||
});
|
||||
createRun.mockResolvedValue({
|
||||
Graph: {},
|
||||
processStream: processStreamMock,
|
||||
});
|
||||
});
|
||||
|
||||
const errorParts = (client) =>
|
||||
(client.getContentParts() || []).filter((p) => p && p.type === ContentTypes.ERROR);
|
||||
|
||||
it('does not push an error content part when processStream nulls config.configurable', async () => {
|
||||
const client = makeClient();
|
||||
|
||||
await expect(
|
||||
client.sendCompletion('hello', { abortController: new AbortController() }),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(processStreamMock).toHaveBeenCalledTimes(1);
|
||||
// The config passed to processStream had configurable nulled (reproduction sanity check).
|
||||
const passedConfig = processStreamMock.mock.calls[0][1];
|
||||
expect(passedConfig.configurable).toBeUndefined();
|
||||
// The fix: no banner-producing ERROR part despite the nulled configurable.
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still applies the deprecated hide_sequential_outputs filter using the pre-stream value', async () => {
|
||||
const client = makeClient({ hide_sequential_outputs: true });
|
||||
// Seed multiple content parts; the filter keeps only the last part plus
|
||||
// tool_call parts. With the value captured before the stream, the filter
|
||||
// must run even though config.configurable is undefined afterwards.
|
||||
client.contentParts = [
|
||||
{ type: 'text', text: 'intermediate-1' },
|
||||
{ type: 'text', text: 'intermediate-2' },
|
||||
{ type: 'text', text: 'final' },
|
||||
];
|
||||
|
||||
await client.sendCompletion('hello', { abortController: new AbortController() });
|
||||
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
// hide_sequential_outputs collapses to the final part only (no tool calls present).
|
||||
expect(client.contentParts).toEqual([{ type: 'text', text: 'final' }]);
|
||||
});
|
||||
|
||||
it('leaves content parts intact when hide_sequential_outputs is falsy', async () => {
|
||||
const client = makeClient({ hide_sequential_outputs: false });
|
||||
client.contentParts = [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
];
|
||||
|
||||
await client.sendCompletion('hello', { abortController: new AbortController() });
|
||||
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
expect(client.contentParts).toEqual([
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getGuestConfig, GUEST_ROLE } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Issues a short-lived guest token for anonymous preview chat.
|
||||
*
|
||||
* The token is signed with `JWT_SECRET` and carries a `guest: true` claim plus a
|
||||
* per-token random id, so guest principals are ephemeral and isolated from each
|
||||
* other. It is rejected by the standard `jwt` strategy (no matching DB user),
|
||||
* which keeps every non-chat route closed to guests.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
*/
|
||||
const guestTokenController = async (req, res) => {
|
||||
const config = getGuestConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
logger.error('[guestTokenController] JWT_SECRET is not configured');
|
||||
return res.status(500).json({ message: 'Server misconfiguration' });
|
||||
}
|
||||
|
||||
const id = `guest_${randomUUID()}`;
|
||||
const expiresInSeconds = Math.floor(config.tokenExpiryMs / 1000);
|
||||
|
||||
const token = jwt.sign({ id, guest: true, role: GUEST_ROLE }, process.env.JWT_SECRET, {
|
||||
expiresIn: expiresInSeconds,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
token,
|
||||
expiresIn: expiresInSeconds,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
messageMax: config.messageMax,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { guestTokenController };
|
||||
@@ -136,7 +136,7 @@ const startServer = async () => {
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat; frame-ancestors 'none';",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.hanzo.ai https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat https://static.cloudflareinsights.com https://cloudflareinsights.com; frame-ancestors 'none';",
|
||||
);
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
next();
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
describe('enforceGuestScope', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
messageMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes non-guest requests through untouched', () => {
|
||||
const req = { user: { id: 'u1', role: 'USER' }, body: { endpoint: 'OpenAI', model: 'gpt-4o' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('OpenAI');
|
||||
expect(req.body.model).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different endpoint (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'OpenAI' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different (paid) model (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'Hanzo', model: 'zen4-max' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pins endpoint, type, and model for a compliant guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: { endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' },
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.endpointType).toBe('custom');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('pins endpoint/model even when the guest omits them', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { text: 'hi' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('strips every privileged capability from a guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: {
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
agent_id: 'agent_evil',
|
||||
spec: 'paid-spec',
|
||||
preset: { foo: 'bar' },
|
||||
files: [{ file_id: 'f1' }],
|
||||
tools: ['execute_code'],
|
||||
tool_resources: { x: 1 },
|
||||
resendFiles: true,
|
||||
promptPrefix: 'jailbreak',
|
||||
web_search: true,
|
||||
},
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.agent_id).toBeUndefined();
|
||||
expect(req.body.spec).toBeUndefined();
|
||||
expect(req.body.preset).toBeUndefined();
|
||||
expect(req.body.files).toBeUndefined();
|
||||
expect(req.body.tools).toBeUndefined();
|
||||
expect(req.body.tool_resources).toBeUndefined();
|
||||
expect(req.body.resendFiles).toBeUndefined();
|
||||
expect(req.body.promptPrefix).toBeUndefined();
|
||||
expect(req.body.web_search).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 404 for a guest when guest chat is disabled mid-flight', () => {
|
||||
getGuestConfig.mockReturnValue({ enabled: false, endpoint: 'Hanzo', model: 'zen3-nano' });
|
||||
const req = { user: { id: 'g1', guest: true }, body: {} };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Integration test for the guest chat middleware chain composition:
|
||||
* guest auth -> scope enforcement -> quota, plus the reserved-subpath guard
|
||||
* that keeps management routes (abort/active/...) on the JWT-only parent router.
|
||||
*/
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () =>
|
||||
jest.fn((req, res, next) => {
|
||||
req.user = { id: 'real-user', role: 'USER' };
|
||||
next();
|
||||
}),
|
||||
);
|
||||
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
const { guestMessageLimiter } = require('../limiters/guestMessageLimiter');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const buildRouter = () => {
|
||||
const router = express.Router();
|
||||
const RESERVED = new Set(['abort', 'active']);
|
||||
|
||||
const chatRouter = express.Router();
|
||||
chatRouter.use((req, res, next) => {
|
||||
const subpath = req.path.split('/').filter(Boolean)[0];
|
||||
if (RESERVED.has(subpath)) {
|
||||
return next('router');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
chatRouter.post('/', (req, res) =>
|
||||
res
|
||||
.status(200)
|
||||
.json({ endpoint: req.body.endpoint, model: req.body.model, role: req.user.role }),
|
||||
);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
// JWT-only management route on the parent (after the guest router)
|
||||
router.post('/chat/abort', require('../requireJwtAuth'), (req, res) =>
|
||||
res.status(200).json({ aborted: true, role: req.user.role }),
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/agents', buildRouter());
|
||||
return app;
|
||||
};
|
||||
|
||||
const guestToken = () => jwt.sign({ id: 'guest_1', guest: true, role: 'GUEST' }, JWT_SECRET);
|
||||
|
||||
describe('guest chat middleware chain', () => {
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_MESSAGE_MAX = '2';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen3-nano';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
delete process.env.GUEST_MESSAGE_MAX;
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
it('lets a guest reach the completion route, pinned to the free endpoint/model', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/agents/chat')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', '10.1.0.1')
|
||||
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ endpoint: 'Hanzo', model: 'zen3-nano', role: 'GUEST' });
|
||||
});
|
||||
|
||||
it('returns 402 GUEST_LIMIT after the quota is exhausted', async () => {
|
||||
const app = buildApp();
|
||||
const ip = '10.1.0.2';
|
||||
const send = () =>
|
||||
request(app)
|
||||
.post('/api/agents/chat')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', ip)
|
||||
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
|
||||
|
||||
expect((await send()).status).toBe(200);
|
||||
expect((await send()).status).toBe(200);
|
||||
const blocked = await send();
|
||||
expect(blocked.status).toBe(402);
|
||||
expect(blocked.body.type).toBe('GUEST_LIMIT');
|
||||
});
|
||||
|
||||
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')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', '10.1.0.3')
|
||||
.send({ streamId: 'x' });
|
||||
// requireJwtAuth mock forces a real USER; guest never reaches abort.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ aborted: true, role: 'USER' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () => jest.fn((req, res, next) => next('jwt-fallback')));
|
||||
|
||||
const requireJwtAuth = require('../requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
const guestToken = (claims = {}) =>
|
||||
jwt.sign({ id: 'guest_abc', guest: true, role: 'GUEST', ...claims }, JWT_SECRET);
|
||||
|
||||
describe('requireGuestOrJwtAuth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest chat is disabled', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'false';
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('authenticates a valid guest token as an ephemeral GUEST principal', () => {
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.user).toEqual({ id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true });
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when no bearer token is present', () => {
|
||||
const req = { headers: {} };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a non-guest (regular) JWT', () => {
|
||||
const userToken = jwt.sign({ id: 'real-user' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${userToken}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a token signed with the wrong secret (fail closed)', () => {
|
||||
const forged = jwt.sign({ id: 'guest_x', guest: true }, 'wrong-secret');
|
||||
const req = { headers: { authorization: `Bearer ${forged}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest claim is missing', () => {
|
||||
const token = jwt.sign({ id: 'guest_x' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest token lacks an id', () => {
|
||||
const token = jwt.sign({ guest: true }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Regression: a logged-in user in "My Agents" with no agent created/selected
|
||||
* could type "hi" and get `400 {message:'agent_id is required in request body'}`.
|
||||
*
|
||||
* A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat, not an
|
||||
* error. This hermetic test locks the access-middleware fix point (no MongoDB /
|
||||
* winston): canAccessAgentFromBody skips the per-agent ACL and calls next().
|
||||
* The downstream build-options fix point is covered by
|
||||
* ../../services/Endpoints/agents/build.spec.js.
|
||||
*/
|
||||
|
||||
// Mock externals so neither winston (data-schemas) nor MongoDB (~/models/Agent) load.
|
||||
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), debug: jest.fn() } }));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
|
||||
ResourceType: { AGENT: 'agent' },
|
||||
// Real predicate semantics (mirrors packages/data-provider/src):
|
||||
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
|
||||
isEphemeralAgentId: (agentId) => !agentId?.startsWith?.('agent_'),
|
||||
}));
|
||||
|
||||
const mockCanAccessResource = jest.fn(() => (req, res, next) => {
|
||||
req.__aclChecked = true;
|
||||
return next();
|
||||
});
|
||||
jest.mock('./canAccessResource', () => ({ canAccessResource: mockCanAccessResource }));
|
||||
|
||||
jest.mock('~/models/Agent', () => ({ getAgent: jest.fn() }));
|
||||
|
||||
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
|
||||
|
||||
const makeReqRes = (body) => {
|
||||
const req = { body, params: {} };
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
const next = jest.fn();
|
||||
return { req, res, next };
|
||||
};
|
||||
|
||||
describe('agent_id missing → ephemeral fallback (no 400)', () => {
|
||||
const mw = canAccessAgentFromBody({ requiredPermission: 1 });
|
||||
|
||||
test('agents endpoint with NO agent_id proceeds without a 400', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', text: 'hi' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(mockCanAccessResource).not.toHaveBeenCalled(); // no per-agent ACL for ephemeral
|
||||
});
|
||||
|
||||
test('non-agents endpoint with no agent_id proceeds (ephemeral)', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'openAI', text: 'hi' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('explicit ephemeral id proceeds', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'ephemeral_primary' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a real agent_id still runs the per-agent ACL check', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'agent_abc123' });
|
||||
await mw(req, res, next);
|
||||
|
||||
// The middleware builds the ACL check with the real agent id as resourceIdParam,
|
||||
// then runs it (which here calls next via the mock).
|
||||
expect(mockCanAccessResource).toHaveBeenCalledTimes(1);
|
||||
expect(mockCanAccessResource.mock.calls[0][0]).toMatchObject({ resourceType: 'agent' });
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -60,15 +60,11 @@ const canAccessAgentFromBody = (options) => {
|
||||
agentId = Constants.EPHEMERAL_AGENT_ID;
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'agent_id is required in request body',
|
||||
});
|
||||
}
|
||||
|
||||
// Skip permission checks for ephemeral agents
|
||||
// Real agent IDs always start with "agent_", so anything else is ephemeral
|
||||
// A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat,
|
||||
// not an error: "type a message and get a reply" must work before the user
|
||||
// has created or selected an agent. Ephemeral agents carry no per-agent ACL,
|
||||
// so skip the permission check and fall through to plain chat. A missing id
|
||||
// is ephemeral by the canonical predicate (isEphemeralAgentId(undefined) === true).
|
||||
if (isEphemeralAgentId(agentId)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -104,13 +104,15 @@ describe('canAccessAgentFromBody middleware', () => {
|
||||
});
|
||||
|
||||
describe('primary agent checks', () => {
|
||||
test('returns 400 when agent_id is missing on agents endpoint', async () => {
|
||||
test('proceeds (ephemeral fallback) when agent_id is missing on agents endpoint', async () => {
|
||||
// No agent created/selected yet → ad-hoc chat. "Type hi and get a reply"
|
||||
// must work out of the box; a missing id is ephemeral, not a 400.
|
||||
req.body.agent_id = undefined;
|
||||
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('proceeds for ephemeral primary agent without addedConvo', async () => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Server-side capability scope for guest principals.
|
||||
*
|
||||
* Runs after guest authentication and before `buildEndpointOption`. For guest
|
||||
* requests it pins the endpoint and model to the configured free Zen endpoint and
|
||||
* strips every capability a guest must not reach (paid models, agents, tools,
|
||||
* files, model specs, presets). The client is never trusted: any guest request
|
||||
* that names a different endpoint/model is rejected rather than silently rewritten.
|
||||
*
|
||||
* Non-guest requests pass through untouched.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const enforceGuestScope = (req, res, next) => {
|
||||
if (req.user?.guest !== true) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
const { endpoint, model } = body;
|
||||
|
||||
if (endpoint != null && endpoint !== config.endpoint) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected endpoint: ${endpoint}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
if (model != null && model !== config.model) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected model: ${model}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
body.endpoint = config.endpoint;
|
||||
body.endpointType = EModelEndpoint.custom;
|
||||
body.model = config.model;
|
||||
|
||||
delete body.agent_id;
|
||||
delete body.spec;
|
||||
delete body.preset;
|
||||
delete body.files;
|
||||
delete body.tools;
|
||||
delete body.tool_resources;
|
||||
delete body.resendFiles;
|
||||
delete body.promptPrefix;
|
||||
delete body.web_search;
|
||||
|
||||
req.body = body;
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = enforceGuestScope;
|
||||
@@ -10,6 +10,8 @@ const requireLdapAuth = require('./requireLdapAuth');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
const checkInviteUser = require('./checkInviteUser');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('./requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('./enforceGuestScope');
|
||||
const configMiddleware = require('./config/app');
|
||||
const validateModel = require('./validateModel');
|
||||
const moderateText = require('./moderateText');
|
||||
@@ -36,6 +38,8 @@ module.exports = {
|
||||
moderateText,
|
||||
validateModel,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
enforceGuestScope,
|
||||
checkInviteUser,
|
||||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
|
||||
const { GUEST_TOKEN_WINDOW = 60, GUEST_TOKEN_MAX = 20 } = process.env;
|
||||
|
||||
const windowMs = GUEST_TOKEN_WINDOW * 60 * 1000;
|
||||
const max = GUEST_TOKEN_MAX;
|
||||
const windowInMinutes = windowMs / 60000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(429).json({
|
||||
message: `Too many guest sessions, please try again after ${windowInMinutes} minutes.`,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for guest token issuance.
|
||||
*/
|
||||
const guestTokenLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
handler,
|
||||
keyGenerator: removePorts,
|
||||
store: limiterCache('guest_token_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestTokenLimiter };
|
||||
@@ -0,0 +1,32 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
const GUEST_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(402).json({
|
||||
type: 'GUEST_LIMIT',
|
||||
message: 'Guest message limit reached. Log in to continue.',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
const guestMessageLimiter = rateLimit({
|
||||
windowMs: GUEST_LIMIT_WINDOW_MS,
|
||||
max: () => getGuestConfig().messageMax,
|
||||
handler,
|
||||
keyGenerator: removePorts,
|
||||
skip: (req) => req.user?.guest !== true,
|
||||
store: limiterCache('guest_message_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestMessageLimiter };
|
||||
@@ -0,0 +1,76 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
|
||||
const buildApp = (user) => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
app.use(guestMessageLimiter);
|
||||
app.post('/chat', (_req, res) => res.status(200).json({ ok: true }));
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('guestMessageLimiter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
messageMax: 3,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows up to GUEST_MESSAGE_MAX guest messages then returns 402 GUEST_LIMIT', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
const ip = '10.0.0.1';
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(blocked.status).toBe(402);
|
||||
expect(blocked.body.type).toBe('GUEST_LIMIT');
|
||||
});
|
||||
|
||||
it('counts quota per IP independently', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
}
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
expect(blocked.status).toBe(402);
|
||||
|
||||
const fresh = await request(app).post('/chat').set('x-test-ip', '10.0.0.3');
|
||||
expect(fresh.status).toBe(200);
|
||||
});
|
||||
|
||||
it('never counts or blocks authenticated (non-guest) users', async () => {
|
||||
const app = buildApp({ id: 'real-user', role: 'USER' });
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', '10.0.0.4');
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ const createTTSLimiters = require('./ttsLimiters');
|
||||
const createSTTLimiters = require('./sttLimiters');
|
||||
|
||||
const loginLimiter = require('./loginLimiter');
|
||||
const { guestTokenLimiter } = require('./guestLimiters');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
const importLimiters = require('./importLimiters');
|
||||
const uploadLimiters = require('./uploadLimiters');
|
||||
const forkLimiters = require('./forkLimiters');
|
||||
@@ -17,6 +19,8 @@ module.exports = {
|
||||
...messageLimiters,
|
||||
...forkLimiters,
|
||||
loginLimiter,
|
||||
guestTokenLimiter,
|
||||
guestMessageLimiter,
|
||||
registerLimiter,
|
||||
toolCallLimiter,
|
||||
createTTSLimiters,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const { getGuestConfig, buildGuestPrincipal } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Extracts a bearer token from the Authorization header.
|
||||
* @param {ServerRequest} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const getBearerToken = (req) => {
|
||||
const header = req.headers?.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return header.slice('Bearer '.length).trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Guest-aware authentication, applied ONLY to chat-completion routes.
|
||||
*
|
||||
* When `ALLOW_GUEST_CHAT` is enabled and the bearer token is a valid guest token
|
||||
* (`guest: true`, signed with `JWT_SECRET`), an ephemeral guest principal is set
|
||||
* on `req.user` and the request proceeds. Otherwise the request falls through to
|
||||
* the standard `requireJwtAuth`, which rejects guest tokens everywhere else
|
||||
* (the `jwt` strategy requires a matching DB user). Fail closed by construction.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const requireGuestOrJwtAuth = (req, res, next) => {
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
const token = getBearerToken(req);
|
||||
if (!token) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (_err) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
if (payload?.guest !== true || !payload.id) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
req.user = buildGuestPrincipal(payload.id);
|
||||
logger.debug(`[requireGuestOrJwtAuth] Guest principal authenticated: ${payload.id}`);
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = requireGuestOrJwtAuth;
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* End-to-end auth chain for guest bootstrap: real passport `jwt` strategy +
|
||||
* real `requireGuestOrJwtAuth` / `requireJwtAuth` + real controllers, driven by
|
||||
* a real signed guest JWT.
|
||||
*
|
||||
* 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,
|
||||
* - a non-bootstrap protected route still 401s for a guest.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
SystemRoles: { USER: 'USER' },
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
// getUserById would throw a Mongoose CastError on a guest id; assert it is never
|
||||
// reached for a guest, and returns null (→ clean 401) for a real-but-missing id.
|
||||
jest.mock('~/models', () => ({
|
||||
getUserById: jest.fn(async (id) => {
|
||||
if (String(id).startsWith('guest_')) {
|
||||
throw new Error('CastError: Cast to ObjectId failed for value "' + id + '"');
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
updateUser: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getUserById } = require('~/models');
|
||||
const jwtLogin = require('~/strategies/jwtStrategy');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const { buildGuestEndpointsConfig, buildGuestUser } = require('~/server/services/guestConfig');
|
||||
|
||||
const JWT_SECRET = 'integration-guest-secret';
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(passport.initialize());
|
||||
passport.use(jwtLogin());
|
||||
|
||||
// Bootstrap (guest-aware) routes.
|
||||
app.get('/api/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) => {
|
||||
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) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json({ conversations: [], nextCursor: null });
|
||||
}
|
||||
return res.status(200).json({ conversations: ['real'] });
|
||||
});
|
||||
|
||||
// Non-bootstrap protected route (JWT-only): must reject guests.
|
||||
app.get('/api/prompts', requireJwtAuth, (req, res) => res.status(200).json({ ok: true }));
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('guest bootstrap auth chain (integration)', () => {
|
||||
let app;
|
||||
const guestToken = jwt.sign({ id: 'guest_abc-123', guest: true, role: 'GUEST' }, JWT_SECRET);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
app = buildApp();
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('GET /api/endpoints → 200 with ONLY the guest endpoint', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/endpoints')
|
||||
.set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = JSON.parse(res.text);
|
||||
expect(Object.keys(body)).toEqual(['Hanzo']);
|
||||
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}`);
|
||||
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}`);
|
||||
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}`);
|
||||
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 () => {
|
||||
const realToken = jwt.sign({ id: '64b2f0c0c0c0c0c0c0c0c0c0' }, JWT_SECRET);
|
||||
const res = await request(app).get('/api/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');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Regression test for the OAuth login rate-limiter scoping fix.
|
||||
*
|
||||
* Background: `loginLimiter` (LOGIN_MAX per 5 min, keyed by IP) used to be
|
||||
* applied as blanket router middleware (`router.use(loginLimiter)`), which also
|
||||
* covered the OIDC/social callback routes. Behind a shared LB/CDN IP, with
|
||||
* OPENID_AUTO_REDIRECT firing `/oauth/openid` on every page load, the per-IP
|
||||
* budget was exhausted instantly and the IdP code-exchange callback got 429 -
|
||||
* aborting the token exchange and breaking login.
|
||||
*
|
||||
* The fix scopes `loginLimiter` to the human-initiated entry points (the GETs
|
||||
* that REDIRECT to the IdP) and leaves the machine-driven callback routes
|
||||
* (the IdP returning a one-time code) un-limited.
|
||||
*
|
||||
* This test mounts the REAL oauth router with the REAL `loginLimiter` and only
|
||||
* stubs the heavy leaf deps (passport, openid-client, controllers, config, db),
|
||||
* mirroring server/routes/__tests__/convos-duplicate-ratelimit.spec.js.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// passport.authenticate(...) -> a middleware that signals "reached the handler"
|
||||
// (in prod this would redirect to the IdP for initiation, or exchange the code
|
||||
// for callbacks). Returning 200 lets us detect whether the limiter intercepted
|
||||
// the request with 429 BEFORE control reached the auth handler.
|
||||
jest.mock('passport', () => ({
|
||||
authenticate: jest.fn(() => (req, res) => res.status(200).json({ reached: true })),
|
||||
}));
|
||||
|
||||
jest.mock('openid-client', () => ({
|
||||
randomState: jest.fn(() => 'test-state'),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
ErrorTypes: { AUTH_FAILED: 'AUTH_FAILED' },
|
||||
ViolationTypes: { LOGINS: 'logins' },
|
||||
}));
|
||||
|
||||
// `limiterCache: () => undefined` makes express-rate-limit fall back to its
|
||||
// built-in in-process MemoryStore (no Redis), same as the convos limiter test.
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
|
||||
limiterCache: jest.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// loginLimiter.js does `require('~/cache')` (the index, which also pulls
|
||||
// getLogStores) and `require('~/server/utils')` (index, which pulls
|
||||
// sendEmail/files/queue). Mock both indexes to the minimal real/inert surface
|
||||
// the limiter actually uses: a no-op violation logger and the dependency-free
|
||||
// `removePorts` IP key generator.
|
||||
jest.mock('~/cache', () => ({
|
||||
logViolation: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: jest.requireActual('~/server/utils/removePorts'),
|
||||
}));
|
||||
|
||||
// oauth.js imports { checkDomainAllowed, loginLimiter, logHeaders } from the
|
||||
// middleware index, whose full require graph is too heavy for a hermetic unit
|
||||
// test. Mock the index but expose the REAL `loginLimiter` (the unit under test)
|
||||
// via requireActual of the leaf file — mirroring how
|
||||
// convos-duplicate-ratelimit.spec.js pulls the real forkLimiters. The other two
|
||||
// are inert passthroughs (not under test here).
|
||||
jest.mock('~/server/middleware', () => {
|
||||
const loginLimiter = jest.requireActual('~/server/middleware/limiters/loginLimiter');
|
||||
return {
|
||||
loginLimiter,
|
||||
logHeaders: (req, res, next) => next(),
|
||||
checkDomainAllowed: (req, res, next) => next(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/server/controllers/auth/oauth', () => ({
|
||||
createOAuthHandler: jest.fn(() => (req, res) => res.status(200).json({ handler: true })),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/db/models', () => ({
|
||||
Balance: {},
|
||||
}));
|
||||
|
||||
const LOGIN_INITIATION = '/oauth/openid';
|
||||
const LOGIN_CALLBACK = '/oauth/openid/callback';
|
||||
|
||||
describe('OAuth router — loginLimiter scoping', () => {
|
||||
const savedEnv = {};
|
||||
|
||||
beforeAll(() => {
|
||||
savedEnv.LOGIN_MAX = process.env.LOGIN_MAX;
|
||||
savedEnv.LOGIN_WINDOW = process.env.LOGIN_WINDOW;
|
||||
savedEnv.DOMAIN_CLIENT = process.env.DOMAIN_CLIENT;
|
||||
savedEnv.DOMAIN_SERVER = process.env.DOMAIN_SERVER;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const key of Object.keys(savedEnv)) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The limiter reads LOGIN_MAX/LOGIN_WINDOW at module-load time, so build the
|
||||
// app inside isolateModules AFTER setting env to get a fresh limiter+store.
|
||||
const buildApp = () => {
|
||||
let oauthRouter;
|
||||
jest.isolateModules(() => {
|
||||
oauthRouter = require('../oauth');
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/oauth', oauthRouter);
|
||||
return app;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.LOGIN_MAX = '1';
|
||||
process.env.LOGIN_WINDOW = '5';
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080';
|
||||
process.env.DOMAIN_SERVER = 'http://localhost:3080';
|
||||
});
|
||||
|
||||
describe('initiation route (redirect-to-IdP) IS rate-limited', () => {
|
||||
it('returns 429 once an initiation route exceeds LOGIN_MAX', async () => {
|
||||
const app = buildApp();
|
||||
const max = parseInt(process.env.LOGIN_MAX, 10);
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const ok = await request(app).get(LOGIN_INITIATION);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.reached).toBe(true);
|
||||
}
|
||||
|
||||
const limited = await request(app).get(LOGIN_INITIATION);
|
||||
expect(limited.status).toBe(429);
|
||||
expect(limited.body.message).toMatch(/too many/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback route (IdP-returns-code) is NOT rate-limited', () => {
|
||||
it('never returns 429 even when hammered well past LOGIN_MAX', async () => {
|
||||
const app = buildApp();
|
||||
const hits = parseInt(process.env.LOGIN_MAX, 10) + 5;
|
||||
|
||||
for (let i = 0; i < hits; i++) {
|
||||
const res = await request(app).get(LOGIN_CALLBACK);
|
||||
// The callback may legitimately 200/redirect/fail auth — it must just
|
||||
// never be throttled with 429.
|
||||
expect(res.status).not.toBe(429);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not consume the initiation budget (callback hits never trip the limiter)', async () => {
|
||||
const app = buildApp();
|
||||
|
||||
// Hammer the callback far past the limit first.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app).get(LOGIN_CALLBACK);
|
||||
expect(res.status).not.toBe(429);
|
||||
}
|
||||
|
||||
// The single allowed initiation request must still succeed: callbacks and
|
||||
// initiations do not share a (broken) blanket budget.
|
||||
const ok = await request(app).get(LOGIN_INITIATION);
|
||||
expect(ok.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('router wiring (defense-in-depth structural assertion)', () => {
|
||||
// Even independent of runtime behavior, assert the limiter is present in the
|
||||
// initiation route's middleware stack and absent from every callback stack.
|
||||
const limiterStackByPath = () => {
|
||||
let oauthRouter;
|
||||
let loginLimiter;
|
||||
jest.isolateModules(() => {
|
||||
oauthRouter = require('../oauth');
|
||||
// Same instance the router received (the mocked index re-exports the
|
||||
// real leaf limiter), so reference-equality in the stack is meaningful.
|
||||
({ loginLimiter } = require('~/server/middleware'));
|
||||
});
|
||||
const byPath = {};
|
||||
for (const layer of oauthRouter.stack) {
|
||||
if (!layer.route) continue; // skip router.use(logHeaders) etc.
|
||||
const handles = layer.route.stack.map((s) => s.handle);
|
||||
byPath[layer.route.path] = handles.includes(loginLimiter);
|
||||
}
|
||||
return byPath;
|
||||
};
|
||||
|
||||
it('applies loginLimiter to every initiation route and to NO callback route', () => {
|
||||
const limited = limiterStackByPath();
|
||||
|
||||
const initiationRoutes = [
|
||||
'/google',
|
||||
'/facebook',
|
||||
'/openid',
|
||||
'/github',
|
||||
'/discord',
|
||||
'/apple',
|
||||
'/saml',
|
||||
];
|
||||
const callbackRoutes = [
|
||||
'/google/callback',
|
||||
'/facebook/callback',
|
||||
'/openid/callback',
|
||||
'/github/callback',
|
||||
'/discord/callback',
|
||||
'/apple/callback',
|
||||
'/saml/callback',
|
||||
];
|
||||
|
||||
for (const path of initiationRoutes) {
|
||||
expect(limited[path]).toBe(true);
|
||||
}
|
||||
for (const path of callbackRoutes) {
|
||||
expect(limited[path]).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@ const {
|
||||
messageIpLimiter,
|
||||
configMiddleware,
|
||||
messageUserLimiter,
|
||||
enforceGuestScope,
|
||||
guestMessageLimiter,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
const { saveMessage } = require('~/models');
|
||||
const openai = require('./openai');
|
||||
@@ -33,6 +36,64 @@ router.use('/v1/responses', responses);
|
||||
*/
|
||||
router.use('/v1', openai);
|
||||
|
||||
/**
|
||||
* Guest-capable chat completion router.
|
||||
*
|
||||
* Mounted before the JWT-only routes below so anonymous guests (when
|
||||
* `ALLOW_GUEST_CHAT` is enabled) can reach ONLY the completion endpoints. Auth
|
||||
* here accepts either a guest token or a real JWT; `enforceGuestScope` then pins
|
||||
* guests to the free Zen endpoint/model and strips every other capability, and
|
||||
* `guestMessageLimiter` enforces the per-IP guest quota. Every other agents route
|
||||
* (management, CRUD, files) remains gated by the strict `requireJwtAuth` below,
|
||||
* which rejects guest tokens.
|
||||
*/
|
||||
const RESERVED_CHAT_SUBPATHS = new Set(['stream', 'active', 'status', 'abort']);
|
||||
|
||||
const chatRouter = express.Router();
|
||||
/**
|
||||
* Defer reserved management subpaths (stream/active/status/abort) to the
|
||||
* JWT-only handlers defined on the parent router. Without this guard the
|
||||
* completion route's `POST /:endpoint` would shadow `POST /chat/abort`.
|
||||
*/
|
||||
chatRouter.use((req, res, next) => {
|
||||
const subpath = req.path.split('/').filter(Boolean)[0];
|
||||
if (RESERVED_CHAT_SUBPATHS.has(subpath)) {
|
||||
return next('router');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(checkBan);
|
||||
chatRouter.use(uaParser);
|
||||
chatRouter.use(configMiddleware);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||
chatRouter.use(messageIpLimiter);
|
||||
}
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_USER)) {
|
||||
chatRouter.use(messageUserLimiter);
|
||||
}
|
||||
|
||||
chatRouter.use('/', chat);
|
||||
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
/**
|
||||
* Guest-safe active-jobs poll. Guests never own a generation job, so this
|
||||
* returns an empty set without a DB/user lookup. Registered BEFORE the strict
|
||||
* JWT guard below so the composer's bootstrap poll doesn't 401-loop for guests;
|
||||
* every other agents route stays JWT-only and rejects guest tokens.
|
||||
*/
|
||||
router.get('/chat/active', requireGuestOrJwtAuth, async (req, res, next) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.json({ activeJobIds: [] });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
router.use(uaParser);
|
||||
@@ -269,18 +330,4 @@ router.post('/chat/abort', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Job not found', streamId: jobStreamId });
|
||||
});
|
||||
|
||||
const chatRouter = express.Router();
|
||||
chatRouter.use(configMiddleware);
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||
chatRouter.use(messageIpLimiter);
|
||||
}
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_USER)) {
|
||||
chatRouter.use(messageUserLimiter);
|
||||
}
|
||||
|
||||
chatRouter.use('/', chat);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { guestTokenController } = require('~/server/controllers/auth/GuestController');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const middleware = require('~/server/middleware');
|
||||
const { Balance } = require('~/db/models');
|
||||
@@ -41,6 +42,7 @@ router.post(
|
||||
loginController,
|
||||
);
|
||||
router.post('/refresh', refreshController);
|
||||
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
|
||||
router.post(
|
||||
'/register',
|
||||
middleware.registerLimiter,
|
||||
|
||||
@@ -5,6 +5,7 @@ const { isEnabled, getBalanceConfig } = require('@hanzochat/api');
|
||||
const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
|
||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
@@ -59,6 +60,8 @@ router.get('/', async function (req, res) {
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
|
||||
const guestConfig = getGuestConfig();
|
||||
|
||||
// Strip server-internal fields from balance config before sending to client.
|
||||
// commerce.endpoint leaks K8s service URLs; commerce.token is a bearer secret.
|
||||
const clientBalanceConfig = balanceConfig
|
||||
@@ -121,6 +124,8 @@ router.get('/', async function (req, res) {
|
||||
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
|
||||
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
|
||||
openidReuseTokens,
|
||||
allowGuestChat: guestConfig.enabled,
|
||||
guestMessageMax: guestConfig.enabled ? guestConfig.messageMax : undefined,
|
||||
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
||||
: 0,
|
||||
|
||||
@@ -15,6 +15,7 @@ const { forkConversation, duplicateConversation } = require('~/server/utils/impo
|
||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
@@ -25,9 +26,17 @@ const assistantClients = {
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
/**
|
||||
* Guest-accessible conversation list. Guests have no persisted conversations, so
|
||||
* this returns an empty, well-formed page — enough for the UI to mount the chat
|
||||
* history pane without a DB lookup. Registered with guest-aware auth BEFORE the
|
||||
* strict JWT guard below; every mutation/read-by-id route stays JWT-only.
|
||||
*/
|
||||
router.get('/', requireGuestOrJwtAuth, async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json({ conversations: [], nextCursor: null });
|
||||
}
|
||||
const limit = parseInt(req.query.limit, 10) || 25;
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
@@ -57,6 +66,9 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Every route below reads or writes persisted user data — JWT-only, no guests. */
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
router.get('/:conversationId', async (req, res) => {
|
||||
const { conversationId } = req.params;
|
||||
const convo = await getConvo(req.user.id, conversationId);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireJwtAuth, endpointController);
|
||||
router.get('/', requireGuestOrJwtAuth, endpointController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
const { requireJwtAuth } = require('~/server/middleware/');
|
||||
const { requireGuestOrJwtAuth } = require('~/server/middleware/');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireJwtAuth, modelController);
|
||||
router.get('/', requireGuestOrJwtAuth, modelController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
|
||||
// file deepcode ignore NoRateLimitingForLogin: `loginLimiter` is applied per-route to the IdP-initiation GETs (not the machine-driven /callback routes, which would break the OIDC code exchange)
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { randomState } = require('openid-client');
|
||||
@@ -23,7 +23,6 @@ const domains = {
|
||||
};
|
||||
|
||||
router.use(logHeaders);
|
||||
router.use(loginLimiter);
|
||||
|
||||
const oauthHandler = createOAuthHandler();
|
||||
|
||||
@@ -42,6 +41,7 @@ router.get('/error', (req, res) => {
|
||||
*/
|
||||
router.get(
|
||||
'/google',
|
||||
loginLimiter,
|
||||
passport.authenticate('google', {
|
||||
scope: ['openid', 'profile', 'email'],
|
||||
session: false,
|
||||
@@ -66,6 +66,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/facebook',
|
||||
loginLimiter,
|
||||
passport.authenticate('facebook', {
|
||||
scope: ['public_profile'],
|
||||
profileFields: ['id', 'email', 'name'],
|
||||
@@ -90,7 +91,7 @@ router.get(
|
||||
/**
|
||||
* OpenID Routes
|
||||
*/
|
||||
router.get('/openid', (req, res, next) => {
|
||||
router.get('/openid', loginLimiter, (req, res, next) => {
|
||||
return passport.authenticate('openid', {
|
||||
session: false,
|
||||
state: randomState(),
|
||||
@@ -114,6 +115,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/github',
|
||||
loginLimiter,
|
||||
passport.authenticate('github', {
|
||||
scope: ['user:email', 'read:user'],
|
||||
session: false,
|
||||
@@ -138,6 +140,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/discord',
|
||||
loginLimiter,
|
||||
passport.authenticate('discord', {
|
||||
scope: ['identify', 'email'],
|
||||
session: false,
|
||||
@@ -162,6 +165,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/apple',
|
||||
loginLimiter,
|
||||
passport.authenticate('apple', {
|
||||
session: false,
|
||||
}),
|
||||
@@ -184,6 +188,7 @@ router.post(
|
||||
*/
|
||||
router.get(
|
||||
'/saml',
|
||||
loginLimiter,
|
||||
passport.authenticate('saml', {
|
||||
session: false,
|
||||
}),
|
||||
|
||||
@@ -3,11 +3,12 @@ const {
|
||||
updateFavoritesController,
|
||||
getFavoritesController,
|
||||
} = require('~/server/controllers/FavoritesController');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/favorites', requireJwtAuth, getFavoritesController);
|
||||
// Read-only favorites are guest-safe (empty list); writes stay JWT-only.
|
||||
router.get('/favorites', requireGuestOrJwtAuth, getFavoritesController);
|
||||
router.post('/favorites', requireJwtAuth, updateFavoritesController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
configMiddleware,
|
||||
canDeleteAccount,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const settings = require('./settings');
|
||||
@@ -20,7 +21,7 @@ const settings = require('./settings');
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/settings', settings);
|
||||
router.get('/', requireJwtAuth, getUserController);
|
||||
router.get('/', requireGuestOrJwtAuth, getUserController);
|
||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||
|
||||
@@ -43,14 +43,31 @@ class CommerceClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check user's balance. Returns cached result if fresh, triggers async
|
||||
* refresh if stale, synchronous fetch on cache miss. Fails open on error.
|
||||
* The Commerce namespace (X-Hanzo-Org) for a billing subject. The subject is
|
||||
* object.BillingSubject(owner, name): "owner/name" (per-user) or "owner"
|
||||
* (pooled) — so the namespace is always the part before the first "/", or the
|
||||
* whole subject. Deriving it here keeps every read/write scoped to the right
|
||||
* tenant without callers having to thread the org separately.
|
||||
*
|
||||
* @param {string} userId - Commerce user ID (e.g. "hanzo/alice")
|
||||
* @param {string} subject
|
||||
* @returns {string}
|
||||
*/
|
||||
_namespaceOf(subject) {
|
||||
const s = (subject ?? '').toString();
|
||||
const i = s.indexOf('/');
|
||||
return i > 0 ? s.slice(0, i) : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a billing subject's balance. Returns cached result if fresh, triggers
|
||||
* async refresh if stale, synchronous fetch on cache miss. Fails CLOSED (the
|
||||
* cold-miss fetch throws) so the caller blocks rather than bleeding.
|
||||
*
|
||||
* @param {string} subject - Commerce billing subject (e.g. "hanzo/alice@gmail.com")
|
||||
* @returns {Promise<{sufficient: boolean, available: number}>}
|
||||
*/
|
||||
async checkBalance(userId) {
|
||||
const cached = this._balanceCache.get(userId);
|
||||
async checkBalance(subject) {
|
||||
const cached = this._balanceCache.get(subject);
|
||||
const now = Date.now();
|
||||
|
||||
if (cached) {
|
||||
@@ -61,13 +78,13 @@ class CommerceClient {
|
||||
// Stale: serve cached, refresh async
|
||||
if (!cached.refreshing) {
|
||||
cached.refreshing = true;
|
||||
this._fetchBalance(userId).catch(() => {});
|
||||
this._fetchBalance(subject).catch(() => {});
|
||||
}
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Cache miss: synchronous fetch
|
||||
return this._fetchBalance(userId);
|
||||
return this._fetchBalance(subject);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,30 +163,31 @@ class CommerceClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a trial credit grant for a new user.
|
||||
* Ensure a subject has the one-time $5 starter credit, idempotently.
|
||||
*
|
||||
* @param {string} userId - Commerce user ID
|
||||
* @param {number} amountCents - Grant amount in cents (e.g. 500 = $5)
|
||||
* @param {number} expiryDays - Days until expiry
|
||||
* @param {string[]} [eligibility] - Meter IDs (empty = all meters)
|
||||
* @returns {Promise<Object|null>} Grant object or null on failure
|
||||
* 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 createTrialGrant(userId, amountCents, expiryDays, eligibility = []) {
|
||||
async grantStarter(subject) {
|
||||
try {
|
||||
const expiresIn = `${expiryDays * 24}h`;
|
||||
const resp = await this._request('POST', '/v1/billing/credit-grants', {
|
||||
userId,
|
||||
name: 'Trial Credit',
|
||||
amountCents,
|
||||
currency: 'usd',
|
||||
expiresIn,
|
||||
priority: 100, // Trial burns before purchased (200)
|
||||
eligibility,
|
||||
tags: 'trial',
|
||||
});
|
||||
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 create trial grant', err);
|
||||
logger.error('[CommerceClient] Failed to ensure starter credit', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -185,6 +203,8 @@ class CommerceClient {
|
||||
const resp = await this._request(
|
||||
'GET',
|
||||
`/v1/billing/credit-balance/breakdown?userId=${encodeURIComponent(userId)}`,
|
||||
undefined,
|
||||
this._namespaceOf(userId),
|
||||
);
|
||||
const breakdown = resp.breakdown || {};
|
||||
return {
|
||||
@@ -200,27 +220,30 @@ class CommerceClient {
|
||||
|
||||
// ── Internal methods ──
|
||||
|
||||
async _fetchBalance(userId) {
|
||||
try {
|
||||
const resp = await this._request(
|
||||
'GET',
|
||||
`/v1/billing/balance?user=${encodeURIComponent(userId)}¤cy=usd`,
|
||||
);
|
||||
const data = {
|
||||
sufficient: (resp.available || 0) > 0,
|
||||
available: resp.available || 0,
|
||||
};
|
||||
this._balanceCache.set(userId, {
|
||||
data,
|
||||
fetchedAt: Date.now(),
|
||||
refreshing: false,
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
logger.warn('[CommerceClient] Balance check failed, failing open', { userId, error: err.message });
|
||||
// Fail open
|
||||
return { sufficient: true, available: 0 };
|
||||
}
|
||||
async _fetchBalance(subject) {
|
||||
// FAIL CLOSED: this is the money gate. On error we THROW so the caller blocks
|
||||
// the request rather than letting unfunded/unknown users spend. The cache
|
||||
// (serve-stale on refresh) smooths transient blips for already-known users;
|
||||
// only a cold miss + error propagates. `subject` is the billing account
|
||||
// (object.BillingSubject) used as `?user=`; the namespace (X-Hanzo-Org) is
|
||||
// its org prefix — matching the gateway's keying so chat reads the SAME
|
||||
// account the gateway debits.
|
||||
const resp = await this._request(
|
||||
'GET',
|
||||
`/v1/billing/balance?user=${encodeURIComponent(subject)}¤cy=usd`,
|
||||
undefined,
|
||||
this._namespaceOf(subject),
|
||||
);
|
||||
const data = {
|
||||
sufficient: (resp.available || 0) > 0,
|
||||
available: resp.available || 0,
|
||||
};
|
||||
this._balanceCache.set(subject, {
|
||||
data,
|
||||
fetchedAt: Date.now(),
|
||||
refreshing: false,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async _fetchTier(userId, tierName) {
|
||||
@@ -229,7 +252,7 @@ class CommerceClient {
|
||||
if (tierName) {
|
||||
url += `&tier=${encodeURIComponent(tierName)}`;
|
||||
}
|
||||
const resp = await this._request('GET', url);
|
||||
const resp = await this._request('GET', url, undefined, this._namespaceOf(userId));
|
||||
const tier = resp.tier || null;
|
||||
if (tier) {
|
||||
this._tierCache.set(userId, {
|
||||
@@ -275,12 +298,17 @@ class CommerceClient {
|
||||
* @param {Object} [body]
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async _request(method, path, body) {
|
||||
async _request(method, path, body, orgId) {
|
||||
const url = `${this.endpoint}${path}`;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
// Scope the service-token call to the tenant's commerce namespace so reads/
|
||||
// writes are correctly per-org (not the service token's default namespace).
|
||||
if (orgId) {
|
||||
headers['X-Hanzo-Org'] = orgId;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
@@ -7,7 +7,10 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
const agentPromise = loadAgent({
|
||||
req,
|
||||
spec,
|
||||
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
|
||||
// No agent selected on the agents endpoint → ad-hoc (ephemeral) chat, so the
|
||||
// user gets a reply without first creating/selecting an agent. Mirrors the
|
||||
// access middleware, which treats a missing id as ephemeral.
|
||||
agent_id: isAgentsEndpoint(endpoint) && agent_id ? agent_id : Constants.EPHEMERAL_AGENT_ID,
|
||||
endpoint,
|
||||
model_parameters,
|
||||
}).catch((error) => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Regression: when no agent is created/selected, the agents endpoint must resolve
|
||||
* to an ephemeral (ad-hoc, plain-model) agent so the user gets a reply — instead
|
||||
* of loadAgent returning null because agent_id was missing.
|
||||
*
|
||||
* Hermetic: mocks data-schemas (no winston) and ~/models/Agent (no MongoDB).
|
||||
*/
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn() } }));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
|
||||
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
|
||||
removeNullishValues: (obj) =>
|
||||
Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null)),
|
||||
}));
|
||||
|
||||
const mockLoadAgent = jest.fn(() => Promise.resolve({ id: 'ephemeral', model: 'zen3-nano' }));
|
||||
jest.mock('~/models/Agent', () => ({ loadAgent: mockLoadAgent }));
|
||||
|
||||
const { buildOptions } = require('./build');
|
||||
|
||||
const EPHEMERAL = 'ephemeral';
|
||||
|
||||
describe('agents buildOptions — missing agent_id resolves to ephemeral', () => {
|
||||
beforeEach(() => mockLoadAgent.mockClear());
|
||||
|
||||
test('no agent_id on agents endpoint → loadAgent called with EPHEMERAL_AGENT_ID', async () => {
|
||||
const req = { body: {}, user: { id: 'u1' } };
|
||||
// parsedBody as built from "My Agents" with no agent selected (no agent_id).
|
||||
const options = buildOptions(req, 'agents', { model: 'zen3-nano' });
|
||||
await options.agent;
|
||||
|
||||
expect(mockLoadAgent).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe(EPHEMERAL);
|
||||
});
|
||||
|
||||
test('real agent_id on agents endpoint is passed through unchanged', async () => {
|
||||
const req = { body: {}, user: { id: 'u1' } };
|
||||
const options = buildOptions(req, 'agents', { model: 'zen3-nano', agent_id: 'agent_real_42' });
|
||||
await options.agent;
|
||||
|
||||
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe('agent_real_42');
|
||||
});
|
||||
|
||||
test('non-agents endpoint always resolves to ephemeral', async () => {
|
||||
const req = { body: {}, user: { id: 'u1' } };
|
||||
const options = buildOptions(req, 'openAI', { model: 'zen3-nano', agent_id: 'agent_real_42' });
|
||||
await options.agent;
|
||||
|
||||
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe(EPHEMERAL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
const { isEnabled } = require('@hanzochat/api');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
|
||||
const GUEST_ROLE = 'GUEST';
|
||||
const GUEST_NAME = 'Guest';
|
||||
const DEFAULT_GUEST_MESSAGE_MAX = 3;
|
||||
const DEFAULT_GUEST_TOKEN_EXPIRY_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_GUEST_ENDPOINT = 'Hanzo';
|
||||
const DEFAULT_GUEST_MODEL = 'zen3-nano';
|
||||
|
||||
/**
|
||||
* Resolves the guest-chat configuration from the environment.
|
||||
* Guest chat is disabled unless `ALLOW_GUEST_CHAT` is explicitly enabled.
|
||||
*
|
||||
* @returns {{
|
||||
* enabled: boolean,
|
||||
* messageMax: number,
|
||||
* tokenExpiryMs: number,
|
||||
* endpoint: string,
|
||||
* model: string,
|
||||
* }}
|
||||
*/
|
||||
const getGuestConfig = () => {
|
||||
const messageMax = Number.parseInt(process.env.GUEST_MESSAGE_MAX, 10);
|
||||
const tokenExpiryMs = Number.parseInt(process.env.GUEST_TOKEN_EXPIRY, 10);
|
||||
|
||||
return {
|
||||
enabled: isEnabled(process.env.ALLOW_GUEST_CHAT),
|
||||
messageMax:
|
||||
Number.isFinite(messageMax) && messageMax > 0 ? messageMax : DEFAULT_GUEST_MESSAGE_MAX,
|
||||
tokenExpiryMs:
|
||||
Number.isFinite(tokenExpiryMs) && tokenExpiryMs > 0
|
||||
? tokenExpiryMs
|
||||
: DEFAULT_GUEST_TOKEN_EXPIRY_MS,
|
||||
endpoint: process.env.GUEST_ENDPOINT || DEFAULT_GUEST_ENDPOINT,
|
||||
model: process.env.GUEST_MODEL || DEFAULT_GUEST_MODEL,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the ephemeral guest principal for a verified guest token.
|
||||
*
|
||||
* This is the SINGLE source of truth for the guest `req.user` shape. It is a
|
||||
* plain object — never a DB document — so no route ever reads or writes real
|
||||
* user data on behalf of a guest. No email, no DB id.
|
||||
*
|
||||
* @param {string} id - The synthetic guest id from the token (`guest_<uuid>`).
|
||||
* @returns {{ id: string, role: string, name: string, guest: true }}
|
||||
*/
|
||||
const buildGuestPrincipal = (id) => ({
|
||||
id,
|
||||
role: GUEST_ROLE,
|
||||
name: GUEST_NAME,
|
||||
guest: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped `/api/user` response: the ephemeral principal only.
|
||||
* Mirrors the safe-field shape the client expects (no password/totp/email/db id).
|
||||
*
|
||||
* @param {{ id: string }} principal
|
||||
* @returns {object}
|
||||
*/
|
||||
const buildGuestUser = (principal) => ({
|
||||
id: principal.id,
|
||||
username: GUEST_NAME,
|
||||
name: GUEST_NAME,
|
||||
role: GUEST_ROLE,
|
||||
provider: 'guest',
|
||||
emailVerified: false,
|
||||
guest: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped endpoints config: ONLY the configured guest endpoint,
|
||||
* with no builder/agent/file/preset capabilities. Everything else is omitted so
|
||||
* the client cannot surface any other endpoint to a guest.
|
||||
*
|
||||
* @returns {Record<string, object>}
|
||||
*/
|
||||
const buildGuestEndpointsConfig = () => {
|
||||
const { endpoint } = getGuestConfig();
|
||||
return {
|
||||
[endpoint]: {
|
||||
type: EModelEndpoint.custom,
|
||||
userProvide: false,
|
||||
modelDisplayLabel: endpoint,
|
||||
order: 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped models config: the single configured guest model under
|
||||
* the guest endpoint. The client pins the composer to exactly this one model.
|
||||
*
|
||||
* @returns {Record<string, string[]>}
|
||||
*/
|
||||
const buildGuestModelsConfig = () => {
|
||||
const { endpoint, model } = getGuestConfig();
|
||||
return {
|
||||
[endpoint]: [model],
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getGuestConfig,
|
||||
buildGuestPrincipal,
|
||||
buildGuestUser,
|
||||
buildGuestEndpointsConfig,
|
||||
buildGuestModelsConfig,
|
||||
GUEST_ROLE,
|
||||
GUEST_NAME,
|
||||
DEFAULT_GUEST_MESSAGE_MAX,
|
||||
DEFAULT_GUEST_TOKEN_EXPIRY_MS,
|
||||
DEFAULT_GUEST_ENDPOINT,
|
||||
DEFAULT_GUEST_MODEL,
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
}));
|
||||
|
||||
const {
|
||||
getGuestConfig,
|
||||
buildGuestPrincipal,
|
||||
buildGuestUser,
|
||||
buildGuestEndpointsConfig,
|
||||
buildGuestModelsConfig,
|
||||
GUEST_ROLE,
|
||||
GUEST_NAME,
|
||||
DEFAULT_GUEST_MESSAGE_MAX,
|
||||
DEFAULT_GUEST_ENDPOINT,
|
||||
DEFAULT_GUEST_MODEL,
|
||||
} = require('./guestConfig');
|
||||
|
||||
describe('getGuestConfig', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
delete process.env.GUEST_MESSAGE_MAX;
|
||||
delete process.env.GUEST_TOKEN_EXPIRY;
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('is disabled by default (fail closed)', () => {
|
||||
expect(getGuestConfig().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled only when ALLOW_GUEST_CHAT is truthy', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
expect(getGuestConfig().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('uses defaults when env is unset', () => {
|
||||
const config = getGuestConfig();
|
||||
expect(config.messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
expect(config.endpoint).toBe(DEFAULT_GUEST_ENDPOINT);
|
||||
expect(config.model).toBe(DEFAULT_GUEST_MODEL);
|
||||
});
|
||||
|
||||
it('honors GUEST_MESSAGE_MAX', () => {
|
||||
process.env.GUEST_MESSAGE_MAX = '7';
|
||||
expect(getGuestConfig().messageMax).toBe(7);
|
||||
});
|
||||
|
||||
it('falls back to default for invalid or non-positive GUEST_MESSAGE_MAX', () => {
|
||||
process.env.GUEST_MESSAGE_MAX = 'abc';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
process.env.GUEST_MESSAGE_MAX = '0';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
process.env.GUEST_MESSAGE_MAX = '-5';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
});
|
||||
|
||||
it('honors the configurable guest endpoint and model', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen4-mini';
|
||||
const config = getGuestConfig();
|
||||
expect(config.endpoint).toBe('Hanzo');
|
||||
expect(config.model).toBe('zen4-mini');
|
||||
});
|
||||
|
||||
it('exposes GUEST_ROLE constant', () => {
|
||||
expect(GUEST_ROLE).toBe('GUEST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guest principal + scoped-config builders', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
it('buildGuestPrincipal returns an ephemeral GUEST principal with no DB id/email', () => {
|
||||
const principal = buildGuestPrincipal('guest_abc');
|
||||
expect(principal).toEqual({
|
||||
id: 'guest_abc',
|
||||
role: GUEST_ROLE,
|
||||
name: GUEST_NAME,
|
||||
guest: true,
|
||||
});
|
||||
expect(principal).not.toHaveProperty('email');
|
||||
expect(principal).not.toHaveProperty('_id');
|
||||
});
|
||||
|
||||
it('buildGuestUser exposes only safe, guest-scoped fields (no email)', () => {
|
||||
const user = buildGuestUser(buildGuestPrincipal('guest_abc'));
|
||||
expect(user.id).toBe('guest_abc');
|
||||
expect(user.role).toBe(GUEST_ROLE);
|
||||
expect(user.name).toBe(GUEST_NAME);
|
||||
expect(user.guest).toBe(true);
|
||||
expect(user).not.toHaveProperty('email');
|
||||
expect(user).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('buildGuestEndpointsConfig exposes ONLY the configured guest endpoint', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
const config = buildGuestEndpointsConfig();
|
||||
expect(Object.keys(config)).toEqual(['Hanzo']);
|
||||
expect(config.Hanzo).toMatchObject({ type: 'custom', userProvide: false });
|
||||
});
|
||||
|
||||
it('buildGuestModelsConfig exposes ONLY the single configured guest model', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
expect(buildGuestModelsConfig()).toEqual({ Hanzo: ['zen5-mini'] });
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,16 @@ const jwtLogin = () =>
|
||||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
/**
|
||||
* Guest tokens carry `guest: true` and a synthetic `guest_<uuid>` id that
|
||||
* is NOT a Mongo ObjectId. Reject them cleanly here so the strict `jwt`
|
||||
* strategy fails with a 401 instead of letting `getUserById` throw a
|
||||
* Mongoose CastError (→ 500). Guests reach their scoped routes through
|
||||
* `requireGuestOrJwtAuth`, never through this strategy. Fail closed.
|
||||
*/
|
||||
if (payload?.guest === true) {
|
||||
return done(null, false);
|
||||
}
|
||||
const user = await getUserById(payload?.id, '-password -__v -totpSecret -backupCodes');
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
|
||||
// --- Capture the verify callback passed to JwtStrategy ---
|
||||
let capturedVerifyCallback;
|
||||
jest.mock('passport-jwt', () => ({
|
||||
Strategy: jest.fn((_opts, verifyCallback) => {
|
||||
capturedVerifyCallback = verifyCallback;
|
||||
return { name: 'jwt' };
|
||||
}),
|
||||
ExtractJwt: {
|
||||
fromAuthHeaderAsBearerToken: jest.fn(() => 'mock-extractor'),
|
||||
},
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
jest.mock('~/models', () => ({
|
||||
getUserById: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getUserById, updateUser } = require('~/models');
|
||||
const jwtLogin = require('./jwtStrategy');
|
||||
|
||||
// Helper: invoke the captured verify callback as a promise
|
||||
const invokeVerify = (payload) =>
|
||||
new Promise((resolve, reject) => {
|
||||
capturedVerifyCallback(payload, (err, user, info) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ user, info });
|
||||
});
|
||||
});
|
||||
|
||||
describe('jwtStrategy verify callback', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Instantiate to capture the verify callback.
|
||||
jwtLogin();
|
||||
});
|
||||
|
||||
it('rejects a guest token cleanly without touching the DB (no CastError → 401)', async () => {
|
||||
const { user } = await invokeVerify({ id: 'guest_abc-123', guest: true, role: 'GUEST' });
|
||||
// done(null, false) → passport responds 401, never 500.
|
||||
expect(user).toBe(false);
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never calls getUserById for a guest id, even if it would CastError', async () => {
|
||||
getUserById.mockRejectedValue(new Error('CastError: not an ObjectId'));
|
||||
const { user } = await invokeVerify({ id: 'guest_xyz', guest: true });
|
||||
expect(user).toBe(false);
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves a real user for a non-guest token', async () => {
|
||||
getUserById.mockResolvedValue({ _id: { toString: () => 'real-id' }, role: SystemRoles.USER });
|
||||
const { user } = await invokeVerify({ id: 'real-id' });
|
||||
expect(getUserById).toHaveBeenCalledWith('real-id', expect.any(String));
|
||||
expect(user).toMatchObject({ id: 'real-id', role: SystemRoles.USER });
|
||||
});
|
||||
|
||||
it('returns false when a non-guest user is not found', async () => {
|
||||
getUserById.mockResolvedValue(null);
|
||||
const { user } = await invokeVerify({ id: 'missing-id' });
|
||||
expect(user).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates a DB error for a non-guest token (done(err, false))', async () => {
|
||||
getUserById.mockRejectedValue(new Error('db down'));
|
||||
await expect(invokeVerify({ id: 'real-id' })).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
it('does not treat guest:false as a guest token', async () => {
|
||||
getUserById.mockResolvedValue({ _id: { toString: () => 'real-id' }, role: SystemRoles.USER });
|
||||
await invokeVerify({ id: 'real-id', guest: false });
|
||||
expect(getUserById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('backfills a missing role for a real user', async () => {
|
||||
getUserById.mockResolvedValue({ _id: { toString: () => 'real-id' } });
|
||||
const { user } = await invokeVerify({ id: 'real-id' });
|
||||
expect(updateUser).toHaveBeenCalledWith('real-id', { role: SystemRoles.USER });
|
||||
expect(user.role).toBe(SystemRoles.USER);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="Hanzo Chat - AI chat platform with support for multiple AI models" />
|
||||
<title>Hanzo Chat</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/logo.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" href="assets/apple-touch-icon-180x180.png" />
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@codesandbox/sandpack-react": "^2.19.10",
|
||||
"@dicebear/collection": "^9.2.2",
|
||||
"@dicebear/core": "^9.2.2",
|
||||
"@hanzo/iam": "^0.4.0",
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@librechat/client": "workspace:*",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 632 B |
|
Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 829 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -1 +1,8 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#f2f2f2"><path d="M3 2 H7 V10 H17 V2 H21 V22 H17 V14 H7 V22 H3 Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 67 67" role="img" aria-label="Hanzo">
|
||||
<style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 140 B After Width: | Height: | Size: 443 B |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -4,7 +4,8 @@ async function postBuild() {
|
||||
try {
|
||||
await fs.copy('public/assets', 'dist/assets');
|
||||
await fs.copy('public/robots.txt', 'dist/robots.txt');
|
||||
console.log('✅ PWA icons and robots.txt copied successfully. Glob pattern warnings resolved.');
|
||||
await fs.copy('public/favicon.ico', 'dist/favicon.ico');
|
||||
console.log('✅ PWA icons, favicon.ico, and robots.txt copied successfully. Glob pattern warnings resolved.');
|
||||
} catch (err) {
|
||||
console.error('❌ Error copying files:', err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect } from 'react';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import * as RadixToast from '@radix-ui/react-toast';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
||||
@@ -14,6 +13,13 @@ import { initializeFontSize } from '~/store/fontSize';
|
||||
import { LiveAnnouncer } from '~/a11y';
|
||||
import { router } from './routes';
|
||||
|
||||
// Dev-only: lazily loaded so the devtools bundle never ships in production builds.
|
||||
const ReactQueryDevtools = import.meta.env.DEV
|
||||
? lazy(() =>
|
||||
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })),
|
||||
)
|
||||
: null;
|
||||
|
||||
const App = () => {
|
||||
const { setError } = useApiErrorBoundary();
|
||||
|
||||
@@ -63,7 +69,11 @@ const App = () => {
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<WakeLockManager />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
{import.meta.env.DEV && ReactQueryDevtools && (
|
||||
<Suspense fallback={null}>
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
</Suspense>
|
||||
)}
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
</DndProvider>
|
||||
|
||||
@@ -453,6 +453,7 @@ export type TAuthContext = {
|
||||
user: t.TUser | undefined;
|
||||
token: string | undefined;
|
||||
isAuthenticated: boolean;
|
||||
isGuest: boolean;
|
||||
error: string | undefined;
|
||||
login: (data: t.TLoginUser) => void;
|
||||
logout: (redirect?: string) => void;
|
||||
|
||||
@@ -57,7 +57,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Set page title
|
||||
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
|
||||
useDocumentTitle(`${localize('com_agents_marketplace')} | Hanzo Chat`);
|
||||
|
||||
// Ensure right sidebar is always visible in marketplace
|
||||
useEffect(() => {
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('AgentCard', () => {
|
||||
expect(avatarImg).toHaveAttribute('src', '/string-avatar.png');
|
||||
});
|
||||
|
||||
it('displays Feather icon fallback when no avatar is provided', () => {
|
||||
it('displays the Hanzo mark fallback when no avatar is provided', () => {
|
||||
const agentWithoutAvatar = {
|
||||
...mockAgent,
|
||||
avatar: undefined,
|
||||
@@ -209,9 +209,9 @@ describe('AgentCard', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Check for Feather icon presence by looking for the svg with lucide-feather class
|
||||
const featherIcon = document.querySelector('.lucide-feather');
|
||||
expect(featherIcon).toBeInTheDocument();
|
||||
// The fallback renders the Hanzo H-mark (aria-label="Hanzo")
|
||||
const hanzoMark = document.querySelector('[aria-label="Hanzo"]');
|
||||
expect(hanzoMark).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('card is clickable and has dialog trigger', () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { OGDialog, OGDialogTemplate, Button } from '@librechat/client';
|
||||
import { getHanzoIamSdk, isStaticIamMode } from '~/utils/iam';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Login gate shown when an anonymous guest exhausts the free message quota.
|
||||
* Reuses the existing OpenID / Hanzo IAM login flow — it does not implement a
|
||||
* new login. Listens for the `guestLimitReached` window event dispatched by the
|
||||
* chat submission path on a `402 { type: 'GUEST_LIMIT' }` response.
|
||||
*/
|
||||
export default function GuestLimitDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const localize = useLocalize();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setOpen(true);
|
||||
window.addEventListener('guestLimitReached', handler);
|
||||
return () => window.removeEventListener('guestLimitReached', handler);
|
||||
}, []);
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
const iamSdk = getHanzoIamSdk();
|
||||
if (isStaticIamMode() && iamSdk) {
|
||||
iamSdk.signinRedirect();
|
||||
return;
|
||||
}
|
||||
if (startupConfig?.openidLoginEnabled && startupConfig?.serverDomain) {
|
||||
window.location.href = `${startupConfig.serverDomain}/oauth/openid`;
|
||||
return;
|
||||
}
|
||||
window.location.href = '/login';
|
||||
}, [startupConfig]);
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_auth_guest_limit_title')}
|
||||
className="max-w-md"
|
||||
main={
|
||||
<div className="text-sm text-text-secondary">
|
||||
{localize('com_auth_guest_limit_message')}
|
||||
</div>
|
||||
}
|
||||
buttons={
|
||||
<Button variant="submit" onClick={handleLogin}>
|
||||
{localize('com_auth_guest_limit_login')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
@@ -34,8 +34,8 @@ export default function OAuthCallback() {
|
||||
try {
|
||||
const tokens = await iamSdk.handleCallback();
|
||||
|
||||
if (tokens.access_token) {
|
||||
setTokenHeader(tokens.access_token);
|
||||
if (tokens.accessToken) {
|
||||
setTokenHeader(tokens.accessToken);
|
||||
}
|
||||
|
||||
navigate('/c/new', { replace: true });
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function MCPConfigDialog({
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onFormSubmit),
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { Feather } from 'lucide-react';
|
||||
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
|
||||
import { EModelEndpoint, isAssistantsEndpoint, alternateName } from 'librechat-data-provider';
|
||||
import {
|
||||
Plugin,
|
||||
@@ -110,7 +110,7 @@ const MessageEndpointIcon: React.FC<IconProps> = (props) => {
|
||||
) : (
|
||||
<div className="h-6 w-6">
|
||||
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<Feather className="h-2/3 w-2/3 text-gray-400" aria-hidden="true" />
|
||||
<HanzoLogoIcon className="h-2/3 w-2/3 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Feather } from 'lucide-react';
|
||||
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
|
||||
import { EModelEndpoint, alternateName } from 'librechat-data-provider';
|
||||
import {
|
||||
Sparkles,
|
||||
@@ -43,7 +43,7 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
|
||||
[EModelEndpoint.assistants]: { icon: <Sparkles className="icon-sm" />, name: 'Assistant' },
|
||||
[EModelEndpoint.azureAssistants]: { icon: <Sparkles className="icon-sm" />, name: 'Assistant' },
|
||||
[EModelEndpoint.agents]: {
|
||||
icon: <Feather className="icon-sm" aria-hidden="true" />,
|
||||
icon: <HanzoLogoIcon className="icon-sm" aria-hidden="true" />,
|
||||
name: props.modelLabel ?? alternateName[EModelEndpoint.agents],
|
||||
},
|
||||
[EModelEndpoint.bedrock]: {
|
||||
|
||||
@@ -83,7 +83,7 @@ const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) =>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: submitPreset,
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 dark:hover:bg-green-600 text-white',
|
||||
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
selectText: localize('com_ui_save'),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function FileListItem2({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="ml-2 mt-1 content-center rounded-full bg-[#f2f8ec] px-2 text-xs text-[#91c561]"
|
||||
className="ml-2 mt-1 content-center rounded-full bg-surface-secondary px-2 text-xs text-text-secondary"
|
||||
>
|
||||
{vectorStore.name}
|
||||
</span>
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function FilePreview() {
|
||||
Status
|
||||
</span>
|
||||
<div className="w-1/2 sm:w-3/4 md:w-3/5">
|
||||
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-[#f2f8ec] p-1 text-[#91c561]">
|
||||
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-surface-secondary p-1 text-text-secondary">
|
||||
<CheckMark className="m-0 p-0" />
|
||||
<div>{file.object}</div>
|
||||
</span>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const fileTableColumns: ColumnDef<TFile>[] = [
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span key={index} className="ml-2 mt-2 rounded-full bg-[#f2f8ec] px-2 text-[#91c561]">
|
||||
<span key={index} className="ml-2 mt-2 rounded-full bg-surface-secondary px-2 text-text-secondary">
|
||||
{vectorStore.name}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function VectorStorePreview() {
|
||||
</span>
|
||||
<div className="w-1/2 md:w-3/5">
|
||||
<p className="text-gray-500">
|
||||
<span className="text-[#91c561]">0 KB hours</span>
|
||||
<span className="text-text-secondary">0 KB hours</span>
|
||||
Free until end of 2024
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,9 @@ const colors = {
|
||||
mutedFg: 'hsla(0, 0%, 70%, 0.85)',
|
||||
border: 'hsla(0, 0%, 40%, 0.2)',
|
||||
fg: 'hsl(0, 0%, 96%)',
|
||||
brand: '#fd4444',
|
||||
brandDim: 'rgba(253, 68, 68, 0.10)',
|
||||
brandGlow: 'rgba(253, 68, 68, 0.04)',
|
||||
brand: '#ffffff',
|
||||
brandDim: 'rgba(255, 255, 255, 0.10)',
|
||||
brandGlow: 'rgba(255, 255, 255, 0.06)',
|
||||
secondary: '#1f1f1f',
|
||||
} as const;
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function LandingPage() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen selection:bg-[#fd4444]/30"
|
||||
className="min-h-screen selection:bg-white/20"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.fg,
|
||||
@@ -169,7 +169,7 @@ export default function LandingPage() {
|
||||
>
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HanzoLogo className="size-5 text-[#fd4444]" />
|
||||
<HanzoLogo className="size-5 text-white" />
|
||||
<span className="text-sm font-bold tracking-tight">Hanzo Chat</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -199,7 +199,7 @@ export default function LandingPage() {
|
||||
href={loginHref}
|
||||
onClick={handleLoginClick}
|
||||
className="rounded-full px-5 py-2 text-sm font-medium tracking-tight transition-colors"
|
||||
style={{ backgroundColor: colors.brand, color: '#fff' }}
|
||||
style={{ backgroundColor: colors.brand, color: '#000' }}
|
||||
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
|
||||
>
|
||||
@@ -215,7 +215,7 @@ export default function LandingPage() {
|
||||
className="relative flex min-h-[600px] flex-col overflow-hidden rounded-2xl"
|
||||
style={{
|
||||
border: `1px solid ${colors.border}`,
|
||||
background: `linear-gradient(135deg, rgba(253,68,68,0.08) 0%, transparent 50%, rgba(253,68,68,0.04) 100%)`,
|
||||
background: `linear-gradient(135deg, rgba(255,255,255,0.08) 0%, transparent 50%, rgba(255,255,255,0.04) 100%)`,
|
||||
}}
|
||||
>
|
||||
{/* Grid pattern overlay */}
|
||||
@@ -232,7 +232,7 @@ export default function LandingPage() {
|
||||
<div
|
||||
className="flex w-fit items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium"
|
||||
style={{
|
||||
border: `1px solid rgba(253,68,68,0.5)`,
|
||||
border: `1px solid rgba(255,255,255,0.5)`,
|
||||
color: colors.brand,
|
||||
}}
|
||||
>
|
||||
@@ -258,7 +258,7 @@ export default function LandingPage() {
|
||||
href={loginHref}
|
||||
onClick={handleLoginClick}
|
||||
className="inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-medium tracking-tight transition-colors"
|
||||
style={{ backgroundColor: colors.brand, color: '#fff' }}
|
||||
style={{ backgroundColor: colors.brand, color: '#000' }}
|
||||
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
|
||||
>
|
||||
@@ -363,8 +363,8 @@ zen4-coder: I'll help you refactor the auth module.
|
||||
<div
|
||||
className="rounded-2xl p-6 text-sm shadow-lg md:p-8"
|
||||
style={{
|
||||
backgroundColor: 'rgba(253, 68, 68, 0.06)',
|
||||
border: `1px solid rgba(253, 68, 68, 0.15)`,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||
border: `1px solid rgba(255, 255, 255, 0.15)`,
|
||||
}}
|
||||
>
|
||||
<IconBolt className="mb-4 size-8" style={{ color: colors.brand }} />
|
||||
@@ -383,7 +383,7 @@ zen4-coder: I'll help you refactor the auth module.
|
||||
border: `1px solid ${colors.border}`,
|
||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.borderColor = 'rgba(253, 68, 68, 0.3)')}
|
||||
onMouseOver={(e) => (e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.3)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.borderColor = colors.border)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -480,11 +480,11 @@ zen4-coder: I'll help you refactor the auth module.
|
||||
<div
|
||||
className="rounded-xl p-6"
|
||||
style={{
|
||||
border: `1px solid rgba(253, 68, 68, 0.3)`,
|
||||
backgroundColor: 'rgba(253, 68, 68, 0.04)',
|
||||
border: `1px solid rgba(255, 255, 255, 0.3)`,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||
}}
|
||||
>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'rgba(253, 68, 68, 0.7)' }}>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Free credit
|
||||
</p>
|
||||
<p className="text-3xl font-bold">$5</p>
|
||||
@@ -528,7 +528,7 @@ zen4-coder: I'll help you refactor the auth module.
|
||||
href={loginHref}
|
||||
onClick={handleLoginClick}
|
||||
className="inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-medium tracking-tight transition-colors"
|
||||
style={{ backgroundColor: colors.brand, color: '#fff' }}
|
||||
style={{ backgroundColor: colors.brand, color: '#000' }}
|
||||
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
|
||||
>
|
||||
|
||||
@@ -125,11 +125,12 @@ const errorMessages = {
|
||||
);
|
||||
}
|
||||
|
||||
// Commerce insufficient balance
|
||||
// Commerce insufficient balance — new users claim the $5 starter credit,
|
||||
// spent-out users add funds. Both live at billing.hanzo.ai.
|
||||
if (reason === 'commerce_insufficient') {
|
||||
return (
|
||||
<>
|
||||
{'Your account balance is empty. Please add funds to continue.'}
|
||||
{'You have no Hanzo Cloud balance. Claim your $5 starter credit (or add funds) to start chatting.'}
|
||||
<br />
|
||||
<br />
|
||||
<a
|
||||
@@ -138,12 +139,18 @@ const errorMessages = {
|
||||
rel="noopener noreferrer"
|
||||
className="text-hanzo-red underline"
|
||||
>
|
||||
Add funds to your Hanzo account
|
||||
Claim your $5 credit at billing.hanzo.ai
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Commerce unreachable — fail closed (we block rather than bleed). The user
|
||||
// may well be funded; do NOT tell them to add funds.
|
||||
if (reason === 'commerce_unavailable') {
|
||||
return 'Billing is temporarily unavailable, so we could not verify your balance. Please try again in a moment.';
|
||||
}
|
||||
|
||||
// Convert tokenCredits to USD: 1,000,000 tokenCredits = $1 USD
|
||||
const balanceUsd = (balance / 1000000).toFixed(4);
|
||||
const costUsd = (tokenCost / 1000000).toFixed(4);
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('About', () => {
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
const [blob, options] = mockCopy.mock.calls[0] as [string, { format: string }];
|
||||
expect(options).toEqual({ format: 'text/plain' });
|
||||
expect(blob).toContain(`LibreChat version: ${Constants.VERSION}`);
|
||||
expect(blob).toContain(`Hanzo Chat version: ${Constants.VERSION}`);
|
||||
expect(blob).toContain(`Commit: ${populatedBuildInfo.commit}`);
|
||||
expect(blob).toContain(`Branch: ${populatedBuildInfo.branch}`);
|
||||
expect(blob).toContain('Build date: 2026-04-20 12:00:00 UTC');
|
||||
|
||||
@@ -27,7 +27,7 @@ function buildDiagnosticsBlob(
|
||||
buildInfo: TStartupConfig['buildInfo'] | undefined,
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
`LibreChat version: ${version}`,
|
||||
`Hanzo Chat version: ${version}`,
|
||||
`Commit: ${buildInfo?.commit ?? UNKNOWN_PLACEHOLDER}`,
|
||||
`Branch: ${buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}`,
|
||||
`Build date: ${formatBuildDate(buildInfo?.buildDate)}`,
|
||||
|
||||
@@ -274,7 +274,7 @@ export default function ActionsInput({
|
||||
<button
|
||||
disabled={!functions || !functions.length}
|
||||
onClick={saveAction}
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-0 disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{getButtonContent()}
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function AgentFooter({
|
||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
|
||||
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold"
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
aria-busy={isSaving}
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function ApiKeyDialog({
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onSubmit),
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
selectText: localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function ApiKeyDialog({
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onSubmit),
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
selectText: localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
|
||||
@@ -286,7 +286,7 @@ export default function ActionsInput({
|
||||
<button
|
||||
disabled={!functions || !functions.length}
|
||||
onClick={saveAction}
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-0 disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{submitContext()}
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function AssistantPanel({
|
||||
{/* Select Button */}
|
||||
{assistant_id && (
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline mx-2 mt-1 h-[40px] rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
className="btn btn-primary focus:shadow-outline mx-2 mt-1 h-[40px] rounded px-4 py-2 font-semibold focus:outline-none focus:ring-0"
|
||||
type="button"
|
||||
disabled={!assistant_id}
|
||||
onClick={(e) => {
|
||||
@@ -460,7 +460,7 @@ export default function AssistantPanel({
|
||||
/>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
|
||||
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold"
|
||||
type="submit"
|
||||
>
|
||||
{submitContext}
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function Parameters() {
|
||||
<div className="mt-2 flex justify-center">
|
||||
<button
|
||||
onClick={openDialog}
|
||||
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
|
||||
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold"
|
||||
type="button"
|
||||
>
|
||||
{localize('com_endpoint_save_as_preset')}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function CompositeCitation(props: CompositeCitationProps) {
|
||||
href={currentSource.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
|
||||
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-text-primary hover:underline dark:text-blue-400 md:line-clamp-3"
|
||||
>
|
||||
{currentSource.attribution}
|
||||
</a>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function SourceHovercard({
|
||||
{isFile ? (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="line-clamp-2 cursor-pointer overflow-hidden text-left text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
|
||||
className="line-clamp-2 cursor-pointer overflow-hidden text-left text-sm font-bold text-text-primary hover:underline dark:text-blue-400 md:line-clamp-3"
|
||||
>
|
||||
{source.attribution || source.title || localize('com_file_source')}
|
||||
</button>
|
||||
@@ -123,7 +123,7 @@ export function SourceHovercard({
|
||||
href={source.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
|
||||
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-text-primary hover:underline dark:text-blue-400 md:line-clamp-3"
|
||||
>
|
||||
{source.attribution || domain}
|
||||
</a>
|
||||
|
||||
@@ -101,7 +101,7 @@ function SourceItem({ source, expanded = false }: SourceItemProps) {
|
||||
href={source.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
|
||||
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-text-primary hover:underline dark:text-blue-400 md:line-clamp-3"
|
||||
>
|
||||
{source.attribution || domain}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface HanzoLogoIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/** Pixel size; when set, applies to width and height. */
|
||||
size?: number | string;
|
||||
/** Accepted for drop-in parity with lucide icons; ignored (mark is fill-based). */
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Official Hanzo ▼/H mark — monochrome, `currentColor` fill.
|
||||
*
|
||||
* Canonical brand source: `@hanzo/logo` (hanzo.app / hanzo.ai favicon set).
|
||||
* Drop-in replacement for the upstream LibreChat lucide `Feather` brand fallback.
|
||||
*/
|
||||
export default function HanzoLogoIcon({
|
||||
className = '',
|
||||
size,
|
||||
strokeWidth: _strokeWidth,
|
||||
...props
|
||||
}: HanzoLogoIconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 67 67"
|
||||
className={className}
|
||||
{...(size != null ? { width: size, height: size } : {})}
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="Hanzo"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" />
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" />
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" />
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
useLoginUserMutation,
|
||||
useLogoutUserMutation,
|
||||
useRefreshTokenMutation,
|
||||
useGetStartupConfig,
|
||||
} from '~/data-provider';
|
||||
import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common';
|
||||
import useGuestAuth from './useGuestAuth';
|
||||
import useTimeout from './useTimeout';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -37,8 +39,12 @@ const AuthContextProvider = ({
|
||||
const [token, setToken] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [isGuest, setIsGuest] = useState<boolean>(false);
|
||||
const logoutRedirectRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { acquireGuestToken } = useGuestAuth();
|
||||
|
||||
const { data: userRole = null } = useGetRole(SystemRoles.USER, {
|
||||
enabled: !!(isAuthenticated && (user?.role ?? '')),
|
||||
});
|
||||
@@ -125,12 +131,44 @@ const AuthContextProvider = ({
|
||||
[logoutUser],
|
||||
);
|
||||
|
||||
const userQuery = useGetUserQuery({ enabled: !!(token ?? '') });
|
||||
const userQuery = useGetUserQuery({ enabled: !!(token ?? '') && !isGuest });
|
||||
|
||||
const acquireGuest = useCallback(async (): Promise<boolean> => {
|
||||
const session = await acquireGuestToken();
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
setUser(session.user);
|
||||
setToken(session.token);
|
||||
setTokenHeader(session.token);
|
||||
setIsGuest(true);
|
||||
setIsAuthenticated(false);
|
||||
return true;
|
||||
}, [acquireGuestToken, setUser]);
|
||||
|
||||
const login = (data: t.TLoginUser) => {
|
||||
loginUser.mutate(data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ref-held guest fallback so `silentRefresh` (intentionally dep-free) always
|
||||
* sees the latest config/handler without resubscribing. When no session
|
||||
* exists and guest chat is enabled, acquire a guest token; otherwise let the
|
||||
* root route show the landing/login gate.
|
||||
*/
|
||||
const guestFallbackRef = useRef<() => void>(() => {});
|
||||
guestFallbackRef.current = () => {
|
||||
if (startupConfig?.allowGuestChat === true) {
|
||||
void acquireGuest().then((ok) => {
|
||||
if (!ok) {
|
||||
setUserContext({ token: undefined, isAuthenticated: false, user: undefined });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUserContext({ token: undefined, isAuthenticated: false, user: undefined });
|
||||
};
|
||||
|
||||
const silentRefresh = useCallback(() => {
|
||||
if (authConfig?.test === true) {
|
||||
console.log('Test mode. Skipping silent refresh.');
|
||||
@@ -150,8 +188,7 @@ const AuthContextProvider = ({
|
||||
if (authConfig?.test === true) {
|
||||
return;
|
||||
}
|
||||
// Let the root route show the landing page instead of forcing /login
|
||||
setUserContext({ token: undefined, isAuthenticated: false, user: undefined });
|
||||
guestFallbackRef.current();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -159,8 +196,7 @@ const AuthContextProvider = ({
|
||||
if (authConfig?.test === true) {
|
||||
return;
|
||||
}
|
||||
// Let the root route show the landing page instead of forcing /login
|
||||
setUserContext({ token: undefined, isAuthenticated: false, user: undefined });
|
||||
guestFallbackRef.current();
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
@@ -175,11 +211,12 @@ const AuthContextProvider = ({
|
||||
if (error != null && error && isAuthenticated) {
|
||||
doSetError(undefined);
|
||||
}
|
||||
if (token == null || !token || !isAuthenticated) {
|
||||
if (!isGuest && (token == null || !token || !isAuthenticated)) {
|
||||
silentRefresh();
|
||||
}
|
||||
}, [
|
||||
token,
|
||||
isGuest,
|
||||
isAuthenticated,
|
||||
userQuery.data,
|
||||
userQuery.isError,
|
||||
@@ -191,6 +228,21 @@ const AuthContextProvider = ({
|
||||
setUserContext,
|
||||
]);
|
||||
|
||||
// Acquire a guest session once startup config confirms guest chat is enabled.
|
||||
// silentRefresh's fallback can run before startupConfig has loaded (when
|
||||
// `allowGuestChat` is still undefined), and it is not retried — so this effect
|
||||
// closes that race by acquiring the guest token when the flag becomes true.
|
||||
useEffect(() => {
|
||||
if (
|
||||
startupConfig?.allowGuestChat === true &&
|
||||
!isAuthenticated &&
|
||||
!isGuest &&
|
||||
(token == null || !token)
|
||||
) {
|
||||
void acquireGuest();
|
||||
}
|
||||
}, [startupConfig?.allowGuestChat, isAuthenticated, isGuest, token, acquireGuest]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTokenUpdate = (event) => {
|
||||
console.log('tokenUpdated event received event');
|
||||
@@ -223,9 +275,10 @@ const AuthContextProvider = ({
|
||||
[SystemRoles.ADMIN]: adminRole,
|
||||
},
|
||||
isAuthenticated,
|
||||
isGuest,
|
||||
}),
|
||||
|
||||
[user, error, isAuthenticated, token, userRole, adminRole],
|
||||
[user, error, isAuthenticated, isGuest, token, userRole, adminRole],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={memoedValue}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Feather } from 'lucide-react';
|
||||
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
GPTIcon,
|
||||
@@ -51,7 +51,7 @@ const AgentAvatar = ({ className = '', avatar = '', agentName, size }: AgentIcon
|
||||
);
|
||||
}
|
||||
|
||||
return <Feather className={cn(agentName === '' ? 'icon-2xl' : '', className)} size={size} />;
|
||||
return <HanzoLogoIcon className={cn(agentName === '' ? 'icon-2xl' : '', className)} size={size} />;
|
||||
};
|
||||
|
||||
const Bedrock = ({ className = '' }: IconMapProps) => {
|
||||
|
||||
@@ -594,8 +594,18 @@ export default function useResumableSSE(
|
||||
|
||||
console.error('[ResumableSSE] Error starting generation:', lastError);
|
||||
|
||||
const axiosError = lastError as { response?: { data?: Record<string, unknown> } };
|
||||
const axiosError = lastError as {
|
||||
response?: { status?: number; data?: Record<string, unknown> };
|
||||
};
|
||||
const errorData = axiosError?.response?.data;
|
||||
|
||||
// Guest quota exhausted: signal the UI to open the login gate (existing OpenID flow).
|
||||
if (axiosError?.response?.status === 402 && errorData?.type === 'GUEST_LIMIT') {
|
||||
window.dispatchEvent(new CustomEvent('guestLimitReached'));
|
||||
setIsSubmitting(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (errorData) {
|
||||
errorHandler({
|
||||
data: { text: JSON.stringify(errorData) } as unknown as Parameters<
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
import { dataService } from 'librechat-data-provider';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
|
||||
const GUEST_ROLE = 'GUEST';
|
||||
|
||||
export type GuestSession = {
|
||||
token: string;
|
||||
user: TUser;
|
||||
model: string;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Acquires an ephemeral guest session for anonymous preview chat.
|
||||
*
|
||||
* Returns `null` when guest issuance fails (e.g. quota for new sessions reached
|
||||
* or guest chat disabled), letting the caller fall back to the login gate.
|
||||
*/
|
||||
export default function useGuestAuth() {
|
||||
const acquireGuestToken = useCallback(async (): Promise<GuestSession | null> => {
|
||||
try {
|
||||
const data = await dataService.getGuestToken();
|
||||
const user = {
|
||||
id: 'guest',
|
||||
role: GUEST_ROLE,
|
||||
username: 'guest',
|
||||
name: 'Guest',
|
||||
email: '',
|
||||
} as unknown as TUser;
|
||||
return { token: data.token, user, model: data.model, endpoint: data.endpoint };
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { acquireGuestToken };
|
||||
}
|
||||
@@ -173,6 +173,9 @@
|
||||
"com_auth_email_verifying_ellipsis": "Verifying...",
|
||||
"com_auth_error_create": "There was an error attempting to register your account. Please try again.",
|
||||
"com_auth_error_invalid_reset_token": "This password reset token is no longer valid.",
|
||||
"com_auth_guest_limit_title": "You've reached the free preview limit",
|
||||
"com_auth_guest_limit_message": "Log in to keep chatting with full access to models, tools, and your conversation history.",
|
||||
"com_auth_guest_limit_login": "Log in to continue",
|
||||
"com_auth_error_login": "Unable to login with the information provided. Please check your credentials and try again.",
|
||||
"com_auth_error_login_ban": "Your account has been temporarily banned due to violations of our service.",
|
||||
"com_auth_error_login_rl": "Too many login attempts in a short amount of time. Please try again later.",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { TermsAndConditionsModal } from '~/components/ui';
|
||||
import { useHealthCheck } from '~/data-provider';
|
||||
import { Banner } from '~/components/Banners';
|
||||
import LandingPage from '~/components/Landing/LandingPage';
|
||||
import GuestLimitDialog from '~/components/Auth/GuestLimitDialog';
|
||||
import ChatHanzoHeader from '~/components/Nav/HanzoHeader';
|
||||
|
||||
export default function Root() {
|
||||
@@ -32,9 +33,13 @@ export default function Root() {
|
||||
return savedNavVisible !== null ? JSON.parse(savedNavVisible) : true;
|
||||
});
|
||||
|
||||
const { isAuthenticated, logout, token } = useAuthContext();
|
||||
const { isAuthenticated, isGuest, logout, token } = useAuthContext();
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
|
||||
// Guests get the chat UI without a full session. Capability-scoped hooks
|
||||
// below stay gated on `isAuthenticated`, so guests never query agents/files/search.
|
||||
const showChat = isAuthenticated || isGuest;
|
||||
|
||||
// Wait for the initial silent refresh before deciding to show landing vs chat
|
||||
useEffect(() => {
|
||||
if (isAuthenticated || token !== undefined) {
|
||||
@@ -86,7 +91,7 @@ export default function Root() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (!showChat) {
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
@@ -97,6 +102,7 @@ export default function Root() {
|
||||
<AgentsMapContext.Provider value={agentsMap}>
|
||||
<PromptGroupsProvider>
|
||||
<ChatHanzoHeader />
|
||||
{isGuest && <GuestLimitDialog />}
|
||||
<Banner onHeightChange={setBannerHeight} />
|
||||
<div className="flex" style={{ height: `calc(100dvh - ${bannerHeight}px)` }}>
|
||||
<div className="relative z-0 flex h-full w-full overflow-hidden">
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
|
||||
export default function useAuthRedirect() {
|
||||
const { user, roles, isAuthenticated } = useAuthContext();
|
||||
const { user, roles, isAuthenticated, isGuest } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Wait until startup config is known before deciding — otherwise a fresh
|
||||
// guest visitor is bounced to /login before `allowGuestChat` (and the guest
|
||||
// session) has loaded.
|
||||
if (startupConfig == null) {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
if (!isAuthenticated) {
|
||||
// Guests (and any context where guest chat is enabled) stay on the chat
|
||||
// surface; only truly unauthenticated, non-guest users go to /login.
|
||||
if (!isAuthenticated && !isGuest && startupConfig.allowGuestChat !== true) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, 300);
|
||||
@@ -16,7 +26,7 @@ export default function useAuthRedirect() {
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [isAuthenticated, navigate]);
|
||||
}, [isAuthenticated, isGuest, startupConfig, navigate]);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -18,17 +18,18 @@
|
||||
--gray-800: #212121;
|
||||
--gray-850: #171717;
|
||||
--gray-900: #0d0d0d;
|
||||
--green-50: #fff5f5;
|
||||
--green-100: #ffe3e3;
|
||||
--green-200: #ffc4c4;
|
||||
--green-300: #ff9494;
|
||||
--green-400: #ff6b6b;
|
||||
--green-500: #fd4444;
|
||||
--green-600: #e03e3e;
|
||||
--green-700: #c53030;
|
||||
--green-800: #9b2c2c;
|
||||
--green-900: #742a2a;
|
||||
--green-950: #4a1d1d;
|
||||
/* Monochrome: legacy "green" (was brand red #fd4444) collapsed to the grey ramp. */
|
||||
--green-50: #f7f7f8;
|
||||
--green-100: #ececec;
|
||||
--green-200: #e3e3e3;
|
||||
--green-300: #cdcdcd;
|
||||
--green-400: #999696;
|
||||
--green-500: #595959;
|
||||
--green-600: #424242;
|
||||
--green-700: #2f2f2f;
|
||||
--green-800: #212121;
|
||||
--green-900: #0d0d0d;
|
||||
--green-950: #0a0a0a;
|
||||
--red-50: #fef2f2;
|
||||
--red-100: #fee2e2;
|
||||
--red-200: #fecaca;
|
||||
@@ -51,7 +52,7 @@
|
||||
--amber-800: #92400e;
|
||||
--amber-900: #78350f;
|
||||
--amber-950: #451a03;
|
||||
--brand-purple: #ab68ff;
|
||||
--brand-purple: #999696;
|
||||
--gizmo-gray-500: #999;
|
||||
--gizmo-gray-600: #666;
|
||||
--gizmo-gray-950: #0f0f0f;
|
||||
@@ -63,13 +64,13 @@
|
||||
--markdown-font-size: 1rem;
|
||||
}
|
||||
html {
|
||||
--brand-purple: #ab68ff;
|
||||
--brand-purple: #999696;
|
||||
--presentation: var(--white);
|
||||
--text-primary: var(--gray-800);
|
||||
--text-secondary: var(--gray-600);
|
||||
--text-secondary-alt: var(--gray-500);
|
||||
--text-tertiary: var(--gray-500);
|
||||
--text-warning: var(--amber-500);
|
||||
--text-warning: var(--gray-400);
|
||||
--text-destructive: var(--red-600);
|
||||
--ring-primary: var(--gray-500);
|
||||
--header-primary: var(--white);
|
||||
@@ -126,13 +127,13 @@ html {
|
||||
--switch-unchecked: 0 0% 58%;
|
||||
}
|
||||
.dark {
|
||||
--brand-purple: #ab68ff;
|
||||
--brand-purple: #999696;
|
||||
--presentation: var(--gray-800);
|
||||
--text-primary: var(--gray-100);
|
||||
--text-secondary: var(--gray-300);
|
||||
--text-secondary-alt: var(--gray-400);
|
||||
--text-tertiary: var(--gray-500);
|
||||
--text-warning: var(--amber-500);
|
||||
--text-warning: var(--gray-400);
|
||||
--text-destructive: var(--red-600);
|
||||
--header-primary: var(--gray-700);
|
||||
--header-hover: var(--gray-600);
|
||||
@@ -162,24 +163,24 @@ html {
|
||||
--border-destructive: var(--red-500);
|
||||
/* Hanzo dark palette */
|
||||
|
||||
--background: 222 47% 5%;
|
||||
--background: 0 0% 5%;
|
||||
--foreground: 0 0% 95%;
|
||||
--card: 222 47% 7%;
|
||||
--card: 0 0% 7%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
--primary: 0 0% 95%;
|
||||
--primary-foreground: 222 47% 9%;
|
||||
--secondary: 222 30% 18%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 18%;
|
||||
--secondary-foreground: 0 0% 95%;
|
||||
--muted: 222 30% 12%;
|
||||
--muted-foreground: 215 16% 56%;
|
||||
--accent: 222 30% 18%;
|
||||
--muted: 0 0% 12%;
|
||||
--muted-foreground: 0 0% 56%;
|
||||
--accent: 0 0% 18%;
|
||||
--accent-foreground: 0 0% 95%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 222 30% 18%;
|
||||
--input: 222 30% 18%;
|
||||
--ring: 215 16% 56%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--border: 0 0% 18%;
|
||||
--input: 0 0% 18%;
|
||||
--ring: 0 0% 56%;
|
||||
--chart-1: 0 0% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
@@ -1353,14 +1354,12 @@ button {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.btn-primary {
|
||||
--tw-bg-opacity: 1;
|
||||
--tw-text-opacity: 1;
|
||||
background-color: rgba(16, 163, 127, var(--tw-bg-opacity));
|
||||
color: rgba(255, 255, 255, var(--tw-text-opacity));
|
||||
/* Monochrome primary: adaptive white-on-dark / black-on-light (matches send button). */
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
.btn-primary:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(26, 127, 100, var(--tw-bg-opacity));
|
||||
background-color: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
.btn-primary:focus {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width)
|
||||
@@ -1376,36 +1375,28 @@ button {
|
||||
var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 transparent);
|
||||
}
|
||||
.btn-primary.focus-visible {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgba(25, 195, 125, var(--tw-ring-opacity));
|
||||
--tw-ring-color: hsl(var(--ring));
|
||||
}
|
||||
.btn-primary:focus-visible {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgba(25, 195, 125, var(--tw-ring-opacity));
|
||||
--tw-ring-color: hsl(var(--ring));
|
||||
}
|
||||
.btn-primary:disabled:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(16, 163, 127, var(--tw-bg-opacity));
|
||||
background-color: hsl(var(--primary));
|
||||
}
|
||||
.btn-secondary {
|
||||
--tw-border-opacity: 1;
|
||||
--tw-bg-opacity: 1;
|
||||
--tw-text-opacity: 1;
|
||||
background-color: transparent;
|
||||
background-color: rgba(16, 163, 156, var(--tw-bg-opacity));
|
||||
color: rgba(255, 255, 255, var(--tw-text-opacity));
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
color: rgba(64, 65, 79, var(--tw-text-opacity));
|
||||
background-color: rgba(236, 236, 241, var(--tw-bg-opacity));
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
.dark .btn-secondary:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
border-color: rgba(8, 82, 78, var(--tw-border-opacity));
|
||||
color: rgba(255, 255, 240, var(--tw-text-opacity));
|
||||
background-color: rgba(8, 82, 78, var(--tw-bg-opacity));
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
.btn-neutral {
|
||||
--tw-bg-opacity: 1;
|
||||
@@ -1719,12 +1710,12 @@ html {
|
||||
--tw-ring-inset: var(--tw-empty, /*!*/ /*!*/);
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: #2563eb;
|
||||
--tw-ring-color: #52525b;
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width)
|
||||
var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width))
|
||||
var(--tw-ring-color);
|
||||
border-color: #2563eb;
|
||||
border-color: #52525b;
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
|
||||
@@ -4,20 +4,6 @@ import '@testing-library/jest-dom';
|
||||
import { getAgentAvatarUrl, renderAgentAvatar, getContactDisplayName } from '../agents';
|
||||
import type t from 'librechat-data-provider';
|
||||
|
||||
// Mock the Feather icon from lucide-react
|
||||
jest.mock('lucide-react', () => ({
|
||||
Feather: ({ className, strokeWidth, ...props }: any) => (
|
||||
<svg
|
||||
data-testid="feather-icon"
|
||||
className={className}
|
||||
data-stroke-width={strokeWidth}
|
||||
{...props}
|
||||
>
|
||||
<title>{/* eslint-disable-line i18next/no-literal-string */}Feather Icon</title>
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('Agent Utilities', () => {
|
||||
describe('getAgentAvatarUrl', () => {
|
||||
it('should return null for null agent', () => {
|
||||
@@ -77,7 +63,7 @@ describe('Agent Utilities', () => {
|
||||
expect(img).toHaveClass('rounded-full', 'object-cover', 'shadow-lg');
|
||||
});
|
||||
|
||||
it('should render Feather icon fallback when no avatar', () => {
|
||||
it('should render the Hanzo mark fallback when no avatar', () => {
|
||||
const agent = {
|
||||
id: '1',
|
||||
name: 'Test Agent',
|
||||
@@ -85,9 +71,8 @@ describe('Agent Utilities', () => {
|
||||
|
||||
render(<div>{renderAgentAvatar(agent)}</div>);
|
||||
|
||||
const featherIcon = screen.getByTestId('feather-icon');
|
||||
expect(featherIcon).toBeInTheDocument();
|
||||
expect(featherIcon).toHaveAttribute('data-stroke-width', '1.5');
|
||||
const hanzoMark = screen.getByLabelText('Hanzo');
|
||||
expect(hanzoMark).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply different size classes', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Feather } from 'lucide-react';
|
||||
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
|
||||
import { Skeleton } from '@librechat/client';
|
||||
import type t from 'librechat-data-provider';
|
||||
|
||||
@@ -116,7 +116,7 @@ export const renderAgentAvatar = (
|
||||
|
||||
return (
|
||||
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
|
||||
<Feather className={`text-text-primary ${iconSizeClasses[size]}`} strokeWidth={1.5} />
|
||||
<HanzoLogoIcon className={`text-text-primary ${iconSizeClasses[size]}`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* from the Vite environment. Returns null when IAM is not configured
|
||||
* (i.e. the app is running in backend-proxied mode).
|
||||
*/
|
||||
import { BrowserIamSdk } from '@hanzo/iam';
|
||||
import { IAM } from '@hanzo/iam';
|
||||
|
||||
let instance: BrowserIamSdk | null = null;
|
||||
let instance: IAM | null = null;
|
||||
let checked = false;
|
||||
|
||||
export function getHanzoIamSdk(): BrowserIamSdk | null {
|
||||
export function getHanzoIamSdk(): IAM | null {
|
||||
if (checked) {
|
||||
return instance;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function getHanzoIamSdk(): BrowserIamSdk | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
instance = new BrowserIamSdk({
|
||||
instance = new IAM({
|
||||
serverUrl,
|
||||
clientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
// const { fontFamily } = require('tailwindcss/defaultTheme');
|
||||
|
||||
// Hanzo monochrome brand — every accent scale collapses to ONE neutral grey ramp.
|
||||
// Black / white / grey only; no color in the UI chrome. Destructive semantics keep a
|
||||
// single muted red via the --surface-destructive / --text-destructive CSS vars (style.css).
|
||||
const mono = {
|
||||
50: '#f7f7f8',
|
||||
100: '#ececec',
|
||||
200: '#e3e3e3',
|
||||
300: '#cdcdcd',
|
||||
400: '#999696',
|
||||
500: '#595959',
|
||||
550: '#4d4d4d',
|
||||
600: '#424242',
|
||||
700: '#2f2f2f',
|
||||
800: '#212121',
|
||||
900: '#0d0d0d',
|
||||
950: '#0a0a0a',
|
||||
};
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
@@ -72,19 +90,23 @@ module.exports = {
|
||||
850: '#171717',
|
||||
900: '#0d0d0d',
|
||||
},
|
||||
green: {
|
||||
50: '#fff5f5',
|
||||
100: '#ffe3e3',
|
||||
200: '#ffc4c4',
|
||||
300: '#ff9494',
|
||||
400: '#ff6b6b',
|
||||
500: '#fd4444',
|
||||
550: '#e03e3e',
|
||||
600: '#c53030',
|
||||
700: '#9b2c2c',
|
||||
800: '#742a2a',
|
||||
900: '#4a1d1d',
|
||||
},
|
||||
green: mono,
|
||||
red: mono,
|
||||
blue: mono,
|
||||
sky: mono,
|
||||
cyan: mono,
|
||||
teal: mono,
|
||||
emerald: mono,
|
||||
lime: mono,
|
||||
yellow: mono,
|
||||
amber: mono,
|
||||
orange: mono,
|
||||
indigo: mono,
|
||||
violet: mono,
|
||||
purple: mono,
|
||||
fuchsia: mono,
|
||||
pink: mono,
|
||||
rose: mono,
|
||||
'brand-purple': 'var(--brand-purple)',
|
||||
presentation: 'var(--presentation)',
|
||||
'text-primary': 'var(--text-primary)',
|
||||
|
||||