Compare commits

...
Author SHA1 Message Date
Hanzo Dev d9c11e9cb4 chat: org switcher UI — active-org picker in the account menu
The account menu lists the member's home org (organization) plus the other orgs
they belong to (IAM groups); picking one POSTs /v1/chat/user/active-org and
reloads so the next chat + cloud-agent calls ride the new X-Org-Id. TUser gains
the IAM org fields (organization/organizationTitle/organizationTag/project/groups)
the picker reads.
2026-07-14 16:13:57 -07:00
Hanzo Dev 1d9b5bcc44 chat: org switcher — forward the selected active org as X-Org-Id to cloud
resolveActiveOrg reads the hanzo_active_org cookie — a selection HINT; the
gateway re-validates it against the caller's membership (HIP-0026) — and both
Hanzo Cloud seams forward it as X-Org-Id: the chat-completion initialize path
(defaultHeaders) and CloudAgentsClient list/get/run. POST /v1/chat/user/active-org
admits only an org in the caller's own membership set (owner + IAM groups) and
pins it in the httpOnly cookie.

Tests: resolveActiveOrg cookie parsing; active-org route validation + membership
rejection + cookie; CloudAgentsClient X-Org-Id forwarding.
2026-07-14 16:13:57 -07:00
zandGitHub 6c0f36eeb2 Merge pull request #69 from hanzoai/fix/guest-stream-401
fix(chat/guest): let guests read back their own SSE stream (task #108)
2026-07-10 11:12:31 -07:00
hanzo-dev 5fa5b7a5b1 fix(chat/guest): let guests read back their own SSE stream (task #108)
All chat streaming (incl. the guest "Hanzo" endpoint) is unified under
`GET /v1/chat/agents/chat/stream/:streamId`, a route that sat BELOW the
blanket `router.use(requireJwtAuth)`. The strict `jwt` strategy rejects
guest tokens by design (`payload.guest === true → 401`), so an anonymous
guest could START a generation (the completion router accepts guests) but
401'd reading its own stream back — the reply rendered as an empty bubble
even though generation ran server-side.

Register the stream route with `requireGuestOrJwtAuth` ABOVE the strict
guard, mirroring the existing `/chat/active` guest-safe poll. The handler's
`job.metadata.userId !== req.user.id` ownership check is unchanged and is the
security boundary: a guest stays pinned to its own ephemeral job (createJob
stamps metadata.userId = req.user.id = the guest token's per-token id), a
foreign job is 403, a missing one 404. No cross-user leak; every other agents
route stays JWT-only. The handler is now a hoisted `streamHandler` declaration
so the early registration references it without duplication.

Adds guestStream.spec.js (4 green): guest reads own stream (200, not 401),
403 on a foreign job, 404 on a missing job, authed user unaffected. Verified
the negative: with the route below the guard the guest cases return 401.

Bump 0.9.17 -> 0.9.18.

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ
2026-07-10 11:10:04 -07:00
14 changed files with 503 additions and 24 deletions
@@ -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);
});
});
+4 -3
View File
@@ -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');
+17 -8
View File
@@ -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
+111
View File
@@ -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 callers 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 callers 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);
});
});
+19
View File
@@ -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);
+21 -9
View File
@@ -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);
+19 -2
View File
@@ -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;
+48 -1
View File
@@ -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
View File
@@ -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,
+10
View File
@@ -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;
};