Skip to main content

Backend Architecture

The API (apps/api) is a NestJS 11 application organized into feature modules under src/modules/. It owns all access to PostgreSQL (via TypeORM) and to the blockchain (via CosmJS).

Module layout

Each module follows a consistent layout. Service classes always live in a services/ folder, one use case per file (e.g. services/create-token.service.ts). The module root holds wiring and cross-cutting declarations only:

src/modules/<module>/
├── <module>.module.ts # NestJS module wiring
├── <module>.controller.ts # HTTP controller(s)
├── <module>.errors.ts # error constants
├── services/ # one injectable use case per file
├── dtos/ # wire shapes (responses)
├── requests/ # request DTOs (validated input)
├── entities/ # TypeORM entities
├── mappers/ # entity ↔ model ↔ DTO translation
├── models/ # in-app domain models
├── listeners/ guards/ strategies/ …

The modules present today:

ModuleResponsibility
authLogin, register, JWT issue/refresh, password reset/change
usersInternal-user management, roles, locale, pinned wallets
invitationsEmail invitations + token validation, resend/revoke
organizationsCentral/Commercial orgs, exchange address, freeze, per-org chain RPC
bridgingFiat↔CBDC disbursement & redemption requests
multisigsAccounts (groups): list, create, members, policy, balance, archive
proposalsMint/burn/transfer/group-change proposals (built on chain x/group)
votesCast & list votes on a proposal
whitelistAllowed recipients for Distribution accounts
operationsUnified read model over chain proposals and admin actions; activity log
instant-payments@alias-addressed retail payments, per-bank settlement accounts, Issuer oversight
tokensToken registry/config + autodetect
dashboardSupply/balance metrics aggregation
control-panelAggregated admin surface (users, alerts)
address-bookSaved counterparty addresses
exportCSV export of transactions & governance
app-configPublic runtime config (currencyName, native token id)
chain@Global() chain abstraction (CosmJS adapters + stubs)
custodyEncrypted key storage + on-chain tx execution
chain-tx-audit, idempotency, events, emailCross-cutting infrastructure

Entity → model → DTO

Substantive modules separate three shapes of the same data — the entity (TypeORM), the domain model (models/), and the DTO (wire shape) — with a mapper (@Injectable()) owning the translations (toService, toController, fromChain/fromEntity). This keeps derived fields, invariants, and sensitive-field gating (password/refresh-token hashes) in one place. Pure-orchestration modules (bridging) and internal-only entities (idempotency, chain-tx-audit) skip the layer.

Authentication & authorization

Three global guards are registered via APP_GUARD in app.module.ts, in order:

  1. JwtAuthGuard — validates the access-token JWT on every request. Skip with @Public().
  2. RolesGuard — enforces @Roles(Role.ADMINISTRATOR) (simple role checks; legacy).
  3. PoliciesGuard — enforces @CheckPolicies({ action, subject }) via CASL abilities (defineAbilitiesFor(roles) from @cbdc-issuer/shared). Preferred for fine-grained access; the dashboard and API share the same ability definitions.

Passport strategies: LocalStrategy (login), JwtStrategy (access token), JwtRefreshStrategy (refresh rotation).

Token flow

  1. POST /api/auth/loginLocalStrategy validates the bcrypt password hash.
  2. The API issues a short-lived access token (JWT_ACCESS_EXPIRATION, default 15m) returned in the body, and a refresh token (JWT_REFRESH_EXPIRATION, default 7d) set as an httpOnly cookie.
  3. POST /api/auth/refresh reads the refresh cookie, rotates it, and returns a fresh access token. A grace window (AUTH_REFRESH_ROTATION_GRACE_MS, default 60s) absorbs concurrent-tab refresh races.

Configuration

Env vars are validated at startup with Joi (src/config/validation.ts). Missing/invalid values crash the process with a descriptive error — no silent runtime failures. JWT_SECRET/JWT_REFRESH_SECRET require ≥32 chars; DATABASE_MIGRATIONS_RUN defaults to true, DATABASE_SYNCHRONIZE to false.

Database & migrations

PostgreSQL 15 with TypeORM. Schema is owned by migrations in src/database/migrations/, which run automatically on startup (migrationsRun, disable with DATABASE_MIGRATIONS_RUN=false). Entities use autoLoadEntities; each module registers its own via TypeOrmModule.forFeature([...]). After changing an entity, generate a migration (pnpm db:migration:generate …) and register it in src/database/migrations/index.ts — migrations are statically bundled, so unregistered files are silently ignored.