457 lines
19 KiB
JavaScript
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));
|
|
}
|