NODEDC_DESIGN_GUIDELINE/server/foundry-agent-store.mjs

350 lines
13 KiB
JavaScript

import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
const STORE_VERSION = 1;
const SETUP_TTL_MS = 15 * 60_000;
const NO_STORE_WRITE = Symbol("no-store-write");
function nowIso(now) {
return new Date(now()).toISOString();
}
function cleanString(value, max = 240) {
return String(value || "").trim().slice(0, max);
}
function cleanId(value, max = 180) {
return cleanString(value, max).replace(/[^A-Za-z0-9_.:@-]/g, "");
}
function cleanAvatarUrl(value) {
const avatarUrl = cleanString(value, 550_000);
if (!avatarUrl) return "";
if (/^https:\/\//i.test(avatarUrl) || /^data:image\/(?:png|jpeg|webp|gif);base64,/i.test(avatarUrl)) return avatarUrl;
return "";
}
function digest(value) {
return createHash("sha256").update(String(value || ""), "utf8").digest("hex");
}
function digestMatches(actual, expected) {
const actualBuffer = Buffer.from(String(actual || ""), "hex");
const expectedBuffer = Buffer.from(String(expected || ""), "hex");
return actualBuffer.length === expectedBuffer.length
&& actualBuffer.length > 0
&& timingSafeEqual(actualBuffer, expectedBuffer);
}
function randomToken(prefix, bytes = 32) {
return `${prefix}_${randomBytes(bytes).toString("base64url")}`;
}
function emptyStore() {
return { schemaVersion: STORE_VERSION, agents: [], setupCodes: [] };
}
function normalizeDevice(value) {
const id = cleanId(value?.id);
const foundryTokenHash = cleanString(value?.foundryTokenHash, 128);
const ontologyTokenHash = cleanString(value?.ontologyTokenHash, 128);
if (!id || !foundryTokenHash || !ontologyTokenHash) return null;
return {
id,
name: cleanString(value?.name, 160) || "Codex Desktop",
foundryTokenHash,
ontologyTokenHash,
createdAt: cleanString(value?.createdAt, 80),
lastUsedAt: cleanString(value?.lastUsedAt, 80) || null,
lastCheckAt: cleanString(value?.lastCheckAt, 80) || null,
revokedAt: cleanString(value?.revokedAt, 80) || null,
};
}
function normalizeAgent(value, now) {
const id = cleanId(value?.id);
const ownerId = cleanId(value?.ownerId);
const ownerKey = cleanString(value?.ownerKey, 320);
if (!id || !ownerId || !ownerKey) return null;
return {
id,
ownerId,
ownerKey,
name: cleanString(value?.name, 160) || "Foundry Codex",
avatarUrl: cleanAvatarUrl(value?.avatarUrl),
status: value?.status === "revoked" ? "revoked" : "active",
devices: Array.isArray(value?.devices) ? value.devices.map(normalizeDevice).filter(Boolean) : [],
createdAt: cleanString(value?.createdAt, 80) || nowIso(now),
updatedAt: cleanString(value?.updatedAt, 80) || nowIso(now),
revokedAt: cleanString(value?.revokedAt, 80) || null,
};
}
function normalizeSetupCode(value) {
const codeHash = cleanString(value?.codeHash, 128);
const agentId = cleanId(value?.agentId);
const ownerKey = cleanString(value?.ownerKey, 320);
if (!codeHash || !agentId || !ownerKey) return null;
return {
codeHash,
agentId,
ownerKey,
createdAt: cleanString(value?.createdAt, 80),
expiresAt: cleanString(value?.expiresAt, 80),
redeemedAt: cleanString(value?.redeemedAt, 80) || null,
redeemedDeviceId: cleanId(value?.redeemedDeviceId) || null,
};
}
function normalizeStore(value, now) {
return {
schemaVersion: STORE_VERSION,
agents: Array.isArray(value?.agents) ? value.agents.map((item) => normalizeAgent(item, now)).filter(Boolean) : [],
setupCodes: Array.isArray(value?.setupCodes) ? value.setupCodes.map(normalizeSetupCode).filter(Boolean) : [],
};
}
function publicDevice(device) {
return {
id: device.id,
name: device.name,
createdAt: device.createdAt,
lastUsedAt: device.lastUsedAt,
lastCheckAt: device.lastCheckAt,
revokedAt: device.revokedAt,
};
}
function publicAgent(agent) {
return {
id: agent.id,
ownerId: agent.ownerId,
name: agent.name,
avatarUrl: agent.avatarUrl || null,
status: agent.status,
devices: agent.devices.map(publicDevice),
deviceCount: agent.devices.filter((device) => !device.revokedAt).length,
lastUsedAt: agent.devices.map((device) => device.lastUsedAt).filter(Boolean).sort().at(-1) || null,
lastCheckAt: agent.devices.map((device) => device.lastCheckAt).filter(Boolean).sort().at(-1) || null,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
revokedAt: agent.revokedAt,
};
}
export function createFoundryAgentStore({ dataRoot, now = Date.now } = {}) {
if (!dataRoot) throw new Error("foundry_agent_data_root_required");
const storePath = join(dataRoot, "agents.json");
const auditPath = join(dataRoot, "audit.ndjson");
let mutationQueue = Promise.resolve();
let auditQueue = Promise.resolve();
async function readStore() {
try {
return normalizeStore(JSON.parse(await readFile(storePath, "utf8")), now);
} catch (error) {
if (error?.code === "ENOENT") return emptyStore();
throw error;
}
}
async function writeStore(store) {
await mkdir(dataRoot, { recursive: true });
const tempPath = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
await writeFile(tempPath, `${JSON.stringify(normalizeStore(store, now), null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
await rename(tempPath, storePath);
}
async function mutate(action) {
const run = mutationQueue.then(async () => {
const store = await readStore();
const result = await action(store);
if (result !== NO_STORE_WRITE) await writeStore(store);
return result;
});
mutationQueue = run.catch(() => undefined);
return run;
}
async function audit(event) {
const run = auditQueue.then(async () => {
const record = {
schemaVersion: "nodedc.module-foundry.agent-audit.v1",
at: nowIso(now),
...event,
};
await mkdir(dataRoot, { recursive: true });
const current = await readFile(auditPath, "utf8").catch((error) => error?.code === "ENOENT" ? "" : Promise.reject(error));
const lines = `${current}${JSON.stringify(record)}\n`.trimEnd().split("\n").slice(-2_000);
const tempPath = `${auditPath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
await writeFile(tempPath, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
await rename(tempPath, auditPath);
});
auditQueue = run.catch(() => undefined);
return run;
}
async function listAgents(ownerKey) {
const normalizedOwner = cleanString(ownerKey, 320);
const store = await readStore();
return store.agents.filter((agent) => agent.ownerKey === normalizedOwner).map(publicAgent);
}
async function createAgent({ ownerId, ownerKey, name, avatarUrl }) {
const normalizedOwnerId = cleanId(ownerId);
const normalizedOwnerKey = cleanString(ownerKey, 320);
if (!normalizedOwnerId || !normalizedOwnerKey) throw new Error("foundry_agent_owner_required");
const agent = await mutate(async (store) => {
const createdAt = nowIso(now);
const next = {
id: randomToken("foundry_agent", 12),
ownerId: normalizedOwnerId,
ownerKey: normalizedOwnerKey,
name: cleanString(name, 160) || "Foundry Codex",
avatarUrl: cleanAvatarUrl(avatarUrl),
status: "active",
devices: [],
createdAt,
updatedAt: createdAt,
revokedAt: null,
};
store.agents.push(next);
return publicAgent(next);
});
await audit({ event: "agent_created", ownerId: normalizedOwnerId, agentId: agent.id, outcome: "ok" });
return agent;
}
async function updateAgent(ownerKey, agentId, input = {}) {
const normalizedOwner = cleanString(ownerKey, 320);
const normalizedId = cleanId(agentId);
const agent = await mutate(async (store) => {
const target = store.agents.find((item) => item.id === normalizedId && item.ownerKey === normalizedOwner);
if (!target) throw new Error("foundry_agent_not_found");
if (target.status === "revoked") throw new Error("foundry_agent_revoked");
if (Object.hasOwn(input, "name")) target.name = cleanString(input.name, 160) || target.name;
if (Object.hasOwn(input, "avatarUrl")) target.avatarUrl = cleanAvatarUrl(input.avatarUrl);
target.updatedAt = nowIso(now);
return publicAgent(target);
});
await audit({ event: "agent_updated", agentId: agent.id, outcome: "ok" });
return agent;
}
async function revokeAgent(ownerKey, agentId) {
const normalizedOwner = cleanString(ownerKey, 320);
const normalizedId = cleanId(agentId);
const agent = await mutate(async (store) => {
const target = store.agents.find((item) => item.id === normalizedId && item.ownerKey === normalizedOwner);
if (!target) throw new Error("foundry_agent_not_found");
const revokedAt = target.revokedAt || nowIso(now);
target.status = "revoked";
target.revokedAt = revokedAt;
target.updatedAt = revokedAt;
target.devices.forEach((device) => { device.revokedAt = device.revokedAt || revokedAt; });
return publicAgent(target);
});
await audit({ event: "agent_revoked", agentId: agent.id, outcome: "ok" });
return agent;
}
async function issueSetupCode(ownerKey, agentId) {
const normalizedOwner = cleanString(ownerKey, 320);
const normalizedId = cleanId(agentId);
const code = randomToken("fnd_setup", 24);
const createdAtMs = now();
const expiresAt = new Date(createdAtMs + SETUP_TTL_MS).toISOString();
await mutate(async (store) => {
const agent = store.agents.find((item) => item.id === normalizedId && item.ownerKey === normalizedOwner);
if (!agent) throw new Error("foundry_agent_not_found");
if (agent.status !== "active") throw new Error("foundry_agent_revoked");
store.setupCodes = store.setupCodes.filter((item) => Date.parse(item.expiresAt || "") > createdAtMs && !item.redeemedAt);
store.setupCodes.push({
codeHash: digest(code),
agentId: agent.id,
ownerKey: agent.ownerKey,
createdAt: new Date(createdAtMs).toISOString(),
expiresAt,
redeemedAt: null,
redeemedDeviceId: null,
});
});
await audit({ event: "setup_code_issued", agentId: normalizedId, outcome: "ok" });
return { code, expiresAt };
}
async function redeemSetupCode(code, deviceName) {
const codeHash = digest(code);
const foundryToken = randomToken("fnda", 36);
const ontologyToken = randomToken("fndo", 36);
const result = await mutate(async (store) => {
const setup = store.setupCodes.find((item) => item.codeHash === codeHash);
if (!setup) throw new Error("setup_code_invalid");
if (setup.redeemedAt) throw new Error("setup_code_already_redeemed");
if (Date.parse(setup.expiresAt || "") <= now()) throw new Error("setup_code_expired");
const agent = store.agents.find((item) => item.id === setup.agentId && item.ownerKey === setup.ownerKey);
if (!agent || agent.status !== "active") throw new Error("foundry_agent_revoked");
const createdAt = nowIso(now);
const device = {
id: randomToken("foundry_device", 12),
name: cleanString(deviceName, 160) || "Codex Desktop",
foundryTokenHash: digest(foundryToken),
ontologyTokenHash: digest(ontologyToken),
createdAt,
lastUsedAt: null,
lastCheckAt: null,
revokedAt: null,
};
agent.devices.push(device);
agent.updatedAt = createdAt;
setup.redeemedAt = createdAt;
setup.redeemedDeviceId = device.id;
return { agent: publicAgent(agent), device: publicDevice(device) };
});
await audit({ event: "setup_code_redeemed", agentId: result.agent.id, deviceId: result.device.id, outcome: "ok" });
return { ...result, foundryToken, ontologyToken };
}
async function authenticate(kind, token, { check = false } = {}) {
const tokenHash = digest(token);
return mutate(async (store) => {
for (const agent of store.agents) {
if (agent.status !== "active") continue;
for (const device of agent.devices) {
const expected = kind === "ontology" ? device.ontologyTokenHash : device.foundryTokenHash;
if (!digestMatches(expected, tokenHash) || device.revokedAt) continue;
const at = nowIso(now);
device.lastUsedAt = at;
if (check) device.lastCheckAt = at;
agent.updatedAt = at;
return {
actor: { actorId: agent.ownerId, ownerKey: agent.ownerKey },
agent: publicAgent(agent),
device: publicDevice(device),
};
}
}
return NO_STORE_WRITE;
});
}
return {
listAgents,
createAgent,
updateAgent,
revokeAgent,
issueSetupCode,
redeemSetupCode,
authenticateFoundryToken: async (token, options) => {
const result = await authenticate("foundry", token, options);
return result === NO_STORE_WRITE ? null : result;
},
authenticateOntologyToken: async (token, options) => {
const result = await authenticate("ontology", token, options);
return result === NO_STORE_WRITE ? null : result;
},
audit,
};
}