TrustBridge Dashboard is the open-source web interface for the TrustBridge project. It connects GitHub contributor identities to Stellar G-addresses and validates payout readiness before Wave disbursements.
The problem: Stellar payments fail with
PAYMENT_NO_TRUSTwhen a recipient account lacks a trustline for the asset being sent (e.g. USDC). Maintainers need a single source of truth mappinggithub_username → stellar_addresswith live trustline and reserve checks — before batch payouts.
- Features
- Quick start
- Documentation
- Tech stack
- Routes & API
- Environment variables
- Testing
- Deployment
- Contributing
- License
| Audience | Capability |
|---|---|
| Contributors | Sign in with GitHub OAuth, register a Stellar G-address, get live Horizon validation (funding, USDC trustline, XLM reserve), view outreach template examples |
| Maintainers | View all registrations, filter by readiness, batch re-check via Horizon, review per-row Horizon diagnostics, use an accessible desktop table or mobile card layout, export enriched CSV/JSON data, generate outreach templates for contributors, review recent Soroban contract events |
| Everyone | Public landing page with Wave readiness stats |
- Freighter ownership proof — the
/registerflow now generates a deterministic message-signing challenge tied to the contributor's GitHub handle and Stellar payout address. Contributors can copy that challenge and sign it in Freighter when maintainers need wallet ownership proof. - Per-row Horizon debug panel — each dashboard row exposes the current readiness summary, the next recommended action, and the underlying funded/trustline/reserve checkpoints used for payout decisions.
- Accessible responsive table — the maintainer table includes captioned headers, sortable column labels with
aria-sort, and a mobile card layout so contributor readiness stays reviewable on smaller screens. - Export parity — CSV and JSON exports now include the Horizon debug summary, recommended next action, and Freighter proof challenge so maintainer reviews stay consistent outside the UI.
The dashboard includes a template generator (on the register page) that creates contributor outreach materials in three formats:
- Email — subject line, body, next steps, wallet proof guidelines
- Markdown — checklist, troubleshooting table, with emoji and formatting
- Plain text — simple, universal format for copy-paste or SMS
Templates are customizable by Wave number, contributor name, minimum XLM requirement, deadline, and support email. Download or copy directly to clipboard.
| Status | Badge | Meaning |
|---|---|---|
| Ready | ✅ | Funded, USDC trustline active and authorized, spendable XLM ≥ minimum reserve |
| Low reserve | Funded + authorized trustline, but spendable XLM below threshold | |
| Not ready | ❌ | Unfunded, missing trustline, or a present-but-unauthorized trustline |
Authorization matters: a trustline can exist but remain unauthorized by the asset issuer (Stellar
AUTH_REQUIREDassets). Payments to an unauthorized trustline still fail, so the dashboard tracksis_authorizedseparately and an account is only verified (✅ on-chain badge) when funded and holding an authorized trustline.
Spendable vs. raw XLM: every Stellar account must keep a minimum reserve locked up —
baseReserve * (2 + subentries + sponsoring − sponsored)— plus any XLM tied up inselling_liabilities. That reserve is not available for payments, so the reserve check compares against spendable XLM (spendableXlmBalance), not the rawxlm_balance. A contributor with 5 XLM raw balance and 3 trustlines can show ~3.5 XLM locked in reserve, leaving well under 1 XLM spendable — this islow_reserve, notready, even though the raw balance alone looks healthy.
- Node.js 18+ and npm
- PostgreSQL database (local, Neon, Supabase, or Vercel Postgres)
- GitHub OAuth App (create one here)
git clone https://github.com/your-org/trustbridge-dashboard.git
cd trustbridge-dashboard
npm installFor local development with PostgreSQL in Docker:
docker-compose up -dSee docs/DOCKER_COMPOSE.md for details.
cp .env.example .env.localFill in all required values — see docs/ENVIRONMENT.md for full reference.
If using Docker Compose, set:
DATABASE_URL="postgresql://trustbridge:trustbridge-dev-password@localhost:5432/trustbridge_dashboard?schema=public"npm run db:pushnpm run devOpen http://localhost:3000.
GitHub OAuth callback URL (local):
http://localhost:3000/api/auth/callback/github
All docs are cross-linked from this README:
| Document | Description |
|---|---|
| Setup guide | Step-by-step local development setup |
| Docker Compose stack | Containerized dev environment with PostgreSQL |
| Environment variables | Every env var explained |
| Architecture | System design, data flow, auth model |
| Project structure | Directory layout and key files |
| Deployment | Vercel deployment checklist |
| Contributing | How to contribute to this repo |
| CSRF protection | Threat model, protected routes, non-browser client policy, testing guide |
| Sentry error tracking | Setup, environment variables, instrumented routes, testing guide |
| Layer | Technology |
|---|---|
| Framework | Next.js 14 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS, shadcn/ui patterns |
| Data fetching | TanStack React Query |
| Auth | NextAuth.js + GitHub OAuth |
| Database | PostgreSQL + Prisma ORM |
| Blockchain | stellar-sdk + Horizon API |
| Deployment | Vercel (recommended) |
Brand colors: Stellar purple #3E1BDB, cyan accent #00B4D8. Dark mode supported — all UI colour pairs meet WCAG 2.1 AA contrast requirements (≥ 4.5:1 for normal text, ≥ 3.0:1 for large text / UI components).
| Route | Auth | Description |
|---|---|---|
/ |
Public | Landing page, TrustBridge explainer, Wave stats |
/register |
GitHub OAuth | Contributor Stellar address registration |
/dashboard |
GitHub OAuth + org member | Maintainer payout readiness table |
/dashboard/metrics |
GitHub OAuth + org member | Admin metrics: readiness counts, audit activity, ops config |
| Endpoint | Method | Description |
|---|---|---|
/api/auth/[...nextauth] |
GET/POST | NextAuth.js handlers |
/api/check |
POST | Horizon validation { address, asset_code?, asset_issuer? } |
/api/register |
GET/POST | Read/save contributor registration (authenticated) |
/api/contributors |
GET/POST | List contributors / batch re-check (maintainer only). Response includes registryMode (REGISTRY_MODE env var) |
/api/contributors/paginated |
GET | Cursor-paginated contributor list for infinite scroll (maintainer only). Also includes registryMode |
/api/contributors/[id] |
POST | Re-check a single contributor via Horizon (maintainer only) |
/api/audit |
GET | Recent maintainer actions — audit log (maintainer only) |
/api/stats |
GET | Aggregate readiness statistics — publicly cached (Cache-Control: public, max-age=<ttl>, stale-while-revalidate) |
/api/actions/lookup |
GET | Cached Horizon readiness lookup + wizard nextAction guidance, ?address=G... — publicly cached (30 s TTL) |
/api/soroban/events |
GET | Recent events for SOROBAN_CONTRACT_ID (maintainer only) |
/api/settings/network |
GET | Resolved Horizon/Soroban network + mismatch warnings (maintainer only) |
/api/health |
GET | Liveness + readiness probe — DB ping, CSV staleness, and contract-sync status (public, always 200) |
/api/contract-sync |
GET/POST | GET: last sync result (public). POST: trigger a contract-to-Postgres sync (maintainer session or CRON_SECRET) |
src/lib/contract-sync.tsre-syncs Postgres registration state against Horizon-verified funded/trustline/balance state, intended to be driven by a scheduler (e.g. Vercel Cron) rather than a maintainer clicking "recheck all". Trigger withPOST /api/contract-sync(maintainer session, or a scheduler presentingAuthorization: Bearer $CRON_SECRET); read the last run viaGET /api/contract-syncor thecontractSyncblock of/api/health.- Rate-limited via
CONTRACT_SYNC_MIN_INTERVAL_MS(default 60s) so a mis-configured scheduler can't fan out into repeated full-table Horizon sweeps, and never throws — Horizon/RPC outages and DB errors are captured in the result instead of raising a 500.
- Stats API cache headers —
GET /api/statsandGET /api/actions/lookupemitCache-Control: public, max-age=<ttl>, stale-while-revalidate=<swr>so CDN edges (Vercel Edge Network, Cloudflare, etc.) and browsers serve cached responses without hitting the origin. Two layers cooperate: an in-processstatsCache(default 60 s TTL, controlled bySTATS_CACHE_TTL_MS) eliminates redundant DB queries within a server instance, and the HTTP headers let the CDN cache aggregate stats globally. The cache is automatically evicted whenever contributor readiness changes (batch recheck, single recheck). The lookup endpoint uses a shorter fixed 30 s TTL to keep wizard validation fresh. Neither endpoint exposes contributor PII — only aggregate counts and per-address Horizon results are returned. - Background recheck queue —
src/lib/background-queue.tsimplements an in-memory job queue for Horizon rechecks. All recheck requests (batch and single) are queued and processed with a default concurrency limit of 2. This prevents Horizon rate-limit exhaustion and allows maintainers to request rechecks without blocking. Check queue status and job results via/api/contributors/queue/statusand/api/contributors/queue/jobs/[jobId]. Configurable concurrency via code (currently hardcoded at 2 jobs max). Job history is retained in memory (last 100 completed jobs). - Horizon circuit breaker —
src/lib/circuit-breaker.tswraps Horizon API calls. After 5 consecutive failures, the breaker opens and fast-fails for 30s, returning a friendly "Horizon is temporarily unavailable" message. Configurable viaHORIZON_CB_FAILURE_THRESHOLD,HORIZON_CB_RECOVERY_MS, andHORIZON_CB_SUCCESS_THRESHOLD. - Stale CSV export guard —
src/lib/stale-export.tscheckslastCheckedAttimestamps before CSV export. If any contributor hasn't been verified within the configured window (default 24h), the dashboard shows an amber warning banner and requires confirmation before exporting. Configurable viaSTALE_CSV_MAX_AGE_MS.
- Request/response logging —
src/lib/logger.tsprovides structured JSON logging for debugging and monitoring. All API requests, Horizon calls, and database operations can be logged with context and metadata. Enable debug logging viaDEBUG=trueenvironment variable. - Observability — Log format includes timestamp, log level (info/warn/error/debug), context identifier, message, and optional details. Perfect for ingestion into centralized logging platforms.
- Cursor-based pagination —
/api/contributors/paginatedsupports efficient cursor-based pagination via theuseInfiniteContributors()React Query hook. Useful for tables with 100+ contributors. - React Query integration —
src/lib/use-infinite-contributors.tsprovides a drop-in hook for infinite scroll UIs. Automatically fetches next pages as users scroll.
src/middleware.ts protects /register (requires sign-in) and /dashboard (requires sign-in + GITHUB_MAINTAINER_ORG membership).
The dashboard listens for GitHub organization membership changes via webhook at /api/webhooks/github-org-membership. When a member is added to or removed from your maintainer organization, the webhook records an audit entry, allowing the system to track membership changes.
Setup:
-
Generate a webhook secret:
openssl rand -base64 32
-
Set
GITHUB_WEBHOOK_SECRETin your.env.local:GITHUB_WEBHOOK_SECRET=<generated-secret>
-
Configure the webhook in your GitHub organization:
- Go to Organization Settings → Webhooks → Add webhook
- Payload URL:
https://your-domain.com/api/webhooks/github-org-membership - Content type:
application/json - Secret: The same secret from step 1
- Events: Select "Organization" and check "Member"
The webhook endpoint returns HTTP 202 (Accepted) for all webhook deliveries to prevent GitHub retry storms. Processing is logged and failures are captured in the audit log.
- CSRF protection — All mutating API routes validate the
Origin/Refererheader against the application's host. See docs/CSRF.md. - Rate limiting —
POST /api/checkis rate-limited per IP (default 10 requests per minute) to prevent Horizon API abuse. Configure viaRATE_LIMIT_WINDOW_MSandRATE_LIMIT_MAX_REQUESTS. - CSV / JSON exports — Maintainer dashboard exports contributor data as CSV or JSON. Export helpers live in
src/lib/csv.tsand are covered by snapshot tests. - Freighter proof workflow —
GET/POST /api/registerreturnswalletProofandhorizonDebugmetadata alongside the registration so the register page, dashboard table, and exports can render the same ownership-proof and troubleshooting guidance.
Copy .env.example to .env.local and configure:
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=
TOKEN_ENCRYPTION_KEY= # required, openssl rand -base64 32 — encrypts stored access tokens
GITHUB_MAINTAINER_ORG=
GITHUB_MAINTAINER_TEAM= # optional, team slug within GITHUB_MAINTAINER_ORG — org-only check if unset
DATABASE_URL=
NEXT_PUBLIC_HORIZON_URL=https://horizon.stellar.org
NEXT_PUBLIC_DEFAULT_ASSET_CODE=USDC
NEXT_PUBLIC_DEFAULT_ASSET_ISSUER=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
NEXT_PUBLIC_MIN_XLM_BALANCE=1
NEXT_PUBLIC_BASE_RESERVE_XLM=0.5 # optional, Stellar base reserve used for spendable-balance checks
SOROBAN_CONTRACT_ID= # optional, future on-chain registry + event timeline panel
SOROBAN_RPC_URL= # optional, defaults to soroban-testnet.stellar.org
STATS_CACHE_TTL_MS= # optional, in-process + HTTP cache TTL for /api/stats (default 60000 ms = 60 s)
CHECK_CACHE_TTL_MS= # optional, in-process cache TTL for /api/check responses (default 120000 ms = 2 min)Note:
NEXT_PUBLIC_HORIZON_URLandSOROBAN_RPC_URLshould point at the same Stellar network. Their defaults don't (mainnet vs. testnet) — the dashboard detects and warns on this mismatch via/api/settings/networkand the maintainer dashboard's network status panel, and records it to the audit log.
Caching:
STATS_CACHE_TTL_MScontrols both the in-processstatsCacheTTL and themax-agevalue in theCache-Controlheader emitted byGET /api/stats. Setting it to0or a non-numeric value falls back to the 60 s default. To disable CDN caching entirely during local development, setSTATS_CACHE_TTL_MS=1(1 ms expires immediately in the in-process cache and producesmax-age=0in the header).
See docs/ENVIRONMENT.md for details.
The dashboard validates all environment variables using Zod at startup (src/lib/env-validation.ts). This schema:
- Ensures required fields are present — missing
GITHUB_CLIENT_ID,DATABASE_URL, etc. will fail fast - Casts numeric values —
RATE_LIMIT_MAX_REQUESTS→ number,NEXT_PUBLIC_MIN_XLM_BALANCE→ float - Validates URLs —
DATABASE_URL,NEXTAUTH_URL,SOROBAN_RPC_URLmust be valid URLs - Provides defaults — optional fields like
NEXT_PUBLIC_HORIZON_URLdefault to Stellar mainnet - Fails on startup — invalid configuration is caught before any route handlers run
Use validateEnv() or getValidatedEnv() to get the fully typed, validated configuration:
import { getValidatedEnv } from "@/lib/env-validation";
const env = getValidatedEnv();
// env is strongly typed and guaranteed validGenerate NEXTAUTH_SECRET:
openssl rand -base64 32Generate TOKEN_ENCRYPTION_KEY:
openssl rand -base64 32Generate GITHUB_WEBHOOK_SECRET (if using org membership sync):
openssl rand -base64 32Unit tests run on Vitest and cover the pure business logic
(readiness/authorization rules, the Horizon retry helper, audit-log formatting,
batch verification, contributor search/filter/sort/column helpers, the
GET /api/metrics admin endpoint, and the Soroban event-timeline read path):
npm test # run all Vitest unit + API tests once
npm run test:watch # watch mode
npm run test:unit # unit tests only
npm run test:api # API route tests onlyEnd-to-end tests run on Playwright and cover the maintainer dashboard flow end-to-end (access control, table search & column toggles, re-check, and the admin metrics page):
npm run test:e2e # headless Playwright run (requires running app)
npm run test:e2e:ui # interactive Playwright UIAll tests run in CI on every push and pull request, before the build.
Audits and enforces WCAG 2.1 AA contrast ratios across all dark-mode colour
pairs in the dashboard. Run automatically as part of npm test.
What was audited and fixed:
| File | Issue | Fix |
|---|---|---|
src/app/globals.css |
--destructive at L=30.6% gave ~2.3:1 on dark bg (hard WCAG AA fail) |
Raised to L=65% → ~5.9:1 |
src/app/globals.css |
--destructive-foreground was near-white (unreadable on new lighter red) |
Changed to dark (0 0% 10%) |
src/app/globals.css |
--muted-foreground at L=65.1% was marginal on card bg |
Raised to L=70% |
src/app/globals.css |
--primary / --ring at L=58% |
Raised to L=65% |
src/app/globals.css |
--accent at L=42% |
Raised to L=48% |
src/app/globals.css |
--border / --input at L=17.5% (invisible dividers in dark) |
Raised to L=22% |
src/components/ui/badge.tsx |
dark:text-*-400 (L≈60%) on dark card bg — below 4.5:1 |
Raised to dark:text-*-300 (L≈73%) |
src/components/ui/badge.tsx |
Light mode text-*-600 on white — marginal |
Raised to text-*-700 |
src/app/dashboard/metrics/page.tsx |
Status-box sub-labels dark:text-*-400 — below AA |
Raised to dark:text-*-200 |
src/app/dashboard/metrics/page.tsx |
Status-box borders dark:border-*-900 — near-invisible |
Raised to dark:border-*-800 |
src/app/register/RegisterClient.tsx |
Amber banner dark:text-amber-300 dark:bg-amber-500/10 |
Fixed to dark:text-amber-200 dark:bg-amber-950/40 |
src/components/ContributorTable.tsx |
Stale-data warning dark:border-amber-900 — near-invisible |
Raised to dark:border-amber-700/60 |
Automated tests live in src/lib/dark-mode-contrast-audit.test.ts. The file
encodes the WCAG 2.1 relative-luminance algorithm in pure TypeScript (no DOM
required) and runs 25 assertions across five describe blocks:
- CSS design tokens (foreground, muted-foreground, destructive, primary, accent)
- Badge dark/light text on card backgrounds
- Metrics-page status-box blended backgrounds (alpha-composited)
- RegisterClient maintainer error banner
- ContributorTable stale-data warning
- Regression guard: four pre-fix colours that must fail WCAG AA — if these ever pass it means the test palette data needs updating
Validates that POST /api/register is correct under concurrent load. Run with
npm run test:api or npm test.
Test file: tests/api/register-concurrency.test.ts — 22 assertions across 7
describe blocks.
| Scenario | Coverage |
|---|---|
| Idempotency | 5 simultaneous requests from the same user all return 200; Horizon called exactly N times |
| Address conflict race | Two users racing to claim one address: exactly one 200 + one 409 |
| Re-assignment (no spurious 409) | User updating their own registered address never gets a 409 |
| 100+ contributor scale | 120 distinct users register concurrently — all 200, no dropped requests, upsert called exactly 120× |
| Horizon outage | checkStellarAddress rejects for all callers → all 500, DB never written |
| Partial Horizon outage | Alternating pass/fail — correct mix of 200/500 responses |
| Mixed address pool | 50 user pairs each racing for the same address — exactly 50 wins (200) + 50 losses (409) |
| Auth edge cases | Unauthenticated → 401, cross-origin → 403, invalid format → 400, empty address → 400 with validationErrors |
All mocks target prisma, @/lib/horizon, next-auth, and @/lib/soroban-register
so no real database or network is needed. The conflict-detection mock simulates the
findUnique → upsert race window that exists in the route handler.
Schema hardening: The
Registrationtable enforces uniquestellarAddressper user (one-to-one viauserId), with comprehensive indexes ontrustlineReady,trustlineAuthorized,funded, andlastCheckedAtfor efficient filtering. All models include detailed field documentation inprisma/schema.prisma. The schema supports optimistic registration updates with proper cascading deletes and constraints to ensure data integrity during high-concurrency Wave operations.
This project is optimized for Vercel:
- Push to GitHub
- Import repo in Vercel
- Add environment variables from docs/ENVIRONMENT.md
- Attach a Postgres database (Vercel Postgres, Neon, etc.)
- Run
npm run db:pushagainst productionDATABASE_URL - Set GitHub OAuth callback to
https://your-domain.vercel.app/api/auth/callback/github
Full checklist: docs/DEPLOYMENT.md
We welcome contributions! Please read docs/CONTRIBUTING.md before opening a PR.
Quick flow:
- Fork the repo
- Create a feature branch (
git checkout -b feat/my-feature) - Commit with clear messages
- Run tests (
npm run test) - Open a pull request against
main
PostgreSQL is the source of truth for registrations. Setting SOROBAN_CONTRACT_ID
(+ optionally SOROBAN_RPC_URL) today enables the read-only maintainer event
timeline (GET /api/soroban/events), which fetches recent contract events and
degrades gracefully — never failing the dashboard — on RPC outages, rate limits,
or a missing contract ID.
Mirroring registrations to a Soroban contract (write-through) is designed but not yet implemented — see docs/ARCHITECTURE.md § Soroban register write-through for the read-vs-write-through breakdown, the intended write design, and how each edge case (outage, missing config, rate limits) is or would be handled.
This project is licensed under the MIT License.