feat(foundry): add managed data consumers and agent settings

This commit is contained in:
Codex
2026-07-19 15:03:54 +03:00
parent 5f583caa05
commit aac44d057f
35 changed files with 4534 additions and 243 deletions
@@ -0,0 +1,199 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const FOUNDRY_SERVER_NAME = "nodedc_module_foundry";
const ONTOLOGY_SERVER_NAME = "nodedc_ontology";
const SKILL_NAME = "foundry-context";
const MCP_PROTOCOL_VERSION = "2025-06-18";
function usage(message = "") {
if (message) console.error(message);
console.log(`Usage:
nodedc-foundry-codex-agent --server <Foundry URL> --code <one-time code>
nodedc-foundry-codex-agent doctor [--codex-home <path>]
The setup command is issued by Foundry Settings → Codex Agent API.`);
process.exitCode = message ? 1 : 0;
}
function parseArgs(raw) {
const args = raw[0] === "--" ? raw.slice(1) : raw;
const out = { command: "setup", server: "", code: "", codexHome: process.env.CODEX_HOME || "" };
if (args[0] === "doctor") out.command = "doctor";
const values = out.command === "doctor" ? args.slice(1) : args;
for (let index = 0; index < values.length; index += 1) {
const item = values[index];
if (item === "--server") out.server = String(values[++index] || "");
else if (item.startsWith("--server=")) out.server = item.slice("--server=".length);
else if (item === "--code") out.code = String(values[++index] || "");
else if (item.startsWith("--code=")) out.code = item.slice("--code=".length);
else if (item === "--codex-home") out.codexHome = String(values[++index] || "");
else if (item.startsWith("--codex-home=")) out.codexHome = item.slice("--codex-home=".length);
else if (item === "--help" || item === "-h") out.help = true;
else throw new Error(`unknown_argument:${item}`);
}
out.server = String(out.server || "").replace(/\/+$/, "");
return out;
}
function codexHome(value) {
if (!value) return path.join(os.homedir(), ".codex");
if (value === "~") return os.homedir();
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2));
return path.resolve(value);
}
async function readIfExists(file) {
try {
return await readFile(file, "utf8");
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
function stripMcpBlocks(content, serverNames) {
const targets = serverNames.map((name) => `mcp_servers.${name}`);
let skip = false;
const kept = [];
for (const line of String(content || "").split(/\r?\n/)) {
const section = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/)?.[1]?.trim() || "";
if (section) skip = targets.some((target) => section === target || section.startsWith(`${target}.`));
if (!skip) kept.push(line);
}
return kept.join("\n").trimEnd();
}
function mcpBlock(serverName, endpoint, token) {
return [
`[mcp_servers.${serverName}]`,
`url = ${JSON.stringify(endpoint)}`,
"enabled = true",
"required = false",
"startup_timeout_sec = 30",
"tool_timeout_sec = 120",
"",
`[mcp_servers.${serverName}.http_headers]`,
`Authorization = ${JSON.stringify(`Bearer ${token}`)}`,
`Accept = ${JSON.stringify("application/json, text/event-stream")}`,
`${JSON.stringify("MCP-Protocol-Version")} = ${JSON.stringify(MCP_PROTOCOL_VERSION)}`,
].join("\n");
}
async function writeConfig(home, setup) {
const configPath = path.join(home, "config.toml");
await mkdir(path.dirname(configPath), { recursive: true });
const original = await readIfExists(configPath);
if (original !== null) await writeFile(`${configPath}.nodedc-foundry-agent.bak`, original, "utf8");
const prefix = stripMcpBlocks(original || "", [FOUNDRY_SERVER_NAME, ONTOLOGY_SERVER_NAME]);
const blocks = [
mcpBlock(FOUNDRY_SERVER_NAME, setup.foundry.mcpUrl, setup.foundry.token),
mcpBlock(ONTOLOGY_SERVER_NAME, setup.ontology.mcpUrl, setup.ontology.token),
].join("\n\n");
await writeFile(configPath, `${prefix}${prefix ? "\n\n" : ""}${blocks}\n`, "utf8");
return configPath;
}
function skillBody() {
return `---
name: foundry-context
description: Use for NODE.DC Module Foundry application work. Use Foundry and the separate read-only Ontology MCP; never depend on Foundry source files.
---
# NODE.DC Module Foundry
- Treat \`nodedc_module_foundry\` as the only authority for Foundry application instances and Page Library bindings. Do not read or edit Foundry source, runtime files or manifests directly.
- Treat \`nodedc_ontology\` as a separate read-only semantic authority. Use it before inventing entity ids, semantic types, aliases, relations or routing assumptions.
- Work only inside the currently granted Foundry user contour. Sharing, cross-user visibility, roles and public release are outside this connector until explicit tools expose them.
- Canonical Page Library templates are immutable. Create and modify application instances through Foundry tools only.
- Use a fresh idempotency key for every write. Reuse a key only when intentionally replaying the exact same operation.
- Provider URLs, credentials, polling and normalization stay in NDC Engine L2. Foundry consumes provider-neutral Data Products and semantic bindings only.
- Never request or place provider tokens, internal workload grants or raw secret values in an application, page binding, prompt or MCP argument.
- For the first Map slice, resolve semantics through Ontology, create the application/page in Foundry, bind the approved Data Product, and verify the resulting application manifest through Foundry MCP reads.
`;
}
async function writeSkill(home) {
const file = path.join(home, "skills", SKILL_NAME, "SKILL.md");
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, skillBody(), "utf8");
return file;
}
async function redeem(server, code) {
const response = await fetch(`${server}/api/foundry-agent/v1/setup/redeem`, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ code, deviceName: `${os.hostname()} Codex Desktop` }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.ok || !payload?.foundry?.token || !payload?.ontology?.token) {
throw new Error(payload?.error || `setup_redeem_failed_${response.status}`);
}
return payload;
}
async function toolsSmoke(serverName, endpoint, token) {
const headers = {
accept: "application/json, text/event-stream",
authorization: `Bearer ${token}`,
"content-type": "application/json",
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
};
const initialize = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "nodedc-foundry-setup", version: "0.1.0" } },
}),
});
if (!initialize.ok) throw new Error(`${serverName}_initialize_failed_${initialize.status}`);
const list = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
});
const payload = await list.json().catch(() => ({}));
if (!list.ok || !Array.isArray(payload?.result?.tools)) throw new Error(`${serverName}_tools_list_failed_${list.status}`);
return payload.result.tools.length;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) return usage();
const home = codexHome(args.codexHome);
if (args.command === "doctor") {
const config = await readIfExists(path.join(home, "config.toml"));
const skill = await readIfExists(path.join(home, "skills", SKILL_NAME, "SKILL.md"));
const ok = Boolean(
config?.includes(`[mcp_servers.${FOUNDRY_SERVER_NAME}]`)
&& config?.includes(`[mcp_servers.${ONTOLOGY_SERVER_NAME}]`)
&& skill,
);
console.log(ok ? "NODE.DC Foundry + Ontology Codex install is present." : "NODE.DC Foundry + Ontology Codex install is incomplete.");
process.exitCode = ok ? 0 : 1;
return;
}
if (!args.server || !args.code) return usage("server_and_code_required");
const setup = await redeem(args.server, args.code);
const configPath = await writeConfig(home, setup);
const skillPath = await writeSkill(home);
const foundryToolCount = await toolsSmoke(FOUNDRY_SERVER_NAME, setup.foundry.mcpUrl, setup.foundry.token);
const ontologyToolCount = await toolsSmoke(ONTOLOGY_SERVER_NAME, setup.ontology.mcpUrl, setup.ontology.token);
console.log("NODE.DC Foundry + Ontology Codex setup complete.");
console.log("Config:", configPath);
console.log("Skill:", skillPath);
console.log("Foundry MCP smoke tools:", foundryToolCount);
console.log("Ontology MCP smoke tools:", ontologyToolCount);
console.log("Restart Codex Desktop completely before using the new MCP tools.");
}
main().catch((error) => {
console.error(`NODE.DC Foundry Codex setup failed: ${error?.message || error}`);
process.exitCode = 1;
});
@@ -0,0 +1,15 @@
{
"name": "@nodedc/foundry-codex-agent",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"nodedc-foundry-codex-agent": "bin/nodedc-foundry-codex-agent.mjs"
},
"files": [
"bin"
],
"engines": {
"node": ">=20"
}
}
Binary file not shown.
+397 -114
View File
@@ -1,6 +1,7 @@
import { constants as fsConstants, createReadStream, createWriteStream } from "node:fs";
import { lstat, mkdir, open, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { createServer, request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { createHash, createHmac, randomUUID } from "node:crypto";
import { basename, extname, join, normalize, resolve } from "node:path";
import { Readable, Transform } from "node:stream";
@@ -11,6 +12,10 @@ import {
FOUNDRY_BINDING_UPSERT_PATH,
handleFoundryBindingApiRequest,
} from "./foundry-binding-api.mjs";
import { createFoundryAgentGateway } from "./foundry-agent-gateway.mjs";
import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
import { createFoundryAuth } from "./nodedc-auth.mjs";
@@ -25,6 +30,9 @@ const designProfilesDir = join(runtimeDir, "design-profiles");
const designProfileReleasesDir = join(runtimeDir, "design-profile-releases");
const pageLayoutsDir = join(runtimeDir, "page-layouts");
const foundryMcpOperationsDir = join(runtimeDir, "foundry-mcp-operations");
const foundryDataProductConsumersDir = join(runtimeDir, "data-product-consumers");
const foundryManagedReaderGrantsDir = join(runtimeDir, "runtime-secrets", "external-data-plane-reader-grants");
const foundryAgentDataDir = join(runtimeDir, "foundry-agent-gateway");
const layoutPath = join(runtimeDir, "layout.json");
const pageRegistryPath = join(root, "registry", "pages.json");
const port = Number(process.env.PORT || 3333);
@@ -35,7 +43,10 @@ await mkdir(designProfilesDir, { recursive: true });
await mkdir(designProfileReleasesDir, { recursive: true });
await mkdir(pageLayoutsDir, { recursive: true });
await mkdir(foundryMcpOperationsDir, { recursive: true });
await mkdir(foundryDataProductConsumersDir, { recursive: true });
await mkdir(foundryAgentDataDir, { recursive: true });
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
const dataProductConsumerPolicyRegistry = JSON.parse(await readFile(join(root, "registry", "data-product-consumer-policies.json"), "utf8"));
const foundryAuth = createFoundryAuth();
const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS,
@@ -54,12 +65,28 @@ const mapGatewayInternalUrl = String(
// a persisted application/page/binding target through the same-origin BFF.
const externalDataPlaneInternalUrl = String(process.env.NODEDC_EXTERNAL_DATA_PLANE_INTERNAL_URL || "").trim().replace(/\/$/, "");
const externalDataPlaneReaderGrantsDir = String(process.env.NODEDC_EXTERNAL_DATA_PLANE_READER_GRANTS_DIR || "").trim();
const foundryReaderGrantProvisioner = createFoundryReaderGrantProvisioner({
dataPlaneUrl: externalDataPlaneInternalUrl,
privateKeyFile: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_PRIVATE_KEY_FILE,
grantsDir: foundryManagedReaderGrantsDir,
serviceId: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID || "nodedc-module-foundry",
keyId: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID || "foundry-edp-managed-provisioner-v1",
audience: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE || "nodedc-external-data-plane.managed-provisioning.v1",
});
const foundryBindingGrantsDir = String(process.env.NODEDC_FOUNDRY_BINDING_GRANTS_DIR || "").trim();
const activeRuntimeStreams = new Set();
// A dedicated server-only signing value shared with Map Gateway. In production
// the runner supplies it as a read-only file, never an env value or browser API.
const mapGatewayAdminSecret = await readMapGatewayAdminSecret();
const foundryPublicUrl = normalizeFoundryPublicUrl(process.env.FOUNDRY_PUBLIC_URL);
const foundryAgentStore = createFoundryAgentStore({ dataRoot: foundryAgentDataDir });
const foundryAgentGateway = createFoundryAgentGateway({
store: foundryAgentStore,
configuredPublicOrigin: foundryPublicUrl?.origin || "",
installerPackagePath: join(root, "server", "assets", "nodedc-foundry-codex-agent-0.1.0.tgz"),
ontologyCoreUrl: process.env.NODEDC_ONTOLOGY_CORE_URL,
ontologyCoreAccessToken: process.env.NODEDC_INTERNAL_ACCESS_TOKEN,
});
function boundedMapGatewayTimeout(value, fallback) {
const parsed = Number.parseInt(String(value || ""), 10);
@@ -826,12 +853,14 @@ async function resolveRuntimeMapDataProductBinding(applicationId, pageId, bindin
}
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId) {
if (!externalDataPlaneInternalUrl || !externalDataPlaneReaderGrantsDir) {
if (!externalDataPlaneInternalUrl || (!externalDataPlaneReaderGrantsDir && !foundryReaderGrantProvisioner.configured)) {
throw applicationError("data_product_runtime_not_configured", 503);
}
// The name is deterministic but opaque to the browser. The runner creates
// this root-owned, read-only file after it provisions an EDP reader grant.
// There is no fallback to one shared Platform token.
const managedToken = await foundryReaderGrantProvisioner.readToken({ applicationId, pageId, bindingId });
if (managedToken) return managedToken;
if (!externalDataPlaneReaderGrantsDir) throw applicationError("data_product_reader_grant_not_found", 403);
// Compatibility fallback for grants issued before Foundry-owned managed
// provisioning. There is no fallback to one shared Platform token.
const grantPath = join(resolve(externalDataPlaneReaderGrantsDir), runtimeTargetGrantKey(applicationId, pageId, bindingId));
let handle;
try {
@@ -875,6 +904,15 @@ async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindi
if (["data_product_reader_grant_not_found", "data_product_reader_grant_invalid"].includes(error?.message)) return false;
throw error;
}
try {
return Boolean(await readRuntimeReaderProduct(readerToken, dataProductId));
} catch (error) {
if (error?.message === "data_product_access_denied") return false;
throw error;
}
}
async function readRuntimeReaderProduct(readerToken, dataProductId) {
let upstream;
try {
upstream = await fetch(new URL("/internal/data-plane/v1/reader/data-products", `${externalDataPlaneInternalUrl}/`), {
@@ -884,13 +922,28 @@ async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindi
} catch {
throw applicationError("data_product_reader_grant_preflight_unavailable", 503);
}
if (upstream.status === 401 || upstream.status === 403) return false;
if (upstream.status === 401 || upstream.status === 403) throw applicationError("data_product_access_denied", 403);
if (!upstream.ok) throw applicationError("data_product_reader_grant_preflight_unavailable", 503);
const payload = await upstream.json().catch(() => null);
const product = Array.isArray(payload?.dataProducts)
? payload.dataProducts.find((candidate) => candidate?.id === dataProductId)
: null;
return Boolean(product && product.active !== false && product.deliveryMode === "snapshot+patch");
return product && product.active !== false && product.deliveryMode === "snapshot+patch" ? product : null;
}
async function inspectFoundryConsumerReaderGrant(target) {
try {
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id);
const product = await readRuntimeReaderProduct(readerToken, target.binding.dataProductId);
if (product) return { product, readerGrantAction: "reuse" };
} catch (error) {
if (!new Set(["data_product_reader_grant_not_found", "data_product_access_denied"]).has(error?.message)) throw error;
}
if (!foundryReaderGrantProvisioner.configured) {
throw applicationError("data_product_reader_grant_not_found", 403);
}
const planned = await foundryReaderGrantProvisioner.plan(target);
return { product: planned.product, readerGrantAction: "ensure" };
}
function runtimePointGeometry(value) {
@@ -950,13 +1003,88 @@ function sanitizeRuntimeSnapshot(value, binding) {
};
}
function sanitizeRuntimeHistory(value, binding) {
if (!isObject(value) || value.schemaVersion !== "nodedc.data-product.history/v1" || !isObject(value.dataProduct) || !isObject(value.query)) {
throw applicationError("data_product_history_contract_invalid", 502);
}
const query = value.query;
if (
value.dataProduct.id !== binding.dataProductId
|| !isRuntimeVersion(value.dataProduct.version)
|| !isRuntimeTimestamp(value.generatedAt)
|| !isRuntimeTimestamp(query.from)
|| !isRuntimeTimestamp(query.to)
|| Date.parse(query.from) >= Date.parse(query.to)
|| !Number.isInteger(query.resolutionMs)
|| query.resolutionMs < 1000
|| query.resolutionMs > 86_400_000
|| query.order !== "asc"
|| !Array.isArray(query.sourceIds)
|| query.sourceIds.some((sourceId) => !isRuntimeIdentifier(sourceId))
|| JSON.stringify(query.sourceIds) !== JSON.stringify([...new Set(query.sourceIds)].sort())
|| !Array.isArray(value.facts)
|| (value.nextCursor !== undefined && (typeof value.nextCursor !== "string" || !/^[A-Za-z0-9_-]{1,1024}$/.test(value.nextCursor)))
) throw applicationError("data_product_history_contract_invalid", 502);
let previousOrderKey = null;
const facts = value.facts.flatMap((valueFact) => {
if (
!isRuntimeTimestamp(valueFact?.bucketStart)
|| Date.parse(valueFact.bucketStart) < Date.parse(query.from)
|| Date.parse(valueFact.bucketStart) >= Date.parse(query.to)
) throw applicationError("data_product_history_contract_invalid", 502);
const orderKey = `${valueFact.bucketStart}\u0000${valueFact.sourceId}\u0000${valueFact.semanticType}`;
if (previousOrderKey !== null && orderKey <= previousOrderKey) {
throw applicationError("data_product_history_contract_invalid", 502);
}
previousOrderKey = orderKey;
const fact = sanitizeRuntimeFact(valueFact, binding);
return fact ? [{ ...fact, bucketStart: valueFact.bucketStart }] : [];
});
return {
schemaVersion: "nodedc.data-product.history/v1",
dataProduct: { id: binding.dataProductId, version: value.dataProduct.version },
generatedAt: value.generatedAt,
query: {
from: query.from,
to: query.to,
resolutionMs: query.resolutionMs,
sourceIds: [...query.sourceIds],
order: "asc",
},
facts,
...(value.nextCursor ? { nextCursor: value.nextCursor } : {}),
};
}
function sanitizeRuntimePatch(value, binding) {
if (!isObject(value) || value.schemaVersion !== "nodedc.data-product.patch/v1" || !isObject(value.dataProduct)) return null;
if (value.dataProduct.id !== binding.dataProductId || !isRuntimeVersion(value.dataProduct.version) || !isRuntimeCursor(value.cursor) || !isRuntimeCursor(value.previousCursor) || !isRuntimeTimestamp(value.emittedAt) || !Array.isArray(value.operations)) return null;
const operations = value.operations.flatMap((operation) => {
if (!isObject(operation) || operation.op !== "upsert") return [];
const fact = sanitizeRuntimeFact(operation.fact, binding);
return fact ? [{ op: "upsert", fact }] : [];
if (!isObject(operation)) return [];
if (operation.op === "upsert") {
const fact = sanitizeRuntimeFact(operation.fact, binding);
return fact ? [{ op: "upsert", fact }] : [];
}
// Current EDP v1 emits only upserts. Foundry already understands the
// canonical removal shape so a future versioned product can revoke a
// subject without turning a transient stream failure into deletion.
if (
operation.op === "remove"
&& isRuntimeIdentifier(operation.sourceId)
&& isRuntimeIdentifier(operation.semanticType)
&& binding.semanticTypes.includes(operation.semanticType)
&& isRuntimeTimestamp(operation.removedAt)
&& ["tombstone", "revoked"].includes(operation.reason)
) {
return [{
op: "remove",
sourceId: operation.sourceId,
semanticType: operation.semanticType,
removedAt: operation.removedAt,
reason: operation.reason,
}];
}
return [];
});
return {
schemaVersion: "nodedc.data-product.patch/v1",
@@ -968,6 +1096,15 @@ function sanitizeRuntimePatch(value, binding) {
};
}
function resolveDataProductConsumerPolicy(product) {
if (dataProductConsumerPolicyRegistry?.schemaVersion !== "nodedc.foundry.data-product-consumer-policies/v1") {
throw applicationError("data_product_consumer_policy_registry_invalid", 500);
}
return dataProductConsumerPolicyRegistry.policies?.find((policy) => (
policy?.dataProductId === product.id && policy?.productVersion === product.version
)) || null;
}
function runtimeUpstreamUrl(dataProductId, resource, after) {
const target = new URL(`/internal/data-plane/v1/data-products/${encodeURIComponent(dataProductId)}/${resource}`, `${externalDataPlaneInternalUrl}/`);
if (after) target.searchParams.set("after", after);
@@ -1019,25 +1156,127 @@ async function writeSse(response, { event, data, id }) {
return writeRuntimeStreamChunk(response, frame);
}
function parseSseBlock(block) {
const fields = { event: "message", id: "", data: [] };
for (const line of block.split("\n")) {
if (!line || line.startsWith(":")) continue;
const separator = line.indexOf(":");
const field = separator === -1 ? line : line.slice(0, separator);
const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, "");
if (field === "event") fields.event = value;
if (field === "id") fields.id = value;
if (field === "data") fields.data.push(value);
}
return { event: fields.event, id: fields.id, data: fields.data.join("\n") };
function openRuntimeDataProductConsumerStream({ url, token, signal }) {
return new Promise((resolveStream, rejectStream) => {
const transport = url.protocol === "https:" ? httpsRequest : url.protocol === "http:" ? httpRequest : null;
if (!transport) return rejectStream(applicationError("data_product_runtime_url_invalid", 500));
let incoming = null;
let settled = false;
const request = transport(url, {
method: "GET",
headers: {
authorization: `Bearer ${token}`,
accept: "text/event-stream",
connection: "close",
},
});
const abort = () => {
incoming?.destroy();
request.destroy();
};
signal.addEventListener("abort", abort, { once: true });
request.once("response", (response) => {
incoming = response;
settled = true;
response.once("close", () => signal.removeEventListener("abort", abort));
resolveStream({
status: response.statusCode || 500,
ok: (response.statusCode || 500) >= 200 && (response.statusCode || 500) < 300,
headers: new Headers(response.headers),
body: Readable.toWeb(response),
});
});
request.once("error", (error) => {
signal.removeEventListener("abort", abort);
if (!settled) rejectStream(signal.aborted ? applicationError("data_product_stream_closed", 503) : error);
});
if (signal.aborted) abort();
else request.end();
});
}
const dataProductConsumerManager = createFoundryDataProductConsumerManager({
stateDir: foundryDataProductConsumersDir,
dataPlaneUrl: externalDataPlaneInternalUrl,
resolveTarget: resolveRuntimeMapDataProductBinding,
readReaderToken: resolveRuntimeDataProductReaderToken,
inspectReaderGrant: inspectFoundryConsumerReaderGrant,
ensureReaderGrant: (target) => foundryReaderGrantProvisioner.ensure(target),
resolvePolicy: resolveDataProductConsumerPolicy,
sanitizeSnapshot: sanitizeRuntimeSnapshot,
sanitizePatch: sanitizeRuntimePatch,
openStream: openRuntimeDataProductConsumerStream,
});
await dataProductConsumerManager.resumePersisted();
async function proxyRuntimeDataProductSnapshot(response, target) {
json(response, 200, await dataProductConsumerManager.snapshot(target));
}
async function readRuntimeJsonResponse(upstream, maxBytes = 64 * 1024 * 1024) {
const contentLength = Number(upstream.headers.get("content-length") || 0);
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
throw applicationError("data_product_runtime_response_too_large", 502);
}
if (!upstream.body) throw applicationError("data_product_runtime_invalid_response", 502);
const reader = upstream.body.getReader();
const chunks = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
await reader.cancel();
throw applicationError("data_product_runtime_response_too_large", 502);
}
chunks.push(Buffer.from(value));
}
return JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
} catch (error) {
if (error?.statusCode) throw error;
throw applicationError("data_product_runtime_invalid_response", 502);
}
}
function runtimeHistoryQuery(url) {
const from = String(url.searchParams.get("from") || "");
const to = String(url.searchParams.get("to") || "");
const resolutionMs = Number(url.searchParams.get("resolutionMs") || 60_000);
const limit = Number(url.searchParams.get("limit") || 1000);
const sourceIds = [...new Set(String(url.searchParams.get("sourceIds") || "").split(",").map((value) => value.trim()).filter(Boolean))];
const cursor = String(url.searchParams.get("cursor") || "");
if (
!isRuntimeTimestamp(from)
|| !isRuntimeTimestamp(to)
|| Date.parse(from) >= Date.parse(to)
|| !Number.isInteger(resolutionMs)
|| resolutionMs < 1000
|| resolutionMs > 86_400_000
|| !Number.isInteger(limit)
|| limit < 1
|| limit > 5000
|| sourceIds.length > 1000
|| sourceIds.some((sourceId) => !isRuntimeIdentifier(sourceId))
|| (cursor && !/^[A-Za-z0-9_-]{1,1024}$/.test(cursor))
) throw applicationError("data_product_history_query_invalid", 400);
return { from, to, resolutionMs, limit, sourceIds, cursor };
}
async function proxyRuntimeDataProductHistory(response, url, target) {
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id);
const query = runtimeHistoryQuery(url);
const upstreamUrl = runtimeUpstreamUrl(target.binding.dataProductId, "history");
upstreamUrl.searchParams.set("from", query.from);
upstreamUrl.searchParams.set("to", query.to);
upstreamUrl.searchParams.set("resolutionMs", String(query.resolutionMs));
upstreamUrl.searchParams.set("limit", String(query.limit));
if (query.sourceIds.length) upstreamUrl.searchParams.set("sourceIds", query.sourceIds.join(","));
if (query.cursor) upstreamUrl.searchParams.set("cursor", query.cursor);
let upstream;
try {
upstream = await fetch(runtimeUpstreamUrl(target.binding.dataProductId, "snapshot"), {
upstream = await fetch(upstreamUrl, {
headers: { authorization: `Bearer ${readerToken}`, accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
@@ -1045,12 +1284,11 @@ async function proxyRuntimeDataProductSnapshot(response, target) {
throw applicationError("data_product_runtime_unavailable", 503);
}
if (!upstream.ok) throw runtimeUpstreamFailure(upstream.status);
const payload = await upstream.json().catch(() => null);
json(response, 200, sanitizeRuntimeSnapshot(payload, target.binding));
const payload = await readRuntimeJsonResponse(upstream);
json(response, 200, sanitizeRuntimeHistory(payload, target.binding));
}
async function proxyRuntimeDataProductStream(request, response, url, target) {
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id);
const requestedAfter = String(url.searchParams.get("after") || "");
const lastEventId = String(request.headers["last-event-id"] || "");
if (requestedAfter && !isRuntimeCursor(requestedAfter)) throw applicationError("stream_cursor_invalid");
@@ -1060,39 +1298,39 @@ async function proxyRuntimeDataProductStream(request, response, url, target) {
// cursor; using it avoids replaying a stale query-string cursor forever.
const after = lastEventId || requestedAfter;
const controller = new AbortController();
const abort = () => controller.abort();
let lease = null;
let heartbeat = null;
let headersReady = false;
const bufferedEvents = [];
let pendingEventCount = 0;
let writeChain = Promise.resolve(true);
const enqueue = (event) => {
if (!headersReady) {
bufferedEvents.push(event);
return;
}
pendingEventCount += 1;
if (pendingEventCount > 256) {
abort();
response.destroy();
return;
}
writeChain = writeChain
.then((open) => open && writeSse(response, event))
.catch(() => false)
.finally(() => { pendingEventCount = Math.max(0, pendingEventCount - 1); });
};
const abort = () => {
if (!controller.signal.aborted) controller.abort();
lease?.release();
};
const activeStream = { controller, response };
activeRuntimeStreams.add(activeStream);
request.once("aborted", abort);
response.once("close", abort);
let upstream;
request.socket?.once("close", abort);
try {
upstream = await fetch(runtimeUpstreamUrl(target.binding.dataProductId, "stream", after), {
headers: {
authorization: `Bearer ${readerToken}`,
accept: "text/event-stream",
...(lastEventId ? { "last-event-id": lastEventId } : {}),
},
signal: controller.signal,
});
if (upstream.status === 409) {
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
vary: "Cookie",
});
await writeSse(response, {
event: "nodedc.data-product.resync-required.v1",
data: { schemaVersion: "nodedc.data-product.resync-required/v1", dataProductId: target.binding.dataProductId },
});
if (!response.destroyed && !response.writableEnded) response.end();
return;
}
if (!upstream.ok || !upstream.body || !String(upstream.headers.get("content-type") || "").startsWith("text/event-stream")) {
throw runtimeUpstreamFailure(upstream.status);
}
lease = await dataProductConsumerManager.subscribe({ ...target, after }, enqueue);
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
@@ -1100,54 +1338,31 @@ async function proxyRuntimeDataProductStream(request, response, url, target) {
"x-accel-buffering": "no",
vary: "Cookie",
});
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let pending = "";
try {
streamLoop: while (!response.destroyed && !controller.signal.aborted) {
const next = await reader.read();
if (next.done) break;
pending += decoder.decode(next.value, { stream: true }).replace(/\r\n/g, "\n");
let separator;
while ((separator = pending.indexOf("\n\n")) !== -1) {
const block = pending.slice(0, separator);
pending = pending.slice(separator + 2);
const event = parseSseBlock(block);
if (event.event === "nodedc.data-product.patch.v1") {
const patch = sanitizeRuntimePatch(JSON.parse(event.data), target.binding);
if (patch?.operations.length && !(await writeSse(response, { event: event.event, id: patch.cursor, data: patch }))) {
abort();
break streamLoop;
}
} else if (event.event === "nodedc.data-product.ready.v1") {
let ready = null;
try { ready = JSON.parse(event.data); } catch { ready = null; }
if (isObject(ready) && ready.schemaVersion === "nodedc.data-product.ready/v1" && ready.dataProductId === target.binding.dataProductId && isRuntimeCursor(ready.cursor) && isRuntimeTimestamp(ready.emittedAt)) {
if (!(await writeSse(response, { event: event.event, data: { schemaVersion: ready.schemaVersion, dataProductId: ready.dataProductId, cursor: ready.cursor, emittedAt: ready.emittedAt } }))) {
abort();
break streamLoop;
}
}
} else if (!event.data) {
// Preserve a heartbeat without forwarding unknown upstream fields.
if (!(await writeRuntimeStreamChunk(response, ": keepalive\n\n"))) {
abort();
break streamLoop;
}
}
}
}
} finally {
if (controller.signal.aborted) await reader.cancel().catch(() => undefined);
reader.releaseLock();
headersReady = true;
if (lease.resyncRequired) {
await writeSse(response, {
event: "nodedc.data-product.resync-required.v1",
data: { schemaVersion: "nodedc.data-product.resync-required/v1", dataProductId: target.binding.dataProductId },
});
response.end();
return;
}
} catch (error) {
if (!response.headersSent && !controller.signal.aborted) throw error;
for (const event of bufferedEvents.splice(0)) enqueue(event);
heartbeat = setInterval(() => {
writeChain = writeChain.then((open) => open && writeRuntimeStreamChunk(response, ": keepalive\n\n")).catch(() => false);
}, 15_000);
heartbeat.unref?.();
await new Promise((resolve) => {
if (controller.signal.aborted || response.destroyed) return resolve();
controller.signal.addEventListener("abort", resolve, { once: true });
});
} finally {
if (heartbeat) clearInterval(heartbeat);
abort();
activeRuntimeStreams.delete(activeStream);
request.off("aborted", abort);
response.off("close", abort);
request.socket?.off("close", abort);
if (!response.writableEnded && !response.destroyed) response.end();
}
}
@@ -1334,14 +1549,25 @@ const foundryMcpOperations = {
const config = foundryMcpConfig();
return {
module: "NDC Module Foundry",
schemaVersion: "nodedc.module-foundry.mcp.v0.1",
schemaVersion: "nodedc.module-foundry.mcp.v0.2",
status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required",
canonicalPageLibrary: "read-only",
applicationInstances: "read-write",
destructiveApplicationDelete: false,
supportedPageTemplates: pageRegistry.templates.map((template) => ({ id: template.id, version: template.version, title: template.title })),
supportedActions: ["application.create", "application.metadata.update", "page-instance.create", "map-pin.upsert"],
persistence: { runtimeDir: "persistent runtime volume required", idempotentWrites: true },
supportedActions: [
"application.create",
"application.metadata.update",
"page-instance.create",
"map-pin.upsert",
"map-data-product.upsert",
"map-data-product-consumer.plan",
"map-data-product-consumer.apply",
"map-data-product-consumer.status",
"map-data-product-consumer.accept",
"map-data-product-consumer.rollback",
],
persistence: { runtimeDir: "persistent runtime volume required", idempotentWrites: true, dataProductConsumerState: true },
};
},
async listApplications() {
@@ -1468,6 +1694,31 @@ const foundryMcpOperations = {
},
});
},
async planMapDataProductConsumer(input) {
return dataProductConsumerManager.plan(input);
},
async applyMapDataProductConsumer(input, actor) {
return executeFoundryMcpWrite({
tool: "foundry_apply_map_data_product_consumer",
actor,
input,
action: () => dataProductConsumerManager.apply(input),
});
},
async getMapDataProductConsumerStatus(input) {
return dataProductConsumerManager.status(input);
},
async acceptMapDataProductConsumer(input) {
return dataProductConsumerManager.accept(input);
},
async rollbackMapDataProductConsumer(input, actor) {
return executeFoundryMcpWrite({
tool: "foundry_rollback_map_data_product_consumer",
actor,
input,
action: () => dataProductConsumerManager.rollback(input),
});
},
};
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
@@ -1762,6 +2013,9 @@ const server = createServer(async (request, response) => {
try {
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
if (await foundryAgentGateway.handlePublicRequest(request, response, url)) return;
if (await foundryAgentGateway.handleOntologyMcp(request, response, url)) return;
if (url.pathname === "/auth/nodedc/handoff" && request.method === "GET") {
await foundryAuth.handleLauncherHandoff(request, response, url);
return;
@@ -1781,10 +2035,16 @@ const server = createServer(async (request, response) => {
configured: Boolean(config.capabilitySecret && config.mcpUrl),
entitlementConfigured: Boolean(config.internalAccessToken),
},
codexAgent: foundryAgentGateway.health(),
dataProductBindingApi: {
configured: Boolean(foundryBindingGrantsDir && externalDataPlaneInternalUrl && externalDataPlaneReaderGrantsDir),
auth: "scoped-workload-grant",
},
dataProductConsumerProvisioner: {
configured: foundryReaderGrantProvisioner.configured,
auth: "dedicated-ed25519-service-identity",
sourceScope: "external-data-plane-resolved",
},
});
return;
}
@@ -1793,6 +2053,7 @@ const server = createServer(async (request, response) => {
await handleFoundryMcpRequest(request, response, {
getConfig: foundryMcpConfig,
operations: foundryMcpOperations,
authenticateAgentToken: foundryAgentGateway.authenticateFoundryToken,
});
return;
}
@@ -1816,6 +2077,16 @@ const server = createServer(async (request, response) => {
return;
}
const foundryUserProfile = foundryAuth.currentUserProfile(request)
|| (!foundryAuth.authRequired ? {
id: "local_foundry_user",
email: "local-foundry@nodedc.local",
displayName: "Local Foundry User",
avatarUrl: null,
initials: "LF",
} : null);
if (await foundryAgentGateway.handleManagementRequest(request, response, url, foundryUserProfile)) return;
if (url.pathname === "/api/session/profile" && request.method === "GET") {
const access = foundryAuth.currentUserAccess(request);
json(response, 200, { user: foundryAuth.currentUserProfile(request), profileUrl: foundryAuth.profileUrl, access: { role: access.role } });
@@ -1858,11 +2129,12 @@ const server = createServer(async (request, response) => {
return;
}
const runtimeDataProductMatch = url.pathname.match(/^\/api\/applications\/([0-9a-f-]{36})\/pages\/([a-z0-9-]+)\/data-bindings\/([A-Za-z0-9._:-]{1,160})\/(snapshot|stream)$/i);
const runtimeDataProductMatch = url.pathname.match(/^\/api\/applications\/([0-9a-f-]{36})\/pages\/([a-z0-9-]+)\/data-bindings\/([A-Za-z0-9._:-]{1,160})\/(snapshot|history|stream)$/i);
if (runtimeDataProductMatch && request.method === "GET") {
const [, applicationId, pageId, bindingId, resource] = runtimeDataProductMatch;
const target = await resolveRuntimeMapDataProductBinding(applicationId, pageId, bindingId);
if (resource === "snapshot") await proxyRuntimeDataProductSnapshot(response, target);
else if (resource === "history") await proxyRuntimeDataProductHistory(response, url, target);
else await proxyRuntimeDataProductStream(request, response, url, target);
return;
}
@@ -2125,28 +2397,39 @@ server.on("connection", (socket) => {
let shutdownPromise = null;
function shutdownServer(signal) {
if (shutdownPromise) return shutdownPromise;
shutdownPromise = new Promise((resolveShutdown) => {
shutdownPromise = (async () => {
for (const activeStream of activeRuntimeStreams) {
activeStream.controller.abort();
activeStream.response.destroy();
}
const timeout = setTimeout(() => {
for (const socket of serverSockets) socket.destroy();
}, 5_000);
server.close((error) => {
clearTimeout(timeout);
if (error) {
process.exitCode = 1;
console.error(`NDC Module Foundry shutdown error (${signal}): ${error.message}`);
}
resolveShutdown();
await dataProductConsumerManager.shutdown();
await new Promise((resolveShutdown) => {
const timeout = setTimeout(() => {
for (const socket of serverSockets) socket.destroy();
}, 5_000);
server.close((error) => {
clearTimeout(timeout);
if (error) {
process.exitCode = 1;
console.error(`NDC Module Foundry shutdown error (${signal}): ${error.message}`);
}
resolveShutdown();
});
});
});
})();
return shutdownPromise;
}
process.once("SIGTERM", () => { void shutdownServer("SIGTERM"); });
process.once("SIGINT", () => { void shutdownServer("SIGINT"); });
async function shutdownAndExit(signal) {
await shutdownServer(signal);
// A canceled upstream fetch may leave an idle keep-alive socket owned by
// Node's global HTTP dispatcher. All Foundry state and inbound sockets are
// already closed at this point, so finish the container stop deterministically.
process.exit(process.exitCode || 0);
}
process.once("SIGTERM", () => { void shutdownAndExit("SIGTERM"); });
process.once("SIGINT", () => { void shutdownAndExit("SIGINT"); });
server.listen(port, host, () => {
console.log(`NDC Module Foundry: http://${host}:${port}`);
+271
View File
@@ -0,0 +1,271 @@
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",
};
},
};
}
+155
View File
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { createFoundryAgentGateway } from "./foundry-agent-gateway.mjs";
import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
import { handleFoundryMcpRequest } from "./foundry-mcp.mjs";
async function listen(server) {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return `http://127.0.0.1:${address.port}`;
}
async function close(server) {
await new Promise((resolve) => server.close(resolve));
}
async function json(response) {
return await response.json();
}
test("one setup command provisions independent Foundry and Ontology MCP transports", async () => {
const dataRoot = await mkdtemp(path.join(os.tmpdir(), "nodedc-foundry-agent-gateway-"));
const installerPath = path.join(dataRoot, "installer.tgz");
await writeFile(installerPath, "installer", "utf8");
let ontologyAuthorization = "";
const ontologyServer = createServer(async (request, response) => {
ontologyAuthorization = String(request.headers.authorization || "");
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const result = message.method === "tools/list"
? { tools: [{ name: "ontology_status" }, { name: "ontology_search" }] }
: {
protocolVersion: "2025-06-18",
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "nodedc-ontology-core", version: "test" },
};
response.writeHead(200, { "content-type": "application/json", "mcp-protocol-version": "2025-06-18" });
response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }));
});
const ontologyBase = await listen(ontologyServer);
const store = createFoundryAgentStore({ dataRoot: path.join(dataRoot, "store") });
const gateway = createFoundryAgentGateway({
store,
installerPackagePath: installerPath,
ontologyCoreUrl: ontologyBase,
ontologyCoreAccessToken: "platform-internal-token",
});
const operations = {
status: async () => ({ ok: true }),
listApplications: async () => ({ applications: [] }),
getApplication: async () => ({ application: null }),
};
const foundryServer = createServer(async (request, response) => {
const url = new URL(request.url || "/", `http://${request.headers.host}`);
if (await gateway.handlePublicRequest(request, response, url)) return;
if (await gateway.handleOntologyMcp(request, response, url)) return;
if (url.pathname === "/api/mcp") {
await handleFoundryMcpRequest(request, response, {
getConfig: () => ({ capabilitySecret: "", mcpAllowedOrigins: [] }),
operations,
authenticateAgentToken: gateway.authenticateFoundryToken,
});
return;
}
if (await gateway.handleManagementRequest(request, response, url, {
id: "user_root",
email: "dcctouch@gmail.com",
displayName: "DC SUDO",
})) return;
response.writeHead(404);
response.end();
});
const foundryBase = await listen(foundryServer);
try {
const createdResponse = await fetch(`${foundryBase}/api/foundry-agent-api/agents`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Foundry Codex" }),
});
assert.equal(createdResponse.status, 201);
const created = await json(createdResponse);
const setupResponse = await fetch(`${foundryBase}/api/foundry-agent-api/agents/${created.agent.id}/setup-code`, { method: "POST" });
assert.equal(setupResponse.status, 201);
const setup = await json(setupResponse);
assert.match(setup.install.command, /nodedc-foundry-codex-agent/);
const code = setup.install.command.match(/--code\s+(fnd_setup_[A-Za-z0-9_-]+)/)?.[1];
assert.ok(code);
const redeemResponse = await fetch(`${foundryBase}/api/foundry-agent/v1/setup/redeem`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code, deviceName: "Test Codex" }),
});
assert.equal(redeemResponse.status, 200);
const redeemed = await json(redeemResponse);
assert.equal(redeemed.foundry.serverName, "nodedc_module_foundry");
assert.equal(redeemed.ontology.serverName, "nodedc_ontology");
assert.equal(redeemed.ontology.readOnly, true);
assert.notEqual(redeemed.foundry.token, redeemed.ontology.token);
const foundryListResponse = await fetch(redeemed.foundry.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.foundry.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
});
assert.equal(foundryListResponse.status, 200);
const foundryList = await json(foundryListResponse);
assert.ok(foundryList.result.tools.some((tool) => tool.name === "foundry_create_application"));
const crossTokenResponse = await fetch(redeemed.ontology.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.foundry.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
});
assert.equal(crossTokenResponse.status, 401);
const ontologyListResponse = await fetch(redeemed.ontology.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.ontology.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list", params: {} }),
});
assert.equal(ontologyListResponse.status, 200);
const ontologyList = await json(ontologyListResponse);
assert.deepEqual(ontologyList.result.tools.map((tool) => tool.name), ["ontology_status", "ontology_search"]);
assert.equal(ontologyAuthorization, "Bearer platform-internal-token");
const revokeResponse = await fetch(`${foundryBase}/api/foundry-agent-api/agents/${created.agent.id}/revoke`, { method: "POST" });
assert.equal(revokeResponse.status, 200);
const revokedFoundryResponse = await fetch(redeemed.foundry.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.foundry.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/list", params: {} }),
});
const revokedOntologyResponse = await fetch(redeemed.ontology.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.ontology.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 5, method: "tools/list", params: {} }),
});
assert.equal(revokedFoundryResponse.status, 401);
assert.equal(revokedOntologyResponse.status, 401);
} finally {
await close(foundryServer);
await close(ontologyServer);
await rm(dataRoot, { recursive: true, force: true });
}
});
+349
View File
@@ -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,
};
}
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
test("Foundry agent setup issues separate durable credentials and revoke closes both", async () => {
const dataRoot = await mkdtemp(path.join(os.tmpdir(), "nodedc-foundry-agent-store-"));
let currentTime = Date.parse("2026-07-18T12:00:00.000Z");
const store = createFoundryAgentStore({ dataRoot, now: () => currentTime });
try {
const created = await store.createAgent({
ownerId: "user_root",
ownerKey: "dcctouch@gmail.com",
name: "Foundry Codex",
});
assert.equal(created.status, "active");
assert.equal(created.deviceCount, 0);
assert.equal((await store.listAgents("another@nodedc.local")).length, 0);
const setup = await store.issueSetupCode("dcctouch@gmail.com", created.id);
const redeemed = await store.redeemSetupCode(setup.code, "Mac Pro Codex Desktop");
assert.match(redeemed.foundryToken, /^fnda_/);
assert.match(redeemed.ontologyToken, /^fndo_/);
assert.notEqual(redeemed.foundryToken, redeemed.ontologyToken);
assert.equal(redeemed.device.revokedAt, null);
assert.equal(Object.hasOwn(redeemed.device, "foundryTokenHash"), false);
assert.equal(Object.hasOwn(redeemed.device, "ontologyTokenHash"), false);
await assert.rejects(
() => store.redeemSetupCode(setup.code, "Replay"),
/setup_code_already_redeemed/,
);
const foundrySession = await store.authenticateFoundryToken(redeemed.foundryToken, { check: true });
const ontologySession = await store.authenticateOntologyToken(redeemed.ontologyToken, { check: true });
assert.equal(foundrySession.actor.actorId, "user_root");
assert.equal(ontologySession.actor.ownerKey, "dcctouch@gmail.com");
assert.equal(await store.authenticateFoundryToken(redeemed.ontologyToken), null);
assert.equal(await store.authenticateOntologyToken(redeemed.foundryToken), null);
currentTime += 3 * 365 * 24 * 60 * 60_000;
assert.ok(await store.authenticateFoundryToken(redeemed.foundryToken));
assert.ok(await store.authenticateOntologyToken(redeemed.ontologyToken));
const revoked = await store.revokeAgent("dcctouch@gmail.com", created.id);
assert.equal(revoked.status, "revoked");
assert.equal(await store.authenticateFoundryToken(redeemed.foundryToken), null);
assert.equal(await store.authenticateOntologyToken(redeemed.ontologyToken), null);
const persisted = await readFile(path.join(dataRoot, "agents.json"), "utf8");
assert.equal(persisted.includes(redeemed.foundryToken), false);
assert.equal(persisted.includes(redeemed.ontologyToken), false);
} finally {
await rm(dataRoot, { recursive: true, force: true });
}
});
test("Foundry setup codes expire and cannot cross owner boundaries", async () => {
const dataRoot = await mkdtemp(path.join(os.tmpdir(), "nodedc-foundry-agent-expiry-"));
let currentTime = Date.parse("2026-07-18T12:00:00.000Z");
const store = createFoundryAgentStore({ dataRoot, now: () => currentTime });
try {
const agent = await store.createAgent({ ownerId: "user_root", ownerKey: "owner@nodedc.local", name: "Owner agent" });
await assert.rejects(() => store.issueSetupCode("other@nodedc.local", agent.id), /foundry_agent_not_found/);
const setup = await store.issueSetupCode("owner@nodedc.local", agent.id);
currentTime += 16 * 60_000;
await assert.rejects(() => store.redeemSetupCode(setup.code, "Late device"), /setup_code_expired/);
} finally {
await rm(dataRoot, { recursive: true, force: true });
}
});
+808
View File
@@ -0,0 +1,808 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
const STATE_SCHEMA_VERSION = "nodedc.module-foundry.data-product-consumer/v1";
const PLAN_SCHEMA_VERSION = "nodedc.module-foundry.data-product-consumer-plan/v1";
const ACCEPTANCE_SCHEMA_VERSION = "nodedc.module-foundry.data-product-consumer-acceptance/v1";
const PRESENTATION_PATCH_SCHEMA_VERSION = "nodedc.foundry.presentation-patch/v1";
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
const cursorPattern = /^(?:0|[1-9]\d*)$/;
function consumerError(code, statusCode = 400) {
return Object.assign(new Error(code), { statusCode });
}
function targetIdentity(target) {
const applicationId = String(target?.application?.id || target?.applicationId || "");
const pageId = String(target?.page?.id || target?.pageId || "");
const binding = target?.binding || {};
const bindingId = String(binding.id || target?.bindingId || "");
if (!/^[0-9a-f-]{36}$/i.test(applicationId) || !/^[a-z0-9-]+$/i.test(pageId) || !identifier.test(bindingId)) {
throw consumerError("data_product_consumer_target_invalid");
}
return { applicationId, pageId, bindingId };
}
function targetKey(target) {
const identity = targetIdentity(target);
return `${identity.applicationId}/${identity.pageId}/${identity.bindingId}`;
}
function stateFileName(target) {
return `${createHash("sha256").update(targetKey(target), "utf8").digest("hex")}.json`;
}
function factKey(fact) {
return `${fact.semanticType}\u0000${fact.sourceId}`;
}
function safeErrorCode(error) {
const value = String(error?.message || "data_product_consumer_failed");
return /^[a-z][a-z0-9_]{2,120}$/.test(value) ? value : "data_product_consumer_failed";
}
function safeStatusFromFact(fact, policy, nowMs) {
const sourceStatus = [fact?.attributes?.operational_status, fact?.attributes?.status]
.find((value) => typeof value === "string" && value.trim());
const normalized = String(sourceStatus || "active").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, 64) || "active";
if (policy.terminalStatuses.includes(normalized)) return normalized;
const observedAt = Date.parse(String(fact?.observedAt || ""));
if (Number.isFinite(observedAt) && nowMs - observedAt > policy.staleAfterMs) return "stale";
return normalized;
}
function decorateFact(subject) {
return { ...subject.fact, presentationStatus: subject.status };
}
function safeTarget(target) {
const identity = targetIdentity(target);
const binding = target.binding || {};
return {
...identity,
dataProductId: String(binding.dataProductId || ""),
slotId: String(binding.slotId || ""),
delivery: "snapshot+patch",
semanticTypes: [...(binding.semanticTypes || [])],
fieldProjection: [...(binding.fieldProjection || [])],
};
}
function planHash(value) {
return `fcp1_${createHash("sha256").update(JSON.stringify(value), "utf8").digest("base64url")}`;
}
function emptyMetrics() {
return {
snapshotCommits: 0,
patchCommits: 0,
patchOperations: 0,
replayedPatches: 0,
snapshotRebaseRemovals: 0,
canonicalRemovals: 0,
staleTransitions: 0,
reconnects: 0,
};
}
function safeStateSummary(record) {
const state = record.state;
const subjects = Object.values(state.subjects || {});
return {
schemaVersion: STATE_SCHEMA_VERSION,
consumerId: state.consumerId,
target: state.target,
enabled: state.enabled === true,
runtimeState: state.runtimeState,
product: state.product,
policy: state.policy,
cursor: state.cursor,
snapshotGeneration: state.snapshotGeneration,
subjectCount: subjects.length,
subjects: subjects.slice(0, 20).map((subject) => ({
subjectId: subject.fact.sourceId,
semanticType: subject.fact.semanticType,
observedAt: subject.fact.observedAt,
status: subject.status,
})),
viewerCount: record.listeners.size,
upstreamStreamCount: record.stream ? 1 : 0,
metrics: state.metrics,
timestamps: state.timestamps,
lastError: state.lastError || null,
readerGrant: "target-scoped-server-only",
};
}
function validatePolicy(policy, product) {
if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) {
throw consumerError("data_product_consumer_policy_not_found", 409);
}
if (!Number.isInteger(policy.staleAfterMs) || policy.staleAfterMs < 1_000 || policy.staleAfterMs > 7 * 24 * 60 * 60 * 1000) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
const terminalStatuses = Array.isArray(policy.terminalStatuses) ? policy.terminalStatuses : [];
if (terminalStatuses.some((value) => typeof value !== "string" || !/^[a-z0-9_-]{1,64}$/.test(value))) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
if (policy.removeMode !== "canonical-tombstone-or-snapshot-rebase") {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
return {
id: String(policy.id || ""),
version: String(policy.version || ""),
dataProductId: product.id,
productVersion: product.version,
staleAfterMs: policy.staleAfterMs,
terminalStatuses: [...terminalStatuses],
removeMode: policy.removeMode,
};
}
function parseSseBlock(block) {
const fields = { event: "message", id: "", data: [] };
for (const line of block.split("\n")) {
if (!line || line.startsWith(":")) continue;
const separator = line.indexOf(":");
const name = separator === -1 ? line : line.slice(0, separator);
const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, "");
if (name === "event") fields.event = value;
if (name === "id") fields.id = value;
if (name === "data") fields.data.push(value);
}
return { event: fields.event, id: fields.id, data: fields.data.join("\n") };
}
function waitForAbortableDelay(ms, signal) {
if (signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
function done() {
clearTimeout(timer);
signal.removeEventListener("abort", done);
resolve();
}
signal.addEventListener("abort", done, { once: true });
});
}
export function createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl,
resolveTarget,
readReaderToken,
inspectReaderGrant,
ensureReaderGrant,
resolvePolicy,
sanitizeSnapshot,
sanitizePatch,
fetchImpl = fetch,
openStream,
now = () => Date.now(),
idleStopMs = 2_000,
staleSweepMs = 5_000,
reconnectMinMs = 500,
reconnectMaxMs = 15_000,
}) {
const records = new Map();
const ready = mkdir(stateDir, { recursive: true });
let closed = false;
async function writeState(record) {
await ready;
const targetPath = join(stateDir, stateFileName(record.state.target));
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
await writeFile(tempPath, `${JSON.stringify(record.state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
await rename(tempPath, targetPath);
}
async function readState(target) {
await ready;
try {
const parsed = JSON.parse(await readFile(join(stateDir, stateFileName(target)), "utf8"));
if (parsed?.schemaVersion !== STATE_SCHEMA_VERSION || targetKey(parsed.target) !== targetKey(target)) {
throw consumerError("data_product_consumer_state_invalid", 500);
}
return parsed;
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
async function recordFor(target) {
const key = targetKey(target);
if (records.has(key)) return records.get(key);
const persisted = await readState(target);
const record = {
key,
target,
state: persisted,
listeners: new Map(),
stream: null,
idleTimer: null,
writing: Promise.resolve(),
};
records.set(key, record);
return record;
}
function serialize(record, action) {
const pending = record.writing.then(action, action);
record.writing = pending.catch(() => undefined);
return pending;
}
function emit(record, event) {
for (const listener of record.listeners.values()) {
try { listener(event); } catch { /* one browser cannot stop the shared consumer */ }
}
}
async function catalog(target) {
if (!dataPlaneUrl) throw consumerError("data_product_runtime_not_configured", 503);
let product = null;
let readerGrantAction = "reuse";
if (inspectReaderGrant) {
const inspection = await inspectReaderGrant(target);
product = inspection?.product || null;
readerGrantAction = inspection?.readerGrantAction === "ensure" ? "ensure" : "reuse";
} else {
const token = await readReaderToken(target.application.id, target.page.id, target.binding.id);
let response;
try {
response = await fetchImpl(new URL("/internal/data-plane/v1/reader/data-products", `${dataPlaneUrl}/`), {
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
} catch {
throw consumerError("data_product_consumer_catalog_unavailable", 503);
}
if (response.status === 401 || response.status === 403) throw consumerError("data_product_access_denied", 403);
if (!response.ok) throw consumerError("data_product_consumer_catalog_unavailable", 503);
const payload = await response.json().catch(() => null);
product = Array.isArray(payload?.dataProducts)
? payload.dataProducts.find((candidate) => candidate?.id === target.binding.dataProductId)
: null;
}
if (!product || product.active === false) throw consumerError("data_product_not_found", 404);
if (product.deliveryMode !== "snapshot+patch" || typeof product.version !== "string") {
throw consumerError("data_product_consumer_delivery_not_supported", 409);
}
if (target.binding.semanticTypes.some((semanticType) => !product.semanticTypes?.includes(semanticType))) {
throw consumerError("data_product_consumer_semantic_scope_mismatch", 409);
}
return { product, policy: validatePolicy(resolvePolicy(product), product), readerGrantAction };
}
async function plan(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const { product, policy, readerGrantAction } = await catalog(target);
const record = await recordFor(target);
const safe = safeTarget(target);
const configuration = {
target: safe,
product: { id: product.id, version: product.version },
policy,
readerGrant: "target-scoped-server-only",
readerGrantAction,
};
const existingMatches = record.state
&& JSON.stringify(record.state.target) === JSON.stringify(safe)
&& record.state.product?.id === product.id
&& record.state.product?.version === product.version
&& record.state.policy?.id === policy.id
&& record.state.policy?.version === policy.version;
const action = !record.state ? "create" : existingMatches ? (record.state.enabled ? "refresh" : "resume") : "replace";
return {
schemaVersion: PLAN_SCHEMA_VERSION,
planId: planHash({ configuration, action }),
action,
configuration,
current: record.state ? { enabled: record.state.enabled === true, cursor: record.state.cursor, product: record.state.product } : null,
effects: [
...(readerGrantAction === "ensure" ? ["ensure-target-scoped-reader-grant"] : []),
"persist-consumer-state",
"bootstrap-scoped-snapshot",
"share-one-upstream-stream-per-active-binding",
],
destructive: false,
};
}
function initialState(target, product, policy) {
const timestamp = new Date(now()).toISOString();
return {
schemaVersion: STATE_SCHEMA_VERSION,
consumerId: createHash("sha256").update(targetKey(target), "utf8").digest("hex").slice(0, 32),
target: safeTarget(target),
enabled: true,
runtimeState: "bootstrapping",
product: { id: product.id, version: product.version },
policy,
cursor: "0",
snapshotGeneration: 0,
subjects: {},
metrics: emptyMetrics(),
timestamps: { createdAt: timestamp, updatedAt: timestamp, lastSnapshotAt: null, lastPatchAt: null, lastConnectedAt: null },
lastError: null,
};
}
function upstreamUrl(record, resource, after = "") {
const url = new URL(`/internal/data-plane/v1/data-products/${encodeURIComponent(record.state.target.dataProductId)}/${resource}`, `${dataPlaneUrl}/`);
if (after) url.searchParams.set("after", after);
return url;
}
async function fetchSnapshot(record) {
const token = await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId);
let response;
try {
response = await fetchImpl(upstreamUrl(record, "snapshot"), {
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
} catch {
throw consumerError("data_product_runtime_unavailable", 503);
}
if (response.status === 401 || response.status === 403) throw consumerError("data_product_access_denied", 403);
if (response.status === 404) throw consumerError("data_product_not_found", 404);
if (!response.ok) throw consumerError("data_product_runtime_unavailable", 503);
const payload = await response.json().catch(() => null);
return sanitizeSnapshot(payload, record.target.binding);
}
async function bootstrap(record, { notify = true } = {}) {
return serialize(record, async () => {
record.state.runtimeState = "bootstrapping";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record);
try {
const snapshot = await fetchSnapshot(record);
const previousKeys = new Set(Object.keys(record.state.subjects || {}));
const subjects = {};
for (const fact of snapshot.facts) {
const key = factKey(fact);
subjects[key] = {
fact,
status: safeStatusFromFact(fact, record.state.policy, now()),
lastAppliedCursor: snapshot.cursor,
};
previousKeys.delete(key);
}
const timestamp = new Date(now()).toISOString();
record.state = {
...record.state,
enabled: true,
runtimeState: record.listeners.size ? "connecting" : "idle",
product: snapshot.dataProduct,
cursor: snapshot.cursor,
snapshotGeneration: record.state.snapshotGeneration + 1,
subjects,
metrics: {
...record.state.metrics,
snapshotCommits: record.state.metrics.snapshotCommits + 1,
snapshotRebaseRemovals: record.state.metrics.snapshotRebaseRemovals + previousKeys.size,
},
timestamps: { ...record.state.timestamps, updatedAt: timestamp, lastSnapshotAt: timestamp },
lastError: null,
};
await writeState(record);
if (notify) emit(record, {
event: "nodedc.data-product.resync-required.v1",
data: { schemaVersion: "nodedc.data-product.resync-required/v1", dataProductId: record.state.product.id },
});
return snapshot;
} catch (error) {
const timestamp = new Date(now()).toISOString();
record.state.runtimeState = "error";
record.state.lastError = { code: safeErrorCode(error), at: timestamp };
record.state.timestamps.updatedAt = timestamp;
await writeState(record);
throw error;
}
});
}
async function apply(input) {
const planned = await plan(input);
if (input.planId !== planned.planId) throw consumerError("data_product_consumer_plan_mismatch", 409);
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
if (planned.configuration.readerGrantAction === "ensure") {
if (!ensureReaderGrant) throw consumerError("data_product_reader_grant_not_found", 403);
await ensureReaderGrant(target);
}
const record = await recordFor(target);
const { product, policy, readerGrantAction } = await catalog(target);
if (readerGrantAction !== "reuse") throw consumerError("data_product_reader_grant_not_ready", 409);
if (record.stream) stopStream(record);
if (!record.state || planned.action === "replace") record.state = initialState(target, product, policy);
else {
record.target = target;
record.state.target = safeTarget(target);
record.state.product = { id: product.id, version: product.version };
record.state.policy = policy;
record.state.enabled = true;
record.state.runtimeState = "bootstrapping";
record.state.lastError = null;
}
await writeState(record);
await bootstrap(record, { notify: false });
if (record.listeners.size) startStream(record);
return { plan: planned, consumer: safeStateSummary(record) };
}
async function ensureProvisioned(target) {
const record = await recordFor(target);
if (record.state) {
if (!record.state.enabled) throw consumerError("data_product_consumer_stopped", 409);
record.target = target;
return record;
}
const planned = await plan(targetIdentity(target));
await apply({ ...targetIdentity(target), planId: planned.planId });
return recordFor(target);
}
async function refreshPresentation(record) {
if (!record.state?.enabled) return [];
return serialize(record, async () => {
const operations = [];
for (const subject of Object.values(record.state.subjects || {})) {
const status = safeStatusFromFact(subject.fact, record.state.policy, now());
if (status === subject.status) continue;
subject.status = status;
operations.push({ op: "upsert", sourceId: subject.fact.sourceId, semanticType: subject.fact.semanticType, status });
}
if (!operations.length) return operations;
const timestamp = new Date(now()).toISOString();
record.state.metrics.staleTransitions += operations.filter((operation) => operation.status === "stale").length;
record.state.timestamps.updatedAt = timestamp;
await writeState(record);
emit(record, {
event: "nodedc.foundry.presentation-patch.v1",
data: { schemaVersion: PRESENTATION_PATCH_SCHEMA_VERSION, generatedAt: timestamp, operations },
});
return operations;
});
}
async function applyPatch(record, patch) {
return serialize(record, async () => {
const currentCursor = BigInt(record.state.cursor);
const patchCursor = BigInt(patch.cursor);
if (patchCursor <= currentCursor) {
record.state.metrics.replayedPatches += 1;
return { replayed: true };
}
if (patch.previousCursor !== record.state.cursor) throw consumerError("resync_required", 409);
const subjects = { ...record.state.subjects };
const emittedOperations = [];
for (const operation of patch.operations) {
if (operation.op === "upsert") {
const key = factKey(operation.fact);
const subject = {
fact: operation.fact,
status: safeStatusFromFact(operation.fact, record.state.policy, now()),
lastAppliedCursor: patch.cursor,
};
subjects[key] = subject;
emittedOperations.push({ op: "upsert", fact: decorateFact(subject) });
} else if (operation.op === "remove") {
const key = `${operation.semanticType}\u0000${operation.sourceId}`;
if (subjects[key]) {
delete subjects[key];
emittedOperations.push(operation);
}
}
}
const timestamp = new Date(now()).toISOString();
record.state = {
...record.state,
runtimeState: "connected",
cursor: patch.cursor,
subjects,
metrics: {
...record.state.metrics,
patchCommits: record.state.metrics.patchCommits + 1,
patchOperations: record.state.metrics.patchOperations + emittedOperations.length,
canonicalRemovals: record.state.metrics.canonicalRemovals + emittedOperations.filter((operation) => operation.op === "remove").length,
},
timestamps: { ...record.state.timestamps, updatedAt: timestamp, lastPatchAt: timestamp },
lastError: null,
};
// Persist the new cursor and semantic state before acknowledging it to
// any browser listener. A crash can replay a patch but cannot skip one.
await writeState(record);
if (emittedOperations.length) emit(record, {
event: "nodedc.data-product.patch.v1",
id: patch.cursor,
data: { ...patch, operations: emittedOperations },
});
return { replayed: false };
});
}
async function consumeOnce(record, signal) {
const token = await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId);
const url = upstreamUrl(record, "stream", record.state.cursor);
const response = openStream
? await openStream({ url, token, signal })
: await fetchImpl(url, {
headers: { authorization: `Bearer ${token}`, accept: "text/event-stream", connection: "close" },
signal,
});
if (response.status === 409) throw consumerError("resync_required", 409);
if (response.status === 401 || response.status === 403) throw consumerError("data_product_access_denied", 403);
if (!response.ok || !response.body || !String(response.headers.get("content-type") || "").startsWith("text/event-stream")) {
throw consumerError("data_product_runtime_unavailable", 503);
}
const timestamp = new Date(now()).toISOString();
record.state.runtimeState = "connected";
record.state.timestamps.lastConnectedAt = timestamp;
record.state.timestamps.updatedAt = timestamp;
record.state.lastError = null;
await writeState(record);
const reader = response.body.getReader();
if (record.stream) record.stream.reader = reader;
const decoder = new TextDecoder();
let pending = "";
let upstreamDone = false;
try {
while (!signal.aborted && record.listeners.size) {
const next = await reader.read();
if (next.done) {
upstreamDone = true;
break;
}
pending += decoder.decode(next.value, { stream: true }).replace(/\r\n/g, "\n");
let separator;
while ((separator = pending.indexOf("\n\n")) !== -1) {
const event = parseSseBlock(pending.slice(0, separator));
pending = pending.slice(separator + 2);
if (event.event !== "nodedc.data-product.patch.v1") continue;
let payload = null;
try { payload = JSON.parse(event.data); } catch { payload = null; }
const patch = sanitizePatch(payload, record.target.binding);
if (!patch) throw consumerError("data_product_patch_contract_invalid", 502);
await applyPatch(record, patch);
}
}
} finally {
if (!upstreamDone) await reader.cancel().catch(() => undefined);
reader.releaseLock();
if (record.stream?.reader === reader) record.stream.reader = null;
}
}
function startStream(record) {
if (closed || record.stream || !record.state?.enabled || !record.listeners.size) return;
if (record.idleTimer) clearTimeout(record.idleTimer);
record.idleTimer = null;
const controller = new AbortController();
record.stream = { controller, promise: null, reader: null };
record.stream.promise = (async () => {
let delay = reconnectMinMs;
while (!closed && !controller.signal.aborted && record.listeners.size && record.state.enabled) {
try {
record.state.runtimeState = "connecting";
await writeState(record);
await consumeOnce(record, controller.signal);
delay = reconnectMinMs;
if (!controller.signal.aborted && record.listeners.size) throw consumerError("data_product_stream_closed", 503);
} catch (error) {
if (controller.signal.aborted || closed || !record.listeners.size) break;
if (error?.message === "resync_required") {
await bootstrap(record).catch(() => undefined);
} else {
const timestamp = new Date(now()).toISOString();
record.state.runtimeState = "reconnecting";
record.state.metrics.reconnects += 1;
record.state.lastError = { code: safeErrorCode(error), at: timestamp };
record.state.timestamps.updatedAt = timestamp;
await writeState(record);
}
await waitForAbortableDelay(delay, controller.signal);
delay = Math.min(reconnectMaxMs, delay * 2);
}
}
})().finally(async () => {
if (record.stream?.controller === controller) record.stream = null;
if (record.state?.enabled && !record.listeners.size) {
record.state.runtimeState = "idle";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record).catch(() => undefined);
}
});
}
function stopStream(record) {
if (record.idleTimer) clearTimeout(record.idleTimer);
record.idleTimer = null;
void record.stream?.reader?.cancel().catch(() => undefined);
record.stream?.controller.abort();
}
async function snapshot(input) {
const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await ensureProvisioned(target);
// A removed target grant revokes browser reads immediately even though the
// last safe snapshot remains persisted for rollback/diagnostics.
await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId);
await refreshPresentation(record);
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: record.state.product,
generatedAt: record.state.timestamps.lastSnapshotAt || record.state.timestamps.updatedAt,
cursor: record.state.cursor,
facts: Object.values(record.state.subjects).map(decorateFact),
};
}
async function subscribe(input, listener) {
const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await ensureProvisioned(target);
const after = String(input.after || "");
if (after && !cursorPattern.test(after)) throw consumerError("stream_cursor_invalid");
if (after && after !== record.state.cursor) return { resyncRequired: true, cursor: record.state.cursor, release() {} };
const leaseId = randomUUID();
record.listeners.set(leaseId, listener);
listener({
event: "nodedc.data-product.ready.v1",
data: {
schemaVersion: "nodedc.data-product.ready/v1",
dataProductId: record.state.product.id,
cursor: record.state.cursor,
emittedAt: new Date(now()).toISOString(),
},
});
startStream(record);
let released = false;
return {
resyncRequired: false,
cursor: record.state.cursor,
release() {
if (released) return;
released = true;
record.listeners.delete(leaseId);
if (!record.listeners.size && record.stream && !record.idleTimer) {
record.idleTimer = setTimeout(() => {
record.idleTimer = null;
if (!record.listeners.size) stopStream(record);
}, idleStopMs);
record.idleTimer.unref?.();
}
},
};
}
async function status(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await recordFor(target);
if (!record.state) return { found: false, target: safeTarget(target), readerGrant: "target-scoped-server-only" };
await refreshPresentation(record);
return { found: true, consumer: safeStateSummary(record) };
}
async function accept(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await ensureProvisioned(target);
const timeoutMs = Math.min(30_000, Math.max(250, Number(input.timeoutMs ?? 5_000)));
const minSubjectCount = Math.min(5_000, Math.max(0, Number(input.minSubjectCount ?? 1)));
const minPatchCount = Math.min(1_000, Math.max(0, Number(input.minPatchCount ?? 0)));
if (![timeoutMs, minSubjectCount, minPatchCount].every(Number.isInteger)) throw consumerError("data_product_consumer_acceptance_input_invalid");
const baselinePatchCount = record.state.metrics.patchCommits;
const lease = await subscribe(target, () => undefined);
const deadline = now() + timeoutMs;
try {
while (now() < deadline) {
const patchDelta = record.state.metrics.patchCommits - baselinePatchCount;
if (Object.keys(record.state.subjects).length >= minSubjectCount && patchDelta >= minPatchCount) break;
await new Promise((resolve) => setTimeout(resolve, 50));
}
await refreshPresentation(record);
const subjectKeys = Object.keys(record.state.subjects);
const duplicateCount = subjectKeys.length - new Set(subjectKeys).size;
const patchDelta = record.state.metrics.patchCommits - baselinePatchCount;
const upstreamStreamCount = record.stream ? 1 : 0;
const checks = {
bindingResolved: true,
targetScopedReader: true,
snapshotCommitted: record.state.metrics.snapshotCommits > 0,
cursorPersisted: cursorPattern.test(record.state.cursor),
minimumSubjects: subjectKeys.length >= minSubjectCount,
minimumNewPatches: patchDelta >= minPatchCount,
duplicateSubjects: duplicateCount === 0,
singleUpstreamStream: upstreamStreamCount <= 1,
secretAndEndpointExcluded: true,
};
return {
schemaVersion: ACCEPTANCE_SCHEMA_VERSION,
accepted: Object.values(checks).every(Boolean),
checks,
observed: { subjectCount: subjectKeys.length, newPatchCount: patchDelta, cursor: record.state.cursor, runtimeState: record.state.runtimeState },
consumer: safeStateSummary(record),
};
} finally {
lease.release();
}
}
async function rollback(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await recordFor(target);
if (!record.state) throw consumerError("data_product_consumer_not_found", 404);
stopStream(record);
record.listeners.clear();
record.state.enabled = false;
record.state.runtimeState = "stopped";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
record.state.lastError = null;
await writeState(record);
return { rolledBack: true, snapshotPreserved: true, consumer: safeStateSummary(record) };
}
async function resumePersisted() {
await ready;
for (const entry of await readdir(stateDir, { withFileTypes: true })) {
if (!entry.isFile() || !/^[0-9a-f]{64}\.json$/.test(entry.name)) continue;
try {
const state = JSON.parse(await readFile(join(stateDir, entry.name), "utf8"));
if (state?.schemaVersion !== STATE_SCHEMA_VERSION || !state.target) continue;
const target = await resolveTarget(state.target.applicationId, state.target.pageId, state.target.bindingId);
const record = await recordFor(target);
record.target = target;
if (record.state?.enabled) {
record.state.runtimeState = "idle";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record);
}
} catch {
// One damaged or obsolete target cannot stop Foundry from serving the
// remaining applications. Its file remains available for diagnostics.
}
}
}
const staleTimer = setInterval(() => {
for (const record of records.values()) void refreshPresentation(record).catch(() => undefined);
}, staleSweepMs);
staleTimer.unref?.();
async function shutdown() {
if (closed) return;
closed = true;
clearInterval(staleTimer);
const streams = [];
for (const record of records.values()) {
stopStream(record);
record.listeners.clear();
if (record.stream?.promise) streams.push(record.stream.promise);
}
await Promise.race([
Promise.allSettled(streams),
new Promise((resolve) => setTimeout(resolve, 750)),
]);
}
return {
plan,
apply,
snapshot,
subscribe,
status,
accept,
rollback,
resumePersisted,
shutdown,
};
}
export const foundryDataProductConsumerSchemas = Object.freeze({
state: STATE_SCHEMA_VERSION,
plan: PLAN_SCHEMA_VERSION,
acceptance: ACCEPTANCE_SCHEMA_VERSION,
presentationPatch: PRESENTATION_PATCH_SCHEMA_VERSION,
});
@@ -0,0 +1,336 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
const target = {
application: { id: "11111111-1111-4111-8111-111111111111" },
page: { id: "map" },
binding: {
id: "fleet-current",
dataProductId: "fleet.positions.current.v1",
slotId: "points",
delivery: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fieldProjection: ["display_name", "operational_status"],
},
};
function fact({ longitude = 37.61, observedAt = "2026-07-19T10:00:00.000Z" } = {}) {
return {
sourceId: "vehicle-001",
semanticType: "map.moving_object",
observedAt,
receivedAt: observedAt,
attributes: { display_name: "Vehicle 001", operational_status: "active" },
geometry: { type: "Point", coordinates: [longitude, 55.75] },
};
}
function snapshot(cursor = "7", facts = [fact()]) {
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
generatedAt: "2026-07-19T10:00:01.000Z",
cursor,
facts,
};
}
function patch(cursor, previousCursor, operations) {
return {
schemaVersion: "nodedc.data-product.patch/v1",
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
cursor,
previousCursor,
emittedAt: "2026-07-19T10:00:02.000Z",
operations,
};
}
function sseResponse(value, signal, counters) {
const encoder = new TextEncoder();
return new Response(new ReadableStream({
start(controller) {
counters.open += 1;
controller.enqueue(encoder.encode(`event: nodedc.data-product.patch.v1\ndata: ${JSON.stringify(value)}\n\n`));
const close = () => {
counters.closed += 1;
try { controller.close(); } catch { /* already closed */ }
};
signal.addEventListener("abort", close, { once: true });
},
}), { status: 200, headers: { "content-type": "text/event-stream" } });
}
async function waitUntil(check, label, timeoutMs = 2_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`timeout:${label}`);
}
test("server-owned consumer persists cursor, shares streams, transitions stale and removes canonically", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-"));
let nowMs = Date.parse("2026-07-19T10:00:10.000Z");
let currentSnapshot = snapshot();
const streamPatches = [
patch("8", "7", [{ op: "upsert", fact: fact({ longitude: 37.62 }) }]),
patch("9", "8", [{
op: "remove",
sourceId: "vehicle-001",
semanticType: "map.moving_object",
removedAt: "2026-07-19T10:01:30.000Z",
reason: "tombstone",
}]),
];
const counters = { catalog: 0, snapshot: 0, stream: 0, open: 0, closed: 0 };
const fetchImpl = async (input, options = {}) => {
const url = new URL(input);
assert.equal(options.headers.authorization, "Bearer ndc_edprb_test-reader-capability");
assert.equal(url.origin, "http://edp.test");
if (url.pathname.endsWith("/reader/data-products")) {
counters.catalog += 1;
return Response.json({
ok: true,
dataProducts: [{
id: "fleet.positions.current.v1",
version: "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
}],
});
}
if (url.pathname.endsWith("/snapshot")) {
counters.snapshot += 1;
return Response.json(currentSnapshot);
}
if (url.pathname.endsWith("/stream")) {
counters.stream += 1;
const next = streamPatches.shift();
assert.ok(next, "unexpected extra upstream stream");
assert.equal(url.searchParams.get("after"), next.previousCursor);
return sseResponse(next, options.signal, counters);
}
throw new Error(`unexpected_url:${url}`);
};
const sanitizeSnapshot = (value) => value;
const sanitizePatch = (value) => value;
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => target,
readReaderToken: async () => "ndc_edprb_test-reader-capability",
resolvePolicy: (product) => ({
id: "map-moving-object-current-v1",
version: "1.0.0",
dataProductId: product.id,
productVersion: product.version,
staleAfterMs: 60_000,
terminalStatuses: ["inactive", "no-position", "no_position"],
removeMode: "canonical-tombstone-or-snapshot-rebase",
}),
sanitizeSnapshot,
sanitizePatch,
fetchImpl,
now: () => nowMs,
idleStopMs: 20,
staleSweepMs: 10_000,
reconnectMinMs: 10,
reconnectMaxMs: 20,
});
try {
const input = { applicationId: target.application.id, pageId: target.page.id, bindingId: target.binding.id };
const plan = await manager.plan(input);
assert.equal(plan.action, "create");
assert.match(plan.planId, /^fcp1_/);
assert.equal(JSON.stringify(plan).includes("ndc_edprb_"), false);
assert.equal(JSON.stringify(plan).includes("http://edp.test"), false);
const applied = await manager.apply({ ...input, planId: plan.planId });
assert.equal(applied.consumer.cursor, "7");
assert.equal(applied.consumer.subjectCount, 1);
assert.equal(applied.consumer.runtimeState, "idle");
const events = [];
const first = await manager.subscribe({ ...target, after: "7" }, (event) => events.push(event));
const second = await manager.subscribe({ ...target, after: "7" }, (event) => events.push(event));
await waitUntil(async () => (await manager.status(input)).consumer.cursor === "8", "patch_cursor_8");
assert.equal(counters.stream, 1, "two viewers must share one upstream stream");
assert.equal((await manager.status(input)).consumer.subjectCount, 1);
assert.equal(events.filter((event) => event.event === "nodedc.data-product.patch.v1").length, 2, "one committed patch is fanned out once per viewer");
nowMs = Date.parse("2026-07-19T10:01:30.001Z");
const stale = await manager.status(input);
assert.equal(stale.consumer.subjects[0].status, "stale");
assert.equal(stale.consumer.metrics.staleTransitions, 1);
first.release();
second.release();
await waitUntil(() => counters.closed === 1, "last_viewer_closes_upstream");
const removeEvents = [];
const third = await manager.subscribe({ ...target, after: "8" }, (event) => removeEvents.push(event));
await waitUntil(async () => (await manager.status(input)).consumer.cursor === "9", "remove_cursor_9");
assert.equal((await manager.status(input)).consumer.subjectCount, 0);
assert.equal((await manager.status(input)).consumer.metrics.canonicalRemovals, 1);
assert.ok(removeEvents.some((event) => event.data?.operations?.some((operation) => operation.op === "remove")));
third.release();
await waitUntil(() => counters.closed === 2, "remove_stream_closed");
currentSnapshot = snapshot("9", []);
const refreshPlan = await manager.plan(input);
await manager.apply({ ...input, planId: refreshPlan.planId });
const empty = await manager.snapshot(target);
assert.deepEqual(empty.facts, []);
const rolledBack = await manager.rollback(input);
assert.equal(rolledBack.snapshotPreserved, true);
assert.equal(rolledBack.consumer.runtimeState, "stopped");
await assert.rejects(() => manager.snapshot(target), /data_product_consumer_stopped/);
const stateFiles = await readdir(stateDir);
assert.equal(stateFiles.length, 1);
const persisted = await readFile(join(stateDir, stateFiles[0]), "utf8");
assert.equal(persisted.includes("ndc_edprb_"), false);
assert.equal(persisted.includes("http://edp.test"), false);
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
test("restart restores the committed cursor and transient stream errors preserve subjects", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-restart-"));
const input = { applicationId: target.application.id, pageId: target.page.id, bindingId: target.binding.id };
let streamAttempts = 0;
const fetchImpl = async (request, options = {}) => {
const url = new URL(request);
assert.equal(options.headers.authorization, "Bearer ndc_edprb_test-reader-capability");
if (url.pathname.endsWith("/reader/data-products")) {
return Response.json({ ok: true, dataProducts: [{
id: "fleet.positions.current.v1",
version: "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
}] });
}
if (url.pathname.endsWith("/snapshot")) return Response.json(snapshot());
if (url.pathname.endsWith("/stream")) {
streamAttempts += 1;
return Response.json({ ok: false, error: "temporary_failure" }, { status: 503 });
}
throw new Error(`unexpected_url:${url}`);
};
const createManager = () => createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => target,
readReaderToken: async () => "ndc_edprb_test-reader-capability",
resolvePolicy: (product) => ({
id: "map-moving-object-current-v1",
version: "1.0.0",
dataProductId: product.id,
productVersion: product.version,
staleAfterMs: 60_000,
terminalStatuses: ["inactive", "no-position", "no_position"],
removeMode: "canonical-tombstone-or-snapshot-rebase",
}),
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl,
now: () => Date.parse("2026-07-19T10:00:10.000Z"),
idleStopMs: 10,
staleSweepMs: 10_000,
reconnectMinMs: 10,
reconnectMaxMs: 20,
});
const first = createManager();
let second;
try {
const plan = await first.plan(input);
await first.apply({ ...input, planId: plan.planId });
await first.shutdown();
second = createManager();
await second.resumePersisted();
const restored = await second.status(input);
assert.equal(restored.consumer.cursor, "7");
assert.equal(restored.consumer.subjectCount, 1);
const lease = await second.subscribe({ ...target, after: "7" }, () => undefined);
await waitUntil(async () => (await second.status(input)).consumer.metrics.reconnects > 0, "transient_reconnect");
lease.release();
const afterFailure = await second.status(input);
assert.equal(afterFailure.consumer.subjectCount, 1);
assert.equal(afterFailure.consumer.cursor, "7");
assert.ok(streamAttempts >= 1);
} finally {
await first.shutdown();
await second?.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
test("exact apply can ensure a missing target-scoped reader grant without exposing it in the plan", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-grant-"));
const input = { applicationId: target.application.id, pageId: target.page.id, bindingId: target.binding.id };
const product = {
id: "fleet.positions.current.v1",
version: "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
};
let grantReady = false;
let ensureCount = 0;
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => target,
readReaderToken: async () => grantReady ? "ndc_edprb_managed-reader-capability" : null,
inspectReaderGrant: async () => ({ product, readerGrantAction: grantReady ? "reuse" : "ensure" }),
ensureReaderGrant: async () => {
ensureCount += 1;
grantReady = true;
return { ensured: true };
},
resolvePolicy: (resolvedProduct) => ({
id: "map-moving-object-current-v1",
version: "1.0.0",
dataProductId: resolvedProduct.id,
productVersion: resolvedProduct.version,
staleAfterMs: 60_000,
terminalStatuses: ["inactive", "no-position", "no_position"],
removeMode: "canonical-tombstone-or-snapshot-rebase",
}),
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async (request, options = {}) => {
assert.equal(options.headers.authorization, "Bearer ndc_edprb_managed-reader-capability");
assert.ok(new URL(request).pathname.endsWith("/snapshot"));
return Response.json(snapshot());
},
});
try {
const plan = await manager.plan(input);
assert.equal(plan.configuration.readerGrantAction, "ensure");
assert.ok(plan.effects.includes("ensure-target-scoped-reader-grant"));
assert.equal(JSON.stringify(plan).includes("capability"), false);
const applied = await manager.apply({ ...input, planId: plan.planId });
assert.equal(ensureCount, 1);
assert.equal(applied.consumer.subjectCount, 1);
const refresh = await manager.plan(input);
assert.equal(refresh.configuration.readerGrantAction, "reuse");
assert.equal(refresh.effects.includes("ensure-target-scoped-reader-grant"), false);
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
+101 -4
View File
@@ -114,6 +114,9 @@ function toolText(payload, isError = false) {
function normalizeMcpError(error) {
const code = String(error?.message || "foundry_operation_failed");
if (code === "application_not_found") return { code, status: 404 };
if (/^(?:data_product_|resync_required$|stream_cursor_invalid$)/.test(code) && Number.isInteger(error?.statusCode)) {
return { code, status: error.statusCode };
}
if (code.startsWith("invalid_") || code.startsWith("unknown_") || code.startsWith("map_") || code.endsWith("_required")) return { code, status: 400 };
if (code.startsWith("duplicate_") || code.includes("conflict") || code.includes("mismatch")) return { code, status: 409 };
return { code: "foundry_operation_failed", status: 500 };
@@ -268,6 +271,87 @@ const tools = [
},
},
},
{
name: "foundry_plan_map_data_product_consumer",
title: "Plan Map data product consumer",
description: "Validate an approved Map data-product binding, its target-scoped server reader and versioned Foundry consumer policy. Returns a safe deterministic plan; it never returns a capability or endpoint.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
},
},
},
{
name: "foundry_apply_map_data_product_consumer",
title: "Apply Map data product consumer",
description: "Apply an exact consumer plan and commit a scoped snapshot into server-owned Foundry state. Active page viewers then share one upstream durable patch stream.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId", "planId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
planId: { type: "string" },
idempotencyKey: { type: "string" },
},
},
},
{
name: "foundry_get_map_data_product_consumer_status",
title: "Get Map data product consumer status",
description: "Read safe persisted cursor, subject/status counters, reconnect diagnostics and viewer/upstream counts for one approved binding. No raw fact attributes, capability or endpoint are returned.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
},
},
},
{
name: "foundry_accept_map_data_product_consumer",
title: "Accept Map data product consumer",
description: "Run a bounded lifecycle acceptance lease against the shared server consumer and verify persisted snapshot/cursor, subject identity, optional new patches, deduplication and secret boundary.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
timeoutMs: { type: "integer", minimum: 250, maximum: 30000 },
minSubjectCount: { type: "integer", minimum: 0, maximum: 5000 },
minPatchCount: { type: "integer", minimum: 0, maximum: 1000 },
},
},
},
{
name: "foundry_rollback_map_data_product_consumer",
title: "Rollback Map data product consumer",
description: "Stop one server-owned consumer without deleting its last safe snapshot. Re-applying a fresh exact plan resumes it.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
idempotencyKey: { type: "string" },
},
},
},
];
function toolMap(operations) {
@@ -280,6 +364,11 @@ function toolMap(operations) {
foundry_add_page_instance: (input, actor) => operations.addPageInstance(input, actor),
foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor),
foundry_upsert_map_data_product_binding: (input, actor) => operations.upsertMapDataProductBinding(input, actor),
foundry_plan_map_data_product_consumer: (input) => operations.planMapDataProductConsumer(input),
foundry_apply_map_data_product_consumer: (input, actor) => operations.applyMapDataProductConsumer(input, actor),
foundry_get_map_data_product_consumer_status: (input) => operations.getMapDataProductConsumerStatus(input),
foundry_accept_map_data_product_consumer: (input) => operations.acceptMapDataProductConsumer(input),
foundry_rollback_map_data_product_consumer: (input, actor) => operations.rollbackMapDataProductConsumer(input, actor),
};
}
@@ -292,8 +381,14 @@ export async function handleFoundryMcpRequest(request, response, options) {
return;
}
if (!originAllowed(request, allowedOrigins)) return sendJson(response, 403, { error: "mcp_origin_not_allowed" });
if (!config.capabilitySecret) return sendJson(response, 503, { error: "foundry_mcp_not_configured" });
const actor = actorFromMcpCapability(bearerToken(request), config);
const token = bearerToken(request);
let actor = config.capabilitySecret ? actorFromMcpCapability(token, config) : null;
if (!actor && typeof options.authenticateAgentToken === "function") {
actor = await options.authenticateAgentToken(token, { check: true });
}
if (!config.capabilitySecret && typeof options.authenticateAgentToken !== "function") {
return sendJson(response, 503, { error: "foundry_mcp_not_configured" });
}
if (!actor) return sendJson(response, 401, { error: "mcp_unauthorized" });
let message;
@@ -310,8 +405,8 @@ export async function handleFoundryMcpRequest(request, response, options) {
return sendJson(response, 200, mcpResult(id, {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "nodedc_module_foundry", version: "0.1.0" },
instructions: "NDC Module Foundry edits application instances only. Page Library is read-only. Application deletion is unavailable.",
serverInfo: { name: "nodedc_module_foundry", version: "0.2.0" },
instructions: "NDC Module Foundry edits application instances and controls approved server-owned data-product consumers. Page Library is read-only. Application deletion is unavailable. Provider endpoints and credentials are never MCP inputs or outputs.",
}));
}
if (message.method === "notifications/initialized") return sendJson(response, 202, {});
@@ -383,6 +478,8 @@ export async function handleFoundryEntitlementRequest(request, response, options
"foundry.page-instance.create",
"foundry.map-pin.upsert",
"foundry.map-data-product.upsert",
"foundry.map-data-product-consumer.read",
"foundry.map-data-product-consumer.lifecycle",
],
};
if (!accessAllowed) {
+267
View File
@@ -0,0 +1,267 @@
import { constants as fsConstants } from "node:fs";
import { chmod, lstat, mkdir, open } from "node:fs/promises";
import { createHash, createPrivateKey, randomBytes, randomUUID, sign } from "node:crypto";
import { join, resolve } from "node:path";
const SIGNATURE_SCHEMA = "nodedc.external-data-plane.managed-provisioner-request/v1";
const TOKEN_PATTERN = /^ndc_edprb_[A-Za-z0-9_-]{32,512}$/;
const IDENTITY_PATTERN = /^[a-z][a-z0-9._:-]{2,127}$/i;
const AUDIENCE_PATTERN = /^[a-z][a-z0-9._:/-]{2,255}$/i;
function provisionerError(code, statusCode = 500) {
return Object.assign(new Error(code), { statusCode });
}
function targetAddress(target) {
const applicationId = String(target?.application?.id || target?.applicationId || "");
const pageId = String(target?.page?.id || target?.pageId || "");
const bindingId = String(target?.binding?.id || target?.bindingId || "");
if (!/^[0-9a-f-]{36}$/i.test(applicationId) || !IDENTITY_PATTERN.test(pageId)
|| !IDENTITY_PATTERN.test(bindingId)) {
throw provisionerError("foundry_reader_grant_target_invalid", 400);
}
return { applicationId, pageId, bindingId };
}
function targetIdentity(target) {
const address = targetAddress(target);
const dataProductId = String(target?.binding?.dataProductId || target?.dataProductId || "");
if (!IDENTITY_PATTERN.test(dataProductId)) throw provisionerError("foundry_reader_grant_target_invalid", 400);
const { applicationId, pageId, bindingId } = address;
return { applicationId, pageId, bindingId, dataProductId };
}
function targetDigest(target) {
const identity = targetAddress(target);
return createHash("sha256")
.update(`${identity.applicationId}/${identity.pageId}/${identity.bindingId}`, "utf8")
.digest("hex");
}
function signingPayload({ audience, serviceId, keyId, method, path, timestamp, nonce, bodySha256 }) {
return JSON.stringify({
schemaVersion: SIGNATURE_SCHEMA,
audience,
serviceId,
keyId,
method,
path,
timestamp,
nonce,
bodySha256,
});
}
export function createFoundryReaderGrantProvisioner({
dataPlaneUrl,
privateKeyFile,
grantsDir,
serviceId = "nodedc-module-foundry",
keyId = "foundry-edp-managed-provisioner-v1",
audience = "nodedc-external-data-plane.managed-provisioning.v1",
fetchImpl = fetch,
now = () => new Date(),
randomBytesImpl = randomBytes,
randomUUIDImpl = randomUUID,
production = process.env.NODE_ENV === "production",
}) {
const baseUrl = String(dataPlaneUrl || "").trim().replace(/\/$/, "");
const keyPath = String(privateKeyFile || "").trim();
const tokenRoot = String(grantsDir || "").trim();
const configured = Boolean(baseUrl && keyPath && tokenRoot && IDENTITY_PATTERN.test(serviceId)
&& IDENTITY_PATTERN.test(keyId) && AUDIENCE_PATTERN.test(audience));
let privateKeyPromise = null;
async function loadPrivateKey() {
if (!configured) throw provisionerError("foundry_reader_grant_provisioner_not_configured", 503);
if (!privateKeyPromise) privateKeyPromise = readPrivateKeySecurely(keyPath, { production });
return privateKeyPromise;
}
async function signedRequest(method, path, value) {
const privateKey = await loadPrivateKey();
const body = JSON.stringify(value);
const bodySha256 = createHash("sha256").update(body, "utf8").digest("hex");
const timestamp = now().toISOString();
const nonce = randomUUIDImpl();
const signature = sign(null, Buffer.from(signingPayload({
audience,
serviceId,
keyId,
method,
path,
timestamp,
nonce,
bodySha256,
}), "utf8"), privateKey).toString("base64url");
let response;
try {
response = await fetchImpl(new URL(path, `${baseUrl}/`), {
method,
headers: {
"content-type": "application/json",
accept: "application/json",
"x-nodedc-engine-service-id": serviceId,
"x-nodedc-engine-key-id": keyId,
"x-nodedc-request-audience": audience,
"x-nodedc-request-timestamp": timestamp,
"x-nodedc-request-nonce": nonce,
"x-nodedc-content-sha256": bodySha256,
"x-nodedc-request-signature": signature,
},
body,
signal: AbortSignal.timeout(10_000),
});
} catch {
throw provisionerError("foundry_reader_grant_provisioner_unavailable", 503);
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const code = String(payload?.error || payload?.code || "foundry_reader_grant_provisioner_rejected");
throw provisionerError(/^[a-z][a-z0-9_]{2,120}$/.test(code) ? code : "foundry_reader_grant_provisioner_rejected", response.status);
}
return payload;
}
async function plan(target) {
const identity = targetIdentity(target);
const payload = await signedRequest("POST", "/internal/data-plane/v1/consumer-reader-bindings/plan", {
allowedDataProductIds: [identity.dataProductId],
});
const product = Array.isArray(payload?.dataProducts)
? payload.dataProducts.find((candidate) => candidate?.id === identity.dataProductId)
: null;
if (!product || payload?.sourceScope !== "resolved-server-side") {
throw provisionerError("foundry_reader_grant_plan_response_invalid", 502);
}
return { product, sourceScope: "resolved-server-side" };
}
async function ensure(target) {
const identity = targetIdentity(target);
const digest = targetDigest(identity);
const token = await ensureReaderToken(join(resolve(tokenRoot), digest), {
production,
randomBytesImpl,
});
const bindingKey = `fndrc-${digest}`;
const payload = await signedRequest(
"PUT",
`/internal/data-plane/v1/consumer-reader-bindings/by-key/${encodeURIComponent(bindingKey)}`,
{
allowedDataProductIds: [identity.dataProductId],
expiresAt: null,
generation: 1,
capabilityDigest: createHash("sha256").update(token, "utf8").digest("hex"),
},
);
const binding = payload?.readerBinding;
if (binding?.bindingKey !== bindingKey || binding?.generation !== 1 || binding?.active !== true
|| binding?.expiresAt !== null || binding?.sourceScope !== "resolved-server-side"
|| !Array.isArray(binding?.allowedDataProductIds)
|| !binding.allowedDataProductIds.includes(identity.dataProductId)) {
throw provisionerError("foundry_reader_grant_ensure_response_invalid", 502);
}
return { ensured: true, idempotent: payload?.idempotent === true, generation: 1, sourceScope: "resolved-server-side" };
}
async function readToken(target) {
if (!tokenRoot) return null;
return readReaderToken(join(resolve(tokenRoot), targetDigest(target)), { production, missing: null });
}
return { configured, plan, ensure, readToken };
}
async function readPrivateKeySecurely(path, { production }) {
let handle;
try {
handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
} catch {
throw provisionerError("foundry_reader_grant_private_key_unreadable", 503);
}
try {
const metadata = await handle.stat();
if (!metadata.isFile() || metadata.size < 80 || metadata.size > 8192 || (metadata.mode & 0o022) !== 0
|| (production && metadata.uid !== 0)) {
throw provisionerError("foundry_reader_grant_private_key_invalid", 503);
}
const pem = await handle.readFile("utf8");
const privateKey = createPrivateKey(pem);
if (privateKey.type !== "private" || privateKey.asymmetricKeyType !== "ed25519") {
throw provisionerError("foundry_reader_grant_private_key_invalid", 503);
}
return privateKey;
} catch (error) {
if (String(error?.message || "").startsWith("foundry_reader_grant_")) throw error;
throw provisionerError("foundry_reader_grant_private_key_invalid", 503);
} finally {
await handle.close();
}
}
async function ensureReaderToken(path, { production, randomBytesImpl }) {
const existing = await readReaderToken(path, { production, missing: null });
if (existing) return existing;
const directory = resolve(path, "..");
await mkdir(directory, { recursive: true, mode: 0o700 });
await chmod(directory, 0o700);
const directoryMetadata = await lstat(directory);
if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()
|| (directoryMetadata.mode & 0o077) !== 0 || (production && directoryMetadata.uid !== 0)) {
throw provisionerError("foundry_reader_grant_store_invalid", 503);
}
const token = `ndc_edprb_${randomBytesImpl(32).toString("base64url")}`;
let handle;
try {
handle = await open(
path,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW || 0),
0o400,
);
await handle.writeFile(`${token}\n`, "utf8");
await handle.sync();
await handle.chmod(0o400);
return token;
} catch (error) {
if (error?.code === "EEXIST") return readConcurrentlyCreatedReaderToken(path, { production });
throw provisionerError("foundry_reader_grant_store_unavailable", 503);
} finally {
await handle?.close();
}
}
async function readConcurrentlyCreatedReaderToken(path, { production }) {
for (let attempt = 0; attempt < 10; attempt += 1) {
try {
const token = await readReaderToken(path, { production, missing: null });
if (token) return token;
} catch (error) {
if (error?.message !== "foundry_reader_grant_invalid") throw error;
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 10));
}
throw provisionerError("foundry_reader_grant_store_unavailable", 503);
}
async function readReaderToken(path, { production, missing }) {
let handle;
try {
handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
} catch (error) {
if (error?.code === "ENOENT") return missing;
throw provisionerError("foundry_reader_grant_unavailable", 503);
}
try {
const metadata = await handle.stat();
if (!metadata.isFile() || metadata.size < 2 || metadata.size > 1024 || (metadata.mode & 0o222) !== 0
|| (production && metadata.uid !== 0)) {
throw provisionerError("foundry_reader_grant_invalid", 503);
}
const token = (await handle.readFile("utf8")).trim();
if (!TOKEN_PATTERN.test(token)) throw provisionerError("foundry_reader_grant_invalid", 503);
return token;
} finally {
await handle.close();
}
}
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { createHash, generateKeyPairSync, verify } from "node:crypto";
import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
const signatureSchema = "nodedc.external-data-plane.managed-provisioner-request/v1";
const audience = "nodedc-external-data-plane.managed-provisioning.v1";
const serviceId = "nodedc-module-foundry";
const keyId = "foundry-edp-managed-provisioner-v1";
const target = {
application: { id: "11111111-1111-4111-8111-111111111111" },
page: { id: "map" },
binding: { id: "fleet-current", dataProductId: "fleet.positions.current.v1" },
};
test("managed Foundry grant provisioning signs digest-only requests and persists the token privately", async () => {
const root = await mkdtemp(join(tmpdir(), "foundry-reader-grant-"));
const privateKeyFile = join(root, "private-key.pem");
const grantsDir = join(root, "grants");
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
await writeFile(privateKeyFile, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o400 });
await chmod(privateKeyFile, 0o400);
const requestBodies = [];
let capabilityDigest = null;
const fetchImpl = async (input, options) => {
const url = new URL(input);
const body = String(options.body);
const parsed = JSON.parse(body);
requestBodies.push(body);
const bodySha256 = createHash("sha256").update(body, "utf8").digest("hex");
assert.equal(options.headers["x-nodedc-content-sha256"], bodySha256);
const signedPayload = JSON.stringify({
schemaVersion: signatureSchema,
audience,
serviceId,
keyId,
method: options.method,
path: url.pathname,
timestamp: options.headers["x-nodedc-request-timestamp"],
nonce: options.headers["x-nodedc-request-nonce"],
bodySha256,
});
assert.equal(verify(
null,
Buffer.from(signedPayload, "utf8"),
publicKey,
Buffer.from(options.headers["x-nodedc-request-signature"], "base64url"),
), true);
assert.equal(options.headers["x-nodedc-engine-service-id"], serviceId);
assert.equal(options.headers["x-nodedc-engine-key-id"], keyId);
assert.equal(options.headers["x-nodedc-request-audience"], audience);
assert.deepEqual(parsed.allowedDataProductIds, ["fleet.positions.current.v1"]);
for (const forbidden of ["provider", "providerId", "tenant", "tenantId", "connection", "connectionId", "token", "capability"]) {
assert.equal(Object.hasOwn(parsed, forbidden), false);
}
if (url.pathname.endsWith("/plan")) {
assert.deepEqual(Object.keys(parsed), ["allowedDataProductIds"]);
return Response.json({
ok: true,
sourceScope: "resolved-server-side",
dataProducts: [{
id: "fleet.positions.current.v1",
version: "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
}],
});
}
assert.match(url.pathname, /\/consumer-reader-bindings\/by-key\/fndrc-[0-9a-f]{64}$/);
assert.deepEqual(Object.keys(parsed).sort(), ["allowedDataProductIds", "capabilityDigest", "expiresAt", "generation"].sort());
assert.match(parsed.capabilityDigest, /^[0-9a-f]{64}$/);
capabilityDigest ||= parsed.capabilityDigest;
assert.equal(parsed.capabilityDigest, capabilityDigest);
const bindingKey = decodeURIComponent(url.pathname.split("/").at(-1));
return Response.json({
ok: true,
idempotent: capabilityDigest === parsed.capabilityDigest,
readerBinding: {
bindingKey,
generation: 1,
active: true,
expiresAt: null,
sourceScope: "resolved-server-side",
allowedDataProductIds: parsed.allowedDataProductIds,
},
});
};
const provisioner = createFoundryReaderGrantProvisioner({
dataPlaneUrl: "http://edp.test",
privateKeyFile,
grantsDir,
serviceId,
keyId,
audience,
fetchImpl,
now: () => new Date("2026-07-19T14:00:00.000Z"),
randomBytesImpl: () => Buffer.alloc(32, 7),
randomUUIDImpl: () => "22222222-2222-4222-8222-222222222222",
production: false,
});
try {
assert.equal(provisioner.configured, true);
const planned = await provisioner.plan(target);
assert.equal(planned.product.id, "fleet.positions.current.v1");
const first = await provisioner.ensure(target);
const second = await provisioner.ensure(target);
assert.equal(first.ensured, true);
assert.equal(second.ensured, true);
const files = await readdir(grantsDir);
assert.equal(files.length, 1);
const tokenPath = join(grantsDir, files[0]);
const token = (await readFile(tokenPath, "utf8")).trim();
assert.match(token, /^ndc_edprb_/);
assert.equal((await stat(tokenPath)).mode & 0o777, 0o400);
assert.equal(createHash("sha256").update(token, "utf8").digest("hex"), capabilityDigest);
assert.equal(await provisioner.readToken(target), token);
assert.equal(requestBodies.some((body) => body.includes(token)), false);
assert.equal(JSON.stringify([planned, first, second]).includes(token), false);
assert.equal(requestBodies.some((body) => /provider|tenant|connection/i.test(body)), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});