feat(foundry): add composable subject detail profiles
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user