feat(context): verify product session scope
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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 工具/技能调用审计日志路径。
|
||||
|
||||
@@ -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<Request, LocalAgentContext>();
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
if (!sessionRecord) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
|
||||
const binding = await sessionBridge.abort({
|
||||
clientSessionId: sessionRecord.sessionId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
})
|
||||
: null;
|
||||
const run = activeRuns.get(parsed.data.session_id);
|
||||
});
|
||||
const run = activeRuns.get(sessionRecord.sessionId);
|
||||
if (run && run.status === "running") {
|
||||
run.status = "aborted";
|
||||
lastRunStatuses.set(parsed.data.session_id, "aborted");
|
||||
@@ -115,7 +117,6 @@ export const registerChatAuxiliaryRoutes = (
|
||||
progress: completeBackendProgress(message.progress),
|
||||
todos: cancelBackendTodos(message.todos),
|
||||
}));
|
||||
if (sessionRecord) {
|
||||
const currentState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord.sessionId),
|
||||
);
|
||||
@@ -124,7 +125,6 @@ export const registerChatAuxiliaryRoutes = (
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)!),
|
||||
|
||||
@@ -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<SessionRecord>(
|
||||
const record = await readJsonFile<SessionRecord>(
|
||||
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<Pick<SessionRecord, "title" | "status">>,
|
||||
) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user