From 3415674e76efaf5c33fb2b810d1b843e7d4914c3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 17 Jul 2026 18:08:36 +0300 Subject: [PATCH] feat(connectors): version provider packages and ontology contracts --- .../examples/gelios-positions-current.v1.mjs | 11 +- .../external-provider-contract/package.json | 8 +- .../v1/fixtures/units-current.fixture.mjs | 74 ++ .../providers/gelios/v1/index.mjs | 11 + .../providers/gelios/v1/package.mjs | 374 ++++++ .../src/contract-version.mjs | 1 + .../src/data-plane.mjs | 10 + .../src/data-product.mjs | 4 +- .../src/engine-credential-sink.mjs | 662 ----------- .../external-provider-contract/src/index.mjs | 153 +-- .../src/intake-batch.mjs | 152 +++ .../src/provider-package.mjs | 1006 +++++++++++++++++ .../src/sensitive-field-policy.mjs | 16 + .../test/contract.test.mjs | 8 + .../test/data-product.test.mjs | 8 + .../test/engine-credential-sink.test.mjs | 242 ---- .../test/engine-private-extension.test.mjs | 10 + .../test/provider-package.test.mjs | 464 ++++++++ .../domain-packages/gelios/aliases.json | 12 +- .../domain-packages/gelios/entities.json | 32 +- .../domain-packages/gelios/evidence.json | 12 +- .../domain-packages/gelios/guardrails.json | 14 +- .../domain-packages/gelios/package.json | 6 +- .../domain-packages/gelios/relations.json | 26 +- .../domain-packages/integration/aliases.json | 4 +- .../domain-packages/integration/entities.json | 32 +- .../domain-packages/integration/evidence.json | 6 +- .../integration/guardrails.json | 10 +- .../domain-packages/integration/package.json | 4 +- .../integration/relations.json | 8 +- .../gelios-map-moving-object.fixture.json | 52 +- 31 files changed, 2283 insertions(+), 1149 deletions(-) create mode 100644 packages/external-provider-contract/providers/gelios/v1/fixtures/units-current.fixture.mjs create mode 100644 packages/external-provider-contract/providers/gelios/v1/index.mjs create mode 100644 packages/external-provider-contract/providers/gelios/v1/package.mjs create mode 100644 packages/external-provider-contract/src/contract-version.mjs create mode 100644 packages/external-provider-contract/src/data-plane.mjs delete mode 100644 packages/external-provider-contract/src/engine-credential-sink.mjs create mode 100644 packages/external-provider-contract/src/intake-batch.mjs create mode 100644 packages/external-provider-contract/src/provider-package.mjs create mode 100644 packages/external-provider-contract/src/sensitive-field-policy.mjs delete mode 100644 packages/external-provider-contract/test/engine-credential-sink.test.mjs create mode 100644 packages/external-provider-contract/test/provider-package.test.mjs diff --git a/packages/external-provider-contract/examples/gelios-positions-current.v1.mjs b/packages/external-provider-contract/examples/gelios-positions-current.v1.mjs index 9828dec..0e148ca 100644 --- a/packages/external-provider-contract/examples/gelios-positions-current.v1.mjs +++ b/packages/external-provider-contract/examples/gelios-positions-current.v1.mjs @@ -8,15 +8,14 @@ export const geliosPositionsCurrentExample = Object.freeze({ version: "1.0.0", ontology: { packageId: "gelios", - revision: "gelios.positions.v1", + revision: "ontology.gelios.v1", }, l2Template: { - id: "gelios.positions.current", + id: "gelios.positions.current.l2.v1", version: "1.0.0", }, capabilities: [ { id: "gelios.units.current.read", classification: "read" }, - { id: "gelios.units.command.write", classification: "write" }, ], dataProductIds: ["fleet.positions.current.v1"], }, @@ -25,10 +24,10 @@ export const geliosPositionsCurrentExample = Object.freeze({ id: "gelios.sample-fleet", providerId: "gelios", tenantId: "sample-tenant", - credentialRef: { owner: "engine", reference: "engine-credential-reference" }, + credentialRef: { owner: "ndc_l2_credentials", reference: "ndc-credref:provider-example-0001" }, scope: { capabilityIds: ["gelios.units.current.read"], - fieldPolicyId: "fleet.position.display.v1", + fieldPolicyId: "gelios.positions.current.fields.v1", }, }, collectionProfile: { @@ -36,7 +35,7 @@ export const geliosPositionsCurrentExample = Object.freeze({ id: "gelios.positions.realtime", connectionId: "gelios.sample-fleet", mode: "realtime", - schedule: { intervalMs: 15000 }, + schedule: { intervalMs: 10000 }, capabilityIds: ["gelios.units.current.read"], dataProductId: "fleet.positions.current.v1", }, diff --git a/packages/external-provider-contract/package.json b/packages/external-provider-contract/package.json index 0493ee7..7a665dc 100644 --- a/packages/external-provider-contract/package.json +++ b/packages/external-provider-contract/package.json @@ -3,8 +3,12 @@ "version": "0.1.0", "private": true, "type": "module", - "exports": "./src/index.mjs", + "exports": { + ".": "./src/index.mjs", + "./data-plane": "./src/data-plane.mjs", + "./providers/*": "./providers/*/index.mjs" + }, "scripts": { - "check": "node test/contract.test.mjs && node test/data-product.test.mjs && node test/engine-credential-sink.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/engine-private-extension.test.mjs" } } diff --git a/packages/external-provider-contract/providers/gelios/v1/fixtures/units-current.fixture.mjs b/packages/external-provider-contract/providers/gelios/v1/fixtures/units-current.fixture.mjs new file mode 100644 index 0000000..6c3331d --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v1/fixtures/units-current.fixture.mjs @@ -0,0 +1,74 @@ +import { DATA_PRODUCT_PUBLISH_SCHEMA_VERSION } from "../../../../src/data-product.mjs"; + +export const geliosUnitsCurrentFixtureV1 = Object.freeze({ + sourceResponse: { + units: [{ + id: 1001, + name: "Synthetic unit 1001", + hw_id: "restricted-fixture-value", + phone: "restricted-fixture-value", + lastMsg: { + time: 1784203200, + lat: "55.7558", + lon: "37.6173", + speed: 24.5, + course: 91, + height: 164, + gps_valid: true, + sats: 11, + hdop: 0.8, + accuracy: 4.2, + params: "restricted-fixture-value", + }, + }, { + id: "001002", + name: "Synthetic unit 1002", + }], + }, + collectionContext: { + receivedAt: "2026-07-16T12:00:00.000Z", + }, + expectedPublish: { + schemaVersion: DATA_PRODUCT_PUBLISH_SCHEMA_VERSION, + batch: { + runId: "gelios-fixture-run-20260716", + sequence: 0, + idempotencyKey: "gelios-fixture-run-20260716.batch-0", + }, + facts: [{ + sourceId: "gelios-unit-1001", + semanticType: "map.moving_object", + observedAt: "2026-07-16T12:00:00.000Z", + geometry: { + type: "Point", + coordinates: [37.6173, 55.7558], + }, + attributes: { + course_degrees: 91, + display_name: "Synthetic unit 1001", + elevation_meters: 164, + hdop: 0.8, + horizontal_accuracy_meters: 4.2, + object_kind: "tracked_unit", + operational_status: "active", + position_source: "gelios", + position_valid: true, + quality_flags: [], + satellite_count: 11, + speed_kph: 24.5, + }, + }, { + sourceId: "gelios-unit-001002", + semanticType: "map.moving_object", + observedAt: "2026-07-16T12:00:00.000Z", + attributes: { + display_name: "Synthetic unit 1002", + object_kind: "tracked_unit", + operational_status: "no_position", + position_source: "gelios", + position_valid: false, + quality_flags: ["missing_geometry"], + }, + }], + }, +}); diff --git a/packages/external-provider-contract/providers/gelios/v1/index.mjs b/packages/external-provider-contract/providers/gelios/v1/index.mjs new file mode 100644 index 0000000..66a0857 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v1/index.mjs @@ -0,0 +1,11 @@ +export { + GELIOS_POSITIONS_DATA_PRODUCT_ID, + GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + GELIOS_POSITIONS_ONTOLOGY_REVISION, + GELIOS_PROVIDER_PACKAGE_ID, + GELIOS_PROVIDER_PACKAGE_VERSION, + GELIOS_UNIT_SOURCE_ID_PREFIX, + geliosProviderPackageV1, +} from "./package.mjs"; + +export { geliosUnitsCurrentFixtureV1 } from "./fixtures/units-current.fixture.mjs"; diff --git a/packages/external-provider-contract/providers/gelios/v1/package.mjs b/packages/external-provider-contract/providers/gelios/v1/package.mjs new file mode 100644 index 0000000..aa42042 --- /dev/null +++ b/packages/external-provider-contract/providers/gelios/v1/package.mjs @@ -0,0 +1,374 @@ +import { + L2_TEMPLATE_SCHEMA_VERSION, + PROVIDER_PACKAGE_SCHEMA_VERSION, + SEMANTIC_MAPPING_SCHEMA_VERSION, +} from "../../../src/provider-package.mjs"; + +export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v1"; +export const GELIOS_PROVIDER_PACKAGE_VERSION = "1.0.0"; +export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v1"; +export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "1.0.0"; +export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v1"; +export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-"; + +const targetFields = Object.freeze([ + "course_degrees", + "display_name", + "elevation_meters", + "geometry", + "hdop", + "horizontal_accuracy_meters", + "object_kind", + "operational_status", + "position_source", + "position_valid", + "quality_flags", + "satellite_count", + "speed_kph", +]); + +export const geliosProviderPackageV1 = deepFreeze({ + schemaVersion: PROVIDER_PACKAGE_SCHEMA_VERSION, + id: GELIOS_PROVIDER_PACKAGE_ID, + providerId: "gelios", + version: GELIOS_PROVIDER_PACKAGE_VERSION, + manifest: { + id: "gelios.provider.manifest.v1", + providerId: "gelios", + version: GELIOS_PROVIDER_PACKAGE_VERSION, + ontology: { + packageId: "gelios", + revision: "ontology.gelios.v1", + }, + authModeIds: ["gelios.rest-bearer.v1"], + capabilityIds: ["gelios.units.current.read"], + fieldPolicyIds: ["gelios.positions.current.fields.v1"], + collectionProfileIds: [ + "gelios.positions.current.realtime.v1", + "gelios.positions.current.manual.v1", + ], + dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID], + mappingContractIds: ["gelios.units.to.fleet.positions.current.v1"], + l2TemplateIds: ["gelios.positions.current.l2.v1"], + }, + authModes: [{ + id: "gelios.rest-bearer.v1", + kind: "api_token", + credentialOwner: "ndc_l2_credentials", + bindingCardinality: "one_per_connection", + transport: { + placement: "header", + name: "Authorization", + }, + tokenLifecycle: { + artifacts: ["access", "refresh"], + requestArtifact: "access", + refreshMode: "operator_managed", + }, + secretHandling: { + store: "ndc_l2_credentials", + exposeToGraph: false, + persistInPackage: false, + }, + }], + capabilities: [{ + id: "gelios.units.current.read", + classification: "read", + status: "implemented", + authModeId: "gelios.rest-bearer.v1", + request: { + method: "GET", + baseUrl: "https://api.geliospro.com", + path: "/api/v1/units", + query: {}, + response: { + collectionPaths: ["units", "data", "data.units", "result", "$"], + pagination: "single_bounded_response", + }, + }, + entityScope: { + mode: "all_visible_to_credential", + refresh: "each_collection_run", + businessEntityFilter: "forbidden", + }, + }], + fieldPolicies: [{ + id: "gelios.positions.current.fields.v1", + version: "1.0.0", + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + targetFields: [...targetFields], + unknownSourceFields: "drop", + dynamicSourceFields: "drop_until_classified", + restrictedSourcePaths: [ + "decrypt_key", + "hw_id", + "imei", + "info", + "lmsg.address", + "lmsg.params", + "lmsg.sensors", + "lastMsg.address", + "lastMsg.params", + "lastMsg.sensors", + "lastMessage.address", + "lastMessage.params", + "lastMessage.sensors", + "phone", + "phone2", + "sensors", + ], + }], + collectionProfiles: [{ + id: "gelios.positions.current.realtime.v1", + version: "1.0.0", + mode: "realtime", + schedule: { intervalMs: 10000 }, + capabilityIds: ["gelios.units.current.read"], + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + mappingContractId: "gelios.units.to.fleet.positions.current.v1", + fieldPolicyId: "gelios.positions.current.fields.v1", + l2TemplateId: "gelios.positions.current.l2.v1", + entityScope: { + mode: "all_visible_to_credential", + refresh: "each_collection_run", + businessEntityFilter: "forbidden", + }, + batching: { maxFacts: 5000 }, + cardinality: { + maxCurrentEntities: 5000, + onExceed: "require_partitioned_data_product", + }, + retry: { + maxAttempts: 4, + backoff: "exponential_with_jitter", + }, + }, { + id: "gelios.positions.current.manual.v1", + version: "1.0.0", + mode: "manual", + capabilityIds: ["gelios.units.current.read"], + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + mappingContractId: "gelios.units.to.fleet.positions.current.v1", + fieldPolicyId: "gelios.positions.current.fields.v1", + l2TemplateId: "gelios.positions.current.l2.v1", + entityScope: { + mode: "all_visible_to_credential", + refresh: "each_collection_run", + businessEntityFilter: "forbidden", + }, + batching: { maxFacts: 5000 }, + cardinality: { + maxCurrentEntities: 5000, + onExceed: "require_partitioned_data_product", + }, + retry: { + maxAttempts: 4, + backoff: "exponential_with_jitter", + }, + }], + dataProducts: [{ + id: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + deliveryMode: "snapshot+patch", + semanticTypes: ["map.moving_object"], + fields: [...targetFields], + history: { + mode: "sampled", + intervalMs: 60000, + strategy: "latest-per-entity-per-bucket", + retentionDays: 90, + }, + }], + mappingContracts: [{ + schemaVersion: SEMANTIC_MAPPING_SCHEMA_VERSION, + id: "gelios.units.to.fleet.positions.current.v1", + version: "1.0.0", + sourceCapabilityId: "gelios.units.current.read", + fieldPolicyId: "gelios.positions.current.fields.v1", + target: { + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION, + semanticType: "map.moving_object", + }, + derivations: { + position_valid: { + kind: "boolean_rule", + rules: [ + "geometry.coordinates_finite_wgs84", + "provider.gps_valid_not_false", + ], + default: false, + }, + operational_status: { + kind: "ordered_rules", + rules: [ + "position_invalid.no_position", + "observed_age_gt_limit.stale", + "otherwise.active", + ], + default: "active", + parameters: { staleAfterMs: 60000 }, + }, + quality_flags: { + kind: "flag_set", + rules: [ + "geometry_missing.missing_geometry", + "provider_gps_false.gps_invalid", + "satellite_count_zero.no_satellites", + "observed_age_gt_limit.stale", + ], + default: [], + parameters: { staleAfterMs: 60000 }, + }, + }, + fact: { + sourceId: { + strategy: "first_non_empty", + paths: ["id", "unit_id", "unitId"], + coerce: "string", + prefix: GELIOS_UNIT_SOURCE_ID_PREFIX, + }, + semanticType: { constant: "map.moving_object" }, + observedAt: { + strategy: "first_non_empty", + paths: [ + "lmsg.time", + "lmsg.ts", + "lmsg.timestamp", + "last_msg.time", + "last_msg.timestamp", + "lastMsg.time", + "lastMessage.time", + "last_msg_time", + "lmsg_time", + ], + coerce: "unix_or_iso_timestamp", + fallback: "collection_received_at", + }, + geometry: { + type: "Point", + longitude: { + strategy: "first_non_empty", + paths: ["lmsg.lon", "lmsg.lng", "lmsg.longitude", "last_msg.lon", "lastMsg.lon", "lastMsg.lng", "lastMsg.longitude", "lastMessage.lon", "lastMessage.lng", "lastMessage.longitude", "lon", "lng", "longitude"], + coerce: "number", + }, + latitude: { + strategy: "first_non_empty", + paths: ["lmsg.lat", "lmsg.latitude", "last_msg.lat", "lastMsg.lat", "lastMsg.latitude", "lastMessage.lat", "lastMessage.latitude", "lat", "latitude"], + coerce: "number", + }, + omitIfInvalid: true, + }, + attributes: { + course_degrees: { + strategy: "first_non_empty", + paths: ["lmsg.course", "lmsg.angle", "last_msg.course", "lastMsg.course", "lastMsg.angle", "lastMessage.course", "lastMessage.angle", "course"], + coerce: "number", + omitIfMissing: true, + }, + display_name: { + strategy: "first_non_empty", + paths: ["name", "unit_name", "title", "label"], + coerce: "string", + }, + elevation_meters: { + strategy: "first_non_empty", + paths: ["lmsg.height", "lmsg.altitude", "last_msg.height", "lastMsg.height", "lastMsg.altitude", "lastMessage.height", "lastMessage.altitude", "height", "altitude"], + coerce: "number", + omitIfMissing: true, + }, + hdop: { + strategy: "first_non_empty", + paths: ["lmsg.hdop", "last_msg.hdop", "lastMsg.hdop", "lastMessage.hdop", "gps_hdop", "hdop"], + coerce: "number", + omitIfMissing: true, + }, + horizontal_accuracy_meters: { + strategy: "first_non_empty", + paths: ["lmsg.accuracy", "last_msg.accuracy", "lastMsg.accuracy", "lastMessage.accuracy", "gps_accuracy_m", "accuracy_m", "accuracy"], + coerce: "number", + omitIfMissing: true, + }, + object_kind: { constant: "tracked_unit" }, + operational_status: { derive: "operational_status" }, + position_source: { constant: "gelios" }, + position_valid: { derive: "position_valid" }, + quality_flags: { derive: "quality_flags" }, + satellite_count: { + strategy: "first_non_empty", + paths: ["lmsg.sats", "lmsg.satellites", "last_msg.sats", "lastMsg.sats", "lastMsg.satellites", "lastMessage.sats", "lastMessage.satellites", "gps_sats", "sats", "satellites"], + coerce: "number", + omitIfMissing: true, + }, + speed_kph: { + strategy: "first_non_empty", + paths: ["lmsg.speed", "last_msg.speed", "lastMsg.speed", "lastMessage.speed", "speed"], + coerce: "number", + omitIfMissing: true, + }, + }, + }, + }], + l2Templates: [{ + schemaVersion: L2_TEMPLATE_SCHEMA_VERSION, + id: "gelios.positions.current.l2.v1", + version: "1.0.0", + runtime: "ndc_l2", + instanceMode: "one_connection_per_workflow", + connectionParameters: [ + "tenant_id", + "connection_id", + "collection_profile_id", + "provider_credential_ref", + ], + credentialBindings: [{ + role: "provider", + owner: "ndc_l2_credentials", + management: "connection_input", + authModeId: "gelios.rest-bearer.v1", + }, { + role: "publisher", + owner: "ndc_l2_credentials", + management: "control_plane_managed", + nodeType: "n8n-nodes-ndc.ndcDataProductPublish", + }], + steps: [{ + id: "collection.trigger", + kind: "collection_trigger", + collectionProfileDriven: true, + }, { + id: "provider.fetch", + kind: "provider_request", + capabilityId: "gelios.units.current.read", + }, { + id: "provider.extract-items", + kind: "extract_items", + capabilityId: "gelios.units.current.read", + }, { + id: "ontology.map", + kind: "semantic_mapping", + mappingContractId: "gelios.units.to.fleet.positions.current.v1", + }, { + id: "data-product.publish", + kind: "data_product_publish", + dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID, + nodeType: "n8n-nodes-ndc.ndcDataProductPublish", + }], + invariants: { + oneConnectionPerInstance: true, + providerSecretByReferenceOnly: true, + publisherBindingControlPlaneManaged: true, + callerScopeForbidden: true, + providerSpecificServiceForbidden: true, + }, + }], +}); + +function deepFreeze(value) { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} diff --git a/packages/external-provider-contract/src/contract-version.mjs b/packages/external-provider-contract/src/contract-version.mjs new file mode 100644 index 0000000..ac10ef8 --- /dev/null +++ b/packages/external-provider-contract/src/contract-version.mjs @@ -0,0 +1 @@ +export const EXTERNAL_PROVIDER_CONTRACT_VERSION = "nodedc.external-provider-contract/v1"; diff --git a/packages/external-provider-contract/src/data-plane.mjs b/packages/external-provider-contract/src/data-plane.mjs new file mode 100644 index 0000000..ce4d6ee --- /dev/null +++ b/packages/external-provider-contract/src/data-plane.mjs @@ -0,0 +1,10 @@ +export { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs"; +export { validateIntakeBatch } from "./intake-batch.mjs"; +export { + DATA_PRODUCT_PATCH_SCHEMA_VERSION, + DATA_PRODUCT_PUBLISH_SCHEMA_VERSION, + DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION, + validateDataProductPatch, + validateDataProductPublish, + validateDataProductSnapshot, +} from "./data-product.mjs"; diff --git a/packages/external-provider-contract/src/data-product.mjs b/packages/external-provider-contract/src/data-product.mjs index e9bfd05..1a44ffa 100644 --- a/packages/external-provider-contract/src/data-product.mjs +++ b/packages/external-provider-contract/src/data-product.mjs @@ -2,11 +2,11 @@ export const DATA_PRODUCT_PUBLISH_SCHEMA_VERSION = "nodedc.data-product.publish/ export const DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION = "nodedc.data-product.snapshot/v1"; export const DATA_PRODUCT_PATCH_SCHEMA_VERSION = "nodedc.data-product.patch/v1"; +import { SECRET_LIKE_KEY, SECRET_LIKE_VALUE } from "./sensitive-field-policy.mjs"; + const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; const CURSOR = /^(?:0|[1-9]\d*)$/; -const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i; -const SECRET_LIKE_VALUE = /(?:ndc_edp(?:wb|rb)_[A-Za-z0-9_-]*|[?&](?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)=|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i; const MAX_BATCH_SEQUENCE = 2_147_483_647; const MAX_FACT_ATTRIBUTES_BYTES = 64 * 1024; diff --git a/packages/external-provider-contract/src/engine-credential-sink.mjs b/packages/external-provider-contract/src/engine-credential-sink.mjs deleted file mode 100644 index c924b9a..0000000 --- a/packages/external-provider-contract/src/engine-credential-sink.mjs +++ /dev/null @@ -1,662 +0,0 @@ -import { createHash, verify as verifySignature } from "node:crypto"; - -export const ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION = "nodedc.engine.credential-sink.provision/v1"; -export const ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION = "nodedc.engine.credential-sink.receipt/v1"; -export const ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION = "nodedc.engine.credential-sink.rollback/v1"; -export const ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION = "nodedc.engine.credential-sink.rollback-receipt/v1"; -export const ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION = "nodedc.engine.credential-sink.audit/v1"; - -const HASH = /^sha256:[a-f0-9]{64}$/; -const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; -const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,159}$/; -const NODE_TYPE = /^[a-z][A-Za-z0-9.-]{2,159}$/; -const CREDENTIAL_TYPE = /^[a-z][A-Za-z0-9]{2,127}$/; -const CREDENTIAL_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9_-]{5,159}$/; -const REASON_CODE = /^[a-z][a-z0-9_.:-]{2,127}$/; -const ED25519_SIGNATURE = /^[A-Za-z0-9_-]{86}$/; -const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|material|value)/i; -const SECRET_LIKE_VALUE = /(?:ndc_(?:edp(?:wb|rb)|fndbg)_[A-Za-z0-9_-]+|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i; -const MAX_BINDINGS = 32; -const MAX_REQUEST_LIFETIME_MS = 15 * 60 * 1000; -const MAX_REQUEST_CLOCK_SKEW_MS = 60 * 1000; - -const CAPABILITY_SPECS = Object.freeze({ - "external-data-plane.writer": Object.freeze({ - nodeType: "n8n-nodes-ndc.ndcDataProductPublish", - credentialType: "ndcDataProductWriterApi", - materialPattern: /^ndc_edpwb_[A-Za-z0-9_-]{43}$/, - }), - "external-data-plane.reader": Object.freeze({ - nodeType: "n8n-nodes-ndc.ndcDataProductRead", - credentialType: "ndcDataProductReaderApi", - materialPattern: /^ndc_edprb_[A-Za-z0-9_-]{43}$/, - }), - "foundry.binding": Object.freeze({ - nodeType: "n8n-nodes-ndc.ndcFoundryBinding", - credentialType: "ndcFoundryBindingApi", - materialPattern: /^ndc_fndbg_[A-Za-z0-9_-]{43}$/, - }), -}); - -export const ENGINE_CREDENTIAL_SINK_CAPABILITY_TYPES = Object.freeze(Object.keys(CAPABILITY_SPECS)); - -const PROVISION_KEYS = new Set(["schemaVersion", "transaction", "bindings"]); -const TRANSACTION_KEYS = new Set([ - "id", - "idempotencyKey", - "requestedAt", - "requestExpiresAt", - "policyHash", - "failureMode", - "issuer", - "attestation", -]); -const ISSUER_KEYS = new Set(["serviceId", "keyId"]); -const ATTESTATION_KEYS = new Set(["algorithm", "signature"]); -const BINDING_KEYS = new Set([ - "bindingId", - "capabilityType", - "grantId", - "target", - "expiresAt", - "policyHash", - "capabilityDigest", - "material", -]); -const TARGET_KEYS = new Set([ - "workflowId", - "workflowRevision", - "nodeId", - "nodeType", - "credentialType", -]); -const MATERIAL_KEYS = new Set(["format", "value"]); -const RECEIPT_KEYS = new Set([ - "schemaVersion", - "transactionId", - "idempotencyKey", - "outcome", - "policyHash", - "processedAt", - "credentials", - "rollback", - "errorCode", -]); -const RECEIPT_CREDENTIAL_KEYS = new Set([ - "bindingId", - "capabilityType", - "grantId", - "target", - "credentialRef", - "expiresAt", - "policyHash", - "capabilityDigest", - "disposition", -]); -const RECEIPT_ROLLBACK_KEYS = new Set(["status", "completedAt"]); -const ROLLBACK_REQUEST_KEYS = new Set(["schemaVersion", "rollback"]); -const ROLLBACK_KEYS = new Set([ - "id", - "idempotencyKey", - "transactionId", - "requestedAt", - "requestExpiresAt", - "policyHash", - "committedReceiptHash", - "reasonCode", -]); -const ROLLBACK_RECEIPT_KEYS = new Set([ - "schemaVersion", - "rollbackId", - "transactionId", - "outcome", - "policyHash", - "committedReceiptHash", - "processedAt", - "errorCode", -]); -const AUDIT_KEYS = new Set([ - "schemaVersion", - "eventId", - "transactionId", - "operationId", - "operation", - "outcome", - "occurredAt", - "policyHash", - "principal", - "targets", - "reasonCode", -]); -const PRINCIPAL_KEYS = new Set(["serviceId", "fingerprint"]); -const AUDIT_TARGET_KEYS = new Set([ - "bindingId", - "capabilityType", - "grantId", - "target", - "expiresAt", - "policyHash", - "capabilityDigest", - "credentialRefHash", -]); - -/** - * Validates the only request allowed to carry plaintext workload capability - * material across the trusted Platform -> Engine server boundary. Callers and - * receivers must never log, trace, persist or return this request body. - */ -export function validateEngineCredentialSinkProvision(value, { now = Date.now(), issuerPublicKeys } = {}) { - const errors = []; - const nowMs = normalizeNow(now, errors); - if (!isPlainObject(value)) return result(["credentialSinkProvision_must_be_object"]); - if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); - rejectUnknownKeys(value, PROVISION_KEYS, "credentialSinkProvision", errors); - - if (!isPlainObject(value.transaction)) { - errors.push("transaction_must_be_object"); - } else { - rejectUnknownKeys(value.transaction, TRANSACTION_KEYS, "transaction", errors); - requiredOpaqueId(value.transaction.id, "transaction.id", errors); - requiredOpaqueId(value.transaction.idempotencyKey, "transaction.idempotencyKey", errors); - requiredTimestamp(value.transaction.requestedAt, "transaction.requestedAt", errors); - requiredTimestamp(value.transaction.requestExpiresAt, "transaction.requestExpiresAt", errors); - requiredHash(value.transaction.policyHash, "transaction.policyHash", errors); - if (value.transaction.failureMode !== "rollback-all") errors.push("transaction.failureMode_must_be_rollback-all"); - validateIssuer(value.transaction.issuer, "transaction.issuer", errors); - validateAttestation(value.transaction.attestation, "transaction.attestation", errors); - validateRequestWindow(value.transaction.requestedAt, value.transaction.requestExpiresAt, nowMs, errors); - } - - if (!Array.isArray(value.bindings) || value.bindings.length === 0) { - errors.push("bindings_must_be_nonempty_array"); - } else if (value.bindings.length > MAX_BINDINGS) { - errors.push("bindings_limit_exceeded"); - } else { - const bindingIds = new Set(); - const targets = new Set(); - value.bindings.forEach((binding, index) => { - validateProvisionBinding(binding, index, value.transaction?.requestedAt, errors); - if (!isPlainObject(binding)) return; - if (bindingIds.has(binding.bindingId)) errors.push("bindings_bindingId_must_be_unique"); - bindingIds.add(binding.bindingId); - const targetKey = targetIdentity(binding.target); - if (targetKey && targets.has(targetKey)) errors.push("bindings_target_credential_must_be_unique"); - if (targetKey) targets.add(targetKey); - }); - } - - if (isPlainObject(value.transaction) && HASH.test(String(value.transaction.policyHash || ""))) { - const expectedHash = computeEngineCredentialSinkPolicyHash(value); - if (value.transaction.policyHash !== expectedHash) errors.push("transaction.policyHash_mismatch"); - verifyProvisionAttestation(value.transaction, issuerPublicKeys, errors); - } - return result(errors); -} - -/** - * Computes the aggregate policy digest over the complete secret-free request - * descriptor. Plaintext `material` is excluded by construction, while its - * high-entropy capability digest is included; changing a target, grant, - * capability, expiry, individual policy hash or transaction envelope changes - * the aggregate digest and invalidates the issuer attestation. - */ -export function computeEngineCredentialSinkPolicyHash(value) { - const descriptor = { - schemaVersion: value?.schemaVersion, - transaction: isPlainObject(value?.transaction) ? { - id: value.transaction.id, - idempotencyKey: value.transaction.idempotencyKey, - requestedAt: value.transaction.requestedAt, - requestExpiresAt: value.transaction.requestExpiresAt, - failureMode: value.transaction.failureMode, - issuer: value.transaction.issuer, - } : value?.transaction, - bindings: Array.isArray(value?.bindings) - ? value.bindings.map(secretFreeBindingDescriptor) - : value?.bindings, - }; - return `sha256:${createHash("sha256").update(stableJson(descriptor), "utf8").digest("hex")}`; -} - -/** - * Receipt is intentionally incapable of carrying credential material. When a - * request is supplied, the validator also proves the sink committed the exact - * requested workflow/node/type set without target substitution. - */ -export function validateEngineCredentialSinkReceipt(value, { request, issuerPublicKeys } = {}) { - const errors = []; - if (!isPlainObject(value)) return result(["credentialSinkReceipt_must_be_object"]); - if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); - rejectUnknownKeys(value, RECEIPT_KEYS, "credentialSinkReceipt", errors); - requiredOpaqueId(value.transactionId, "transactionId", errors); - requiredOpaqueId(value.idempotencyKey, "idempotencyKey", errors); - requiredHash(value.policyHash, "policyHash", errors); - requiredTimestamp(value.processedAt, "processedAt", errors); - const outcomes = new Set(["committed", "rolled-back", "rejected", "rollback-failed"]); - if (!outcomes.has(value.outcome)) errors.push("outcome_invalid"); - - if (!Array.isArray(value.credentials)) { - errors.push("credentials_must_be_array"); - } else { - const bindingIds = new Set(); - const refs = new Set(); - value.credentials.forEach((credential, index) => { - validateReceiptCredential(credential, index, errors); - if (!isPlainObject(credential)) return; - if (bindingIds.has(credential.bindingId)) errors.push("credentials_bindingId_must_be_unique"); - bindingIds.add(credential.bindingId); - if (refs.has(credential.credentialRef)) errors.push("credentials_credentialRef_must_be_unique"); - refs.add(credential.credentialRef); - }); - } - validateReceiptOutcome(value, errors); - if (containsSecretLikeMaterial(value)) errors.push("receipt_must_not_contain_secret_material"); - if (request !== undefined) compareReceiptToProvisionRequest(value, request, issuerPublicKeys, errors); - return result(errors); -} - -export function computeEngineCredentialSinkReceiptHash(value) { - return `sha256:${createHash("sha256").update(stableJson(value), "utf8").digest("hex")}`; -} - -export function validateEngineCredentialSinkRollback(value, { now = Date.now() } = {}) { - const errors = []; - const nowMs = normalizeNow(now, errors); - if (!isPlainObject(value)) return result(["credentialSinkRollback_must_be_object"]); - if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); - rejectUnknownKeys(value, ROLLBACK_REQUEST_KEYS, "credentialSinkRollback", errors); - if (!isPlainObject(value.rollback)) { - errors.push("rollback_must_be_object"); - return result(errors); - } - rejectUnknownKeys(value.rollback, ROLLBACK_KEYS, "rollback", errors); - requiredOpaqueId(value.rollback.id, "rollback.id", errors); - requiredOpaqueId(value.rollback.idempotencyKey, "rollback.idempotencyKey", errors); - requiredOpaqueId(value.rollback.transactionId, "rollback.transactionId", errors); - requiredTimestamp(value.rollback.requestedAt, "rollback.requestedAt", errors); - requiredTimestamp(value.rollback.requestExpiresAt, "rollback.requestExpiresAt", errors); - requiredHash(value.rollback.policyHash, "rollback.policyHash", errors); - requiredHash(value.rollback.committedReceiptHash, "rollback.committedReceiptHash", errors); - requiredReason(value.rollback.reasonCode, "rollback.reasonCode", errors); - validateRequestWindow(value.rollback.requestedAt, value.rollback.requestExpiresAt, nowMs, errors); - if (containsSecretLikeMaterial(value)) errors.push("rollback_must_not_contain_secret_material"); - return result(errors); -} - -export function validateEngineCredentialSinkRollbackReceipt(value, { request } = {}) { - const errors = []; - if (!isPlainObject(value)) return result(["credentialSinkRollbackReceipt_must_be_object"]); - if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); - rejectUnknownKeys(value, ROLLBACK_RECEIPT_KEYS, "credentialSinkRollbackReceipt", errors); - requiredOpaqueId(value.rollbackId, "rollbackId", errors); - requiredOpaqueId(value.transactionId, "transactionId", errors); - requiredHash(value.policyHash, "policyHash", errors); - requiredHash(value.committedReceiptHash, "committedReceiptHash", errors); - requiredTimestamp(value.processedAt, "processedAt", errors); - if (!new Set(["rolled-back", "rejected", "rollback-failed"]).has(value.outcome)) errors.push("outcome_invalid"); - if (value.outcome === "rolled-back") { - if (value.errorCode !== undefined) errors.push("errorCode_forbidden_for_success"); - } else { - requiredReason(value.errorCode, "errorCode", errors); - } - if (containsSecretLikeMaterial(value)) errors.push("rollbackReceipt_must_not_contain_secret_material"); - if (request !== undefined && isPlainObject(request?.rollback)) { - if (!validateEngineCredentialSinkRollback(request, { now: value.processedAt }).ok) { - errors.push("request_invalid_for_rollback_receipt_comparison"); - } - if (value.rollbackId !== request.rollback.id) errors.push("rollbackId_request_mismatch"); - if (value.transactionId !== request.rollback.transactionId) errors.push("transactionId_request_mismatch"); - if (value.policyHash !== request.rollback.policyHash) errors.push("policyHash_request_mismatch"); - if (value.committedReceiptHash !== request.rollback.committedReceiptHash) { - errors.push("committedReceiptHash_request_mismatch"); - } - } - return result(errors); -} - -export function validateEngineCredentialSinkAudit(value) { - const errors = []; - if (!isPlainObject(value)) return result(["credentialSinkAudit_must_be_object"]); - if (value.schemaVersion !== ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); - rejectUnknownKeys(value, AUDIT_KEYS, "credentialSinkAudit", errors); - requiredOpaqueId(value.eventId, "eventId", errors); - requiredOpaqueId(value.transactionId, "transactionId", errors); - requiredOpaqueId(value.operationId, "operationId", errors); - if (!new Set(["provision", "rollback"]).has(value.operation)) errors.push("operation_invalid"); - if (!new Set(["committed", "rolled-back", "rejected", "rollback-failed"]).has(value.outcome)) errors.push("outcome_invalid"); - if (value.operation === "provision" && value.outcome === "rolled-back" && !value.reasonCode) { - errors.push("reasonCode_required"); - } - if (value.operation === "rollback" && value.outcome === "committed") errors.push("rollback_outcome_invalid"); - requiredTimestamp(value.occurredAt, "occurredAt", errors); - requiredHash(value.policyHash, "policyHash", errors); - if (!isPlainObject(value.principal)) { - errors.push("principal_must_be_object"); - } else { - rejectUnknownKeys(value.principal, PRINCIPAL_KEYS, "principal", errors); - requiredIdentifier(value.principal.serviceId, "principal.serviceId", errors); - requiredHash(value.principal.fingerprint, "principal.fingerprint", errors); - } - if (!Array.isArray(value.targets)) { - errors.push("targets_must_be_array"); - } else { - value.targets.forEach((target, index) => validateAuditTarget(target, index, errors)); - } - if (value.reasonCode !== undefined) requiredReason(value.reasonCode, "reasonCode", errors); - if (new Set(["rejected", "rollback-failed"]).has(value.outcome) && value.reasonCode === undefined) { - errors.push("reasonCode_required"); - } - if (containsSecretLikeMaterial(value)) errors.push("audit_must_not_contain_secret_material"); - return result(errors); -} - -/** Returns audit-safe target descriptors; material and credential refs cannot escape. */ -export function engineCredentialSinkAuditTargets(request, receipt) { - const refByBinding = new Map( - Array.isArray(receipt?.credentials) - ? receipt.credentials.map((item) => [item.bindingId, item.credentialRef]) - : [], - ); - return Array.isArray(request?.bindings) ? request.bindings.map((binding) => { - const descriptor = secretFreeBindingDescriptor(binding); - const credentialRef = refByBinding.get(binding.bindingId); - return credentialRef - ? { ...descriptor, credentialRefHash: sha256Value(credentialRef) } - : descriptor; - }) : []; -} - -function validateProvisionBinding(value, index, requestedAt, errors) { - const path = `bindings[${index}]`; - if (!isPlainObject(value)) { - errors.push(`${path}_must_be_object`); - return; - } - rejectUnknownKeys(value, BINDING_KEYS, path, errors); - requiredIdentifier(value.bindingId, `${path}.bindingId`, errors); - const spec = CAPABILITY_SPECS[value.capabilityType]; - if (!spec) errors.push(`${path}.capabilityType_invalid`); - requiredOpaqueId(value.grantId, `${path}.grantId`, errors); - validateTarget(value.target, path, errors); - requiredTimestamp(value.expiresAt, `${path}.expiresAt`, errors); - requiredHash(value.policyHash, `${path}.policyHash`, errors); - requiredHash(value.capabilityDigest, `${path}.capabilityDigest`, errors); - if (isTimestamp(requestedAt) && isTimestamp(value.expiresAt) && Date.parse(value.expiresAt) <= Date.parse(requestedAt)) { - errors.push(`${path}.expiresAt_must_be_after_requestedAt`); - } - if (spec && isPlainObject(value.target)) { - if (value.target.nodeType !== spec.nodeType) errors.push(`${path}.target.nodeType_capability_mismatch`); - if (value.target.credentialType !== spec.credentialType) errors.push(`${path}.target.credentialType_capability_mismatch`); - } - if (!isPlainObject(value.material)) { - errors.push(`${path}.material_must_be_object`); - } else { - rejectUnknownKeys(value.material, MATERIAL_KEYS, `${path}.material`, errors); - if (value.material.format !== "opaque-bearer") errors.push(`${path}.material.format_must_be_opaque-bearer`); - if (!spec || typeof value.material.value !== "string" || !spec.materialPattern.test(value.material.value)) { - errors.push(`${path}.material.value_invalid_for_capability`); - } else if (value.capabilityDigest !== computeEngineCredentialCapabilityDigest(value.material.value)) { - errors.push(`${path}.capabilityDigest_material_mismatch`); - } - } -} - -function validateTarget(value, path, errors) { - if (!isPlainObject(value)) { - errors.push(`${path}.target_must_be_object`); - return; - } - rejectUnknownKeys(value, TARGET_KEYS, `${path}.target`, errors); - requiredOpaqueId(value.workflowId, `${path}.target.workflowId`, errors); - requiredOpaqueId(value.workflowRevision, `${path}.target.workflowRevision`, errors); - requiredOpaqueId(value.nodeId, `${path}.target.nodeId`, errors); - if (typeof value.nodeType !== "string" || !NODE_TYPE.test(value.nodeType)) errors.push(`${path}.target.nodeType_invalid`); - if (typeof value.credentialType !== "string" || !CREDENTIAL_TYPE.test(value.credentialType)) { - errors.push(`${path}.target.credentialType_invalid`); - } -} - -function validateReceiptCredential(value, index, errors) { - const path = `credentials[${index}]`; - if (!isPlainObject(value)) { - errors.push(`${path}_must_be_object`); - return; - } - rejectUnknownKeys(value, RECEIPT_CREDENTIAL_KEYS, path, errors); - requiredIdentifier(value.bindingId, `${path}.bindingId`, errors); - if (!CAPABILITY_SPECS[value.capabilityType]) errors.push(`${path}.capabilityType_invalid`); - requiredOpaqueId(value.grantId, `${path}.grantId`, errors); - validateTarget(value.target, path, errors); - if (typeof value.credentialRef !== "string" || !CREDENTIAL_REFERENCE.test(value.credentialRef)) { - errors.push(`${path}.credentialRef_invalid`); - } - requiredTimestamp(value.expiresAt, `${path}.expiresAt`, errors); - requiredHash(value.policyHash, `${path}.policyHash`, errors); - requiredHash(value.capabilityDigest, `${path}.capabilityDigest`, errors); - if (!new Set(["created", "reused", "rotated"]).has(value.disposition)) errors.push(`${path}.disposition_invalid`); -} - -function validateReceiptOutcome(value, errors) { - if (!isPlainObject(value.rollback)) { - errors.push("rollback_must_be_object"); - return; - } - rejectUnknownKeys(value.rollback, RECEIPT_ROLLBACK_KEYS, "rollback", errors); - const expectedRollback = { - committed: "not-required", - "rolled-back": "complete", - rejected: "not-started", - "rollback-failed": "incomplete", - }[value.outcome]; - if (expectedRollback && value.rollback.status !== expectedRollback) errors.push("rollback.status_outcome_mismatch"); - if (new Set(["complete", "incomplete"]).has(value.rollback.status)) { - requiredTimestamp(value.rollback.completedAt, "rollback.completedAt", errors); - } else if (value.rollback.completedAt !== undefined) { - errors.push("rollback.completedAt_not_allowed"); - } - if (value.outcome === "committed") { - if (!Array.isArray(value.credentials) || value.credentials.length === 0) errors.push("committed_credentials_required"); - if (value.errorCode !== undefined) errors.push("errorCode_forbidden_for_success"); - } else { - if (Array.isArray(value.credentials) && value.credentials.length !== 0) errors.push("noncommitted_credentials_must_be_empty"); - requiredReason(value.errorCode, "errorCode", errors); - } -} - -function compareReceiptToProvisionRequest(receipt, request, issuerPublicKeys, errors) { - const requestValidation = validateEngineCredentialSinkProvision(request, { - now: receipt.processedAt, - issuerPublicKeys, - }); - if (!requestValidation.ok) { - errors.push("request_invalid_for_receipt_comparison"); - return; - } - if (receipt.transactionId !== request.transaction.id) errors.push("transactionId_request_mismatch"); - if (receipt.idempotencyKey !== request.transaction.idempotencyKey) errors.push("idempotencyKey_request_mismatch"); - if (receipt.policyHash !== request.transaction.policyHash) errors.push("policyHash_request_mismatch"); - if (receipt.outcome !== "committed") return; - if (receipt.credentials.length !== request.bindings.length) errors.push("credentials_request_count_mismatch"); - const requested = new Map(request.bindings.map((binding) => [binding.bindingId, secretFreeBindingDescriptor(binding)])); - for (const credential of receipt.credentials) { - const expected = requested.get(credential.bindingId); - if (!expected || stableJson({ - bindingId: credential.bindingId, - capabilityType: credential.capabilityType, - grantId: credential.grantId, - target: credential.target, - expiresAt: credential.expiresAt, - policyHash: credential.policyHash, - capabilityDigest: credential.capabilityDigest, - }) !== stableJson(expected)) { - errors.push("credentials_request_target_mismatch"); - } - } -} - -function validateAuditTarget(value, index, errors) { - const path = `targets[${index}]`; - if (!isPlainObject(value)) { - errors.push(`${path}_must_be_object`); - return; - } - rejectUnknownKeys(value, AUDIT_TARGET_KEYS, path, errors); - requiredIdentifier(value.bindingId, `${path}.bindingId`, errors); - if (!CAPABILITY_SPECS[value.capabilityType]) errors.push(`${path}.capabilityType_invalid`); - requiredOpaqueId(value.grantId, `${path}.grantId`, errors); - validateTarget(value.target, path, errors); - requiredTimestamp(value.expiresAt, `${path}.expiresAt`, errors); - requiredHash(value.policyHash, `${path}.policyHash`, errors); - requiredHash(value.capabilityDigest, `${path}.capabilityDigest`, errors); - if (value.credentialRefHash !== undefined) requiredHash(value.credentialRefHash, `${path}.credentialRefHash`, errors); -} - -function secretFreeBindingDescriptor(value) { - return { - bindingId: value?.bindingId, - capabilityType: value?.capabilityType, - grantId: value?.grantId, - target: value?.target, - expiresAt: value?.expiresAt, - policyHash: value?.policyHash, - capabilityDigest: value?.capabilityDigest, - }; -} - -export function computeEngineCredentialCapabilityDigest(value) { - return sha256Value(value); -} - -function validateIssuer(value, path, errors) { - if (!isPlainObject(value)) { - errors.push(`${path}_must_be_object`); - return; - } - rejectUnknownKeys(value, ISSUER_KEYS, path, errors); - requiredIdentifier(value.serviceId, `${path}.serviceId`, errors); - requiredOpaqueId(value.keyId, `${path}.keyId`, errors); -} - -function validateAttestation(value, path, errors) { - if (!isPlainObject(value)) { - errors.push(`${path}_must_be_object`); - return; - } - rejectUnknownKeys(value, ATTESTATION_KEYS, path, errors); - if (value.algorithm !== "Ed25519") errors.push(`${path}.algorithm_must_be_Ed25519`); - if (typeof value.signature !== "string" || !ED25519_SIGNATURE.test(value.signature)) { - errors.push(`${path}.signature_invalid`); - } -} - -function verifyProvisionAttestation(transaction, issuerPublicKeys, errors) { - if (!isPlainObject(transaction?.issuer) || !isPlainObject(transaction?.attestation)) return; - if (transaction.attestation.algorithm !== "Ed25519" || !ED25519_SIGNATURE.test(String(transaction.attestation.signature || ""))) return; - const keyIdentity = `${transaction.issuer.serviceId}:${transaction.issuer.keyId}`; - const publicKey = isPlainObject(issuerPublicKeys) && Object.hasOwn(issuerPublicKeys, keyIdentity) - ? issuerPublicKeys[keyIdentity] - : undefined; - if (!publicKey) { - errors.push("transaction.issuer_public_key_required"); - return; - } - let valid = false; - try { - valid = verifySignature( - null, - Buffer.from(String(transaction.policyHash), "utf8"), - publicKey, - Buffer.from(transaction.attestation.signature, "base64url"), - ); - } catch { - valid = false; - } - if (!valid) errors.push("transaction.attestation_invalid"); -} - -function validateRequestWindow(requestedAt, requestExpiresAt, nowMs, errors) { - if (!isTimestamp(requestedAt) || !isTimestamp(requestExpiresAt)) return; - const requested = Date.parse(requestedAt); - const expires = Date.parse(requestExpiresAt); - if (expires <= requested) errors.push("requestExpiresAt_must_be_after_requestedAt"); - if (expires - requested > MAX_REQUEST_LIFETIME_MS) errors.push("request_lifetime_exceeds_15_minutes"); - if (Number.isFinite(nowMs)) { - if (requested > nowMs + MAX_REQUEST_CLOCK_SKEW_MS) errors.push("requestedAt_exceeds_clock_skew"); - if (expires <= nowMs) errors.push("request_expired"); - } -} - -function normalizeNow(value, errors) { - const normalized = value instanceof Date ? value.getTime() : typeof value === "string" ? Date.parse(value) : Number(value); - if (!Number.isFinite(normalized)) { - errors.push("validation_now_invalid"); - return Number.NaN; - } - return normalized; -} - -function targetIdentity(value) { - if (!isPlainObject(value)) return ""; - return [value.workflowId, value.workflowRevision, value.nodeId, value.nodeType, value.credentialType].join("\u0000"); -} - -function requiredIdentifier(value, path, errors) { - if (typeof value !== "string" || !IDENTIFIER.test(value)) errors.push(`${path}_invalid`); -} - -function requiredOpaqueId(value, path, errors) { - if (typeof value !== "string" || !OPAQUE_ID.test(value)) errors.push(`${path}_invalid`); -} - -function requiredHash(value, path, errors) { - if (typeof value !== "string" || !HASH.test(value)) errors.push(`${path}_invalid`); -} - -function requiredReason(value, path, errors) { - if (typeof value !== "string" || !REASON_CODE.test(value)) errors.push(`${path}_invalid`); -} - -function requiredTimestamp(value, path, errors) { - if (!isTimestamp(value)) errors.push(`${path}_invalid_timestamp`); -} - -function isTimestamp(value) { - return typeof value === "string" && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value; -} - -function sha256Value(value) { - return `sha256:${createHash("sha256").update(String(value), "utf8").digest("hex")}`; -} - -function stableJson(value) { - return JSON.stringify(sortValue(value)); -} - -function sortValue(value) { - if (Array.isArray(value)) return value.map(sortValue); - if (!isPlainObject(value)) return value; - return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])])); -} - -function containsSecretLikeMaterial(value) { - if (typeof value === "string") return SECRET_LIKE_VALUE.test(value); - if (Array.isArray(value)) return value.some(containsSecretLikeMaterial); - if (!isPlainObject(value)) return false; - return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretLikeMaterial(child)); -} - -function isPlainObject(value) { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function rejectUnknownKeys(value, allowed, path, errors) { - if (!isPlainObject(value)) return; - for (const key of Object.keys(value)) { - if (!allowed.has(key)) errors.push(`${path}.${key}_not_allowed`); - } -} - -function result(errors) { - const uniqueErrors = [...new Set(errors)]; - return Object.freeze({ ok: uniqueErrors.length === 0, errors: Object.freeze(uniqueErrors) }); -} diff --git a/packages/external-provider-contract/src/index.mjs b/packages/external-provider-contract/src/index.mjs index eb6546b..2fa8c3e 100644 --- a/packages/external-provider-contract/src/index.mjs +++ b/packages/external-provider-contract/src/index.mjs @@ -1,6 +1,23 @@ -export const EXTERNAL_PROVIDER_CONTRACT_VERSION = "nodedc.external-provider-contract/v1"; +import { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs"; + +export { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs"; +export { validateIntakeBatch } from "./intake-batch.mjs"; export const FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION = "nodedc.foundry.binding-upsert/v1"; +import { + SECRET_LIKE_KEY, + SECRET_LIKE_VALUE, +} from "./sensitive-field-policy.mjs"; + +export { + L2_CONNECTION_INSTANCE_SCHEMA_VERSION, + L2_TEMPLATE_SCHEMA_VERSION, + PROVIDER_PACKAGE_SCHEMA_VERSION, + SEMANTIC_MAPPING_SCHEMA_VERSION, + instantiateL2Connection, + validateProviderPackage, +} from "./provider-package.mjs"; + export { DATA_PRODUCT_PATCH_SCHEMA_VERSION, DATA_PRODUCT_PUBLISH_SCHEMA_VERSION, @@ -10,24 +27,6 @@ export { validateDataProductSnapshot, } from "./data-product.mjs"; -export { - ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION, - ENGINE_CREDENTIAL_SINK_CAPABILITY_TYPES, - ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION, - ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION, - ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION, - ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION, - computeEngineCredentialCapabilityDigest, - computeEngineCredentialSinkPolicyHash, - computeEngineCredentialSinkReceiptHash, - engineCredentialSinkAuditTargets, - validateEngineCredentialSinkAudit, - validateEngineCredentialSinkProvision, - validateEngineCredentialSinkReceipt, - validateEngineCredentialSinkRollback, - validateEngineCredentialSinkRollbackReceipt, -} from "./engine-credential-sink.mjs"; - export { ENGINE_PRIVATE_EXTENSION_APPLY_RECEIPT_SCHEMA_VERSION, ENGINE_PRIVATE_EXTENSION_APPLY_REQUEST_SCHEMA_VERSION, @@ -55,16 +54,11 @@ export { const COLLECTION_MODES = new Set(["realtime", "manual", "weekly", "history"]); const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); const CAPABILITY_CLASSIFICATIONS = new Set(["read", "metadata", "write", "destructive", "unknown"]); -const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i; -const SECRET_LIKE_REFERENCE = /(?:[?&](?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)=|(?:bearer|basic)\s+)/i; -const SECRET_LIKE_VALUE = /(?:ndc_edp(?:wb|rb)_[A-Za-z0-9_-]*|[?&](?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)=|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const FOUNDRY_APPLICATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const FOUNDRY_PAGE_ID = /^[a-z0-9][a-z0-9-]{0,79}$/; const FOUNDRY_SLOT_ID = /^[A-Za-z0-9][A-Za-z0-9-]{0,79}$/; const CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; -const MAX_BATCH_SEQUENCE = 2_147_483_647; -const MAX_FACT_ATTRIBUTES_BYTES = 64 * 1024; const PROVIDER_MANIFEST_KEYS = new Set(["schemaVersion", "id", "providerId", "version", "ontology", "l2Template", "capabilities", "dataProductIds"]); const CONNECTION_PROFILE_KEYS = new Set(["schemaVersion", "id", "providerId", "tenantId", "credentialRef", "scope"]); @@ -159,8 +153,8 @@ export function validateConnectionProfile(value) { requiredString(value?.credentialRef?.owner, "credentialRef.owner", errors); requiredString(value?.credentialRef?.reference, "credentialRef.reference", errors); - if (value?.credentialRef?.owner && value.credentialRef.owner !== "engine") { - errors.push("credentialRef.owner_must_be_engine"); + if (value?.credentialRef?.owner && value.credentialRef.owner !== "ndc_l2_credentials") { + errors.push("credentialRef.owner_must_be_ndc_l2_credentials"); } if (containsSecretLikeMaterial(value)) errors.push("profile_must_not_contain_secret_material"); if (value?.scope !== undefined) { @@ -301,45 +295,6 @@ export function validateFoundryBindingUpsert(value) { return result(errors); } -/** - * Provider-neutral batch written by an L2 connector to External Data Plane. - * The payload deliberately describes source facts rather than any provider - * field names, customer entities or renderer representation. - */ -export function validateIntakeBatch(value) { - const errors = baseErrors(value, "intakeBatch"); - rejectUnknownKeys(value, new Set(["schemaVersion", "source", "contract", "batch", "raw", "facts"]), "intakeBatch", errors); - rejectUnknownKeys(value?.source, new Set(["providerId", "tenantId", "connectionId"]), "source", errors); - rejectUnknownKeys(value?.contract, new Set(["dataProductId", "ontologyRevision", "version"]), "contract", errors); - rejectUnknownKeys(value?.batch, new Set(["runId", "sequence", "idempotencyKey", "receivedAt"]), "batch", errors); - requiredIdentifier(value?.source?.providerId, "source.providerId", errors); - requiredIdentifier(value?.source?.tenantId, "source.tenantId", errors); - requiredIdentifier(value?.source?.connectionId, "source.connectionId", errors); - requiredIdentifier(value?.contract?.dataProductId, "contract.dataProductId", errors); - requiredIdentifier(value?.contract?.ontologyRevision, "contract.ontologyRevision", errors); - requiredString(value?.contract?.version, "contract.version", errors); - if (value?.contract?.version && !CONTRACT_VERSION.test(value.contract.version)) { - errors.push("contract.version_must_be_semver"); - } - requiredIdentifier(value?.batch?.runId, "batch.runId", errors); - requiredIdentifier(value?.batch?.idempotencyKey, "batch.idempotencyKey", errors); - if (!Number.isInteger(value?.batch?.sequence) || value.batch.sequence < 0 || value.batch.sequence > MAX_BATCH_SEQUENCE) { - errors.push("batch.sequence_must_be_integer_0_to_2147483647"); - } - requiredIsoTimestamp(value?.batch?.receivedAt, "batch.receivedAt", errors); - - if (!Array.isArray(value?.facts) || value.facts.length === 0) { - errors.push("facts_must_be_nonempty_array"); - } else { - value.facts.forEach((fact, index) => validateFact(fact, `facts[${index}]`, errors)); - } - - if (value?.raw !== undefined) validateRawEnvelope(value.raw, errors); - if (containsSecretLikeMaterial(value)) errors.push("intake_must_not_contain_secret_material"); - - return result(errors); -} - export function assertValid(validator, value) { const validation = validator(value); if (!validation.ok) throw new Error(`external_provider_contract_invalid:${validation.errors.join(",")}`); @@ -394,66 +349,6 @@ function validateUniqueIdentifierArray(value, path, errors) { if (new Set(value).size !== value.length) errors.push(`${path}_must_not_contain_duplicates`); } -function requiredIsoTimestamp(value, path, errors) { - if (typeof value !== "string" || Number.isNaN(Date.parse(value))) errors.push(`${path}_invalid_timestamp`); -} - -function validateFact(value, path, errors) { - if (!isPlainObject(value)) { - errors.push(`${path}_must_be_object`); - return; - } - rejectUnknownKeys(value, new Set(["sourceId", "semanticType", "observedAt", "attributes", "geometry"]), path, errors); - requiredIdentifier(value.sourceId, `${path}.sourceId`, errors); - requiredIdentifier(value.semanticType, `${path}.semanticType`, errors); - requiredIsoTimestamp(value.observedAt, `${path}.observedAt`, errors); - if (value.attributes !== undefined) { - if (!isPlainObject(value.attributes)) { - errors.push(`${path}.attributes_must_be_object`); - } else if (serializedByteLength(value.attributes) > MAX_FACT_ATTRIBUTES_BYTES) { - errors.push(`${path}.attributes_size_exceeded`); - } - } - if (value.geometry !== undefined) validatePointGeometry(value.geometry, `${path}.geometry`, errors); -} - -function validatePointGeometry(value, path, errors) { - if (!isPlainObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) { - errors.push(`${path}_must_be_geojson_point`); - return; - } - rejectUnknownKeys(value, new Set(["type", "coordinates"]), path, errors); - if (!value.coordinates.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate))) { - errors.push(`${path}_coordinates_must_be_finite_numbers`); - return; - } - const [longitude, latitude] = value.coordinates; - if (longitude < -180 || longitude > 180) errors.push(`${path}.longitude_out_of_range`); - if (latitude < -90 || latitude > 90) errors.push(`${path}.latitude_out_of_range`); -} - -function validateRawEnvelope(value, errors) { - if (!isPlainObject(value)) { - errors.push("raw_must_be_object"); - return; - } - rejectUnknownKeys(value, new Set(["contentType", "payload", "hash", "ref", "retentionDays"]), "raw", errors); - requiredString(value.contentType, "raw.contentType", errors); - if (value.payload === undefined && value.ref === undefined) errors.push("raw_requires_payload_or_ref"); - if (value.payload !== undefined) errors.push("raw.inline_payload_not_supported"); - if (value.payload === undefined) requiredString(value.hash, "raw.hash", errors); - if (value.hash !== undefined) requiredString(value.hash, "raw.hash", errors); - if (value.ref !== undefined) { - requiredString(value.ref, "raw.ref", errors); - if (typeof value.ref === "string" && SECRET_LIKE_REFERENCE.test(value.ref)) { - errors.push("raw.ref_must_not_contain_secret_material"); - } - } - if (value.retentionDays !== undefined && (!Number.isInteger(value.retentionDays) || value.retentionDays < 1)) { - errors.push("raw.retentionDays_must_be_positive_integer"); - } -} - function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -465,14 +360,6 @@ function rejectUnknownKeys(value, allowedKeys, path, errors) { } } -function serializedByteLength(value) { - try { - return Buffer.byteLength(JSON.stringify(value)); - } catch { - return Number.POSITIVE_INFINITY; - } -} - function containsSecretLikeMaterial(value) { if (typeof value === "string") return SECRET_LIKE_VALUE.test(value); if (Array.isArray(value)) return value.some(containsSecretLikeMaterial); diff --git a/packages/external-provider-contract/src/intake-batch.mjs b/packages/external-provider-contract/src/intake-batch.mjs new file mode 100644 index 0000000..9f12683 --- /dev/null +++ b/packages/external-provider-contract/src/intake-batch.mjs @@ -0,0 +1,152 @@ +import { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs"; +import { + SECRET_LIKE_KEY, + SECRET_LIKE_REFERENCE, + SECRET_LIKE_VALUE, +} from "./sensitive-field-policy.mjs"; + +const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; +const CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; +const MAX_BATCH_SEQUENCE = 2_147_483_647; +const MAX_FACT_ATTRIBUTES_BYTES = 64 * 1024; + +/** Provider-neutral batch written by an L2 connector to External Data Plane. */ +export function validateIntakeBatch(value) { + const errors = baseErrors(value, "intakeBatch"); + rejectUnknownKeys(value, new Set(["schemaVersion", "source", "contract", "batch", "raw", "facts"]), "intakeBatch", errors); + rejectUnknownKeys(value?.source, new Set(["providerId", "tenantId", "connectionId"]), "source", errors); + rejectUnknownKeys(value?.contract, new Set(["dataProductId", "ontologyRevision", "version"]), "contract", errors); + rejectUnknownKeys(value?.batch, new Set(["runId", "sequence", "idempotencyKey", "receivedAt"]), "batch", errors); + requiredIdentifier(value?.source?.providerId, "source.providerId", errors); + requiredIdentifier(value?.source?.tenantId, "source.tenantId", errors); + requiredIdentifier(value?.source?.connectionId, "source.connectionId", errors); + requiredIdentifier(value?.contract?.dataProductId, "contract.dataProductId", errors); + requiredIdentifier(value?.contract?.ontologyRevision, "contract.ontologyRevision", errors); + requiredString(value?.contract?.version, "contract.version", errors); + if (value?.contract?.version && !CONTRACT_VERSION.test(value.contract.version)) { + errors.push("contract.version_must_be_semver"); + } + requiredIdentifier(value?.batch?.runId, "batch.runId", errors); + requiredIdentifier(value?.batch?.idempotencyKey, "batch.idempotencyKey", errors); + if (!Number.isInteger(value?.batch?.sequence) || value.batch.sequence < 0 || value.batch.sequence > MAX_BATCH_SEQUENCE) { + errors.push("batch.sequence_must_be_integer_0_to_2147483647"); + } + requiredIsoTimestamp(value?.batch?.receivedAt, "batch.receivedAt", errors); + + if (!Array.isArray(value?.facts) || value.facts.length === 0) { + errors.push("facts_must_be_nonempty_array"); + } else { + value.facts.forEach((fact, index) => validateFact(fact, `facts[${index}]`, errors)); + } + + if (value?.raw !== undefined) validateRawEnvelope(value.raw, errors); + if (containsSecretLikeMaterial(value)) errors.push("intake_must_not_contain_secret_material"); + + return result(errors); +} + +function baseErrors(value, label) { + const errors = []; + if (!isPlainObject(value)) return [`${label}_must_be_object`]; + if (value.schemaVersion !== EXTERNAL_PROVIDER_CONTRACT_VERSION) errors.push("schemaVersion_mismatch"); + return errors; +} + +function requiredString(value, path, errors) { + if (typeof value !== "string" || !value.trim()) errors.push(`${path}_required`); +} + +function requiredIdentifier(value, path, errors) { + if (typeof value !== "string" || !IDENTIFIER.test(value)) errors.push(`${path}_invalid`); +} + +function requiredIsoTimestamp(value, path, errors) { + if (typeof value !== "string" || Number.isNaN(Date.parse(value))) errors.push(`${path}_invalid_timestamp`); +} + +function validateFact(value, path, errors) { + if (!isPlainObject(value)) { + errors.push(`${path}_must_be_object`); + return; + } + rejectUnknownKeys(value, new Set(["sourceId", "semanticType", "observedAt", "attributes", "geometry"]), path, errors); + requiredIdentifier(value.sourceId, `${path}.sourceId`, errors); + requiredIdentifier(value.semanticType, `${path}.semanticType`, errors); + requiredIsoTimestamp(value.observedAt, `${path}.observedAt`, errors); + if (value.attributes !== undefined) { + if (!isPlainObject(value.attributes)) { + errors.push(`${path}.attributes_must_be_object`); + } else if (serializedByteLength(value.attributes) > MAX_FACT_ATTRIBUTES_BYTES) { + errors.push(`${path}.attributes_size_exceeded`); + } + } + if (value.geometry !== undefined) validatePointGeometry(value.geometry, `${path}.geometry`, errors); +} + +function validatePointGeometry(value, path, errors) { + if (!isPlainObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) { + errors.push(`${path}_must_be_geojson_point`); + return; + } + rejectUnknownKeys(value, new Set(["type", "coordinates"]), path, errors); + if (!value.coordinates.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate))) { + errors.push(`${path}_coordinates_must_be_finite_numbers`); + return; + } + const [longitude, latitude] = value.coordinates; + if (longitude < -180 || longitude > 180) errors.push(`${path}.longitude_out_of_range`); + if (latitude < -90 || latitude > 90) errors.push(`${path}.latitude_out_of_range`); +} + +function validateRawEnvelope(value, errors) { + if (!isPlainObject(value)) { + errors.push("raw_must_be_object"); + return; + } + rejectUnknownKeys(value, new Set(["contentType", "payload", "hash", "ref", "retentionDays"]), "raw", errors); + requiredString(value.contentType, "raw.contentType", errors); + if (value.payload === undefined && value.ref === undefined) errors.push("raw_requires_payload_or_ref"); + if (value.payload !== undefined) errors.push("raw.inline_payload_not_supported"); + if (value.payload === undefined) requiredString(value.hash, "raw.hash", errors); + if (value.hash !== undefined) requiredString(value.hash, "raw.hash", errors); + if (value.ref !== undefined) { + requiredString(value.ref, "raw.ref", errors); + if (typeof value.ref === "string" && SECRET_LIKE_REFERENCE.test(value.ref)) { + errors.push("raw.ref_must_not_contain_secret_material"); + } + } + if (value.retentionDays !== undefined && (!Number.isInteger(value.retentionDays) || value.retentionDays < 1)) { + errors.push("raw.retentionDays_must_be_positive_integer"); + } +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function rejectUnknownKeys(value, allowedKeys, path, errors) { + if (!isPlainObject(value)) return; + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) errors.push(`${path}.${key}_not_allowed`); + } +} + +function serializedByteLength(value) { + try { + return Buffer.byteLength(JSON.stringify(value)); + } catch { + return Number.POSITIVE_INFINITY; + } +} + +function containsSecretLikeMaterial(value) { + if (typeof value === "string") return SECRET_LIKE_VALUE.test(value); + if (Array.isArray(value)) return value.some(containsSecretLikeMaterial); + if (!isPlainObject(value)) return false; + return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretLikeMaterial(child)); +} + +function result(errors) { + const uniqueErrors = [...new Set(errors)]; + return Object.freeze({ ok: uniqueErrors.length === 0, errors: Object.freeze(uniqueErrors) }); +} diff --git a/packages/external-provider-contract/src/provider-package.mjs b/packages/external-provider-contract/src/provider-package.mjs new file mode 100644 index 0000000..bd0570f --- /dev/null +++ b/packages/external-provider-contract/src/provider-package.mjs @@ -0,0 +1,1006 @@ +export const PROVIDER_PACKAGE_SCHEMA_VERSION = "nodedc.external-provider-package/v1"; +export const L2_TEMPLATE_SCHEMA_VERSION = "nodedc.l2-template/v1"; +export const L2_CONNECTION_INSTANCE_SCHEMA_VERSION = "nodedc.l2-connection-instance/v1"; +export const SEMANTIC_MAPPING_SCHEMA_VERSION = "nodedc.semantic-mapping/v1"; + +import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs"; + +const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; +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 SAFE_PATH_SEGMENT = /^(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[A-Fa-f0-9]{2})*$/; +const MAGIC_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); +const SECRET_QUERY_KEY = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i; +const SECRET_FIELD_NAME = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i; +const CAPABILITY_CLASSIFICATIONS = new Set(["read", "metadata", "write", "destructive"]); +const CAPABILITY_STATUSES = new Set(["implemented", "catalogued", "disabled"]); +const AUTH_KINDS = new Set(["api_token", "oauth2", "basic", "custom"]); +const TOKEN_ARTIFACTS = new Set(["access", "refresh"]); +const TOKEN_REFRESH_MODES = new Set(["not_applicable", "operator_managed", "runtime_managed"]); +const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]); +const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); +const HISTORY_MODES = new Set(["none", "all", "sampled"]); +const L2_STEP_KINDS = new Set([ + "collection_trigger", + "provider_request", + "extract_items", + "semantic_mapping", + "data_product_publish", +]); +const L2_CONNECTION_PARAMETERS = Object.freeze([ + "tenant_id", + "connection_id", + "collection_profile_id", + "provider_credential_ref", +]); + +const PACKAGE_KEYS = new Set([ + "schemaVersion", + "id", + "providerId", + "version", + "manifest", + "authModes", + "capabilities", + "fieldPolicies", + "collectionProfiles", + "dataProducts", + "mappingContracts", + "l2Templates", +]); +const MANIFEST_KEYS = new Set([ + "id", + "providerId", + "version", + "ontology", + "authModeIds", + "capabilityIds", + "fieldPolicyIds", + "collectionProfileIds", + "dataProductIds", + "mappingContractIds", + "l2TemplateIds", +]); +const AUTH_MODE_KEYS = new Set([ + "id", + "kind", + "credentialOwner", + "bindingCardinality", + "transport", + "tokenLifecycle", + "secretHandling", +]); +const AUTH_TRANSPORT_KEYS = new Set(["placement", "name"]); +const TOKEN_LIFECYCLE_KEYS = new Set(["artifacts", "requestArtifact", "refreshMode"]); +const SECRET_HANDLING_KEYS = new Set(["store", "exposeToGraph", "persistInPackage"]); +const CAPABILITY_KEYS = new Set([ + "id", + "classification", + "status", + "authModeId", + "request", + "entityScope", +]); +const REQUEST_KEYS = new Set(["method", "baseUrl", "path", "query", "response"]); +const RESPONSE_KEYS = new Set(["collectionPaths", "pagination"]); +const ENTITY_SCOPE_KEYS = new Set(["mode", "refresh", "businessEntityFilter"]); +const FIELD_POLICY_KEYS = new Set([ + "id", + "version", + "dataProductId", + "targetFields", + "unknownSourceFields", + "dynamicSourceFields", + "restrictedSourcePaths", +]); +const COLLECTION_PROFILE_KEYS = new Set([ + "id", + "version", + "mode", + "schedule", + "capabilityIds", + "dataProductId", + "mappingContractId", + "fieldPolicyId", + "l2TemplateId", + "entityScope", + "batching", + "cardinality", + "retry", +]); +const DATA_PRODUCT_KEYS = new Set([ + "id", + "version", + "ontologyRevision", + "deliveryMode", + "semanticTypes", + "fields", + "history", +]); +const HISTORY_KEYS = new Set(["mode", "intervalMs", "strategy", "retentionDays"]); +const MAPPING_KEYS = new Set([ + "schemaVersion", + "id", + "version", + "sourceCapabilityId", + "fieldPolicyId", + "target", + "derivations", + "fact", +]); +const MAPPING_TARGET_KEYS = new Set(["dataProductId", "version", "ontologyRevision", "semanticType"]); +const FACT_KEYS = new Set(["sourceId", "semanticType", "observedAt", "geometry", "attributes"]); +const EXPRESSION_KEYS = new Set(["strategy", "paths", "coerce", "prefix", "fallback", "constant", "derive", "omitIfMissing"]); +const GEOMETRY_KEYS = new Set(["type", "longitude", "latitude", "omitIfInvalid"]); +const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]); +const TEMPLATE_KEYS = new Set([ + "schemaVersion", + "id", + "version", + "runtime", + "instanceMode", + "connectionParameters", + "credentialBindings", + "steps", + "invariants", +]); +const CREDENTIAL_BINDING_KEYS = new Set(["role", "owner", "management", "authModeId", "nodeType"]); +const STEP_KEYS = new Set([ + "id", + "kind", + "collectionProfileDriven", + "capabilityId", + "mappingContractId", + "dataProductId", + "nodeType", +]); +const INVARIANT_KEYS = new Set([ + "oneConnectionPerInstance", + "providerSecretByReferenceOnly", + "publisherBindingControlPlaneManaged", + "callerScopeForbidden", + "providerSpecificServiceForbidden", +]); +const INSTANCE_OPTION_KEYS = new Set([ + "tenantId", + "connectionId", + "collectionProfileId", + "providerCredentialRef", +]); + +/** + * Validates one immutable provider package. Runtime account state and secret + * values deliberately live outside this artifact. + */ +export function validateProviderPackage(value) { + const errors = []; + if (!isPlainObject(value)) return result(["providerPackage_must_be_object"]); + rejectUnknownKeys(value, PACKAGE_KEYS, "providerPackage", errors); + if (value.schemaVersion !== PROVIDER_PACKAGE_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); + requiredIdentifier(value.id, "id", errors); + requiredIdentifier(value.providerId, "providerId", errors); + requiredSemver(value.version, "version", errors); + + validateManifest(value.manifest, errors); + const authModes = validateArtifactArray(value.authModes, "authModes", validateAuthMode, errors); + const capabilities = validateArtifactArray(value.capabilities, "capabilities", validateCapability, errors); + const fieldPolicies = validateArtifactArray(value.fieldPolicies, "fieldPolicies", validateFieldPolicy, errors); + const collectionProfiles = validateArtifactArray(value.collectionProfiles, "collectionProfiles", validateCollectionProfileTemplate, errors); + const dataProducts = validateArtifactArray(value.dataProducts, "dataProducts", validateDataProductDefinition, errors); + const mappingContracts = validateArtifactArray(value.mappingContracts, "mappingContracts", validateMappingContract, errors); + const l2Templates = validateArtifactArray(value.l2Templates, "l2Templates", validateL2Template, errors); + + if (isPlainObject(value.manifest)) { + if (value.manifest.providerId !== value.providerId) errors.push("manifest.providerId_must_match_package"); + if (value.manifest.version !== value.version) errors.push("manifest.version_must_match_package"); + assertExactReferences(value.manifest.authModeIds, authModes, "manifest.authModeIds", errors); + assertExactReferences(value.manifest.capabilityIds, capabilities, "manifest.capabilityIds", errors); + assertExactReferences(value.manifest.fieldPolicyIds, fieldPolicies, "manifest.fieldPolicyIds", errors); + assertExactReferences(value.manifest.collectionProfileIds, collectionProfiles, "manifest.collectionProfileIds", errors); + assertExactReferences(value.manifest.dataProductIds, dataProducts, "manifest.dataProductIds", errors); + assertExactReferences(value.manifest.mappingContractIds, mappingContracts, "manifest.mappingContractIds", errors); + assertExactReferences(value.manifest.l2TemplateIds, l2Templates, "manifest.l2TemplateIds", errors); + } + + const authModeIds = ids(authModes); + const capabilityIds = ids(capabilities); + const fieldPolicyIds = ids(fieldPolicies); + const collectionProfileIds = ids(collectionProfiles); + const dataProductIds = ids(dataProducts); + const mappingContractIds = ids(mappingContracts); + const l2TemplateIds = ids(l2Templates); + + for (const capability of capabilities) { + assertReference(capability.authModeId, authModeIds, `capability.${capability.id}.authModeId`, errors); + const authMode = authModes.find((item) => item.id === capability.authModeId); + if (authMode && Object.hasOwn(capability.request?.query || {}, authMode.transport?.name)) { + errors.push(`capability.${capability.id}.request.query_must_not_embed_credential_parameter`); + } + } + for (const fieldPolicy of fieldPolicies) { + assertReference(fieldPolicy.dataProductId, dataProductIds, `fieldPolicy.${fieldPolicy.id}.dataProductId`, errors); + const product = dataProducts.find((item) => item.id === fieldPolicy.dataProductId); + if (product && !sameSet(fieldPolicy.targetFields, product.fields)) { + errors.push(`fieldPolicy.${fieldPolicy.id}.targetFields_must_match_data_product_fields`); + } + } + for (const profile of collectionProfiles) { + const selectedCapabilityIds = Array.isArray(profile.capabilityIds) ? profile.capabilityIds : []; + if (selectedCapabilityIds.length !== 1) { + errors.push(`collectionProfile.${profile.id}.capabilityIds_must_select_one_v1_read`); + } + selectedCapabilityIds.forEach((id) => { + assertReference(id, capabilityIds, `collectionProfile.${profile.id}.capabilityIds`, errors); + const capability = capabilities.find((item) => item.id === id); + if (capability && (capability.status !== "implemented" || !new Set(["read", "metadata"]).has(capability.classification))) { + errors.push(`collectionProfile.${profile.id}.capabilityIds_must_reference_implemented_safe_read`); + } + }); + assertReference(profile.dataProductId, dataProductIds, `collectionProfile.${profile.id}.dataProductId`, errors); + assertReference(profile.fieldPolicyId, fieldPolicyIds, `collectionProfile.${profile.id}.fieldPolicyId`, errors); + assertReference(profile.mappingContractId, mappingContractIds, `collectionProfile.${profile.id}.mappingContractId`, errors); + assertReference(profile.l2TemplateId, l2TemplateIds, `collectionProfile.${profile.id}.l2TemplateId`, errors); + const mapping = mappingContracts.find((item) => item.id === profile.mappingContractId); + const fieldPolicy = fieldPolicies.find((item) => item.id === profile.fieldPolicyId); + const template = l2Templates.find((item) => item.id === profile.l2TemplateId); + if (mapping) { + if (!selectedCapabilityIds.includes(mapping.sourceCapabilityId)) { + errors.push(`collectionProfile.${profile.id}.mapping_source_capability_must_be_selected`); + } + if (selectedCapabilityIds.length !== 1 + || selectedCapabilityIds[0] !== mapping.sourceCapabilityId) { + errors.push(`collectionProfile.${profile.id}.capability_chain_must_be_exact_singleton`); + } + if (mapping.fieldPolicyId !== profile.fieldPolicyId) errors.push(`collectionProfile.${profile.id}.mapping_field_policy_mismatch`); + if (mapping.target?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.mapping_data_product_mismatch`); + } + if (fieldPolicy && fieldPolicy.dataProductId !== profile.dataProductId) { + errors.push(`collectionProfile.${profile.id}.field_policy_data_product_mismatch`); + } + if (template) { + const [, requestStep, extractStep, mappingStep, publishStep] = Array.isArray(template.steps) ? template.steps : []; + if (!selectedCapabilityIds.includes(requestStep?.capabilityId) || extractStep?.capabilityId !== requestStep?.capabilityId) { + errors.push(`collectionProfile.${profile.id}.template_capability_chain_mismatch`); + } + if (mappingStep?.mappingContractId !== profile.mappingContractId) errors.push(`collectionProfile.${profile.id}.template_mapping_mismatch`); + if (publishStep?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.template_data_product_mismatch`); + } + } + for (const mapping of mappingContracts) { + assertReference(mapping.sourceCapabilityId, capabilityIds, `mappingContract.${mapping.id}.sourceCapabilityId`, errors); + assertReference(mapping.fieldPolicyId, fieldPolicyIds, `mappingContract.${mapping.id}.fieldPolicyId`, errors); + assertReference(mapping.target?.dataProductId, dataProductIds, `mappingContract.${mapping.id}.target.dataProductId`, errors); + const product = dataProducts.find((item) => item.id === mapping.target?.dataProductId); + const fieldPolicy = fieldPolicies.find((item) => item.id === mapping.fieldPolicyId); + if (product) validateMappingAgainstProduct(mapping, product, errors); + if (fieldPolicy) { + if (fieldPolicy.dataProductId !== mapping.target?.dataProductId) { + errors.push(`mappingContract.${mapping.id}.field_policy_data_product_mismatch`); + } + const restricted = Array.isArray(fieldPolicy.restrictedSourcePaths) + ? fieldPolicy.restrictedSourcePaths.filter(isCanonicalSourcePath) + : []; + if (mappingSourcePaths(mapping.fact).some((path) => ( + restricted.some((restrictedPath) => ( + path === restrictedPath + || path.startsWith(`${restrictedPath}.`) + || restrictedPath.startsWith(`${path}.`) + )) + ))) { + errors.push(`mappingContract.${mapping.id}.fact_source_path_restricted`); + } + } + } + for (const template of l2Templates) { + for (const binding of Array.isArray(template.credentialBindings) ? template.credentialBindings : []) { + if (!isPlainObject(binding)) continue; + if (binding.authModeId !== undefined) { + assertReference(binding.authModeId, authModeIds, `l2Template.${template.id}.credentialBindings.authModeId`, errors); + } + } + for (const step of Array.isArray(template.steps) ? template.steps : []) { + if (!isPlainObject(step)) continue; + if (step.capabilityId !== undefined) { + assertReference(step.capabilityId, capabilityIds, `l2Template.${template.id}.steps.capabilityId`, errors); + if (step.kind === "provider_request") { + const capability = capabilities.find((item) => item.id === step.capabilityId); + if (capability && (capability.status !== "implemented" || !new Set(["read", "metadata"]).has(capability.classification))) { + errors.push(`l2Template.${template.id}.provider_request_must_reference_implemented_safe_read`); + } + } + } + if (step.mappingContractId !== undefined) assertReference(step.mappingContractId, mappingContractIds, `l2Template.${template.id}.steps.mappingContractId`, errors); + if (step.dataProductId !== undefined) assertReference(step.dataProductId, dataProductIds, `l2Template.${template.id}.steps.dataProductId`, errors); + } + const providerBinding = Array.isArray(template.credentialBindings) + ? template.credentialBindings.find((binding) => binding?.role === "provider") + : undefined; + const providerRequest = Array.isArray(template.steps) + ? template.steps.find((step) => step?.kind === "provider_request") + : undefined; + const requestCapability = capabilities.find((capability) => capability.id === providerRequest?.capabilityId); + if (providerBinding && requestCapability + && providerBinding.authModeId !== requestCapability.authModeId) { + errors.push(`l2Template.${template.id}.provider_credential_auth_mode_mismatch`); + } + } + + if (value.tenantId !== undefined || value.connectionId !== undefined || value.credentialRef !== undefined) { + errors.push("providerPackage_must_not_contain_connection_instance_state"); + } + if (containsSecretValue(value)) errors.push("providerPackage_must_not_contain_secret_material"); + return result(errors); +} + +/** + * Materializes secret-free NDC L2 connection metadata from a provider package. + * A new account is data, not source code: callers supply only the provider + * credential reference. Internal publish binding resolution belongs to the + * NDC L2 control plane and never crosses this input boundary. + */ +export function instantiateL2Connection(providerPackage, options) { + const packageValidation = validateProviderPackage(providerPackage); + if (!packageValidation.ok) { + throw new Error(`provider_package_invalid:${packageValidation.errors.join(",")}`); + } + const errors = []; + if (!isPlainObject(options)) throw new Error("l2_connection_options_must_be_object"); + rejectUnknownKeys(options, INSTANCE_OPTION_KEYS, "options", errors); + requiredIdentifier(options.tenantId, "tenantId", errors); + requiredIdentifier(options.connectionId, "connectionId", errors); + requiredIdentifier(options.collectionProfileId, "collectionProfileId", errors); + requiredOpaqueReference(options.providerCredentialRef, "providerCredentialRef", errors); + if (containsSecretValue(options)) errors.push("l2_connection_options_must_use_opaque_references"); + + const profile = providerPackage.collectionProfiles.find((item) => item.id === options.collectionProfileId); + if (!profile) errors.push("collectionProfileId_not_in_provider_package"); + const template = providerPackage.l2Templates.find((item) => item.id === profile?.l2TemplateId); + if (!template) errors.push("l2Template_missing"); + if (errors.length) throw new Error(`l2_connection_invalid:${[...new Set(errors)].join(",")}`); + const publisherBinding = template.credentialBindings.find((binding) => binding.role === "publisher"); + + return deepFreeze({ + schemaVersion: L2_CONNECTION_INSTANCE_SCHEMA_VERSION, + package: { + id: providerPackage.id, + providerId: providerPackage.providerId, + version: providerPackage.version, + }, + tenantId: options.tenantId, + connectionId: options.connectionId, + l2TemplateId: template.id, + collectionProfileId: profile.id, + credentialRefs: { + provider: { owner: "ndc_l2_credentials", reference: options.providerCredentialRef }, + }, + systemBindings: { + publisher: { + role: "publisher", + owner: publisherBinding.owner, + management: publisherBinding.management, + nodeType: publisherBinding.nodeType, + desiredState: "bound", + status: "unresolved", + }, + }, + scope: { + mode: "all_visible_to_credential", + refresh: "each_collection_run", + capabilityIds: [...profile.capabilityIds], + }, + }); +} + +function validateManifest(value, errors) { + if (!isPlainObject(value)) { + errors.push("manifest_must_be_object"); + return; + } + rejectUnknownKeys(value, MANIFEST_KEYS, "manifest", errors); + requiredIdentifier(value.id, "manifest.id", errors); + requiredIdentifier(value.providerId, "manifest.providerId", errors); + requiredSemver(value.version, "manifest.version", errors); + if (!isPlainObject(value.ontology)) { + errors.push("manifest.ontology_must_be_object"); + } else { + rejectUnknownKeys(value.ontology, new Set(["packageId", "revision"]), "manifest.ontology", errors); + requiredIdentifier(value.ontology.packageId, "manifest.ontology.packageId", errors); + requiredIdentifier(value.ontology.revision, "manifest.ontology.revision", errors); + } + for (const key of ["authModeIds", "capabilityIds", "fieldPolicyIds", "collectionProfileIds", "dataProductIds", "mappingContractIds", "l2TemplateIds"]) { + requiredUniqueIdentifierArray(value[key], `manifest.${key}`, errors); + } +} + +function validateAuthMode(value, path, errors) { + rejectUnknownKeys(value, AUTH_MODE_KEYS, path, errors); + requiredIdentifier(value.id, `${path}.id`, errors); + if (!AUTH_KINDS.has(value.kind)) errors.push(`${path}.kind_invalid`); + if (value.credentialOwner !== "ndc_l2_credentials") errors.push(`${path}.credentialOwner_must_be_ndc_l2_credentials`); + if (value.bindingCardinality !== "one_per_connection") errors.push(`${path}.bindingCardinality_must_be_one_per_connection`); + if (!isPlainObject(value.transport)) { + errors.push(`${path}.transport_must_be_object`); + } else { + rejectUnknownKeys(value.transport, AUTH_TRANSPORT_KEYS, `${path}.transport`, errors); + if (!new Set(["header", "query", "body"]).has(value.transport.placement)) errors.push(`${path}.transport.placement_invalid`); + requiredString(value.transport.name, `${path}.transport.name`, errors); + } + if (value.tokenLifecycle !== undefined) { + validateTokenLifecycle(value.tokenLifecycle, `${path}.tokenLifecycle`, errors); + } + if (!isPlainObject(value.secretHandling)) { + errors.push(`${path}.secretHandling_must_be_object`); + } else { + rejectUnknownKeys(value.secretHandling, SECRET_HANDLING_KEYS, `${path}.secretHandling`, errors); + if (value.secretHandling.store !== "ndc_l2_credentials") errors.push(`${path}.secretHandling.store_must_be_ndc_l2_credentials`); + if (value.secretHandling.exposeToGraph !== false) errors.push(`${path}.secretHandling.exposeToGraph_must_be_false`); + if (value.secretHandling.persistInPackage !== false) errors.push(`${path}.secretHandling.persistInPackage_must_be_false`); + } +} + +function validateTokenLifecycle(value, path, errors) { + if (!isPlainObject(value)) { + errors.push(`${path}_must_be_object`); + return; + } + rejectUnknownKeys(value, TOKEN_LIFECYCLE_KEYS, path, errors); + if (!Array.isArray(value.artifacts) || value.artifacts.length === 0) { + errors.push(`${path}.artifacts_must_be_nonempty_array`); + } else { + for (const artifact of value.artifacts) { + if (!TOKEN_ARTIFACTS.has(artifact)) errors.push(`${path}.artifacts_invalid`); + } + if (new Set(value.artifacts).size !== value.artifacts.length) { + errors.push(`${path}.artifacts_must_not_contain_duplicates`); + } + } + if (value.requestArtifact !== "access") { + errors.push(`${path}.requestArtifact_must_be_access`); + } else if (Array.isArray(value.artifacts) && !value.artifacts.includes(value.requestArtifact)) { + errors.push(`${path}.requestArtifact_must_reference_artifact`); + } + const hasRefresh = Array.isArray(value.artifacts) && value.artifacts.includes("refresh"); + if (!TOKEN_REFRESH_MODES.has(value.refreshMode)) { + errors.push(`${path}.refreshMode_invalid`); + } else if (hasRefresh && value.refreshMode === "not_applicable") { + errors.push(`${path}.refreshMode_required_for_refresh_artifact`); + } else if (!hasRefresh && value.refreshMode !== "not_applicable") { + errors.push(`${path}.refreshMode_must_be_not_applicable_without_refresh_artifact`); + } +} + +function validateCapability(value, path, errors) { + rejectUnknownKeys(value, CAPABILITY_KEYS, path, errors); + requiredIdentifier(value.id, `${path}.id`, errors); + if (!CAPABILITY_CLASSIFICATIONS.has(value.classification)) errors.push(`${path}.classification_invalid`); + if (!CAPABILITY_STATUSES.has(value.status)) errors.push(`${path}.status_invalid`); + requiredIdentifier(value.authModeId, `${path}.authModeId`, errors); + if (!isPlainObject(value.request)) { + errors.push(`${path}.request_must_be_object`); + } else { + rejectUnknownKeys(value.request, REQUEST_KEYS, `${path}.request`, errors); + if (value.request.method !== "GET") errors.push(`${path}.request.method_must_be_GET_for_v1`); + validateProviderBaseUrl(value.request.baseUrl, `${path}.request.baseUrl`, errors); + validateProviderRequestPath(value.request.path, `${path}.request.path`, errors); + validateStaticQuery(value.request.query, `${path}.request.query`, errors); + if (!isPlainObject(value.request.response)) { + errors.push(`${path}.request.response_must_be_object`); + } else { + rejectUnknownKeys(value.request.response, RESPONSE_KEYS, `${path}.request.response`, errors); + requiredCollectionPathArray(value.request.response.collectionPaths, `${path}.request.response.collectionPaths`, errors); + if (value.request.response.pagination !== "single_bounded_response") errors.push(`${path}.request.response.pagination_invalid`); + } + } + validateCredentialVisibleScope(value.entityScope, `${path}.entityScope`, errors); +} + +function validateFieldPolicy(value, path, errors) { + rejectUnknownKeys(value, FIELD_POLICY_KEYS, path, errors); + requiredIdentifier(value.id, `${path}.id`, errors); + requiredSemver(value.version, `${path}.version`, errors); + requiredIdentifier(value.dataProductId, `${path}.dataProductId`, errors); + requiredUniqueIdentifierArray(value.targetFields, `${path}.targetFields`, errors); + if (Array.isArray(value.targetFields) && value.targetFields.some((field) => SECRET_FIELD_NAME.test(String(field)))) { + errors.push(`${path}.targetFields_must_not_contain_secret_fields`); + } + if (value.unknownSourceFields !== "drop") errors.push(`${path}.unknownSourceFields_must_be_drop`); + if (value.dynamicSourceFields !== "drop_until_classified") errors.push(`${path}.dynamicSourceFields_must_be_drop_until_classified`); + requiredSourcePathArray(value.restrictedSourcePaths, `${path}.restrictedSourcePaths`, errors); +} + +function validateCollectionProfileTemplate(value, path, errors) { + rejectUnknownKeys(value, COLLECTION_PROFILE_KEYS, path, errors); + requiredIdentifier(value.id, `${path}.id`, errors); + requiredSemver(value.version, `${path}.version`, errors); + if (!COLLECTION_MODES.has(value.mode)) errors.push(`${path}.mode_invalid`); + requiredUniqueIdentifierArray(value.capabilityIds, `${path}.capabilityIds`, errors); + requiredIdentifier(value.dataProductId, `${path}.dataProductId`, errors); + requiredIdentifier(value.mappingContractId, `${path}.mappingContractId`, errors); + requiredIdentifier(value.fieldPolicyId, `${path}.fieldPolicyId`, errors); + requiredIdentifier(value.l2TemplateId, `${path}.l2TemplateId`, errors); + if (value.mode === "realtime") { + if (!isPlainObject(value.schedule)) errors.push(`${path}.schedule_must_be_object`); + else if (!Number.isInteger(value.schedule.intervalMs) || value.schedule.intervalMs < 1000) errors.push(`${path}.schedule.intervalMs_invalid`); + } else if (value.schedule !== undefined) { + errors.push(`${path}.schedule_not_allowed_for_non_realtime_profile`); + } + validateCredentialVisibleScope(value.entityScope, `${path}.entityScope`, errors); + if (!isPlainObject(value.batching) || !Number.isInteger(value.batching.maxFacts) || value.batching.maxFacts < 1 || value.batching.maxFacts > 5000) { + errors.push(`${path}.batching.maxFacts_must_be_integer_1_to_5000`); + } else { + rejectUnknownKeys(value.batching, new Set(["maxFacts"]), `${path}.batching`, errors); + } + if (!isPlainObject(value.cardinality)) { + errors.push(`${path}.cardinality_must_be_object`); + } else { + rejectUnknownKeys(value.cardinality, new Set(["maxCurrentEntities", "onExceed"]), `${path}.cardinality`, errors); + if (value.cardinality.maxCurrentEntities !== 5000) errors.push(`${path}.cardinality.maxCurrentEntities_must_be_5000`); + if (value.cardinality.onExceed !== "require_partitioned_data_product") errors.push(`${path}.cardinality.onExceed_invalid`); + } + if (!isPlainObject(value.retry)) { + errors.push(`${path}.retry_must_be_object`); + } else { + rejectUnknownKeys(value.retry, new Set(["maxAttempts", "backoff"]), `${path}.retry`, errors); + if (!Number.isInteger(value.retry.maxAttempts) || value.retry.maxAttempts < 1 || value.retry.maxAttempts > 10) errors.push(`${path}.retry.maxAttempts_invalid`); + if (value.retry.backoff !== "exponential_with_jitter") errors.push(`${path}.retry.backoff_invalid`); + } +} + +function validateDataProductDefinition(value, path, errors) { + rejectUnknownKeys(value, DATA_PRODUCT_KEYS, path, errors); + requiredIdentifier(value.id, `${path}.id`, errors); + requiredSemver(value.version, `${path}.version`, errors); + requiredIdentifier(value.ontologyRevision, `${path}.ontologyRevision`, errors); + if (!DELIVERY_MODES.has(value.deliveryMode)) errors.push(`${path}.deliveryMode_invalid`); + requiredUniqueIdentifierArray(value.semanticTypes, `${path}.semanticTypes`, errors); + requiredUniqueIdentifierArray(value.fields, `${path}.fields`, errors); + if (Array.isArray(value.fields) && value.fields.some((field) => SECRET_FIELD_NAME.test(String(field)))) { + errors.push(`${path}.fields_must_not_contain_secret_fields`); + } + if (!isPlainObject(value.history)) { + errors.push(`${path}.history_must_be_object`); + } else { + rejectUnknownKeys(value.history, HISTORY_KEYS, `${path}.history`, errors); + if (!HISTORY_MODES.has(value.history.mode)) errors.push(`${path}.history.mode_invalid`); + if (value.history.mode === "sampled") { + if (!Number.isInteger(value.history.intervalMs) + || value.history.intervalMs < 1000 + || value.history.intervalMs > 24 * 60 * 60 * 1000) { + errors.push(`${path}.history.intervalMs_invalid`); + } + if (value.history.strategy !== "latest-per-entity-per-bucket") { + errors.push(`${path}.history.strategy_invalid`); + } + } else if (value.history.intervalMs !== undefined || value.history.strategy !== undefined) { + errors.push(`${path}.history.sampling_fields_forbidden`); + } + if (!Number.isInteger(value.history.retentionDays) + || value.history.retentionDays < 1 + || value.history.retentionDays > 3650) { + errors.push(`${path}.history.retentionDays_invalid`); + } + } +} + +function validateMappingContract(value, path, errors) { + rejectUnknownKeys(value, MAPPING_KEYS, path, errors); + if (value.schemaVersion !== SEMANTIC_MAPPING_SCHEMA_VERSION) errors.push(`${path}.schemaVersion_mismatch`); + requiredIdentifier(value.id, `${path}.id`, errors); + requiredSemver(value.version, `${path}.version`, errors); + requiredIdentifier(value.sourceCapabilityId, `${path}.sourceCapabilityId`, errors); + requiredIdentifier(value.fieldPolicyId, `${path}.fieldPolicyId`, errors); + if (!isPlainObject(value.target)) { + errors.push(`${path}.target_must_be_object`); + } else { + rejectUnknownKeys(value.target, MAPPING_TARGET_KEYS, `${path}.target`, errors); + requiredIdentifier(value.target.dataProductId, `${path}.target.dataProductId`, errors); + requiredSemver(value.target.version, `${path}.target.version`, errors); + requiredIdentifier(value.target.ontologyRevision, `${path}.target.ontologyRevision`, errors); + requiredIdentifier(value.target.semanticType, `${path}.target.semanticType`, errors); + } + if (!isPlainObject(value.fact)) { + errors.push(`${path}.fact_must_be_object`); + return; + } + rejectUnknownKeys(value.fact, FACT_KEYS, `${path}.fact`, errors); + validateExpression(value.fact.sourceId, `${path}.fact.sourceId`, errors); + validateExpression(value.fact.semanticType, `${path}.fact.semanticType`, errors); + validateExpression(value.fact.observedAt, `${path}.fact.observedAt`, errors); + if (value.fact.geometry !== undefined && !isPlainObject(value.fact.geometry)) { + errors.push(`${path}.fact.geometry_must_be_object`); + } else if (isPlainObject(value.fact.geometry)) { + rejectUnknownKeys(value.fact.geometry, GEOMETRY_KEYS, `${path}.fact.geometry`, errors); + if (value.fact.geometry.type !== "Point") errors.push(`${path}.fact.geometry.type_must_be_Point`); + validateExpression(value.fact.geometry.longitude, `${path}.fact.geometry.longitude`, errors); + validateExpression(value.fact.geometry.latitude, `${path}.fact.geometry.latitude`, errors); + if (value.fact.geometry.omitIfInvalid !== true) errors.push(`${path}.fact.geometry.omitIfInvalid_must_be_true`); + } + if (value.fact.attributes !== undefined && !isPlainObject(value.fact.attributes)) { + errors.push(`${path}.fact.attributes_must_be_object`); + } else if (isPlainObject(value.fact.attributes)) { + for (const [attribute, expression] of Object.entries(value.fact.attributes)) { + requiredIdentifier(attribute, `${path}.fact.attributes.${attribute}`, errors); + if (SECRET_FIELD_NAME.test(attribute)) errors.push(`${path}.fact.attributes.${attribute}_secret_field_not_allowed`); + validateExpression(expression, `${path}.fact.attributes.${attribute}`, errors); + } + } + const derivationReferences = mappingDerivationReferences(value.fact); + if (value.derivations !== undefined && !isPlainObject(value.derivations)) { + errors.push(`${path}.derivations_must_be_object`); + } else if (isPlainObject(value.derivations)) { + for (const [id, derivation] of Object.entries(value.derivations)) { + requiredIdentifier(id, `${path}.derivations.${id}`, errors); + validateDerivation(derivation, `${path}.derivations.${id}`, errors); + } + const derivationIds = new Set(Object.keys(value.derivations)); + for (const id of derivationReferences) { + if (!derivationIds.has(id)) errors.push(`${path}.derive_${id}_missing_definition`); + } + for (const id of derivationIds) { + if (!derivationReferences.has(id)) errors.push(`${path}.derivation_${id}_unused`); + } + } else if (derivationReferences.size) { + for (const id of derivationReferences) errors.push(`${path}.derive_${id}_missing_definition`); + } +} + +function validateL2Template(value, path, errors) { + rejectUnknownKeys(value, TEMPLATE_KEYS, path, errors); + if (value.schemaVersion !== L2_TEMPLATE_SCHEMA_VERSION) errors.push(`${path}.schemaVersion_mismatch`); + requiredIdentifier(value.id, `${path}.id`, errors); + requiredSemver(value.version, `${path}.version`, errors); + if (value.runtime !== "ndc_l2") errors.push(`${path}.runtime_must_be_ndc_l2`); + if (value.instanceMode !== "one_connection_per_workflow") errors.push(`${path}.instanceMode_must_be_one_connection_per_workflow`); + requiredStringArray(value.connectionParameters, `${path}.connectionParameters`, errors); + if (!sameArray(value.connectionParameters, L2_CONNECTION_PARAMETERS)) { + errors.push(`${path}.connectionParameters_must_match_v1_contract`); + } + if (!Array.isArray(value.credentialBindings) || value.credentialBindings.length !== 2) { + errors.push(`${path}.credentialBindings_must_have_provider_and_publisher`); + } else { + const roles = new Set(); + value.credentialBindings.forEach((binding, index) => { + const bindingPath = `${path}.credentialBindings[${index}]`; + if (!isPlainObject(binding)) return errors.push(`${bindingPath}_must_be_object`); + rejectUnknownKeys(binding, CREDENTIAL_BINDING_KEYS, bindingPath, errors); + if (!new Set(["provider", "publisher"]).has(binding.role)) errors.push(`${bindingPath}.role_invalid`); + roles.add(binding.role); + if (binding.owner !== "ndc_l2_credentials") errors.push(`${bindingPath}.owner_must_be_ndc_l2_credentials`); + if (!new Set(["connection_input", "control_plane_managed"]).has(binding.management)) { + errors.push(`${bindingPath}.management_invalid`); + } + if (binding.authModeId !== undefined) requiredIdentifier(binding.authModeId, `${bindingPath}.authModeId`, errors); + if (binding.nodeType !== undefined) requiredString(binding.nodeType, `${bindingPath}.nodeType`, errors); + if (binding.role === "provider" && ( + binding.authModeId === undefined + || binding.nodeType !== undefined + || binding.management !== "connection_input" + )) { + errors.push(`${bindingPath}.provider_binding_shape_invalid`); + } + if (binding.role === "publisher" && ( + binding.authModeId !== undefined + || binding.nodeType !== "n8n-nodes-ndc.ndcDataProductPublish" + || binding.management !== "control_plane_managed" + )) { + errors.push(`${bindingPath}.publisher_binding_shape_invalid`); + } + }); + if (!roles.has("provider") || !roles.has("publisher")) errors.push(`${path}.credentialBindings_roles_invalid`); + } + if (!Array.isArray(value.steps) || value.steps.length !== 5) { + errors.push(`${path}.steps_must_define_five_boundary_steps`); + } else { + const stepIds = new Set(); + value.steps.forEach((step, index) => { + const stepPath = `${path}.steps[${index}]`; + if (!isPlainObject(step)) return errors.push(`${stepPath}_must_be_object`); + rejectUnknownKeys(step, STEP_KEYS, stepPath, errors); + requiredIdentifier(step.id, `${stepPath}.id`, errors); + if (stepIds.has(step.id)) errors.push(`${path}.steps_ids_must_be_unique`); + stepIds.add(step.id); + if (!L2_STEP_KINDS.has(step.kind)) errors.push(`${stepPath}.kind_invalid`); + }); + const expectedKinds = [ + "collection_trigger", + "provider_request", + "extract_items", + "semantic_mapping", + "data_product_publish", + ]; + if (!value.steps.every((step, index) => step?.kind === expectedKinds[index])) errors.push(`${path}.steps_sequence_invalid`); + if (value.steps[0]?.collectionProfileDriven !== true) errors.push(`${path}.collection_trigger_must_be_profile_driven`); + if (!value.steps[1]?.capabilityId || value.steps[1]?.mappingContractId !== undefined || value.steps[1]?.dataProductId !== undefined) { + errors.push(`${path}.provider_request_shape_invalid`); + } + if (!value.steps[2]?.capabilityId || value.steps[2]?.mappingContractId !== undefined || value.steps[2]?.dataProductId !== undefined) { + errors.push(`${path}.extract_items_shape_invalid`); + } + if (value.steps[1]?.capabilityId !== value.steps[2]?.capabilityId) errors.push(`${path}.request_extract_capability_mismatch`); + if (!value.steps[3]?.mappingContractId || value.steps[3]?.capabilityId !== undefined || value.steps[3]?.dataProductId !== undefined) { + errors.push(`${path}.semantic_mapping_shape_invalid`); + } + if (!value.steps[4]?.dataProductId || value.steps[4]?.nodeType !== "n8n-nodes-ndc.ndcDataProductPublish") { + errors.push(`${path}.data_product_publish_shape_invalid`); + } + } + if (!isPlainObject(value.invariants)) { + errors.push(`${path}.invariants_must_be_object`); + } else { + rejectUnknownKeys(value.invariants, INVARIANT_KEYS, `${path}.invariants`, errors); + for (const key of INVARIANT_KEYS) { + if (value.invariants[key] !== true) errors.push(`${path}.invariants.${key}_must_be_true`); + } + } +} + +function validateMappingAgainstProduct(mapping, product, errors) { + const path = `mappingContract.${mapping.id}`; + if (mapping.target.version !== product.version) errors.push(`${path}.target.version_must_match_data_product`); + if (mapping.target.ontologyRevision !== product.ontologyRevision) errors.push(`${path}.target.ontologyRevision_must_match_data_product`); + const semanticTypes = Array.isArray(product.semanticTypes) ? product.semanticTypes : []; + if (!semanticTypes.includes(mapping.target.semanticType)) errors.push(`${path}.target.semanticType_not_allowed`); + if (mapping.fact?.semanticType?.constant !== mapping.target.semanticType) { + errors.push(`${path}.fact.semanticType_must_equal_target_constant`); + } + const mappedFields = [...Object.keys(mapping.fact?.attributes || {}), ...(mapping.fact?.geometry ? ["geometry"] : [])]; + if (!sameSet(mappedFields, product.fields)) errors.push(`${path}.fact_fields_must_match_data_product_fields`); +} + +function validateExpression(value, path, errors) { + if (!isPlainObject(value)) { + errors.push(`${path}_must_be_mapping_expression`); + return; + } + rejectUnknownKeys(value, EXPRESSION_KEYS, path, errors); + const selectors = [Array.isArray(value.paths) && value.paths.length > 0, value.constant !== undefined, typeof value.derive === "string"].filter(Boolean).length; + if (selectors !== 1) errors.push(`${path}_must_define_exactly_one_source`); + if (value.paths !== undefined) requiredSourcePathArray(value.paths, `${path}.paths`, errors); + if (value.strategy !== undefined && value.strategy !== "first_non_empty") errors.push(`${path}.strategy_invalid`); + if (value.coerce !== undefined && !new Set(["string", "number", "boolean", "unix_or_iso_timestamp"]).has(value.coerce)) { + errors.push(`${path}.coerce_invalid`); + } + if (value.prefix !== undefined && (typeof value.prefix !== "string" || !value.prefix || value.paths === undefined)) { + errors.push(`${path}.prefix_invalid`); + } + if (value.constant !== undefined && !isMappingLiteral(value.constant)) { + errors.push(`${path}.constant_must_be_scalar_or_scalar_array`); + } + if (value.fallback !== undefined && value.fallback !== "collection_received_at") errors.push(`${path}.fallback_invalid`); + if (value.derive !== undefined) requiredIdentifier(value.derive, `${path}.derive`, errors); + if (value.omitIfMissing !== undefined && typeof value.omitIfMissing !== "boolean") errors.push(`${path}.omitIfMissing_must_be_boolean`); +} + +function validateDerivation(value, path, errors) { + if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`); + rejectUnknownKeys(value, DERIVATION_KEYS, path, errors); + if (!new Set(["boolean_rule", "ordered_rules", "flag_set"]).has(value.kind)) errors.push(`${path}.kind_invalid`); + requiredUniqueIdentifierArray(value.rules, `${path}.rules`, errors); + if (value.default === undefined) { + errors.push(`${path}.default_required`); + } else if (!isMappingLiteral(value.default)) { + errors.push(`${path}.default_must_be_scalar_or_scalar_array`); + } + if (value.parameters !== undefined) { + if (!isPlainObject(value.parameters)) { + errors.push(`${path}.parameters_must_be_object`); + } else { + for (const [key, item] of Object.entries(value.parameters)) { + if (!/^[a-z][a-zA-Z0-9]{1,63}$/.test(key)) errors.push(`${path}.parameters.${key}_name_invalid`); + if (SECRET_FIELD_NAME.test(key)) errors.push(`${path}.parameters.${key}_secret_field_not_allowed`); + if (!["string", "number", "boolean"].includes(typeof item)) errors.push(`${path}.parameters.${key}_value_invalid`); + } + } + } +} + +function mappingDerivationReferences(value, found = new Set()) { + if (Array.isArray(value)) { + value.forEach((item) => mappingDerivationReferences(item, found)); + return found; + } + if (!isPlainObject(value)) return found; + if (typeof value.derive === "string") found.add(value.derive); + Object.values(value).forEach((item) => mappingDerivationReferences(item, found)); + return found; +} + +function mappingSourcePaths(value, found = []) { + if (Array.isArray(value)) { + value.forEach((item) => mappingSourcePaths(item, found)); + return found; + } + if (!isPlainObject(value)) return found; + if (Array.isArray(value.paths)) { + value.paths.forEach((path) => { + if (isCanonicalSourcePath(path)) found.push(path); + }); + } + Object.entries(value).forEach(([key, item]) => { + if (key !== "paths") mappingSourcePaths(item, found); + }); + return found; +} + +function validateCredentialVisibleScope(value, path, errors) { + if (!isPlainObject(value)) { + errors.push(`${path}_must_be_object`); + return; + } + rejectUnknownKeys(value, ENTITY_SCOPE_KEYS, path, errors); + if (value.mode !== "all_visible_to_credential") errors.push(`${path}.mode_must_be_all_visible_to_credential`); + if (value.refresh !== "each_collection_run") errors.push(`${path}.refresh_must_be_each_collection_run`); + if (value.businessEntityFilter !== "forbidden") errors.push(`${path}.businessEntityFilter_must_be_forbidden`); +} + +function validateArtifactArray(value, path, validator, errors) { + if (!Array.isArray(value) || value.length === 0) { + errors.push(`${path}_must_be_nonempty_array`); + return []; + } + const seen = new Set(); + value.forEach((item, index) => { + if (!isPlainObject(item)) return errors.push(`${path}[${index}]_must_be_object`); + validator(item, `${path}[${index}]`, errors); + if (seen.has(item.id)) errors.push(`${path}_ids_must_be_unique`); + seen.add(item.id); + }); + return value.filter(isPlainObject); +} + +function assertExactReferences(references, artifacts, path, errors) { + if (!sameSet(references, artifacts.map((item) => item.id))) errors.push(`${path}_must_exactly_reference_package_artifacts`); +} + +function assertReference(value, allowed, path, errors) { + if (!allowed.has(value)) errors.push(`${path}_unknown_reference`); +} + +function requiredIdentifier(value, path, errors) { + if (typeof value !== "string" || !IDENTIFIER.test(value)) errors.push(`${path}_invalid`); +} + +function requiredSemver(value, path, errors) { + if (typeof value !== "string" || !SEMVER.test(value)) errors.push(`${path}_must_be_semver`); +} + +function requiredString(value, path, errors) { + if (typeof value !== "string" || !value.trim()) errors.push(`${path}_required`); +} + +function requiredOpaqueReference(value, path, errors) { + if (typeof value !== "string" || !/^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/.test(value)) errors.push(`${path}_invalid`); +} + +function validateProviderBaseUrl(value, path, errors) { + if (typeof value !== "string") return errors.push(`${path}_must_be_https_origin`); + try { + const parsed = new URL(value); + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== "/") { + errors.push(`${path}_must_be_https_origin`); + } + } catch { + errors.push(`${path}_must_be_https_origin`); + } +} + +function validateProviderRequestPath(value, path, errors) { + if (typeof value !== "string" + || value.length < 1 + || value.length > 2048 + || !value.startsWith("/") + || /[\u0000-\u001f\u007f\\?#]/.test(value) + || value.split("/").some((segment) => !SAFE_PATH_SEGMENT.test(segment))) { + errors.push(`${path}_invalid`); + } +} + +function validateStaticQuery(value, path, errors) { + if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`); + for (const [key, item] of Object.entries(value)) { + if (!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/.test(key)) errors.push(`${path}.${key}_name_invalid`); + if (SECRET_QUERY_KEY.test(key)) errors.push(`${path}.${key}_credential_parameter_not_allowed`); + if (!["string", "number", "boolean"].includes(typeof item) || (typeof item === "string" && item.length > 2048)) { + errors.push(`${path}.${key}_value_invalid`); + } + if (containsSecretValue(item)) errors.push(`${path}.${key}_must_not_contain_secret_material`); + } +} + +function requiredStringArray(value, path, errors) { + if (!Array.isArray(value) || value.length === 0) { + errors.push(`${path}_must_be_nonempty_array`); + return; + } + value.forEach((item, index) => requiredString(item, `${path}[${index}]`, errors)); + if (new Set(value).size !== value.length) errors.push(`${path}_must_not_contain_duplicates`); +} + +function requiredSourcePathArray(value, path, errors) { + requiredStringArray(value, path, errors); + if (!Array.isArray(value)) return; + value.forEach((item, index) => { + if (typeof item === "string" && !isCanonicalSourcePath(item)) { + errors.push(`${path}[${index}]_must_be_canonical_dot_path`); + } + }); +} + +function requiredCollectionPathArray(value, path, errors) { + requiredStringArray(value, path, errors); + if (!Array.isArray(value)) return; + value.forEach((item, index) => { + if (item !== "$" && !isCanonicalSourcePath(item)) { + errors.push(`${path}[${index}]_invalid`); + } + }); +} + +function isCanonicalSourcePath(value) { + return typeof value === "string" + && SOURCE_PATH.test(value) + && value.split(".").every((segment) => !MAGIC_PATH_SEGMENTS.has(segment)); +} + +function requiredUniqueIdentifierArray(value, path, errors) { + if (!Array.isArray(value) || value.length === 0) { + errors.push(`${path}_must_be_nonempty_array`); + return; + } + value.forEach((item, index) => requiredIdentifier(item, `${path}[${index}]`, errors)); + if (new Set(value).size !== value.length) errors.push(`${path}_must_not_contain_duplicates`); +} + +function rejectUnknownKeys(value, allowed, path, errors) { + if (!isPlainObject(value)) return; + for (const key of Object.keys(value)) { + if (!allowed.has(key)) errors.push(`${path}.${key}_not_allowed`); + } +} + +function ids(values) { + return new Set(values.map((item) => item.id)); +} + +function sameSet(left, right) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; + const leftSet = new Set(left); + return leftSet.size === left.length && right.every((item) => leftSet.has(item)); +} + +function sameArray(left, right) { + return Array.isArray(left) + && Array.isArray(right) + && left.length === right.length + && left.every((item, index) => item === right[index]); +} + +function containsSecretValue(value) { + if (typeof value === "string") return SECRET_VALUE.test(value); + if (Array.isArray(value)) return value.some(containsSecretValue); + if (!isPlainObject(value)) return false; + return Object.values(value).some(containsSecretValue); +} + +function isMappingLiteral(value) { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) return true; + return Array.isArray(value) + && value.length <= 256 + && value.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item)); +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function deepFreeze(value) { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} + +function result(errors) { + const unique = [...new Set(errors)]; + return Object.freeze({ ok: unique.length === 0, errors: Object.freeze(unique) }); +} diff --git a/packages/external-provider-contract/src/sensitive-field-policy.mjs b/packages/external-provider-contract/src/sensitive-field-policy.mjs new file mode 100644 index 0000000..2bdf63a --- /dev/null +++ b/packages/external-provider-contract/src/sensitive-field-policy.mjs @@ -0,0 +1,16 @@ +export const NDC_SECRET_CAPABILITY_SOURCE = + String.raw`ndc_(?:edp(?:wb|rb|pr)|fndbg)_[A-Za-z0-9_-]+`; + +export const SECRET_LIKE_KEY = + /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i; + +export const SECRET_LIKE_MATERIAL_KEY = + /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|material|value)/i; + +export const SECRET_LIKE_REFERENCE = + /(?:[?&](?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)=|(?:bearer|basic)\s+)/i; + +export const SECRET_LIKE_VALUE = new RegExp( + `(?:${NDC_SECRET_CAPABILITY_SOURCE}|[?&](?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)=|(?:bearer|basic)\\s+\\S+|eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+)`, + "i", +); diff --git a/packages/external-provider-contract/test/contract.test.mjs b/packages/external-provider-contract/test/contract.test.mjs index 8e9ffec..a0b3b37 100644 --- a/packages/external-provider-contract/test/contract.test.mjs +++ b/packages/external-provider-contract/test/contract.test.mjs @@ -15,6 +15,10 @@ import { geliosPositionsCurrentExample } from "../examples/gelios-positions-curr assert.equal(validateProviderManifest(geliosPositionsCurrentExample.providerManifest).ok, true); assert.equal(validateConnectionProfile(geliosPositionsCurrentExample.connection).ok, true); +assert.equal(validateConnectionProfile({ + ...geliosPositionsCurrentExample.connection, + credentialRef: { ...geliosPositionsCurrentExample.connection.credentialRef, owner: "engine" }, +}).errors.includes("credentialRef.owner_must_be_ndc_l2_credentials"), true); assert.equal(validateCollectionProfile(geliosPositionsCurrentExample.collectionProfile).ok, true); assert.equal(validateDataProduct(geliosPositionsCurrentExample.dataProduct).ok, true); assert.equal(validateFoundryBinding(geliosPositionsCurrentExample.foundryBinding).ok, true); @@ -108,6 +112,10 @@ assert.equal(validateIntakeBatch({ ...neutralIntakeBatch, facts: [{ ...neutralIntakeBatch.facts[0], attributes: { metadata: "ndc_edprb_abcdefghijklmnopqrstuvwxyz0123456789ABCDE" } }], }).errors.includes("intake_must_not_contain_secret_material"), true); +assert.equal(validateIntakeBatch({ + ...neutralIntakeBatch, + facts: [{ ...neutralIntakeBatch.facts[0], attributes: { metadata: `ndc_edppr_${"P".repeat(43)}` } }], +}).errors.includes("intake_must_not_contain_secret_material"), true); assert.equal(validateIntakeBatch({ ...neutralIntakeBatch, raw: { contentType: "application/json", hash: "prefix ndc_edpwb_abcdefghijklmnopqrstuvwxyz0123456789ABCDE suffix", ref: "restricted://raw/fixture" }, diff --git a/packages/external-provider-contract/test/data-product.test.mjs b/packages/external-provider-contract/test/data-product.test.mjs index 28e32bb..693cb93 100644 --- a/packages/external-provider-contract/test/data-product.test.mjs +++ b/packages/external-provider-contract/test/data-product.test.mjs @@ -34,6 +34,10 @@ assert.equal(validateDataProductPublish({ ...publish, facts: [{ ...fact, attributes: { accessToken: "must-never-cross-the-boundary" } }], }).errors.includes("publish_must_not_contain_secret_material"), true); +assert.equal(validateDataProductPublish({ + ...publish, + facts: [{ ...fact, attributes: { status: `ndc_edppr_${"P".repeat(43)}` } }], +}).errors.includes("publish_must_not_contain_secret_material"), true); assert.equal(validateDataProductPublish({ ...publish, facts: [fact, { ...fact, observedAt: "2026-07-15T10:00:01.000Z" }], @@ -85,6 +89,10 @@ assert.equal(validateDataProductSnapshot({ ...snapshot, facts: [{ ...canonicalFact, attributes: { status: "ndc_edprb_forbidden-reader-token" } }], }).errors.includes("snapshot_must_not_contain_secret_material"), true); +assert.equal(validateDataProductSnapshot({ + ...snapshot, + facts: [{ ...canonicalFact, attributes: { status: `ndc_edppr_${"P".repeat(43)}` } }], +}).errors.includes("snapshot_must_not_contain_secret_material"), true); const patch = { schemaVersion: DATA_PRODUCT_PATCH_SCHEMA_VERSION, diff --git a/packages/external-provider-contract/test/engine-credential-sink.test.mjs b/packages/external-provider-contract/test/engine-credential-sink.test.mjs deleted file mode 100644 index 9d933c6..0000000 --- a/packages/external-provider-contract/test/engine-credential-sink.test.mjs +++ /dev/null @@ -1,242 +0,0 @@ -import assert from "node:assert/strict"; -import { generateKeyPairSync, sign } from "node:crypto"; - -import { - ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION, - ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION, - ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION, - ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION, - ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION, - computeEngineCredentialCapabilityDigest, - computeEngineCredentialSinkPolicyHash, - computeEngineCredentialSinkReceiptHash, - engineCredentialSinkAuditTargets, - validateEngineCredentialSinkAudit, - validateEngineCredentialSinkProvision, - validateEngineCredentialSinkReceipt, - validateEngineCredentialSinkRollback, - validateEngineCredentialSinkRollbackReceipt, -} from "../src/index.mjs"; - -const hash = (character) => `sha256:${character.repeat(64)}`; -const issuer = { serviceId: "platform.external-data-plane", keyId: "edp-issuer-20260715-001" }; -const { publicKey: issuerPublicKey, privateKey: issuerPrivateKey } = generateKeyPairSync("ed25519"); -const issuerPublicKeys = { [`${issuer.serviceId}:${issuer.keyId}`]: issuerPublicKey }; -const target = (nodeId, nodeType, credentialType) => ({ - workflowId: "WCb62yGL8v", - workflowRevision: "revision-20260715-001", - nodeId, - nodeType, - credentialType, -}); - -const request = { - schemaVersion: ENGINE_CREDENTIAL_SINK_PROVISION_SCHEMA_VERSION, - transaction: { - id: "credential-transaction-20260715-001", - idempotencyKey: "credential-transaction-20260715-001", - requestedAt: "2026-07-15T18:00:00.000Z", - requestExpiresAt: "2026-07-15T18:10:00.000Z", - policyHash: hash("0"), - failureMode: "rollback-all", - issuer, - attestation: { algorithm: "Ed25519", signature: "A".repeat(86) }, - }, - bindings: [ - { - bindingId: "positions-writer", - capabilityType: "external-data-plane.writer", - grantId: "writer-grant-001", - target: target( - "publish-node-001", - "n8n-nodes-ndc.ndcDataProductPublish", - "ndcDataProductWriterApi", - ), - expiresAt: "2026-08-15T18:00:00.000Z", - policyHash: hash("1"), - material: { format: "opaque-bearer", value: `ndc_edpwb_${"A".repeat(43)}` }, - }, - { - bindingId: "positions-reader", - capabilityType: "external-data-plane.reader", - grantId: "reader-grant-001", - target: target( - "read-node-001", - "n8n-nodes-ndc.ndcDataProductRead", - "ndcDataProductReaderApi", - ), - expiresAt: "2026-08-15T18:00:00.000Z", - policyHash: hash("2"), - material: { format: "opaque-bearer", value: `ndc_edprb_${"B".repeat(43)}` }, - }, - { - bindingId: "positions-foundry", - capabilityType: "foundry.binding", - grantId: "foundry-grant-001", - target: target( - "foundry-node-001", - "n8n-nodes-ndc.ndcFoundryBinding", - "ndcFoundryBindingApi", - ), - expiresAt: "2026-08-15T18:00:00.000Z", - policyHash: hash("3"), - material: { format: "opaque-bearer", value: `ndc_fndbg_${"C".repeat(43)}` }, - }, - ], -}; -for (const binding of request.bindings) { - binding.capabilityDigest = computeEngineCredentialCapabilityDigest(binding.material.value); -} -attest(request); -const provisionValidationNow = "2026-07-15T18:00:01.000Z"; - -assert.equal(validateEngineCredentialSinkProvision(request, { now: provisionValidationNow, issuerPublicKeys }).ok, true); -assert.equal(validateEngineCredentialSinkProvision(request, { - now: provisionValidationNow, -}).errors.includes("transaction.issuer_public_key_required"), true); -assert.equal(validateEngineCredentialSinkProvision({ - ...request, - transaction: { ...request.transaction, policyHash: hash("f") }, -}, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("transaction.policyHash_mismatch"), true); -assert.equal(validateEngineCredentialSinkProvision({ - ...request, - bindings: [{ - ...request.bindings[0], - target: { ...request.bindings[0].target, nodeType: "n8n-nodes-ndc.ndcDataProductRead" }, - }], -}, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("bindings[0].target.nodeType_capability_mismatch"), true); -assert.equal(validateEngineCredentialSinkProvision({ - ...request, - bindings: [{ - ...request.bindings[0], - material: { format: "opaque-bearer", value: request.bindings[1].material.value }, - }], -}, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("bindings[0].material.value_invalid_for_capability"), true); - -const swappedCapabilityRequest = structuredClone(request); -swappedCapabilityRequest.bindings[0].material.value = `ndc_edpwb_${"D".repeat(43)}`; -assert.equal(validateEngineCredentialSinkProvision(swappedCapabilityRequest, { - now: provisionValidationNow, - issuerPublicKeys, -}).errors.includes("bindings[0].capabilityDigest_material_mismatch"), true); - -const resignedByAttackerRequest = structuredClone(swappedCapabilityRequest); -resignedByAttackerRequest.bindings[0].capabilityDigest = computeEngineCredentialCapabilityDigest( - resignedByAttackerRequest.bindings[0].material.value, -); -resignedByAttackerRequest.transaction.policyHash = computeEngineCredentialSinkPolicyHash(resignedByAttackerRequest); -assert.equal(validateEngineCredentialSinkProvision(resignedByAttackerRequest, { - now: provisionValidationNow, - issuerPublicKeys, -}).errors.includes("transaction.attestation_invalid"), true); - -const staleRequest = structuredClone(request); -staleRequest.transaction.requestedAt = "2020-01-01T00:00:00.000Z"; -staleRequest.transaction.requestExpiresAt = "2020-01-01T00:10:00.000Z"; -attest(staleRequest); -assert.equal(validateEngineCredentialSinkProvision(staleRequest, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("request_expired"), true); - -const futureRequest = structuredClone(request); -futureRequest.transaction.requestedAt = "2026-07-15T18:02:00.000Z"; -futureRequest.transaction.requestExpiresAt = "2026-07-15T18:12:00.000Z"; -attest(futureRequest); -assert.equal(validateEngineCredentialSinkProvision(futureRequest, { now: provisionValidationNow, issuerPublicKeys }).errors.includes("requestedAt_exceeds_clock_skew"), true); - -const credentials = request.bindings.map((binding, index) => ({ - bindingId: binding.bindingId, - capabilityType: binding.capabilityType, - grantId: binding.grantId, - target: binding.target, - credentialRef: `engcred_${index + 1}_opaque_reference`, - expiresAt: binding.expiresAt, - policyHash: binding.policyHash, - capabilityDigest: binding.capabilityDigest, - disposition: "created", -})); -const receipt = { - schemaVersion: ENGINE_CREDENTIAL_SINK_RECEIPT_SCHEMA_VERSION, - transactionId: request.transaction.id, - idempotencyKey: request.transaction.idempotencyKey, - outcome: "committed", - policyHash: request.transaction.policyHash, - processedAt: "2026-07-15T18:00:02.000Z", - credentials, - rollback: { status: "not-required" }, -}; -assert.equal(validateEngineCredentialSinkReceipt(receipt, { request, issuerPublicKeys }).ok, true); -assert.equal(validateEngineCredentialSinkReceipt({ - ...receipt, - credentials: [{ ...credentials[0], target: { ...credentials[0].target, nodeId: "wrong-node" } }, ...credentials.slice(1)], -}, { request, issuerPublicKeys }).errors.includes("credentials_request_target_mismatch"), true); -assert.equal(validateEngineCredentialSinkReceipt({ - ...receipt, - capability: request.bindings[0].material.value, -}).errors.includes("credentialSinkReceipt.capability_not_allowed"), true); -assert.equal(validateEngineCredentialSinkReceipt({ - ...receipt, - outcome: "rolled-back", - credentials: credentials.slice(0, 1), - rollback: { status: "complete", completedAt: "2026-07-15T18:00:02.000Z" }, - errorCode: "engine_binding_failed", -}).errors.includes("noncommitted_credentials_must_be_empty"), true); - -const committedReceiptHash = computeEngineCredentialSinkReceiptHash(receipt); -const rollbackRequest = { - schemaVersion: ENGINE_CREDENTIAL_SINK_ROLLBACK_SCHEMA_VERSION, - rollback: { - id: "credential-rollback-20260715-001", - idempotencyKey: "credential-rollback-20260715-001", - transactionId: request.transaction.id, - requestedAt: "2026-07-15T18:05:00.000Z", - requestExpiresAt: "2026-07-15T18:10:00.000Z", - policyHash: request.transaction.policyHash, - committedReceiptHash, - reasonCode: "operator_requested", - }, -}; -assert.equal(validateEngineCredentialSinkRollback(rollbackRequest, { now: "2026-07-15T18:05:01.000Z" }).ok, true); -const staleRollbackRequest = structuredClone(rollbackRequest); -staleRollbackRequest.rollback.requestedAt = "2020-01-01T00:00:00.000Z"; -staleRollbackRequest.rollback.requestExpiresAt = "2020-01-01T00:10:00.000Z"; -assert.equal(validateEngineCredentialSinkRollback(staleRollbackRequest, { now: "2026-07-15T18:05:01.000Z" }).errors.includes("request_expired"), true); -const rollbackReceipt = { - schemaVersion: ENGINE_CREDENTIAL_SINK_ROLLBACK_RECEIPT_SCHEMA_VERSION, - rollbackId: rollbackRequest.rollback.id, - transactionId: rollbackRequest.rollback.transactionId, - outcome: "rolled-back", - policyHash: rollbackRequest.rollback.policyHash, - committedReceiptHash, - processedAt: "2026-07-15T18:05:01.000Z", -}; -assert.equal(validateEngineCredentialSinkRollbackReceipt(rollbackReceipt, { request: rollbackRequest }).ok, true); - -const audit = { - schemaVersion: ENGINE_CREDENTIAL_SINK_AUDIT_SCHEMA_VERSION, - eventId: "credential-audit-20260715-001", - transactionId: request.transaction.id, - operationId: request.transaction.id, - operation: "provision", - outcome: "committed", - occurredAt: receipt.processedAt, - policyHash: request.transaction.policyHash, - principal: { serviceId: "platform.credential-provisioner", fingerprint: hash("a") }, - targets: engineCredentialSinkAuditTargets(request, receipt), -}; -assert.equal(validateEngineCredentialSinkAudit(audit).ok, true); -assert.equal(JSON.stringify(audit).includes("ndc_edpwb_"), false); -assert.equal(JSON.stringify(audit).includes("engcred_1_opaque_reference"), false); -assert.equal(validateEngineCredentialSinkAudit({ - ...audit, - authorization: `Bearer ${request.bindings[0].material.value}`, -}).errors.includes("audit_must_not_contain_secret_material"), true); - -function attest(value) { - value.transaction.policyHash = computeEngineCredentialSinkPolicyHash(value); - value.transaction.attestation.signature = sign( - null, - Buffer.from(value.transaction.policyHash, "utf8"), - issuerPrivateKey, - ).toString("base64url"); -} - -console.log("external-provider credential sink contract: ok"); diff --git a/packages/external-provider-contract/test/engine-private-extension.test.mjs b/packages/external-provider-contract/test/engine-private-extension.test.mjs index 0231750..f763d01 100644 --- a/packages/external-provider-contract/test/engine-private-extension.test.mjs +++ b/packages/external-provider-contract/test/engine-private-extension.test.mjs @@ -132,6 +132,16 @@ assert.equal( }).errors.includes("transition_policy_mismatch"), true, ); +assert.equal( + validateEnginePrivateExtensionPlan({ + ...activatePlan, + transition: { + ...transition(), + loaderEnvironment: { ...transition().loaderEnvironment, NODE_PATH: "/tmp/untrusted-node-modules" }, + }, + }).errors.includes("transition.loaderEnvironment.NODE_PATH_not_allowed"), + true, +); const applyRequest = { schemaVersion: ENGINE_PRIVATE_EXTENSION_APPLY_REQUEST_SCHEMA_VERSION, diff --git a/packages/external-provider-contract/test/provider-package.test.mjs b/packages/external-provider-contract/test/provider-package.test.mjs new file mode 100644 index 0000000..e1eb8a4 --- /dev/null +++ b/packages/external-provider-contract/test/provider-package.test.mjs @@ -0,0 +1,464 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { + L2_CONNECTION_INSTANCE_SCHEMA_VERSION, + instantiateL2Connection, + validateDataProductPublish, + validateProviderPackage, +} from "../src/index.mjs"; +import { + GELIOS_POSITIONS_DATA_PRODUCT_ID, + GELIOS_POSITIONS_DATA_PRODUCT_VERSION, + GELIOS_POSITIONS_ONTOLOGY_REVISION, + GELIOS_UNIT_SOURCE_ID_PREFIX, + geliosProviderPackageV1, + geliosUnitsCurrentFixtureV1, +} from "../providers/gelios/v1/index.mjs"; +import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs"; + +const expectedFields = [ + "course_degrees", + "display_name", + "elevation_meters", + "geometry", + "hdop", + "horizontal_accuracy_meters", + "object_kind", + "operational_status", + "position_source", + "position_valid", + "quality_flags", + "satellite_count", + "speed_kph", +]; + +assert.deepEqual(validateProviderPackage(geliosProviderPackageV1), { ok: true, errors: [] }); + +const geliosAuth = geliosProviderPackageV1.authModes[0]; +const geliosUnitsRead = geliosProviderPackageV1.capabilities[0]; +assert.equal(geliosAuth.id, "gelios.rest-bearer.v1"); +assert.deepEqual(geliosAuth.transport, { placement: "header", name: "Authorization" }); +assert.deepEqual(geliosAuth.tokenLifecycle, { + artifacts: ["access", "refresh"], + requestArtifact: "access", + refreshMode: "operator_managed", +}); +assert.equal(geliosAuth.tokenLifecycle.artifacts.includes("writer"), false); +assert.equal(geliosUnitsRead.request.baseUrl, "https://api.geliospro.com"); +assert.equal(geliosUnitsRead.request.path, "/api/v1/units"); +assert.deepEqual(geliosUnitsRead.request.query, {}); + +const product = geliosProviderPackageV1.dataProducts[0]; +const registeredProduct = JSON.parse(await readFile(new URL( + "../../../services/external-data-plane/definitions/fleet.positions.current.v1.json", + import.meta.url, +), "utf8")); +assert.equal(product.id, GELIOS_POSITIONS_DATA_PRODUCT_ID); +assert.equal(product.version, GELIOS_POSITIONS_DATA_PRODUCT_VERSION); +assert.equal(product.ontologyRevision, GELIOS_POSITIONS_ONTOLOGY_REVISION); +assert.equal(product.id, "fleet.positions.current.v1"); +assert.equal(product.version, "1.0.0"); +assert.equal(product.ontologyRevision, "ontology.map.moving_object.v1"); +assert.deepEqual(product.fields, expectedFields); +assert.deepEqual(product, registeredProduct); +assert.doesNotThrow(() => normalizeDataProductDefinition(product)); +assert.equal(product.fields.every((field) => /^[a-z][a-z0-9_]*$/.test(field)), true); + +const mapping = geliosProviderPackageV1.mappingContracts[0]; +assert.equal(mapping.target.dataProductId, product.id); +assert.equal(mapping.target.version, product.version); +assert.equal(mapping.target.ontologyRevision, product.ontologyRevision); +assert.deepEqual( + [...Object.keys(mapping.fact.attributes), "geometry"].sort(), + [...product.fields].sort(), +); +assert.equal(mapping.derivations.operational_status.parameters.staleAfterMs, 60000); +assert.deepEqual(mapping.derivations.quality_flags.default, []); +assert.equal(mapping.fact.observedAt.fallback, "collection_received_at"); +assert.equal(GELIOS_UNIT_SOURCE_ID_PREFIX, "gelios-unit-"); +assert.equal(mapping.fact.sourceId.prefix, GELIOS_UNIT_SOURCE_ID_PREFIX); +assert.equal(mapping.fact.sourceId.coerce, "string"); +assert.equal(mapping.fact.geometry.longitude.paths.includes("lastMsg.lon"), true); +assert.equal(mapping.fact.geometry.latitude.paths.includes("lastMsg.lat"), true); +assert.equal(mapping.fact.attributes.speed_kph.paths.includes("lastMsg.speed"), true); + +assert.equal(validateDataProductPublish(geliosUnitsCurrentFixtureV1.expectedPublish).ok, true); +assert.equal( + geliosUnitsCurrentFixtureV1.sourceResponse.units.length, + geliosUnitsCurrentFixtureV1.expectedPublish.facts.length, + "every entity returned for the bound credential remains in the publish fixture", +); +assert.deepEqual( + geliosUnitsCurrentFixtureV1.expectedPublish.facts.map((fact) => fact.sourceId), + geliosUnitsCurrentFixtureV1.sourceResponse.units.map( + (unit) => `${GELIOS_UNIT_SOURCE_ID_PREFIX}${String(unit.id)}`, + ), +); +assert.equal( + geliosUnitsCurrentFixtureV1.expectedPublish.facts[1].sourceId, + "gelios-unit-001002", + "provider ID strings must remain byte-for-byte stable after the canonical prefix", +); +assert.equal(geliosUnitsCurrentFixtureV1.expectedPublish.facts[1].geometry, undefined); +assert.equal(geliosUnitsCurrentFixtureV1.expectedPublish.facts[1].attributes.operational_status, "no_position"); +assert.equal(geliosUnitsCurrentFixtureV1.sourceResponse.units[1].lastMsg, undefined); +assert.equal( + geliosUnitsCurrentFixtureV1.expectedPublish.facts[1].observedAt, + geliosUnitsCurrentFixtureV1.collectionContext.receivedAt, +); + +const realtimeProfileId = "gelios.positions.current.realtime.v1"; +assert.equal( + geliosProviderPackageV1.collectionProfiles[0].cardinality.onExceed, + "require_partitioned_data_product", +); +const accountA = instantiateL2Connection(geliosProviderPackageV1, { + tenantId: "tenant-alpha", + connectionId: "gelios-account-alpha", + collectionProfileId: realtimeProfileId, + providerCredentialRef: "ndc-credref:provider-alpha-0001", +}); +const accountB = instantiateL2Connection(geliosProviderPackageV1, { + tenantId: "tenant-beta", + connectionId: "gelios-account-beta", + collectionProfileId: realtimeProfileId, + providerCredentialRef: "ndc-credref:provider-beta-0001", +}); + +assert.equal(accountA.schemaVersion, L2_CONNECTION_INSTANCE_SCHEMA_VERSION); +assert.equal(accountA.package.id, accountB.package.id); +assert.equal(accountA.package.version, accountB.package.version); +assert.equal(accountA.l2TemplateId, accountB.l2TemplateId); +assert.notEqual(accountA.connectionId, accountB.connectionId); +assert.notEqual(accountA.credentialRefs.provider.reference, accountB.credentialRefs.provider.reference); +assert.equal(accountA.credentialRefs.provider.owner, "ndc_l2_credentials"); +assert.deepEqual(Object.keys(accountA.credentialRefs), ["provider"]); +assert.deepEqual(accountA.systemBindings.publisher, { + role: "publisher", + owner: "ndc_l2_credentials", + management: "control_plane_managed", + nodeType: "n8n-nodes-ndc.ndcDataProductPublish", + desiredState: "bound", + status: "unresolved", +}); +assert.equal(accountA.scope.mode, "all_visible_to_credential"); +assert.equal(accountA.scope.refresh, "each_collection_run"); +assert.equal(Object.isFrozen(accountA), true); +assert.equal(Object.isFrozen(accountA.credentialRefs), true); +assert.equal(Object.isFrozen(accountA.systemBindings), true); + +const withEntityAllowlist = structuredClone(geliosProviderPackageV1); +withEntityAllowlist.capabilities[0].entityScope.unitIds = ["gelios-unit-1001"]; +assert.equal( + validateProviderPackage(withEntityAllowlist).errors.includes("capabilities[0].entityScope.unitIds_not_allowed"), + true, +); + +const withGroupFilter = structuredClone(geliosProviderPackageV1); +withGroupFilter.collectionProfiles[0].entityScope.businessEntityFilter = "provider_group"; +assert.equal( + validateProviderPackage(withGroupFilter).errors.includes("collectionProfiles[0].entityScope.businessEntityFilter_must_be_forbidden"), + true, +); + +const withWrongRevision = structuredClone(geliosProviderPackageV1); +withWrongRevision.mappingContracts[0].target.ontologyRevision = "gelios.positions.v1"; +assert.equal( + validateProviderPackage(withWrongRevision).errors.includes("mappingContract.gelios.units.to.fleet.positions.current.v1.target.ontologyRevision_must_match_data_product"), + true, +); + +const withMissingDerivation = structuredClone(geliosProviderPackageV1); +delete withMissingDerivation.mappingContracts[0].derivations.position_valid; +assert.equal( + validateProviderPackage(withMissingDerivation).errors.includes("mappingContracts[0].derive_position_valid_missing_definition"), + true, +); + +const withWrongFactSemanticType = structuredClone(geliosProviderPackageV1); +withWrongFactSemanticType.mappingContracts[0].fact.semanticType.constant = "map.other"; +assert.equal( + validateProviderPackage(withWrongFactSemanticType).errors.includes( + "mappingContract.gelios.units.to.fleet.positions.current.v1.fact.semanticType_must_equal_target_constant", + ), + true, +); + +const nonGeospatialPackage = structuredClone(geliosProviderPackageV1); +nonGeospatialPackage.dataProducts[0].fields = ["display_name"]; +nonGeospatialPackage.fieldPolicies[0].targetFields = ["display_name"]; +delete nonGeospatialPackage.mappingContracts[0].fact.geometry; +nonGeospatialPackage.mappingContracts[0].fact.attributes = { + display_name: { strategy: "first_non_empty", paths: ["name"], coerce: "string" }, +}; +delete nonGeospatialPackage.mappingContracts[0].derivations; +assert.deepEqual(validateProviderPackage(nonGeospatialPackage), { ok: true, errors: [] }); + +for (const restrictedPath of ["phone", "phone.number", "lmsg"]) { + const withRestrictedMappingPath = structuredClone(geliosProviderPackageV1); + withRestrictedMappingPath.mappingContracts[0].fact.attributes.display_name.paths = [restrictedPath]; + assert.equal( + validateProviderPackage(withRestrictedMappingPath).errors.includes( + "mappingContract.gelios.units.to.fleet.positions.current.v1.fact_source_path_restricted", + ), + true, + ); +} + +const withFieldPolicyProductMismatch = structuredClone(geliosProviderPackageV1); +const secondProduct = structuredClone(withFieldPolicyProductMismatch.dataProducts[0]); +secondProduct.id = "fleet.positions.alternate.v1"; +withFieldPolicyProductMismatch.dataProducts.push(secondProduct); +withFieldPolicyProductMismatch.manifest.dataProductIds.push(secondProduct.id); +withFieldPolicyProductMismatch.fieldPolicies[0].dataProductId = secondProduct.id; +assert.equal( + validateProviderPackage(withFieldPolicyProductMismatch).errors.includes( + "mappingContract.gelios.units.to.fleet.positions.current.v1.field_policy_data_product_mismatch", + ), + true, +); + +const withCamelCaseField = structuredClone(geliosProviderPackageV1); +withCamelCaseField.dataProducts[0].fields[12] = "speedKph"; +assert.equal(validateProviderPackage(withCamelCaseField).ok, false); + +const withSecretField = structuredClone(geliosProviderPackageV1); +withSecretField.dataProducts[0].fields[12] = "api_token"; +withSecretField.fieldPolicies[0].targetFields[12] = "api_token"; +withSecretField.mappingContracts[0].fact.attributes.api_token = { constant: "metadata-only" }; +delete withSecretField.mappingContracts[0].fact.attributes.speed_kph; +const secretFieldValidation = validateProviderPackage(withSecretField); +assert.equal(secretFieldValidation.errors.includes("dataProducts[0].fields_must_not_contain_secret_fields"), true); +assert.equal(secretFieldValidation.errors.includes("fieldPolicies[0].targetFields_must_not_contain_secret_fields"), true); +assert.equal(secretFieldValidation.errors.includes("mappingContracts[0].fact.attributes.api_token_secret_field_not_allowed"), true); + +const withRuntimeAccountState = structuredClone(geliosProviderPackageV1); +withRuntimeAccountState.tenantId = "must-not-live-in-package"; +assert.equal( + validateProviderPackage(withRuntimeAccountState).errors.includes("providerPackage_must_not_contain_connection_instance_state"), + true, +); + +const withPrimitiveCapability = structuredClone(geliosProviderPackageV1); +withPrimitiveCapability.capabilities.push(null); +assert.doesNotThrow(() => validateProviderPackage(withPrimitiveCapability)); +assert.equal(validateProviderPackage(withPrimitiveCapability).ok, false); + +const withMalformedNestedArrays = structuredClone(geliosProviderPackageV1); +withMalformedNestedArrays.collectionProfiles[0].capabilityIds = {}; +withMalformedNestedArrays.l2Templates[0].credentialBindings = {}; +withMalformedNestedArrays.l2Templates[0].steps = {}; +assert.doesNotThrow(() => validateProviderPackage(withMalformedNestedArrays)); +assert.equal(validateProviderPackage(withMalformedNestedArrays).ok, false); + +const withCredentialInStaticQuery = structuredClone(geliosProviderPackageV1); +withCredentialInStaticQuery.authModes[0].transport = { placement: "query", name: "token" }; +withCredentialInStaticQuery.capabilities[0].request.query.token = "plain-provider-key"; +assert.equal( + validateProviderPackage(withCredentialInStaticQuery).errors.includes("capabilities[0].request.query.token_credential_parameter_not_allowed"), + true, +); +assert.equal( + validateProviderPackage(withCredentialInStaticQuery).errors.includes("capability.gelios.units.current.read.request.query_must_not_embed_credential_parameter"), + true, +); + +const withCredentialInBaseUrl = structuredClone(geliosProviderPackageV1); +withCredentialInBaseUrl.capabilities[0].request.baseUrl = "https://user:pass@api.geliospro.com/?token=forbidden"; +assert.equal(validateProviderPackage(withCredentialInBaseUrl).ok, false); + +for (const unsafePath of ["/ok\nX-Evil: yes", "/back\\slash", "/query?x=1"]) { + const withUnsafeRequestPath = structuredClone(geliosProviderPackageV1); + withUnsafeRequestPath.capabilities[0].request.path = unsafePath; + assert.equal(validateProviderPackage(withUnsafeRequestPath).ok, false); +} + +for (const unsafeCollectionPath of ["__proto__.polluted", "data.items[0]", "constructor.prototype"]) { + const withUnsafeCollectionPath = structuredClone(geliosProviderPackageV1); + withUnsafeCollectionPath.capabilities[0].request.response.collectionPaths = [unsafeCollectionPath]; + assert.equal(validateProviderPackage(withUnsafeCollectionPath).ok, false); +} + +const withUnsafeMappingPath = structuredClone(geliosProviderPackageV1); +withUnsafeMappingPath.mappingContracts[0].fact.attributes.display_name.paths = ["__proto__.polluted"]; +assert.equal(validateProviderPackage(withUnsafeMappingPath).ok, false); + +const withUnsafeCollectionCapability = structuredClone(geliosProviderPackageV1); +withUnsafeCollectionCapability.capabilities[0].classification = "write"; +assert.equal( + validateProviderPackage(withUnsafeCollectionCapability).errors.includes("collectionProfile.gelios.positions.current.realtime.v1.capabilityIds_must_reference_implemented_safe_read"), + true, +); + +const withUnexecutedSecondCapability = structuredClone(geliosProviderPackageV1); +const secondCapability = structuredClone(withUnexecutedSecondCapability.capabilities[0]); +secondCapability.id = "gelios.units.metadata.read"; +withUnexecutedSecondCapability.capabilities.push(secondCapability); +withUnexecutedSecondCapability.manifest.capabilityIds.push(secondCapability.id); +for (const profile of withUnexecutedSecondCapability.collectionProfiles) { + profile.capabilityIds.push(secondCapability.id); +} +assert.equal( + validateProviderPackage(withUnexecutedSecondCapability).errors.includes( + "collectionProfile.gelios.positions.current.realtime.v1.capabilityIds_must_select_one_v1_read", + ), + true, +); + +const withReorderedSteps = structuredClone(geliosProviderPackageV1); +[withReorderedSteps.l2Templates[0].steps[0], withReorderedSteps.l2Templates[0].steps[1]] = [ + withReorderedSteps.l2Templates[0].steps[1], + withReorderedSteps.l2Templates[0].steps[0], +]; +assert.equal( + validateProviderPackage(withReorderedSteps).errors.includes("l2Templates[0].steps_sequence_invalid"), + true, +); + +const withUnknownConnectionParameters = structuredClone(geliosProviderPackageV1); +withUnknownConnectionParameters.l2Templates[0].connectionParameters = ["foo"]; +assert.equal( + validateProviderPackage(withUnknownConnectionParameters).errors.includes( + "l2Templates[0].connectionParameters_must_match_v1_contract", + ), + true, +); + +const withCredentialAuthMismatch = structuredClone(geliosProviderPackageV1); +const secondAuthMode = structuredClone(withCredentialAuthMismatch.authModes[0]); +secondAuthMode.id = "gelios.rest-bearer.alternate.v1"; +withCredentialAuthMismatch.authModes.push(secondAuthMode); +withCredentialAuthMismatch.manifest.authModeIds.push(secondAuthMode.id); +withCredentialAuthMismatch.l2Templates[0].credentialBindings[0].authModeId = secondAuthMode.id; +assert.equal( + validateProviderPackage(withCredentialAuthMismatch).errors.includes( + "l2Template.gelios.positions.current.l2.v1.provider_credential_auth_mode_mismatch", + ), + true, +); + +const withCallerManagedPublisher = structuredClone(geliosProviderPackageV1); +withCallerManagedPublisher.l2Templates[0].credentialBindings[1].management = "connection_input"; +assert.equal( + validateProviderPackage(withCallerManagedPublisher).errors.includes( + "l2Templates[0].credentialBindings[1].publisher_binding_shape_invalid", + ), + true, +); + +const withUnknownTokenArtifact = structuredClone(geliosProviderPackageV1); +withUnknownTokenArtifact.authModes[0].tokenLifecycle.artifacts.push("writer"); +assert.equal( + validateProviderPackage(withUnknownTokenArtifact).errors.includes( + "authModes[0].tokenLifecycle.artifacts_invalid", + ), + true, +); + +const withRefreshUsedForRequest = structuredClone(geliosProviderPackageV1); +withRefreshUsedForRequest.authModes[0].tokenLifecycle.requestArtifact = "refresh"; +assert.equal( + validateProviderPackage(withRefreshUsedForRequest).errors.includes( + "authModes[0].tokenLifecycle.requestArtifact_must_be_access", + ), + true, +); + +const withUnmanagedRefreshArtifact = structuredClone(geliosProviderPackageV1); +withUnmanagedRefreshArtifact.authModes[0].tokenLifecycle.refreshMode = "not_applicable"; +assert.equal( + validateProviderPackage(withUnmanagedRefreshArtifact).errors.includes( + "authModes[0].tokenLifecycle.refreshMode_required_for_refresh_artifact", + ), + true, +); + +for (const history of [{ + mode: "none", + intervalMs: 60000, + strategy: "latest-per-entity-per-bucket", + retentionDays: 90, +}, { + mode: "sampled", + intervalMs: 24 * 60 * 60 * 1000 + 1, + strategy: "latest-per-entity-per-bucket", + retentionDays: 90, +}, { + mode: "all", + retentionDays: 3651, +}]) { + const withInvalidHistory = structuredClone(geliosProviderPackageV1); + withInvalidHistory.dataProducts[0].history = history; + assert.equal(validateProviderPackage(withInvalidHistory).ok, false); + assert.throws(() => normalizeDataProductDefinition(withInvalidHistory.dataProducts[0])); +} + +const withBrokenArtifactChain = structuredClone(geliosProviderPackageV1); +withBrokenArtifactChain.l2Templates[0].steps[4].dataProductId = "unrelated.product.v1"; +assert.equal( + validateProviderPackage(withBrokenArtifactChain).errors.includes("collectionProfile.gelios.positions.current.realtime.v1.template_data_product_mismatch"), + true, +); + +const withSecretNamedDerivationParameter = structuredClone(geliosProviderPackageV1); +withSecretNamedDerivationParameter.mappingContracts[0].derivations.position_valid.parameters = { + accessToken: "opaque-provider-value", +}; +assert.equal( + validateProviderPackage(withSecretNamedDerivationParameter).errors.includes( + "mappingContracts[0].derivations.position_valid.parameters.accessToken_secret_field_not_allowed", + ), + true, +); + +const withObjectConstant = structuredClone(geliosProviderPackageV1); +withObjectConstant.mappingContracts[0].fact.attributes.object_kind.constant = { + accessToken: "opaque-provider-value", +}; +assert.equal( + validateProviderPackage(withObjectConstant).errors.includes( + "mappingContracts[0].fact.attributes.object_kind.constant_must_be_scalar_or_scalar_array", + ), + true, +); + +assert.throws(() => instantiateL2Connection(geliosProviderPackageV1, { + tenantId: "tenant-alpha", + connectionId: "gelios-account-alpha", + collectionProfileId: realtimeProfileId, + providerCredentialRef: "Bearer plaintext-must-not-cross-boundary", +}), /l2_connection_invalid/); + +assert.throws(() => instantiateL2Connection(geliosProviderPackageV1, { + tenantId: "tenant-alpha", + connectionId: "gelios-account-alpha", + collectionProfileId: realtimeProfileId, + providerCredentialRef: "ndc-credref:provider-alpha-0001", + writerCredentialRef: "ndc-credref:caller-writer-forbidden", +}), /options\.writerCredentialRef_not_allowed/); + +const serializedPackage = JSON.stringify(geliosProviderPackageV1); +assert.equal(serializedPackage.includes('"prefix":"unit-"'), false); +assert.equal(serializedPackage.includes("unitIds"), false); +assert.equal(serializedPackage.includes("tenant-alpha"), false); +assert.equal(serializedPackage.includes("gelios-gateway"), false); +assert.equal(serializedPackage.includes("writer_credential_ref"), false); +assert.equal(serializedPackage.includes("writerCapabilityByReferenceOnly"), false); +assert.equal(serializedPackage.includes("publisherBindingControlPlaneManaged\":true"), true); +assert.equal(serializedPackage.includes("providerSpecificServiceForbidden\":true"), true); +assert.deepEqual( + geliosProviderPackageV1.l2Templates[0].steps.map((step) => step.kind), + [ + "collection_trigger", + "provider_request", + "extract_items", + "semantic_mapping", + "data_product_publish", + ], +); +assert.equal( + geliosProviderPackageV1.l2Templates[0].steps.at(-1).nodeType, + "n8n-nodes-ndc.ndcDataProductPublish", +); + +console.log("external-provider-contract provider-package: ok"); diff --git a/services/ontology-core/catalog/domain-packages/gelios/aliases.json b/services/ontology-core/catalog/domain-packages/gelios/aliases.json index 38f9529..2bc4b85 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/aliases.json +++ b/services/ontology-core/catalog/domain-packages/gelios/aliases.json @@ -1,19 +1,19 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "aliases": [ { "alias": "gelios", "canonicalId": "gelios.integration" }, { "alias": "gelius", "canonicalId": "gelios.integration" }, { "alias": "helius", "canonicalId": "gelios.integration" }, { "alias": "hel i os", "canonicalId": "gelios.integration" }, { "alias": "гелиос", "canonicalId": "gelios.integration" }, - { "alias": "трайк robot2b", "canonicalId": "gelios.unit" }, + { "alias": "отслеживаемый объект гелиос", "canonicalId": "gelios.unit" }, { "alias": "юнит гелиос", "canonicalId": "gelios.unit" }, { "alias": "группа юнитов гелиос", "canonicalId": "gelios.unit_group" }, { "alias": "последняя телеметрия гелиос", "canonicalId": "gelios.telemetry_snapshot" }, { "alias": "сырая телеметрия гелиос", "canonicalId": "gelios.raw_telemetry_message" }, - { "alias": "позиция трайка", "canonicalId": "gelios.position_fix" }, - { "alias": "статус трайка", "canonicalId": "gelios.operational_status" }, + { "alias": "позиция объекта гелиос", "canonicalId": "gelios.position_fix" }, + { "alias": "статус объекта гелиос", "canonicalId": "gelios.operational_status" }, { "alias": "датчик гелиос", "canonicalId": "gelios.sensor_definition" }, { "alias": "показание датчика", "canonicalId": "gelios.sensor_reading" }, { "alias": "калибровка датчика", "canonicalId": "gelios.sensor_conversion" }, @@ -23,7 +23,7 @@ { "alias": "шаблон команды гелиос", "canonicalId": "gelios.command_template" }, { "alias": "диспетчеризация команды гелиос", "canonicalId": "gelios.command_dispatch" }, { "alias": "аудит команды гелиос", "canonicalId": "gelios.command_audit" }, - { "alias": "контур robot2b", "canonicalId": "gelios.access_scope" }, + { "alias": "область доступа credential гелиос", "canonicalId": "gelios.access_scope" }, { "alias": "курсор загрузки гелиос", "canonicalId": "gelios.ingestion_cursor" } ] } diff --git a/services/ontology-core/catalog/domain-packages/gelios/entities.json b/services/ontology-core/catalog/domain-packages/gelios/entities.json index edefa07..7fe4ae4 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/entities.json +++ b/services/ontology-core/catalog/domain-packages/gelios/entities.json @@ -1,19 +1,19 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "entities": [ - { "id": "gelios.integration", "name": "Gelios Integration", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "Gelios Gateway", "summary": "Server-side provider integration boundary for Gelios REST OAuth and legacy compatibility. It owns no UI, map renderer or workflow secret." }, - { "id": "gelios.access_scope", "name": "Gelios Access Scope", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "Robot2B owner + Gelios Gateway", "summary": "Approved collection boundary expressed as an allowlist or owner rule for units, groups and allowed capabilities; distinct from broad provider-account visibility." }, - { "id": "gelios.unit", "name": "Gelios Unit", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Tracked operational object, including a Robot2B trike when its stable source unit ID is inside the approved scope." }, - { "id": "gelios.unit_group", "name": "Gelios Unit Group", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Provider-managed grouping of units. It is evidence for navigation and access, not an automatic definition of the Robot2B business scope." }, + { "id": "gelios.integration", "name": "Gelios Integration", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "NDC L2 connection", "summary": "One tenant-scoped Gelios provider connection backed by one opaque NDC L2 credential reference. It owns no UI, renderer, secret value or provider-specific Platform service." }, + { "id": "gelios.access_scope", "name": "Gelios Access Scope", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "Gelios credential visibility + NDC L2 connection policy", "summary": "Dynamic collection boundary: selected safe-read capabilities include every entity visible to the bound credential on each run. Entity allowlists and provider-group business filters are not part of collection scope." }, + { "id": "gelios.unit", "name": "Gelios Unit", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Tracked operational object with a stable provider source ID. Every unit returned for the bound credential is eligible for canonical mapping." }, + { "id": "gelios.unit_group", "name": "Gelios Unit Group", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Provider-managed grouping of units used as domain data and optional consumer metadata, never as an implicit collection filter." }, { "id": "gelios.tracker_device", "name": "Tracker Device", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Hardware tracker associated with a unit, carrying device type/manufacturer and restricted hardware identity fields." }, - { "id": "gelios.telemetry_snapshot", "name": "Telemetry Snapshot", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "Gelios Gateway", "summary": "Normalized current telemetry state for one unit at observed and received time; derived from the Gelios pure last message contract." }, + { "id": "gelios.telemetry_snapshot", "name": "Telemetry Snapshot", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Normalized current telemetry state for one unit at observed and received time; derived from the versioned Gelios last-message mapping contract." }, { "id": "gelios.raw_telemetry_message", "name": "Raw Telemetry Message", "surface": "telemetry", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Provider hardware message preserved only behind restricted raw-data policy; it is not the default Studio or analytics contract." }, - { "id": "gelios.position_fix", "name": "Position Fix", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "Gelios Gateway", "summary": "Time-qualified geographic position with latitude, longitude, height, course, speed, satellite and quality attributes for one unit." }, + { "id": "gelios.position_fix", "name": "Position Fix", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Time-qualified geographic position with latitude, longitude, height, course, speed, satellite and quality attributes for one unit." }, { "id": "gelios.telemetry_parameter", "name": "Telemetry Parameter", "surface": "telemetry", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Name/value parameter received with a message. Parameters are dynamic and require a namespaced registry and whitelist before product exposure." }, - { "id": "gelios.operational_status", "name": "Operational Status", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "Gelios Gateway", "summary": "Derived unit state such as online, offline, moving, parked, no-position or low-GPS; distinct from raw provider message fields." }, + { "id": "gelios.operational_status", "name": "Operational Status", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Derived unit state such as active, stale, moving, parked, no-position or low-GPS; distinct from raw provider message fields." }, { "id": "gelios.sensor_definition", "name": "Sensor Definition", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Declared sensor on a unit with message parameter, type, unit of measure, visibility and optional fuel semantics." }, - { "id": "gelios.sensor_reading", "name": "Sensor Reading", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "Gelios Gateway", "summary": "Time-qualified normalized reading of a defined sensor, retaining both numeric value and human-readable representation when permitted." }, + { "id": "gelios.sensor_reading", "name": "Sensor Reading", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Time-qualified normalized reading of a defined sensor, retaining both numeric value and human-readable representation only when permitted by a versioned field policy." }, { "id": "gelios.sensor_conversion", "name": "Sensor Conversion", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Calibration/conversion rule or row for translating raw sensor values into operational measures." }, { "id": "gelios.fuel_profile", "name": "Fuel Profile", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Fuel and consumption configuration associated with a unit or fuel sensor; use only after sensor semantics are approved." }, { "id": "gelios.maintenance_plan", "name": "Maintenance Plan", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Planned maintenance configuration for a unit, separate from historical maintenance events." }, @@ -22,13 +22,13 @@ { "id": "gelios.geozone_group", "name": "Gelios Geozone Group", "surface": "spatial", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Provider grouping of geozones." }, { "id": "gelios.geopoint", "name": "Gelios Geopoint", "surface": "spatial", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Named provider spatial point; it must remain distinct from a live unit position." }, { "id": "gelios.report_template", "name": "Gelios Report Template", "surface": "analytics", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Reusable provider report definition available to the account or scope." }, - { "id": "gelios.report_result", "name": "Gelios Report Result", "surface": "analytics", "status": ["source-evidenced"], "authority": "Gelios Pro + Gelios Gateway", "summary": "Bounded result, table, graphic or map output produced from an approved report request; not a default realtime stream." }, - { "id": "gelios.collection_run", "name": "Gelios Collection Run", "surface": "platform", "status": ["product-required"], "authority": "Gelios Gateway", "summary": "Auditable read-only collection attempt with scope, endpoint family, volume controls and outcome; it contains no token or raw payload." }, - { "id": "gelios.ingestion_cursor", "name": "Gelios Ingestion Cursor", "surface": "platform", "status": ["product-required"], "authority": "Gelios Gateway + telemetry storage", "summary": "Checkpoint and watermark controlling resumable historical or incremental ingestion for an approved unit and data family." }, + { "id": "gelios.report_result", "name": "Gelios Report Result", "surface": "analytics", "status": ["source-evidenced"], "authority": "Gelios Pro + NDC L2 semantic mapping", "summary": "Bounded result, table, graphic or map output produced from an approved report request; not a default realtime stream." }, + { "id": "gelios.collection_run", "name": "Gelios Collection Run", "surface": "platform", "status": ["product-required"], "authority": "NDC L2 workflow", "summary": "Auditable safe-read collection attempt with credential-visible entity scope, endpoint family, volume controls and outcome; it contains no credential value or raw payload." }, + { "id": "gelios.ingestion_cursor", "name": "Gelios Ingestion Cursor", "surface": "platform", "status": ["product-required"], "authority": "NDC L2 workflow state + External Data Plane", "summary": "Checkpoint and watermark controlling resumable historical or incremental ingestion for a connection and data family." }, { "id": "gelios.command_template", "name": "Gelios Command Template", "surface": "command", "status": ["source-evidenced", "product-required"], "authority": "Gelios Pro", "summary": "Read-only catalogued command template assigned to a unit. Template content is red-domain configuration, not a telemetry field." }, { "id": "gelios.command_group", "name": "Gelios Command Group", "surface": "command", "status": ["source-evidenced", "product-required"], "authority": "Gelios Pro", "summary": "Provider grouping of command templates, managed separately from operational unit groups." }, - { "id": "gelios.command_dispatch", "name": "Gelios Command Dispatch", "surface": "command", "status": ["product-required"], "authority": "Future Command Gateway", "summary": "Explicit, user-confirmed request to send a command to one approved unit or approved group. It must never be created by background ingestion or map rendering." }, - { "id": "gelios.command_delivery", "name": "Gelios Command Delivery", "surface": "command", "status": ["product-required", "source-evidenced"], "authority": "Gelios Pro + Future Command Gateway", "summary": "Provider delivery/task status associated with a command dispatch." }, - { "id": "gelios.command_audit", "name": "Gelios Command Audit", "surface": "command", "status": ["product-required"], "authority": "Future Command Gateway", "summary": "Immutable governance record of an explicit command decision, confirmation, target, outcome and actor; separate from provider command history." } + { "id": "gelios.command_dispatch", "name": "Gelios Command Dispatch", "surface": "command", "status": ["future-concept"], "authority": "Unimplemented NDC red-domain command boundary", "summary": "Future explicit, user-confirmed request to send a command to one approved unit or approved group. It must never be created by background ingestion or map rendering." }, + { "id": "gelios.command_delivery", "name": "Gelios Command Delivery", "surface": "command", "status": ["future-concept", "source-evidenced"], "authority": "Gelios Pro; future NDC red-domain reconciliation", "summary": "Provider delivery/task status that may be reconciled with a future governed command dispatch; no command transport exists in the current package." }, + { "id": "gelios.command_audit", "name": "Gelios Command Audit", "surface": "command", "status": ["future-concept"], "authority": "Unimplemented NDC red-domain command boundary", "summary": "Future immutable governance record of an explicit command decision, confirmation, target, outcome and actor; separate from provider command history." } ] } diff --git a/services/ontology-core/catalog/domain-packages/gelios/evidence.json b/services/ontology-core/catalog/domain-packages/gelios/evidence.json index 6e87f0e..36a91d6 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/evidence.json +++ b/services/ontology-core/catalog/domain-packages/gelios/evidence.json @@ -1,6 +1,6 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "sourceRoots": [ { "surface": "gelios-rest", @@ -8,9 +8,9 @@ "mode": "OpenAPI inspection and safe read-only runtime audit; no write routes executed" }, { - "surface": "engine", + "surface": "ndc-l2-legacy-evidence", "path": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_ENGINE_INFRA/nodedc-source", - "mode": "Read-only MMAP workflow, Gelios node and historical snapshot donor inspection" + "mode": "Read-only historical NDC L2 donor, Gelios mapping and synthetic-safe field-shape inspection" }, { "surface": "module-studio", @@ -41,9 +41,9 @@ "baselineDocs": ["docs/GELIOS_DOMAIN_ONTOLOGY.md"], "restrictions": [ "Do not copy access/refresh tokens, passwords, hardware decrypt keys or runtime snapshots into Ontology Core.", - "Do not make the current Engine Gelios node, Cesium entities or provider group names canonical domain identities.", + "Do not make a current NDC L2 workflow node, renderer entities or provider group names canonical domain identities.", "Do not invoke command send, create, update, delete or purge routes as ontology evidence.", - "Do not treat the 107 currently visible provider-account units or the 95 legacy snapshot units as the final Robot2B scope without owner approval.", + "Robot2B pilot counts (107 credential-visible units and 95 legacy snapshot units) are historical evidence only; canonical collection scope is dynamically all entities visible to the bound credential.", "Do not bulk-download history, media or full geozone geometry before collection policy and storage architecture are approved." ] } diff --git a/services/ontology-core/catalog/domain-packages/gelios/guardrails.json b/services/ontology-core/catalog/domain-packages/gelios/guardrails.json index 5c9d161..efea77b 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/guardrails.json +++ b/services/ontology-core/catalog/domain-packages/gelios/guardrails.json @@ -1,6 +1,6 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "rules": [ { "id": "guardrail.gelios.ontology_not_runtime_store", @@ -8,10 +8,16 @@ "summary": "Ontology Core describes Gelios meanings and contracts only. It must not contain access/refresh tokens, raw messages, live positions, provider command text, hardware decrypt keys or bulk geometry.", "entityIds": ["gelios.integration", "gelios.telemetry_snapshot", "gelios.raw_telemetry_message", "gelios.position_fix", "gelios.command_template", "gelios.geozone"] }, + { + "id": "guardrail.gelios.provider_auth_pair_not_capability_scope", + "severity": "error", + "summary": "Gelios provider authentication consists of access and refresh tokens only. Credential labels and safe-read classifications are workflow policy, not provider token scope; internal External Data Plane capabilities are not Gelios tokens.", + "entityIds": ["gelios.integration", "gelios.access_scope", "gelios.collection_run"] + }, { "id": "guardrail.gelios.scope_precedes_collection", "severity": "error", - "summary": "A Gelios collection run must resolve an approved Robot2B access scope before addressing units. Provider-account visibility and provider group membership are not sufficient product scope on their own.", + "summary": "A Gelios safe-read collection run must process every valid entity returned for the bound credential and selected capability. Static unit allowlists and provider-group business filters are forbidden at collection boundary; consumer visibility is handled downstream.", "entityIds": ["gelios.integration", "gelios.access_scope", "gelios.collection_run", "gelios.unit", "gelios.unit_group"] }, { @@ -41,7 +47,7 @@ { "id": "guardrail.gelios.history_requires_cursor", "severity": "warning", - "summary": "Any future history ingestion must be bounded by unit scope, time window, field whitelist and durable cursor. It must not turn a historical API family into an uncontrolled account dump.", + "summary": "Any future history ingestion must be bounded by connection, credential-visible entity scope, time window, field policy and durable cursor. It must not turn a historical API family into an uncontrolled account dump.", "entityIds": ["gelios.collection_run", "gelios.ingestion_cursor", "gelios.raw_telemetry_message", "gelios.report_result"] } ], diff --git a/services/ontology-core/catalog/domain-packages/gelios/package.json b/services/ontology-core/catalog/domain-packages/gelios/package.json index 8eacdc6..bb90411 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/package.json +++ b/services/ontology-core/catalog/domain-packages/gelios/package.json @@ -1,7 +1,7 @@ { "id": "gelios", - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "status": "product-required/source-evidenced", - "summary": "Gelios Pro telemetry, fleet, spatial, reporting and command-domain ontology for the Robot2B client context. The package describes canonical meanings and contracts; it does not store credentials or runtime telemetry." + "summary": "Provider-neutral Gelios Pro telemetry, fleet, spatial, reporting and command-domain ontology. It describes canonical meanings and contracts; it does not store credentials, account scope or runtime telemetry." } diff --git a/services/ontology-core/catalog/domain-packages/gelios/relations.json b/services/ontology-core/catalog/domain-packages/gelios/relations.json index 333590a..c952198 100644 --- a/services/ontology-core/catalog/domain-packages/gelios/relations.json +++ b/services/ontology-core/catalog/domain-packages/gelios/relations.json @@ -1,23 +1,23 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "relations": [ { "id": "gelios.integration.is_provider_connection", "from": ["gelios.integration"], "to": ["integration.connection"], "status": "product-required", "summary": "A Gelios integration is a tenant-scoped instance of the common external provider connection contract." }, - { "id": "gelios.access_scope.specializes_integration_scope", "from": ["gelios.access_scope"], "to": ["integration.access_scope"], "status": "product-required", "summary": "Robot2B/Gelios object approval specializes the common provider access-scope contract." }, + { "id": "gelios.access_scope.specializes_integration_scope", "from": ["gelios.access_scope"], "to": ["integration.access_scope"], "status": "product-required", "summary": "Gelios credential-visible capability scope specializes the common provider access-scope contract." }, { "id": "gelios.collection_run.is_integration_collection_run", "from": ["gelios.collection_run"], "to": ["integration.collection_run"], "status": "product-required", "summary": "Gelios collection runs inherit the bounded collection and audit lifecycle of external integrations." }, { "id": "gelios.raw_message.is_integration_raw_envelope", "from": ["gelios.raw_telemetry_message"], "to": ["integration.raw_envelope"], "status": "product-required", "summary": "A Gelios raw message is a restricted provider envelope, not a UI read model." }, { "id": "gelios.unit.is_integration_canonical_subject", "from": ["gelios.unit"], "to": ["integration.canonical_subject"], "status": "product-required", "summary": "A Gelios unit is a stable canonical subject produced by the provider adapter." }, { "id": "gelios.command_template.is_integration_command_capability", "from": ["gelios.command_template"], "to": ["integration.command_capability"], "status": "product-required", "summary": "Gelios command templates are catalogued provider command capabilities only." }, { "id": "gelios.command_dispatch.is_integration_command_intent", "from": ["gelios.command_dispatch"], "to": ["integration.command_intent"], "status": "future-concept", "summary": "Any future Gelios command dispatch must become an explicitly governed generic command intent." }, { "id": "gelios.command_audit.is_integration_command_audit", "from": ["gelios.command_audit"], "to": ["integration.command_audit"], "status": "future-concept", "summary": "Gelios command audit specializes the generic red-domain audit contract." }, - { "id": "gelios.integration.enforces_scope", "from": ["gelios.integration"], "to": ["gelios.access_scope"], "status": "product-required", "summary": "The Gateway applies an approved Robot2B scope before reading or publishing data." }, - { "id": "gelios.access_scope.includes_unit", "from": ["gelios.access_scope"], "to": ["gelios.unit", "gelios.unit_group"], "status": "product-required", "summary": "An approved scope can include explicit units and approved group criteria, but group membership alone is not authoritative." }, + { "id": "gelios.integration.enforces_scope", "from": ["gelios.integration"], "to": ["gelios.access_scope"], "status": "product-required", "summary": "NDC L2 resolves selected safe-read capabilities and current provider visibility from the bound credential on every collection run." }, + { "id": "gelios.access_scope.includes_unit", "from": ["gelios.access_scope"], "to": ["gelios.unit", "gelios.unit_group"], "status": "product-required", "summary": "Scope dynamically includes every unit and group record returned by the selected capability; it contains no static entity allowlist." }, { "id": "gelios.unit.belongs_to_group", "from": ["gelios.unit"], "to": ["gelios.unit_group"], "status": "source-evidenced", "summary": "A provider unit can belong to one or more provider unit groups." }, { "id": "gelios.unit.uses_tracker_device", "from": ["gelios.unit"], "to": ["gelios.tracker_device"], "status": "source-evidenced", "summary": "A tracked unit is associated with a hardware tracker device." }, - { "id": "gelios.unit.has_telemetry_snapshot", "from": ["gelios.unit"], "to": ["gelios.telemetry_snapshot"], "status": "product-required", "summary": "The Gateway maintains normalized current telemetry for an approved unit." }, + { "id": "gelios.unit.has_telemetry_snapshot", "from": ["gelios.unit"], "to": ["gelios.telemetry_snapshot"], "status": "product-required", "summary": "The versioned NDC L2 mapping produces normalized current telemetry for every valid returned unit." }, { "id": "gelios.raw_message.normalizes_to_snapshot", "from": ["gelios.raw_telemetry_message"], "to": ["gelios.telemetry_snapshot"], "status": "product-required", "summary": "Restricted raw provider telemetry can be normalized into the stable snapshot contract." }, { "id": "gelios.telemetry_snapshot.has_position_fix", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.position_fix"], "status": "product-required", "summary": "A current telemetry snapshot can carry one time-qualified position fix." }, - { "id": "gelios.telemetry_snapshot.has_operational_status", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.operational_status"], "status": "product-required", "summary": "Gateway derives normalized operational state from telemetry and data-quality policy." }, + { "id": "gelios.telemetry_snapshot.has_operational_status", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.operational_status"], "status": "product-required", "summary": "The semantic mapping derives normalized operational state from telemetry and data-quality policy." }, { "id": "gelios.telemetry_snapshot.has_parameter", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.telemetry_parameter"], "status": "source-evidenced", "summary": "A provider snapshot can contain dynamic telemetry parameters." }, { "id": "gelios.unit.has_sensor", "from": ["gelios.unit"], "to": ["gelios.sensor_definition"], "status": "source-evidenced", "summary": "A unit exposes zero or more sensor definitions." }, { "id": "gelios.sensor_definition.has_conversion", "from": ["gelios.sensor_definition"], "to": ["gelios.sensor_conversion"], "status": "source-evidenced", "summary": "A sensor can define conversion/calibration rows." }, @@ -29,14 +29,14 @@ { "id": "gelios.geozone.belongs_to_group", "from": ["gelios.geozone"], "to": ["gelios.geozone_group"], "status": "source-evidenced", "summary": "A provider geozone can belong to a provider geozone group." }, { "id": "gelios.report_result.uses_template", "from": ["gelios.report_result"], "to": ["gelios.report_template"], "status": "source-evidenced", "summary": "A report result is produced from an approved report template and bounded request." }, { "id": "gelios.collection_run.checkpoints_with_cursor", "from": ["gelios.collection_run"], "to": ["gelios.ingestion_cursor"], "status": "product-required", "summary": "Collection runs advance resumable ingestion only through a durable cursor." }, - { "id": "gelios.collection_run.collects_unit", "from": ["gelios.collection_run"], "to": ["gelios.unit"], "status": "product-required", "summary": "A read-only collection run records the approved unit scope it addressed." }, + { "id": "gelios.collection_run.collects_unit", "from": ["gelios.collection_run"], "to": ["gelios.unit"], "status": "product-required", "summary": "A safe-read collection run records every valid credential-visible unit returned by the selected capability." }, { "id": "gelios.unit.has_command_template", "from": ["gelios.unit"], "to": ["gelios.command_template"], "status": "source-evidenced", "summary": "Command templates are catalogued per unit through read-only routes." }, { "id": "gelios.command_template.belongs_to_group", "from": ["gelios.command_template"], "to": ["gelios.command_group"], "status": "source-evidenced", "summary": "A command template can belong to a provider command group." }, - { "id": "gelios.command_dispatch.uses_template", "from": ["gelios.command_dispatch"], "to": ["gelios.command_template"], "status": "product-required", "summary": "A dispatch may reference a reviewed command template." }, - { "id": "gelios.command_dispatch.targets_scope", "from": ["gelios.command_dispatch"], "to": ["gelios.unit", "gelios.unit_group", "gelios.access_scope"], "status": "product-required", "summary": "A dispatch target must resolve to an approved unit or explicitly approved group in scope." }, - { "id": "gelios.command_dispatch.has_delivery", "from": ["gelios.command_dispatch"], "to": ["gelios.command_delivery"], "status": "product-required", "summary": "A dispatch is associated with delivery/task state rather than assumed successful on request creation." }, - { "id": "gelios.command_dispatch.has_audit", "from": ["gelios.command_dispatch"], "to": ["gelios.command_audit"], "status": "product-required", "summary": "Every dispatch requires immutable user-confirmation and outcome audit." }, - { "id": "gelios.unit.is_map_moving_object", "from": ["gelios.unit"], "to": ["map.moving_object"], "status": "product-required", "summary": "An approved Gelios unit is rendered through the provider-neutral moving-object contract, never as a Cesium entity identity." }, + { "id": "gelios.command_dispatch.uses_template", "from": ["gelios.command_dispatch"], "to": ["gelios.command_template"], "status": "future-concept", "summary": "A future dispatch may reference a reviewed command template." }, + { "id": "gelios.command_dispatch.targets_scope", "from": ["gelios.command_dispatch"], "to": ["gelios.unit", "gelios.unit_group", "gelios.access_scope"], "status": "future-concept", "summary": "A future dispatch target must resolve to an approved unit or explicitly approved group in scope." }, + { "id": "gelios.command_dispatch.has_delivery", "from": ["gelios.command_dispatch"], "to": ["gelios.command_delivery"], "status": "future-concept", "summary": "A future dispatch is associated with delivery/task state rather than assumed successful on request creation." }, + { "id": "gelios.command_dispatch.has_audit", "from": ["gelios.command_dispatch"], "to": ["gelios.command_audit"], "status": "future-concept", "summary": "Every future dispatch requires immutable user-confirmation and outcome audit." }, + { "id": "gelios.unit.is_map_moving_object", "from": ["gelios.unit"], "to": ["map.moving_object"], "status": "product-required", "summary": "A valid credential-visible Gelios unit is exposed through the provider-neutral moving-object contract, never as a renderer entity identity." }, { "id": "gelios.position_fix.positions_map_subject", "from": ["gelios.position_fix"], "to": ["map.moving_object"], "status": "product-required", "summary": "A normalized position fix updates the stable map subject bound to the Gelios unit." }, { "id": "gelios.geozone.is_map_zone", "from": ["gelios.geozone"], "to": ["map.zone"], "status": "product-required", "summary": "An approved Gelios geozone is exposed to the map through the generic zone contract." }, { "id": "gelios.geopoint.is_map_place_target", "from": ["gelios.geopoint"], "to": ["map.place_target"], "status": "product-required", "summary": "An approved Gelios geopoint is exposed through a provider-neutral place target." } diff --git a/services/ontology-core/catalog/domain-packages/integration/aliases.json b/services/ontology-core/catalog/domain-packages/integration/aliases.json index 93944ff..8594532 100644 --- a/services/ontology-core/catalog/domain-packages/integration/aliases.json +++ b/services/ontology-core/catalog/domain-packages/integration/aliases.json @@ -1,6 +1,6 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "aliases": [ {"alias":"коннектор","canonicalId":"integration.connection","language":"ru","status":"product-required"}, {"alias":"connector","canonicalId":"integration.connection","language":"en","status":"product-required"}, diff --git a/services/ontology-core/catalog/domain-packages/integration/entities.json b/services/ontology-core/catalog/domain-packages/integration/entities.json index 0fcc764..4277a52 100644 --- a/services/ontology-core/catalog/domain-packages/integration/entities.json +++ b/services/ontology-core/catalog/domain-packages/integration/entities.json @@ -1,21 +1,21 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "entities": [ - {"id":"integration.provider","name":"External Provider","surface":"integration","status":["product-required"],"authority":"Platform External Provider Data Plane","summary":"A third-party product or API family integrated through an app-owned adapter, not a tenant or a renderer."}, - {"id":"integration.connection","name":"Provider Connection","surface":"integration","status":["product-required"],"authority":"Provider Gateway","summary":"A tenant-scoped configured connection to one provider account or endpoint; it contains policy and secret references, never secret values."}, - {"id":"integration.credential_reference","name":"Provider Credential Reference","surface":"integration","status":["product-required"],"authority":"Provider Gateway / secret store","summary":"Opaque reference to a server-side secret or token lifecycle, not a token, login or password."}, - {"id":"integration.access_scope","name":"Integration Access Scope","surface":"integration","status":["product-required"],"authority":"Client owner + Provider Gateway","summary":"Approved product boundary for source accounts, objects, fields and permitted operations; provider visibility alone is insufficient."}, - {"id":"integration.capability","name":"Provider Capability","surface":"integration","status":["product-required"],"authority":"Provider Adapter","summary":"Versioned provider operation or feature classified as read, metadata, write, destructive or unknown."}, - {"id":"integration.field_definition","name":"Provider Field Definition","surface":"integration","status":["product-required"],"authority":"Provider Adapter + Ontology Core","summary":"A documented source field, its semantics, sensitivity, units and normalisation mapping; it is not a live value."}, - {"id":"integration.collection_profile","name":"Collection Profile","surface":"integration","status":["product-required"],"authority":"Provider Gateway","summary":"Approved rate, paging, cursor, capability and field selection for a connection; prevents uncontrolled account-wide polling."}, - {"id":"integration.retention_policy","name":"Integration Retention Policy","surface":"integration","status":["product-required"],"authority":"Client owner + Provider Gateway","summary":"Lifecycle rule for current state, raw envelope, normalised history, aggregates and backups."}, - {"id":"integration.collection_run","name":"Integration Collection Run","surface":"integration","status":["product-required"],"authority":"Provider Gateway","summary":"Auditable bounded execution of a safe-read collection profile with cursor, response metrics and outcome."}, - {"id":"integration.raw_envelope","name":"Raw Provider Envelope","surface":"integration","status":["product-required"],"authority":"Provider Gateway","summary":"Restricted provenance-preserving representation of a safe-read provider response, stored in a bounded cold layer rather than exposed to UI."}, - {"id":"integration.canonical_subject","name":"Canonical Integrated Subject","surface":"integration","status":["product-required"],"authority":"Provider Domain Adapter","summary":"Stable domain subject normalised from a provider object and suitable for relations, analytics and interfaces."}, - {"id":"integration.read_model","name":"Integration Read Model","surface":"integration","status":["product-required"],"authority":"Provider Gateway","summary":"Scoped, versioned current, history or aggregate projection delivered to approved consumers without raw provider bypass."}, - {"id":"integration.realtime_channel","name":"Integration Realtime Channel","surface":"integration","status":["product-required"],"authority":"Provider Gateway","summary":"Internal bounded event or subscription contract for projection changes; its emission rate is independent from provider collection rate."}, - {"id":"integration.command_capability","name":"Provider Command Capability","surface":"integration","status":["product-required"],"authority":"Provider Adapter","summary":"Catalogued command metadata, parameters and risk classification. It is not permission or a send route."}, + {"id":"integration.provider","name":"External Provider","surface":"integration","status":["product-required"],"authority":"Versioned provider package + Ontology Core","summary":"A third-party product or API family integrated through NDC L2, not a tenant, runtime service or renderer."}, + {"id":"integration.connection","name":"Provider Connection","surface":"integration","status":["product-required"],"authority":"NDC L2 connection","summary":"A tenant-scoped instance of one versioned provider package for one provider account; it contains policy and opaque credential references, never secret values."}, + {"id":"integration.credential_reference","name":"Provider Credential Reference","surface":"integration","status":["product-required"],"authority":"NDC L2 Credentials","summary":"Opaque reference to an NDC L2-managed provider credential lifecycle, not a credential value, login or password."}, + {"id":"integration.access_scope","name":"Integration Access Scope","surface":"integration","status":["product-required"],"authority":"Provider credential visibility + NDC L2 connection profile","summary":"Selected safe-read capabilities and fields applied to every valid entity visible to the bound credential. Static entity allowlists and provider-group business filters are not collection scope."}, + {"id":"integration.capability","name":"Provider Capability","surface":"integration","status":["product-required"],"authority":"Versioned provider package","summary":"Versioned provider operation or feature classified as read, metadata, write, destructive or unknown."}, + {"id":"integration.field_definition","name":"Provider Field Definition","surface":"integration","status":["product-required"],"authority":"Versioned provider package + Ontology Core","summary":"A documented source field, its semantics, sensitivity, units and normalisation mapping; it is not a live value."}, + {"id":"integration.collection_profile","name":"Collection Profile","surface":"integration","status":["product-required"],"authority":"NDC L2 connection profile","summary":"Rate, paging, cursor, safe capability, field policy and batching selection for one connection; every valid entity returned for the credential is processed."}, + {"id":"integration.retention_policy","name":"Integration Retention Policy","surface":"integration","status":["product-required"],"authority":"External Data Plane Data Product definition","summary":"Lifecycle rule for current state, normalised history, aggregates and any separately approved raw reference."}, + {"id":"integration.collection_run","name":"Integration Collection Run","surface":"integration","status":["product-required"],"authority":"NDC L2 workflow","summary":"Auditable bounded execution of a safe-read collection profile with credential-visible entity scope, cursor, response metrics and outcome."}, + {"id":"integration.raw_envelope","name":"Raw Provider Envelope","surface":"integration","status":["product-required"],"authority":"Restricted raw-data policy","summary":"Optional provenance reference to a separately approved bounded raw layer; inline provider payload is not a Data Product publish field."}, + {"id":"integration.canonical_subject","name":"Canonical Integrated Subject","surface":"integration","status":["product-required"],"authority":"NDC L2 semantic mapping + Ontology Core","summary":"Stable domain subject normalised from a provider object and suitable for relations, analytics and interfaces."}, + {"id":"integration.read_model","name":"Integration Read Model","surface":"integration","status":["product-required"],"authority":"External Data Plane","summary":"Scoped, versioned Data Product current/history projection delivered to approved consumers without provider transport or raw bypass."}, + {"id":"integration.realtime_channel","name":"Integration Realtime Channel","surface":"integration","status":["product-required"],"authority":"External Data Plane","summary":"Bounded snapshot-and-patch contract for Data Product changes; its delivery and history cadence are independent from provider collection rate."}, + {"id":"integration.command_capability","name":"Provider Command Capability","surface":"integration","status":["product-required"],"authority":"Versioned provider package","summary":"Catalogued command metadata, parameters and risk classification. It is not permission or a send route."}, {"id":"integration.command_intent","name":"Provider Command Intent","surface":"integration","status":["future-concept"],"authority":"Future Provider Command Gateway","summary":"A future explicitly confirmed, scoped and idempotent request to execute a catalogued command; no read collector may create it."}, {"id":"integration.command_audit","name":"Provider Command Audit","surface":"integration","status":["future-concept"],"authority":"Future Provider Command Gateway","summary":"Immutable approval, execution and delivery audit for a red-domain command lifecycle."} ] diff --git a/services/ontology-core/catalog/domain-packages/integration/evidence.json b/services/ontology-core/catalog/domain-packages/integration/evidence.json index 4f5daf1..1dc704d 100644 --- a/services/ontology-core/catalog/domain-packages/integration/evidence.json +++ b/services/ontology-core/catalog/domain-packages/integration/evidence.json @@ -2,11 +2,11 @@ "sourceRoots": ["platform/docs", "platform/packages", "platform/services"], "ledgers": [ { - "id": "external_provider_data_plane_adr", - "path": "../../docs/ADR_EXTERNAL_PROVIDER_DATA_PLANE.md", + "id": "l2_owned_external_connectors_adr", + "path": "../../docs/ADR_L2_OWNED_EXTERNAL_CONNECTORS.md", "entityIds": ["integration.provider", "integration.connection", "integration.access_scope", "integration.capability", "integration.collection_profile", "integration.read_model", "integration.command_capability"] } ], - "baselineDocs": ["../../docs/ADR_EXTERNAL_PROVIDER_DATA_PLANE.md", "../../packages/external-provider-contract/README.md"], + "baselineDocs": ["../../docs/ADR_L2_OWNED_EXTERNAL_CONNECTORS.md", "../../packages/external-provider-contract/README.md"], "restrictions": ["The package is a semantic contract. Provider secrets, runtime data and command transport remain outside Ontology Core."] } diff --git a/services/ontology-core/catalog/domain-packages/integration/guardrails.json b/services/ontology-core/catalog/domain-packages/integration/guardrails.json index 07b194b..52e47a7 100644 --- a/services/ontology-core/catalog/domain-packages/integration/guardrails.json +++ b/services/ontology-core/catalog/domain-packages/integration/guardrails.json @@ -1,11 +1,11 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "rules": [ {"id":"guardrail.integration.ontology_is_not_data_plane","severity":"error","summary":"Ontology Core may describe providers and contracts but must not store secret values, live payloads, tenant telemetry, raw envelopes or provider execution state.","entityIds":["integration.provider","integration.connection","integration.credential_reference","integration.raw_envelope","integration.read_model"]}, - {"id":"guardrail.integration.scope_precedes_collection","severity":"error","summary":"A collection run must resolve tenant scope, field policy and bounded collection profile before it addresses a provider. Account visibility does not define product scope.","entityIds":["integration.connection","integration.access_scope","integration.collection_profile","integration.collection_run"]}, - {"id":"guardrail.integration.read_model_not_raw_bypass","severity":"error","summary":"L2 workflows, interfaces and renderer adapters consume scoped read models or realtime channels, never provider credentials, raw envelopes or direct adapter database access.","entityIds":["integration.credential_reference","integration.raw_envelope","integration.read_model","integration.realtime_channel"]}, - {"id":"guardrail.integration.commands_are_separate_red_domain","severity":"error","summary":"Cataloguing a command does not activate it. Read collectors, L2 workflows, maps and AI Workspace may not create command intent or delivery. A later command gateway requires explicit human confirmation, scope, idempotency and audit.","entityIds":["integration.command_capability","integration.command_intent","integration.command_audit","integration.collection_run"]}, + {"id":"guardrail.integration.scope_precedes_collection","severity":"error","summary":"A collection run must resolve tenant/connection, safe capabilities, field policy and bounded profile before calling a provider, then process every valid entity returned for the bound credential. Static entity allowlists and provider-group business filters are forbidden at collection boundary.","entityIds":["integration.connection","integration.access_scope","integration.collection_profile","integration.collection_run"]}, + {"id":"guardrail.integration.read_model_not_raw_bypass","severity":"error","summary":"NDC L2 workflows, interfaces and renderer adapters consume scoped Data Products or realtime channels, never provider credentials, raw envelopes or direct adapter database access.","entityIds":["integration.credential_reference","integration.raw_envelope","integration.read_model","integration.realtime_channel"]}, + {"id":"guardrail.integration.commands_are_separate_red_domain","severity":"error","summary":"Cataloguing a command does not activate it. Read collectors, NDC L2 workflows, maps and AI Workspace may not create command intent or delivery. A later command boundary requires explicit human confirmation, scope, idempotency and audit.","entityIds":["integration.command_capability","integration.command_intent","integration.command_audit","integration.collection_run"]}, {"id":"guardrail.integration.full_catalog_not_full_rate","severity":"warning","summary":"Every provider field may be catalogued, but enabling continuous collection requires an explicit collection, retention and rate policy. Do not turn a capability catalog into unbounded account polling.","entityIds":["integration.capability","integration.field_definition","integration.collection_profile","integration.retention_policy"]} ], "blockedConflations": [ diff --git a/services/ontology-core/catalog/domain-packages/integration/package.json b/services/ontology-core/catalog/domain-packages/integration/package.json index e504b7a..c30ee80 100644 --- a/services/ontology-core/catalog/domain-packages/integration/package.json +++ b/services/ontology-core/catalog/domain-packages/integration/package.json @@ -1,7 +1,7 @@ { "id": "integration", - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "status": "product-required/source-evidenced", "summary": "Provider-neutral external integration ontology: tenant-scoped connections, capabilities, collection, controlled storage projections and a separated red command domain." } diff --git a/services/ontology-core/catalog/domain-packages/integration/relations.json b/services/ontology-core/catalog/domain-packages/integration/relations.json index 3c0fd3b..f5ae45f 100644 --- a/services/ontology-core/catalog/domain-packages/integration/relations.json +++ b/services/ontology-core/catalog/domain-packages/integration/relations.json @@ -1,13 +1,13 @@ { - "version": "0.1.0", - "updatedAt": "2026-07-13", + "version": "1.0.0", + "updatedAt": "2026-07-16", "relations": [ {"id":"integration.provider.offers_connection","from":["integration.provider"],"to":["integration.connection"],"status":"product-required","summary":"A provider is connected through one or more tenant-scoped connection instances."}, {"id":"integration.connection.references_credential","from":["integration.connection"],"to":["integration.credential_reference"],"status":"product-required","summary":"A connection uses an opaque server-side credential reference rather than exposing a secret."}, - {"id":"integration.connection.enforces_scope","from":["integration.connection"],"to":["integration.access_scope"],"status":"product-required","summary":"Every source request is bounded by an approved product scope."}, + {"id":"integration.connection.enforces_scope","from":["integration.connection"],"to":["integration.access_scope"],"status":"product-required","summary":"Every source request is bounded by selected safe capabilities and field policy while entity membership is dynamically all valid objects visible to the bound credential."}, {"id":"integration.provider.declares_capability","from":["integration.provider"],"to":["integration.capability","integration.command_capability"],"status":"product-required","summary":"Adapter manifests catalogue source read and command capabilities independently from their activation."}, {"id":"integration.capability.exposes_field","from":["integration.capability"],"to":["integration.field_definition"],"status":"product-required","summary":"A safe-read capability documents the fields it may return before a collection profile enables them."}, - {"id":"integration.collection_profile.selects_capability","from":["integration.collection_profile"],"to":["integration.capability","integration.field_definition"],"status":"product-required","summary":"A profile selects bounded capabilities and fields rather than polling every API feature continuously."}, + {"id":"integration.collection_profile.selects_capability","from":["integration.collection_profile"],"to":["integration.capability","integration.field_definition"],"status":"product-required","summary":"A profile selects bounded safe capabilities and fields rather than polling every API feature; it does not select business entity IDs."}, {"id":"integration.collection_profile.applies_retention","from":["integration.collection_profile"],"to":["integration.retention_policy"],"status":"product-required","summary":"Collection and storage lifecycle are explicit per connection profile."}, {"id":"integration.collection_run.uses_connection","from":["integration.collection_run"],"to":["integration.connection","integration.collection_profile"],"status":"product-required","summary":"A collection run records which connection and bounded policy it used."}, {"id":"integration.collection_run.records_raw_envelope","from":["integration.collection_run"],"to":["integration.raw_envelope"],"status":"product-required","summary":"Safe-read response provenance is retained only according to the active policy."}, diff --git a/services/ontology-core/examples/gelios-map-moving-object.fixture.json b/services/ontology-core/examples/gelios-map-moving-object.fixture.json index a732e52..d73892d 100644 --- a/services/ontology-core/examples/gelios-map-moving-object.fixture.json +++ b/services/ontology-core/examples/gelios-map-moving-object.fixture.json @@ -1,25 +1,33 @@ { - "contractVersion": "0.1.0", - "kind": "map.moving_object.current_position", - "source": "gelios", - "subjectId": "gelios.unit:example-unit-id", - "observedAt": "2026-07-13T00:00:00.000Z", - "receivedAt": "2026-07-13T00:00:03.000Z", - "position": { - "latitude": 55.000001, - "longitude": 37.000001, - "heightMeters": 0, - "courseDegrees": 90, - "speedKph": 0, - "satellites": 0 + "schemaVersion": "nodedc.data-product.publish/v1", + "batch": { + "runId": "gelios-ontology-fixture-20260716", + "sequence": 0, + "idempotencyKey": "gelios-ontology-fixture-20260716.batch-0" }, - "operationalStatus": "unknown", - "display": { - "label": "Robot2B unit", - "visibilityProfile": "fleet-default" - }, - "policy": { - "scopeApproved": true, - "fieldSet": "studio-current-position-v1" - } + "facts": [ + { + "sourceId": "gelios-unit-1001", + "semanticType": "map.moving_object", + "observedAt": "2026-07-16T12:00:00.000Z", + "geometry": { + "type": "Point", + "coordinates": [37.6173, 55.7558] + }, + "attributes": { + "course_degrees": 90, + "display_name": "Synthetic Gelios unit", + "elevation_meters": 0, + "hdop": 0.8, + "horizontal_accuracy_meters": 4.2, + "object_kind": "tracked_unit", + "operational_status": "active", + "position_source": "gelios", + "position_valid": true, + "quality_flags": [], + "satellite_count": 11, + "speed_kph": 0 + } + } + ] }