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.
- 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
- 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, …)
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. |
pnpm install
pnpm run start:dev # watch modeThe 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
| 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.
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, anddto/. - 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 prefix
v1. - Global
ValidationPipewithwhitelist + forbidNonWhitelisted + transform— every 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) andthalos-internal(internal relay header).
| 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) |
Two independent mechanisms:
- App JWT (
Bearer) — protects user-facing routes viaJwtAuthGuard. The token must be signed withJWT_SECRET(HS256), unexpired, and carry asubclaim. Issued by the frontend on login. - Internal secret (
x-thalos-internal-secret) — protects server-to-server routes viaInternalSecretGuard. 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.
| 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.
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).
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/, tablekyb_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).
Transactional email notifications are wired through in-process EventEmitter2 events — no direct coupling between modules.
DisputesService.openDispute()emitsdispute.opened→NotificationsService.handleDisputeOpened()looks up agreement title + opener name and sends emailDisputesService.resolveDispute()emitsdispute.resolved→NotificationsService.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.
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 duplicateidempotencyKeyis a no-op — it returns the existing job instead of creating a second one. - Register a handler per
RetryJobType(typically in a consumer module'sonModuleInit) viaretryQueueService.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 toRETRY_QUEUE_MAX_ATTEMPTS, then marks the jobfailed. An admin can force one more attempt viaPOST /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.
CONTRIBUTING.md— contribution guide; bug fixes require a regression test.docs/integration-tests.md— integration fixtures, KYC/KYB suite, and regression suite index.docs/SCOPE.md— closed scope decisions.docs/EMAIL_NOTIFICATIONS_PLAN.md— event-driven email notifications plan (epic + tickets).