Skip to content

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.tscreateForSelldispatchToGateway, 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)

  1. 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/quote then POST /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-readiness ANDed with VITE_DIGITAL_GOLD_PAYMENT_ENABLED, which is currently unset, so web checkout is off (aimgold-website/src/lib/usePaymentReadiness.ts).
  2. 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 the payment_orders table — a DB uniqueness check, not a Redis lock — so a retry returns the ORIGINAL order without spending a provider price call. It then calls QuotesService.createBuyQuote (limits + KYC + integer-paise breakdown + provider price lock) and PaymentOrdersService.createFromQuote.
  3. PaymentOrdersService.createFromQuote runs 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 the gold_quotes row under SELECT … FOR UPDATE and writes the gold_orders
  4. payment_orders pair 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).
  5. SafeGold buy-gold-verify returns 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 by SAFEGOLD_ENCRYPTED=true, which the generated production env does not set, see libs/config/src/app.config.ts and deploy/gen-env.sh). A gold_orders row is saved PENDING.
  6. 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, and libs/payments/src/gateway-neutrality.spec.ts fails 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 our amount_paise and a mismatch aborts. The payment order moves to GATEWAY_ORDER_CREATED; the client gets the public checkout config, with no secret in it.
  7. 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 bare POST /v1/webhooks/payment survives only as a legacy alias onto the configured default gateway. The controller is @Public() AND @SkipThrottle()@Public skips authentication only. HMAC-SHA256 is checked with timingSafeEqual over the exact bytes Caddy passed through untouched (deploy/Caddyfile); a missing raw body is a 401, never a re-serialised HMAC.
  8. On a verified capture the order is marked PAID — and ONLY here. There is deliberately no markPaid method; capture is applied inside the same transaction that verifies the HMAC, de-duplicates on UNIQUE (gateway, event_id) and re-checks amount and currency against the stored amount_paise (modules/payments/payment-webhook.service.ts). test/payment-authority.spec.ts fails the build if any other code path sets an order to PAID; an amount mismatch parks the payment in MANUAL_REVIEW and never allocates gold. AllocationService.allocateBuy (modules/orders/allocation.service.ts) then guards the 8-minute provider expiry and calls SafeGold buy-gold-confirm.
  9. A failure in allocation never marks the order FAILED. It sets RETRY_PENDING with backoff and escalates to MANUAL_REVIEW (allocation.service.ts), and OrdersService.markFailed refuses a PAID or ALLOCATED order outright — it flips the order to MANUAL_REVIEW instead 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.
  10. PortfolioService.applyBuyExact updates gold_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). applyBuy is only the number/string convenience wrapper around it. Order → ALLOCATED; a buy_success notification row is written (push/SMS/email handoff is still a TODO in modules/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)

  1. POST /v1/auth/send-otp (public): a 6-digit OTP is generated (DEV_FIXED_OTP override in dev), bcrypt-hashed into Redis with a 10-minute TTL (modules/auth/otp.service.ts).
  2. The SMS goes out through the alots.in gateway with a DLT-registered template, sender AIMECO (libs/common/src/sms/sms.service.ts).
  3. 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).
  4. TokenService.issueForUser signs the access JWT ({sub, phone, deviceId}) and stores only a bcrypt hash of the random refresh token in the sessions table (30-day expiry). POST /v1/auth/refresh rotates: old session revoked, new pair issued.
  5. 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)

  1. digigold-admin/app/(admin)/build-tools/ lists recent runs via GET /v1/devops/runs; the Deploy button (SUPER_ADMIN only) posts {target} through the token-injecting Next.js proxy (app/api/proxy/[...path]/route.ts).
  2. AdminDevopsService.triggerDeploy (apps/admin-api/src/modules/devops/devops.service.ts): server-side target→workflow allowlist, 60 s Redis lock against double-fires, GitHub workflow_dispatch on main using the server-side GITHUB_PAT, and an AuditLog row (devops.deploy.trigger).
  3. 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)

  1. 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).
  2. build job: Buildx builds the image (GHA layer cache) and pushes latest + sha-<short> to GHCR using the workflow's GITHUB_TOKEN.
  3. deploy job: copies deploy/docker-compose.yml + deploy/Caddyfile to /opt/aim/deploy (git is the source of truth; server copies are overwritten), then over SSH — serialized by flock, fail-fast — logs into GHCR and pulls the new image.
  4. Backend only: pg_dump into /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 runs typeorm migration:show and 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-gated migrate-production workflow first, below.
  5. docker compose up -d --no-build <services> caddy. The Caddyfile is a BIND MOUNT, so up -d does not recreate caddy on a content change and the reload is the only thing that applies a new config. scp replaces the inode, which means caddy validate && caddy reload can 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 validate runs BEFORE caddy reload and neither is || true-suppressed. Old-image prune last.
  6. verify step: 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/metrics endpoint from inside digigold_api, polling up to 60s, requiring a # TYPE line 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.

  1. Run migrate-production with dry_run=true and read the pending list.
  2. Arm deploy/production-migration-authorization.json on a manifest-only branch: a one-time authorization_id, the approved_commit_sha, the exact ordered authorized_migrations, approved_by, and an expires_at.
  3. Dispatch migrate-production with confirm=MIGRATE. aim-digigold-real/scripts/verify-migration-authorization.js re-checks four independent bindings and fails closed on any mismatch: COMMIT-BOUND (HEAD may differ from approved_commit_sha only by the manifest file itself), LIST-BOUND (exact pending migrations in exact order), OPERATOR-BOUND (approved_by must equal the dispatching actor), and SINGLE-USE (the id must not already appear in the server-side consumed ledger) — plus ref=refs/heads/main and an unexpired expires_at. The workflow migrates against whatever API_IMAGE_TAG the server's .env.production pins; only the manual fallback derives the tag from approved_commit_sha (see below).
  4. A verified pre-migrate-*.sql.gz backup is taken first. The authorization id is appended to /opt/aim/deploy/consumed-migration-authorizations.txt only after success, so a run that failed before mutating anything can be retried.
  5. 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.jsondeploy/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.fromInr is half-up, Weight.fromGrams is 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 in Paise. A JSON number is accepted but is String()-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 through Money.fromDb / toDb; legacy and commerce tables store numeric(12,2) rupee strings (gold_orders.amount_inr, products.price). Gold weight is numeric(10,4) on legacy tables and numeric(18,8) on ledger tables. There is no real or double precision anywhere in the financial schema.
  • Money.toInrNumber is display-only. It appears in every toView projection 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 expectedTotal they 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.ts in each app registers the guards as global APP_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's RolesGuard is EXACT-MATCH, not hierarchical: @Roles(ADMIN) alone would lock out SUPER_ADMIN, and a handler with no @Roles is 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-admin keeps it in the NextAuth server session and injects it in app/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_DISABLED unless ACCOUNTING_AUTO_POSTING_ENABLED is exactly 'true';
  • ACCOUNTING_POSTING_BLOCKED_ENGINE_NOT_WIRED even 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 carry mem_limits; every server-touching job serialises on the host with flock -w 300 /tmp/aim-deploy.lock, with a separate /tmp/aim-build.lock.d mutex 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.sh generates fresh ones; .gitignore blocks .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_body blocks) because the gateway and SafeGold webhook HMAC checks need the exact bytes.
  • One image, three appsapi, admin_api and accounting_api all run ghcr.io/aimgoldorg/digigold-api, switched by the compose command (aim-digigold-real/Dockerfile). api and admin_api always deploy together; accounting_api sits behind the accounting compose profile and is NOT restarted by deploy-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 the account-admin-enable workflow).
  • Kill switches fail closed, but not all on the same comparison. ACCOUNTING_AUTO_POSTING_ENABLED and ACCOUNTING_PROVIDER_BALANCE_ENABLED require the exact lowercase string true (=== 'true'). DIGITAL_SELL_ENABLED and DIGITAL_GOLD_PAYMENT_ENABLED are compared case-insensitively (.toLowerCase() === 'true'), so TRUE and True also switch them on. In all four, 1, yes, a placeholder and unset are OFF. ACCOUNTING_PORTAL_MODE defaults to readonly and only the exact string readwrite unlocks writes; it is stated explicitly in deploy/docker-compose.yml rather than left to the env file so docker compose config shows the mode instead of anyone inferring it from an absence. The website's build-time VITE_DIGITAL_GOLD_PAYMENT_ENABLED follows 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.production only, so backend secrets never enter the frontend container.