1458 lines
68 KiB
JavaScript
1458 lines
68 KiB
JavaScript
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";
|
|
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
|
|
import { isBoundedTelemetryReadings } from "./telemetry-readings.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 FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "telemetry_readings", "point", "geometry"]);
|
|
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 OFFSET_PAGINATION_KEYS = new Set([
|
|
"mode", "limitParameter", "offsetParameter", "pageSize", "itemsPath", "totalPath",
|
|
"maxPages", "maxItems", "maxResponseBytes",
|
|
]);
|
|
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",
|
|
"fieldContracts",
|
|
"history",
|
|
]);
|
|
const FIELD_CONTRACT_KEYS = new Set(["type", "required", "enum", "minimum", "maximum"]);
|
|
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", "omitIfInvalid", "minimum", "maximum", "guardPath", "guardEquals",
|
|
]);
|
|
const GEOMETRY_KEYS = new Set([
|
|
"type", "longitude", "latitude", "strategy", "paths", "allowedTypes", "omitIfInvalid",
|
|
"kindPath", "polygonPointsPath", "corridorPointsPath", "radiusPath",
|
|
"coordinateOrder", "circleSegments",
|
|
]);
|
|
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 : [];
|
|
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`);
|
|
}
|
|
for (const derivation of Object.values(mapping.derivations || {})) {
|
|
if (
|
|
derivation?.kind === "bounded_response_lookup"
|
|
&& !selectedCapabilityIds.includes(derivation.parameters?.responseCapabilityId)
|
|
) {
|
|
errors.push(`collectionProfile.${profile.id}.lookup_response_capability_must_be_selected`);
|
|
}
|
|
}
|
|
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 steps = Array.isArray(template.steps) ? template.steps : [];
|
|
const requestSteps = steps.filter((step) => step?.kind === "provider_request");
|
|
const extractSteps = steps.filter((step) => step?.kind === "extract_items");
|
|
const mappingSteps = steps.filter((step) => step?.kind === "semantic_mapping");
|
|
const publishSteps = steps.filter((step) => step?.kind === "data_product_publish");
|
|
const executedCapabilityIds = requestSteps.map((step) => step.capabilityId);
|
|
if (!sameSet(selectedCapabilityIds, executedCapabilityIds)
|
|
|| extractSteps.length !== 1
|
|
|| extractSteps[0]?.capabilityId !== mapping?.sourceCapabilityId) {
|
|
errors.push(`collectionProfile.${profile.id}.template_capability_chain_mismatch`);
|
|
}
|
|
if (mappingSteps.length !== 1 || mappingSteps[0]?.mappingContractId !== profile.mappingContractId) errors.push(`collectionProfile.${profile.id}.template_mapping_mismatch`);
|
|
if (publishSteps.length !== 1 || publishSteps[0]?.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);
|
|
for (const [derivationId, derivation] of Object.entries(mapping.derivations || {})) {
|
|
if (derivation?.kind === "bounded_response_lookup") {
|
|
assertReference(
|
|
derivation.parameters?.responseCapabilityId,
|
|
capabilityIds,
|
|
`mappingContract.${mapping.id}.derivations.${derivationId}.parameters.responseCapabilityId`,
|
|
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({
|
|
fact: mapping.fact,
|
|
derivations: mapping.derivations,
|
|
}).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 providerRequests = Array.isArray(template.steps)
|
|
? template.steps.filter((step) => step?.kind === "provider_request")
|
|
: [];
|
|
for (const providerRequest of providerRequests) {
|
|
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);
|
|
validateResponsePagination(value.request.response.pagination, value.request.query, `${path}.request.response.pagination`, errors);
|
|
}
|
|
}
|
|
validateCredentialVisibleScope(value.entityScope, `${path}.entityScope`, errors);
|
|
}
|
|
|
|
function validateResponsePagination(value, query, path, errors) {
|
|
if (value === "single_bounded_response") return;
|
|
if (!isPlainObject(value)) {
|
|
errors.push(`${path}_invalid`);
|
|
return;
|
|
}
|
|
rejectUnknownKeys(value, OFFSET_PAGINATION_KEYS, path, errors);
|
|
if (value.mode !== "offset") errors.push(`${path}.mode_must_be_offset`);
|
|
for (const field of ["limitParameter", "offsetParameter"]) {
|
|
if (typeof value[field] !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value[field])) {
|
|
errors.push(`${path}.${field}_invalid`);
|
|
}
|
|
}
|
|
if (!Number.isInteger(value.pageSize) || value.pageSize < 1 || value.pageSize > 500) {
|
|
errors.push(`${path}.pageSize_invalid`);
|
|
}
|
|
if (!Number.isInteger(value.maxPages) || value.maxPages < 1 || value.maxPages > 1000) {
|
|
errors.push(`${path}.maxPages_invalid`);
|
|
}
|
|
if (!Number.isInteger(value.maxItems) || value.maxItems < 1 || value.maxItems > 5000) {
|
|
errors.push(`${path}.maxItems_invalid`);
|
|
}
|
|
if (!Number.isInteger(value.maxResponseBytes) || value.maxResponseBytes < 1024 || value.maxResponseBytes > 32 * 1024 * 1024) {
|
|
errors.push(`${path}.maxResponseBytes_invalid`);
|
|
}
|
|
for (const field of ["itemsPath", "totalPath"]) {
|
|
if (!isCanonicalSourcePath(value[field])) errors.push(`${path}.${field}_invalid`);
|
|
}
|
|
if (isPlainObject(query)) {
|
|
if (query[value.limitParameter] !== value.pageSize) errors.push(`${path}.pageSize_must_match_static_query_limit`);
|
|
if (query[value.offsetParameter] !== 0) errors.push(`${path}.offset_query_must_start_at_zero`);
|
|
}
|
|
if (Number.isInteger(value.maxPages) && Number.isInteger(value.pageSize)
|
|
&& Number.isInteger(value.maxItems) && value.maxPages * value.pageSize < value.maxItems) {
|
|
errors.push(`${path}.maxPages_cannot_cover_maxItems`);
|
|
}
|
|
}
|
|
|
|
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", "delayMs"]), `${path}.retry`, errors);
|
|
if (!Number.isInteger(value.retry.maxAttempts) || value.retry.maxAttempts < 1 || value.retry.maxAttempts > 10) errors.push(`${path}.retry.maxAttempts_invalid`);
|
|
if (!["exponential_with_jitter", "fixed_delay"].includes(value.retry.backoff)) {
|
|
errors.push(`${path}.retry.backoff_invalid`);
|
|
}
|
|
if (value.retry.backoff === "fixed_delay") {
|
|
if (!Number.isInteger(value.retry.delayMs) || value.retry.delayMs < 100 || value.retry.delayMs > 60_000) {
|
|
errors.push(`${path}.retry.delayMs_invalid`);
|
|
}
|
|
} else if (value.retry.delayMs !== undefined) {
|
|
errors.push(`${path}.retry.delayMs_requires_fixed_delay`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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`);
|
|
}
|
|
validateFieldContracts(value.fieldContracts, value.fields, `${path}.fieldContracts`, errors);
|
|
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") {
|
|
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.geometry.strategy !== undefined || value.fact.geometry.paths !== undefined || value.fact.geometry.allowedTypes !== undefined) {
|
|
errors.push(`${path}.fact.geometry.point_shape_invalid`);
|
|
}
|
|
} else if (value.fact.geometry.type === "GeoJSON") {
|
|
if (value.fact.geometry.strategy !== "bounded_zone_geometry_v1") errors.push(`${path}.fact.geometry.strategy_invalid`);
|
|
for (const key of ["kindPath", "polygonPointsPath", "corridorPointsPath", "radiusPath"]) {
|
|
if (!isCanonicalSourcePath(value.fact.geometry[key])) {
|
|
errors.push(`${path}.fact.geometry.${key}_invalid`);
|
|
}
|
|
}
|
|
if (!["latitude_longitude", "longitude_latitude"].includes(value.fact.geometry.coordinateOrder)) {
|
|
errors.push(`${path}.fact.geometry.coordinateOrder_invalid`);
|
|
}
|
|
if (!Number.isInteger(value.fact.geometry.circleSegments)
|
|
|| value.fact.geometry.circleSegments < 16
|
|
|| value.fact.geometry.circleSegments > 256) {
|
|
errors.push(`${path}.fact.geometry.circleSegments_invalid`);
|
|
}
|
|
if (!sameSet(value.fact.geometry.allowedTypes, ["Polygon", "MultiPolygon"])) {
|
|
errors.push(`${path}.fact.geometry.allowedTypes_must_be_polygon_or_multipolygon`);
|
|
}
|
|
if (value.fact.geometry.omitIfInvalid !== false) errors.push(`${path}.fact.geometry.omitIfInvalid_must_be_false`);
|
|
if (
|
|
value.fact.geometry.longitude !== undefined
|
|
|| value.fact.geometry.latitude !== undefined
|
|
|| value.fact.geometry.paths !== undefined
|
|
) {
|
|
errors.push(`${path}.fact.geometry.geojson_shape_invalid`);
|
|
}
|
|
} else {
|
|
errors.push(`${path}.fact.geometry.type_invalid`);
|
|
}
|
|
}
|
|
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_complete_boundary`);
|
|
} 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 requestSteps = value.steps.filter((step) => step?.kind === "provider_request");
|
|
const extractSteps = value.steps.filter((step) => step?.kind === "extract_items");
|
|
const mappingSteps = value.steps.filter((step) => step?.kind === "semantic_mapping");
|
|
const publishSteps = value.steps.filter((step) => step?.kind === "data_product_publish");
|
|
const requestStart = 1;
|
|
const extractIndex = value.steps.findIndex((step) => step?.kind === "extract_items");
|
|
const expectedSequence = (
|
|
value.steps[0]?.kind === "collection_trigger"
|
|
&& requestSteps.length >= 1
|
|
&& value.steps.slice(requestStart, extractIndex).every((step) => step?.kind === "provider_request")
|
|
&& extractSteps.length === 1
|
|
&& mappingSteps.length === 1
|
|
&& publishSteps.length === 1
|
|
&& value.steps.at(-3)?.kind === "extract_items"
|
|
&& value.steps.at(-2)?.kind === "semantic_mapping"
|
|
&& value.steps.at(-1)?.kind === "data_product_publish"
|
|
);
|
|
if (!expectedSequence) errors.push(`${path}.steps_sequence_invalid`);
|
|
if (value.steps[0]?.collectionProfileDriven !== true) errors.push(`${path}.collection_trigger_must_be_profile_driven`);
|
|
for (const step of requestSteps) {
|
|
if (!step?.capabilityId || step?.mappingContractId !== undefined || step?.dataProductId !== undefined) {
|
|
errors.push(`${path}.provider_request_shape_invalid`);
|
|
}
|
|
}
|
|
const extractStep = extractSteps[0];
|
|
if (!extractStep?.capabilityId || extractStep?.mappingContractId !== undefined || extractStep?.dataProductId !== undefined) {
|
|
errors.push(`${path}.extract_items_shape_invalid`);
|
|
}
|
|
if (!requestSteps.some((step) => step.capabilityId === extractStep?.capabilityId)) errors.push(`${path}.request_extract_capability_mismatch`);
|
|
const mappingStep = mappingSteps[0];
|
|
if (!mappingStep?.mappingContractId || mappingStep?.capabilityId !== undefined || mappingStep?.dataProductId !== undefined) {
|
|
errors.push(`${path}.semantic_mapping_shape_invalid`);
|
|
}
|
|
const publishStep = publishSteps[0];
|
|
if (!publishStep?.dataProductId || publishStep?.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`);
|
|
const contracts = isPlainObject(product.fieldContracts) ? product.fieldContracts : {};
|
|
for (const [field, contract] of Object.entries(contracts)) {
|
|
if (!isPlainObject(contract)) continue;
|
|
if (field === "geometry") {
|
|
if (contract.type === "point" && mapping.fact?.geometry?.type !== "Point") {
|
|
errors.push(`${path}.fact.geometry_contract_must_be_point`);
|
|
}
|
|
if (contract.type === "geometry" && mapping.fact?.geometry?.type !== "GeoJSON") {
|
|
errors.push(`${path}.fact.geometry_contract_must_be_geojson`);
|
|
}
|
|
if (!new Set(["point", "geometry"]).has(contract.type)) errors.push(`${path}.fact.geometry_contract_invalid`);
|
|
if (contract.required === true && mapping.fact?.geometry?.omitIfInvalid === true) {
|
|
errors.push(`${path}.fact.geometry_required_but_mapping_can_omit`);
|
|
}
|
|
continue;
|
|
}
|
|
const expression = mapping.fact?.attributes?.[field];
|
|
if (!isPlainObject(expression)) continue;
|
|
validateExpressionAgainstFieldContract(expression, contract, mapping.derivations, `${path}.fact.attributes.${field}`, errors);
|
|
}
|
|
}
|
|
|
|
function validateFieldContracts(value, fields, path, errors) {
|
|
if (value === undefined) return;
|
|
if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`);
|
|
const names = Object.keys(value);
|
|
if (names.length && !sameSet(names, fields)) errors.push(`${path}_must_exactly_match_fields`);
|
|
for (const [field, contract] of Object.entries(value)) {
|
|
requiredIdentifier(field, `${path}.${field}`, errors);
|
|
if (SECRET_FIELD_NAME.test(field)) errors.push(`${path}.${field}_secret_field_not_allowed`);
|
|
validateFieldContract(contract, `${path}.${field}`, errors);
|
|
}
|
|
}
|
|
|
|
function validateFieldContract(value, path, errors) {
|
|
if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`);
|
|
rejectUnknownKeys(value, FIELD_CONTRACT_KEYS, path, errors);
|
|
if (!FIELD_CONTRACT_TYPES.has(value.type)) errors.push(`${path}.type_invalid`);
|
|
if (typeof value.required !== "boolean") errors.push(`${path}.required_must_be_boolean`);
|
|
if (value.enum !== undefined) {
|
|
if (!Array.isArray(value.enum) || value.enum.length === 0 || new Set(value.enum.map(stableLiteral)).size !== value.enum.length) {
|
|
errors.push(`${path}.enum_must_be_nonempty_unique_array`);
|
|
} else if (new Set(["point", "geometry", "string_array", "telemetry_readings"]).has(value.type)
|
|
|| value.enum.some((item) => !fieldContractValueMatchesType(item, value.type))) {
|
|
errors.push(`${path}.enum_value_type_invalid`);
|
|
}
|
|
}
|
|
if (value.minimum !== undefined || value.maximum !== undefined) {
|
|
if (value.type !== "number") errors.push(`${path}.range_only_allowed_for_number`);
|
|
if (value.minimum !== undefined && !Number.isFinite(value.minimum)) errors.push(`${path}.minimum_invalid`);
|
|
if (value.maximum !== undefined && !Number.isFinite(value.maximum)) errors.push(`${path}.maximum_invalid`);
|
|
if (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum) {
|
|
errors.push(`${path}.range_invalid`);
|
|
}
|
|
if (Array.isArray(value.enum) && value.enum.some((item) => (
|
|
typeof item === "number"
|
|
&& ((Number.isFinite(value.minimum) && item < value.minimum)
|
|
|| (Number.isFinite(value.maximum) && item > value.maximum))
|
|
))) errors.push(`${path}.enum_value_out_of_range`);
|
|
}
|
|
}
|
|
|
|
function validateExpressionAgainstFieldContract(expression, contract, derivations, path, errors) {
|
|
if (contract.required === true && expression.omitIfMissing === true) {
|
|
errors.push(`${path}_required_but_mapping_can_omit`);
|
|
}
|
|
if (contract.required === true && expression.omitIfInvalid === true) {
|
|
errors.push(`${path}_required_but_mapping_can_omit_invalid`);
|
|
}
|
|
if (expression.paths !== undefined) {
|
|
if (contract.required === false && expression.omitIfMissing !== true) {
|
|
errors.push(`${path}_optional_source_field_must_omit_missing`);
|
|
}
|
|
const expectedCoerce = new Map([["string", "string"], ["number", "number"], ["boolean", "boolean"]]).get(contract.type);
|
|
if (expectedCoerce && expression.coerce !== expectedCoerce) errors.push(`${path}.coerce_must_match_field_contract`);
|
|
if (Array.isArray(contract.enum)) errors.push(`${path}.enum_field_must_use_constant_or_derivation`);
|
|
if (contract.type === "number") {
|
|
if (!Object.is(expression.minimum, contract.minimum)) errors.push(`${path}.minimum_must_match_field_contract`);
|
|
if (!Object.is(expression.maximum, contract.maximum)) errors.push(`${path}.maximum_must_match_field_contract`);
|
|
if ((contract.minimum !== undefined || contract.maximum !== undefined) && expression.omitIfInvalid !== true) {
|
|
errors.push(`${path}.bounded_number_must_omit_invalid`);
|
|
}
|
|
}
|
|
}
|
|
if (expression.constant !== undefined) {
|
|
validateMappedValueAgainstFieldContract(expression.constant, contract, `${path}.constant`, errors);
|
|
}
|
|
if (typeof expression.derive === "string") {
|
|
const derivation = isPlainObject(derivations) ? derivations[expression.derive] : undefined;
|
|
if (!isPlainObject(derivation)) return;
|
|
validateMappedValueAgainstFieldContract(derivation.default, contract, `${path}.derivation.default`, errors);
|
|
if (Array.isArray(contract.enum) && Array.isArray(derivation.rules)) {
|
|
for (const [index, rule] of derivation.rules.entries()) {
|
|
const output = typeof rule === "string" ? rule.slice(rule.lastIndexOf(".") + 1) : undefined;
|
|
validateMappedValueAgainstFieldContract(output, contract, `${path}.derivation.rules[${index}]`, errors);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateMappedValueAgainstFieldContract(value, contract, path, errors) {
|
|
if (!fieldContractValueMatchesType(value, contract.type)) errors.push(`${path}_type_mismatch`);
|
|
if (Array.isArray(contract.enum) && !contract.enum.some((allowed) => Object.is(allowed, value))) {
|
|
errors.push(`${path}_not_in_field_contract_enum`);
|
|
}
|
|
if (typeof value === "number") {
|
|
if (Number.isFinite(contract.minimum) && value < contract.minimum) errors.push(`${path}_below_field_contract_minimum`);
|
|
if (Number.isFinite(contract.maximum) && value > contract.maximum) errors.push(`${path}_above_field_contract_maximum`);
|
|
}
|
|
}
|
|
|
|
function fieldContractValueMatchesType(value, type) {
|
|
if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
if (type === "telemetry_readings") return isBoundedTelemetryReadings(value);
|
|
if (type === "point") {
|
|
return isPlainObject(value)
|
|
&& value.type === "Point"
|
|
&& Array.isArray(value.coordinates)
|
|
&& value.coordinates.length === 2
|
|
&& value.coordinates.every(Number.isFinite);
|
|
}
|
|
if (type === "geometry") {
|
|
return isPlainObject(value)
|
|
&& new Set(["Point", "LineString", "Polygon", "MultiPolygon"]).has(value.type)
|
|
&& Array.isArray(value.coordinates);
|
|
}
|
|
return typeof value === type && (type !== "number" || Number.isFinite(value));
|
|
}
|
|
|
|
function stableLiteral(value) {
|
|
return `${typeof value}:${JSON.stringify(value)}`;
|
|
}
|
|
|
|
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`);
|
|
if (value.omitIfInvalid !== undefined && typeof value.omitIfInvalid !== "boolean") errors.push(`${path}.omitIfInvalid_must_be_boolean`);
|
|
const hasGuardPath = value.guardPath !== undefined;
|
|
const hasGuardEquals = value.guardEquals !== undefined;
|
|
if (hasGuardPath !== hasGuardEquals) errors.push(`${path}.guard_requires_path_and_equals`);
|
|
if (hasGuardPath) {
|
|
if (!isCanonicalSourcePath(value.guardPath)) {
|
|
errors.push(`${path}.guardPath_must_be_canonical_dot_path`);
|
|
}
|
|
if (!["string", "number", "boolean"].includes(typeof value.guardEquals)) {
|
|
errors.push(`${path}.guardEquals_must_be_scalar`);
|
|
}
|
|
if (value.omitIfMissing !== true) errors.push(`${path}.guard_requires_omitIfMissing`);
|
|
}
|
|
if (value.minimum !== undefined || value.maximum !== undefined) {
|
|
if (value.coerce !== "number") errors.push(`${path}.range_requires_number_coercion`);
|
|
if (value.minimum !== undefined && !Number.isFinite(value.minimum)) errors.push(`${path}.minimum_invalid`);
|
|
if (value.maximum !== undefined && !Number.isFinite(value.maximum)) errors.push(`${path}.maximum_invalid`);
|
|
if (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum) {
|
|
errors.push(`${path}.range_invalid`);
|
|
}
|
|
if (value.omitIfInvalid !== true) errors.push(`${path}.range_requires_omitIfInvalid`);
|
|
} else if (value.omitIfInvalid !== undefined) {
|
|
errors.push(`${path}.omitIfInvalid_requires_range`);
|
|
}
|
|
}
|
|
|
|
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",
|
|
"bounded_readings",
|
|
"bounded_response_lookup",
|
|
"bounded_string_list",
|
|
"bounded_named_values",
|
|
]).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`);
|
|
}
|
|
}
|
|
}
|
|
if (value.kind === "bounded_string_list") {
|
|
validateBoundedStringListDerivation(value, path, errors);
|
|
}
|
|
if (value.kind === "bounded_named_values") {
|
|
validateBoundedNamedValuesDerivation(value, path, errors);
|
|
}
|
|
if (value.kind === "bounded_response_lookup") {
|
|
validateBoundedResponseLookupDerivation(value, path, errors);
|
|
}
|
|
}
|
|
|
|
function validateBoundedResponseLookupDerivation(value, path, errors) {
|
|
const parameters = isPlainObject(value.parameters) ? value.parameters : {};
|
|
const allowed = new Set([
|
|
"responseCapabilityId",
|
|
"responseCollectionPath",
|
|
"responseKeyPath",
|
|
"sourceArrayPath",
|
|
"sourceKeyPath",
|
|
"valuePath",
|
|
"resultMode",
|
|
"maxResponseItems",
|
|
"maxSourceKeys",
|
|
"maxItems",
|
|
"maxItemLength",
|
|
]);
|
|
if (Object.keys(parameters).some((key) => !allowed.has(key))) {
|
|
errors.push(`${path}.parameters_shape_invalid`);
|
|
}
|
|
if (!IDENTIFIER.test(String(parameters.responseCapabilityId || ""))) {
|
|
errors.push(`${path}.parameters.responseCapabilityId_invalid`);
|
|
}
|
|
for (const key of [
|
|
"responseCollectionPath",
|
|
"responseKeyPath",
|
|
"sourceKeyPath",
|
|
"valuePath",
|
|
]) {
|
|
if (!isCanonicalSourcePath(parameters[key])) {
|
|
errors.push(`${path}.parameters.${key}_must_be_canonical_dot_path`);
|
|
}
|
|
}
|
|
if (SECRET_FIELD_NAME.test(String(parameters.valuePath || ""))) {
|
|
errors.push(`${path}.parameters.valuePath_secret_field_not_allowed`);
|
|
}
|
|
if (
|
|
parameters.sourceArrayPath !== undefined
|
|
&& !isCanonicalSourcePath(parameters.sourceArrayPath)
|
|
) {
|
|
errors.push(`${path}.parameters.sourceArrayPath_must_be_canonical_dot_path`);
|
|
}
|
|
if (!new Set(["first", "list"]).has(parameters.resultMode)) {
|
|
errors.push(`${path}.parameters.resultMode_invalid`);
|
|
}
|
|
if (
|
|
!Number.isInteger(parameters.maxResponseItems)
|
|
|| parameters.maxResponseItems < 1
|
|
|| parameters.maxResponseItems > 5000
|
|
) {
|
|
errors.push(`${path}.parameters.maxResponseItems_invalid`);
|
|
}
|
|
if (
|
|
!Number.isInteger(parameters.maxSourceKeys)
|
|
|| parameters.maxSourceKeys < 1
|
|
|| parameters.maxSourceKeys > 256
|
|
) {
|
|
errors.push(`${path}.parameters.maxSourceKeys_invalid`);
|
|
}
|
|
if (
|
|
!Number.isInteger(parameters.maxItems)
|
|
|| parameters.maxItems < 1
|
|
|| parameters.maxItems > 256
|
|
) {
|
|
errors.push(`${path}.parameters.maxItems_invalid`);
|
|
}
|
|
if (
|
|
!Number.isInteger(parameters.maxItemLength)
|
|
|| parameters.maxItemLength < 1
|
|
|| parameters.maxItemLength > 512
|
|
) {
|
|
errors.push(`${path}.parameters.maxItemLength_invalid`);
|
|
}
|
|
if (parameters.resultMode === "list") {
|
|
if (!Array.isArray(value.default) || value.default.length !== 0) {
|
|
errors.push(`${path}.default_must_be_empty_array_for_list`);
|
|
}
|
|
} else if (value.default !== "") {
|
|
errors.push(`${path}.default_must_be_empty_string_for_first`);
|
|
}
|
|
}
|
|
|
|
function validateBoundedStringListDerivation(value, path, errors) {
|
|
const parameters = isPlainObject(value.parameters) ? value.parameters : {};
|
|
const allowed = new Set(["arrayPath", "valuePath", "maxItems", "maxItemLength"]);
|
|
if (Object.keys(parameters).some((key) => !allowed.has(key))) {
|
|
errors.push(`${path}.parameters_shape_invalid`);
|
|
}
|
|
for (const key of ["arrayPath", "valuePath"]) {
|
|
if (!isCanonicalSourcePath(parameters[key])) {
|
|
errors.push(`${path}.parameters.${key}_must_be_canonical_dot_path`);
|
|
}
|
|
}
|
|
if (!Number.isInteger(parameters.maxItems) || parameters.maxItems < 1 || parameters.maxItems > 256) {
|
|
errors.push(`${path}.parameters.maxItems_invalid`);
|
|
}
|
|
if (
|
|
!Number.isInteger(parameters.maxItemLength)
|
|
|| parameters.maxItemLength < 1
|
|
|| parameters.maxItemLength > 512
|
|
) {
|
|
errors.push(`${path}.parameters.maxItemLength_invalid`);
|
|
}
|
|
if (!Array.isArray(value.default) || value.default.length !== 0) {
|
|
errors.push(`${path}.default_must_be_empty_array`);
|
|
}
|
|
}
|
|
|
|
function validateBoundedNamedValuesDerivation(value, path, errors) {
|
|
const parameters = isPlainObject(value.parameters) ? value.parameters : {};
|
|
const allowed = new Set([
|
|
"arrayPath",
|
|
"namePath",
|
|
"valuePath",
|
|
"maxItems",
|
|
"maxItemLength",
|
|
]);
|
|
if (Object.keys(parameters).some((key) => !allowed.has(key))) {
|
|
errors.push(`${path}.parameters_shape_invalid`);
|
|
}
|
|
for (const key of ["arrayPath", "namePath", "valuePath"]) {
|
|
if (!isCanonicalSourcePath(parameters[key])) {
|
|
errors.push(`${path}.parameters.${key}_must_be_canonical_dot_path`);
|
|
}
|
|
}
|
|
if (!Number.isInteger(parameters.maxItems) || parameters.maxItems < 1 || parameters.maxItems > 256) {
|
|
errors.push(`${path}.parameters.maxItems_invalid`);
|
|
}
|
|
if (
|
|
!Number.isInteger(parameters.maxItemLength)
|
|
|| parameters.maxItemLength < 1
|
|
|| parameters.maxItemLength > 512
|
|
) {
|
|
errors.push(`${path}.parameters.maxItemLength_invalid`);
|
|
}
|
|
if (!Array.isArray(value.default) || value.default.length !== 0) {
|
|
errors.push(`${path}.default_must_be_empty_array`);
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
for (const key of ["kindPath", "polygonPointsPath", "corridorPointsPath", "radiusPath"]) {
|
|
if (isCanonicalSourcePath(value[key])) found.push(value[key]);
|
|
}
|
|
Object.entries(value).forEach(([key, item]) => {
|
|
if (key !== "paths" && key.endsWith("Path") && isCanonicalSourcePath(item)) {
|
|
found.push(item);
|
|
} else if (key !== "paths" && !key.endsWith("Path")) {
|
|
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 (!isNdcCredentialReferenceValue(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) });
|
|
}
|