Skip to content

Repository files navigation

Thalos Backend (NestJS)

API layer for the Thalos escrow orchestration platform. It handles agreements, contacts, profile search, disputes, email notifications, and an internal relay to Trustless Work that keeps the Trustless Work API key on the server (it never reaches the browser).

Thalos is an escrow orchestration layer on the Stellar network, connected to the Trustless Work protocol. The user always signs transactions client-side; this backend only adds the server-side API key and forwards requests — it never custodies keys or funds.

Tech stack

  • NestJS 11 + TypeScript
  • Supabase (@supabase/supabase-js) for data access (Postgres)
  • Passport JWT for app authentication
  • class-validator / class-transformer for input validation (DTOs)
  • Swagger (@nestjs/swagger) for interactive API docs
  • Resend for transactional emails

Requirements

  • Node.js 20+
  • pnpm (recommended) or npm
  • A Supabase project with the tables used by the platform (agreements, agreement_participants, agreement_activity, profiles, contacts, auth_users, disputes, dispute_resolutions, …)

Environment variables

Copy .env.example to .env.local and fill in real values. .env / .env.local are gitignored and must never be committed.

Variable Required Description
JWT_SECRET Yes Secret used to verify the app JWT (HS256). Must be identical to the frontend's JWT_SECRET, or tokens are rejected with 401.
SUPABASE_URL Yes Supabase project URL (same project the frontend uses).
SUPABASE_SERVICE_ROLE_KEY Yes Supabase service role key. Server-side only — full DB access, keep secret.
THALOS_INTERNAL_SECRET Yes Shared secret for the internal Next.js → Nest relay (x-thalos-internal-secret). Must match the frontend's value.
TRUSTLESSWORK_API_URL For escrow ops Base URL of the Trustless Work API.
TRUSTLESSWORK_API_KEY For escrow ops Trustless Work API key, injected server-side by the relay. Never expose to the browser.
TRUSTLESS_WORK_WEBHOOK_SECRET For TW webhooks HMAC secret to verify incoming Trustless Work webhook calls (x-trustless-signature). If unset, all webhook requests are rejected with 401.
RETRY_QUEUE_MAX_ATTEMPTS No Max attempts per retry-queue job before it's marked failed. Defaults to 5.
RETRY_QUEUE_BASE_DELAY_MS No Base delay (ms) for the retry queue's exponential backoff. Defaults to 1000.
RETRY_QUEUE_MAX_DELAY_MS No Cap (ms) on the retry queue's backoff delay. Defaults to 60000.
RETRY_QUEUE_POLL_INTERVAL_MS No How often (ms) the retry queue polls for due jobs. Defaults to 5000.
RETRY_QUEUE_CONCURRENCY No Max due jobs processed per retry-queue poll cycle. Defaults to 3.
RETRY_QUEUE_STALE_PROCESSING_MS No How long (ms) a job can sit in processing before being reclaimed as orphaned by a crash. Defaults to 60000.
STELLAR_NETWORK No Stellar network: testnet (default) or mainnet.
PLATFORM_ADDRESS No Platform address used when creating escrows. Has a testnet default.
DISPUTE_RESOLVER No Dispute resolver address used when creating escrows. Has a testnet default.
TRUSTLINE_USDC_ADDRESS No USDC trustline address used when creating escrows. Has a testnet default.
RESEND_API_KEY For emails Resend API key. If unset, email notifications are disabled (logged, non-fatal).
PORT No HTTP port. Defaults to 3001.
THALOS_CORS_ORIGIN No Comma-separated list of allowed CORS origins. Defaults to allowing all. In production include the frontend origin (e.g. https://www.thalosplatform.xyz).
THALOS_APP_PUBLIC_URL No Public frontend URL used to build links in outgoing emails. Falls back to the first THALOS_CORS_ORIGIN, then http://localhost:3000.

Getting started

pnpm install
pnpm run start:dev   # watch mode

The server listens on port 3001 by default with a global v1 prefix.

  • Swagger UI: http://localhost:3001/v1/docs
  • OpenAPI JSON: http://localhost:3001/v1/docs-json
  • API root (pointers): GET http://localhost:3001/v1

Scripts

| Command | Description | |---|---|---| | pnpm run start:dev | Start in watch mode (development) | | pnpm run start | Start without watch | | pnpm run build | Compile to dist/ | | pnpm run start:prod | Run the compiled build (node dist/main) | | pnpm test | Run Jest unit + integration + regression specs | | pnpm exec jest regression --runInBand | Run only *.regression.spec.ts |

There is also a smoke-test-backend.ps1 PowerShell script for a quick end-to-end check.

Architecture

Browser (wallet signs client-side)
   │
   ▼
Next.js  ──/api/* (server)──►  NestJS (v1/*)  ──►  Supabase (Postgres)
   │                                │
   └──internal relay───────────────►└──► Trustless Work API (x-api-key, server only)
  • Feature modules live under src/<feature>/, each with *.module.ts, *.controller.ts, *.service.ts, and dto/.
  • Controllers are thin: they route, apply guards, extract the user via @CurrentUser(), and translate the service result into HTTP responses. Business logic lives in the service.
  • Data access always goes through SupabaseService.getClient(), injected into the service constructor.
  • Authorization is enforced in services before touching data (e.g. assertActorWallet, assertCanAccessAgreement): the wallet from the JWT must match the actor and be a participant of the resource.
  • Recoverable errors: services return { data, error } and the controller decides the HTTP status; non-critical failures (activity logs, emails) are swallowed and logged so they don't break the main flow.

Global configuration (src/main.ts)

  • Global prefix v1.
  • Global ValidationPipe with whitelist + forbidNonWhitelisted + transformevery accepted field must be declared in a DTO, or the request is rejected.
  • CORS configurable via THALOS_CORS_ORIGIN.
  • Swagger with two auth schemes: bearer (app JWT) and thalos-internal (internal relay header).

Modules

Module Responsibility
auth JWT strategy, JwtAuthGuard, @CurrentUser() decorator
supabase Singleton Supabase client (SupabaseService)
agreements Agreement CRUD, milestones, status, activity log
agreement-chat Per-agreement chat messages
contacts User contacts
disputes Dispute lifecycle (open, assign resolver, resolve, cancel)
profiles / users Profile data and user search
wallets Wallet linking
notifications Resend-based email notifications + HTML templates
internal-trustless Relay to Trustless Work + typed escrow read endpoints
retry-queue Persistent retry/recovery queue for Trustless Work operations — the single retry primitive shared by every module (@Global(), admin-only manual-retry endpoint)
verification KYC/KYB compliance status, aggregated across providers (single source of truth)

Authentication & security

Two independent mechanisms:

  1. App JWT (Bearer) — protects user-facing routes via JwtAuthGuard. The token must be signed with JWT_SECRET (HS256), unexpired, and carry a sub claim. Issued by the frontend on login.
  2. Internal secret (x-thalos-internal-secret) — protects server-to-server routes via InternalSecretGuard. Intended only for the Next.js server, never the browser.

The browser must call the Next.js frontend (/api/...), which forwards to this backend — this keeps secrets server-side and avoids CORS issues.

Main routes

Route Auth Description
GET /v1 API root / pointers
POST /v1/trustless/prepare Bearer JWT Relay to Trustless Work; returns the upstream { status, data } (e.g. { unsignedTransaction } to sign client-side)
POST /v1/internal/trustless/relay Internal secret Same relay, for the Next.js server only
GET /v1/escrows/by-signer/:address Bearer JWT Escrows where the address is a signer
GET /v1/escrows/by-role Bearer JWT Escrows filtered by role/status/type
POST /v1/escrows/{create,fund,approve-milestone,change-milestone-status,release,dispute} Bearer JWT Escrow writes; return an unsignedTransaction to sign client-side. Enforce that the signer matches the JWT wallet
POST /v1/escrows/send-transaction Bearer JWT Submit the already-signed XDR to the network
GET|POST|PATCH /v1/agreements/* Bearer JWT Agreement CRUD, milestones, status, activity
GET|POST|PATCH /v1/disputes/* Bearer JWT Dispute lifecycle
GET /v1/users/search Bearer JWT Profile search
GET /v1/verification/user/:id Bearer JWT (self/admin) or internal secret Standardized KYC compliance status for a user. 403 unless the caller is the user, an admin, or an internal service
GET /v1/verification/business/:id Bearer JWT (admin) or internal secret Standardized KYB compliance status for a business. 403 unless the caller is an admin or an internal service
GET|POST|DELETE /v1/contacts Bearer JWT Contacts
POST /v1/internal/notifications/* Internal secret Trigger transactional emails
GET /v1/retry-queue Bearer JWT (admin) List retry-queue jobs, optionally filtered by status
GET /v1/retry-queue/:id Bearer JWT (admin) Get one retry-queue job
POST /v1/retry-queue/:id/retry Bearer JWT (admin) Manually re-run a job, bypassing its scheduled backoff

The Trustless Work relay only allows paths under deployer/, escrow/, and helper/.

Tip: in Swagger UI (/v1/docs), use Authorize to paste a JWT and try the protected routes without building the curl by hand.

Database & migrations

SQL migrations live under scripts/, numbered in apply order. Apply them to the Supabase project before running the API. Numbers must be unique across open PRs — when two branches would claim the same number, the later one bumps to the next free number (this is why verifications is 004_create_verifications.sql, clearing 002/003 used by the KYB, KYC and retry-queue migrations).

Verification as a single source of truth (verifications table)

scripts/004_create_verifications.sql creates public.verifications — the standardized, provider-agnostic projection the Verification API (issue #74) reads from. It is intentionally read/aggregation only: this module never writes to it.

For a subject to ever report as verified, the identity-provider flows must upsert their outcome into verifications (one row per subject + provider):

  • KYB (src/kyb/, table kyb_verifications) and KYC (issue #72 / #80) remain the systems of record for their own provider sessions.
  • On a terminal status change (verified / rejected / expired) they must sync a row into verifications — keyed (subject_type, subject_id, provider) — so downstream consumers (Agreements, Reputation, Enterprise) read one shape regardless of provider.
  • Until that write path exists, these endpoints correctly return unverified; they are not aggregating the legacy provider tables directly, by design (so #71 / #72 / #75 don't each invent a different status shape).

Notifications (EventEmitter2)

Transactional email notifications are wired through in-process EventEmitter2 events — no direct coupling between modules.

  • DisputesService.openDispute() emits dispute.openedNotificationsService.handleDisputeOpened() looks up agreement title + opener name and sends email
  • DisputesService.resolveDispute() emits dispute.resolvedNotificationsService.handleDisputeResolved() calculates refund/release amounts and sends email

Event name constants live in src/common/constants/notification-events.ts (single source of truth, no string literals).

If RESEND_API_KEY is not set, emails are skipped with a warning log — the originating action is never blocked.

Retry queue (Trustless Work operations)

src/retry-queue is the single retry/recovery primitive for Trustless Work operations (agreement creation, milestone updates, status sync, contract retrieval, payment execution). Jobs are persisted in the retry_jobs table so a crashed/restarted process resumes where it left off, instead of losing in-flight work.

  • Enqueue from any module: retryQueueService.enqueue(jobType, payload, idempotencyKey). A duplicate idempotencyKey is a no-op — it returns the existing job instead of creating a second one.
  • Register a handler per RetryJobType (typically in a consumer module's onModuleInit) via retryQueueService.registerHandler(jobType, async (payload, attempt) => { ... }). Throw to signal failure; the queue retries with exponential backoff (RETRY_QUEUE_BASE_DELAY_MS / RETRY_QUEUE_MAX_DELAY_MS) up to RETRY_QUEUE_MAX_ATTEMPTS, then marks the job failed. An admin can force one more attempt via POST /v1/retry-queue/:id/retry.
  • Other Trustless Work features (sync, webhooks, milestones, lifecycle) must call into this queue rather than implementing their own retry loop.

Docs

About

Backend infrastructure for programmable digital agreements, handling escrow logic, validation workflows, and on-chain execution.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages