Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9c11e9cb4 | ||
|
|
1d9b5bcc44 | ||
|
|
6c0f36eeb2 | ||
|
|
5fa5b7a5b1 |
@@ -0,0 +1,150 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
/**
|
||||
* Task #108 — guest streamed-reply 401.
|
||||
*
|
||||
* All chat streaming (incl. the guest "Hanzo" endpoint) is unified under
|
||||
* `GET /chat/stream/:streamId`. The bug: that route sat BELOW the blanket
|
||||
* `router.use(requireJwtAuth)`, whose `jwt` strategy rejects guest tokens by
|
||||
* design (`payload.guest === true → 401`). So a guest could START a generation
|
||||
* (the completion router accepts guests) but 401'd reading its own stream back →
|
||||
* empty bubble. The fix registers the route with `requireGuestOrJwtAuth` ABOVE
|
||||
* the strict guard; the handler's `job.metadata.userId !== req.user.id` ownership
|
||||
* check is the security boundary that keeps each guest pinned to its own job.
|
||||
*
|
||||
* This suite is fully mocked (no real winston/data-schemas/cloud client) so it
|
||||
* exercises ONLY the guest-vs-jwt gating + ownership of the read-back route.
|
||||
*/
|
||||
|
||||
const mockGenerationJobManager = {
|
||||
getJob: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
getResumeState: jest.fn(),
|
||||
getActiveJobIdsForUser: jest.fn().mockResolvedValue([]),
|
||||
abortJob: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: jest.fn().mockReturnValue(false),
|
||||
GenerationJobManager: mockGenerationJobManager,
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({ saveMessage: jest.fn() }));
|
||||
|
||||
jest.mock('~/server/routes/agents/chat', () => require('express').Router());
|
||||
jest.mock('~/server/routes/agents/v1', () => ({ v1: require('express').Router() }));
|
||||
jest.mock('~/server/routes/agents/openai', () => require('express').Router());
|
||||
jest.mock('~/server/routes/agents/responses', () => require('express').Router());
|
||||
jest.mock('~/server/routes/agents/cloud', () => require('express').Router());
|
||||
|
||||
// Per-test auth state. `mockGuest` set => the request carries a guest token.
|
||||
let mockUserId = 'user-123';
|
||||
let mockGuest = null;
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
uaParser: (req, res, next) => next(),
|
||||
checkBan: (req, res, next) => next(),
|
||||
configMiddleware: (req, res, next) => next(),
|
||||
messageIpLimiter: (req, res, next) => next(),
|
||||
messageUserLimiter: (req, res, next) => next(),
|
||||
enforceGuestScope: (req, res, next) => next(),
|
||||
guestMessageLimiter: (req, res, next) => next(),
|
||||
// Faithful to the real `jwt` strategy: guest tokens are rejected (401).
|
||||
requireJwtAuth: (req, res, next) => {
|
||||
if (mockGuest) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
req.user = { id: mockUserId };
|
||||
return next();
|
||||
},
|
||||
// Faithful to `requireGuestOrJwtAuth`: a guest token yields a guest principal,
|
||||
// otherwise it defers to the jwt path.
|
||||
requireGuestOrJwtAuth: (req, res, next) => {
|
||||
if (mockGuest) {
|
||||
req.user = { id: mockGuest, guest: true };
|
||||
return next();
|
||||
}
|
||||
req.user = { id: mockUserId };
|
||||
return next();
|
||||
},
|
||||
}));
|
||||
|
||||
const agentsRouter = require('../index');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/agents', agentsRouter);
|
||||
|
||||
function subscribeResolvesDone() {
|
||||
mockGenerationJobManager.subscribe.mockImplementation((_id, _onEvent, onDone) => {
|
||||
process.nextTick(() => onDone({ done: true }));
|
||||
return { unsubscribe: jest.fn() };
|
||||
});
|
||||
}
|
||||
|
||||
describe('GET /chat/stream/:streamId — guest read-back (task #108)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUserId = 'user-123';
|
||||
mockGuest = null;
|
||||
mockGenerationJobManager.getActiveJobIdsForUser.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('lets a guest read back the stream of ITS OWN job (200, not 401)', async () => {
|
||||
mockGuest = 'guest-abc';
|
||||
subscribeResolvesDone();
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
metadata: { userId: 'guest-abc' },
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/agents/chat/stream/job-1');
|
||||
|
||||
// Regression guard: before the fix a guest hit the strict `requireJwtAuth`
|
||||
// (registered globally below the route) and got 401 here.
|
||||
expect(res.status).not.toBe(401);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockGenerationJobManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns 403 when a guest requests ANOTHER principal's job (ownership boundary)", async () => {
|
||||
mockGuest = 'guest-abc';
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
metadata: { userId: 'someone-else' },
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/agents/chat/stream/job-1');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
expect(mockGenerationJobManager.subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 to a guest when the job does not exist', async () => {
|
||||
mockGuest = 'guest-abc';
|
||||
mockGenerationJobManager.getJob.mockResolvedValue(null);
|
||||
|
||||
const res = await request(app).get('/agents/chat/stream/missing');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockGenerationJobManager.subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still serves an authenticated user their own stream (200)', async () => {
|
||||
subscribeResolvesDone();
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
metadata: { userId: 'user-123' },
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/agents/chat/stream/job-1');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockGenerationJobManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { resolveActiveOrg } = require('@hanzochat/api');
|
||||
const { requireJwtAuth, cloudAgentLimiter } = require('~/server/middleware');
|
||||
const { getCloudAgentsClient, AGENT_NAME_RE } = require('~/server/services/CloudAgentsClient');
|
||||
|
||||
@@ -155,7 +156,7 @@ router.get('/', async (req, res) => {
|
||||
return res.status(401).json({ error: 'cloud agents require hanzo.id sign-in' });
|
||||
}
|
||||
try {
|
||||
const data = await client.list(bearer);
|
||||
const data = await client.list(bearer, resolveActiveOrg(req));
|
||||
return res.json({ ...data, enabled: true });
|
||||
} catch (err) {
|
||||
return sendCloudError(res, err, 'list');
|
||||
@@ -173,7 +174,7 @@ router.get('/:name', async (req, res) => {
|
||||
return res.status(401).json({ error: 'cloud agents require hanzo.id sign-in' });
|
||||
}
|
||||
try {
|
||||
const data = await client.get(bearer, req.params.name);
|
||||
const data = await client.get(bearer, req.params.name, resolveActiveOrg(req));
|
||||
return res.json(data);
|
||||
} catch (err) {
|
||||
return sendCloudError(res, err, 'get');
|
||||
@@ -191,7 +192,7 @@ router.post('/:name/run', async (req, res) => {
|
||||
return res.status(401).json({ error: 'cloud agents require hanzo.id sign-in' });
|
||||
}
|
||||
try {
|
||||
const run = await client.run(bearer, req.params.name, req.body?.input ?? '');
|
||||
const run = await client.run(bearer, req.params.name, req.body?.input ?? '', resolveActiveOrg(req));
|
||||
return res.json(run);
|
||||
} catch (err) {
|
||||
return sendCloudError(res, err, 'run');
|
||||
|
||||
@@ -95,6 +95,18 @@ router.get('/chat/active', requireGuestOrJwtAuth, async (req, res, next) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Guest-safe SSE stream read-back. Mounted with `requireGuestOrJwtAuth` BEFORE
|
||||
* the strict `requireJwtAuth` below so a guest can read back its OWN generation's
|
||||
* stream — the strict `jwt` strategy rejects guest tokens by design (401), which
|
||||
* left the guest with an empty bubble even though generation ran server-side. The
|
||||
* handler's ownership check (`job.metadata.userId !== req.user.id`) is the security
|
||||
* boundary: a guest stays pinned to its own ephemeral job (a foreign job is 403,
|
||||
* a missing one 404). `streamHandler` is a hoisted declaration defined below.
|
||||
* Every other agents route stays JWT-only.
|
||||
*/
|
||||
router.get('/chat/stream/:streamId', requireGuestOrJwtAuth, streamHandler);
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
router.use(uaParser);
|
||||
@@ -109,19 +121,16 @@ router.use('/cloud', cloud);
|
||||
|
||||
router.use('/', v1);
|
||||
|
||||
/**
|
||||
* Stream endpoints - mounted before chatRouter to bypass rate limiters
|
||||
* These are GET requests and don't need message body validation or rate limiting
|
||||
*/
|
||||
|
||||
/**
|
||||
* @route GET /chat/stream/:streamId
|
||||
* @desc Subscribe to an ongoing generation job's SSE stream with replay support
|
||||
* @access Private
|
||||
* @access Private (guest-or-JWT) — registered above the strict `requireJwtAuth`
|
||||
* guard so guests can read back their own stream; bypasses rate limiters (a GET
|
||||
* with no message body). Hoisted so the early registration can reference it.
|
||||
* @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';
|
||||
|
||||
@@ -201,7 +210,7 @@ router.get('/chat/stream/:streamId', async (req, res) => {
|
||||
logger.debug(`[AgentStream] Client disconnected from ${streamId}`);
|
||||
result.unsubscribe();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /chat/active
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// The authenticated principal requireJwtAuth injects for each test.
|
||||
let mockCurrentUser = null;
|
||||
|
||||
jest.mock('~/server/controllers/UserController', () => ({
|
||||
updateUserPluginsController: (req, res) => res.json({}),
|
||||
resendVerificationController: (req, res) => res.json({}),
|
||||
getTermsStatusController: (req, res) => res.json({}),
|
||||
acceptTermsController: (req, res) => res.json({}),
|
||||
verifyEmailController: (req, res) => res.json({}),
|
||||
deleteUserController: (req, res) => res.json({}),
|
||||
getUserController: (req, res) => res.json({}),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
requireJwtAuth: (req, _res, next) => {
|
||||
req.user = mockCurrentUser;
|
||||
next();
|
||||
},
|
||||
requireGuestOrJwtAuth: (req, _res, next) => {
|
||||
req.user = mockCurrentUser;
|
||||
next();
|
||||
},
|
||||
verifyEmailLimiter: (req, _res, next) => next(),
|
||||
configMiddleware: (req, _res, next) => next(),
|
||||
canDeleteAccount: (req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('./settings', () => require('express').Router());
|
||||
|
||||
// The route imports only ACTIVE_ORG_COOKIE from the package barrel; mock it so the
|
||||
// test stays a focused unit and doesn't boot the whole @hanzochat/api runtime
|
||||
// (logger/data-schemas). The constant's value + resolveActiveOrg's read of it are
|
||||
// covered by packages/api/src/endpoints/custom/activeOrg.spec.ts.
|
||||
jest.mock('@hanzochat/api', () => ({ ACTIVE_ORG_COOKIE: 'hanzo_active_org' }));
|
||||
|
||||
const { ACTIVE_ORG_COOKIE } = require('@hanzochat/api');
|
||||
const userRoutes = require('./user');
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/v1/chat/user', userRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
/** The Set-Cookie header for the active-org cookie, or undefined. */
|
||||
function activeOrgCookie(res) {
|
||||
return (res.headers['set-cookie'] ?? []).find((c) => c.startsWith(`${ACTIVE_ORG_COOKIE}=`));
|
||||
}
|
||||
|
||||
describe('POST /v1/chat/user/active-org', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
app = buildApp();
|
||||
mockCurrentUser = null;
|
||||
});
|
||||
|
||||
it('sets the httpOnly cookie for the caller’s home org (owner)', async () => {
|
||||
mockCurrentUser = { organization: 'acme', groups: ['beta'] };
|
||||
const res = await request(app).post('/v1/chat/user/active-org').send({ organization: 'acme' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ organization: 'acme' });
|
||||
const cookie = activeOrgCookie(res);
|
||||
expect(cookie).toContain(`${ACTIVE_ORG_COOKIE}=acme`);
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
});
|
||||
|
||||
it('sets the cookie for an org the caller belongs to via groups', async () => {
|
||||
mockCurrentUser = { organization: 'acme', groups: ['beta', 'gamma'] };
|
||||
const res = await request(app).post('/v1/chat/user/active-org').send({ organization: 'gamma' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ organization: 'gamma' });
|
||||
expect(activeOrgCookie(res)).toContain(`${ACTIVE_ORG_COOKIE}=gamma`);
|
||||
});
|
||||
|
||||
it('rejects (400) an org outside the caller’s memberships and sets no cookie', async () => {
|
||||
mockCurrentUser = { organization: 'acme', groups: ['beta'] };
|
||||
const res = await request(app).post('/v1/chat/user/active-org').send({ organization: 'evil' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not in your memberships/i);
|
||||
expect(activeOrgCookie(res)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects (400) a missing/blank organization', async () => {
|
||||
mockCurrentUser = { organization: 'acme', groups: ['beta'] };
|
||||
|
||||
const missing = await request(app).post('/v1/chat/user/active-org').send({});
|
||||
expect(missing.status).toBe(400);
|
||||
expect(activeOrgCookie(missing)).toBeUndefined();
|
||||
|
||||
const blank = await request(app).post('/v1/chat/user/active-org').send({ organization: ' ' });
|
||||
expect(blank.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a non-home org when the caller has no groups (fail closed)', async () => {
|
||||
mockCurrentUser = { organization: 'acme' };
|
||||
|
||||
const home = await request(app).post('/v1/chat/user/active-org').send({ organization: 'acme' });
|
||||
expect(home.status).toBe(200);
|
||||
|
||||
const other = await request(app).post('/v1/chat/user/active-org').send({ organization: 'beta' });
|
||||
expect(other.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const { ACTIVE_ORG_COOKIE } = require('@hanzochat/api');
|
||||
const {
|
||||
updateUserPluginsController,
|
||||
resendVerificationController,
|
||||
@@ -25,6 +26,24 @@ router.get('/', requireGuestOrJwtAuth, getUserController);
|
||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||
router.post('/active-org', requireJwtAuth, (req, res) => {
|
||||
// Pin the active org for downstream api.hanzo.ai calls. Fail closed: the org must
|
||||
// be in the caller's own membership set (owner + IAM `groups`); the gateway then
|
||||
// re-validates X-Org-Id ∈ the verified membership set (HIP-0026), so this cookie
|
||||
// is only ever a selection HINT.
|
||||
const requested = String(req.body?.organization ?? '').trim();
|
||||
const allowed = new Set([req.user?.organization, ...(req.user?.groups ?? [])].filter(Boolean));
|
||||
if (!requested || !allowed.has(requested)) {
|
||||
return res.status(400).json({ error: 'organization not in your memberships' });
|
||||
}
|
||||
res.cookie(ACTIVE_ORG_COOKIE, requested, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
});
|
||||
return res.json({ organization: requested });
|
||||
});
|
||||
router.delete('/delete', requireJwtAuth, canDeleteAccount, configMiddleware, deleteUserController);
|
||||
router.post('/verify', verifyEmailController);
|
||||
router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);
|
||||
|
||||
@@ -94,21 +94,23 @@ class CloudAgentsClient {
|
||||
/**
|
||||
* List the caller's cloud agents.
|
||||
* @param {string} bearer - the caller's hanzo.id id_token
|
||||
* @param {string} [activeOrg] - the member's selected working org (X-Org-Id)
|
||||
* @returns {Promise<{agents: Array}>}
|
||||
*/
|
||||
async list(bearer) {
|
||||
return this._request('GET', '/v1/agents', bearer);
|
||||
async list(bearer, activeOrg) {
|
||||
return this._request('GET', '/v1/agents', bearer, undefined, activeOrg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one cloud agent (detail + recent runs).
|
||||
* @param {string} bearer
|
||||
* @param {string} name
|
||||
* @param {string} [activeOrg] - the member's selected working org (X-Org-Id)
|
||||
* @returns {Promise<Object>} cloud's AgentDetail
|
||||
*/
|
||||
async get(bearer, name) {
|
||||
async get(bearer, name, activeOrg) {
|
||||
const n = CloudAgentsClient.requireValidName(name);
|
||||
return this._request('GET', `/v1/agents/${encodeURIComponent(n)}`, bearer);
|
||||
return this._request('GET', `/v1/agents/${encodeURIComponent(n)}`, bearer, undefined, activeOrg);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,9 +120,10 @@ class CloudAgentsClient {
|
||||
* @param {string} bearer
|
||||
* @param {string} name
|
||||
* @param {string} input
|
||||
* @param {string} [activeOrg] - the member's selected working org (X-Org-Id)
|
||||
* @returns {Promise<Object>} cloud's RunResult
|
||||
*/
|
||||
async run(bearer, name, input) {
|
||||
async run(bearer, name, input, activeOrg) {
|
||||
const n = CloudAgentsClient.requireValidName(name);
|
||||
const body = (input ?? '').toString();
|
||||
// Byte length, not UTF-16 units — matches cloud's byte-based maxInput exactly
|
||||
@@ -130,9 +133,13 @@ class CloudAgentsClient {
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
return this._request('POST', `/v1/agents/${encodeURIComponent(n)}/run`, bearer, {
|
||||
input: body,
|
||||
});
|
||||
return this._request(
|
||||
'POST',
|
||||
`/v1/agents/${encodeURIComponent(n)}/run`,
|
||||
bearer,
|
||||
{ input: body },
|
||||
activeOrg,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,9 +147,11 @@ class CloudAgentsClient {
|
||||
* @param {string} path - one of the fixed templates above
|
||||
* @param {string} bearer - the caller's hanzo.id bearer (required)
|
||||
* @param {Object} [body]
|
||||
* @param {string} [activeOrg] - the member's selected working org; forwarded as
|
||||
* `X-Org-Id` for cloud to validate against the bearer's membership (HIP-0026)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async _request(method, path, bearer, body) {
|
||||
async _request(method, path, bearer, body, activeOrg) {
|
||||
if (!bearer) {
|
||||
// Fail secure: never fall back to an ambient/service credential — that
|
||||
// would run as the wrong principal. Absent a user bearer, deny.
|
||||
@@ -164,6 +173,9 @@ class CloudAgentsClient {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
};
|
||||
if (activeOrg) {
|
||||
headers['X-Org-Id'] = activeOrg;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('CloudAgentsClient', () => {
|
||||
});
|
||||
|
||||
describe('bearer forwarding + tenant model', () => {
|
||||
it('forwards the user bearer and NEVER sends an org header', async () => {
|
||||
it('forwards the user bearer and sends no org header when no active org is selected', async () => {
|
||||
const fetch = mockFetch({ body: { agents: [] } });
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
@@ -73,11 +73,28 @@ describe('CloudAgentsClient', () => {
|
||||
const { url, opts } = fetch.calls[0];
|
||||
expect(url).toBe('https://api.hanzo.ai/v1/agents');
|
||||
expect(opts.headers.Authorization).toBe(`Bearer ${BEARER}`);
|
||||
// Tenant isolation is cloud's job; chat must not assert an org.
|
||||
// No selection ⇒ no hint; cloud pins the tenant from the credential's home org.
|
||||
expect(opts.headers['X-Org-Id']).toBeUndefined();
|
||||
expect(opts.headers['X-Hanzo-Org']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards X-Org-Id when the member has selected an active org', async () => {
|
||||
const fetch = mockFetch({ body: { agents: [] } });
|
||||
global.fetch = fetch;
|
||||
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
|
||||
// list / get / run each thread the selected org through as `X-Org-Id`; the
|
||||
// gateway validates it against the bearer's membership (HIP-0026).
|
||||
await client.list(BEARER, 'acme');
|
||||
await client.get(BEARER, 'researcher', 'acme');
|
||||
await client.run(BEARER, 'researcher', 'hi', 'acme');
|
||||
|
||||
for (const { opts } of fetch.calls) {
|
||||
expect(opts.headers.Authorization).toBe(`Bearer ${BEARER}`);
|
||||
expect(opts.headers['X-Org-Id']).toBe('acme');
|
||||
}
|
||||
expect(fetch.calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('fails secure (401) with no fallback credential when bearer is missing', async () => {
|
||||
const fetch = mockFetch();
|
||||
global.fetch = fetch;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, memo, useRef } from 'react';
|
||||
import * as Select from '@ariakit/react/select';
|
||||
import { FileText, LogOut, CreditCard } from 'lucide-react';
|
||||
import { FileText, LogOut, CreditCard, Building2, FolderGit2 } from 'lucide-react';
|
||||
import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
|
||||
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
@@ -19,6 +19,25 @@ function AccountSettings() {
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Active org selection: the home org plus the other orgs the member belongs to
|
||||
// (IAM `groups`). Picking one pins `hanzo_active_org` server-side; the gateway
|
||||
// then scopes chat + billing to it (X-Org-Id, HIP-0026).
|
||||
const currentOrg = user?.organization ?? '';
|
||||
const otherOrgs = (user?.groups ?? []).filter((g) => g && g !== currentOrg);
|
||||
const switchOrg = async (organization: string) => {
|
||||
try {
|
||||
await fetch('/v1/chat/user/active-org', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ organization }),
|
||||
credentials: 'include',
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('[AccountSettings] org switch failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select.SelectProvider>
|
||||
<Select.Select
|
||||
@@ -49,6 +68,34 @@ function AccountSettings() {
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
||||
{user?.email ?? localize('com_nav_user')}
|
||||
</div>
|
||||
{currentOrg ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="ml-3 mr-2 py-1.5 text-xs" role="note">
|
||||
<div className="flex items-center gap-1.5 text-text-primary">
|
||||
<Building2 className="icon-sm opacity-70" aria-hidden="true" />
|
||||
<span className="truncate font-medium">{currentOrg}</span>
|
||||
</div>
|
||||
{user?.project ? (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-token-text-secondary">
|
||||
<FolderGit2 className="icon-sm" aria-hidden="true" />
|
||||
<span className="truncate">{user.project}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{otherOrgs.map((org) => (
|
||||
<Select.SelectItem
|
||||
key={org}
|
||||
value=""
|
||||
onClick={() => switchOrg(org)}
|
||||
className="select-item text-sm"
|
||||
>
|
||||
<Building2 className="icon-md opacity-70" aria-hidden="true" />
|
||||
{org}
|
||||
</Select.SelectItem>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
{startupConfig?.balance?.enabled === true && balanceQuery.data != null && (() => {
|
||||
const credits = balanceQuery.data.tokenCredits;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzochat/chat",
|
||||
"version": "0.9.17",
|
||||
"version": "0.9.18",
|
||||
"description": "Chat - Unified interface to Agents, LLMs, MCPs and any AI model or OpenAPI documented API with full RAG stack",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"workspaces": [
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { resolveActiveOrg, ACTIVE_ORG_COOKIE } from './activeOrg';
|
||||
|
||||
/** Build a minimal request carrying a raw Cookie header. */
|
||||
const reqWithCookie = (cookie?: string) => ({ headers: { cookie } });
|
||||
|
||||
describe('resolveActiveOrg', () => {
|
||||
it('returns the selected org from the hanzo_active_org cookie', () => {
|
||||
expect(resolveActiveOrg(reqWithCookie(`${ACTIVE_ORG_COOKIE}=acme`))).toBe('acme');
|
||||
});
|
||||
|
||||
it('picks the org out of a Cookie header carrying several pairs', () => {
|
||||
const cookie = `theme=dark; ${ACTIVE_ORG_COOKIE}=acme; token_provider=openid`;
|
||||
expect(resolveActiveOrg(reqWithCookie(cookie))).toBe('acme');
|
||||
});
|
||||
|
||||
it('URL-decodes the cookie value', () => {
|
||||
expect(resolveActiveOrg(reqWithCookie(`${ACTIVE_ORG_COOKIE}=acme%2Fteam`))).toBe('acme/team');
|
||||
});
|
||||
|
||||
it('returns null when the cookie is absent', () => {
|
||||
expect(resolveActiveOrg(reqWithCookie('theme=dark'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no Cookie header at all', () => {
|
||||
expect(resolveActiveOrg(reqWithCookie(undefined))).toBeNull();
|
||||
expect(resolveActiveOrg({ headers: {} })).toBeNull();
|
||||
expect(resolveActiveOrg({})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty cookie value (falls back to the home org)', () => {
|
||||
expect(resolveActiveOrg(reqWithCookie(`${ACTIVE_ORG_COOKIE}=`))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The active org a multi-org member has switched to, or null for their home org.
|
||||
*
|
||||
* A member selects a working org in the account menu; the choice is pinned in the
|
||||
* httpOnly `hanzo_active_org` cookie (set server-side by
|
||||
* `POST /v1/chat/user/active-org`, which admits ONLY an org in the caller's own
|
||||
* membership set). Chat forwards it as `X-Org-Id` on the on-behalf-of calls to
|
||||
* cloud (api.hanzo.ai) — both the chat-completion path (custom/initialize.ts) and
|
||||
* the cloud-agents path (server/routes/agents/cloud.js) — so the completion, and
|
||||
* its billing, lands on the chosen org.
|
||||
*
|
||||
* This value is a HINT, not an authority: the gateway (HIP-0026) re-derives the
|
||||
* tenant from the verified credential and admits a sent `X-Org-Id` only when it
|
||||
* is in that member's set, pinning to `owner` otherwise. A forged cookie can
|
||||
* never reach an org the caller isn't a member of — cloud is the enforcer, this
|
||||
* is the selection. Returns null (home org, no header) when unset.
|
||||
*/
|
||||
|
||||
/** The name of the cookie that pins a member's selected working org. */
|
||||
export const ACTIVE_ORG_COOKIE = 'hanzo_active_org';
|
||||
|
||||
/** The request shape this resolver needs — a subset of the Express request. */
|
||||
export interface ActiveOrgRequest {
|
||||
headers?: {
|
||||
cookie?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a Cookie header into a flat map (RFC 6265 name=value pairs, no dependency). */
|
||||
function parseCookies(header?: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!header) {
|
||||
return out;
|
||||
}
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
const key = part.slice(0, idx).trim();
|
||||
if (key && out[key] === undefined) {
|
||||
out[key] = decodeURIComponent(part.slice(idx + 1).trim());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active org the caller has selected, or null for their home org.
|
||||
* Reads only the `hanzo_active_org` cookie — the gateway validates it against the
|
||||
* caller's membership, so an unset/forged value simply falls back to the home org.
|
||||
*/
|
||||
export function resolveActiveOrg(req: ActiveOrgRequest): string | null {
|
||||
const org = parseCookies(req.headers?.cookie)[ACTIVE_ORG_COOKIE];
|
||||
return org ? org : null;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './activeOrg';
|
||||
export * from './config';
|
||||
export * from './hanzoCloudKey';
|
||||
export * from './hanzoGatewayFetch';
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveHanzoCloudKey,
|
||||
type HanzoBillingUser,
|
||||
} from './hanzoCloudKey';
|
||||
import { resolveActiveOrg } from './activeOrg';
|
||||
import { wrapHanzoGatewayFetch, type GatewayFetch } from './hanzoGatewayFetch';
|
||||
|
||||
const { PROXY } = process.env;
|
||||
@@ -179,6 +180,18 @@ export async function initializeCustom({
|
||||
|
||||
const customOptions = buildCustomOptions(endpointConfig, appConfig, endpointTokenConfig);
|
||||
|
||||
// The active org a multi-org member has switched to rides as `X-Org-Id` to the
|
||||
// Hanzo Cloud gateway, which validates it against the request credential's
|
||||
// membership (HIP-0026) and scopes the completion — and its billing — to that
|
||||
// org. Unset ⇒ no header ⇒ the credential's home org, unchanged.
|
||||
const activeOrg = resolveActiveOrg(req as unknown as Parameters<typeof resolveActiveOrg>[0]);
|
||||
if (activeOrg) {
|
||||
customOptions.headers = {
|
||||
...((customOptions.headers as Record<string, string> | undefined) ?? {}),
|
||||
'X-Org-Id': activeOrg,
|
||||
};
|
||||
}
|
||||
|
||||
const clientOptions: Record<string, unknown> = {
|
||||
reverseProxyUrl: baseURL ?? null,
|
||||
proxy: PROXY ?? null,
|
||||
|
||||
@@ -206,6 +206,16 @@ export type TUser = {
|
||||
personalization?: {
|
||||
memories?: boolean;
|
||||
};
|
||||
/** Home org from the Hanzo IAM `owner` claim. */
|
||||
organization?: string;
|
||||
/** User's title/role within their org. */
|
||||
organizationTitle?: string;
|
||||
/** User's tag within their org (e.g. 'founder', 'member'). */
|
||||
organizationTag?: string;
|
||||
/** Org's default project from the IAM `project` claim; the gateway mints X-Project-Id from it. */
|
||||
project?: string;
|
||||
/** Org memberships from the IAM `groups` claim — the set the user may switch among. */
|
||||
groups?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user