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
Full index: docs/README.md
| 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 |
| Grafana dashboards & metrics | Ready-to-import Grafana JSON dashboards, Prometheus metric freeze specification, on-call alert rules |
| 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/address-history |
GET | Contributor's own AddressHistoryRecord timeline, newest first (self only; maintainers may pass ?userId= for another user) |
/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/contract-sync |
GET/POST | GET: last sync result (public). POST: trigger a contract-to-Postgres sync (maintainer session or CRON_SECRET) |
/api/cron/export |
GET/POST | GET: last export result (public). POST: trigger an automated nightly treasury CSV dump (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.
src/lib/cron-export.tsautomates nightly contributor CSV dumps for treasury payout operations during Waves. Trigger withPOST /api/cron/export(maintainer session orAuthorization: Bearer $CRON_SECRET).- Checks contributor staleness against
STALE_CSV_MAX_AGE_MS(default 24h), attaches the full CSV dataset, emails the destination configured viaTREASURY_EXPORT_EMAIL, and records an audit log entry (export.cron). - Rate-limited via
CRON_EXPORT_MIN_INTERVAL_MS(default 60s) to prevent spamming destinations, and never throws unhandled errors. Check the last run status viaGET /api/cron/export.
- 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) after signature verification, including processing failures, to prevent GitHub retry storms. Invalid or missing signatures return HTTP 401; an unset or blank GITHUB_WEBHOOK_SECRET returns HTTP 503. Processing failures are captured in Sentry, and recognized membership changes for registered users produce audit entries.
Admins can replay a membership payload with POST /api/webhooks/github-org-membership/replay. Replay requires an admin session (HTTP 403 otherwise). When GITHUB_WEBHOOK_SECRET is configured, include a valid X-Hub-Signature-256 HMAC for the exact request body; invalid or missing signatures return HTTP 401. When the secret is unset or blank, this admin-only replay endpoint skips signature verification to allow manual recovery. Successful processing returns HTTP 200 with status and event; malformed JSON returns HTTP 400. Preserve X-GitHub-Delivery when replaying to retain the original delivery ID in the membership audit entry.
- 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.5
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.