#!/usr/bin/env node import { createHash } from "node:crypto"; import { createRequire } from "node:module"; import { spawnSync } from "node:child_process"; import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const platformRoot = resolve(scriptDir, "../.."); const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc"); const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); const requireModule = createRequire(import.meta.url); const expectedPackageVersion = "0.1.4"; const [patchId = "n8n-nodes-ndc-provider-rotating-access-20260718-006", ...extra] = process.argv.slice(2); const expectedRuntimeNodes = [ { file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", exportName: "NdcDataProductPublish", name: "ndcDataProductPublish", }, { file: "dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", exportName: "NdcDataProductRead", name: "ndcDataProductRead", }, { file: "dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js", exportName: "NdcFoundryBinding", name: "ndcFoundryBinding", }, ]; const expectedN8nNodes = expectedRuntimeNodes.map((node) => node.file); const expectedRuntimeNodeTypes = expectedRuntimeNodes.map((node) => `n8n-nodes-ndc.${node.name}`); const expectedN8nCredentials = [ "dist/credentials/NdcDataProductWriterApi.credentials.js", "dist/credentials/NdcDataProductReaderApi.credentials.js", "dist/credentials/NdcFoundryBindingApi.credentials.js", "dist/credentials/NdcProviderRotatingAccessApi.credentials.js", ]; const expectedCredentialTypes = [ "ndcDataProductWriterApi", "ndcDataProductReaderApi", "ndcFoundryBindingApi", "ndcProviderRotatingAccessApi", ]; if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { throw new Error("usage: build-n8n-private-extension-artifact.mjs [patch-id]"); } const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); assertPackageSourcePolicy(packageJson); run("npm", ["test"], packageRoot); run("npm", ["run", "lint"], packageRoot); assertRuntimeNodePolicy(packageJson); const stage = await mkdtemp(join(tmpdir(), "nodedc-n8n-private-extension-")); const packDir = join(stage, "pack"); const payload = join(stage, "payload"); try { await mkdir(packDir, { recursive: true }); const pack = run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], packageRoot); const packResult = JSON.parse(pack.stdout); if (!Array.isArray(packResult) || packResult.length !== 1) throw new Error("npm_pack_result_invalid"); const metadata = packResult[0]; assertPackedFilePolicy(metadata, packageJson); const packedPath = join(packDir, metadata.filename); const canonicalPackedPath = join(packDir, "n8n-nodes-ndc.canonical.tgz"); const canonicalPackageScript = [ "import gzip, io, sys, tarfile", "members = []", "with tarfile.open(sys.argv[1], 'r:gz') as source:", " for original in source:", " if not (original.isfile() or original.isdir()):", " raise SystemExit('unsupported npm package member')", " content = b''", " if original.isfile():", " extracted = source.extractfile(original)", " if extracted is None:", " raise SystemExit('unreadable npm package member')", " content = extracted.read()", " members.append((original.name, original.isdir(), content))", "with open(sys.argv[2], 'wb') as output:", " with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:", " with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as target:", " for name, is_dir, content in sorted(members, key=lambda item: item[0]):", " member = tarfile.TarInfo(name)", " member.uid = member.gid = 0", " member.uname = member.gname = 'root'", " member.mtime = 0", " member.mode = 0o755 if is_dir else 0o644", " member.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE", " member.size = 0 if is_dir else len(content)", " target.addfile(member, None if is_dir else io.BytesIO(content))", ].join("\n"); run("python3", ["-c", canonicalPackageScript, packedPath, canonicalPackedPath], stage); const packageBytes = await readFile(canonicalPackedPath); const packageSha256 = createHash("sha256").update(packageBytes).digest("hex"); const releaseId = `${packageJson.version}-${packageSha256.slice(0, 16)}`; const relativeReleasePath = `releases/n8n-nodes-ndc/${releaseId}`; const releaseDir = join(payload, relativeReleasePath); await mkdir(releaseDir, { recursive: true }); await cp(canonicalPackedPath, join(releaseDir, "package.tgz"), { force: false }); const rollbackBaselinePolicy = { allowed: [ "previous_verified_immutable_release", "verified_inactive", ], firstActivation: "verified_inactive", requiresPreActivationVerification: true, }; const release = { schemaVersion: "nodedc.n8n-private-extension-release/v2", releaseId, package: { name: "n8n-nodes-ndc", version: packageJson.version, sha256: packageSha256, bytes: packageBytes.byteLength, runtimeTypePrefix: "n8n-nodes-ndc.", }, storage: { relativePath: relativeReleasePath, immutable: true, }, activation: { owner: "engine", status: "blocked_pending_engine_owned_mount", requiredCommunityPackagePath: "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc", requiresAtomicReleaseSwitch: true, requiresAllN8nProcessesRestart: true, requiresMcpSchemaAcceptance: true, rollbackBaselinePolicy, }, }; const rollback = { schemaVersion: "nodedc.n8n-private-extension-rollback/v2", releaseId, packageSha256, mode: "engine-owned-atomic-release-switch", baselinePolicy: rollbackBaselinePolicy, steps: [ "select_verified_previous_release_or_preverified_inactive_baseline", "switch_engine_owned_mount_atomically", "restart_all_n8n_processes", "verify_mcp_schema_state_matches_selected_baseline", ], forbidden: [ "delete_active_release", "mutate_engine_core", "live_npm_install", ], }; await writeFile(join(releaseDir, "release.json"), `${JSON.stringify(release, null, 2)}\n`, "utf8"); await writeFile(join(releaseDir, "rollback.json"), `${JSON.stringify(rollback, null, 2)}\n`, "utf8"); await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=n8n-private-extension\ntype=app-overlay\n`, "utf8"); await writeFile(join(stage, "files.txt"), `${relativeReleasePath}\n`, "utf8"); await mkdir(artifactDir, { recursive: true }); const target = join(artifactDir, `nodedc-n8n-private-extension-${patchId}.tgz`); const tarScript = [ "import gzip, os, pathlib, sys, tarfile", "root = pathlib.Path(sys.argv[2])", "def clean(info):", " info.uid = info.gid = 0", " info.uname = info.gname = 'root'", " info.mtime = 0", " info.mode = 0o755 if info.isdir() else 0o644", " return info", "with open(sys.argv[1], 'wb') as output:", " with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:", " with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as archive:", " for top in ('manifest.env', 'files.txt', 'payload'):", " path = root / top", " archive.add(path, arcname=top, recursive=False, filter=clean)", " if path.is_dir():", " for child in sorted(path.rglob('*'), key=lambda item: item.as_posix()):", " archive.add(child, arcname=child.relative_to(root).as_posix(), recursive=False, filter=clean)", ].join("\n"); run("python3", ["-c", tarScript, target, stage], stage); const artifactSha256 = createHash("sha256").update(await readFile(target)).digest("hex"); console.log(JSON.stringify({ ok: true, patchId, artifact: target, artifactSha256, releaseId, packageSha256, nodeTypes: expectedRuntimeNodeTypes, credentialTypes: expectedCredentialTypes, activation: "blocked_pending_engine_owned_mount", }, null, 2)); } finally { await rm(stage, { recursive: true, force: true }); } function assertPackageSourcePolicy(value) { if (value.name !== "n8n-nodes-ndc") throw new Error("package_name_invalid"); if (value.version !== expectedPackageVersion) throw new Error("package_version_invalid"); if (value.private !== true) throw new Error("package_must_remain_private"); if (value.dependencies !== undefined) throw new Error("runtime_dependencies_forbidden"); for (const lifecycle of ["preinstall", "install", "postinstall", "prepack", "prepare", "postpack"]) { if (value.scripts?.[lifecycle] !== undefined) throw new Error(`lifecycle_script_forbidden:${lifecycle}`); } assertExactRegistration(value.n8n?.nodes, expectedN8nNodes, "n8n_nodes"); assertExactRegistration(value.n8n?.credentials, expectedN8nCredentials, "n8n_credentials"); } function assertRuntimeNodePolicy(packageJson) { const observedTypes = []; for (const expected of expectedRuntimeNodes) { const loaded = requireModule(join(packageRoot, expected.file)); const NodeClass = loaded?.[expected.exportName]; if (typeof NodeClass !== "function") throw new Error(`runtime_node_export_missing:${expected.exportName}`); const description = new NodeClass()?.description; if (!description || description.name !== expected.name) { throw new Error(`runtime_node_name_mismatch:${expected.exportName}`); } if ("usableAsTool" in description) { throw new Error(`runtime_tool_variant_forbidden:${expected.name}`); } observedTypes.push(`${packageJson.name}.${description.name}`); } if (JSON.stringify(observedTypes) !== JSON.stringify(expectedRuntimeNodeTypes)) { throw new Error("runtime_node_types_mismatch"); } } function assertExactRegistration(actual, expected, label) { if (!Array.isArray(actual)) throw new Error(`${label}_missing`); if (actual.length !== new Set(actual).size) throw new Error(`${label}_duplicate`); if (actual.length !== expected.length || expected.some((value) => !actual.includes(value))) { throw new Error(`${label}_mismatch`); } } function assertPackedFilePolicy(metadata, sourcePackage) { if (metadata.name !== sourcePackage.name || metadata.version !== sourcePackage.version) { throw new Error("npm_pack_identity_mismatch"); } if (!Number.isSafeInteger(metadata.size) || metadata.size < 1024 || metadata.size > 32 * 1024 * 1024) { throw new Error("npm_pack_size_invalid"); } if (!Array.isArray(metadata.files) || metadata.files.length > 512) throw new Error("npm_pack_file_list_invalid"); const paths = new Set(); for (const file of metadata.files) { if (!file || typeof file.path !== "string" || paths.has(file.path)) throw new Error("npm_pack_file_invalid"); paths.add(file.path); if (!(file.path === "README.md" || file.path === "package.json" || file.path.startsWith("dist/"))) { throw new Error(`npm_pack_path_forbidden:${file.path}`); } const parts = file.path.split("/"); if ( file.path.includes("\\") || parts.some((part) => !part || part === "." || part === ".." || part.startsWith(".")) || file.mode !== 0o644 ) { throw new Error(`npm_pack_path_unsafe:${file.path}`); } } assertExactRegistration( [...paths].filter((value) => /^dist\/nodes\/.+\.node\.js$/.test(value)), expectedN8nNodes, "npm_pack_nodes", ); assertExactRegistration( [...paths].filter((value) => /^dist\/credentials\/.+\.credentials\.js$/.test(value)), expectedN8nCredentials, "npm_pack_credentials", ); for (const registered of [...sourcePackage.n8n.nodes, ...sourcePackage.n8n.credentials]) { if (!paths.has(registered)) throw new Error(`npm_pack_registration_missing:${registered}`); } } function run(command, args, cwd) { const result = spawnSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); if (result.status !== 0) { throw new Error(`${command}_failed:${result.stderr || result.stdout}`); } return result; }