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",