From 92070f21e84b8611f59d5ac498763a81a288d2ff Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 19:15:12 +0300 Subject: [PATCH] feat(provider): catalogue complete Gelios data surface --- .../external-provider-contract/package.json | 2 +- .../providers/gelios/CAPABILITY_CATALOG.md | 28 ++ .../gelios/capability-catalog-v1.mjs | 332 ++++++++++++++++++ .../providers/gelios/index.mjs | 2 + .../external-provider-contract/src/index.mjs | 5 + .../src/provider-capability-catalog.mjs | 178 ++++++++++ .../test/provider-capability-catalog.test.mjs | 51 +++ 7 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 packages/external-provider-contract/providers/gelios/CAPABILITY_CATALOG.md create mode 100644 packages/external-provider-contract/providers/gelios/capability-catalog-v1.mjs create mode 100644 packages/external-provider-contract/providers/gelios/index.mjs create mode 100644 packages/external-provider-contract/src/provider-capability-catalog.mjs create mode 100644 packages/external-provider-contract/test/provider-capability-catalog.test.mjs diff --git a/packages/external-provider-contract/package.json b/packages/external-provider-contract/package.json index 21b6c97..2e6a07b 100644 --- a/packages/external-provider-contract/package.json +++ b/packages/external-provider-contract/package.json @@ -9,6 +9,6 @@ "./providers/*": "./providers/*/index.mjs" }, "scripts": { - "check": "node test/contract.test.mjs && node test/data-product.test.mjs && node test/provider-package.test.mjs && node test/zone-source.test.mjs && node test/engine-private-extension.test.mjs" + "check": "node test/contract.test.mjs && node test/data-product.test.mjs && node test/provider-package.test.mjs && node test/provider-capability-catalog.test.mjs && node test/zone-source.test.mjs && node test/engine-private-extension.test.mjs" } } diff --git a/packages/external-provider-contract/providers/gelios/CAPABILITY_CATALOG.md b/packages/external-provider-contract/providers/gelios/CAPABILITY_CATALOG.md new file mode 100644 index 0000000..fa79fbd --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/CAPABILITY_CATALOG.md @@ -0,0 +1,28 @@ +# Gelios capability catalog + +`capability-catalog-v1.mjs` is the complete classified inventory for the +current `UnitModelOutput` surface and the adjacent object APIs that NODE.DC may +consume. Its authority is the official Gelios OpenAPI, checked on 2026-07-22. + +The catalog is deliberately separate from an active L2 package. `implemented` +means a capability already has a governed collection and Data Product path; +`catalogued` means NODE.DC understands the surface but has not activated it; +`disabled` is mandatory for control and destructive operations. + +The catalog accounts for every top-level Unit field and all twelve official +`/api/v1/units` include flags. It also records the separate read surfaces for +sensor metadata, latest sensor values, maintenance, filling, schedules and +command audit. Raw messages, decrypt material and command execution never +enter an operational UI projection. + +Cadence is a product boundary: + +- `hot`: current position, state, counters and classified latest readings; +- `warm`: slowly changing operational records such as maintenance plans; +- `cold`: unit, equipment and calculation profiles; +- `on_demand`: restricted or historical details that need explicit policy; +- `never`: secret, raw or command material outside the telemetry plane. + +Provider packages may implement catalogued families incrementally, but they +must not move fields between classifications or cadences implicitly. A new +Data Product and its Foundry subject aspect must be explicit and versioned. diff --git a/packages/external-provider-contract/providers/gelios/capability-catalog-v1.mjs b/packages/external-provider-contract/providers/gelios/capability-catalog-v1.mjs new file mode 100644 index 0000000..f0f115e --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/capability-catalog-v1.mjs @@ -0,0 +1,332 @@ +import { PROVIDER_CAPABILITY_CATALOG_SCHEMA_VERSION } from "../../src/provider-capability-catalog.mjs"; + +const unitCapabilityId = "gelios.units.current.read"; + +export const geliosCapabilityCatalogV1 = deepFreeze({ + schemaVersion: PROVIDER_CAPABILITY_CATALOG_SCHEMA_VERSION, + providerId: "gelios", + version: "1.0.0", + authority: { + kind: "official-openapi", + url: "https://api.geliospro.com/openapi.json", + checkedAt: "2026-07-22T15:30:00Z", + }, + coverage: { + schemaId: "UnitModelOutput", + sourceRoots: [ + "activatedAt", "availableToUsers", "blockTime", "commands", "counters", "createdAt", + "creator", "currentUserAccess", "customFields", "driver", "extraInfo", "fuelParams", + "hwDecryptKey", "hwManufacturer", "hwType", "id", "imei", "imeiIsVisible", "isBlock", + "isFree", "lastMsg", "lastSensorsVal", "maintenancePlan", "name", "phone", "phone2", + "phone2IsVisible", "phoneIsVisible", "preSetCommandGroup", "removed", "removedAt", "sensors", + "stationaryLat", "stationaryLon", "storageInfo", "tripParams", "unblockTime", "unitType", + ], + }, + queryFlags: [ + flag("inclextra", ["gelios.unit.asset_profile", "gelios.unit.asset_identifiers", "gelios.unit.extra_internal"]), + flag("incltrip", ["gelios.unit.trip_settings"]), + flag("inclfuel", ["gelios.unit.fuel_settings"]), + flag("inclcntrs", ["gelios.unit.counters"]), + flag("inclcstmflds", ["gelios.unit.custom_fields"]), + flag("inclsnsrs", ["gelios.unit.sensor_definitions", "gelios.unit.sensor_conversion"]), + flag("inclcmmnds", ["gelios.unit.command_catalog"]), + flag("incldrvrs", ["gelios.unit.driver"]), + flag("inclstinf", ["gelios.unit.storage"]), + flag("inclusersav", ["gelios.unit.access_metadata"]), + flag("incllsv", ["gelios.unit.latest_sensor_values"]), + flag("inclplan", ["gelios.unit.maintenance_plan"]), + ], + dataFamilies: [ + family({ + id: "gelios.unit.identity", + title: "Stable unit identity and type", + sourcePaths: ["id", "name", "unitType.id", "unitType.name", "unitType.type"], + cadence: "cold", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.unit"], uiTab: "overview", + }), + family({ + id: "gelios.unit.lifecycle", + title: "Activation, blocking and removal lifecycle", + sourcePaths: ["activatedAt", "blockTime", "createdAt", "isBlock", "isFree", "removed", "removedAt", "unblockTime"], + cadence: "cold", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.unit"], uiTab: "overview", + }), + family({ + id: "gelios.unit.access_metadata", + title: "Provider ownership and access metadata", + sourcePaths: ["availableToUsers[]", "creator.id", "creator.login", "currentUserAccess"], + cadence: "cold", classification: "internal", disposition: "catalogue", status: "catalogued", + ontologyEntities: ["gelios.unit"], + }), + family({ + id: "gelios.unit.equipment", + title: "Tracker hardware classification", + sourcePaths: ["hwManufacturer.id", "hwManufacturer.name", "hwType.id", "hwType.name"], + cadence: "cold", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.tracker_device"], uiTab: "equipment", + }), + family({ + id: "gelios.unit.device_identifiers", + title: "Restricted device and phone identifiers", + sourcePaths: ["imei", "imeiIsVisible", "phone", "phone2", "phone2IsVisible", "phoneIsVisible"], + cadence: "on_demand", classification: "restricted", disposition: "profile", status: "catalogued", + ontologyEntities: ["gelios.tracker_device"], uiTab: "equipment", + }), + family({ + id: "gelios.unit.cryptographic_material", + title: "Provider cryptographic material", + sourcePaths: ["hwDecryptKey"], + cadence: "never", classification: "secret", disposition: "forbid", status: "forbidden", + ontologyEntities: ["gelios.tracker_device"], + }), + family({ + id: "gelios.unit.stationary_location", + title: "Restricted configured stationary location", + sourcePaths: ["stationaryLat", "stationaryLon"], + cadence: "on_demand", classification: "restricted", disposition: "profile", status: "catalogued", + ontologyEntities: ["gelios.unit"], uiTab: "position", + }), + family({ + id: "gelios.unit.position_current", + title: "Current normalized position and movement sample", + sourcePaths: [ + "lastMsg.course", "lastMsg.height", "lastMsg.lat", "lastMsg.lon", "lastMsg.receiveTime", + "lastMsg.sats", "lastMsg.speed", "lastMsg.time", + ], + cadence: "hot", classification: "operational", disposition: "publish", status: "implemented", + ontologyEntities: ["gelios.telemetry_snapshot", "map.moving_object"], uiTab: "position", + dataProductIds: ["fleet.positions.current.v5"], + }), + family({ + id: "gelios.unit.raw_message_parameters", + title: "Unclassified raw tracker message parameters", + sourcePaths: ["lastMsg.params"], + cadence: "never", classification: "unclassified", disposition: "forbid", status: "forbidden", + ontologyEntities: ["gelios.telemetry_snapshot"], + }), + family({ + id: "gelios.unit.counters", + title: "Operational mileage and engine-hour counters", + sourcePaths: ["counters.engineHours.value", "counters.mileage.value"], + cadence: "hot", classification: "operational", disposition: "publish", status: "implemented", + ontologyEntities: ["gelios.telemetry_snapshot"], uiTab: "counters", + dataProductIds: ["fleet.positions.current.v5"], + }), + family({ + id: "gelios.unit.latest_sensor_values", + title: "Latest values for explicitly classified sensors", + sourcePaths: ["lastSensorsVal[].tm", "lastSensorsVal[].val"], + cadence: "hot", classification: "unclassified", disposition: "profile", status: "implemented", + ontologyEntities: ["gelios.sensor_reading"], uiTab: "telemetry", + }), + family({ + id: "gelios.unit.sensor_definitions", + title: "Sensor identity, units, visibility and limits", + sourcePaths: [ + "sensors[].displayFuelTankVolume", "sensors[].fuelConsumptionRate", "sensors[].fuelTankVolume", + "sensors[].id", "sensors[].isVisible", "sensors[].maxValidParamValue", "sensors[].measure", + "sensors[].minValidParamValue", "sensors[].msgParam", "sensors[].name", "sensors[].type.id", "sensors[].type.name", + ], + cadence: "cold", classification: "unclassified", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.sensor_definition"], uiTab: "equipment", + }), + family({ + id: "gelios.unit.sensor_conversion", + title: "Raw sensor conversion tables", + sourcePaths: ["sensors[].conversionTable"], + cadence: "cold", classification: "internal", disposition: "catalogue", status: "catalogued", + ontologyEntities: ["gelios.sensor_definition"], + }), + family({ + id: "gelios.unit.asset_profile", + title: "Vehicle and asset descriptive profile", + sourcePaths: [ + "extraInfo.brand", "extraInfo.color", "extraInfo.engineCubicCapacity", "extraInfo.enginePower", + "extraInfo.fuelType", "extraInfo.iconLink", "extraInfo.model", "extraInfo.year", + ], + cadence: "cold", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.unit"], uiTab: "equipment", + }), + family({ + id: "gelios.unit.asset_identifiers", + title: "Restricted VIN and number plate", + sourcePaths: ["extraInfo.numberPlate", "extraInfo.vin"], + cadence: "on_demand", classification: "restricted", disposition: "profile", status: "catalogued", + ontologyEntities: ["gelios.unit"], uiTab: "equipment", + }), + family({ + id: "gelios.unit.extra_internal", + title: "Provider autocomplete metadata", + sourcePaths: ["extraInfo.autocompleteParams"], + cadence: "cold", classification: "internal", disposition: "catalogue", status: "catalogued", + ontologyEntities: ["gelios.unit"], + }), + family({ + id: "gelios.unit.trip_settings", + title: "Trip detection and movement policy", + sourcePaths: prefixed("tripParams", [ + "correctedEngineHoursFactor", "correctedMileageFactor", "filterBySatellitesCountIsOn", + "filterBySatellitesCountValue", "filterEmissions", "limitAcceleration", "lostConnectionIsOn", + "lostConnectionTimeValue", "maximumPermissibleSpeed", "maximumValidHeight", "maximumValidSpeed", + "mileageByIgnition", "minimumMovementSpeed", "minimumMovementTime", "minimumParkingTime", + "minimumTimeStop", "minimumTripDistance", "minimumValidHeight", "speedMeasurementItemsId", + "speedParam", "tripDetectionType.id", "tripDetectionType.name", "useOdometer", + ]), + cadence: "cold", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.unit"], uiTab: "settings", + }), + family({ + id: "gelios.unit.fuel_settings", + title: "Fuel calculation and theft/filling policy", + sourcePaths: prefixed("fuelParams", [ + "cityConsumptionSummer", "cityConsumptionWinter", "fillingByIgnOff", "fillingInterval", + "fillingSplitTimeout", "fillingTheftOnParkingOnly", "filterZeroFls", "idleConsumptionSummer", + "idleConsumptionWinter", "ignoreMsgsTimeAfterStart", "includeTheftsInConsumption", + "maximumConsumptionForPeriod", "minimumFilling", "minimumFuelTheft", "outOfTheCityConsumptionSummer", + "outOfTheCityConsumptionWinter", "secondsToIgnoreAfterMovementStart", "theftByIgnOff", + "theftSplitTimeout", "useAverageFlsValue", "useFilterIsOn", "useFilterValue", + "useNewFuelCalcAlgorithm", "useSeparateFls", "winterEnd", "winterStart", + ]), + cadence: "cold", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.fuel_profile"], uiTab: "settings", + }), + family({ + id: "gelios.unit.maintenance_plan", + title: "Maintenance thresholds and progress", + sourcePaths: prefixed("maintenancePlan[]", [ + "created_at", "description", "id", "interval_day", "interval_mileage", "interval_motohour", + "is_day", "is_mileage", "is_motohour", "last_day", "last_mileage", "last_motohour", + "name", "repeat_count", "total_count", "unit_id", "updated_at", + ]), + cadence: "warm", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.maintenance_plan"], uiTab: "maintenance", + }), + family({ + id: "gelios.unit.custom_fields", + title: "Tenant-defined fields requiring explicit classification", + sourcePaths: ["customFields[].id", "customFields[].isVisible", "customFields[].name", "customFields[].value"], + cadence: "cold", classification: "unclassified", disposition: "profile", status: "catalogued", + ontologyEntities: ["gelios.custom_field"], uiTab: "diagnostics", + }), + family({ + id: "gelios.unit.driver", + title: "Driver personal data", + sourcePaths: ["driver.name", "driver.phone"], + cadence: "on_demand", classification: "restricted", disposition: "profile", status: "catalogued", + ontologyEntities: ["gelios.unit"], + }), + family({ + id: "gelios.unit.storage", + title: "Provider storage policy", + sourcePaths: [ + "storageInfo.storagePeriod.description", "storageInfo.storagePeriod.id", "storageInfo.storagePeriod.name", + "storageInfo.storagePeriodLastUpdUtc", + ], + cadence: "cold", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.unit"], uiTab: "equipment", + }), + family({ + id: "gelios.unit.command_catalog", + title: "Command templates and preset groups", + sourcePaths: [ + "commands[].id", "commands[].name", "commands[].text", "commands[].type", + "preSetCommandGroup.id", "preSetCommandGroup.name", + ], + cadence: "never", classification: "control", disposition: "catalogue", status: "catalogued", + ontologyEntities: ["integration.command_capability"], + }), + family({ + id: "gelios.unit.maintenance_events", + title: "Maintenance event history", + sourcePaths: ["maintenanceEvents[].id", "maintenanceEvents[].date", "maintenanceEvents[].description"], + cadence: "on_demand", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.maintenance_plan"], uiTab: "maintenance", + }), + family({ + id: "gelios.unit.filling_events", + title: "Fuel filling event history", + sourcePaths: ["fillingEvents[].id", "fillingEvents[].date", "fillingEvents[].volume", "fillingEvents[].price"], + cadence: "on_demand", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.fuel_profile"], uiTab: "maintenance", + }), + family({ + id: "gelios.unit.work_schedule", + title: "Shifts and work-time rules", + sourcePaths: ["shifts[].id", "shifts[].name", "workTime[].id", "workTime[].startDay", "workTime[].endDay"], + cadence: "cold", classification: "operational", disposition: "profile", status: "planned", + ontologyEntities: ["gelios.unit"], uiTab: "settings", + }), + family({ + id: "gelios.unit.command_history", + title: "Command execution audit history", + sourcePaths: ["commandHistory[].id", "commandHistory[].status", "commandHistory[].timeCmd", "commandHistory[].timeSend"], + cadence: "on_demand", classification: "control", disposition: "catalogue", status: "catalogued", + ontologyEntities: ["integration.command_capability"], + }), + family({ + id: "gelios.unit.raw_messages", + title: "Pure and raw tracker messages", + sourcePaths: ["rawMessages[].payload"], + cadence: "never", classification: "restricted", disposition: "forbid", status: "forbidden", + ontologyEntities: ["gelios.telemetry_snapshot"], + }), + family({ + id: "gelios.geozones.current", + title: "Provider geozone catalogue", + sourcePaths: ["geozones[].geometry", "geozones[].id", "geozones[].name"], + cadence: "cold", classification: "operational", disposition: "publish", status: "implemented", + ontologyEntities: ["map.zone"], dataProductIds: ["map.gelios-geofences.current.v1"], + }), + ], + capabilities: [ + capability("gelios.units.current.read", "GET", "/api/v1/units", "read", "implemented", [ + "gelios.unit.identity", "gelios.unit.lifecycle", "gelios.unit.access_metadata", "gelios.unit.equipment", + "gelios.unit.device_identifiers", "gelios.unit.cryptographic_material", "gelios.unit.stationary_location", + "gelios.unit.position_current", "gelios.unit.raw_message_parameters", "gelios.unit.counters", + "gelios.unit.latest_sensor_values", "gelios.unit.sensor_definitions", "gelios.unit.sensor_conversion", + "gelios.unit.asset_profile", "gelios.unit.asset_identifiers", "gelios.unit.extra_internal", + "gelios.unit.trip_settings", "gelios.unit.fuel_settings", "gelios.unit.maintenance_plan", + "gelios.unit.custom_fields", "gelios.unit.driver", "gelios.unit.storage", "gelios.unit.command_catalog", + ]), + capability("gelios.unit.current.read", "GET", "/api/v1/units/{unit_id}", "read", "catalogued", ["gelios.unit.identity"]), + capability("gelios.units.last_messages.read", "POST", "/api/v1/units/last-msgs/full", "read", "catalogued", ["gelios.unit.position_current"]), + capability("gelios.unit.sensors.read", "GET", "/api/v1/units/{unit_id}/sensors", "metadata", "catalogued", ["gelios.unit.sensor_definitions", "gelios.unit.sensor_conversion"]), + capability("gelios.unit.sensor_values.read", "GET", "/api/v1/units/{unit_id}/sensors/values/last", "read", "catalogued", ["gelios.unit.latest_sensor_values"]), + capability("gelios.unit.maintenance_plan.read", "GET", "/api/v1/units/{unit_id}/maintenance/plan", "read", "catalogued", ["gelios.unit.maintenance_plan"]), + capability("gelios.unit.maintenance_events.read", "GET", "/api/v1/units/{unit_id}/maintenance/event", "read", "catalogued", ["gelios.unit.maintenance_events"]), + capability("gelios.unit.filling_events.read", "GET", "/api/v1/units/{unit_id}/filling/event", "read", "catalogued", ["gelios.unit.filling_events"]), + capability("gelios.unit.shifts.read", "GET", "/api/v1/units/{unit_id}/shifts", "read", "catalogued", ["gelios.unit.work_schedule"]), + capability("gelios.unit.work_time.read", "GET", "/api/v1/units/{unit_id}/work-time", "read", "catalogued", ["gelios.unit.work_schedule"]), + capability("gelios.unit.command_history.read", "GET", "/api/v1/units/{unit_id}/commands/history", "metadata", "catalogued", ["gelios.unit.command_history"]), + capability("gelios.unit.commands.read", "GET", "/api/v1/units/{unit_id}/commands", "metadata", "catalogued", ["gelios.unit.command_catalog"]), + capability("gelios.unit.last_message_params.read", "GET", "/api/v1/units/{unit_id}/last-msg-params", "metadata", "catalogued", ["gelios.unit.raw_message_parameters"]), + capability("gelios.unit.raw_message.read", "GET", "/api/v1/units/{unit_id}/last-msg/raw", "read", "disabled", ["gelios.unit.raw_messages"]), + capability("gelios.geozones.current.read", "GET", "/api/v1/geozones", "read", "implemented", ["gelios.geozones.current"]), + capability("gelios.unit.command.send", "POST", "/api/v1/units/{unit_id}/commands/send", "control", "disabled", ["gelios.unit.command_catalog"]), + capability("gelios.unit.update", "PATCH", "/api/v1/units/{unit_id}", "write", "disabled", ["gelios.unit.identity"]), + capability("gelios.unit.delete", "DELETE", "/api/v1/units/{unit_id}", "destructive", "disabled", ["gelios.unit.lifecycle"]), + ], +}); + +function flag(id, familyIds) { + return { id, capabilityId: unitCapabilityId, familyIds }; +} + +function family({ dataProductIds = [], ...value }) { + return { ...value, dataProductIds }; +} + +function capability(id, method, path, classification, status, familyIds) { + return { id, method, path, classification, status, familyIds }; +} + +function prefixed(prefix, fields) { + return fields.map((field) => `${prefix}.${field}`); +} + +function deepFreeze(input) { + if (!input || typeof input !== "object" || Object.isFrozen(input)) return input; + Object.freeze(input); + for (const child of Object.values(input)) deepFreeze(child); + return input; +} diff --git a/packages/external-provider-contract/providers/gelios/index.mjs b/packages/external-provider-contract/providers/gelios/index.mjs new file mode 100644 index 0000000..6f1a7b2 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/index.mjs @@ -0,0 +1,2 @@ +export { geliosCapabilityCatalogV1 } from "./capability-catalog-v1.mjs"; +export * from "./v7/index.mjs"; diff --git a/packages/external-provider-contract/src/index.mjs b/packages/external-provider-contract/src/index.mjs index 1f0e377..5a45300 100644 --- a/packages/external-provider-contract/src/index.mjs +++ b/packages/external-provider-contract/src/index.mjs @@ -35,6 +35,11 @@ export { validateProviderPackage, } from "./provider-package.mjs"; +export { + PROVIDER_CAPABILITY_CATALOG_SCHEMA_VERSION, + validateProviderCapabilityCatalog, +} from "./provider-capability-catalog.mjs"; + export { DATA_PRODUCT_HISTORY_SCHEMA_VERSION, DATA_PRODUCT_PATCH_SCHEMA_VERSION, diff --git a/packages/external-provider-contract/src/provider-capability-catalog.mjs b/packages/external-provider-contract/src/provider-capability-catalog.mjs new file mode 100644 index 0000000..730d952 --- /dev/null +++ b/packages/external-provider-contract/src/provider-capability-catalog.mjs @@ -0,0 +1,178 @@ +export const PROVIDER_CAPABILITY_CATALOG_SCHEMA_VERSION = "nodedc.external-provider-capability-catalog/v1"; + +const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,159}$/; +const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; +const SOURCE_PATH = /^[A-Za-z_][A-Za-z0-9_]*(?:\[\])?(?:\.[A-Za-z_][A-Za-z0-9_]*(?:\[\])?)*$/; +const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); +const CAPABILITY_CLASSES = new Set(["read", "metadata", "write", "destructive", "control"]); +const CAPABILITY_STATUSES = new Set(["implemented", "catalogued", "disabled"]); +const CADENCE_CLASSES = new Set(["hot", "warm", "cold", "on_demand", "never"]); +const DATA_CLASSES = new Set(["operational", "restricted", "secret", "control", "internal", "unclassified"]); +const DISPOSITIONS = new Set(["publish", "profile", "catalogue", "forbid"]); +const FAMILY_STATUSES = new Set(["implemented", "planned", "catalogued", "forbidden"]); +const SENSITIVE_PATH = /(?:imei|phone|decrypt|vin|numberPlate|driver|creator|access|customFields|stationary(?:Lat|Lon)|(?:^|\.)raw(?:\.|$)|(?:^|\.)params(?:\.|$))/i; +const CATALOG_KEYS = new Set(["schemaVersion", "providerId", "version", "authority", "coverage", "queryFlags", "dataFamilies", "capabilities"]); +const AUTHORITY_KEYS = new Set(["kind", "url", "checkedAt"]); +const COVERAGE_KEYS = new Set(["schemaId", "sourceRoots"]); +const QUERY_FLAG_KEYS = new Set(["id", "capabilityId", "familyIds"]); +const FAMILY_KEYS = new Set(["id", "title", "sourcePaths", "cadence", "classification", "disposition", "status", "ontologyEntities", "uiTab", "dataProductIds"]); +const CAPABILITY_KEYS = new Set(["id", "method", "path", "classification", "status", "familyIds"]); + +export function validateProviderCapabilityCatalog(value) { + const errors = []; + if (!plain(value)) return result(["catalog_must_be_object"]); + rejectUnknown(value, CATALOG_KEYS, "catalog", errors); + if (value.schemaVersion !== PROVIDER_CAPABILITY_CATALOG_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); + identifier(value.providerId, "providerId", errors); + if (typeof value.version !== "string" || !SEMVER.test(value.version)) errors.push("version_invalid"); + validateAuthority(value.authority, errors); + validateCoverage(value.coverage, errors); + + const families = artifactArray(value.dataFamilies, "dataFamilies", validateFamily, errors); + const capabilities = artifactArray(value.capabilities, "capabilities", validateCapability, errors); + const flags = artifactArray(value.queryFlags, "queryFlags", validateQueryFlag, errors); + unique(families.map((item) => item.id), "dataFamilies.id_duplicate", errors); + unique(capabilities.map((item) => item.id), "capabilities.id_duplicate", errors); + unique(flags.map((item) => item.id), "queryFlags.id_duplicate", errors); + + const familyIds = new Set(families.map((item) => item.id)); + const capabilityIds = new Set(capabilities.map((item) => item.id)); + for (const [index, family] of families.entries()) { + for (const entity of family.ontologyEntities || []) identifier(entity, `dataFamilies[${index}].ontologyEntities`, errors); + for (const product of family.dataProductIds || []) identifier(product, `dataFamilies[${index}].dataProductIds`, errors); + if (family.disposition === "publish" && !(family.dataProductIds || []).length) { + errors.push(`dataFamilies[${index}].publish_requires_data_product`); + } + if (new Set(["restricted", "secret", "control", "internal", "unclassified"]).has(family.classification) + && family.disposition === "publish") { + errors.push(`dataFamilies[${index}].restricted_family_must_not_publish`); + } + if ((family.sourcePaths || []).some((path) => SENSITIVE_PATH.test(path)) + && family.classification === "operational") { + errors.push(`dataFamilies[${index}].sensitive_path_must_be_classified`); + } + } + unique(families.flatMap((family) => family.sourcePaths || []), "dataFamilies.sourcePath_duplicate", errors); + + for (const [index, capability] of capabilities.entries()) { + for (const familyId of capability.familyIds || []) { + if (!familyIds.has(familyId)) errors.push(`capabilities[${index}].family_not_found:${familyId}`); + } + if (capability.classification === "control" && capability.status !== "disabled") { + errors.push(`capabilities[${index}].control_capability_must_be_disabled`); + } + if (capability.classification === "destructive" && capability.status !== "disabled") { + errors.push(`capabilities[${index}].destructive_capability_must_be_disabled`); + } + } + for (const [index, flag] of flags.entries()) { + if (!capabilityIds.has(flag.capabilityId)) errors.push(`queryFlags[${index}].capability_not_found`); + for (const familyId of flag.familyIds || []) { + if (!familyIds.has(familyId)) errors.push(`queryFlags[${index}].family_not_found:${familyId}`); + } + } + + const roots = new Set(families.flatMap((family) => (family.sourcePaths || []).map(sourceRoot))); + for (const root of value.coverage?.sourceRoots || []) { + if (!roots.has(root)) errors.push(`coverage.source_root_unaccounted:${root}`); + } + return result(errors); +} + +function validateAuthority(value, errors) { + if (!plain(value)) return errors.push("authority_must_be_object"); + rejectUnknown(value, AUTHORITY_KEYS, "authority", errors); + if (value.kind !== "official-openapi") errors.push("authority.kind_invalid"); + if (typeof value.url !== "string" || !/^https:\/\//.test(value.url)) errors.push("authority.url_invalid"); + if (typeof value.checkedAt !== "string" || Number.isNaN(Date.parse(value.checkedAt))) errors.push("authority.checkedAt_invalid"); +} + +function validateCoverage(value, errors) { + if (!plain(value)) return errors.push("coverage_must_be_object"); + rejectUnknown(value, COVERAGE_KEYS, "coverage", errors); + if (typeof value.schemaId !== "string" || !value.schemaId.trim()) errors.push("coverage.schemaId_invalid"); + strings(value.sourceRoots, "coverage.sourceRoots", errors, { sourcePaths: true, min: 1 }); +} + +function validateFamily(value, index, errors) { + const path = `dataFamilies[${index}]`; + if (!plain(value)) return errors.push(`${path}_must_be_object`); + rejectUnknown(value, FAMILY_KEYS, path, errors); + identifier(value.id, `${path}.id`, errors); + nonempty(value.title, `${path}.title`, errors); + strings(value.sourcePaths, `${path}.sourcePaths`, errors, { sourcePaths: true, min: 1 }); + if (!CADENCE_CLASSES.has(value.cadence)) errors.push(`${path}.cadence_invalid`); + if (!DATA_CLASSES.has(value.classification)) errors.push(`${path}.classification_invalid`); + if (!DISPOSITIONS.has(value.disposition)) errors.push(`${path}.disposition_invalid`); + if (!FAMILY_STATUSES.has(value.status)) errors.push(`${path}.status_invalid`); + strings(value.ontologyEntities, `${path}.ontologyEntities`, errors); + strings(value.dataProductIds, `${path}.dataProductIds`, errors); + if (value.uiTab !== undefined && value.uiTab !== null) identifier(value.uiTab, `${path}.uiTab`, errors); +} + +function validateCapability(value, index, errors) { + const path = `capabilities[${index}]`; + if (!plain(value)) return errors.push(`${path}_must_be_object`); + rejectUnknown(value, CAPABILITY_KEYS, path, errors); + identifier(value.id, `${path}.id`, errors); + if (!HTTP_METHODS.has(value.method)) errors.push(`${path}.method_invalid`); + if (typeof value.path !== "string" || !value.path.startsWith("/api/")) errors.push(`${path}.path_invalid`); + if (!CAPABILITY_CLASSES.has(value.classification)) errors.push(`${path}.classification_invalid`); + if (!CAPABILITY_STATUSES.has(value.status)) errors.push(`${path}.status_invalid`); + strings(value.familyIds, `${path}.familyIds`, errors); +} + +function validateQueryFlag(value, index, errors) { + const path = `queryFlags[${index}]`; + if (!plain(value)) return errors.push(`${path}_must_be_object`); + rejectUnknown(value, QUERY_FLAG_KEYS, path, errors); + if (typeof value.id !== "string" || !/^[a-z][a-z0-9_]{2,63}$/.test(value.id)) errors.push(`${path}.id_invalid`); + identifier(value.capabilityId, `${path}.capabilityId`, errors); + strings(value.familyIds, `${path}.familyIds`, errors, { min: 1 }); +} + +function artifactArray(value, path, validator, errors) { + if (!Array.isArray(value) || value.length < 1 || value.length > 256) { + errors.push(`${path}_must_be_bounded_nonempty_array`); + return []; + } + value.forEach((item, index) => validator(item, index, errors)); + return value.filter(plain); +} + +function strings(value, path, errors, { sourcePaths = false, min = 0 } = {}) { + if (!Array.isArray(value) || value.length < min || value.length > 512) return errors.push(`${path}_invalid`); + for (const item of value) { + if (typeof item !== "string" || !item.trim() || (sourcePaths && !SOURCE_PATH.test(item))) errors.push(`${path}_invalid`); + } + unique(value, `${path}_duplicate`, errors); +} + +function identifier(value, path, errors) { + if (typeof value !== "string" || !IDENTIFIER.test(value)) errors.push(`${path}_invalid`); +} + +function nonempty(value, path, errors) { + if (typeof value !== "string" || !value.trim()) errors.push(`${path}_invalid`); +} + +function rejectUnknown(value, allowed, path, errors) { + if (!plain(value)) return; + for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key}_not_allowed`); +} + +function unique(values, code, errors) { + if (new Set(values).size !== values.length) errors.push(code); +} + +function sourceRoot(path) { + return String(path).split(".")[0].replace(/\[\]$/, ""); +} + +function plain(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function result(errors) { + return { ok: errors.length === 0, errors }; +} diff --git a/packages/external-provider-contract/test/provider-capability-catalog.test.mjs b/packages/external-provider-contract/test/provider-capability-catalog.test.mjs new file mode 100644 index 0000000..9538e5e --- /dev/null +++ b/packages/external-provider-contract/test/provider-capability-catalog.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { validateProviderCapabilityCatalog } from "../src/index.mjs"; +import { geliosCapabilityCatalogV1, geliosProviderPackageV7 } from "../providers/gelios/index.mjs"; + +assert.deepEqual(validateProviderCapabilityCatalog(geliosCapabilityCatalogV1), { ok: true, errors: [] }); + +const expectedUnitRoots = [ + "activatedAt", "availableToUsers", "blockTime", "commands", "counters", "createdAt", + "creator", "currentUserAccess", "customFields", "driver", "extraInfo", "fuelParams", + "hwDecryptKey", "hwManufacturer", "hwType", "id", "imei", "imeiIsVisible", "isBlock", + "isFree", "lastMsg", "lastSensorsVal", "maintenancePlan", "name", "phone", "phone2", + "phone2IsVisible", "phoneIsVisible", "preSetCommandGroup", "removed", "removedAt", "sensors", + "stationaryLat", "stationaryLon", "storageInfo", "tripParams", "unblockTime", "unitType", +]; +assert.deepEqual(geliosCapabilityCatalogV1.coverage.sourceRoots, expectedUnitRoots); + +const expectedIncludeFlags = [ + "inclextra", "incltrip", "inclfuel", "inclcntrs", "inclcstmflds", "inclsnsrs", + "inclcmmnds", "incldrvrs", "inclstinf", "inclusersav", "incllsv", "inclplan", +]; +assert.deepEqual(geliosCapabilityCatalogV1.queryFlags.map(({ id }) => id), expectedIncludeFlags); + +const unitCapability = geliosProviderPackageV7.capabilities.find(({ id }) => id === "gelios.units.current.read"); +assert.ok(unitCapability); +for (const enabledFlag of Object.keys(unitCapability.request.query)) { + assert.ok(expectedIncludeFlags.includes(enabledFlag), `v7 query flag is missing from the capability catalog: ${enabledFlag}`); +} + +assert.deepEqual( + geliosCapabilityCatalogV1.capabilities.filter(({ status }) => status === "implemented").map(({ id }) => id), + ["gelios.units.current.read", "gelios.geozones.current.read"], +); +assert.equal( + geliosCapabilityCatalogV1.capabilities.filter(({ classification }) => classification === "control").every(({ status }) => status === "disabled"), + true, +); + +const restrictedPublish = structuredClone(geliosCapabilityCatalogV1); +restrictedPublish.dataFamilies.find(({ id }) => id === "gelios.unit.device_identifiers").disposition = "publish"; +restrictedPublish.dataFamilies.find(({ id }) => id === "gelios.unit.device_identifiers").dataProductIds = ["unsafe.identifiers.current.v1"]; +assert.equal(validateProviderCapabilityCatalog(restrictedPublish).ok, false); + +const missingCoverage = structuredClone(geliosCapabilityCatalogV1); +missingCoverage.dataFamilies.find(({ id }) => id === "gelios.unit.identity").sourcePaths = ["name", "unitType.id"]; +assert.ok(validateProviderCapabilityCatalog(missingCoverage).errors.includes("coverage.source_root_unaccounted:id")); + +const enabledControl = structuredClone(geliosCapabilityCatalogV1); +enabledControl.capabilities.find(({ id }) => id === "gelios.unit.command.send").status = "catalogued"; +assert.equal(validateProviderCapabilityCatalog(enabledControl).ok, false); + +console.log("Gelios provider capability catalog: ok");