From 0611a88971c48f648f01339f247845614dd93735 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 19 Jul 2026 10:11:56 +0300 Subject: [PATCH] feat(n8n): add rotating provider access credential --- ...-engine-n8n-private-extension-artifact.mjs | 82 +++++--- .../build-n8n-private-extension-artifact.mjs | 6 +- infra/deploy-runner/nodedc-deploy | 43 ++++- .../test_engine_n8n_private_extension.py | 41 ++-- .../test_n8n_private_extension.py | 14 +- packages/n8n-nodes-ndc/README.md | 12 +- ...dcProviderRotatingAccessApi.credentials.ts | 182 ++++++++++++++++++ packages/n8n-nodes-ndc/package-lock.json | 4 +- packages/n8n-nodes-ndc/package.json | 5 +- .../test/package-policy.test.cjs | 83 +++++++- 10 files changed, 408 insertions(+), 64 deletions(-) create mode 100644 packages/n8n-nodes-ndc/credentials/NdcProviderRotatingAccessApi.credentials.ts diff --git a/infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs b/infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs index bb9c436..a8b80ed 100644 --- a/infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs +++ b/infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs @@ -13,19 +13,30 @@ 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-history-read-20260717-004.tgz"), + || join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-provider-rotating-access-20260718-006.tgz"), +); +const predecessorArtifact = resolve( + process.env.NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT + || join(artifactRoot, "nodedc-engine-n8n-private-extension-20260717-005.tgz"), ); -const stageArtifactSha256 = "a78f8250704e4893eab727cdb862b69ce45a84310fbb73ecd3dfd968b72d12ee"; -const releaseId = "0.1.3-3354149b5245e39a"; -const packageVersion = "0.1.3"; -const packageSha256 = "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d"; +const stageArtifactSha256 = "2699e53c482f2d2bec6d13709a7e9b9a0bb3791ad2018c142e9bc4e7bf371e4a"; +const predecessorArtifactSha256 = "f0f687ddcc1bc006db56c75e73c8213e99298c31c2d881cde078e5ddc9600172"; +const releaseId = "0.1.4-59dc9f7882721d6a"; +const packageVersion = "0.1.4"; +const packageSha256 = "59dc9f7882721d6a5e2ae84ffa6aa8cd9bc5432f5904d24e70e363573ed2757f"; const n8nVersion = "2.3.2"; const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2"; const architecture = "amd64"; -const generatedAt = "2026-07-17T21:00:00.000Z"; -const predecessorGitRevision = "96ad46e3c62943f818233481957c62e5088ae35c"; -const previouslyIssuedTransitionIds = new Set(["20260715-002", "20260716-003", "20260717-004"]); +const generatedAt = "2026-07-18T21:00:00.000Z"; +const previouslyIssuedTransitionIds = new Set([ + "20260715-002", + "20260716-003", + "20260717-004", + "20260717-005", + "20260718-006", + "20260718-007", +]); const transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID); const activationId = `engine-n8n-private-extension-${transitionId}`; const rollbackId = `engine-n8n-private-extension-rollback-${transitionId}`; @@ -51,7 +62,9 @@ const expectedCredentialTypes = [ "ndcDataProductWriterApi", "ndcDataProductReaderApi", "ndcFoundryBindingApi", + "ndcProviderRotatingAccessApi", ]; +const predecessorCredentialTypes = expectedCredentialTypes.slice(0, 3); const nodeModules = [ ["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"], ["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"], @@ -61,12 +74,14 @@ const credentialModules = [ ["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"], ["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"], ["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"], + ["dist/credentials/NdcProviderRotatingAccessApi.credentials.js", "NdcProviderRotatingAccessApi"], ]; await mkdir(artifactRoot, { recursive: true }); await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${activationId}.tgz`)); await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${rollbackId}.tgz`)); assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact"); +assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, "predecessor artifact"); assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8")); const work = await mkdtemp(join(tmpdir(), "nodedc-engine-n8n-sealed-")); @@ -111,10 +126,10 @@ try { assertExact(privateNodes.map((item) => item.name), expectedNodeTypes, "node types"); assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types"); - const predecessorDescriptor = JSON.parse(gitFile(descriptorRel)); - const predecessorNodes = JSON.parse(gitFile(nodesCatalogRel)); - const predecessorCredentials = JSON.parse(gitFile(credentialsCatalogRel)); - const predecessorMeta = JSON.parse(gitFile(metaRel)); + const predecessorDescriptor = JSON.parse(predecessorArtifactFile(descriptorRel)); + const predecessorNodes = JSON.parse(predecessorArtifactFile(nodesCatalogRel)); + const predecessorCredentials = JSON.parse(predecessorArtifactFile(credentialsCatalogRel)); + const predecessorMeta = JSON.parse(predecessorArtifactFile(metaRel)); assertPredecessorCatalogs( predecessorDescriptor, predecessorNodes, @@ -148,8 +163,18 @@ try { packageVersion: predecessorDescriptor.packageVersion, packageSha256: predecessorDescriptor.packageSha256, }; - const activationDescriptor = descriptor(target, predecessor.releaseId, predecessor.releaseId); - const rollbackDescriptor = descriptor(predecessor, releaseId, releaseId); + const activationDescriptor = descriptor( + target, + predecessor.releaseId, + predecessor.releaseId, + expectedCredentialTypes, + ); + const rollbackDescriptor = descriptor( + predecessor, + releaseId, + releaseId, + predecessorCredentialTypes, + ); const override = composeOverride(target); const rollbackOverride = composeOverride(predecessor); @@ -204,7 +229,7 @@ try { await rm(work, { recursive: true, force: true }); } -function descriptor(target, expectedCurrent, rollbackBaseline) { +function descriptor(target, expectedCurrent, rollbackBaseline, credentialTypes) { return { schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1", action: "activate", @@ -221,7 +246,7 @@ function descriptor(target, expectedCurrent, rollbackBaseline) { topologyServices: ["n8n"], expectedCurrent, expectedNodeTypes, - expectedCredentialTypes, + expectedCredentialTypes: credentialTypes, rollbackBaseline, }; } @@ -327,15 +352,15 @@ function assertPackage(value) { function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) { if (descriptorValue?.action !== "activate" - || descriptorValue?.releaseId !== "0.1.2-05e4b38b14b4a019" - || descriptorValue?.packageVersion !== "0.1.2" - || descriptorValue?.packageSha256 !== "05e4b38b14b4a019ce1f6eee27b9e320094cb3560903bd68074966b3a1267af5") { + || descriptorValue?.releaseId !== "0.1.3-3354149b5245e39a" + || descriptorValue?.packageVersion !== "0.1.3" + || descriptorValue?.packageSha256 !== "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d") { throw new Error("predecessor_descriptor_mismatch"); } if (!Array.isArray(nodes) || nodes.length !== 437 || !Array.isArray(credentials) || credentials.length !== 388 || meta?.n8nVersion !== n8nVersion - || meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.2" + || meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.3" || meta?.nodeCount !== 437 || meta?.credentialCount !== 388) { throw new Error("predecessor_catalog_mismatch"); @@ -346,8 +371,8 @@ function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) { "predecessor node types", ); assertExact( - credentials.filter((item) => expectedCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name), - expectedCredentialTypes, + credentials.filter((item) => predecessorCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name), + predecessorCredentialTypes, "predecessor credential types", ); } @@ -381,8 +406,17 @@ function assertSha(bytes, expected, label) { if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`); } -function gitFile(rel) { - return run("git", ["show", `${predecessorGitRevision}:${rel}`], engineRoot).stdout; +function predecessorArtifactFile(rel) { + const script = [ + "import pathlib,sys,tarfile", + "archive=pathlib.Path(sys.argv[1])", + "member='payload/'+sys.argv[2]", + "with tarfile.open(archive,'r:gz') as source:", + " extracted=source.extractfile(member)", + " if extracted is None: raise SystemExit('predecessor member missing')", + " sys.stdout.buffer.write(extracted.read())", + ].join("\n"); + return run("python3", ["-c", script, predecessorArtifact, rel]).stdout; } async function writeJson(path, value) { diff --git a/infra/deploy-runner/build-n8n-private-extension-artifact.mjs b/infra/deploy-runner/build-n8n-private-extension-artifact.mjs index 55c978f..6e0eb10 100644 --- a/infra/deploy-runner/build-n8n-private-extension-artifact.mjs +++ b/infra/deploy-runner/build-n8n-private-extension-artifact.mjs @@ -12,8 +12,8 @@ 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.3"; -const [patchId = "n8n-nodes-ndc-history-read-20260717-004", ...extra] = process.argv.slice(2); +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", @@ -37,11 +37,13 @@ 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)) { diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index bb8bdfd..5a69109 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -208,6 +208,11 @@ ENGINE_N8N_RELEASE_CATALOG_JSON_SHA256 = { "credentials": "a26824ffc0e15db857c792153de3417febc0dbba4880380d6c8cd544b3c80f4d", "meta": "b8aa60c0d919573626fa0694a1e4ae9ede015325a5d312e3e05d5ac293084aa7", }, + "0.1.4-59dc9f7882721d6a": { + "nodes": "b0a5215699a6e691b8cfc505ab457d5632ef6d83adb3537520d0b60760ba16b2", + "credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e", + "meta": "8fadbc594c5d14b1d53ab69b677f00823949410b9a37f4e43ecc321e89d68983", + }, } ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256 = "b70d9d8130d498c55de46a5d0758c844f1242b70803457b2291ab3a9de8056f2" ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256 = "680e9f52aac791efbd38e3bd99bd51ef5cded9756d867897c6c2755850e87b50" @@ -236,6 +241,7 @@ N8N_PRIVATE_EXTENSION_CREDENTIALS = ( "dist/credentials/NdcDataProductWriterApi.credentials.js", "dist/credentials/NdcDataProductReaderApi.credentials.js", "dist/credentials/NdcFoundryBindingApi.credentials.js", + "dist/credentials/NdcProviderRotatingAccessApi.credentials.js", ) ENGINE_N8N_NODE_TYPES = ( "n8n-nodes-ndc.ndcDataProductPublish", @@ -246,7 +252,13 @@ ENGINE_N8N_CREDENTIAL_TYPES = ( "ndcDataProductWriterApi", "ndcDataProductReaderApi", "ndcFoundryBindingApi", + "ndcProviderRotatingAccessApi", ) +ENGINE_N8N_CREDENTIAL_TYPES_BY_RELEASE = { + "0.1.2-05e4b38b14b4a019": ENGINE_N8N_CREDENTIAL_TYPES[:3], + "0.1.3-3354149b5245e39a": ENGINE_N8N_CREDENTIAL_TYPES[:3], + "0.1.4-59dc9f7882721d6a": ENGINE_N8N_CREDENTIAL_TYPES, +} ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES = ( "nodedc-source/server/credentialPolicies/ndcPrivateNode.js", "nodedc-source/server/credentialSink", @@ -2191,6 +2203,13 @@ def is_engine_n8n_transition(component, entries): return component == "engine" and entries is not None and ENGINE_N8N_TRANSITION_DESCRIPTOR_REL in entries +def engine_n8n_credential_types_for_release(release_id): + expected = ENGINE_N8N_CREDENTIAL_TYPES_BY_RELEASE.get(release_id) + if expected is None: + die("Engine n8n credential catalog release is not registered") + return expected + + def read_engine_n8n_transition_descriptor(path, label="Engine n8n transition descriptor"): descriptor = read_strict_json(path, label) require_exact_json_keys( @@ -2259,7 +2278,9 @@ def read_engine_n8n_transition_descriptor(path, label="Engine n8n transition des die("Engine n8n activation rollback baseline mismatch") if descriptor.get("expectedNodeTypes") != list(ENGINE_N8N_NODE_TYPES): die("Engine n8n activation node type set mismatch") - if descriptor.get("expectedCredentialTypes") != list(ENGINE_N8N_CREDENTIAL_TYPES): + if descriptor.get("expectedCredentialTypes") != list( + engine_n8n_credential_types_for_release(release_id) + ): die("Engine n8n activation credential type set mismatch") else: if descriptor.get("expectedCurrent") != release_id: @@ -2332,7 +2353,11 @@ def validate_engine_n8n_catalog_payload(payload_dir, descriptor): if isinstance(item, dict) and str(item.get("name") or "") in ENGINE_N8N_CREDENTIAL_TYPES ] expected_node_types = list(ENGINE_N8N_NODE_TYPES) if action == "activate" else [] - expected_credential_types = list(ENGINE_N8N_CREDENTIAL_TYPES) if action == "activate" else [] + expected_credential_types = ( + list(engine_n8n_credential_types_for_release(descriptor["releaseId"])) + if action == "activate" + else [] + ) if [item.get("name") for item in private_nodes] != expected_node_types: die("Engine n8n pinned node catalog exact set mismatch") if [item.get("name") for item in private_credentials] != expected_credential_types: @@ -2340,7 +2365,7 @@ def validate_engine_n8n_catalog_payload(payload_dir, descriptor): if any("usableAsTool" in item for item in private_nodes): die("Engine n8n pinned node catalog would generate tool variants") if action == "activate": - if len(nodes) != 437 or len(credentials) != 388: + if len(nodes) != 437 or len(credentials) != 385 + len(expected_credential_types): die("Engine n8n activation catalog count mismatch") if any(not str(item.get("displayName") or "").startswith("NDC ") for item in private_nodes): die("Engine n8n private node visible name mismatch") @@ -5694,7 +5719,11 @@ def preflight_engine_n8n_transition(descriptor, enforce_expected_current): current_types = current_catalog["node_types"] current_credentials = current_catalog["credential_types"] expected_current_types = list(ENGINE_N8N_NODE_TYPES) if current_state != "verified_inactive" else [] - expected_current_credentials = list(ENGINE_N8N_CREDENTIAL_TYPES) if current_state != "verified_inactive" else [] + expected_current_credentials = ( + list(engine_n8n_credential_types_for_release(current_state)) + if current_state != "verified_inactive" + else [] + ) if current_types != expected_current_types: die("Engine n8n current live private-node set does not match its transition state") if current_credentials != expected_current_credentials: @@ -5929,7 +5958,11 @@ def accept_engine_n8n_runtime(descriptor): die("Engine n8n private extension loader log acceptance failed") expected_types = list(ENGINE_N8N_NODE_TYPES) if descriptor["action"] == "activate" else [] - expected_credentials = list(ENGINE_N8N_CREDENTIAL_TYPES) if descriptor["action"] == "activate" else [] + expected_credentials = ( + list(engine_n8n_credential_types_for_release(descriptor["releaseId"])) + if descriptor["action"] == "activate" + else [] + ) loader_catalog = engine_n8n_private_loader_catalog(container_id) if loader_catalog["node_types"] != expected_types: die("Engine n8n live package-loader node catalog acceptance failed") diff --git a/infra/deploy-runner/test_engine_n8n_private_extension.py b/infra/deploy-runner/test_engine_n8n_private_extension.py index 52f3871..45db412 100644 --- a/infra/deploy-runner/test_engine_n8n_private_extension.py +++ b/infra/deploy-runner/test_engine_n8n_private_extension.py @@ -16,17 +16,24 @@ from unittest import mock SCRIPT_DIR = Path(__file__).resolve().parent PLATFORM_ROOT = SCRIPT_DIR.parent.parent ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA" -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +RUNNER_PATH = Path( + os.environ.get("NODEDC_DEPLOY_RUNNER_UNDER_TEST", SCRIPT_DIR / "nodedc-deploy") +).resolve() BUILDER_PATH = SCRIPT_DIR / "build-engine-n8n-private-extension-artifact.mjs" STAGE_ARTIFACT = ( PLATFORM_ROOT / "infra/deploy-artifacts" - / "nodedc-n8n-private-extension-n8n-nodes-ndc-history-read-20260717-004.tgz" + / "nodedc-n8n-private-extension-n8n-nodes-ndc-provider-rotating-access-20260718-006.tgz" ) -TRANSITION_ID = "20260717-005" -SEALED_RELEASE_ID = "0.1.3-3354149b5245e39a" -SEALED_PACKAGE_SHA256 = "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d" -PREDECESSOR_RELEASE_ID = "0.1.2-05e4b38b14b4a019" +PREDECESSOR_ARTIFACT = ( + PLATFORM_ROOT + / "infra/deploy-artifacts" + / "nodedc-engine-n8n-private-extension-20260717-005.tgz" +) +TRANSITION_ID = "20260718-008" +SEALED_RELEASE_ID = "0.1.4-59dc9f7882721d6a" +SEALED_PACKAGE_SHA256 = "59dc9f7882721d6a5e2ae84ffa6aa8cd9bc5432f5904d24e70e363573ed2757f" +PREDECESSOR_RELEASE_ID = "0.1.3-3354149b5245e39a" BUILDER_ENGINE_PATHS = ( "nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json", "nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json", @@ -45,14 +52,16 @@ EXPECTED_CREDENTIALS = [ "ndcDataProductWriterApi", "ndcDataProductReaderApi", "ndcFoundryBindingApi", + "ndcProviderRotatingAccessApi", ] +PREDECESSOR_CREDENTIALS = EXPECTED_CREDENTIALS[:3] PRIVATE_EXTENSION_CANON_FUNCTION_SHA256 = { "expected_engine_n8n_compose_override": "8ee93f3e7c407f5557c711119236449a440d73a7fcac1bbfa50a09e6a13c1cff", "engine_n8n_package_loader_probe_script": "2b3b3c96fad6e5e48ebea74426b21bc7eea6b6fe0e786b537da3366d18aedd9b", "engine_n8n_private_loader_catalog": "a2ae389b4ef215900cf967b863d01056bd2a8eec55554566d0f6005e4e0bcbac", "validate_engine_n8n_base_compose_source": "adacc5b20e52684cbc427362ddba5a6d2e13ef2091a89859e2b8f7bbf2e20458", - "preflight_engine_n8n_transition": "3d574acb081fb752e45cc0c3286d3ae4596dcee83a85e2c3fee81ac65ec796e0", - "accept_engine_n8n_runtime": "1a2614a465161e3833e84aca402817682da1a538bdb2aecc4af66b665497338d", + "preflight_engine_n8n_transition": "25bf33b3be0916a9dd82f064a710060eaf6bd7d87b3614ee0065af07ddbb446e", + "accept_engine_n8n_runtime": "bc49275adb2d5ab4fd98abd07b9059a197d023e03b40145b6fc910b70b2f738b", } @@ -107,6 +116,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): env.pop("NODEDC_N8N_TRANSITION_ID", None) env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output) env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT) + env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_ARTIFACT) command = ["node", str(BUILDER_PATH)] if index == 0: command.append(TRANSITION_ID) @@ -165,8 +175,8 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): def test_transition_id_rejects_missing_invalid_and_previously_issued_values(self): cases = ( ([], "transition_id_required"), - (["20260717-004"], "transition_id_already_issued"), - (["engine-n8n-private-extension-20260717-005"], "transition_id_invalid"), + (["20260718-007"], "transition_id_already_issued"), + (["engine-n8n-private-extension-20260718-008"], "transition_id_invalid"), (["20260230-004"], "transition_id_invalid"), (["20260717-000"], "transition_id_invalid"), ([TRANSITION_ID, "unexpected-second-id"], "transition_id_argument_count_invalid"), @@ -191,6 +201,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): env.pop("NODEDC_N8N_TRANSITION_ID", None) env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.artifact(0, "activation").parent) env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT) + env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_ARTIFACT) result = subprocess.run( ["node", str(BUILDER_PATH), TRANSITION_ID], cwd=PLATFORM_ROOT, @@ -233,7 +244,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): self.assertEqual([item["name"] for item in private_nodes], EXPECTED_NODES) self.assertEqual([item["name"] for item in private_credentials], EXPECTED_CREDENTIALS) self.assertTrue(all("usableAsTool" not in item for item in private_nodes)) - self.assertEqual((len(nodes), len(credentials)), (437, 388)) + self.assertEqual((len(nodes), len(credentials)), (437, 389)) def test_rollback_catalog_restores_verified_predecessor_release(self): with tempfile.TemporaryDirectory() as directory: @@ -253,8 +264,8 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): EXPECTED_NODES, ) self.assertEqual( - [item.get("name") for item in credentials if item.get("name") in EXPECTED_CREDENTIALS], - EXPECTED_CREDENTIALS, + [item.get("name") for item in credentials if item.get("name") in PREDECESSOR_CREDENTIALS], + PREDECESSOR_CREDENTIALS, ) self.assertEqual((len(nodes), len(credentials)), (437, 388)) @@ -271,7 +282,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor)) self.assertEqual( hashlib.sha256(override.encode("utf-8")).hexdigest(), - "256142c14ae295bf9be4d74fdc8dcd6e134e4ded8a264f15e66b38e85503b589", + "d6d1249e93b5bed5e697715b6b773c7efa4ac234f12eab57d868407e6bb5390e", ) self.assertIn("pull_policy: never", override) self.assertIn("N8N_USER_FOLDER: /home/node", override) @@ -563,7 +574,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase): def test_sealed_release_exact_set_includes_implicit_directories(self): release_relative = Path( - "payload/releases/n8n-nodes-ndc/0.1.3-3354149b5245e39a" + "payload/releases/n8n-nodes-ndc/0.1.4-59dc9f7882721d6a" ) with tempfile.TemporaryDirectory() as directory: work = Path(directory) diff --git a/infra/deploy-runner/test_n8n_private_extension.py b/infra/deploy-runner/test_n8n_private_extension.py index b75c7fc..dbaef80 100644 --- a/infra/deploy-runner/test_n8n_private_extension.py +++ b/infra/deploy-runner/test_n8n_private_extension.py @@ -15,9 +15,11 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent PLATFORM_ROOT = SCRIPT_DIR.parent.parent -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +RUNNER_PATH = Path( + os.environ.get("NODEDC_DEPLOY_RUNNER_UNDER_TEST", SCRIPT_DIR / "nodedc-deploy") +).resolve() BUILDER_PATH = SCRIPT_DIR / "build-n8n-private-extension-artifact.mjs" -PATCH_ID = "n8n-nodes-ndc-history-read-20260717-004" +PATCH_ID = "n8n-nodes-ndc-provider-rotating-access-20260718-006" EXPECTED_NODES = [ "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", @@ -32,11 +34,13 @@ EXPECTED_CREDENTIALS = [ "dist/credentials/NdcDataProductWriterApi.credentials.js", "dist/credentials/NdcDataProductReaderApi.credentials.js", "dist/credentials/NdcFoundryBindingApi.credentials.js", + "dist/credentials/NdcProviderRotatingAccessApi.credentials.js", ] EXPECTED_CREDENTIAL_TYPES = [ "ndcDataProductWriterApi", "ndcDataProductReaderApi", "ndcFoundryBindingApi", + "ndcProviderRotatingAccessApi", ] @@ -106,7 +110,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase): manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory)) self.assertEqual(manifest["component"], "n8n-private-extension") self.assertEqual(len(entries), 1) - self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.3-[a-f0-9]{16}$") + self.assertRegex(entries[0], r"^releases/n8n-nodes-ndc/0\.1\.4-[a-f0-9]{16}$") with tarfile.open(self.artifacts[0], "r:gz") as archive: names = archive.getnames() @@ -118,7 +122,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase): with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive: package_json_member = archive.getmember("package/package.json") package_json = json.loads(archive.extractfile(package_json_member).read()) - self.assertEqual(package_json["version"], "0.1.3") + self.assertEqual(package_json["version"], "0.1.4") self.assertEqual(package_json["n8n"]["nodes"], EXPECTED_NODES) self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS) for node_path in EXPECTED_NODES: @@ -154,7 +158,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase): "requiresPreActivationVerification": True, } self.assertEqual(release["schemaVersion"], "nodedc.n8n-private-extension-release/v2") - self.assertEqual(release["package"]["version"], "0.1.3") + self.assertEqual(release["package"]["version"], "0.1.4") self.assertEqual(release["activation"]["rollbackBaselinePolicy"], expected_policy) self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2") self.assertEqual(rollback["baselinePolicy"], expected_policy) diff --git a/packages/n8n-nodes-ndc/README.md b/packages/n8n-nodes-ndc/README.md index 5915897..d35422e 100644 --- a/packages/n8n-nodes-ndc/README.md +++ b/packages/n8n-nodes-ndc/README.md @@ -30,8 +30,16 @@ The defaults are `http://external-data-plane:18106` and `http://nodedc-module-foundry:3333`. They are provider-neutral Platform service addresses, not workflow configuration. -The three credential types contain only one password-protected opaque -capability. Writer, reader, and Foundry capabilities are intentionally distinct. +The writer, reader, and Foundry credential types contain only one +password-protected opaque capability and are intentionally distinct. The +separate `NDC Provider Rotating Access API` credential keeps the Gelios REST access +and refresh pair inside native Engine Credentials. It exchanges the refresh +token only against the fixed Gelios refresh endpoint, persists both rotated +tokens through the supported expirable-credential lifecycle, and injects only +the current access token into provider requests. Concurrent refresh attempts in +the single-service L2 runtime are coalesced and briefly replay the same rotated +pair so a stale caller cannot immediately spend the invalidated predecessor +refresh token again. Provider identity, product version, ontology revision, persistence policy, and tenant scope are materialized by the receiving service from the grant. The node never accepts them from a workflow. diff --git a/packages/n8n-nodes-ndc/credentials/NdcProviderRotatingAccessApi.credentials.ts b/packages/n8n-nodes-ndc/credentials/NdcProviderRotatingAccessApi.credentials.ts new file mode 100644 index 0000000..784303c --- /dev/null +++ b/packages/n8n-nodes-ndc/credentials/NdcProviderRotatingAccessApi.credentials.ts @@ -0,0 +1,182 @@ +import { createHash } from 'node:crypto'; + +import type { + IAuthenticateGeneric, + ICredentialDataDecryptedObject, + ICredentialTestRequest, + ICredentialType, + IHttpRequestHelper, + IHttpRequestOptions, + INodeProperties, + Icon, +} from 'n8n-workflow'; + +const GELIOS_API_BASE_URL = 'https://api.geliospro.com'; +const GELIOS_REFRESH_URL = `${GELIOS_API_BASE_URL}/api/v1/auth/refresh`; +const RECENT_ROTATION_TTL_MS = 30_000; +const MAX_RECENT_ROTATIONS = 64; + +interface RotatedTokens extends ICredentialDataDecryptedObject { + accessToken: string; + refreshToken: string; + accessExpiresAt: string; +} + +interface RecentRotation { + expiresAt: number; + tokens: RotatedTokens; +} + +const refreshInFlight = new Map>(); +const recentRotations = new Map(); + +export class NdcProviderRotatingAccessApi implements ICredentialType { + name = 'ndcProviderRotatingAccessApi'; + displayName = 'NDC Provider Rotating Access API'; + icon: Icon = { + light: 'file:../icons/ndc.svg', + dark: 'file:../icons/ndc.dark.svg', + }; + documentationUrl = ''; + properties: INodeProperties[] = [ + { + displayName: 'Refresh Token', + name: 'refreshToken', + type: 'string', + typeOptions: { password: true }, + default: '', + required: true, + description: 'Rotating refresh token issued by the approved provider profile', + }, + { + displayName: 'Access Token', + name: 'accessToken', + type: 'hidden', + typeOptions: { + expirable: true, + password: true, + }, + default: '', + }, + { + displayName: 'Access Token Expires At', + name: 'accessExpiresAt', + type: 'hidden', + default: '', + }, + ]; + + async preAuthentication( + this: IHttpRequestHelper, + credentials: ICredentialDataDecryptedObject, + ): Promise { + const refreshToken = credentials.refreshToken; + if (typeof refreshToken !== 'string' || refreshToken.trim() === '') { + throw new Error('provider_refresh_token_required'); + } + + const key = createHash('sha256').update(refreshToken, 'utf8').digest('hex'); + const now = Date.now(); + pruneRecentRotations(now); + const recent = recentRotations.get(key); + if (recent && recent.expiresAt > now) return { ...recent.tokens }; + + let pending = refreshInFlight.get(key); + if (!pending) { + pending = rotateTokens(this, refreshToken) + .then((tokens) => { + rememberRotation(key, tokens); + return tokens; + }) + .finally(() => refreshInFlight.delete(key)); + refreshInFlight.set(key, pending); + } + + return { ...(await pending) }; + } + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.accessToken}}', + }, + }, + }; + + get test(): ICredentialTestRequest { + return { + request: { + method: 'GET', + url: `${GELIOS_API_BASE_URL}/api/v1/auth`, + }, + }; + } +} + +async function rotateTokens( + helper: IHttpRequestHelper, + refreshToken: string, +): Promise { + let response: unknown; + try { + response = await helper.helpers.httpRequest({ + method: 'POST', + url: GELIOS_REFRESH_URL, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: { refresh_token: refreshToken }, + json: true, + } satisfies IHttpRequestOptions); + } catch { + throw new Error('provider_access_refresh_failed'); + } + + if (!isRecord(response)) throw new Error('provider_access_refresh_failed'); + const accessToken = response.access_token; + const nextRefreshToken = response.refresh_token; + const tokenType = response.token_type; + const expiresIn = response.expires_in; + if ( + typeof accessToken !== 'string' + || accessToken.trim() === '' + || typeof nextRefreshToken !== 'string' + || nextRefreshToken.trim() === '' + || typeof tokenType !== 'string' + || tokenType.toLowerCase() !== 'bearer' + || !Number.isSafeInteger(expiresIn) + || Number(expiresIn) <= 0 + ) { + throw new Error('provider_access_refresh_failed'); + } + + return { + accessToken, + refreshToken: nextRefreshToken, + accessExpiresAt: new Date(Date.now() + Number(expiresIn) * 1000).toISOString(), + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function pruneRecentRotations(now: number): void { + for (const [key, value] of recentRotations) { + if (value.expiresAt <= now) recentRotations.delete(key); + } +} + +function rememberRotation(key: string, tokens: RotatedTokens): void { + while (recentRotations.size >= MAX_RECENT_ROTATIONS) { + const oldest = recentRotations.keys().next().value as string | undefined; + if (!oldest) break; + recentRotations.delete(oldest); + } + recentRotations.set(key, { + expiresAt: Date.now() + RECENT_ROTATION_TTL_MS, + tokens: { ...tokens }, + }); +} diff --git a/packages/n8n-nodes-ndc/package-lock.json b/packages/n8n-nodes-ndc/package-lock.json index d974659..1c0428e 100644 --- a/packages/n8n-nodes-ndc/package-lock.json +++ b/packages/n8n-nodes-ndc/package-lock.json @@ -1,12 +1,12 @@ { "name": "n8n-nodes-ndc", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "n8n-nodes-ndc", - "version": "0.1.3", + "version": "0.1.4", "license": "UNLICENSED", "devDependencies": { "@n8n/node-cli": "0.39.3", diff --git a/packages/n8n-nodes-ndc/package.json b/packages/n8n-nodes-ndc/package.json index ac4c852..7a450e0 100644 --- a/packages/n8n-nodes-ndc/package.json +++ b/packages/n8n-nodes-ndc/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-ndc", - "version": "0.1.3", + "version": "0.1.4", "description": "Private NODE.DC nodes for scoped data products and Foundry bindings.", "private": true, "license": "UNLICENSED", @@ -24,7 +24,8 @@ "credentials": [ "dist/credentials/NdcDataProductWriterApi.credentials.js", "dist/credentials/NdcDataProductReaderApi.credentials.js", - "dist/credentials/NdcFoundryBindingApi.credentials.js" + "dist/credentials/NdcFoundryBindingApi.credentials.js", + "dist/credentials/NdcProviderRotatingAccessApi.credentials.js" ], "nodes": [ "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", diff --git a/packages/n8n-nodes-ndc/test/package-policy.test.cjs b/packages/n8n-nodes-ndc/test/package-policy.test.cjs index dd7614d..c30ef06 100644 --- a/packages/n8n-nodes-ndc/test/package-policy.test.cjs +++ b/packages/n8n-nodes-ndc/test/package-policy.test.cjs @@ -12,9 +12,10 @@ const nodeSpecs = [ ['NdcFoundryBinding', 'ndcFoundryBinding', 'NDC Foundry Binding'], ]; const credentialSpecs = [ - ['NdcDataProductWriterApi', 'ndcDataProductWriterApi'], - ['NdcDataProductReaderApi', 'ndcDataProductReaderApi'], - ['NdcFoundryBindingApi', 'ndcFoundryBindingApi'], + ['NdcDataProductWriterApi', 'ndcDataProductWriterApi', 'opaque'], + ['NdcDataProductReaderApi', 'ndcDataProductReaderApi', 'opaque'], + ['NdcFoundryBindingApi', 'ndcFoundryBindingApi', 'opaque'], + ['NdcProviderRotatingAccessApi', 'ndcProviderRotatingAccessApi', 'rotating'], ]; const forbiddenNodeParameter = /(url|provider|tenant|connection|token|secret|password|credential)/i; @@ -67,19 +68,34 @@ async function main() { assertProductSurfaceHasNdcBrand(node.description, className); } - for (const [className, internalName] of credentialSpecs) { + const credentials = new Map(); + for (const [className, internalName, kind] of credentialSpecs) { const modulePath = path.join(packageRoot, 'dist', 'credentials', `${className}.credentials.js`); const CredentialClass = require(modulePath)[className]; const credential = new CredentialClass(); + credentials.set(className, credential); assert.match(credential.displayName, /^NDC /); assert.equal(credential.name, internalName); assert.deepEqual(credential.icon, { light: 'file:../icons/ndc.svg', dark: 'file:../icons/ndc.dark.svg', }); - assert.deepEqual(credential.properties.map((property) => property.name), ['capability']); - assert.equal(credential.properties[0].typeOptions.password, true); - assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.capability}}'); + if (kind === 'opaque') { + assert.deepEqual(credential.properties.map((property) => property.name), ['capability']); + assert.equal(credential.properties[0].typeOptions.password, true); + assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.capability}}'); + } else { + assert.deepEqual( + credential.properties.map((property) => property.name), + ['refreshToken', 'accessToken', 'accessExpiresAt'], + ); + assert.equal(credential.properties[0].typeOptions.password, true); + assert.equal(credential.properties[1].type, 'hidden'); + assert.equal(credential.properties[1].typeOptions.password, true); + assert.equal(credential.properties[1].typeOptions.expirable, true); + assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.accessToken}}'); + assert.equal(credential.test.request.url, 'https://api.geliospro.com/api/v1/auth'); + } assertProductSurfaceHasNdcBrand({ displayName: credential.displayName, documentationUrl: credential.documentationUrl, @@ -87,6 +103,8 @@ async function main() { }, className); } + await assertProviderRotatingCredential(credentials.get('NdcProviderRotatingAccessApi')); + for (const icon of ['ndc.svg', 'ndc.dark.svg']) { const iconPath = path.join(packageRoot, 'dist', 'icons', icon); assert.equal(fs.existsSync(iconPath), true, `${icon} was not copied`); @@ -187,6 +205,57 @@ async function main() { console.log('n8n-nodes-ndc package policy: ok'); } +async function assertProviderRotatingCredential(credential) { + let requestCount = 0; + let releaseRequest; + const request = new Promise((resolve) => { releaseRequest = resolve; }); + const helper = { + helpers: { + async httpRequest(options) { + requestCount += 1; + assert.equal(options.method, 'POST'); + assert.equal(options.url, 'https://api.geliospro.com/api/v1/auth/refresh'); + assert.deepEqual(options.body, { refresh_token: 'refresh-old-single-flight' }); + assert.equal(options.headers.Authorization, undefined); + return request; + }, + }, + }; + const credentials = { refreshToken: 'refresh-old-single-flight', accessToken: '' }; + const first = credential.preAuthentication.call(helper, credentials); + const second = credential.preAuthentication.call(helper, credentials); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(requestCount, 1, 'rotating refresh must be single-flight inside the one-service L2 runtime'); + releaseRequest({ + access_token: 'access-new', + refresh_token: 'refresh-new', + token_type: 'Bearer', + expires_in: 3600, + }); + const [firstTokens, secondTokens] = await Promise.all([first, second]); + assert.deepEqual(firstTokens, secondTokens); + assert.equal(firstTokens.accessToken, 'access-new'); + assert.equal(firstTokens.refreshToken, 'refresh-new'); + assert.match(firstTokens.accessExpiresAt, /^\d{4}-\d{2}-\d{2}T/); + + const replay = await credential.preAuthentication.call(helper, credentials); + assert.deepEqual(replay, firstTokens); + assert.equal(requestCount, 1, 'a stale concurrent caller must reuse the recent rotation result'); + + const leakedRefresh = 'refresh-token-must-not-leak'; + await assert.rejects( + credential.preAuthentication.call({ + helpers: { + async httpRequest() { + throw new Error(`remote rejected ${leakedRefresh}`); + }, + }, + }, { refreshToken: leakedRefresh, accessToken: '' }), + (error) => error?.message === 'provider_access_refresh_failed' + && !error.message.includes(leakedRefresh), + ); +} + function assertProductSurfaceHasNdcBrand(surface, className) { const visibleStrings = []; collectVisibleStrings(surface, visibleStrings);