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)); }