NODEDC_DESIGN_GUIDELINE/server/foundry-binding-api.mjs

457 lines
19 KiB
JavaScript

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