From 90652fe4038999c22593e65815771049d886b01b Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:45:56 +0000 Subject: [PATCH 1/8] update(versions): update pins with a declared source (web-app-n8n) --- roles/web-app-n8n/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-n8n/meta/services.yml b/roles/web-app-n8n/meta/services.yml index 156c60abc5..586642de01 100644 --- a/roles/web-app-n8n/meta/services.yml +++ b/roles/web-app-n8n/meta/services.yml @@ -67,7 +67,7 @@ n8n: backup: no_stop_required: false image: docker.n8n.io/n8nio/n8n - version: "2.42.3" + version: "2.43.2" update: type: registry_tags image: docker.n8n.io/n8nio/n8n From e4078c05fda67f91db547ca5c40fb11fc04dbfd2 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 8 Oct 2026 19:38:44 +0200 Subject: [PATCH 2/8] fix(web-app-n8n): provision the MCP surfaces against the n8n 2 API n8n 2 pages GET /rest/api-keys as {data: {items, counts, totals, owners}}, ten keys at a time, and matches `label` as a substring, so walking the body raised "'str' object has no attribute 'get'". It also enforces API key scopes, so activating the MCP server workflow needs `workflow:activate`. Both scripts now read every list and create response through one normalisation that unwraps the page, look the managed key up by label, and name an unexpected shape by its keys only. The normalisation is taken over from a392e2dd49 on feature/store-i18n. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../files/python/provision/client.py | 71 ++++++++++++++--- .../web-app-n8n/files/python/provision/mcp.py | 76 ++++++++++++++++--- .../files/provision/test_client.py | 30 +++++++- .../web-app-n8n/files/provision/test_mcp.py | 54 ++++++++++++- 4 files changed, 207 insertions(+), 24 deletions(-) diff --git a/roles/web-app-n8n/files/python/provision/client.py b/roles/web-app-n8n/files/python/provision/client.py index e1c6241d3d..1eec995767 100644 --- a/roles/web-app-n8n/files/python/provision/client.py +++ b/roles/web-app-n8n/files/python/provision/client.py @@ -25,6 +25,7 @@ import os import re import sys import urllib.error +import urllib.parse import urllib.request BASE = os.environ.get("N8N_BASE", "").rstrip("/") @@ -90,6 +91,52 @@ def call(path, method="GET", payload=None, api_key=None): return error.code, error.read().decode(errors="replace") +def shape(value): + """Describe a value by its type and, for a mapping, its keys, never its content. + + Args: + value: decoded response fragment. + """ + if isinstance(value, dict): + return f"a mapping with keys {sorted(map(str, value))}" + if isinstance(value, list): + return f"a list of {sorted({type(item).__name__ for item in value})}" + return f"a {type(value).__name__}" + + +def records(body, what): + """Return the records of a list response. + + Args: + body: decoded body: ``{"data": [...]}``, a bare list, or the paged + ``{"data": {"items": [...], ...}}`` of n8n 2. + what: noun naming the collection, for the failure message. + """ + listed = body.get("data", body) if isinstance(body, dict) else body + if isinstance(listed, dict) and "items" in listed: + listed = listed["items"] + if listed is None: + return [] + if not isinstance(listed, list) or not all( + isinstance(item, dict) for item in listed + ): + sys.exit(f"FAILED listing {what}: expected records, got {shape(listed)}") + return listed + + +def record(body, what): + """Return the single record of a create response. + + Args: + body: decoded body, either ``{"data": {...}}`` or a bare mapping. + what: noun naming the record, for the failure message. + """ + created = body.get("data", body) if isinstance(body, dict) else body + if not isinstance(created, dict): + sys.exit(f"FAILED creating {what}: expected a record, got {shape(created)}") + return created + + def login(): status, body = call( f"{REST}/login", @@ -106,14 +153,18 @@ def api_key(): n8n returns the usable secret only once, as ``rawApiKey``, and a listed key is redacted, so the key cannot be carried between runs. It is minted here and revoked before the run ends; an entry left behind under the managed name - belongs to a run that died and is deleted rather than guessed at. + belongs to a run that died and is deleted rather than guessed at. n8n pages + the listing at ten keys and matches ``label`` as a substring, so the query + narrows to the managed name and the exact match happens here. """ - status, body = call(f"{REST}/api-keys") + query = urllib.parse.urlencode({"label": KEY_NAME}) + status, body = call(f"{REST}/api-keys?{query}") if status != 200: sys.exit(f"FAILED listing api keys: {status} {body}") - existing = (body or {}).get("data") if isinstance(body, dict) else body - matches = [key for key in existing or [] if key.get("label") == KEY_NAME] + matches = [ + key for key in records(body, "api keys") if key.get("label") == KEY_NAME + ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}") for key in matches: @@ -136,7 +187,7 @@ def api_key(): ) if status not in (200, 201): sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}") - created = (body or {}).get("data") if isinstance(body, dict) else body + created = record(body, f"api key {KEY_NAME}") return str(created["rawApiKey"]), str(created["id"]) @@ -174,8 +225,7 @@ def listed_credentials(): status, body = call(f"{REST}/credentials") if status != 200: sys.exit(f"FAILED listing credentials: {status} {body}") - existing = (body or {}).get("data") if isinstance(body, dict) else body - return list(existing or []) + return records(body, "credentials") def credential(provider): @@ -198,7 +248,7 @@ def credential(provider): status, body = call(f"{REST}/credentials", method="POST", payload=payload) if status not in (200, 201): sys.exit(f"FAILED creating {name}: {status} {body}") - created = (body or {}).get("data") if isinstance(body, dict) else body + created = record(body, name) return {"id": str(created["id"]), "name": name}, True status, body = call( @@ -317,8 +367,9 @@ def managed_workflow(key): status, body = call(f"{PUBLIC}/workflows", api_key=key) if status != 200: sys.exit(f"FAILED listing workflows: {status} {body}") - existing = (body or {}).get("data") if isinstance(body, dict) else body - matches = [flow for flow in existing or [] if flow.get("name") == WORKFLOW_NAME] + matches = [ + flow for flow in records(body, "workflows") if flow.get("name") == WORKFLOW_NAME + ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}") return matches[0] if matches else None diff --git a/roles/web-app-n8n/files/python/provision/mcp.py b/roles/web-app-n8n/files/python/provision/mcp.py index e54f5f5d39..007927a301 100644 --- a/roles/web-app-n8n/files/python/provision/mcp.py +++ b/roles/web-app-n8n/files/python/provision/mcp.py @@ -19,6 +19,7 @@ import json import os import sys import urllib.error +import urllib.parse import urllib.request BASE = os.environ.get("N8N_BASE", "").rstrip("/") @@ -95,6 +96,52 @@ def call(path, method="GET", payload=None, api_key=None): return error.code, error.read().decode(errors="replace") +def shape(value): + """Describe a value by its type and, for a mapping, its keys, never its content. + + Args: + value: decoded response fragment. + """ + if isinstance(value, dict): + return f"a mapping with keys {sorted(map(str, value))}" + if isinstance(value, list): + return f"a list of {sorted({type(item).__name__ for item in value})}" + return f"a {type(value).__name__}" + + +def records(body, what): + """Return the records of a list response. + + Args: + body: decoded body: ``{"data": [...]}``, a bare list, or the paged + ``{"data": {"items": [...], ...}}`` of n8n 2. + what: noun naming the collection, for the failure message. + """ + listed = body.get("data", body) if isinstance(body, dict) else body + if isinstance(listed, dict) and "items" in listed: + listed = listed["items"] + if listed is None: + return [] + if not isinstance(listed, list) or not all( + isinstance(item, dict) for item in listed + ): + sys.exit(f"FAILED listing {what}: expected records, got {shape(listed)}") + return listed + + +def record(body, what): + """Return the single record of a create response. + + Args: + body: decoded body, either ``{"data": {...}}`` or a bare mapping. + what: noun naming the record, for the failure message. + """ + created = body.get("data", body) if isinstance(body, dict) else body + if not isinstance(created, dict): + sys.exit(f"FAILED creating {what}: expected a record, got {shape(created)}") + return created + + def login(): status, body = call( f"{REST}/login", @@ -111,14 +158,18 @@ def api_key(): n8n returns the usable secret only once, as ``rawApiKey``, and a listed key is redacted, so the key cannot be carried between runs. It is minted here and revoked before the run ends; an entry left behind under the managed name - belongs to a run that died and is deleted rather than guessed at. + belongs to a run that died and is deleted rather than guessed at. n8n pages + the listing at ten keys and matches ``label`` as a substring, so the query + narrows to the managed name and the exact match happens here. """ - status, body = call(f"{REST}/api-keys") + query = urllib.parse.urlencode({"label": KEY_NAME}) + status, body = call(f"{REST}/api-keys?{query}") if status != 200: sys.exit(f"FAILED listing api keys: {status} {body}") - existing = (body or {}).get("data") if isinstance(body, dict) else body - matches = [key for key in existing or [] if key.get("label") == KEY_NAME] + matches = [ + key for key in records(body, "api keys") if key.get("label") == KEY_NAME + ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}") for key in matches: @@ -134,13 +185,14 @@ def api_key(): "workflow:read", "workflow:update", "workflow:list", + "workflow:activate", ], "expiresAt": None, }, ) if status not in (200, 201): sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}") - created = (body or {}).get("data") if isinstance(body, dict) else body + created = record(body, f"api key {KEY_NAME}") return str(created["rawApiKey"]), str(created["id"]) @@ -166,8 +218,11 @@ def bearer_credential(): if status != 200: sys.exit(f"FAILED listing credentials: {status} {body}") - existing = (body or {}).get("data") if isinstance(body, dict) else body - matches = [item for item in existing or [] if item.get("name") == CREDENTIAL_NAME] + matches = [ + item + for item in records(body, "credentials") + if item.get("name") == CREDENTIAL_NAME + ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} credentials named {CREDENTIAL_NAME}") @@ -180,7 +235,7 @@ def bearer_credential(): status, body = call(f"{REST}/credentials", method="POST", payload=payload) if status not in (200, 201): sys.exit(f"FAILED creating {CREDENTIAL_NAME}: {status} {body}") - created = (body or {}).get("data") if isinstance(body, dict) else body + created = record(body, CREDENTIAL_NAME) return {"id": str(created["id"]), "name": CREDENTIAL_NAME}, True status, body = call( @@ -268,8 +323,9 @@ def upsert_workflow(key, credential): if status != 200: sys.exit(f"FAILED listing workflows: {status} {body}") - existing = (body or {}).get("data") if isinstance(body, dict) else body - matches = [flow for flow in existing or [] if flow.get("name") == WORKFLOW_NAME] + matches = [ + flow for flow in records(body, "workflows") if flow.get("name") == WORKFLOW_NAME + ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}") diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py index d698b892dc..261a6b5080 100644 --- a/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py @@ -8,10 +8,12 @@ import io import json import unittest from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit from . import PROJECT_ROOT SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/client.py" +PAGE_SIZE = 10 PROVIDER = { "id": "web-app-gitea", @@ -78,8 +80,10 @@ class FakeApi: self.deleted_credentials.append(dead) self.credentials = [c for c in self.credentials if c["id"] != dead] return 200, {"success": True} - if path == "/rest/api-keys" and method == "GET": - return 200, {"data": [dict(key) for key in self.keys]} + if path.startswith("/rest/api-keys?") and method == "GET": + label = parse_qs(urlsplit(path).query)["label"][0].lower() + items = [dict(key) for key in self.keys if label in key["label"].lower()] + return 200, {"data": {"items": items[:PAGE_SIZE]}} if path == "/rest/api-keys" and method == "POST": self.keys = [{"id": "k1", "label": payload["label"]}] return 200, {"data": {"id": "k1", "rawApiKey": "n8n-raw-key"}} @@ -179,6 +183,28 @@ class TestProvisionClient(unittest.TestCase): module.main() self.assertEqual(["k1"], api.deleted_keys) + def test_a_stale_managed_key_behind_a_full_page_is_still_replaced(self) -> None: + module = load_script() + humans = [{"id": f"h{n}", "label": f"human {n}"} for n in range(PAGE_SIZE)] + api = FakeApi(keys=[*humans, {"id": "old", "label": "infinito:mcp"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_an_unknown_listing_shape_names_its_keys_but_never_a_value(self) -> None: + module = load_script() + api = FakeApi() + + def odd(path, method="GET", payload=None, api_key=None): + if path == "/rest/credentials" and method == "GET": + return 200, {"data": {"token": "t0ps3cret"}} + return api(path, method, payload, api_key) + + with patch.object(module, "call", odd), self.assertRaises(SystemExit) as exit_: + module.main() + self.assertIn("['token']", str(exit_.exception)) + self.assertNotIn("t0ps3cret", str(exit_.exception)) + def test_a_rotated_provider_token_replaces_the_stored_one(self) -> None: module = load_script() api = FakeApi(credentials=[dict(REFERENCE)]) diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py index 81dcecf03a..ae5365a107 100644 --- a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py @@ -8,10 +8,12 @@ import io import json import unittest from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit from . import PROJECT_ROOT SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/mcp.py" +PAGE_SIZE = 10 ENV = { "N8N_BASE": "http://n8n:5678", @@ -51,6 +53,7 @@ class FakeApi: self.deleted_keys = [] self.logins = 0 self.minted = 0 + self.scopes = set() def __call__(self, path, method="GET", payload=None, api_key=None): if path == "/rest/login": @@ -72,10 +75,13 @@ class FakeApi: self.deleted_credentials.append(dead) self.credentials = [c for c in self.credentials if c["id"] != dead] return 200, {"success": True} - if path == "/rest/api-keys" and method == "GET": - return 200, {"data": [dict(key) for key in self.keys]} + if path.startswith("/rest/api-keys?") and method == "GET": + label = parse_qs(urlsplit(path).query)["label"][0].lower() + items = [dict(key) for key in self.keys if label in key["label"].lower()] + return 200, {"data": {"items": items[:PAGE_SIZE]}} if path == "/rest/api-keys" and method == "POST": self.keys = [{"id": "k1", "label": payload["label"]}] + self.scopes = set(payload["scopes"]) return 200, {"data": {"id": "k1", "rawApiKey": "n8n-raw-key"}} if path.startswith("/rest/api-keys/") and method == "DELETE": self.deleted_keys.append(path.rsplit("/", 1)[-1]) @@ -89,6 +95,8 @@ class FakeApi: self.updated_workflows.append(payload) return 200, payload if path.endswith("/activate") and method == "POST": + if "workflow:activate" not in self.scopes: + return 403, '{"message":"Forbidden"}' self.activated_workflows.append(path.split("/")[-2]) return 200, {"active": True} if path.startswith("/api/v1/workflows/") and method == "DELETE": @@ -184,6 +192,48 @@ class TestProvisionMcp(unittest.TestCase): module.main() self.assertEqual(["old", "k1"], api.deleted_keys) + def test_a_stale_managed_key_behind_a_full_page_is_still_replaced(self) -> None: + module = load_script() + humans = [{"id": f"h{n}", "label": f"human {n}"} for n in range(PAGE_SIZE)] + api = FakeApi(keys=[*humans, {"id": "old", "label": "infinito:mcp"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_an_unknown_listing_shape_names_its_keys_but_never_a_value(self) -> None: + module = load_script() + api = FakeApi() + + def odd(path, method="GET", payload=None, api_key=None): + if path.startswith("/rest/api-keys?"): + return 200, {"data": {"secret": "t0ps3cret"}} + return api(path, method, payload, api_key) + + with patch.object(module, "call", odd), self.assertRaises(SystemExit) as exit_: + module.main() + self.assertIn("['secret']", str(exit_.exception)) + self.assertNotIn("t0ps3cret", str(exit_.exception)) + + def test_an_unpaged_listing_of_n8n_1_still_finds_the_stale_key(self) -> None: + module = load_script() + api = FakeApi() + + def unpaged(path, method="GET", payload=None, api_key=None): + if path.startswith("/rest/api-keys?"): + return 200, {"data": [{"id": "old", "label": "infinito:mcp"}]} + return api(path, method, payload, api_key) + + with patch.object(module, "call", unpaged): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_a_key_that_only_contains_the_managed_name_is_left_alone(self) -> None: + module = load_script() + api = FakeApi(keys=[{"id": "near", "label": "infinito:mcp-legacy"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["k1"], api.deleted_keys) + def test_the_run_leaves_no_api_key_behind(self) -> None: module = load_script() api = FakeApi() From 75f024e71ae126316c6a02c579685178574c7aaa Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 8 Oct 2026 22:00:15 +0200 Subject: [PATCH 3/8] fix(web-app-n8n): page through API keys and workflows when provisioning n8n 2 returns ten API keys and a hundred workflows per page by default. A stale managed key behind the first page survived, and a managed workflow behind it was created a second time. Both scripts now follow every page, narrowed by label or name, and keep the exact match in the script. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../files/python/provision/client.py | 62 +++++++++++++---- .../web-app-n8n/files/python/provision/mcp.py | 63 ++++++++++++++---- .../files/provision/test_client.py | 66 +++++++++++++++++-- .../web-app-n8n/files/provision/test_mcp.py | 66 +++++++++++++++++-- 4 files changed, 222 insertions(+), 35 deletions(-) diff --git a/roles/web-app-n8n/files/python/provision/client.py b/roles/web-app-n8n/files/python/provision/client.py index 1eec995767..b3c673b289 100644 --- a/roles/web-app-n8n/files/python/provision/client.py +++ b/roles/web-app-n8n/files/python/provision/client.py @@ -39,6 +39,7 @@ PROVIDERS = json.loads(os.environ.get("N8N_MCP_PROVIDERS", "[]")) REST = "/rest" PUBLIC = "/api/v1" +PAGE = 250 CREDENTIAL_TYPE = "httpBearerAuth" BEARER_AUTH = "bearerAuth" CLIENT_PREFIX = "infinito:mcp-client:" @@ -137,6 +138,48 @@ def record(body, what): return created +def api_keys_labelled(label): + """Return every API key whose label n8n matches against ``label``, all pages. + + Args: + label: text n8n looks for inside each label. + """ + found = [] + while True: + query = urllib.parse.urlencode( + {"label": label, "take": PAGE, "skip": len(found)} + ) + status, body = call(f"{REST}/api-keys?{query}") + if status != 200: + sys.exit(f"FAILED listing api keys: {status} {body}") + page = records(body, "api keys") + found.extend(page) + if len(page) < PAGE: + return found + + +def workflows_named(key, name): + """Return every workflow n8n matches against ``name``, following ``nextCursor``. + + Args: + key: the public-API key. + name: workflow name n8n filters on. + """ + found = [] + query = {"name": name, "limit": PAGE} + while True: + status, body = call( + f"{PUBLIC}/workflows?{urllib.parse.urlencode(query)}", api_key=key + ) + if status != 200: + sys.exit(f"FAILED listing workflows: {status} {body}") + found.extend(records(body, "workflows")) + cursor = body.get("nextCursor") if isinstance(body, dict) else None + if not cursor: + return found + query = {"name": name, "cursor": cursor} + + def login(): status, body = call( f"{REST}/login", @@ -153,17 +196,11 @@ def api_key(): n8n returns the usable secret only once, as ``rawApiKey``, and a listed key is redacted, so the key cannot be carried between runs. It is minted here and revoked before the run ends; an entry left behind under the managed name - belongs to a run that died and is deleted rather than guessed at. n8n pages - the listing at ten keys and matches ``label`` as a substring, so the query - narrows to the managed name and the exact match happens here. + belongs to a run that died and is deleted rather than guessed at. n8n matches + ``label`` as a substring, so the exact match happens here. """ - query = urllib.parse.urlencode({"label": KEY_NAME}) - status, body = call(f"{REST}/api-keys?{query}") - if status != 200: - sys.exit(f"FAILED listing api keys: {status} {body}") - matches = [ - key for key in records(body, "api keys") if key.get("label") == KEY_NAME + key for key in api_keys_labelled(KEY_NAME) if key.get("label") == KEY_NAME ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}") @@ -364,11 +401,10 @@ def managed_workflow(key): Args: key: the public-API key. """ - status, body = call(f"{PUBLIC}/workflows", api_key=key) - if status != 200: - sys.exit(f"FAILED listing workflows: {status} {body}") matches = [ - flow for flow in records(body, "workflows") if flow.get("name") == WORKFLOW_NAME + flow + for flow in workflows_named(key, WORKFLOW_NAME) + if flow.get("name") == WORKFLOW_NAME ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}") diff --git a/roles/web-app-n8n/files/python/provision/mcp.py b/roles/web-app-n8n/files/python/provision/mcp.py index 007927a301..8e9745b144 100644 --- a/roles/web-app-n8n/files/python/provision/mcp.py +++ b/roles/web-app-n8n/files/python/provision/mcp.py @@ -33,6 +33,7 @@ CREDENTIAL_NAME = os.environ.get("N8N_MCP_CREDENTIAL", "") REST = "/rest" PUBLIC = "/api/v1" +PAGE = 250 TRIGGER_TYPE = "@n8n/n8n-nodes-langchain.mcpTrigger" TRIGGER_VERSION = 2 TRIGGER_NAME = "Infinito MCP Trigger" @@ -142,6 +143,48 @@ def record(body, what): return created +def api_keys_labelled(label): + """Return every API key whose label n8n matches against ``label``, all pages. + + Args: + label: text n8n looks for inside each label. + """ + found = [] + while True: + query = urllib.parse.urlencode( + {"label": label, "take": PAGE, "skip": len(found)} + ) + status, body = call(f"{REST}/api-keys?{query}") + if status != 200: + sys.exit(f"FAILED listing api keys: {status} {body}") + page = records(body, "api keys") + found.extend(page) + if len(page) < PAGE: + return found + + +def workflows_named(key, name): + """Return every workflow n8n matches against ``name``, following ``nextCursor``. + + Args: + key: the public-API key. + name: workflow name n8n filters on. + """ + found = [] + query = {"name": name, "limit": PAGE} + while True: + status, body = call( + f"{PUBLIC}/workflows?{urllib.parse.urlencode(query)}", api_key=key + ) + if status != 200: + sys.exit(f"FAILED listing workflows: {status} {body}") + found.extend(records(body, "workflows")) + cursor = body.get("nextCursor") if isinstance(body, dict) else None + if not cursor: + return found + query = {"name": name, "cursor": cursor} + + def login(): status, body = call( f"{REST}/login", @@ -158,17 +201,11 @@ def api_key(): n8n returns the usable secret only once, as ``rawApiKey``, and a listed key is redacted, so the key cannot be carried between runs. It is minted here and revoked before the run ends; an entry left behind under the managed name - belongs to a run that died and is deleted rather than guessed at. n8n pages - the listing at ten keys and matches ``label`` as a substring, so the query - narrows to the managed name and the exact match happens here. + belongs to a run that died and is deleted rather than guessed at. n8n matches + ``label`` as a substring, so the exact match happens here. """ - query = urllib.parse.urlencode({"label": KEY_NAME}) - status, body = call(f"{REST}/api-keys?{query}") - if status != 200: - sys.exit(f"FAILED listing api keys: {status} {body}") - matches = [ - key for key in records(body, "api keys") if key.get("label") == KEY_NAME + key for key in api_keys_labelled(KEY_NAME) if key.get("label") == KEY_NAME ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}") @@ -319,12 +356,10 @@ def upsert_workflow(key, credential): key: the public-API key. credential: ``{"id", "name"}`` reference to the managed bearer credential. """ - status, body = call(f"{PUBLIC}/workflows", api_key=key) - if status != 200: - sys.exit(f"FAILED listing workflows: {status} {body}") - matches = [ - flow for flow in records(body, "workflows") if flow.get("name") == WORKFLOW_NAME + flow + for flow in workflows_named(key, WORKFLOW_NAME) + if flow.get("name") == WORKFLOW_NAME ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}") diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py index 261a6b5080..875a4abe4c 100644 --- a/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py @@ -14,6 +14,20 @@ from . import PROJECT_ROOT SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/client.py" PAGE_SIZE = 10 +WORKFLOW_PAGE_SIZE = 100 +MAX_PAGE_SIZE = 250 + + +def paged(items, offset, limit): + """Return one page the way n8n slices a listing. + + Args: + items: every record the listing holds. + offset: index of the first record on the page. + limit: requested page size, capped like n8n caps it. + """ + return items[offset : offset + min(limit, MAX_PAGE_SIZE)] + PROVIDER = { "id": "web-app-gitea", @@ -81,17 +95,33 @@ class FakeApi: self.credentials = [c for c in self.credentials if c["id"] != dead] return 200, {"success": True} if path.startswith("/rest/api-keys?") and method == "GET": - label = parse_qs(urlsplit(path).query)["label"][0].lower() + query = parse_qs(urlsplit(path).query) + label = query["label"][0].lower() items = [dict(key) for key in self.keys if label in key["label"].lower()] - return 200, {"data": {"items": items[:PAGE_SIZE]}} + skip = int(query.get("skip", ["0"])[0]) + take = int(query.get("take", [str(PAGE_SIZE)])[0]) + return 200, {"data": {"items": paged(items, skip, take)}} if path == "/rest/api-keys" and method == "POST": self.keys = [{"id": "k1", "label": payload["label"]}] return 200, {"data": {"id": "k1", "rawApiKey": "n8n-raw-key"}} if path.startswith("/rest/api-keys/") and method == "DELETE": self.deleted_keys.append(path.rsplit("/", 1)[-1]) return 200, {"success": True} - if path == "/api/v1/workflows" and method == "GET": - return 200, {"data": [dict(flow) for flow in self.workflows]} + if path.split("?")[0] == "/api/v1/workflows" and method == "GET": + query = parse_qs(urlsplit(path).query) + name = query.get("name", [""])[0] + hits = [dict(flow) for flow in self.workflows if name in flow["name"]] + cursor = query.get("cursor", [f"0:{WORKFLOW_PAGE_SIZE}"])[0].split(":") + offset, limit = (int(cursor[0]), int(cursor[1])) + if "limit" in query: + limit = int(query["limit"][0]) + page = paged(hits, offset, limit) + following = offset + len(page) + more = following < len(hits) + return 200, { + "data": page, + "nextCursor": f"{following}:{limit}" if more else None, + } if path == "/api/v1/workflows" and method == "POST": self.created_workflows.append(payload) return 200, dict(payload, id="w1", active=False) @@ -191,6 +221,34 @@ class TestProvisionClient(unittest.TestCase): module.main() self.assertEqual(["old", "k1"], api.deleted_keys) + def test_a_stale_managed_key_behind_a_full_filtered_page_is_still_replaced( + self, + ) -> None: + module = load_script() + near = [ + {"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"} + for n in range(MAX_PAGE_SIZE) + ] + api = FakeApi(keys=[*near, {"id": "old", "label": "infinito:mcp"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_a_managed_workflow_on_a_later_page_is_updated_not_duplicated( + self, + ) -> None: + module = load_script() + near = [ + {"id": f"n{n}", "name": f"infinito:mcp-agent copy {n}"} + for n in range(MAX_PAGE_SIZE) + ] + managed = {"id": "w9", "name": "infinito:mcp-agent"} + api = FakeApi(workflows=[*near, managed], credentials=[dict(REFERENCE)]) + with patch.object(module, "call", api): + module.main() + self.assertEqual([], api.created_workflows) + self.assertEqual(1, len(api.updated_workflows)) + def test_an_unknown_listing_shape_names_its_keys_but_never_a_value(self) -> None: module = load_script() api = FakeApi() diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py index ae5365a107..2cd90c0d7c 100644 --- a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py @@ -14,6 +14,20 @@ from . import PROJECT_ROOT SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/mcp.py" PAGE_SIZE = 10 +WORKFLOW_PAGE_SIZE = 100 +MAX_PAGE_SIZE = 250 + + +def paged(items, offset, limit): + """Return one page the way n8n slices a listing. + + Args: + items: every record the listing holds. + offset: index of the first record on the page. + limit: requested page size, capped like n8n caps it. + """ + return items[offset : offset + min(limit, MAX_PAGE_SIZE)] + ENV = { "N8N_BASE": "http://n8n:5678", @@ -76,9 +90,12 @@ class FakeApi: self.credentials = [c for c in self.credentials if c["id"] != dead] return 200, {"success": True} if path.startswith("/rest/api-keys?") and method == "GET": - label = parse_qs(urlsplit(path).query)["label"][0].lower() + query = parse_qs(urlsplit(path).query) + label = query["label"][0].lower() items = [dict(key) for key in self.keys if label in key["label"].lower()] - return 200, {"data": {"items": items[:PAGE_SIZE]}} + skip = int(query.get("skip", ["0"])[0]) + take = int(query.get("take", [str(PAGE_SIZE)])[0]) + return 200, {"data": {"items": paged(items, skip, take)}} if path == "/rest/api-keys" and method == "POST": self.keys = [{"id": "k1", "label": payload["label"]}] self.scopes = set(payload["scopes"]) @@ -86,8 +103,21 @@ class FakeApi: if path.startswith("/rest/api-keys/") and method == "DELETE": self.deleted_keys.append(path.rsplit("/", 1)[-1]) return 200, {"success": True} - if path == "/api/v1/workflows" and method == "GET": - return 200, {"data": [dict(flow) for flow in self.workflows]} + if path.split("?")[0] == "/api/v1/workflows" and method == "GET": + query = parse_qs(urlsplit(path).query) + name = query.get("name", [""])[0] + hits = [dict(flow) for flow in self.workflows if name in flow["name"]] + cursor = query.get("cursor", [f"0:{WORKFLOW_PAGE_SIZE}"])[0].split(":") + offset, limit = (int(cursor[0]), int(cursor[1])) + if "limit" in query: + limit = int(query["limit"][0]) + page = paged(hits, offset, limit) + following = offset + len(page) + more = following < len(hits) + return 200, { + "data": page, + "nextCursor": f"{following}:{limit}" if more else None, + } if path == "/api/v1/workflows" and method == "POST": self.created_workflows.append(payload) return 200, dict(payload, id="w1", active=False) @@ -227,6 +257,34 @@ class TestProvisionMcp(unittest.TestCase): module.main() self.assertEqual(["old", "k1"], api.deleted_keys) + def test_a_stale_managed_key_behind_a_full_filtered_page_is_still_replaced( + self, + ) -> None: + module = load_script() + near = [ + {"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"} + for n in range(MAX_PAGE_SIZE) + ] + api = FakeApi(keys=[*near, {"id": "old", "label": "infinito:mcp"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_a_managed_workflow_on_a_later_page_is_updated_not_duplicated( + self, + ) -> None: + module = load_script() + near = [ + {"id": f"n{n}", "name": f"infinito:mcp-server copy {n}"} + for n in range(MAX_PAGE_SIZE) + ] + managed = {"id": "w9", "name": "infinito:mcp-server", "active": True} + api = FakeApi(workflows=[*near, managed]) + with patch.object(module, "call", api): + module.main() + self.assertEqual([], api.created_workflows) + self.assertEqual(1, len(api.updated_workflows)) + def test_a_key_that_only_contains_the_managed_name_is_left_alone(self) -> None: module = load_script() api = FakeApi(keys=[{"id": "near", "label": "infinito:mcp-legacy"}]) From ad99ded588bb1b9fb134e8fec2e89b3e9d6aba40 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 9 Oct 2026 09:35:13 +0200 Subject: [PATCH 4/8] fix(web-app-n8n): stop paging when n8n hands back a page it already sent Both provisioners follow nextCursor for workflows and step skip for API keys until a short page arrives. A cursor n8n repeats, or a key listing that ignores skip, would keep that loop running forever and hang the deploy instead of failing it. workflows_named now remembers every cursor it followed and exits with "FAILED listing workflows: nextCursor repeats" when one comes back. api_keys_labelled exits when a non-empty page brings no key id it has not already seen. Normal paging is unchanged. Four regression tests drive both scripts against a fake API that repeats its cursor or ignores skip; without the guards exactly those four fail. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../files/python/provision/client.py | 9 ++++ .../web-app-n8n/files/python/provision/mcp.py | 9 ++++ .../files/provision/test_client.py | 44 +++++++++++++++++++ .../web-app-n8n/files/provision/test_mcp.py | 44 +++++++++++++++++++ 4 files changed, 106 insertions(+) diff --git a/roles/web-app-n8n/files/python/provision/client.py b/roles/web-app-n8n/files/python/provision/client.py index b3c673b289..e81b3598cb 100644 --- a/roles/web-app-n8n/files/python/provision/client.py +++ b/roles/web-app-n8n/files/python/provision/client.py @@ -145,6 +145,7 @@ def api_keys_labelled(label): label: text n8n looks for inside each label. """ found = [] + seen = set() while True: query = urllib.parse.urlencode( {"label": label, "take": PAGE, "skip": len(found)} @@ -153,6 +154,10 @@ def api_keys_labelled(label): if status != 200: sys.exit(f"FAILED listing api keys: {status} {body}") page = records(body, "api keys") + ids = {str(key.get("id")) for key in page} + if page and ids <= seen: + sys.exit(f"FAILED listing api keys: page at skip {len(found)} repeats") + seen |= ids found.extend(page) if len(page) < PAGE: return found @@ -166,6 +171,7 @@ def workflows_named(key, name): name: workflow name n8n filters on. """ found = [] + cursors = set() query = {"name": name, "limit": PAGE} while True: status, body = call( @@ -177,6 +183,9 @@ def workflows_named(key, name): cursor = body.get("nextCursor") if isinstance(body, dict) else None if not cursor: return found + if cursor in cursors: + sys.exit("FAILED listing workflows: nextCursor repeats") + cursors.add(cursor) query = {"name": name, "cursor": cursor} diff --git a/roles/web-app-n8n/files/python/provision/mcp.py b/roles/web-app-n8n/files/python/provision/mcp.py index 8e9745b144..81d485c19c 100644 --- a/roles/web-app-n8n/files/python/provision/mcp.py +++ b/roles/web-app-n8n/files/python/provision/mcp.py @@ -150,6 +150,7 @@ def api_keys_labelled(label): label: text n8n looks for inside each label. """ found = [] + seen = set() while True: query = urllib.parse.urlencode( {"label": label, "take": PAGE, "skip": len(found)} @@ -158,6 +159,10 @@ def api_keys_labelled(label): if status != 200: sys.exit(f"FAILED listing api keys: {status} {body}") page = records(body, "api keys") + ids = {str(key.get("id")) for key in page} + if page and ids <= seen: + sys.exit(f"FAILED listing api keys: page at skip {len(found)} repeats") + seen |= ids found.extend(page) if len(page) < PAGE: return found @@ -171,6 +176,7 @@ def workflows_named(key, name): name: workflow name n8n filters on. """ found = [] + cursors = set() query = {"name": name, "limit": PAGE} while True: status, body = call( @@ -182,6 +188,9 @@ def workflows_named(key, name): cursor = body.get("nextCursor") if isinstance(body, dict) else None if not cursor: return found + if cursor in cursors: + sys.exit("FAILED listing workflows: nextCursor repeats") + cursors.add(cursor) query = {"name": name, "cursor": cursor} diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py index 875a4abe4c..914ab8b83f 100644 --- a/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py @@ -249,6 +249,50 @@ class TestProvisionClient(unittest.TestCase): self.assertEqual([], api.created_workflows) self.assertEqual(1, len(api.updated_workflows)) + def test_a_repeated_workflow_cursor_aborts_rather_than_loops(self) -> None: + module = load_script() + api = FakeApi() + listings = [] + + def stuck(path, method="GET", payload=None, api_key=None): + if path.startswith("/api/v1/workflows?"): + listings.append(path) + if len(listings) > 3: + raise AssertionError("the workflow listing never ends") + return 200, {"data": [], "nextCursor": "same"} + return api(path, method, payload, api_key) + + with ( + patch.object(module, "call", stuck), + self.assertRaises(SystemExit) as exit_, + ): + module.main() + self.assertIn("nextCursor repeats", str(exit_.exception)) + + def test_a_key_listing_that_ignores_skip_aborts_rather_than_loops(self) -> None: + module = load_script() + api = FakeApi() + full = [ + {"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"} + for n in range(MAX_PAGE_SIZE) + ] + listings = [] + + def frozen(path, method="GET", payload=None, api_key=None): + if path.startswith("/rest/api-keys?"): + listings.append(path) + if len(listings) > 3: + raise AssertionError("the key listing never ends") + return 200, {"data": {"items": full}} + return api(path, method, payload, api_key) + + with ( + patch.object(module, "call", frozen), + self.assertRaises(SystemExit) as exit_, + ): + module.main() + self.assertIn("repeats", str(exit_.exception)) + def test_an_unknown_listing_shape_names_its_keys_but_never_a_value(self) -> None: module = load_script() api = FakeApi() diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py index 2cd90c0d7c..a664866a33 100644 --- a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py @@ -285,6 +285,50 @@ class TestProvisionMcp(unittest.TestCase): self.assertEqual([], api.created_workflows) self.assertEqual(1, len(api.updated_workflows)) + def test_a_repeated_workflow_cursor_aborts_rather_than_loops(self) -> None: + module = load_script() + api = FakeApi() + listings = [] + + def stuck(path, method="GET", payload=None, api_key=None): + if path.startswith("/api/v1/workflows?"): + listings.append(path) + if len(listings) > 3: + raise AssertionError("the workflow listing never ends") + return 200, {"data": [], "nextCursor": "same"} + return api(path, method, payload, api_key) + + with ( + patch.object(module, "call", stuck), + self.assertRaises(SystemExit) as exit_, + ): + module.main() + self.assertIn("nextCursor repeats", str(exit_.exception)) + + def test_a_key_listing_that_ignores_skip_aborts_rather_than_loops(self) -> None: + module = load_script() + api = FakeApi() + full = [ + {"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"} + for n in range(MAX_PAGE_SIZE) + ] + listings = [] + + def frozen(path, method="GET", payload=None, api_key=None): + if path.startswith("/rest/api-keys?"): + listings.append(path) + if len(listings) > 3: + raise AssertionError("the key listing never ends") + return 200, {"data": {"items": full}} + return api(path, method, payload, api_key) + + with ( + patch.object(module, "call", frozen), + self.assertRaises(SystemExit) as exit_, + ): + module.main() + self.assertIn("repeats", str(exit_.exception)) + def test_a_key_that_only_contains_the_managed_name_is_left_alone(self) -> None: module = load_script() api = FakeApi(keys=[{"id": "near", "label": "infinito:mcp-legacy"}]) From 01ed6d9916cefdfdf2bfa3f3d7d0231775a2e213 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:17:19 +0000 Subject: [PATCH 5/8] update(versions): update pins with a declared source (web-app-n8n) --- roles/web-app-n8n/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-n8n/meta/services.yml b/roles/web-app-n8n/meta/services.yml index 64aa57e29f..d4e7d5e36b 100644 --- a/roles/web-app-n8n/meta/services.yml +++ b/roles/web-app-n8n/meta/services.yml @@ -67,7 +67,7 @@ n8n: backup: no_stop_required: false image: docker.n8n.io/n8nio/n8n - version: "2.43.4" + version: "2.44.0" update: type: registry_tags image: docker.n8n.io/n8nio/n8n From cdcfc178781ff75d712b851c3d3f5b9721f8b3ad Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:05:47 +0000 Subject: [PATCH 6/8] update(images): update Docker image versions (web-app-keycloak) --- roles/web-app-keycloak/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-keycloak/meta/services.yml b/roles/web-app-keycloak/meta/services.yml index 2071d0fa64..379394b130 100644 --- a/roles/web-app-keycloak/meta/services.yml +++ b/roles/web-app-keycloak/meta/services.yml @@ -51,7 +51,7 @@ keycloak: shared: true provides: sso image: quay.io/keycloak/keycloak - version: "26.8.0" + version: "26.8.1" name: keycloak min_storage: 468MB cpus: "2.0" From 13354a26cf3f4b1c0d0ec640b570346010709eee Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:05:54 +0000 Subject: [PATCH 7/8] update(images): update Docker image versions (web-app-matrix) --- roles/web-app-matrix/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-matrix/meta/services.yml b/roles/web-app-matrix/meta/services.yml index 5a59dbcf13..e8e59ab894 100644 --- a/roles/web-app-matrix/meta/services.yml +++ b/roles/web-app-matrix/meta/services.yml @@ -66,7 +66,7 @@ synapse: element: healthcheck: flavor: wget - version: v1.12.30 + version: v1.12.31 image: vectorim/element-web name: matrix-element ports: From 789c1a1b631c842ee01d4e381f13027cd47590dc Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 13 Oct 2026 15:24:56 +0200 Subject: [PATCH 8/8] docs(web-app-mailu): point the mail server test link at a live service The three workspace jobs of main run 38318560960 failed in test-external on one URL: https://de.ssl-tools.net/mailservers/ in roles/web-app-mailu/docs/Testing.md answers HTTP 404, and ssl-tools.net now serves an empty page. The doc links the Internet.nl email test instead, which checks STARTTLS, DANE, SPF, DKIM and DMARC of a mail domain. Verified: the repository's own probe (_probe_with_retry in tests/external/repository/test_urls_reachable.py) returns "fail HTTP 404" for the old URL and "ok HTTP 200" for https://internet.nl/test-mail/ and for the unchanged http://testemail.de/, and _extract_urls finds both links in the changed file. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-mailu/docs/Testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-mailu/docs/Testing.md b/roles/web-app-mailu/docs/Testing.md index f5e69f4230..db0f20a84f 100644 --- a/roles/web-app-mailu/docs/Testing.md +++ b/roles/web-app-mailu/docs/Testing.md @@ -2,5 +2,5 @@ Use the following tools to test your server instance: -- [SSL-Tools Mailserver Test](https://de.ssl-tools.net/mailservers/) +- [Internet.nl Email Test](https://internet.nl/test-mail/) - [TestEmail.de](http://testemail.de/)