mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
Re-adds the deliberately-deleted (ee1d712254) 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.
79 lines
2.3 KiB
JavaScript
79 lines
2.3 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|