2814 lines
124 KiB
JavaScript
2814 lines
124 KiB
JavaScript
import { constants as fsConstants, createReadStream, createWriteStream } from "node:fs";
|
|
import { lstat, mkdir, open, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
import { createServer, request as httpRequest } from "node:http";
|
|
import { request as httpsRequest } from "node:https";
|
|
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
import { basename, extname, join, normalize, resolve } from "node:path";
|
|
import { Readable, Transform } from "node:stream";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
FOUNDRY_BINDING_CATALOG_PATH,
|
|
FOUNDRY_BINDING_UPSERT_PATH,
|
|
handleFoundryBindingApiRequest,
|
|
} from "./foundry-binding-api.mjs";
|
|
import { createFoundryAgentGateway } from "./foundry-agent-gateway.mjs";
|
|
import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
|
|
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
|
|
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
|
|
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
|
|
import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
|
|
import { createFoundryAuth } from "./nodedc-auth.mjs";
|
|
|
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
const distDir = join(root, "apps", "catalog", "dist");
|
|
const runtimeDir = process.env.FOUNDRY_RUNTIME_DIR
|
|
? resolve(root, process.env.FOUNDRY_RUNTIME_DIR)
|
|
: join(root, "runtime-data");
|
|
const uploadDir = join(runtimeDir, "uploads");
|
|
const applicationsDir = join(runtimeDir, "applications");
|
|
const designProfilesDir = join(runtimeDir, "design-profiles");
|
|
const designProfileReleasesDir = join(runtimeDir, "design-profile-releases");
|
|
const pageLayoutsDir = join(runtimeDir, "page-layouts");
|
|
const foundryMcpOperationsDir = join(runtimeDir, "foundry-mcp-operations");
|
|
const foundryDataProductConsumersDir = join(runtimeDir, "data-product-consumers");
|
|
const foundryManagedReaderGrantsDir = join(runtimeDir, "runtime-secrets", "external-data-plane-reader-grants");
|
|
const foundryAgentDataDir = join(runtimeDir, "foundry-agent-gateway");
|
|
const layoutPath = join(runtimeDir, "layout.json");
|
|
const pageRegistryPath = join(root, "registry", "pages.json");
|
|
const port = Number(process.env.PORT || 3333);
|
|
const host = process.env.HOST || "127.0.0.1";
|
|
await mkdir(uploadDir, { recursive: true });
|
|
await mkdir(applicationsDir, { recursive: true });
|
|
await mkdir(designProfilesDir, { recursive: true });
|
|
await mkdir(designProfileReleasesDir, { recursive: true });
|
|
await mkdir(pageLayoutsDir, { recursive: true });
|
|
await mkdir(foundryMcpOperationsDir, { recursive: true });
|
|
await mkdir(foundryDataProductConsumersDir, { recursive: true });
|
|
await mkdir(foundryAgentDataDir, { recursive: true });
|
|
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
|
|
const dataProductConsumerPolicyRegistry = JSON.parse(await readFile(join(root, "registry", "data-product-consumer-policies.json"), "utf8"));
|
|
const mapPresentationProfileRegistry = JSON.parse(await readFile(join(root, "registry", "map-presentation-profiles.json"), "utf8"));
|
|
if (mapPresentationProfileRegistry?.schemaVersion !== "nodedc.map-presentation-profiles/v1") {
|
|
throw new Error("map_presentation_profile_registry_invalid");
|
|
}
|
|
const canonicalMapPresentationProfiles = normalizeMapPresentationProfiles(mapPresentationProfileRegistry.profiles);
|
|
const foundryAuth = createFoundryAuth();
|
|
const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
|
|
process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS,
|
|
15_000,
|
|
);
|
|
const mapGatewayBodyIdleTimeoutMs = boundedMapGatewayTimeout(
|
|
process.env.NODEDC_MAP_GATEWAY_BODY_IDLE_TIMEOUT_MS,
|
|
30_000,
|
|
);
|
|
const mapGatewayInternalUrl = String(
|
|
process.env.NODEDC_MAP_GATEWAY_INTERNAL_URL
|
|
|| process.env.NODEDC_MAP_GATEWAY_URL
|
|
|| (process.env.NODE_ENV === "production" ? "" : "http://127.0.0.1:18103"),
|
|
).trim().replace(/\/$/, "");
|
|
// External Data Plane remains private to Platform. A browser can only address
|
|
// a persisted application/page/binding target through the same-origin BFF.
|
|
const externalDataPlaneInternalUrl = String(process.env.NODEDC_EXTERNAL_DATA_PLANE_INTERNAL_URL || "").trim().replace(/\/$/, "");
|
|
const externalDataPlaneReaderGrantsDir = String(process.env.NODEDC_EXTERNAL_DATA_PLANE_READER_GRANTS_DIR || "").trim();
|
|
const foundryReaderGrantProvisioner = createFoundryReaderGrantProvisioner({
|
|
dataPlaneUrl: externalDataPlaneInternalUrl,
|
|
privateKeyFile: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_PRIVATE_KEY_FILE,
|
|
grantsDir: foundryManagedReaderGrantsDir,
|
|
serviceId: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID || "nodedc-module-foundry",
|
|
keyId: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID || "foundry-edp-managed-provisioner-v1",
|
|
audience: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE || "nodedc-external-data-plane.managed-provisioning.v1",
|
|
});
|
|
const foundryBindingGrantsDir = String(process.env.NODEDC_FOUNDRY_BINDING_GRANTS_DIR || "").trim();
|
|
const activeRuntimeStreams = new Set();
|
|
// A dedicated server-only signing value shared with Map Gateway. In production
|
|
// the runner supplies it as a read-only file, never an env value or browser API.
|
|
const mapGatewayAdminSecret = await readMapGatewayAdminSecret();
|
|
const foundryPublicUrl = normalizeFoundryPublicUrl(process.env.FOUNDRY_PUBLIC_URL);
|
|
const foundryAgentStore = createFoundryAgentStore({ dataRoot: foundryAgentDataDir });
|
|
const foundryAgentGateway = createFoundryAgentGateway({
|
|
store: foundryAgentStore,
|
|
configuredPublicOrigin: foundryPublicUrl?.origin || "",
|
|
installerPackagePath: join(root, "server", "assets", "nodedc-foundry-codex-agent-0.1.0.tgz"),
|
|
ontologyCoreUrl: process.env.NODEDC_ONTOLOGY_CORE_URL,
|
|
ontologyCoreAccessToken: process.env.NODEDC_INTERNAL_ACCESS_TOKEN,
|
|
});
|
|
|
|
function boundedMapGatewayTimeout(value, fallback) {
|
|
const parsed = Number.parseInt(String(value || ""), 10);
|
|
if (!Number.isFinite(parsed)) return fallback;
|
|
return Math.min(120_000, Math.max(50, parsed));
|
|
}
|
|
|
|
async function readMapGatewayAdminSecret() {
|
|
const secretFile = String(process.env.NODEDC_MAP_GATEWAY_ADMIN_SECRET_FILE || "").trim();
|
|
if (secretFile) {
|
|
let secret;
|
|
try {
|
|
secret = (await readFile(secretFile, "utf8")).trim();
|
|
} catch {
|
|
throw new Error("map_gateway_admin_secret_file_unreadable");
|
|
}
|
|
if (!/^[A-Za-z0-9_-]{48,256}$/.test(secret)) throw new Error("map_gateway_admin_secret_file_invalid");
|
|
return secret;
|
|
}
|
|
if (process.env.NODE_ENV === "production") throw new Error("map_gateway_admin_secret_file_required");
|
|
return String(process.env.NODEDC_MAP_GATEWAY_ADMIN_SECRET || "").trim();
|
|
}
|
|
|
|
function normalizeFoundryPublicUrl(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw) return null;
|
|
let url;
|
|
try { url = new URL(raw); } catch { throw new Error("invalid_foundry_public_url"); }
|
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
throw new Error("invalid_foundry_public_url");
|
|
}
|
|
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
return url;
|
|
}
|
|
|
|
function normalizeFoundryReferer(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw || raw.length > 2048) return null;
|
|
let url;
|
|
try { url = new URL(raw); } catch { return null; }
|
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return null;
|
|
url.search = "";
|
|
url.hash = "";
|
|
return url;
|
|
}
|
|
|
|
function foundryRequestIonReferer(request) {
|
|
const browserReferer = normalizeFoundryReferer(request.headers.referer);
|
|
if (foundryPublicUrl) {
|
|
// Do not turn an arbitrary browser-provided Referer into Cesium provider
|
|
// authorization. Only the configured public Foundry origin is delegated.
|
|
if (browserReferer && browserReferer.origin === foundryPublicUrl.origin) return browserReferer.toString();
|
|
return foundryPublicUrl.toString();
|
|
}
|
|
return browserReferer?.toString() || "";
|
|
}
|
|
|
|
function findPageTemplate(id, version) {
|
|
return pageRegistry.templates.find((template) => template.id === id && (!version || template.version === version));
|
|
}
|
|
|
|
const mimeTypes = {
|
|
".css": "text/css; charset=utf-8",
|
|
".gif": "image/gif",
|
|
".html": "text/html; charset=utf-8",
|
|
".ico": "image/x-icon",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".map": "application/json; charset=utf-8",
|
|
".mp4": "video/mp4",
|
|
".png": "image/png",
|
|
".svg": "image/svg+xml",
|
|
".webm": "video/webm",
|
|
".webp": "image/webp",
|
|
".mov": "video/quicktime",
|
|
};
|
|
|
|
function json(response, statusCode, value) {
|
|
// A downstream stream can fail after its status and headers have already
|
|
// reached the browser. Never attempt a second response in that case: Node
|
|
// would throw ERR_HTTP_HEADERS_SENT and a normal Cesium request cancellation
|
|
// could terminate the complete Foundry process.
|
|
if (response.headersSent || response.writableEnded || response.destroyed) {
|
|
if (!response.writableEnded && !response.destroyed) response.destroy();
|
|
return;
|
|
}
|
|
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
response.end(JSON.stringify(value));
|
|
}
|
|
|
|
function applicationError(code, statusCode = 400) {
|
|
const error = new Error(code);
|
|
error.statusCode = statusCode;
|
|
return error;
|
|
}
|
|
|
|
function normalizeSlug(value) {
|
|
return String(value || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
.replace(/-{2,}/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 64);
|
|
}
|
|
|
|
function applicationPath(id) {
|
|
const safeId = String(id || "");
|
|
if (!/^[0-9a-f-]{36}$/i.test(safeId)) throw applicationError("invalid_application_id");
|
|
return join(applicationsDir, `${safeId}.json`);
|
|
}
|
|
|
|
function designProfilePath(id) {
|
|
const safeId = String(id || "");
|
|
if (safeId !== "default" && !/^[0-9a-f-]{36}$/i.test(safeId)) throw applicationError("invalid_design_profile_id");
|
|
return join(designProfilesDir, `${safeId}.json`);
|
|
}
|
|
|
|
function designProfileReleaseDir(id) {
|
|
const safeId = String(id || "");
|
|
if (safeId !== "default" && !/^[0-9a-f-]{36}$/i.test(safeId)) throw applicationError("invalid_design_profile_id");
|
|
return join(designProfileReleasesDir, safeId);
|
|
}
|
|
|
|
function designProfileReleasePath(id, version) {
|
|
const safeVersion = String(version || "");
|
|
if (!/^\d+\.\d+\.\d+$/.test(safeVersion)) throw applicationError("invalid_design_profile_version");
|
|
return join(designProfileReleaseDir(id), `${safeVersion}.json`);
|
|
}
|
|
|
|
function pageLayoutPath(pageId) {
|
|
const safePageId = String(pageId || "");
|
|
if (!/^[a-z0-9-]+$/i.test(safePageId) || !findPageTemplate(safePageId)) {
|
|
throw applicationError("unknown_page_template");
|
|
}
|
|
return join(pageLayoutsDir, `${safePageId}.json`);
|
|
}
|
|
|
|
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
const requireHex = (value, code) => {
|
|
const normalized = String(value || "");
|
|
if (!/^#[0-9a-f]{6}$/i.test(normalized)) throw applicationError(code);
|
|
return normalized.toLowerCase();
|
|
};
|
|
const requireNumber = (value, min, max, code) => {
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw applicationError(code);
|
|
return value;
|
|
};
|
|
const requireInteger = (value, min, max, code) => {
|
|
const normalized = requireNumber(value, min, max, code);
|
|
if (!Number.isInteger(normalized)) throw applicationError(code);
|
|
return normalized;
|
|
};
|
|
const requireString = (value, code, max = 4096) => {
|
|
if (typeof value !== "string" || value.length > max) throw applicationError(code);
|
|
return value;
|
|
};
|
|
const requireNonEmptyString = (value, code, max = 4096) => {
|
|
const normalized = requireString(value, code, max);
|
|
if (!normalized) throw applicationError(code);
|
|
return normalized;
|
|
};
|
|
const requireBoolean = (value, code) => {
|
|
if (typeof value !== "boolean") throw applicationError(code);
|
|
return value;
|
|
};
|
|
|
|
function validateMapPinBinding(value) {
|
|
if (!isObject(value)) throw applicationError("invalid_map_pin_binding");
|
|
const id = requireNonEmptyString(value.id, "invalid_map_pin_id", 128);
|
|
const subjectId = requireNonEmptyString(value.subjectId, "invalid_map_pin_subject_id", 256);
|
|
if (!/^[A-Za-z0-9._:-]+$/.test(id)) throw applicationError("invalid_map_pin_id");
|
|
if (!isObject(value.coordinates)) throw applicationError("invalid_map_pin_coordinates");
|
|
if (!isObject(value.source)) throw applicationError("invalid_map_pin_source");
|
|
const attributes = value.attributes === undefined ? {} : value.attributes;
|
|
if (!isObject(attributes) || Object.keys(attributes).length > 30) throw applicationError("invalid_map_pin_attributes");
|
|
const normalizedAttributes = {};
|
|
for (const [key, raw] of Object.entries(attributes)) {
|
|
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(key)) throw applicationError("invalid_map_pin_attribute_key");
|
|
if (!["string", "number", "boolean"].includes(typeof raw) || (typeof raw === "string" && raw.length > 500)) {
|
|
throw applicationError("invalid_map_pin_attribute_value");
|
|
}
|
|
normalizedAttributes[key] = raw;
|
|
}
|
|
const displayFields = value.source.displayFields === undefined ? [] : value.source.displayFields;
|
|
if (!Array.isArray(displayFields) || displayFields.length > 24 || displayFields.some((field) => typeof field !== "string" || field.length > 128)) {
|
|
throw applicationError("invalid_map_pin_display_fields");
|
|
}
|
|
return {
|
|
id,
|
|
subjectId,
|
|
kind: "elevated-spike",
|
|
label: String(value.label || "").slice(0, 240),
|
|
status: String(value.status || "").slice(0, 80),
|
|
coordinates: {
|
|
longitude: requireNumber(value.coordinates.longitude, -180, 180, "invalid_map_pin_longitude"),
|
|
latitude: requireNumber(value.coordinates.latitude, -90, 90, "invalid_map_pin_latitude"),
|
|
heightMeters: value.coordinates.heightMeters === undefined
|
|
? 0
|
|
: requireNumber(value.coordinates.heightMeters, -10000, 10000000, "invalid_map_pin_height"),
|
|
},
|
|
source: {
|
|
entityId: requireNonEmptyString(value.source.entityId, "invalid_map_pin_source_entity", 240),
|
|
streamId: String(value.source.streamId || "").slice(0, 240),
|
|
displayFields: [...displayFields],
|
|
},
|
|
attributes: normalizedAttributes,
|
|
};
|
|
}
|
|
|
|
function validateMapPinBindings(value) {
|
|
if (!Array.isArray(value) || value.length > 10000) throw applicationError("invalid_map_pin_bindings");
|
|
const ids = new Set();
|
|
return value.map((item) => {
|
|
const binding = validateMapPinBinding(item);
|
|
if (ids.has(binding.id)) throw applicationError("duplicate_map_pin_id");
|
|
ids.add(binding.id);
|
|
return binding;
|
|
});
|
|
}
|
|
|
|
function validateMapDataProductBinding(value) {
|
|
if (!isObject(value)) throw applicationError("invalid_map_data_product_binding");
|
|
const id = requireNonEmptyString(value.id, "invalid_map_data_product_binding_id", 128);
|
|
const dataProductId = requireNonEmptyString(value.dataProductId, "invalid_map_data_product_id", 160);
|
|
const slotId = requireNonEmptyString(value.slotId, "invalid_map_data_product_slot", 80);
|
|
const displayName = value.displayName === undefined
|
|
? null
|
|
: requireNonEmptyString(value.displayName, "invalid_map_data_product_display_name", 120).trim();
|
|
const order = value.order === undefined
|
|
? null
|
|
: requireInteger(value.order, 0, 10_000, "invalid_map_data_product_order");
|
|
const semanticTypes = value.semanticTypes === undefined ? [] : value.semanticTypes;
|
|
const fieldProjection = value.fieldProjection === undefined ? [] : value.fieldProjection;
|
|
const presentationProfileId = value.presentationProfileId === undefined
|
|
? null
|
|
: requireNonEmptyString(value.presentationProfileId, "invalid_map_data_product_presentation_profile_id", 160);
|
|
if (!/^[A-Za-z0-9._:-]+$/.test(id)) throw applicationError("invalid_map_data_product_binding_id");
|
|
if (!/^[A-Za-z0-9._:-]+$/.test(dataProductId)) throw applicationError("invalid_map_data_product_id");
|
|
if (!/^[A-Za-z0-9-]+$/.test(slotId)) throw applicationError("invalid_map_data_product_slot");
|
|
if (!Array.isArray(semanticTypes) || semanticTypes.length === 0 || semanticTypes.length > 8 || semanticTypes.some((item) => typeof item !== "string" || !/^[A-Za-z0-9._:-]+$/.test(item))) {
|
|
throw applicationError("invalid_map_data_product_semantic_types");
|
|
}
|
|
if (!Array.isArray(fieldProjection) || fieldProjection.length > 32 || fieldProjection.some((item) => typeof item !== "string" || !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item))) {
|
|
throw applicationError("invalid_map_data_product_field_projection");
|
|
}
|
|
if (presentationProfileId && !/^[a-z][a-z0-9._:-]{1,159}$/.test(presentationProfileId)) {
|
|
throw applicationError("invalid_map_data_product_presentation_profile_id");
|
|
}
|
|
if (Object.keys(value).some((key) => /(provider|tenant|connection|endpoint|url|credential|token|secret|payload)/i.test(key))) {
|
|
throw applicationError("map_data_product_binding_contains_transport");
|
|
}
|
|
return {
|
|
id,
|
|
dataProductId,
|
|
slotId,
|
|
delivery: "snapshot+patch",
|
|
semanticTypes: [...semanticTypes],
|
|
fieldProjection: [...fieldProjection],
|
|
...(displayName ? { displayName } : {}),
|
|
...(order !== null ? { order } : {}),
|
|
...(presentationProfileId ? { presentationProfileId } : {}),
|
|
};
|
|
}
|
|
|
|
function validateMapDataProductBindings(value) {
|
|
if (!Array.isArray(value) || value.length > 64) throw applicationError("invalid_map_data_product_bindings");
|
|
const ids = new Set();
|
|
return value.map((item) => {
|
|
const binding = validateMapDataProductBinding(item);
|
|
if (ids.has(binding.id)) throw applicationError("duplicate_map_data_product_binding_id");
|
|
ids.add(binding.id);
|
|
return binding;
|
|
});
|
|
}
|
|
|
|
function defaultMapSubjectState(bindingId, index) {
|
|
return {
|
|
bindingId,
|
|
visible: true,
|
|
filters: {},
|
|
window: {
|
|
open: false,
|
|
rect: { x: 24 + (index % 5) * 28, y: 56 + (index % 5) * 28, width: 280, height: 260 },
|
|
maximized: false,
|
|
zIndex: 20 + index,
|
|
},
|
|
};
|
|
}
|
|
|
|
function validateMapSubjectStates(value, dataProductBindings) {
|
|
if (!Array.isArray(value) || value.length > 64) throw applicationError("invalid_map_subject_states");
|
|
const bindingIds = new Set(dataProductBindings.map((binding) => binding.id));
|
|
const states = new Map();
|
|
for (const raw of value) {
|
|
if (!isObject(raw)) throw applicationError("invalid_map_subject_state");
|
|
const bindingId = requireNonEmptyString(raw.bindingId, "invalid_map_subject_state_binding_id", 128);
|
|
if (!bindingIds.has(bindingId)) continue;
|
|
if (states.has(bindingId)) throw applicationError("duplicate_map_subject_state_binding_id");
|
|
const filters = raw.filters === undefined ? {} : raw.filters;
|
|
if (!isObject(filters) || Object.keys(filters).length > 32) throw applicationError("invalid_map_subject_filters");
|
|
const normalizedFilters = {};
|
|
for (const [field, selected] of Object.entries(filters)) {
|
|
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(field)) throw applicationError("invalid_map_subject_filter_field");
|
|
if (!Array.isArray(selected) || selected.length > 64 || selected.some((item) => typeof item !== "string" || item.length > 160)) {
|
|
throw applicationError("invalid_map_subject_filter_values");
|
|
}
|
|
normalizedFilters[field] = [...new Set(selected)];
|
|
}
|
|
if (!isObject(raw.window) || !isObject(raw.window.rect)) throw applicationError("invalid_map_subject_window");
|
|
states.set(bindingId, {
|
|
bindingId,
|
|
visible: requireBoolean(raw.visible, "invalid_map_subject_visibility"),
|
|
filters: normalizedFilters,
|
|
window: {
|
|
open: requireBoolean(raw.window.open, "invalid_map_subject_window_open"),
|
|
rect: {
|
|
x: requireNumber(raw.window.rect.x, -100_000, 100_000, "invalid_map_subject_window_x"),
|
|
y: requireNumber(raw.window.rect.y, -100_000, 100_000, "invalid_map_subject_window_y"),
|
|
width: requireNumber(raw.window.rect.width, 200, 10_000, "invalid_map_subject_window_width"),
|
|
height: requireNumber(raw.window.rect.height, 160, 10_000, "invalid_map_subject_window_height"),
|
|
},
|
|
maximized: requireBoolean(raw.window.maximized, "invalid_map_subject_window_maximized"),
|
|
zIndex: requireInteger(raw.window.zIndex, 1, 100_000, "invalid_map_subject_window_z_index"),
|
|
},
|
|
});
|
|
}
|
|
return dataProductBindings.map((binding, index) => states.get(binding.id) ?? defaultMapSubjectState(binding.id, index));
|
|
}
|
|
|
|
const MAP_PAGE_SETTING_KEYS = new Set([
|
|
"imagerySource", "imageryVisible", "cacheEnabled", "cacheNoOverwrite", "terrainEnabled",
|
|
"terrainExaggeration", "monochrome", "monochromeColor", "imageryGamma", "imageryHue",
|
|
"imageryAlpha", "globeColor", "backgroundColor", "atmosphereEnabled", "atmosphereHue",
|
|
"atmosphereSaturation", "atmosphereBrightness", "fogEnabled", "fogDensity", "sunEnabled",
|
|
"sunHour", "sunIntensity", "shadowsEnabled", "buildingsVisible", "buildingsColor",
|
|
"buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast",
|
|
"imagerySaturation", "gridVisible", "gridLodEnabled", "gridHeightMeters",
|
|
"gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm",
|
|
"gridLod3StepKm", "gridRadiusKm", "gridLineWidth", "gridColor", "gridOpacity",
|
|
"gridDotsEnabled", "gridDotsSize", "gridDotsColor", "gridDotsOpacity",
|
|
]);
|
|
|
|
function validateMapPageSettingsPatch(value) {
|
|
if (!isObject(value) || Object.keys(value).length === 0) throw applicationError("invalid_map_page_settings_patch");
|
|
if (Object.keys(value).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) throw applicationError("invalid_map_page_settings_patch_field");
|
|
return value;
|
|
}
|
|
|
|
function validateMapPageLayout(value) {
|
|
if (!isObject(value)) throw applicationError("invalid_map_page_layout");
|
|
if (value.schemaVersion !== 1 || value.pageId !== "map") throw applicationError("unsupported_map_page_layout");
|
|
if (!isObject(value.settings)) throw applicationError("invalid_map_page_settings");
|
|
if (!isObject(value.camera)) throw applicationError("invalid_map_page_camera");
|
|
const settings = value.settings;
|
|
if (Object.keys(settings).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) throw applicationError("invalid_map_page_setting_field");
|
|
for (const key of ["imageryVisible", "cacheEnabled", "terrainEnabled", "monochrome", "atmosphereEnabled", "fogEnabled", "sunEnabled", "shadowsEnabled", "buildingsVisible", "gridVisible", "gridLodEnabled", "gridDotsEnabled"]) {
|
|
requireBoolean(settings[key], `invalid_map_page_setting_${key}`);
|
|
}
|
|
// Layouts written before this field existed are safely upgraded to the
|
|
// no-overwrite default during read/save; they are not rejected as damaged.
|
|
if (settings.cacheNoOverwrite !== undefined) requireBoolean(settings.cacheNoOverwrite, "invalid_map_page_setting_cacheNoOverwrite");
|
|
requireString(settings.imagerySource, "invalid_map_page_imagery_source", 64);
|
|
for (const key of ["terrainExaggeration", "imageryGamma", "imageryHue", "imageryAlpha", "atmosphereHue", "atmosphereSaturation", "atmosphereBrightness", "fogDensity", "sunHour", "sunIntensity", "buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast", "imagerySaturation", "gridHeightMeters", "gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm", "gridLod3StepKm", "gridRadiusKm", "gridLineWidth", "gridOpacity", "gridDotsSize", "gridDotsOpacity"]) {
|
|
requireNumber(settings[key], -100000, 100000, `invalid_map_page_setting_${key}`);
|
|
}
|
|
for (const key of ["monochromeColor", "globeColor", "backgroundColor", "buildingsColor", "gridColor", "gridDotsColor"]) {
|
|
requireHex(settings[key], `invalid_map_page_setting_${key}`);
|
|
}
|
|
const camera = value.camera;
|
|
for (const key of ["longitude", "latitude", "height", "heading", "pitch", "roll"]) {
|
|
requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`);
|
|
}
|
|
const presentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles === undefined ? [] : value.presentationProfiles);
|
|
const dataProductBindings = validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings);
|
|
const subjectStates = validateMapSubjectStates(value.subjectStates === undefined ? [] : value.subjectStates, dataProductBindings);
|
|
const presentationProfileIds = new Set(presentationProfiles.map((profile) => profile.id));
|
|
if (dataProductBindings.some((binding) => binding.presentationProfileId && !presentationProfileIds.has(binding.presentationProfileId))) {
|
|
throw applicationError("map_data_product_presentation_profile_not_found");
|
|
}
|
|
return {
|
|
schemaVersion: 1,
|
|
pageId: "map",
|
|
settings: { ...settings, cacheNoOverwrite: settings.cacheNoOverwrite ?? true },
|
|
mapHeight: requireInteger(value.mapHeight, 360, 5000, "invalid_map_page_height"),
|
|
camera: {
|
|
longitude: camera.longitude,
|
|
latitude: camera.latitude,
|
|
height: camera.height,
|
|
heading: camera.heading,
|
|
pitch: camera.pitch,
|
|
roll: camera.roll,
|
|
},
|
|
pinBindings: validateMapPinBindings(value.pinBindings === undefined ? [] : value.pinBindings),
|
|
presentationProfiles,
|
|
dataProductBindings,
|
|
subjectStates,
|
|
};
|
|
}
|
|
|
|
function validateMapDesignOverrides(value) {
|
|
if (!isObject(value)) throw applicationError("invalid_map_design_overrides");
|
|
if (Object.keys(value).some((key) => !["settings", "presentationProfiles"].includes(key))) {
|
|
throw applicationError("invalid_map_design_override_field");
|
|
}
|
|
const result = {};
|
|
if (value.settings !== undefined) {
|
|
if (!isObject(value.settings) || Object.keys(value.settings).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) {
|
|
throw applicationError("invalid_map_design_override_settings");
|
|
}
|
|
const defaults = defaultMapPageLayout();
|
|
const normalized = validateMapPageLayout({
|
|
...defaults,
|
|
settings: { ...defaults.settings, ...value.settings },
|
|
}).settings;
|
|
result.settings = Object.fromEntries(Object.keys(value.settings).map((key) => [key, normalized[key]]));
|
|
}
|
|
if (value.presentationProfiles !== undefined) {
|
|
result.presentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function defaultMapPageLayout() {
|
|
return {
|
|
schemaVersion: 1,
|
|
pageId: "map",
|
|
settings: {
|
|
imagerySource: "cesium-live",
|
|
imageryVisible: true,
|
|
cacheEnabled: true,
|
|
cacheNoOverwrite: true,
|
|
terrainEnabled: true,
|
|
terrainExaggeration: 1,
|
|
monochrome: false,
|
|
monochromeColor: "#15151b",
|
|
imageryGamma: 57,
|
|
imageryHue: 13,
|
|
imageryAlpha: 27,
|
|
globeColor: "#15151b",
|
|
backgroundColor: "#08090d",
|
|
atmosphereEnabled: false,
|
|
atmosphereHue: 0,
|
|
atmosphereSaturation: 0,
|
|
atmosphereBrightness: 0,
|
|
fogEnabled: true,
|
|
fogDensity: 2,
|
|
sunEnabled: true,
|
|
sunHour: 12,
|
|
sunIntensity: 200,
|
|
shadowsEnabled: true,
|
|
buildingsVisible: true,
|
|
buildingsColor: "#a27aff",
|
|
buildingsOpacity: 1,
|
|
buildingsDetail: 4,
|
|
imageryBrightness: 118,
|
|
imageryContrast: 102,
|
|
imagerySaturation: 0,
|
|
gridVisible: true,
|
|
gridLodEnabled: true,
|
|
gridHeightMeters: 500,
|
|
gridLod1MaxHeightKm: 10,
|
|
gridLod1StepKm: 1,
|
|
gridLod2MaxHeightKm: 50,
|
|
gridLod2StepKm: 5,
|
|
gridLod3StepKm: 25,
|
|
gridRadiusKm: 40,
|
|
gridLineWidth: 4,
|
|
gridColor: "#f5f5f5",
|
|
gridOpacity: 12,
|
|
gridDotsEnabled: true,
|
|
gridDotsSize: 7,
|
|
gridDotsColor: "#ffffff",
|
|
gridDotsOpacity: 58,
|
|
},
|
|
mapHeight: 470,
|
|
camera: {
|
|
longitude: 37.618423,
|
|
latitude: 55.751244,
|
|
height: 40000,
|
|
heading: 0,
|
|
pitch: -0.9,
|
|
roll: 0,
|
|
},
|
|
pinBindings: [],
|
|
presentationProfiles: structuredClone(canonicalMapPresentationProfiles),
|
|
dataProductBindings: [],
|
|
subjectStates: [],
|
|
};
|
|
}
|
|
|
|
function validateMaterial(value, theme) {
|
|
if (!isObject(value)) throw applicationError(`invalid_${theme}_material`);
|
|
return {
|
|
panelHex: requireHex(value.panelHex, `invalid_${theme}_panel_color`),
|
|
panelOpacity: requireNumber(value.panelOpacity, 0, 100, `invalid_${theme}_panel_opacity`),
|
|
fieldHex: requireHex(value.fieldHex, `invalid_${theme}_field_color`),
|
|
fieldOpacity: requireNumber(value.fieldOpacity, 0, 100, `invalid_${theme}_field_opacity`),
|
|
nestedHex: requireHex(value.nestedHex, `invalid_${theme}_nested_color`),
|
|
};
|
|
}
|
|
|
|
function validateDesignProfileLayout(value) {
|
|
if (!isObject(value)) throw applicationError("invalid_design_profile_layout");
|
|
if (value.theme !== "dark" && value.theme !== "light") throw applicationError("invalid_design_profile_theme");
|
|
if (!isObject(value.materialByTheme) || !isObject(value.environment) || !isObject(value.media) || !isObject(value.glass) || !isObject(value.toolbar)) {
|
|
throw applicationError("incomplete_design_profile_layout");
|
|
}
|
|
const environment = value.environment;
|
|
const media = value.media;
|
|
const glass = value.glass;
|
|
const toolbar = value.toolbar;
|
|
if (media.source !== "file" && media.source !== "url") throw applicationError("invalid_design_profile_media_source");
|
|
if (media.logoSource !== "file" && media.logoSource !== "url") throw applicationError("invalid_design_profile_logo_source");
|
|
if (!isObject(media.faviconAssets)) throw applicationError("invalid_design_profile_favicon_assets");
|
|
if (glass.version !== 4) throw applicationError("unsupported_glass_material_version");
|
|
if (!["left", "right", "bottom"].includes(toolbar.placement)) throw applicationError("invalid_toolbar_placement");
|
|
return {
|
|
theme: value.theme,
|
|
accentHex: requireHex(value.accentHex, "invalid_design_profile_accent"),
|
|
materialByTheme: {
|
|
dark: validateMaterial(value.materialByTheme.dark, "dark"),
|
|
light: validateMaterial(value.materialByTheme.light, "light"),
|
|
},
|
|
environment: {
|
|
lightColor: requireHex(environment.lightColor, "invalid_environment_light_color"),
|
|
brightness: requireNumber(environment.brightness, 0, 100, "invalid_environment_brightness"),
|
|
glowDistance: requireNumber(environment.glowDistance, 0, 500, "invalid_environment_glow_distance"),
|
|
connectionType: requireString(environment.connectionType, "invalid_environment_connection_type", 64),
|
|
connectionColor: requireHex(environment.connectionColor, "invalid_environment_connection_color"),
|
|
usePortColors: requireBoolean(environment.usePortColors, "invalid_environment_use_port_colors"),
|
|
fillColor: requireHex(environment.fillColor, "invalid_environment_fill_color"),
|
|
fillOpacity: requireNumber(environment.fillOpacity, 0, 100, "invalid_environment_fill_opacity"),
|
|
strokeColor: requireHex(environment.strokeColor, "invalid_environment_stroke_color"),
|
|
strokeOpacity: requireNumber(environment.strokeOpacity, 0, 100, "invalid_environment_stroke_opacity"),
|
|
},
|
|
media: {
|
|
source: media.source,
|
|
url: requireString(media.url, "invalid_design_profile_media_url"),
|
|
fileName: requireString(media.fileName, "invalid_design_profile_media_file_name", 255),
|
|
fileSrc: requireString(media.fileSrc, "invalid_design_profile_media_file_src"),
|
|
visible: requireBoolean(media.visible, "invalid_design_profile_media_visibility"),
|
|
logoSource: media.logoSource,
|
|
logoUrl: requireString(media.logoUrl, "invalid_design_profile_logo_url"),
|
|
logoFileName: requireString(media.logoFileName, "invalid_design_profile_logo_file_name", 255),
|
|
logoFileSrc: requireString(media.logoFileSrc, "invalid_design_profile_logo_file_src"),
|
|
faviconFileName: requireString(media.faviconFileName, "invalid_design_profile_favicon_file_name", 255),
|
|
faviconAssets: {
|
|
ico: requireNonEmptyString(media.faviconAssets.ico, "invalid_design_profile_favicon_ico"),
|
|
apple: requireNonEmptyString(media.faviconAssets.apple, "invalid_design_profile_favicon_apple"),
|
|
icon192: requireNonEmptyString(media.faviconAssets.icon192, "invalid_design_profile_favicon_192"),
|
|
icon512: requireNonEmptyString(media.faviconAssets.icon512, "invalid_design_profile_favicon_512"),
|
|
},
|
|
},
|
|
glass: {
|
|
version: 4,
|
|
tintHex: requireHex(glass.tintHex, "invalid_glass_tint"),
|
|
tintOpacity: requireNumber(glass.tintOpacity, 0, 100, "invalid_glass_tint_opacity"),
|
|
blur: requireNumber(glass.blur, 0, 120, "invalid_glass_blur"),
|
|
saturation: requireNumber(glass.saturation, 0, 300, "invalid_glass_saturation"),
|
|
brightness: requireNumber(glass.brightness, 0, 200, "invalid_glass_brightness"),
|
|
outlineOpacity: requireNumber(glass.outlineOpacity, 0, 100, "invalid_glass_outline_opacity"),
|
|
shadowOpacity: requireNumber(glass.shadowOpacity, 0, 100, "invalid_glass_shadow_opacity"),
|
|
},
|
|
toolbar: {
|
|
placement: toolbar.placement,
|
|
background: requireHex(toolbar.background, "invalid_toolbar_background"),
|
|
border: requireHex(toolbar.border, "invalid_toolbar_border"),
|
|
outline: requireHex(toolbar.outline, "invalid_toolbar_outline"),
|
|
minSize: requireNumber(toolbar.minSize, 16, 64, "invalid_toolbar_min_size"),
|
|
maxSize: requireNumber(toolbar.maxSize, 32, 160, "invalid_toolbar_max_size"),
|
|
lensCount: requireInteger(toolbar.lensCount, 1, 12, "invalid_toolbar_lens_count"),
|
|
autoHide: requireBoolean(toolbar.autoHide, "invalid_toolbar_auto_hide"),
|
|
},
|
|
pageTypes: validateDesignProfilePageTypes(value.pageTypes),
|
|
};
|
|
}
|
|
|
|
function validateDesignProfilePageTypes(value) {
|
|
// Profiles and immutable releases created before page-type fragments were
|
|
// introduced inherit the registered provider-neutral Map design default.
|
|
if (value === undefined) return defaultDesignProfilePageTypes();
|
|
if (!isObject(value) || Object.keys(value).length > 64) throw applicationError("invalid_design_profile_page_types");
|
|
const pageTypes = {};
|
|
for (const [key, fragment] of Object.entries(value)) {
|
|
if (!isObject(fragment) || fragment.schemaVersion !== 1) throw applicationError("invalid_design_profile_page_fragment");
|
|
if (Object.keys(fragment).some((field) => !["schemaVersion", "templateId", "templateVersion", "settings", "presentationProfiles"].includes(field))) {
|
|
throw applicationError("design_profile_page_fragment_contains_runtime");
|
|
}
|
|
const templateId = requireNonEmptyString(fragment.templateId, "invalid_design_profile_page_template_id", 80);
|
|
const templateVersion = requireNonEmptyString(fragment.templateVersion, "invalid_design_profile_page_template_version", 32);
|
|
if (key !== `${templateId}@${templateVersion}`) throw applicationError("invalid_design_profile_page_fragment_key");
|
|
const template = findPageTemplate(templateId, templateVersion);
|
|
if (!template) throw applicationError("unknown_design_profile_page_template");
|
|
if (templateId !== "map") throw applicationError("unsupported_design_profile_page_template");
|
|
const defaults = defaultMapPageLayout();
|
|
const normalized = validateMapPageLayout({
|
|
...defaults,
|
|
settings: fragment.settings,
|
|
presentationProfiles: fragment.presentationProfiles,
|
|
pinBindings: [],
|
|
dataProductBindings: [],
|
|
});
|
|
pageTypes[key] = {
|
|
schemaVersion: 1,
|
|
templateId: "map",
|
|
templateVersion,
|
|
settings: normalized.settings,
|
|
presentationProfiles: normalized.presentationProfiles,
|
|
};
|
|
}
|
|
return pageTypes;
|
|
}
|
|
|
|
function defaultDesignProfilePageTypes() {
|
|
const mapLayout = defaultMapPageLayout();
|
|
return {
|
|
"map@0.1.0": {
|
|
schemaVersion: 1,
|
|
templateId: "map",
|
|
templateVersion: "0.1.0",
|
|
settings: mapLayout.settings,
|
|
presentationProfiles: mapLayout.presentationProfiles,
|
|
},
|
|
};
|
|
}
|
|
|
|
function validateDesignProfile(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw applicationError("invalid_design_profile");
|
|
if (value.schemaVersion !== "0.1.0") throw applicationError("unsupported_design_profile_schema");
|
|
if (value.id !== "default" && !/^[0-9a-f-]{36}$/i.test(String(value.id || ""))) throw applicationError("invalid_design_profile_id");
|
|
const name = String(value.name || "").trim();
|
|
if (!name || name.length > 120) throw applicationError("invalid_design_profile_name");
|
|
if (!/^\d+\.\d+\.\d+$/.test(String(value.version || ""))) throw applicationError("invalid_design_profile_version");
|
|
if (value.status !== "draft" && value.status !== "published") throw applicationError("invalid_design_profile_status");
|
|
if (!isObject(value.timestamps) || !value.timestamps.createdAt || !value.timestamps.updatedAt) throw applicationError("invalid_design_profile_timestamps");
|
|
if (value.status === "published" && !value.timestamps.publishedAt) throw applicationError("missing_design_profile_published_at");
|
|
return { ...value, name, layout: validateDesignProfileLayout(value.layout) };
|
|
}
|
|
|
|
function defaultDesignProfileLayout() {
|
|
return {
|
|
theme: "dark",
|
|
accentHex: "#ff2f92",
|
|
materialByTheme: {
|
|
dark: { panelHex: "#151517", panelOpacity: 100, fieldHex: "#2a2a2c", fieldOpacity: 100, nestedHex: "#0b0b0d" },
|
|
light: { panelHex: "#ffffff", panelOpacity: 100, fieldHex: "#ffffff", fieldOpacity: 100, nestedHex: "#f4f4f4" },
|
|
},
|
|
environment: {
|
|
lightColor: "#ff2f92",
|
|
brightness: 49,
|
|
glowDistance: 105,
|
|
connectionType: "spline",
|
|
connectionColor: "#404040",
|
|
usePortColors: false,
|
|
fillColor: "#ff6caf",
|
|
fillOpacity: 100,
|
|
strokeColor: "#2b2b36",
|
|
strokeOpacity: 100,
|
|
},
|
|
media: {
|
|
source: "file",
|
|
url: "",
|
|
fileName: "possible shapes.mp4",
|
|
fileSrc: "/uploads/1783715822132-possible-shapes.mp4",
|
|
visible: true,
|
|
logoSource: "file",
|
|
logoUrl: "",
|
|
logoFileName: "nodedc-mark.svg",
|
|
logoFileSrc: "/nodedc-mark.svg",
|
|
faviconFileName: "icon-adaptive.svg",
|
|
faviconAssets: {
|
|
ico: "/favicon/favicon.ico",
|
|
apple: "/favicon/apple-touch-icon.png",
|
|
icon192: "/favicon/icon-192.png",
|
|
icon512: "/favicon/icon-512.png",
|
|
},
|
|
},
|
|
glass: {
|
|
version: 4,
|
|
tintHex: "#0b0b0e",
|
|
tintOpacity: 94,
|
|
blur: 62,
|
|
saturation: 170,
|
|
brightness: 129,
|
|
outlineOpacity: 11,
|
|
shadowOpacity: 88,
|
|
},
|
|
toolbar: {
|
|
placement: "bottom",
|
|
background: "#111115",
|
|
border: "#111117",
|
|
outline: "#1c1c1c",
|
|
minSize: 25,
|
|
maxSize: 78,
|
|
lensCount: 3,
|
|
autoHide: true,
|
|
},
|
|
pageTypes: defaultDesignProfilePageTypes(),
|
|
};
|
|
}
|
|
|
|
async function ensureDefaultDesignProfile() {
|
|
try {
|
|
await readFile(designProfilePath("default"), "utf8");
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
let layout = defaultDesignProfileLayout();
|
|
try { layout = JSON.parse(await readFile(layoutPath, "utf8")); } catch (layoutError) { if (layoutError?.code !== "ENOENT") throw layoutError; }
|
|
const now = new Date().toISOString();
|
|
await writeFile(designProfilePath("default"), `${JSON.stringify({
|
|
schemaVersion: "0.1.0",
|
|
id: "default",
|
|
name: "NODE.DC Default",
|
|
version: "0.6.0",
|
|
status: "draft",
|
|
layout,
|
|
timestamps: { createdAt: now, updatedAt: now },
|
|
}, null, 2)}\n`, "utf8");
|
|
}
|
|
}
|
|
|
|
await ensureDefaultDesignProfile();
|
|
|
|
async function writeDesignProfile(profile) {
|
|
const targetPath = designProfilePath(profile.id);
|
|
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
|
|
await writeFile(tempPath, `${JSON.stringify(profile, null, 2)}\n`, "utf8");
|
|
await rename(tempPath, targetPath);
|
|
}
|
|
|
|
async function writeDesignProfileRelease(profile) {
|
|
const release = validateDesignProfile({
|
|
...profile,
|
|
status: "published",
|
|
timestamps: { ...profile.timestamps, publishedAt: new Date().toISOString() },
|
|
});
|
|
await mkdir(designProfileReleaseDir(release.id), { recursive: true });
|
|
try {
|
|
await writeFile(designProfileReleasePath(release.id, release.version), `${JSON.stringify(release, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
} catch (error) {
|
|
if (error?.code === "EEXIST") throw applicationError("design_profile_version_already_published", 409);
|
|
throw error;
|
|
}
|
|
return release;
|
|
}
|
|
|
|
async function readDesignProfileReleases(id) {
|
|
const releases = [];
|
|
try {
|
|
for (const entry of await readdir(designProfileReleaseDir(id), { withFileTypes: true })) {
|
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
try { releases.push(validateDesignProfile(JSON.parse(await readFile(join(designProfileReleaseDir(id), entry.name), "utf8")))); } catch { /* skip damaged immutable snapshot */ }
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
return releases.sort((left, right) => String(right.version).localeCompare(String(left.version), undefined, { numeric: true }));
|
|
}
|
|
|
|
async function ensureDefaultDesignProfileRelease() {
|
|
const profile = validateDesignProfile(JSON.parse(await readFile(designProfilePath("default"), "utf8")));
|
|
try { await readFile(designProfileReleasePath(profile.id, profile.version), "utf8"); }
|
|
catch (error) { if (error?.code === "ENOENT") await writeDesignProfileRelease(profile); else throw error; }
|
|
}
|
|
|
|
await ensureDefaultDesignProfileRelease();
|
|
|
|
function designProfileVersionSummary(profile) {
|
|
return { version: profile.version, status: profile.status, theme: profile.layout.theme, publishedAt: profile.timestamps.publishedAt };
|
|
}
|
|
|
|
function designProfileSummary(profile, releases = []) {
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
version: profile.version,
|
|
status: profile.status,
|
|
theme: profile.layout.theme,
|
|
updatedAt: profile.timestamps.updatedAt,
|
|
versions: releases.map(designProfileVersionSummary),
|
|
latestPublishedVersion: releases[0]?.version ?? null,
|
|
};
|
|
}
|
|
|
|
function nextPatchVersion(version) {
|
|
const match = String(version || "0.1.0").match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
return match ? `${match[1]}.${match[2]}.${Number(match[3]) + 1}` : "0.1.0";
|
|
}
|
|
|
|
function validateApplicationManifest(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw applicationError("invalid_application_manifest");
|
|
if (value.schemaVersion !== "0.1.0") throw applicationError("unsupported_application_schema");
|
|
if (!/^[0-9a-f-]{36}$/i.test(String(value.id || ""))) throw applicationError("invalid_application_id");
|
|
if (value.status !== "draft") throw applicationError("invalid_application_status");
|
|
if (!/^0\.\d+\.\d+$/.test(String(value.version || ""))) throw applicationError("invalid_application_version");
|
|
if (!value.metadata || typeof value.metadata !== "object") throw applicationError("invalid_application_metadata");
|
|
const name = String(value.metadata.name || "").trim();
|
|
const slug = normalizeSlug(value.metadata.slug);
|
|
if (!name || name.length > 120) throw applicationError("invalid_application_name");
|
|
if (!slug) throw applicationError("invalid_application_slug");
|
|
if (!value.designProfile || typeof value.designProfile !== "object") throw applicationError("invalid_design_profile");
|
|
// Pre-versioning manifests already represented the stable default 0.6.0 snapshot.
|
|
const designProfileStatus = value.designProfile.status === "draft" ? "draft" : "published";
|
|
if (value.designProfile.theme !== "dark" && value.designProfile.theme !== "light") throw applicationError("invalid_application_theme");
|
|
if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages");
|
|
const pageIds = new Set();
|
|
const normalizedPages = [];
|
|
for (const page of value.pages) {
|
|
if (!page || typeof page !== "object") throw applicationError("invalid_application_page");
|
|
const pageId = String(page.id || "").trim();
|
|
if (!/^[a-z0-9-]+$/.test(pageId) || pageIds.has(pageId)) throw applicationError("invalid_application_page_id");
|
|
pageIds.add(pageId);
|
|
if (!page.template || typeof page.template !== "object" || !String(page.template.id || "").trim()) {
|
|
throw applicationError("invalid_application_page_template");
|
|
}
|
|
const template = findPageTemplate(String(page.template.id), String(page.template.version || ""));
|
|
if (!template) throw applicationError("unknown_application_page_template");
|
|
if (!page.navigation || typeof page.navigation.visible !== "boolean") throw applicationError("invalid_application_navigation");
|
|
if (!page.features || typeof page.features !== "object" || Array.isArray(page.features)) throw applicationError("invalid_application_features");
|
|
let normalizedPage = page;
|
|
if (page.layout !== undefined) {
|
|
if (!isObject(page.layout)) throw applicationError("invalid_application_page_layout");
|
|
if (page.template.id === "map" && page.layout.map !== undefined) {
|
|
normalizedPage = {
|
|
...page,
|
|
layout: { ...page.layout, map: validateMapPageLayout(page.layout.map) },
|
|
};
|
|
}
|
|
}
|
|
if (page.designOverrides !== undefined) {
|
|
if (!isObject(page.designOverrides) || Object.keys(page.designOverrides).some((key) => key !== "map")) {
|
|
throw applicationError("invalid_application_page_design_overrides");
|
|
}
|
|
if (page.designOverrides.map !== undefined) {
|
|
if (page.template.id !== "map") throw applicationError("unsupported_application_page_design_overrides");
|
|
normalizedPage = {
|
|
...normalizedPage,
|
|
designOverrides: { map: validateMapDesignOverrides(page.designOverrides.map) },
|
|
};
|
|
}
|
|
}
|
|
const allowedFeatures = new Set(template.features.map((feature) => feature.id));
|
|
if (Object.keys(page.features).some((feature) => !allowedFeatures.has(feature))) throw applicationError("unsupported_application_feature");
|
|
for (const feature of template.features) {
|
|
if (feature.required && page.features[feature.id] !== true) throw applicationError("required_application_feature_disabled");
|
|
}
|
|
normalizedPages.push(normalizedPage);
|
|
}
|
|
return {
|
|
...value,
|
|
pages: normalizedPages,
|
|
designProfile: { ...value.designProfile, status: designProfileStatus },
|
|
metadata: {
|
|
...value.metadata,
|
|
name,
|
|
slug,
|
|
description: String(value.metadata.description || "").slice(0, 500),
|
|
},
|
|
};
|
|
}
|
|
|
|
function createApplicationManifest(input = {}) {
|
|
const templateId = String(input?.templateId || "").trim();
|
|
const templateVersion = String(input?.templateVersion || "").trim() || undefined;
|
|
const template = templateId ? findPageTemplate(templateId, templateVersion) : null;
|
|
if (templateId && !template) throw applicationError("unknown_page_template");
|
|
const now = new Date().toISOString();
|
|
const name = String(input?.name || (template ? `NODE.DC ${template.title} Demo` : "Новый модуль")).trim() || "Новый модуль";
|
|
const slug = normalizeSlug(input?.slug || name) || "nodedc-map-demo";
|
|
return {
|
|
schemaVersion: "0.1.0",
|
|
id: randomUUID(),
|
|
status: "draft",
|
|
version: "0.1.0",
|
|
metadata: {
|
|
name,
|
|
slug,
|
|
description: String(input?.description || "Черновик NDC Module Foundry."),
|
|
},
|
|
designProfile: {
|
|
id: "default",
|
|
version: "0.6.0",
|
|
status: "published",
|
|
theme: input?.theme === "light" ? "light" : "dark",
|
|
},
|
|
pages: template ? [{
|
|
id: template.page.id,
|
|
title: template.page.title,
|
|
path: template.page.path,
|
|
template: { id: template.id, version: template.version },
|
|
navigation: { visible: true, label: template.page.navigationLabel, order: 0 },
|
|
features: Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible])),
|
|
}] : [],
|
|
favicon: {
|
|
source: "design-profile",
|
|
},
|
|
timestamps: {
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function writeApplicationManifest(manifest) {
|
|
const targetPath = applicationPath(manifest.id);
|
|
await writeJsonAtomically(targetPath, manifest);
|
|
}
|
|
|
|
async function writeJsonAtomically(targetPath, value) {
|
|
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
|
|
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
await rename(tempPath, targetPath);
|
|
}
|
|
|
|
async function readApplicationManifest(id) {
|
|
try {
|
|
return validateApplicationManifest(JSON.parse(await readFile(applicationPath(id), "utf8")));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") throw applicationError("application_not_found", 404);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function runtimeTargetGrantKey(applicationId, pageId, bindingId) {
|
|
return createHash("sha256").update(`${applicationId}/${pageId}/${bindingId}`, "utf8").digest("hex");
|
|
}
|
|
|
|
function isRuntimeIdentifier(value) {
|
|
return typeof value === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(value);
|
|
}
|
|
|
|
function isRuntimeCursor(value) {
|
|
return typeof value === "string" && /^(?:0|[1-9]\d*)$/.test(value);
|
|
}
|
|
|
|
function isRuntimeTimestamp(value) {
|
|
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
}
|
|
|
|
function isRuntimeVersion(value) {
|
|
return typeof value === "string" && /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i.test(value);
|
|
}
|
|
|
|
async function resolveRuntimeMapDataProductBinding(applicationId, pageId, bindingId) {
|
|
const application = await readApplicationManifest(applicationId);
|
|
const page = application.pages.find((candidate) => candidate.id === pageId);
|
|
if (!page) throw applicationError("application_page_not_found", 404);
|
|
if (page.template?.id !== "map") throw applicationError("map_page_required", 404);
|
|
const template = findPageTemplate(page.template.id, page.template.version);
|
|
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
|
const binding = layout.dataProductBindings.find((candidate) => candidate.id === bindingId);
|
|
if (!binding) throw applicationError("data_product_binding_not_found", 404);
|
|
const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId);
|
|
if (!slot || slot.kind !== "entity-stream") throw applicationError("map_entity_stream_slot_required", 409);
|
|
return { application, page, binding };
|
|
}
|
|
|
|
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId, generation = 1) {
|
|
if (!externalDataPlaneInternalUrl || (!externalDataPlaneReaderGrantsDir && !foundryReaderGrantProvisioner.configured)) {
|
|
throw applicationError("data_product_runtime_not_configured", 503);
|
|
}
|
|
const managedToken = await foundryReaderGrantProvisioner.readToken(
|
|
{ applicationId, pageId, bindingId },
|
|
{ generation },
|
|
);
|
|
if (managedToken) return managedToken;
|
|
if (generation !== 1) throw applicationError("data_product_reader_grant_not_found", 403);
|
|
if (!externalDataPlaneReaderGrantsDir) throw applicationError("data_product_reader_grant_not_found", 403);
|
|
// Compatibility fallback for grants issued before Foundry-owned managed
|
|
// provisioning. There is no fallback to one shared Platform token.
|
|
const grantPath = join(resolve(externalDataPlaneReaderGrantsDir), runtimeTargetGrantKey(applicationId, pageId, bindingId));
|
|
let handle;
|
|
try {
|
|
handle = await open(grantPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") throw applicationError("data_product_reader_grant_not_found", 403);
|
|
if (error?.code === "ELOOP") throw applicationError("data_product_reader_grant_invalid", 503);
|
|
throw applicationError("data_product_reader_grant_unavailable", 503);
|
|
}
|
|
try {
|
|
const metadata = await handle.stat();
|
|
if (!metadata.isFile() || metadata.size < 2 || metadata.size > 1024 || (metadata.mode & 0o222) !== 0) {
|
|
throw applicationError("data_product_reader_grant_invalid", 503);
|
|
}
|
|
// The production artifact mounts a root-owned, read-only secret
|
|
// directory. Open the inode with O_NOFOLLOW and validate that same handle
|
|
// before reading so an atomic rotation cannot become a symlink race.
|
|
if (process.env.NODE_ENV === "production" && metadata.uid !== 0) {
|
|
throw applicationError("data_product_reader_grant_invalid", 503);
|
|
}
|
|
let token;
|
|
try {
|
|
token = (await handle.readFile("utf8")).trim();
|
|
} catch {
|
|
throw applicationError("data_product_reader_grant_unavailable", 503);
|
|
}
|
|
if (!/^ndc_edprb_[A-Za-z0-9_-]{32,512}$/.test(token)) {
|
|
throw applicationError("data_product_reader_grant_invalid", 503);
|
|
}
|
|
return token;
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindingId, dataProductId }) {
|
|
let readerToken;
|
|
try {
|
|
let generation = 1;
|
|
try {
|
|
const statePath = join(foundryDataProductConsumersDir, `${runtimeTargetGrantKey(applicationId, pageId, bindingId)}.json`);
|
|
const state = JSON.parse(await readFile(statePath, "utf8"));
|
|
if (Number.isSafeInteger(state?.readerGrantGeneration) && state.readerGrantGeneration > 0) {
|
|
generation = state.readerGrantGeneration;
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
readerToken = await resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId, generation);
|
|
} catch (error) {
|
|
if (["data_product_reader_grant_not_found", "data_product_reader_grant_invalid"].includes(error?.message)) return false;
|
|
throw error;
|
|
}
|
|
try {
|
|
return Boolean(await readRuntimeReaderProduct(readerToken, dataProductId));
|
|
} catch (error) {
|
|
if (error?.message === "data_product_access_denied") return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readRuntimeReaderProduct(readerToken, dataProductId) {
|
|
let upstream;
|
|
try {
|
|
upstream = await fetch(new URL("/internal/data-plane/v1/reader/data-products", `${externalDataPlaneInternalUrl}/`), {
|
|
headers: { authorization: `Bearer ${readerToken}`, accept: "application/json" },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
} catch {
|
|
throw applicationError("data_product_reader_grant_preflight_unavailable", 503);
|
|
}
|
|
if (upstream.status === 401 || upstream.status === 403) throw applicationError("data_product_access_denied", 403);
|
|
if (!upstream.ok) throw applicationError("data_product_reader_grant_preflight_unavailable", 503);
|
|
const payload = await upstream.json().catch(() => null);
|
|
const product = Array.isArray(payload?.dataProducts)
|
|
? payload.dataProducts.find((candidate) => candidate?.id === dataProductId)
|
|
: null;
|
|
return product && product.active !== false && product.deliveryMode === "snapshot+patch" ? product : null;
|
|
}
|
|
|
|
async function inspectFoundryConsumerReaderGrant(target, { generation = 1 } = {}) {
|
|
try {
|
|
const readerToken = await resolveRuntimeDataProductReaderToken(
|
|
target.application.id,
|
|
target.page.id,
|
|
target.binding.id,
|
|
generation,
|
|
);
|
|
const product = await readRuntimeReaderProduct(readerToken, target.binding.dataProductId);
|
|
if (product) return { product, readerGrantAction: "reuse", readerGrantGeneration: generation };
|
|
} catch (error) {
|
|
if (!new Set(["data_product_reader_grant_not_found", "data_product_access_denied"]).has(error?.message)) throw error;
|
|
}
|
|
if (!foundryReaderGrantProvisioner.configured) {
|
|
throw applicationError("data_product_reader_grant_not_found", 403);
|
|
}
|
|
const planned = await foundryReaderGrantProvisioner.plan(target);
|
|
return { product: planned.product, readerGrantAction: "ensure", readerGrantGeneration: generation };
|
|
}
|
|
|
|
function runtimePointGeometry(value) {
|
|
if (!isObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) return null;
|
|
const [longitude, latitude] = value.coordinates;
|
|
if (typeof longitude !== "number" || !Number.isFinite(longitude) || typeof latitude !== "number" || !Number.isFinite(latitude)) return null;
|
|
return { type: "Point", coordinates: [longitude, latitude] };
|
|
}
|
|
|
|
const RUNTIME_SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i;
|
|
|
|
function safeRuntimeAttributeValue(value, depth = 0) {
|
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (depth >= 4) return null;
|
|
if (Array.isArray(value)) return value.map((item) => safeRuntimeAttributeValue(item, depth + 1));
|
|
if (!isObject(value)) return null;
|
|
return Object.fromEntries(Object.entries(value)
|
|
.filter(([key]) => !RUNTIME_SECRET_LIKE_KEY.test(key))
|
|
.map(([key, item]) => [key, safeRuntimeAttributeValue(item, depth + 1)]));
|
|
}
|
|
|
|
function sanitizeRuntimeFact(value, binding) {
|
|
if (!isObject(value)) return null;
|
|
const sourceId = value.sourceId;
|
|
const semanticType = value.semanticType;
|
|
if (!isRuntimeIdentifier(sourceId) || !isRuntimeIdentifier(semanticType) || !binding.semanticTypes.includes(semanticType)) return null;
|
|
if (!isRuntimeTimestamp(value.observedAt) || !isRuntimeTimestamp(value.receivedAt)) return null;
|
|
const sourceAttributes = isObject(value.attributes) ? value.attributes : {};
|
|
const allowedFields = new Set(binding.fieldProjection);
|
|
const attributes = Object.fromEntries(Object.entries(sourceAttributes)
|
|
.filter(([key]) => allowedFields.has(key) && !RUNTIME_SECRET_LIKE_KEY.test(key))
|
|
.map(([key, item]) => [key, safeRuntimeAttributeValue(item)]));
|
|
return {
|
|
sourceId,
|
|
semanticType,
|
|
observedAt: value.observedAt,
|
|
receivedAt: value.receivedAt,
|
|
attributes,
|
|
geometry: runtimePointGeometry(value.geometry),
|
|
};
|
|
}
|
|
|
|
function sanitizeRuntimeSnapshot(value, binding) {
|
|
if (!isObject(value) || value.schemaVersion !== "nodedc.data-product.snapshot/v1" || !isObject(value.dataProduct)) {
|
|
throw applicationError("data_product_snapshot_contract_invalid", 502);
|
|
}
|
|
if (value.dataProduct.id !== binding.dataProductId || !isRuntimeVersion(value.dataProduct.version) || !isRuntimeCursor(value.cursor) || !isRuntimeTimestamp(value.generatedAt) || !Array.isArray(value.facts)) {
|
|
throw applicationError("data_product_snapshot_contract_invalid", 502);
|
|
}
|
|
return {
|
|
schemaVersion: "nodedc.data-product.snapshot/v1",
|
|
dataProduct: { id: binding.dataProductId, version: String(value.dataProduct.version || "") },
|
|
generatedAt: value.generatedAt,
|
|
cursor: value.cursor,
|
|
facts: value.facts.map((fact) => sanitizeRuntimeFact(fact, binding)).filter(Boolean),
|
|
};
|
|
}
|
|
|
|
function sanitizeRuntimeHistory(value, binding) {
|
|
if (!isObject(value) || value.schemaVersion !== "nodedc.data-product.history/v1" || !isObject(value.dataProduct) || !isObject(value.query)) {
|
|
throw applicationError("data_product_history_contract_invalid", 502);
|
|
}
|
|
const query = value.query;
|
|
if (
|
|
value.dataProduct.id !== binding.dataProductId
|
|
|| !isRuntimeVersion(value.dataProduct.version)
|
|
|| !isRuntimeTimestamp(value.generatedAt)
|
|
|| !isRuntimeTimestamp(query.from)
|
|
|| !isRuntimeTimestamp(query.to)
|
|
|| Date.parse(query.from) >= Date.parse(query.to)
|
|
|| !Number.isInteger(query.resolutionMs)
|
|
|| query.resolutionMs < 1000
|
|
|| query.resolutionMs > 86_400_000
|
|
|| query.order !== "asc"
|
|
|| !Array.isArray(query.sourceIds)
|
|
|| query.sourceIds.some((sourceId) => !isRuntimeIdentifier(sourceId))
|
|
|| JSON.stringify(query.sourceIds) !== JSON.stringify([...new Set(query.sourceIds)].sort())
|
|
|| !Array.isArray(value.facts)
|
|
|| (value.nextCursor !== undefined && (typeof value.nextCursor !== "string" || !/^[A-Za-z0-9_-]{1,1024}$/.test(value.nextCursor)))
|
|
) throw applicationError("data_product_history_contract_invalid", 502);
|
|
let previousOrderKey = null;
|
|
const facts = value.facts.flatMap((valueFact) => {
|
|
if (
|
|
!isRuntimeTimestamp(valueFact?.bucketStart)
|
|
|| Date.parse(valueFact.bucketStart) < Date.parse(query.from)
|
|
|| Date.parse(valueFact.bucketStart) >= Date.parse(query.to)
|
|
) throw applicationError("data_product_history_contract_invalid", 502);
|
|
const orderKey = `${valueFact.bucketStart}\u0000${valueFact.sourceId}\u0000${valueFact.semanticType}`;
|
|
if (previousOrderKey !== null && orderKey <= previousOrderKey) {
|
|
throw applicationError("data_product_history_contract_invalid", 502);
|
|
}
|
|
previousOrderKey = orderKey;
|
|
const fact = sanitizeRuntimeFact(valueFact, binding);
|
|
return fact ? [{ ...fact, bucketStart: valueFact.bucketStart }] : [];
|
|
});
|
|
return {
|
|
schemaVersion: "nodedc.data-product.history/v1",
|
|
dataProduct: { id: binding.dataProductId, version: value.dataProduct.version },
|
|
generatedAt: value.generatedAt,
|
|
query: {
|
|
from: query.from,
|
|
to: query.to,
|
|
resolutionMs: query.resolutionMs,
|
|
sourceIds: [...query.sourceIds],
|
|
order: "asc",
|
|
},
|
|
facts,
|
|
...(value.nextCursor ? { nextCursor: value.nextCursor } : {}),
|
|
};
|
|
}
|
|
|
|
function sanitizeRuntimePatch(value, binding) {
|
|
if (!isObject(value) || value.schemaVersion !== "nodedc.data-product.patch/v1" || !isObject(value.dataProduct)) return null;
|
|
if (value.dataProduct.id !== binding.dataProductId || !isRuntimeVersion(value.dataProduct.version) || !isRuntimeCursor(value.cursor) || !isRuntimeCursor(value.previousCursor) || !isRuntimeTimestamp(value.emittedAt) || !Array.isArray(value.operations)) return null;
|
|
const operations = value.operations.flatMap((operation) => {
|
|
if (!isObject(operation)) return [];
|
|
if (operation.op === "upsert") {
|
|
const fact = sanitizeRuntimeFact(operation.fact, binding);
|
|
return fact ? [{ op: "upsert", fact }] : [];
|
|
}
|
|
// Current EDP v1 emits only upserts. Foundry already understands the
|
|
// canonical removal shape so a future versioned product can revoke a
|
|
// subject without turning a transient stream failure into deletion.
|
|
if (
|
|
operation.op === "remove"
|
|
&& isRuntimeIdentifier(operation.sourceId)
|
|
&& isRuntimeIdentifier(operation.semanticType)
|
|
&& binding.semanticTypes.includes(operation.semanticType)
|
|
&& isRuntimeTimestamp(operation.removedAt)
|
|
&& ["tombstone", "revoked"].includes(operation.reason)
|
|
) {
|
|
return [{
|
|
op: "remove",
|
|
sourceId: operation.sourceId,
|
|
semanticType: operation.semanticType,
|
|
removedAt: operation.removedAt,
|
|
reason: operation.reason,
|
|
}];
|
|
}
|
|
return [];
|
|
});
|
|
return {
|
|
schemaVersion: "nodedc.data-product.patch/v1",
|
|
dataProduct: { id: binding.dataProductId, version: String(value.dataProduct.version || "") },
|
|
cursor: value.cursor,
|
|
previousCursor: value.previousCursor,
|
|
emittedAt: value.emittedAt,
|
|
operations,
|
|
};
|
|
}
|
|
|
|
function resolveDataProductConsumerPolicy(product) {
|
|
if (dataProductConsumerPolicyRegistry?.schemaVersion !== "nodedc.foundry.data-product-consumer-policies/v1") {
|
|
throw applicationError("data_product_consumer_policy_registry_invalid", 500);
|
|
}
|
|
return dataProductConsumerPolicyRegistry.policies?.find((policy) => (
|
|
policy?.dataProductId === product.id && policy?.productVersion === product.version
|
|
)) || null;
|
|
}
|
|
|
|
function runtimeUpstreamUrl(dataProductId, resource, after) {
|
|
const target = new URL(`/internal/data-plane/v1/data-products/${encodeURIComponent(dataProductId)}/${resource}`, `${externalDataPlaneInternalUrl}/`);
|
|
if (after) target.searchParams.set("after", after);
|
|
return target;
|
|
}
|
|
|
|
function runtimeUpstreamFailure(status) {
|
|
if (status === 401 || status === 403) return applicationError("data_product_access_denied", 403);
|
|
if (status === 404) return applicationError("data_product_not_found", 404);
|
|
if (status === 409) return applicationError("resync_required", 409);
|
|
return applicationError("data_product_runtime_unavailable", 503);
|
|
}
|
|
|
|
function waitForResponseDrain(response) {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const settle = (result, error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
response.off("drain", onDrain);
|
|
response.off("close", onClose);
|
|
response.off("error", onError);
|
|
if (error) reject(error);
|
|
else resolve(result);
|
|
};
|
|
const onDrain = () => settle(true);
|
|
const onClose = () => settle(false);
|
|
const onError = (error) => settle(false, error);
|
|
response.once("drain", onDrain);
|
|
response.once("close", onClose);
|
|
response.once("error", onError);
|
|
if (response.destroyed || response.writableEnded) settle(false);
|
|
});
|
|
}
|
|
|
|
async function writeRuntimeStreamChunk(response, chunk) {
|
|
if (response.destroyed || response.writableEnded) return false;
|
|
try {
|
|
if (response.write(chunk)) return true;
|
|
return await waitForResponseDrain(response);
|
|
} catch (error) {
|
|
if (response.destroyed || response.writableEnded) return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function writeSse(response, { event, data, id }) {
|
|
const frame = `${id ? `id: ${id}\n` : ""}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
return writeRuntimeStreamChunk(response, frame);
|
|
}
|
|
|
|
function openRuntimeDataProductConsumerStream({ url, token, signal }) {
|
|
return new Promise((resolveStream, rejectStream) => {
|
|
const transport = url.protocol === "https:" ? httpsRequest : url.protocol === "http:" ? httpRequest : null;
|
|
if (!transport) return rejectStream(applicationError("data_product_runtime_url_invalid", 500));
|
|
let incoming = null;
|
|
let settled = false;
|
|
const request = transport(url, {
|
|
method: "GET",
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
accept: "text/event-stream",
|
|
connection: "close",
|
|
},
|
|
});
|
|
const abort = () => {
|
|
incoming?.destroy();
|
|
request.destroy();
|
|
};
|
|
signal.addEventListener("abort", abort, { once: true });
|
|
request.once("response", (response) => {
|
|
incoming = response;
|
|
settled = true;
|
|
response.once("close", () => signal.removeEventListener("abort", abort));
|
|
resolveStream({
|
|
status: response.statusCode || 500,
|
|
ok: (response.statusCode || 500) >= 200 && (response.statusCode || 500) < 300,
|
|
headers: new Headers(response.headers),
|
|
body: Readable.toWeb(response),
|
|
});
|
|
});
|
|
request.once("error", (error) => {
|
|
signal.removeEventListener("abort", abort);
|
|
if (!settled) rejectStream(signal.aborted ? applicationError("data_product_stream_closed", 503) : error);
|
|
});
|
|
if (signal.aborted) abort();
|
|
else request.end();
|
|
});
|
|
}
|
|
|
|
const dataProductConsumerManager = createFoundryDataProductConsumerManager({
|
|
stateDir: foundryDataProductConsumersDir,
|
|
dataPlaneUrl: externalDataPlaneInternalUrl,
|
|
resolveTarget: resolveRuntimeMapDataProductBinding,
|
|
readReaderToken: resolveRuntimeDataProductReaderToken,
|
|
inspectReaderGrant: inspectFoundryConsumerReaderGrant,
|
|
ensureReaderGrant: (target, options) => foundryReaderGrantProvisioner.ensure(target, options),
|
|
revokeReaderGrant: (target, options) => foundryReaderGrantProvisioner.revoke(target, options),
|
|
resolvePolicy: resolveDataProductConsumerPolicy,
|
|
sanitizeSnapshot: sanitizeRuntimeSnapshot,
|
|
sanitizePatch: sanitizeRuntimePatch,
|
|
openStream: openRuntimeDataProductConsumerStream,
|
|
});
|
|
await dataProductConsumerManager.resumePersisted();
|
|
|
|
async function proxyRuntimeDataProductSnapshot(response, target) {
|
|
json(response, 200, await dataProductConsumerManager.snapshot(target));
|
|
}
|
|
|
|
async function readRuntimeJsonResponse(upstream, maxBytes = 64 * 1024 * 1024) {
|
|
const contentLength = Number(upstream.headers.get("content-length") || 0);
|
|
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
throw applicationError("data_product_runtime_response_too_large", 502);
|
|
}
|
|
if (!upstream.body) throw applicationError("data_product_runtime_invalid_response", 502);
|
|
const reader = upstream.body.getReader();
|
|
const chunks = [];
|
|
let bytes = 0;
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
bytes += value.byteLength;
|
|
if (bytes > maxBytes) {
|
|
await reader.cancel();
|
|
throw applicationError("data_product_runtime_response_too_large", 502);
|
|
}
|
|
chunks.push(Buffer.from(value));
|
|
}
|
|
return JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
|
|
} catch (error) {
|
|
if (error?.statusCode) throw error;
|
|
throw applicationError("data_product_runtime_invalid_response", 502);
|
|
}
|
|
}
|
|
|
|
function runtimeHistoryQuery(url) {
|
|
const from = String(url.searchParams.get("from") || "");
|
|
const to = String(url.searchParams.get("to") || "");
|
|
const resolutionMs = Number(url.searchParams.get("resolutionMs") || 60_000);
|
|
const limit = Number(url.searchParams.get("limit") || 1000);
|
|
const sourceIds = [...new Set(String(url.searchParams.get("sourceIds") || "").split(",").map((value) => value.trim()).filter(Boolean))];
|
|
const cursor = String(url.searchParams.get("cursor") || "");
|
|
if (
|
|
!isRuntimeTimestamp(from)
|
|
|| !isRuntimeTimestamp(to)
|
|
|| Date.parse(from) >= Date.parse(to)
|
|
|| !Number.isInteger(resolutionMs)
|
|
|| resolutionMs < 1000
|
|
|| resolutionMs > 86_400_000
|
|
|| !Number.isInteger(limit)
|
|
|| limit < 1
|
|
|| limit > 5000
|
|
|| sourceIds.length > 1000
|
|
|| sourceIds.some((sourceId) => !isRuntimeIdentifier(sourceId))
|
|
|| (cursor && !/^[A-Za-z0-9_-]{1,1024}$/.test(cursor))
|
|
) throw applicationError("data_product_history_query_invalid", 400);
|
|
return { from, to, resolutionMs, limit, sourceIds, cursor };
|
|
}
|
|
|
|
async function proxyRuntimeDataProductHistory(response, url, target) {
|
|
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id);
|
|
const query = runtimeHistoryQuery(url);
|
|
const upstreamUrl = runtimeUpstreamUrl(target.binding.dataProductId, "history");
|
|
upstreamUrl.searchParams.set("from", query.from);
|
|
upstreamUrl.searchParams.set("to", query.to);
|
|
upstreamUrl.searchParams.set("resolutionMs", String(query.resolutionMs));
|
|
upstreamUrl.searchParams.set("limit", String(query.limit));
|
|
if (query.sourceIds.length) upstreamUrl.searchParams.set("sourceIds", query.sourceIds.join(","));
|
|
if (query.cursor) upstreamUrl.searchParams.set("cursor", query.cursor);
|
|
let upstream;
|
|
try {
|
|
upstream = await fetch(upstreamUrl, {
|
|
headers: { authorization: `Bearer ${readerToken}`, accept: "application/json" },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
} catch {
|
|
throw applicationError("data_product_runtime_unavailable", 503);
|
|
}
|
|
if (!upstream.ok) throw runtimeUpstreamFailure(upstream.status);
|
|
const payload = await readRuntimeJsonResponse(upstream);
|
|
json(response, 200, sanitizeRuntimeHistory(payload, target.binding));
|
|
}
|
|
|
|
async function proxyRuntimeDataProductStream(request, response, url, target) {
|
|
const requestedAfter = String(url.searchParams.get("after") || "");
|
|
const lastEventId = String(request.headers["last-event-id"] || "");
|
|
if (requestedAfter && !isRuntimeCursor(requestedAfter)) throw applicationError("stream_cursor_invalid");
|
|
if (lastEventId && !isRuntimeCursor(lastEventId)) throw applicationError("stream_cursor_invalid");
|
|
// EventSource preserves the original URL on an automatic reconnect while
|
|
// adding Last-Event-ID. The latter is the authoritative acknowledged
|
|
// cursor; using it avoids replaying a stale query-string cursor forever.
|
|
const after = lastEventId || requestedAfter;
|
|
const controller = new AbortController();
|
|
let lease = null;
|
|
let heartbeat = null;
|
|
let headersReady = false;
|
|
const bufferedEvents = [];
|
|
let pendingEventCount = 0;
|
|
let writeChain = Promise.resolve(true);
|
|
const enqueue = (event) => {
|
|
if (!headersReady) {
|
|
bufferedEvents.push(event);
|
|
return;
|
|
}
|
|
pendingEventCount += 1;
|
|
if (pendingEventCount > 256) {
|
|
abort();
|
|
response.destroy();
|
|
return;
|
|
}
|
|
writeChain = writeChain
|
|
.then((open) => open && writeSse(response, event))
|
|
.catch(() => false)
|
|
.finally(() => { pendingEventCount = Math.max(0, pendingEventCount - 1); });
|
|
};
|
|
const abort = () => {
|
|
if (!controller.signal.aborted) controller.abort();
|
|
lease?.release();
|
|
};
|
|
const activeStream = { controller, response };
|
|
activeRuntimeStreams.add(activeStream);
|
|
request.once("aborted", abort);
|
|
response.once("close", abort);
|
|
request.socket?.once("close", abort);
|
|
try {
|
|
lease = await dataProductConsumerManager.subscribe({ ...target, after }, enqueue);
|
|
response.writeHead(200, {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-store, no-transform",
|
|
connection: "keep-alive",
|
|
"x-accel-buffering": "no",
|
|
vary: "Cookie",
|
|
});
|
|
headersReady = true;
|
|
if (lease.resyncRequired) {
|
|
await writeSse(response, {
|
|
event: "nodedc.data-product.resync-required.v1",
|
|
data: { schemaVersion: "nodedc.data-product.resync-required/v1", dataProductId: target.binding.dataProductId },
|
|
});
|
|
response.end();
|
|
return;
|
|
}
|
|
for (const event of bufferedEvents.splice(0)) enqueue(event);
|
|
heartbeat = setInterval(() => {
|
|
writeChain = writeChain.then((open) => open && writeRuntimeStreamChunk(response, ": keepalive\n\n")).catch(() => false);
|
|
}, 15_000);
|
|
heartbeat.unref?.();
|
|
await new Promise((resolve) => {
|
|
if (controller.signal.aborted || response.destroyed) return resolve();
|
|
controller.signal.addEventListener("abort", resolve, { once: true });
|
|
});
|
|
} finally {
|
|
if (heartbeat) clearInterval(heartbeat);
|
|
abort();
|
|
activeRuntimeStreams.delete(activeStream);
|
|
request.off("aborted", abort);
|
|
response.off("close", abort);
|
|
request.socket?.off("close", abort);
|
|
if (!response.writableEnded && !response.destroyed) response.end();
|
|
}
|
|
}
|
|
|
|
async function listApplicationManifests() {
|
|
const manifests = [];
|
|
for (const entry of await readdir(applicationsDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
try {
|
|
manifests.push(validateApplicationManifest(JSON.parse(await readFile(join(applicationsDir, entry.name), "utf8"))));
|
|
} catch {
|
|
// A damaged draft must not make the complete Foundry project list unavailable.
|
|
}
|
|
}
|
|
return manifests.sort((left, right) => String(right.timestamps.updatedAt).localeCompare(String(left.timestamps.updatedAt)));
|
|
}
|
|
|
|
function applicationSummary(manifest) {
|
|
return {
|
|
id: manifest.id,
|
|
name: manifest.metadata.name,
|
|
slug: manifest.metadata.slug,
|
|
status: manifest.status,
|
|
version: manifest.version,
|
|
theme: manifest.designProfile.theme,
|
|
pageCount: manifest.pages.length,
|
|
updatedAt: manifest.timestamps.updatedAt,
|
|
};
|
|
}
|
|
|
|
async function assertDesignProfileReference(reference) {
|
|
if (!reference || typeof reference !== "object") throw applicationError("invalid_design_profile_reference");
|
|
const profileId = String(reference.id || "");
|
|
const version = String(reference.version || "");
|
|
let profile;
|
|
try {
|
|
const sourcePath = reference.status === "published"
|
|
? designProfileReleasePath(profileId, version)
|
|
: designProfilePath(profileId);
|
|
profile = validateDesignProfile(JSON.parse(await readFile(sourcePath, "utf8")));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") throw applicationError("design_profile_reference_not_found", 409);
|
|
throw error;
|
|
}
|
|
if (profile.version !== version || profile.layout.theme !== reference.theme) {
|
|
throw applicationError("design_profile_reference_mismatch", 409);
|
|
}
|
|
if (reference.status === "published" && profile.status !== "published") {
|
|
throw applicationError("design_profile_reference_not_published", 409);
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
function stringListEnv(name) {
|
|
return String(process.env[name] || "")
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function foundryMcpConfig() {
|
|
const publicUrl = String(process.env.FOUNDRY_PUBLIC_URL || "").trim().replace(/\/$/, "");
|
|
const mcpUrl = String(process.env.FOUNDRY_MCP_URL || (publicUrl ? `${publicUrl}/api/mcp` : "")).trim();
|
|
const internalAccessToken = String(process.env.NODEDC_INTERNAL_ACCESS_TOKEN || process.env.NODEDC_PLATFORM_SERVICE_TOKEN || "").trim();
|
|
const requestedCapabilityTtlMs = Number(process.env.FOUNDRY_MCP_CAPABILITY_TTL_MS || 600_000);
|
|
return {
|
|
// The shared platform service credential never leaves a server process. It is used
|
|
// only to authenticate the AI Workspace entitlement request and to sign a short-lived
|
|
// Foundry MCP capability bound to the resolved actor and owner key.
|
|
internalAccessToken,
|
|
capabilitySecret: internalAccessToken,
|
|
mcpCapabilityTtlMs: Number.isFinite(requestedCapabilityTtlMs)
|
|
? Math.min(Math.max(Math.trunc(requestedCapabilityTtlMs), 60_000), 3_600_000)
|
|
: 600_000,
|
|
mcpUrl,
|
|
mcpAllowedOrigins: stringListEnv("FOUNDRY_MCP_ALLOWED_ORIGINS"),
|
|
allowAllAuthenticated: String(process.env.FOUNDRY_ALLOW_ALL_AUTHENTICATED || "").trim().toLowerCase() === "true",
|
|
// dcctouch@gmail.com is the platform superadmin (user_root). The group is also
|
|
// provisioned through Authentik/Launcher as the canonical service access rule.
|
|
allowedOwnerIds: stringListEnv("FOUNDRY_ALLOWED_OWNER_IDS").concat("user_root"),
|
|
allowedOwnerGroups: stringListEnv("FOUNDRY_ALLOWED_OWNER_GROUPS")
|
|
.concat("nodedc:superadmin", "nodedc:module-foundry:admin", "nodedc:module-foundry:user", "nodedc:module-foundry:access")
|
|
.map((value) => value.toLowerCase()),
|
|
};
|
|
}
|
|
|
|
function requiredMcpIdempotencyKey(value) {
|
|
const key = requireNonEmptyString(value, "idempotency_key_required", 160);
|
|
if (!/^[A-Za-z0-9._:-]+$/.test(key)) throw applicationError("invalid_idempotency_key");
|
|
return key;
|
|
}
|
|
|
|
const foundryMcpInFlightOperations = new Map();
|
|
|
|
async function executeFoundryMcpWrite({ tool, actor, input, action }) {
|
|
const idempotencyKey = requiredMcpIdempotencyKey(input.idempotencyKey);
|
|
const actorId = requireNonEmptyString(actor?.actorId, "mcp_actor_context_required", 256);
|
|
const ownerKey = requireNonEmptyString(actor?.ownerKey, "mcp_actor_context_required", 256);
|
|
const inputForHash = { ...input };
|
|
delete inputForHash.idempotencyKey;
|
|
const inputHash = createHash("sha256").update(JSON.stringify(inputForHash)).digest("hex");
|
|
const operationHash = createHash("sha256").update(`${tool}\n${ownerKey}\n${idempotencyKey}`).digest("hex");
|
|
const path = join(foundryMcpOperationsDir, `${operationHash}.json`);
|
|
const inFlight = foundryMcpInFlightOperations.get(operationHash);
|
|
if (inFlight) return inFlight;
|
|
const pending = (async () => {
|
|
try {
|
|
const recorded = JSON.parse(await readFile(path, "utf8"));
|
|
if (recorded.tool !== tool || recorded.ownerKey !== ownerKey || recorded.inputHash !== inputHash) {
|
|
throw applicationError("idempotency_key_reused", 409);
|
|
}
|
|
return { ...recorded.result, idempotency: { replayed: true } };
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
const result = await action();
|
|
await writeJsonAtomically(path, {
|
|
schemaVersion: "nodedc.module-foundry.mcp-operation.v1",
|
|
tool,
|
|
ownerKey,
|
|
actorId,
|
|
inputHash,
|
|
completedAt: new Date().toISOString(),
|
|
result,
|
|
});
|
|
return { ...result, idempotency: { replayed: false } };
|
|
})();
|
|
foundryMcpInFlightOperations.set(operationHash, pending);
|
|
try {
|
|
return await pending;
|
|
} finally {
|
|
foundryMcpInFlightOperations.delete(operationHash);
|
|
}
|
|
}
|
|
|
|
function normalizeMcpPageTitle(value, fallback, code) {
|
|
if (value === undefined) return fallback;
|
|
const title = requireNonEmptyString(value, code, 120).trim();
|
|
return title || fallback;
|
|
}
|
|
|
|
function nextPageInstance(manifest, input) {
|
|
const templateId = requireNonEmptyString(input.templateId, "invalid_application_page_template", 64);
|
|
const templateVersion = input.templateVersion === undefined ? undefined : requireNonEmptyString(input.templateVersion, "invalid_application_page_template_version", 64);
|
|
const template = findPageTemplate(templateId, templateVersion);
|
|
if (!template) throw applicationError("unknown_application_page_template");
|
|
const suffix = randomUUID().slice(0, 8);
|
|
const id = `${template.page.id}-${suffix}`;
|
|
const maxOrder = manifest.pages.reduce((max, page) => Math.max(max, Number(page.navigation?.order ?? -1)), -1);
|
|
return {
|
|
id,
|
|
title: normalizeMcpPageTitle(input.title, template.page.title, "invalid_application_page_title"),
|
|
path: `${template.page.path.replace(/\/$/, "") || "/"}-${suffix}`.replace(/^\/-/, "/"),
|
|
template: { id: template.id, version: template.version },
|
|
navigation: {
|
|
visible: true,
|
|
label: normalizeMcpPageTitle(input.navigationLabel, template.page.navigationLabel, "invalid_application_page_navigation_label"),
|
|
order: maxOrder + 1,
|
|
},
|
|
features: Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible])),
|
|
};
|
|
}
|
|
|
|
async function writeFoundryApplicationUpdate(applicationId, update) {
|
|
const current = await readApplicationManifest(applicationId);
|
|
const nextCandidate = await update(current);
|
|
const next = validateApplicationManifest({
|
|
...nextCandidate,
|
|
id: current.id,
|
|
schemaVersion: current.schemaVersion,
|
|
status: current.status,
|
|
timestamps: {
|
|
createdAt: current.timestamps.createdAt,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
});
|
|
await assertDesignProfileReference(next.designProfile);
|
|
await writeApplicationManifest(next);
|
|
return next;
|
|
}
|
|
|
|
const foundryMcpOperations = {
|
|
async status() {
|
|
const config = foundryMcpConfig();
|
|
return {
|
|
module: "NDC Module Foundry",
|
|
schemaVersion: "nodedc.module-foundry.mcp.v0.5",
|
|
status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required",
|
|
canonicalPageLibrary: "read-only",
|
|
applicationInstances: "read-write",
|
|
destructiveApplicationDelete: false,
|
|
supportedPageTemplates: pageRegistry.templates.map((template) => ({ id: template.id, version: template.version, title: template.title })),
|
|
supportedActions: [
|
|
"application.create",
|
|
"application.metadata.update",
|
|
"page-instance.create",
|
|
"map-pin.upsert",
|
|
"map-pin.remove",
|
|
"map-presentation-profile.upsert",
|
|
"map-page-settings.update",
|
|
"map-page-view-state.save",
|
|
"map-data-product.upsert",
|
|
"map-data-product-consumer.plan",
|
|
"map-data-product-consumer.apply",
|
|
"map-data-product-consumer.status",
|
|
"map-data-product-consumer.accept",
|
|
"map-data-product-consumer.rollback",
|
|
],
|
|
persistence: { runtimeDir: "persistent runtime volume required", idempotentWrites: true, dataProductConsumerState: true },
|
|
};
|
|
},
|
|
async listApplications() {
|
|
const manifests = await listApplicationManifests();
|
|
return { applications: manifests.map(applicationSummary) };
|
|
},
|
|
async getApplication(applicationId) {
|
|
return { application: await readApplicationManifest(applicationId) };
|
|
},
|
|
async createApplication(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_create_application",
|
|
actor,
|
|
input,
|
|
action: async () => {
|
|
const name = normalizeMcpPageTitle(input.name, "Новый модуль", "invalid_application_name");
|
|
const manifest = validateApplicationManifest(createApplicationManifest({
|
|
...input,
|
|
name,
|
|
templateId: input.templateId || "map",
|
|
description: input.description === undefined ? "Черновик NDC Module Foundry." : input.description,
|
|
}));
|
|
const collision = (await listApplicationManifests()).some((item) => item.metadata.slug === manifest.metadata.slug);
|
|
if (collision) throw applicationError("application_slug_conflict", 409);
|
|
await assertDesignProfileReference(manifest.designProfile);
|
|
await writeApplicationManifest(manifest);
|
|
return { application: manifest };
|
|
},
|
|
});
|
|
},
|
|
async updateApplicationMetadata(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_update_application_metadata",
|
|
actor,
|
|
input,
|
|
action: async () => {
|
|
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
|
...current,
|
|
metadata: {
|
|
...current.metadata,
|
|
...(Object.hasOwn(input, "name") ? { name: normalizeMcpPageTitle(input.name, current.metadata.name, "invalid_application_name") } : {}),
|
|
...(Object.hasOwn(input, "slug") ? (() => {
|
|
const slug = normalizeSlug(input.slug);
|
|
if (!slug) throw applicationError("invalid_application_slug");
|
|
return { slug };
|
|
})() : {}),
|
|
...(Object.hasOwn(input, "description") ? { description: String(input.description || "").slice(0, 500) } : {}),
|
|
},
|
|
}));
|
|
return { application };
|
|
},
|
|
});
|
|
},
|
|
async addPageInstance(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_add_page_instance",
|
|
actor,
|
|
input,
|
|
action: async () => {
|
|
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
|
...current,
|
|
pages: [...current.pages, nextPageInstance(current, input)],
|
|
}));
|
|
return { application, page: application.pages.at(-1) };
|
|
},
|
|
});
|
|
},
|
|
async upsertMapPinBinding(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_upsert_map_pin_binding",
|
|
actor,
|
|
input,
|
|
action: async () => {
|
|
const binding = validateMapPinBinding(input.binding);
|
|
let updatedPage = null;
|
|
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
|
...current,
|
|
pages: current.pages.map((page) => {
|
|
if (page.id !== input.pageId) return page;
|
|
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
|
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
|
const pinBindings = layout.pinBindings.filter((item) => item.id !== binding.id);
|
|
pinBindings.push(binding);
|
|
updatedPage = {
|
|
...page,
|
|
layout: { ...page.layout, map: { ...layout, pinBindings, savedAt: new Date().toISOString() } },
|
|
};
|
|
return updatedPage;
|
|
}),
|
|
}));
|
|
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
|
return { application, page: updatedPage, binding };
|
|
},
|
|
});
|
|
},
|
|
async removeMapPinBinding(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_remove_map_pin_binding",
|
|
actor,
|
|
input,
|
|
action: async () => {
|
|
const bindingId = requireNonEmptyString(input.bindingId, "invalid_map_pin_id", 128);
|
|
if (!/^[A-Za-z0-9._:-]+$/.test(bindingId)) throw applicationError("invalid_map_pin_id");
|
|
let updatedPage = null;
|
|
let removed = false;
|
|
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
|
...current,
|
|
pages: current.pages.map((page) => {
|
|
if (page.id !== input.pageId) return page;
|
|
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
|
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
|
const pinBindings = layout.pinBindings.filter((item) => item.id !== bindingId);
|
|
removed = pinBindings.length !== layout.pinBindings.length;
|
|
updatedPage = {
|
|
...page,
|
|
layout: { ...page.layout, map: { ...layout, pinBindings, savedAt: new Date().toISOString() } },
|
|
};
|
|
return updatedPage;
|
|
}),
|
|
}));
|
|
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
|
return { application, page: updatedPage, bindingId, removed };
|
|
},
|
|
});
|
|
},
|
|
async upsertMapPresentationProfile(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_upsert_map_presentation_profile",
|
|
actor,
|
|
input,
|
|
action: async () => {
|
|
const profile = normalizeMapPresentationProfile(input.profile);
|
|
let updatedPage = null;
|
|
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
|
...current,
|
|
pages: current.pages.map((page) => {
|
|
if (page.id !== input.pageId) return page;
|
|
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
|
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
|
const presentationProfiles = layout.presentationProfiles.filter((item) => item.id !== profile.id);
|
|
presentationProfiles.push(profile);
|
|
updatedPage = {
|
|
...page,
|
|
layout: { ...page.layout, map: validateMapPageLayout({
|
|
...layout,
|
|
presentationProfiles,
|
|
savedAt: new Date().toISOString(),
|
|
}) },
|
|
designOverrides: {
|
|
...page.designOverrides,
|
|
map: {
|
|
...page.designOverrides?.map,
|
|
presentationProfiles,
|
|
},
|
|
},
|
|
};
|
|
return updatedPage;
|
|
}),
|
|
}));
|
|
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
|
return { application, page: updatedPage, profile };
|
|
},
|
|
});
|
|
},
|
|
async updateMapPageSettings(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_update_map_page_settings",
|
|
actor,
|
|
input,
|
|
action: async () => {
|
|
const settingsPatch = validateMapPageSettingsPatch(input.settings);
|
|
let updatedPage = null;
|
|
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
|
...current,
|
|
pages: current.pages.map((page) => {
|
|
if (page.id !== input.pageId) return page;
|
|
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
|
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
|
updatedPage = {
|
|
...page,
|
|
layout: { ...page.layout, map: validateMapPageLayout({
|
|
...layout,
|
|
settings: { ...layout.settings, ...settingsPatch },
|
|
}) },
|
|
};
|
|
return updatedPage;
|
|
}),
|
|
}));
|
|
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
|
return { application, page: updatedPage, settings: updatedPage.layout.map.settings };
|
|
},
|
|
});
|
|
},
|
|
async saveMapPageViewState(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_save_map_page_view_state",
|
|
actor,
|
|
input,
|
|
action: async () => {
|
|
if (!isObject(input.viewState)) throw applicationError("invalid_map_page_view_state");
|
|
let updatedPage = null;
|
|
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
|
...current,
|
|
pages: current.pages.map((page) => {
|
|
if (page.id !== input.pageId) return page;
|
|
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
|
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
|
const settings = input.viewState.settings === undefined
|
|
? layout.settings
|
|
: { ...layout.settings, ...validateMapPageSettingsPatch(input.viewState.settings) };
|
|
updatedPage = {
|
|
...page,
|
|
layout: { ...page.layout, map: validateMapPageLayout({
|
|
...layout,
|
|
settings,
|
|
mapHeight: input.viewState.mapHeight,
|
|
camera: input.viewState.camera,
|
|
subjectStates: input.viewState.subjectStates,
|
|
}) },
|
|
};
|
|
return updatedPage;
|
|
}),
|
|
}));
|
|
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
|
return { application, page: updatedPage, viewState: {
|
|
settings: updatedPage.layout.map.settings,
|
|
mapHeight: updatedPage.layout.map.mapHeight,
|
|
camera: updatedPage.layout.map.camera,
|
|
subjectStates: updatedPage.layout.map.subjectStates,
|
|
} };
|
|
},
|
|
});
|
|
},
|
|
async upsertMapDataProductBinding(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_upsert_map_data_product_binding",
|
|
actor,
|
|
input,
|
|
action: async () => {
|
|
const binding = validateMapDataProductBinding(input.binding);
|
|
let updatedPage = null;
|
|
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
|
...current,
|
|
pages: current.pages.map((page) => {
|
|
if (page.id !== input.pageId) return page;
|
|
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
|
const template = findPageTemplate(page.template.id, page.template.version);
|
|
const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId);
|
|
if (!slot || slot.kind !== "entity-stream") throw applicationError("map_entity_stream_slot_required");
|
|
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
|
const existingBinding = layout.dataProductBindings.find((item) => item.id === binding.id);
|
|
const resolvedBinding = {
|
|
...existingBinding,
|
|
...binding,
|
|
...(!binding.presentationProfileId && existingBinding?.presentationProfileId
|
|
? { presentationProfileId: existingBinding.presentationProfileId }
|
|
: {}),
|
|
};
|
|
if (resolvedBinding.presentationProfileId && !layout.presentationProfiles.some((profile) => profile.id === resolvedBinding.presentationProfileId)) {
|
|
throw applicationError("map_data_product_presentation_profile_not_found");
|
|
}
|
|
const dataProductBindings = layout.dataProductBindings.filter((item) => item.id !== binding.id);
|
|
dataProductBindings.push(resolvedBinding);
|
|
updatedPage = {
|
|
...page,
|
|
layout: { ...page.layout, map: validateMapPageLayout({
|
|
...layout,
|
|
dataProductBindings,
|
|
savedAt: new Date().toISOString(),
|
|
}) },
|
|
};
|
|
return updatedPage;
|
|
}),
|
|
}));
|
|
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
|
return {
|
|
application,
|
|
page: updatedPage,
|
|
binding: updatedPage.layout.map.dataProductBindings.find((item) => item.id === binding.id),
|
|
};
|
|
},
|
|
});
|
|
},
|
|
async planMapDataProductConsumer(input) {
|
|
return dataProductConsumerManager.plan(input);
|
|
},
|
|
async applyMapDataProductConsumer(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_apply_map_data_product_consumer",
|
|
actor,
|
|
input,
|
|
action: () => dataProductConsumerManager.apply(input),
|
|
});
|
|
},
|
|
async getMapDataProductConsumerStatus(input) {
|
|
return dataProductConsumerManager.status(input);
|
|
},
|
|
async acceptMapDataProductConsumer(input) {
|
|
return dataProductConsumerManager.accept(input);
|
|
},
|
|
async rollbackMapDataProductConsumer(input, actor) {
|
|
return executeFoundryMcpWrite({
|
|
tool: "foundry_rollback_map_data_product_consumer",
|
|
actor,
|
|
input,
|
|
action: () => dataProductConsumerManager.rollback(input),
|
|
});
|
|
},
|
|
};
|
|
|
|
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
|
|
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 safeUploadName(name, contentType = "") {
|
|
const allowed = new Set([".gif", ".ico", ".jpeg", ".jpg", ".m4v", ".mov", ".mp4", ".png", ".webm", ".webp"]);
|
|
const contentTypeExtensions = {
|
|
"image/gif": ".gif",
|
|
"image/x-icon": ".ico",
|
|
"image/jpeg": ".jpg",
|
|
"image/png": ".png",
|
|
"image/webp": ".webp",
|
|
"video/mp4": ".mp4",
|
|
"video/quicktime": ".mov",
|
|
"video/webm": ".webm",
|
|
};
|
|
const requestedExtension = extname(name).toLowerCase();
|
|
const extension = allowed.has(requestedExtension) ? requestedExtension : contentTypeExtensions[contentType] || ".bin";
|
|
const stem = name.replace(/\.[^.]+$/, "").replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "stage-video";
|
|
return `${Date.now()}-${stem}${extension}`;
|
|
}
|
|
|
|
async function serveFile(request, response, filePath) {
|
|
const info = await stat(filePath);
|
|
const contentType = mimeTypes[extname(filePath).toLowerCase()] || "application/octet-stream";
|
|
const range = request.headers.range?.match(/^bytes=(\d*)-(\d*)$/);
|
|
if (range) {
|
|
const start = range[1] ? Number(range[1]) : 0;
|
|
const end = range[2] ? Math.min(Number(range[2]), info.size - 1) : info.size - 1;
|
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= info.size) {
|
|
response.writeHead(416, { "content-range": `bytes */${info.size}` });
|
|
response.end();
|
|
return;
|
|
}
|
|
response.writeHead(206, {
|
|
"content-type": contentType,
|
|
"content-length": end - start + 1,
|
|
"content-range": `bytes ${start}-${end}/${info.size}`,
|
|
"accept-ranges": "bytes",
|
|
});
|
|
if (request.method === "HEAD") return response.end();
|
|
createReadStream(filePath, { start, end }).pipe(response);
|
|
return;
|
|
}
|
|
response.writeHead(200, {
|
|
"content-type": contentType,
|
|
"content-length": info.size,
|
|
"accept-ranges": "bytes",
|
|
});
|
|
if (request.method === "HEAD") return response.end();
|
|
createReadStream(filePath).pipe(response);
|
|
}
|
|
|
|
const browserCacheableMapStates = new Set([
|
|
"live-cache-hit",
|
|
"live-cache-stale",
|
|
"legacy-cache-hit",
|
|
"legacy-cache-stale",
|
|
"live-offline-hit",
|
|
"live-offline-stale",
|
|
"offline-offline-hit",
|
|
"offline-offline-stale",
|
|
"live-stale-upstream-error",
|
|
]);
|
|
|
|
function mapGatewayRefreshRequested(incoming, pathname) {
|
|
if (pathname !== "/api/map/cache") return false;
|
|
const rawTarget = incoming.searchParams.get("url");
|
|
if (!rawTarget) return false;
|
|
try {
|
|
return new URL(rawTarget).searchParams.get("nodedc_cache_refresh") === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function weakEtagValue(value) {
|
|
return String(value || "").trim().replace(/^W\//i, "");
|
|
}
|
|
|
|
function requestValidatorMatches(request, etag, lastModified) {
|
|
const ifNoneMatch = String(request.headers["if-none-match"] || "").trim();
|
|
if (ifNoneMatch && etag) {
|
|
if (ifNoneMatch === "*") return true;
|
|
const expected = weakEtagValue(etag);
|
|
return ifNoneMatch.split(",").some((candidate) => weakEtagValue(candidate) === expected);
|
|
}
|
|
const ifModifiedSince = Date.parse(String(request.headers["if-modified-since"] || ""));
|
|
const modifiedAt = Date.parse(String(lastModified || ""));
|
|
return !ifNoneMatch && Number.isFinite(ifModifiedSince) && Number.isFinite(modifiedAt) && modifiedAt <= ifModifiedSince;
|
|
}
|
|
|
|
async function proxyMapGateway(request, response, pathname) {
|
|
if (!mapGatewayInternalUrl) return json(response, 503, { error: "map_gateway_not_configured" });
|
|
if (!pathname.startsWith("/healthz") && !pathname.startsWith("/api/map/")) {
|
|
return json(response, 404, { error: "map_gateway_route_not_found" });
|
|
}
|
|
// Gateway administration is intentionally not a transparent browser proxy.
|
|
// It is reachable only through the admin-only signed path below.
|
|
if (pathname.startsWith("/api/map/admin/")) return json(response, 404, { error: "map_gateway_route_not_found" });
|
|
const sessionUser = foundryAuth.currentUserProfile(request);
|
|
// Local development deliberately runs without Hub handoff; production always
|
|
// requires a validated session before this proxy is reachable.
|
|
const subjectId = sessionUser?.id || (!foundryAuth.authRequired ? "local-dev" : "");
|
|
if (!subjectId) return json(response, 401, { error: "module_foundry_auth_required" });
|
|
const incoming = new URL(request.url || "/", "http://foundry.local");
|
|
const target = new URL(`${pathname}${incoming.search}`, `${mapGatewayInternalUrl}/`);
|
|
const ionReferer = foundryRequestIonReferer(request);
|
|
const controller = new AbortController();
|
|
let clientDisconnected = false;
|
|
let headersTimedOut = false;
|
|
let bodyIdleTimedOut = false;
|
|
let bodyIdleTimeout = null;
|
|
const abortClientRequest = () => {
|
|
clientDisconnected = true;
|
|
if (!controller.signal.aborted) controller.abort();
|
|
};
|
|
const abortClosedResponse = () => {
|
|
if (!response.writableEnded) abortClientRequest();
|
|
};
|
|
const headersTimeout = setTimeout(() => {
|
|
headersTimedOut = true;
|
|
if (!controller.signal.aborted) controller.abort();
|
|
}, mapGatewayHeadersTimeoutMs);
|
|
const clearBodyIdleTimeout = () => {
|
|
if (bodyIdleTimeout !== null) clearTimeout(bodyIdleTimeout);
|
|
bodyIdleTimeout = null;
|
|
};
|
|
const armBodyIdleTimeout = () => {
|
|
clearBodyIdleTimeout();
|
|
bodyIdleTimeout = setTimeout(() => {
|
|
bodyIdleTimedOut = true;
|
|
if (!controller.signal.aborted) controller.abort();
|
|
}, mapGatewayBodyIdleTimeoutMs);
|
|
};
|
|
request.once("aborted", abortClientRequest);
|
|
response.once("close", abortClosedResponse);
|
|
|
|
let upstream;
|
|
try {
|
|
const upstreamHeaders = {
|
|
accept: String(request.headers.accept || "*/*"),
|
|
"x-nodedc-user-id": subjectId,
|
|
...(ionReferer ? { "x-nodedc-ion-referer": ionReferer } : {}),
|
|
};
|
|
for (const name of ["range", "if-none-match", "if-modified-since"]) {
|
|
const value = String(request.headers[name] || "").trim();
|
|
if (value) upstreamHeaders[name] = value;
|
|
}
|
|
upstream = await fetch(target, {
|
|
method: request.method,
|
|
headers: upstreamHeaders,
|
|
signal: controller.signal,
|
|
});
|
|
// The connection/header deadline ends as soon as Gateway has produced a
|
|
// response. The body has a separate progress-based idle timeout below, so
|
|
// a healthy large Cesium tile is never killed by an absolute wall clock.
|
|
clearTimeout(headersTimeout);
|
|
if (clientDisconnected || response.destroyed) {
|
|
await upstream.body?.cancel().catch(() => undefined);
|
|
return;
|
|
}
|
|
|
|
const cacheState = String(upstream.headers.get("x-nodedc-map-cache") || "");
|
|
const cacheable = pathname === "/api/map/cache"
|
|
&& [200, 206, 304].includes(upstream.status)
|
|
&& browserCacheableMapStates.has(cacheState)
|
|
&& !mapGatewayRefreshRequested(incoming, pathname);
|
|
const etag = upstream.headers.get("etag");
|
|
const lastModified = upstream.headers.get("last-modified");
|
|
const notModified = cacheable
|
|
&& upstream.status === 200
|
|
&& request.method === "GET"
|
|
&& requestValidatorMatches(request, etag, lastModified);
|
|
const headers = ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "x-nodedc-map-cache", "x-nodedc-map-cache-age"];
|
|
for (const header of headers) {
|
|
const value = upstream.headers.get(header);
|
|
if (value) response.setHeader(header, value);
|
|
}
|
|
if (cacheable) {
|
|
// The object was confirmed by the private Gateway as a persistent-cache
|
|
// hit. Keep it in this browser only; shared reverse proxies must still
|
|
// revalidate the authenticated request.
|
|
response.setHeader("cache-control", "private, max-age=300, stale-while-revalidate=60");
|
|
response.setHeader("vary", "Cookie");
|
|
} else {
|
|
// Provider responses, health/configuration calls, errors and explicit
|
|
// refreshes must never create an uncontrolled browser-side copy.
|
|
response.setHeader("cache-control", "no-store");
|
|
}
|
|
response.statusCode = notModified ? 304 : upstream.status;
|
|
if (notModified) {
|
|
response.removeHeader("content-length");
|
|
response.removeHeader("content-range");
|
|
await upstream.body?.cancel().catch(() => undefined);
|
|
return response.end();
|
|
}
|
|
if (request.method === "HEAD" || upstream.status === 304 || !upstream.body) {
|
|
await upstream.body?.cancel().catch(() => undefined);
|
|
return response.end();
|
|
}
|
|
const bodyProgress = new Transform({
|
|
transform(chunk, _encoding, callback) {
|
|
armBodyIdleTimeout();
|
|
callback(null, chunk);
|
|
},
|
|
flush(callback) {
|
|
// Once Gateway reached EOF there is no upstream body left to time out.
|
|
// Let Node finish draining buffered bytes to a slow browser; its own
|
|
// disconnect is still handled by the response close listener.
|
|
clearBodyIdleTimeout();
|
|
callback();
|
|
},
|
|
});
|
|
armBodyIdleTimeout();
|
|
await pipeline(Readable.fromWeb(upstream.body), bodyProgress, response, { signal: controller.signal });
|
|
} catch (error) {
|
|
if (clientDisconnected || response.destroyed) return;
|
|
if (response.headersSent) {
|
|
if (!response.writableEnded && !response.destroyed) response.destroy(error);
|
|
return;
|
|
}
|
|
if (headersTimedOut) throw applicationError("map_gateway_headers_timeout", 504);
|
|
if (bodyIdleTimedOut) throw applicationError("map_gateway_body_idle_timeout", 504);
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(headersTimeout);
|
|
clearBodyIdleTimeout();
|
|
request.off("aborted", abortClientRequest);
|
|
response.off("close", abortClosedResponse);
|
|
}
|
|
}
|
|
|
|
function requireFoundryAdmin(request) {
|
|
const access = foundryAuth.currentUserAccess(request);
|
|
if (!access.admin) throw applicationError("module_foundry_admin_required", 403);
|
|
const user = foundryAuth.currentUserProfile(request);
|
|
const actorId = String(user?.id || (!foundryAuth.authRequired ? "local-dev" : "")).trim();
|
|
if (!actorId) throw applicationError("module_foundry_admin_required", 403);
|
|
return { actorId };
|
|
}
|
|
|
|
function signedGatewayAdminHeaders(method, pathname, actorId, body, ionReferer) {
|
|
if (!mapGatewayAdminSecret) throw applicationError("map_gateway_admin_not_configured", 503);
|
|
const timestamp = String(Date.now());
|
|
const bodyHash = createHash("sha256").update(body).digest("hex");
|
|
const message = `nodedc.map-gateway.admin.v2\n${method}\n${pathname}\n${timestamp}\n${actorId}\n${bodyHash}\n${ionReferer}`;
|
|
const signature = createHmac("sha256", mapGatewayAdminSecret).update(message).digest("hex");
|
|
return {
|
|
"x-nodedc-admin-timestamp": timestamp,
|
|
"x-nodedc-admin-actor": actorId,
|
|
"x-nodedc-admin-signature": signature,
|
|
...(ionReferer ? { "x-nodedc-ion-referer": ionReferer } : {}),
|
|
};
|
|
}
|
|
|
|
async function requestMapGatewayAdmin({ method, pathname, actorId, payload, ionReferer = "" }) {
|
|
if (!mapGatewayInternalUrl) throw applicationError("map_gateway_not_configured", 503);
|
|
const body = payload === undefined ? "" : JSON.stringify(payload);
|
|
const upstream = await fetch(new URL(pathname, `${mapGatewayInternalUrl}/`), {
|
|
method,
|
|
headers: {
|
|
accept: "application/json",
|
|
...(body ? { "content-type": "application/json" } : {}),
|
|
...signedGatewayAdminHeaders(method, pathname, actorId, body, ionReferer),
|
|
},
|
|
body: body || undefined,
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const result = await upstream.json().catch(() => null);
|
|
if (!upstream.ok || !result?.ok) {
|
|
const status = upstream.status >= 400 && upstream.status < 600 ? upstream.status : 502;
|
|
const safeError = ["map_gateway_admin_not_configured", "map_gateway_admin_unauthorized", "cesium_ion_token_verification_failed"].includes(result?.error)
|
|
? result.error
|
|
: "map_gateway_settings_unavailable";
|
|
throw applicationError(safeError, status);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const serverSockets = new Set();
|
|
const server = createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
|
|
|
|
if (await foundryAgentGateway.handlePublicRequest(request, response, url)) return;
|
|
if (await foundryAgentGateway.handleOntologyMcp(request, response, url)) return;
|
|
|
|
if (url.pathname === "/auth/nodedc/handoff" && request.method === "GET") {
|
|
await foundryAuth.handleLauncherHandoff(request, response, url);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/auth/logout" && request.method === "GET") {
|
|
foundryAuth.handleLogout(request, response);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/healthz" && request.method === "GET") {
|
|
const config = foundryMcpConfig();
|
|
json(response, 200, {
|
|
status: "ok",
|
|
service: "nodedc-module-foundry",
|
|
mcp: {
|
|
configured: Boolean(config.capabilitySecret && config.mcpUrl),
|
|
entitlementConfigured: Boolean(config.internalAccessToken),
|
|
},
|
|
codexAgent: foundryAgentGateway.health(),
|
|
dataProductBindingApi: {
|
|
configured: Boolean(foundryBindingGrantsDir && externalDataPlaneInternalUrl && externalDataPlaneReaderGrantsDir),
|
|
auth: "scoped-workload-grant",
|
|
},
|
|
dataProductConsumerProvisioner: {
|
|
configured: foundryReaderGrantProvisioner.configured,
|
|
auth: "dedicated-ed25519-service-identity",
|
|
sourceScope: "external-data-plane-resolved",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/mcp") {
|
|
await handleFoundryMcpRequest(request, response, {
|
|
getConfig: foundryMcpConfig,
|
|
operations: foundryMcpOperations,
|
|
authenticateAgentToken: foundryAgentGateway.authenticateFoundryToken,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/ai-workspace/entitlements") {
|
|
await handleFoundryEntitlementRequest(request, response, { getConfig: foundryMcpConfig });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === FOUNDRY_BINDING_CATALOG_PATH || url.pathname === FOUNDRY_BINDING_UPSERT_PATH) {
|
|
await handleFoundryBindingApiRequest(request, response, {
|
|
grantsDir: foundryBindingGrantsDir,
|
|
production: process.env.NODE_ENV === "production",
|
|
preflightReaderGrant: preflightFoundryBindingReaderGrant,
|
|
upsertBinding: (input, actor) => foundryMcpOperations.upsertMapDataProductBinding(input, actor),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (await foundryAuth.ensureRequestSession(request, response, url)) {
|
|
return;
|
|
}
|
|
|
|
const foundryUserProfile = foundryAuth.currentUserProfile(request)
|
|
|| (!foundryAuth.authRequired ? {
|
|
id: "local_foundry_user",
|
|
email: "local-foundry@nodedc.local",
|
|
displayName: "Local Foundry User",
|
|
avatarUrl: null,
|
|
initials: "LF",
|
|
} : null);
|
|
if (await foundryAgentGateway.handleManagementRequest(request, response, url, foundryUserProfile)) return;
|
|
|
|
if (url.pathname === "/api/session/profile" && request.method === "GET") {
|
|
const access = foundryAuth.currentUserAccess(request);
|
|
json(response, 200, { user: foundryAuth.currentUserProfile(request), profileUrl: foundryAuth.profileUrl, access: { role: access.role } });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/platform-settings/cesium-ion" && request.method === "GET") {
|
|
const { actorId } = requireFoundryAdmin(request);
|
|
const settings = await requestMapGatewayAdmin({
|
|
method: "GET",
|
|
pathname: "/api/map/admin/cesium-ion",
|
|
actorId,
|
|
ionReferer: foundryRequestIonReferer(request),
|
|
});
|
|
json(response, 200, { configured: settings.configured === true, updatedAt: settings.updatedAt || null, updatedBy: settings.updatedBy || null, verification: settings.verification || "not-configured" });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/platform-settings/cesium-ion" && request.method === "PUT") {
|
|
const { actorId } = requireFoundryAdmin(request);
|
|
const input = await readJsonBody(request, 8 * 1024);
|
|
const token = typeof input?.token === "string" ? input.token.trim() : "";
|
|
if (token.length < 16 || token.length > 4096 || /[\u0000-\u001f\u007f\s]/.test(token)) {
|
|
throw applicationError("invalid_cesium_ion_token");
|
|
}
|
|
const settings = await requestMapGatewayAdmin({
|
|
method: "PUT",
|
|
pathname: "/api/map/admin/cesium-ion",
|
|
actorId,
|
|
payload: { token },
|
|
ionReferer: foundryRequestIonReferer(request),
|
|
});
|
|
json(response, 200, { configured: settings.configured === true, updatedAt: settings.updatedAt || null, updatedBy: settings.updatedBy || null, verification: settings.verification || "not-configured" });
|
|
return;
|
|
}
|
|
|
|
const mapGatewayMatch = url.pathname.match(/^\/api\/map-gateway(\/.*)$/);
|
|
if (mapGatewayMatch && ["GET", "HEAD"].includes(request.method || "")) {
|
|
await proxyMapGateway(request, response, mapGatewayMatch[1]);
|
|
return;
|
|
}
|
|
|
|
const runtimeDataProductMatch = url.pathname.match(/^\/api\/applications\/([0-9a-f-]{36})\/pages\/([a-z0-9-]+)\/data-bindings\/([A-Za-z0-9._:-]{1,160})\/(snapshot|history|stream)$/i);
|
|
if (runtimeDataProductMatch && request.method === "GET") {
|
|
const [, applicationId, pageId, bindingId, resource] = runtimeDataProductMatch;
|
|
const target = await resolveRuntimeMapDataProductBinding(applicationId, pageId, bindingId);
|
|
if (resource === "snapshot") await proxyRuntimeDataProductSnapshot(response, target);
|
|
else if (resource === "history") await proxyRuntimeDataProductHistory(response, url, target);
|
|
else await proxyRuntimeDataProductStream(request, response, url, target);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/layout" && request.method === "GET") {
|
|
try {
|
|
json(response, 200, JSON.parse(await readFile(layoutPath, "utf8")));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return json(response, 200, null);
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/applications" && request.method === "GET") {
|
|
const manifests = [];
|
|
for (const entry of await readdir(applicationsDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
try {
|
|
const manifest = validateApplicationManifest(JSON.parse(await readFile(join(applicationsDir, entry.name), "utf8")));
|
|
manifests.push(applicationSummary(manifest));
|
|
} catch {
|
|
// A damaged draft must not make the complete Foundry project list unavailable.
|
|
}
|
|
}
|
|
manifests.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
|
|
json(response, 200, manifests);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/page-templates" && request.method === "GET") {
|
|
json(response, 200, pageRegistry);
|
|
return;
|
|
}
|
|
|
|
const pageLayoutMatch = url.pathname.match(/^\/api\/page-layouts\/([a-z0-9-]+)$/i);
|
|
if (pageLayoutMatch && request.method === "GET") {
|
|
try {
|
|
json(response, 200, JSON.parse(await readFile(pageLayoutPath(pageLayoutMatch[1]), "utf8")));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return json(response, 200, null);
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
if (pageLayoutMatch && request.method === "PUT") {
|
|
throw applicationError("page_library_read_only", 405);
|
|
}
|
|
|
|
if (url.pathname === "/api/map/runtime-config" && request.method === "GET") {
|
|
// Foundry has no provider credentials. The private Platform Gateway is
|
|
// mandatory for live Cesium resources in every environment.
|
|
const gatewayReady = Boolean(mapGatewayInternalUrl);
|
|
const gaussianAssetId = String(process.env.CESIUM_GAUSSIAN_SPLAT_ASSET_ID || "").trim();
|
|
json(response, 200, {
|
|
cesiumVersion: "1.143.0",
|
|
provider: gatewayReady ? "nodedc-map-gateway" : "gateway-required",
|
|
ionReady: gatewayReady,
|
|
gatewayReady,
|
|
osmBuildingsReady: gatewayReady,
|
|
gaussianSplatsReady: gatewayReady && Boolean(gaussianAssetId),
|
|
gaussianAssetId: gaussianAssetId || null,
|
|
assetEndpointBase: "/api/map-gateway/api/map/ion/assets",
|
|
resourceProxyBase: gatewayReady ? "/api/map-gateway/api/map/cache?url=" : null,
|
|
gatewayHealthUrl: gatewayReady ? "/api/map-gateway/healthz" : null,
|
|
cache: { mode: gatewayReady ? "gateway" : "external-required", persistent: gatewayReady },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/design-profiles" && request.method === "GET") {
|
|
const profiles = [];
|
|
for (const entry of await readdir(designProfilesDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
try {
|
|
const profile = validateDesignProfile(JSON.parse(await readFile(join(designProfilesDir, entry.name), "utf8")));
|
|
profiles.push(designProfileSummary(profile, await readDesignProfileReleases(profile.id)));
|
|
} catch { /* skip damaged draft */ }
|
|
}
|
|
profiles.sort((left, right) => left.name.localeCompare(right.name));
|
|
json(response, 200, profiles);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/design-profiles" && request.method === "POST") {
|
|
const input = await readJsonBody(request);
|
|
const now = new Date().toISOString();
|
|
const profile = validateDesignProfile({
|
|
schemaVersion: "0.1.0",
|
|
id: randomUUID(),
|
|
name: input?.name,
|
|
version: "0.1.0",
|
|
status: "draft",
|
|
layout: input?.layout,
|
|
timestamps: { createdAt: now, updatedAt: now },
|
|
});
|
|
await writeDesignProfile(profile);
|
|
json(response, 201, profile);
|
|
return;
|
|
}
|
|
|
|
const designProfileMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})$/i);
|
|
if (designProfileMatch && request.method === "GET") {
|
|
try { json(response, 200, validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfileMatch[1]), "utf8")))); }
|
|
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
|
return;
|
|
}
|
|
if (designProfileMatch && request.method === "PUT") {
|
|
let current;
|
|
try { current = validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfileMatch[1]), "utf8"))); }
|
|
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
|
const input = await readJsonBody(request);
|
|
const next = validateDesignProfile({
|
|
...current,
|
|
name: input?.name || current.name,
|
|
version: nextPatchVersion(current.version),
|
|
layout: input?.layout,
|
|
timestamps: { createdAt: current.timestamps.createdAt, updatedAt: new Date().toISOString() },
|
|
});
|
|
await writeDesignProfile(next);
|
|
json(response, 200, next);
|
|
return;
|
|
}
|
|
|
|
const designProfileVersionsMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})\/versions$/i);
|
|
if (designProfileVersionsMatch && request.method === "GET") {
|
|
let current;
|
|
try { current = validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfileVersionsMatch[1]), "utf8"))); }
|
|
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
|
const releases = await readDesignProfileReleases(current.id);
|
|
json(response, 200, { id: current.id, draft: designProfileVersionSummary(current), published: releases.map(designProfileVersionSummary) });
|
|
return;
|
|
}
|
|
|
|
const designProfileVersionMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})\/versions\/(\d+\.\d+\.\d+)$/i);
|
|
if (designProfileVersionMatch && request.method === "GET") {
|
|
try { json(response, 200, validateDesignProfile(JSON.parse(await readFile(designProfileReleasePath(designProfileVersionMatch[1], designProfileVersionMatch[2]), "utf8")))); }
|
|
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_version_not_found" }); throw error; }
|
|
return;
|
|
}
|
|
|
|
const designProfilePublishMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})\/publish$/i);
|
|
if (designProfilePublishMatch && request.method === "POST") {
|
|
let current;
|
|
try { current = validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfilePublishMatch[1]), "utf8"))); }
|
|
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
|
const release = await writeDesignProfileRelease(current);
|
|
json(response, 201, release);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/applications" && request.method === "POST") {
|
|
const input = await readJsonBody(request);
|
|
const manifest = validateApplicationManifest(createApplicationManifest(input));
|
|
await assertDesignProfileReference(manifest.designProfile);
|
|
await writeApplicationManifest(manifest);
|
|
json(response, 201, manifest);
|
|
return;
|
|
}
|
|
|
|
const applicationMatch = url.pathname.match(/^\/api\/applications\/([0-9a-f-]{36})$/i);
|
|
if (applicationMatch && request.method === "GET") {
|
|
try {
|
|
const manifest = validateApplicationManifest(JSON.parse(await readFile(applicationPath(applicationMatch[1]), "utf8")));
|
|
json(response, 200, manifest);
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return json(response, 404, { error: "application_not_found" });
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (applicationMatch && request.method === "PUT") {
|
|
const currentPath = applicationPath(applicationMatch[1]);
|
|
let current;
|
|
try {
|
|
current = validateApplicationManifest(JSON.parse(await readFile(currentPath, "utf8")));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return json(response, 404, { error: "application_not_found" });
|
|
throw error;
|
|
}
|
|
const input = await readJsonBody(request);
|
|
if (String(input?.id || "") !== applicationMatch[1]) throw applicationError("application_id_mismatch");
|
|
const next = validateApplicationManifest({
|
|
...input,
|
|
timestamps: {
|
|
createdAt: current.timestamps.createdAt,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
});
|
|
await assertDesignProfileReference(next.designProfile);
|
|
await writeApplicationManifest(next);
|
|
json(response, 200, next);
|
|
return;
|
|
}
|
|
|
|
if (applicationMatch && request.method === "DELETE") {
|
|
const currentPath = applicationPath(applicationMatch[1]);
|
|
try { await stat(currentPath); } catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "application_not_found" }); throw error; }
|
|
const deletedPath = join(runtimeDir, "deleted-applications");
|
|
await mkdir(deletedPath, { recursive: true });
|
|
await rename(currentPath, join(deletedPath, `${applicationMatch[1]}-${Date.now()}.json`));
|
|
json(response, 200, { deleted: true, id: applicationMatch[1] });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/layout" && request.method === "PUT") {
|
|
const layout = await readJsonBody(request);
|
|
const next = { ...layout, savedAt: new Date().toISOString(), schemaVersion: 1 };
|
|
const tempPath = `${layoutPath}.tmp`;
|
|
await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
await rename(tempPath, layoutPath);
|
|
json(response, 200, next);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/layout/media" && request.method === "PUT") {
|
|
const originalName = decodeURIComponent(String(request.headers["x-file-name"] || "stage-video.mp4"));
|
|
const fileName = safeUploadName(originalName, String(request.headers["content-type"] || ""));
|
|
const targetPath = join(uploadDir, fileName);
|
|
await pipeline(request, createWriteStream(targetPath, { flags: "wx" }));
|
|
json(response, 201, { fileName: originalName, url: `/uploads/${fileName}` });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith("/uploads/")) {
|
|
const fileName = basename(normalize(url.pathname.slice("/uploads/".length)));
|
|
await serveFile(request, response, join(uploadDir, fileName));
|
|
return;
|
|
}
|
|
|
|
const relativePath = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\//, "");
|
|
const candidate = join(distDir, normalize(relativePath));
|
|
try {
|
|
await serveFile(request, response, candidate);
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
await serveFile(request, response, join(distDir, "index.html"));
|
|
}
|
|
} catch (error) {
|
|
const statusCode = error?.statusCode || (error?.message === "payload_too_large" ? 413 : 500);
|
|
json(response, statusCode, { error: error?.message || "server_error" });
|
|
}
|
|
});
|
|
|
|
server.on("connection", (socket) => {
|
|
serverSockets.add(socket);
|
|
socket.once("close", () => serverSockets.delete(socket));
|
|
});
|
|
|
|
let shutdownPromise = null;
|
|
function shutdownServer(signal) {
|
|
if (shutdownPromise) return shutdownPromise;
|
|
shutdownPromise = (async () => {
|
|
for (const activeStream of activeRuntimeStreams) {
|
|
activeStream.controller.abort();
|
|
activeStream.response.destroy();
|
|
}
|
|
await dataProductConsumerManager.shutdown();
|
|
await new Promise((resolveShutdown) => {
|
|
const timeout = setTimeout(() => {
|
|
for (const socket of serverSockets) socket.destroy();
|
|
}, 5_000);
|
|
server.close((error) => {
|
|
clearTimeout(timeout);
|
|
if (error) {
|
|
process.exitCode = 1;
|
|
console.error(`NDC Module Foundry shutdown error (${signal}): ${error.message}`);
|
|
}
|
|
resolveShutdown();
|
|
});
|
|
});
|
|
})();
|
|
return shutdownPromise;
|
|
}
|
|
|
|
async function shutdownAndExit(signal) {
|
|
await shutdownServer(signal);
|
|
// A canceled upstream fetch may leave an idle keep-alive socket owned by
|
|
// Node's global HTTP dispatcher. All Foundry state and inbound sockets are
|
|
// already closed at this point, so finish the container stop deterministically.
|
|
process.exit(process.exitCode || 0);
|
|
}
|
|
|
|
process.once("SIGTERM", () => { void shutdownAndExit("SIGTERM"); });
|
|
process.once("SIGINT", () => { void shutdownAndExit("SIGINT"); });
|
|
|
|
server.listen(port, host, () => {
|
|
console.log(`NDC Module Foundry: http://${host}:${port}`);
|
|
console.log(`Layout store: ${layoutPath}`);
|
|
console.log(`Application drafts: ${applicationsDir}`);
|
|
console.log(`Foundry MCP: /api/mcp`);
|
|
console.log(`AI Workspace entitlement adapter: /api/ai-workspace/entitlements`);
|
|
});
|