feat(n8n): add rotating provider access credential

This commit is contained in:
Codex 2026-07-19 10:11:56 +03:00
parent 3c5d8f6cef
commit 0611a88971
10 changed files with 408 additions and 64 deletions

View File

@ -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 artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"));
const stageArtifact = resolve( const stageArtifact = resolve(
process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT 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 stageArtifactSha256 = "2699e53c482f2d2bec6d13709a7e9b9a0bb3791ad2018c142e9bc4e7bf371e4a";
const releaseId = "0.1.3-3354149b5245e39a"; const predecessorArtifactSha256 = "f0f687ddcc1bc006db56c75e73c8213e99298c31c2d881cde078e5ddc9600172";
const packageVersion = "0.1.3"; const releaseId = "0.1.4-59dc9f7882721d6a";
const packageSha256 = "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d"; const packageVersion = "0.1.4";
const packageSha256 = "59dc9f7882721d6a5e2ae84ffa6aa8cd9bc5432f5904d24e70e363573ed2757f";
const n8nVersion = "2.3.2"; const n8nVersion = "2.3.2";
const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2"; const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2";
const architecture = "amd64"; const architecture = "amd64";
const generatedAt = "2026-07-17T21:00:00.000Z"; const generatedAt = "2026-07-18T21:00:00.000Z";
const predecessorGitRevision = "96ad46e3c62943f818233481957c62e5088ae35c"; const previouslyIssuedTransitionIds = new Set([
const previouslyIssuedTransitionIds = new Set(["20260715-002", "20260716-003", "20260717-004"]); "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 transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID);
const activationId = `engine-n8n-private-extension-${transitionId}`; const activationId = `engine-n8n-private-extension-${transitionId}`;
const rollbackId = `engine-n8n-private-extension-rollback-${transitionId}`; const rollbackId = `engine-n8n-private-extension-rollback-${transitionId}`;
@ -51,7 +62,9 @@ const expectedCredentialTypes = [
"ndcDataProductWriterApi", "ndcDataProductWriterApi",
"ndcDataProductReaderApi", "ndcDataProductReaderApi",
"ndcFoundryBindingApi", "ndcFoundryBindingApi",
"ndcProviderRotatingAccessApi",
]; ];
const predecessorCredentialTypes = expectedCredentialTypes.slice(0, 3);
const nodeModules = [ const nodeModules = [
["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"], ["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"],
["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"], ["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"],
@ -61,12 +74,14 @@ const credentialModules = [
["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"], ["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"],
["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"], ["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"],
["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"], ["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"],
["dist/credentials/NdcProviderRotatingAccessApi.credentials.js", "NdcProviderRotatingAccessApi"],
]; ];
await mkdir(artifactRoot, { recursive: true }); await mkdir(artifactRoot, { recursive: true });
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${activationId}.tgz`)); await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${activationId}.tgz`));
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${rollbackId}.tgz`)); await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${rollbackId}.tgz`));
assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact"); assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact");
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, "predecessor artifact");
assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8")); assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8"));
const work = await mkdtemp(join(tmpdir(), "nodedc-engine-n8n-sealed-")); 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(privateNodes.map((item) => item.name), expectedNodeTypes, "node types");
assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types"); assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types");
const predecessorDescriptor = JSON.parse(gitFile(descriptorRel)); const predecessorDescriptor = JSON.parse(predecessorArtifactFile(descriptorRel));
const predecessorNodes = JSON.parse(gitFile(nodesCatalogRel)); const predecessorNodes = JSON.parse(predecessorArtifactFile(nodesCatalogRel));
const predecessorCredentials = JSON.parse(gitFile(credentialsCatalogRel)); const predecessorCredentials = JSON.parse(predecessorArtifactFile(credentialsCatalogRel));
const predecessorMeta = JSON.parse(gitFile(metaRel)); const predecessorMeta = JSON.parse(predecessorArtifactFile(metaRel));
assertPredecessorCatalogs( assertPredecessorCatalogs(
predecessorDescriptor, predecessorDescriptor,
predecessorNodes, predecessorNodes,
@ -148,8 +163,18 @@ try {
packageVersion: predecessorDescriptor.packageVersion, packageVersion: predecessorDescriptor.packageVersion,
packageSha256: predecessorDescriptor.packageSha256, packageSha256: predecessorDescriptor.packageSha256,
}; };
const activationDescriptor = descriptor(target, predecessor.releaseId, predecessor.releaseId); const activationDescriptor = descriptor(
const rollbackDescriptor = descriptor(predecessor, releaseId, releaseId); target,
predecessor.releaseId,
predecessor.releaseId,
expectedCredentialTypes,
);
const rollbackDescriptor = descriptor(
predecessor,
releaseId,
releaseId,
predecessorCredentialTypes,
);
const override = composeOverride(target); const override = composeOverride(target);
const rollbackOverride = composeOverride(predecessor); const rollbackOverride = composeOverride(predecessor);
@ -204,7 +229,7 @@ try {
await rm(work, { recursive: true, force: true }); await rm(work, { recursive: true, force: true });
} }
function descriptor(target, expectedCurrent, rollbackBaseline) { function descriptor(target, expectedCurrent, rollbackBaseline, credentialTypes) {
return { return {
schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1", schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1",
action: "activate", action: "activate",
@ -221,7 +246,7 @@ function descriptor(target, expectedCurrent, rollbackBaseline) {
topologyServices: ["n8n"], topologyServices: ["n8n"],
expectedCurrent, expectedCurrent,
expectedNodeTypes, expectedNodeTypes,
expectedCredentialTypes, expectedCredentialTypes: credentialTypes,
rollbackBaseline, rollbackBaseline,
}; };
} }
@ -327,15 +352,15 @@ function assertPackage(value) {
function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) { function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
if (descriptorValue?.action !== "activate" if (descriptorValue?.action !== "activate"
|| descriptorValue?.releaseId !== "0.1.2-05e4b38b14b4a019" || descriptorValue?.releaseId !== "0.1.3-3354149b5245e39a"
|| descriptorValue?.packageVersion !== "0.1.2" || descriptorValue?.packageVersion !== "0.1.3"
|| descriptorValue?.packageSha256 !== "05e4b38b14b4a019ce1f6eee27b9e320094cb3560903bd68074966b3a1267af5") { || descriptorValue?.packageSha256 !== "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d") {
throw new Error("predecessor_descriptor_mismatch"); throw new Error("predecessor_descriptor_mismatch");
} }
if (!Array.isArray(nodes) || nodes.length !== 437 if (!Array.isArray(nodes) || nodes.length !== 437
|| !Array.isArray(credentials) || credentials.length !== 388 || !Array.isArray(credentials) || credentials.length !== 388
|| meta?.n8nVersion !== n8nVersion || 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?.nodeCount !== 437
|| meta?.credentialCount !== 388) { || meta?.credentialCount !== 388) {
throw new Error("predecessor_catalog_mismatch"); throw new Error("predecessor_catalog_mismatch");
@ -346,8 +371,8 @@ function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
"predecessor node types", "predecessor node types",
); );
assertExact( assertExact(
credentials.filter((item) => expectedCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name), credentials.filter((item) => predecessorCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name),
expectedCredentialTypes, predecessorCredentialTypes,
"predecessor credential types", "predecessor credential types",
); );
} }
@ -381,8 +406,17 @@ function assertSha(bytes, expected, label) {
if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`); if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
} }
function gitFile(rel) { function predecessorArtifactFile(rel) {
return run("git", ["show", `${predecessorGitRevision}:${rel}`], engineRoot).stdout; 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) { async function writeJson(path, value) {

View File

@ -12,8 +12,8 @@ const platformRoot = resolve(scriptDir, "../..");
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc"); const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const requireModule = createRequire(import.meta.url); const requireModule = createRequire(import.meta.url);
const expectedPackageVersion = "0.1.3"; const expectedPackageVersion = "0.1.4";
const [patchId = "n8n-nodes-ndc-history-read-20260717-004", ...extra] = process.argv.slice(2); const [patchId = "n8n-nodes-ndc-provider-rotating-access-20260718-006", ...extra] = process.argv.slice(2);
const expectedRuntimeNodes = [ const expectedRuntimeNodes = [
{ {
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
@ -37,11 +37,13 @@ const expectedN8nCredentials = [
"dist/credentials/NdcDataProductWriterApi.credentials.js", "dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js", "dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js", "dist/credentials/NdcFoundryBindingApi.credentials.js",
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
]; ];
const expectedCredentialTypes = [ const expectedCredentialTypes = [
"ndcDataProductWriterApi", "ndcDataProductWriterApi",
"ndcDataProductReaderApi", "ndcDataProductReaderApi",
"ndcFoundryBindingApi", "ndcFoundryBindingApi",
"ndcProviderRotatingAccessApi",
]; ];
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {

View File

@ -208,6 +208,11 @@ ENGINE_N8N_RELEASE_CATALOG_JSON_SHA256 = {
"credentials": "a26824ffc0e15db857c792153de3417febc0dbba4880380d6c8cd544b3c80f4d", "credentials": "a26824ffc0e15db857c792153de3417febc0dbba4880380d6c8cd544b3c80f4d",
"meta": "b8aa60c0d919573626fa0694a1e4ae9ede015325a5d312e3e05d5ac293084aa7", "meta": "b8aa60c0d919573626fa0694a1e4ae9ede015325a5d312e3e05d5ac293084aa7",
}, },
"0.1.4-59dc9f7882721d6a": {
"nodes": "b0a5215699a6e691b8cfc505ab457d5632ef6d83adb3537520d0b60760ba16b2",
"credentials": "af8ada839070a4c24b9b10981a751c2b2db651dced1392333d7a4ef13de4564e",
"meta": "8fadbc594c5d14b1d53ab69b677f00823949410b9a37f4e43ecc321e89d68983",
},
} }
ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256 = "b70d9d8130d498c55de46a5d0758c844f1242b70803457b2291ab3a9de8056f2" ENGINE_N8N_INACTIVE_NODES_CATALOG_JSON_SHA256 = "b70d9d8130d498c55de46a5d0758c844f1242b70803457b2291ab3a9de8056f2"
ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256 = "680e9f52aac791efbd38e3bd99bd51ef5cded9756d867897c6c2755850e87b50" ENGINE_N8N_INACTIVE_CREDENTIALS_CATALOG_JSON_SHA256 = "680e9f52aac791efbd38e3bd99bd51ef5cded9756d867897c6c2755850e87b50"
@ -236,6 +241,7 @@ N8N_PRIVATE_EXTENSION_CREDENTIALS = (
"dist/credentials/NdcDataProductWriterApi.credentials.js", "dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js", "dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js", "dist/credentials/NdcFoundryBindingApi.credentials.js",
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
) )
ENGINE_N8N_NODE_TYPES = ( ENGINE_N8N_NODE_TYPES = (
"n8n-nodes-ndc.ndcDataProductPublish", "n8n-nodes-ndc.ndcDataProductPublish",
@ -246,7 +252,13 @@ ENGINE_N8N_CREDENTIAL_TYPES = (
"ndcDataProductWriterApi", "ndcDataProductWriterApi",
"ndcDataProductReaderApi", "ndcDataProductReaderApi",
"ndcFoundryBindingApi", "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 = ( ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES = (
"nodedc-source/server/credentialPolicies/ndcPrivateNode.js", "nodedc-source/server/credentialPolicies/ndcPrivateNode.js",
"nodedc-source/server/credentialSink", "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 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"): def read_engine_n8n_transition_descriptor(path, label="Engine n8n transition descriptor"):
descriptor = read_strict_json(path, label) descriptor = read_strict_json(path, label)
require_exact_json_keys( 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") die("Engine n8n activation rollback baseline mismatch")
if descriptor.get("expectedNodeTypes") != list(ENGINE_N8N_NODE_TYPES): if descriptor.get("expectedNodeTypes") != list(ENGINE_N8N_NODE_TYPES):
die("Engine n8n activation node type set mismatch") 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") die("Engine n8n activation credential type set mismatch")
else: else:
if descriptor.get("expectedCurrent") != release_id: 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 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_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: if [item.get("name") for item in private_nodes] != expected_node_types:
die("Engine n8n pinned node catalog exact set mismatch") die("Engine n8n pinned node catalog exact set mismatch")
if [item.get("name") for item in private_credentials] != expected_credential_types: 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): if any("usableAsTool" in item for item in private_nodes):
die("Engine n8n pinned node catalog would generate tool variants") die("Engine n8n pinned node catalog would generate tool variants")
if action == "activate": 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") die("Engine n8n activation catalog count mismatch")
if any(not str(item.get("displayName") or "").startswith("NDC ") for item in private_nodes): if any(not str(item.get("displayName") or "").startswith("NDC ") for item in private_nodes):
die("Engine n8n private node visible name mismatch") 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_types = current_catalog["node_types"]
current_credentials = current_catalog["credential_types"] current_credentials = current_catalog["credential_types"]
expected_current_types = list(ENGINE_N8N_NODE_TYPES) if current_state != "verified_inactive" else [] 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: if current_types != expected_current_types:
die("Engine n8n current live private-node set does not match its transition state") die("Engine n8n current live private-node set does not match its transition state")
if current_credentials != expected_current_credentials: 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") die("Engine n8n private extension loader log acceptance failed")
expected_types = list(ENGINE_N8N_NODE_TYPES) if descriptor["action"] == "activate" else [] 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) loader_catalog = engine_n8n_private_loader_catalog(container_id)
if loader_catalog["node_types"] != expected_types: if loader_catalog["node_types"] != expected_types:
die("Engine n8n live package-loader node catalog acceptance failed") die("Engine n8n live package-loader node catalog acceptance failed")

View File

@ -16,17 +16,24 @@ from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
PLATFORM_ROOT = SCRIPT_DIR.parent.parent PLATFORM_ROOT = SCRIPT_DIR.parent.parent
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA" 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" BUILDER_PATH = SCRIPT_DIR / "build-engine-n8n-private-extension-artifact.mjs"
STAGE_ARTIFACT = ( STAGE_ARTIFACT = (
PLATFORM_ROOT PLATFORM_ROOT
/ "infra/deploy-artifacts" / "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" PREDECESSOR_ARTIFACT = (
SEALED_RELEASE_ID = "0.1.3-3354149b5245e39a" PLATFORM_ROOT
SEALED_PACKAGE_SHA256 = "3354149b5245e39a10f6f03a4b43796602d7e57ea1f91dcc6cb6774ead97501d" / "infra/deploy-artifacts"
PREDECESSOR_RELEASE_ID = "0.1.2-05e4b38b14b4a019" / "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 = ( BUILDER_ENGINE_PATHS = (
"nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json", "nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json",
"nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json", "nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json",
@ -45,14 +52,16 @@ EXPECTED_CREDENTIALS = [
"ndcDataProductWriterApi", "ndcDataProductWriterApi",
"ndcDataProductReaderApi", "ndcDataProductReaderApi",
"ndcFoundryBindingApi", "ndcFoundryBindingApi",
"ndcProviderRotatingAccessApi",
] ]
PREDECESSOR_CREDENTIALS = EXPECTED_CREDENTIALS[:3]
PRIVATE_EXTENSION_CANON_FUNCTION_SHA256 = { PRIVATE_EXTENSION_CANON_FUNCTION_SHA256 = {
"expected_engine_n8n_compose_override": "8ee93f3e7c407f5557c711119236449a440d73a7fcac1bbfa50a09e6a13c1cff", "expected_engine_n8n_compose_override": "8ee93f3e7c407f5557c711119236449a440d73a7fcac1bbfa50a09e6a13c1cff",
"engine_n8n_package_loader_probe_script": "2b3b3c96fad6e5e48ebea74426b21bc7eea6b6fe0e786b537da3366d18aedd9b", "engine_n8n_package_loader_probe_script": "2b3b3c96fad6e5e48ebea74426b21bc7eea6b6fe0e786b537da3366d18aedd9b",
"engine_n8n_private_loader_catalog": "a2ae389b4ef215900cf967b863d01056bd2a8eec55554566d0f6005e4e0bcbac", "engine_n8n_private_loader_catalog": "a2ae389b4ef215900cf967b863d01056bd2a8eec55554566d0f6005e4e0bcbac",
"validate_engine_n8n_base_compose_source": "adacc5b20e52684cbc427362ddba5a6d2e13ef2091a89859e2b8f7bbf2e20458", "validate_engine_n8n_base_compose_source": "adacc5b20e52684cbc427362ddba5a6d2e13ef2091a89859e2b8f7bbf2e20458",
"preflight_engine_n8n_transition": "3d574acb081fb752e45cc0c3286d3ae4596dcee83a85e2c3fee81ac65ec796e0", "preflight_engine_n8n_transition": "25bf33b3be0916a9dd82f064a710060eaf6bd7d87b3614ee0065af07ddbb446e",
"accept_engine_n8n_runtime": "1a2614a465161e3833e84aca402817682da1a538bdb2aecc4af66b665497338d", "accept_engine_n8n_runtime": "bc49275adb2d5ab4fd98abd07b9059a197d023e03b40145b6fc910b70b2f738b",
} }
@ -107,6 +116,7 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
env.pop("NODEDC_N8N_TRANSITION_ID", None) env.pop("NODEDC_N8N_TRANSITION_ID", None)
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output) env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT) env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_ARTIFACT)
command = ["node", str(BUILDER_PATH)] command = ["node", str(BUILDER_PATH)]
if index == 0: if index == 0:
command.append(TRANSITION_ID) command.append(TRANSITION_ID)
@ -165,8 +175,8 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
def test_transition_id_rejects_missing_invalid_and_previously_issued_values(self): def test_transition_id_rejects_missing_invalid_and_previously_issued_values(self):
cases = ( cases = (
([], "transition_id_required"), ([], "transition_id_required"),
(["20260717-004"], "transition_id_already_issued"), (["20260718-007"], "transition_id_already_issued"),
(["engine-n8n-private-extension-20260717-005"], "transition_id_invalid"), (["engine-n8n-private-extension-20260718-008"], "transition_id_invalid"),
(["20260230-004"], "transition_id_invalid"), (["20260230-004"], "transition_id_invalid"),
(["20260717-000"], "transition_id_invalid"), (["20260717-000"], "transition_id_invalid"),
([TRANSITION_ID, "unexpected-second-id"], "transition_id_argument_count_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.pop("NODEDC_N8N_TRANSITION_ID", None)
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.artifact(0, "activation").parent) env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.artifact(0, "activation").parent)
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT) env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_ARTIFACT)
result = subprocess.run( result = subprocess.run(
["node", str(BUILDER_PATH), TRANSITION_ID], ["node", str(BUILDER_PATH), TRANSITION_ID],
cwd=PLATFORM_ROOT, 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_nodes], EXPECTED_NODES)
self.assertEqual([item["name"] for item in private_credentials], EXPECTED_CREDENTIALS) self.assertEqual([item["name"] for item in private_credentials], EXPECTED_CREDENTIALS)
self.assertTrue(all("usableAsTool" not in item for item in private_nodes)) 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): def test_rollback_catalog_restores_verified_predecessor_release(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@ -253,8 +264,8 @@ class EngineN8nPrivateExtensionTest(unittest.TestCase):
EXPECTED_NODES, EXPECTED_NODES,
) )
self.assertEqual( self.assertEqual(
[item.get("name") for item in credentials if item.get("name") in EXPECTED_CREDENTIALS], [item.get("name") for item in credentials if item.get("name") in PREDECESSOR_CREDENTIALS],
EXPECTED_CREDENTIALS, PREDECESSOR_CREDENTIALS,
) )
self.assertEqual((len(nodes), len(credentials)), (437, 388)) 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(override, RUNNER.expected_engine_n8n_compose_override(descriptor))
self.assertEqual( self.assertEqual(
hashlib.sha256(override.encode("utf-8")).hexdigest(), hashlib.sha256(override.encode("utf-8")).hexdigest(),
"256142c14ae295bf9be4d74fdc8dcd6e134e4ded8a264f15e66b38e85503b589", "d6d1249e93b5bed5e697715b6b773c7efa4ac234f12eab57d868407e6bb5390e",
) )
self.assertIn("pull_policy: never", override) self.assertIn("pull_policy: never", override)
self.assertIn("N8N_USER_FOLDER: /home/node", 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): def test_sealed_release_exact_set_includes_implicit_directories(self):
release_relative = Path( 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: with tempfile.TemporaryDirectory() as directory:
work = Path(directory) work = Path(directory)

View File

@ -15,9 +15,11 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
PLATFORM_ROOT = SCRIPT_DIR.parent.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" 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 = [ EXPECTED_NODES = [
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
"dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
@ -32,11 +34,13 @@ EXPECTED_CREDENTIALS = [
"dist/credentials/NdcDataProductWriterApi.credentials.js", "dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js", "dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js", "dist/credentials/NdcFoundryBindingApi.credentials.js",
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
] ]
EXPECTED_CREDENTIAL_TYPES = [ EXPECTED_CREDENTIAL_TYPES = [
"ndcDataProductWriterApi", "ndcDataProductWriterApi",
"ndcDataProductReaderApi", "ndcDataProductReaderApi",
"ndcFoundryBindingApi", "ndcFoundryBindingApi",
"ndcProviderRotatingAccessApi",
] ]
@ -106,7 +110,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory)) manifest, entries, _payload = RUNNER.load_artifact(self.artifacts[0], Path(directory))
self.assertEqual(manifest["component"], "n8n-private-extension") self.assertEqual(manifest["component"], "n8n-private-extension")
self.assertEqual(len(entries), 1) 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: with tarfile.open(self.artifacts[0], "r:gz") as archive:
names = archive.getnames() names = archive.getnames()
@ -118,7 +122,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive: with tarfile.open(fileobj=io.BytesIO(package), mode="r:gz") as archive:
package_json_member = archive.getmember("package/package.json") package_json_member = archive.getmember("package/package.json")
package_json = json.loads(archive.extractfile(package_json_member).read()) 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"]["nodes"], EXPECTED_NODES)
self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS) self.assertEqual(package_json["n8n"]["credentials"], EXPECTED_CREDENTIALS)
for node_path in EXPECTED_NODES: for node_path in EXPECTED_NODES:
@ -154,7 +158,7 @@ class N8nPrivateExtensionPolicyTest(unittest.TestCase):
"requiresPreActivationVerification": True, "requiresPreActivationVerification": True,
} }
self.assertEqual(release["schemaVersion"], "nodedc.n8n-private-extension-release/v2") 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(release["activation"]["rollbackBaselinePolicy"], expected_policy)
self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2") self.assertEqual(rollback["schemaVersion"], "nodedc.n8n-private-extension-rollback/v2")
self.assertEqual(rollback["baselinePolicy"], expected_policy) self.assertEqual(rollback["baselinePolicy"], expected_policy)

View File

@ -30,8 +30,16 @@ The defaults are `http://external-data-plane:18106` and
`http://nodedc-module-foundry:3333`. They are provider-neutral Platform service `http://nodedc-module-foundry:3333`. They are provider-neutral Platform service
addresses, not workflow configuration. addresses, not workflow configuration.
The three credential types contain only one password-protected opaque The writer, reader, and Foundry credential types contain only one
capability. Writer, reader, and Foundry capabilities are intentionally distinct. 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 Provider identity, product version, ontology revision, persistence policy, and
tenant scope are materialized by the receiving service from the grant. The node tenant scope are materialized by the receiving service from the grant. The node
never accepts them from a workflow. never accepts them from a workflow.

View File

@ -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<string, Promise<RotatedTokens>>();
const recentRotations = new Map<string, RecentRotation>();
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<ICredentialDataDecryptedObject> {
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<RotatedTokens> {
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<string, unknown> {
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 },
});
}

View File

@ -1,12 +1,12 @@
{ {
"name": "n8n-nodes-ndc", "name": "n8n-nodes-ndc",
"version": "0.1.3", "version": "0.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "n8n-nodes-ndc", "name": "n8n-nodes-ndc",
"version": "0.1.3", "version": "0.1.4",
"license": "UNLICENSED", "license": "UNLICENSED",
"devDependencies": { "devDependencies": {
"@n8n/node-cli": "0.39.3", "@n8n/node-cli": "0.39.3",

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n-nodes-ndc", "name": "n8n-nodes-ndc",
"version": "0.1.3", "version": "0.1.4",
"description": "Private NODE.DC nodes for scoped data products and Foundry bindings.", "description": "Private NODE.DC nodes for scoped data products and Foundry bindings.",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
@ -24,7 +24,8 @@
"credentials": [ "credentials": [
"dist/credentials/NdcDataProductWriterApi.credentials.js", "dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js", "dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js" "dist/credentials/NdcFoundryBindingApi.credentials.js",
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js"
], ],
"nodes": [ "nodes": [
"dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",

View File

@ -12,9 +12,10 @@ const nodeSpecs = [
['NdcFoundryBinding', 'ndcFoundryBinding', 'NDC Foundry Binding'], ['NdcFoundryBinding', 'ndcFoundryBinding', 'NDC Foundry Binding'],
]; ];
const credentialSpecs = [ const credentialSpecs = [
['NdcDataProductWriterApi', 'ndcDataProductWriterApi'], ['NdcDataProductWriterApi', 'ndcDataProductWriterApi', 'opaque'],
['NdcDataProductReaderApi', 'ndcDataProductReaderApi'], ['NdcDataProductReaderApi', 'ndcDataProductReaderApi', 'opaque'],
['NdcFoundryBindingApi', 'ndcFoundryBindingApi'], ['NdcFoundryBindingApi', 'ndcFoundryBindingApi', 'opaque'],
['NdcProviderRotatingAccessApi', 'ndcProviderRotatingAccessApi', 'rotating'],
]; ];
const forbiddenNodeParameter = /(url|provider|tenant|connection|token|secret|password|credential)/i; const forbiddenNodeParameter = /(url|provider|tenant|connection|token|secret|password|credential)/i;
@ -67,19 +68,34 @@ async function main() {
assertProductSurfaceHasNdcBrand(node.description, className); 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 modulePath = path.join(packageRoot, 'dist', 'credentials', `${className}.credentials.js`);
const CredentialClass = require(modulePath)[className]; const CredentialClass = require(modulePath)[className];
const credential = new CredentialClass(); const credential = new CredentialClass();
credentials.set(className, credential);
assert.match(credential.displayName, /^NDC /); assert.match(credential.displayName, /^NDC /);
assert.equal(credential.name, internalName); assert.equal(credential.name, internalName);
assert.deepEqual(credential.icon, { assert.deepEqual(credential.icon, {
light: 'file:../icons/ndc.svg', light: 'file:../icons/ndc.svg',
dark: 'file:../icons/ndc.dark.svg', dark: 'file:../icons/ndc.dark.svg',
}); });
assert.deepEqual(credential.properties.map((property) => property.name), ['capability']); if (kind === 'opaque') {
assert.equal(credential.properties[0].typeOptions.password, true); assert.deepEqual(credential.properties.map((property) => property.name), ['capability']);
assert.equal(credential.authenticate.properties.headers.Authorization, '=Bearer {{$credentials.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({ assertProductSurfaceHasNdcBrand({
displayName: credential.displayName, displayName: credential.displayName,
documentationUrl: credential.documentationUrl, documentationUrl: credential.documentationUrl,
@ -87,6 +103,8 @@ async function main() {
}, className); }, className);
} }
await assertProviderRotatingCredential(credentials.get('NdcProviderRotatingAccessApi'));
for (const icon of ['ndc.svg', 'ndc.dark.svg']) { for (const icon of ['ndc.svg', 'ndc.dark.svg']) {
const iconPath = path.join(packageRoot, 'dist', 'icons', icon); const iconPath = path.join(packageRoot, 'dist', 'icons', icon);
assert.equal(fs.existsSync(iconPath), true, `${icon} was not copied`); 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'); 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) { function assertProductSurfaceHasNdcBrand(surface, className) {
const visibleStrings = []; const visibleStrings = [];
collectVisibleStrings(surface, visibleStrings); collectVisibleStrings(surface, visibleStrings);