feat(foundry): close the operational map data loop
This commit is contained in:
+405
-32
@@ -17,6 +17,7 @@ import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
|
||||
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
|
||||
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
|
||||
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
|
||||
import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
|
||||
import { createFoundryAuth } from "./nodedc-auth.mjs";
|
||||
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
@@ -47,6 +48,11 @@ await mkdir(foundryDataProductConsumersDir, { recursive: true });
|
||||
await mkdir(foundryAgentDataDir, { recursive: true });
|
||||
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
|
||||
const dataProductConsumerPolicyRegistry = JSON.parse(await readFile(join(root, "registry", "data-product-consumer-policies.json"), "utf8"));
|
||||
const mapPresentationProfileRegistry = JSON.parse(await readFile(join(root, "registry", "map-presentation-profiles.json"), "utf8"));
|
||||
if (mapPresentationProfileRegistry?.schemaVersion !== "nodedc.map-presentation-profiles/v1") {
|
||||
throw new Error("map_presentation_profile_registry_invalid");
|
||||
}
|
||||
const canonicalMapPresentationProfiles = normalizeMapPresentationProfiles(mapPresentationProfileRegistry.profiles);
|
||||
const foundryAuth = createFoundryAuth();
|
||||
const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
|
||||
process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS,
|
||||
@@ -315,8 +321,17 @@ function validateMapDataProductBinding(value) {
|
||||
const id = requireNonEmptyString(value.id, "invalid_map_data_product_binding_id", 128);
|
||||
const dataProductId = requireNonEmptyString(value.dataProductId, "invalid_map_data_product_id", 160);
|
||||
const slotId = requireNonEmptyString(value.slotId, "invalid_map_data_product_slot", 80);
|
||||
const displayName = value.displayName === undefined
|
||||
? null
|
||||
: requireNonEmptyString(value.displayName, "invalid_map_data_product_display_name", 120).trim();
|
||||
const order = value.order === undefined
|
||||
? null
|
||||
: requireInteger(value.order, 0, 10_000, "invalid_map_data_product_order");
|
||||
const semanticTypes = value.semanticTypes === undefined ? [] : value.semanticTypes;
|
||||
const fieldProjection = value.fieldProjection === undefined ? [] : value.fieldProjection;
|
||||
const presentationProfileId = value.presentationProfileId === undefined
|
||||
? null
|
||||
: requireNonEmptyString(value.presentationProfileId, "invalid_map_data_product_presentation_profile_id", 160);
|
||||
if (!/^[A-Za-z0-9._:-]+$/.test(id)) throw applicationError("invalid_map_data_product_binding_id");
|
||||
if (!/^[A-Za-z0-9._:-]+$/.test(dataProductId)) throw applicationError("invalid_map_data_product_id");
|
||||
if (!/^[A-Za-z0-9-]+$/.test(slotId)) throw applicationError("invalid_map_data_product_slot");
|
||||
@@ -326,6 +341,9 @@ function validateMapDataProductBinding(value) {
|
||||
if (!Array.isArray(fieldProjection) || fieldProjection.length > 32 || fieldProjection.some((item) => typeof item !== "string" || !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item))) {
|
||||
throw applicationError("invalid_map_data_product_field_projection");
|
||||
}
|
||||
if (presentationProfileId && !/^[a-z][a-z0-9._:-]{1,159}$/.test(presentationProfileId)) {
|
||||
throw applicationError("invalid_map_data_product_presentation_profile_id");
|
||||
}
|
||||
if (Object.keys(value).some((key) => /(provider|tenant|connection|endpoint|url|credential|token|secret|payload)/i.test(key))) {
|
||||
throw applicationError("map_data_product_binding_contains_transport");
|
||||
}
|
||||
@@ -336,6 +354,9 @@ function validateMapDataProductBinding(value) {
|
||||
delivery: "snapshot+patch",
|
||||
semanticTypes: [...semanticTypes],
|
||||
fieldProjection: [...fieldProjection],
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(order !== null ? { order } : {}),
|
||||
...(presentationProfileId ? { presentationProfileId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,12 +371,86 @@ function validateMapDataProductBindings(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function defaultMapSubjectState(bindingId, index) {
|
||||
return {
|
||||
bindingId,
|
||||
visible: true,
|
||||
filters: {},
|
||||
window: {
|
||||
open: false,
|
||||
rect: { x: 24 + (index % 5) * 28, y: 56 + (index % 5) * 28, width: 280, height: 260 },
|
||||
maximized: false,
|
||||
zIndex: 20 + index,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateMapSubjectStates(value, dataProductBindings) {
|
||||
if (!Array.isArray(value) || value.length > 64) throw applicationError("invalid_map_subject_states");
|
||||
const bindingIds = new Set(dataProductBindings.map((binding) => binding.id));
|
||||
const states = new Map();
|
||||
for (const raw of value) {
|
||||
if (!isObject(raw)) throw applicationError("invalid_map_subject_state");
|
||||
const bindingId = requireNonEmptyString(raw.bindingId, "invalid_map_subject_state_binding_id", 128);
|
||||
if (!bindingIds.has(bindingId)) continue;
|
||||
if (states.has(bindingId)) throw applicationError("duplicate_map_subject_state_binding_id");
|
||||
const filters = raw.filters === undefined ? {} : raw.filters;
|
||||
if (!isObject(filters) || Object.keys(filters).length > 32) throw applicationError("invalid_map_subject_filters");
|
||||
const normalizedFilters = {};
|
||||
for (const [field, selected] of Object.entries(filters)) {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(field)) throw applicationError("invalid_map_subject_filter_field");
|
||||
if (!Array.isArray(selected) || selected.length > 64 || selected.some((item) => typeof item !== "string" || item.length > 160)) {
|
||||
throw applicationError("invalid_map_subject_filter_values");
|
||||
}
|
||||
normalizedFilters[field] = [...new Set(selected)];
|
||||
}
|
||||
if (!isObject(raw.window) || !isObject(raw.window.rect)) throw applicationError("invalid_map_subject_window");
|
||||
states.set(bindingId, {
|
||||
bindingId,
|
||||
visible: requireBoolean(raw.visible, "invalid_map_subject_visibility"),
|
||||
filters: normalizedFilters,
|
||||
window: {
|
||||
open: requireBoolean(raw.window.open, "invalid_map_subject_window_open"),
|
||||
rect: {
|
||||
x: requireNumber(raw.window.rect.x, -100_000, 100_000, "invalid_map_subject_window_x"),
|
||||
y: requireNumber(raw.window.rect.y, -100_000, 100_000, "invalid_map_subject_window_y"),
|
||||
width: requireNumber(raw.window.rect.width, 200, 10_000, "invalid_map_subject_window_width"),
|
||||
height: requireNumber(raw.window.rect.height, 160, 10_000, "invalid_map_subject_window_height"),
|
||||
},
|
||||
maximized: requireBoolean(raw.window.maximized, "invalid_map_subject_window_maximized"),
|
||||
zIndex: requireInteger(raw.window.zIndex, 1, 100_000, "invalid_map_subject_window_z_index"),
|
||||
},
|
||||
});
|
||||
}
|
||||
return dataProductBindings.map((binding, index) => states.get(binding.id) ?? defaultMapSubjectState(binding.id, index));
|
||||
}
|
||||
|
||||
const MAP_PAGE_SETTING_KEYS = new Set([
|
||||
"imagerySource", "imageryVisible", "cacheEnabled", "cacheNoOverwrite", "terrainEnabled",
|
||||
"terrainExaggeration", "monochrome", "monochromeColor", "imageryGamma", "imageryHue",
|
||||
"imageryAlpha", "globeColor", "backgroundColor", "atmosphereEnabled", "atmosphereHue",
|
||||
"atmosphereSaturation", "atmosphereBrightness", "fogEnabled", "fogDensity", "sunEnabled",
|
||||
"sunHour", "sunIntensity", "shadowsEnabled", "buildingsVisible", "buildingsColor",
|
||||
"buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast",
|
||||
"imagerySaturation", "gridVisible", "gridLodEnabled", "gridHeightMeters",
|
||||
"gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm",
|
||||
"gridLod3StepKm", "gridRadiusKm", "gridLineWidth", "gridColor", "gridOpacity",
|
||||
"gridDotsEnabled", "gridDotsSize", "gridDotsColor", "gridDotsOpacity",
|
||||
]);
|
||||
|
||||
function validateMapPageSettingsPatch(value) {
|
||||
if (!isObject(value) || Object.keys(value).length === 0) throw applicationError("invalid_map_page_settings_patch");
|
||||
if (Object.keys(value).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) throw applicationError("invalid_map_page_settings_patch_field");
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateMapPageLayout(value) {
|
||||
if (!isObject(value)) throw applicationError("invalid_map_page_layout");
|
||||
if (value.schemaVersion !== 1 || value.pageId !== "map") throw applicationError("unsupported_map_page_layout");
|
||||
if (!isObject(value.settings)) throw applicationError("invalid_map_page_settings");
|
||||
if (!isObject(value.camera)) throw applicationError("invalid_map_page_camera");
|
||||
const settings = value.settings;
|
||||
if (Object.keys(settings).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) throw applicationError("invalid_map_page_setting_field");
|
||||
for (const key of ["imageryVisible", "cacheEnabled", "terrainEnabled", "monochrome", "atmosphereEnabled", "fogEnabled", "sunEnabled", "shadowsEnabled", "buildingsVisible", "gridVisible", "gridLodEnabled", "gridDotsEnabled"]) {
|
||||
requireBoolean(settings[key], `invalid_map_page_setting_${key}`);
|
||||
}
|
||||
@@ -373,6 +468,13 @@ function validateMapPageLayout(value) {
|
||||
for (const key of ["longitude", "latitude", "height", "heading", "pitch", "roll"]) {
|
||||
requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`);
|
||||
}
|
||||
const presentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles === undefined ? [] : value.presentationProfiles);
|
||||
const dataProductBindings = validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings);
|
||||
const subjectStates = validateMapSubjectStates(value.subjectStates === undefined ? [] : value.subjectStates, dataProductBindings);
|
||||
const presentationProfileIds = new Set(presentationProfiles.map((profile) => profile.id));
|
||||
if (dataProductBindings.some((binding) => binding.presentationProfileId && !presentationProfileIds.has(binding.presentationProfileId))) {
|
||||
throw applicationError("map_data_product_presentation_profile_not_found");
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
pageId: "map",
|
||||
@@ -387,10 +489,35 @@ function validateMapPageLayout(value) {
|
||||
roll: camera.roll,
|
||||
},
|
||||
pinBindings: validateMapPinBindings(value.pinBindings === undefined ? [] : value.pinBindings),
|
||||
dataProductBindings: validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings),
|
||||
presentationProfiles,
|
||||
dataProductBindings,
|
||||
subjectStates,
|
||||
};
|
||||
}
|
||||
|
||||
function validateMapDesignOverrides(value) {
|
||||
if (!isObject(value)) throw applicationError("invalid_map_design_overrides");
|
||||
if (Object.keys(value).some((key) => !["settings", "presentationProfiles"].includes(key))) {
|
||||
throw applicationError("invalid_map_design_override_field");
|
||||
}
|
||||
const result = {};
|
||||
if (value.settings !== undefined) {
|
||||
if (!isObject(value.settings) || Object.keys(value.settings).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) {
|
||||
throw applicationError("invalid_map_design_override_settings");
|
||||
}
|
||||
const defaults = defaultMapPageLayout();
|
||||
const normalized = validateMapPageLayout({
|
||||
...defaults,
|
||||
settings: { ...defaults.settings, ...value.settings },
|
||||
}).settings;
|
||||
result.settings = Object.fromEntries(Object.keys(value.settings).map((key) => [key, normalized[key]]));
|
||||
}
|
||||
if (value.presentationProfiles !== undefined) {
|
||||
result.presentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function defaultMapPageLayout() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
@@ -404,9 +531,9 @@ function defaultMapPageLayout() {
|
||||
terrainExaggeration: 1,
|
||||
monochrome: false,
|
||||
monochromeColor: "#15151b",
|
||||
imageryGamma: 100,
|
||||
imageryHue: 0,
|
||||
imageryAlpha: 100,
|
||||
imageryGamma: 57,
|
||||
imageryHue: 13,
|
||||
imageryAlpha: 27,
|
||||
globeColor: "#15151b",
|
||||
backgroundColor: "#08090d",
|
||||
atmosphereEnabled: false,
|
||||
@@ -421,11 +548,11 @@ function defaultMapPageLayout() {
|
||||
shadowsEnabled: true,
|
||||
buildingsVisible: true,
|
||||
buildingsColor: "#a27aff",
|
||||
buildingsOpacity: 0.82,
|
||||
buildingsDetail: 16,
|
||||
imageryBrightness: 100,
|
||||
imageryContrast: 100,
|
||||
imagerySaturation: 100,
|
||||
buildingsOpacity: 1,
|
||||
buildingsDetail: 4,
|
||||
imageryBrightness: 118,
|
||||
imageryContrast: 102,
|
||||
imagerySaturation: 0,
|
||||
gridVisible: true,
|
||||
gridLodEnabled: true,
|
||||
gridHeightMeters: 500,
|
||||
@@ -453,7 +580,9 @@ function defaultMapPageLayout() {
|
||||
roll: 0,
|
||||
},
|
||||
pinBindings: [],
|
||||
presentationProfiles: structuredClone(canonicalMapPresentationProfiles),
|
||||
dataProductBindings: [],
|
||||
subjectStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -540,6 +669,56 @@ function validateDesignProfileLayout(value) {
|
||||
lensCount: requireInteger(toolbar.lensCount, 1, 12, "invalid_toolbar_lens_count"),
|
||||
autoHide: requireBoolean(toolbar.autoHide, "invalid_toolbar_auto_hide"),
|
||||
},
|
||||
pageTypes: validateDesignProfilePageTypes(value.pageTypes),
|
||||
};
|
||||
}
|
||||
|
||||
function validateDesignProfilePageTypes(value) {
|
||||
// Profiles and immutable releases created before page-type fragments were
|
||||
// introduced inherit the registered provider-neutral Map design default.
|
||||
if (value === undefined) return defaultDesignProfilePageTypes();
|
||||
if (!isObject(value) || Object.keys(value).length > 64) throw applicationError("invalid_design_profile_page_types");
|
||||
const pageTypes = {};
|
||||
for (const [key, fragment] of Object.entries(value)) {
|
||||
if (!isObject(fragment) || fragment.schemaVersion !== 1) throw applicationError("invalid_design_profile_page_fragment");
|
||||
if (Object.keys(fragment).some((field) => !["schemaVersion", "templateId", "templateVersion", "settings", "presentationProfiles"].includes(field))) {
|
||||
throw applicationError("design_profile_page_fragment_contains_runtime");
|
||||
}
|
||||
const templateId = requireNonEmptyString(fragment.templateId, "invalid_design_profile_page_template_id", 80);
|
||||
const templateVersion = requireNonEmptyString(fragment.templateVersion, "invalid_design_profile_page_template_version", 32);
|
||||
if (key !== `${templateId}@${templateVersion}`) throw applicationError("invalid_design_profile_page_fragment_key");
|
||||
const template = findPageTemplate(templateId, templateVersion);
|
||||
if (!template) throw applicationError("unknown_design_profile_page_template");
|
||||
if (templateId !== "map") throw applicationError("unsupported_design_profile_page_template");
|
||||
const defaults = defaultMapPageLayout();
|
||||
const normalized = validateMapPageLayout({
|
||||
...defaults,
|
||||
settings: fragment.settings,
|
||||
presentationProfiles: fragment.presentationProfiles,
|
||||
pinBindings: [],
|
||||
dataProductBindings: [],
|
||||
});
|
||||
pageTypes[key] = {
|
||||
schemaVersion: 1,
|
||||
templateId: "map",
|
||||
templateVersion,
|
||||
settings: normalized.settings,
|
||||
presentationProfiles: normalized.presentationProfiles,
|
||||
};
|
||||
}
|
||||
return pageTypes;
|
||||
}
|
||||
|
||||
function defaultDesignProfilePageTypes() {
|
||||
const mapLayout = defaultMapPageLayout();
|
||||
return {
|
||||
"map@0.1.0": {
|
||||
schemaVersion: 1,
|
||||
templateId: "map",
|
||||
templateVersion: "0.1.0",
|
||||
settings: mapLayout.settings,
|
||||
presentationProfiles: mapLayout.presentationProfiles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -614,6 +793,7 @@ function defaultDesignProfileLayout() {
|
||||
lensCount: 3,
|
||||
autoHide: true,
|
||||
},
|
||||
pageTypes: defaultDesignProfilePageTypes(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -722,6 +902,7 @@ function validateApplicationManifest(value) {
|
||||
if (value.designProfile.theme !== "dark" && value.designProfile.theme !== "light") throw applicationError("invalid_application_theme");
|
||||
if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages");
|
||||
const pageIds = new Set();
|
||||
const normalizedPages = [];
|
||||
for (const page of value.pages) {
|
||||
if (!page || typeof page !== "object") throw applicationError("invalid_application_page");
|
||||
const pageId = String(page.id || "").trim();
|
||||
@@ -734,18 +915,38 @@ function validateApplicationManifest(value) {
|
||||
if (!template) throw applicationError("unknown_application_page_template");
|
||||
if (!page.navigation || typeof page.navigation.visible !== "boolean") throw applicationError("invalid_application_navigation");
|
||||
if (!page.features || typeof page.features !== "object" || Array.isArray(page.features)) throw applicationError("invalid_application_features");
|
||||
let normalizedPage = page;
|
||||
if (page.layout !== undefined) {
|
||||
if (!isObject(page.layout)) throw applicationError("invalid_application_page_layout");
|
||||
if (page.template.id === "map" && page.layout.map !== undefined) validateMapPageLayout(page.layout.map);
|
||||
if (page.template.id === "map" && page.layout.map !== undefined) {
|
||||
normalizedPage = {
|
||||
...page,
|
||||
layout: { ...page.layout, map: validateMapPageLayout(page.layout.map) },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (page.designOverrides !== undefined) {
|
||||
if (!isObject(page.designOverrides) || Object.keys(page.designOverrides).some((key) => key !== "map")) {
|
||||
throw applicationError("invalid_application_page_design_overrides");
|
||||
}
|
||||
if (page.designOverrides.map !== undefined) {
|
||||
if (page.template.id !== "map") throw applicationError("unsupported_application_page_design_overrides");
|
||||
normalizedPage = {
|
||||
...normalizedPage,
|
||||
designOverrides: { map: validateMapDesignOverrides(page.designOverrides.map) },
|
||||
};
|
||||
}
|
||||
}
|
||||
const allowedFeatures = new Set(template.features.map((feature) => feature.id));
|
||||
if (Object.keys(page.features).some((feature) => !allowedFeatures.has(feature))) throw applicationError("unsupported_application_feature");
|
||||
for (const feature of template.features) {
|
||||
if (feature.required && page.features[feature.id] !== true) throw applicationError("required_application_feature_disabled");
|
||||
}
|
||||
normalizedPages.push(normalizedPage);
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
pages: normalizedPages,
|
||||
designProfile: { ...value.designProfile, status: designProfileStatus },
|
||||
metadata: {
|
||||
...value.metadata,
|
||||
@@ -852,12 +1053,16 @@ async function resolveRuntimeMapDataProductBinding(applicationId, pageId, bindin
|
||||
return { application, page, binding };
|
||||
}
|
||||
|
||||
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId) {
|
||||
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId, generation = 1) {
|
||||
if (!externalDataPlaneInternalUrl || (!externalDataPlaneReaderGrantsDir && !foundryReaderGrantProvisioner.configured)) {
|
||||
throw applicationError("data_product_runtime_not_configured", 503);
|
||||
}
|
||||
const managedToken = await foundryReaderGrantProvisioner.readToken({ applicationId, pageId, bindingId });
|
||||
const managedToken = await foundryReaderGrantProvisioner.readToken(
|
||||
{ applicationId, pageId, bindingId },
|
||||
{ generation },
|
||||
);
|
||||
if (managedToken) return managedToken;
|
||||
if (generation !== 1) throw applicationError("data_product_reader_grant_not_found", 403);
|
||||
if (!externalDataPlaneReaderGrantsDir) throw applicationError("data_product_reader_grant_not_found", 403);
|
||||
// Compatibility fallback for grants issued before Foundry-owned managed
|
||||
// provisioning. There is no fallback to one shared Platform token.
|
||||
@@ -899,7 +1104,17 @@ async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindi
|
||||
async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindingId, dataProductId }) {
|
||||
let readerToken;
|
||||
try {
|
||||
readerToken = await resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId);
|
||||
let generation = 1;
|
||||
try {
|
||||
const statePath = join(foundryDataProductConsumersDir, `${runtimeTargetGrantKey(applicationId, pageId, bindingId)}.json`);
|
||||
const state = JSON.parse(await readFile(statePath, "utf8"));
|
||||
if (Number.isSafeInteger(state?.readerGrantGeneration) && state.readerGrantGeneration > 0) {
|
||||
generation = state.readerGrantGeneration;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
readerToken = await resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId, generation);
|
||||
} catch (error) {
|
||||
if (["data_product_reader_grant_not_found", "data_product_reader_grant_invalid"].includes(error?.message)) return false;
|
||||
throw error;
|
||||
@@ -931,11 +1146,16 @@ async function readRuntimeReaderProduct(readerToken, dataProductId) {
|
||||
return product && product.active !== false && product.deliveryMode === "snapshot+patch" ? product : null;
|
||||
}
|
||||
|
||||
async function inspectFoundryConsumerReaderGrant(target) {
|
||||
async function inspectFoundryConsumerReaderGrant(target, { generation = 1 } = {}) {
|
||||
try {
|
||||
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id);
|
||||
const readerToken = await resolveRuntimeDataProductReaderToken(
|
||||
target.application.id,
|
||||
target.page.id,
|
||||
target.binding.id,
|
||||
generation,
|
||||
);
|
||||
const product = await readRuntimeReaderProduct(readerToken, target.binding.dataProductId);
|
||||
if (product) return { product, readerGrantAction: "reuse" };
|
||||
if (product) return { product, readerGrantAction: "reuse", readerGrantGeneration: generation };
|
||||
} catch (error) {
|
||||
if (!new Set(["data_product_reader_grant_not_found", "data_product_access_denied"]).has(error?.message)) throw error;
|
||||
}
|
||||
@@ -943,7 +1163,7 @@ async function inspectFoundryConsumerReaderGrant(target) {
|
||||
throw applicationError("data_product_reader_grant_not_found", 403);
|
||||
}
|
||||
const planned = await foundryReaderGrantProvisioner.plan(target);
|
||||
return { product: planned.product, readerGrantAction: "ensure" };
|
||||
return { product: planned.product, readerGrantAction: "ensure", readerGrantGeneration: generation };
|
||||
}
|
||||
|
||||
function runtimePointGeometry(value) {
|
||||
@@ -1201,7 +1421,8 @@ const dataProductConsumerManager = createFoundryDataProductConsumerManager({
|
||||
resolveTarget: resolveRuntimeMapDataProductBinding,
|
||||
readReaderToken: resolveRuntimeDataProductReaderToken,
|
||||
inspectReaderGrant: inspectFoundryConsumerReaderGrant,
|
||||
ensureReaderGrant: (target) => foundryReaderGrantProvisioner.ensure(target),
|
||||
ensureReaderGrant: (target, options) => foundryReaderGrantProvisioner.ensure(target, options),
|
||||
revokeReaderGrant: (target, options) => foundryReaderGrantProvisioner.revoke(target, options),
|
||||
resolvePolicy: resolveDataProductConsumerPolicy,
|
||||
sanitizeSnapshot: sanitizeRuntimeSnapshot,
|
||||
sanitizePatch: sanitizeRuntimePatch,
|
||||
@@ -1549,7 +1770,7 @@ const foundryMcpOperations = {
|
||||
const config = foundryMcpConfig();
|
||||
return {
|
||||
module: "NDC Module Foundry",
|
||||
schemaVersion: "nodedc.module-foundry.mcp.v0.2",
|
||||
schemaVersion: "nodedc.module-foundry.mcp.v0.5",
|
||||
status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required",
|
||||
canonicalPageLibrary: "read-only",
|
||||
applicationInstances: "read-write",
|
||||
@@ -1560,6 +1781,10 @@ const foundryMcpOperations = {
|
||||
"application.metadata.update",
|
||||
"page-instance.create",
|
||||
"map-pin.upsert",
|
||||
"map-pin.remove",
|
||||
"map-presentation-profile.upsert",
|
||||
"map-page-settings.update",
|
||||
"map-page-view-state.save",
|
||||
"map-data-product.upsert",
|
||||
"map-data-product-consumer.plan",
|
||||
"map-data-product-consumer.apply",
|
||||
@@ -1663,6 +1888,144 @@ const foundryMcpOperations = {
|
||||
},
|
||||
});
|
||||
},
|
||||
async removeMapPinBinding(input, actor) {
|
||||
return executeFoundryMcpWrite({
|
||||
tool: "foundry_remove_map_pin_binding",
|
||||
actor,
|
||||
input,
|
||||
action: async () => {
|
||||
const bindingId = requireNonEmptyString(input.bindingId, "invalid_map_pin_id", 128);
|
||||
if (!/^[A-Za-z0-9._:-]+$/.test(bindingId)) throw applicationError("invalid_map_pin_id");
|
||||
let updatedPage = null;
|
||||
let removed = false;
|
||||
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
||||
...current,
|
||||
pages: current.pages.map((page) => {
|
||||
if (page.id !== input.pageId) return page;
|
||||
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
||||
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
||||
const pinBindings = layout.pinBindings.filter((item) => item.id !== bindingId);
|
||||
removed = pinBindings.length !== layout.pinBindings.length;
|
||||
updatedPage = {
|
||||
...page,
|
||||
layout: { ...page.layout, map: { ...layout, pinBindings, savedAt: new Date().toISOString() } },
|
||||
};
|
||||
return updatedPage;
|
||||
}),
|
||||
}));
|
||||
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
||||
return { application, page: updatedPage, bindingId, removed };
|
||||
},
|
||||
});
|
||||
},
|
||||
async upsertMapPresentationProfile(input, actor) {
|
||||
return executeFoundryMcpWrite({
|
||||
tool: "foundry_upsert_map_presentation_profile",
|
||||
actor,
|
||||
input,
|
||||
action: async () => {
|
||||
const profile = normalizeMapPresentationProfile(input.profile);
|
||||
let updatedPage = null;
|
||||
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
||||
...current,
|
||||
pages: current.pages.map((page) => {
|
||||
if (page.id !== input.pageId) return page;
|
||||
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
||||
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
||||
const presentationProfiles = layout.presentationProfiles.filter((item) => item.id !== profile.id);
|
||||
presentationProfiles.push(profile);
|
||||
updatedPage = {
|
||||
...page,
|
||||
layout: { ...page.layout, map: validateMapPageLayout({
|
||||
...layout,
|
||||
presentationProfiles,
|
||||
savedAt: new Date().toISOString(),
|
||||
}) },
|
||||
designOverrides: {
|
||||
...page.designOverrides,
|
||||
map: {
|
||||
...page.designOverrides?.map,
|
||||
presentationProfiles,
|
||||
},
|
||||
},
|
||||
};
|
||||
return updatedPage;
|
||||
}),
|
||||
}));
|
||||
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
||||
return { application, page: updatedPage, profile };
|
||||
},
|
||||
});
|
||||
},
|
||||
async updateMapPageSettings(input, actor) {
|
||||
return executeFoundryMcpWrite({
|
||||
tool: "foundry_update_map_page_settings",
|
||||
actor,
|
||||
input,
|
||||
action: async () => {
|
||||
const settingsPatch = validateMapPageSettingsPatch(input.settings);
|
||||
let updatedPage = null;
|
||||
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
||||
...current,
|
||||
pages: current.pages.map((page) => {
|
||||
if (page.id !== input.pageId) return page;
|
||||
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
||||
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
||||
updatedPage = {
|
||||
...page,
|
||||
layout: { ...page.layout, map: validateMapPageLayout({
|
||||
...layout,
|
||||
settings: { ...layout.settings, ...settingsPatch },
|
||||
}) },
|
||||
};
|
||||
return updatedPage;
|
||||
}),
|
||||
}));
|
||||
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
||||
return { application, page: updatedPage, settings: updatedPage.layout.map.settings };
|
||||
},
|
||||
});
|
||||
},
|
||||
async saveMapPageViewState(input, actor) {
|
||||
return executeFoundryMcpWrite({
|
||||
tool: "foundry_save_map_page_view_state",
|
||||
actor,
|
||||
input,
|
||||
action: async () => {
|
||||
if (!isObject(input.viewState)) throw applicationError("invalid_map_page_view_state");
|
||||
let updatedPage = null;
|
||||
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
|
||||
...current,
|
||||
pages: current.pages.map((page) => {
|
||||
if (page.id !== input.pageId) return page;
|
||||
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
||||
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
||||
const settings = input.viewState.settings === undefined
|
||||
? layout.settings
|
||||
: { ...layout.settings, ...validateMapPageSettingsPatch(input.viewState.settings) };
|
||||
updatedPage = {
|
||||
...page,
|
||||
layout: { ...page.layout, map: validateMapPageLayout({
|
||||
...layout,
|
||||
settings,
|
||||
mapHeight: input.viewState.mapHeight,
|
||||
camera: input.viewState.camera,
|
||||
subjectStates: input.viewState.subjectStates,
|
||||
}) },
|
||||
};
|
||||
return updatedPage;
|
||||
}),
|
||||
}));
|
||||
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
||||
return { application, page: updatedPage, viewState: {
|
||||
settings: updatedPage.layout.map.settings,
|
||||
mapHeight: updatedPage.layout.map.mapHeight,
|
||||
camera: updatedPage.layout.map.camera,
|
||||
subjectStates: updatedPage.layout.map.subjectStates,
|
||||
} };
|
||||
},
|
||||
});
|
||||
},
|
||||
async upsertMapDataProductBinding(input, actor) {
|
||||
return executeFoundryMcpWrite({
|
||||
tool: "foundry_upsert_map_data_product_binding",
|
||||
@@ -1680,17 +2043,36 @@ const foundryMcpOperations = {
|
||||
const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId);
|
||||
if (!slot || slot.kind !== "entity-stream") throw applicationError("map_entity_stream_slot_required");
|
||||
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
||||
const existingBinding = layout.dataProductBindings.find((item) => item.id === binding.id);
|
||||
const resolvedBinding = {
|
||||
...existingBinding,
|
||||
...binding,
|
||||
...(!binding.presentationProfileId && existingBinding?.presentationProfileId
|
||||
? { presentationProfileId: existingBinding.presentationProfileId }
|
||||
: {}),
|
||||
};
|
||||
if (resolvedBinding.presentationProfileId && !layout.presentationProfiles.some((profile) => profile.id === resolvedBinding.presentationProfileId)) {
|
||||
throw applicationError("map_data_product_presentation_profile_not_found");
|
||||
}
|
||||
const dataProductBindings = layout.dataProductBindings.filter((item) => item.id !== binding.id);
|
||||
dataProductBindings.push(binding);
|
||||
dataProductBindings.push(resolvedBinding);
|
||||
updatedPage = {
|
||||
...page,
|
||||
layout: { ...page.layout, map: { ...layout, dataProductBindings, savedAt: new Date().toISOString() } },
|
||||
layout: { ...page.layout, map: validateMapPageLayout({
|
||||
...layout,
|
||||
dataProductBindings,
|
||||
savedAt: new Date().toISOString(),
|
||||
}) },
|
||||
};
|
||||
return updatedPage;
|
||||
}),
|
||||
}));
|
||||
if (!updatedPage) throw applicationError("application_page_not_found", 404);
|
||||
return { application, page: updatedPage, binding };
|
||||
return {
|
||||
application,
|
||||
page: updatedPage,
|
||||
binding: updatedPage.layout.map.dataProductBindings.find((item) => item.id === binding.id),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -2181,16 +2563,7 @@ const server = createServer(async (request, response) => {
|
||||
return;
|
||||
}
|
||||
if (pageLayoutMatch && request.method === "PUT") {
|
||||
const pageId = pageLayoutMatch[1];
|
||||
if (pageId !== "map") throw applicationError("unsupported_page_layout");
|
||||
const layout = validateMapPageLayout(await readJsonBody(request));
|
||||
const next = { ...layout, savedAt: new Date().toISOString() };
|
||||
const targetPath = pageLayoutPath(pageId);
|
||||
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
||||
await rename(tempPath, targetPath);
|
||||
json(response, 200, next);
|
||||
return;
|
||||
throw applicationError("page_library_read_only", 405);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/map/runtime-config" && request.method === "GET") {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user