diff --git a/Caddyfile b/Caddyfile deleted file mode 100644 index 130ed9f..0000000 --- a/Caddyfile +++ /dev/null @@ -1,34 +0,0 @@ -# Caddyfile — ConFam reverse proxy -# -# PUBLIC_HOSTNAME is injected from .env (e.g. 54-123-45-67.sslip.io). -# Caddy automatically obtains and renews a Let's Encrypt TLS certificate. -# -# Public routes: -# /webhooks/whatsapp → messaging:8002 -# /webhooks/paystack → settlement-engine:8000 (webhook path only) -# /{uuid}* → checkout:8001 -# -# settlement-engine's internal endpoints (/merchants, /links, /internal/*) -# are NOT routed here — they stay inside the Docker network only (OQ-022). - -{$PUBLIC_HOSTNAME} { - # Meta WhatsApp webhook (POST inbound + GET verify-token handshake) - handle /webhooks/whatsapp* { - reverse_proxy messaging:8002 - } - - # Paystack webhook (POST only — Caddy forwards all methods) - handle /webhooks/paystack { - reverse_proxy settlement-engine:8000 - } - - # Checkout health check - handle /health { - reverse_proxy checkout:8001 - } - - # Buyer-facing checkout pages: /{uuid}, /{uuid}/json, /{uuid}/pay/bank - handle_path /* { - reverse_proxy checkout:8001 - } -} diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 9359b7a..d0f8221 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,235 +1,130 @@ -# ConFam — Deployment Guide +# ConFam — Deployment Guide (Render) -This document covers deploying ConFam to a single EC2 instance for pilot testing (~5 merchants). This is not a production-grade multi-instance setup — that comes later when there's a real reason to need it. +Pilot deployment for ~5 WhatsApp testers. Free tier. Not for real merchant traffic. --- -## Architecture on the instance +## Architecture on Render + +One Docker web service serves all three internal services on a single port via `app.py`. +Migrations run at container start via `db/render_bootstrap.sh` before uvicorn. ``` -Internet - │ - 443/80 (HTTPS/HTTP) +Internet → HTTPS (Render-managed TLS) │ - Caddy (reverse proxy, auto-TLS via Let's Encrypt) + confam-xxxx.onrender.com │ - ├── /webhooks/whatsapp* ──→ messaging:8002 (Meta webhook) - ├── /webhooks/paystack ──→ settlement-engine:8000 (Paystack webhook only) - └── /{everything else} ──→ checkout:8001 (buyer checkout pages) - -Internal Docker network only (not reachable from internet): - settlement-engine:8000 — /merchants, /links, /internal/* - postgres:5432 + ├── /webhooks/whatsapp — messaging (Meta webhook, public) + ├── /webhooks/paystack — settlement engine (Paystack webhook, public) + ├── /pay/{link_id} — checkout pages (buyers, public) + └── /links, /merchants — admin endpoints (X-Admin-Key required) ``` -OQ-022 network isolation is preserved: settlement-engine's internal endpoints are never reachable from the public internet. - ---- - -## Step 1: Launch an EC2 instance +### Single-role DB difference from local setup -1. In the AWS Console (or CLI), launch an EC2 instance: - - **AMI**: Ubuntu 24.04 LTS - - **Instance type**: `t3.small` (2 vCPU, 2GB RAM — adequate for ~5 testers) - - **Storage**: 20GB gp3 - - **Security group** — inbound rules: - | Port | Source | Purpose | - |------|--------|---------| - | 22 | Your IP only (`x.x.x.x/32`) | SSH | - | 80 | 0.0.0.0/0 | Let's Encrypt HTTP challenge | - | 443 | 0.0.0.0/0 | HTTPS (Caddy) | - - No other ports open publicly. +Render's managed Postgres provides one DB user — not a superuser. +The confam_app/confam_migrator role split cannot be created. +Migration 012 adapts RLS policies to work with a single user. -2. **Elastic IP (recommended):** Allocate an Elastic IP and associate it with the instance. Without this, the instance gets a new public IP on every restart, which means: - - The sslip.io hostname changes - - You must update Meta and Paystack webhook URLs again - - An Elastic IP costs ~$0/month while attached to a running instance +What is preserved: append-only trigger on ledger_entries (fires for all users), +FORCE ROW LEVEL SECURITY on payout_accounts (owner still subject to policies), +DELETE still restricted to active_from > now(). - If you skip the Elastic IP, accept that a restart requires a one-time webhook URL update in both dashboards (documented in Step 6 below). - -3. Note the public IP address. +What is weaker: the running app connects as the full DB owner, not the restricted +confam_app role. Known gap for the pilot. Restore the role split when upgrading +to a dedicated Postgres instance. --- -## Step 2: Derive the sslip.io hostname - -Replace dots in the IP with dashes and append `.sslip.io`: - -``` -IP: 54.123.45.67 -Hostname: 54-123-45-67.sslip.io -``` - -This hostname resolves to your IP automatically — no signup, no DNS config. Let's Encrypt will issue a real TLS certificate for it. +## Step 1: Connect GitHub repo to Render -Test it resolves correctly: -```bash -dig 54-123-45-67.sslip.io +short # should return 54.123.45.67 -``` +1. dashboard.render.com → New → Blueprint +2. Connect GitHub → select DevSolex/ConFam +3. Render detects render.yaml automatically → click Apply --- -## Step 3: Provision the instance - -SSH in and run the setup script: - -```bash -ssh -i your-key.pem ubuntu@ +## Step 2: Set secrets -# Option A: run directly from GitHub -curl -fsSL https://raw.githubusercontent.com/DevSolex/ConFam/main/scripts/ec2_setup.sh | bash +After the Blueprint deploys, go to the confam service → Environment. +Fill in every sync:false variable: -# Option B: if repo is already cloned -bash /home/ubuntu/ConFam/scripts/ec2_setup.sh -``` - -This installs Docker, clones the repo, copies `.env.example` → `.env`, and opens ports 22/80/443 in ufw. + ADMIN_API_KEY — openssl rand -base64 32 (protects admin routes) + PAYSTACK_SECRET_KEY — from Paystack dashboard (test key for pilot) + PAYSTACK_WEBHOOK_SECRET — same as or derived from Paystack secret key + WHATSAPP_PHONE_NUMBER_ID — Meta App → WhatsApp → API Setup + WHATSAPP_ACCESS_TOKEN — permanent System User token (NOT the 24h temp token) + WHATSAPP_APP_SECRET — Meta App → Settings → Basic → App Secret + WHATSAPP_VERIFY_TOKEN — any string you choose + CHECKOUT_BASE_URL — https://confam-xxxx.onrender.com/pay + PAYMENT_LINK_SIGNING_SECRET — openssl rand -base64 32 + SENTRY_DSN — optional (sentry.io) -After the script completes, log out and back in (or run `newgrp docker`) so the docker group takes effect. +IMPORTANT: Use a permanent System User token for WHATSAPP_ACCESS_TOKEN. +The temporary dashboard token expires in 24 hours. +Meta Business Suite → Settings → System Users → create user → add app → generate token. --- -## Step 4: Configure `.env` on the instance - -```bash -nano /home/ubuntu/ConFam/.env -``` - -Fill in every `PLACEHOLDER_` value. Key ones: - -```bash -# Instance hostname (from Step 2) -PUBLIC_HOSTNAME=54-123-45-67.sslip.io - -# Database passwords — choose your own strong passwords -POSTGRES_SUPERUSER_PASSWORD=choose_a_strong_password -CONFAM_APP_PASSWORD=choose_a_strong_password -CONFAM_MIGRATOR_PASSWORD=choose_a_strong_password - -# Paystack — use test keys until you're ready for live -PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxx -PAYSTACK_WEBHOOK_SECRET=sk_test_xxxxxxxxxxxx - -# Meta WhatsApp Cloud API -WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id -WHATSAPP_ACCESS_TOKEN=your_permanent_system_user_token -WHATSAPP_APP_SECRET=your_app_secret -WHATSAPP_VERIFY_TOKEN=choose_any_string +## Step 3: Deploy -# Checkout URL — must match the public hostname -CHECKOUT_BASE_URL=https://54-123-45-67.sslip.io - -# Sentry (optional but recommended for production) -SENTRY_DSN=your_sentry_dsn - -# Cooling-off for payout account changes (48h default) -PAYOUT_ACCOUNT_COOLING_OFF_SECONDS=172800 -``` - -**Important — permanent WhatsApp access token:** The temporary token from the Meta dashboard expires in 24 hours. For a stable deployment, create a System User token: -1. Meta Business Suite → Settings → System Users -2. Create a system user, add your WhatsApp app, generate a permanent token -3. Use that token as `WHATSAPP_ACCESS_TOKEN` - -Lock down the file: -```bash -chmod 600 /home/ubuntu/ConFam/.env -``` - ---- - -## Step 5: Start the stack - -```bash -cd /home/ubuntu/ConFam -docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d -``` - -Watch the logs to confirm migrations ran and all services started: -```bash -docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f -``` - -Verify Caddy obtained a TLS certificate (check logs for `certificate obtained successfully`): -```bash -docker compose -f docker-compose.yml -f docker-compose.prod.yml logs caddy -``` +Render deploys automatically on push to main. +Watch logs for: + [render-bootstrap] Postgres is ready. + [render-bootstrap] Running Alembic migrations... + [render-bootstrap] Done. Starting application... + INFO: Application startup complete. Health check: -```bash -curl https://54-123-45-67.sslip.io/health -# {"status":"ok","service":"checkout"} -``` + curl https://confam-xxxx.onrender.com/health --- -## Step 6: Update webhook URLs (one-time manual step) +## Step 4: Update webhook URLs (you do this manually) -### Paystack -1. Paystack Dashboard → Settings → API Keys & Webhooks -2. Set webhook URL to: - ``` - https://54-123-45-67.sslip.io/webhooks/paystack - ``` +Paystack: Dashboard → Settings → API Keys & Webhooks + https://confam-xxxx.onrender.com/webhooks/paystack -### Meta WhatsApp -1. Meta App Dashboard → WhatsApp → Configuration → Webhook -2. Set callback URL to: - ``` - https://54-123-45-67.sslip.io/webhooks/whatsapp - ``` -3. Set verify token to whatever you put in `WHATSAPP_VERIFY_TOKEN` -4. Click "Verify and Save" — Caddy must be running for this to succeed +Meta WhatsApp: App Dashboard → WhatsApp → Configuration → Webhook + Callback URL: https://confam-xxxx.onrender.com/webhooks/whatsapp + Verify token: whatever you set for WHATSAPP_VERIFY_TOKEN + Click "Verify and Save" --- -## Step 7: Verify the full flow - -1. Send `REGISTER My Business` to your WhatsApp number → should get a reply -2. Send `ONBOARD 0123456789 058` → Paystack verifies bank, subaccount created -3. Send `PAY 750 Test item` → get a checkout URL back -4. Open the URL in a browser → checkout page renders at the sslip.io domain -5. Complete a test payment → receive "Payment received ✅" WhatsApp message +## Calling admin endpoints ---- + curl -X POST https://confam-xxxx.onrender.com/merchants \ + -H "X-Admin-Key: your-admin-key" \ + -H "Content-Type: application/json" \ + -d '{"whatsapp_number": "+234...", "business_name": "My Store"}' -## Redeploying (code updates) - -From your local machine: -```bash -./deploy.sh ubuntu@54.123.45.67 -``` - -Or directly on the instance: -```bash -cd /home/ubuntu/ConFam -git pull origin main -docker compose -f docker-compose.yml -f docker-compose.prod.yml build -docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d -``` + curl -X POST https://confam-xxxx.onrender.com/links \ + -H "X-Admin-Key: your-admin-key" \ + -H "Content-Type: application/json" \ + -d '{"merchant_id": "...", "amount_minor_units": 75000, "currency": "NGN", "description": "Item"}' --- -## If the instance is restarted (no Elastic IP) +## Free tier constraints -The public IP changes → the sslip.io hostname changes → you must: +SERVICE SLEEP: Render free services sleep after ~15 min idle. ~60s wake-up time. + Workaround: external pinger (UptimeRobot free, cron-job.org) hitting /health + every 10 minutes. Testing workaround only — not a production setup. -1. Update `PUBLIC_HOSTNAME` in `/home/ubuntu/ConFam/.env` -2. Update `CHECKOUT_BASE_URL` in `.env` -3. Restart Caddy: `docker compose -f docker-compose.yml -f docker-compose.prod.yml restart caddy` -4. Update Paystack webhook URL (Step 6 above) -5. Update Meta webhook URL (Step 6 above) +POSTGRES EXPIRY: Free Postgres expires 30 days after creation (14-day grace). + Before expiry: + Option A: Upgrade to Render paid Postgres ($7/month) — no data loss + Option B: Create new free DB, update DATABASE_URL, re-run migrations, + re-onboard merchants (data loss — acceptable for pilot) -**Recommendation:** Allocate an Elastic IP to avoid this entirely. +INSTANCE HOURS: ~750 free hours/month per workspace = one always-on service. + Do not add a second free web service or the first gets suspended mid-month. --- -## Teardown +## Redeploying -```bash -# Stop the stack (keeps data) -docker compose -f docker-compose.yml -f docker-compose.prod.yml down + git push origin main # triggers auto-deploy -# Full reset including database volume -docker compose -f docker-compose.yml -f docker-compose.prod.yml down -v -``` +Or: Render dashboard → confam → Manual Deploy → Deploy latest commit. diff --git a/alembic/versions/0006_render_single_role.py b/alembic/versions/0006_render_single_role.py new file mode 100644 index 0000000..dc99805 --- /dev/null +++ b/alembic/versions/0006_render_single_role.py @@ -0,0 +1,19 @@ +"""Alembic revision 0006: Render single-role RLS adaptation.""" + +from pathlib import Path +from alembic import op + +revision = "0006_render_single_role" +down_revision = "0005_payout_accounts_rls" +branch_labels = None +depends_on = None + +_SQL = Path(__file__).parent.parent.parent / "db" / "migrations" / "012_render_single_role_adaptation.sql" + + +def upgrade() -> None: + op.execute(_SQL.read_text()) + + +def downgrade() -> None: + pass # Conditional — no downgrade needed diff --git a/app.py b/app.py new file mode 100644 index 0000000..f95ef2e --- /dev/null +++ b/app.py @@ -0,0 +1,255 @@ +""" +app.py — Unified ASGI entrypoint for Render deployment. + +Serves all three services (settlement-engine, checkout, messaging) on a +single port. Required because Render's free tier allows only one web service. + +Route layout (designed to avoid collisions with checkout's /{link_id} wildcard): + /health — health check (public) + /webhooks/paystack — Paystack webhook (public, HMAC-verified) + /webhooks/whatsapp — Meta webhook (public, HMAC-verified) + /pay/{link_id} — checkout page (public, buyers) + /pay/{link_id}/json — checkout JSON (public) + /pay/{link_id}/pay/bank — initiate Paystack payment (public) + /links — create payment link (admin-key required) + /merchants — merchant creation (admin-key required) + /merchants/{id}/payout-account — onboarding (admin-key required) + /merchants/{id}/payout-account/change — (admin-key required) + /merchants/{id}/payout-account/change/cancel — (admin-key required) + /internal/reconcile — reconciliation trigger (admin-key required) + +Admin key: X-Admin-Key header must match ADMIN_API_KEY env var. +Compared in constant time. If ADMIN_API_KEY unset → 503 (fail closed). + +CHECKOUT_BASE_URL must include the /pay prefix on Render: + https://.onrender.com/pay +""" + +import hashlib +import hmac +import os +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +import structlog +from fastapi import FastAPI, Request, Response +from fastapi.responses import JSONResponse + +from confam.db import close_pool + +log = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Admin key middleware — protects non-public routes +# --------------------------------------------------------------------------- + +# Routes that are public without an admin key +PUBLIC_PREFIXES = ( + "/health", + "/webhooks/", + "/pay/", +) + + +async def admin_key_middleware(request: Request, call_next): + """ + Require X-Admin-Key for any route not in PUBLIC_PREFIXES. + + Compared with hmac.compare_digest (constant time) to prevent timing attacks. + If ADMIN_API_KEY is not set, all protected routes return 503 (fail closed). + """ + path = request.url.path + + is_public = any(path.startswith(prefix) for prefix in PUBLIC_PREFIXES) + if is_public: + return await call_next(request) + + admin_key = os.environ.get("ADMIN_API_KEY", "") + if not admin_key: + log.error("admin_api_key_not_set", path=path) + return JSONResponse( + status_code=503, + content={"error": "service_unavailable", "detail": "Admin API not configured."}, + ) + + provided = request.headers.get("X-Admin-Key", "") + if not hmac.compare_digest( + hashlib.sha256(provided.encode()).digest(), + hashlib.sha256(admin_key.encode()).digest(), + ): + log.warning("admin_key_rejected", path=path, source_ip=request.client.host if request.client else "unknown") + return JSONResponse( + status_code=403, + content={"error": "forbidden", "detail": "Invalid or missing X-Admin-Key header."}, + ) + + return await call_next(request) + + +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + yield + close_pool() + + +app = FastAPI( + title="ConFam", + description="Unified ConFam API — settlement engine, checkout, and messaging.", + version="1.0.0", + lifespan=lifespan, +) + +# Admin key middleware — runs before routing +app.middleware("http")(admin_key_middleware) + +# Rate limiting on checkout (already in services/checkout/middleware.py) +from services.checkout.middleware import RateLimitMiddleware +app.add_middleware(RateLimitMiddleware) + + +# --------------------------------------------------------------------------- +# Health check (public) +# --------------------------------------------------------------------------- + +@app.get("/health") +def health() -> dict: + return {"status": "ok", "service": "confam"} + + +# --------------------------------------------------------------------------- +# Settlement engine routes +# --------------------------------------------------------------------------- + +from services.settlement_engine.main import app as _engine_app + +# Mount routers directly (avoid sub-application mounting which breaks middleware) +from services.settlement_engine.webhook import router as webhook_router +from services.settlement_engine.onboarding import router as onboarding_router +from services.settlement_engine.reconciliation import run_reconciliation +from confam.db import get_conn +from confam.links import LinkValidationError, create_link +from pydantic import BaseModel, Field, field_validator + +app.include_router(webhook_router) # /webhooks/paystack +app.include_router(onboarding_router) # /merchants/* + + +class CreateLinkRequest(BaseModel): + merchant_id: str = Field(...) + amount_minor_units: int = Field(..., gt=0) + currency: str = Field(default="NGN") + description: str = Field(..., min_length=1, max_length=500) + + @field_validator("amount_minor_units", mode="before") + @classmethod + def reject_floats(cls, v): + if isinstance(v, float): + raise ValueError("amount_minor_units must be an integer (kobo). Rule 6.") + return v + + @field_validator("currency") + @classmethod + def validate_currency(cls, v): + if v not in ("NGN",): + raise ValueError(f"currency '{v}' is not supported") + return v + + +class CreateLinkResponse(BaseModel): + link_id: str + checkout_url: str + expires_at: str + status: str + + +@app.post("/links", response_model=CreateLinkResponse, status_code=201) +def create_payment_link(body: CreateLinkRequest) -> CreateLinkResponse: + checkout_base = os.environ.get("CHECKOUT_BASE_URL", "http://localhost:8001/pay") + try: + with get_conn() as conn: + link = create_link( + conn=conn, + merchant_id=body.merchant_id, + amount_minor_units=body.amount_minor_units, + currency=body.currency, + description=body.description, + ) + except LinkValidationError as exc: + from fastapi import HTTPException + raise HTTPException(status_code=422, detail=str(exc)) + except Exception as exc: + log.error("create_link_failed", error=str(exc)) + from fastapi import HTTPException + if "foreign key" in str(exc).lower(): + raise HTTPException(status_code=404, detail="merchant_id not found") + raise HTTPException(status_code=500, detail="Internal error creating payment link") + + return CreateLinkResponse( + link_id=link.link_id, + checkout_url=f"{checkout_base.rstrip('/')}/{link.link_id}", + expires_at=link.expires_at.isoformat(), + status=link.status, + ) + + +@app.post("/internal/reconcile", status_code=200) +def trigger_reconciliation() -> dict: + summary = run_reconciliation() + return {"status": "complete", **summary} + + +# --------------------------------------------------------------------------- +# Checkout routes — mounted under /pay to avoid wildcard collision +# --------------------------------------------------------------------------- + +from fastapi import APIRouter as _APIRouter +from fastapi.responses import HTMLResponse + +from services.checkout.main import _load_and_open, CheckoutDetail, _render_checkout_page, _render_invalid_page +from fastapi import HTTPException as _HTTPException + +checkout_router = _APIRouter(prefix="/pay") + + +@checkout_router.get("/{link_id}/json", response_model=CheckoutDetail) +def get_checkout_json(link_id: str) -> CheckoutDetail: + return _load_and_open(link_id) + + +@checkout_router.get("/{link_id}", response_class=HTMLResponse) +def get_checkout_page(link_id: str) -> HTMLResponse: + try: + detail = _load_and_open(link_id) + except _HTTPException as exc: + return HTMLResponse(content=_render_invalid_page(exc.detail), status_code=exc.status_code) + return HTMLResponse(content=_render_checkout_page(detail), status_code=200) + + +# Pay by bank — imported from checkout pay module +from services.checkout.pay import router as _pay_router + +# Re-mount the pay router under /pay prefix +for route in _pay_router.routes: + # Prepend /pay to the existing /{link_id}/pay/bank path + checkout_router.add_api_route( + route.path, + route.endpoint, + methods=list(route.methods), + response_model=getattr(route, 'response_model', None), + status_code=getattr(route, 'status_code', 200), + ) + +app.include_router(checkout_router) + + +# --------------------------------------------------------------------------- +# Messaging routes +# --------------------------------------------------------------------------- + +from services.messaging.main import router as messaging_router + +app.include_router(messaging_router) # /webhooks/whatsapp diff --git a/db/migrations/012_render_single_role_adaptation.sql b/db/migrations/012_render_single_role_adaptation.sql new file mode 100644 index 0000000..753ab35 --- /dev/null +++ b/db/migrations/012_render_single_role_adaptation.sql @@ -0,0 +1,66 @@ +-- ============================================================================= +-- Migration: 012_render_single_role_adaptation +-- +-- Applied on Render where only one DB user exists (the DB owner). +-- Rewrites the role-specific RLS policies from migration 011 to work +-- without the confam_app / confam_migrator role split. +-- +-- On Render: +-- - No CREATE ROLE (requires superuser) +-- - No ALTER ROLE BYPASSRLS (requires superuser) +-- - FORCE ROW LEVEL SECURITY still applies to the table owner, so RLS +-- is still enforced structurally — the policy logic is preserved, +-- just scoped to PUBLIC rather than a specific role. +-- +-- What is preserved: +-- 1. Append-only trigger on ledger_entries (fires for ALL users). +-- 2. FORCE ROW LEVEL SECURITY on payout_accounts (owner is subject to policy). +-- 3. DELETE restricted to active_from > now() — the actual security guarantee. +-- +-- What differs from local Docker setup: +-- - Policies are for PUBLIC (all users) instead of confam_app specifically. +-- - The single Render DB user can technically do more than confam_app could. +-- - This is documented as a known gap for the pilot deployment. +-- - When migrating to a dedicated Postgres instance (OQ-007 / paid tier), +-- the confam_app/confam_migrator split should be restored. +-- +-- This migration is conditional — it only applies meaningful changes on +-- Render where confam_app role does not exist. +-- ============================================================================= + +DO $$ +BEGIN + -- Only run this adaptation if confam_app role does NOT exist + -- (i.e., this is a Render deployment without the role split) + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'confam_app') THEN + + -- Drop the role-specific RLS policies from migration 011 + DROP POLICY IF EXISTS payout_accounts_select_policy ON payout_accounts; + DROP POLICY IF EXISTS payout_accounts_insert_policy ON payout_accounts; + DROP POLICY IF EXISTS payout_accounts_update_policy ON payout_accounts; + DROP POLICY IF EXISTS payout_accounts_delete_pending_only ON payout_accounts; + + -- Recreate policies for PUBLIC (applies to all users including DB owner) + -- RLS is already FORCE, so even the table owner is subject to these. + + CREATE POLICY payout_accounts_select_policy + ON payout_accounts FOR SELECT USING (true); + + CREATE POLICY payout_accounts_insert_policy + ON payout_accounts FOR INSERT WITH CHECK (true); + + CREATE POLICY payout_accounts_update_policy + ON payout_accounts FOR UPDATE USING (true); + + -- THE KEY POLICY: DELETE only allowed for pending rows (active_from > now()) + -- This is the structural guarantee that survives the single-role model. + CREATE POLICY payout_accounts_delete_pending_only + ON payout_accounts FOR DELETE USING (active_from > now()); + + RAISE NOTICE 'Render adaptation: RLS policies rewritten for PUBLIC (single-role deployment)'; + + ELSE + RAISE NOTICE 'confam_app role exists — Render adaptation not needed (standard deployment)'; + END IF; +END +$$; diff --git a/db/render_bootstrap.sh b/db/render_bootstrap.sh new file mode 100755 index 0000000..4b73bc0 --- /dev/null +++ b/db/render_bootstrap.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# ============================================================================= +# db/render_bootstrap.sh +# +# Bootstrap script for Render deployments where only ONE database user exists +# (the DB owner — no superuser access to create additional roles). +# +# Differences from bootstrap_roles.sh (local Docker setup): +# - No CREATE ROLE — uses the single Render DB user for everything +# - No ALTER ROLE BYPASSRLS — not available without superuser +# - RLS policies in migration 011 are rewritten for Render (see migration 012) +# - The append-only trigger on ledger_entries still holds for all users +# - FORCE ROW LEVEL SECURITY still applies to the table owner +# +# What is preserved: +# - Append-only enforcement on ledger_entries (BEFORE trigger — Rule 2) +# - RLS on payout_accounts with FORCE (owner is still subject to policies) +# - The ledger trigger as second-line defence +# +# What is weaker: +# - Single DB user for both migrations and runtime (no app/migrator split) +# - DATABASE_URL and ALEMBIC_DATABASE_URL point to the same user +# - Documented as a known gap for the Render pilot deployment +# +# Required environment variables: +# DATABASE_URL — Render-provided connection string (used for migrations too) +# +# ============================================================================= + +set -euo pipefail + +echo "[render-bootstrap] Starting..." + +# Wait for Postgres to be ready +python3 -c " +import os, time, psycopg2 +url = os.environ['DATABASE_URL'] +for i in range(30): + try: + conn = psycopg2.connect(url) + conn.close() + print('[render-bootstrap] Postgres is ready.') + break + except Exception as e: + print(f'[render-bootstrap] Waiting for Postgres... ({i+1}/30)') + time.sleep(2) +else: + print('[render-bootstrap] ERROR: Postgres not ready after 60s') + exit(1) +" + +# Run Alembic migrations using the single Render DB user +# ALEMBIC_DATABASE_URL and DATABASE_URL point to the same user on Render +export ALEMBIC_DATABASE_URL="${DATABASE_URL}" + +echo "[render-bootstrap] Running Alembic migrations..." +alembic upgrade head + +echo "[render-bootstrap] Done. Starting application..." diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index c990cb1..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,54 +0,0 @@ -# docker-compose.prod.yml -# -# Production overlay — adds Caddy reverse proxy for HTTPS termination. -# Removes direct host port exposure from checkout/messaging (Caddy handles it). -# -# Usage: -# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d -# -# Caddy handles: -# - Automatic TLS via Let's Encrypt (requires ports 80+443 open on the instance) -# - Routing public traffic to the correct internal service -# - Keeping settlement-engine's internal endpoints off the public internet -# -# The sslip.io hostname is set via PUBLIC_HOSTNAME in .env: -# PUBLIC_HOSTNAME=54-123-45-67.sslip.io -# -# See DEPLOYMENT.md for provisioning steps. - -services: - # Override checkout: remove direct host port — Caddy handles public traffic - checkout: - ports: [] - - # Override messaging: remove direct host port — Caddy handles public traffic - messaging: - ports: [] - - # Override postgres: remove host port — not needed in production - postgres: - ports: [] - - # Caddy reverse proxy — public HTTPS termination - caddy: - image: caddy:2-alpine - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - - caddy_config:/config - environment: - PUBLIC_HOSTNAME: ${PUBLIC_HOSTNAME} - depends_on: - - checkout - - messaging - - settlement-engine - networks: - - default - -volumes: - caddy_data: - caddy_config: diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..3d58a90 --- /dev/null +++ b/render.yaml @@ -0,0 +1,51 @@ +services: + - type: web + name: confam + runtime: docker + plan: free + healthCheckPath: /health + dockerfilePath: ./Dockerfile + startCommand: /bin/sh -c "db/render_bootstrap.sh && uvicorn app:app --host 0.0.0.0 --port $PORT" + envVars: + - key: DATABASE_URL + fromDatabase: + name: confam-db + property: connectionString + - key: ADMIN_API_KEY + sync: false + - key: PAYSTACK_SECRET_KEY + sync: false + - key: PAYSTACK_WEBHOOK_SECRET + sync: false + - key: PAYSTACK_BASE_URL + value: https://api.paystack.co + - key: WHATSAPP_PHONE_NUMBER_ID + sync: false + - key: WHATSAPP_ACCESS_TOKEN + sync: false + - key: WHATSAPP_APP_SECRET + sync: false + - key: WHATSAPP_VERIFY_TOKEN + sync: false + - key: CHECKOUT_BASE_URL + sync: false + - key: PAYMENT_LINK_EXPIRY_SECONDS + value: "1800" + - key: PAYOUT_ACCOUNT_COOLING_OFF_SECONDS + value: "172800" + - key: RECONCILIATION_LOOKBACK_MINUTES + value: "20" + - key: PAYMENT_LINK_SIGNING_SECRET + sync: false + - key: SENTRY_DSN + sync: false + - key: LOG_LEVEL + value: INFO + - key: ENVIRONMENT + value: production + +databases: + - name: confam-db + plan: free + databaseName: confam + user: confam diff --git a/scripts/ec2_setup.sh b/scripts/ec2_setup.sh deleted file mode 100755 index de39a00..0000000 --- a/scripts/ec2_setup.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# scripts/ec2_setup.sh -# -# Run once on a fresh Ubuntu 24.04 EC2 instance to install Docker, -# clone the repo, and prepare the environment. -# -# Usage (as ubuntu user after SSH): -# curl -fsSL https://raw.githubusercontent.com/DevSolex/ConFam/main/scripts/ec2_setup.sh | bash -# OR copy the file and run: -# bash ec2_setup.sh -# ============================================================================= - -set -euo pipefail - -echo "=== [1/5] System update ===" -sudo apt-get update -q -sudo apt-get upgrade -yq - -echo "=== [2/5] Install Docker ===" -sudo apt-get install -yq ca-certificates curl gnupg -sudo install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg -sudo chmod a+r /etc/apt/keyrings/docker.gpg -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ - https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ - sudo tee /etc/apt/sources.list.d/docker.list > /dev/null -sudo apt-get update -q -sudo apt-get install -yq docker-ce docker-ce-cli containerd.io docker-compose-plugin -# Allow ubuntu user to run docker without sudo -sudo usermod -aG docker ubuntu -echo "Docker installed: $(docker --version)" - -echo "=== [3/5] Clone repository ===" -cd /home/ubuntu -git clone https://github.com/DevSolex/ConFam.git -cd ConFam -echo "Repository cloned." - -echo "=== [4/5] Create .env ===" -# Copy the example — you MUST fill in real values before starting the stack -cp .env.example .env -chmod 600 .env -echo "" -echo "IMPORTANT: Edit /home/ubuntu/ConFam/.env with real values before running the stack." -echo " - Set PUBLIC_HOSTNAME to your sslip.io hostname (see DEPLOYMENT.md)" -echo " - Set all PAYSTACK_*, WHATSAPP_*, and database passwords" -echo "" - -echo "=== [5/5] Open firewall ports (ufw) ===" -# Allow SSH, HTTP (for Let's Encrypt), HTTPS -sudo ufw allow 22/tcp -sudo ufw allow 80/tcp -sudo ufw allow 443/tcp -sudo ufw --force enable -sudo ufw status - -echo "" -echo "=== Setup complete ===" -echo "Next steps:" -echo " 1. newgrp docker (or log out and back in for docker group to take effect)" -echo " 2. nano /home/ubuntu/ConFam/.env (fill in real values)" -echo " 3. docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d" -echo " 4. See DEPLOYMENT.md for the full checklist." diff --git a/tests/test_unified_app.py b/tests/test_unified_app.py new file mode 100644 index 0000000..43e1a1c --- /dev/null +++ b/tests/test_unified_app.py @@ -0,0 +1,189 @@ +""" +tests/test_unified_app.py + +Tests for the unified ASGI entrypoint (app.py) used on Render. + +Coverage: + 1. All three route groups respond on the unified app + 2. Admin key: missing → 403, wrong → 403, correct → passes middleware + 3. ADMIN_API_KEY unset → 503 (fail closed) + 4. Public routes work without any key + 5. Checkout routes respond under /pay prefix + 6. checkout_url includes /pay prefix +""" + +import os +import uuid +import pytest +import psycopg2 +from httpx import AsyncClient, ASGITransport + + +def _make_app(monkeypatch, admin_key="test-admin-key"): + monkeypatch.setenv("ADMIN_API_KEY", admin_key) + monkeypatch.setenv("DATABASE_URL", os.environ.get("TEST_DATABASE_URL", "")) + monkeypatch.setenv("PAYSTACK_SECRET_KEY", "test_key") + monkeypatch.setenv("CHECKOUT_BASE_URL", "https://confam.onrender.com/pay") + monkeypatch.setenv("PAYMENT_LINK_EXPIRY_SECONDS", "1800") + monkeypatch.setenv("WHATSAPP_APP_SECRET", "test_secret") + monkeypatch.setenv("WHATSAPP_VERIFY_TOKEN", "test_verify_token") + import importlib + import app as _app + importlib.reload(_app) + return _app.app + + +# --------------------------------------------------------------------------- +# Health (public) +# --------------------------------------------------------------------------- + +class TestHealthCheck: + @pytest.mark.asyncio + async def test_health_returns_200_no_key(self, monkeypatch): + application = _make_app(monkeypatch) + async with AsyncClient(transport=ASGITransport(app=application), base_url="http://test") as client: + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +# --------------------------------------------------------------------------- +# Admin key middleware +# --------------------------------------------------------------------------- + +class TestAdminKeyMiddleware: + @pytest.mark.asyncio + async def test_missing_key_returns_403(self, monkeypatch): + application = _make_app(monkeypatch) + async with AsyncClient(transport=ASGITransport(app=application), base_url="http://test") as client: + resp = await client.post("/links", json={ + "merchant_id": str(uuid.uuid4()), + "amount_minor_units": 1000, "currency": "NGN", "description": "t" + }) + assert resp.status_code == 403 + assert resp.json()["error"] == "forbidden" + + @pytest.mark.asyncio + async def test_wrong_key_returns_403(self, monkeypatch): + application = _make_app(monkeypatch) + async with AsyncClient(transport=ASGITransport(app=application), base_url="http://test") as client: + resp = await client.post("/links", + headers={"X-Admin-Key": "wrong-key"}, + json={"merchant_id": str(uuid.uuid4()), + "amount_minor_units": 1000, "currency": "NGN", "description": "t"} + ) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_correct_key_passes_middleware(self, monkeypatch): + application = _make_app(monkeypatch) + async with AsyncClient(transport=ASGITransport(app=application), base_url="http://test") as client: + resp = await client.post("/links", + headers={"X-Admin-Key": "test-admin-key"}, + json={"merchant_id": str(uuid.uuid4()), + "amount_minor_units": 1000, "currency": "NGN", "description": "t"} + ) + # 404 = middleware passed, endpoint rejected nonexistent merchant + assert resp.status_code != 403, "Key was incorrectly rejected" + + @pytest.mark.asyncio + async def test_unset_admin_key_returns_503(self, monkeypatch): + monkeypatch.delenv("ADMIN_API_KEY", raising=False) + monkeypatch.setenv("DATABASE_URL", os.environ.get("TEST_DATABASE_URL", "")) + monkeypatch.setenv("PAYSTACK_SECRET_KEY", "test_key") + monkeypatch.setenv("CHECKOUT_BASE_URL", "http://localhost/pay") + monkeypatch.setenv("PAYMENT_LINK_EXPIRY_SECONDS", "1800") + monkeypatch.setenv("WHATSAPP_APP_SECRET", "s") + monkeypatch.setenv("WHATSAPP_VERIFY_TOKEN", "t") + import importlib, app as _app + importlib.reload(_app) + async with AsyncClient(transport=ASGITransport(app=_app.app), base_url="http://test") as client: + resp = await client.post("/links", + headers={"X-Admin-Key": "any-key"}, + json={"merchant_id": str(uuid.uuid4()), + "amount_minor_units": 1000, "currency": "NGN", "description": "t"} + ) + assert resp.status_code == 503 + assert resp.json()["error"] == "service_unavailable" + + +# --------------------------------------------------------------------------- +# Public routes — no key needed +# --------------------------------------------------------------------------- + +class TestPublicRoutes: + @pytest.mark.asyncio + async def test_webhook_verify_no_key(self, monkeypatch): + application = _make_app(monkeypatch) + async with AsyncClient(transport=ASGITransport(app=application), base_url="http://test") as client: + resp = await client.get("/webhooks/whatsapp", params={ + "hub.mode": "subscribe", + "hub.verify_token": "test_verify_token", + "hub.challenge": "abc123", + }) + assert resp.status_code == 200 + assert resp.text == "abc123" + + @pytest.mark.asyncio + async def test_checkout_page_no_key_returns_404_not_403(self, monkeypatch): + application = _make_app(monkeypatch) + async with AsyncClient(transport=ASGITransport(app=application), base_url="http://test") as client: + resp = await client.get(f"/pay/{uuid.uuid4()}/json") + assert resp.status_code == 404 + assert resp.status_code != 403 + + +# --------------------------------------------------------------------------- +# Checkout /pay prefix + URL shape +# --------------------------------------------------------------------------- + +@pytest.mark.integration +class TestCheckoutPayPrefix: + @pytest.mark.asyncio + async def test_checkout_url_includes_pay_prefix(self, monkeypatch): + """Links created via the unified app must have /pay/ in the checkout URL.""" + application = _make_app(monkeypatch) + + # Insert a test merchant + db_url = os.environ.get("TEST_DATABASE_URL", "") + if not db_url: + pytest.skip("TEST_DATABASE_URL not set") + conn = psycopg2.connect(db_url.replace("postgresql+psycopg2://", "postgresql://") + if "postgresql+psycopg2" in db_url else db_url) + run_id = uuid.uuid4().hex[:10] + with conn.cursor() as cur: + cur.execute( + "INSERT INTO merchants (whatsapp_number, confam_thread_id, status) " + "VALUES (%s, %s, 'active') RETURNING merchant_id", + (f"+234{run_id}", f"unified-{run_id}"), + ) + merchant_id = str(cur.fetchone()[0]) + conn.commit() + conn.close() + + async with AsyncClient(transport=ASGITransport(app=application), base_url="http://test") as client: + resp = await client.post("/links", + headers={"X-Admin-Key": "test-admin-key"}, + json={"merchant_id": merchant_id, "amount_minor_units": 50000, + "currency": "NGN", "description": "prefix test"}, + ) + assert resp.status_code == 201 + url = resp.json()["checkout_url"] + assert "/pay/" in url, f"Expected /pay/ in URL, got: {url}" + assert "onrender.com/pay/" in url + + @pytest.mark.asyncio + async def test_all_three_services_respond(self, monkeypatch): + """Smoke: health, webhook verify, and checkout 404 all return expected codes.""" + application = _make_app(monkeypatch) + async with AsyncClient(transport=ASGITransport(app=application), base_url="http://test") as client: + h = await client.get("/health") + w = await client.get("/webhooks/whatsapp", params={ + "hub.mode": "subscribe", + "hub.verify_token": "test_verify_token", + "hub.challenge": "xyz", + }) + c = await client.get(f"/pay/{uuid.uuid4()}/json") + assert h.status_code == 200 # settlement-engine health + assert w.status_code == 200 # messaging webhook + assert c.status_code == 404 # checkout (link not found — correct)