feat(foundry): close the operational map data loop
This commit is contained in:
+405
-32
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user