NODEDC_PLATFORM/packages/external-provider-contract/src/engine-private-extension.mjs

722 lines
30 KiB
JavaScript

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) });
}