From dc82ae4c07175ecbfad8e84b86f67c8ef66f422a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 19:06:33 +0300 Subject: [PATCH] feat(foundry): add composable subject detail profiles --- apps/catalog/src/MapFixturePreview.tsx | 150 ++++++++--- apps/catalog/src/mapSubjectCard.d.mts | 56 +++- apps/catalog/src/mapSubjectCard.mjs | 250 +++++++++++++----- apps/catalog/src/styles.css | 29 ++ docs/MAP_TEMPLATE.md | 22 +- docs/MODULE_STUDIO.md | 2 +- package.json | 3 +- registry/map-subject-detail-profiles.json | 106 ++++++++ registry/pages.json | 1 + registry/registry.json | 1 + .../application-manifest-v0.1.schema.json | 22 +- scripts/map-subject-card.test.mjs | 84 ++++-- scripts/smoke-data-product-runtime.mjs | 5 +- scripts/validate-registry.mjs | 10 + server/catalog-server.mjs | 89 ++++++- server/foundry-agent-gateway.test.mjs | 11 + server/foundry-mcp.mjs | 89 ++++++- server/map-live-data-slot.mjs | 8 +- server/map-live-data-slot.test.mjs | 4 +- server/map-subject-detail-profile.mjs | 163 ++++++++++++ server/map-subject-detail-profile.test.mjs | 42 +++ 21 files changed, 1007 insertions(+), 140 deletions(-) create mode 100644 registry/map-subject-detail-profiles.json create mode 100644 server/map-subject-detail-profile.mjs create mode 100644 server/map-subject-detail-profile.test.mjs diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index 7d2e88d..4b0b1e8 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -1,5 +1,5 @@ import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react"; -import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window, WorkspaceWindow } from "@nodedc/ui-react"; +import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, Window, WorkspaceWindow } from "@nodedc/ui-react"; import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react"; import type { CameraSpiralState, @@ -31,7 +31,7 @@ import { findCameraSurveyPreset, type CameraSurveySelection, } from "./mapCameraPresets.js"; -import { buildMapSubjectCardModel } from "./mapSubjectCard.mjs"; +import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs"; const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; @@ -112,6 +112,36 @@ export type MapDataProductBinding = { semanticTypes: string[]; fieldProjection: string[]; presentationProfileId?: string; + subjectDetailProfileId?: string; + aspectId?: string; + joinToBindingId?: string; +}; + +export type MapSubjectDetailProfile = { + id: string; + version: string; + title: string; + semanticTypes: string[]; + defaultTabId: string; + tabs: Array<{ + id: string; + label: string; + emptyMessage: string; + sections: Array<{ + id: string; + label: string; + fields: Array<{ + id: string; + aspectId?: string; + source: "fact" | "attribute" | "geometry" | "context"; + field: string; + label: string; + format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "telemetry_readings"; + unit?: string; + allowedReadingIds?: string[]; + }>; + }>; + }>; }; export type MapSubjectWindowState = { @@ -137,6 +167,7 @@ export type MapPageLayout = { camera: MapCameraView; pinBindings: MapPinBinding[]; presentationProfiles: MapPresentationProfile[]; + subjectDetailProfiles: MapSubjectDetailProfile[]; dataProductBindings: MapDataProductBinding[]; subjectStates: MapSubjectState[]; savedAt?: string; @@ -216,6 +247,7 @@ export function createDefaultMapPageLayout(expanded = false): MapPageLayout { camera: { ...fallbackMapCamera }, pinBindings: [], presentationProfiles: [], + subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile], dataProductBindings: [], subjectStates: [], }; @@ -320,6 +352,7 @@ export const MapFixturePreview = forwardRef(defaultLayersWindowRect); @@ -356,6 +389,11 @@ export const MapFixturePreview = forwardRef(() => ( normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? []) )); + const [subjectDetailProfiles] = useState(() => ( + initialLayout?.subjectDetailProfiles?.length + ? initialLayout.subjectDetailProfiles + : [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile] + )); // Data-product bindings are provisioned by Foundry MCP / Platform and do // not belong to the visual inspector. Preserve them verbatim when a human // edits camera or presentation settings and saves the page layout. @@ -379,6 +417,7 @@ export const MapFixturePreview = forwardRef ( runtimeBindings.flatMap((binding) => { const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId); + if (bindingConfig?.joinToBindingId) return []; const facts = [...binding.facts]; const primaryProfile = mapPresentationProfileForFact( presentationProfiles, @@ -476,12 +515,33 @@ export const MapFixturePreview = forwardRef entity.id === selectedId) ?? selectable[0]; const selectedSubjectCard = useMemo(() => { const entity = selectable.find((candidate) => candidate.id === selectedId); - return entity ? buildMapSubjectCardModel(entity.fact, { + if (!entity) return null; + const primaryBinding = dataProductBindings.find((binding) => binding.id === entity.bindingId); + const profile = subjectDetailProfiles.find((candidate) => candidate.id === primaryBinding?.subjectDetailProfileId) + ?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity.fact.semanticType)) + ?? DEFAULT_MAP_SUBJECT_DETAIL_PROFILE; + const aspects = Object.fromEntries(dataProductBindings + .filter((binding) => binding.id === entity.bindingId || binding.joinToBindingId === entity.bindingId) + .flatMap((binding) => { + const runtime = runtimeBindings.find((candidate) => candidate.bindingId === binding.id); + const fact = binding.id === entity.bindingId + ? entity.fact + : runtime?.facts.find((candidate) => candidate.sourceId === entity.fact.sourceId); + if (!fact) return []; + return [[binding.id === entity.bindingId ? "primary" : (binding.aspectId ?? binding.id), { + fact, + bindingId: binding.id, + dataProductId: binding.dataProductId, + }]]; + })); + return buildMapSubjectCardModel(entity.fact, { title: entity.title, bindingId: entity.bindingId, dataProductId: entity.dataProductId, - }) : null; - }, [selectable, selectedId]); + profile, + aspects, + }); + }, [dataProductBindings, runtimeBindings, selectable, selectedId, subjectDetailProfiles]); const presentation = useMemo( () => ({ ...mapSettings, cacheRefresh }), [cacheRefresh, mapSettings], @@ -650,6 +710,7 @@ export const MapFixturePreview = forwardRef subjectStates[binding.id] ?? { bindingId: binding.id, @@ -663,7 +724,7 @@ export const MapFixturePreview = forwardRef MapSubjectState) => { setSubjectStates((current) => { @@ -744,12 +805,17 @@ export const MapFixturePreview = forwardRef { if (!selectable.some((entity) => entity.id === entityId)) return; setSelectedId(entityId); + const entity = selectable.find((candidate) => candidate.id === entityId); + const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId); + const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId) + ?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity?.fact.semanticType ?? "")); + setSubjectCardTabId(profile?.defaultTabId ?? "overview"); setSubjectCardOpen(true); setSubjectCardActive(true); setLayersWindowActive(false); setActiveSubjectBindingId(undefined); setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1); - }, [layersWindowZIndex, selectable, subjectStates]); + }, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]); const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => { gatewayHealthRef.current = health; @@ -1368,36 +1434,52 @@ export const MapFixturePreview = forwardRef
- {selectedSubjectCard.sections.map((section) => ( -
-

{section.label}

-
- {section.rows.map((row) => ( -
-
{row.label}
-
{row.value}
-
- ))} -
-
+
+ tab.id === subjectCardTabId) ? subjectCardTabId : selectedSubjectCard.defaultTabId} + items={selectedSubjectCard.tabs.map((tab) => ({ value: tab.id, label: tab.label }))} + label="Разделы карточки объекта" + onChange={setSubjectCardTabId} + /> +
+ {selectedSubjectCard.tabs.filter((tab) => tab.id === ( + selectedSubjectCard.tabs.some((candidate) => candidate.id === subjectCardTabId) + ? subjectCardTabId + : selectedSubjectCard.defaultTabId + )).map((tab) => ( +
+ {tab.empty ?
{tab.emptyMessage}
: null} + {tab.sections.map((section) => ( +
+

{section.label}

+ {section.rows.length ? ( +
+ {section.rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ ) : null} + {section.readings.length ? ( +
+ {section.readings.map((reading) => ( +
+ {reading.label} + {reading.value} + {reading.observedAt ? : null} +
+ ))} +
+ ) : null} +
+ ))} +
))} -
-

Датчики

- {selectedSubjectCard.readings.length ? ( -
- {selectedSubjectCard.readings.map((reading) => ( -
- {reading.label} - {reading.value} - {reading.observedAt ? : null} -
- ))} -
- ) : Gelios не передал разрешённых значений датчиков для этого объекта.} -
) : null} diff --git a/apps/catalog/src/mapSubjectCard.d.mts b/apps/catalog/src/mapSubjectCard.d.mts index 9d7f4ea..d844d96 100644 --- a/apps/catalog/src/mapSubjectCard.d.mts +++ b/apps/catalog/src/mapSubjectCard.d.mts @@ -1,18 +1,64 @@ import type { MapRuntimeFact } from "./useMapDataProductRuntime.js"; +export type MapSubjectDetailField = { + id: string; + aspectId?: string; + source: "fact" | "attribute" | "geometry" | "context"; + field: string; + label: string; + format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "telemetry_readings"; + unit?: string; + allowedReadingIds?: string[]; +}; + +export type MapSubjectDetailProfile = { + id: string; + version: string; + title: string; + semanticTypes: string[]; + defaultTabId: string; + tabs: Array<{ + id: string; + label: string; + emptyMessage: string; + sections: Array<{ id: string; label: string; fields: MapSubjectDetailField[] }>; + }>; +}; + export type MapSubjectCardRow = { key: string; label: string; value: string }; -export type MapSubjectCardSection = { id: string; label: string; rows: MapSubjectCardRow[] }; export type MapTelemetryReading = { id: string; label: string; value: string; observedAt?: string }; +export type MapSubjectCardSection = { + id: string; + label: string; + rows: MapSubjectCardRow[]; + readings: MapTelemetryReading[]; +}; +export type MapSubjectCardTab = { + id: string; + label: string; + emptyMessage: string; + sections: MapSubjectCardSection[]; + empty: boolean; +}; export type MapSubjectCardModel = { title: string; sourceId: string; - sections: MapSubjectCardSection[]; - readings: MapTelemetryReading[]; + profileId: string; + defaultTabId: string; + tabs: MapSubjectCardTab[]; }; +export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE: Readonly; + export function buildMapSubjectCardModel( fact: MapRuntimeFact, - context?: { title?: string; bindingId?: string; dataProductId?: string }, + context?: { + title?: string; + bindingId?: string; + dataProductId?: string; + profile?: MapSubjectDetailProfile; + aspects?: Record; + }, ): MapSubjectCardModel | null; -export function normalizeTelemetryReadings(value: unknown): MapTelemetryReading[]; +export function normalizeTelemetryReadings(value: unknown, allowedReadingIds?: string[]): MapTelemetryReading[]; diff --git a/apps/catalog/src/mapSubjectCard.mjs b/apps/catalog/src/mapSubjectCard.mjs index 65d2464..26f4c97 100644 --- a/apps/catalog/src/mapSubjectCard.mjs +++ b/apps/catalog/src/mapSubjectCard.mjs @@ -1,75 +1,184 @@ -const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i; +const SECRET_LIKE = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i; +const DIRECT_IDENTIFIER_VALUE = /^(?:\+?\d[\d ()-]{8,18}|\d{14,16})$/; -const FIELD_PRESENTATION = Object.freeze({ - signal_state: { section: "state", label: "Связь" }, - movement_state: { section: "state", label: "Движение" }, - speed_kph: { section: "position", label: "Скорость", unit: "км/ч" }, - course_degrees: { section: "position", label: "Курс", unit: "°" }, - elevation_meters: { section: "position", label: "Высота", unit: "м" }, - satellite_count: { section: "quality", label: "Спутники" }, - hdop: { section: "quality", label: "HDOP" }, - horizontal_accuracy_meters: { section: "quality", label: "Точность", unit: "м" }, - mileage_km: { section: "operation", label: "Пробег", unit: "км" }, - engine_hours: { section: "operation", label: "Моточасы", unit: "ч" }, - object_kind: { section: "identity", label: "Класс объекта" }, - position_source: { section: "identity", label: "Источник позиции" }, +export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE = Object.freeze({ + id: "map.subject-detail.operational.default", + version: "1.0.0", + title: "Техническая карточка объекта", + semanticTypes: ["map.moving_object"], + defaultTabId: "overview", + tabs: [ + { + id: "overview", + label: "Обзор", + emptyMessage: "Оперативные данные объекта пока не получены.", + sections: [ + { + id: "state", + label: "Состояние", + fields: [ + { id: "overview.signal", aspectId: "primary", source: "attribute", field: "signal_state", label: "Связь", format: "signal_state" }, + { id: "overview.movement", aspectId: "primary", source: "attribute", field: "movement_state", label: "Движение", format: "movement_state" }, + { id: "overview.speed", aspectId: "primary", source: "attribute", field: "speed_kph", label: "Скорость", format: "number", unit: "км/ч" }, + { id: "overview.observed", aspectId: "primary", source: "fact", field: "observedAt", label: "Данные объекта", format: "timestamp" }, + ], + }, + ], + }, + { + id: "position", + label: "Позиция", + emptyMessage: "Для объекта нет разрешённой позиции.", + sections: [ + { + id: "coordinates", + label: "Координаты и движение", + fields: [ + { id: "position.latitude", aspectId: "primary", source: "geometry", field: "latitude", label: "Широта", format: "coordinate" }, + { id: "position.longitude", aspectId: "primary", source: "geometry", field: "longitude", label: "Долгота", format: "coordinate" }, + { id: "position.course", aspectId: "primary", source: "attribute", field: "course_degrees", label: "Курс", format: "number", unit: "°" }, + { id: "position.elevation", aspectId: "primary", source: "attribute", field: "elevation_meters", label: "Высота", format: "number", unit: "м" }, + ], + }, + { + id: "quality", + label: "Качество GPS", + fields: [ + { id: "position.satellites", aspectId: "primary", source: "attribute", field: "satellite_count", label: "Спутники", format: "number" }, + { id: "position.hdop", aspectId: "primary", source: "attribute", field: "hdop", label: "HDOP", format: "number" }, + { id: "position.accuracy", aspectId: "primary", source: "attribute", field: "horizontal_accuracy_meters", label: "Точность", format: "number", unit: "м" }, + ], + }, + ], + }, + { + id: "telemetry", + label: "Телеметрия", + emptyMessage: "Разрешённые сенсоры появятся после профилирования подключения.", + sections: [{ + id: "readings", + label: "Датчики", + fields: [{ + id: "telemetry.readings", + aspectId: "primary", + source: "attribute", + field: "sensor_readings", + label: "Датчики", + format: "telemetry_readings", + allowedReadingIds: [], + }], + }], + }, + { + id: "counters", + label: "Счётчики", + emptyMessage: "Счётчики для объекта не получены.", + sections: [{ + id: "operation", + label: "Эксплуатация", + fields: [ + { id: "counters.mileage", aspectId: "primary", source: "attribute", field: "mileage_km", label: "Пробег", format: "number", unit: "км" }, + { id: "counters.engine-hours", aspectId: "primary", source: "attribute", field: "engine_hours", label: "Моточасы", format: "number", unit: "ч" }, + ], + }], + }, + { id: "equipment", label: "Оборудование", emptyMessage: "Технический профиль оборудования ещё не подключён.", sections: [] }, + { id: "settings", label: "Настройки", emptyMessage: "Trip/fuel settings ещё не подключены.", sections: [] }, + { id: "maintenance", label: "ТО", emptyMessage: "Планы технического обслуживания ещё не подключены.", sections: [] }, + { + id: "diagnostics", + label: "Данные", + emptyMessage: "Диагностические метаданные не получены.", + sections: [{ + id: "provenance", + label: "Происхождение", + fields: [ + { id: "diagnostics.source-id", aspectId: "primary", source: "fact", field: "sourceId", label: "ID объекта", format: "text" }, + { id: "diagnostics.semantic-type", aspectId: "primary", source: "fact", field: "semanticType", label: "Семантический тип", format: "text" }, + { id: "diagnostics.product", aspectId: "primary", source: "context", field: "dataProductId", label: "Продукт данных", format: "text" }, + { id: "diagnostics.received", aspectId: "primary", source: "fact", field: "receivedAt", label: "Получено NODE.DC", format: "timestamp" }, + ], + }], + }, + ], }); -const SECTION_ORDER = Object.freeze([ - ["state", "Состояние"], - ["position", "Позиция"], - ["quality", "Качество GPS"], - ["operation", "Эксплуатация"], - ["identity", "Идентификация"], - ["other", "Прочие данные"], -]); - export function buildMapSubjectCardModel(fact, context = {}) { - if (!fact || typeof fact !== "object") return null; + if (!isPlainObject(fact)) return null; + const profile = isPlainObject(context.profile) ? context.profile : DEFAULT_MAP_SUBJECT_DETAIL_PROFILE; + const primary = { fact, dataProductId: context.dataProductId, bindingId: context.bindingId }; + const aspects = { primary, ...(isPlainObject(context.aspects) ? context.aspects : {}) }; + const tabs = Array.isArray(profile.tabs) ? profile.tabs.flatMap((tab) => buildTab(tab, aspects)) : []; + if (!tabs.length) return null; const attributes = isPlainObject(fact.attributes) ? fact.attributes : {}; - const rows = new Map(SECTION_ORDER.map(([id, label]) => [id, { id, label, rows: [] }])); - const append = (section, key, label, value, unit) => { - if (value === undefined || SECRET_LIKE_KEY.test(key)) return; - rows.get(section)?.rows.push({ key, label, value: formatValue(key, value, unit) }); - }; - - append("identity", "source_id", "ID", fact.sourceId); - append("identity", "semantic_type", "Семантический тип", fact.semanticType); - append("identity", "data_product_id", "Продукт данных", context.dataProductId); - append("identity", "binding_id", "Binding", context.bindingId); - append("state", "presentation_status", "Статус отображения", fact.presentationStatus); - - if (fact.geometry?.type === "Point" && Array.isArray(fact.geometry.coordinates)) { - append("position", "latitude", "Широта", fact.geometry.coordinates[1]); - append("position", "longitude", "Долгота", fact.geometry.coordinates[0]); - } - append("state", "observed_at", "Данные объекта", fact.observedAt); - append("state", "received_at", "Получено NODE.DC", fact.receivedAt); - - for (const [key, value] of Object.entries(attributes)) { - if (key === "display_name" || key === "sensor_readings" || SECRET_LIKE_KEY.test(key)) continue; - const presentation = FIELD_PRESENTATION[key]; - append(presentation?.section ?? "other", key, presentation?.label ?? humanizeKey(key), value, presentation?.unit); - } - - const readings = normalizeTelemetryReadings(attributes.sensor_readings); + const defaultTabId = tabs.some((tab) => tab.id === profile.defaultTabId) ? profile.defaultTabId : tabs[0].id; return { title: stringValue(attributes.display_name) || stringValue(context.title) || stringValue(fact.sourceId) || "Объект", sourceId: stringValue(fact.sourceId), - sections: [...rows.values()].filter((section) => section.rows.length), - readings, + profileId: stringValue(profile.id), + defaultTabId, + tabs, }; } -export function normalizeTelemetryReadings(value) { - if (!Array.isArray(value)) return []; +function buildTab(tab, aspects) { + if (!isPlainObject(tab) || !safeIdentifier(tab.id) || !stringValue(tab.label)) return []; + const sections = Array.isArray(tab.sections) ? tab.sections.flatMap((section) => buildSection(section, aspects)) : []; + return [{ + id: tab.id, + label: tab.label, + emptyMessage: stringValue(tab.emptyMessage) || "Данные для этого раздела пока не получены.", + sections, + empty: sections.length === 0, + }]; +} + +function buildSection(section, aspects) { + if (!isPlainObject(section) || !safeIdentifier(section.id) || !stringValue(section.label)) return []; + const rows = []; + const readings = []; + for (const field of Array.isArray(section.fields) ? section.fields : []) { + if (!isPlainObject(field) || SECRET_LIKE.test(String(field.field || "")) || SECRET_LIKE.test(String(field.label || ""))) continue; + const aspect = aspects[stringValue(field.aspectId) || "primary"]; + if (!isPlainObject(aspect) || !isPlainObject(aspect.fact)) continue; + const value = resolveFieldValue(aspect, field); + if (field.format === "telemetry_readings") { + readings.push(...normalizeTelemetryReadings(value, field.allowedReadingIds)); + continue; + } + if (value === undefined || value === null || value === "") continue; + rows.push({ + key: String(field.id || `${field.source}.${field.field}`), + label: stringValue(field.label), + value: formatValue(field.format, value, stringValue(field.unit)), + }); + } + if (!rows.length && !readings.length) return []; + return [{ id: section.id, label: section.label, rows, readings }]; +} + +function resolveFieldValue(aspect, field) { + const fact = aspect.fact; + if (field.source === "attribute") return isPlainObject(fact.attributes) ? fact.attributes[field.field] : undefined; + if (field.source === "fact") return fact[field.field]; + if (field.source === "context") return aspect[field.field]; + if (field.source === "geometry" && fact.geometry?.type === "Point" && Array.isArray(fact.geometry.coordinates)) { + if (field.field === "latitude") return fact.geometry.coordinates[1]; + if (field.field === "longitude") return fact.geometry.coordinates[0]; + } + return undefined; +} + +export function normalizeTelemetryReadings(value, allowedReadingIds = []) { + if (!Array.isArray(value) || !Array.isArray(allowedReadingIds) || allowedReadingIds.length === 0) return []; + const allowed = new Set(allowedReadingIds.filter((item) => safeIdentifier(item) && !SECRET_LIKE.test(item))); const seen = new Set(); return value.slice(0, 128).flatMap((candidate) => { if (!isPlainObject(candidate)) return []; const id = stringValue(candidate.id); const label = stringValue(candidate.label); - if (!/^[a-z][a-z0-9._:-]{1,127}$/.test(id) || !label || seen.has(id) || SECRET_LIKE_KEY.test(id)) return []; - if (!isScalar(candidate.value)) return []; + if (!allowed.has(id) || !label || seen.has(id) || SECRET_LIKE.test(id) || SECRET_LIKE.test(label)) return []; + if (!isSafeScalar(candidate.value)) return []; seen.add(id); const unit = stringValue(candidate.unit).slice(0, 32); const observedAt = isIsoTimestamp(candidate.observedAt) ? candidate.observedAt : undefined; @@ -77,12 +186,14 @@ export function normalizeTelemetryReadings(value) { }); } -function formatValue(key, value, unit) { - if ((key === "observed_at" || key === "received_at") && isIsoTimestamp(value)) return formatTimestamp(value); - if (key === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit); - if (key === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit); - if (Array.isArray(value)) return value.map((item) => formatScalar(item)).join(", "); - if (isPlainObject(value)) return JSON.stringify(value); +function formatValue(format, value, unit) { + if (format === "timestamp") return isIsoTimestamp(value) ? formatTimestamp(value) : "—"; + if (format === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit); + if (format === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit); + if (format === "boolean") return Boolean(value) ? "Да" : "Нет"; + if (format === "coordinate" && typeof value === "number" && Number.isFinite(value)) { + return new Intl.NumberFormat("ru-RU", { minimumFractionDigits: 5, maximumFractionDigits: 6 }).format(value); + } return formatScalar(value, unit); } @@ -101,15 +212,16 @@ function formatTimestamp(value) { return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU"); } -function humanizeKey(value) { - const text = String(value).replace(/[._-]+/g, " ").trim(); - return text ? text[0].toUpperCase() + text.slice(1) : String(value); +function isSafeScalar(value) { + if (typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value) && (!Number.isInteger(value) || Math.abs(value) < 1_000_000_000_000); + if (typeof value !== "string") return false; + const normalized = value.trim(); + return normalized.length <= 512 && !SECRET_LIKE.test(normalized) && !DIRECT_IDENTIFIER_VALUE.test(normalized); } -function isScalar(value) { - return typeof value === "string" - || typeof value === "boolean" - || (typeof value === "number" && Number.isFinite(value)); +function safeIdentifier(value) { + return typeof value === "string" && /^[a-z][a-z0-9._:-]{1,159}$/.test(value); } function stringValue(value) { diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index e6f608b..46ee687 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -823,6 +823,35 @@ textarea { padding-bottom: 0.2rem; } +.catalog-map-subject-card__tabs { + overflow-x: auto; + padding: 0.08rem 0 0.12rem; + scrollbar-width: thin; +} + +.catalog-map-subject-card__tabs .nodedc-segmented { + width: max-content; + min-width: 100%; +} + +.catalog-map-subject-card__tabs .nodedc-segmented__item { + white-space: nowrap; +} + +.catalog-map-subject-card__tab-panel { + display: grid; + gap: 0.65rem; +} + +.catalog-map-subject-card__empty { + border-radius: var(--nodedc-radius-control); + background: rgba(255, 255, 255, 0.34); + padding: 0.9rem; + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); + line-height: 1.45; +} + .catalog-map-subject-card__section { display: grid; gap: 0.36rem; diff --git a/docs/MAP_TEMPLATE.md b/docs/MAP_TEMPLATE.md index ad9f856..dd1c34e 100644 --- a/docs/MAP_TEMPLATE.md +++ b/docs/MAP_TEMPLATE.md @@ -43,6 +43,22 @@ profile; renderer не содержит provider-specific условий. Кнопка `Объекты` открывает вверх текстовый dropdown из Application bindings. Выбор строки открывает или фокусирует отдельное canonical `WorkspaceWindow`, а его универсальное содержимое строится из presentation profile/facets. Несколько binding-окон могут быть открыты одновременно; stable identity всегда `bindingId`, поэтому пользовательское переименование не ломает сохранённый layout. +Клик по pin или его label открывает второе canonical `WorkspaceWindow` — карточку +выбранного объекта. Её вкладки и поля задаёт versioned provider-neutral +`subjectDetailProfile`, а не renderer и не provider payload. Базовый профиль +находится в `registry/map-subject-detail-profiles.json`. Он разделяет обзор, +позицию, телеметрию, счётчики, оборудование, настройки, ТО и происхождение +данных. Неизвестные поля не получают автоматическую вкладку `Other`: UI +показывает только явно классифицированную проекцию. Динамические показания +сенсоров требуют отдельного `allowedReadingIds`; пустой список означает явный +запрет вывода. + +Несколько Data Product могут собираться в одну карточку без смешивания +контрактов. Primary binding владеет `subjectDetailProfileId`, а +`subject-aspect-stream` bindings указывают `joinToBindingId` и уникальный +`aspectId`. Соединение выполняется по стабильному `sourceId`; provider endpoint, +credentials и raw payload в Application Manifest не попадают. + Facet-фильтры поддерживают мультивыбор: OR внутри одного facet и AND между facets. Missing facet означает отсутствие ограничения, а явно пустой список — ноль совпадений. Отжатие последнего chip не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием. ## Acceptance fixtures @@ -98,8 +114,10 @@ Runtime tile cache не является исходным кодом и не х ## Live data-product runtime `Map Page` хранит только provider-neutral binding: `dataProductId`, approved -semantic types, field projection, `presentationProfileId` и `slotId`: `points` -для live point entities или `zones` для polygon/multipolygon `map.zone`. +semantic types, field projection, `presentationProfileId`, optional +`subjectDetailProfileId`, `aspectId`/`joinToBindingId` и `slotId`: `points` +для live point entities, `zones` для polygon/multipolygon `map.zone` или +`subject-details` для non-spatial current aspects выбранного объекта. При открытии Application page browser делает same-origin запрос к Foundry: ```text diff --git a/docs/MODULE_STUDIO.md b/docs/MODULE_STUDIO.md index d1b8c81..b75011a 100644 --- a/docs/MODULE_STUDIO.md +++ b/docs/MODULE_STUDIO.md @@ -97,7 +97,7 @@ Foundry предоставляет базовый MCP-контур для уже - `Page Library` и канонические шаблоны доступны только на чтение; - изменяются только экземпляры в `Applications`; -- доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы, upsert provider-neutral map pin bindings, versioned Map presentation profiles и data-product bindings; +- доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы, upsert provider-neutral map pin bindings, versioned Map presentation profiles, versioned subject-detail profiles и data-product bindings; - удаление модуля через MCP намеренно отсутствует; - каждая write-операция требует idempotency key и сохраняет аудит операции в persistent runtime store. diff --git a/package.json b/package.json index 0e1be4f..dda3382 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns", - "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-subject-card", + "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-subject-card && npm run test:map-subject-detail-profile", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "serve": "node server/catalog-server.mjs", "validate:registry": "node scripts/validate-registry.mjs", @@ -25,6 +25,7 @@ "test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs", "test:map-object-layers": "node --test scripts/map-object-layers.test.mjs", "test:map-subject-card": "node --test scripts/map-subject-card.test.mjs", + "test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs" }, diff --git a/registry/map-subject-detail-profiles.json b/registry/map-subject-detail-profiles.json new file mode 100644 index 0000000..a734c01 --- /dev/null +++ b/registry/map-subject-detail-profiles.json @@ -0,0 +1,106 @@ +{ + "schemaVersion": "nodedc.map-subject-detail-profiles/v1", + "profiles": [ + { + "id": "map.subject-detail.operational.default", + "version": "1.0.0", + "title": "Техническая карточка объекта", + "semanticTypes": ["map.moving_object"], + "defaultTabId": "overview", + "tabs": [ + { + "id": "overview", + "label": "Обзор", + "emptyMessage": "Оперативные данные объекта пока не получены.", + "sections": [ + { + "id": "state", + "label": "Состояние", + "fields": [ + { "id": "overview.signal", "source": "attribute", "field": "signal_state", "label": "Связь", "format": "signal_state" }, + { "id": "overview.movement", "source": "attribute", "field": "movement_state", "label": "Движение", "format": "movement_state" }, + { "id": "overview.speed", "source": "attribute", "field": "speed_kph", "label": "Скорость", "format": "number", "unit": "км/ч" }, + { "id": "overview.observed", "source": "fact", "field": "observedAt", "label": "Данные объекта", "format": "timestamp" } + ] + } + ] + }, + { + "id": "position", + "label": "Позиция", + "emptyMessage": "Для объекта нет разрешённой позиции.", + "sections": [ + { + "id": "coordinates", + "label": "Координаты и движение", + "fields": [ + { "id": "position.latitude", "source": "geometry", "field": "latitude", "label": "Широта", "format": "coordinate" }, + { "id": "position.longitude", "source": "geometry", "field": "longitude", "label": "Долгота", "format": "coordinate" }, + { "id": "position.course", "source": "attribute", "field": "course_degrees", "label": "Курс", "format": "number", "unit": "°" }, + { "id": "position.elevation", "source": "attribute", "field": "elevation_meters", "label": "Высота", "format": "number", "unit": "м" } + ] + }, + { + "id": "quality", + "label": "Качество GPS", + "fields": [ + { "id": "position.satellites", "source": "attribute", "field": "satellite_count", "label": "Спутники", "format": "number" }, + { "id": "position.hdop", "source": "attribute", "field": "hdop", "label": "HDOP", "format": "number" }, + { "id": "position.accuracy", "source": "attribute", "field": "horizontal_accuracy_meters", "label": "Точность", "format": "number", "unit": "м" } + ] + } + ] + }, + { + "id": "telemetry", + "label": "Телеметрия", + "emptyMessage": "Разрешённые сенсоры появятся после профилирования подключения.", + "sections": [ + { + "id": "readings", + "label": "Датчики", + "fields": [ + { "id": "telemetry.readings", "source": "attribute", "field": "sensor_readings", "label": "Датчики", "format": "telemetry_readings", "allowedReadingIds": [] } + ] + } + ] + }, + { + "id": "counters", + "label": "Счётчики", + "emptyMessage": "Счётчики для объекта не получены.", + "sections": [ + { + "id": "operation", + "label": "Эксплуатация", + "fields": [ + { "id": "counters.mileage", "source": "attribute", "field": "mileage_km", "label": "Пробег", "format": "number", "unit": "км" }, + { "id": "counters.engine-hours", "source": "attribute", "field": "engine_hours", "label": "Моточасы", "format": "number", "unit": "ч" } + ] + } + ] + }, + { "id": "equipment", "label": "Оборудование", "emptyMessage": "Технический профиль оборудования ещё не подключён.", "sections": [] }, + { "id": "settings", "label": "Настройки", "emptyMessage": "Trip/fuel settings ещё не подключены.", "sections": [] }, + { "id": "maintenance", "label": "ТО", "emptyMessage": "Планы технического обслуживания ещё не подключены.", "sections": [] }, + { + "id": "diagnostics", + "label": "Данные", + "emptyMessage": "Диагностические метаданные не получены.", + "sections": [ + { + "id": "provenance", + "label": "Происхождение", + "fields": [ + { "id": "diagnostics.source-id", "source": "fact", "field": "sourceId", "label": "ID объекта", "format": "text" }, + { "id": "diagnostics.semantic-type", "source": "fact", "field": "semanticType", "label": "Семантический тип", "format": "text" }, + { "id": "diagnostics.product", "source": "context", "field": "dataProductId", "label": "Продукт данных", "format": "text" }, + { "id": "diagnostics.received", "source": "fact", "field": "receivedAt", "label": "Получено NODE.DC", "format": "timestamp" } + ] + } + ] + } + ] + } + ] +} diff --git a/registry/pages.json b/registry/pages.json index 14dcfe3..e9e7936 100644 --- a/registry/pages.json +++ b/registry/pages.json @@ -24,6 +24,7 @@ { "id": "traces", "kind": "trace-stream", "label": "Traces", "description": "Time-ordered entity traces.", "required": false, "multiple": true }, { "id": "routes", "kind": "route-stream", "label": "Routes", "description": "Planned or computed routes.", "required": false, "multiple": true }, { "id": "zones", "kind": "zone-stream", "label": "Zones", "description": "Polygons, geofences and operational areas.", "required": false, "multiple": true }, + { "id": "subject-details", "kind": "subject-aspect-stream", "label": "Subject details", "description": "Provider-neutral current aspects joined to a stable selected subject.", "required": false, "multiple": true }, { "id": "selection", "kind": "selection", "label": "Selection", "description": "Selected map entity and contextual state.", "required": false, "multiple": false }, { "id": "commands", "kind": "command", "label": "Commands", "description": "Capability-backed actions for selected entities.", "required": false, "multiple": true }, { "id": "assistant", "kind": "assistant", "label": "Assistant context", "description": "Context exposed to the assistant overlay.", "required": false, "multiple": false } diff --git a/registry/registry.json b/registry/registry.json index 503a6ba..b95ead2 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -55,5 +55,6 @@ "mapSceneFixtureSchema": "schemas/map-scene-fixture-v0.1.schema.json", "mapLodPolicySchema": "schemas/map-lod-policy-v0.1.schema.json", "mapPresentationProfileRegistry": "map-presentation-profiles.json", + "mapSubjectDetailProfileRegistry": "map-subject-detail-profiles.json", "operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy." } diff --git a/registry/schemas/application-manifest-v0.1.schema.json b/registry/schemas/application-manifest-v0.1.schema.json index a52e4c7..71e54a0 100644 --- a/registry/schemas/application-manifest-v0.1.schema.json +++ b/registry/schemas/application-manifest-v0.1.schema.json @@ -80,7 +80,27 @@ "properties": { "id": { "type": "string", "minLength": 1 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 120 }, - "order": { "type": "integer", "minimum": 0, "maximum": 10000 } + "order": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "aspectId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "joinToBindingId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "subjectDetailProfileId": { "type": "string", "minLength": 1, "maxLength": 160 } + } + } + }, + "subjectDetailProfiles": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, + "title": { "type": "string", "minLength": 1, "maxLength": 120 }, + "semanticTypes": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "string" } }, + "defaultTabId": { "type": "string", "minLength": 1 }, + "tabs": { "type": "array", "minItems": 1, "maxItems": 12, "items": { "type": "object" } } } } }, diff --git a/scripts/map-subject-card.test.mjs b/scripts/map-subject-card.test.mjs index 9b8c837..69a4540 100644 --- a/scripts/map-subject-card.test.mjs +++ b/scripts/map-subject-card.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { buildMapSubjectCardModel, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs"; +import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE, normalizeTelemetryReadings } from "../apps/catalog/src/mapSubjectCard.mjs"; const fact = { - sourceId: "867236078012345", + sourceId: "gelios-unit-555590", semanticType: "map.moving_object", presentationStatus: "current", observedAt: "2026-07-22T08:42:03.000Z", @@ -31,26 +31,32 @@ const fact = { }; test("subject card exposes the full provider-neutral telemetry projection", () => { + const profile = structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE); + profile.tabs.find((tab) => tab.id === "telemetry").sections[0].fields[0].allowedReadingIds = ["sensor.temperature"]; const card = buildMapSubjectCardModel(fact, { bindingId: "trike-current-positions", dataProductId: "fleet.positions.current.v5", + profile, }); assert.ok(card); assert.equal(card.title, "555590 B2 867236078012345"); assert.equal(card.sourceId, fact.sourceId); - const values = new Map(card.sections.flatMap((section) => section.rows.map((row) => [row.key, row.value]))); - assert.equal(values.get("source_id"), fact.sourceId); - assert.equal(values.get("data_product_id"), "fleet.positions.current.v5"); - assert.equal(values.get("binding_id"), "trike-current-positions"); - assert.equal(values.get("latitude"), "55,756"); - assert.equal(values.get("longitude"), "37,618"); - assert.equal(values.get("signal_state"), "На связи"); - assert.equal(values.get("movement_state"), "В движении"); - assert.equal(values.get("speed_kph"), "17,4 км/ч"); - assert.equal(values.get("mileage_km"), "4 832,25 км"); - assert.equal(values.get("engine_hours"), "912,5 ч"); - assert.equal(card.readings[0]?.value, "21,5 °C"); + assert.equal(card.defaultTabId, "overview"); + assert.deepEqual(card.tabs.map((tab) => tab.id), ["overview", "position", "telemetry", "counters", "equipment", "settings", "maintenance", "diagnostics"]); + const values = new Map(card.tabs.flatMap((tab) => tab.sections.flatMap((section) => section.rows.map((row) => [row.key, row.value])))); + assert.equal(values.get("diagnostics.source-id"), fact.sourceId); + assert.equal(values.get("diagnostics.product"), "fleet.positions.current.v5"); + assert.equal(values.get("position.latitude"), "55,75580"); + assert.equal(values.get("position.longitude"), "37,61760"); + assert.equal(values.get("overview.signal"), "На связи"); + assert.equal(values.get("overview.movement"), "В движении"); + assert.equal(values.get("overview.speed"), "17,4 км/ч"); + assert.equal(values.get("counters.mileage"), "4 832,25 км"); + assert.equal(values.get("counters.engine-hours"), "912,5 ч"); + const readings = card.tabs.find((tab) => tab.id === "telemetry").sections[0].readings; + assert.equal(readings[0]?.value, "21,5 °C"); + assert.equal(card.tabs.find((tab) => tab.id === "equipment").empty, true); }); test("sensor readings remain bounded and reject duplicate, complex and restricted values", () => { @@ -60,7 +66,8 @@ test("sensor readings remain bounded and reject duplicate, complex and restricte { id: "sensor.imei", label: "IMEI", value: "forbidden" }, { id: "sensor.payload", label: "Payload", value: { nested: true } }, { id: "Sensor Bad", label: "Bad id", value: 1 }, - ]); + { id: "sensor.account", label: "Аккаунт", value: "+79991234567" }, + ], ["sensor.voltage", "sensor.imei", "sensor.payload", "sensor.account"]); assert.deepEqual(readings, [{ id: "sensor.voltage", label: "Напряжение", value: "48,2 V", observedAt: undefined }]); }); @@ -76,13 +83,55 @@ test("card model never renders provider secrets or direct identifiers from attri raw_params: "forbidden", }, }); - const keys = card.sections.flatMap((section) => section.rows.map((row) => row.key)); + const keys = card.tabs.flatMap((tab) => tab.sections.flatMap((section) => section.rows.map((row) => row.key))); assert.ok(!keys.includes("api_key")); assert.ok(!keys.includes("imei")); assert.ok(!keys.includes("phone")); assert.ok(!keys.includes("raw_params")); }); +test("unknown fields are fail-closed instead of becoming an automatic Other section", () => { + const card = buildMapSubjectCardModel({ + ...fact, + attributes: { ...fact.attributes, unclassified_payload: { arbitrary: true }, surprise_value: "must not render" }, + }); + const keys = card.tabs.flatMap((tab) => tab.sections.flatMap((section) => section.rows.map((row) => row.key))); + assert.ok(!keys.includes("unclassified_payload")); + assert.ok(!keys.includes("surprise_value")); + assert.equal(card.tabs.some((tab) => tab.id === "other"), false); +}); + +test("subject details compose declared aspect bindings by stable source id", () => { + const profile = { + id: "map.subject-detail.composed", + version: "1.0.0", + title: "Composed", + semanticTypes: ["map.moving_object"], + defaultTabId: "equipment", + tabs: [{ + id: "equipment", + label: "Оборудование", + emptyMessage: "Нет данных", + sections: [{ + id: "identity", + label: "Профиль", + fields: [{ id: "equipment.kind", aspectId: "technical", source: "attribute", field: "equipment_kind", label: "Тип", format: "text" }], + }], + }], + }; + const card = buildMapSubjectCardModel(fact, { + profile, + aspects: { + technical: { + bindingId: "trike-technical-profile", + dataProductId: "fleet.technical-profile.current.v1", + fact: { sourceId: fact.sourceId, semanticType: "asset.technical_profile", attributes: { equipment_kind: "cargo_trike" } }, + }, + }, + }); + assert.equal(card.tabs[0].sections[0].rows[0].value, "cargo_trike"); +}); + test("Cesium pin and label share one entity selection that opens the subject card", async () => { const [renderer, preview] = await Promise.all([ readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), @@ -95,5 +144,6 @@ test("Cesium pin and label share one entity selection that opens the subject car assert.match(preview, /const handleSelect = useCallback/); assert.match(preview, /setSubjectCardOpen\(true\)/); assert.match(preview, /title=\{selectedSubjectCard\.title\}/); - assert.match(preview, /Телеметрия:/); + assert.match(preview, /Карточка объекта:/); + assert.match(preview, /SegmentedControl/); }); diff --git a/scripts/smoke-data-product-runtime.mjs b/scripts/smoke-data-product-runtime.mjs index 0716a73..4c8f261 100644 --- a/scripts/smoke-data-product-runtime.mjs +++ b/scripts/smoke-data-product-runtime.mjs @@ -323,12 +323,13 @@ try { return payload.result; }; const initialized = await mcpRequest("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "runtime-smoke", version: "1" } }); - assert.equal(initialized.serverInfo.version, "0.5.0"); + assert.equal(initialized.serverInfo.version, "0.6.0"); const listedTools = await mcpRequest("tools/list"); - assert.equal(listedTools.tools.length, 17); + assert.equal(listedTools.tools.length, 18); assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_apply_map_data_product_consumer")); assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_save_map_page_view_state")); assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_remove_map_pin_binding")); + assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_upsert_map_subject_detail_profile")); const workloadHeaders = { authorization: `Bearer ${bindingGrantToken}`, diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 33e1f39..b5f83f1 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -1,6 +1,7 @@ import { access, readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { normalizeMapSubjectDetailProfiles } from "../server/map-subject-detail-profile.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const parse = async (path) => JSON.parse(await readFile(resolve(root, path), "utf8")); @@ -16,6 +17,7 @@ const designProfileSchema = await parse("registry/schemas/design-profile-v0.1.sc const mapSceneFixtureSchema = await parse("registry/schemas/map-scene-fixture-v0.1.schema.json"); const mapLodPolicySchema = await parse("registry/schemas/map-lod-policy-v0.1.schema.json"); const mapPresentationProfiles = await parse("registry/map-presentation-profiles.json"); +const mapSubjectDetailProfiles = await parse("registry/map-subject-detail-profiles.json"); const dataProductConsumerPolicies = await parse("registry/data-product-consumer-policies.json"); const failures = []; @@ -43,12 +45,20 @@ requireValue(registry.designProfileSchema === "schemas/design-profile-v0.1.schem requireValue(registry.mapSceneFixtureSchema === "schemas/map-scene-fixture-v0.1.schema.json", "map scene fixture schema must be registered"); requireValue(registry.mapLodPolicySchema === "schemas/map-lod-policy-v0.1.schema.json", "map LOD policy schema must be registered"); requireValue(registry.mapPresentationProfileRegistry === "map-presentation-profiles.json", "map presentation profile registry must be registered"); +requireValue(registry.mapSubjectDetailProfileRegistry === "map-subject-detail-profiles.json", "map subject detail profile registry must be registered"); requireValue(applicationManifestSchema.properties?.schemaVersion?.const === "0.1.0", "application manifest schema version must be 0.1.0"); requireValue(designProfileSchema.properties?.schemaVersion?.const === "0.1.0", "design profile schema version must be 0.1.0"); requireValue(mapSceneFixtureSchema.properties?.schemaVersion?.const === "0.1.0", "map scene fixture schema version must be 0.1.0"); requireValue(mapLodPolicySchema.properties?.schemaVersion?.const === "0.1.0", "map LOD policy schema version must be 0.1.0"); requireValue(mapPresentationProfiles.schemaVersion === "nodedc.map-presentation-profiles/v1", "map presentation profile registry schema is unsupported"); requireValue(Array.isArray(mapPresentationProfiles.profiles) && mapPresentationProfiles.profiles.length >= 1, "map presentation profiles are missing"); +requireValue(mapSubjectDetailProfiles.schemaVersion === "nodedc.map-subject-detail-profiles/v1", "map subject detail profile registry schema is unsupported"); +try { + const normalizedSubjectDetailProfiles = normalizeMapSubjectDetailProfiles(mapSubjectDetailProfiles.profiles); + requireValue(normalizedSubjectDetailProfiles.length >= 1, "map subject detail profiles are missing"); +} catch (error) { + requireValue(false, `invalid map subject detail profile registry: ${error instanceof Error ? error.message : String(error)}`); +} requireValue(dataProductConsumerPolicies.schemaVersion === "nodedc.foundry.data-product-consumer-policies/v1", "data product consumer policy registry schema is unsupported"); requireValue(Array.isArray(dataProductConsumerPolicies.policies) && dataProductConsumerPolicies.policies.length >= 1, "data product consumer policies are missing"); diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index 32e6bca..cecb683 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -19,6 +19,7 @@ import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-prov import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs"; import { isMapLiveDataSlot } from "./map-live-data-slot.mjs"; import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs"; +import { normalizeMapSubjectDetailProfile, normalizeMapSubjectDetailProfiles } from "./map-subject-detail-profile.mjs"; import { createFoundryAuth } from "./nodedc-auth.mjs"; const root = fileURLToPath(new URL("..", import.meta.url)); @@ -54,6 +55,11 @@ if (mapPresentationProfileRegistry?.schemaVersion !== "nodedc.map-presentation-p throw new Error("map_presentation_profile_registry_invalid"); } const canonicalMapPresentationProfiles = normalizeMapPresentationProfiles(mapPresentationProfileRegistry.profiles); +const mapSubjectDetailProfileRegistry = JSON.parse(await readFile(join(root, "registry", "map-subject-detail-profiles.json"), "utf8")); +if (mapSubjectDetailProfileRegistry?.schemaVersion !== "nodedc.map-subject-detail-profiles/v1") { + throw new Error("map_subject_detail_profile_registry_invalid"); +} +const canonicalMapSubjectDetailProfiles = normalizeMapSubjectDetailProfiles(mapSubjectDetailProfileRegistry.profiles); const foundryAuth = createFoundryAuth(); const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout( process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS, @@ -333,6 +339,15 @@ function validateMapDataProductBinding(value) { const presentationProfileId = value.presentationProfileId === undefined ? null : requireNonEmptyString(value.presentationProfileId, "invalid_map_data_product_presentation_profile_id", 160); + const subjectDetailProfileId = value.subjectDetailProfileId === undefined + ? null + : requireNonEmptyString(value.subjectDetailProfileId, "invalid_map_data_product_subject_detail_profile_id", 160); + const aspectId = value.aspectId === undefined + ? "primary" + : requireNonEmptyString(value.aspectId, "invalid_map_data_product_aspect_id", 160); + const joinToBindingId = value.joinToBindingId === undefined + ? null + : requireNonEmptyString(value.joinToBindingId, "invalid_map_data_product_join_binding_id", 128); 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"); @@ -345,6 +360,13 @@ function validateMapDataProductBinding(value) { if (presentationProfileId && !/^[a-z][a-z0-9._:-]{1,159}$/.test(presentationProfileId)) { throw applicationError("invalid_map_data_product_presentation_profile_id"); } + if (subjectDetailProfileId && !/^[a-z][a-z0-9._:-]{1,159}$/.test(subjectDetailProfileId)) { + throw applicationError("invalid_map_data_product_subject_detail_profile_id"); + } + if (!/^[a-z][a-z0-9._:-]{1,159}$/.test(aspectId)) throw applicationError("invalid_map_data_product_aspect_id"); + if (joinToBindingId && !/^[A-Za-z0-9._:-]+$/.test(joinToBindingId)) { + throw applicationError("invalid_map_data_product_join_binding_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"); } @@ -358,6 +380,9 @@ function validateMapDataProductBinding(value) { ...(displayName ? { displayName } : {}), ...(order !== null ? { order } : {}), ...(presentationProfileId ? { presentationProfileId } : {}), + ...(subjectDetailProfileId ? { subjectDetailProfileId } : {}), + aspectId, + ...(joinToBindingId ? { joinToBindingId } : {}), }; } @@ -470,12 +495,39 @@ function validateMapPageLayout(value) { 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 subjectDetailProfiles = normalizeMapSubjectDetailProfiles( + value.subjectDetailProfiles === undefined + ? structuredClone(canonicalMapSubjectDetailProfiles) + : value.subjectDetailProfiles, + ); 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"); } + const subjectDetailProfilesById = new Map(subjectDetailProfiles.map((profile) => [profile.id, profile])); + const bindingsById = new Map(dataProductBindings.map((binding) => [binding.id, binding])); + for (const binding of dataProductBindings) { + const detailProfile = binding.subjectDetailProfileId + ? subjectDetailProfilesById.get(binding.subjectDetailProfileId) + : null; + if (binding.subjectDetailProfileId && !detailProfile) throw applicationError("map_data_product_subject_detail_profile_not_found"); + if (detailProfile && !binding.semanticTypes.some((semanticType) => detailProfile.semanticTypes.includes(semanticType))) { + throw applicationError("map_data_product_subject_detail_profile_semantic_type_mismatch"); + } + if (binding.joinToBindingId) { + if (binding.joinToBindingId === binding.id || !bindingsById.has(binding.joinToBindingId)) { + throw applicationError("map_data_product_join_binding_not_found"); + } + if (binding.subjectDetailProfileId) throw applicationError("map_data_product_joined_binding_owns_detail_profile"); + } + } + for (const primary of dataProductBindings.filter((binding) => !binding.joinToBindingId)) { + const aspectIds = [primary, ...dataProductBindings.filter((binding) => binding.joinToBindingId === primary.id)] + .map((binding) => binding.aspectId); + if (new Set(aspectIds).size !== aspectIds.length) throw applicationError("duplicate_map_data_product_aspect_id"); + } return { schemaVersion: 1, pageId: "map", @@ -491,6 +543,7 @@ function validateMapPageLayout(value) { }, pinBindings: validateMapPinBindings(value.pinBindings === undefined ? [] : value.pinBindings), presentationProfiles, + subjectDetailProfiles, dataProductBindings, subjectStates, }; @@ -582,6 +635,7 @@ function defaultMapPageLayout() { }, pinBindings: [], presentationProfiles: structuredClone(canonicalMapPresentationProfiles), + subjectDetailProfiles: structuredClone(canonicalMapSubjectDetailProfiles), dataProductBindings: [], subjectStates: [], }; @@ -1806,7 +1860,7 @@ const foundryMcpOperations = { const config = foundryMcpConfig(); return { module: "NDC Module Foundry", - schemaVersion: "nodedc.module-foundry.mcp.v0.5", + schemaVersion: "nodedc.module-foundry.mcp.v0.6", status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required", canonicalPageLibrary: "read-only", applicationInstances: "read-write", @@ -1819,6 +1873,7 @@ const foundryMcpOperations = { "map-pin.upsert", "map-pin.remove", "map-presentation-profile.upsert", + "map-subject-detail-profile.upsert", "map-page-settings.update", "map-page-view-state.save", "map-data-product.upsert", @@ -1993,6 +2048,38 @@ const foundryMcpOperations = { }, }); }, + async upsertMapSubjectDetailProfile(input, actor) { + return executeFoundryMcpWrite({ + tool: "foundry_upsert_map_subject_detail_profile", + actor, + input, + action: async () => { + const profile = normalizeMapSubjectDetailProfile(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 subjectDetailProfiles = layout.subjectDetailProfiles.filter((item) => item.id !== profile.id); + subjectDetailProfiles.push(profile); + updatedPage = { + ...page, + layout: { ...page.layout, map: validateMapPageLayout({ + ...layout, + subjectDetailProfiles, + savedAt: new Date().toISOString(), + }) }, + }; + 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", diff --git a/server/foundry-agent-gateway.test.mjs b/server/foundry-agent-gateway.test.mjs index d69ba61..ff76d95 100644 --- a/server/foundry-agent-gateway.test.mjs +++ b/server/foundry-agent-gateway.test.mjs @@ -126,6 +126,14 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor 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 subjectDetailTool = foundryList.result.tools.find((tool) => tool.name === "foundry_upsert_map_subject_detail_profile"); + assert.ok(subjectDetailTool); + assert.equal(subjectDetailTool.inputSchema.properties.profile.additionalProperties, false); + assert.deepEqual(subjectDetailTool.inputSchema.properties.profile.required, ["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]); + assert.deepEqual( + subjectDetailTool.inputSchema.properties.profile.properties.tabs.items.properties.sections.items.properties.fields.items.properties.format.enum, + ["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "telemetry_readings"], + ); 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); @@ -139,6 +147,9 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor 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); + assert.ok(bindingTool.inputSchema.properties.binding.properties.subjectDetailProfileId); + assert.ok(bindingTool.inputSchema.properties.binding.properties.aspectId); + assert.ok(bindingTool.inputSchema.properties.binding.properties.joinToBindingId); const crossTokenResponse = await fetch(redeemed.ontology.mcpUrl, { method: "POST", diff --git a/server/foundry-mcp.mjs b/server/foundry-mcp.mjs index 6c4b4a1..d456bbf 100644 --- a/server/foundry-mcp.mjs +++ b/server/foundry-mcp.mjs @@ -303,6 +303,73 @@ const mapPresentationProfileInputSchema = { }, }; +const mapSubjectDetailProfileFieldInputSchema = { + type: "object", + additionalProperties: false, + required: ["id", "source", "field", "label", "format"], + properties: { + id: { type: "string", description: "Stable field presentation id." }, + aspectId: { type: "string", description: "Provider-neutral subject aspect id; defaults to primary." }, + source: { type: "string", enum: ["fact", "attribute", "geometry", "context"] }, + field: { type: "string", description: "Exact registered fact, context, geometry or provider-neutral attribute field." }, + label: { type: "string", minLength: 1, maxLength: 100 }, + format: { + type: "string", + enum: ["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "telemetry_readings"], + }, + unit: { type: "string", minLength: 1, maxLength: 24 }, + allowedReadingIds: { + type: "array", + maxItems: 256, + uniqueItems: true, + description: "Explicit classified sensor ids. Empty means no dynamic readings are rendered.", + items: { type: "string" }, + }, + }, +}; + +const mapSubjectDetailProfileInputSchema = { + type: "object", + additionalProperties: false, + required: ["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"], + properties: { + id: { type: "string", description: "Stable provider-neutral subject.detail_profile id." }, + version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" }, + title: { type: "string", minLength: 1, maxLength: 120 }, + semanticTypes: { type: "array", minItems: 1, maxItems: 8, uniqueItems: true, items: { type: "string" } }, + defaultTabId: { type: "string" }, + tabs: { + type: "array", + minItems: 1, + maxItems: 12, + items: { + type: "object", + additionalProperties: false, + required: ["id", "label", "emptyMessage", "sections"], + properties: { + id: { type: "string" }, + label: { type: "string", minLength: 1, maxLength: 80 }, + emptyMessage: { type: "string", minLength: 1, maxLength: 240 }, + sections: { + type: "array", + maxItems: 12, + items: { + type: "object", + additionalProperties: false, + required: ["id", "label", "fields"], + properties: { + id: { type: "string" }, + label: { type: "string", minLength: 1, maxLength: 80 }, + fields: { type: "array", maxItems: 32, items: mapSubjectDetailProfileFieldInputSchema }, + }, + }, + }, + }, + }, + }, + }, +}; + const mapPageSettingsPatchInputSchema = { type: "object", additionalProperties: false, @@ -551,6 +618,22 @@ const tools = [ }, }, }, + { + name: "foundry_upsert_map_subject_detail_profile", + title: "Upsert Map subject detail profile", + description: "Create or update a versioned provider-neutral subject detail profile. Tabs and fields are exact, fail-closed presentation declarations; provider transport and unrestricted payload rendering are forbidden.", + 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: mapSubjectDetailProfileInputSchema, + }, + }, + }, { name: "foundry_update_map_page_settings", title: "Update Application Map settings", @@ -629,6 +712,9 @@ const tools = [ 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." }, + subjectDetailProfileId: { type: "string", description: "Existing page-owned provider-neutral subject detail profile id." }, + aspectId: { type: "string", description: "Stable provider-neutral aspect id inside the selected subject composition." }, + joinToBindingId: { type: "string", description: "Primary Map binding whose stable sourceId is used to join this subject-details aspect." }, }, }, }, @@ -728,6 +814,7 @@ function toolMap(operations) { 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_upsert_map_subject_detail_profile: (input, actor) => operations.upsertMapSubjectDetailProfile(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), @@ -772,7 +859,7 @@ 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.5.0" }, + serverInfo: { name: "nodedc_module_foundry", version: "0.6.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.", })); } diff --git a/server/map-live-data-slot.mjs b/server/map-live-data-slot.mjs index 347dab3..0095b1f 100644 --- a/server/map-live-data-slot.mjs +++ b/server/map-live-data-slot.mjs @@ -1,11 +1,11 @@ -const MAP_LIVE_DATA_SLOT_KINDS = new Set(["entity-stream", "zone-stream"]); +const MAP_LIVE_DATA_SLOT_KINDS = new Set(["entity-stream", "zone-stream", "subject-aspect-stream"]); /** * Map runtime bindings may only target slots that deliver canonical live - * entity facts. Point entities and zones have distinct template slots, but - * share the same provider-neutral snapshot/patch transport contract. + * entity facts. Point entities, zones and non-spatial selected-subject aspects + * have distinct template slots, but share the same provider-neutral + * snapshot/patch transport contract. */ export function isMapLiveDataSlot(slot) { return Boolean(slot && MAP_LIVE_DATA_SLOT_KINDS.has(slot.kind)); } - diff --git a/server/map-live-data-slot.test.mjs b/server/map-live-data-slot.test.mjs index 2b6c716..a7028d7 100644 --- a/server/map-live-data-slot.test.mjs +++ b/server/map-live-data-slot.test.mjs @@ -2,9 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { isMapLiveDataSlot } from "./map-live-data-slot.mjs"; -test("point and zone streams are approved Map live-data slots", () => { +test("point, zone and subject aspect streams are approved Map live-data slots", () => { assert.equal(isMapLiveDataSlot({ id: "points", kind: "entity-stream" }), true); assert.equal(isMapLiveDataSlot({ id: "zones", kind: "zone-stream" }), true); + assert.equal(isMapLiveDataSlot({ id: "subject-details", kind: "subject-aspect-stream" }), true); }); test("non-entity Map slots cannot receive a data-product binding", () => { @@ -12,4 +13,3 @@ test("non-entity Map slots cannot receive a data-product binding", () => { assert.equal(isMapLiveDataSlot(kind ? { id: "other", kind } : null), false); } }); - diff --git a/server/map-subject-detail-profile.mjs b/server/map-subject-detail-profile.mjs new file mode 100644 index 0000000..b9bc207 --- /dev/null +++ b/server/map-subject-detail-profile.mjs @@ -0,0 +1,163 @@ +const IDENTIFIER = /^[a-z][a-z0-9._:-]{1,159}$/; +const FIELD = /^[a-z][a-z0-9_.-]{0,127}$/; +const SEMVER = /^\d+\.\d+\.\d+$/; +const SECRET_LIKE = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i; +const PROFILE_KEYS = new Set(["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]); +const TAB_KEYS = new Set(["id", "label", "emptyMessage", "sections"]); +const SECTION_KEYS = new Set(["id", "label", "fields"]); +const FIELD_KEYS = new Set(["id", "aspectId", "source", "field", "label", "format", "unit", "allowedReadingIds"]); +const SOURCES = new Set(["fact", "attribute", "geometry", "context"]); +const FORMATS = new Set([ + "text", "number", "timestamp", "boolean", "coordinate", + "signal_state", "movement_state", "telemetry_readings", +]); +const FACT_FIELDS = new Set(["sourceId", "semanticType", "observedAt", "receivedAt", "presentationStatus"]); +const GEOMETRY_FIELDS = new Set(["latitude", "longitude"]); +const CONTEXT_FIELDS = new Set(["dataProductId", "bindingId"]); + +export function normalizeMapSubjectDetailProfile(value) { + object(value, "invalid_map_subject_detail_profile"); + onlyKeys(value, PROFILE_KEYS, "invalid_map_subject_detail_profile_fields"); + const id = identifier(value.id, "invalid_map_subject_detail_profile_id"); + const version = text(value.version, 32, "invalid_map_subject_detail_profile_version"); + if (!SEMVER.test(version)) fail("invalid_map_subject_detail_profile_version"); + const title = safeLabel(value.title, 120, "invalid_map_subject_detail_profile_title"); + const semanticTypes = identifiers(value.semanticTypes, 1, 8, "invalid_map_subject_detail_profile_semantic_types"); + if (!Array.isArray(value.tabs) || value.tabs.length < 1 || value.tabs.length > 12) { + fail("invalid_map_subject_detail_profile_tabs"); + } + const tabs = value.tabs.map(normalizeTab); + unique(tabs.map((tab) => tab.id), "duplicate_map_subject_detail_profile_tab_id"); + const defaultTabId = identifier(value.defaultTabId, "invalid_map_subject_detail_profile_default_tab"); + if (!tabs.some((tab) => tab.id === defaultTabId)) fail("map_subject_detail_profile_default_tab_not_found"); + const fieldIds = tabs.flatMap((tab) => tab.sections.flatMap((section) => section.fields.map((field) => field.id))); + unique(fieldIds, "duplicate_map_subject_detail_profile_field_id"); + return { id, version, title, semanticTypes, defaultTabId, tabs }; +} + +export function normalizeMapSubjectDetailProfiles(value) { + if (!Array.isArray(value) || value.length > 32) fail("invalid_map_subject_detail_profiles"); + const profiles = value.map(normalizeMapSubjectDetailProfile); + unique(profiles.map((profile) => profile.id), "duplicate_map_subject_detail_profile_id"); + return profiles; +} + +function normalizeTab(value) { + object(value, "invalid_map_subject_detail_profile_tab"); + onlyKeys(value, TAB_KEYS, "invalid_map_subject_detail_profile_tab_fields"); + if (!Array.isArray(value.sections) || value.sections.length > 12) fail("invalid_map_subject_detail_profile_sections"); + const sections = value.sections.map(normalizeSection); + unique(sections.map((section) => section.id), "duplicate_map_subject_detail_profile_section_id"); + return { + id: identifier(value.id, "invalid_map_subject_detail_profile_tab_id"), + label: safeLabel(value.label, 80, "invalid_map_subject_detail_profile_tab_label"), + emptyMessage: safeLabel(value.emptyMessage, 240, "invalid_map_subject_detail_profile_empty_message"), + sections, + }; +} + +function normalizeSection(value) { + object(value, "invalid_map_subject_detail_profile_section"); + onlyKeys(value, SECTION_KEYS, "invalid_map_subject_detail_profile_section_fields"); + if (!Array.isArray(value.fields) || value.fields.length > 32) fail("invalid_map_subject_detail_profile_section_fields"); + return { + id: identifier(value.id, "invalid_map_subject_detail_profile_section_id"), + label: safeLabel(value.label, 80, "invalid_map_subject_detail_profile_section_label"), + fields: value.fields.map(normalizeField), + }; +} + +function normalizeField(value) { + object(value, "invalid_map_subject_detail_profile_field"); + onlyKeys(value, FIELD_KEYS, "invalid_map_subject_detail_profile_field_fields"); + const source = text(value.source, 32, "invalid_map_subject_detail_profile_field_source"); + const format = text(value.format, 32, "invalid_map_subject_detail_profile_field_format"); + if (!SOURCES.has(source)) fail("invalid_map_subject_detail_profile_field_source"); + if (!FORMATS.has(format)) fail("invalid_map_subject_detail_profile_field_format"); + const field = sourceField(source, value.field); + if (source === "attribute" && SECRET_LIKE.test(field)) fail("map_subject_detail_profile_restricted_field"); + const allowedReadingIds = value.allowedReadingIds === undefined + ? [] + : fields(value.allowedReadingIds, 0, 256, "invalid_map_subject_detail_profile_reading_ids"); + if (allowedReadingIds.some((item) => SECRET_LIKE.test(item))) fail("map_subject_detail_profile_restricted_reading_id"); + if (format === "telemetry_readings" && (source !== "attribute" || field !== "sensor_readings")) { + fail("map_subject_detail_profile_readings_source_invalid"); + } + if (format !== "telemetry_readings" && allowedReadingIds.length) { + fail("map_subject_detail_profile_reading_ids_not_allowed"); + } + if (source === "geometry" && format !== "coordinate") fail("map_subject_detail_profile_geometry_format_invalid"); + const unit = value.unit === undefined ? undefined : safeLabel(value.unit, 24, "invalid_map_subject_detail_profile_field_unit"); + return { + id: identifier(value.id, "invalid_map_subject_detail_profile_field_id"), + aspectId: identifier(value.aspectId ?? "primary", "invalid_map_subject_detail_profile_aspect_id"), + source, + field, + label: safeLabel(value.label, 100, "invalid_map_subject_detail_profile_field_label"), + format, + ...(unit ? { unit } : {}), + ...(format === "telemetry_readings" ? { allowedReadingIds } : {}), + }; +} + +function sourceField(source, value) { + const normalized = text(value, 128, "invalid_map_subject_detail_profile_field_name"); + if (source === "fact" && !FACT_FIELDS.has(normalized)) fail("invalid_map_subject_detail_profile_fact_field"); + if (source === "geometry" && !GEOMETRY_FIELDS.has(normalized)) fail("invalid_map_subject_detail_profile_geometry_field"); + if (source === "context" && !CONTEXT_FIELDS.has(normalized)) fail("invalid_map_subject_detail_profile_context_field"); + if (source === "attribute" && !FIELD.test(normalized)) fail("invalid_map_subject_detail_profile_attribute_field"); + return normalized; +} + +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 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) => { + const result = text(item, 128, code); + if (!FIELD.test(result)) fail(code); + return result; + }); + unique(normalized, code); + return normalized; +} + +function safeLabel(value, max, code) { + const normalized = text(value, max, code); + if (SECRET_LIKE.test(normalized)) fail(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 unique(values, code) { + if (new Set(values).size !== values.length) fail(code); +} + +function fail(code) { + throw Object.assign(new Error(code), { statusCode: 400 }); +} diff --git a/server/map-subject-detail-profile.test.mjs b/server/map-subject-detail-profile.test.mjs new file mode 100644 index 0000000..e41f17a --- /dev/null +++ b/server/map-subject-detail-profile.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { normalizeMapSubjectDetailProfile, normalizeMapSubjectDetailProfiles } from "./map-subject-detail-profile.mjs"; + +const registry = JSON.parse(await readFile(new URL("../registry/map-subject-detail-profiles.json", import.meta.url), "utf8")); + +test("canonical subject detail profiles are provider-neutral and valid", () => { + const profiles = normalizeMapSubjectDetailProfiles(registry.profiles); + assert.equal(profiles.length, 1); + assert.equal(profiles[0].id, "map.subject-detail.operational.default"); + assert.deepEqual(profiles[0].tabs.map((tab) => tab.id), [ + "overview", "position", "telemetry", "counters", "equipment", "settings", "maintenance", "diagnostics", + ]); +}); + +test("dynamic sensor readings are fail-closed until ids are explicitly classified", () => { + const profile = normalizeMapSubjectDetailProfile(registry.profiles[0]); + const field = profile.tabs.find((tab) => tab.id === "telemetry").sections[0].fields[0]; + assert.equal(field.format, "telemetry_readings"); + assert.deepEqual(field.allowedReadingIds, []); +}); + +test("restricted fields and reading ids cannot be introduced through MCP profiles", () => { + const restrictedField = structuredClone(registry.profiles[0]); + restrictedField.tabs[0].sections[0].fields[0].field = "api_key"; + assert.throws(() => normalizeMapSubjectDetailProfile(restrictedField), /map_subject_detail_profile_restricted_field/); + + const restrictedReading = structuredClone(registry.profiles[0]); + restrictedReading.tabs.find((tab) => tab.id === "telemetry").sections[0].fields[0].allowedReadingIds = ["sensor.imei"]; + assert.throws(() => normalizeMapSubjectDetailProfile(restrictedReading), /map_subject_detail_profile_restricted_reading_id/); +}); + +test("profiles reject unknown schema fields and duplicate presentation ids", () => { + const unknown = structuredClone(registry.profiles[0]); + unknown.provider = "gelios"; + assert.throws(() => normalizeMapSubjectDetailProfile(unknown), /invalid_map_subject_detail_profile_fields/); + + const duplicate = structuredClone(registry.profiles[0]); + duplicate.tabs[1].sections[0].fields[0].id = duplicate.tabs[0].sections[0].fields[0].id; + assert.throws(() => normalizeMapSubjectDetailProfile(duplicate), /duplicate_map_subject_detail_profile_field_id/); +});