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