From 49f0c449c1419a205cbda15037a3ecf08e5c874e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 09:31:34 +0300 Subject: [PATCH] feat(foundry): support restricted subject aspects --- apps/catalog/src/MapFixturePreview.tsx | 2 + apps/catalog/src/mapSubjectCard.mjs | 38 +++++++++-- apps/catalog/src/useMapDataProductRuntime.ts | 1 + registry/data-product-consumer-policies.json | 36 ++++++++++ .../application-manifest-v0.1.schema.json | 1 + scripts/map-subject-card.test.mjs | 67 +++++++++++++++++++ scripts/validate-registry.mjs | 38 +++++++++++ server/catalog-server.mjs | 29 +++++++- server/foundry-data-product-consumer.mjs | 9 +++ server/foundry-data-product-consumer.test.mjs | 43 ++++++++++++ server/foundry-mcp.mjs | 10 +++ server/map-subject-detail-profile.mjs | 36 ++++++++-- server/map-subject-detail-profile.test.mjs | 18 ++++- 13 files changed, 313 insertions(+), 15 deletions(-) diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index 4b0b1e8..13074fa 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -115,6 +115,7 @@ export type MapDataProductBinding = { subjectDetailProfileId?: string; aspectId?: string; joinToBindingId?: string; + dataClass?: "operational" | "restricted"; }; export type MapSubjectDetailProfile = { @@ -532,6 +533,7 @@ export const MapFixturePreview = forwardRef buildTab(tab, aspects)) : []; if (!tabs.length) return null; @@ -138,15 +144,24 @@ function buildSection(section, aspects) { 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; + if (!isPlainObject(field) + || SECRET_MATERIAL.test(String(field.field || "")) + || SECRET_MATERIAL.test(String(field.label || ""))) continue; const aspect = aspects[stringValue(field.aspectId) || "primary"]; if (!isPlainObject(aspect) || !isPlainObject(aspect.fact)) continue; + const restricted = field.dataClass === "restricted" && aspect.dataClass === "restricted"; + if ( + (RESTRICTED_IDENTIFIER.test(String(field.field || "")) + || RESTRICTED_IDENTIFIER.test(String(field.label || ""))) + && !restricted + ) 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; + if (typeof value === "string" && DIRECT_IDENTIFIER_VALUE.test(value.trim()) && !restricted) continue; rows.push({ key: String(field.id || `${field.source}.${field.field}`), label: stringValue(field.label), @@ -171,13 +186,21 @@ function resolveFieldValue(aspect, field) { 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 allowed = new Set(allowedReadingIds.filter( + (item) => safeIdentifier(item) && !SECRET_MATERIAL.test(item) && !RESTRICTED_IDENTIFIER.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 (!allowed.has(id) || !label || seen.has(id) || SECRET_LIKE.test(id) || SECRET_LIKE.test(label)) return []; + if (!allowed.has(id) + || !label + || seen.has(id) + || SECRET_MATERIAL.test(id) + || SECRET_MATERIAL.test(label) + || RESTRICTED_IDENTIFIER.test(id) + || RESTRICTED_IDENTIFIER.test(label)) return []; if (!isSafeScalar(candidate.value)) return []; seen.add(id); const unit = stringValue(candidate.unit).slice(0, 32); @@ -217,7 +240,10 @@ function isSafeScalar(value) { 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); + return normalized.length <= 512 + && !SECRET_MATERIAL.test(normalized) + && !RESTRICTED_IDENTIFIER.test(normalized) + && !DIRECT_IDENTIFIER_VALUE.test(normalized); } function safeIdentifier(value) { diff --git a/apps/catalog/src/useMapDataProductRuntime.ts b/apps/catalog/src/useMapDataProductRuntime.ts index e1649d1..504e3d0 100644 --- a/apps/catalog/src/useMapDataProductRuntime.ts +++ b/apps/catalog/src/useMapDataProductRuntime.ts @@ -305,6 +305,7 @@ export function useMapDataProductRuntime({ semanticTypes: binding.semanticTypes, fieldProjection: binding.fieldProjection, presentationProfileId: binding.presentationProfileId, + dataClass: binding.dataClass ?? "operational", }))), [bindings], ); diff --git a/registry/data-product-consumer-policies.json b/registry/data-product-consumer-policies.json index db74c4f..03488c1 100644 --- a/registry/data-product-consumer-policies.json +++ b/registry/data-product-consumer-policies.json @@ -149,6 +149,42 @@ }, "removeMode": "canonical-tombstone-or-snapshot-rebase" }, + { + "id": "map-moving-object-unit-contacts-current-v1", + "version": "1.0.0", + "dataProductId": "fleet.units.contacts.current.v1", + "productVersion": "1.0.0", + "dataClass": "restricted", + "freshness": "none", + "staleAfterMs": null, + "terminalStatuses": [], + "consumerContract": { + "ontologyRevision": "ontology.map.moving_object.v3", + "deliveryMode": "snapshot+patch", + "semanticTypes": [ + "map.moving_object" + ], + "fieldProjection": [ + "device_imei", + "device_phone_primary", + "device_phone_secondary", + "display_name", + "provider_creator_login" + ], + "fieldTypes": { + "device_imei": "string", + "device_phone_primary": "string", + "device_phone_secondary": "string", + "display_name": "string", + "provider_creator_login": "string" + }, + "geometryTypes": [], + "subjectIdentity": "semantic-type+source-id", + "snapshotMode": "atomic-replace", + "patchMode": "atomic" + }, + "removeMode": "canonical-tombstone-or-snapshot-rebase" + }, { "id": "map-zone-current-v1", "version": "1.0.0", diff --git a/registry/schemas/application-manifest-v0.1.schema.json b/registry/schemas/application-manifest-v0.1.schema.json index 71e54a0..a934e03 100644 --- a/registry/schemas/application-manifest-v0.1.schema.json +++ b/registry/schemas/application-manifest-v0.1.schema.json @@ -83,6 +83,7 @@ "order": { "type": "integer", "minimum": 0, "maximum": 10000 }, "aspectId": { "type": "string", "minLength": 1, "maxLength": 160 }, "joinToBindingId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "dataClass": { "enum": ["operational", "restricted"] }, "subjectDetailProfileId": { "type": "string", "minLength": 1, "maxLength": 160 } } } diff --git a/scripts/map-subject-card.test.mjs b/scripts/map-subject-card.test.mjs index 69a4540..57ece54 100644 --- a/scripts/map-subject-card.test.mjs +++ b/scripts/map-subject-card.test.mjs @@ -132,6 +132,73 @@ test("subject details compose declared aspect bindings by stable source id", () assert.equal(card.tabs[0].sections[0].rows[0].value, "cargo_trike"); }); +test("restricted joined aspects render declared identifiers and remain fail-closed otherwise", () => { + const profile = { + id: "map.subject-detail.restricted", + version: "1.0.0", + title: "Restricted", + semanticTypes: ["map.moving_object"], + defaultTabId: "contacts", + tabs: [{ + id: "contacts", + label: "Контакты", + emptyMessage: "Нет данных", + sections: [{ + id: "device", + label: "Контакты устройства", + fields: [ + { + id: "contacts.phone", + aspectId: "unit_contacts", + source: "attribute", + field: "device_phone_primary", + label: "Телефон 1", + format: "text", + dataClass: "restricted", + }, + { + id: "contacts.imei", + aspectId: "unit_contacts", + source: "attribute", + field: "device_imei", + label: "IMEI", + format: "text", + dataClass: "restricted", + }, + ], + }], + }], + }; + const contactAspect = { + bindingId: "trike-unit-contacts", + dataProductId: "fleet.units.contacts.current.v1", + dataClass: "restricted", + fact: { + sourceId: fact.sourceId, + semanticType: "map.moving_object", + attributes: { + device_phone_primary: "+79991234567", + device_imei: "867236078012345", + }, + }, + }; + const card = buildMapSubjectCardModel(fact, { + profile, + aspects: { unit_contacts: contactAspect }, + }); + assert.deepEqual( + card.tabs[0].sections[0].rows.map((row) => row.value), + ["+79991234567", "867236078012345"], + ); + const rejected = buildMapSubjectCardModel(fact, { + profile, + aspects: { + unit_contacts: { ...contactAspect, dataClass: "operational" }, + }, + }); + assert.equal(rejected.tabs[0].empty, true); +}); + 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"), diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 7097f7c..1f77c9b 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -80,6 +80,7 @@ for (const policy of consumerPolicies) { "terminalStatuses", "statusContract", "consumerContract", + "dataClass", "removeMode", ]); requireValue(Object.keys(policy).every((key) => allowedPolicyKeys.has(key)), `unsupported data product consumer policy key: ${policy.id ?? "unknown"}`); @@ -89,6 +90,7 @@ for (const policy of consumerPolicies) { requireValue(typeof policy.productVersion === "string" && /^\d+\.\d+\.\d+$/.test(policy.productVersion), `invalid data product consumer product version: ${policy.id ?? "unknown"}`); requireValue(Array.isArray(policy.terminalStatuses) && policy.terminalStatuses.every((value) => typeof value === "string" && /^[a-z0-9_-]{1,64}$/.test(value)), `invalid terminal statuses: ${policy.id ?? "unknown"}`); requireValue(policy.removeMode === "canonical-tombstone-or-snapshot-rebase", `invalid remove mode: ${policy.id ?? "unknown"}`); + requireValue(["operational", "restricted"].includes(policy.dataClass ?? "operational"), `invalid data class: ${policy.id ?? "unknown"}`); requireValue(["none", "observed-at"].includes(freshness), `invalid freshness mode: ${policy.id ?? "unknown"}`); if (policy.consumerContract !== undefined) { const contract = policy.consumerContract; @@ -205,6 +207,42 @@ requireValue(JSON.stringify(unitProfileV1Policy?.consumerContract?.fieldProjecti requireValue(JSON.stringify(unitProfileV1Policy?.consumerContract?.geometryTypes) === JSON.stringify([]), "fleet.units.profile.current.v1 must remain a non-geometric aspect"); requireValue(!/(provider|tenant|endpoint|credential|token|secret|authorization)/i.test(JSON.stringify(unitProfileV1Policy)), "fleet.units.profile.current.v1 consumer policy crosses the transport/secret boundary"); +const unitContactsV1Policy = consumerPolicies.find( + (policy) => policy.dataProductId === "fleet.units.contacts.current.v1" + && policy.productVersion === "1.0.0", +); +const unitContactsV1Projection = [ + "device_imei", + "device_phone_primary", + "device_phone_secondary", + "display_name", + "provider_creator_login", +]; +requireValue( + unitContactsV1Policy?.id === "map-moving-object-unit-contacts-current-v1" + && unitContactsV1Policy?.version === "1.0.0", + "fleet.units.contacts.current.v1 consumer policy is missing", +); +requireValue( + unitContactsV1Policy?.dataClass === "restricted", + "fleet.units.contacts.current.v1 must be explicitly restricted", +); +requireValue( + JSON.stringify(unitContactsV1Policy?.consumerContract?.fieldProjection) + === JSON.stringify(unitContactsV1Projection), + "fleet.units.contacts.current.v1 field projection must be exact", +); +requireValue( + JSON.stringify(unitContactsV1Policy?.consumerContract?.geometryTypes) === JSON.stringify([]), + "fleet.units.contacts.current.v1 must remain a non-geometric aspect", +); +requireValue( + !/(tenant|endpoint|credential|token|secret|authorization)/i.test( + JSON.stringify(unitContactsV1Policy), + ), + "fleet.units.contacts.current.v1 consumer policy crosses the transport/secret boundary", +); + const ids = components.components.map((component) => component.id); requireValue(new Set(ids).size === ids.length, "component ids must be unique"); const candidateIds = candidates.candidates.map((component) => component.id); diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index cecb683..df5a9e1 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -348,6 +348,9 @@ function validateMapDataProductBinding(value) { const joinToBindingId = value.joinToBindingId === undefined ? null : requireNonEmptyString(value.joinToBindingId, "invalid_map_data_product_join_binding_id", 128); + const dataClass = value.dataClass === undefined + ? "operational" + : requireNonEmptyString(value.dataClass, "invalid_map_data_product_data_class", 32); 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"); @@ -367,6 +370,9 @@ function validateMapDataProductBinding(value) { if (joinToBindingId && !/^[A-Za-z0-9._:-]+$/.test(joinToBindingId)) { throw applicationError("invalid_map_data_product_join_binding_id"); } + if (!new Set(["operational", "restricted"]).has(dataClass)) { + throw applicationError("invalid_map_data_product_data_class"); + } 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"); } @@ -383,6 +389,7 @@ function validateMapDataProductBinding(value) { ...(subjectDetailProfileId ? { subjectDetailProfileId } : {}), aspectId, ...(joinToBindingId ? { joinToBindingId } : {}), + dataClass, }; } @@ -524,9 +531,27 @@ function validateMapPageLayout(value) { } } for (const primary of dataProductBindings.filter((binding) => !binding.joinToBindingId)) { - const aspectIds = [primary, ...dataProductBindings.filter((binding) => binding.joinToBindingId === primary.id)] - .map((binding) => binding.aspectId); + const composition = [ + primary, + ...dataProductBindings.filter((binding) => binding.joinToBindingId === primary.id), + ]; + const aspectIds = composition.map((binding) => binding.aspectId); if (new Set(aspectIds).size !== aspectIds.length) throw applicationError("duplicate_map_data_product_aspect_id"); + const detailProfile = primary.subjectDetailProfileId + ? subjectDetailProfilesById.get(primary.subjectDetailProfileId) + : null; + if (!detailProfile) continue; + const aspectsById = new Map(composition.map((binding) => [binding.aspectId, binding])); + const detailFields = detailProfile.tabs.flatMap( + (tab) => tab.sections.flatMap((section) => section.fields), + ); + for (const field of detailFields) { + const aspect = aspectsById.get(field.aspectId); + if (!aspect) throw applicationError("map_subject_detail_profile_aspect_not_found"); + if (field.dataClass !== aspect.dataClass) { + throw applicationError("map_subject_detail_profile_data_class_mismatch"); + } + } } return { schemaVersion: 1, diff --git a/server/foundry-data-product-consumer.mjs b/server/foundry-data-product-consumer.mjs index b1fed4a..ac62a3b 100644 --- a/server/foundry-data-product-consumer.mjs +++ b/server/foundry-data-product-consumer.mjs @@ -80,6 +80,7 @@ function safeTarget(target) { delivery: "snapshot+patch", semanticTypes: [...(binding.semanticTypes || [])], fieldProjection: [...(binding.fieldProjection || [])], + dataClass: binding.dataClass === "restricted" ? "restricted" : "operational", }; } @@ -271,11 +272,16 @@ function validatePolicy(policy, product) { throw consumerError("data_product_consumer_policy_invalid", 500); } const consumerContract = validateConsumerContract(policy.consumerContract); + const dataClass = policy.dataClass ?? "operational"; + if (!["operational", "restricted"].includes(dataClass)) { + throw consumerError("data_product_consumer_policy_invalid", 500); + } return { id: String(policy.id || ""), version: String(policy.version || ""), dataProductId: product.id, productVersion: product.version, + dataClass, freshness, staleAfterMs: policy.staleAfterMs, terminalStatuses: [...terminalStatuses], @@ -450,6 +456,9 @@ export function createFoundryDataProductConsumerManager({ throw consumerError("data_product_consumer_semantic_scope_mismatch", 409); } const policy = validatePolicy(resolvePolicy(product), product); + if ((target.binding.dataClass ?? "operational") !== policy.dataClass) { + throw consumerError("data_product_consumer_data_class_mismatch", 409); + } assertConsumerContract(policy.consumerContract, product, target.binding); if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) { throw consumerError("data_product_consumer_status_field_not_projected", 409); diff --git a/server/foundry-data-product-consumer.test.mjs b/server/foundry-data-product-consumer.test.mjs index df3eda2..6c3786f 100644 --- a/server/foundry-data-product-consumer.test.mjs +++ b/server/foundry-data-product-consumer.test.mjs @@ -1079,3 +1079,46 @@ test("non-geometric unit profile aspect fails closed on projection drift and inj } } }); + +test("restricted aspect consumers require the exact declared binding data class", async () => { + for (const [bindingDataClass, expected] of [ + ["restricted", "ok"], + ["operational", "data_product_consumer_data_class_mismatch"], + ]) { + const stateDir = await mkdtemp(join(tmpdir(), `foundry-consumer-data-class-${bindingDataClass}-`)); + const currentTarget = unitProfileV1Target(); + currentTarget.binding.dataClass = bindingDataClass; + const manager = createFoundryDataProductConsumerManager({ + stateDir, + dataPlaneUrl: "http://edp.test", + resolveTarget: async () => structuredClone(currentTarget), + readReaderToken: async () => "ndc_edprb_profile-reader-capability", + inspectReaderGrant: async () => ({ + product: unitProfileV1Product, + readerGrantAction: "reuse", + readerGrantGeneration: 1, + }), + resolvePolicy: () => ({ ...unitProfileV1Policy, dataClass: "restricted" }), + sanitizeSnapshot: (value) => value, + sanitizePatch: (value) => value, + fetchImpl: async () => Response.json(unitProfileV1Snapshot()), + }); + const input = { + applicationId: currentTarget.application.id, + pageId: currentTarget.page.id, + bindingId: currentTarget.binding.id, + }; + try { + if (expected === "ok") { + const plan = await manager.plan(input); + assert.equal(plan.configuration.policy.dataClass, "restricted"); + assert.equal(plan.configuration.target.dataClass, "restricted"); + } else { + await assert.rejects(manager.plan(input), new RegExp(expected)); + } + } finally { + await manager.shutdown(); + await rm(stateDir, { recursive: true, force: true }); + } + } +}); diff --git a/server/foundry-mcp.mjs b/server/foundry-mcp.mjs index d456bbf..f5ea078 100644 --- a/server/foundry-mcp.mjs +++ b/server/foundry-mcp.mjs @@ -310,6 +310,11 @@ const mapSubjectDetailProfileFieldInputSchema = { properties: { id: { type: "string", description: "Stable field presentation id." }, aspectId: { type: "string", description: "Provider-neutral subject aspect id; defaults to primary." }, + dataClass: { + type: "string", + enum: ["operational", "restricted"], + description: "Explicit field presentation class. Restricted identifiers require a restricted joined aspect.", + }, 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 }, @@ -715,6 +720,11 @@ const tools = [ 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." }, + dataClass: { + type: "string", + enum: ["operational", "restricted"], + description: "Declared data class, verified against the versioned Foundry consumer policy before runtime.", + }, }, }, }, diff --git a/server/map-subject-detail-profile.mjs b/server/map-subject-detail-profile.mjs index b9bc207..bae1c21 100644 --- a/server/map-subject-detail-profile.mjs +++ b/server/map-subject-detail-profile.mjs @@ -1,16 +1,20 @@ 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 SECRET_MATERIAL = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|decrypt|raw[_-]?params?)/i; +const RESTRICTED_IDENTIFIER = /(?:imei|phone|telephone|address)/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 FIELD_KEYS = new Set([ + "id", "aspectId", "source", "field", "label", "format", "unit", "allowedReadingIds", "dataClass", +]); const SOURCES = new Set(["fact", "attribute", "geometry", "context"]); const FORMATS = new Set([ "text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "telemetry_readings", ]); +const DATA_CLASSES = new Set(["operational", "restricted"]); const FACT_FIELDS = new Set(["sourceId", "semanticType", "observedAt", "receivedAt", "presentationStatus"]); const GEOMETRY_FIELDS = new Set(["latitude", "longitude"]); const CONTEXT_FIELDS = new Set(["dataProductId", "bindingId"]); @@ -75,11 +79,22 @@ function normalizeField(value) { 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 dataClass = value.dataClass === undefined + ? "operational" + : text(value.dataClass, 32, "invalid_map_subject_detail_profile_field_data_class"); + if (!DATA_CLASSES.has(dataClass)) fail("invalid_map_subject_detail_profile_field_data_class"); + if (source === "attribute" && SECRET_MATERIAL.test(field)) { + fail("map_subject_detail_profile_secret_field"); + } + if (source === "attribute" && RESTRICTED_IDENTIFIER.test(field) && dataClass !== "restricted") { + 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 (allowedReadingIds.some((item) => SECRET_MATERIAL.test(item) || RESTRICTED_IDENTIFIER.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"); } @@ -93,8 +108,9 @@ function normalizeField(value) { 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"), + label: fieldLabel(value.label, dataClass), format, + dataClass, ...(unit ? { unit } : {}), ...(format === "telemetry_readings" ? { allowedReadingIds } : {}), }; @@ -143,7 +159,15 @@ function fields(value, min, max, code) { function safeLabel(value, max, code) { const normalized = text(value, max, code); - if (SECRET_LIKE.test(normalized)) fail(code); + if (SECRET_MATERIAL.test(normalized)) fail(code); + return normalized; +} + +function fieldLabel(value, dataClass) { + const normalized = safeLabel(value, 100, "invalid_map_subject_detail_profile_field_label"); + if (RESTRICTED_IDENTIFIER.test(normalized) && dataClass !== "restricted") { + fail("map_subject_detail_profile_restricted_field"); + } return normalized; } diff --git a/server/map-subject-detail-profile.test.mjs b/server/map-subject-detail-profile.test.mjs index e41f17a..aa32d2f 100644 --- a/server/map-subject-detail-profile.test.mjs +++ b/server/map-subject-detail-profile.test.mjs @@ -24,13 +24,29 @@ test("dynamic sensor readings are fail-closed until ids are explicitly classifie 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/); + assert.throws(() => normalizeMapSubjectDetailProfile(restrictedField), /map_subject_detail_profile_secret_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("restricted identifiers require an explicit restricted presentation class", () => { + const profile = structuredClone(registry.profiles[0]); + const field = profile.tabs[0].sections[0].fields[0]; + field.field = "device_phone_primary"; + field.label = "Телефон 1"; + assert.throws( + () => normalizeMapSubjectDetailProfile(profile), + /map_subject_detail_profile_restricted_field/, + ); + field.dataClass = "restricted"; + assert.equal( + normalizeMapSubjectDetailProfile(profile).tabs[0].sections[0].fields[0].dataClass, + "restricted", + ); +}); + test("profiles reject unknown schema fields and duplicate presentation ids", () => { const unknown = structuredClone(registry.profiles[0]); unknown.provider = "gelios";