feat(foundry): add composable subject detail profiles

This commit is contained in:
Codex
2026-07-22 19:06:33 +03:00
parent dd2febe7cf
commit dc82ae4c07
21 changed files with 1007 additions and 140 deletions
+88 -1
View File
@@ -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",
+11
View File
@@ -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",
+88 -1
View File
@@ -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.",
}));
}
+4 -4
View File
@@ -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));
}
+2 -2
View File
@@ -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);
}
});
+163
View File
@@ -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 });
}
@@ -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/);
});