WaFine docs
Project docsResearch & decisions

Architecture review — t_wa_arch_001 (OpenWA on OCI, Supabase datastore)

Reviewer: Claude Code (session on omnizya), 2026-10-09. Read-only review; nothing deployed, no secrets printed. Inputs: supabase/migrations/20261007120000_openwa_datastore.sql, stack/compose.infra.yml, terraform/oci/{main,variables}.tf + cloud-init.yaml.tftpl, terraform/cloudflare/main.tf, stack/.env (keys and non-secret values only), OpenWA/docker-compose.yml (v0.24.0), CLAUDE.md, AGENTS.md, TASKS.md.

✅ Validated

Supabase migration

  • Isolated openwa schema; openwa role created nologin with connection limit 20 (≥ DATABASE_POOL_SIZE=5); usage, create granted; public/anon/authenticated revoked so PostgREST never exposes it even if the schema is listed by mistake.
  • search_path = openwa, public and timezone = 'UTC' pinned on the role (OpenWA refuses to boot otherwise); password set out-of-band, never committed.
  • Private buckets openwa-media / openwa-backups created idempotently. DATABASE_SYNCHRONIZE=false; TypeORM owns the tables. CLAUDE.md records the migration as applied and verified.

Compose overlay (stack/compose.infra.yml)

  • docker-proxy disabled via profile; upstream depends_on is required: false, so this is supported.
  • Upstream postgres, redis, minio are profile-gated (postgres|redis|minio|full) and not enabled → no Redis or MQ runs in this stack. openwa-api depends on them with required: false.
  • cloudflared pinned 2026.8.1, profile public (only when CLOUDFLARE_TUNNEL_TOKEN is set), read_only, cap_drop: ALL, no-new-privileges, readiness probe tunnel ready, metrics bound inside the compose network only, waits for openwa-api healthy (/api/health/ready).
  • Network pinned to 172.31.42.0/24 and TRUSTED_PROXIES matches it, so per-IP rate limits see the real visitor IP.
  • Supabase CA mounted read-only via NODE_EXTRA_CA_CERTS; DATABASE_SSL=true + REJECT_UNAUTHORIZED=true. CA (Supabase Root 2021) valid until 2031-04-26.

OpenTofu (terraform/oci)

  • Zero inbound by default: egress-only security list, ingress ICMP type 3/code 4 only (PMTU), optional UDP 41641 (tailscale_direct_udp, default false), optional TCP 22 from admin_cidr (default empty).
  • VM.Standard.A1.Flex, 2 OCPU / 12 GB, 100 GB boot, latest Canonical Ubuntu 24.04 aarch64 with ignore_changes on image id and user_data; compartment propagation wait; credentials from ~/.oci/config, none in tfvars; *.tfvars git-ignored.
  • cloud-init: Docker + compose plugin, host-level Tailscale with --ssh --advertise-tags=tag:server --accept-dns=false, UDP rule persisted before Docker chains.

Environment (stack/.env)

  • All 39 expected keys present and non-empty. File is git-ignored (*.env) and untracked; deploy.sh copies it mode 600 and never prints it.
  • Every OpenWA-consumed key is forwarded by the upstream compose explicit list (STORAGE_TYPE, S3_*, MCP_*, METRICS_TOKEN, TRUSTED_PROXIES, DB vars). Keys not forwarded (OPENWA_REF, CLOUDFLARE_TUNNEL_TOKEN, BACKUP_BUCKET, S3_PROVIDER) are consumed by the overlay and preflight.sh/backup.sh, by design.
  • Safe posture: ENGINE_TYPE=whatsapp-web.js, SEND_PACING_ENABLED=true, MCP_ENABLED=true + MCP_READONLY=true, NODE_ENV=production, DATABASE_TYPE=postgres on the session pooler.

Cloudflare phase-2 module (terraform/cloudflare)

  • Ingress: ^/mcp → http_status:404, FQDN → http://openwa-api:2785, catch-all → 404. Access application with email OTP for operators plus a service token for integrations. Matches the "MCP tailnet-only" rule.

⚠️ Risks and concerns

  1. The brief does not match the repository. Repo domain is wa.ai4x.academy (DNS on Netlify, phase 2 blocked); the playground zone appears nowhere. A1 is 2 OCPU / 12 GB, not 4 OCPU. Redis, an MQ and a Vercel frontend are not in the stack (Redis exists only as an optional upstream profile and in research notes R4/R6). The socat bridge 100.112.105.21:443 → localhost:8443 is not referenced anywhere; the repo's documented path is scripts/tailnet-sidecar (SOCKS5). Treat these as unverified workstation-local facts, not architecture.
  2. Memory headroom. OPENWA_MEM_LIMIT=8g of 12 GB, whatsapp-web.js ≈ 300–500 MB per session (Chromium), plus OS, dockerd, tailscaled, cloudflared. Realistic ceiling ≈ 8–12 sessions. cloud-init configures no swap or zram; a container OOM kills every session at once and forces QR re-links (passkey-gate risk on re-link).
  3. Backups unencrypted. BACKUP_AGE_RECIPIENT is not set in .env (TASKS §66 open). The archive contains WhatsApp auth state, which is a login credential. R2 keys are not in place yet (TASKS §26/27/34), so backups currently stay on the VM.
  4. Media on the VM. STORAGE_TYPE=local until R2 is configured; the 100 GB boot volume is the only copy.
  5. Phase-2 URL switch. BASE_URL, DASHBOARD_URL, CORS_ORIGINS are tailnet hostnames; they must change when the public hostname goes live (TASKS §63) and be pushed with PUSH_ENV=1.
  6. Supabase free tier pauses after 7 idle days (check due 2026-10-14, TASKS §70). A paused DB means OpenWA refuses to boot. Always Free VM can be reclaimed after 7 idle days (TASKS §13).
  7. Data residency. VM in Morocco, Postgres in Paris: international transfer under law 09-08 / CNDP (TASKS §69). Not for client data before legal review.
  8. Doc inconsistency. CLAUDE.md says stack/.env "lives only on the VM", but deploy.sh supports a local copy and one exists on this workstation (git-ignored, LUKS-encrypted disk). Either wording or practice should change.
  9. OpenWA pinned to v0.24.0 (Nov 2024). Upgrade hazards are documented; no action now, but note the age.
  10. tailscale_direct_udp=false means relayed (DERP) traffic; acceptable for phase 1, adds latency to QR and dashboard use.

🔧 Required fixes before deployment

#FixWhereBlocks
F1Reconcile the brief with the repo: confirm the target domain. If the playground zone is already a Cloudflare zone, set zone_name/subdomain in terraform/cloudflare/terraform.tfvars and phase 2 is unblocked without touching Netlify.brief + tfvarsphase 2
F2Set BACKUP_AGE_RECIPIENT (private key offline) before any backup leaves the VM.stack/.env, TASKS §66backups
F3Add zram or a swapfile in cloud-init, or lower OPENWA_MEM_LIMIT to ~7g and cap concurrent sessions, so ~2 GB stays for the host.cloud-init.yaml.tftpl / .envstability
F4Phase-2 cutover: switch BASE_URL/DASHBOARD_URL/CORS_ORIGINS, PUSH_ENV=1 scripts/deploy.sh, then verify /mcp returns 404 publicly and 200 over Tailscale..env, TASKS §62–63phase 2
F5Keep Supabase alive before 2026-10-14 (a scheduled health query is enough).TASKS §70uptime
F6Mint a scoped viewer key for Claude MCP; leave MCP_READONLY=true until a named pilot group needs writes.TASKS §68agent work

📋 Dependencies on other tasks

  • Operator-only: Tailscale auth key (§11), R2 buckets + token (§26), dedicated warmed-up number (§35), tailnet approval for mo (§42), Netlify/Cloudflare logins (§57–58), registrar NS switch (§60), Cloudflare API token (§61), age key (§66), legal check (§69).
  • Claude-tagged follow-ups: R2 wiring + first backup (§27, §34), Cloudflare zone prep with dig diff (§59), scripts/tf cloudflare apply + deploy (§62), URL switch (§63), metrics/alerting (§67), MCP key (§68).
  • Decision gates: OCI PAYG deferred until end-to-end public test (§13); tailscale_direct_udp (§14).

Not verified in this review

  • Live VM state (stack/wa ps, actual memory use, session count): requires SSH to wa-ma-1; not attempted.
  • That the Supabase migration is still in sync with the project: supabase migration list requires the CLI login.
  • The socat/8443 bridge named in the brief. Verified later the same day: it exists on the operator workstation (socat TCP-LISTEN:8443 → SOCKS5 127.0.0.1:1055 → wa-ma-1:443, riding scripts/tailnet-sidecar). It is a workstation→VM admin path for the API over the second tailnet, not part of the WhatsApp message path and nothing on the VM. It is undocumented; worth a line in docs/03-runbook.md or the sidecar header.

On this page