feat(context): verify product session scope

This commit is contained in:
2026-09-22 10:43:36 +08:00
parent 88b41ba04c
commit 9faed59f23
12 changed files with 358 additions and 56 deletions
+8 -2
View File
@@ -15,10 +15,12 @@ export type SessionBinding = {
}; };
export type SessionContext = { export type SessionContext = {
actorKey?: string;
clientSessionId: string; clientSessionId: string;
network?: string; network?: string;
projectId?: string; projectId?: string;
userId?: string; userId?: string;
projectKey?: string;
}; };
export type ChatRequestContext = SessionContext & { export type ChatRequestContext = SessionContext & {
@@ -38,6 +40,8 @@ export class ChatSessionBridge {
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
userId?: string; userId?: string;
actorKey?: string;
projectKey?: string;
}): Promise<{ }): Promise<{
binding: SessionBinding; binding: SessionBinding;
requestContext: ChatRequestContext; requestContext: ChatRequestContext;
@@ -139,19 +143,21 @@ export class ChatSessionBridge {
} }
private buildRequestContext(context: { private buildRequestContext(context: {
actorKey?: string;
sessionId?: string; sessionId?: string;
network?: string; network?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
userId?: string; userId?: string;
projectKey?: string;
}): ChatRequestContext { }): ChatRequestContext {
const sessionId = context.sessionId?.trim(); const sessionId = context.sessionId?.trim();
return { return {
clientSessionId: sessionId || this.createClientSessionId(), clientSessionId: sessionId || this.createClientSessionId(),
actorKey: toActorKey(context.userId), actorKey: context.actorKey ?? toActorKey(`local:${context.userId ?? "local-user"}`),
network: context.network, network: context.network,
projectId: context.projectId, 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)}`, traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(), userId: context.userId?.trim(),
}; };
+2
View File
@@ -46,6 +46,8 @@ const envSchema = z
PORT: z.coerce.number().int().positive().default(8787), PORT: z.coerce.number().int().positive().default(8787),
// HTTP 服务监听地址。 // HTTP 服务监听地址。
HOST: z.string().default("0.0.0.0"), 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 日志级别。 // Pino 日志级别。
LOG_LEVEL: z.string().default("info"), LOG_LEVEL: z.string().default("info"),
// LLM 工具/技能调用审计日志路径。 // LLM 工具/技能调用审计日志路径。
+55 -2
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { Request } from "express"; import type { Request } from "express";
import { ProductServerClient } from "../product/server-auth-client.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type LocalAgentContext = { export type LocalAgentContext = {
@@ -15,17 +16,69 @@ export type LocalAgentContext = {
const DEFAULT_USER_ID = process.env.AGENT_LOCAL_USER_ID ?? "local-user"; 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_PROJECT_ID = process.env.AGENT_LOCAL_PROJECT_ID ?? "local-project";
const DEFAULT_NETWORK = process.env.AGENT_LOCAL_NETWORK ?? "local-network"; const DEFAULT_NETWORK = process.env.AGENT_LOCAL_NETWORK ?? "local-network";
const trustedContexts = new WeakMap<Request, LocalAgentContext>();
const productAuthClient = new ProductServerClient();
export const getLocalAgentContext = (req?: Request): LocalAgentContext => { 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 userId = req?.header("x-agent-local-user")?.trim() || DEFAULT_USER_ID;
const projectId = const projectId =
req?.header("x-agent-local-project")?.trim() || DEFAULT_PROJECT_ID; req?.header("x-agent-local-project")?.trim() || DEFAULT_PROJECT_ID;
return { return {
actorKey: toActorKey(userId), actorKey: toActorKey(`local:${userId}`),
network: req?.header("x-agent-local-network")?.trim() || DEFAULT_NETWORK, network: req?.header("x-agent-local-network")?.trim() || DEFAULT_NETWORK,
projectId, projectId,
projectKey: toProjectKey(projectId), projectKey: toProjectKey(`local:${projectId}`),
traceId: req?.header("x-trace-id")?.trim() || `trace-${randomUUID().slice(0, 12)}`, traceId: req?.header("x-trace-id")?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId, 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;
};
+65
View File
@@ -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<AgentAuthContext> {
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<string, unknown>;
if (typeof record.detail === "string") return record.detail;
if (typeof record.message === "string") return record.message;
return fallback;
};
+32 -1
View File
@@ -13,6 +13,11 @@ import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { uiRegistryResponse } from "../uiEnvelope/registry.js"; import { uiRegistryResponse } from "../uiEnvelope/registry.js";
import {
attachTrustedAgentContext,
getLocalAgentContext,
ProductContextError,
} from "../context/localContext.js";
import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js"; import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js"; import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import { createChatRunState } from "./chatRunState.js"; import { createChatRunState } from "./chatRunState.js";
@@ -48,6 +53,18 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
const chatRouter = Router(); const chatRouter = Router();
const { activeRuns, lastRunStatuses } = runState; 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) => { chatRouter.get("/models", (_req, res) => {
res.json({ res.json({
default_model: resolveDefaultModel(config.OPENCODE_MODEL), default_model: resolveDefaultModel(config.OPENCODE_MODEL),
@@ -63,13 +80,27 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
res.json(frontendActionRegistryResponse); 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() ?? ""; const sessionId = req.header("x-agent-session-id")?.trim() ?? "";
if (!sessionId || req.params.action_id !== req.body?.actionId) { if (!sessionId || req.params.action_id !== req.body?.actionId) {
res.status(400).json({ message: "invalid frontend action result" }); res.status(400).json({ message: "invalid frontend action result" });
return; return;
} }
try { 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); const outcome = frontendActionCoordinator.submitResult(sessionId, req.body);
res.json({ res.json({
accepted: true, accepted: true,
+11 -16
View File
@@ -59,7 +59,7 @@ export const registerChatAuxiliaryRoutes = (
return; return;
} }
const result = await resultReferenceResolver.getFullAuthorized(resultRef, { 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 }); }, { expectedKind: RESULT_REFERENCE_KIND.serverApiPayload });
if (!result) { if (!result) {
res.status(404).json({ message: "result_ref not found" }); res.status(404).json({ message: "result_ref not found" });
@@ -84,23 +84,25 @@ export const registerChatAuxiliaryRoutes = (
} }
try { try {
frontendActionCoordinator.cancelSession(parsed.data.session_id);
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
parsed.data.session_id, parsed.data.session_id,
); );
const binding = sessionRecord if (!sessionRecord) {
? await sessionBridge.abort({ res.status(204).end();
return;
}
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
const binding = await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId, clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId,
}) });
: null; const run = activeRuns.get(sessionRecord.sessionId);
const run = activeRuns.get(parsed.data.session_id);
if (run && run.status === "running") { if (run && run.status === "running") {
run.status = "aborted"; run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted"); lastRunStatuses.set(parsed.data.session_id, "aborted");
@@ -115,7 +117,6 @@ export const registerChatAuxiliaryRoutes = (
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos), todos: cancelBackendTodos(message.todos),
})); }));
if (sessionRecord) {
const currentState = await sessionUiStateStore.read( const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId), toSessionUiStateContext(sessionRecord.sessionId),
); );
@@ -124,7 +125,6 @@ export const registerChatAuxiliaryRoutes = (
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages, messages: run.messages,
}); });
}
for (const subscriber of run.subscribers) { for (const subscriber of run.subscribers) {
subscriber.write("error", { subscriber.write("error", {
session_id: parsed.data.session_id, session_id: parsed.data.session_id,
@@ -135,11 +135,6 @@ export const registerChatAuxiliaryRoutes = (
run.subscribers.clear(); run.subscribers.clear();
} }
if (!binding && !run) {
res.status(204).end();
return;
}
logger.info( logger.info(
{ {
clientSessionId: parsed.data.session_id, clientSessionId: parsed.data.session_id,
+6 -6
View File
@@ -91,8 +91,8 @@ export const registerChatInteractionRoutes = (
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
parsed.data.session_id, parsed.data.session_id,
@@ -216,8 +216,8 @@ export const registerChatInteractionRoutes = (
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
parsed.data.session_id, parsed.data.session_id,
@@ -348,8 +348,8 @@ export const registerChatInteractionRoutes = (
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
parsed.data.session_id, parsed.data.session_id,
+15 -15
View File
@@ -73,8 +73,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
const requestedSessionId = parsed.data.session_id?.trim(); const requestedSessionId = parsed.data.session_id?.trim();
let sessionId = requestedSessionId; let sessionId = requestedSessionId;
if (!sessionId) { if (!sessionId) {
@@ -112,8 +112,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
const records = await sessionMetadataStore.list({ const records = await sessionMetadataStore.list({
actorKey, actorKey,
projectId, projectId,
@@ -139,8 +139,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
if (!sessionId) { if (!sessionId) {
res.status(400).json({ message: "session_id is required" }); res.status(400).json({ message: "session_id is required" });
return; return;
@@ -183,8 +183,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
if (!sessionId) { if (!sessionId) {
res.status(400).json({ message: "session_id is required" }); res.status(400).json({ message: "session_id is required" });
return; return;
@@ -213,7 +213,7 @@ export const registerChatSessionRoutes = (
session_id: sessionRecord.sessionId, session_id: sessionRecord.sessionId,
messages: state?.messages ?? run?.messages ?? [], messages: state?.messages ?? run?.messages ?? [],
is_streaming: run?.status === "running", 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 authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
if (!sessionId || !title) { if (!sessionId || !title) {
res.status(400).json({ message: "session_id and title are required" }); res.status(400).json({ message: "session_id and title are required" });
return; return;
@@ -296,8 +296,8 @@ export const registerChatSessionRoutes = (
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
if (!sessionId) { if (!sessionId) {
res.status(400).json({ message: "session_id is required" }); res.status(400).json({ message: "session_id is required" });
return; return;
@@ -338,8 +338,8 @@ export const registerChatSessionRoutes = (
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
const sourceSessionId = parsed.data.session_id?.trim(); const sourceSessionId = parsed.data.session_id?.trim();
const sourceSessionRecord = sourceSessionId const sourceSessionRecord = sourceSessionId
? await sessionMetadataStore.get( ? await sessionMetadataStore.get(
+8 -2
View File
@@ -197,8 +197,8 @@ export const registerChatStreamRoute = (
const projectId = authContext.projectId; const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = authContext.actorKey;
const projectKey = toProjectKey(projectId); const projectKey = authContext.projectKey;
const requestUiMessages = toUiMessages(parsed.data.messages); const requestUiMessages = toUiMessages(parsed.data.messages);
const promptText = const promptText =
parsed.data.message?.trim() || readLatestUserText(requestUiMessages); parsed.data.message?.trim() || readLatestUserText(requestUiMessages);
@@ -219,6 +219,10 @@ export const registerChatStreamRoute = (
requestedSessionId, requestedSessionId,
) )
: null; : null;
if (requestedSessionId && !existingSessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const hadExistingRuntimeSession = Boolean(existingSessionRecord); const hadExistingRuntimeSession = Boolean(existingSessionRecord);
const { binding, requestContext, created } = await sessionBridge.resolve({ const { binding, requestContext, created } = await sessionBridge.resolve({
@@ -227,6 +231,8 @@ export const registerChatStreamRoute = (
projectId, projectId,
traceId, traceId,
userId, userId,
actorKey,
projectKey,
}); });
setRuntimeSessionContext({ setRuntimeSessionContext({
...(getRuntimeSessionContext(binding.sessionId)!), ...(getRuntimeSessionContext(binding.sessionId)!),
+17 -1
View File
@@ -53,6 +53,9 @@ export class SessionMetadataStore {
this.filePath(sessionId), this.filePath(sessionId),
); );
if (existing) { if (existing) {
if (!matchesContext(existing, input)) {
throw new SessionContextMismatchError();
}
return { created: false, record: existing }; return { created: false, record: existing };
} }
@@ -80,9 +83,10 @@ export class SessionMetadataStore {
if (!normalizedSessionId) { if (!normalizedSessionId) {
return null; return null;
} }
return await readJsonFile<SessionRecord>( const record = await readJsonFile<SessionRecord>(
this.filePath(normalizedSessionId), this.filePath(normalizedSessionId),
); );
return record && matchesContext(record, context) ? record : null;
} }
async touch( async touch(
@@ -132,6 +136,18 @@ const normalizeSessionId = (value?: string) => {
return normalized ? normalized.slice(0, 128) : undefined; 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 = ( const normalizeSessionUpdates = (
updates: Partial<Pick<SessionRecord, "title" | "status">>, updates: Partial<Pick<SessionRecord, "title" | "status">>,
) => { ) => {
+94
View File
@@ -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<string, string>) => ({
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);
});
});
+35 -1
View File
@@ -3,7 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js"; import {
SessionContextMismatchError,
SessionMetadataStore,
} from "../../src/sessions/metadataStore.js";
describe("SessionMetadataStore", () => { describe("SessionMetadataStore", () => {
let tempDir: string; let tempDir: string;
@@ -15,6 +18,37 @@ describe("SessionMetadataStore", () => {
await store.initialize(); 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 () => { afterEach(async () => {
await rm(tempDir, { force: true, recursive: true }); await rm(tempDir, { force: true, recursive: true });
}); });