272 lines
10 KiB
JavaScript
272 lines
10 KiB
JavaScript
import { createReadStream } from "node:fs";
|
|
import { stat } from "node:fs/promises";
|
|
|
|
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
|
|
function sendJson(response, statusCode, payload, headers = {}) {
|
|
response.writeHead(statusCode, {
|
|
"cache-control": "no-store",
|
|
"content-type": "application/json; charset=utf-8",
|
|
...headers,
|
|
});
|
|
response.end(JSON.stringify(payload));
|
|
}
|
|
|
|
async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of request) {
|
|
size += chunk.length;
|
|
if (size > maxBytes) throw new Error("payload_too_large");
|
|
chunks.push(chunk);
|
|
}
|
|
if (!chunks.length) return {};
|
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
}
|
|
|
|
function bearerToken(request) {
|
|
const match = String(request.headers.authorization || "").match(/^Bearer\s+(.+)$/i);
|
|
return match?.[1]?.trim() || "";
|
|
}
|
|
|
|
function publicOrigin(request, configuredOrigin) {
|
|
if (configuredOrigin) return String(configuredOrigin).replace(/\/+$/, "");
|
|
const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim();
|
|
const protocol = forwardedProto === "https" ? "https" : "http";
|
|
const forwardedHost = String(request.headers["x-forwarded-host"] || "").split(",")[0].trim();
|
|
const host = forwardedHost || String(request.headers.host || "127.0.0.1");
|
|
return `${protocol}://${host}`.replace(/\/+$/, "");
|
|
}
|
|
|
|
function actorFromProfile(profile) {
|
|
const ownerId = String(profile?.id || "").trim();
|
|
const ownerKey = String(profile?.email || ownerId).trim().toLowerCase();
|
|
if (!ownerId || !ownerKey) throw new Error("foundry_agent_owner_required");
|
|
return { ownerId, ownerKey };
|
|
}
|
|
|
|
function errorCode(error) {
|
|
return String(error?.message || error || "foundry_agent_operation_failed").slice(0, 160);
|
|
}
|
|
|
|
function managementMatch(pathname) {
|
|
const match = pathname.match(/^\/api\/foundry-agent-api\/agents(?:\/([^/]+)(?:\/(revoke|setup-code))?)?$/);
|
|
if (!match) return null;
|
|
return { agentId: match[1] ? decodeURIComponent(match[1]) : null, action: match[2] || null };
|
|
}
|
|
|
|
export function createFoundryAgentGateway({
|
|
store,
|
|
configuredPublicOrigin = "",
|
|
installerPackagePath,
|
|
ontologyCoreUrl = "",
|
|
ontologyCoreAccessToken = "",
|
|
}) {
|
|
if (!store) throw new Error("foundry_agent_store_required");
|
|
|
|
const ontologyUrl = String(ontologyCoreUrl || "").trim().replace(/\/+$/, "");
|
|
const ontologyToken = String(ontologyCoreAccessToken || "").trim();
|
|
|
|
async function handleInstaller(request, response, url) {
|
|
if (url.pathname !== "/api/foundry-agent/install/nodedc-foundry-codex-agent-setup.tgz") return false;
|
|
if (request.method !== "GET") {
|
|
response.writeHead(405, { allow: "GET" });
|
|
response.end();
|
|
return true;
|
|
}
|
|
try {
|
|
const info = await stat(installerPackagePath);
|
|
response.writeHead(200, {
|
|
"cache-control": "no-store",
|
|
"content-disposition": 'attachment; filename="nodedc-foundry-codex-agent-setup.tgz"',
|
|
"content-length": info.size,
|
|
"content-type": "application/octet-stream",
|
|
});
|
|
createReadStream(installerPackagePath).pipe(response);
|
|
} catch {
|
|
sendJson(response, 404, { ok: false, error: "foundry_agent_installer_not_found" });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function handleSetupRedeem(request, response, url) {
|
|
if (url.pathname !== "/api/foundry-agent/v1/setup/redeem") return false;
|
|
if (request.method !== "POST") {
|
|
response.writeHead(405, { allow: "POST" });
|
|
response.end();
|
|
return true;
|
|
}
|
|
try {
|
|
const input = await readJsonBody(request, 16 * 1024);
|
|
const redeemed = await store.redeemSetupCode(input.code, input.deviceName);
|
|
const origin = publicOrigin(request, configuredPublicOrigin);
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
agent: redeemed.agent,
|
|
device: redeemed.device,
|
|
foundry: {
|
|
serverName: "nodedc_module_foundry",
|
|
mcpUrl: `${origin}/api/mcp`,
|
|
token: redeemed.foundryToken,
|
|
},
|
|
ontology: {
|
|
serverName: "nodedc_ontology",
|
|
mcpUrl: `${origin}/api/ontology-mcp`,
|
|
token: redeemed.ontologyToken,
|
|
readOnly: true,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const code = errorCode(error);
|
|
sendJson(response, code === "payload_too_large" ? 413 : 400, { ok: false, error: code });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function handleDoctor(request, response, url) {
|
|
if (url.pathname !== "/api/foundry-agent/v1/doctor") return false;
|
|
if (request.method !== "GET") {
|
|
response.writeHead(405, { allow: "GET" });
|
|
response.end();
|
|
return true;
|
|
}
|
|
const session = await store.authenticateFoundryToken(bearerToken(request), { check: true });
|
|
if (!session) {
|
|
sendJson(response, 401, { ok: false, error: "foundry_agent_unauthorized" });
|
|
return true;
|
|
}
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
agent: session.agent,
|
|
device: session.device,
|
|
foundryMcpUrl: `${publicOrigin(request, configuredPublicOrigin)}/api/mcp`,
|
|
ontologyMcpUrl: `${publicOrigin(request, configuredPublicOrigin)}/api/ontology-mcp`,
|
|
ontologyReadOnly: true,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async function handlePublicRequest(request, response, url) {
|
|
return await handleInstaller(request, response, url)
|
|
|| await handleSetupRedeem(request, response, url)
|
|
|| await handleDoctor(request, response, url);
|
|
}
|
|
|
|
async function handleManagementRequest(request, response, url, profile) {
|
|
const match = managementMatch(url.pathname);
|
|
if (!match) return false;
|
|
const owner = actorFromProfile(profile);
|
|
try {
|
|
if (!match.agentId && request.method === "GET") {
|
|
sendJson(response, 200, { ok: true, agents: await store.listAgents(owner.ownerKey) });
|
|
return true;
|
|
}
|
|
if (!match.agentId && request.method === "POST") {
|
|
const input = await readJsonBody(request, 640 * 1024);
|
|
const agent = await store.createAgent({ ...owner, name: input.name, avatarUrl: input.avatarUrl });
|
|
sendJson(response, 201, { ok: true, agent });
|
|
return true;
|
|
}
|
|
if (match.agentId && !match.action && request.method === "PATCH") {
|
|
const input = await readJsonBody(request, 640 * 1024);
|
|
const agent = await store.updateAgent(owner.ownerKey, match.agentId, input);
|
|
sendJson(response, 200, { ok: true, agent });
|
|
return true;
|
|
}
|
|
if (match.agentId && match.action === "revoke" && request.method === "POST") {
|
|
const agent = await store.revokeAgent(owner.ownerKey, match.agentId);
|
|
sendJson(response, 200, { ok: true, agent });
|
|
return true;
|
|
}
|
|
if (match.agentId && match.action === "setup-code" && request.method === "POST") {
|
|
const setup = await store.issueSetupCode(owner.ownerKey, match.agentId);
|
|
const origin = publicOrigin(request, configuredPublicOrigin);
|
|
const packageUrl = `${origin}/api/foundry-agent/install/nodedc-foundry-codex-agent-setup.tgz`;
|
|
const command = `npm exec --yes --package=${packageUrl} nodedc-foundry-codex-agent -- --server ${origin} --code ${setup.code}`;
|
|
sendJson(response, 201, { ok: true, install: { command, expiresAt: setup.expiresAt } });
|
|
return true;
|
|
}
|
|
response.writeHead(405, { allow: "GET, POST, PATCH" });
|
|
response.end();
|
|
} catch (error) {
|
|
const code = errorCode(error);
|
|
const status = code === "foundry_agent_not_found" ? 404 : code === "payload_too_large" ? 413 : 400;
|
|
sendJson(response, status, { ok: false, error: code });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function authenticateFoundryToken(token, options) {
|
|
const session = await store.authenticateFoundryToken(token, options);
|
|
return session?.actor || null;
|
|
}
|
|
|
|
async function handleOntologyMcp(request, response, url) {
|
|
if (url.pathname !== "/api/ontology-mcp") return false;
|
|
if (request.method !== "POST") {
|
|
response.writeHead(405, { allow: "POST" });
|
|
response.end();
|
|
return true;
|
|
}
|
|
const session = await store.authenticateOntologyToken(bearerToken(request), { check: true });
|
|
if (!session) {
|
|
sendJson(response, 401, { ok: false, error: "ontology_agent_unauthorized" });
|
|
return true;
|
|
}
|
|
if (!ontologyUrl || !ontologyToken) {
|
|
sendJson(response, 503, { ok: false, error: "ontology_agent_proxy_not_configured" });
|
|
return true;
|
|
}
|
|
let input;
|
|
try {
|
|
input = await readJsonBody(request);
|
|
} catch (error) {
|
|
sendJson(response, error?.message === "payload_too_large" ? 413 : 400, { ok: false, error: errorCode(error) });
|
|
return true;
|
|
}
|
|
let upstream;
|
|
try {
|
|
upstream = await fetch(`${ontologyUrl}/mcp`, {
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: {
|
|
accept: String(request.headers.accept || "application/json, text/event-stream"),
|
|
authorization: `Bearer ${ontologyToken}`,
|
|
"content-type": "application/json",
|
|
"mcp-protocol-version": String(request.headers["mcp-protocol-version"] || MCP_PROTOCOL_VERSION),
|
|
},
|
|
body: JSON.stringify(input),
|
|
signal: AbortSignal.timeout(60_000),
|
|
});
|
|
} catch {
|
|
sendJson(response, 503, { ok: false, error: "ontology_agent_proxy_unavailable" });
|
|
return true;
|
|
}
|
|
const payload = await upstream.text();
|
|
response.statusCode = upstream.status;
|
|
response.setHeader("cache-control", "no-store");
|
|
response.setHeader("content-type", upstream.headers.get("content-type") || "application/json; charset=utf-8");
|
|
for (const header of ["mcp-protocol-version", "mcp-session-id", "vary"]) {
|
|
const value = upstream.headers.get(header);
|
|
if (value) response.setHeader(header, value);
|
|
}
|
|
response.end(payload);
|
|
return true;
|
|
}
|
|
|
|
return {
|
|
handlePublicRequest,
|
|
handleManagementRequest,
|
|
handleOntologyMcp,
|
|
authenticateFoundryToken,
|
|
health() {
|
|
return {
|
|
configured: Boolean(installerPackagePath),
|
|
ontologyProxyConfigured: Boolean(ontologyUrl && ontologyToken),
|
|
tokenLifecycle: "durable-until-revoke",
|
|
};
|
|
},
|
|
};
|
|
}
|