"""Fix the v5 Quote Email Monitor reply-branch auth bug. Background ---------- The reply branch HTTP nodes (`Find Target Quote (by In-Reply-To)`, `Find Target Quote (by Subject)`, `Insert quote_replies`, `Flip has_new_reply`) all use `Authorization: Bearer {{ $json.pb_token }}` but `pb_token` is never set on the reply path. The CREATE branch authenticates inline inside `Catch and Create Uploader` (via `this.helpers.httpRequest`) and sets `pb_token` on every item it emits, which downstream CREATE-branch nodes inherit. The reply branch never goes through `Catch and Create Uploader`, so its items arrive at the HTTP nodes without a token. Both `PocketBase Admin Auth` (HTTP) and `Parse PB Auth` (Code) are `disabled: true` in the live workflow — leftover scaffolding from a pre-v5 design where auth was a separate pre-step. Result: GET lookups succeed because PB's viewer rules allow listing the `quotes` collection to anonymous callers, but writes (insert `quote_replies`, PATCH `has_new_reply`) would 401 in production. Fix design ---------- 1. Add a new Code node `PB Auth Inline` between `PocketBase Connection` and `Is Reply?` that does inline auth via `helpers.httpRequest`, matching the pattern already used in `Catch and Create Uploader`. Both branches inherit `pb_token` on the shared context. 2. Reconnect `PocketBase Connection → PB Auth Inline → Is Reply?` (replaces the direct `PocketBase Connection → Is Reply?` link). 3. Tighten `Encode Reply Filters`: the empty-`in_reply_to` fallback is currently `''` (empty filter), which PB interprets as "no filter" and returns all records (with `perPage=1`, just the first). Replace with a guaranteed no-match sentinel `id="__NO_MATCH__"` so PB returns zero records — same shape as the subject fallback. Backwards compatibility ----------------------- - `Catch and Create Uploader` still does its own inline auth for the CREATE branch (its token is newer and overwrites the upstream one before downstream CREATE-branch HTTP nodes read it). No conflict. - Both branches now have a valid `pb_token`; existing reply-branch HTTP nodes continue to work without modification. - The CREATE branch is unaffected: nothing downstream of `Is Reply?` in the CREATE branch relies on `pb_token` being absent. Safety ------ - GET the live workflow → save a fresh backup before mutating. - Add exactly one node and update two connections; leave every other node and connection untouched. - After PUT, GET it again and diff against the backup to confirm only the intended changes moved. - Auth failures throw — visible in n8n UI — instead of producing an empty token and a confusing 401 cascade downstream. Run: python3 scripts/fixes/fix_reply_branch_auth.py --apply """ from __future__ import annotations import json import os import subprocess import sys from pathlib import Path # n8n public API (read from env so this script doesn't hard-code a secret) N8N_URL = os.environ.get( "N8N_URL", "http://n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io", ) N8N_KEY = os.environ.get( "N8N_API_KEY", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0Zjc3MGRmMi1iZDJmLTQ0MzYtYjE1My1iMjcwMDRlZmM3YjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwianRpIjoiMmVhOTU4NTktZDcxYS00YTQ0LTk2OTAtNGNlMjk0NTBlZGViIiwiaWF0IjoxNzg1MTQ0Njk3fQ.QIh8JOSZpb9qFJ3-D82-YyRTXJFdlsvBgwQyjWzB-ho", ) WORKFLOW_ID = "eXSh623OjByIcip8" REPO_ROOT = Path(__file__).resolve().parents[2] BACKUP_PATH = REPO_ROOT / "n8n_workfows" / "WFDASH — Quote Email Monitor (v5 catch-and-create).PRE-FIX-REPLY-BRANCH-AUTH.json" LOCAL_WF_PATH = REPO_ROOT / "n8n_workfows" / "WFDASH — Quote Email Monitor (v5 catch-and-create).json" # ============================================================================= # The replacement code for Encode Reply Filters (tightened empty fallback) # ============================================================================= ENCODE_REPLY_FILTERS_FIX = r'''// Encode Reply Filters // Pre-encode the two PB filters used by the reply-branch HTTP nodes. // // FIX 2026-09-09 (3 changes): // 1. Subject filter no longer passes the WHOLE subject to PB's // `quote_number~` (contains). Uses the digits-only canonical form // extracted by Parse Email (`quote_number_canonical`, e.g. // "202609246") so PB's substring matches whether PB stores // `Q202609246` or `Q-2026-09-246`. // 2. If Parse Email couldn't pull a canonical number (no Q-digit // pattern in subject), the filter is set to a guaranteed no-match // sentinel so PB returns zero records instead of "all records". // 3. In-Reply-To uses a SUBSTRING match on the BARE UUID (no `<>`, // no `@domain`). Reasons: // (a) PB only accepts lowercase `%3c`/`%3e` for `<`/`>`. URL // encoders (incl. n8n's HTTP client) sometimes emit uppercase // percent escapes, breaking the filter silently. // (b) Parse Email strips `<>` from message-id headers per RFC // 5322. PB stores them with brackets. A substring match on // the bracket-less value works either way. // (c) The UUID portion of a message-id is the only universally // unique segment. `@domain` differs between senders; angle // brackets differ between clients; the UUID is canonical. // The UUID regex tolerates all common message-id formats. // // FIX 2026-09-09 (4th change — auth fix): the empty `in_reply_to` // fallback used to be `''` (empty filter string). PB interprets an // empty filter as "no filter" and would return the first record in // the collection with `perPage=1`, which is the opposite of the // intended "no match" behaviour. Now uses the same no-match sentinel // pattern as the subject fallback: `id="__NO_MATCH__"`, which is // guaranteed not to match any record (record ids are 15-char PB base62 // strings). const inReplyToRaw = String($json.in_reply_to || '').trim(); const quoteNum = String($json.quote_number_canonical || '').trim(); // Pull the bare UUID out of the message-id. Tolerates brackets, @domain, // and capital hex. Falls back to the full bracket-less string when no // UUID-shaped token is present (e.g. weird custom message-ids). const uuidMatch = inReplyToRaw.match(/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/); const inReplySubstr = uuidMatch ? uuidMatch[0] : inReplyToRaw; // In-Reply-To: substring match on the UUID (or fallback to bracket-less). // Missing/empty → sentinel that no record's id equals. const inReplyFilter = inReplySubstr ? 'email_message_id~' + JSON.stringify(inReplySubstr) : 'id=' + JSON.stringify('__NO_MATCH__'); // Subject fallback: substring on digits-only canonical quote number. // Empty canonical → sentinel that no quote_number equals. const subjectFilter = quoteNum ? 'quote_number~' + JSON.stringify(quoteNum) : 'quote_number=' + JSON.stringify('__NO_MATCH__'); return { json: Object.assign({}, $json, { _reply_filter_in_reply_to: inReplyFilter, _reply_filter_subject: subjectFilter, // Keep the bracketless form downstream in case any other node needs it. in_reply_to_canonical: inReplyToRaw, }) }; ''' # ============================================================================= # The new PB Auth Inline node (sits between PocketBase Connection and Is Reply?) # ============================================================================= PB_AUTH_INLINE_NODE = { "parameters": { "mode": "runOnceForEachItem", "jsCode": ( "// PB Auth Inline (v5 — reply-branch auth)\n" "// Authenticate with PB admin once, set pb_token + pb_admin_id\n" "// on the shared context. Both the reply branch (Find Target\n" "// Quote, Insert quote_replies, Flip has_new_reply) and the\n" "// create branch (Carry Email Ctx → downstream HTTP nodes)\n" "// consume $json.pb_token.\n" "//\n" "// The create branch ALSO calls auth inline inside Catch and\n" "// Create Uploader for its own attachment uploads. That's\n" "// intentional — its token is overwritten downstream anyway\n" "// and the duplicate call is cheap against local PB.\n" "//\n" "// On auth failure: throw so the operator sees the failure in\n" "// the n8n UI rather than silently producing an empty token\n" "// and a confusing 401 from the next HTTP node.\n" "\n" "const input = $json || {};\n" "const pbUrl = String(input.pb_url || '').trim();\n" "const pbEmail = String(input.pb_admin_email || '').trim();\n" "const pbPassword = String(input.pb_admin_password || '').trim();\n" "\n" "if (!pbUrl) throw new Error('PB Auth Inline: pb_url missing on input');\n" "if (!pbEmail) throw new Error('PB Auth Inline: pb_admin_email missing on input');\n" "if (!pbPassword) throw new Error('PB Auth Inline: pb_admin_password missing on input');\n" "\n" "const subj = String(input.subject || '(no subject)');\n" "const fromEmail = String(input.from_email || '(unknown sender)');\n" "\n" "let authResp;\n" "try {\n" " authResp = await this.helpers.httpRequest({\n" " method: 'POST',\n" " url: pbUrl.replace(/\\/$/, '') + '/api/collections/_superusers/auth-with-password',\n" " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },\n" " body: { identity: pbEmail, password: pbPassword },\n" " json: true,\n" " timeout: 10000,\n" " });\n" "} catch (e) {\n" " throw new Error(\n" " 'PB Auth Inline: HTTP call to ' + pbUrl + ' failed for \"' + subj + '\" from ' + fromEmail + ': ' +\n" " (e.message || String(e))\n" " );\n" "}\n" "\n" "const token = authResp && typeof authResp.token === 'string' ? authResp.token : '';\n" "if (!token) {\n" " throw new Error(\n" " 'PB Auth Inline: ' + pbUrl + ' returned no token for \"' + subj + '\" from ' + fromEmail +\n" " ' (response: ' + JSON.stringify(authResp).slice(0, 200) + ')'\n" " );\n" "}\n" "\n" "const adminId = authResp.admin && typeof authResp.admin.id === 'string' ? authResp.admin.id : '';\n" "\n" "return {\n" " json: Object.assign({}, input, {\n" " pb_token: token,\n" " pb_admin_id: adminId,\n" " _pb_auth_ok: true,\n" " }),\n" "};\n" ), }, "id": "pb-auth-inline-2026-09-09", "name": "PB Auth Inline", "type": "n8n-nodes-base.code", "typeVersion": 2, # Slot between PocketBase Connection (x=1120) and Is Reply? (x=1344) # at y=832 (the common-path lane). "position": [1232, 832], } # ============================================================================= # Apply Subject Result node (sits between Find Target Quote (by Subject) # and Route Reply Result (matched?) — fixes the wrapper-context loss) # ============================================================================= APPLY_SUBJECT_RESULT_NODE = { "parameters": { "mode": "runOnceForEachItem", "jsCode": ( "// Apply Subject Result\n" "// Merges the PB response from Find Target Quote (by Subject)\n" "// with the wrapper context built by Decide Reply Lookup.\n" "//\n" "// The HTTP Request node that ran just before this one\n" "// REPLACES $json with the PB response (e.g. {items: [...]}),\n" "// dropping _reply_quote_id, pb_token, message_id, etc.\n" "// Without this merge, Route Reply Result evaluates an empty\n" "// _reply_quote_id and never fires the matched branch, even\n" "// when the subject fallback found the right quote.\n" "//\n" "// Behaviour:\n" "// - items.length > 0: override _reply_quote_id with items[0].id\n" "// (subject fallback matched; In-Reply-To did not).\n" "// - items.length === 0: keep _reply_quote_id from Decide Reply\n" "// Lookup (which used the In-Reply-To result, if any).\n" "// - Always preserve pb_token, message_id, in_reply_to, from_email,\n" "// from_name, subject, body_plain, body_html, received_at so the\n" "// downstream Insert quote_replies + Flip has_new_reply HTTP\n" "// nodes have the auth token and email context they need.\n" "\n" "const resp = $json || {};\n" "const items = Array.isArray(resp.items) ? resp.items : [];\n" "const subjectHit = items[0] || null;\n" "\n" "// Pull the wrapper context that Decide Reply Lookup assembled.\n" "// Fall back to Encode Reply Filters, then to Parse Email, in case\n" "// upstream topology ever changes.\n" "let wrapper = {};\n" "const sources = [\n" " () => { try { return $('Decide Reply Lookup').first().json || {}; } catch (e) { return {}; } },\n" " () => { try { return $('Encode Reply Filters').first().json || {}; } catch (e) { return {}; } },\n" " () => { try { return $('Parse Email').item.json || {}; } catch (e) { return {}; } },\n" "];\n" "for (const get of sources) {\n" " wrapper = get();\n" " if (wrapper && (wrapper.pb_token || wrapper.message_id || wrapper.from_email)) break;\n" "}\n" "\n" "const existingQuoteId = String(wrapper._reply_quote_id || '');\n" "const subjectQuoteId = subjectHit && subjectHit.id ? String(subjectHit.id) : '';\n" "\n" "return { json: Object.assign({}, wrapper, {\n" " // Subject match wins over empty In-Reply-To result; preserve\n" " // any non-empty In-Reply-To result if subject returned nothing.\n" " _reply_quote_id: subjectQuoteId || existingQuoteId,\n" " _subject_lookup_total: typeof resp.totalItems === 'number' ? resp.totalItems : -1,\n" " _subject_lookup_matched: !!subjectHit,\n" "}) };\n" ), }, "id": "apply-subject-result-2026-09-09", "name": "Apply Subject Result", "type": "n8n-nodes-base.code", "typeVersion": 2, # Slot between Find Target Quote (by Subject) (x=2240) and Route Reply # Result (matched?) (x=2464) at y=448 (the reply-branch lane). "position": [2352, 448], } # ============================================================================= # Preserve Wrapper for Flip node (sits between Insert quote_replies and # Flip has_new_reply — same wrapper-loss pattern as Apply Subject Result) # ============================================================================= PRESERVE_WRAPPER_FOR_FLIP_NODE = { "parameters": { "mode": "runOnceForEachItem", "jsCode": ( "// Preserve Wrapper for Flip\n" "// Insert quote_replies is an HTTP POST that REPLACES $json\n" "// with the new PB record (id, collectionId, etc.). That wipes\n" "// pb_url, pb_token, _reply_quote_id, message_id, etc. — the\n" "// fields Flip has_new_reply needs to build its PATCH URL\n" "// (`pb_url/api/collections/quotes/records/_reply_quote_id`) and\n" "// Authorization header.\n" "//\n" "// This node re-merges the wrapper context from the most recent\n" "// upstream Code node that carries it (Apply Subject Result →\n" "// Decide Reply Lookup → Encode Reply Filters → Parse Email,\n" "// first one that has pb_token wins), and stashes the new\n" "// quote_replies record id for any debug step downstream.\n" "//\n" "// Insert's response body is intentionally not re-emitted —\n" "// downstream only needs the wrapper fields to build the PATCH.\n" "\n" "let wrapper = {};\n" "const sources = [\n" " () => { try { return $('Apply Subject Result').first().json || {}; } catch (e) { return {}; } },\n" " () => { try { return $('Decide Reply Lookup').first().json || {}; } catch (e) { return {}; } },\n" " () => { try { return $('Encode Reply Filters').first().json || {}; } catch (e) { return {}; } },\n" " () => { try { return $('Parse Email').item.json || {}; } catch (e) { return {}; } },\n" "];\n" "for (const get of sources) {\n" " wrapper = get();\n" " if (wrapper && wrapper.pb_token) break;\n" "}\n" "\n" "const insResp = $json || {};\n" "\n" "return { json: Object.assign({}, wrapper, {\n" " // Stash the freshly created quote_replies record id for any\n" " // downstream debug step (Flip itself doesn't need it).\n" " _quote_reply_record_id: insResp.id || '',\n" "}) };\n" ), }, "id": "preserve-wrapper-for-flip-2026-09-09", "name": "Preserve Wrapper for Flip", "type": "n8n-nodes-base.code", "typeVersion": 2, # Slot between Insert quote_replies (x=2688) and Flip has_new_reply # (x=2976) at y=448 (the reply-branch lane). "position": [2832, 448], } # ============================================================================= # n8n HTTP helpers (curl, no extra libraries) # ============================================================================= def n8n_get(path: str) -> dict: out = subprocess.run( ["curl", "-s", "-H", f"X-N8N-API-KEY: {N8N_KEY}", f"{N8N_URL}{path}"], capture_output=True, text=True, timeout=30, ) if out.returncode != 0: raise RuntimeError(f"GET {path} failed: {out.stderr}") return json.loads(out.stdout) def n8n_put(path: str, payload: dict) -> dict: # Strip server-only fields that should not be sent on PUT. STRIP = { "id", "createdAt", "updatedAt", "versionId", "activeVersionId", "versionCounter", "triggerCount", "sourceWorkflowId", "shared", "activeVersion", "isArchived", "active", "tags", "meta", } body_payload = {k: v for k, v in payload.items() if k not in STRIP} body = json.dumps(body_payload) out = subprocess.run( [ "curl", "-s", "-X", "PUT", "-H", f"X-N8N-API-KEY: {N8N_KEY}", "-H", "Content-Type: application/json", "--data-binary", "@-", f"{N8N_URL}{path}", ], input=body, capture_output=True, text=True, timeout=60, ) if out.returncode != 0: raise RuntimeError(f"PUT {path} failed: {out.stderr}") try: return json.loads(out.stdout) except json.JSONDecodeError: raise RuntimeError(f"PUT {path} returned non-JSON: {out.stdout[:500]}") def n8n_post(path: str, payload: dict | None = None) -> dict: cmd = [ "curl", "-s", "-X", "POST", "-H", f"X-N8N-API-KEY: {N8N_KEY}", f"{N8N_URL}{path}", ] if payload is not None: cmd[3:3] = ["-H", "Content-Type: application/json", "--data-binary", "@-"] out = subprocess.run( cmd, input=json.dumps(payload) if payload is not None else None, capture_output=True, text=True, timeout=30, ) if out.returncode != 0: raise RuntimeError(f"POST {path} failed: {out.stderr}") try: return json.loads(out.stdout) except json.JSONDecodeError: return {"raw": out.stdout} # ============================================================================= # Fix application # ============================================================================= def apply_fix_to_workflow(wf: dict) -> tuple[dict, list[str]]: """Add PB Auth Inline node, rewire PocketBase Connection → it → Is Reply?, tighten Encode Reply Filters empty in_reply_to fallback. Idempotent: detects the already-applied state and skips each piece. """ changes = [] by_name = {n["name"]: n for n in wf["nodes"]} # --- 1. Encode Reply Filters: tighten empty in_reply_to fallback --- erf = by_name.get("Encode Reply Filters") if not erf: raise RuntimeError("Encode Reply Filters node not found in workflow") old_erf = erf["parameters"]["jsCode"] # Detect the latest tightened state: both subject and in_reply_to # fall back to a `id="__NO_MATCH__"`-style sentinel. # The literal in the JS code is `id="__NO_MATCH__"` (PB filter syntax). already_tightened = 'id="__NO_MATCH__"' in old_erf if already_tightened: changes.append("Encode Reply Filters: empty-in_reply_to fallback already tightened (skipped)") else: if "[0-9A-Fa-f]{8}" not in old_erf: raise RuntimeError( "Encode Reply Filters does not look like the expected v5 " "shape (missing UUID-regex `[0-9A-Fa-f]{8}`). Refusing to " "overwrite. Inspect the live workflow manually." ) erf["parameters"]["jsCode"] = ENCODE_REPLY_FILTERS_FIX erf["parameters"]["mode"] = "runOnceForEachItem" changes.append("Encode Reply Filters: empty in_reply_to fallback → no-match sentinel") # --- 2. Add PB Auth Inline node (idempotent: skip if already present) --- has_auth_node = "PB Auth Inline" in by_name if has_auth_node: changes.append("PB Auth Inline node: already present (skipped add)") else: wf["nodes"].append(PB_AUTH_INLINE_NODE) changes.append(f"PB Auth Inline node: added (id={PB_AUTH_INLINE_NODE['id']})") # Refresh by_name after potential node add by_name = {n["name"]: n for n in wf["nodes"]} # --- 3. Rewire connections --- conns = wf.setdefault("connections", {}) pb_conn_out = conns.get("PocketBase Connection", {}).get("main", []) is_reply_in = conns.get("Is Reply?", {}).get("main", []) # 3a. Insert PB Auth Inline between PocketBase Connection and Is Reply? # First, drop the existing direct PocketBase Connection → Is Reply? # link so it doesn't bypass the new auth node. new_pb_conn_out: list[list[dict]] = [[] for _ in pb_conn_out] rewired = False for branch in pb_conn_out: new_branch = [] for link in branch: if link.get("node") == "Is Reply?": # Replace direct link with link to PB Auth Inline new_branch.append({ "node": "PB Auth Inline", "type": "main", "index": 0, }) rewired = True else: new_branch.append(link) new_pb_conn_out[branch_idx_for(pb_conn_out, branch)] = new_branch if rewired: conns["PocketBase Connection"]["main"] = new_pb_conn_out changes.append("PocketBase Connection → Is Reply?: replaced with link to PB Auth Inline") else: changes.append("PocketBase Connection → Is Reply?: link not found (skipping)") # 3b. Add PB Auth Inline → Is Reply? link (only if missing) pb_auth_out = conns.get("PB Auth Inline", {}).get("main", [[]]) already_linked_to_is_reply = any( link.get("node") == "Is Reply?" for branch in pb_auth_out for link in branch ) if not already_linked_to_is_reply: conns.setdefault("PB Auth Inline", {"main": [[]]}) # Append Is Reply? link to the first branch of PB Auth Inline conns["PB Auth Inline"]["main"][0].append({ "node": "Is Reply?", "type": "main", "index": 0, }) changes.append("PB Auth Inline → Is Reply?: added") else: changes.append("PB Auth Inline → Is Reply?: already linked (skipped)") # --- 4. Add Apply Subject Result node (idempotent) --- by_name = {n["name"]: n for n in wf["nodes"]} if "Apply Subject Result" in by_name: changes.append("Apply Subject Result node: already present (skipped add)") else: wf["nodes"].append(APPLY_SUBJECT_RESULT_NODE) changes.append(f"Apply Subject Result node: added (id={APPLY_SUBJECT_RESULT_NODE['id']})") # --- 5. Rewire: Find Target Quote (by Subject) → Apply Subject Result → # Route Reply Result (matched?) (was direct to Route Reply Result) --- ftq_subject_out = conns.get("Find Target Quote (by Subject)", {}).get("main", []) new_ftq_out: list[list[dict]] = [[] for _ in ftq_subject_out] rewired2 = False for branch in ftq_subject_out: new_branch = [] for link in branch: if link.get("node") == "Route Reply Result (matched?)": # Replace direct link with link to Apply Subject Result new_branch.append({ "node": "Apply Subject Result", "type": "main", "index": 0, }) rewired2 = True else: new_branch.append(link) new_ftq_out[branch_idx_for(ftq_subject_out, branch)] = new_branch if rewired2: conns["Find Target Quote (by Subject)"]["main"] = new_ftq_out changes.append("Find Target Quote (by Subject) → Route Reply Result: replaced with link to Apply Subject Result") else: changes.append("Find Target Quote (by Subject) → Route Reply Result: link not found (skipping)") # 5b. Add Apply Subject Result → Route Reply Result (matched?) link asr_out = conns.get("Apply Subject Result", {}).get("main", [[]]) already_linked = any( link.get("node") == "Route Reply Result (matched?)" for branch in asr_out for link in branch ) if not already_linked: conns.setdefault("Apply Subject Result", {"main": [[]]}) conns["Apply Subject Result"]["main"][0].append({ "node": "Route Reply Result (matched?)", "type": "main", "index": 0, }) changes.append("Apply Subject Result → Route Reply Result: added") else: changes.append("Apply Subject Result → Route Reply Result: already linked (skipped)") # --- 6. Add Preserve Wrapper for Flip node (idempotent) --- by_name = {n["name"]: n for n in wf["nodes"]} if "Preserve Wrapper for Flip" in by_name: changes.append("Preserve Wrapper for Flip node: already present (skipped add)") else: wf["nodes"].append(PRESERVE_WRAPPER_FOR_FLIP_NODE) changes.append(f"Preserve Wrapper for Flip node: added (id={PRESERVE_WRAPPER_FOR_FLIP_NODE['id']})") # --- 7. Rewire: Insert quote_replies → Preserve Wrapper for Flip → # Flip has_new_reply (was direct to Flip) --- insert_out = conns.get("Insert quote_replies", {}).get("main", []) new_insert_out: list[list[dict]] = [[] for _ in insert_out] rewired3 = False for branch in insert_out: new_branch = [] for link in branch: if link.get("node") == "Flip has_new_reply": new_branch.append({ "node": "Preserve Wrapper for Flip", "type": "main", "index": 0, }) rewired3 = True else: new_branch.append(link) new_insert_out[branch_idx_for(insert_out, branch)] = new_branch if rewired3: conns["Insert quote_replies"]["main"] = new_insert_out changes.append("Insert quote_replies → Flip has_new_reply: replaced with link to Preserve Wrapper for Flip") else: changes.append("Insert quote_replies → Flip has_new_reply: link not found (skipping)") # 7b. Add Preserve Wrapper for Flip → Flip has_new_reply link pwf_out = conns.get("Preserve Wrapper for Flip", {}).get("main", [[]]) already_linked_pwf = any( link.get("node") == "Flip has_new_reply" for branch in pwf_out for link in branch ) if not already_linked_pwf: conns.setdefault("Preserve Wrapper for Flip", {"main": [[]]}) conns["Preserve Wrapper for Flip"]["main"][0].append({ "node": "Flip has_new_reply", "type": "main", "index": 0, }) changes.append("Preserve Wrapper for Flip → Flip has_new_reply: added") else: changes.append("Preserve Wrapper for Flip → Flip has_new_reply: already linked (skipped)") return wf, changes def branch_idx_for(outer: list, branch: list) -> int: """Return the index of `branch` within `outer` (linear search).""" for i, b in enumerate(outer): if b is branch: return i return 0 def diff_workflow(before: dict, after: dict) -> dict: """Return a summary of structural changes (nodes added/removed/modified, connection graph differences).""" before_nodes = {n["name"]: n for n in before["nodes"]} after_nodes = {n["name"]: n for n in after["nodes"]} added = sorted(set(after_nodes) - set(before_nodes)) removed = sorted(set(before_nodes) - set(after_nodes)) modified: dict[str, dict] = {} for name in set(before_nodes) | set(after_nodes): b = before_nodes.get(name) a = after_nodes.get(name) if b is None or a is None: continue diffs: list[str] = [] if b.get("parameters") != a.get("parameters"): diffs.append("parameters") if b.get("position") != a.get("position"): diffs.append("position") if b.get("disabled") != a.get("disabled"): diffs.append("disabled") if b.get("type") != a.get("type"): diffs.append("type") if b.get("typeVersion") != a.get("typeVersion"): diffs.append("typeVersion") if diffs: modified[name] = {"fields": diffs} conn_diff: list[str] = [] before_conns = before.get("connections", {}) after_conns = after.get("connections", {}) if before_conns != after_conns: # Show which keys changed for key in set(before_conns) | set(after_conns): if before_conns.get(key) != after_conns.get(key): conn_diff.append(key) return { "nodes_added": added, "nodes_removed": removed, "nodes_modified": modified, "connections_changed": sorted(conn_diff), } def main(): dry_run = "--apply" not in sys.argv print(f"=== n8n URL: {N8N_URL}") print(f"=== Workflow ID: {WORKFLOW_ID}") print(f"=== Mode: {'DRY RUN' if dry_run else 'APPLY'}\n") # 1. Fetch live workflow print("→ GET /api/v1/workflows/{id}") live = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}") print(f" name: {live.get('name')}") print(f" active: {live.get('active')} isArchived: {live.get('isArchived')}") print(f" nodes: {len(live.get('nodes', []))}") if live.get("isArchived"): print("\n→ POST /unarchive (workflow was archived; PUT refuses on archived)") n8n_post(f"/api/v1/workflows/{WORKFLOW_ID}/unarchive") live = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}") print(f" now active: {live.get('active')} isArchived: {live.get('isArchived')}") # 2. Save backup (always — even on dry runs) if not BACKUP_PATH.exists() or "--force-backup" in sys.argv: BACKUP_PATH.write_text(json.dumps(live, indent=2)) print(f" backup written: {BACKUP_PATH} ({BACKUP_PATH.stat().st_size} bytes)") else: print(f" backup already exists: {BACKUP_PATH} (skipping; use --force-backup to overwrite)") # 3. Apply the fix print("\n→ Applying fix …") patched, changes = apply_fix_to_workflow(live) for c in changes: print(f" ✓ {c}") # 4. Diff against backup print("\n→ Structural diff vs backup:") diff = diff_workflow(json.loads(BACKUP_PATH.read_text()), patched) print(f" nodes added: {diff['nodes_added']}") print(f" nodes removed: {diff['nodes_removed']}") for name, info in diff["nodes_modified"].items(): print(f" nodes modified: {name} (fields: {info['fields']})") print(f" connections changed: {diff['connections_changed']}") # 5. Sanity: nothing unexpected. # The auth-fix portion (PB Auth Inline + Encode Reply Filters tightening # + those two connections) and the merge-fix portion (Apply Subject # Result + those two connections) are idempotent and may already be # live, so we allow them to be EITHER in this diff OR already present. auth_fix_already_live = ( "PB Auth Inline" not in diff["nodes_added"] and "PB Auth Inline" in {n["name"] for n in live["nodes"]} ) erf_already_fixed = ( "Encode Reply Filters" not in diff["nodes_modified"] ) merge_fix_already_live = ( "Apply Subject Result" not in diff["nodes_added"] and "Apply Subject Result" in {n["name"] for n in live["nodes"]} ) expected_added_after_auth_fix = set() expected_conn_changes_after_auth_fix = set() # Apply Subject Result portion if not merge_fix_already_live: expected_added_after_auth_fix.add("Apply Subject Result") expected_conn_changes_after_auth_fix.update({ "Find Target Quote (by Subject)", "Apply Subject Result", }) # Preserve Wrapper for Flip portion — always fresh in this diff if "Preserve Wrapper for Flip" in diff["nodes_added"]: expected_added_after_auth_fix.add("Preserve Wrapper for Flip") expected_conn_changes_after_auth_fix.update({ "Insert quote_replies", "Preserve Wrapper for Flip", }) # PB Auth Inline / Encode Reply Filters portion (first-time auth fix) if not auth_fix_already_live: expected_added_after_auth_fix.add("PB Auth Inline") expected_conn_changes_after_auth_fix.update({ "PocketBase Connection", "PB Auth Inline", }) if not erf_already_fixed: expected_conn_changes_after_auth_fix.add("PocketBase Connection") actual_added = set(diff["nodes_added"]) actual_modified = set(diff["nodes_modified"]) actual_conn_changed = set(diff["connections_changed"]) extra_added = actual_added - expected_added_after_auth_fix missing_added = expected_added_after_auth_fix - actual_added extra_modified = actual_modified # No modifications expected (encode reply filters already fixed) missing_modified = set() extra_conn = actual_conn_changed - expected_conn_changes_after_auth_fix problems = [] if extra_added: problems.append(f"unexpected nodes added: {sorted(extra_added)}") if missing_added: problems.append(f"expected add missing: {sorted(missing_added)}") if extra_modified: problems.append(f"unexpected nodes modified: {sorted(extra_modified)}") if missing_modified: problems.append(f"expected modifications missing: {sorted(missing_modified)}") if extra_conn: problems.append(f"unexpected connection changes: {sorted(extra_conn)}") if problems: print("\n ✗ UNEXPECTED CHANGES:") for p in problems: print(f" - {p}") print("\n=== ABORT: structural diff does not match intent. ===") return 1 print(" ✓ structural diff matches intent exactly") if dry_run: print("\n=== DRY RUN — no changes pushed. Re-run with --apply. ===") return # 6. PUT the patched workflow back print("\n→ PUT /api/v1/workflows/{id} (full replace)") result = n8n_put(f"/api/v1/workflows/{WORKFLOW_ID}", patched) new_id = result.get("id", "?") print(f" PUT returned id={new_id}") # 7. Re-GET and verify diff against backup print("\n→ GET (post-deploy verification)") verified = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}") v_diff = diff_workflow(json.loads(BACKUP_PATH.read_text()), verified) print(f" nodes added (post): {v_diff['nodes_added']}") print(f" nodes modified (post): {sorted(v_diff['nodes_modified'])}") print(f" connections changed (post): {v_diff['connections_changed']}") actual_post = set(v_diff["nodes_added"]) | set(v_diff["nodes_modified"]) # For post-deploy verification, only the NEW additions this run should appear. expected_post = expected_added_after_auth_fix if actual_post != expected_post: print(f"\n ✗ POST-DEPLOY DIFF MISMATCH") print(f" expected: {sorted(expected_post)}") print(f" actual: {sorted(actual_post)}") print("\n=== ABORT: live state may have diverged from intent. ===") return 1 print(" ✓ exactly the intended changes are live") # 8. Save the new local file LOCAL_WF_PATH.write_text(json.dumps(verified, indent=2)) print(f"\n local file updated: {LOCAL_WF_PATH}") print("\n=== DONE ===") return 0 if __name__ == "__main__": sys.exit(main() or 0)