Architecture¶
System diagram¶
flowchart TB
subgraph Clients
APP[Flutter app<br/>aimgold_app]
WEB[Browser<br/>aimgold.org]
ADMIN[Admin browser<br/>admin.aimgold.org]
ACCT[Accounting browser<br/>accounts.aimgold.org]
end
subgraph GitHub
REPO[aimgoldorg/Karat<br/>main branch]
GHA[GitHub Actions<br/>deploy-* workflows]
GHCR[(ghcr.io/aimgoldorg/*)]
end
subgraph VPS["Production VPS 200.141.2.55 (Docker Compose)"]
CADDY[Caddy 2<br/>TLS + vhost routing<br/>only published ports 80/443]
subgraph edge[aim_edge network]
API[digigold_api<br/>NestJS apps/api :3000]
ADMINAPI[digigold_admin_api<br/>NestJS apps/admin-api :3001]
ACCAPI[digigold_accounting_api<br/>NestJS apps/accounting-api :3003<br/>profile: accounting]
PORTAL[digigold_admin_portal<br/>Next.js :3002]
ACCADMIN[aim_account_admin<br/>Next.js :3004<br/>profile: accounting]
SITE[aim_gold_web<br/>nginx static React]
DOCS[aim_docs<br/>nginx static MkDocs]
end
subgraph data[aim_data network]
PG[(PostgreSQL 16)]
REDIS[(Redis 7)]
MINIO[(MinIO S3)]
end
subgraph mon[aim_mon network]
GRAFANA[Grafana]
PROM[Prometheus]
LOKI[Loki]
TEMPO[Tempo]
ALLOY[Alloy]
BLACKBOX[blackbox-exporter]
end
end
subgraph External
SAFEGOLD[SafeGold partner API]
RZP[Razorpay payments/payouts]
SMS[alots.in SMS gateway]
LE[Let's Encrypt]
end
APP -->|HTTPS /v1| CADDY
WEB --> CADDY
ADMIN --> CADDY
ACCT --> CADDY
CADDY --> API
CADDY --> ADMINAPI
CADDY --> ACCAPI
CADDY --> PORTAL
CADDY --> ACCADMIN
CADDY --> SITE
CADDY --> DOCS
CADDY --> GRAFANA
PORTAL -->|server-side proxy + bearer| ADMINAPI
PORTAL -->|socket.io /price namespace| API
API --> PG
API --> REDIS
API --> MINIO
ADMINAPI --> PG
ADMINAPI --> REDIS
ADMINAPI --> MINIO
ACCAPI --> PG
ACCAPI --> REDIS
API --> SAFEGOLD
API --> RZP
API --> SMS
ADMINAPI -->|KYC registration, live price| SAFEGOLD
ADMINAPI -->|RazorpayX payout dispatch| RZP
ADMINAPI -->|devops module| GHA
PROM -->|scrape /v1/metrics via aim_edge| API
PROM --> ADMINAPI
BLACKBOX -->|public HTTPS probe| CADDY
ALLOY -->|docker logs| LOKI
CADDY --> LE
REPO --> GHA
GHA -->|build + push| GHCR
GHA -->|SSH: backup, pull, restart| VPS
GHCR -->|docker pull| VPS
accounting_api and account_admin sit behind the accounting compose
profile (deploy/docker-compose.yml), so they are excluded from every
default docker compose up, pull, build and config. Bringing them up
is an explicit act.
Components¶
| Component | Tech | Code path | Runs as |
|---|---|---|---|
| Customer API | NestJS 10 + Fastify, TypeORM, socket.io | aim-digigold-real/apps/api |
digigold_api (:3000) |
| Admin API | NestJS 10 + Fastify (same image, different command) |
aim-digigold-real/apps/admin-api |
digigold_admin_api (:3001) |
| Accounting API | NestJS 10 + Fastify (same image, different command); prefix /api/v1/accounting, x-portal-token auth |
aim-digigold-real/apps/accounting-api |
digigold_accounting_api (:3003, profile accounting) |
| Shared backend libs | config (joi), database (entities+migrations), redis, safegold, razorpay, payments (provider-neutral gateway registry/resolver + adapters), finance (append-only ledger, accounting posting gate, reconciliation, refunds), storage (S3), common (money, guards, metrics, SMS), events | aim-digigold-real/libs/* |
compiled into all three apps |
| Website | React 18 + Vite (built with Bun), served by nginx | aimgold-website/ |
aim_gold_web |
| Admin portal | Next.js (App Router, standalone) + NextAuth | digigold-admin/ |
digigold_admin_portal (:3002) |
| Account Admin portal | Next.js 14 App Router (standalone); accounting/tax/auditor surface | aim-gold-account-admin/ |
aim_account_admin (:3004, profile accounting) |
| Docs site | MkDocs Material → nginx | docs/ + docs.Dockerfile + mkdocs.yml |
aim_docs (:80) |
| Mobile app | Flutter (bloc, go_router, dio) | aimgold_app/ |
store releases, not on VPS |
| Edge | Caddy 2, automatic Let's Encrypt | deploy/Caddyfile |
aim_caddy (80/443) |
| Data stores | PostgreSQL 16, Redis 7, MinIO | deploy/docker-compose.yml |
digigold_postgres / digigold_redis / digigold_minio |
| Monitoring | Grafana, Prometheus, Loki, Tempo, Alloy, blackbox-exporter (external HTTPS probe of api.aimgold.org), node-exporter, cAdvisor | monitoring/ |
mon_* containers |
| CI/CD | GitHub Actions + GHCR + SSH | .github/workflows/ |
GitHub-hosted runners |
| Future backend | Go 1.25 + Fiber (not deployed, see ADR 0012) | aim-gold-backend/ |
— |
Published hostnames¶
deploy/Caddyfile serves seven hostname blocks. Nothing else is reachable
from the internet; only Caddy publishes ports.
| Hostname | Upstream |
|---|---|
| api.aimgold.org | digigold_api:3000 (2MB body cap; /v1/metrics and /metrics are 404'd) |
| admin-api.aimgold.org | digigold_admin_api:3001 (metrics 404'd) |
| aimgold.org, www.aimgold.org | aim_gold_web:80 |
| admin.aimgold.org | digigold_admin_portal:3002 (25MB body cap) |
| accounts.aimgold.org | /api/v1/accounting/* → digigold_accounting_api:3003; everything else → aim_account_admin:3004 |
| docs.aimgold.org | aim_docs:80 |
| monitor.aimgold.org | mon_grafana:3000 |
accounts-api.aimgold.org is deliberately NOT published and has no DNS
record — the accounting API is reachable only through the path split on
accounts.aimgold.org (deploy/Caddyfile).
Payouts split on ₹1,00,000. A sell payout at or below the cap is
auto-dispatched to RazorpayX by the customer API with no operator in the
loop (apps/api/src/modules/payouts/payouts.service.ts — createForSell →
dispatchToGateway, AUTO_DISPATCH_LIMIT_PAISE). Only payouts above the cap
queue for approval and are dispatched from the admin API
(apps/admin-api/src/modules/payouts/payouts.service.ts), where an ADMIN's
approval is itself capped at ₹1,00,000 and anything larger needs SUPER_ADMIN.
The admin API also talks to SafeGold directly (customer
registration on KYC approval, live price) and proxies KYC document bytes
out of MinIO.
Key flows¶
Buy gold (customer)¶
- Both clients place buys. The Flutter app calls
POST /v1/gold/buy(aimgold_app/lib/core/network/api_endpoints.dart), the legacy one-shot endpoint. The website uses the two-step form —POST /v1/gold/quotethenPOST /v1/payments/orders(aimgold-website/src/lib/gold.ts) — so it can show a live expiry countdown. Checkout availability on the website is backend readiness AND the build-time flag:GET /v1/public/payment-readinessANDed withVITE_DIGITAL_GOLD_PAYMENT_ENABLED, which is currently unset, so web checkout is off (aimgold-website/src/lib/usePaymentReadiness.ts). OrdersService.createBuy(aim-digigold-real/apps/api/src/modules/orders/orders.service.ts) is a thin composition, not its own implementation. It first resolves the idempotency key against thepayment_orderstable — a DB uniqueness check, not a Redis lock — so a retry returns the ORIGINAL order without spending a provider price call. It then callsQuotesService.createBuyQuote(limits + KYC + integer-paise breakdown + provider price lock) andPaymentOrdersService.createFromQuote.PaymentOrdersService.createFromQuoteruns an evidence-based gate BEFORE anything else:MerchantReadinessService.assertPaymentsPermitted()requires zero blocking readiness items AND the feature flag (libs/common/src/legal/merchant-readiness.service.ts), so an accidentally-flipped flag cannot open checkout while the GSTIN, provider disclosure or legal content are unverified. It then consumes thegold_quotesrow underSELECT … FOR UPDATEand writes thegold_orderspayment_orderspair in one transaction. Quote expiry is clamped inside SafeGold's 8-minute lock minus a 45s safety margin (apps/api/src/modules/quotes/quotes.service.ts), so a customer can never pay against a lapsed provider lock. Default quote TTL is 420s (QUOTE_TTL_SECONDS,libs/config/src/app.config.ts).- SafeGold
buy-gold-verifyreturns a transaction id valid for 8 minutes (libs/safegold/src/safegold.service.ts; plain JSON on the wire — the AES-256-CBC envelope is a legacy mode enabled only bySAFEGOLD_ENCRYPTED=true, which the generated production env does not set, seelibs/config/src/app.config.tsanddeploy/gen-env.sh). Agold_ordersrow is savedPENDING. - A gateway order is created AFTER the DB transaction commits. Which
provider takes the payment is resolved by
PaymentGatewayResolver(libs/payments/src/gateway-resolver.service.ts) from an explicit preference → per-instrument route → priority list → default; no business service ever names a provider, andlibs/payments/src/gateway-neutrality.spec.tsfails the build if a provider token appears in neutral code. Razorpay is currently the only registered adapter (libs/payments/src/adapters/razorpay/razorpay.adapter.ts). The amount the gateway echoes back is compared against ouramount_paiseand a mismatch aborts. The payment order moves toGATEWAY_ORDER_CREATED; the client gets the public checkout config, with no secret in it. - The gateway calls
POST /v1/webhooks/payment/:gateway(modules/payments/payments-webhook.controller.ts). The path is what names the adapter that must verify the signature, so each provider has its own path and its own secret. The barePOST /v1/webhooks/paymentsurvives only as a legacy alias onto the configured default gateway. The controller is@Public()AND@SkipThrottle()—@Publicskips authentication only. HMAC-SHA256 is checked withtimingSafeEqualover the exact bytes Caddy passed through untouched (deploy/Caddyfile); a missing raw body is a 401, never a re-serialised HMAC. - On a verified capture the order is marked
PAID— and ONLY here. There is deliberately nomarkPaidmethod; capture is applied inside the same transaction that verifies the HMAC, de-duplicates onUNIQUE (gateway, event_id)and re-checks amount and currency against the storedamount_paise(modules/payments/payment-webhook.service.ts).test/payment-authority.spec.tsfails the build if any other code path sets an order to PAID; an amount mismatch parks the payment inMANUAL_REVIEWand never allocates gold.AllocationService.allocateBuy(modules/orders/allocation.service.ts) then guards the 8-minute provider expiry and calls SafeGoldbuy-gold-confirm. - A failure in allocation never marks the order
FAILED. It setsRETRY_PENDINGwith backoff and escalates toMANUAL_REVIEW(allocation.service.ts), andOrdersService.markFailedrefuses a PAID or ALLOCATED order outright — it flips the order toMANUAL_REVIEWinstead and logs the refusal (orders.service.ts). Showing a customer "failed" while holding their money is the specific outcome that guard exists to prevent. There is also never an automatic refund. PortfolioService.applyBuyExactupdatesgold_holdings(balance/invested/average price) inside the same allocation transaction, taking integer paise and 1e8-scaled grams rather than floats, and busts the Redis portfolio cache (modules/portfolio/portfolio.service.ts).applyBuyis only the number/string convenience wrapper around it. Order →ALLOCATED; abuy_successnotification row is written (push/SMS/email handoff is still a TODO inmodules/notifications/notifications.service.ts— there is no FCM, SMS or SMTP adapter behind it).
External services: the resolved payment gateway (orders + webhooks), SafeGold (price, verify, confirm, invoice link).
Sell gold + payout (customer)¶
DigitalSellGate.assertEnabled() is the literal first statement of
OrdersService.createSell. Selling is off unless DIGITAL_SELL_ENABLED is
lowercases to true, and the gate refuses with 503, not 403
(libs/common/src/capability/digital-sell.gate.ts) — it is a statement about
the service, not about the customer.
When on: owner-scoped idempotency plus a 120s Redis lock → capability check →
consume a stored SELL quote under a row lock → verified bank account →
holding lock → balance check → SafeGold verify-sell / confirm-sell /
order-status → debit the holding. Once verify-sell has returned, no error
may produce a terminal state: the order keeps its status, takes a
manual_review_reason, and the customer gets a 503 that says the sell has
NOT been cancelled. Withholding is hard-zero today.
The payout row is created by the customer API at the end of the sell. At or
below ₹1,00,000 it auto-dispatches to RazorpayX immediately, with no
operator in the loop (apps/api/src/modules/payouts/payouts.service.ts,
createForSell / AUTO_DISPATCH_LIMIT_PAISE). Above the cap it stays
PENDING for an operator and is dispatched from
apps/admin-api/src/modules/payouts/payouts.service.ts, where an ADMIN
approval is capped at ₹1,00,000 and above that SUPER_ADMIN is required.
Physical shop (COD)¶
A public catalogue at GET /v1/products plus cart checkout at
POST /v1/product-orders (apps/api/src/modules/products,
modules/product-orders). Products are FIXED or DYNAMIC; a DYNAMIC row's
price is derived from the live 24K rate at read time and again at order time
(libs/common/src/money/product-pricing.ts). When the rate feed is down the
catalogue degrades to the stored price snapshot flagged
priceBasis: 'FIXED', while the ORDER path refuses with 503 instead — a
stale price may be shown, never charged. The flow requires account standing
but deliberately NOT digital-gold KYC. Idempotency is a partial unique index
on (user_id, idempotency_key), not Redis.
OTP login (customer)¶
POST /v1/auth/send-otp(public): a 6-digit OTP is generated (DEV_FIXED_OTPoverride in dev), bcrypt-hashed into Redis with a 10-minute TTL (modules/auth/otp.service.ts).- The SMS goes out through the alots.in gateway with a DLT-registered
template, sender
AIMECO(libs/common/src/sms/sms.service.ts). POST /v1/auth/verify-otp: max 3 attempts then a 30-minute Redis lockout; first-time phones auto-create a user with a referral code (modules/auth/auth.service.ts).TokenService.issueForUsersigns the access JWT ({sub, phone, deviceId}) and stores only a bcrypt hash of the random refresh token in thesessionstable (30-day expiry).POST /v1/auth/refreshrotates: old session revoked, new pair issued.- Optional layers: MPIN (bcrypt, 5-attempt lockout) and biometric login (device-bound token, hash kept in Redis keyed by user+device).
External service: alots.in SMS. The gateway credentials come from the
environment (SMS_USERNAME / SMS_APIKEY, required in production —
libs/config/src/validation.schema.ts); once set, OTP requests send live SMS.
Admin deploy (Build Tools page)¶
digigold-admin/app/(admin)/build-tools/lists recent runs viaGET /v1/devops/runs; the Deploy button (SUPER_ADMIN only) posts{target}through the token-injecting Next.js proxy (app/api/proxy/[...path]/route.ts).AdminDevopsService.triggerDeploy(apps/admin-api/src/modules/devops/devops.service.ts): server-side target→workflow allowlist, 60 s Redis lock against double-fires, GitHubworkflow_dispatchonmainusing the server-sideGITHUB_PAT, and anAuditLogrow (devops.deploy.trigger).- From there it is the standard deploy flow below.
Live price¶
SafeGold buy/sell price fetched and cached in Redis for 10 s
(libs/safegold/src/safegold.service.ts), exposed publicly at
GET /v1/price/current and broadcast every 10 s on the socket.io /price
namespace (modules/price/price.gateway.ts).
The two customer clients poll REST — the website on a 30s interval
(aimgold-website/src/store/price.ts) and Flutter's PriceCubit likewise
(socket_io_client is declared in aimgold_app/pubspec.yaml but has zero
usages under lib/). The one shipped socket.io consumer is the ADMIN
PORTAL's dashboard ticker
(digigold-admin/app/(admin)/dashboard/live-price-ticker.tsx), which
connects to the customer API's /price namespace via
NEXT_PUBLIC_API_WS_URL, baked in at image build.
Warning
The price gateway sets cors: { origin: '*' }
(modules/price/price.gateway.ts) and does not inherit the REST
allowlist.
Background jobs¶
ScheduleModule.forRoot() is registered in apps/api only
(apps/api/src/app.module.ts), so every @Cron in the platform fires from
that one process. A scheduled job placed in admin-api or accounting-api
would never fire and would never say so. Ten jobs run there: quote expiry
(1m), payment-order expiry sweep (5m), allocation retry (1m) and its
escalation pass (10m), SIP due plans (every 15m), payout status polling (5m),
the reconciliation tick (1m, Redis-locked), buy and sell metric refresh (1m),
and the sell-invariant sweep (10m, Redis-locked). The cluster-wide ones take
Redis locks because the schedule limit is per-process, not per-cluster.
Deploy flow (push → production)¶
- Push to
main. Path filters pick the affected workflow(s) (.github/workflows/deploy-backend.yml,deploy-website.yml,deploy-admin-portal.yml,deploy-docs.yml,deploy-monitoring.yml). buildjob: Buildx builds the image (GHA layer cache) and pusheslatest+sha-<short>to GHCR using the workflow'sGITHUB_TOKEN.deployjob: copiesdeploy/docker-compose.yml+deploy/Caddyfileto/opt/aim/deploy(git is the source of truth; server copies are overwritten), then over SSH — serialized byflock, fail-fast — logs into GHCR and pulls the new image.- Backend only:
pg_dumpinto/opt/aim/backups/pre-deploy-*.sql.gz, verified (non-empty, ≥10240 bytes,gzip -t, dump-complete marker) before the 14-item retention sweep runs, so retention can never delete the last good backup in favour of a truncated one. Migrations are NOT applied here. The workflow runstypeorm migration:showand REFUSES to deploy if anything is pending (deploy-backend.yml) — a merge must not be able to alter the production schema as a side effect of shipping code. Schema changes go through the approval-gatedmigrate-productionworkflow first, below. docker compose up -d --no-build <services> caddy. The Caddyfile is a BIND MOUNT, soup -ddoes not recreate caddy on a content change and the reload is the only thing that applies a new config.scpreplaces the inode, which meanscaddy validate && caddy reloadcan both report success against a stale in-container file — a hardening edit could be merged, deployed, verified and never take effect, and the broken file would then crash-loop TLS at the next unrelated restart. Every deploy path therefore sha256-compares the host file against the container's and force-recreates caddy if they differ.caddy validateruns BEFOREcaddy reloadand neither is|| true-suppressed. Old-image prune last.verifystep:curl --resolve <host>:443:VPS_IP https://<host>/...health checks pinned to the VPS, retried up to a minute. Backend only: twelve sell/payout metric series are additionally verified present on the live/v1/metricsendpoint from insidedigigold_api, polling up to 60s, requiring a# TYPEline and a sample for each and refusing on unexpected labels (deploy-backend.yml). A red run means the deploy did not verify; previous containers keep serving.
Manual triggers: every workflow except ci.yml has workflow_dispatch
(ci.yml is pull_request only). Only FOUR are reachable from the admin
portal's Build Tools page — backend, website, admin-portal and
monitoring — via POST /v1/devops/deploy (SUPER_ADMIN only; the
target→workflow map in
apps/admin-api/src/modules/devops/devops.service.ts is a closed allow-list
and client input never reaches a URL). migrate-production, deploy-docs,
deploy-android, account-admin-enable, the backup and drill jobs and the
read-only production probes are deliberately NOT dispatchable from the portal
and must be run from GitHub. The button always dispatches ref: 'main';
there is no branch or SHA selector.
Production migrations¶
Schema changes never ride in on a merge. deploy-backend refuses to deploy
while any migration is pending; applying one is a separate, manually
dispatched act. migrate-production.yml is workflow_dispatch only, takes a
confirm input that must be the literal string MIGRATE, defaults
dry_run to true, and shares the deploy-backend concurrency group.
- Run
migrate-productionwithdry_run=trueand read the pending list. - Arm
deploy/production-migration-authorization.jsonon a manifest-only branch: a one-timeauthorization_id, theapproved_commit_sha, the exact orderedauthorized_migrations,approved_by, and anexpires_at. - Dispatch
migrate-productionwithconfirm=MIGRATE.aim-digigold-real/scripts/verify-migration-authorization.jsre-checks four independent bindings and fails closed on any mismatch: COMMIT-BOUND (HEAD may differ fromapproved_commit_shaonly by the manifest file itself), LIST-BOUND (exact pending migrations in exact order), OPERATOR-BOUND (approved_bymust equal the dispatching actor), and SINGLE-USE (the id must not already appear in the server-side consumed ledger) — plusref=refs/heads/mainand an unexpiredexpires_at. The workflow migrates against whateverAPI_IMAGE_TAGthe server's.env.productionpins; only the manual fallback derives the tag fromapproved_commit_sha(see below). - A verified
pre-migrate-*.sql.gzbackup is taken first. The authorization id is appended to/opt/aim/deploy/consumed-migration-authorizations.txtonly after success, so a run that failed before mutating anything can be retried. - Disarm (
authorization_id: null) — that is the resting state, and the state the manifest is in today.
Warning
This is single-owner control, not four-eyes approval: one GitHub account both arms and spends the authorization. The manifest header records that trade-off explicitly. Ordering for a release that carries a migration is therefore: deploy backend (builds the image, then refuses) → migrate → deploy backend again.
Fallback: manual deploy¶
When Actions cannot run, npm run deploy:<backend|website|admin|migrate|all>
(repo-root package.json → deploy/scripts/manual-deploy.sh) drives the
same sequence over SSH. It is not a shortcut: it enforces the same gates —
verified backup, pending-migration refusal, Caddyfile bind-mount staleness
check, metrics verification — and refuses in all the same places.
Two build transports: build locally for linux/amd64 and ship the image with
docker save | gzip | ssh docker load (the default when Docker is running
here), or --transport server to rsync source and build on the VPS. It tags
:sha-<rev> only and moves :latest inside the deploy transaction; docker
compose pull is never run for a component it just built; pruning is
dangling-only so the previous release survives for rollback. deploy:migrate
is always strict (no --allow-dirty) and additionally requires
AIM_MIGRATE_CONFIRM=MIGRATE in the environment. It derives the migration
image tag from the manifest's approved_commit_sha rather than HEAD —
arming is itself a commit, so a HEAD-derived tag would name an image that
was never built — and dies immediately if the manifest is disarmed
(6bf91a8). It also overrides API_IMAGE_TAG for its migration:show probe,
because this script promotes :latest only later; the Actions workflow needs
no such override, since it pulls the freshly built :latest first. deploy:preflight,
deploy:status and deploy:dry-run are read-only.
Money model¶
Nothing performs monetary arithmetic in a JS number.
- In memory: money is an integer count of PAISE held in
bigint; gold weight is grams scaled by 1e8.MONEY_DP = 2,WEIGHT_DP = 8,PROVIDER_WEIGHT_DP = 4(SafeGold's precision) —libs/common/src/money/money.ts. - Rounding is always explicit:
'floor' | 'ceil' | 'half-up' | 'exact', where'exact'THROWS rather than dropping digits. Defaults differ by unit on purpose —Money.fromInris half-up,Weight.fromGramsis floor, so we never over-promise gold. - On the wire: rupees cross as an exact DECIMAL STRING
(
/^\d{1,12}(\.\d{1,2})?$/), validated by@IsMoneyWire. A field already denominated in paise crosses as an integer and its name ends inPaise. A JSON number is accepted but isString()-ed before validation and held to the same regex —libs/common/src/money/money-wire.ts. - At rest: two generations coexist deliberately. Tables from the
payment-gateway migration onward store integer paise as
bigint(gold_quotes.*_paise,payment_orders.amount_paise) read and written throughMoney.fromDb/toDb; legacy and commerce tables storenumeric(12,2)rupee strings (gold_orders.amount_inr,products.price). Gold weight isnumeric(10,4)on legacy tables andnumeric(18,8)on ledger tables. There is norealordouble precisionanywhere in the financial schema. Money.toInrNumberis display-only. It appears in everytoViewprojection and must never be fed back into a calculation.- Clients mirror the rule where they must compute. The website and the
Flutter cart do line totals, GST and delivery in integer paise so the
expectedTotalthey send matches the server bit-for-bit (aimgold-website/src/lib/types.ts).
Auth boundaries¶
Three backend apps, three token models. A credential for one is rejected by the others.
| App | Transport | Global guards (in order) | Revocation |
|---|---|---|---|
apps/api (:3000) |
Authorization: Bearer JWT, 15m access + 30d rotating refresh |
ThrottlerGuard (100/60s/IP) → JwtAuthGuard | Redis denylist user:revoked:<id>, checked per request — fails OPEN when Redis is down, degrading to token expiry |
apps/admin-api (:3001) |
Authorization: Bearer JWT, 8h, same JWT_SECRET |
ThrottlerGuard (60/60s) → JwtAuthGuard → RolesGuard | Per-request Postgres lookup of admin_users (id, email, role, is_active); the DB role wins over the token's, so demotion or deactivation takes effect on the next request |
apps/accounting-api (:3003) |
x-portal-token, opaque 32 random bytes; only its SHA-256 hex is stored |
ThrottlerGuard (120/60s) → PortalTokenGuard (401) → PortalPermissionsGuard (403) → PortalReadOnlyGuard (403) | Session row revoked in the DB; role, active flag and the permission array are re-read on every request |
Things that matter when changing any of this:
- Deny by default in all three (
app.module.tsin each app registers the guards as globalAPP_GUARDs).@Public()skips authentication only — anything that must also bypass rate limiting needs@SkipThrottle(), which is why every webhook controller carries both. apps/admin-api'sRolesGuardis EXACT-MATCH, not hierarchical:@Roles(ADMIN)alone would lock out SUPER_ADMIN, and a handler with no@Rolesis reachable by any authenticated admin. The operations portal uses a rank ladder instead (digigold-admin/lib/permissions.ts), so the two layers disagree in shape.- Customer and admin JWTs share one secret and differ only in TTL
(
ADMIN_JWT_ACCESS_TTL, default 8h —libs/config/src/app.config.ts). Cross-use fails closed only because each strategy looks the subject up in its own table; there is no audience or issuer claim separating them. - The accounting API deliberately does not accept
Authorization: Bearer, so a token minted for either other app cannot be forwarded into it. It also publishes no Swagger schema at all. - Browsers never hold the admin JWT:
digigold-adminkeeps it in the NextAuth server session and injects it inapp/api/proxy/[...path]/route.ts.
Accounting and the ledger¶
An append-only double-entry ledger exists and is structurally empty by
design. ledger_entries is guarded by a digigold_prevent_mutation()
BEFORE UPDATE OR DELETE trigger, and LedgerService.reverseGroup() has no
production caller.
AccountingPostingGate.post()
(libs/finance/src/accounting/posting-gate.service.ts) is the only route
from an operational service to the ledger, and it refuses twice:
ACCOUNTING_POSTING_DISABLEDunlessACCOUNTING_AUTO_POSTING_ENABLEDis exactly'true';ACCOUNTING_POSTING_BLOCKED_ENGINE_NOT_WIREDeven when the flag IS on, because operational events are not yet routed through the posting engine and a journal assembled by an operational service is exactly the ungated posting this gate was created to stop.
A refusal is logged with the reference and returned — never thrown — because
the customer transaction has already happened. Turning the flag on changes
nothing except the log level. This is why the passbook derives balances from
gold_holdings rather than from the ledger, and why the accounting portal is
a reporting surface over operational tables rather than a view of the books.
Tech stack¶
| Layer | Choice | Why |
|---|---|---|
| Backend | NestJS 10 + Fastify | see ADR 0003 (reconstructed) |
| Database | PostgreSQL 16 + TypeORM migrations | see ADR 0004 |
| Cache/locks | Redis 7 | see ADR 0005 |
| Object storage | MinIO (S3 API) | see ADR 0006 |
| Web | React 18 + Vite + zustand, Bun build | Bun is also why the website is the one component that cannot be cross-built under QEMU and must build on the VPS (deploy/scripts/manual-deploy.sh) |
| Admin | Next.js standalone + NextAuth | see ADR 0009 |
| Accounting portal | Next.js 14 + separate accounting-api, x-portal-token auth |
see ADR 0014 |
| Mobile | Flutter + bloc | see ADR 0010 |
| Edge/TLS | Caddy 2 | see ADR 0007 |
| CI/CD | GitHub Actions → GHCR → SSH pull | see ADR 0008 |
| Monitoring | Grafana LGTM stack | see ADR 0011 |
| Security posture | header/CSP/guard hardening pass | see ADR 0013 |
ADR 0014 (decisions/0014-account-admin-portal.md) is the decision that
explains the accounting API and portal: digigold-admin answers "is the
system working?", and nobody could answer "are the books right?".
Repository layout¶
Karat/
├── aim-digigold-real/ # THE deployed backend (NestJS monorepo)
│ ├── apps/api/ # customer API (:3000, prefix /v1)
│ ├── apps/admin-api/ # admin API (:3001, prefix /v1) + seed CLIs
│ ├── apps/accounting-api/ # accounting API (:3003, /api/v1/accounting)
│ ├── libs/ # shared: config, database, redis, safegold,
│ │ # razorpay, payments, finance, storage,
│ │ # common, events
│ └── Dockerfile # one 3-stage image carrying all three apps
├── digigold-admin/ # Operations portal (Next.js, admin.aimgold.org)
├── aim-gold-account-admin/# Accounting portal (Next.js, accounts.aimgold.org)
├── aimgold-website/ # Public website (React/Vite → nginx, aimgold.org)
├── aimgold_app/ # Flutter mobile app (3 flavors, fastlane)
├── aim-gold-backend/ # Go/Fiber rewrite — NOT deployed (ADR 0012)
├── deploy/ # Compose stack, Caddyfile, gen-env.sh, scripts/,
│ # systemd/, production-migration-authorization.json
├── monitoring/ # Grafana/Prometheus/Loki/Tempo/Alloy stack
├── internal/ # Security registers — OUTSIDE docs_dir so MkDocs
│ # cannot publish them (mkdocs.yml)
├── .github/workflows/ # 21 workflows: 5 push-triggered deploys (backend,
│ # website, admin-portal, docs, monitoring),
│ # 3 dispatch-only production actions
│ # (migrate-production, deploy-android,
│ # account-admin-enable), 2 scheduled
│ # (scheduled-backup, restore-drill), 3 PR/CI,
│ # 8 read-only production probes
├── package.json # repo-root deploy:* scripts → manual-deploy.sh
├── mkdocs.yml, docs.Dockerfile
└── docs/ # this documentation → served at docs.aimgold.org
Design constraints visible in the code¶
- Single 1-vCPU VPS: CI never builds on the server — it builds on a
runner and the box only pulls from GHCR. The manual fallback
(
deploy/scripts/manual-deploy.sh) can build on the server via--transport server, and MUST for the website, which cannot be cross-built under QEMU (bun dies with SIGILL). Monitoring containers carrymem_limits; every server-touching job serialises on the host withflock -w 300 /tmp/aim-deploy.lock, with a separate/tmp/aim-build.lock.dmutex so a long build does not block the nightly backup. - Secrets never in git: runtime secrets live only in
/opt/aim/deploy/.env.production,/opt/aim/deploy/portal.env.production, and/opt/aim/monitoring/.env(all chmod 600).gen-env.shgenerates fresh ones;.gitignoreblocks.env*. - Data network isolation: postgres/redis/minio sit on
aim_data, which Caddy and the monitoring stack never join. - Webhook raw bodies: Caddy passes bodies through untouched
(
request_bodyblocks) because the gateway and SafeGold webhook HMAC checks need the exact bytes. - One image, three apps —
api,admin_apiandaccounting_apiall runghcr.io/aimgoldorg/digigold-api, switched by the composecommand(aim-digigold-real/Dockerfile).apiandadmin_apialways deploy together;accounting_apisits behind theaccountingcompose profile and is NOT restarted bydeploy-backend, so after a backend deploy it keeps running the previous image until it is explicitly restarted (docker compose --profile accounting up -d accounting_api, or theaccount-admin-enableworkflow). - Kill switches fail closed, but not all on the same comparison.
ACCOUNTING_AUTO_POSTING_ENABLEDandACCOUNTING_PROVIDER_BALANCE_ENABLEDrequire the exact lowercase stringtrue(=== 'true').DIGITAL_SELL_ENABLEDandDIGITAL_GOLD_PAYMENT_ENABLEDare compared case-insensitively (.toLowerCase() === 'true'), soTRUEandTruealso switch them on. In all four,1,yes, a placeholder and unset are OFF.ACCOUNTING_PORTAL_MODEdefaults toreadonlyand only the exact stringreadwriteunlocks writes; it is stated explicitly indeploy/docker-compose.ymlrather than left to the env file sodocker compose configshows the mode instead of anyone inferring it from an absence. The website's build-timeVITE_DIGITAL_GOLD_PAYMENT_ENABLEDfollows the same rule and is ANDed with the backend's readiness answer, so it can only ever turn checkout OFF. - Portal env split: the portal container gets
portal.env.productiononly, so backend secrets never enter the frontend container.