feat(foundry): add managed data consumers and agent settings
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user