feat(guest): forward-port anonymous guest-composer chat (@hanzo/iam-compatible)

Re-adds the deliberately-deleted (98d0dad34b) guest stack so an anonymous
visitor lands on the chat composer and can chat ONE free model without login,
then hits the SSO wall after a small per-IP quota. Adapted to today's code
(@hanzochat/* package names, @hanzo/iam bridge, tenant-bearer billing) — NOT a
blind revert: hanzoCloudKey.ts stays deleted.

Server (fail-closed, server-enforced):
- services/guestConfig.js: env-gated config + ephemeral guest principal/user +
  guest-scoped endpoints/models builders (default model zen5-flash).
- controllers/auth/GuestController.js + POST /v1/chat/auth/guest: short-lived
  guest JWT ({guest:true}, per-token random id) signed with JWT_SECRET; the
  route is per-IP mint-limited (guestTokenLimiter).
- middleware/requireGuestOrJwtAuth.js: accepts guest tokens ONLY where mounted;
  the jwt strategy still rejects guests everywhere else (clean 401, no CastError).
- middleware/enforceGuestScope.js: pins endpoint+model, strips
  agents/tools/files/spec/preset; 403 on any other endpoint/model.
- limiters/{guestLimiters,guestMessageLimiter}.js + utils/guestClientIp.js:
  per-REAL-IP quota (CF-Connecting-IP, not the token) via in-memory limiterCache
  (correct at replicas:1/Recreate; Redis NOT required). Exhaust -> 402 GUEST_LIMIT.
- endpoints/custom/initialize.ts: guest principal -> shared capped GUEST_API_KEY
  (KMS chat-guest-key), skipping per-user hk-/bearer billing; fail-closed if
  unset. Authenticated bearer path unchanged.
- agents/index.js: guest-capable completion chain; guest-safe /chat/active poll;
  AND the guest SSE read-back — GET /chat/stream/:streamId registered with
  requireGuestOrJwtAuth ABOVE the strict guard (else a guest 401s reading its own
  stream -> empty reply). Per-job ownership check (job.metadata.userId===req.user.id)
  is unchanged: foreign=403, missing=404, no cross-principal stream leak.
- Wire models/endpoints + guest-safe bootstrap (user/convos/favorites) to
  requireGuestOrJwtAuth; emit allowGuestChat/guestMessageMax in /v1/config;
  balance-gate bypass for guests (no org, bounded by limiter + capped key).

Tests: restored 6 guest specs (40/40 green) + 3 guest-billing cases in
initialize.spec. Client already guest-ready (zero client changes).

Docs: LLM.md guest section corrected to in-memory-at-replicas:1 + GUEST_API_KEY.

Do NOT deploy: red security review next, then cto native build + CR image pin.
This commit is contained in:
Hanzo AI
2026-07-24 13:13:41 -07:00
parent cebbd5a42c
commit e0fd669598
33 changed files with 1298 additions and 46 deletions
+22 -13
View File
@@ -95,7 +95,7 @@ CREDS_KEY= CREDS_IV= # Credential encryption
Off by default (`ALLOW_GUEST_CHAT=false`). When enabled, the landing IS the chat
composer (ChatGPT-style): an unauthenticated visitor renders the real chat view —
composer, starter cards, model picker — WITHOUT logging in, scoped to the free
Zen model (`GUEST_MODEL`, default `zen3-nano`) via the `Hanzo` custom endpoint
Zen model (`GUEST_MODEL`, default `zen5-flash`) via the `Hanzo` custom endpoint
(`api.hanzo.ai`). Prod uses `GUEST_MESSAGE_MAX=2`. Exhausting the quota returns
`402 {type:'GUEST_LIMIT'}` and the client opens the existing OpenID/hanzo.id login.
@@ -117,27 +117,36 @@ Security model (fail-closed, server-enforced):
per-token random id) signed with `JWT_SECRET`. Rate-limited per IP
(`guestTokenLimiter`, `GUEST_TOKEN_MAX`/`GUEST_TOKEN_WINDOW`) so tokens can't be
spam-minted.
- `requireGuestOrJwtAuth` (chat-completion route ONLY) accepts guest tokens;
the standard `jwt` strategy rejects them everywhere else (no DB user), so
every other route stays closed. `enforceGuestScope` pins endpoint+model and
strips agents/tools/files/spec/preset. Guests always use the shared, capped
`HANZO_API_KEY` (per-user `hk-` billing is skipped for `guest` principals).
- `requireGuestOrJwtAuth` (chat-completion + guest-safe bootstrap routes: models,
endpoints, user, convos, favorites, agents `/chat/active`) 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. Guests use the shared, capped guest gateway key
`GUEST_API_KEY` (the KMS `chat-guest-key`; `HANZO_API_KEY` is the dev fallback),
resolved in `packages/api/src/endpoints/custom/initialize.ts` — the guest key's
OWN org is metered+capped at the gateway, and per-user `hk-` billing is skipped
for `guest` principals (they carry no forwardable bearer and no `X-Org-Id`).
- `guestMessageLimiter` enforces the quota against the REAL client IP
(`utils/guestClientIp` → Cloudflare `CF-Connecting-IP`, falls back to `req.ip`),
NOT the token — clearing cookies / incognito / minting a fresh token does NOT
reset it. Backed by the shared Redis `limiterCache` so it holds across replicas.
`USE_REDIS=true` is MANDATORY (a memory store would let a visitor round-robin
pods to multiply their quota).
reset it. The store is `limiterCache`, which returns `undefined` when `USE_REDIS`
is off → `express-rate-limit` uses its in-process MemoryStore. That store is
authoritative at the live deploy's `replicas: 1` + `Recreate` (never two live
pods to round-robin); Redis is NOT required (it was killed platform-wide). The
only reset is a pod restart — operational, not attacker-triggerable. If guest
chat ever scales past one replica, set `USE_REDIS=true` so the count holds
across pods.
- 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`,
`api/server/utils/guestClientIp.js`,
router wiring in `api/server/routes/agents/index.js`.
- Env: `ALLOW_GUEST_CHAT`, `GUEST_MESSAGE_MAX`, `GUEST_ENDPOINT`, `GUEST_MODEL`,
`GUEST_TOKEN_EXPIRY`, `GUEST_TOKEN_MAX`, `GUEST_TOKEN_WINDOW`. Requires
`HANZO_API_KEY` (the free publishable gateway key) and `USE_REDIS` for the
shared per-IP quota across replicas.
- Env: `ALLOW_GUEST_CHAT`, `GUEST_MODEL` (prod `zen5-flash`), `GUEST_ENDPOINT`
(`Hanzo`), `GUEST_MESSAGE_MAX` (prod `2`), `GUEST_TOKEN_EXPIRY`, `GUEST_TOKEN_MAX`,
`GUEST_TOKEN_WINDOW`. Requires the shared, capped guest key `GUEST_API_KEY`
(KMS `chat-guest-key`; falls back to `HANZO_API_KEY`). No Redis dependency at
`replicas: 1` — the in-process MemoryStore is the quota's single source of truth.
## Cloud Agents (canonical /v1/agents)
+10
View File
@@ -94,6 +94,16 @@ const checkBalanceRecord = async function ({
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
const tokenCost = amount * multiplier;
// Guests (anonymous preview) are NOT balance-gated here — they have no org and no
// DB balance record, so both the Commerce and legacy local gates would false-block
// them. Guest spend is bounded two other ways: (1) the per-IP guest message
// limiter (GUEST_MESSAGE_MAX) and (2) the shared, small-capped guest key
// (GUEST_API_KEY) whose OWN org's balance the cloud gateway debits and 402s when
// empty. Never an authed user's org balance.
if (req?.user?.guest === true) {
return { canSpend: true, balance: 0, tokenCost };
}
const commerceClient = getCommerceClient();
// Tenant billing key = the org (the token `owner`). cloud is the authoritative
// meter+gate and bills per-org (see AUTH_BILLING_CONTRACT.md); this optional
@@ -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('@hanzochat/data-schemas');
const { CacheKeys } = require('@hanzochat/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) {
+4
View File
@@ -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,45 @@
const { randomUUID } = require('node:crypto');
const jwt = require('jsonwebtoken');
const { logger } = require('@hanzochat/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 };
@@ -0,0 +1,118 @@
jest.mock('@hanzochat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
jest.mock('@hanzochat/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,134 @@
/**
* 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('@hanzochat/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('@hanzochat/data-provider', () => ({
EModelEndpoint: { custom: 'custom', agents: 'agents' },
}));
jest.mock('~/server/utils', () => ({
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
guestClientIp: (req) =>
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
}));
jest.mock('../requireJwtAuth', () =>
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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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,94 @@
const jwt = require('jsonwebtoken');
jest.mock('@hanzochat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
// Mock @hanzochat/api's isEnabled (the only symbol guestConfig needs) so the real
// package dist — which eagerly pulls the agents/langchain bundle (ESM, unparseable
// by this jest config) — never enters the module graph. Same behavior guestConfig
// relies on, kept hermetic like the sibling guest specs.
jest.mock('@hanzochat/api', () => ({
isEnabled: (value) => value === 'true' || value === true,
}));
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,61 @@
const { logger } = require('@hanzochat/data-schemas');
const { EModelEndpoint } = require('@hanzochat/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;
+4
View File
@@ -8,6 +8,8 @@ const accessResources = require('./accessResources');
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');
@@ -34,6 +36,8 @@ module.exports = {
moderateText,
validateModel,
requireJwtAuth,
requireGuestOrJwtAuth,
enforceGuestScope,
checkInviteUser,
canDeleteAccount,
configMiddleware,
@@ -0,0 +1,45 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { guestClientIp } = require('~/server/utils');
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const GUEST_TOKEN_WINDOW = parsePositiveInt(process.env.GUEST_TOKEN_WINDOW, 60);
const GUEST_TOKEN_MAX = parsePositiveInt(process.env.GUEST_TOKEN_MAX, 20);
const windowMs = GUEST_TOKEN_WINDOW * 60 * 1000;
const max = GUEST_TOKEN_MAX;
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.
*
* Caps how many guest tokens a single client IP can mint per window so nobody can
* spam-mint tokens (a DoS / quota-probe vector). Keyed on the REAL client IP
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`). The store comes from
* `limiterCache`, which returns `undefined` when `USE_REDIS` is off — so
* `express-rate-limit` uses its in-process MemoryStore. That is the correct
* single source of truth for hanzo.chat's `replicas: 1` / `Recreate` deployment
* (never two live pods, so no cross-pod round-robin to defeat), and it does NOT
* require Redis (killed platform-wide). This is defense in depth only: even
* unlimited tokens cannot multiply the message quota, which is itself keyed
* per-IP (see `guestMessageLimiter`).
*/
const guestTokenLimiter = rateLimit({
windowMs,
max,
handler,
keyGenerator: guestClientIp,
store: limiterCache('guest_token_limiter'),
});
module.exports = { guestTokenLimiter };
@@ -0,0 +1,41 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { guestClientIp } = 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.
*
* The key is the REAL client IP (`guestClientIp` → Cloudflare `CF-Connecting-IP`),
* NOT the guest token, so clearing cookies, opening incognito, or minting a fresh
* guest token does not reset the count. It only counts requests made by an
* authenticated guest principal; logged-in users skip the counter entirely. On
* exhaustion it returns `402 { type: 'GUEST_LIMIT' }`, the signal the client maps
* to the login gate.
*
* The store comes from `limiterCache`, which returns `undefined` when `USE_REDIS`
* is off — so `express-rate-limit` uses its in-process MemoryStore. At
* `replicas: 1` with a `Recreate` rollout there is never a second live pod to
* round-robin, so the in-memory count is the authoritative per-IP quota; Redis is
* NOT required (it was killed platform-wide). The only reset is a pod restart — an
* infrequent operational event, not an attacker-triggerable action.
*/
const guestMessageLimiter = rateLimit({
windowMs: GUEST_LIMIT_WINDOW_MS,
max: () => getGuestConfig().messageMax,
handler,
keyGenerator: guestClientIp,
skip: (req) => req.user?.guest !== true,
store: limiterCache('guest_message_limiter'),
});
module.exports = { guestMessageLimiter };
@@ -0,0 +1,78 @@
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,
guestClientIp: (req) =>
req.headers['cf-connecting-ip'] || 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);
}
});
});
+4
View File
@@ -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');
@@ -18,6 +20,8 @@ module.exports = {
...messageLimiters,
...forkLimiters,
loginLimiter,
guestTokenLimiter,
guestMessageLimiter,
registerLimiter,
toolCallLimiter,
cloudAgentLimiter,
@@ -0,0 +1,62 @@
const jwt = require('jsonwebtoken');
const { logger } = require('@hanzochat/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 (and guest-safe
* bootstrap) 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:
* an OpenID bearer (signed by hanzo.id, not `JWT_SECRET`) fails `jwt.verify` here
* and falls through to `requireJwtAuth`, preserving the OIDC-reuse path.
*
* @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),
* - /v1/chat/endpoints, /v1/chat/user, /v1/chat/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('@hanzochat/data-provider', () => ({
EModelEndpoint: { custom: 'custom' },
SystemRoles: { USER: 'USER' },
}));
jest.mock('@hanzochat/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('/v1/chat/endpoints', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
}
return res.send(JSON.stringify({ openAI: {}, Hanzo: {} }));
});
app.get('/v1/chat/user', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.status(200).send(buildGuestUser(req.user));
}
return res.status(200).send(req.user);
});
app.get('/v1/chat/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('/v1/chat/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 /v1/chat/endpoints → 200 with ONLY the guest endpoint', async () => {
const res = await request(app)
.get('/v1/chat/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 /v1/chat/user → 200 ephemeral guest principal (no email, no DB lookup)', async () => {
const res = await request(app).get('/v1/chat/user').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: 'guest_abc-123', role: 'GUEST', guest: true });
expect(res.body).not.toHaveProperty('email');
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /v1/chat/convos → 200 empty list for a guest', async () => {
const res = await request(app).get('/v1/chat/convos').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ conversations: [], nextCursor: null });
});
it('GET /v1/chat/prompts (JWT-only) → clean 401 for a guest, NEVER 500/CastError', async () => {
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(401);
// The guest id must never reach getUserById (that is what caused the CastError → 500).
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /v1/chat/prompts → clean 401 for a real-but-missing user (no 500)', async () => {
const realToken = jwt.sign({ id: '64b2f0c0c0c0c0c0c0c0c0c0' }, JWT_SECRET);
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${realToken}`);
expect(res.status).toBe(401);
expect(getUserById).toHaveBeenCalledTimes(1);
});
it('GET /v1/chat/prompts → 401 with no token (fail closed)', async () => {
const res = await request(app).get('/v1/chat/prompts');
expect(res.status).toBe(401);
});
});
+46 -8
View File
@@ -8,6 +8,9 @@ const {
messageIpLimiter,
configMiddleware,
messageUserLimiter,
enforceGuestScope,
guestMessageLimiter,
requireGuestOrJwtAuth,
} = require('~/server/middleware');
const { saveMessage } = require('~/models');
const openai = require('./openai');
@@ -35,11 +38,17 @@ router.use('/v1/responses', responses);
router.use('/v1', openai);
/**
* Chat completion router. Every request is gated by the strict `requireJwtAuth`
* (there is no guest chat — a signed-in IAM identity is required so the request
* bills to the user's own org via their forwarded bearer). Split from the parent
* router only so the completion middleware chain (ban/uaParser/config/limiters)
* applies to completions but not to management/CRUD routes.
* Guest-capable chat completion router.
*
* Auth here accepts either a real JWT or a guest token (`requireGuestOrJwtAuth`);
* when guest chat is off, or the token isn't a valid guest token, it falls through
* to the strict `requireJwtAuth`. `enforceGuestScope` then pins guests to the free
* Zen endpoint/model and strips every other capability (agents/tools/files/spec/
* preset), and `guestMessageLimiter` enforces the per-IP guest quota (real users
* are skipped). A signed-in user still bills to their own org via their forwarded
* bearer; a guest uses the shared, capped guest key (see custom/initialize.ts).
* Every OTHER agents route (management, CRUD, files) stays gated by the strict
* `requireJwtAuth` below, which rejects guest tokens.
*/
const RESERVED_CHAT_SUBPATHS = new Set(['stream', 'active', 'status', 'abort']);
@@ -56,10 +65,12 @@ chatRouter.use((req, res, next) => {
}
return next();
});
chatRouter.use(requireJwtAuth);
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);
@@ -73,6 +84,33 @@ 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 (and reachable because 'active' is a RESERVED_CHAT_SUBPATH deferred out of
* chatRouter) so the composer's bootstrap poll doesn't 401-loop for guests; every
* other agents route stays JWT-only and rejects guest tokens. Non-guests fall
* through to the authoritative `/chat/active` handler further down.
*/
router.get('/chat/active', requireGuestOrJwtAuth, async (req, res, next) => {
if (req.user?.guest === true) {
return res.json({ activeJobIds: [] });
}
return next();
});
/**
* Guest-safe SSE read-back: a guest MUST be able to read its OWN generation
* stream. All chat streaming is unified under this route; reached via the
* reserved-subpath deferral ('stream'), it is registered with
* `requireGuestOrJwtAuth` ABOVE the strict JWT guard so a guest token is accepted
* (the strict `jwt` strategy would 401 it → the streamed reply never reaches the
* guest browser → empty bubble). `streamHandler` (hoisted, defined below) pins
* every caller to their OWN job (`job.metadata.userId === req.user.id` → foreign
* 403, missing 404), so a guest can never read another principal's stream.
*/
router.get('/chat/stream/:streamId', requireGuestOrJwtAuth, streamHandler);
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
@@ -99,7 +137,7 @@ router.use('/', v1);
* @description Sends sync event with resume state, replays missed chunks, then streams live
* @query resume=true - Indicates this is a reconnection (sends sync event)
*/
router.get('/chat/stream/:streamId', async (req, res) => {
async function streamHandler(req, res) {
const { streamId } = req.params;
const isResume = req.query.resume === 'true';
@@ -179,7 +217,7 @@ router.get('/chat/stream/:streamId', async (req, res) => {
logger.debug(`[AgentStream] Client disconnected from ${streamId}`);
result.unsubscribe();
});
});
}
/**
* @route GET /chat/active
+4
View File
@@ -12,6 +12,7 @@ const {
} = require('~/server/controllers/TwoFactorController');
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
const { logoutController } = require('~/server/controllers/auth/LogoutController');
const { guestTokenController } = require('~/server/controllers/auth/GuestController');
const middleware = require('~/server/middleware');
const router = express.Router();
@@ -22,6 +23,9 @@ const router = express.Router();
// refresh, 2FA and the graph token — all guarded by IAM-issued JWTs.
router.post('/logout', middleware.requireJwtAuth, logoutController);
router.post('/refresh', refreshController);
// Anonymous guest-preview token. Gated on ALLOW_GUEST_CHAT inside the controller
// (404 when off) and per-IP rate-limited so tokens can't be spam-minted.
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
router.get('/2fa/enable', middleware.requireJwtAuth, enable2FA);
router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
+5
View File
@@ -5,6 +5,7 @@ const { isEnabled, getBalanceConfig } = require('@hanzochat/api');
const { Constants, CacheKeys, defaultSocialLogins } = require('@hanzochat/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,
+11 -2
View File
@@ -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');
@@ -26,8 +27,16 @@ const assistantClients = {
const router = express.Router();
/** Conversation list — JWT-only (no guest chat). */
router.get('/', requireJwtAuth, 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; every
* mutation / read-by-id route below stays JWT-only and rejects guest tokens.
*/
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);
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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;
+3 -3
View File
@@ -3,12 +3,12 @@ const {
updateFavoritesController,
getFavoritesController,
} = require('~/server/controllers/FavoritesController');
const { requireJwtAuth } = require('~/server/middleware');
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
const router = express.Router();
// Favorites — JWT-only (no guest chat).
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;
+2 -1
View File
@@ -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);
+118
View File
@@ -0,0 +1,118 @@
const { isEnabled } = require('@hanzochat/api');
const { EModelEndpoint } = require('@hanzochat/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 = 'zen5-flash';
/**
* 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 `/v1/chat/user` response: the ephemeral principal only.
* Mirrors the safe-field shape the client expects (no password/totp/email/db id).
*
* @param {{ id: string }} principal
* @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,
};
+120
View File
@@ -0,0 +1,120 @@
jest.mock('@hanzochat/api', () => ({
isEnabled: (value) => value === 'true' || value === true,
}));
jest.mock('@hanzochat/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'] });
});
});
+30
View File
@@ -0,0 +1,30 @@
const removePorts = require('./removePorts');
/**
* Resolves the real client IP for per-IP guest rate limiting.
*
* hanzo.chat is served behind Cloudflare → the DO LB → the ingress. With that
* many hops, Express `req.ip` (via `trust proxy`) is not reliably the visitor's
* address, which would let anonymous users share/reset their free-message bucket
* (or collapse everyone into one bucket). Cloudflare always sets
* `CF-Connecting-IP` to the true originating client and — unlike a
* client-supplied `X-Forwarded-For` entry — a browser cannot forge it through
* the CF edge. Prefer it; fall back to the trust-proxy-resolved `req.ip` when the
* request did not transit Cloudflare (e.g. in-cluster/local).
*
* The returned string is the SOLE identity the guest quota keys on, so it must be
* stable across guest tokens, cookie clears, and incognito sessions from the same
* network origin.
*
* @param {import('express').Request} req
* @returns {string}
*/
const guestClientIp = (req) => {
const cf = req.headers?.['cf-connecting-ip'];
if (typeof cf === 'string' && cf.trim()) {
return cf.trim();
}
return removePorts(req);
};
module.exports = guestClientIp;
+2
View File
@@ -1,4 +1,5 @@
const removePorts = require('./removePorts');
const guestClientIp = require('./guestClientIp');
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const queue = require('./queue');
@@ -7,6 +8,7 @@ const files = require('./files');
module.exports = {
...handleText,
removePorts,
guestClientIp,
sendEmail,
...files,
...queue,
+5 -5
View File
@@ -13,11 +13,11 @@ const jwtLogin = () =>
async (payload, done) => {
try {
/**
* Guest chat is removed (every request requires a signed-in IAM identity).
* A legacy guest token left in a browser after deploy still carries
* `guest: true` and a synthetic `guest_<uuid>` id that is NOT a Mongo
* ObjectId — reject it cleanly here (401) rather than letting `getUserById`
* throw a Mongoose CastError (→ 500). Fail closed.
* 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);
@@ -210,3 +210,55 @@ describe('initializeCustom SSRF guard wiring', () => {
expect(mockGetOpenAIConfig).not.toHaveBeenCalled();
});
});
describe('initializeCustom guest principal billing (shared capped key)', () => {
const OPENID_BEARER_SENTINEL = '{{CHAT_OPENID_TOKEN}}';
const ORIGINAL_ENV = { ...process.env };
const guestParams = () => {
const params = createParams({
apiKey: OPENID_BEARER_SENTINEL,
baseURL: 'https://api.example.com/v1',
});
params.req.user = { id: 'guest_abc', guest: true } as unknown as typeof params.req.user;
params.req.body = { model: 'zen5-flash' };
return params;
};
beforeEach(() => {
jest.clearAllMocks();
delete process.env.GUEST_API_KEY;
delete process.env.HANZO_API_KEY;
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
it('routes a guest to the shared GUEST_API_KEY, never a per-user bearer', async () => {
process.env.GUEST_API_KEY = 'hk-guest-capped';
await initializeCustom(guestParams());
expect(mockGetOpenAIConfig).toHaveBeenCalledWith(
'hk-guest-capped',
expect.any(Object),
'test-custom',
);
});
it('falls back to HANZO_API_KEY when GUEST_API_KEY is unset', async () => {
process.env.HANZO_API_KEY = 'hk-fallback';
await initializeCustom(guestParams());
expect(mockGetOpenAIConfig).toHaveBeenCalledWith(
'hk-fallback',
expect.any(Object),
'test-custom',
);
});
it('fails closed for a guest when no guest key is configured', async () => {
await expect(initializeCustom(guestParams())).rejects.toThrow(
'Guest chat is temporarily unavailable',
);
expect(mockGetOpenAIConfig).not.toHaveBeenCalled();
});
});
+27 -10
View File
@@ -114,16 +114,33 @@ export async function initializeCustom({
// whose membership the gateway checks it against.
let tenantHeaders: Record<string, string> | undefined;
if (apiKey === OPENID_BEARER_SENTINEL) {
const bearer = resolveTenantBearer(req as unknown as Parameters<typeof resolveTenantBearer>[0]);
if (!bearer) {
throw new Error('Sign in with Hanzo to chat — your Hanzo account funds this request.');
}
apiKey = bearer;
const activeOrg = resolveActiveOrg(
req as unknown as Parameters<typeof resolveActiveOrg>[0],
);
if (activeOrg) {
tenantHeaders = { 'X-Org-Id': activeOrg };
const isGuest = (req.user as { guest?: boolean } | undefined)?.guest === true;
if (isGuest) {
// Anonymous guest preview: there is NO IAM identity to forward and NO org to
// bill. Use the shared, capped guest gateway key — the guest key's OWN org is
// metered + capped at api.hanzo.ai (402 when exhausted, surfaced by
// wrapHanzoGatewayFetch below), never a real user's org. Per-user hk- billing
// is skipped by construction: a guest carries no bearer and no active org, so
// NO `X-Org-Id` is sent. The guest key never leaves the server. `GUEST_API_KEY`
// (the KMS `chat-guest-key`) is preferred; `HANZO_API_KEY` is the dev fallback.
// Fail closed if neither is configured.
const guestKey = process.env.GUEST_API_KEY || process.env.HANZO_API_KEY || '';
if (!guestKey) {
throw new Error('Guest chat is temporarily unavailable.');
}
apiKey = guestKey;
} else {
const bearer = resolveTenantBearer(
req as unknown as Parameters<typeof resolveTenantBearer>[0],
);
if (!bearer) {
throw new Error('Sign in with Hanzo to chat — your Hanzo account funds this request.');
}
apiKey = bearer;
const activeOrg = resolveActiveOrg(req as unknown as Parameters<typeof resolveActiveOrg>[0]);
if (activeOrg) {
tenantHeaders = { 'X-Org-Id': activeOrg };
}
}
}