import { createHash } from "node:crypto"; export const ENGINE_PRIVATE_EXTENSION_PLAN_REQUEST_SCHEMA_VERSION = "nodedc.engine.private-extension.plan-request/v1"; export const ENGINE_PRIVATE_EXTENSION_PLAN_SCHEMA_VERSION = "nodedc.engine.private-extension.plan/v1"; export const ENGINE_PRIVATE_EXTENSION_APPLY_REQUEST_SCHEMA_VERSION = "nodedc.engine.private-extension.apply-request/v1"; export const ENGINE_PRIVATE_EXTENSION_APPLY_RECEIPT_SCHEMA_VERSION = "nodedc.engine.private-extension.apply-receipt/v1"; export const ENGINE_PRIVATE_EXTENSION_OPERATION_SCHEMA_VERSION = "nodedc.engine.private-extension.operation/v1"; export const ENGINE_PRIVATE_EXTENSION_STATUS_SCHEMA_VERSION = "nodedc.engine.private-extension.status/v1"; export const ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY = "engine.private-extension.manage"; export const ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY = "engine.private-extension.read"; export const ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME = "n8n-nodes-ndc"; export const ENGINE_PRIVATE_EXTENSION_INACTIVE_BASELINE = "n8n-nodes-ndc.inactive/v1"; export const ENGINE_PRIVATE_EXTENSION_NODE_TYPES = Object.freeze([ "n8n-nodes-ndc.ndcDataProductPublish", "n8n-nodes-ndc.ndcDataProductRead", "n8n-nodes-ndc.ndcFoundryBinding", ]); export const ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES = Object.freeze([ "ndcDataProductWriterApi", "ndcDataProductReaderApi", "ndcFoundryBindingApi", ]); export const ENGINE_PRIVATE_EXTENSION_CREDENTIAL_SCHEMAS = ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES; const PLAN_REQUEST_KEYS = new Set([ "schemaVersion", "requestId", "idempotencyKey", "action", "requestedAt", "requestExpiresAt", "expectedCurrentGeneration", "target", ]); const RELEASE_STATE_KEYS = new Set(["kind", "packageName", "releaseId", "packageSha256"]); const INACTIVE_STATE_KEYS = new Set(["kind", "packageName", "baselineId"]); const PREVIOUS_STATE_TARGET_KEYS = new Set(["kind", "packageName"]); const PLAN_KEYS = new Set([ "schemaVersion", "planId", "planHash", "requestId", "idempotencyKey", "action", "createdAt", "expiresAt", "singleUse", "requiredCapability", "expectedCurrentGeneration", "nextGeneration", "currentState", "targetState", "recoveryState", "actions", "transition", "acceptance", "failurePolicy", ]); const TRANSITION_KEYS = new Set([ "mountMode", "loaderMode", "loaderPath", "loaderEnvironment", "quiesceMode", "stateSwitch", "runtimeAction", "scope", "requireUniformGeneration", "hotReload", "preserveCredentials", ]); const LOADER_ENVIRONMENT_KEYS = new Set([ "N8N_COMMUNITY_PACKAGES_ENABLED", "N8N_COMMUNITY_PACKAGES_PREVENT_LOADING", "N8N_REINSTALL_MISSING_PACKAGES", ]); const ACCEPTANCE_SPEC_KEYS = new Set([ "mode", "nodeTypes", "credentialSchemas", "requireUniformGeneration", ]); const FAILURE_POLICY_KEYS = new Set([ "mode", "rollbackFailureOutcome", "preserveImmutableRelease", "preserveCredentials", ]); const APPLY_REQUEST_KEYS = new Set([ "schemaVersion", "planId", "planHash", "idempotencyKey", "confirmedAt", ]); const APPLY_RECEIPT_KEYS = new Set([ "schemaVersion", "operationId", "planId", "planHash", "action", "acceptedAt", "state", ]); const OPERATION_KEYS = new Set([ "schemaVersion", "operationId", "planId", "planHash", "action", "state", "outcome", "phase", "expectedCurrentGeneration", "nextGeneration", "targetState", "recoveryState", "effectiveState", "runtime", "acceptance", "updatedAt", "errorCode", ]); const RUNTIME_KEYS = new Set(["mode", "expectedInstances", "readyInstances", "generation"]); const ACCEPTANCE_REPORT_KEYS = new Set([ "state", "nodeTypes", "credentialSchemas", "uniformGeneration", ]); const OBSERVATION_KEYS = new Set(["expected", "observed"]); const STATUS_KEYS = new Set([ "schemaVersion", "packageName", "generation", "health", "currentState", "previousState", "activeOperationId", "runtime", "acceptance", "updatedAt", "errorCode", ]); const RELEASE_ID = /^\d+\.\d+\.\d+-[a-f0-9]{16}$/; const SHA256 = /^[a-f0-9]{64}$/; const HASH = /^sha256:[a-f0-9]{64}$/; const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,159}$/; const REASON_CODE = /^[a-z][a-z0-9_.:-]{2,127}$/; const MAX_REQUEST_LIFETIME_MS = 15 * 60 * 1000; const MAX_CLOCK_SKEW_MS = 60 * 1000; const ACTIVATE_ACTIONS = Object.freeze([ "verify_staged_immutable_release", "prepare_sealed_package_tree", "verify_community_package_loader_policy", "quiesce_deploy_run_and_drain_queue", "record_recovery_state", "atomic_switch_current", "force_recreate_main_workers_webhooks_as_version_barrier", "verify_exact_runtime_acceptance", "commit_active_state", "resume_deploy_run", ]); const ROLLBACK_ACTIONS = Object.freeze([ "verify_previous_activation_state", "verify_community_package_loader_policy", "quiesce_deploy_run_and_drain_queue", "record_recovery_state", "atomic_switch_current_to_previous", "force_recreate_main_workers_webhooks_as_version_barrier", "verify_exact_runtime_acceptance", "commit_rolled_back_state", "resume_deploy_run", ]); const NON_TERMINAL_STATES = new Set([ "queued", "preparing", "switching", "recreating", "accepting", "rolling-back", ]); const TERMINAL_STATE_OUTCOMES = Object.freeze({ active: "committed", rejected: "rejected", quarantined: "quarantined", }); export function authorizeEnginePrivateExtensionOperation(operation, grantedCapabilities) { const capabilities = new Set(Array.isArray(grantedCapabilities) ? grantedCapabilities : []); const requiredCapability = operation === "status" ? ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY : ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY; const authorized = operation === "status" ? capabilities.has(ENGINE_PRIVATE_EXTENSION_READ_CAPABILITY) || capabilities.has(ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY) : capabilities.has(ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY); return Object.freeze({ ok: authorized, requiredCapability, errors: Object.freeze(authorized ? [] : ["engine_private_extension_capability_required"]), }); } export function validateEnginePrivateExtensionPlanRequest(value, options = {}) { const errors = []; validatePlanRequestBody(value, options.now, errors); addAuthorizationErrors("plan", options.grantedCapabilities, errors); return result(errors); } export function computeEnginePrivateExtensionPlanHash(value) { if (!isPlainObject(value)) return ""; const descriptor = { ...value }; delete descriptor.planHash; return "sha256:" + createHash("sha256").update(stableJson(descriptor), "utf8").digest("hex"); } export function validateEnginePrivateExtensionPlan(value, { request } = {}) { const errors = []; if (!isPlainObject(value)) return result(["enginePrivateExtensionPlan_must_be_object"]); if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_PLAN_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); rejectUnknownKeys(value, PLAN_KEYS, "enginePrivateExtensionPlan", errors); requiredOpaqueId(value.planId, "planId", errors); requiredHash(value.planHash, "planHash", errors); requiredOpaqueId(value.requestId, "requestId", errors); requiredOpaqueId(value.idempotencyKey, "idempotencyKey", errors); if (!new Set(["activate", "rollback"]).has(value.action)) errors.push("action_invalid"); requiredTimestamp(value.createdAt, "createdAt", errors); requiredTimestamp(value.expiresAt, "expiresAt", errors); validateWindow(value.createdAt, value.expiresAt, Date.parse(value.createdAt), errors, "plan"); if (value.singleUse !== true) errors.push("singleUse_must_be_true"); if (value.requiredCapability !== ENGINE_PRIVATE_EXTENSION_MANAGE_CAPABILITY) { errors.push("requiredCapability_mismatch"); } requiredGeneration(value.expectedCurrentGeneration, "expectedCurrentGeneration", errors); requiredGeneration(value.nextGeneration, "nextGeneration", errors); if (Number.isInteger(value.expectedCurrentGeneration) && value.nextGeneration !== value.expectedCurrentGeneration + 1) { errors.push("nextGeneration_must_increment_current_generation"); } validateActivationState(value.currentState, "currentState", errors); validateActivationState(value.targetState, "targetState", errors); validateActivationState(value.recoveryState, "recoveryState", errors); if (!sameValue(value.currentState, value.recoveryState)) errors.push("recoveryState_must_equal_currentState"); if (value.action === "activate" && value.targetState?.kind !== "release") { errors.push("activate_targetState_must_be_release"); } if (sameValue(value.currentState, value.targetState)) errors.push("targetState_must_differ_from_currentState"); validateExactArray( value.actions, value.action === "rollback" ? ROLLBACK_ACTIONS : ACTIVATE_ACTIONS, "actions", errors, ); validateTransition(value.transition, errors); validateAcceptanceSpec(value.acceptance, value.targetState, errors); validateFailurePolicy(value.failurePolicy, errors); if (HASH.test(String(value.planHash || "")) && value.planHash !== computeEnginePrivateExtensionPlanHash(value)) { errors.push("planHash_mismatch"); } if (request !== undefined) comparePlanToRequest(value, request, errors); return result(errors); } export function validateEnginePrivateExtensionApplyRequest(value, { plan, now = Date.now(), grantedCapabilities } = {}) { const errors = []; if (!isPlainObject(value)) return result(["enginePrivateExtensionApplyRequest_must_be_object"]); if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_APPLY_REQUEST_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); rejectUnknownKeys(value, APPLY_REQUEST_KEYS, "enginePrivateExtensionApplyRequest", errors); requiredOpaqueId(value.planId, "planId", errors); requiredHash(value.planHash, "planHash", errors); requiredOpaqueId(value.idempotencyKey, "idempotencyKey", errors); requiredTimestamp(value.confirmedAt, "confirmedAt", errors); addAuthorizationErrors("apply", grantedCapabilities, errors); const nowMs = normalizeNow(now, errors); if (isTimestamp(value.confirmedAt) && Date.parse(value.confirmedAt) > nowMs + MAX_CLOCK_SKEW_MS) { errors.push("confirmedAt_exceeds_clock_skew"); } if (plan !== undefined) { const planValidation = validateEnginePrivateExtensionPlan(plan); if (!planValidation.ok) errors.push("plan_invalid_for_apply"); if (value.planId !== plan?.planId) errors.push("planId_plan_mismatch"); if (value.planHash !== plan?.planHash) errors.push("planHash_plan_mismatch"); if (value.idempotencyKey !== plan?.idempotencyKey) errors.push("idempotencyKey_plan_mismatch"); if (isTimestamp(plan?.expiresAt) && nowMs > Date.parse(plan.expiresAt)) errors.push("plan_expired"); if (isTimestamp(value.confirmedAt) && isTimestamp(plan?.expiresAt) && Date.parse(value.confirmedAt) > Date.parse(plan.expiresAt)) { errors.push("confirmedAt_after_plan_expiresAt"); } if (isTimestamp(value.confirmedAt) && isTimestamp(plan?.createdAt) && Date.parse(value.confirmedAt) < Date.parse(plan.createdAt)) { errors.push("confirmedAt_before_plan_createdAt"); } } return result(errors); } export function validateEnginePrivateExtensionApplyReceipt(value, { plan } = {}) { const errors = []; if (!isPlainObject(value)) return result(["enginePrivateExtensionApplyReceipt_must_be_object"]); if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_APPLY_RECEIPT_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); rejectUnknownKeys(value, APPLY_RECEIPT_KEYS, "enginePrivateExtensionApplyReceipt", errors); requiredOpaqueId(value.operationId, "operationId", errors); requiredOpaqueId(value.planId, "planId", errors); requiredHash(value.planHash, "planHash", errors); if (!new Set(["activate", "rollback"]).has(value.action)) errors.push("action_invalid"); requiredTimestamp(value.acceptedAt, "acceptedAt", errors); if (value.state !== "queued") errors.push("apply_receipt_state_must_be_queued"); if (plan !== undefined) { if (value.planId !== plan?.planId) errors.push("planId_plan_mismatch"); if (value.planHash !== plan?.planHash) errors.push("planHash_plan_mismatch"); if (value.action !== plan?.action) errors.push("action_plan_mismatch"); if (isTimestamp(value.acceptedAt) && isTimestamp(plan?.expiresAt) && Date.parse(value.acceptedAt) > Date.parse(plan.expiresAt)) { errors.push("acceptedAt_after_plan_expiresAt"); } } return result(errors); } export function validateEnginePrivateExtensionOperation(value) { const errors = []; if (!isPlainObject(value)) return result(["enginePrivateExtensionOperation_must_be_object"]); if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_OPERATION_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); rejectUnknownKeys(value, OPERATION_KEYS, "enginePrivateExtensionOperation", errors); requiredOpaqueId(value.operationId, "operationId", errors); requiredOpaqueId(value.planId, "planId", errors); requiredHash(value.planHash, "planHash", errors); if (!new Set(["activate", "rollback"]).has(value.action)) errors.push("action_invalid"); if (!new Set([...NON_TERMINAL_STATES, "active", "rolled-back", "rejected", "quarantined"]).has(value.state)) { errors.push("state_invalid"); } if (!new Set([ "pending", "committed", "automatically-rolled-back", "explicitly-rolled-back", "rejected", "quarantined", ]).has(value.outcome)) errors.push("outcome_invalid"); if (!new Set([ "queued", "prepare", "switch", "force-recreate", "acceptance", "rollback", "complete", ]).has(value.phase)) errors.push("phase_invalid"); requiredGeneration(value.expectedCurrentGeneration, "expectedCurrentGeneration", errors); requiredGeneration(value.nextGeneration, "nextGeneration", errors); if (Number.isInteger(value.expectedCurrentGeneration) && value.nextGeneration !== value.expectedCurrentGeneration + 1) { errors.push("nextGeneration_must_increment_current_generation"); } validateActivationState(value.targetState, "targetState", errors); validateActivationState(value.recoveryState, "recoveryState", errors); validateActivationState(value.effectiveState, "effectiveState", errors); if (sameValue(value.targetState, value.recoveryState)) errors.push("targetState_must_differ_from_recoveryState"); validateRuntimeReport(value.runtime, errors); validateAcceptanceReport(value.acceptance, value.effectiveState, errors); requiredTimestamp(value.updatedAt, "updatedAt", errors); validateOperationOutcome(value, errors); return result(errors); } export function validateEnginePrivateExtensionStatus(value) { const errors = []; if (!isPlainObject(value)) return result(["enginePrivateExtensionStatus_must_be_object"]); if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_STATUS_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); rejectUnknownKeys(value, STATUS_KEYS, "enginePrivateExtensionStatus", errors); if (value.packageName !== ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME) errors.push("packageName_mismatch"); requiredGeneration(value.generation, "generation", errors); if (!new Set(["ready", "transitioning", "quarantined"]).has(value.health)) errors.push("health_invalid"); validateActivationState(value.currentState, "currentState", errors); validateActivationState(value.previousState, "previousState", errors); if (value.activeOperationId !== undefined) requiredOpaqueId(value.activeOperationId, "activeOperationId", errors); validateRuntimeReport(value.runtime, errors); validateAcceptanceReport(value.acceptance, value.currentState, errors); requiredTimestamp(value.updatedAt, "updatedAt", errors); if (value.runtime?.generation !== value.generation) errors.push("runtime_generation_mismatch"); if (value.health === "ready") { if (value.activeOperationId !== undefined) errors.push("ready_status_must_not_have_active_operation"); if (value.acceptance?.state !== "accepted") errors.push("ready_status_requires_acceptance"); if (value.errorCode !== undefined) errors.push("ready_status_must_not_have_errorCode"); } else if (value.health === "transitioning") { if (value.activeOperationId === undefined) errors.push("transitioning_status_requires_active_operation"); } else { requiredReason(value.errorCode, "errorCode", errors); } return result(errors); } function validatePlanRequestBody(value, now, errors) { if (!isPlainObject(value)) { errors.push("enginePrivateExtensionPlanRequest_must_be_object"); return; } if (value.schemaVersion !== ENGINE_PRIVATE_EXTENSION_PLAN_REQUEST_SCHEMA_VERSION) errors.push("schemaVersion_mismatch"); rejectUnknownKeys(value, PLAN_REQUEST_KEYS, "enginePrivateExtensionPlanRequest", errors); requiredOpaqueId(value.requestId, "requestId", errors); requiredOpaqueId(value.idempotencyKey, "idempotencyKey", errors); if (!new Set(["activate", "rollback"]).has(value.action)) errors.push("action_invalid"); requiredTimestamp(value.requestedAt, "requestedAt", errors); requiredTimestamp(value.requestExpiresAt, "requestExpiresAt", errors); requiredGeneration(value.expectedCurrentGeneration, "expectedCurrentGeneration", errors); validateWindow(value.requestedAt, value.requestExpiresAt, normalizeNow(now, errors), errors, "request"); validateRequestTarget(value.target, value.action, errors); } function validateRequestTarget(value, action, errors) { if (!isPlainObject(value)) { errors.push("target_must_be_object"); return; } if (action === "activate") { validateActivationState(value, "target", errors); if (value.kind !== "release") errors.push("activate_target_must_be_release"); return; } rejectUnknownKeys(value, PREVIOUS_STATE_TARGET_KEYS, "target", errors); if (value.kind !== "previous-state") errors.push("rollback_target_must_be_previous-state"); if (value.packageName !== ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME) errors.push("target.packageName_mismatch"); } function validateActivationState(value, path, errors) { if (!isPlainObject(value)) { errors.push(path + "_must_be_object"); return; } if (value.kind === "release") { rejectUnknownKeys(value, RELEASE_STATE_KEYS, path, errors); if (value.packageName !== ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME) errors.push(path + ".packageName_mismatch"); if (typeof value.releaseId !== "string" || !RELEASE_ID.test(value.releaseId)) { errors.push(path + ".releaseId_invalid"); } if (typeof value.packageSha256 !== "string" || !SHA256.test(value.packageSha256)) { errors.push(path + ".packageSha256_invalid"); } else if (typeof value.releaseId === "string" && RELEASE_ID.test(value.releaseId) && !value.releaseId.endsWith("-" + value.packageSha256.slice(0, 16))) { errors.push(path + ".releaseId_digest_mismatch"); } return; } if (value.kind === "inactive-baseline") { rejectUnknownKeys(value, INACTIVE_STATE_KEYS, path, errors); if (value.packageName !== ENGINE_PRIVATE_EXTENSION_PACKAGE_NAME) errors.push(path + ".packageName_mismatch"); if (value.baselineId !== ENGINE_PRIVATE_EXTENSION_INACTIVE_BASELINE) errors.push(path + ".baselineId_mismatch"); return; } errors.push(path + ".kind_invalid"); } function validateTransition(value, errors) { if (!isPlainObject(value)) { errors.push("transition_must_be_object"); return; } rejectUnknownKeys(value, TRANSITION_KEYS, "transition", errors); const expected = { mountMode: "read-only", loaderMode: "community-package", loaderPath: "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc", loaderEnvironment: { N8N_COMMUNITY_PACKAGES_ENABLED: "true", N8N_COMMUNITY_PACKAGES_PREVENT_LOADING: "false", N8N_REINSTALL_MISSING_PACKAGES: "false", }, quiesceMode: "block-deploy-run-and-drain-queue", stateSwitch: "atomic-current-previous", runtimeAction: "force-recreate", scope: "main-workers-webhooks", requireUniformGeneration: true, hotReload: false, preserveCredentials: true, }; if (isPlainObject(value.loaderEnvironment)) { rejectUnknownKeys(value.loaderEnvironment, LOADER_ENVIRONMENT_KEYS, "transition.loaderEnvironment", errors); } if (!sameValue(value, expected)) errors.push("transition_policy_mismatch"); } function validateAcceptanceSpec(value, targetState, errors) { if (!isPlainObject(value)) { errors.push("acceptance_must_be_object"); return; } rejectUnknownKeys(value, ACCEPTANCE_SPEC_KEYS, "acceptance", errors); if (value.mode !== "exact") errors.push("acceptance.mode_must_be_exact"); const expectedNodes = expectedTypes(targetState, ENGINE_PRIVATE_EXTENSION_NODE_TYPES); const expectedCredentials = expectedTypes(targetState, ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES); validateExactArray(value.nodeTypes, expectedNodes, "acceptance.nodeTypes", errors); validateExactArray(value.credentialSchemas, expectedCredentials, "acceptance.credentialSchemas", errors); if (value.requireUniformGeneration !== true) errors.push("acceptance.requireUniformGeneration_must_be_true"); } function validateFailurePolicy(value, errors) { if (!isPlainObject(value)) { errors.push("failurePolicy_must_be_object"); return; } rejectUnknownKeys(value, FAILURE_POLICY_KEYS, "failurePolicy", errors); const expected = { mode: "automatic-rollback", rollbackFailureOutcome: "quarantined", preserveImmutableRelease: true, preserveCredentials: true, }; if (!sameValue(value, expected)) errors.push("failurePolicy_mismatch"); } function validateRuntimeReport(value, errors) { if (!isPlainObject(value)) { errors.push("runtime_must_be_object"); return; } rejectUnknownKeys(value, RUNTIME_KEYS, "runtime", errors); if (value.mode !== "force-recreate") errors.push("runtime.mode_must_be_force-recreate"); requiredPositiveInteger(value.expectedInstances, "runtime.expectedInstances", errors); requiredNonnegativeInteger(value.readyInstances, "runtime.readyInstances", errors); if (Number.isInteger(value.expectedInstances) && Number.isInteger(value.readyInstances) && value.readyInstances > value.expectedInstances) errors.push("runtime.readyInstances_exceeds_expectedInstances"); requiredGeneration(value.generation, "runtime.generation", errors); } function validateAcceptanceReport(value, effectiveState, errors) { if (!isPlainObject(value)) { errors.push("acceptance_must_be_object"); return; } rejectUnknownKeys(value, ACCEPTANCE_REPORT_KEYS, "acceptance", errors); if (!new Set(["pending", "accepted", "rejected"]).has(value.state)) errors.push("acceptance.state_invalid"); const expectedNodes = expectedTypes(effectiveState, ENGINE_PRIVATE_EXTENSION_NODE_TYPES); const expectedCredentials = expectedTypes(effectiveState, ENGINE_PRIVATE_EXTENSION_CREDENTIAL_TYPES); validateObservation(value.nodeTypes, expectedNodes, "acceptance.nodeTypes", value.state, errors); validateObservation(value.credentialSchemas, expectedCredentials, "acceptance.credentialSchemas", value.state, errors); if (typeof value.uniformGeneration !== "boolean") errors.push("acceptance.uniformGeneration_must_be_boolean"); if (value.state === "accepted" && value.uniformGeneration !== true) { errors.push("accepted_runtime_requires_uniform_generation"); } } function validateObservation(value, expected, path, state, errors) { if (!isPlainObject(value)) { errors.push(path + "_must_be_object"); return; } rejectUnknownKeys(value, OBSERVATION_KEYS, path, errors); validateExactArray(value.expected, expected, path + ".expected", errors); if (!Array.isArray(value.observed) || value.observed.some((item) => typeof item !== "string")) { errors.push(path + ".observed_must_be_string_array"); return; } if (new Set(value.observed).size !== value.observed.length) errors.push(path + ".observed_must_be_unique"); if (state === "accepted" && !sameValue(value.observed, expected)) errors.push(path + ".observed_exact_set_required"); } function validateOperationOutcome(value, errors) { if (NON_TERMINAL_STATES.has(value.state)) { if (value.outcome !== "pending") errors.push("nonterminal_operation_outcome_must_be_pending"); return; } if (value.state === "rolled-back") { const expected = value.action === "rollback" ? "explicitly-rolled-back" : "automatically-rolled-back"; if (value.outcome !== expected) errors.push("rolled_back_outcome_mismatch"); } else if (TERMINAL_STATE_OUTCOMES[value.state] !== value.outcome) { errors.push("terminal_operation_outcome_mismatch"); } if (value.state === "active" || value.state === "rolled-back") { if (value.acceptance?.state !== "accepted") errors.push("successful_terminal_state_requires_acceptance"); if (value.runtime?.readyInstances !== value.runtime?.expectedInstances) { errors.push("successful_terminal_state_requires_all_instances_ready"); } if (value.runtime?.generation !== value.nextGeneration) { errors.push("successful_terminal_state_runtime_generation_mismatch"); } if (value.errorCode !== undefined && value.state === "active") errors.push("active_state_must_not_have_errorCode"); if (value.state === "active" && value.action !== "activate") errors.push("active_state_action_mismatch"); const expectedEffective = value.state === "active" ? value.targetState : value.action === "rollback" ? value.targetState : value.recoveryState; if (!sameValue(value.effectiveState, expectedEffective)) errors.push("effectiveState_terminal_mismatch"); if (value.state === "rolled-back" && value.action === "activate") { requiredReason(value.errorCode, "errorCode", errors); } if (value.state === "rolled-back" && value.action === "rollback" && value.errorCode !== undefined) { errors.push("explicit_rollback_must_not_have_errorCode"); } } else if (value.state === "rejected" || value.state === "quarantined") { requiredReason(value.errorCode, "errorCode", errors); } } function comparePlanToRequest(plan, request, errors) { const requestErrors = []; validatePlanRequestBody(request, plan.createdAt, requestErrors); if (requestErrors.length) errors.push("request_invalid_for_plan_comparison"); if (plan.requestId !== request?.requestId) errors.push("requestId_request_mismatch"); if (plan.idempotencyKey !== request?.idempotencyKey) errors.push("idempotencyKey_request_mismatch"); if (plan.action !== request?.action) errors.push("action_request_mismatch"); if (plan.expectedCurrentGeneration !== request?.expectedCurrentGeneration) { errors.push("expectedCurrentGeneration_request_mismatch"); } if (request?.action === "activate" && !sameValue(plan.targetState, request?.target)) { errors.push("targetState_request_mismatch"); } if (isTimestamp(request?.requestExpiresAt) && isTimestamp(plan.expiresAt) && Date.parse(plan.expiresAt) > Date.parse(request.requestExpiresAt)) { errors.push("plan_expiresAt_exceeds_request"); } } function addAuthorizationErrors(operation, grantedCapabilities, errors) { errors.push(...authorizeEnginePrivateExtensionOperation(operation, grantedCapabilities).errors); } function validateWindow(start, end, nowMs, errors, label) { if (!isTimestamp(start) || !isTimestamp(end) || !Number.isFinite(nowMs)) return; const startMs = Date.parse(start); const endMs = Date.parse(end); if (endMs <= startMs) errors.push(label + "_expiresAt_must_be_after_start"); if (endMs - startMs > MAX_REQUEST_LIFETIME_MS) errors.push(label + "_lifetime_exceeds_15_minutes"); if (startMs > nowMs + MAX_CLOCK_SKEW_MS) errors.push(label + "_start_exceeds_clock_skew"); if (endMs < nowMs) errors.push(label + "_expired"); } function normalizeNow(value, errors) { const candidate = value === undefined ? Date.now() : value; const milliseconds = typeof candidate === "number" ? candidate : Date.parse(candidate); if (!Number.isFinite(milliseconds)) { errors.push("now_invalid"); return Number.NaN; } return milliseconds; } function requiredGeneration(value, path, errors) { requiredNonnegativeInteger(value, path, errors); } function requiredPositiveInteger(value, path, errors) { if (!Number.isInteger(value) || value < 1) errors.push(path + "_must_be_positive_integer"); } function requiredNonnegativeInteger(value, path, errors) { if (!Number.isInteger(value) || value < 0) errors.push(path + "_must_be_nonnegative_integer"); } function requiredOpaqueId(value, path, errors) { if (typeof value !== "string" || !OPAQUE_ID.test(value)) errors.push(path + "_invalid"); } function requiredReason(value, path, errors) { if (typeof value !== "string" || !REASON_CODE.test(value)) errors.push(path + "_invalid"); } function requiredHash(value, path, errors) { if (typeof value !== "string" || !HASH.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.isFinite(Date.parse(value)); } function expectedTypes(state, releaseTypes) { return state?.kind === "release" ? [...releaseTypes] : []; } function validateExactArray(actual, expected, path, errors) { if (!Array.isArray(actual)) { errors.push(path + "_must_be_array"); return; } if (!sameValue(actual, [...expected])) errors.push(path + "_mismatch"); } 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 isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function sameValue(left, right) { return stableJson(left) === stableJson(right); } function stableJson(value) { if (Array.isArray(value)) return "[" + value.map(stableJson).join(",") + "]"; if (isPlainObject(value)) { return "{" + Object.keys(value).sort().map((key) => JSON.stringify(key) + ":" + stableJson(value[key])).join(",") + "}"; } return JSON.stringify(value); } function result(errors) { const unique = [...new Set(errors)]; return Object.freeze({ ok: unique.length === 0, errors: Object.freeze(unique) }); }