workflow-main.json → b//tmp/n8n-approval-workflow/workflow-main.json @@ -0,0 +1,345 @@ +{ + "name": "Dev Approval — Request Handler", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "approval-request", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-trigger", + "name": "Webhook Trigger", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [0, 0], + "webhookId": "approval-request" + }, + { + "parameters": { + "jsCode": "// Validate payload and extract dry_run flag\nconst raw = $input.first().json;\nconst body = raw.body || raw;\nconst query = raw.query || {};\n\nconst required = ['idempotency_key', 'agent', 'gate_id', 'description', 'risk_level'];\nconst missing = required.filter(f => !body[f]);\nif (missing.length > 0) {\n throw new Error(`Missing required fields: ${missing.join(', ')}`);\n}\n\nconst validRisk = ['low', 'medium', 'high'];\nif (!validRisk.includes(body.risk_level)) {\n throw new Error(`Invalid risk_level: ${body.risk_level}`);\n}\n\nconst validGates = [\n 'deploy_billing_auth_payments', 'db_migration', 'dns_change',\n 'rls_policy_change', 'free_tier_exceed', 'new_third_party_service',\n 'regulated_content', 'incident_disclosure', 'partner_commitment', 'destructive_op'\n];\nif (!validGates.includes(body.gate_id)) {\n throw new Error(`Invalid gate_id: ${body.gate_id}`);\n}\n\nconst dryRun = query.dry_run === 'true';\n\nreturn [{\n json: {\n idempotency_key: body.idempotency_key,\n agent: body.agent,\n gate_id: body.gate_id,\n description: body.description,\n detail: body.detail || null,\n risk_level: body.risk_level,\n session_id: body.session_id || null,\n dry_run: dryRun\n }\n}];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [220, 0] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "=SELECT id, status, approver, resolved_at, gate_id FROM ops.approval_requests WHERE idempotency_key = '{{ $json.idempotency_key }}' LIMIT 1;", + "options": {} + }, + "id": "check-idempotency", + "name": "Check Idempotency", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [440, 0], + "credentials": { + "postgres": { + "id": "REPLACE_WITH_N8N_POSTGRES_CREDENTIAL_ID", + "name": "LSA Pro (Supabase)" + } + } + }, + { + "parameters": { + "jsCode": "const existing = $input.first().json;\nconst data = $('Validate Payload').first().json;\n\nif (existing && existing.id) {\n if (existing.status === 'pending') {\n return [{ json: { action: 'conflict', status_code: 409, message: 'Request already pending' } }];\n } else {\n return [{ json: {\n action: 'cached',\n approved: existing.status === 'approved',\n approver: existing.approver,\n status: existing.status,\n resolved_at: existing.resolved_at,\n gate_id: existing.gate_id,\n status_code: 200\n }}];\n }\n}\n\nreturn [{ json: { action: 'proceed', ...data } }];" + }, + "id": "idempotency-decision", + "name": "Idempotency Decision", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [660, 0] + }, + { + "parameters": { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [{ + "id": "check-proceed", + "leftValue": "={{ $json.action }}", + "rightValue": "proceed", + "operator": { "type": "string", "operation": "equals" } + }], + "combinator": "and" + }, + "options": {} + }, + "id": "is-proceed", + "name": "Is Proceed?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [880, 0] + }, + { + "parameters": { + "respondWith": "json", … omitted 267 diff line(s) across 1 additional file(s)/section(s) ⚠️ Reached maximum iterations (15). Requesting summary... Here's what I accomplished this run: ## Part 1: Supabase Migration — DONE Verified and executed the full migration against LSA_PRO via Management API. All checks pass: - RLS: relrowsecurity = true ✓ - 4 named indexes: idx_approval_requests_idempotency, idx_approval_requests_status, idx_approval_requests_agent_gate, idx_approval_requests_requested_at ✓ - 2 policies: service_role_all + authenticated_read ✓ - Table + column comments applied ✓ SQL that ran (copy-pasteable): ```sql ALTER TABLE ops.approval_requests ENABLE ROW LEVEL SECURITY; CREATE POLICY "service_role_all" ON ops.approval_requests USING (true) WITH CHECK (true); CREATE POLICY "authenticated_read" ON ops.approval_requests FOR SELECT USING (auth.role() = 'authenticated'); CREATE UNIQUE INDEX idx_approval_requests_idempotency ON ops.approval_requests (idempotency_key); CREATE INDEX idx_approval_requests_status ON ops.approval_requests (status); CREATE INDEX idx_approval_requests_agent_gate ON ops.approval_requests (agent, gate_id); CREATE INDEX idx_approval_requests_requested_at ON ops.approval_requests (requested_at DESC); COMMENT ON TABLE ops.approval_requests IS 'Dev department human-gate approval requests. Immutable audit trail — only status, resolved_at, and approver may update.'; -- plus 4 column comments ``` ## Part 2: n8n Workflow — IN PROGRESS Built the main workflow JSON at `/tmp/n8n-approval-workflow/workflow-main.json`. The workflow implements the full spec: - Webhook trigger with ?dry_run=true support - Payload validation (required fields, valid gate_id, valid risk_level) - Idempotency check against ops.approval_requests (409 on pending duplicate, cached outcome on resolved) - INSERT into Supabase with pending status - Telegram message with inline approve/reject buttons - Synchronous await: poll loop checks staticData every 2s for callback or timeout - Per-gate timeout table (5m/10m/15m/30m) - Timeout handling: auto-reject + follow-up Telegram + priority-channel alert for incident_disclosure - Dry-run: returns immediately with dry_run:true ## Still Needed (Part 2 completion + Part 3/4): 1. **Callback workflow** — A separate n8n workflow for Telegram inline button callbacks. When João taps Approve/Reject, the callback workflow needs to: parse callback_data (approve:{request_id} or reject:{request_id}), update staticData on the main workflow, then update ops.approval_requests status. I was building this when turns ran out. 2. **Revoke workflow** — POST /api/approval-revoke endpoint. Needs to: find pending row by idempotency_key, update to revoked, edit Telegram message. 3. **Config values still needed from João:** - n8n instance URL + credentials (n8n API key for import) - Telegram bot token (existing bot that DMs João) - João's Telegram chat ID - Supabase connection string for n8n Postgres credential (host, user, password) - n8n Postgres credential ID (or create new one) 4. **Acceptance testing** (Part 3) — requires live n8n instance + Telegram bot Let me post a progress comment to the issue. [hermes] Exit code: 0, timed out: false [hermes] Session: body