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)¶
- 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 returns503— it never falls back to an unverified path (internal/handler/wallet_handler.go). - 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). - On the gateway's success callback, the website server calls the backend's
public
POST /api/v1/wallet/topup-creditwith an HMAC signature overSTORE_WEBHOOK_SECRET; a missing secret means503, a bad signature is rejected. Only then are shopping credits written. - The client polls
GET /api/v1/wallet/topup-status/:token; if PhonePe reportsCOMPLETEDbefore 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¶
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).- The SMS goes out through the alots.in gateway (
pkg/sms); a provider failure deletes the stored OTP and returns a generic error. POST /api/v1/auth/otp/verify: attempt-capped; first-time phones auto-create a user + wallet and the response carries the custom JWT used asx-sdk-access-tokenthereafter. On that first signup the optionalreferral_code/referral_sourcefields (or, failing those, an IP match against recent share-link clicks) attribute the new user to a referrer — see the referral flow below.
Referral share links & signup attribution¶
Attribution only — no wallet rewards (see ADR 0012).
- An authenticated user fetches
GET /api/v1/referrals/my-code, which lazily generatesusers.referral_codeand returns a share URL of the formhttps://aimstoreorg.com/r/<CODE>. - A visitor opens the link. Caddy routes
/r/*on the website vhost toaim_api(everything else still goes toaim_website); the backend logs a row inreferral_clicks(code, client IP from Caddy'sX-Forwarded-For, user-agent) and302-redirects to the Play Store with the code encoded in the install-referrerquery parameter. Unknown codes still redirect — the link never dead-ends. - The app reads the Play Install Referrer after install and passes
referral_code+referral_source: "install_referrer"toPOST /api/v1/auth/otp/verifyat signup (a manually typed code is"manual"; the password pathPOST /auth/registeraccepts the same field). If no explicit code arrives, the backend falls back to the most recentreferral_clicksrow for the client IP withinREFERRAL_IP_MATCH_WINDOW_HOURS(default 48h) — method"ip". - A successful match sets
users.referred_by(first touch only, never self-referral) and writes an audit row toreferral_attributions. Invalid or unknown codes never fail registration. - 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)¶
POST /api/v1/orders/quoterecomputes prices server-side from the catalogue — the client's amounts are never trusted.POST /api/v1/ordersplaces the order and debits the shopping-credits wallet atomically;POST /orders/:id/cancelrefunds it.- Back-office reconciliation lives under
/api/v1/admin/orders*,/admin/refunds,/admin/reconciliation.
Deploy flow (push → production)¶
- Push to
main. Path filters pick the affected workflow(s) (.github/workflows/deploy-*.yml). buildjob: Buildx builds the image (GHA layer cache) and pusheslatest+sha-<short>toghcr.io/aimorgcode/*.deployjob: copiesdeploy/configs to/opt/aim-store(git is the source of truth), then over SSH — serialized byflock /tmp/aim-store-deploy.lock, fail-fast — logs into GHCR and pulls the new image.- Backend only:
pg_dumpinto/opt/aim-store/backups(last 14 of each kept), thendeploy/apply-migrations.shbefore restart. docker compose up -d --no-build <services> caddy, then a health check pinned to the VPS withcurl --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.shgenerates 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/staticis mounted intoaim_apiat/root/static; the backend serves/team-images,/player-images, and/kyc-documentsfrom there (routes.go), so images survive container replacement. /metricsis blocked at Caddy (404 publicly) but scraped internally by Prometheus overaim_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 therole == "admin"claim.