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") {
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const foundryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
test("Design Profile owns design-only page fragments while Page Library stays read-only", async () => {
const runtimeDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-design-profile-"));
const port = await freePort();
const foundry = spawn(process.execPath, ["server/catalog-server.mjs"], {
cwd: foundryRoot,
env: {
...process.env,
NODE_ENV: "development",
HOST: "127.0.0.1",
PORT: String(port),
FOUNDRY_RUNTIME_DIR: runtimeDir,
NODEDC_FOUNDRY_AUTH_REQUIRED: "false",
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForService(port, foundry);
const base = `http://127.0.0.1:${port}`;
const profileResponse = await fetch(`${base}/api/design-profiles/default`);
assert.equal(profileResponse.status, 200);
const profile = await profileResponse.json();
const fragment = profile.layout.pageTypes["map@0.1.0"];
assert.equal(fragment.templateId, "map");
assert.equal(fragment.templateVersion, "0.1.0");
assert.equal("camera" in fragment, false);
assert.equal("pinBindings" in fragment, false);
assert.equal("dataProductBindings" in fragment, false);
const pageLibraryMutation = await fetch(`${base}/api/page-layouts/map`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
assert.equal(pageLibraryMutation.status, 405);
assert.equal((await pageLibraryMutation.json()).error, "page_library_read_only");
const forbiddenFragment = await fetch(`${base}/api/design-profiles/default`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: profile.name,
layout: {
...profile.layout,
pageTypes: {
...profile.layout.pageTypes,
"map@0.1.0": { ...fragment, camera: { longitude: 0, latitude: 0 } },
},
},
}),
});
assert.equal(forbiddenFragment.status, 400);
assert.equal((await forbiddenFragment.json()).error, "design_profile_page_fragment_contains_runtime");
const savedProfileResponse = await fetch(`${base}/api/design-profiles/default`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: profile.name,
layout: {
...profile.layout,
pageTypes: {
...profile.layout.pageTypes,
"map@0.1.0": {
...fragment,
settings: { ...fragment.settings, buildingsOpacity: 0.74 },
},
},
},
}),
});
assert.equal(savedProfileResponse.status, 200);
const savedProfile = await savedProfileResponse.json();
assert.equal(savedProfile.version, "0.6.1");
assert.equal(savedProfile.layout.pageTypes["map@0.1.0"].settings.buildingsOpacity, 0.74);
const applicationResponse = await fetch(`${base}/api/applications`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Profile merge test", slug: "profile-merge-test", templateId: "map", templateVersion: "0.1.0" }),
});
assert.equal(applicationResponse.status, 201);
const application = await applicationResponse.json();
application.pages[0].designOverrides = { map: { settings: { buildingsOpacity: 0.5 } } };
const applicationSaveResponse = await fetch(`${base}/api/applications/${application.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(application),
});
assert.equal(applicationSaveResponse.status, 200);
const savedApplication = await applicationSaveResponse.json();
assert.deepEqual(savedApplication.pages[0].designOverrides, { map: { settings: { buildingsOpacity: 0.5 } } });
} finally {
foundry.kill("SIGTERM");
await Promise.race([once(foundry, "exit"), new Promise((resolveWait) => setTimeout(resolveWait, 2_000))]);
if (foundry.exitCode === null) foundry.kill("SIGKILL");
await rm(runtimeDir, { recursive: true, force: true });
}
});
async function freePort() {
const server = createNetServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const port = address.port;
server.close();
await once(server, "close");
return port;
}
async function waitForService(port, child) {
let output = "";
child.stderr.on("data", (chunk) => { output += String(chunk); });
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) throw new Error(`foundry_exited:${child.exitCode}:${output}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
}
throw new Error(`foundry_start_timeout:${output}`);
}
+24
View File
@@ -115,6 +115,30 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor
assert.equal(foundryListResponse.status, 200);
const foundryList = await json(foundryListResponse);
assert.ok(foundryList.result.tools.some((tool) => tool.name === "foundry_create_application"));
const removePinTool = foundryList.result.tools.find((tool) => tool.name === "foundry_remove_map_pin_binding");
assert.deepEqual(removePinTool.inputSchema.required, ["applicationId", "pageId", "bindingId", "idempotencyKey"]);
assert.equal(removePinTool.inputSchema.additionalProperties, false);
const presentationTool = foundryList.result.tools.find((tool) => tool.name === "foundry_upsert_map_presentation_profile");
assert.equal(presentationTool.inputSchema.properties.profile.additionalProperties, false);
assert.ok(presentationTool.inputSchema.properties.profile.required.includes("facets"));
assert.ok(presentationTool.inputSchema.properties.profile.required.includes("target"));
assert.equal(Object.hasOwn(presentationTool.inputSchema.properties.profile.properties, "pin"), false);
assert.equal(presentationTool.inputSchema.properties.profile.properties.label.properties.sizePx.minimum, 8);
assert.equal(Object.hasOwn(presentationTool.inputSchema.properties.profile.properties.label.properties, "fontSizePx"), false);
assert.deepEqual(presentationTool.inputSchema.properties.profile.properties.label.properties.mode.enum, ["subject_id", "attributes", "none"]);
const mapSettingsTool = foundryList.result.tools.find((tool) => tool.name === "foundry_update_map_page_settings");
assert.ok(mapSettingsTool);
assert.equal(mapSettingsTool.inputSchema.properties.settings.additionalProperties, false);
assert.equal(Object.hasOwn(mapSettingsTool.inputSchema.properties.settings.properties, "providerUrl"), false);
const mapViewStateTool = foundryList.result.tools.find((tool) => tool.name === "foundry_save_map_page_view_state");
assert.ok(mapViewStateTool);
const subjectStateSchema = mapViewStateTool.inputSchema.properties.viewState.properties.subjectStates.items;
assert.deepEqual(subjectStateSchema.required, ["bindingId", "visible", "filters", "window"]);
assert.equal(subjectStateSchema.properties.filters.additionalProperties.items.type, "string");
assert.equal(subjectStateSchema.properties.window.properties.rect.additionalProperties, false);
const bindingTool = foundryList.result.tools.find((tool) => tool.name === "foundry_upsert_map_data_product_binding");
assert.equal(bindingTool.inputSchema.properties.binding.properties.displayName.maxLength, 120);
assert.equal(bindingTool.inputSchema.properties.binding.properties.order.maximum, 10000);
const crossTokenResponse = await fetch(redeemed.ontology.mcpUrl, {
method: "POST",
+194 -20
View File
@@ -43,6 +43,19 @@ function safeErrorCode(error) {
}
function safeStatusFromFact(fact, policy, nowMs) {
const statusContract = policy.statusContract || null;
if (statusContract) {
const sourceStatus = fact?.attributes?.[statusContract.attribute];
if (typeof sourceStatus !== "string" || !statusContract.allowedValues.includes(sourceStatus)) {
throw consumerError("data_product_consumer_fact_status_invalid", 502);
}
if (statusContract.freshness === "none" || policy.terminalStatuses.includes(sourceStatus)) {
return sourceStatus;
}
const observedAt = Date.parse(String(fact?.observedAt || ""));
if (Number.isFinite(observedAt) && nowMs - observedAt > policy.staleAfterMs) return "stale";
return sourceStatus;
}
const sourceStatus = [fact?.attributes?.operational_status, fact?.attributes?.status]
.find((value) => typeof value === "string" && value.trim());
const normalized = String(sourceStatus || "active").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, 64) || "active";
@@ -112,20 +125,39 @@ function safeStateSummary(record) {
timestamps: state.timestamps,
lastError: state.lastError || null,
readerGrant: "target-scoped-server-only",
readerGrantGeneration: Number.isSafeInteger(state.readerGrantGeneration) ? state.readerGrantGeneration : 1,
};
}
function activeReaderGrantGeneration(state) {
const generation = Number(state?.readerGrantGeneration ?? 1);
if (!Number.isSafeInteger(generation) || generation < 1 || generation > 2_147_483_647) {
throw consumerError("data_product_reader_grant_generation_invalid", 500);
}
return generation;
}
function validatePolicy(policy, product) {
if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) {
throw consumerError("data_product_consumer_policy_not_found", 409);
}
if (!Number.isInteger(policy.staleAfterMs) || policy.staleAfterMs < 1_000 || policy.staleAfterMs > 7 * 24 * 60 * 60 * 1000) {
const statusContract = policy.statusContract === undefined
? null
: validateStatusContract(policy.statusContract);
const staleDisabled = statusContract?.freshness === "none";
if (
(staleDisabled && policy.staleAfterMs !== null)
|| (!staleDisabled && (!Number.isInteger(policy.staleAfterMs) || policy.staleAfterMs < 1_000 || policy.staleAfterMs > 7 * 24 * 60 * 60 * 1000))
) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
const terminalStatuses = Array.isArray(policy.terminalStatuses) ? policy.terminalStatuses : [];
if (terminalStatuses.some((value) => typeof value !== "string" || !/^[a-z0-9_-]{1,64}$/.test(value))) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
if (statusContract && terminalStatuses.some((value) => !statusContract.allowedValues.includes(value))) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
if (policy.removeMode !== "canonical-tombstone-or-snapshot-rebase") {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
@@ -136,10 +168,39 @@ function validatePolicy(policy, product) {
productVersion: product.version,
staleAfterMs: policy.staleAfterMs,
terminalStatuses: [...terminalStatuses],
...(statusContract ? { statusContract } : {}),
removeMode: policy.removeMode,
};
}
function validateStatusContract(value) {
const keys = value && typeof value === "object" && !Array.isArray(value)
? Object.keys(value).sort()
: [];
if (JSON.stringify(keys) !== JSON.stringify(["allowedValues", "attribute", "freshness", "missing"])) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
const attribute = String(value.attribute || "");
const allowedValues = Array.isArray(value.allowedValues) ? value.allowedValues : [];
if (
!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(attribute)
|| allowedValues.length === 0
|| allowedValues.length > 32
|| new Set(allowedValues).size !== allowedValues.length
|| allowedValues.some((item) => typeof item !== "string" || !/^[a-z0-9_-]{1,64}$/.test(item))
|| value.missing !== "reject"
|| !["none", "observed-at"].includes(value.freshness)
) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
return {
attribute,
allowedValues: [...allowedValues],
missing: "reject",
freshness: value.freshness,
};
}
function parseSseBlock(block) {
const fields = { event: "message", id: "", data: [] };
for (const line of block.split("\n")) {
@@ -174,6 +235,7 @@ export function createFoundryDataProductConsumerManager({
readReaderToken,
inspectReaderGrant,
ensureReaderGrant,
revokeReaderGrant,
resolvePolicy,
sanitizeSnapshot,
sanitizePatch,
@@ -240,16 +302,18 @@ export function createFoundryDataProductConsumerManager({
}
}
async function catalog(target) {
async function catalog(target, { generation = 1, ensureGeneration = generation } = {}) {
if (!dataPlaneUrl) throw consumerError("data_product_runtime_not_configured", 503);
let product = null;
let readerGrantAction = "reuse";
let readerGrantGeneration = generation;
if (inspectReaderGrant) {
const inspection = await inspectReaderGrant(target);
const inspection = await inspectReaderGrant(target, { generation });
product = inspection?.product || null;
readerGrantAction = inspection?.readerGrantAction === "ensure" ? "ensure" : "reuse";
readerGrantGeneration = readerGrantAction === "ensure" ? ensureGeneration : generation;
} else {
const token = await readReaderToken(target.application.id, target.page.id, target.binding.id);
const token = await readReaderToken(target.application.id, target.page.id, target.binding.id, generation);
let response;
try {
response = await fetchImpl(new URL("/internal/data-plane/v1/reader/data-products", `${dataPlaneUrl}/`), {
@@ -273,13 +337,32 @@ export function createFoundryDataProductConsumerManager({
if (target.binding.semanticTypes.some((semanticType) => !product.semanticTypes?.includes(semanticType))) {
throw consumerError("data_product_consumer_semantic_scope_mismatch", 409);
}
return { product, policy: validatePolicy(resolvePolicy(product), product), readerGrantAction };
const policy = validatePolicy(resolvePolicy(product), product);
if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) {
throw consumerError("data_product_consumer_status_field_not_projected", 409);
}
return {
product,
policy,
readerGrantAction,
readerGrantGeneration,
};
}
async function plan(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const { product, policy, readerGrantAction } = await catalog(target);
const record = await recordFor(target);
const currentReaderGrantGeneration = activeReaderGrantGeneration(record.state);
const ensureReaderGrantGeneration = record.state
? Math.min(2_147_483_647, currentReaderGrantGeneration + 1)
: currentReaderGrantGeneration;
const { product, policy, readerGrantAction, readerGrantGeneration } = await catalog(target, {
generation: currentReaderGrantGeneration,
ensureGeneration: ensureReaderGrantGeneration,
});
if (readerGrantAction === "ensure" && record.state && currentReaderGrantGeneration === 2_147_483_647) {
throw consumerError("data_product_reader_grant_generation_exhausted", 409);
}
const safe = safeTarget(target);
const configuration = {
target: safe,
@@ -287,6 +370,7 @@ export function createFoundryDataProductConsumerManager({
policy,
readerGrant: "target-scoped-server-only",
readerGrantAction,
readerGrantGeneration,
};
const existingMatches = record.state
&& JSON.stringify(record.state.target) === JSON.stringify(safe)
@@ -294,7 +378,16 @@ export function createFoundryDataProductConsumerManager({
&& record.state.product?.version === product.version
&& record.state.policy?.id === policy.id
&& record.state.policy?.version === policy.version;
const action = !record.state ? "create" : existingMatches ? (record.state.enabled ? "refresh" : "resume") : "replace";
const predecessorReaderGrantGeneration = Number.isSafeInteger(record.state?.predecessorReaderGrantGeneration)
? record.state.predecessorReaderGrantGeneration
: null;
const action = !record.state
? "create"
: existingMatches && predecessorReaderGrantGeneration
? "finalize-reader-grant-rotation"
: existingMatches
? (record.state.enabled ? "refresh" : "resume")
: "replace";
return {
schemaVersion: PLAN_SCHEMA_VERSION,
planId: planHash({ configuration, action }),
@@ -302,16 +395,19 @@ export function createFoundryDataProductConsumerManager({
configuration,
current: record.state ? { enabled: record.state.enabled === true, cursor: record.state.cursor, product: record.state.product } : null,
effects: [
...(readerGrantAction === "ensure" ? ["ensure-target-scoped-reader-grant"] : []),
...(readerGrantAction === "ensure"
? [record.state ? "ensure-successor-target-scoped-reader-grant" : "ensure-target-scoped-reader-grant"]
: []),
"persist-consumer-state",
"bootstrap-scoped-snapshot",
...(action === "finalize-reader-grant-rotation" ? [] : ["bootstrap-scoped-snapshot"]),
...(predecessorReaderGrantGeneration ? ["revoke-predecessor-reader-grant"] : []),
"share-one-upstream-stream-per-active-binding",
],
destructive: false,
};
}
function initialState(target, product, policy) {
function initialState(target, product, policy, readerGrantGeneration) {
const timestamp = new Date(now()).toISOString();
return {
schemaVersion: STATE_SCHEMA_VERSION,
@@ -321,6 +417,8 @@ export function createFoundryDataProductConsumerManager({
runtimeState: "bootstrapping",
product: { id: product.id, version: product.version },
policy,
readerGrantGeneration,
predecessorReaderGrantGeneration: null,
cursor: "0",
snapshotGeneration: 0,
subjects: {},
@@ -337,7 +435,12 @@ export function createFoundryDataProductConsumerManager({
}
async function fetchSnapshot(record) {
const token = await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId);
const token = await readReaderToken(
record.state.target.applicationId,
record.state.target.pageId,
record.state.target.bindingId,
activeReaderGrantGeneration(record.state),
);
let response;
try {
response = await fetchImpl(upstreamUrl(record, "snapshot"), {
@@ -410,26 +513,77 @@ export function createFoundryDataProductConsumerManager({
const planned = await plan(input);
if (input.planId !== planned.planId) throw consumerError("data_product_consumer_plan_mismatch", 409);
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await recordFor(target);
const plannedGeneration = planned.configuration.readerGrantGeneration;
if (planned.action === "finalize-reader-grant-rotation") {
const predecessorGeneration = record.state?.predecessorReaderGrantGeneration;
if (!revokeReaderGrant || !Number.isSafeInteger(predecessorGeneration)) {
throw consumerError("data_product_reader_grant_rotation_invalid", 500);
}
await revokeReaderGrant(record.target, { generation: predecessorGeneration });
record.state.predecessorReaderGrantGeneration = null;
record.state.timestamps.updatedAt = new Date(now()).toISOString();
record.state.lastError = null;
await writeState(record);
return { plan: planned, consumer: safeStateSummary(record) };
}
if (planned.configuration.readerGrantAction === "ensure") {
if (!ensureReaderGrant) throw consumerError("data_product_reader_grant_not_found", 403);
await ensureReaderGrant(target);
await ensureReaderGrant(target, { generation: plannedGeneration });
}
const record = await recordFor(target);
const { product, policy, readerGrantAction } = await catalog(target);
const { product, policy, readerGrantAction, readerGrantGeneration } = await catalog(target, {
generation: plannedGeneration,
ensureGeneration: plannedGeneration,
});
if (readerGrantAction !== "reuse") throw consumerError("data_product_reader_grant_not_ready", 409);
if (record.stream) stopStream(record);
if (!record.state || planned.action === "replace") record.state = initialState(target, product, policy);
if (readerGrantGeneration !== plannedGeneration) throw consumerError("data_product_reader_grant_generation_mismatch", 409);
const previousTarget = record.target;
const previousState = record.state ? structuredClone(record.state) : null;
const previousGeneration = previousState ? activeReaderGrantGeneration(previousState) : null;
if (record.stream) await stopStreamAndWait(record);
if (!record.state || planned.action === "replace") {
record.target = target;
record.state = initialState(target, product, policy, plannedGeneration);
if (previousGeneration && previousGeneration !== plannedGeneration) {
record.state.predecessorReaderGrantGeneration = previousGeneration;
}
}
else {
record.target = target;
record.state.target = safeTarget(target);
record.state.product = { id: product.id, version: product.version };
record.state.policy = policy;
record.state.readerGrantGeneration = plannedGeneration;
record.state.enabled = true;
record.state.runtimeState = "bootstrapping";
record.state.lastError = null;
}
await writeState(record);
await bootstrap(record, { notify: false });
try {
await writeState(record);
await bootstrap(record, { notify: false });
} catch (error) {
if (previousState) {
record.target = previousTarget;
record.state = previousState;
record.state.runtimeState = record.listeners.size ? "connecting" : "idle";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record);
if (record.listeners.size) startStream(record);
}
throw error;
}
const predecessorGeneration = record.state.predecessorReaderGrantGeneration;
if (Number.isSafeInteger(predecessorGeneration)) {
if (!revokeReaderGrant) throw consumerError("data_product_reader_grant_rotation_not_configured", 503);
await revokeReaderGrant(previousTarget, { generation: predecessorGeneration });
record.state.predecessorReaderGrantGeneration = null;
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record);
}
if (record.listeners.size) startStream(record);
return { plan: planned, consumer: safeStateSummary(record) };
}
@@ -526,7 +680,12 @@ export function createFoundryDataProductConsumerManager({
}
async function consumeOnce(record, signal) {
const token = await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId);
const token = await readReaderToken(
record.state.target.applicationId,
record.state.target.pageId,
record.state.target.bindingId,
activeReaderGrantGeneration(record.state),
);
const url = upstreamUrl(record, "stream", record.state.cursor);
const response = openStream
? await openStream({ url, token, signal })
@@ -625,12 +784,27 @@ export function createFoundryDataProductConsumerManager({
record.stream?.controller.abort();
}
async function stopStreamAndWait(record) {
const pending = record.stream?.promise;
stopStream(record);
if (!pending) return;
await Promise.race([
pending.catch(() => undefined),
new Promise((resolve) => setTimeout(resolve, 750)),
]);
}
async function snapshot(input) {
const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await ensureProvisioned(target);
// A removed target grant revokes browser reads immediately even though the
// last safe snapshot remains persisted for rollback/diagnostics.
await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId);
await readReaderToken(
record.state.target.applicationId,
record.state.target.pageId,
record.state.target.bindingId,
activeReaderGrantGeneration(record.state),
);
await refreshPresentation(record);
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
@@ -334,3 +334,283 @@ test("exact apply can ensure a missing target-scoped reader grant without exposi
await rm(stateDir, { recursive: true, force: true });
}
});
test("Data Product replacement bootstraps a successor reader generation before revoking its predecessor", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-generation-"));
const input = { applicationId: target.application.id, pageId: target.page.id, bindingId: target.binding.id };
let currentTarget = structuredClone(target);
let failV2Snapshot = true;
let failPredecessorRevoke = true;
const grants = new Map([[1, "fleet.positions.current.v1"]]);
const events = [];
const product = (id) => ({
id,
version: id.endsWith(".v2") ? "2.0.0" : "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
});
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(currentTarget),
readReaderToken: async (_applicationId, _pageId, _bindingId, generation = 1) => {
return grants.has(generation) ? `ndc_edprb_generation-${generation}` : null;
},
inspectReaderGrant: async (resolvedTarget, { generation = 1 } = {}) => {
const desiredProductId = resolvedTarget.binding.dataProductId;
return {
product: product(desiredProductId),
readerGrantAction: grants.get(generation) === desiredProductId ? "reuse" : "ensure",
readerGrantGeneration: generation,
};
},
ensureReaderGrant: async (resolvedTarget, { generation }) => {
grants.set(generation, resolvedTarget.binding.dataProductId);
events.push(`ensure-g${generation}`);
return { ensured: true, generation };
},
revokeReaderGrant: async (_resolvedTarget, { generation }) => {
if (generation === 1 && failPredecessorRevoke) {
failPredecessorRevoke = false;
events.push("revoke-g1-failed");
throw Object.assign(new Error("foundry_reader_grant_provisioner_unavailable"), { statusCode: 503 });
}
grants.delete(generation);
events.push(`revoke-g${generation}`);
return { revoked: true, generation };
},
resolvePolicy: (resolvedProduct) => ({
id: resolvedProduct.id.endsWith(".v2") ? "map-moving-object-current-v2" : "map-moving-object-current-v1",
version: resolvedProduct.version,
dataProductId: resolvedProduct.id,
productVersion: resolvedProduct.version,
staleAfterMs: 60_000,
terminalStatuses: ["inactive", "no-position", "no_position"],
removeMode: "canonical-tombstone-or-snapshot-rebase",
}),
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async (request, options = {}) => {
const dataProductId = decodeURIComponent(new URL(request).pathname.split("/").at(-2));
const generation = Number(String(options.headers.authorization).match(/generation-(\d+)/)?.[1]);
assert.equal(grants.get(generation), dataProductId);
events.push(`snapshot-${dataProductId}-g${generation}`);
if (dataProductId.endsWith(".v2") && failV2Snapshot) {
throw Object.assign(new Error("data_product_runtime_unavailable"), { statusCode: 503 });
}
return Response.json({
...snapshot(dataProductId.endsWith(".v2") ? "20" : "10"),
dataProduct: { id: dataProductId, version: dataProductId.endsWith(".v2") ? "2.0.0" : "1.0.0" },
});
},
idleStopMs: 10,
staleSweepMs: 10_000,
});
try {
const createPlan = await manager.plan(input);
assert.equal(createPlan.configuration.readerGrantGeneration, 1);
const created = await manager.apply({ ...input, planId: createPlan.planId });
assert.equal(created.consumer.product.id, "fleet.positions.current.v1");
assert.equal(created.consumer.readerGrantGeneration, 1);
currentTarget.binding.dataProductId = "fleet.positions.current.v2";
currentTarget.binding.fieldProjection = [
"display_name", "availability_state", "motion_state", "position_state", "freshness_state",
];
const replacePlan = await manager.plan(input);
assert.equal(replacePlan.action, "replace");
assert.equal(replacePlan.configuration.readerGrantAction, "ensure");
assert.equal(replacePlan.configuration.readerGrantGeneration, 2);
assert.ok(replacePlan.effects.includes("ensure-successor-target-scoped-reader-grant"));
await assert.rejects(
manager.apply({ ...input, planId: replacePlan.planId }),
/data_product_runtime_unavailable/,
);
const preserved = await manager.status(input);
assert.equal(preserved.consumer.product.id, "fleet.positions.current.v1");
assert.equal(preserved.consumer.readerGrantGeneration, 1);
assert.equal(grants.get(1), "fleet.positions.current.v1");
assert.equal(grants.get(2), "fleet.positions.current.v2");
assert.equal(events.includes("revoke-g1"), false);
failV2Snapshot = false;
const retryPlan = await manager.plan(input);
assert.equal(retryPlan.configuration.readerGrantGeneration, 2);
await assert.rejects(
manager.apply({ ...input, planId: retryPlan.planId }),
/foundry_reader_grant_provisioner_unavailable/,
);
const pendingFinalization = await manager.status(input);
assert.equal(pendingFinalization.consumer.product.id, "fleet.positions.current.v2");
assert.equal(pendingFinalization.consumer.readerGrantGeneration, 2);
assert.equal(grants.has(1), true);
const finalizePlan = await manager.plan(input);
assert.equal(finalizePlan.action, "finalize-reader-grant-rotation");
assert.equal(finalizePlan.effects.includes("bootstrap-scoped-snapshot"), false);
assert.ok(finalizePlan.effects.includes("revoke-predecessor-reader-grant"));
const replaced = await manager.apply({ ...input, planId: finalizePlan.planId });
assert.equal(replaced.consumer.product.id, "fleet.positions.current.v2");
assert.equal(replaced.consumer.readerGrantGeneration, 2);
assert.equal(grants.has(1), false);
assert.equal(grants.get(2), "fleet.positions.current.v2");
assert.ok(events.lastIndexOf("snapshot-fleet.positions.current.v2-g2") < events.indexOf("revoke-g1"));
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
const movingObjectV4Policy = {
id: "map-moving-object-current-v4",
version: "4.0.0",
dataProductId: "fleet.positions.current.v4",
productVersion: "4.0.0",
staleAfterMs: null,
terminalStatuses: ["inactive"],
statusContract: {
attribute: "signal_state",
allowedValues: ["active", "inactive"],
missing: "reject",
freshness: "none",
},
removeMode: "canonical-tombstone-or-snapshot-rebase",
};
const movingObjectV4Product = {
id: "fleet.positions.current.v4",
version: "4.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
};
function movingObjectV4Target(fieldProjection = ["display_name", "signal_state", "movement_state", "speed_kph"]) {
return {
application: { id: "44444444-4444-4444-8444-444444444444" },
page: { id: "map" },
binding: {
id: "fleet-current-v4",
dataProductId: movingObjectV4Product.id,
slotId: "points",
delivery: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fieldProjection,
},
};
}
function movingObjectV4Snapshot(signalState = "active") {
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: { id: movingObjectV4Product.id, version: movingObjectV4Product.version },
generatedAt: "2026-07-20T12:00:01.000Z",
cursor: "40",
facts: [{
sourceId: "gelios-unit-001",
semanticType: "map.moving_object",
observedAt: "2026-01-01T00:00:00.000Z",
receivedAt: "2026-07-20T12:00:00.000Z",
attributes: {
display_name: "Unit 001",
signal_state: signalState,
movement_state: "stopped",
speed_kph: 0,
},
geometry: { type: "Point", coordinates: [37.61, 55.75] },
}],
};
}
test("v4 consumer preserves the closed signal and movement states without inventing freshness", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-v4-"));
const currentTarget = movingObjectV4Target();
let signalState = "active";
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(currentTarget),
readReaderToken: async () => "ndc_edprb_v4-reader-capability",
inspectReaderGrant: async () => ({ product: movingObjectV4Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
resolvePolicy: () => movingObjectV4Policy,
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async (request, options = {}) => {
assert.equal(options.headers.authorization, "Bearer ndc_edprb_v4-reader-capability");
assert.ok(new URL(request).pathname.endsWith("/snapshot"));
return Response.json(movingObjectV4Snapshot(signalState));
},
now: () => Date.parse("2026-07-20T12:00:10.000Z"),
});
try {
const input = { applicationId: currentTarget.application.id, pageId: currentTarget.page.id, bindingId: currentTarget.binding.id };
const plan = await manager.plan(input);
assert.deepEqual(plan.configuration.policy.statusContract.allowedValues, ["active", "inactive"]);
assert.equal(plan.configuration.policy.staleAfterMs, null);
const active = await manager.apply({ ...input, planId: plan.planId });
assert.equal(active.consumer.subjects[0].status, "active");
assert.equal(active.consumer.metrics.staleTransitions, 0);
assert.equal((await manager.snapshot(currentTarget)).facts[0].attributes.movement_state, "stopped");
signalState = "inactive";
const refreshPlan = await manager.plan(input);
const inactive = await manager.apply({ ...input, planId: refreshPlan.planId });
assert.equal(inactive.consumer.subjects[0].status, "inactive");
assert.equal(inactive.consumer.metrics.staleTransitions, 0);
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
test("v4 consumer rejects bindings that omit signal_state and snapshots outside the closed contract", async () => {
const omittedStateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-v4-omitted-"));
const omittedTarget = movingObjectV4Target(["display_name", "movement_state"]);
const omittedManager = createFoundryDataProductConsumerManager({
stateDir: omittedStateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(omittedTarget),
readReaderToken: async () => "ndc_edprb_v4-reader-capability",
inspectReaderGrant: async () => ({ product: movingObjectV4Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
resolvePolicy: () => movingObjectV4Policy,
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
});
try {
await assert.rejects(
omittedManager.plan({ applicationId: omittedTarget.application.id, pageId: omittedTarget.page.id, bindingId: omittedTarget.binding.id }),
/data_product_consumer_status_field_not_projected/,
);
} finally {
await omittedManager.shutdown();
await rm(omittedStateDir, { recursive: true, force: true });
}
const invalidStateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-v4-invalid-"));
const invalidTarget = movingObjectV4Target();
const invalidManager = createFoundryDataProductConsumerManager({
stateDir: invalidStateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(invalidTarget),
readReaderToken: async () => "ndc_edprb_v4-reader-capability",
inspectReaderGrant: async () => ({ product: movingObjectV4Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
resolvePolicy: () => movingObjectV4Policy,
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async () => Response.json(movingObjectV4Snapshot("unknown")),
});
try {
const input = { applicationId: invalidTarget.application.id, pageId: invalidTarget.page.id, bindingId: invalidTarget.binding.id };
const plan = await invalidManager.plan(input);
await assert.rejects(
invalidManager.apply({ ...input, planId: plan.planId }),
/data_product_consumer_fact_status_invalid/,
);
const failed = await invalidManager.status(input);
assert.equal(failed.consumer.runtimeState, "error");
assert.equal(failed.consumer.subjectCount, 0);
} finally {
await invalidManager.shutdown();
await rm(invalidStateDir, { recursive: true, force: true });
}
});
+358 -3
View File
@@ -122,6 +122,261 @@ function normalizeMcpError(error) {
return { code: "foundry_operation_failed", status: 500 };
}
const mapPresentationProfileInputSchema = {
type: "object",
additionalProperties: false,
required: [
"id", "version", "title", "semanticTypes", "label", "target", "facets",
"styles", "classes", "defaultClassId", "sort",
],
properties: {
id: { type: "string", description: "Stable provider-neutral map.style_profile id." },
version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
title: { type: "string" },
semanticTypes: {
type: "array",
minItems: 1,
maxItems: 8,
uniqueItems: true,
items: { type: "string" },
},
label: {
type: "object",
additionalProperties: false,
required: [
"mode", "fields", "fontWeight", "sizePx", "color", "outlineColor",
"outlineWidthPx", "backgroundColor", "backgroundOpacity", "paddingX", "paddingY",
"maxLength", "offsetX", "offsetY", "hideCameraHeightMeters",
],
properties: {
mode: { type: "string", enum: ["subject_id", "attributes", "none"] },
fields: {
type: "array",
minItems: 1,
maxItems: 16,
uniqueItems: true,
description: "Provider-neutral fact attributes in fallback order, for example display_name, label, name, title.",
items: { type: "string" },
},
fontWeight: { type: "integer", minimum: 400, maximum: 700 },
sizePx: { type: "number", minimum: 8, maximum: 32 },
color: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
outlineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
outlineWidthPx: { type: "number", minimum: 0, maximum: 6 },
backgroundColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
backgroundOpacity: { type: "number", minimum: 0, maximum: 1 },
paddingX: { type: "number", minimum: 0, maximum: 40 },
paddingY: { type: "number", minimum: 0, maximum: 40 },
maxLength: { type: "integer", minimum: 8, maximum: 240 },
offsetX: { type: "number", minimum: -100, maximum: 100 },
offsetY: { type: "number", minimum: -100, maximum: 100 },
hideCameraHeightMeters: { type: "number", minimum: 1, maximum: 100000000 },
},
},
target: {
type: "object",
additionalProperties: false,
required: [
"variant", "stemHeightMeters", "headSizePx", "stemWidthPx", "outlineColor",
"outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters",
],
properties: {
variant: {
type: "string",
enum: ["elevated-spike"],
description: "Renderer-neutral presentation preset interpreted by the active Map adapter.",
},
stemHeightMeters: { type: "number", minimum: 1, maximum: 100000 },
headSizePx: { type: "number", minimum: 1, maximum: 64 },
stemWidthPx: { type: "number", minimum: 0.25, maximum: 16 },
outlineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
outlineOpacity: { type: "number", minimum: 0, maximum: 1 },
outlineWidthPx: { type: "number", minimum: 0, maximum: 8 },
hideCameraHeightMeters: { type: "number", minimum: 1, maximum: 100000000 },
},
},
facets: {
type: "array",
minItems: 1,
maxItems: 16,
items: {
type: "object",
additionalProperties: false,
required: ["id", "field", "label", "filterable", "counter", "values"],
properties: {
id: { type: "string" },
field: { type: "string", description: "Normalized provider-neutral Data Product field." },
label: { type: "string" },
filterable: { type: "boolean" },
counter: { type: "boolean" },
values: {
type: "array",
minItems: 1,
maxItems: 32,
items: {
type: "object",
additionalProperties: false,
required: ["value", "label", "order"],
properties: {
value: { type: "string" },
label: { type: "string" },
order: { type: "integer", minimum: 0, maximum: 1000 },
},
},
},
},
},
},
styles: {
type: "array",
minItems: 1,
maxItems: 32,
items: {
type: "object",
additionalProperties: false,
required: ["id", "color", "opacity"],
properties: {
id: { type: "string" },
color: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
opacity: { type: "number", minimum: 0, maximum: 1 },
},
},
},
classes: {
type: "array",
minItems: 1,
maxItems: 64,
items: {
type: "object",
additionalProperties: false,
required: ["id", "label", "priority", "match", "styleId", "renderable"],
properties: {
id: { type: "string" },
label: { type: "string" },
priority: { type: "integer", minimum: -10000, maximum: 10000 },
match: {
type: "array",
maxItems: 8,
description: "All conditions use declared normalized facet fields; an empty list is the fallback class.",
items: {
type: "object",
additionalProperties: false,
required: ["field", "equals"],
properties: { field: { type: "string" }, equals: { type: "string" } },
},
},
styleId: { type: "string" },
renderable: { type: "boolean" },
},
},
},
defaultClassId: { type: "string" },
sort: {
type: "array",
maxItems: 16,
items: {
type: "object",
additionalProperties: false,
required: ["field", "order"],
properties: {
field: { type: "string" },
order: { type: "array", minItems: 1, uniqueItems: true, items: { type: "string" } },
},
},
},
},
};
const mapPageSettingsPatchInputSchema = {
type: "object",
additionalProperties: false,
minProperties: 1,
properties: {
imagerySource: { type: "string" },
imageryVisible: { type: "boolean" },
cacheEnabled: { type: "boolean" },
cacheNoOverwrite: { type: "boolean" },
terrainEnabled: { type: "boolean" },
terrainExaggeration: { type: "number" },
monochrome: { type: "boolean" },
monochromeColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
imageryGamma: { type: "number" },
imageryHue: { type: "number" },
imageryAlpha: { type: "number" },
globeColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
backgroundColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
atmosphereEnabled: { type: "boolean" },
atmosphereHue: { type: "number" },
atmosphereSaturation: { type: "number" },
atmosphereBrightness: { type: "number" },
fogEnabled: { type: "boolean" },
fogDensity: { type: "number" },
sunEnabled: { type: "boolean" },
sunHour: { type: "number" },
sunIntensity: { type: "number" },
shadowsEnabled: { type: "boolean" },
buildingsVisible: { type: "boolean" },
buildingsColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
buildingsOpacity: { type: "number" },
buildingsDetail: { type: "number" },
imageryBrightness: { type: "number" },
imageryContrast: { type: "number" },
imagerySaturation: { type: "number" },
gridVisible: { type: "boolean" },
gridLodEnabled: { type: "boolean" },
gridHeightMeters: { type: "number" },
gridLod1MaxHeightKm: { type: "number" },
gridLod1StepKm: { type: "number" },
gridLod2MaxHeightKm: { type: "number" },
gridLod2StepKm: { type: "number" },
gridLod3StepKm: { type: "number" },
gridRadiusKm: { type: "number" },
gridLineWidth: { type: "number" },
gridColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
gridOpacity: { type: "number" },
gridDotsEnabled: { type: "boolean" },
gridDotsSize: { type: "number" },
gridDotsColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
gridDotsOpacity: { type: "number" },
},
};
const mapSubjectStateInputSchema = {
type: "object",
additionalProperties: false,
required: ["bindingId", "visible", "filters", "window"],
properties: {
bindingId: { type: "string", description: "Stable Map Data Product binding id. Editable labels are never state keys." },
visible: { type: "boolean", description: "False is an explicit empty map state and must not be normalized to all." },
filters: {
type: "object",
description: "Facet selections keyed by normalized field. Missing field is unconstrained; an empty array matches nothing.",
additionalProperties: { type: "array", uniqueItems: true, items: { type: "string" } },
},
window: {
type: "object",
additionalProperties: false,
required: ["open", "rect", "maximized", "zIndex"],
properties: {
open: { type: "boolean" },
rect: {
type: "object",
additionalProperties: false,
required: ["x", "y", "width", "height"],
properties: {
x: { type: "number" },
y: { type: "number" },
width: { type: "number", minimum: 200 },
height: { type: "number", minimum: 160 },
},
},
maximized: { type: "boolean" },
zIndex: { type: "integer", minimum: 1 },
},
},
},
};
const tools = [
{
name: "foundry_status",
@@ -138,7 +393,7 @@ const tools = [
{
name: "foundry_get_application",
title: "Get application instance",
description: "Read one editable application instance, its page instances, features and map pin bindings.",
description: "Read one editable application instance, its page instances, features, map presentation profiles and bindings.",
inputSchema: {
type: "object",
additionalProperties: false,
@@ -245,6 +500,95 @@ const tools = [
},
},
},
{
name: "foundry_remove_map_pin_binding",
title: "Remove map pin binding",
description: "Remove one obsolete visual map-pin binding from a Map Page instance. The operation does not affect data-product consumers or the canonical Page Library.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the application." },
bindingId: { type: "string", description: "Stable id of the obsolete visual map-pin binding." },
idempotencyKey: { type: "string" },
},
},
},
{
name: "foundry_upsert_map_presentation_profile",
title: "Upsert Map presentation profile",
description: "Create or update a versioned provider-neutral Map presentation profile. The profile owns target geometry, state classes, filters, counters, sort order and labels; it contains no provider transport or credentials.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "profile"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the application." },
idempotencyKey: { type: "string" },
profile: {
...mapPresentationProfileInputSchema,
description: "Versioned provider-neutral map.style_profile. Classification uses declared normalized facets, never provider-specific raw status values.",
},
},
},
},
{
name: "foundry_update_map_page_settings",
title: "Update Application Map settings",
description: "Update visual environment settings on one Map page instance inside one Application. This operation never modifies the canonical Page Library template.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "settings"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map page instance id inside the editable Application." },
idempotencyKey: { type: "string" },
settings: mapPageSettingsPatchInputSchema,
},
},
},
{
name: "foundry_save_map_page_view_state",
title: "Save Application Map view state",
description: "Persist one complete current Map view through the Application Save path: camera, map geometry, base settings, exact subject visibility/facets and every binding window geometry/z-order. Stable identity is bindingId.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "viewState"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the editable Application." },
idempotencyKey: { type: "string" },
viewState: {
type: "object",
additionalProperties: false,
required: ["mapHeight", "camera", "subjectStates"],
properties: {
settings: mapPageSettingsPatchInputSchema,
mapHeight: { type: "integer", minimum: 360, maximum: 5000 },
camera: {
type: "object",
additionalProperties: false,
required: ["longitude", "latitude", "height", "heading", "pitch", "roll"],
properties: {
longitude: { type: "number" },
latitude: { type: "number" },
height: { type: "number" },
heading: { type: "number" },
pitch: { type: "number" },
roll: { type: "number" },
},
},
subjectStates: { type: "array", maxItems: 64, items: mapSubjectStateInputSchema },
},
},
},
},
},
{
name: "foundry_upsert_map_data_product_binding",
title: "Upsert Map data product binding",
@@ -262,10 +606,13 @@ const tools = [
required: ["id", "dataProductId", "slotId", "semanticTypes"],
properties: {
id: { type: "string" },
displayName: { type: "string", minLength: 1, maxLength: 120, description: "User-defined text label shown in the Objects menu and window header." },
order: { type: "integer", minimum: 0, maximum: 10000, description: "Application composition order inside the Objects menu." },
dataProductId: { type: "string", description: "Versioned provider-neutral product, for example fleet.positions.current.v1." },
slotId: { type: "string", description: "Approved Map Page entity-stream slot." },
semanticTypes: { type: "array", items: { type: "string" } },
fieldProjection: { type: "array", items: { type: "string" } },
presentationProfileId: { type: "string", description: "Existing page-owned provider-neutral Map presentation profile id." },
},
},
},
@@ -363,6 +710,10 @@ function toolMap(operations) {
foundry_update_application_metadata: (input, actor) => operations.updateApplicationMetadata(input, actor),
foundry_add_page_instance: (input, actor) => operations.addPageInstance(input, actor),
foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor),
foundry_remove_map_pin_binding: (input, actor) => operations.removeMapPinBinding(input, actor),
foundry_upsert_map_presentation_profile: (input, actor) => operations.upsertMapPresentationProfile(input, actor),
foundry_update_map_page_settings: (input, actor) => operations.updateMapPageSettings(input, actor),
foundry_save_map_page_view_state: (input, actor) => operations.saveMapPageViewState(input, actor),
foundry_upsert_map_data_product_binding: (input, actor) => operations.upsertMapDataProductBinding(input, actor),
foundry_plan_map_data_product_consumer: (input) => operations.planMapDataProductConsumer(input),
foundry_apply_map_data_product_consumer: (input, actor) => operations.applyMapDataProductConsumer(input, actor),
@@ -405,8 +756,8 @@ export async function handleFoundryMcpRequest(request, response, options) {
return sendJson(response, 200, mcpResult(id, {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "nodedc_module_foundry", version: "0.2.0" },
instructions: "NDC Module Foundry edits application instances and controls approved server-owned data-product consumers. Page Library is read-only. Application deletion is unavailable. Provider endpoints and credentials are never MCP inputs or outputs.",
serverInfo: { name: "nodedc_module_foundry", version: "0.5.0" },
instructions: "NDC Module Foundry edits application instances, provider-neutral map presentation profiles and approved server-owned data-product consumers. Page Library is read-only. Application deletion is unavailable. Provider endpoints and credentials are never MCP inputs or outputs.",
}));
}
if (message.method === "notifications/initialized") return sendJson(response, 202, {});
@@ -477,6 +828,10 @@ export async function handleFoundryEntitlementRequest(request, response, options
"foundry.application.update",
"foundry.page-instance.create",
"foundry.map-pin.upsert",
"foundry.map-pin.remove",
"foundry.map-presentation-profile.upsert",
"foundry.map-page-settings.update",
"foundry.map-page-view-state.save",
"foundry.map-data-product.upsert",
"foundry.map-data-product-consumer.read",
"foundry.map-data-product-consumer.lifecycle",
+42 -8
View File
@@ -38,6 +38,21 @@ function targetDigest(target) {
.digest("hex");
}
function grantGeneration(value) {
const generation = Number(value ?? 1);
if (!Number.isSafeInteger(generation) || generation < 1 || generation > 2_147_483_647) {
throw provisionerError("foundry_reader_grant_generation_invalid", 400);
}
return generation;
}
function readerTokenPath(tokenRoot, digest, generation) {
// Generation one keeps the deployed path for a zero-copy migration. Every
// successor gets a separate immutable capability file so a new snapshot can
// be accepted before the predecessor is revoked.
return join(resolve(tokenRoot), generation === 1 ? digest : `${digest}.g${generation}`);
}
function signingPayload({ audience, serviceId, keyId, method, path, timestamp, nonce, bodySha256 }) {
return JSON.stringify({
schemaVersion: SIGNATURE_SCHEMA,
@@ -137,10 +152,11 @@ export function createFoundryReaderGrantProvisioner({
return { product, sourceScope: "resolved-server-side" };
}
async function ensure(target) {
async function ensure(target, options = {}) {
const identity = targetIdentity(target);
const digest = targetDigest(identity);
const token = await ensureReaderToken(join(resolve(tokenRoot), digest), {
const generation = grantGeneration(options.generation);
const token = await ensureReaderToken(readerTokenPath(tokenRoot, digest, generation), {
production,
randomBytesImpl,
});
@@ -151,26 +167,44 @@ export function createFoundryReaderGrantProvisioner({
{
allowedDataProductIds: [identity.dataProductId],
expiresAt: null,
generation: 1,
generation,
capabilityDigest: createHash("sha256").update(token, "utf8").digest("hex"),
},
);
const binding = payload?.readerBinding;
if (binding?.bindingKey !== bindingKey || binding?.generation !== 1 || binding?.active !== true
if (binding?.bindingKey !== bindingKey || binding?.generation !== generation || binding?.active !== true
|| binding?.expiresAt !== null || binding?.sourceScope !== "resolved-server-side"
|| !Array.isArray(binding?.allowedDataProductIds)
|| !binding.allowedDataProductIds.includes(identity.dataProductId)) {
throw provisionerError("foundry_reader_grant_ensure_response_invalid", 502);
}
return { ensured: true, idempotent: payload?.idempotent === true, generation: 1, sourceScope: "resolved-server-side" };
return { ensured: true, idempotent: payload?.idempotent === true, generation, sourceScope: "resolved-server-side" };
}
async function readToken(target) {
async function revoke(target, options = {}) {
const digest = targetDigest(target);
const generation = grantGeneration(options.generation);
const bindingKey = `fndrc-${digest}`;
const payload = await signedRequest(
"POST",
`/internal/data-plane/v1/consumer-reader-bindings/by-key/${encodeURIComponent(bindingKey)}/generations/${generation}/revoke`,
{},
);
const binding = payload?.readerBinding;
if (binding?.bindingKey !== bindingKey || binding?.generation !== generation || binding?.active !== false
|| binding?.sourceScope !== "resolved-server-side") {
throw provisionerError("foundry_reader_grant_revoke_response_invalid", 502);
}
return { revoked: true, idempotent: payload?.idempotent === true, generation, sourceScope: "resolved-server-side" };
}
async function readToken(target, options = {}) {
if (!tokenRoot) return null;
return readReaderToken(join(resolve(tokenRoot), targetDigest(target)), { production, missing: null });
const generation = grantGeneration(options.generation);
return readReaderToken(readerTokenPath(tokenRoot, targetDigest(target), generation), { production, missing: null });
}
return { configured, plan, ensure, readToken };
return { configured, plan, ensure, revoke, readToken };
}
async function readPrivateKeySecurely(path, { production }) {
@@ -125,3 +125,94 @@ test("managed Foundry grant provisioning signs digest-only requests and persists
await rm(root, { recursive: true, force: true });
}
});
test("managed Foundry grant provisioning keeps successor capabilities separate and revokes an exact predecessor generation", async () => {
const root = await mkdtemp(join(tmpdir(), "foundry-reader-grant-generation-"));
const privateKeyFile = join(root, "private-key.pem");
const grantsDir = join(root, "grants");
const { privateKey } = generateKeyPairSync("ed25519");
await writeFile(privateKeyFile, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o400 });
await chmod(privateKeyFile, 0o400);
const requests = [];
const fetchImpl = async (input, options) => {
const url = new URL(input);
const parsed = JSON.parse(String(options.body));
requests.push({ method: options.method, path: url.pathname, body: parsed });
const bindingKey = url.pathname.match(/by-key\/([^/]+)/)?.[1];
if (url.pathname.endsWith("/plan")) {
const id = parsed.allowedDataProductIds[0];
return Response.json({
ok: true,
sourceScope: "resolved-server-side",
dataProducts: [{
id,
version: id.endsWith(".v2") ? "2.0.0" : "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
}],
});
}
if (url.pathname.endsWith("/revoke")) {
const generation = Number(url.pathname.split("/").at(-2));
return Response.json({
ok: true,
idempotent: false,
readerBinding: {
bindingKey: decodeURIComponent(bindingKey),
generation,
active: false,
expiresAt: null,
sourceScope: "resolved-server-side",
allowedDataProductIds: ["fleet.positions.current.v1"],
},
});
}
return Response.json({
ok: true,
idempotent: false,
readerBinding: {
bindingKey: decodeURIComponent(bindingKey),
generation: parsed.generation,
active: true,
expiresAt: null,
sourceScope: "resolved-server-side",
allowedDataProductIds: parsed.allowedDataProductIds,
},
});
};
const provisioner = createFoundryReaderGrantProvisioner({
dataPlaneUrl: "http://edp.test",
privateKeyFile,
grantsDir,
serviceId,
keyId,
audience,
fetchImpl,
randomBytesImpl: (size) => Buffer.alloc(size, requests.length + 1),
randomUUIDImpl: () => "33333333-3333-4333-8333-333333333333",
production: false,
});
const successorTarget = structuredClone(target);
successorTarget.binding.dataProductId = "fleet.positions.current.v2";
try {
const first = await provisioner.ensure(target, { generation: 1 });
const second = await provisioner.ensure(successorTarget, { generation: 2 });
assert.equal(first.generation, 1);
assert.equal(second.generation, 2);
const firstToken = await provisioner.readToken(target, { generation: 1 });
const secondToken = await provisioner.readToken(successorTarget, { generation: 2 });
assert.notEqual(firstToken, secondToken);
const files = (await readdir(grantsDir)).sort();
const baseFile = files.find((entry) => !entry.includes(".g"));
assert.ok(baseFile);
assert.deepEqual(files, [baseFile, `${baseFile}.g2`]);
const revoked = await provisioner.revoke(target, { generation: 1 });
assert.equal(revoked.generation, 1);
assert.ok(requests.some((request) => request.method === "POST" && request.path.endsWith("/generations/1/revoke")));
assert.equal(requests.some((request) => JSON.stringify(request.body).includes(firstToken)), false);
assert.equal(requests.some((request) => JSON.stringify(request.body).includes(secondToken)), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+275
View File
@@ -0,0 +1,275 @@
const IDENTIFIER = /^[a-z][a-z0-9._:-]{1,159}$/;
const FIELD = /^[a-z][a-z0-9_.-]{0,127}$/;
const SEMVER = /^\d+\.\d+\.\d+$/;
const PROFILE_KEYS = new Set([
"id", "version", "title", "semanticTypes", "label", "target", "pin", "facets",
"styles", "classes", "defaultClassId", "sort",
]);
const LABEL_KEYS = new Set([
"mode", "fields", "fontWeight", "sizePx", "color", "outlineColor", "outlineWidthPx",
"backgroundColor", "backgroundOpacity", "paddingX", "paddingY", "maxLength",
"offsetX", "offsetY", "hideCameraHeightMeters",
]);
const TARGET_KEYS = new Set([
"variant", "stemHeightMeters", "headSizePx", "stemWidthPx", "outlineColor",
"outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters",
]);
const FACET_KEYS = new Set(["id", "field", "label", "filterable", "counter", "values"]);
const FACET_VALUE_KEYS = new Set(["value", "label", "order"]);
const STYLE_KEYS = new Set(["id", "color", "opacity"]);
const CLASS_KEYS = new Set(["id", "label", "priority", "match", "styleId", "renderable"]);
const MATCH_KEYS = new Set(["field", "equals"]);
const SORT_KEYS = new Set(["field", "order"]);
export function normalizeMapPresentationProfile(value) {
object(value, "invalid_map_presentation_profile");
onlyKeys(value, PROFILE_KEYS, "invalid_map_presentation_profile_fields");
const id = identifier(value.id, "invalid_map_presentation_profile_id");
const version = text(value.version, 32, "invalid_map_presentation_profile_version");
if (!SEMVER.test(version)) fail("invalid_map_presentation_profile_version");
const title = text(value.title, 120, "invalid_map_presentation_profile_title");
const semanticTypes = identifiers(value.semanticTypes, 1, 8, "invalid_map_presentation_profile_semantic_types");
const label = normalizeLabel(value.label);
if (value.target !== undefined && value.pin !== undefined) fail("duplicate_map_presentation_profile_target");
const target = normalizeTarget(value.target ?? value.pin);
const facets = normalizeFacets(value.facets);
const facetByField = new Map(facets.map((facet) => [facet.field, facet]));
const styles = normalizeStyles(value.styles);
const styleIds = new Set(styles.map((style) => style.id));
const classes = normalizeClasses(value.classes, facetByField, styleIds);
const classIds = new Set(classes.map((item) => item.id));
const defaultClassId = identifier(value.defaultClassId, "invalid_map_presentation_profile_default_class");
if (!classIds.has(defaultClassId)) fail("map_presentation_profile_default_class_not_found");
const sort = normalizeSort(value.sort, facetByField);
return {
id,
version,
title,
semanticTypes,
label,
target,
facets,
styles,
classes: [...classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id)),
defaultClassId,
sort,
};
}
export function normalizeMapPresentationProfiles(value) {
if (!Array.isArray(value) || value.length > 32) fail("invalid_map_presentation_profiles");
const profiles = value.map(normalizeMapPresentationProfile);
unique(profiles.map((profile) => profile.id), "duplicate_map_presentation_profile_id");
return profiles;
}
function normalizeLabel(value) {
object(value, "invalid_map_presentation_profile_label");
onlyKeys(value, LABEL_KEYS, "invalid_map_presentation_profile_label_fields");
const mode = value.mode === undefined ? "attributes" : value.mode;
if (!["subject_id", "attributes", "none"].includes(mode)) fail("invalid_map_presentation_profile_label_mode");
return {
mode,
fields: fields(value.fields, 1, 16, "invalid_map_presentation_profile_label_fields"),
fontWeight: integer(value.fontWeight, 400, 700, "invalid_map_presentation_profile_label_weight"),
sizePx: number(value.sizePx, 8, 32, "invalid_map_presentation_profile_label_size"),
color: hex(value.color, "invalid_map_presentation_profile_label_color"),
outlineColor: hex(value.outlineColor, "invalid_map_presentation_profile_label_outline"),
outlineWidthPx: number(value.outlineWidthPx, 0, 6, "invalid_map_presentation_profile_label_outline_width"),
backgroundColor: hex(value.backgroundColor ?? "#0c0d12", "invalid_map_presentation_profile_label_background"),
backgroundOpacity: number(value.backgroundOpacity ?? 0.86, 0, 1, "invalid_map_presentation_profile_label_background_opacity"),
paddingX: number(value.paddingX ?? 8, 0, 40, "invalid_map_presentation_profile_label_padding"),
paddingY: number(value.paddingY ?? 5, 0, 40, "invalid_map_presentation_profile_label_padding"),
maxLength: integer(value.maxLength, 8, 240, "invalid_map_presentation_profile_label_max_length"),
offsetX: number(value.offsetX, -100, 100, "invalid_map_presentation_profile_label_offset"),
offsetY: number(value.offsetY, -100, 100, "invalid_map_presentation_profile_label_offset"),
hideCameraHeightMeters: number(value.hideCameraHeightMeters, 1, 100_000_000, "invalid_map_presentation_profile_label_lod"),
};
}
function normalizeTarget(value) {
object(value, "invalid_map_presentation_profile_target");
onlyKeys(value, TARGET_KEYS, "invalid_map_presentation_profile_target_fields");
if (value.variant !== "elevated-spike") fail("invalid_map_presentation_profile_target_variant");
return {
variant: "elevated-spike",
stemHeightMeters: number(value.stemHeightMeters, 1, 100_000, "invalid_map_presentation_profile_target_stem_height"),
headSizePx: number(value.headSizePx, 1, 64, "invalid_map_presentation_profile_target_head_size"),
stemWidthPx: number(value.stemWidthPx, 0.25, 16, "invalid_map_presentation_profile_target_stem_width"),
outlineColor: hex(value.outlineColor, "invalid_map_presentation_profile_target_outline"),
outlineOpacity: number(value.outlineOpacity, 0, 1, "invalid_map_presentation_profile_target_outline_opacity"),
outlineWidthPx: number(value.outlineWidthPx, 0, 8, "invalid_map_presentation_profile_target_outline_width"),
hideCameraHeightMeters: number(value.hideCameraHeightMeters, 1, 100_000_000, "invalid_map_presentation_profile_target_lod"),
};
}
function normalizeFacets(value) {
if (!Array.isArray(value) || value.length < 1 || value.length > 16) fail("invalid_map_presentation_profile_facets");
const facets = value.map((facet) => {
object(facet, "invalid_map_presentation_profile_facet");
onlyKeys(facet, FACET_KEYS, "invalid_map_presentation_profile_facet_fields");
const values = Array.isArray(facet.values) ? facet.values.map((item) => {
object(item, "invalid_map_presentation_profile_facet_value");
onlyKeys(item, FACET_VALUE_KEYS, "invalid_map_presentation_profile_facet_value_fields");
return {
value: identifier(item.value, "invalid_map_presentation_profile_facet_value"),
label: text(item.label, 80, "invalid_map_presentation_profile_facet_value_label"),
order: integer(item.order, 0, 1000, "invalid_map_presentation_profile_facet_value_order"),
};
}) : fail("invalid_map_presentation_profile_facet_values");
if (values.length < 1 || values.length > 32) fail("invalid_map_presentation_profile_facet_values");
unique(values.map((item) => item.value), "duplicate_map_presentation_profile_facet_value");
return {
id: identifier(facet.id, "invalid_map_presentation_profile_facet_id"),
field: field(facet.field, "invalid_map_presentation_profile_facet_field"),
label: text(facet.label, 80, "invalid_map_presentation_profile_facet_label"),
filterable: boolean(facet.filterable, "invalid_map_presentation_profile_facet_filterable"),
counter: boolean(facet.counter, "invalid_map_presentation_profile_facet_counter"),
values: [...values].sort((left, right) => left.order - right.order || left.value.localeCompare(right.value)),
};
});
unique(facets.map((facet) => facet.id), "duplicate_map_presentation_profile_facet_id");
unique(facets.map((facet) => facet.field), "duplicate_map_presentation_profile_facet_field");
return facets;
}
function normalizeStyles(value) {
if (!Array.isArray(value) || value.length < 1 || value.length > 32) fail("invalid_map_presentation_profile_styles");
const styles = value.map((style) => {
object(style, "invalid_map_presentation_profile_style");
onlyKeys(style, STYLE_KEYS, "invalid_map_presentation_profile_style_fields");
return {
id: identifier(style.id, "invalid_map_presentation_profile_style_id"),
color: hex(style.color, "invalid_map_presentation_profile_style_color"),
opacity: number(style.opacity, 0, 1, "invalid_map_presentation_profile_style_opacity"),
};
});
unique(styles.map((style) => style.id), "duplicate_map_presentation_profile_style_id");
return styles;
}
function normalizeClasses(value, facetByField, styleIds) {
if (!Array.isArray(value) || value.length < 1 || value.length > 64) fail("invalid_map_presentation_profile_classes");
const classes = value.map((item) => {
object(item, "invalid_map_presentation_profile_class");
onlyKeys(item, CLASS_KEYS, "invalid_map_presentation_profile_class_fields");
if (!Array.isArray(item.match) || item.match.length > 8) fail("invalid_map_presentation_profile_class_match");
const match = item.match.map((condition) => {
object(condition, "invalid_map_presentation_profile_class_condition");
onlyKeys(condition, MATCH_KEYS, "invalid_map_presentation_profile_class_condition_fields");
const matchField = field(condition.field, "invalid_map_presentation_profile_class_condition_field");
const equals = identifier(condition.equals, "invalid_map_presentation_profile_class_condition_value");
const facet = facetByField.get(matchField);
if (!facet || !facet.values.some((item) => item.value === equals)) {
fail("map_presentation_profile_class_condition_not_declared");
}
return { field: matchField, equals };
});
const styleId = identifier(item.styleId, "invalid_map_presentation_profile_class_style");
if (!styleIds.has(styleId)) fail("map_presentation_profile_class_style_not_found");
return {
id: identifier(item.id, "invalid_map_presentation_profile_class_id"),
label: text(item.label, 80, "invalid_map_presentation_profile_class_label"),
priority: integer(item.priority, -10000, 10000, "invalid_map_presentation_profile_class_priority"),
match,
styleId,
renderable: boolean(item.renderable, "invalid_map_presentation_profile_class_renderable"),
};
});
unique(classes.map((item) => item.id), "duplicate_map_presentation_profile_class_id");
return classes;
}
function normalizeSort(value, facetByField) {
if (!Array.isArray(value) || value.length > 16) fail("invalid_map_presentation_profile_sort");
const sort = value.map((item) => {
object(item, "invalid_map_presentation_profile_sort_item");
onlyKeys(item, SORT_KEYS, "invalid_map_presentation_profile_sort_fields");
const sortField = field(item.field, "invalid_map_presentation_profile_sort_field");
const facet = facetByField.get(sortField);
if (!facet || !Array.isArray(item.order) || item.order.length !== facet.values.length) {
fail("map_presentation_profile_sort_not_declared");
}
const order = item.order.map((entry) => identifier(entry, "invalid_map_presentation_profile_sort_order"));
unique(order, "duplicate_map_presentation_profile_sort_order");
if (order.some((entry) => !facet.values.some((item) => item.value === entry))) {
fail("map_presentation_profile_sort_not_declared");
}
return { field: sortField, order };
});
unique(sort.map((item) => item.field), "duplicate_map_presentation_profile_sort_field");
return sort;
}
function onlyKeys(value, allowed, code) {
if (Object.keys(value).some((key) => !allowed.has(key))) fail(code);
}
function object(value, code) {
if (!value || typeof value !== "object" || Array.isArray(value)) fail(code);
}
function identifier(value, code) {
const normalized = text(value, 160, code);
if (!IDENTIFIER.test(normalized)) fail(code);
return normalized;
}
function field(value, code) {
const normalized = text(value, 128, code);
if (!FIELD.test(normalized)) fail(code);
return normalized;
}
function identifiers(value, min, max, code) {
if (!Array.isArray(value) || value.length < min || value.length > max) fail(code);
const normalized = value.map((item) => identifier(item, code));
unique(normalized, code);
return normalized;
}
function fields(value, min, max, code) {
if (!Array.isArray(value) || value.length < min || value.length > max) fail(code);
const normalized = value.map((item) => field(item, code));
unique(normalized, code);
return normalized;
}
function text(value, max, code) {
if (typeof value !== "string") fail(code);
const normalized = value.trim();
if (!normalized || normalized.length > max) fail(code);
return normalized;
}
function hex(value, code) {
const normalized = text(value, 7, code).toLowerCase();
if (!/^#[0-9a-f]{6}$/.test(normalized)) fail(code);
return normalized;
}
function number(value, min, max, code) {
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) fail(code);
return value;
}
function integer(value, min, max, code) {
const normalized = number(value, min, max, code);
if (!Number.isInteger(normalized)) fail(code);
return normalized;
}
function boolean(value, code) {
if (typeof value !== "boolean") fail(code);
return value;
}
function unique(values, code) {
if (new Set(values).size !== values.length) fail(code);
}
function fail(code) {
throw Object.assign(new Error(code), { statusCode: 400 });
}
+67
View File
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
const registry = JSON.parse(await readFile(new URL("../registry/map-presentation-profiles.json", import.meta.url), "utf8"));
const mapFixtureSource = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
const catalogStyles = await readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8");
test("canonical moving-object profile is provider-neutral and internally consistent", () => {
assert.equal(registry.schemaVersion, "nodedc.map-presentation-profiles/v1");
const profiles = normalizeMapPresentationProfiles(registry.profiles);
assert.equal(profiles.length, 1);
const profile = profiles[0];
assert.equal(profile.id, "map.moving-object.operational.default");
assert.deepEqual(profile.semanticTypes, ["map.moving_object"]);
assert.equal(profile.target.variant, "elevated-spike");
assert.equal(profile.target.stemHeightMeters, 1500);
assert.equal(profile.target.headSizePx, 9);
assert.equal(profile.target.stemWidthPx, 5);
assert.equal(profile.label.mode, "subject_id");
assert.equal(profile.label.sizePx, 18);
assert.equal(profile.label.backgroundOpacity, 0.86);
assert.equal(profile.styles.find((style) => style.id === "online")?.color, "#86fdb8");
assert.equal(profile.styles.find((style) => style.id === "moving")?.color, "#6fb5fb");
assert.equal(JSON.stringify(profile).toLowerCase().includes("gelios"), false);
assert.deepEqual(profile.facets.map((facet) => facet.field), [
"availability_state",
"motion_state",
]);
assert.deepEqual(profile.facets.flatMap((facet) => facet.values.map((value) => value.label)), [
"Онлайн",
"Офлайн",
"В движении",
"Стоят",
]);
assert.equal(JSON.stringify(profile).includes("Неизвестно"), false);
});
test("fleet filter window is one compact vertical list without group rows or All", () => {
assert.match(mapFixtureSource, /catalog-map-fixture__target-filter-list/);
assert.doesNotMatch(mapFixtureSource, /catalog-map-fixture__target-profile/);
assert.doesNotMatch(mapFixtureSource, /catalog-map-fixture__target-facet/);
assert.doesNotMatch(mapFixtureSource, />Все <span>/);
assert.match(catalogStyles, /\.catalog-map-fixture__target-filter-list\s*\{[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/);
assert.match(catalogStyles, /\.catalog-map-fixture__target-filter-list button\s*\{[\s\S]*width:\s*100%/);
});
test("profile rejects undeclared state matches and renderer transport", () => {
const source = registry.profiles[0];
const undeclared = structuredClone(source);
undeclared.classes[0].match[0].equals = "provider_magic";
assert.throws(() => normalizeMapPresentationProfile(undeclared), /map_presentation_profile_class_condition_not_declared/);
const transport = structuredClone(source);
transport.providerUrl = "https://example.test";
assert.throws(() => normalizeMapPresentationProfile(transport), /invalid_map_presentation_profile_fields/);
});
test("legacy pin profiles normalize into the provider-neutral target contract", () => {
const legacy = structuredClone(registry.profiles[0]);
legacy.pin = legacy.target;
delete legacy.target;
const normalized = normalizeMapPresentationProfile(legacy);
assert.equal(normalized.target.stemHeightMeters, 1500);
assert.equal(Object.hasOwn(normalized, "pin"), false);
});