WFDASH-WEBAPP/scripts/fixes/fix_reply_branch_auth.py

834 lines
37 KiB
Python
Raw Normal View History

feat(quotes): split attachments into internal vs outbound with operator upload UI Splits the quote attachment model along the seam that actually matters: 'operator-only references' (drawings the operator consulted, supplier emails they filed away) vs 'customer-facing files' (supplier quotes, drawings, NDAs that need to ride along on the email). Schema (PB): new `internal_attachments` + `outbound_attachments` fields on the `quotes` collection, maxSelect 10 each. Existing `attachments` field kept for back-compat but is no longer the source of truth for outbound emails. API: - GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}] - GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}] - POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments` MCP: `quotes.send_email` description now correctly states `selectedAttachmentIds` are PB file tokens from `outbound_attachments` (supplier quotes, drawings, NDAs), not IDs of the legacy `attachments` collection. Frontend: `ProcessOutboundAttachmentsCard` component lets the operator upload/remove customer-facing files during the process page; approval + process pages now read the new fields. Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`): - Old workflow (id `WF_Email_Send_SMTP`) and stale repo file (`WFDASH — Quote Outbound Send.json`) replaced. - New revision intersects `quotes.outbound_attachments` with `email_outbound_attachment_ids`, downloads each from `/api/files/quotes/<id>/<token>`, and emits `options.attachments: [{name, content, type}]` consumed by `Send Email (SMTP)` (PO Send pattern). - Failure path flipped from `send_failed` to `mail_failed` so `retryQuoteEmail` can find failed sends (it only accepts the latter). Previously failed sends were stranded. Email Monitor (v5) workflow fixes: - Reply-branch auth: any authenticated request now passes through; pre-fix bug rejected branches that the IMAP trigger hadn't seen yet. - Reply detection: bulletproof subject regex (/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246` and current `Q202609246` formats; canonicalised digits-only for PB `~` substring match. Delivery notes: new pricing endpoint at /api/v1/delivery-notes/items/[id]/pricing + service-layer methods. Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json and push-payload.baseline.json are the previously-deployed workflow shapes, retained so we can diff/rollback if regression hits. Verified: - PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes - quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged) - mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged) - node --check on new Code node JS passes Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy procedure; docs/api-mcp-layer.md description sync.
2026-09-09 11:43:56 +00:00
"""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)