import { createHash, randomBytes } from "node:crypto"; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const TOKEN_PREFIX = "ndc_edpwb_"; const SECRET_LIKE_KEY = /(token|secret|password|access[_-]?token|refresh[_-]?token|api[_-]?key)/i; const BINDING_REQUEST_KEYS = new Set(["source", "allowedDataProductIds", "expiresAt"]); const MANAGED_BINDING_REQUEST_KEYS = new Set([ "source", "allowedDataProductIds", "expiresAt", "generation", "capabilityDigest", ]); const BINDING_SOURCE_KEYS = new Set(["tenantId", "connectionId", "providerId"]); const SHA256_DIGEST = /^[a-f0-9]{64}$/; /** * Produces an opaque, high-entropy capability. The plaintext is returned only * to the trusted provisioning caller; persistence uses its SHA-256 digest. */ export function createWriterToken() { return `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`; } export function hashWriterToken(token) { return createHash("sha256").update(String(token), "utf8").digest("hex"); } export function normalizeWriterBindingRequest(value, { now = new Date(), maxTtlDays = 90 } = {}) { if (!isPlainObject(value) || !isPlainObject(value.source)) { throw writerBindingError("writer_binding_request_invalid"); } if (containsSecretLikeKey(value)) { throw writerBindingError("writer_binding_request_secret_material_forbidden"); } if (value.endpoint !== undefined || value.url !== undefined || value.host !== undefined) { throw writerBindingError("writer_binding_request_transport_forbidden"); } if (!hasOnlyKeys(value, BINDING_REQUEST_KEYS) || !hasOnlyKeys(value.source, BINDING_SOURCE_KEYS)) { throw writerBindingError("writer_binding_request_fields_invalid"); } const tenantId = normalizeIdentifier(value.source.tenantId); const connectionId = normalizeIdentifier(value.source.connectionId); const providerId = normalizeIdentifier(value.source.providerId); if (!tenantId || !connectionId || !providerId) { throw writerBindingError("writer_binding_scope_invalid"); } const allowedDataProductIds = uniqueIdentifiers(value.allowedDataProductIds); if (!allowedDataProductIds.length) { throw writerBindingError("writer_binding_data_products_invalid"); } const expiresAt = new Date(String(value.expiresAt || "")); const maxExpiresAt = new Date(now.getTime() + maxTtlDays * 24 * 60 * 60 * 1000); if (Number.isNaN(expiresAt.getTime()) || expiresAt <= now || expiresAt > maxExpiresAt) { throw writerBindingError("writer_binding_expiry_invalid"); } return Object.freeze({ tenantId, connectionId, providerId, allowedDataProductIds, expiresAt: expiresAt.toISOString(), }); } /** * Validates the zero-touch control-plane form. The native credential secret is * generated and stored inside Engine; EDP receives only its SHA-256 digest. * `generation` makes a retry address the same immutable binding generation. */ export function normalizeManagedWriterBindingRequest(value) { if (!isPlainObject(value) || !isPlainObject(value.source)) { throw writerBindingError("managed_writer_binding_request_invalid"); } if (containsSecretLikeKey(value)) { throw writerBindingError("managed_writer_binding_secret_material_forbidden"); } if (!hasOnlyKeys(value, MANAGED_BINDING_REQUEST_KEYS) || !hasOnlyKeys(value.source, BINDING_SOURCE_KEYS)) { throw writerBindingError("managed_writer_binding_request_fields_invalid"); } const tenantId = normalizeIdentifier(value.source.tenantId); const connectionId = normalizeIdentifier(value.source.connectionId); const providerId = normalizeIdentifier(value.source.providerId); const allowedDataProductIds = uniqueIdentifiers(value.allowedDataProductIds); if (!tenantId || !connectionId || !providerId || !allowedDataProductIds.length) { throw writerBindingError("managed_writer_binding_scope_invalid"); } if (value.expiresAt !== null) { throw writerBindingError("managed_writer_binding_must_be_durable"); } const generation = Number(value.generation); if (!Number.isSafeInteger(generation) || generation < 1 || generation > 2_147_483_647) { throw writerBindingError("managed_writer_binding_generation_invalid"); } const capabilityDigest = String(value.capabilityDigest || "").toLowerCase(); if (!SHA256_DIGEST.test(capabilityDigest)) { throw writerBindingError("managed_writer_binding_capability_digest_invalid"); } return Object.freeze({ tenantId, connectionId, providerId, allowedDataProductIds: Object.freeze([...allowedDataProductIds].sort()), expiresAt: null, generation, capabilityDigest, }); } export function writerBindingRequestHash(policy) { const canonical = JSON.stringify({ tenantId: policy.tenantId, connectionId: policy.connectionId, providerId: policy.providerId, allowedDataProductIds: [...policy.allowedDataProductIds].sort(), expiresAt: null, generation: policy.generation, capabilityDigest: policy.capabilityDigest, }); return createHash("sha256").update(canonical, "utf8").digest("hex"); } export function canMigrateManagedWriterBindingToDurable(binding, policy, now = new Date()) { if (!isPlainObject(binding) || !isPlainObject(policy) || binding.active !== true) return false; const expiresAt = new Date(binding.expiresAt); if (binding.expiresAt === null || Number.isNaN(expiresAt.getTime()) || expiresAt <= now) return false; return binding.tenantId === policy.tenantId && binding.connectionId === policy.connectionId && binding.providerId === policy.providerId && binding.capabilityDigest === policy.capabilityDigest && JSON.stringify(uniqueIdentifiers(binding.allowedDataProductIds).sort()) === JSON.stringify(uniqueIdentifiers(policy.allowedDataProductIds).sort()) && policy.expiresAt === null; } /** * Converts a caller-provided, deliberately unscoped intake envelope into the * canonical scoped form. Caller scope is rejected, never trusted or merged. */ export function materializeWriterBoundBatch(value, binding, { hasScopeHeaders = false, now = new Date() } = {}) { if (!isPlainObject(value) || !isPlainObject(value.source) || !isPlainObject(binding)) { throw writerBindingError("writer_bound_intake_invalid"); } if (hasScopeHeaders || value.source.tenantId !== undefined || value.source.connectionId !== undefined) { throw writerBindingError("writer_bound_scope_forbidden"); } if (binding.active !== true || !bindingIsCurrent(binding, now)) { throw writerBindingError("writer_binding_inactive"); } const providerId = normalizeIdentifier(value.source.providerId); const tenantId = normalizeIdentifier(binding.tenantId); const connectionId = normalizeIdentifier(binding.connectionId); if (!providerId || !tenantId || !connectionId || providerId !== binding.providerId) { throw writerBindingError("writer_binding_provider_forbidden"); } const dataProductId = normalizeIdentifier(value.contract?.dataProductId); const allowedDataProductIds = uniqueIdentifiers(binding.allowedDataProductIds); if (!dataProductId || !allowedDataProductIds.includes(dataProductId)) { throw writerBindingError("writer_binding_data_product_forbidden"); } return { ...value, source: { providerId, tenantId, connectionId }, }; } /** * Materializes the provider-neutral Data Product publish wire form. Unlike the * legacy writer-bound intake, the caller cannot send provider identity, * contract metadata, receivedAt or any scope at all. */ export function materializeDataProductPublish(value, binding, definition, dataProductId, { now = new Date() } = {}) { if (!isPlainObject(value) || !isPlainObject(binding) || !isPlainObject(definition)) { throw writerBindingError("data_product_publish_invalid"); } if (binding.active !== true || !bindingIsCurrent(binding, now)) { throw writerBindingError("writer_binding_inactive"); } const normalizedProductId = normalizeIdentifier(dataProductId); const allowedDataProductIds = uniqueIdentifiers(binding.allowedDataProductIds); if (!normalizedProductId || normalizedProductId !== definition.id || !allowedDataProductIds.includes(normalizedProductId)) { throw writerBindingError("writer_binding_data_product_forbidden"); } const tenantId = normalizeIdentifier(binding.tenantId); const connectionId = normalizeIdentifier(binding.connectionId); const providerId = normalizeIdentifier(binding.providerId); if (!tenantId || !connectionId || !providerId) throw writerBindingError("writer_binding_scope_invalid"); return { schemaVersion: "nodedc.external-provider-contract/v1", source: { tenantId, connectionId, providerId }, contract: { dataProductId: normalizedProductId, ontologyRevision: definition.ontologyRevision, version: definition.version, }, batch: { runId: value.batch?.runId, sequence: value.batch?.sequence, idempotencyKey: value.batch?.idempotencyKey, receivedAt: now.toISOString(), ...(value.batch?.mode === "replace" ? { mode: "replace", generationAt: value.batch?.generationAt, } : {}), }, facts: value.facts, }; } export function safeWriterBinding(binding) { return { id: binding.id, ...(binding.bindingKey ? { bindingKey: binding.bindingKey } : {}), ...(Number.isInteger(Number(binding.generation)) ? { generation: Number(binding.generation) } : {}), tenantId: binding.tenantId, connectionId: binding.connectionId, providerId: binding.providerId, allowedDataProductIds: uniqueIdentifiers(binding.allowedDataProductIds), active: binding.active === true, expiresAt: binding.expiresAt === null ? null : new Date(binding.expiresAt).toISOString(), createdAt: binding.createdAt ? new Date(binding.createdAt).toISOString() : undefined, rotatedAt: binding.rotatedAt ? new Date(binding.rotatedAt).toISOString() : undefined, revokedAt: binding.revokedAt ? new Date(binding.revokedAt).toISOString() : undefined, }; } export function writerBindingError(code) { return Object.assign(new Error(code), { status: 400, code }); } function bindingIsCurrent(binding, now) { if (binding.expiresAt === null) return true; const expiresAt = new Date(binding.expiresAt); return !Number.isNaN(expiresAt.getTime()) && expiresAt > now; } function normalizeIdentifier(value) { const normalized = typeof value === "string" ? value.trim() : ""; return IDENTIFIER.test(normalized) ? normalized : ""; } function uniqueIdentifiers(value) { if (!Array.isArray(value)) return []; return [...new Set(value.map(normalizeIdentifier).filter(Boolean))]; } function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function containsSecretLikeKey(value) { if (Array.isArray(value)) return value.some(containsSecretLikeKey); if (!isPlainObject(value)) return false; return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretLikeKey(child)); } function hasOnlyKeys(value, allowedKeys) { return Object.keys(value).every((key) => allowedKeys.has(key)); }