Skip to content

PoutineAI — Comprehensive Architecture & Security Review

Date: 2026-02-11
Reviewer: Friday (AI Agent)
Repo: /root/clawd/repos/poutineai (~927 files)
Stack: Python 3.11 / FastAPI + React 18 / TypeScript + PostgreSQL 17.6
Deployment: Docker Compose on Hetzner VPS, Caddy reverse proxy, Ansible provisioning


1. Architecture Overview

System Diagram

                    ┌─────────┐
  Email (Mailgun) ──┤         │
                    │  Caddy  │──── Static React SPA
  WhatsApp (Twilio)─┤  :80/443│
                    └────┬────┘
                         │ reverse_proxy
                    ┌────▼────┐        ┌──────────┐
                    │   API   │◄──────►│ Postgres  │
                    │ FastAPI │        │   17.6    │
                    │  :8000  │        └──────────┘
                    └────┬────┘
                         │ filesystem queue
                    ┌────▼────┐
                    │  Worker │ (polls local_queue/)
                    │ Gemini  │
                    │ 2.5Flash│
                    └─────────┘

Components

Component Role Notes
Caddy TLS termination, static file serving, reverse proxy Auto HTTPS, security headers, CSP
API (FastAPI) REST API, webhook receiver, auth, SPA serving 14 routers, slowapi rate limiting
Worker Filesystem queue poller, AI extraction, price history Gemini 2.5 Flash primary, OpenRouter fallback
PostgreSQL 17.6 Primary datastore 15 tables, Alembic migrations
Local Filesystem File storage + message queue local_storage/uploads/, local_queue/

Data Flow

  1. Invoice arrives via email webhook (Mailgun), WhatsApp webhook (Twilio), or manual upload
  2. File saved to local filesystem, Invoice record created in DB (status=PROCESSING)
  3. Message written to filesystem queue (local_queue/doc.ingested/)
  4. Worker polls queue, sends file to Gemini 2.5 Flash for extraction
  5. Structured data persisted (InvoiceExtraction + InvoiceLines), status → AWAITING_APPROVAL
  6. User reviews/approves in React UI
  7. Export to Excel, QuickBooks IIF, Xero CSV, hledger journal, or universal CSV

Multi-Tenancy

  • Full tenant isolation via tenant_id FK on Invoice, enforced through require_tenant_id dependency
  • Each tenant gets a unique Mailgun inbound alias email
  • WhatsApp sender allowlist maps phone → tenant
  • Roles: global_admin (cross-tenant), admin (tenant), member (tenant)

Multi-Brand / Multi-Region

  • Brand system: poutineai (CA-QC), sundayroast (UK), vetbooks (US future)
  • Region-aware tax config (GST/QST, VAT, Sales Tax)
  • Per-instance .env config for brand/region/locale

2. Security Audit

2.1 Authentication & Authorization ✅ Generally Good

Auth flow: Passwordless magic links (email) → JWT in httpOnly cookie

Strengths: - Magic link tokens hashed (SHA-256) before storage - Single-use tokens with 15-min expiry - Per-email rate limiting (3 links per 15 min) - JWT in httpOnly, secure (prod), samesite=lax cookie - Timing-attack resistant constant-time comparison for Mailgun HMAC - Dev-only JWT fallback explicitly blocked in production (_is_production_env()) - Revoked users properly blocked - Primary super admin protected from demotion/revocation

Concerns: - P1: auth/login endpoint allows passwordless login with just an email — no magic link required. This is effectively an auth bypass for any known email. It issues a full session cookie. This endpoint should be removed or restricted to dev only. - P2: JWT secret falls back to "dev-ephemeral-secret" in dev mode — acceptable but logged as warning. - P2: Session TTL is 7 days (SESSION_TTL_MINUTES = 60 * 24 * 7). Consider shorter sessions with refresh.

2.2 Hardcoded Secrets ✅ Clean

  • No hardcoded API keys or secrets found in code
  • All secrets loaded from environment variables
  • .env.example contains only placeholder values
  • Super admin email in docker-compose is configurable via env var (not hardcoded in code logic)

2.3 SQL Injection ✅ Safe

  • All queries use SQLAlchemy ORM — no raw SQL anywhere (only text("SELECT 1") for health check)
  • Parameterized queries throughout
  • No string interpolation in queries

2.4 CORS ✅ Properly Configured

  • CORS origins loaded from environment variable (POUTINEAI_CORS_ORIGINS)
  • Not wildcard — explicitly listed origins only
  • allow_credentials=True with explicit origin list (not *)

2.5 Input Validation ⚠️ Mixed

Good: - File upload: magic byte validation prevents spoofed extensions - File size limit: 20MB enforced - Allowed types whitelist (PDF, JPEG, PNG, HEIC/HEIF) - Pydantic models for request bodies with EmailStr validation - Path traversal protection on document serving (uploads_dir prefix check) - SSRF protection on Twilio media URLs (domain allowlist) - Batch upload limited to 10 files

Gaps: - P1: UpdateLinesRequest.lines accepts list[dict] with no field validation — any keys/values pass through - P2: Supplier name in URL path (/suppliers/{supplier_name}) is URL-decoded but not sanitized for display - P2: sort and order query params have whitelist for sort but order only checks "desc" vs anything else

2.6 Webhook Security ✅ Good

  • Mailgun: HMAC-SHA256 signature verification with replay protection (5-min timestamp window)
  • Twilio: Official RequestValidator from twilio SDK
  • Both can be disabled ONLY in non-production environments (env check)
  • Unknown email recipients are silently dropped (prevents spam intake)

2.7 Dependency Vulnerabilities ⚠️ Review Needed

requirements-docker.txt — no version pins at all:

fastapi
uvicorn[standard]
requests
pydantic
...
- P0: Unpinned dependencies mean any pip install pulls latest — risk of supply chain attacks or breaking changes in production - oci package included but Oracle Document AI doesn't appear actively used (dead dependency?) - twilio>=9.0.0 in pyproject.toml but unpinned in docker requirements

Frontend (web/package.json): Versions look reasonable and recent.

2.8 Docker Security ⚠️ Runs as Root

  • P1: Both Dockerfile.api and Dockerfile.worker use python:3.11-slim base and run as root (no USER directive)
  • P2: API container exposes port 8010 to host (ports: "8010:8000") — should be internal-only if Caddy is the entry point
  • DB password defaults to change-me in docker-compose (acceptable since it's overridden by .env)
  • Host volumes mounted at /var/lib/poutineai/ — ensure proper permissions on host

2.9 Rate Limiting ✅ Good

  • slowapi integrated at app level and per-router
  • Per-user rate limiting (tenant+email identity, fallback to IP)
  • Configurable limits via environment variables
  • Separate limits for webhooks, reprocessing, and general API
  • Magic link requests have per-email rate limiting (3/15min)

2.10 File Upload Security ✅ Good

  • Magic byte validation (PDF, JPEG, PNG, HEIC signatures)
  • Content hash deduplication
  • Files stored with UUID prefix (prevents name collisions)
  • Date-partitioned directory structure
  • Path traversal protection on retrieval

2.11 Security Headers ✅ Excellent (Caddy)

  • HSTS with preload
  • X-Frame-Options: SAMEORIGIN
  • X-Content-Type-Options: nosniff
  • CSP policy (default-src 'self')
  • Permissions-Policy (camera/mic/geo/payment disabled)
  • Referrer-Policy: strict-origin-when-cross-origin

2.12 Error Handling in Webhooks ⚠️

  • P2: WhatsApp webhook catches all exceptions and returns 500 with detail=f"Internal error: {str(e)}" — leaks internal error messages to Twilio. Should return generic error.

3. Code Quality

3.1 Strengths

  • Clean separation: API routers / security / services / models / worker
  • Consistent use of SQLAlchemy ORM with context managers (with SessionLocal() as db)
  • Good audit logging throughout (AuditEvent for all invoice state changes)
  • Comprehensive extraction post-processing (_validate_and_fix)
  • Smart price history tracking with alert detection
  • Multi-region/brand abstraction is clean

3.2 Issues

Dead Code / Unused: - oci dependency — Oracle Document AI not actively used - _run_pubsub() function in worker — just logs a warning - common/gcp_secrets.py referenced from mailgun.py — GCP Secret Manager for self-hosted? - jinja2 in dependencies — not used anywhere visible - Legacy gst_total/qst_total fields maintained alongside v4 tax fields — tech debt

Inconsistencies: - _audit() in invoices.py has parameter order (db, invoice_id, event, actor, meta) but some calls pass positional args as (db, invoice_id, "edited", {dict}) — skipping actor, passing meta as actor - Actually looking closer: _audit signature is _audit(db, invoice_id, event, actor="system", meta=None) but the update_extraction call does _audit(db, invoice_id, "edited", {"changed_fields": ...}) — this passes the dict as actor, not meta. This is a bug. - Boolean columns use Integer type (active, is_active) with comments "for SQLite simplicity" — but the project is Postgres-only now

Missing Error Handling: - Batch upload silently swallows exceptions in the loop with bare except Exception - Worker _write_logs has no error handling — filesystem write failure could crash extraction

Test Coverage: - P1: Zero test files found. No unit tests, no integration tests. Only e2e Playwright config exists (playwright.config.js) but no test files.

3.3 Code Duplication

  • _display_status() defined identically in invoices.py and suppliers.py
  • Status filter parsing (approved_paid/unpaid/rejected) duplicated across invoices and suppliers
  • Confidence calculation logic duplicated between _persist_extraction and _write_logs in worker

4. Feature Map

API Endpoints

Prefix Endpoint Method Auth Description
/health /health GET None Health check
/api/config /config GET None Instance config (region, brand, tax)
Auth
/api/auth /magic/request POST None Request magic link email
/verify GET None Verify magic link token
/login POST None Direct login (passwordless!)
/me GET User Current user info
/logout POST Any Clear session cookie
Invoices
/api/invoices / GET Tenant List with filters, search, sort, pagination
/{id} GET Tenant Detail with extraction + lines
/{id} DELETE Tenant Delete invoice + related
/{id}/document GET Tenant Stream original file
/{id}/reprocess POST Tenant Re-queue for extraction
/{id}/approve POST Tenant Approve invoice
/{id}/reject POST Tenant Reject invoice
/{id}/mark-paid PATCH Tenant Toggle paid status
/{id}/extraction PATCH Tenant Edit extracted fields
/{id}/lines PATCH Tenant Replace line items
/{id}/audit-events GET Tenant Audit history
/poll-status POST Tenant Batch status polling
/bulk-approve POST Tenant Bulk approve
/bulk-reject POST Tenant Bulk reject
/bulk-mark-paid POST Tenant Bulk mark paid
Upload
/api/upload / POST Tenant Single file upload
/batch POST Tenant Multi-file upload (max 10)
Exports
/api/exports /excel GET Tenant Excel download
/journal GET Tenant hledger journal
/quickbooks GET Tenant QuickBooks IIF
/xero GET Tenant Xero CSV
/universal GET Tenant Generic CSV
/summary GET Tenant Export preview/totals
Suppliers
/api/suppliers / GET Tenant List with stats
/{name}/invoices GET Tenant Supplier's invoices
Stats
/api/stats /overview GET Tenant Dashboard stats
WhatsApp
/api/whatsapp /senders GET/POST Tenant Manage sender allowlist
/senders/{id} PATCH/DELETE Tenant Update/remove sender
/status GET Tenant Connection status
Settings
/api/settings /email-alias GET Tenant Mailgun alias
/users GET Tenant Tenant user list
Onboarding
/api/onboarding /status GET User Check if onboarding needed
/complete POST User Create tenant + complete
/checklist GET Admin Checklist progress
/checklist/alias-copied POST Admin Mark alias copied
Invitations
/api/invitations Various POST Admin+ Invite users to tenant
Admin (global)
/api/admin /allowlist GET/POST/DELETE GlobalAdmin Email allowlist
/waitlist GET GlobalAdmin Waitlist management
/waitlist/{id}/approve POST GlobalAdmin Approve waitlist entry
/tenants GET GlobalAdmin List all tenants
/tenants/{id} DELETE GlobalAdmin Soft-delete tenant
/users GET/PATCH GlobalAdmin Manage all users
Webhooks
/webhooks/email /email POST Mailgun HMAC Inbound email
/webhooks/wa /wa POST Twilio sig Inbound WhatsApp

Frontend Pages (React SPA)

  • Login / Magic Link Verify
  • Onboarding (create restaurant)
  • Dashboard (stats overview)
  • Invoices list (with filters, search, bulk actions)
  • Invoice detail (extraction view, edit, approve/reject, payment tracking)
  • Upload page
  • Suppliers list + supplier detail
  • Exports page
  • Settings (email alias, users)
  • Admin panel (allowlist, waitlist, tenants, users) — global admin only

Worker Features

  • Gemini 2.5 Flash extraction with OpenRouter fallback
  • Versioned XML prompts (prompt/live/prompt-v*.xml)
  • Document validity detection (non-financial document rejection)
  • Low confidence auto-rejection
  • Arithmetic validation & auto-correction
  • Date inference (issue_date from invoice number or received date)
  • Due date inference (immediate payment policy)
  • Price history tracking with hike detection (>5% threshold)
  • Retry with exponential backoff (rate limiting aware)
  • Automatic cleanup of rejected invoices (configurable retention)
  • Per-extraction cost tracking

5. Recommendations

P0 — Critical (Fix Before Next Deploy)

# Issue Impact Fix
1 Unpinned Docker dependencies Supply chain attack, broken builds Pin all versions in requirements-docker.txt with hashes. Use pip-compile or uv lock.
2 /api/auth/login is an auth bypass Any known email can get a full session without magic link Remove this endpoint or restrict to dev environment only. It bypasses the entire magic link security model.

P1 — Important (Fix This Sprint)

# Issue Impact Fix
3 Zero test coverage Regressions, no confidence in changes Add pytest tests for auth, invoices CRUD, webhook signature verification, worker extraction. Start with auth and tenant isolation.
4 Docker containers run as root Container escape → host compromise Add RUN useradd -r appuser && USER appuser to Dockerfiles
5 _audit() bug in update_extraction Dict passed as actor instead of meta Fix call to _audit(db, invoice_id, "edited", actor="system", meta={...})
6 UpdateLinesRequest.lines unvalidated Arbitrary data stored in DB Define a proper LineItem Pydantic model with typed fields
7 API port 8010 exposed to host Bypasses Caddy security headers/TLS Change to expose: 8000 (internal only) or bind to 127.0.0.1
8 WhatsApp webhook leaks error details Information disclosure to Twilio Return generic 500 message, log details server-side only

P2 — Nice to Have (Backlog)

# Issue Impact Fix
9 Remove dead oci, jinja2 dependencies Smaller attack surface, cleaner builds Remove from requirements
10 Migrate Boolean columns from Integer to Boolean Code clarity, Postgres-native types Alembic migration
11 Extract _display_status() to shared utility DRY Move to common/
12 Shorter session TTL with refresh Security best practice 24h TTL + silent refresh on /auth/me
13 Add ON DELETE CASCADE to foreign keys Simplify deletion logic Alembic migration, remove manual cascade deletes
14 Structured logging (JSON) Better log aggregation Use structlog or JSON formatter
15 Add health check endpoint for worker Monitoring, Docker healthcheck Simple HTTP server on worker (already has FastAPI import)
16 Content-Disposition header injection Filenames in inline; filename= not sanitized Sanitize or quote filename

6. Summary

PoutineAI is a well-architected, cleanly structured invoicing SaaS with thoughtful security measures throughout. The magic link auth, webhook verification, SSRF protection, file upload validation, and Caddy security headers demonstrate security-conscious development.

Critical gaps are the untyped /auth/login endpoint (effectively an auth bypass), unpinned dependencies, and zero test coverage. The Docker-as-root issue is standard for early-stage projects but should be fixed before handling sensitive financial data at scale.

The codebase is clean and readable with good separation of concerns. The multi-tenant isolation is consistently enforced. The worker's extraction pipeline is robust with retry logic, fallback providers, and intelligent validation.

Overall assessment: Solid foundation, needs the P0/P1 fixes before production scale-up.