From 9faed59f2323bc2b9bd26dc7a31cc95135fc6fbd Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 22 Sep 2026 10:43:36 +0800 Subject: [PATCH] feat(context): verify product session scope --- src/chat/sessionBridge.ts | 10 ++- src/config.ts | 2 + src/context/localContext.ts | 57 ++++++++++++++++- src/product/server-auth-client.ts | 65 +++++++++++++++++++ src/routes/chat.ts | 33 +++++++++- src/routes/chatAuxiliaryRoutes.ts | 47 +++++++------- src/routes/chatInteractionRoutes.ts | 12 ++-- src/routes/chatSessionRoutes.ts | 30 ++++----- src/routes/chatStreamRoute.ts | 10 ++- src/sessions/metadataStore.ts | 18 +++++- tests/context/productContext.test.ts | 94 ++++++++++++++++++++++++++++ tests/sessions/metadataStore.test.ts | 36 ++++++++++- 12 files changed, 358 insertions(+), 56 deletions(-) create mode 100644 src/product/server-auth-client.ts create mode 100644 tests/context/productContext.test.ts diff --git a/src/chat/sessionBridge.ts b/src/chat/sessionBridge.ts index 623cc69..4608bdf 100644 --- a/src/chat/sessionBridge.ts +++ b/src/chat/sessionBridge.ts @@ -15,10 +15,12 @@ export type SessionBinding = { }; export type SessionContext = { + actorKey?: string; clientSessionId: string; network?: string; projectId?: string; userId?: string; + projectKey?: string; }; export type ChatRequestContext = SessionContext & { @@ -38,6 +40,8 @@ export class ChatSessionBridge { projectId?: string; traceId?: string; userId?: string; + actorKey?: string; + projectKey?: string; }): Promise<{ binding: SessionBinding; requestContext: ChatRequestContext; @@ -139,19 +143,21 @@ export class ChatSessionBridge { } private buildRequestContext(context: { + actorKey?: string; sessionId?: string; network?: string; projectId?: string; traceId?: string; userId?: string; + projectKey?: string; }): ChatRequestContext { const sessionId = context.sessionId?.trim(); return { clientSessionId: sessionId || this.createClientSessionId(), - actorKey: toActorKey(context.userId), + actorKey: context.actorKey ?? toActorKey(`local:${context.userId ?? "local-user"}`), network: context.network, projectId: context.projectId, - projectKey: toProjectKey(context.projectId), + projectKey: context.projectKey ?? toProjectKey(`local:${context.projectId ?? "local-project"}`), traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`, userId: context.userId?.trim(), }; diff --git a/src/config.ts b/src/config.ts index c4116e0..f48fd8d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -46,6 +46,8 @@ const envSchema = z PORT: z.coerce.number().int().positive().default(8787), // HTTP 服务监听地址。 HOST: z.string().default("0.0.0.0"), + // Product Server base URL used to verify browser identity and project scope. + TJWATER_SERVER_BASE_URL: z.string().url().default("http://127.0.0.1:8000"), // Pino 日志级别。 LOG_LEVEL: z.string().default("info"), // LLM 工具/技能调用审计日志路径。 diff --git a/src/context/localContext.ts b/src/context/localContext.ts index 2f148cf..c382dfd 100644 --- a/src/context/localContext.ts +++ b/src/context/localContext.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { Request } from "express"; +import { ProductServerClient } from "../product/server-auth-client.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js"; export type LocalAgentContext = { @@ -15,17 +16,69 @@ export type LocalAgentContext = { const DEFAULT_USER_ID = process.env.AGENT_LOCAL_USER_ID ?? "local-user"; const DEFAULT_PROJECT_ID = process.env.AGENT_LOCAL_PROJECT_ID ?? "local-project"; const DEFAULT_NETWORK = process.env.AGENT_LOCAL_NETWORK ?? "local-network"; +const trustedContexts = new WeakMap(); +const productAuthClient = new ProductServerClient(); export const getLocalAgentContext = (req?: Request): LocalAgentContext => { + if (req) { + const trusted = trustedContexts.get(req); + if (trusted) return { ...trusted }; + } const userId = req?.header("x-agent-local-user")?.trim() || DEFAULT_USER_ID; const projectId = req?.header("x-agent-local-project")?.trim() || DEFAULT_PROJECT_ID; return { - actorKey: toActorKey(userId), + actorKey: toActorKey(`local:${userId}`), network: req?.header("x-agent-local-network")?.trim() || DEFAULT_NETWORK, projectId, - projectKey: toProjectKey(projectId), + projectKey: toProjectKey(`local:${projectId}`), traceId: req?.header("x-trace-id")?.trim() || `trace-${randomUUID().slice(0, 12)}`, userId, }; }; + +export class ProductContextError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +export const attachTrustedAgentContext = async (req: Request) => { + const authorizationHeader = req.header("authorization")?.trim(); + const projectHeader = req.header("x-project-id")?.trim(); + const hasProductCredential = Boolean(authorizationHeader || projectHeader); + if (!hasProductCredential) { + trustedContexts.set(req, getLocalAgentContext()); + return; + } + const authorization = normalizeBearerAuthorization(authorizationHeader); + if (!authorization || !projectHeader) { + throw new ProductContextError(401, "valid Bearer authorization and X-Project-Id are required"); + } + try { + const verified = await productAuthClient.authenticate({ + authorization, + projectId: projectHeader, + }); + trustedContexts.set(req, { + actorKey: toActorKey(`product:${verified.user_id}`), + network: verified.network, + projectId: verified.project_id, + projectKey: toProjectKey(`product:${verified.project_id}`), + traceId: req.header("x-trace-id")?.trim() || `trace-${randomUUID().slice(0, 12)}`, + userId: verified.user_id, + }); + } catch (error) { + const status = error && typeof error === "object" && "status" in error && + typeof error.status === "number" ? error.status : 503; + throw new ProductContextError(status, "product identity verification failed"); + } +}; + +const normalizeBearerAuthorization = (value?: string) => { + const normalized = value?.trim(); + return normalized && /^Bearer\s+\S+$/i.test(normalized) ? normalized : undefined; +}; diff --git a/src/product/server-auth-client.ts b/src/product/server-auth-client.ts new file mode 100644 index 0000000..7613765 --- /dev/null +++ b/src/product/server-auth-client.ts @@ -0,0 +1,65 @@ +import { config } from "../config.js"; +import type { components } from "../generated/server-api.js"; + +export type ProductCredential = { + authorization: string; + projectId: string; +}; + +export type AgentAuthContext = components["schemas"]["AgentAuthContextResponse"]; + +export class ProductServerClient { + constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {} + + async authenticate(credential: ProductCredential): Promise { + const url = new URL("/api/v1/agent-auth-context", this.baseUrl); + let response: Response; + try { + response = await fetch(url, { + headers: { + Accept: "application/json", + Authorization: credential.authorization, + "X-Project-Id": credential.projectId, + }, + }); + } catch { + throw new ProductServerClientError(503, "TJWater Server unavailable"); + } + + const text = await response.text(); + const parsed = parseJson(text); + if (!response.ok) { + throw new ProductServerClientError( + response.status, + readErrorMessage(parsed, "TJWater Server rejected identity verification"), + ); + } + if (!parsed || typeof parsed !== "object") { + throw new ProductServerClientError(502, "TJWater Server returned invalid JSON"); + } + return parsed as AgentAuthContext; + } +} + +export class ProductServerClientError extends Error { + constructor(readonly status: number, message: string) { + super(message); + } +} + +const parseJson = (text: string): unknown => { + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +}; + +const readErrorMessage = (value: unknown, fallback: string) => { + if (!value || typeof value !== "object") return fallback; + const record = value as Record; + if (typeof record.detail === "string") return record.detail; + if (typeof record.message === "string") return record.message; + return fallback; +}; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index a945064..025d572 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -13,6 +13,11 @@ import { type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { uiRegistryResponse } from "../uiEnvelope/registry.js"; +import { + attachTrustedAgentContext, + getLocalAgentContext, + ProductContextError, +} from "../context/localContext.js"; import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js"; import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js"; import { createChatRunState } from "./chatRunState.js"; @@ -48,6 +53,18 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => { const chatRouter = Router(); const { activeRuns, lastRunStatuses } = runState; + chatRouter.use((req, res, next) => { + void attachTrustedAgentContext(req) + .then(() => next()) + .catch((error) => { + const status = error instanceof ProductContextError ? error.status : 503; + res.status(status).json({ + code: "PRODUCT_CONTEXT_UNAVAILABLE", + message: error instanceof Error ? error.message : "product identity verification failed", + }); + }); + }); + chatRouter.get("/models", (_req, res) => { res.json({ default_model: resolveDefaultModel(config.OPENCODE_MODEL), @@ -63,13 +80,27 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => { res.json(frontendActionRegistryResponse); }); - chatRouter.post("/frontend-actions/:action_id/result", (req, res) => { + chatRouter.post("/frontend-actions/:action_id/result", async (req, res) => { const sessionId = req.header("x-agent-session-id")?.trim() ?? ""; if (!sessionId || req.params.action_id !== req.body?.actionId) { res.status(400).json({ message: "invalid frontend action result" }); return; } try { + const context = getLocalAgentContext(req); + const sessionRecord = await sessionMetadataStore.get( + { + actorKey: context.actorKey, + projectId: context.projectId, + projectKey: context.projectKey, + userId: context.userId, + }, + sessionId, + ); + if (!sessionRecord) { + res.status(404).json({ message: "session not found" }); + return; + } const outcome = frontendActionCoordinator.submitResult(sessionId, req.body); res.json({ accepted: true, diff --git a/src/routes/chatAuxiliaryRoutes.ts b/src/routes/chatAuxiliaryRoutes.ts index 0bebe4c..83c8f3b 100644 --- a/src/routes/chatAuxiliaryRoutes.ts +++ b/src/routes/chatAuxiliaryRoutes.ts @@ -59,7 +59,7 @@ export const registerChatAuxiliaryRoutes = ( return; } const result = await resultReferenceResolver.getFullAuthorized(resultRef, { - actorKey: toActorKey(context.userId), clientSessionId, projectId: context.projectId, + actorKey: context.actorKey, clientSessionId, projectId: context.projectId, }, { expectedKind: RESULT_REFERENCE_KIND.serverApiPayload }); if (!result) { res.status(404).json({ message: "result_ref not found" }); @@ -84,23 +84,25 @@ export const registerChatAuxiliaryRoutes = ( } try { - frontendActionCoordinator.cancelSession(parsed.data.session_id); const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, parsed.data.session_id, ); - const binding = sessionRecord - ? await sessionBridge.abort({ - clientSessionId: sessionRecord.sessionId, - sessionId: sessionRecord.sessionId, - }) - : null; - const run = activeRuns.get(parsed.data.session_id); + if (!sessionRecord) { + res.status(204).end(); + return; + } + frontendActionCoordinator.cancelSession(sessionRecord.sessionId); + const binding = await sessionBridge.abort({ + clientSessionId: sessionRecord.sessionId, + sessionId: sessionRecord.sessionId, + }); + const run = activeRuns.get(sessionRecord.sessionId); if (run && run.status === "running") { run.status = "aborted"; lastRunStatuses.set(parsed.data.session_id, "aborted"); @@ -115,16 +117,14 @@ export const registerChatAuxiliaryRoutes = ( progress: completeBackendProgress(message.progress), todos: cancelBackendTodos(message.todos), })); - if (sessionRecord) { - const currentState = await sessionUiStateStore.read( - toSessionUiStateContext(sessionRecord.sessionId), - ); - await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), { - sessionId: sessionRecord.sessionId, - isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, - messages: run.messages, - }); - } + const currentState = await sessionUiStateStore.read( + toSessionUiStateContext(sessionRecord.sessionId), + ); + await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), { + sessionId: sessionRecord.sessionId, + isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, + messages: run.messages, + }); for (const subscriber of run.subscribers) { subscriber.write("error", { session_id: parsed.data.session_id, @@ -135,11 +135,6 @@ export const registerChatAuxiliaryRoutes = ( run.subscribers.clear(); } - if (!binding && !run) { - res.status(204).end(); - return; - } - logger.info( { clientSessionId: parsed.data.session_id, diff --git a/src/routes/chatInteractionRoutes.ts b/src/routes/chatInteractionRoutes.ts index 313e434..2496fc1 100644 --- a/src/routes/chatInteractionRoutes.ts +++ b/src/routes/chatInteractionRoutes.ts @@ -91,8 +91,8 @@ export const registerChatInteractionRoutes = ( const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, parsed.data.session_id, @@ -216,8 +216,8 @@ export const registerChatInteractionRoutes = ( const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, parsed.data.session_id, @@ -348,8 +348,8 @@ export const registerChatInteractionRoutes = ( const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, parsed.data.session_id, diff --git a/src/routes/chatSessionRoutes.ts b/src/routes/chatSessionRoutes.ts index 9594adb..809c203 100644 --- a/src/routes/chatSessionRoutes.ts +++ b/src/routes/chatSessionRoutes.ts @@ -73,8 +73,8 @@ export const registerChatSessionRoutes = ( const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; const requestedSessionId = parsed.data.session_id?.trim(); let sessionId = requestedSessionId; if (!sessionId) { @@ -112,8 +112,8 @@ export const registerChatSessionRoutes = ( const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; const records = await sessionMetadataStore.list({ actorKey, projectId, @@ -139,8 +139,8 @@ export const registerChatSessionRoutes = ( const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; if (!sessionId) { res.status(400).json({ message: "session_id is required" }); return; @@ -183,8 +183,8 @@ export const registerChatSessionRoutes = ( const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; if (!sessionId) { res.status(400).json({ message: "session_id is required" }); return; @@ -213,7 +213,7 @@ export const registerChatSessionRoutes = ( session_id: sessionRecord.sessionId, messages: state?.messages ?? run?.messages ?? [], is_streaming: run?.status === "running", - run_status: getSessionRunStatus(sessionRecord.sessionId) ?? "completed", + run_status: getSessionRunStatus(sessionRecord.sessionId), }), ); @@ -254,8 +254,8 @@ export const registerChatSessionRoutes = ( const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; if (!sessionId || !title) { res.status(400).json({ message: "session_id and title are required" }); return; @@ -296,8 +296,8 @@ export const registerChatSessionRoutes = ( const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; if (!sessionId) { res.status(400).json({ message: "session_id is required" }); return; @@ -338,8 +338,8 @@ export const registerChatSessionRoutes = ( const traceId = req.header("x-trace-id") ?? undefined; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; const sourceSessionId = parsed.data.session_id?.trim(); const sourceSessionRecord = sourceSessionId ? await sessionMetadataStore.get( diff --git a/src/routes/chatStreamRoute.ts b/src/routes/chatStreamRoute.ts index a16810c..7dcd107 100644 --- a/src/routes/chatStreamRoute.ts +++ b/src/routes/chatStreamRoute.ts @@ -197,8 +197,8 @@ export const registerChatStreamRoute = ( const projectId = authContext.projectId; const traceId = req.header("x-trace-id") ?? undefined; const userId = authContext.userId; - const actorKey = toActorKey(userId); - const projectKey = toProjectKey(projectId); + const actorKey = authContext.actorKey; + const projectKey = authContext.projectKey; const requestUiMessages = toUiMessages(parsed.data.messages); const promptText = parsed.data.message?.trim() || readLatestUserText(requestUiMessages); @@ -219,6 +219,10 @@ export const registerChatStreamRoute = ( requestedSessionId, ) : null; + if (requestedSessionId && !existingSessionRecord) { + res.status(404).json({ message: "session not found" }); + return; + } const hadExistingRuntimeSession = Boolean(existingSessionRecord); const { binding, requestContext, created } = await sessionBridge.resolve({ @@ -227,6 +231,8 @@ export const registerChatStreamRoute = ( projectId, traceId, userId, + actorKey, + projectKey, }); setRuntimeSessionContext({ ...(getRuntimeSessionContext(binding.sessionId)!), diff --git a/src/sessions/metadataStore.ts b/src/sessions/metadataStore.ts index 220086c..9b148ee 100644 --- a/src/sessions/metadataStore.ts +++ b/src/sessions/metadataStore.ts @@ -53,6 +53,9 @@ export class SessionMetadataStore { this.filePath(sessionId), ); if (existing) { + if (!matchesContext(existing, input)) { + throw new SessionContextMismatchError(); + } return { created: false, record: existing }; } @@ -80,9 +83,10 @@ export class SessionMetadataStore { if (!normalizedSessionId) { return null; } - return await readJsonFile( + const record = await readJsonFile( this.filePath(normalizedSessionId), ); + return record && matchesContext(record, context) ? record : null; } async touch( @@ -132,6 +136,18 @@ const normalizeSessionId = (value?: string) => { return normalized ? normalized.slice(0, 128) : undefined; }; +export class SessionContextMismatchError extends Error { + constructor() { + super("session not found"); + } +} + +const matchesContext = (record: SessionRecord, context: SessionMetadataContext) => + record.actorKey === context.actorKey && + record.projectKey === context.projectKey && + (context.userId === undefined || record.ownerUserId === context.userId) && + (context.projectId === undefined || record.projectId === context.projectId); + const normalizeSessionUpdates = ( updates: Partial>, ) => { diff --git a/tests/context/productContext.test.ts b/tests/context/productContext.test.ts new file mode 100644 index 0000000..23f68b7 --- /dev/null +++ b/tests/context/productContext.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Request } from "express"; + +import { + attachTrustedAgentContext, + getLocalAgentContext, + ProductContextError, +} from "../../src/context/localContext.js"; +import { SessionMetadataStore } from "../../src/sessions/metadataStore.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const requestWithHeaders = (headers: Record) => ({ + header: (name: string) => headers[name.toLowerCase()], +}) as Request; + +const authResponse = (userId: string, projectId: string) => ({ + is_superuser: false, + keycloak_sub: `sub-${userId}`, + network: "supply", + permissions: ["analysis:read"], + project_id: projectId, + project_role: "member", + role: "user", + token_expires_at: "2026-09-17T00:00:00Z", + user_id: userId, + username: userId, +}); + +describe("trusted product chat context", () => { + it("uses verified product users and projects instead of spoofable local headers", async () => { + const identities = [authResponse("product-user-1", "project-1"), authResponse("product-user-2", "project-2")]; + globalThis.fetch = (async () => Response.json(identities.shift())) as unknown as typeof fetch; + const first = requestWithHeaders({ + authorization: "Bearer product-token-1", + "x-project-id": "project-1", + "x-agent-local-user": "spoofed-user", + "x-agent-local-project": "spoofed-project", + }); + const second = requestWithHeaders({ + authorization: "Bearer product-token-2", + "x-project-id": "project-2", + "x-agent-local-user": "spoofed-user", + "x-agent-local-project": "spoofed-project", + }); + await attachTrustedAgentContext(first); + await attachTrustedAgentContext(second); + + const firstContext = getLocalAgentContext(first); + const secondContext = getLocalAgentContext(second); + expect(firstContext.userId).toBe("product-user-1"); + expect(firstContext.projectId).toBe("project-1"); + expect(secondContext.userId).toBe("product-user-2"); + expect(secondContext.projectId).toBe("project-2"); + expect(firstContext.actorKey).not.toBe(secondContext.actorKey); + expect(firstContext.projectKey).not.toBe(secondContext.projectKey); + + const localSpoof = requestWithHeaders({ + "x-agent-local-user": "product-user-1", + "x-agent-local-project": "project-1", + }); + await attachTrustedAgentContext(localSpoof); + const localContext = getLocalAgentContext(localSpoof); + expect(localContext.actorKey).not.toBe(firstContext.actorKey); + expect(localContext.projectKey).not.toBe(firstContext.projectKey); + + const tempDir = await mkdtemp(join(tmpdir(), "tjwater-product-session-")); + try { + const store = new SessionMetadataStore(tempDir); + await store.initialize(); + await store.ensure({ ...firstContext, sessionId: "private-session" }); + expect(await store.get(secondContext, "private-session")).toBeNull(); + expect((await store.get(firstContext, "private-session"))?.sessionId).toBe("private-session"); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); + + it("fails closed when any supplied product credential cannot be verified", async () => { + globalThis.fetch = (async () => Response.json({ detail: "expired" }, { status: 401 })) as unknown as typeof fetch; + const request = requestWithHeaders({ + authorization: "Bearer expired-token", + "x-project-id": "project-1", + }); + expect(attachTrustedAgentContext(request)).rejects.toBeInstanceOf(ProductContextError); + }); +}); diff --git a/tests/sessions/metadataStore.test.ts b/tests/sessions/metadataStore.test.ts index 2b1436c..833c733 100644 --- a/tests/sessions/metadataStore.test.ts +++ b/tests/sessions/metadataStore.test.ts @@ -3,7 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { SessionMetadataStore } from "../../src/sessions/metadataStore.js"; +import { + SessionContextMismatchError, + SessionMetadataStore, +} from "../../src/sessions/metadataStore.js"; describe("SessionMetadataStore", () => { let tempDir: string; @@ -15,6 +18,37 @@ describe("SessionMetadataStore", () => { await store.initialize(); }); + it("does not return or ensure a session owned by another context", async () => { + await store.ensure({ + actorKey: "actor-1", + projectId: "project-1", + projectKey: "project-key-1", + sessionId: "shared-id", + userId: "user-1", + }); + + expect( + await store.get( + { + actorKey: "actor-2", + projectId: "project-2", + projectKey: "project-key-2", + userId: "user-2", + }, + "shared-id", + ), + ).toBeNull(); + expect( + store.ensure({ + actorKey: "actor-2", + projectId: "project-2", + projectKey: "project-key-2", + sessionId: "shared-id", + userId: "user-2", + }), + ).rejects.toBeInstanceOf(SessionContextMismatchError); + }); + afterEach(async () => { await rm(tempDir, { force: true, recursive: true }); });