feat(foundry): productionize Cesium Map Page and platform runtime

This commit is contained in:
Codex
2026-07-16 02:25:58 +03:00
parent 1a2ca8c82c
commit 5e8c1cc3fc
41 changed files with 6977 additions and 179 deletions
+231
View File
@@ -0,0 +1,231 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer as createHttpServer, request as httpRequest } from "node:http";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const foundryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
test("Map Gateway BFF caches only confirmed hits, honors validators and aborts abandoned streams", async () => {
const root = await mkdtemp(join(tmpdir(), "nodedc-foundry-map-proxy-"));
const gatewayPort = await freePort();
const foundryPort = await freePort();
let conditionalHeader = "";
let resolveSlowClosed;
const slowClosed = new Promise((resolveClosed) => { resolveSlowClosed = resolveClosed; });
let resolveIdleClosed;
const idleClosed = new Promise((resolveClosed) => { resolveIdleClosed = resolveClosed; });
const gateway = createHttpServer((request, response) => {
const url = new URL(request.url || "/", "http://gateway.local");
if (url.pathname === "/healthz") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ cache: { persistent: true, entries: 1, bytes: 4 } }));
return;
}
if (url.pathname.startsWith("/api/map/ion/assets/")) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, credentialMode: "gateway" }));
return;
}
if (url.pathname !== "/api/map/cache") {
response.writeHead(404);
response.end();
return;
}
const target = String(url.searchParams.get("url") || "");
conditionalHeader = String(request.headers["if-none-match"] || conditionalHeader);
if (target.includes("/provider-error")) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "provider_unauthorized" }));
return;
}
const headers = {
"content-type": "application/octet-stream",
etag: '"tile-v1"',
"x-nodedc-map-cache": target.includes("nodedc_cache_refresh=1") ? "live-stale-upstream-error" : "live-cache-hit",
};
if (target.includes("/headers-slow")) {
const timer = setTimeout(() => {
if (response.destroyed) return;
response.writeHead(200, { ...headers, "content-length": "4" });
response.end("tile");
}, 180);
response.once("close", () => clearTimeout(timer));
return;
}
if (target.includes("/body-idle")) {
response.writeHead(200, headers);
response.write("start");
const timer = setTimeout(() => response.end("late"), 500);
response.once("close", () => {
clearTimeout(timer);
resolveIdleClosed();
});
return;
}
if (target.includes("/progress")) {
response.writeHead(200, { ...headers, "content-length": String(8 * 256) });
let chunks = 0;
const interval = setInterval(() => {
chunks += 1;
response.write(Buffer.alloc(256, chunks));
if (chunks === 8) {
clearInterval(interval);
response.end();
}
}, 30);
response.once("close", () => clearInterval(interval));
return;
}
if (target.includes("/slow")) {
response.writeHead(200, headers);
response.write(Buffer.alloc(1024, 1));
const interval = setInterval(() => response.write(Buffer.alloc(1024, 2)), 20);
response.once("close", () => {
clearInterval(interval);
resolveSlowClosed();
});
return;
}
response.writeHead(200, { ...headers, "content-length": "4" });
response.end("tile");
});
let foundry;
try {
gateway.listen(gatewayPort, "127.0.0.1");
await once(gateway, "listening");
foundry = spawn(process.execPath, ["server/catalog-server.mjs"], {
cwd: foundryRoot,
env: {
...process.env,
NODE_ENV: "development",
HOST: "127.0.0.1",
PORT: String(foundryPort),
FOUNDRY_RUNTIME_DIR: root,
NODEDC_FOUNDRY_AUTH_REQUIRED: "false",
NODEDC_MAP_GATEWAY_INTERNAL_URL: `http://127.0.0.1:${gatewayPort}`,
NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS: "100",
NODEDC_MAP_GATEWAY_BODY_IDLE_TIMEOUT_MS: "80",
},
stdio: ["ignore", "pipe", "pipe"],
});
await waitForService(foundryPort, foundry);
const base = `http://127.0.0.1:${foundryPort}`;
const proxied = (target) => `${base}/api/map-gateway/api/map/cache?url=${encodeURIComponent(target)}`;
const hit = await fetch(proxied("https://assets.ion.cesium.com/1/tile.bin"));
assert.equal(hit.status, 200);
assert.equal(await hit.text(), "tile");
assert.equal(hit.headers.get("cache-control"), "private, max-age=300, stale-while-revalidate=60");
assert.equal(hit.headers.get("vary"), "Cookie");
assert.equal(hit.headers.get("etag"), '"tile-v1"');
const conditional = await fetch(proxied("https://assets.ion.cesium.com/1/tile.bin"), {
headers: { "if-none-match": 'W/"tile-v1"' },
});
assert.equal(conditional.status, 304);
assert.equal(conditionalHeader, 'W/"tile-v1"');
assert.equal(conditional.headers.get("cache-control"), "private, max-age=300, stale-while-revalidate=60");
const refresh = await fetch(proxied("https://assets.ion.cesium.com/1/tile.bin?nodedc_cache_refresh=1"));
assert.equal(refresh.status, 200);
assert.equal(refresh.headers.get("cache-control"), "no-store");
const providerError = await fetch(proxied("https://assets.ion.cesium.com/1/provider-error"));
assert.equal(providerError.status, 401);
assert.equal(providerError.headers.get("cache-control"), "no-store");
const endpoint = await fetch(`${base}/api/map-gateway/api/map/ion/assets/1/endpoint`);
assert.equal(endpoint.status, 200);
assert.equal(endpoint.headers.get("cache-control"), "no-store");
const health = await fetch(`${base}/api/map-gateway/healthz`);
assert.equal(health.status, 200);
assert.equal(health.headers.get("cache-control"), "no-store");
const progressing = await fetch(proxied("https://assets.ion.cesium.com/1/progress"));
assert.equal(progressing.status, 200);
assert.equal((await progressing.arrayBuffer()).byteLength, 8 * 256);
const headersTimeout = await fetch(proxied("https://assets.ion.cesium.com/1/headers-slow"));
assert.equal(headersTimeout.status, 504);
assert.equal((await headersTimeout.json()).error, "map_gateway_headers_timeout");
const idle = await fetch(proxied("https://assets.ion.cesium.com/1/body-idle"));
assert.equal(idle.status, 200);
await assert.rejects(idle.arrayBuffer());
await Promise.race([
idleClosed,
new Promise((_, reject) => setTimeout(() => reject(new Error("upstream_body_idle_abort_timeout")), 2_000)),
]);
await abortAfterFirstChunk(proxied("https://assets.ion.cesium.com/1/slow"));
await Promise.race([
slowClosed,
new Promise((_, reject) => setTimeout(() => reject(new Error("upstream_abort_timeout")), 2_000)),
]);
const stillHealthy = await fetch(`${base}/healthz`);
assert.equal(stillHealthy.status, 200);
} finally {
await stop(foundry);
if (gateway.listening) {
gateway.close();
await once(gateway, "close");
}
await rm(root, { recursive: true, force: true });
}
});
function abortAfterFirstChunk(url) {
return new Promise((resolveAbort, rejectAbort) => {
const request = httpRequest(url, (response) => {
response.once("data", () => {
response.destroy();
request.destroy();
resolveAbort();
});
});
request.once("error", (error) => {
if (error?.code === "ECONNRESET") resolveAbort();
else rejectAbort(error);
});
request.end();
});
}
async function freePort() {
const server = createNetServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const port = address.port;
server.close();
await once(server, "close");
return port;
}
async function waitForService(port, child) {
let output = "";
child.stderr.on("data", (chunk) => { output += String(chunk); });
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) throw new Error(`foundry_exited:${child.exitCode}:${output}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
}
throw new Error(`foundry_start_timeout:${output}`);
}
async function stop(child) {
if (!child || child.exitCode !== null) return;
child.kill("SIGTERM");
await once(child, "exit").catch(() => undefined);
}
+1373 -58
View File
File diff suppressed because it is too large Load Diff
+456
View File
@@ -0,0 +1,456 @@
import { createHash, randomBytes } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { open } from "node:fs/promises";
import { resolve } from "node:path";
export const FOUNDRY_BINDING_GRANT_SCHEMA_VERSION = "nodedc.module-foundry.binding-grant.v1";
export const FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION = "nodedc.foundry.binding-upsert/v1";
export const FOUNDRY_BINDING_CATALOG_ACTION = "foundry.data-product.catalog.read";
export const FOUNDRY_BINDING_UPSERT_ACTION = "foundry.map-data-product.upsert";
export const FOUNDRY_BINDING_CATALOG_PATH = "/internal/foundry/v1/data-products";
export const FOUNDRY_BINDING_UPSERT_PATH = "/internal/foundry/v1/data-product-bindings";
const TOKEN_PREFIX = "ndc_fndbg_";
const TOKEN_PATTERN = /^ndc_fndbg_[A-Za-z0-9_-]{43}$/;
const HASH_PATTERN = /^[a-f0-9]{64}$/;
const IDENTIFIER = /^[A-Za-z0-9._:-]{1,160}$/;
// Keep caller-controlled identifiers compatible with
// @nodedc/external-provider-contract. Grant metadata has its own, deliberately
// broader IDENTIFIER grammar above, while binding values use the canonical
// lower-case identifier grammar.
const CONTRACT_IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
const APPLICATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const PAGE_ID = /^[a-z0-9][a-z0-9-]{0,79}$/;
const SLOT_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9-]{0,79}$/;
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i;
const SECRET_LIKE_VALUE = /(?:ndc_(?:edp(?:wb|rb)|fndbg)_[A-Za-z0-9_-]+|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
const TRANSPORT_OR_SCOPE_KEY = /(provider|tenant|connection|endpoint|url|credential|payload)/i;
const ALLOWED_ACTIONS = new Set([FOUNDRY_BINDING_CATALOG_ACTION, FOUNDRY_BINDING_UPSERT_ACTION]);
const GRANT_KEYS = new Set([
"schemaVersion",
"grantId",
"tokenHash",
"active",
"issuedAt",
"expiresAt",
"actorId",
"ownerKey",
"actions",
"targets",
]);
const TARGET_KEYS = new Set([
"applicationId",
"pageId",
"bindingId",
"dataProductId",
"slotId",
"semanticTypes",
"fieldProjection",
]);
const REQUEST_KEYS = new Set(["schemaVersion", "applicationId", "pageId", "idempotencyKey", "binding"]);
const BINDING_KEYS = new Set(["id", "dataProductId", "slotId", "semanticTypes", "fieldProjection"]);
const MAX_REQUEST_BYTES = 64 * 1024;
const MAX_GRANT_BYTES = 128 * 1024;
const DEFAULT_MAX_GRANT_TTL_MS = 90 * 24 * 60 * 60 * 1000;
export function createFoundryBindingGrantToken() {
return `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
}
export function foundryBindingGrantFileName(token) {
if (!TOKEN_PATTERN.test(String(token || ""))) throw apiError("foundry_binding_unauthorized", 401);
return createHash("sha256").update(token, "utf8").digest("hex");
}
/**
* Handles only the private Foundry binding workload surface. Authentication is
* an opaque, revocable workload grant. Browser sessions, shared Platform
* credentials and EDP reader/writer tokens are deliberately unsupported.
*/
export async function handleFoundryBindingApiRequest(request, response, options = {}) {
try {
const url = new URL(request.url || "/", `http://${request.headers.host || "foundry.local"}`);
if (url.pathname !== FOUNDRY_BINDING_CATALOG_PATH && url.pathname !== FOUNDRY_BINDING_UPSERT_PATH) {
return sendJson(response, 404, { ok: false, error: "foundry_binding_route_not_found" });
}
// Authorization and grant records are evaluated on every request so
// revocation/rotation takes effect immediately. Never let an intermediary
// replay a previously authorized catalog or binding result.
if (request.headers.origin) throw apiError("foundry_binding_browser_origin_forbidden", 403);
const expectedMethod = url.pathname === FOUNDRY_BINDING_CATALOG_PATH ? "GET" : "POST";
if (request.method !== expectedMethod) {
response.setHeader("allow", expectedMethod);
throw apiError("method_not_allowed", 405);
}
const token = bearerToken(request);
const grant = await resolveGrant(token, options);
if (url.pathname === FOUNDRY_BINDING_CATALOG_PATH) {
assertAction(grant, FOUNDRY_BINDING_CATALOG_ACTION);
return sendJson(response, 200, {
ok: true,
dataProducts: grantedDataProducts(grant),
});
}
assertAction(grant, FOUNDRY_BINDING_UPSERT_ACTION);
const contentType = String(request.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
if (contentType !== "application/json") throw apiError("foundry_binding_content_type_required", 415);
const input = validateUpsertInput(await readJsonBody(request));
const target = findExactTarget(grant, input);
if (!target) throw apiError("foundry_binding_target_forbidden", 403);
if (typeof options.preflightReaderGrant !== "function") {
throw apiError("foundry_binding_reader_grant_preflight_unavailable", 503);
}
if (typeof options.upsertBinding !== "function") {
throw apiError("foundry_binding_upsert_unavailable", 503);
}
const actor = Object.freeze({ actorId: grant.actorId, ownerKey: grant.ownerKey });
const preflight = await options.preflightReaderGrant({
applicationId: input.applicationId,
pageId: input.pageId,
bindingId: input.binding.id,
dataProductId: input.binding.dataProductId,
actor,
grantId: grant.grantId,
});
if (preflight === false) throw apiError("data_product_reader_grant_not_ready", 409);
const result = await options.upsertBinding(input, actor);
// The callback may return a complete application/page object. This private
// workload surface deliberately projects only the already-authorized
// binding instead of reflecting callback-owned state back to L2.
const resultBinding = { ...input.binding, delivery: "snapshot+patch" };
const replayed = result?.idempotency?.replayed === true;
return sendJson(response, 200, {
ok: true,
applicationId: input.applicationId,
pageId: input.pageId,
binding: resultBinding,
idempotency: { replayed },
});
} catch (error) {
const normalized = normalizeError(error);
return sendJson(response, normalized.statusCode, { ok: false, error: normalized.code });
}
}
async function resolveGrant(token, options) {
const fileName = foundryBindingGrantFileName(token);
const grantsDir = String(options.grantsDir || "").trim();
if (!grantsDir) throw apiError("foundry_binding_grants_not_configured", 503);
const path = resolve(grantsDir, fileName);
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0);
let handle;
try {
handle = await open(path, flags);
} catch (error) {
if (error?.code === "ENOENT") throw apiError("foundry_binding_unauthorized", 401);
if (error?.code === "ELOOP") throw apiError("foundry_binding_grant_file_invalid", 503);
throw apiError("foundry_binding_grant_file_unavailable", 503);
}
try {
const metadata = await handle.stat();
if (!metadata.isFile() || metadata.size < 2 || metadata.size > MAX_GRANT_BYTES) {
throw apiError("foundry_binding_grant_file_invalid", 503);
}
// Grant files are immutable capability records. They may be replaced
// atomically for rotation/revocation, but the open inode itself is never
// writable. Production additionally requires root ownership.
if ((metadata.mode & 0o222) !== 0) throw apiError("foundry_binding_grant_file_invalid", 503);
const production = options.production ?? process.env.NODE_ENV === "production";
if (production && metadata.uid !== 0) throw apiError("foundry_binding_grant_file_invalid", 503);
let value;
try {
value = JSON.parse(await handle.readFile("utf8"));
} catch {
throw apiError("foundry_binding_grant_file_invalid", 503);
}
return validateGrant(value, fileName, options);
} finally {
await handle.close();
}
}
function validateGrant(value, expectedTokenHash, options) {
if (!isPlainObject(value) || !hasOnlyKeys(value, GRANT_KEYS)) {
throw apiError("foundry_binding_grant_invalid", 503);
}
if (value.schemaVersion !== FOUNDRY_BINDING_GRANT_SCHEMA_VERSION) {
throw apiError("foundry_binding_grant_invalid", 503);
}
const grantId = requireIdentifier(value.grantId, "foundry_binding_grant_invalid");
const tokenHash = String(value.tokenHash || "");
if (!HASH_PATTERN.test(tokenHash) || tokenHash !== expectedTokenHash) {
throw apiError("foundry_binding_grant_invalid", 503);
}
if (value.active !== true) throw apiError("foundry_binding_grant_inactive", 401);
const actorId = requireIdentifier(value.actorId, "foundry_binding_grant_invalid");
const ownerKey = requireIdentifier(value.ownerKey, "foundry_binding_grant_invalid");
const issuedAt = timestamp(value.issuedAt, "foundry_binding_grant_invalid");
const expiresAt = timestamp(value.expiresAt, "foundry_binding_grant_invalid");
const now = nowMs(options.now);
const maxTtlMs = boundedMaxTtl(options.maxGrantTtlMs);
if (issuedAt > now + 60_000 || expiresAt <= issuedAt || expiresAt - issuedAt > maxTtlMs) {
throw apiError("foundry_binding_grant_invalid", 503);
}
if (expiresAt <= now) throw apiError("foundry_binding_grant_expired", 401);
const actions = uniqueStrings(value.actions, (item) => ALLOWED_ACTIONS.has(item), 1, ALLOWED_ACTIONS.size);
if (!actions) throw apiError("foundry_binding_grant_invalid", 503);
if (!Array.isArray(value.targets) || value.targets.length < 1 || value.targets.length > 128) {
throw apiError("foundry_binding_grant_invalid", 503);
}
const targets = value.targets.map(validateGrantTarget);
const targetKeys = new Set(targets.map(targetKey));
if (targetKeys.size !== targets.length) throw apiError("foundry_binding_grant_invalid", 503);
return Object.freeze({ grantId, actorId, ownerKey, actions, targets });
}
function validateGrantTarget(value) {
if (!isPlainObject(value) || !hasOnlyKeys(value, TARGET_KEYS)) {
throw apiError("foundry_binding_grant_invalid", 503);
}
return Object.freeze({
applicationId: requireApplicationId(value.applicationId, "foundry_binding_grant_invalid"),
pageId: requirePageId(value.pageId, "foundry_binding_grant_invalid"),
bindingId: requireContractIdentifier(value.bindingId, "foundry_binding_grant_invalid"),
dataProductId: requireContractIdentifier(value.dataProductId, "foundry_binding_grant_invalid"),
slotId: requireSlot(value.slotId, "foundry_binding_grant_invalid"),
semanticTypes: Object.freeze(identifierList(value.semanticTypes, 1, 8, "foundry_binding_grant_invalid")),
fieldProjection: Object.freeze(fieldList(value.fieldProjection, "foundry_binding_grant_invalid")),
});
}
function validateUpsertInput(value) {
if (containsSecretMaterial(value)) throw apiError("foundry_binding_secret_material_forbidden", 400);
if (containsTransportOrScopeKey(value)) throw apiError("foundry_binding_transport_or_scope_forbidden", 400);
if (!isPlainObject(value) || !hasOnlyKeys(value, REQUEST_KEYS) || !isPlainObject(value.binding) || !hasOnlyKeys(value.binding, BINDING_KEYS)) {
throw apiError("foundry_binding_request_invalid", 400);
}
if (value.schemaVersion !== FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION) {
throw apiError("foundry_binding_schema_version_unsupported", 400);
}
const input = {
schemaVersion: FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
applicationId: requireApplicationId(value.applicationId, "foundry_binding_request_invalid"),
pageId: requirePageId(value.pageId, "foundry_binding_request_invalid"),
idempotencyKey: requireIdempotencyKey(value.idempotencyKey),
binding: validateBinding(value.binding),
};
return Object.freeze({ ...input, binding: Object.freeze(input.binding) });
}
function validateBinding(value) {
if (!isPlainObject(value) || !hasOnlyKeys(value, BINDING_KEYS)) {
throw apiError("foundry_binding_request_invalid", 400);
}
const semanticTypes = Object.freeze(identifierList(value.semanticTypes, 1, 8, "foundry_binding_request_invalid"));
const fieldProjection = Object.freeze(fieldList(value.fieldProjection, "foundry_binding_request_invalid"));
if (fieldProjection.some((field) => SECRET_LIKE_KEY.test(field))) {
throw apiError("foundry_binding_secret_material_forbidden", 400);
}
return {
id: requireContractIdentifier(value.id, "foundry_binding_request_invalid"),
dataProductId: requireContractIdentifier(value.dataProductId, "foundry_binding_request_invalid"),
slotId: requireSlot(value.slotId, "foundry_binding_request_invalid"),
semanticTypes,
fieldProjection,
};
}
function findExactTarget(grant, input) {
const candidate = {
applicationId: input.applicationId,
pageId: input.pageId,
bindingId: input.binding.id,
dataProductId: input.binding.dataProductId,
slotId: input.binding.slotId,
semanticTypes: input.binding.semanticTypes,
fieldProjection: input.binding.fieldProjection,
};
const key = targetKey(candidate);
return grant.targets.find((target) => targetKey(target) === key) || null;
}
function targetKey(target) {
return JSON.stringify([
target.applicationId,
target.pageId,
target.bindingId,
target.dataProductId,
target.slotId,
sorted(target.semanticTypes),
sorted(target.fieldProjection),
]);
}
function grantedDataProducts(grant) {
const products = new Map();
for (const target of grant.targets) {
const existing = products.get(target.dataProductId) || new Set();
for (const semanticType of target.semanticTypes) existing.add(semanticType);
products.set(target.dataProductId, existing);
}
return [...products.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([id, semanticTypes]) => ({ id, semanticTypes: [...semanticTypes].sort() }));
}
function assertAction(grant, action) {
if (!grant.actions.includes(action)) throw apiError("foundry_binding_action_forbidden", 403);
}
async function readJsonBody(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_REQUEST_BYTES) throw apiError("foundry_binding_payload_too_large", 413);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw apiError("foundry_binding_invalid_json", 400);
}
}
function bearerToken(request) {
const value = String(request.headers.authorization || "");
const match = /^Bearer ([^\s]+)$/.exec(value);
if (!match || !TOKEN_PATTERN.test(match[1])) throw apiError("foundry_binding_unauthorized", 401);
return match[1];
}
function requireIdentifier(value, code) {
const normalized = String(value || "").trim();
if (!IDENTIFIER.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requireContractIdentifier(value, code) {
const normalized = typeof value === "string" ? value : "";
if (!CONTRACT_IDENTIFIER.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requireSlot(value, code) {
const normalized = typeof value === "string" ? value : "";
if (!SLOT_IDENTIFIER.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requireApplicationId(value, code) {
const normalized = typeof value === "string" ? value : "";
if (!APPLICATION_ID.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requirePageId(value, code) {
const normalized = typeof value === "string" ? value : "";
if (!PAGE_ID.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requireIdempotencyKey(value) {
const normalized = typeof value === "string" ? value : "";
if (!CONTRACT_IDENTIFIER.test(normalized)) throw apiError("foundry_binding_request_invalid", 400);
return normalized;
}
function identifierList(value, min, max, code) {
const list = uniqueStrings(value, (item) => CONTRACT_IDENTIFIER.test(item), min, max);
if (!list) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return list;
}
function fieldList(value, code) {
const list = uniqueStrings(value, (item) => CONTRACT_IDENTIFIER.test(item), 0, 32);
if (!list) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return list;
}
function uniqueStrings(value, predicate, min, max) {
if (!Array.isArray(value) || value.length < min || value.length > max) return null;
const normalized = value.map((item) => typeof item === "string" ? item : "");
if (normalized.some((item) => !item || !predicate(item))) return null;
if (new Set(normalized).size !== normalized.length) return null;
return normalized;
}
function timestamp(value, code) {
const number = Date.parse(String(value || ""));
if (!Number.isFinite(number)) throw apiError(code, 503);
return number;
}
function nowMs(value) {
const candidate = typeof value === "function" ? value() : value;
if (candidate instanceof Date) return candidate.getTime();
if (candidate === undefined) return Date.now();
const number = Number(candidate);
if (!Number.isFinite(number)) throw apiError("foundry_binding_clock_invalid", 503);
return number;
}
function boundedMaxTtl(value) {
if (value === undefined) return DEFAULT_MAX_GRANT_TTL_MS;
const number = Number(value);
if (!Number.isInteger(number) || number < 60_000 || number > 365 * 24 * 60 * 60 * 1000) {
throw apiError("foundry_binding_max_ttl_invalid", 503);
}
return number;
}
function containsSecretMaterial(value) {
if (typeof value === "string") return SECRET_LIKE_VALUE.test(value);
if (Array.isArray(value)) return value.some(containsSecretMaterial);
if (!isPlainObject(value)) return false;
return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretMaterial(child));
}
function containsTransportOrScopeKey(value) {
if (Array.isArray(value)) return value.some(containsTransportOrScopeKey);
if (!isPlainObject(value)) return false;
return Object.entries(value).some(([key, child]) => TRANSPORT_OR_SCOPE_KEY.test(key) || containsTransportOrScopeKey(child));
}
function hasOnlyKeys(value, allowed) {
return Object.keys(value).every((key) => allowed.has(key));
}
function sorted(value) {
return [...value].sort();
}
function isPlainObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function apiError(code, statusCode) {
return Object.assign(new Error(code), { code, statusCode });
}
function normalizeError(error) {
const statusCode = Number(error?.statusCode);
const code = String(error?.code || error?.message || "");
if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599 && /^[a-z0-9_.:-]{1,120}$/i.test(code)) {
return { statusCode, code };
}
return { statusCode: 500, code: "foundry_binding_internal_error" };
}
function sendJson(response, statusCode, payload) {
if (response.writableEnded) return;
response.writeHead(statusCode, {
"cache-control": "no-store, max-age=0",
"content-type": "application/json; charset=utf-8",
pragma: "no-cache",
vary: "authorization",
});
response.end(JSON.stringify(payload));
}
+311
View File
@@ -0,0 +1,311 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { chmod, mkdtemp, rename, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
FOUNDRY_BINDING_CATALOG_ACTION,
FOUNDRY_BINDING_CATALOG_PATH,
FOUNDRY_BINDING_GRANT_SCHEMA_VERSION,
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
FOUNDRY_BINDING_UPSERT_ACTION,
FOUNDRY_BINDING_UPSERT_PATH,
createFoundryBindingGrantToken,
foundryBindingGrantFileName,
handleFoundryBindingApiRequest,
} from "./foundry-binding-api.mjs";
const NOW = Date.parse("2026-07-15T12:00:00.000Z");
const TARGET = Object.freeze({
applicationId: "11111111-1111-4111-8111-111111111111",
pageId: "map-page-1",
bindingId: "fleet-live-points",
dataProductId: "fleet.positions.current.v1",
slotId: "points",
semanticTypes: ["map.moving_object"],
fieldProjection: ["course", "name", "speed"],
});
test("Foundry binding workload API is scoped, revocable and replay-safe at its operation boundary", async (t) => {
const grantsDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-binding-grants-"));
const token = createFoundryBindingGrantToken();
const calls = { preflight: [], upsert: [] };
let readerGrantReady = true;
const options = {
grantsDir,
production: false,
now: () => NOW,
async preflightReaderGrant(input) {
calls.preflight.push(structuredClone(input));
return readerGrantReady;
},
async upsertBinding(input, actor) {
calls.upsert.push({ input: structuredClone(input), actor: structuredClone(actor) });
return {
application: { privateManifestMaterial: "must-not-leak" },
page: { unrelatedBindings: ["must-not-leak"] },
binding: { ...input.binding, delivery: "snapshot+patch" },
idempotency: { replayed: calls.upsert.length > 1 },
};
},
};
await writeGrant(grantsDir, token, grantFor(token));
const server = createServer((request, response) => {
void handleFoundryBindingApiRequest(request, response, options);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
await rm(grantsDir, { recursive: true, force: true });
});
await t.test("catalog exposes only grant-allowlisted products", async () => {
const response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token });
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store, max-age=0");
assert.equal(response.headers.get("pragma"), "no-cache");
assert.equal(response.headers.get("vary"), "authorization");
assert.deepEqual(response.body, {
ok: true,
dataProducts: [{ id: TARGET.dataProductId, semanticTypes: TARGET.semanticTypes }],
});
});
await t.test("POST forwards the stable idempotency key and grant actor without leaking full manifests", async () => {
const payload = upsertPayload();
const first = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, {
token,
method: "POST",
body: payload,
headers: { "X-NODEDC-Actor": "attacker-supplied-actor" },
});
const second = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: payload });
assert.equal(first.status, 200);
assert.equal(second.status, 200);
assert.equal(first.body.idempotency.replayed, false);
assert.equal(second.body.idempotency.replayed, true);
assert.equal(first.body.application, undefined);
assert.equal(first.body.page, undefined);
assert.deepEqual(calls.upsert.map((call) => call.input.idempotencyKey), [payload.idempotencyKey, payload.idempotencyKey]);
assert.deepEqual(calls.upsert.map((call) => call.actor), [
{ actorId: "engine-agent-42", ownerKey: "workspace-42" },
{ actorId: "engine-agent-42", ownerKey: "workspace-42" },
]);
assert.deepEqual(calls.preflight[0], {
applicationId: TARGET.applicationId,
pageId: TARGET.pageId,
bindingId: TARGET.bindingId,
dataProductId: TARGET.dataProductId,
actor: { actorId: "engine-agent-42", ownerKey: "workspace-42" },
grantId: "foundry-binding-grant-42",
});
});
await t.test("action scope and reader-grant preflight fail closed", async () => {
const catalogDeniedToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, catalogDeniedToken, grantFor(catalogDeniedToken, {
actions: [FOUNDRY_BINDING_UPSERT_ACTION],
}));
let response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: catalogDeniedToken });
assert.equal(response.status, 403);
assert.equal(response.body.error, "foundry_binding_action_forbidden");
const baselineUpserts = calls.upsert.length;
readerGrantReady = false;
response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, {
token,
method: "POST",
body: upsertPayload(),
});
readerGrantReady = true;
assert.equal(response.status, 409);
assert.equal(response.body.error, "data_product_reader_grant_not_ready");
assert.equal(calls.upsert.length, baselineUpserts);
});
await t.test("exact target scope rejects any changed dimension before callbacks", async () => {
const baselinePreflight = calls.preflight.length;
const baselineUpsert = calls.upsert.length;
const mutations = [
(body) => { body.applicationId = "22222222-2222-4222-8222-222222222222"; },
(body) => { body.pageId = "map-page-2"; },
(body) => { body.binding.id = "fleet-live-points-2"; },
(body) => { body.binding.dataProductId = "fleet.positions.current.v2"; },
(body) => { body.binding.slotId = "traces"; },
(body) => { body.binding.semanticTypes = ["map.route"]; },
(body) => { body.binding.fieldProjection = ["name"]; },
];
for (const mutate of mutations) {
const body = upsertPayload();
mutate(body);
const response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body });
assert.equal(response.status, 403);
assert.equal(response.body.error, "foundry_binding_target_forbidden");
}
assert.equal(calls.preflight.length, baselinePreflight);
assert.equal(calls.upsert.length, baselineUpsert);
});
await t.test("wire schema enforces the canonical UUID and identifier grammar", async () => {
const mutations = [
(body) => { body.applicationId = "00000000-0000-0000-0000-000000000000"; },
(body) => { body.binding.id = "Fleet-Live-Points"; },
(body) => { body.binding.dataProductId = "Fleet.Positions.Current.V1"; },
(body) => { body.binding.slotId = "-points"; },
(body) => { body.binding.semanticTypes = ["Map.Moving_Object"]; },
(body) => { body.binding.fieldProjection = ["Display_Name"]; },
(body) => { body.idempotencyKey = "X"; },
(body) => { body.binding.dataProductId = ` ${TARGET.dataProductId}`; },
];
for (const mutate of mutations) {
const body = upsertPayload();
mutate(body);
const response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_request_invalid");
}
});
await t.test("secret material, transport scope, browser origin and shared tokens are rejected", async () => {
const wrongSchema = upsertPayload();
wrongSchema.schemaVersion = "nodedc.foundry.binding-upsert/v2";
let response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: wrongSchema });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_schema_version_unsupported");
const withSecret = upsertPayload();
withSecret.binding.token = "ndc_edprb_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: withSecret });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_secret_material_forbidden");
const withProvider = upsertPayload();
withProvider.binding.providerId = "gelios";
response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: withProvider });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_transport_or_scope_forbidden");
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, {
token,
headers: { Origin: "https://foundry.nodedc.ru" },
});
assert.equal(response.status, 403);
assert.equal(response.body.error, "foundry_binding_browser_origin_forbidden");
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { rawAuthorization: "Bearer shared-platform-token" });
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_unauthorized");
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, {
headers: { Cookie: "nodedc_foundry_session=browser-session" },
});
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_unauthorized");
});
await t.test("writable and symlink grant records are rejected", async () => {
const writableToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, writableToken, grantFor(writableToken));
const writablePath = join(grantsDir, foundryBindingGrantFileName(writableToken));
await chmod(writablePath, 0o600);
let response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: writableToken });
assert.equal(response.status, 503);
assert.equal(response.body.error, "foundry_binding_grant_file_invalid");
const symlinkToken = createFoundryBindingGrantToken();
const sourcePath = join(grantsDir, `source-${randomSuffix()}`);
await writeFile(sourcePath, `${JSON.stringify(grantFor(symlinkToken))}\n`, { mode: 0o400 });
await chmod(sourcePath, 0o400);
await symlink(sourcePath, join(grantsDir, foundryBindingGrantFileName(symlinkToken)));
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: symlinkToken });
assert.equal(response.status, 503);
assert.equal(response.body.error, "foundry_binding_grant_file_invalid");
});
await t.test("grant expiry is enforced", async () => {
const expiredToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, expiredToken, grantFor(expiredToken, {
issuedAt: "2026-07-14T10:00:00.000Z",
expiresAt: "2026-07-15T11:59:59.000Z",
}));
const response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: expiredToken });
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_grant_expired");
});
await t.test("revocation and rotation take effect on the next request", async () => {
let response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token });
assert.equal(response.status, 200);
await writeGrant(grantsDir, token, grantFor(token, { active: false }));
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token });
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_grant_inactive");
const rotatedToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, rotatedToken, grantFor(rotatedToken));
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: rotatedToken });
assert.equal(response.status, 200);
});
});
function grantFor(token, overrides = {}) {
return {
schemaVersion: FOUNDRY_BINDING_GRANT_SCHEMA_VERSION,
grantId: "foundry-binding-grant-42",
tokenHash: foundryBindingGrantFileName(token),
active: true,
issuedAt: "2026-07-15T11:00:00.000Z",
expiresAt: "2026-07-16T12:00:00.000Z",
actorId: "engine-agent-42",
ownerKey: "workspace-42",
actions: [FOUNDRY_BINDING_CATALOG_ACTION, FOUNDRY_BINDING_UPSERT_ACTION],
targets: [structuredClone(TARGET)],
...overrides,
};
}
function upsertPayload() {
return {
schemaVersion: FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
applicationId: TARGET.applicationId,
pageId: TARGET.pageId,
idempotencyKey: "foundry-binding-3b7e28b52f7212a843fa5aa4809b03ea",
binding: {
id: TARGET.bindingId,
dataProductId: TARGET.dataProductId,
slotId: TARGET.slotId,
semanticTypes: [...TARGET.semanticTypes],
fieldProjection: [...TARGET.fieldProjection],
},
};
}
async function writeGrant(grantsDir, token, grant) {
const target = join(grantsDir, foundryBindingGrantFileName(token));
const temp = `${target}.${randomSuffix()}.tmp`;
await writeFile(temp, `${JSON.stringify(grant)}\n`, { mode: 0o400 });
await chmod(temp, 0o400);
await rename(temp, target);
}
function randomSuffix() {
return Math.random().toString(16).slice(2);
}
async function request(baseUrl, path, { token, rawAuthorization, method = "GET", body, headers = {} } = {}) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(rawAuthorization ? { Authorization: rawAuthorization } : {}),
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, headers: response.headers, body: await response.json() };
}
+425
View File
@@ -0,0 +1,425 @@
import { createHmac, timingSafeEqual } from "node:crypto";
const MCP_PROTOCOL_VERSION = "2025-06-18";
const MAX_BODY_BYTES = 1024 * 1024;
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
});
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);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function timingSafeTokenEqual(left, right) {
const leftBuffer = Buffer.from(String(left || ""));
const rightBuffer = Buffer.from(String(right || ""));
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
function bearerToken(request) {
const value = String(request.headers.authorization || "");
return value.startsWith("Bearer ") ? value.slice("Bearer ".length).trim() : "";
}
function originAllowed(request, allowedOrigins) {
const origin = String(request.headers.origin || "").trim();
if (!origin) return true;
return allowedOrigins.has(origin);
}
function base64UrlJson(value) {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
}
function parseBase64UrlJson(value) {
try {
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
} catch {
return null;
}
}
function signCapability(encodedPayload, secret) {
return createHmac("sha256", secret)
.update(`nodedc.module-foundry.mcp-capability.v1.${encodedPayload}`)
.digest("base64url");
}
function mintMcpCapability(identity, config) {
const now = Date.now();
const payload = {
schemaVersion: "nodedc.module-foundry.mcp-capability.v1",
service: "module-foundry",
actorId: identity.actorId,
ownerKey: identity.ownerKey,
issuedAt: now,
expiresAt: now + config.mcpCapabilityTtlMs,
};
const encodedPayload = base64UrlJson(payload);
return `fnd1.${encodedPayload}.${signCapability(encodedPayload, config.capabilitySecret)}`;
}
function actorFromMcpCapability(token, config) {
const [prefix, encodedPayload, signature, ...rest] = String(token || "").split(".");
if (prefix !== "fnd1" || !encodedPayload || !signature || rest.length || !config.capabilitySecret) return null;
const expectedSignature = signCapability(encodedPayload, config.capabilitySecret);
if (!timingSafeTokenEqual(signature, expectedSignature)) return null;
const payload = parseBase64UrlJson(encodedPayload);
if (!payload || payload.schemaVersion !== "nodedc.module-foundry.mcp-capability.v1" || payload.service !== "module-foundry") return null;
const actorId = String(payload.actorId || "").trim();
const ownerKey = String(payload.ownerKey || "").trim();
const expiresAt = Number(payload.expiresAt);
const issuedAt = Number(payload.issuedAt);
if (!actorId || !ownerKey || !Number.isFinite(expiresAt) || !Number.isFinite(issuedAt)) return null;
if (issuedAt > Date.now() + 60_000 || expiresAt <= Date.now() || expiresAt - issuedAt > config.mcpCapabilityTtlMs + 60_000) return null;
return { actorId, ownerKey };
}
function mcpError(id, code, message, data) {
return {
jsonrpc: "2.0",
id: id ?? null,
error: {
code,
message,
...(data === undefined ? {} : { data }),
},
};
}
function mcpResult(id, result) {
return { jsonrpc: "2.0", id: id ?? null, result };
}
function toolText(payload, isError = false) {
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
structuredContent: payload,
...(isError ? { isError: true } : {}),
};
}
function normalizeMcpError(error) {
const code = String(error?.message || "foundry_operation_failed");
if (code === "application_not_found") return { code, status: 404 };
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 };
}
const tools = [
{
name: "foundry_status",
title: "NDC Module Foundry status",
description: "Read the availability and baseline scope of NDC Module Foundry. Canonical Page Library templates are read-only.",
inputSchema: { type: "object", additionalProperties: false, properties: {} },
},
{
name: "foundry_list_applications",
title: "List application instances",
description: "List editable application instances in NDC Module Foundry. This does not mutate Page Library.",
inputSchema: { type: "object", additionalProperties: false, properties: {} },
},
{
name: "foundry_get_application",
title: "Get application instance",
description: "Read one editable application instance, its page instances, features and map pin bindings.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId"],
properties: { applicationId: { type: "string", description: "UUID of an application instance." } },
},
},
{
name: "foundry_create_application",
title: "Create application instance",
description: "Create a draft application instance from a registered page template. The canonical template itself remains unchanged. Application deletion is deliberately unavailable.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["name", "idempotencyKey"],
properties: {
name: { type: "string", description: "Human-readable application name." },
idempotencyKey: { type: "string", description: "Stable key for replay-safe writes." },
slug: { type: "string" },
description: { type: "string" },
templateId: { type: "string", description: "Registered Page Library template id. Defaults to map." },
templateVersion: { type: "string" },
theme: { type: "string", enum: ["dark", "light"] },
},
},
},
{
name: "foundry_update_application_metadata",
title: "Update application metadata",
description: "Rename or describe an application instance. It cannot change canonical templates or delete an application.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
idempotencyKey: { type: "string" },
name: { type: "string" },
slug: { type: "string" },
description: { type: "string" },
},
},
},
{
name: "foundry_add_page_instance",
title: "Add page instance to application",
description: "Create one more instance of a registered Page Library template inside an application. A template may be used repeatedly; Page Library remains read-only.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "templateId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
templateId: { type: "string", description: "Registered Page Library template id." },
templateVersion: { type: "string" },
idempotencyKey: { type: "string" },
title: { type: "string" },
navigationLabel: { type: "string" },
},
},
},
{
name: "foundry_upsert_map_pin_binding",
title: "Upsert map pin binding",
description: "Create or update a visual map-pin binding on a Map Page instance. This stores a stable visual binding; it does not call a provider, Engine or an external data source.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "binding"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the application." },
idempotencyKey: { type: "string" },
binding: {
type: "object",
description: "Provider-neutral map-pin binding. Coordinates and source entity are mandatory.",
required: ["id", "subjectId", "coordinates", "source"],
properties: {
id: { type: "string" },
subjectId: { type: "string" },
label: { type: "string" },
status: { type: "string" },
coordinates: {
type: "object",
required: ["longitude", "latitude"],
properties: {
longitude: { type: "number" },
latitude: { type: "number" },
heightMeters: { type: "number" },
},
},
source: {
type: "object",
required: ["entityId"],
properties: {
entityId: { type: "string" },
streamId: { type: "string" },
displayFields: { type: "array", items: { type: "string" } },
},
},
attributes: { type: "object" },
},
},
},
},
},
{
name: "foundry_upsert_map_data_product_binding",
title: "Upsert Map data product binding",
description: "Bind one approved provider-neutral data product to an entity-stream slot of a Map Page instance. The binding stores no provider transport, endpoint, tenant, connection or credential.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "binding"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the application." },
idempotencyKey: { type: "string" },
binding: {
type: "object",
required: ["id", "dataProductId", "slotId", "semanticTypes"],
properties: {
id: { type: "string" },
dataProductId: { type: "string", description: "Versioned provider-neutral product, for example fleet.positions.current.v1." },
slotId: { type: "string", description: "Approved Map Page entity-stream slot." },
semanticTypes: { type: "array", items: { type: "string" } },
fieldProjection: { type: "array", items: { type: "string" } },
},
},
},
},
},
];
function toolMap(operations) {
return {
foundry_status: () => operations.status(),
foundry_list_applications: () => operations.listApplications(),
foundry_get_application: (input) => operations.getApplication(input.applicationId),
foundry_create_application: (input, actor) => operations.createApplication(input, actor),
foundry_update_application_metadata: (input, actor) => operations.updateApplicationMetadata(input, actor),
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),
};
}
export async function handleFoundryMcpRequest(request, response, options) {
const config = options.getConfig();
const allowedOrigins = new Set(config.mcpAllowedOrigins || []);
if (request.method !== "POST") {
response.writeHead(405, { Allow: "POST" });
response.end();
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);
if (!actor) return sendJson(response, 401, { error: "mcp_unauthorized" });
let message;
try {
message = await readJsonBody(request);
} catch (error) {
return sendJson(response, error?.message === "payload_too_large" ? 413 : 400, { error: error?.message === "payload_too_large" ? "payload_too_large" : "invalid_json" });
}
if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
return sendJson(response, 400, mcpError(message?.id, -32600, "Invalid Request"));
}
const id = message.id;
if (message.method === "initialize") {
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.",
}));
}
if (message.method === "notifications/initialized") return sendJson(response, 202, {});
if (message.method === "ping") return sendJson(response, 200, mcpResult(id, {}));
if (message.method === "tools/list") return sendJson(response, 200, mcpResult(id, { tools }));
if (message.method !== "tools/call") return sendJson(response, 200, mcpError(id, -32601, "Method not found"));
const params = message.params && typeof message.params === "object" ? message.params : {};
const name = String(params.name || "");
const handler = toolMap(options.operations)[name];
if (!handler) return sendJson(response, 200, mcpError(id, -32602, "Unknown tool"));
try {
const result = await handler(params.arguments && typeof params.arguments === "object" ? params.arguments : {}, actor);
return sendJson(response, 200, mcpResult(id, toolText(result)));
} catch (error) {
const normalized = normalizeMcpError(error);
return sendJson(response, 200, mcpResult(id, toolText({ error: normalized.code }, true)));
}
}
function ownerIdentity(owner) {
const source = owner && typeof owner === "object" ? owner : {};
const actorId = String(source.userId || source.user_id || source.id || "").trim();
const ownerKey = String(source.key || source.ownerKey || source.owner_key || actorId).trim();
const groups = [source.groups, source.roles, source.roleKeys]
.flatMap((value) => Array.isArray(value) ? value : [])
.map((value) => String(value || "").trim().toLowerCase())
.filter(Boolean);
return { actorId, ownerKey, groups };
}
function hasFoundryAccess(identity, config) {
if (!identity.actorId || !identity.ownerKey) return false;
// A Launcher/Authentik block revokes every Foundry surface, including a
// previously discoverable AI Workspace entitlement.
if (identity.groups.includes("nodedc:module-foundry:blocked")) return false;
if (config.allowAllAuthenticated) return true;
if (config.allowedOwnerIds.includes(identity.actorId) || config.allowedOwnerIds.includes(identity.ownerKey)) return true;
return identity.groups.some((group) => config.allowedOwnerGroups.includes(group));
}
export async function handleFoundryEntitlementRequest(request, response, options) {
const config = options.getConfig();
if (request.method !== "POST") {
response.writeHead(405, { Allow: "POST" });
response.end();
return;
}
if (!config.internalAccessToken || !config.capabilitySecret) return sendJson(response, 503, { ok: false, error: "foundry_entitlement_not_configured" });
if (!timingSafeTokenEqual(bearerToken(request), config.internalAccessToken)) return sendJson(response, 401, { ok: false, error: "entitlement_unauthorized" });
let body;
try {
body = await readJsonBody(request);
} catch (error) {
return sendJson(response, error?.message === "payload_too_large" ? 413 : 400, { ok: false, error: "invalid_json" });
}
if (body?.schemaVersion !== "ai-workspace.entitlement-request.v1") return sendJson(response, 400, { ok: false, error: "unsupported_entitlement_schema" });
if (body?.appId !== "module-foundry") return sendJson(response, 400, { ok: false, error: "unsupported_entitlement_app" });
const identity = ownerIdentity(body.owner);
const accessAllowed = hasFoundryAccess(identity, config);
const grantBase = {
appId: "module-foundry",
appTitle: "NDC Module Foundry",
surface: "module-foundry",
scopes: [
"foundry.application.read",
"foundry.application.create",
"foundry.application.update",
"foundry.page-instance.create",
"foundry.map-pin.upsert",
"foundry.map-data-product.upsert",
],
};
if (!accessAllowed) {
return sendJson(response, 200, {
appGrants: {
"module-foundry": {
...grantBase,
enabled: false,
denied: true,
reason: "foundry_access_not_granted",
deniedText: "NDC Module Foundry недоступен в текущем контексте.",
mcpServers: [],
},
},
});
}
if (!config.mcpUrl) return sendJson(response, 503, { ok: false, error: "foundry_mcp_not_configured" });
const capability = mintMcpCapability(identity, config);
return sendJson(response, 200, {
appGrants: {
"module-foundry": {
...grantBase,
enabled: true,
context: { actorId: identity.actorId },
mcpServers: [{
serverName: "nodedc_module_foundry",
url: config.mcpUrl,
enabled: true,
required: false,
startupTimeoutSec: 20,
toolTimeoutSec: 60,
httpHeaders: {
Authorization: `Bearer ${capability}`,
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
},
}],
},
},
});
}
+556
View File
@@ -0,0 +1,556 @@
import { randomBytes } from "node:crypto";
import { extname } from "node:path";
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const DEFAULT_SESSION_VALIDATION_TTL_MS = 20_000;
const MIN_SESSION_VALIDATION_TTL_MS = 15_000;
const MAX_SESSION_VALIDATION_TTL_MS = 30_000;
const DEFAULT_SESSION_VALIDATION_GRACE_MS = 30_000;
const MAX_SESSION_VALIDATION_GRACE_MS = 60_000;
const DEFAULT_SESSION_VALIDATION_RETRY_MS = 2_000;
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
function boundedInteger(value, fallback, minimum, maximum) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(maximum, Math.max(minimum, parsed));
}
function normalizedBaseUrl(value, fallback) {
return String(value || fallback).trim().replace(/\/$/, "");
}
function sanitizeReturnTo(value, fallback = "/") {
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") ? value : fallback;
}
function parseCookies(cookieHeader) {
if (!cookieHeader) return {};
return Object.fromEntries(
String(cookieHeader)
.split(";")
.flatMap((part) => {
const separator = part.indexOf("=");
if (separator === -1) return [];
const key = part.slice(0, separator).trim();
const rawValue = part.slice(separator + 1).trim();
try {
return [[key, decodeURIComponent(rawValue)]];
} catch {
return [[key, rawValue]];
}
})
);
}
function appendSetCookie(response, cookie) {
const current = response.getHeader("Set-Cookie");
if (!current) {
response.setHeader("Set-Cookie", cookie);
return;
}
response.setHeader("Set-Cookie", Array.isArray(current) ? [...current, cookie] : [current, cookie]);
}
function sendJson(response, statusCode, body) {
response.statusCode = statusCode;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify(body));
}
function sendText(response, statusCode, body) {
response.statusCode = statusCode;
response.setHeader("Content-Type", "text/plain; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(body);
}
function isHtmlNavigation(request, url) {
if (request.method !== "GET" && request.method !== "HEAD") return false;
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return false;
const accept = String(request.headers.accept || "");
return url.pathname === "/" || (!extname(url.pathname) && accept.includes("text/html"));
}
function identityGroupNames(user) {
if (!user || typeof user !== "object") return new Set();
const values = [user.groups, user.roles, user.roleKeys, user.permissions];
const names = new Set();
for (const value of values) {
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
for (const item of items) {
const name = typeof item === "string"
? item
: item && typeof item === "object"
? item.name || item.key || item.slug || item.id
: "";
const normalized = String(name || "").trim().toLowerCase();
if (normalized) names.add(normalized);
}
}
return names;
}
function resolveFoundryAccess(user, authRequired) {
// Local development deliberately remains operable without a Launcher. In a
// deployed instance every decision below comes only from the revalidated
// Launcher/Authentik session identity, never from a browser header.
if (!authRequired && !user) return { role: "admin", allowed: true, admin: true };
if (!user || typeof user !== "object") return { role: "none", allowed: false, admin: false };
const id = String(user.id || user.subject || user.sub || "").trim().toLowerCase();
const groups = identityGroupNames(user);
// A block is deny-first: it wins even over an accidentally retained admin
// group, so Launcher can revoke a service session deterministically.
if (groups.has("nodedc:module-foundry:blocked")) return { role: "blocked", allowed: false, admin: false };
if (id === "user_root" || groups.has("nodedc:superadmin") || groups.has("nodedc:module-foundry:admin")) {
return { role: "admin", allowed: true, admin: true };
}
if (groups.has("nodedc:module-foundry:user") || groups.has("nodedc:module-foundry:access")) {
return { role: "user", allowed: true, admin: false };
}
return { role: "none", allowed: false, admin: false };
}
export function createFoundryAuth(options = {}) {
const now = typeof options.now === "function" ? options.now : Date.now;
const fetchImpl = typeof options.fetch === "function" ? options.fetch : fetch;
const authRequired = parseBoolean(process.env.NODEDC_FOUNDRY_AUTH_REQUIRED, process.env.NODE_ENV === "production");
const serviceSlug = String(process.env.NODEDC_FOUNDRY_SERVICE_SLUG || "module-foundry").trim() || "module-foundry";
const launcherBaseUrl = normalizedBaseUrl(process.env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
const launcherInternalUrl = normalizedBaseUrl(process.env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
const internalAccessToken = String(
process.env.NODEDC_INTERNAL_ACCESS_TOKEN || process.env.NODEDC_PLATFORM_SERVICE_TOKEN || ""
).trim();
const sessionCookie = String(process.env.NODEDC_FOUNDRY_SESSION_COOKIE || "nodedc_foundry_session").trim();
const sessionTtlMs = Math.max(
60 * 1000,
Number.parseInt(process.env.NODEDC_FOUNDRY_SESSION_TTL_MS || String(DEFAULT_SESSION_TTL_MS), 10) || DEFAULT_SESSION_TTL_MS
);
// Launcher revocation remains bounded while a tile burst no longer performs
// one control-plane validation per object. Production configuration is
// deliberately clamped to a narrow range; tests may inject deterministic
// values without changing runtime policy.
const sessionValidationTtlMs = options.validationTtlMs ?? boundedInteger(
process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_TTL_MS,
DEFAULT_SESSION_VALIDATION_TTL_MS,
MIN_SESSION_VALIDATION_TTL_MS,
MAX_SESSION_VALIDATION_TTL_MS,
);
const sessionValidationGraceMs = options.validationGraceMs ?? boundedInteger(
process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_GRACE_MS,
DEFAULT_SESSION_VALIDATION_GRACE_MS,
0,
MAX_SESSION_VALIDATION_GRACE_MS,
);
const sessionValidationRetryMs = options.validationRetryMs ?? boundedInteger(
process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_RETRY_MS,
DEFAULT_SESSION_VALIDATION_RETRY_MS,
250,
5_000,
);
const cookieSecure = parseBoolean(process.env.NODEDC_FOUNDRY_COOKIE_SECURE, authRequired);
const cookieSameSite = String(process.env.NODEDC_FOUNDRY_COOKIE_SAMESITE || "Lax").trim() || "Lax";
const sessions = new Map();
function emitAuthDiagnostic({ outcome, reason, launcherStatus, durationMs }) {
// The allowlisted record intentionally excludes the local cookie id,
// Launcher session id, internal bearer token and user identity.
const record = Object.freeze({
event: "foundry_session_validation",
outcome,
reason,
launcherStatus: Number.isInteger(launcherStatus) ? launcherStatus : null,
durationMs: Math.max(0, Math.round(Number(durationMs) || 0)),
serviceSlug,
});
if (typeof options.onDiagnostic === "function") {
try {
options.onDiagnostic(record);
} catch {
// Diagnostics are best-effort and must never change an authorization
// decision or turn a successful validation into a transient failure.
}
return;
}
const method = outcome === "active" ? "info" : "warn";
try {
console[method](JSON.stringify(record));
} catch {
// A broken log sink must not affect the request path.
}
}
function buildCookie(value, maxAgeSeconds) {
const parts = [
`${sessionCookie}=${encodeURIComponent(value)}`,
"Path=/",
"HttpOnly",
`SameSite=${cookieSameSite}`,
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
];
if (cookieSecure) parts.push("Secure");
return parts.join("; ");
}
function clearSession(request, response) {
const sessionId = parseCookies(request.headers.cookie || "")[sessionCookie];
if (sessionId) sessions.delete(sessionId);
appendSetCookie(response, buildCookie("", 0));
}
function pruneExpiredSessions() {
const currentTime = now();
for (const [id, session] of sessions.entries()) {
if (!session || session.expiresAt <= currentTime) sessions.delete(id);
}
}
function createSession(response, handoff) {
pruneExpiredSessions();
const createdAtMs = now();
const id = randomBytes(32).toString("base64url");
const session = {
id,
user: handoff.user || null,
launcherSessionId: typeof handoff.launcherSessionId === "string" ? handoff.launcherSessionId : null,
createdAt: new Date(createdAtMs).toISOString(),
expiresAt: createdAtMs + sessionTtlMs,
lastValidatedAt: createdAtMs,
validationInFlight: null,
validationRetryAt: 0,
lastValidationOutcome: "active",
};
sessions.set(id, session);
appendSetCookie(response, buildCookie(id, sessionTtlMs / 1000));
return session;
}
function currentSession(request) {
const sessionId = parseCookies(request.headers.cookie || "")[sessionCookie];
if (!sessionId) return null;
const session = sessions.get(sessionId);
if (!session || session.expiresAt <= now()) {
sessions.delete(sessionId);
return null;
}
return session;
}
function buildLauncherLaunchUrl(nextPath) {
const launchUrl = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl);
launchUrl.searchParams.set("returnTo", sanitizeReturnTo(nextPath));
return launchUrl.toString();
}
function buildLauncherLoginUrl(nextPath) {
const loginUrl = new URL("/auth/login", launcherBaseUrl);
const launchUrl = new URL(buildLauncherLaunchUrl(nextPath));
loginUrl.searchParams.set("returnTo", `${launchUrl.pathname}${launchUrl.search}`);
return loginUrl.toString();
}
async function launcherRequest(pathname, payload) {
if (!internalAccessToken) {
throw new Error("NODE.DC internal access configuration is not available.");
}
const response = await fetchImpl(new URL(pathname, launcherInternalUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${internalAccessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(8_000),
});
const body = await response.json().catch(() => null);
return { response, body };
}
async function consumeHandoff(token) {
const { response, body } = await launcherRequest("/api/internal/handoff/consume", { token, serviceSlug });
if (!response.ok || !body?.ok) {
throw new Error(body?.error || `Launcher handoff failed: HTTP ${response.status}`);
}
return {
user: body.user || null,
launcherSessionId: typeof body.launcherSessionId === "string" ? body.launcherSessionId : null,
};
}
async function validateSession(session) {
if (!authRequired) return { outcome: "active", user: session?.user || null, reason: "auth_not_required" };
if (!session?.launcherSessionId) {
return { outcome: "inactive", reason: "launcher_session_missing", launcherStatus: null };
}
const { response, body } = await launcherRequest("/api/internal/session/validate", {
serviceSlug,
launcherSessionId: session.launcherSessionId,
});
if (response.ok && body?.ok === true && body.active === true) {
return {
outcome: "active",
user: body.user || session.user || null,
reason: "launcher_session_active",
launcherStatus: response.status,
};
}
// Launcher deliberately represents a revoked/expired user session as a
// successful validation response with active=false. HTTP failures here
// describe the internal validation service (including its own bearer-token
// configuration), so they must not destroy an otherwise valid user cookie.
if (response.ok && body?.ok === true && body.active === false) {
return {
outcome: "inactive",
reason: "launcher_session_inactive",
launcherStatus: response.status,
};
}
return {
outcome: "transient",
reason: response.status >= 500
? "launcher_unavailable"
: response.status === 401 || response.status === 403
? "launcher_internal_auth_rejected"
: "launcher_validation_invalid_response",
launcherStatus: response.status,
};
}
function isReadOnlyRequest(request) {
return request.method === "GET" || request.method === "HEAD";
}
function transientFailureReason(error) {
return error?.name === "TimeoutError" || error?.name === "AbortError"
? "launcher_timeout"
: "launcher_network_error";
}
function startSessionValidation(session) {
if (session.validationInFlight) return session.validationInFlight;
const startedAt = now();
const validation = (async () => {
try {
const result = await validateSession(session);
if (result.outcome === "active") {
session.user = result.user;
session.lastValidatedAt = now();
session.validationRetryAt = 0;
} else if (result.outcome === "transient") {
session.validationRetryAt = now() + sessionValidationRetryMs;
}
session.lastValidationOutcome = result.outcome;
emitAuthDiagnostic({
outcome: result.outcome,
reason: result.reason,
launcherStatus: result.launcherStatus,
durationMs: now() - startedAt,
});
return result;
} catch (error) {
const result = {
outcome: "transient",
reason: transientFailureReason(error),
launcherStatus: null,
};
session.validationRetryAt = now() + sessionValidationRetryMs;
session.lastValidationOutcome = result.outcome;
emitAuthDiagnostic({
...result,
durationMs: now() - startedAt,
});
return result;
} finally {
session.validationInFlight = null;
}
})();
session.validationInFlight = validation;
return validation;
}
function useSession(request, session) {
request.nodedcFoundrySession = session;
return session;
}
function canUseValidationGrace(request, session) {
if (!isReadOnlyRequest(request)) return false;
const validationAge = Math.max(0, now() - session.lastValidatedAt);
return validationAge <= sessionValidationTtlMs + sessionValidationGraceMs;
}
async function getValidatedSession(request, response) {
const session = currentSession(request);
if (!session) {
// Sessions intentionally remain process-local: after a deploy/restart the
// opaque cookie cannot be reconstructed safely. Expire a stale cookie and
// require a fresh Launcher handoff instead of treating it as recoverable.
const staleSessionId = parseCookies(request.headers.cookie || "")[sessionCookie];
if (staleSessionId) {
clearSession(request, response);
emitAuthDiagnostic({
outcome: "inactive",
reason: "local_session_missing_or_expired",
launcherStatus: null,
durationMs: 0,
});
}
return null;
}
if (now() - session.lastValidatedAt <= sessionValidationTtlMs) {
return useSession(request, session);
}
let validation;
if (session.lastValidationOutcome === "transient" && now() < session.validationRetryAt) {
validation = { outcome: "transient", reason: "launcher_retry_backoff", launcherStatus: null };
} else {
validation = await startSessionValidation(session);
}
// Logout or another explicit invalidation may have removed the process-local
// session while a shared validation was in flight. Never resurrect it.
if (sessions.get(session.id) !== session) return null;
if (validation.outcome === "active") return useSession(request, session);
if (validation.outcome === "inactive") {
clearSession(request, response);
return null;
}
if (canUseValidationGrace(request, session)) return useSession(request, session);
request.nodedcFoundryAuthUnavailable = true;
return null;
}
function sendAuthRequired(request, response, url) {
const nextPath = sanitizeReturnTo(`${url.pathname}${url.search || ""}`);
if (!internalAccessToken) {
sendText(response, 503, "Module Foundry access is not configured on this server.");
return;
}
const loginUrl = buildLauncherLoginUrl(nextPath);
if (isHtmlNavigation(request, url)) {
response.statusCode = 302;
response.setHeader("Location", loginUrl);
response.setHeader("Cache-Control", "no-store");
response.end();
return;
}
sendJson(response, 401, {
ok: false,
authenticated: false,
error: "module_foundry_auth_required",
loginUrl,
});
}
function sendAuthUnavailable(request, response) {
if (String(request.url || "").startsWith("/api/")) {
sendJson(response, 503, {
ok: false,
authenticated: true,
error: "module_foundry_auth_unavailable",
});
return;
}
sendText(response, 503, "Module Foundry session validation is temporarily unavailable.");
}
function sendAccessDenied(response, access) {
sendJson(response, 403, {
ok: false,
authenticated: true,
error: access?.role === "blocked" ? "module_foundry_access_blocked" : "module_foundry_access_denied",
});
}
async function handleLauncherHandoff(request, response, url) {
if (!authRequired) {
response.statusCode = 302;
response.setHeader("Location", sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/"));
response.end();
return;
}
const token = String(url.searchParams.get("token") || "");
const nextPath = sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/");
if (!token) {
sendText(response, 400, "Missing Launcher handoff token.");
return;
}
try {
const handoff = await consumeHandoff(token);
createSession(response, handoff);
response.statusCode = 302;
response.setHeader("Location", nextPath);
response.setHeader("Cache-Control", "no-store");
response.end();
} catch (error) {
sendText(response, 401, `Launcher handoff failed: ${error.message || "unknown error"}`);
}
}
function handleLogout(request, response) {
clearSession(request, response);
response.statusCode = 302;
response.setHeader("Location", "/");
response.setHeader("Cache-Control", "no-store");
response.end();
}
async function ensureRequestSession(request, response, url) {
if (!authRequired) return false;
if (url.pathname === "/auth/nodedc/handoff" || url.pathname === "/auth/logout") return false;
if (url.pathname === "/healthz") return false;
if (url.pathname === "/api/mcp" || url.pathname === "/api/ai-workspace/entitlements") return false;
if (await getValidatedSession(request, response)) {
const access = currentUserAccess(request);
if (access.allowed) return false;
sendAccessDenied(response, access);
return true;
}
if (request.nodedcFoundryAuthUnavailable) {
sendAuthUnavailable(request, response);
return true;
}
sendAuthRequired(request, response, url);
return true;
}
function currentUserAccess(request) {
return resolveFoundryAccess(request.nodedcFoundrySession?.user, authRequired);
}
function currentUserProfile(request) {
const user = request.nodedcFoundrySession?.user;
if (!user || typeof user !== "object") return null;
const id = String(user.id || user.subject || user.sub || "").trim();
const email = String(user.email || "").trim();
const displayName = String(user.name || user.displayName || email || "NODE.DC").trim().slice(0, 240);
const avatarCandidate = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
const avatarUrl = /^https:\/\//i.test(avatarCandidate) || avatarCandidate.startsWith("/") ? avatarCandidate : null;
const initials = displayName.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase() || "DC";
return { id, email, displayName, avatarUrl, initials };
}
return {
authRequired,
serviceSlug,
handleLauncherHandoff,
handleLogout,
ensureRequestSession,
currentUserAccess,
currentUserProfile,
profileUrl: new URL("/profile", launcherBaseUrl).toString(),
};
}
+252
View File
@@ -0,0 +1,252 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createFoundryAuth } from "./nodedc-auth.mjs";
const HANDOFF_TOKEN = "handoff-token-must-never-appear-in-diagnostics";
const LAUNCHER_SESSION_ID = "launcher-session-must-never-appear-in-diagnostics";
const INTERNAL_ACCESS_TOKEN = "internal-access-token-must-never-appear-in-diagnostics";
const USER = Object.freeze({
id: "user_root",
email: "root@example.test",
name: "DC SUDO",
groups: ["nodedc:superadmin"],
});
test("Foundry session validation is burst-safe, failure-tolerant and revocation-bounded", async (t) => {
const restoreEnvironment = setEnvironment({
NODE_ENV: "production",
NODEDC_FOUNDRY_AUTH_REQUIRED: "true",
NODEDC_FOUNDRY_COOKIE_SECURE: "false",
NODEDC_FOUNDRY_SESSION_TTL_MS: String(60 * 60 * 1000),
NODEDC_LAUNCHER_BASE_URL: "https://launcher.example.test",
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher.internal.test",
NODEDC_INTERNAL_ACCESS_TOKEN: INTERNAL_ACCESS_TOKEN,
});
t.after(restoreEnvironment);
let clock = 1_000_000;
let validationMode = "active";
let validationCalls = 0;
const diagnostics = [];
const fetchStub = async (url, init) => {
assert.equal(init.headers.Authorization, `Bearer ${INTERNAL_ACCESS_TOKEN}`);
const pathname = new URL(url).pathname;
const payload = JSON.parse(init.body);
if (pathname === "/api/internal/handoff/consume") {
assert.equal(payload.token, HANDOFF_TOKEN);
return jsonResponse(200, {
ok: true,
launcherSessionId: LAUNCHER_SESSION_ID,
user: USER,
});
}
assert.equal(pathname, "/api/internal/session/validate");
assert.equal(payload.launcherSessionId, LAUNCHER_SESSION_ID);
validationCalls += 1;
// Let every request in a simulated Cesium tile burst reach the shared
// validation promise before it resolves.
await new Promise((resolve) => setImmediate(resolve));
if (validationMode === "transient") {
return jsonResponse(503, { ok: false, active: false, error: "launcher_temporarily_unavailable" });
}
if (validationMode === "network") {
throw new TypeError(`network failed ${INTERNAL_ACCESS_TOKEN}`);
}
if (validationMode === "timeout") {
const error = new Error(`timeout ${LAUNCHER_SESSION_ID}`);
error.name = "TimeoutError";
throw error;
}
if (validationMode === "inactive") {
return jsonResponse(200, { ok: true, active: false, error: "service_access_denied" });
}
return jsonResponse(200, { ok: true, active: true, user: USER });
};
const auth = createFoundryAuth({
now: () => clock,
fetch: fetchStub,
validationTtlMs: 20,
validationGraceMs: 30,
validationRetryMs: 5,
onDiagnostic: (record) => diagnostics.push(record),
});
const cookie = await createSessionCookie(auth);
await t.test("concurrent tile burst performs one Launcher validation", async () => {
clock += 21;
const results = await Promise.all(
Array.from({ length: 64 }, (_, index) => authorize(auth, cookie, `/api/map-gateway/api/map/cache?tile=${index}`)),
);
assert.equal(validationCalls, 1);
assert.ok(results.every(({ handled }) => handled === false));
assert.ok(results.every(({ response }) => response.getHeader("set-cookie") === undefined));
const withinTtl = await Promise.all(
Array.from({ length: 64 }, (_, index) => authorize(auth, cookie, `/api/map-gateway/api/map/cache?warm=${index}`)),
);
assert.equal(validationCalls, 1);
assert.ok(withinTtl.every(({ handled }) => handled === false));
});
await t.test("transient Launcher failure keeps the cookie and uses bounded read-only grace", async () => {
validationMode = "transient";
clock += 21;
const graceResults = await Promise.all(
Array.from({ length: 32 }, (_, index) => authorize(auth, cookie, `/api/map-gateway/api/map/cache?grace=${index}`)),
);
assert.equal(validationCalls, 2);
assert.ok(graceResults.every(({ handled }) => handled === false));
assert.ok(graceResults.every(({ response }) => response.getHeader("set-cookie") === undefined));
const mutation = await authorize(auth, cookie, "/api/platform-settings/cesium-ion", "PUT");
assert.equal(mutation.handled, true);
assert.equal(mutation.response.statusCode, 503);
assert.equal(JSON.parse(mutation.response.body).error, "module_foundry_auth_unavailable");
assert.equal(mutation.response.getHeader("set-cookie"), undefined);
// The short retry backoff shares the transient result instead of creating a
// control-plane retry storm from the same burst.
assert.equal(validationCalls, 2);
clock += 31;
const outsideGrace = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(outsideGrace.handled, true);
assert.equal(outsideGrace.response.statusCode, 503);
assert.equal(JSON.parse(outsideGrace.response.body).error, "module_foundry_auth_unavailable");
assert.equal(outsideGrace.response.getHeader("set-cookie"), undefined);
assert.equal(validationCalls, 3);
});
await t.test("network errors and timeouts keep the last-known-good read-only session", async () => {
validationMode = "active";
clock += 6;
const recovered = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(recovered.handled, false);
assert.equal(validationCalls, 4);
validationMode = "network";
clock += 21;
const networkFailure = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(networkFailure.handled, false);
assert.equal(networkFailure.response.getHeader("set-cookie"), undefined);
assert.equal(validationCalls, 5);
validationMode = "timeout";
clock += 6;
const timeout = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(timeout.handled, false);
assert.equal(timeout.response.getHeader("set-cookie"), undefined);
assert.equal(validationCalls, 6);
});
await t.test("explicit Launcher inactive response revokes the local session", async () => {
validationMode = "inactive";
clock += 6;
const revoked = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(revoked.handled, true);
assert.equal(revoked.response.statusCode, 401);
assert.equal(JSON.parse(revoked.response.body).error, "module_foundry_auth_required");
assert.match(String(revoked.response.getHeader("set-cookie")), /Max-Age=0/);
assert.equal(validationCalls, 7);
const afterRevoke = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(afterRevoke.handled, true);
assert.equal(afterRevoke.response.statusCode, 401);
assert.equal(validationCalls, 7);
});
await t.test("diagnostics contain outcomes but no cookie, Launcher id or token", () => {
assert.ok(diagnostics.some((record) => record.outcome === "active"));
assert.ok(diagnostics.some((record) => record.outcome === "transient"));
assert.ok(diagnostics.some((record) => record.outcome === "inactive"));
const serialized = JSON.stringify(diagnostics);
assert.equal(serialized.includes(cookie), false);
assert.equal(serialized.includes(HANDOFF_TOKEN), false);
assert.equal(serialized.includes(LAUNCHER_SESSION_ID), false);
assert.equal(serialized.includes(INTERNAL_ACCESS_TOKEN), false);
assert.equal(serialized.includes(USER.email), false);
});
await t.test("a process restart expires an opaque process-local cookie safely", async () => {
let restartedValidationCalls = 0;
const restartDiagnostics = [];
const restartedAuth = createFoundryAuth({
now: () => clock,
fetch: async () => {
restartedValidationCalls += 1;
throw new Error("must not validate an unrecoverable local cookie");
},
validationTtlMs: 20,
validationGraceMs: 30,
validationRetryMs: 5,
onDiagnostic: (record) => restartDiagnostics.push(record),
});
const result = await authorize(restartedAuth, cookie, "/api/map/runtime-config");
assert.equal(result.handled, true);
assert.equal(result.response.statusCode, 401);
assert.match(String(result.response.getHeader("set-cookie")), /Max-Age=0/);
assert.equal(restartedValidationCalls, 0);
assert.ok(restartDiagnostics.some((record) => record.reason === "local_session_missing_or_expired"));
});
});
async function createSessionCookie(auth) {
const request = {
method: "GET",
url: `/auth/nodedc/handoff?token=${encodeURIComponent(HANDOFF_TOKEN)}&returnTo=%2F`,
headers: {},
};
const response = mockResponse();
await auth.handleLauncherHandoff(request, response, new URL(request.url, "https://foundry.example.test"));
assert.equal(response.statusCode, 302);
const setCookie = String(response.getHeader("set-cookie"));
assert.match(setCookie, /^nodedc_foundry_session=/);
return setCookie.split(";", 1)[0];
}
async function authorize(auth, cookie, path, method = "GET") {
const request = { method, url: path, headers: { cookie, accept: "application/json" } };
const response = mockResponse();
const handled = await auth.ensureRequestSession(
request,
response,
new URL(path, "https://foundry.example.test"),
);
return { handled, request, response };
}
function mockResponse() {
const headers = new Map();
return {
statusCode: 200,
body: "",
setHeader(name, value) {
headers.set(String(name).toLowerCase(), value);
},
getHeader(name) {
return headers.get(String(name).toLowerCase());
},
end(body = "") {
this.body = String(body);
},
};
}
function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function setEnvironment(values) {
const original = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]));
for (const [key, value] of Object.entries(values)) process.env[key] = value;
return () => {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
};
}
+9
View File
@@ -0,0 +1,9 @@
import { spawn } from "node:child_process";
await import("./runtime-seed.mjs");
const [command, ...args] = process.argv.slice(2);
if (!command) throw new Error("foundry_runtime_command_required");
const child = spawn(command, args, { stdio: "inherit" });
child.on("exit", (code, signal) => process.exitCode = code ?? (signal ? 1 : 0));
child.on("error", (error) => { throw error; });
+61
View File
@@ -0,0 +1,61 @@
import { cp, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
const root = resolve(new URL("..", import.meta.url).pathname);
const runtimeDir = process.env.FOUNDRY_RUNTIME_DIR ? resolve(root, process.env.FOUNDRY_RUNTIME_DIR) : join(root, "runtime-data");
const seedDir = resolve(process.env.FOUNDRY_RUNTIME_SEED_DIR || join(root, "runtime-seed"));
const manifestPath = join(seedDir, "seed-manifest.json");
function isInside(parent, target) {
const path = relative(parent, target);
return path && !path.startsWith("..") && !path.includes("../");
}
async function exists(path) {
try { await stat(path); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; }
}
async function copySeedFile(file) {
const source = resolve(seedDir, file);
const target = resolve(runtimeDir, file);
if (!isInside(seedDir, source) || !isInside(runtimeDir, target)) throw new Error("runtime_seed_path_invalid");
if (!await exists(source)) throw new Error(`runtime_seed_file_missing:${file}`);
await mkdir(dirname(target), { recursive: true });
await cp(source, target, { force: true, preserveTimestamps: true });
}
async function isGeneratedFallback() {
const profilePath = join(runtimeDir, "design-profiles", "default.json");
if (!await exists(profilePath)) return true;
try {
const profile = JSON.parse(await readFile(profilePath, "utf8"));
// The server fallback has the same default media paths as the canonical
// profile, but it has never been persisted as a real Design Profile
// layout. `savedAt` is written by the baseline/profile save path. A
// subsequently edited user profile must never be replaced implicitly.
return profile?.id === "default"
&& profile?.name === "NODE.DC Default"
&& profile?.status === "draft"
&& !String(profile?.layout?.savedAt || "").trim()
&& String(profile?.timestamps?.createdAt || "") === String(profile?.timestamps?.updatedAt || "");
} catch {
return false;
}
}
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (!/^\d+\.\d+\.\d+$/.test(String(manifest.version || ""))) throw new Error("runtime_seed_version_invalid");
if (!Array.isArray(manifest.files) || !manifest.files.every((file) => typeof file === "string" && file.length > 0)) {
throw new Error("runtime_seed_manifest_invalid");
}
const marker = join(runtimeDir, "runtime-seed", `${manifest.version}.json`);
if (!await exists(marker)) {
const shouldApply = await isGeneratedFallback();
if (shouldApply) for (const file of manifest.files) await copySeedFile(file);
await mkdir(dirname(marker), { recursive: true });
const temporaryMarker = `${marker}.tmp`;
await writeFile(temporaryMarker, `${JSON.stringify({ version: manifest.version, appliedAt: new Date().toISOString(), applied: shouldApply }, null, 2)}\n`, "utf8");
await rename(temporaryMarker, marker);
console.log(`Foundry runtime seed ${manifest.version}: ${shouldApply ? "applied" : "preserved existing runtime"}`);
}