feat(foundry): close the operational map data loop

This commit is contained in:
Codex
2026-07-20 20:45:05 +03:00
parent aac44d057f
commit a02c3ff3dd
44 changed files with 4158 additions and 811 deletions
+405 -32
View File
@@ -17,6 +17,7 @@ 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));
@@ -47,6 +48,11 @@ 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,
@@ -315,8 +321,17 @@ function validateMapDataProductBinding(value) {
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");
@@ -326,6 +341,9 @@ function validateMapDataProductBinding(value) {
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");
}
@@ -336,6 +354,9 @@ function validateMapDataProductBinding(value) {
delivery: "snapshot+patch",
semanticTypes: [...semanticTypes],
fieldProjection: [...fieldProjection],
...(displayName ? { displayName } : {}),
...(order !== null ? { order } : {}),
...(presentationProfileId ? { presentationProfileId } : {}),
};
}
@@ -350,12 +371,86 @@ function validateMapDataProductBindings(value) {
});
}
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}`);
}
@@ -373,6 +468,13 @@ function validateMapPageLayout(value) {
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",
@@ -387,10 +489,35 @@ function validateMapPageLayout(value) {
roll: camera.roll,
},
pinBindings: validateMapPinBindings(value.pinBindings === undefined ? [] : value.pinBindings),
dataProductBindings: validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings),
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,
@@ -404,9 +531,9 @@ function defaultMapPageLayout() {
terrainExaggeration: 1,
monochrome: false,
monochromeColor: "#15151b",
imageryGamma: 100,
imageryHue: 0,
imageryAlpha: 100,
imageryGamma: 57,
imageryHue: 13,
imageryAlpha: 27,
globeColor: "#15151b",
backgroundColor: "#08090d",
atmosphereEnabled: false,
@@ -421,11 +548,11 @@ function defaultMapPageLayout() {
shadowsEnabled: true,
buildingsVisible: true,
buildingsColor: "#a27aff",
buildingsOpacity: 0.82,
buildingsDetail: 16,
imageryBrightness: 100,
imageryContrast: 100,
imagerySaturation: 100,
buildingsOpacity: 1,
buildingsDetail: 4,
imageryBrightness: 118,
imageryContrast: 102,
imagerySaturation: 0,
gridVisible: true,
gridLodEnabled: true,
gridHeightMeters: 500,
@@ -453,7 +580,9 @@ function defaultMapPageLayout() {
roll: 0,
},
pinBindings: [],
presentationProfiles: structuredClone(canonicalMapPresentationProfiles),
dataProductBindings: [],
subjectStates: [],
};
}
@@ -540,6 +669,56 @@ function validateDesignProfileLayout(value) {
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,
},
};
}
@@ -614,6 +793,7 @@ function defaultDesignProfileLayout() {
lensCount: 3,
autoHide: true,
},
pageTypes: defaultDesignProfilePageTypes(),
};
}
@@ -722,6 +902,7 @@ function validateApplicationManifest(value) {
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();
@@ -734,18 +915,38 @@ function validateApplicationManifest(value) {
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) validateMapPageLayout(page.layout.map);
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,
@@ -852,12 +1053,16 @@ async function resolveRuntimeMapDataProductBinding(applicationId, pageId, bindin
return { application, page, binding };
}
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId) {
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 });
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.
@@ -899,7 +1104,17 @@ async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindi
async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindingId, dataProductId }) {
let readerToken;
try {
readerToken = await resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId);
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;
@@ -931,11 +1146,16 @@ async function readRuntimeReaderProduct(readerToken, dataProductId) {
return product && product.active !== false && product.deliveryMode === "snapshot+patch" ? product : null;
}
async function inspectFoundryConsumerReaderGrant(target) {
async function inspectFoundryConsumerReaderGrant(target, { generation = 1 } = {}) {
try {
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id);
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" };
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;
}
@@ -943,7 +1163,7 @@ async function inspectFoundryConsumerReaderGrant(target) {
throw applicationError("data_product_reader_grant_not_found", 403);
}
const planned = await foundryReaderGrantProvisioner.plan(target);
return { product: planned.product, readerGrantAction: "ensure" };
return { product: planned.product, readerGrantAction: "ensure", readerGrantGeneration: generation };
}
function runtimePointGeometry(value) {
@@ -1201,7 +1421,8 @@ const dataProductConsumerManager = createFoundryDataProductConsumerManager({
resolveTarget: resolveRuntimeMapDataProductBinding,
readReaderToken: resolveRuntimeDataProductReaderToken,
inspectReaderGrant: inspectFoundryConsumerReaderGrant,
ensureReaderGrant: (target) => foundryReaderGrantProvisioner.ensure(target),
ensureReaderGrant: (target, options) => foundryReaderGrantProvisioner.ensure(target, options),
revokeReaderGrant: (target, options) => foundryReaderGrantProvisioner.revoke(target, options),
resolvePolicy: resolveDataProductConsumerPolicy,
sanitizeSnapshot: sanitizeRuntimeSnapshot,
sanitizePatch: sanitizeRuntimePatch,
@@ -1549,7 +1770,7 @@ const foundryMcpOperations = {
const config = foundryMcpConfig();
return {
module: "NDC Module Foundry",
schemaVersion: "nodedc.module-foundry.mcp.v0.2",
schemaVersion: "nodedc.module-foundry.mcp.v0.5",
status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required",
canonicalPageLibrary: "read-only",
applicationInstances: "read-write",
@@ -1560,6 +1781,10 @@ const foundryMcpOperations = {
"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",
@@ -1663,6 +1888,144 @@ const foundryMcpOperations = {
},
});
},
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",
@@ -1680,17 +2043,36 @@ const foundryMcpOperations = {
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(binding);
dataProductBindings.push(resolvedBinding);
updatedPage = {
...page,
layout: { ...page.layout, map: { ...layout, dataProductBindings, savedAt: new Date().toISOString() } },
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 };
return {
application,
page: updatedPage,
binding: updatedPage.layout.map.dataProductBindings.find((item) => item.id === binding.id),
};
},
});
},
@@ -2181,16 +2563,7 @@ const server = createServer(async (request, response) => {
return;
}
if (pageLayoutMatch && request.method === "PUT") {
const pageId = pageLayoutMatch[1];
if (pageId !== "map") throw applicationError("unsupported_page_layout");
const layout = validateMapPageLayout(await readJsonBody(request));
const next = { ...layout, savedAt: new Date().toISOString() };
const targetPath = pageLayoutPath(pageId);
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
await rename(tempPath, targetPath);
json(response, 200, next);
return;
throw applicationError("page_library_read_only", 405);
}
if (url.pathname === "/api/map/runtime-config" && request.method === "GET") {