350 lines
13 KiB
JavaScript
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,
|
|
};
|
|
}
|