#!/usr/bin/env python3 """Construct the three n8n approval-workflow JSONs for GAL-109. Architecture: - Main (approval-request): webhook -> validate -> idempotency check -> insert pending -> send Telegram -> POLL Supabase every 3s for status change (up to per-gate timeout) -> on resolve/timeout: update row + respond to caller. - Callback (telegram-callback): receives Telegram callback_query, parses callback_data "approve:" or "reject:", updates row, edits message. - Revoke (approval-revoke): webhook POST /api/approval-revoke, updates pending row to revoked, edits Telegram message. Supabase REST base: https://jiidzeympaalzljyqvjq.supabase.co/rest/v1 - Schema is `ops`, so we must send `Content-Profile: ops` and `Accept-Profile: ops` headers (Supabase PostgREST convention for non-public schemas). Telegram Bot API: https://api.telegram.org/bot/ """ import json import uuid SUPABASE_URL = "https://jiidzeympaalzljyqvjq.supabase.co" TABLE = "approval_requests" N8N_URL = "https://n8n.galhardo.cloud" # Per-gate timeouts in minutes (incident is special — 5m + priority alert) GATE_TIMEOUTS = { "deploy_billing_auth_payments": 10, "db_migration": 10, "dns_change": 10, "rls_policy_change": 10, "free_tier_exceed": 15, "new_third_party_service": 15, "regulated_content": 30, "incident_disclosure": 5, "partner_commitment": 30, "destructive_op": 5, } def node(id_, name, type_, pos, params, type_version=1): """Common n8n node shape.""" return { "id": id_, "name": name, "type": type_, "typeVersion": type_version, "position": pos, "parameters": params, } def supabase_select_headers(): """Headers for reading from ops schema via PostgREST.""" return { "parameters": [ {"name": "apikey", "value": "={{ $env.SB_LSA_PRO_SR }}"}, {"name": "Authorization", "value": "=Bearer {{ $env.SB_LSA_PRO_SR }}"}, {"name": "Accept-Profile", "value": "ops"}, ] } def supabase_write_headers(): """Headers for INSERT/UPDATE on ops schema.""" return { "parameters": [ {"name": "apikey", "value": "={{ $env.SB_LSA_PRO_SR }}"}, {"name": "Authorization", "value": "=Bearer {{ $env.SB_LSA_PRO_SR }}"}, {"name": "Content-Profile", "value": "ops"}, {"name": "Content-Type", "value": "application/json"}, {"name": "Prefer", "value": "return=representation"}, ] } # ----------------------------------------------------------------------------- # MAIN WORKFLOW — approval-request (webhook → poll → respond) # ----------------------------------------------------------------------------- def build_main_workflow(): nodes = [] connections = {} # 1. Webhook trigger nodes.append(node( "webhook-trigger", "Webhook Trigger", "n8n-nodes-base.webhook", [240, 300], { "httpMethod": "POST", "path": "approval-request", "responseMode": "responseNode", "options": {}, }, type_version=2, )) # 2. Set — normalize inputs + compute dry_run flag nodes.append(node( "normalize", "Normalize Inputs", "n8n-nodes-base.set", [440, 300], { "mode": "manual", "duplicateItem": False, "assignments": { "assignments": [ {"id": str(uuid.uuid4()), "name": "idempotency_key", "value": "={{ $json.body.idempotency_key }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "agent", "value": "={{ $json.body.agent }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "gate_id", "value": "={{ $json.body.gate_id }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "description", "value": "={{ $json.body.description }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "detail", "value": "={{ $json.body.detail || '' }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "risk_level", "value": "={{ $json.body.risk_level || 'medium' }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "session_id", "value": "={{ $json.body.session_id || '' }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "dry_run", "value": "={{ $json.query && $json.query.dry_run === 'true' }}", "type": "boolean"}, {"id": str(uuid.uuid4()), "name": "timeout_minutes", "value": "={{ ({" + ", ".join(f'\"{k}\": {v}' for k, v in GATE_TIMEOUTS.items()) + "})[$json.body.gate_id] || 10 }}", "type": "number"}, ] }, "options": {}, }, type_version=3.4, )) # 3. Validate required fields nodes.append(node( "validate", "Validate Payload", "n8n-nodes-base.if", [640, 300], { "conditions": { "options": {"caseSensitive": True, "typeValidation": "strict"}, "conditions": [ {"id": str(uuid.uuid4()), "leftValue": "={{ $json.idempotency_key }}", "rightValue": "", "operator": {"type": "string", "operation": "notEmpty"}}, {"id": str(uuid.uuid4()), "leftValue": "={{ $json.agent }}", "rightValue": "", "operator": {"type": "string", "operation": "notEmpty"}}, {"id": str(uuid.uuid4()), "leftValue": "={{ $json.gate_id }}", "rightValue": "", "operator": {"type": "string", "operation": "notEmpty"}}, {"id": str(uuid.uuid4()), "leftValue": "={{ $json.description }}", "rightValue": "", "operator": {"type": "string", "operation": "notEmpty"}}, ], "combinator": "and", }, }, type_version=2.2, )) # 3b. Respond 400 (validation failure branch) nodes.append(node( "respond-400", "Respond: 400 Bad Request", "n8n-nodes-base.respondToWebhook", [840, 460], { "respondWith": "json", "responseBody": '={{ JSON.stringify({error: "missing required field(s): idempotency_key, agent, gate_id, description"}) }}', "options": {"responseCode": 400}, }, type_version=1.1, )) # 4. Check idempotency — SELECT existing row. # alwaysOutputData ensures we get an item even when response is empty `[]` # (otherwise n8n splits the empty array into 0 items and halts the workflow). check_node = node( "check-idempotency", "Check Idempotency", "n8n-nodes-base.httpRequest", [840, 300], { "method": "GET", "url": f"={SUPABASE_URL}/rest/v1/{TABLE}?idempotency_key=eq.{{{{ $json.idempotency_key }}}}&select=*", "authentication": "none", "sendHeaders": True, "headerParameters": supabase_select_headers(), "options": {"response": {"response": {"neverError": True, "responseFormat": "json"}}}, }, type_version=4.2, ) check_node["alwaysOutputData"] = True nodes.append(check_node) # 4b. Wrap response so the next IF always has an item to evaluate nodes.append(node( "wrap-idempotency", "Wrap Idempotency Result", "n8n-nodes-base.code", [940, 300], { "mode": "runOnceForAllItems", "language": "javaScript", "jsCode": "// n8n's HTTP Request splits JSON arrays into items; each item's json is a single row.\n// With alwaysOutputData=true, a truly empty response still emits 1 item (possibly empty json).\n// Filter by idempotency_key presence to distinguish real rows from the phantom item.\nconst items = $input.all();\nconst rows = items.map(i => i.json).filter(r => r && typeof r === 'object' && r.idempotency_key);\nreturn [{ json: { rows, row_count: rows.length, first_row: rows[0] || null } }];\n", }, type_version=2, )) # 5. Route: existing pending (409) / existing resolved (cached) / new nodes.append(node( "route-existing", "Existing?", "n8n-nodes-base.if", [1040, 300], { "conditions": { "options": {"caseSensitive": True, "typeValidation": "loose"}, "conditions": [ {"id": str(uuid.uuid4()), "leftValue": "={{ $json.row_count }}", "rightValue": 0, "operator": {"type": "number", "operation": "gt"}}, ], "combinator": "and", }, }, type_version=2.2, )) # 5a. Route: existing pending → 409 nodes.append(node( "route-pending-or-resolved", "Pending or Resolved?", "n8n-nodes-base.if", [1240, 200], { "conditions": { "options": {"caseSensitive": True, "typeValidation": "strict"}, "conditions": [ {"id": str(uuid.uuid4()), "leftValue": "={{ $json.first_row.status }}", "rightValue": "pending", "operator": {"type": "string", "operation": "equals"}}, ], "combinator": "and", }, }, type_version=2.2, )) # 5b. Respond 409 nodes.append(node( "respond-409", "Respond: 409 Pending Dup", "n8n-nodes-base.respondToWebhook", [1440, 100], { "respondWith": "json", "responseBody": '={{ JSON.stringify({error: "duplicate pending request", idempotency_key: $json.first_row.idempotency_key, status: "pending"}) }}', "options": {"responseCode": 409}, }, type_version=1.1, )) # 5c. Respond with cached outcome nodes.append(node( "respond-cached", "Respond: Cached Outcome", "n8n-nodes-base.respondToWebhook", [1440, 300], { "respondWith": "json", "responseBody": '={{ JSON.stringify({gate_id: $json.first_row.gate_id, approved: $json.first_row.status === "approved", approver: $json.first_row.approver, timestamp: $json.first_row.resolved_at, status: $json.first_row.status, dry_run: $json.first_row.is_dry_run, cached: true}) }}', "options": {"responseCode": 200}, }, type_version=1.1, )) # 6. Insert new row (status=pending) nodes.append(node( "insert-row", "Insert Pending Row", "n8n-nodes-base.httpRequest", [1240, 500], { "method": "POST", "url": f"={SUPABASE_URL}/rest/v1/{TABLE}", "authentication": "none", "sendHeaders": True, "headerParameters": supabase_write_headers(), "sendBody": True, "bodyParameters": { "parameters": [ {"name": "idempotency_key", "value": "={{ $('Normalize Inputs').item.json.idempotency_key }}"}, {"name": "agent", "value": "={{ $('Normalize Inputs').item.json.agent }}"}, {"name": "gate_id", "value": "={{ $('Normalize Inputs').item.json.gate_id }}"}, {"name": "description", "value": "={{ $('Normalize Inputs').item.json.description }}"}, {"name": "detail", "value": "={{ $('Normalize Inputs').item.json.detail }}"}, {"name": "risk_level", "value": "={{ $('Normalize Inputs').item.json.risk_level }}"}, {"name": "session_id", "value": "={{ $('Normalize Inputs').item.json.session_id }}"}, {"name": "is_dry_run", "value": "={{ $('Normalize Inputs').item.json.dry_run }}"}, {"name": "status", "value": "pending"}, ] }, "options": {}, }, type_version=4.2, )) # 7. Send Telegram message with inline buttons nodes.append(node( "send-telegram", "Send Telegram Approval", "n8n-nodes-base.httpRequest", [1440, 500], { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN_OPS }}/sendMessage", "authentication": "none", "sendHeaders": True, "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}, "sendBody": True, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({\n" " chat_id: $env.TELEGRAM_CHAT_ID,\n" " text: `\\ud83d\\udd12 Human Gate — ${$('Normalize Inputs').item.json.gate_id}\\n\\nAgent: ${$('Normalize Inputs').item.json.agent}\\nAction: ${$('Normalize Inputs').item.json.description}\\n\\n${$('Normalize Inputs').item.json.detail}\\n\\nRisk: ${$('Normalize Inputs').item.json.risk_level}\\n\\n\\u23f0 Auto-rejects in ${$('Normalize Inputs').item.json.timeout_minutes}m.${$('Normalize Inputs').item.json.dry_run ? '\\n\\n(DRY-RUN — not actually blocking)' : ''}`,\n" " reply_markup: {\n" " inline_keyboard: [[\n" " {text: '\\u2705 Approve', callback_data: `approve:${$('Insert Pending Row').item.json.id}`},\n" " {text: '\\u274c Reject', callback_data: `reject:${$('Insert Pending Row').item.json.id}`}\n" " ]]\n" " }\n" "}) }}", "options": {}, }, type_version=4.2, )) # 7b. Store Telegram message_id on the row so timeout/revoke paths can edit it nodes.append(node( "store-msgid", "Store Message ID", "n8n-nodes-base.httpRequest", [1540, 620], { "method": "PATCH", "url": f"={SUPABASE_URL}/rest/v1/{TABLE}?idempotency_key=eq.{{{{ $('Normalize Inputs').item.json.idempotency_key }}}}", "authentication": "none", "sendHeaders": True, "headerParameters": supabase_write_headers(), "sendBody": True, "bodyParameters": { "parameters": [ {"name": "telegram_message_id", "value": "={{ $json.result.message_id }}"}, ] }, "options": {"response": {"response": {"neverError": True}}}, }, type_version=4.2, )) # 8. IF dry_run → respond immediately nodes.append(node( "is-dry-run", "Is Dry Run?", "n8n-nodes-base.if", [1740, 500], { "conditions": { "options": {"caseSensitive": True, "typeValidation": "strict"}, "conditions": [ {"id": str(uuid.uuid4()), "leftValue": "={{ $('Normalize Inputs').item.json.dry_run }}", "rightValue": True, "operator": {"type": "boolean", "operation": "true"}}, ], "combinator": "and", }, }, type_version=2.2, )) # 8a. Dry-run response nodes.append(node( "respond-dry-run", "Respond: Dry Run", "n8n-nodes-base.respondToWebhook", [1840, 400], { "respondWith": "json", "responseBody": '={{ JSON.stringify({gate_id: $(\'Normalize Inputs\').item.json.gate_id, approved: false, status: "pending", dry_run: true, message: "Dry run — not blocking"}) }}', "options": {"responseCode": 200}, }, type_version=1.1, )) # 9. POLLING LOOP — Wait 3s then check status nodes.append(node( "poll-wait", "Poll Wait 3s", "n8n-nodes-base.wait", [1840, 600], {"amount": 3, "unit": "seconds"}, type_version=1.1, )) # 10. Check current status nodes.append(node( "check-status", "Check Status", "n8n-nodes-base.httpRequest", [2040, 600], { "method": "GET", "url": f"={SUPABASE_URL}/rest/v1/{TABLE}?idempotency_key=eq.{{{{ $('Normalize Inputs').item.json.idempotency_key }}}}&select=*", "authentication": "none", "sendHeaders": True, "headerParameters": supabase_select_headers(), "options": {"response": {"response": {"neverError": True, "responseFormat": "json"}}}, }, type_version=4.2, )) # 11. IF status != pending → break out of loop nodes.append(node( "is-resolved", "Is Resolved?", "n8n-nodes-base.if", [2240, 600], { "conditions": { "options": {"caseSensitive": True, "typeValidation": "strict"}, "conditions": [ {"id": str(uuid.uuid4()), "leftValue": "={{ $json.status }}", "rightValue": "pending", "operator": {"type": "string", "operation": "notEquals"}}, ], "combinator": "and", }, }, type_version=2.2, )) # 12. IF total elapsed > timeout → flip to timeout nodes.append(node( "check-timeout", "Timeout Reached?", "n8n-nodes-base.if", [2240, 800], { "conditions": { "options": {"caseSensitive": True, "typeValidation": "loose"}, "conditions": [ {"id": str(uuid.uuid4()), "leftValue": "={{ (new Date() - new Date($('Check Status').item.json.requested_at)) / 60000 }}", "rightValue": "={{ $('Normalize Inputs').item.json.timeout_minutes }}", "operator": {"type": "number", "operation": "gte"}}, ], "combinator": "and", }, }, type_version=2.2, )) # 13. On timeout: update row to timeout status nodes.append(node( "update-timeout", "Update: Timeout", "n8n-nodes-base.httpRequest", [2440, 900], { "method": "PATCH", "url": f"={SUPABASE_URL}/rest/v1/{TABLE}?idempotency_key=eq.{{{{ $('Normalize Inputs').item.json.idempotency_key }}}}&status=eq.pending", "authentication": "none", "sendHeaders": True, "headerParameters": supabase_write_headers(), "sendBody": True, "bodyParameters": { "parameters": [ {"name": "status", "value": "timeout"}, {"name": "resolved_at", "value": "={{ new Date().toISOString() }}"}, ] }, "options": {}, }, type_version=4.2, )) # 14a. Edit original approval message to strip buttons + show timeout nodes.append(node( "edit-timeout-msg", "Edit Original: Timeout", "n8n-nodes-base.httpRequest", [2640, 800], { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN_OPS }}/editMessageText", "authentication": "none", "sendHeaders": True, "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}, "sendBody": True, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({chat_id: $env.TELEGRAM_CHAT_ID, message_id: $('Check Status').item.json.telegram_message_id, text: `\\ud83d\\udd12 Human Gate — ${$('Normalize Inputs').item.json.gate_id}\\n\\nAgent: ${$('Normalize Inputs').item.json.agent}\\nAction: ${$('Normalize Inputs').item.json.description}\\n\\nRisk: ${$('Normalize Inputs').item.json.risk_level}\\n\\n\\u23f0 AUTO-REJECTED — no response in ${$('Normalize Inputs').item.json.timeout_minutes}m`}) }}", "options": {"response": {"response": {"neverError": True}}}, }, type_version=4.2, )) # 14. Send timeout follow-up Telegram (priority alert for incidents only) nodes.append(node( "send-timeout-msg", "Send Timeout Notice", "n8n-nodes-base.httpRequest", [2840, 900], { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN_OPS }}/sendMessage", "authentication": "none", "sendHeaders": True, "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}, "sendBody": True, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({chat_id: $env.TELEGRAM_CHAT_ID, text: `\\u23f0 Auto-rejected: ${$('Normalize Inputs').item.json.description} (no response in ${$('Normalize Inputs').item.json.timeout_minutes}m)` + ($('Normalize Inputs').item.json.gate_id === 'incident_disclosure' ? '\\n\\n\\ud83d\\udea8 INCIDENT DISCLOSURE — immediate visibility required' : '')}) }}", "options": {"response": {"response": {"neverError": True}}}, }, type_version=4.2, )) # 15. Respond timeout nodes.append(node( "respond-timeout", "Respond: Timeout", "n8n-nodes-base.respondToWebhook", [3040, 900], { "respondWith": "json", "responseBody": '={{ JSON.stringify({gate_id: $(\'Normalize Inputs\').item.json.gate_id, approved: false, status: "timeout", dry_run: false, message: "Auto-rejected on timeout"}) }}', "options": {"responseCode": 200}, }, type_version=1.1, )) # 16. On resolved (approve/reject/revoke): respond with row data nodes.append(node( "respond-resolved", "Respond: Resolved", "n8n-nodes-base.respondToWebhook", [2440, 500], { "respondWith": "json", "responseBody": '={{ JSON.stringify({gate_id: $(\'Check Status\').item.json.gate_id, approved: $(\'Check Status\').item.json.status === "approved", approver: $(\'Check Status\').item.json.approver, timestamp: $(\'Check Status\').item.json.resolved_at, status: $(\'Check Status\').item.json.status, dry_run: $(\'Check Status\').item.json.is_dry_run}) }}', "options": {"responseCode": 200}, }, type_version=1.1, )) # Connections (edges) connections = { "Webhook Trigger": {"main": [[{"node": "Normalize Inputs", "type": "main", "index": 0}]]}, "Normalize Inputs": {"main": [[{"node": "Validate Payload", "type": "main", "index": 0}]]}, "Validate Payload": { "main": [ [{"node": "Check Idempotency", "type": "main", "index": 0}], # true [{"node": "Respond: 400 Bad Request", "type": "main", "index": 0}], # false ] }, "Check Idempotency": {"main": [[{"node": "Wrap Idempotency Result", "type": "main", "index": 0}]]}, "Wrap Idempotency Result": {"main": [[{"node": "Existing?", "type": "main", "index": 0}]]}, "Existing?": { "main": [ [{"node": "Pending or Resolved?", "type": "main", "index": 0}], # existing [{"node": "Insert Pending Row", "type": "main", "index": 0}], # new ] }, "Pending or Resolved?": { "main": [ [{"node": "Respond: 409 Pending Dup", "type": "main", "index": 0}], # pending [{"node": "Respond: Cached Outcome", "type": "main", "index": 0}], # resolved ] }, "Insert Pending Row": {"main": [[{"node": "Send Telegram Approval", "type": "main", "index": 0}]]}, "Send Telegram Approval": {"main": [[{"node": "Store Message ID", "type": "main", "index": 0}]]}, "Store Message ID": {"main": [[{"node": "Is Dry Run?", "type": "main", "index": 0}]]}, "Is Dry Run?": { "main": [ [{"node": "Respond: Dry Run", "type": "main", "index": 0}], # true [{"node": "Poll Wait 3s", "type": "main", "index": 0}], # false ] }, "Poll Wait 3s": {"main": [[{"node": "Check Status", "type": "main", "index": 0}]]}, "Check Status": {"main": [[{"node": "Is Resolved?", "type": "main", "index": 0}]]}, "Is Resolved?": { "main": [ [{"node": "Respond: Resolved", "type": "main", "index": 0}], # resolved [{"node": "Timeout Reached?", "type": "main", "index": 0}], # still pending ] }, "Timeout Reached?": { "main": [ [{"node": "Update: Timeout", "type": "main", "index": 0}], # timeout [{"node": "Poll Wait 3s", "type": "main", "index": 0}], # keep polling ] }, "Update: Timeout": {"main": [[{"node": "Edit Original: Timeout", "type": "main", "index": 0}]]}, "Edit Original: Timeout": {"main": [[{"node": "Send Timeout Notice", "type": "main", "index": 0}]]}, "Send Timeout Notice": {"main": [[{"node": "Respond: Timeout", "type": "main", "index": 0}]]}, } return { "name": "Approvals — Request Handler (GAL-109)", "nodes": nodes, "connections": connections, "settings": {"executionOrder": "v1", "saveExecutionProgress": True, "saveManualExecutions": True, "callerPolicy": "workflowsFromSameOwner"}, } # ----------------------------------------------------------------------------- # CALLBACK WORKFLOW — telegram-callback (button press → update row + edit msg) # ----------------------------------------------------------------------------- def build_callback_workflow(): nodes = [] # 1. Webhook for Telegram bot updates (setWebhook points here) nodes.append(node( "tg-webhook", "Telegram Webhook", "n8n-nodes-base.webhook", [240, 300], { "httpMethod": "POST", "path": "telegram-approvals-callback", "responseMode": "onReceived", "responseCode": 200, "responseData": "noData", "options": {}, }, type_version=2, )) # 2. Filter: is this a callback_query? (robust against non-callback updates) nodes.append(node( "is-callback", "Is Callback Query?", "n8n-nodes-base.if", [440, 300], { "conditions": { "options": {"caseSensitive": True, "typeValidation": "loose"}, "conditions": [ {"id": str(uuid.uuid4()), "leftValue": "={{ !!($json.body && $json.body.callback_query && $json.body.callback_query.data) }}", "rightValue": True, "operator": {"type": "boolean", "operation": "true"}}, ], "combinator": "and", }, }, type_version=2.2, )) # 3. Parse callback_data: "approve:" or "reject:" nodes.append(node( "parse", "Parse Callback", "n8n-nodes-base.set", [640, 300], { "mode": "manual", "assignments": { "assignments": [ {"id": str(uuid.uuid4()), "name": "action", "value": "={{ ($json.body.callback_query.data || '').split(':')[0] }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "row_id", "value": "={{ ($json.body.callback_query.data || '').split(':').slice(1).join(':') }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "callback_query_id", "value": "={{ $json.body.callback_query.id }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "chat_id", "value": "={{ $json.body.callback_query.message.chat.id }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "message_id", "value": "={{ $json.body.callback_query.message.message_id }}", "type": "number"}, {"id": str(uuid.uuid4()), "name": "approver", "value": "={{ $json.body.callback_query.from.username || $json.body.callback_query.from.first_name }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "new_status", "value": "={{ $json.body.callback_query.data.startsWith('approve:') ? 'approved' : 'rejected' }}", "type": "string"}, ] }, }, type_version=3.4, )) # 4. Update approval_requests row (only if still pending) nodes.append(node( "update-row", "Update Row Status", "n8n-nodes-base.httpRequest", [840, 300], { "method": "PATCH", "url": f"={SUPABASE_URL}/rest/v1/{TABLE}?id=eq.{{{{ $json.row_id }}}}&status=eq.pending", "authentication": "none", "sendHeaders": True, "headerParameters": supabase_write_headers(), "sendBody": True, "bodyParameters": { "parameters": [ {"name": "status", "value": "={{ $json.new_status }}"}, {"name": "approver", "value": "={{ $json.approver }}"}, {"name": "resolved_at", "value": "={{ new Date().toISOString() }}"}, ] }, "options": {"response": {"response": {"neverError": True, "responseFormat": "json"}}}, }, type_version=4.2, )) # 5. Answer the Telegram callback query (UX — dismisses the "loading" spinner) nodes.append(node( "answer-cbq", "Answer Callback Query", "n8n-nodes-base.httpRequest", [1040, 200], { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN_OPS }}/answerCallbackQuery", "authentication": "none", "sendHeaders": True, "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}, "sendBody": True, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({callback_query_id: $('Parse Callback').item.json.callback_query_id, text: `Marked as ${$('Parse Callback').item.json.new_status}`, show_alert: false}) }}", "options": {}, }, type_version=4.2, )) # 6. Edit the Telegram message to show the outcome nodes.append(node( "edit-msg", "Edit Message", "n8n-nodes-base.httpRequest", [1040, 400], { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN_OPS }}/editMessageText", "authentication": "none", "sendHeaders": True, "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}, "sendBody": True, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({chat_id: $('Parse Callback').item.json.chat_id, message_id: $('Parse Callback').item.json.message_id, text: $('Telegram Webhook').item.json.body.callback_query.message.text + `\\n\\n${$('Parse Callback').item.json.new_status === 'approved' ? '\\u2705 APPROVED' : '\\u274c REJECTED'} by ${$('Parse Callback').item.json.approver} at ${new Date().toISOString()}`}) }}", "options": {}, }, type_version=4.2, )) connections = { "Telegram Webhook": {"main": [[{"node": "Is Callback Query?", "type": "main", "index": 0}]]}, "Is Callback Query?": {"main": [[{"node": "Parse Callback", "type": "main", "index": 0}], []]}, "Parse Callback": {"main": [[{"node": "Update Row Status", "type": "main", "index": 0}]]}, "Update Row Status": {"main": [[{"node": "Answer Callback Query", "type": "main", "index": 0}, {"node": "Edit Message", "type": "main", "index": 0}]]}, } return { "name": "Approvals — Telegram Callback Handler (GAL-109)", "nodes": nodes, "connections": connections, "settings": {"executionOrder": "v1", "saveExecutionProgress": True, "callerPolicy": "workflowsFromSameOwner"}, } # ----------------------------------------------------------------------------- # REVOKE WORKFLOW — approval-revoke (agent cancels pending approval) # ----------------------------------------------------------------------------- def build_revoke_workflow(): nodes = [] # 1. Webhook nodes.append(node( "revoke-webhook", "Revoke Webhook", "n8n-nodes-base.webhook", [240, 300], { "httpMethod": "POST", "path": "approval-revoke", "responseMode": "responseNode", "options": {}, }, type_version=2, )) # 2. Normalize nodes.append(node( "normalize-revoke", "Normalize", "n8n-nodes-base.set", [440, 300], { "mode": "manual", "assignments": { "assignments": [ {"id": str(uuid.uuid4()), "name": "idempotency_key", "value": "={{ $json.body.idempotency_key }}", "type": "string"}, {"id": str(uuid.uuid4()), "name": "reason", "value": "={{ $json.body.reason || 'no reason given' }}", "type": "string"}, ] }, }, type_version=3.4, )) # 3. Fetch current row (alwaysOutputData for empty-response case) fetch_node = node( "fetch-row", "Fetch Row", "n8n-nodes-base.httpRequest", [640, 300], { "method": "GET", "url": f"={SUPABASE_URL}/rest/v1/{TABLE}?idempotency_key=eq.{{{{ $json.idempotency_key }}}}&select=*", "authentication": "none", "sendHeaders": True, "headerParameters": supabase_select_headers(), "options": {"response": {"response": {"neverError": True, "responseFormat": "json"}}}, }, type_version=4.2, ) fetch_node["alwaysOutputData"] = True nodes.append(fetch_node) # 4a. Wrap response (0-rows case) so IF always has an item nodes.append(node( "wrap-fetch", "Wrap Fetch Result", "n8n-nodes-base.code", [740, 300], { "mode": "runOnceForAllItems", "language": "javaScript", "jsCode": "// n8n's HTTP Request splits JSON arrays into items; each item's json is a single row.\n// With alwaysOutputData=true, a truly empty response still emits 1 item (possibly empty json).\n// Filter by idempotency_key presence to distinguish real rows from the phantom item.\nconst items = $input.all();\nconst rows = items.map(i => i.json).filter(r => r && typeof r === 'object' && r.idempotency_key);\nreturn [{ json: { rows, row_count: rows.length, first_row: rows[0] || null } }];\n", }, type_version=2, )) # 4b. Branch: exists & pending / exists & resolved / not found nodes.append(node( "is-pending", "Row Exists & Pending?", "n8n-nodes-base.if", [840, 300], { "conditions": { "options": {"caseSensitive": True, "typeValidation": "strict"}, "conditions": [ {"id": str(uuid.uuid4()), "leftValue": "={{ $json.row_count }}", "rightValue": 0, "operator": {"type": "number", "operation": "gt"}}, {"id": str(uuid.uuid4()), "leftValue": "={{ $json.first_row.status }}", "rightValue": "pending", "operator": {"type": "string", "operation": "equals"}}, ], "combinator": "and", }, }, type_version=2.2, )) # 5. Update row to revoked nodes.append(node( "update-revoked", "Update to Revoked", "n8n-nodes-base.httpRequest", [1040, 200], { "method": "PATCH", "url": f"={SUPABASE_URL}/rest/v1/{TABLE}?idempotency_key=eq.{{{{ $('Normalize').item.json.idempotency_key }}}}&status=eq.pending", "authentication": "none", "sendHeaders": True, "headerParameters": supabase_write_headers(), "sendBody": True, "bodyParameters": { "parameters": [ {"name": "status", "value": "revoked"}, {"name": "resolved_at", "value": "={{ new Date().toISOString() }}"}, ] }, "options": {}, }, type_version=4.2, )) # 6a. Edit original approval message to strip buttons + show revoke nodes.append(node( "edit-revoke-msg", "Edit Original: Revoke", "n8n-nodes-base.httpRequest", [1240, 120], { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN_OPS }}/editMessageText", "authentication": "none", "sendHeaders": True, "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}, "sendBody": True, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({chat_id: $env.TELEGRAM_CHAT_ID, message_id: $('Wrap Fetch Result').item.json.first_row.telegram_message_id, text: `\\ud83d\\udd04 Human Gate — ${$('Wrap Fetch Result').item.json.first_row.gate_id}\\n\\nAgent: ${$('Wrap Fetch Result').item.json.first_row.agent}\\nAction: ${$('Wrap Fetch Result').item.json.first_row.description}\\n\\nRisk: ${$('Wrap Fetch Result').item.json.first_row.risk_level}\\n\\n\\ud83d\\udd04 REVOKED by agent — ${$('Normalize').item.json.reason}`}) }}", "options": {"response": {"response": {"neverError": True}}}, }, type_version=4.2, )) # 6b. Send revoke follow-up Telegram (visible notification in the chat) nodes.append(node( "send-revoke-notice", "Send Revoke Notice", "n8n-nodes-base.httpRequest", [1440, 200], { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN_OPS }}/sendMessage", "authentication": "none", "sendHeaders": True, "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}, "sendBody": True, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({chat_id: $env.TELEGRAM_CHAT_ID, text: `\\ud83d\\udd04 Revoked by agent ${$('Wrap Fetch Result').item.json.first_row.agent}: ${$('Wrap Fetch Result').item.json.first_row.description}\\nReason: ${$('Normalize').item.json.reason}`}) }}", "options": {"response": {"response": {"neverError": True}}}, }, type_version=4.2, )) # 7. Respond 200 success nodes.append(node( "respond-revoked", "Respond: Revoked", "n8n-nodes-base.respondToWebhook", [1440, 200], { "respondWith": "json", "responseBody": '={{ JSON.stringify({status: "revoked", idempotency_key: $(\'Normalize\').item.json.idempotency_key}) }}', "options": {"responseCode": 200}, }, type_version=1.1, )) # 8. Respond 409 (already resolved or not found) nodes.append(node( "respond-conflict", "Respond: Conflict", "n8n-nodes-base.respondToWebhook", [1040, 400], { "respondWith": "json", "responseBody": '={{ JSON.stringify({error: $json.row_count === 0 ? "not_found" : "already_resolved", current_status: $json.first_row ? $json.first_row.status : null}) }}', "options": {"responseCode": 409}, }, type_version=1.1, )) connections = { "Revoke Webhook": {"main": [[{"node": "Normalize", "type": "main", "index": 0}]]}, "Normalize": {"main": [[{"node": "Fetch Row", "type": "main", "index": 0}]]}, "Fetch Row": {"main": [[{"node": "Wrap Fetch Result", "type": "main", "index": 0}]]}, "Wrap Fetch Result": {"main": [[{"node": "Row Exists & Pending?", "type": "main", "index": 0}]]}, "Row Exists & Pending?": { "main": [ [{"node": "Update to Revoked", "type": "main", "index": 0}], [{"node": "Respond: Conflict", "type": "main", "index": 0}], ] }, "Update to Revoked": {"main": [[{"node": "Edit Original: Revoke", "type": "main", "index": 0}]]}, "Edit Original: Revoke": {"main": [[{"node": "Send Revoke Notice", "type": "main", "index": 0}]]}, "Send Revoke Notice": {"main": [[{"node": "Respond: Revoked", "type": "main", "index": 0}]]}, } return { "name": "Approvals — Revoke Handler (GAL-109)", "nodes": nodes, "connections": connections, "settings": {"executionOrder": "v1", "saveExecutionProgress": True, "callerPolicy": "workflowsFromSameOwner"}, } # ----------------------------------------------------------------------------- # Emit JSON files # ----------------------------------------------------------------------------- if __name__ == "__main__": import os out = os.path.dirname(os.path.abspath(__file__)) main_wf = build_main_workflow() cb_wf = build_callback_workflow() rv_wf = build_revoke_workflow() for fn, wf in [ ("01-approval-request.json", main_wf), ("02-telegram-callback.json", cb_wf), ("03-approval-revoke.json", rv_wf), ]: path = os.path.join(out, fn) with open(path, "w") as f: json.dump(wf, f, indent=2) print(f"wrote {fn} ({len(wf['nodes'])} nodes)")