#!/usr/bin/env node import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire, Module } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const platformRoot = resolve(here, "../.."); const engineRoot = resolve(platformRoot, "../NODEDC_ENGINE_INFRA"); const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts")); const stageArtifact = resolve( process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT || join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-release-20260716-003.tgz"), ); const stageArtifactSha256 = "4601c16c57e5182996adf18d0163837511b27ab3f7cfdf97eac679418ee2d078"; const releaseId = "0.1.2-05e4b38b14b4a019"; const packageVersion = "0.1.2"; const packageSha256 = "05e4b38b14b4a019ce1f6eee27b9e320094cb3560903bd68074966b3a1267af5"; const n8nVersion = "2.3.2"; const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2"; const architecture = "amd64"; const generatedAt = "2026-07-15T21:51:41.000Z"; const activationId = "engine-n8n-private-extension-20260716-003"; const rollbackId = "engine-n8n-private-extension-rollback-20260716-003"; const transitionRoot = "nodedc-source/services/n8n/private-extensions"; const descriptorRel = `${transitionRoot}/ndc-activation.json`; const overrideRel = `${transitionRoot}/docker-compose.ndc-private-extension.yml`; const schemaRoot = "nodedc-source/server/assets/n8n/schema/v2.3.2"; const nodesCatalogRel = `${schemaRoot}/nodes.catalog.json`; const credentialsCatalogRel = `${schemaRoot}/credentials.catalog.json`; const metaRel = `${schemaRoot}/meta.json`; const iconRoot = "nodedc-source/server/assets/n8n/icons"; const iconRel = `${iconRoot}/ndc.svg`; const darkIconRel = `${iconRoot}/ndc.dark.svg`; const sealedReleaseRelativePath = `n8n-private-extensions/releases/n8n-nodes-ndc/${releaseId}/package`; const runtimePackagePath = "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc"; const expectedNodeTypes = [ "n8n-nodes-ndc.ndcDataProductPublish", "n8n-nodes-ndc.ndcDataProductRead", "n8n-nodes-ndc.ndcFoundryBinding", ]; const expectedCredentialTypes = [ "ndcDataProductWriterApi", "ndcDataProductReaderApi", "ndcFoundryBindingApi", ]; const nodeModules = [ ["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"], ["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"], ["dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js", "NdcFoundryBinding"], ]; const credentialModules = [ ["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"], ["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"], ["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"], ]; await mkdir(artifactRoot, { recursive: true }); assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact"); assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8")); const work = await mkdtemp(join(tmpdir(), "nodedc-engine-n8n-sealed-")); try { extractArchive(stageArtifact, work); const stagedRelease = join(work, "payload", "releases", "n8n-nodes-ndc", releaseId); const release = JSON.parse(await readFile(join(stagedRelease, "release.json"), "utf8")); assertRelease(release); assertSha(await readFile(join(stagedRelease, "package.tgz")), packageSha256, "private package"); const unpacked = join(work, "unpacked"); await mkdir(unpacked); extractArchive(join(stagedRelease, "package.tgz"), unpacked); const packageRoot = join(unpacked, "package"); const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); assertPackage(packageJson); const devNodeModules = join(platformRoot, "packages", "n8n-nodes-ndc", "node_modules"); const nodePath = String(process.env.NODE_PATH || "").split(":").filter(Boolean); if (!nodePath.includes(devNodeModules)) nodePath.unshift(devNodeModules); process.env.NODE_PATH = nodePath.join(":"); Module._initPaths(); const packageRequire = createRequire(join(packageRoot, "package.json")); const privateNodes = nodeModules.map(([path, className], index) => { const NodeClass = packageRequire(join(packageRoot, path))[className]; if (typeof NodeClass !== "function") throw new Error(`node_class_missing:${className}`); const description = structuredClone(new NodeClass().description); description.name = expectedNodeTypes[index]; description.icon = { light: "file:ndc.svg", dark: "file:ndc.dark.svg" }; if (Object.prototype.hasOwnProperty.call(description, "usableAsTool")) { throw new Error(`tool_variant_forbidden:${description.name}`); } return description; }); const privateCredentials = credentialModules.map(([path, className]) => { const CredentialClass = packageRequire(join(packageRoot, path))[className]; if (typeof CredentialClass !== "function") throw new Error(`credential_class_missing:${className}`); const description = structuredClone(new CredentialClass()); description.icon = { light: "file:ndc.svg", dark: "file:ndc.dark.svg" }; return description; }); assertExact(privateNodes.map((item) => item.name), expectedNodeTypes, "node types"); assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types"); const baselineNodes = JSON.parse(gitFile(nodesCatalogRel)); const baselineCredentials = JSON.parse(gitFile(credentialsCatalogRel)); const baselineMeta = JSON.parse(gitFile(metaRel)); assertBaselineCatalogs(baselineNodes, baselineCredentials, baselineMeta); const activeNodes = [...baselineNodes, ...privateNodes]; const activeCredentials = [...baselineCredentials, ...privateCredentials]; const activeMeta = { n8nVersion, generatedAt, source: `n8n-core+n8n-nodes-ndc@${packageVersion}`, nodeCount: activeNodes.length, credentialCount: activeCredentials.length, }; const activationDescriptor = descriptor("activate", expectedNodeTypes, expectedCredentialTypes, "verified_inactive"); const rollbackDescriptor = descriptor("rollback-inactive", [], [], releaseId); const override = composeOverride(); await writeJson(join(engineRoot, nodesCatalogRel), activeNodes); await writeJson(join(engineRoot, credentialsCatalogRel), activeCredentials); await writeJson(join(engineRoot, metaRel), activeMeta); await writeJson(join(engineRoot, descriptorRel), activationDescriptor); await writeFile(join(engineRoot, overrideRel), override, "utf8"); await cp(join(packageRoot, "dist/icons/ndc.svg"), join(engineRoot, iconRel), { force: true }); await cp(join(packageRoot, "dist/icons/ndc.dark.svg"), join(engineRoot, darkIconRel), { force: true }); const activationEntries = [ descriptorRel, overrideRel, nodesCatalogRel, credentialsCatalogRel, metaRel, iconRel, darkIconRel, ]; const activationArtifact = await buildArtifact(work, activationId, activationEntries, async (payload) => { for (const rel of activationEntries) { await cp(join(engineRoot, rel), join(payload, rel), { recursive: true, force: false }); } }); const rollbackEntries = [descriptorRel, nodesCatalogRel, credentialsCatalogRel, metaRel]; const rollbackArtifact = await buildArtifact(work, rollbackId, rollbackEntries, async (payload) => { await writeJson(join(payload, descriptorRel), rollbackDescriptor); await mkdir(join(payload, schemaRoot), { recursive: true }); await writeFile(join(payload, nodesCatalogRel), `${JSON.stringify(baselineNodes, null, 2)}\n`, "utf8"); await writeFile(join(payload, credentialsCatalogRel), `${JSON.stringify(baselineCredentials, null, 2)}\n`, "utf8"); await writeFile(join(payload, metaRel), `${JSON.stringify(baselineMeta, null, 2)}\n`, "utf8"); }); console.log(JSON.stringify({ ok: true, releaseId, packageSha256, nodeTypes: expectedNodeTypes, credentialTypes: expectedCredentialTypes, activation: activationArtifact, rollback: rollbackArtifact, }, null, 2)); } finally { await rm(work, { recursive: true, force: true }); } function descriptor(action, nodeTypes, credentialTypes, expectedCurrent) { return { schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1", action, releaseId, packageVersion, packageSha256, n8nVersion, baseImage, baseImageArchitecture: architecture, baseImageIdentityPolicy: "running-container-and-local-tag-must-match", sealedReleaseRelativePath, composeOverride: overrideRel, runtimePackagePath, topologyServices: ["n8n"], expectedCurrent, expectedNodeTypes: nodeTypes, expectedCredentialTypes: credentialTypes, rollbackBaseline: "verified_inactive", }; } function composeOverride() { const health = "const http=require('http');const req=http.get('http://127.0.0.1:5678/healthz/readiness',r=>{r.resume();process.exit(r.statusCode===200?0:1)});req.on('error',()=>process.exit(1));req.setTimeout(4000,()=>{req.destroy();process.exit(1)});"; return [ "services:", " n8n:", ` image: ${baseImage}`, " platform: linux/amd64", " pull_policy: never", " environment:", " N8N_USER_FOLDER: /home/node", " N8N_COMMUNITY_PACKAGES_ENABLED: \"true\"", " N8N_COMMUNITY_PACKAGES_PREVENT_LOADING: \"false\"", " N8N_REINSTALL_MISSING_PACKAGES: \"false\"", " volumes:", ` - /volume2/nodedc-demo/${sealedReleaseRelativePath}:${runtimePackagePath}:ro`, " healthcheck:", ` test: ${JSON.stringify(["CMD", "node", "-e", health])}`, " interval: 10s", " timeout: 5s", " retries: 30", " start_period: 30s", " labels:", ` nodedc.n8n-private-extension.release: ${releaseId}`, ` nodedc.n8n-private-extension.package-sha256: ${packageSha256}`, "", ].join("\n"); } async function buildArtifact(workRoot, id, entries, populate) { const stage = join(workRoot, id); const payload = join(stage, "payload"); await mkdir(payload, { recursive: true }); await populate(payload); await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8"); await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8"); const artifact = join(artifactRoot, `nodedc-${id}.tgz`); run("python3", ["-c", canonicalTarScript(), artifact, stage]); return { id, artifact, sha256: sha(await readFile(artifact)), entries }; } function assertRelease(value) { if (value?.releaseId !== releaseId || value?.package?.name !== "n8n-nodes-ndc" || value?.package?.version !== packageVersion || value?.package?.sha256 !== packageSha256 || value?.storage?.relativePath !== `releases/n8n-nodes-ndc/${releaseId}`) { throw new Error("staged_release_identity_mismatch"); } } function assertPackage(value) { if (value.name !== "n8n-nodes-ndc" || value.version !== packageVersion || value.private !== true) { throw new Error("package_identity_mismatch"); } if (value.dependencies !== undefined) throw new Error("runtime_dependencies_forbidden"); for (const name of ["preinstall", "install", "postinstall", "prepare", "prepack", "postpack"]) { if (value.scripts?.[name] !== undefined) throw new Error(`lifecycle_forbidden:${name}`); } assertExact(value.n8n?.nodes, nodeModules.map(([path]) => path), "package nodes"); assertExact(value.n8n?.credentials, credentialModules.map(([path]) => path), "package credentials"); } function assertBaselineCatalogs(nodes, credentials, meta) { if (!Array.isArray(nodes) || nodes.length !== 434 || nodes.some((item) => String(item?.name || "").startsWith("n8n-nodes-ndc."))) { throw new Error("baseline_node_catalog_mismatch"); } if (!Array.isArray(credentials) || credentials.length !== 385 || credentials.some((item) => expectedCredentialTypes.includes(String(item?.name || "")))) { throw new Error("baseline_credential_catalog_mismatch"); } if (meta?.n8nVersion !== n8nVersion || meta?.nodeCount !== 434 || meta?.credentialCount !== 385) { throw new Error("baseline_meta_mismatch"); } } function assertEngineBaseline(compose) { const exactImage = `image: docker.n8n.io/n8nio/n8n:\${N8N_IMAGE_TAG:-${n8nVersion}}`; if (!compose.includes(exactImage)) throw new Error("engine_n8n_version_mismatch"); if ((compose.match(/^ n8n:\s*$/gm) || []).length !== 1) throw new Error("engine_n8n_topology_mismatch"); if (/^ n8n-(?:worker|webhook)|^ (?:worker|webhook):/gm.test(compose)) throw new Error("unexpected_n8n_process_service"); if (compose.includes("N8N_CUSTOM_EXTENSIONS") || compose.includes("CUSTOM.")) throw new Error("custom_extension_loader_forbidden"); } function assertExact(actual, expected, label) { if (!Array.isArray(actual) || JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error(`${label.replaceAll(" ", "_")}_mismatch`); } } function assertSha(bytes, expected, label) { const actual = sha(bytes); if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`); } function gitFile(rel) { return run("git", ["show", `HEAD:${rel}`], engineRoot).stdout; } async function writeJson(path, value) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function extractArchive(archive, destination) { const script = [ "import pathlib, sys, tarfile", "src=pathlib.Path(sys.argv[1]); dst=pathlib.Path(sys.argv[2]).resolve()", "with tarfile.open(src, 'r:gz') as tf:", " for m in tf:", " p=pathlib.PurePosixPath(m.name)", " if p.is_absolute() or '..' in p.parts or any(x.startswith('._') for x in p.parts) or not (m.isfile() or m.isdir()): raise SystemExit('unsafe archive member')", " target=dst.joinpath(*p.parts)", " target.mkdir(parents=True, exist_ok=True) if m.isdir() else target.parent.mkdir(parents=True, exist_ok=True)", " if m.isfile():", " source=tf.extractfile(m)", " with open(target, 'xb') as out: out.write(source.read())", ].join("\n"); run("python3", ["-c", script, archive, destination]); } function canonicalTarScript() { return [ "import gzip, io, pathlib, sys, tarfile", "root=pathlib.Path(sys.argv[2])", "with open(sys.argv[1], 'wb') as out:", " with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:", " with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:", " for top in ('manifest.env','files.txt','payload'):", " p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])", " for x in paths:", " info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())", " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)", ].join("\n"); } function sha(bytes) { return createHash("sha256").update(bytes).digest("hex"); } function run(command, args, cwd) { const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 128 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }); if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`); return result; }