Self-hosted net-worth tracker with an AI-agent interface. Connect your bank and brokerage accounts via Plaid, see your portfolio, transactions, and capital gains in a web dashboard — and let AI agents (Claude Desktop, Claude Code, or anything that speaks MCP) query and annotate the same data over an OAuth-protected MCP endpoint.
Live demo / hosted option: www.texasnetworth.com (invite-only; email support@texasnetworth.com for access). Everything the hosted service runs is in this repo — you can spin up the identical stack on your own server.
- Portfolio dashboard — accounts, holdings, balance history, allocation breakdowns
- Transactions — search, tags, notes, saved searches, bulk edit
- Capital gains — realized-lot tracking with user-supplied cost basis for unknown lots
- Plaid sync — daily automatic refresh; access tokens encrypted at rest (ChaCha20-Poly1305)
- MCP server —
/mcpendpoint with OAuth 2.1 (PKCE, dynamic client registration) or personal API tokens, so AI agents can read/annotate your finances with scoped access - Invite-only multi-user — Google sign-in, gated by an allowlist you control
| Layer | Tech |
|---|---|
| Backend | Rust (axum, sqlx), Postgres 16 |
| Frontend | React + TypeScript + Vite + Tailwind |
| Edge | Caddy (TLS, security headers, reverse proxy) |
| Deploy | Docker Compose on a single VM |
Prerequisites:
- Rust (stable), Node 20+, Docker
- A Plaid account — free sandbox keys work
- A Google OAuth 2.0 Client ID (type: Web application, authorized JS origin
http://localhost:5173)
docker run -d --name wealth-pg -p 5432:5432 \
-e POSTGRES_USER=wealthagent -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=wealthagent \
postgres:16-alpinecd backend
openssl rand 32 > keyfile.local # encryption key for Plaid tokens (gitignored)
cat > .env <<EOF
DATABASE_URL=postgres://wealthagent:dev@localhost:5432/wealthagent
JWT_SECRET=$(openssl rand -base64 64 | tr -d '\n')
APP_ENCRYPTION_KEY_PATH=./keyfile.local
GOOGLE_CLIENT_ID=<your-google-client-id>.apps.googleusercontent.com
OWNER_EMAILS=you@example.com # your Google email — seeds the invite allowlist
PLAID_CLIENT_ID=<your-plaid-client-id>
PLAID_SECRET=<your-plaid-sandbox-secret>
PLAID_ENV=sandbox
PLAID_PRODUCTS=auth,transactions,investments
PLAID_COUNTRY_CODES=US
EOF
cargo run # migrations run automatically; listens on :8080cd frontend
npm install
echo 'VITE_GOOGLE_CLIENT_ID=<your-google-client-id>.apps.googleusercontent.com' > .env
npm run dev # http://localhost:5173, proxies /api to :8080Sign in with the Google account you put in OWNER_EMAILS, click Add
institution, and use Plaid sandbox credentials (user_good / pass_good)
to link a fake bank.
Sign-in is allowlist-gated. Add people with the admin CLI:
# local dev
cargo run --bin admin -- invite add friend@example.com "college roommate"
# production (inside the container)
docker compose exec backend entrypoint.sh admin invite add friend@example.comThe full runbook is in DEPLOY.md: provision an Ubuntu VM, run
ops/bootstrap-vm.sh (Docker, UFW, hardened SSH), generate secrets per
secrets/README.md, point your DNS at the box, edit
Caddyfile with your domains, and docker compose up -d. Caddy obtains TLS
certificates automatically. Encrypted off-site backups to Backblaze B2 are
set up via ops/backup.sh and the systemd units in ops/systemd/.
Deploys after the first one are a single command from your laptop:
DEPLOY_SERVER=wealth@<your-vm-ip> DEPLOY_APP_URL=https://app.example.com ./ops/deploy.shOnce running, the backend serves MCP at https://<your-app-domain>/mcp with
two auth options:
- OAuth 2.1 — Claude Desktop and other MCP clients can connect with one click; the app shows a consent screen with scoped grants you can revoke.
- Personal API tokens — create one in the app under Settings → API
Tokens, then use
Authorization: Bearer wa_pat_....
See backend/skills/wealthAgent/skill.md for client configuration examples.
Set PRIVACY_ENCRYPTION=on and users can opt in (via POST /api/privacy/setup with a passphrase) to per-user encryption of the
identifying text in their data — transaction descriptions, merchant names,
notes, account names, and securities identity (tickers, security names,
holdings). How it works:
- Each opted-in user gets an X25519 keypair. The public key stays in the DB, so the nightly Plaid sync seals new data while the user is offline — encryption needs no secret.
- The private key is stored only wrapped: under an Argon2id key derived
from the user's passphrase, and under keys derived from each API token's
raw value (of which the DB stores only a hash). Unlocking
(
POST /api/privacy/unlock) holds the key in server memory with a 12h TTL. - Result:
SELECT * FROM transactionson the server shows ciphertext for those fields. The operator cannot read what users bought or where by querying the database or reading disk.
Honest limits: amounts, dates, tags, and quantities stay plaintext (SQL
aggregation depends on them), as do sentinel symbols (CASH/DEBT/N-A) and
Plaid security_ids (the holdings upsert key — resolvable by an operator
with their own Plaid credentials); capital gains require an unlocked
session, since FIFO lot matching needs decrypted tickers. A malicious
operator could
still capture plaintext at Plaid-sync time or dump process memory during an
active session — this protects against an honest-but-curious operator and
stolen database copies, not a fully compromised server. There is no
passphrase recovery: a lost passphrase means wiping and re-syncing from the
bank (only tags/notes/manual cost basis are truly user-generated). API tokens
created before opting in can't decrypt — recreate them while unlocked.
- All secrets live in files (Docker secrets /
/etc/wealthagent/secrets/), never in the repo. Never commit any.envvariant. - Financial rows (balances, transactions) are plaintext inside Postgres by
default;
ops/encrypt-pgdata.shmoves the data volume onto a LUKS-encrypted filesystem (see DEPLOY.md §14), andPRIVACY_ENCRYPTION=onadds per-user operator-blind encryption of the identifying text columns (see above). - Plaid access tokens are encrypted at rest; deleting an item calls Plaid's
/item/removeso leaked rows can't be replayed. - Session JWTs are short-window with a hard 30-day cap; OAuth access tokens are audience-bound and type-guarded.
- A pre-commit gitleaks hook is configured in
.pre-commit-config.yaml— runpre-commit installafter cloning if you plan to contribute.
MIT. This is not financial advice, and the software comes with no warranty; you are handling your own bank credentials and data.