feat(connectors): version provider packages and ontology contracts
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const EXTERNAL_PROVIDER_CONTRACT_VERSION = "nodedc.external-provider-contract/v1";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
);
|
||||
Reference in New Issue
Block a user