feat(foundry): productionize Cesium Map Page and platform runtime
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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; });
|
||||
@@ -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"}`);
|
||||
}
|
||||
Reference in New Issue
Block a user