Skip to content

Architecture

System diagram

flowchart TB
    subgraph Clients
        APP[Expo app<br/>aim-store-app]
        WEB[Browser<br/>aimstoreorg.com]
        ADMIN[Admin browser<br/>admin.aimstoreorg.com]
    end

    subgraph GitHub
        REPO[Aimorgcode/Hub<br/>main branch]
        GHA[GitHub Actions<br/>deploy-* workflows]
        GHCR[(ghcr.io/aimorgcode/*)]
    end

    subgraph VPS["Production VPS 200.141.2.84 (Docker Compose)"]
        CADDY[Caddy 2<br/>TLS + vhost routing<br/>only published ports 80/443]
        subgraph edge[aim_edge network]
            API[aim_api<br/>Go/Fiber :8080]
            SITE[aim_website<br/>SPA + payment server :3001]
            PORTAL[aim_admin_portal<br/>Next.js :3002]
            DOCS[aim_docs<br/>nginx static MkDocs]
        end
        subgraph data[aim_data network]
            PG[(PostgreSQL 16)]
            REDIS[(Redis 7)]
        end
        subgraph mon[aim_mon network]
            GRAFANA[Grafana]
            PROM[Prometheus]
            LOKI[Loki]
            TEMPO[Tempo]
            ALLOY[Alloy]
        end
    end

    subgraph External
        CCAV[CCAvenue gateway]
        PHONEPE[PhonePe gateway]
        SMS[alots.in SMS gateway]
        FB[Firebase Auth]
        LE[Let's Encrypt]
    end

    APP -->|HTTPS /api/v1| CADDY
    WEB --> CADDY
    ADMIN --> CADDY
    CADDY --> API
    CADDY --> SITE
    CADDY --> PORTAL
    CADDY --> DOCS
    CADDY --> GRAFANA
    PORTAL -->|server-side proxy<br/>x-sdk-access-token| API
    SITE -->|topup-credit HMAC| API
    API --> PG
    API --> REDIS
    API --> SMS
    API --> FB
    API -->|status verify| PHONEPE
    SITE --> CCAV
    SITE --> PHONEPE
    PROM -->|scrape /metrics via aim_edge| API
    ALLOY -->|docker logs| LOKI
    CADDY --> LE

    REPO --> GHA
    GHA -->|build + push| GHCR
    GHA -->|SSH: backup, migrate, pull, restart| VPS
    GHCR -->|docker pull| VPS

Two application networks separate concerns: aim_edge (Caddy ↔ apps; the monitoring stack joins it only for Grafana routing and Prometheus scraping) and aim_data (apps ↔ postgres/redis — internal, never published, never joined by Caddy). Monitoring keeps its own internal aim_mon network.

Components

Component Tech Code path Runs as
Backend API Go 1.25 + Fiber v2, pgx/v5, go-redis aim-backend/ (entrypoint cmd/api/) aim_api (:8080)
Website + payment server React 19 + Vite SPA, Express server fronting CCAvenue/PhonePe aim-store-website/ (server/index.js) aim_website (:3001)
Admin portal Next.js 14 (App Router, standalone) + NextAuth aim-store-admin/ aim_admin_portal (:3002)
Mobile app Expo SDK 54 / React Native, expo-router aim-store-app/ store releases, not on VPS
Docs MkDocs Material → nginx docs/ + mkdocs.yml + docs.Dockerfile aim_docs
Edge Caddy 2, automatic Let's Encrypt deploy/Caddyfile aim_caddy (80/443)
Data stores PostgreSQL 16, Redis 7 deploy/docker-compose.yml aim_postgres / aim_redis
Monitoring Grafana, Prometheus, Loki, Tempo, Alloy, node-exporter, cAdvisor monitoring/ mon_* containers
CI/CD GitHub Actions + GHCR + SSH .github/workflows/ GitHub-hosted runners
Mobile CI/CD EAS Workflows (build + submit to Google Play) aim-store-app/.eas/workflows/ Expo-hosted runners

The backend keeps its historical Go module name (github.com/Aimorgcode/Hub/aim-backend) — see ADR 0002.

Storage split

  • PostgreSQL — users, wallets, orders, tickets, payment gateways, accounting (internal/repository/postgres/). The gaming and affiliate domains were removed 2026-08-10 — see ADR 0010.
  • Redis — OTP codes and rate limits, session revocation, order/topup idempotency tokens.

Key flows

Wallet top-up (the only money-in path)

  1. Client calls POST /api/v1/wallet/topup-initiate (authenticated). The backend mints a one-time token and returns a URL on the hosted top-up page (TOPUP_PAGE_BASE_URL, the website). Without that variable the endpoint returns 503 — it never falls back to an unverified path (internal/handler/wallet_handler.go).
  2. The website's Express server (aim-store-website/server/index.js) runs the gateway handshake: CCAvenue (/api/ccavenue/topup-initiate → encrypted redirect) or PhonePe (/api/phonepe/topup-initiate → checkout URL).
  3. On the gateway's success callback, the website server calls the backend's public POST /api/v1/wallet/topup-credit with an HMAC signature over STORE_WEBHOOK_SECRET; a missing secret means 503, a bad signature is rejected. Only then are shopping credits written.
  4. The client polls GET /api/v1/wallet/topup-status/:token; if PhonePe reports COMPLETED before the callback landed, the backend verifies the order state directly with PhonePe's status API and credits the wallet itself.

POST /wallet/topup is intentionally not routed — the old direct route credited wallets with no payment. The handler and repo method are kept only because the verified callbacks reuse them (internal/handler/routes.go).

OTP login

  1. POST /api/v1/auth/otp/send (public): a 6-digit OTP goes into Redis with a TTL; resend cooldown and per-phone send caps guard against SMS-bombing (internal/handler/auth_handler.go).
  2. The SMS goes out through the alots.in gateway (pkg/sms); a provider failure deletes the stored OTP and returns a generic error.
  3. POST /api/v1/auth/otp/verify: attempt-capped; first-time phones auto-create a user + wallet and the response carries the custom JWT used as x-sdk-access-token thereafter. On that first signup the optional referral_code/referral_source fields (or, failing those, an IP match against recent share-link clicks) attribute the new user to a referrer — see the referral flow below.

Attribution only — no wallet rewards (see ADR 0012).

  1. An authenticated user fetches GET /api/v1/referrals/my-code, which lazily generates users.referral_code and returns a share URL of the form https://aimstoreorg.com/r/<CODE>.
  2. A visitor opens the link. Caddy routes /r/* on the website vhost to aim_api (everything else still goes to aim_website); the backend logs a row in referral_clicks (code, client IP from Caddy's X-Forwarded-For, user-agent) and 302-redirects to the Play Store with the code encoded in the install-referrer query parameter. Unknown codes still redirect — the link never dead-ends.
  3. The app reads the Play Install Referrer after install and passes referral_code + referral_source: "install_referrer" to POST /api/v1/auth/otp/verify at signup (a manually typed code is "manual"; the password path POST /auth/register accepts the same field). If no explicit code arrives, the backend falls back to the most recent referral_clicks row for the client IP within REFERRAL_IP_MATCH_WINDOW_HOURS (default 48h) — method "ip".
  4. A successful match sets users.referred_by (first touch only, never self-referral) and writes an audit row to referral_attributions. Invalid or unknown codes never fail registration.
  5. Separately, users can verify their email via an authenticated 6-digit OTP flow (POST /api/v1/me/email/send-otp / verify-otp), gated on SMTP configuration; changing the email clears its verified status.

Checkout (server-authoritative)

  1. POST /api/v1/orders/quote recomputes prices server-side from the catalogue — the client's amounts are never trusted.
  2. POST /api/v1/orders places the order and debits the shopping-credits wallet atomically; POST /orders/:id/cancel refunds it.
  3. Back-office reconciliation lives under /api/v1/admin/orders*, /admin/refunds, /admin/reconciliation.

Deploy flow (push → production)

  1. Push to main. Path filters pick the affected workflow(s) (.github/workflows/deploy-*.yml).
  2. build job: Buildx builds the image (GHA layer cache) and pushes latest + sha-<short> to ghcr.io/aimorgcode/*.
  3. deploy job: copies deploy/ configs to /opt/aim-store (git is the source of truth), then over SSH — serialized by flock /tmp/aim-store-deploy.lock, fail-fast — logs into GHCR and pulls the new image.
  4. Backend only: pg_dump into /opt/aim-store/backups (last 14 of each kept), then deploy/apply-migrations.sh before restart.
  5. docker compose up -d --no-build <services> caddy, then a health check pinned to the VPS with curl --resolve <domain>:443:200.141.2.84.

Repository layout

Hub/
├── aim-backend/           # Go/Fiber API (entrypoint cmd/api/, NOT root main.go)
│   ├── cmd/api/           #   main + telemetry (otelfiber, fiberprometheus)
│   ├── internal/          #   handler/ middleware/ repository/ services/
│   ├── pkg/               #   sms, email, jwt, firebase, payments…
│   └── scripts/           #   postgres schema + migrations
├── aim-store-app/         # Expo / React Native app (expo-router)
├── aim-store-website/     # Vite SPA + Express payment server (server/)
├── aim-store-admin/       # Admin portal (Next.js 14, admin.aimstoreorg.com)
├── deploy/                # Production compose stack + Caddyfile + gen-env.sh
├── monitoring/            # Grafana/Prometheus/Loki/Tempo/Alloy stack
├── .github/workflows/     # ci.yml + deploy-{backend,website,admin-portal,docs,monitoring}.yml
└── docs/                  # this documentation (mkdocs)

Design constraints visible in the code

  • Single VPS: images are never built on the server; deploys are flock-serialized; monitoring containers carry memory limits.
  • Secrets never in git: runtime secrets live only in /opt/aim-store/deploy/.env, /opt/aim-store/deploy/portal.env.production, and /opt/aim-store/monitoring/.env. deploy/gen-env.sh generates fresh ones server-side. See ADR 0009.
  • Data network isolation: postgres/redis sit on aim_data, which Caddy and the monitoring stack never join.
  • Static uploads are a bind mount: /opt/aim-store/static is mounted into aim_api at /root/static; the backend serves /team-images, /player-images, and /kyc-documents from there (routes.go), so images survive container replacement.
  • /metrics is blocked at Caddy (404 publicly) but scraped internally by Prometheus over aim_edge.
  • Dual auth: one middleware accepts the custom JWT (x-sdk-access-token) or a Firebase ID token (Authorization: Bearer) — internal/middleware/firebase_auth.go. Admin routes additionally require the role == "admin" claim.