feat(data-plane): add provider contracts and ontology delivery

This commit is contained in:
Codex
2026-07-16 02:23:34 +03:00
parent e527812826
commit 569b8762e6
84 changed files with 11170 additions and 70 deletions
@@ -0,0 +1,197 @@
export const DATA_PRODUCT_PUBLISH_SCHEMA_VERSION = "nodedc.data-product.publish/v1";
export const DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION = "nodedc.data-product.snapshot/v1";
export const DATA_PRODUCT_PATCH_SCHEMA_VERSION = "nodedc.data-product.patch/v1";
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;
/**
* Wire form accepted from an NDC Data Product Publish node.
*
* Scope, provider identity, product version, ontology revision and storage
* policy are deliberately absent: the Data Plane materializes them from the
* opaque writer grant and its product registry.
*/
export function validateDataProductPublish(value, { maxFacts = 5000, maxAttributesBytes = 64 * 1024 } = {}) {
const errors = [];
const attributesCeiling = Number.isInteger(maxAttributesBytes) && maxAttributesBytes > 0
? Math.min(maxAttributesBytes, MAX_FACT_ATTRIBUTES_BYTES)
: MAX_FACT_ATTRIBUTES_BYTES;
if (!isPlainObject(value)) return result(["publish_must_be_object"]);
if (value.schemaVersion !== DATA_PRODUCT_PUBLISH_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
rejectUnknownKeys(value, new Set(["schemaVersion", "batch", "facts"]), "publish", errors);
if (!isPlainObject(value.batch)) {
errors.push("batch_must_be_object");
} else {
rejectUnknownKeys(value.batch, new Set(["runId", "sequence", "idempotencyKey"]), "batch", errors);
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");
}
}
if (!Array.isArray(value.facts) || value.facts.length === 0) {
errors.push("facts_must_be_nonempty_array");
} else if (value.facts.length > maxFacts) {
errors.push("facts_limit_exceeded");
} else {
const entityKeys = new Set();
value.facts.forEach((fact, index) => {
validateFact(fact, `facts[${index}]`, errors, { maxAttributesBytes: attributesCeiling });
if (!isPlainObject(fact) || typeof fact.sourceId !== "string" || typeof fact.semanticType !== "string") return;
const entityKey = `${fact.sourceId}\u0000${fact.semanticType}`;
if (entityKeys.has(entityKey)) errors.push("facts_duplicate_entity_key");
entityKeys.add(entityKey);
});
}
if (containsSecretLikeMaterial(value)) errors.push("publish_must_not_contain_secret_material");
return result(errors);
}
export function validateDataProductSnapshot(value) {
const errors = envelopeErrors(value, DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION, "snapshot");
rejectUnknownKeys(value, new Set(["schemaVersion", "dataProduct", "generatedAt", "cursor", "facts", "nextPageCursor"]), "snapshot", errors);
requiredCursor(value?.cursor, "cursor", errors);
requiredIsoTimestamp(value?.generatedAt, "generatedAt", errors);
if (!Array.isArray(value?.facts)) {
errors.push("facts_must_be_array");
} else {
value.facts.forEach((fact, index) => validateCanonicalFact(fact, `facts[${index}]`, errors));
}
if (value?.nextPageCursor !== undefined) requiredString(value.nextPageCursor, "nextPageCursor", errors);
if (containsSecretLikeMaterial(value)) errors.push("snapshot_must_not_contain_secret_material");
return result(errors);
}
export function validateDataProductPatch(value) {
const errors = envelopeErrors(value, DATA_PRODUCT_PATCH_SCHEMA_VERSION, "patch");
rejectUnknownKeys(value, new Set(["schemaVersion", "dataProduct", "cursor", "previousCursor", "emittedAt", "operations"]), "patch", errors);
requiredCursor(value?.cursor, "cursor", errors);
requiredCursor(value?.previousCursor, "previousCursor", errors);
requiredIsoTimestamp(value?.emittedAt, "emittedAt", errors);
if (!Array.isArray(value?.operations) || value.operations.length === 0) {
errors.push("operations_must_be_nonempty_array");
} else {
value.operations.forEach((operation, index) => {
if (!isPlainObject(operation) || operation.op !== "upsert") {
errors.push(`operations[${index}].op_must_be_upsert`);
return;
}
rejectUnknownKeys(operation, new Set(["op", "fact"]), `operations[${index}]`, errors);
validateCanonicalFact(operation.fact, `operations[${index}].fact`, errors);
});
}
if (containsSecretLikeMaterial(value)) errors.push("patch_must_not_contain_secret_material");
return result(errors);
}
function envelopeErrors(value, schemaVersion, label) {
const errors = [];
if (!isPlainObject(value)) return [`${label}_must_be_object`];
if (value.schemaVersion !== schemaVersion) errors.push("schemaVersion_mismatch");
if (!isPlainObject(value.dataProduct)) {
errors.push("dataProduct_must_be_object");
} else {
rejectUnknownKeys(value.dataProduct, new Set(["id", "version"]), "dataProduct", errors);
requiredIdentifier(value.dataProduct.id, "dataProduct.id", errors);
requiredString(value.dataProduct.version, "dataProduct.version", errors);
if (value.dataProduct.version && !SEMVER.test(value.dataProduct.version)) errors.push("dataProduct.version_must_be_semver");
}
return errors;
}
function validateFact(value, path, errors, { maxAttributesBytes, canonical = false }) {
if (!isPlainObject(value)) {
errors.push(`${path}_must_be_object`);
return;
}
const allowedKeys = new Set(["sourceId", "semanticType", "observedAt", "attributes", "geometry"]);
if (canonical) allowedKeys.add("receivedAt");
rejectUnknownKeys(value, allowedKeys, 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) > maxAttributesBytes) {
errors.push(`${path}.attributes_size_exceeded`);
}
}
if (value.geometry !== undefined) validatePointGeometry(value.geometry, `${path}.geometry`, errors);
}
function validateCanonicalFact(value, path, errors) {
validateFact(value, path, errors, { maxAttributesBytes: 64 * 1024, canonical: true });
if (!isPlainObject(value)) return;
requiredIsoTimestamp(value.receivedAt, `${path}.receivedAt`, 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 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 requiredIdentifier(value, path, errors) {
if (typeof value !== "string" || !IDENTIFIER.test(value)) errors.push(`${path}_invalid`);
}
function requiredString(value, path, errors) {
if (typeof value !== "string" || !value.trim()) errors.push(`${path}_required`);
}
function requiredCursor(value, path, errors) {
if (typeof value !== "string" || !CURSOR.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 isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
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) {
return Object.freeze({ ok: errors.length === 0, errors: Object.freeze([...new Set(errors)]) });
}