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
openwaschema;openwarole creatednologinwithconnection limit 20(≥DATABASE_POOL_SIZE=5);usage, creategranted;public/anon/authenticatedrevoked so PostgREST never exposes it even if the schema is listed by mistake. search_path = openwa, publicandtimezone = 'UTC'pinned on the role (OpenWA refuses to boot otherwise); password set out-of-band, never committed.- Private buckets
openwa-media/openwa-backupscreated idempotently.DATABASE_SYNCHRONIZE=false; TypeORM owns the tables. CLAUDE.md records the migration as applied and verified.
Compose overlay (stack/compose.infra.yml)
docker-proxydisabled via profile; upstreamdepends_onisrequired: false, so this is supported.- Upstream
postgres,redis,minioare profile-gated (postgres|redis|minio|full) and not enabled → no Redis or MQ runs in this stack.openwa-apidepends on them withrequired: false. cloudflaredpinned2026.8.1, profilepublic(only whenCLOUDFLARE_TUNNEL_TOKENis set),read_only,cap_drop: ALL,no-new-privileges, readiness probetunnel ready, metrics bound inside the compose network only, waits foropenwa-apihealthy (/api/health/ready).- Network pinned to
172.31.42.0/24andTRUSTED_PROXIESmatches 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, defaultfalse), optional TCP 22 fromadmin_cidr(default empty). VM.Standard.A1.Flex, 2 OCPU / 12 GB, 100 GB boot, latest Canonical Ubuntu 24.04 aarch64 withignore_changeson image id and user_data; compartment propagation wait; credentials from~/.oci/config, none in tfvars;*.tfvarsgit-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.shcopies 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 andpreflight.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=postgreson 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
- 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). Thesocatbridge100.112.105.21:443 → localhost:8443is not referenced anywhere; the repo's documented path isscripts/tailnet-sidecar(SOCKS5). Treat these as unverified workstation-local facts, not architecture. - Memory headroom.
OPENWA_MEM_LIMIT=8gof 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). - Backups unencrypted.
BACKUP_AGE_RECIPIENTis 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. - Media on the VM.
STORAGE_TYPE=localuntil R2 is configured; the 100 GB boot volume is the only copy. - Phase-2 URL switch.
BASE_URL,DASHBOARD_URL,CORS_ORIGINSare tailnet hostnames; they must change when the public hostname goes live (TASKS §63) and be pushed withPUSH_ENV=1. - 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).
- Data residency. VM in Morocco, Postgres in Paris: international transfer under law 09-08 / CNDP (TASKS §69). Not for client data before legal review.
- Doc inconsistency. CLAUDE.md says
stack/.env"lives only on the VM", butdeploy.shsupports a local copy and one exists on this workstation (git-ignored, LUKS-encrypted disk). Either wording or practice should change. - OpenWA pinned to v0.24.0 (Nov 2024). Upgrade hazards are documented; no action now, but note the age.
tailscale_direct_udp=falsemeans relayed (DERP) traffic; acceptable for phase 1, adds latency to QR and dashboard use.
🔧 Required fixes before deployment
| # | Fix | Where | Blocks |
|---|---|---|---|
| F1 | Reconcile 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 + tfvars | phase 2 |
| F2 | Set BACKUP_AGE_RECIPIENT (private key offline) before any backup leaves the VM. | stack/.env, TASKS §66 | backups |
| F3 | Add 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 / .env | stability |
| F4 | Phase-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–63 | phase 2 |
| F5 | Keep Supabase alive before 2026-10-14 (a scheduled health query is enough). | TASKS §70 | uptime |
| F6 | Mint a scoped viewer key for Claude MCP; leave MCP_READONLY=true until a named pilot group needs writes. | TASKS §68 | agent 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),agekey (§66), legal check (§69). - Claude-tagged follow-ups: R2 wiring + first backup (§27, §34), Cloudflare zone prep with
digdiff (§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 towa-ma-1; not attempted. - That the Supabase migration is still in sync with the project:
supabase migration listrequires the CLI login. TheVerified later the same day: it exists on the operator workstation (socat/8443bridge named in the brief.socat TCP-LISTEN:8443 → SOCKS5 127.0.0.1:1055 → wa-ma-1:443, ridingscripts/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 indocs/03-runbook.mdor the sidecar header.