"use strict"; const versions = require("../versions"); const { canonicalJson, canonicalize, sha256 } = require("../canonical-json"); const { DIGEST_PATTERN, ID_PATTERN, validateManifest } = require("./manifest"); const { validateInstance } = require("../schema-validator"); const VERSION_KEYS = Object.freeze([ "protocolVersion", "coreVersion", "catalogSchemaVersion", ]); const OPERATION_KINDS = new Set(["install", "replaceManaged", "removeManaged"]); const OVERRIDE_KINDS = new Set(["managedDrift"]); const OPERATION_ORDER = Object.freeze({ removeManaged: 0, replaceManaged: 1, install: 2 }); function compareStrings(left, right) { return left < right ? -1 : (left > right ? 1 : 0); } function planError(code, category, details = {}) { const error = new Error(code); error.code = code; error.category = category; error.details = details; return error; } function assertPlainObject(value, name) { const prototype = value && typeof value === "object" ? Object.getPrototypeOf(value) : null; if (!value || Array.isArray(value) || (prototype !== Object.prototype && prototype !== null)) { throw planError("AAS_PLAN_INPUT_INVALID", "invalidInput", { field: name }); } } function assertExactKeys(value, allowed, field) { const unknown = Object.keys(value).filter((key) => !allowed.has(key)).sort(); if (unknown.length) throw planError("AAS_PLAN_FIELD_UNKNOWN", "invalidInput", { field, keys: unknown }); } function assertDigest(value, field) { if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) { throw planError("AAS_PLAN_DIGEST_INVALID", "invalidInput", { field }); } } function assertNonEmptyString(value, field, maximum = 256) { if (typeof value !== "string" || value.length < 1 || value.length > maximum) { throw planError("AAS_PLAN_STRING_INVALID", "invalidInput", { field }); } } function assertVersionHandshake(handshake) { assertPlainObject(handshake, "handshake"); assertExactKeys(handshake, new Set(VERSION_KEYS), "handshake"); const incompatible = VERSION_KEYS.filter((key) => handshake[key] !== versions[key]); if (incompatible.length) { throw planError("AAS_PLAN_VERSION_INCOMPATIBLE", "incompatibleVersion", { fields: incompatible, expected: Object.fromEntries(incompatible.map((key) => [key, versions[key]])), received: Object.fromEntries(incompatible.map((key) => [key, handshake[key] ?? null])), }); } return Object.fromEntries(VERSION_KEYS.map((key) => [key, versions[key]])); } function normalizeCatalog(catalog, manifestCatalog) { assertPlainObject(catalog, "catalog"); assertExactKeys(catalog, new Set(["package", "version", "integrity"]), "catalog"); for (const key of ["package", "version", "integrity"]) { if (catalog[key] !== manifestCatalog[key]) { throw planError("AAS_PLAN_CATALOG_MISMATCH", "integrity", { field: key }); } } assertDigest(catalog.integrity, "catalog.integrity"); return { package: catalog.package, version: catalog.version, integrity: catalog.integrity }; } function normalizeRuntime(runtime) { assertPlainObject(runtime, "runtime"); assertExactKeys(runtime, new Set(["package", "version", "integrity", "closureDigest"]), "runtime"); assertNonEmptyString(runtime.package, "runtime.package", 214); assertNonEmptyString(runtime.version, "runtime.version", 64); assertNonEmptyString(runtime.integrity, "runtime.integrity", 512); assertDigest(runtime.closureDigest, "runtime.closureDigest"); return { ...runtime }; } function normalizeTarget(target, manifest) { assertPlainObject(target, "target"); assertExactKeys(target, new Set(["host", "scope", "adapterVersion", "identityDigest"]), "target"); assertNonEmptyString(target.adapterVersion, "target.adapterVersion", 64); assertDigest(target.identityDigest, "target.identityDigest"); if (!manifest.targets.some((candidate) => candidate.host === target.host && candidate.scope === target.scope)) { throw planError("AAS_PLAN_TARGET_NOT_IN_MANIFEST", "invalidInput", { host: target.host, scope: target.scope }); } return { ...target }; } function normalizeInstalledState(installedState) { assertPlainObject(installedState, "installedState"); assertExactKeys(installedState, new Set(["digest", "entries"]), "installedState"); assertDigest(installedState.digest, "installedState.digest"); if (!Array.isArray(installedState.entries) || installedState.entries.length > 128) { throw planError("AAS_PLAN_INSTALLED_STATE_INVALID", "invalidInput", {}); } const seen = new Set(); const entries = installedState.entries.map((entry, index) => { assertPlainObject(entry, `installedState.entries[${index}]`); assertExactKeys(entry, new Set(["skillId", "treeDigest", "catalogIntegrity"]), `installedState.entries[${index}]`); if (typeof entry.skillId !== "string" || !ID_PATTERN.test(entry.skillId) || seen.has(entry.skillId)) { throw planError("AAS_PLAN_INSTALLED_ENTRY_INVALID", "invalidInput", { index }); } seen.add(entry.skillId); assertDigest(entry.treeDigest, `installedState.entries[${index}].treeDigest`); assertDigest(entry.catalogIntegrity, `installedState.entries[${index}].catalogIntegrity`); return { ...entry }; }); entries.sort((left, right) => compareStrings(left.skillId, right.skillId)); const computedDigest = sha256(canonicalJson({ schemaVersion: 1, entries })); if (installedState.digest !== computedDigest) { throw planError("AAS_PLAN_INSTALLED_STATE_DIGEST_MISMATCH", "integrity", {}); } return { digest: installedState.digest, entries }; } function normalizeOperation(operation, index, desiredSkillIds) { assertPlainObject(operation, `operations[${index}]`); assertExactKeys( operation, new Set(["kind", "skillId", "sourceTreeDigest", "expectedTreeDigest", "resultTreeDigest", "backupRequired"]), `operations[${index}]`, ); if (!OPERATION_KINDS.has(operation.kind) || typeof operation.skillId !== "string" || !ID_PATTERN.test(operation.skillId)) { throw planError("AAS_PLAN_OPERATION_INVALID", "invalidInput", { index }); } if (operation.kind !== "removeManaged" && !desiredSkillIds.has(operation.skillId)) { throw planError("AAS_PLAN_OPERATION_SKILL_NOT_DESIRED", "invalidInput", { index, skillId: operation.skillId }); } if (operation.kind === "removeManaged" && desiredSkillIds.has(operation.skillId)) { throw planError("AAS_PLAN_REMOVE_SKILL_STILL_DESIRED", "invalidInput", { index, skillId: operation.skillId }); } if (operation.kind === "install") { if (operation.expectedTreeDigest !== null || operation.backupRequired !== false) { throw planError("AAS_PLAN_OPERATION_PRECONDITION_INVALID", "invalidInput", { index }); } assertDigest(operation.sourceTreeDigest, `operations[${index}].sourceTreeDigest`); assertDigest(operation.resultTreeDigest, `operations[${index}].resultTreeDigest`); if (operation.sourceTreeDigest !== operation.resultTreeDigest) { throw planError("AAS_PLAN_OPERATION_RESULT_MISMATCH", "integrity", { index }); } } else if (operation.kind === "replaceManaged") { assertDigest(operation.sourceTreeDigest, `operations[${index}].sourceTreeDigest`); assertDigest(operation.expectedTreeDigest, `operations[${index}].expectedTreeDigest`); assertDigest(operation.resultTreeDigest, `operations[${index}].resultTreeDigest`); if (operation.sourceTreeDigest !== operation.resultTreeDigest) { throw planError("AAS_PLAN_OPERATION_RESULT_MISMATCH", "integrity", { index }); } if (operation.backupRequired !== true) { throw planError("AAS_PLAN_OPERATION_BACKUP_REQUIRED", "invalidInput", { index }); } } else { if (operation.sourceTreeDigest !== null || operation.resultTreeDigest !== null || operation.backupRequired !== true) { throw planError("AAS_PLAN_OPERATION_PRECONDITION_INVALID", "invalidInput", { index }); } assertDigest(operation.expectedTreeDigest, `operations[${index}].expectedTreeDigest`); } return { ...operation }; } function normalizeOperations(operations, manifest) { if (!Array.isArray(operations) || operations.length > 256) { throw planError("AAS_PLAN_OPERATIONS_INVALID", "invalidInput", {}); } const desiredSkillIds = new Set(manifest.skills.map((skill) => skill.id)); const normalized = operations.map((operation, index) => normalizeOperation(operation, index, desiredSkillIds)); const seen = new Set(); for (const operation of normalized) { if (seen.has(operation.skillId)) { throw planError("AAS_PLAN_OPERATION_DUPLICATE", "invalidInput", { skillId: operation.skillId }); } seen.add(operation.skillId); } return normalized.sort((left, right) => ( OPERATION_ORDER[left.kind] - OPERATION_ORDER[right.kind] || compareStrings(left.skillId, right.skillId) )); } function normalizeOverrides(overrides, operationIds) { if (!Array.isArray(overrides) || overrides.length > 128) { throw planError("AAS_PLAN_OVERRIDES_INVALID", "invalidInput", {}); } const seen = new Set(); const normalized = overrides.map((override, index) => { assertPlainObject(override, `overrides[${index}]`); assertExactKeys(override, new Set(["kind", "skillId", "reasonCodes"]), `overrides[${index}]`); if (!OVERRIDE_KINDS.has(override.kind) || typeof override.skillId !== "string" || !operationIds.has(override.skillId)) { throw planError("AAS_PLAN_OVERRIDE_INVALID", "invalidInput", { index }); } if (!Array.isArray(override.reasonCodes) || override.reasonCodes.length < 1 || override.reasonCodes.some((value) => typeof value !== "string")) { throw planError("AAS_PLAN_OVERRIDE_REASON_REQUIRED", "invalidInput", { index }); } const key = `${override.kind}:${override.skillId}`; if (seen.has(key)) throw planError("AAS_PLAN_OVERRIDE_DUPLICATE", "invalidInput", { index }); seen.add(key); return { kind: override.kind, skillId: override.skillId, reasonCodes: [...new Set(override.reasonCodes)].sort(), }; }); return normalized.sort((left, right) => compareStrings(`${left.kind}:${left.skillId}`, `${right.kind}:${right.skillId}`)); } function normalizeStateCommit(stateCommit, installedStateDigest) { assertPlainObject(stateCommit, "stateCommit"); assertExactKeys(stateCommit, new Set(["previousDigest", "nextDigest", "position"]), "stateCommit"); if (stateCommit.previousDigest !== installedStateDigest) { throw planError("AAS_PLAN_STATE_COMMIT_MISMATCH", "drift", {}); } assertDigest(stateCommit.nextDigest, "stateCommit.nextDigest"); if (stateCommit.position !== "final") throw planError("AAS_PLAN_STATE_COMMIT_NOT_FINAL", "invalidInput", {}); return { ...stateCommit }; } function expectedNextStateDigest(installedState, operations, catalogIntegrity) { const entries = new Map(installedState.entries.map((entry) => [entry.skillId, entry])); for (const operation of operations) { if (operation.kind === "removeManaged") entries.delete(operation.skillId); else entries.set(operation.skillId, { skillId: operation.skillId, treeDigest: operation.resultTreeDigest, catalogIntegrity, }); } const normalized = [...entries.values()].sort((left, right) => compareStrings(left.skillId, right.skillId)); return sha256(canonicalJson({ schemaVersion: 1, entries: normalized })); } function buildPlanEnvelope(input) { assertPlainObject(input, "input"); assertExactKeys( input, new Set(["manifest", "handshake", "catalog", "runtime", "target", "installedState", "operations", "overrides", "stateCommit"]), "input", ); const validation = validateManifest(input.manifest); if (!validation.ok) throw planError("AAS_PLAN_MANIFEST_INVALID", "invalidInput", validation.details); const boundVersions = assertVersionHandshake(input.handshake); const catalog = normalizeCatalog(input.catalog, input.manifest.catalog); const runtime = normalizeRuntime(input.runtime); const target = normalizeTarget(input.target, input.manifest); const installedState = normalizeInstalledState(input.installedState); const operations = normalizeOperations(input.operations, input.manifest); const operationIds = new Set(operations.map((operation) => operation.skillId)); const overrides = normalizeOverrides(input.overrides ?? [], operationIds); const stateCommit = normalizeStateCommit(input.stateCommit, installedState.digest); if (runtime.package !== catalog.package || runtime.version !== catalog.version) { throw planError("AAS_PLAN_RUNTIME_CATALOG_MISMATCH", "integrity", {}); } if (stateCommit.nextDigest !== expectedNextStateDigest(installedState, operations, catalog.integrity)) { throw planError("AAS_PLAN_NEXT_STATE_MISMATCH", "integrity", {}); } const payload = canonicalize({ schemaVersion: 2, kind: "aas.stack-plan.payload", versions: boundVersions, manifestDigest: validation.manifestDigest, catalog, runtime, target, installedState, desiredSkills: input.manifest.skills.map((skill) => skill.id).sort(), profile: input.manifest.profile, operations, overrides, stateCommit, }); const payloadJson = canonicalJson(payload); return validateInstance("plan.schema.json", { schemaVersion: 2, kind: "aas.stack-plan", digest: sha256(payloadJson), payload, }, "AAS_PLAN_SCHEMA_INVALID", "invalidInput"); } function validatePlanEnvelope(plan) { validateInstance("plan.schema.json", plan, "AAS_TRANSACTION_PLAN_SCHEMA_INVALID"); assertPlainObject(plan, "plan"); assertExactKeys(plan, new Set(["schemaVersion", "kind", "digest", "payload"]), "plan"); if (plan.schemaVersion !== 2 || plan.kind !== "aas.stack-plan") { throw planError("AAS_TRANSACTION_PLAN_INVALID", "integrity", {}); } assertDigest(plan.digest, "plan.digest"); assertPlainObject(plan.payload, "plan.payload"); assertExactKeys(plan.payload, new Set([ "schemaVersion", "kind", "versions", "manifestDigest", "catalog", "runtime", "target", "installedState", "desiredSkills", "profile", "operations", "overrides", "stateCommit", ]), "plan.payload"); if (plan.payload.schemaVersion !== 2 || plan.payload.kind !== "aas.stack-plan.payload" || plan.digest !== sha256(canonicalJson(plan.payload))) { throw planError("AAS_TRANSACTION_PLAN_INVALID", "integrity", {}); } assertDigest(plan.payload.manifestDigest, "manifestDigest"); if (!Array.isArray(plan.payload.desiredSkills) || plan.payload.desiredSkills.length > 128 || plan.payload.desiredSkills.some((id) => typeof id !== "string" || !ID_PATTERN.test(id))) { throw planError("AAS_PLAN_DESIRED_SKILLS_INVALID", "invalidInput", {}); } const syntheticManifest = { schemaVersion: 2, name: "validated-plan", catalog: plan.payload.catalog, targets: [{ host: plan.payload.target?.host, scope: plan.payload.target?.scope }], profile: plan.payload.profile, skills: plan.payload.desiredSkills.map((id) => ({ id })), }; const rebuilt = buildPlanEnvelope({ manifest: syntheticManifest, handshake: plan.payload.versions, catalog: plan.payload.catalog, runtime: plan.payload.runtime, target: plan.payload.target, installedState: plan.payload.installedState, operations: plan.payload.operations, overrides: plan.payload.overrides, stateCommit: plan.payload.stateCommit, }); const expectedPayload = { ...rebuilt.payload, manifestDigest: plan.payload.manifestDigest }; if (canonicalJson(expectedPayload) !== canonicalJson(plan.payload)) { throw planError("AAS_TRANSACTION_PLAN_INVALID", "integrity", {}); } return plan; } module.exports = { OPERATION_KINDS, OVERRIDE_KINDS, VERSION_KEYS, assertVersionHandshake, buildPlanEnvelope, validatePlanEnvelope, };