feat(data-plane): add provider contracts and ontology delivery
This commit is contained in:
@@ -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)]) });
|
||||
}
|
||||
Reference in New Issue
Block a user