270 lines
11 KiB
JavaScript
270 lines
11 KiB
JavaScript
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));
|
|
}
|