Wealthboard is a self-hosted, multi-user wealth and goals tracker. Each user has an independent portfolio, settings, categories, exchange rates, reports, and portable exports. The current application is a Go HTTP service backed by PostgreSQL and serves a Vite/React web client. Authentication can remain fully local or use one operator-configured OpenID Connect provider.
See the product guide for annotated desktop and mobile walkthroughs built from fictional portfolio data.
- Deployment-selected local, OpenID Connect, or hybrid authentication
- Versioned Go API with owner-scoped, revocable personal API keys
- Strict owner-scoped accounts, transactions, valuations, goals, analytics, rates, imports, exports, restores, caches, and idempotency keys
- Accounts and liabilities with custom categories, archives, filters, and base-currency values
- Position-tracked investment accounts with broker cash, fractional units, effective prices, conversion, reconciliation, imports, and corporate actions
- Per-user base currency and enabled ISO currency catalog, including East African and common international currencies
- Deposits, withdrawals, income, fees, gains/losses, valuations, and atomic paired transfers
- Net-worth history, portfolio analytics, and linked-goal forecasting
- Non-persistent goal scenario comparisons, milestones, and dismissible behind-plan dashboard reminders
- Private beneficiary and estate-distribution planning with printable, privacy-controlled retained summaries
- Per-user JSON portability and account/transaction CSV export
- Operator-only PostgreSQL custom-format backup and maintenance-mode restore
- Installable PWA shell with explicit offline safety
- Browser-local System, Light, and Dark appearance themes
- Optional on-demand, evidence-linked AI portfolio review with private BYOK
- Non-root Docker image, Docker Compose, and Kubernetes examples
Money is stored as integer minor units in PostgreSQL bigint columns. Exchange
rates are effective-dated exact numeric values serialized as decimal strings,
and authoritative server calculations use checked integers and exact
math/big arithmetic.
The user and operator guide is published at https://wechuliprojects.github.io/wealthboard/. It covers first setup, accounts, position-tracked investments, activity, goals, reports, estate planning, portability, deployment, authentication, backups, and troubleshooting with fictional product screenshots.
Documentation source lives under docs/ and is built with VitePress:
npm ci
npm run docs:dev
DOCS_BASE=/wealthboard/ npm run docs:build
npm run docs:previewRegenerate product screenshots from a disposable database with:
npm run docs:captureThe capture workflow creates only fictional data and writes the reviewed images
to docs/public/images/screenshots/.
Open /estate to maintain private beneficiaries, describe how each active asset
is held, allocate primary and contingent percentages, cover unallocated property
through residual beneficiaries, and review recorded liabilities separately.
Percentages use exact basis points. Current estate valuations are incomplete;
retained summaries are not a fully replayed historical valuation. Review the
estate guide before relying on indicative values.
The Summary view creates immutable, hashed Estate Planning Summary snapshots. Its print/Save as PDF controls exclude exact values, contacts, account/document references, and notes until you deliberately include them; the global privacy toggle can still mask rendered values. These controls do not redact stored or downloaded JSON, and a snapshot's completeness flag is not a full server-validated financial check. These documents are planning worksheets, not legally executed wills, and do not grant beneficiary access or transfer assets.
The Go/PostgreSQL migration is complete, but some legacy product contracts remain incomplete. Linked goals and estate summaries do not yet propagate every valuation/completeness case; exchange-rate edits are not atomic in the settings UI; some conversion and import surfaces are narrower than the full contract. The guides describe those limits, and backlog A17 tracks the implementation work.
- Go 1.27 or newer
- Node.js 24 and npm for building the Vite client and documentation
- PostgreSQL 18 and matching
pg_dump/pg_restoretools for operator recovery
make postgres-up
npm ci
make web-install
make build
DATABASE_URL='postgres://wealthboard:wealthboard@localhost:5433/wealthboard?sslmode=disable' \
SESSION_SECRET="$(openssl rand -hex 32)" \
APP_URL=http://localhost:3000 \
AUTH_METHODS=local \
./bin/wealthboard serveserve applies pending PostgreSQL migrations before listening on
http://localhost:3000. Keep SESSION_SECRET stable and at least 32
characters; the command above generates a disposable development secret.
The Go binary reads process environment variables, not .env files.
.env.example documents the available settings; export them or supply them
through your process manager. Compose reads .env for its own interpolation.
The root npm dependencies support documentation, browser tests, and direct-install
document extraction. The separate web/ dependencies build and test the client.
The default AUTH_METHODS=local mode preserves the original workflow. Open
/signup to create local users. Signup atomically creates the internal identity,
selected base/enabled currency settings, and default categories; it does not
create exchange rates, financial accounts, goals, or sample data. OIDC-only
deployments have no local signup or password-login path and provision internal
users only after a validated provider login.
Build the client once so web/dist exists, then export DATABASE_URL and a
stable SESSION_SECRET in the Go terminal and run:
PORT=3100 APP_URL=http://127.0.0.1:5173 AUTH_METHODS=local make go-runIn a second terminal:
make web-devOpen http://127.0.0.1:5173. Vite proxies /api to
http://127.0.0.1:3100, and APP_URL must match the browser origin for mutation
origin checks. The Go listener still serves the last built client; it does not
provide Vite hot reload. Do not start another server on an occupied port.
Demo data is never loaded by signup. Target one existing user explicitly:
make seed-demo DEMO_DATA=true TARGET_USERNAME=aliceBoth DEMO_DATA=true and an explicit existing username are required. The
command never creates an identity or seeds every user; deterministic IDs and
conflict handling make repeat runs safe for the same target.
| Variable | Purpose |
|---|---|
DATABASE_URL |
Required PostgreSQL connection URL |
SESSION_SECRET |
HMAC session secret; at least 32 characters |
APP_URL |
Canonical deployment URL used for origin validation |
NODE_ENV |
Set production to require secure session cookies |
PORT |
Go HTTP listener port; default 3000 |
TRUST_PROXY_HEADERS |
Legacy setting; ignored by Go, which uses the socket peer IP for rate limits |
AUTH_METHODS |
local, oidc, or local,oidc; default local |
OIDC_ISSUER |
Exact provider issuer when OIDC is enabled |
OIDC_CLIENT_ID |
Confidential OIDC client ID |
OIDC_CLIENT_SECRET |
Confidential OIDC client secret |
OIDC_PROVIDER_NAME |
Login-button provider label; 1-60 characters |
OIDC_TRANSACTION_SECRET |
Dedicated base64-encoded 32-byte OIDC transaction key |
TZ |
Default timezone for new users; default Africa/Nairobi |
AI_CREDENTIAL_ENCRYPTION_KEY |
Optional base64 32-byte key for remembered AI provider API keys |
AI_ALLOWED_ENDPOINTS |
Comma-separated exact custom OpenAI-compatible base URLs |
AI_EXTRACTION_SOCKET |
Optional Unix socket for the isolated document parser |
AI_EXTRACTION_SCRIPT |
Local Node parser script; default scripts/extract-import-source-cli.mjs |
WEB_DIST_PATH |
Built Vite assets; default web/dist |
There is no initial-user password or environment-created identity.
AUTH_METHODS is deployment policy and is read at startup:
| Value | Login and account creation behavior |
|---|---|
local |
Username/password login and public local signup only |
oidc |
Only Continue with <provider>; /signup redirects to /login |
local,oidc |
Local login/signup plus explicit OIDC login and linking |
OIDC uses Authorization Code flow, PKCE S256, state, nonce, discovery, and
RS256 ID-token verification through Go's github.com/golang-jwt/jwt/v5.
The exact callback is
${APP_URL}/api/v1/auth/oidc/callback; register that URI with the provider. Use an
HTTPS APP_URL and issuer in production. Plain HTTP is accepted only for an
explicit localhost address. Issuer URLs may contain a path, such as a Keycloak
realm, but not credentials, a query, or a fragment.
Generate independent secrets:
openssl rand -hex 32 # SESSION_SECRET
openssl rand -base64 32 # OIDC_TRANSACTION_SECRETDo not reuse either value as OIDC_CLIENT_SECRET. OIDC transaction state is
encrypted in a short-lived, callback-scoped, HTTP-only SameSite=Lax cookie.
Provider tokens, authorization codes, PKCE verifiers, and claim payloads are
never stored in PostgreSQL, exports, browser storage, analytics, or logs. A
successful callback issues the ordinary Wealthboard session containing only the
internal user UUID, session version, issue/expiry times, and a per-session CSRF
token, not provider credentials.
Create a confidential Keycloak client with standard Authorization Code flow, client authentication enabled, PKCE method S256, and this exact valid redirect URI:
https://wealthboard.example.com/api/v1/auth/oidc/callback
Assign only intended users or groups to the client. Wealthboard accepts every identity the configured client permits; provider-side assignment is the default admission policy. Configure only the realm issuer, not Keycloak endpoint paths:
APP_URL=https://wealthboard.example.com
AUTH_METHODS=local,oidc
OIDC_ISSUER=https://id.example.com/realms/wealthboard
OIDC_CLIENT_ID=wealthboard
OIDC_CLIENT_SECRET=replace-with-keycloak-client-secret
OIDC_PROVIDER_NAME=Company SSO
OIDC_TRANSACTION_SECRET=replace-with-openssl-base64-outputWealthboard discovers Keycloak's authorization, token, and JWKS endpoints from
${OIDC_ISSUER}/.well-known/openid-configuration. Scopes are openid profile email; only issuer, opaque subject, nonce, audience, and token validity are
authentication evidence. Email and display claims never link or merge users.
Existing installations start in local. To adopt OIDC without duplicate
portfolios:
- Configure
local,oidc, restart, and verify readiness. - Existing local users link the provider explicitly under Settings > Authentication methods after confirming their password.
- Confirm every active user has a link, then change to
oidcand restart.
Readiness refuses OIDC-only mode while any active user lacks a link for
the configured issuer. It likewise refuses local-only mode while any active user
lacks a password. Disable users deliberately or complete their migration first.
Password hashes and identity links remain dormant when their method is disabled,
so rollback does not require recreating credentials. Hybrid mode remains ready
and local login remains usable during a temporary provider outage; OIDC-only
login and readiness require valid discovery. Invalid startup configuration fails
before listening, but authentication readiness is evaluated by the health
endpoint, not as a startup gate. Check /api/health/ready before routing traffic.
Go rate limiting uses the socket peer IP (RemoteAddr). X-Forwarded-For,
X-Real-IP, and the legacy TRUST_PROXY_HEADERS toggle do not change that
identity. Direct clients cannot spoof it through headers, but clients behind
the same reverse proxy can share its rate-limit bucket. Account for this when
configuring ingress-side throttling; enabling the legacy toggle does not
provide per-client rate limiting behind a proxy.
Generate a dedicated AI credential key only when users should be able to save provider keys:
openssl rand -base64 32Do not reuse SESSION_SECRET. The value must be canonical base64 for exactly 32
bytes. Without it, users can still submit a session-only provider key for one
request. OpenAI and DeepSeek use fixed built-in endpoints; custom endpoints
must exactly match a normalized URL in AI_ALLOWED_ENDPOINTS. Every provider
hostname is resolved again before use, and private, loopback, link-local,
multicast, and other local ranges are rejected even if allowlisted. Redirects
and environment HTTP proxies are disabled. Keep the encryption key stable;
changing it makes remembered keys undecryptable until users replace them.
Users with a local credential change it under Settings > Password when local authentication is enabled. Hybrid users explicitly link/unlink OIDC or enable and remove local login under Settings > Authentication methods. Every method change increments that user's session version and invalidates other sessions.
There is no email reset flow. An operator can reset one user by normalized username; the password is read from the environment rather than command arguments:
NEW_USER_PASSWORD='a-new-password-with-12-characters' \
DATABASE_URL='postgres://wealthboard:wealthboard@localhost:5433/wealthboard?sslmode=disable' \
./bin/wealthboard reset-password --username aliceThe reset command works only when local authentication is enabled and only for a user who already has a local credential. It never creates a password for an OIDC-only user.
For Docker Compose:
docker compose exec \
-e NEW_USER_PASSWORD='a-new-password-with-12-characters' \
wealthboard /app/wealthboard reset-password --username aliceThe browser uses the signed session cookie. Authenticated browser mutations
require the exact Origin from APP_URL and the session's X-CSRF-Token;
login/signup require the trusted origin before a session exists. The client
retrieves its session and CSRF token from /api/v1/session.
External clients may use personal API keys in Authorization: Bearer <token>.
Keys are owner-scoped, show their full secret only at creation, and default to
portfolio:read. Other explicit scopes are portfolio:write, imports:write,
exports:read, and ai:invoke. They do not grant credential management or
user-restore access. Password resets invalidate browser sessions, not API keys;
revoke keys separately when needed.
See Go authentication and API keys and the OpenAPI contract for endpoints and request formats.
The Go service embeds the append-only PostgreSQL migrations under
db/postgres/migrations. serve runs pending migrations at startup; operators
can run or inspect them separately with make migrate and
make migrate-status. Never edit an applied migration.
PostgreSQL is a fresh-start boundary. There is no SQLite-to-PostgreSQL importer,
dual-write mode, or supported in-place conversion. The legacy Next.js, Drizzle,
and SQLite runtime was removed after the
cutover checklist passed. Historical Drizzle migrations
remain under docs/archive as non-executable provenance.
The Go binary owns runtime and database operations:
./bin/wealthboard serve
./bin/wealthboard migrate
./bin/wealthboard migrate-status
NEW_USER_PASSWORD='replacement-password' ./bin/wealthboard reset-password --username alice
./bin/wealthboard backup --file /secure/wealthboard.dump
./bin/wealthboard restore --file /secure/wealthboard.dump --confirm-maintenance
DEMO_DATA=true ./bin/wealthboard seed-demo --username alice
./bin/wealthboard healthcheckmake migrate, make migrate-status, make backup, make restore, and
make seed-demo are source-tree wrappers around these commands. Backup and
restore require compatible PostgreSQL client tools on the operator host; they
are intentionally absent from the distroless application image.
Export the intended deployment's DATABASE_URL before operator commands.
The Makefile's fallback URL targets the local development database, not a
Compose or Kubernetes production deployment.
The application image is a non-root distroless Go runtime serving the built
Vite assets. Compose and Kubernetes enforce a read-only application filesystem.
The image contains neither PostgreSQL client tools nor Node.js.
Supply an external DATABASE_URL and run operator backups from a trusted host
or admin job with matching PostgreSQL tools. The Compose stack builds the
separate, network-disabled extraction-worker target for PDF/XLSX/DOCX.
The Compose examples use postgres:18 and mount database storage at
/var/lib/postgresql, matching the image's versioned data directory.
An existing PostgreSQL 17 volume requires a deliberate major-version upgrade;
changing the image tag or mount path does not migrate its data. Follow the
PostgreSQL upgrade guidance
and retain a verified backup before replacing the database container.
Edit the image, hostname, auth mode/provider values, storage classes, and
resource limits in deploy/kubernetes.yaml, then create secrets separately:
kubectl create secret generic wealthboard-secrets \
--from-literal=session-secret="$(openssl rand -hex 32)" \
--from-literal=oidc-client-secret='replace-with-provider-secret' \
--from-literal=oidc-transaction-secret="$(openssl rand -base64 32)"
kubectl apply -f deploy/kubernetes.yamlCreate the separate wealthboard-database secret with its database-url key.
The manifest deploys two rolling application replicas with a pod-local
extraction sidecar, but no PostgreSQL server, database volume, or backup
controller. Operate PostgreSQL, point the egress policy at its actual
namespace/labels, schedule and retain backups outside the application pods, and
test restores. TLS must terminate at
the configured APP_URL; the ingress must overwrite rather than append
client-supplied forwarding headers. The example egress policy allows only DNS
and the selected PostgreSQL pods. Add narrowly scoped provider egress before
enabling OIDC or AI; those integrations cannot use the default policy unchanged.
Portfolio Review is optional, read-only, and generated only on request. Configure OpenAI, DeepSeek, or an operator-approved OpenAI-compatible endpoint under Settings → AI portfolio review, then open Review. The integration uses the provider's supported HTTP API through the Go AI workflow.
Wealthboard calculates a bounded, versioned snapshot before contacting a model. By default it contains ratios, concentration, goal trajectory, and data-quality warnings with pseudonymous account and goal labels. Exact aggregate amounts and names are separate per-request opt-ins. Notes, account references, transaction descriptions, raw activity rows, and the current cash-flow-naive annualized return figures are never sent.
Generated reviews are not stored. The database retains only owner-scoped usage
metadata such as provider host, model, status, latency, and token counts; users
can clear that history or disconnect the provider. Prompts, responses, API keys,
and portfolio values are not written to usage records. Review and import
conversion share a limit of 10 reservations per rolling minute per user, a UTC
calendar-month token budget, response bounds, redirect blocking, and local
response validation. OpenAI uses Responses with store: false; current review
and conversion requests ask for JSON in the prompt rather than enabling the
transport's optional native JSON Schema output.
AI reviews and import conversion use a two-minute provider request timeout with automatic retries disabled. The monthly token limit accepts 10,000 to 100,000,000 tokens; existing saved limits are not increased automatically.
Provider keys entered for one request are not persisted. Remembered keys are encrypted with AES-256-GCM and bound to the owning user. They are excluded from per-user exports, but deployment-wide PostgreSQL backups contain encrypted credential rows and must remain access-restricted. AI output is explanatory and is not financial advice.
UTF-8 CSV/TSV/JSON/TXT extraction runs in Go. PDF/XLSX/DOCX extraction uses
AI_EXTRACTION_SOCKET when set. Compose runs the bundled worker without a
network; Kubernetes runs it as a bounded sidecar sharing only the pod-local
Unix socket with the application. Without a socket, a direct installation falls
back to node plus AI_EXTRACTION_SCRIPT. The distroless application image has
no Node fallback.
Settings provides:
- A complete JSON export of the authenticated user's settings and portfolio
- Account and transaction CSV exports
- A validated JSON restore that replaces only the authenticated user's portfolio in one transaction
Each active balance-tracked account provides an Import action for strict Account History Import v1 CSV or JSON files. The direct workflow accepts a file or pasted content and performs no provider request. Templates and format rules are available in the guides. The legacy browser-only copyable-prompt component is not currently mounted on the import page.
The separate Convert with AI mode supports CSV, TSV, JSON, TXT, XLSX, text-based PDF, and DOCX. Wealthboard extracts text locally in the self-hosted application, lets you select/redact sections, and requests explicit consent before contacting your configured provider/model. Selected text is sent with section IDs, types, and location labels; review labels for sensitive content despite the current UI's narrower sharing description. It reuses your encrypted remembered key or accepts a session-only key. Review and correct the generated JSON draft, then use the existing preview and confirmation flow. No conversion automatically posts financial records. Cancel clears visible draft state but does not reliably abort an in-flight extraction/provider request.
Sources are limited to 5 MB and extracted content to 64 KB/1,000 sections. Password-protected PDFs accept an optional one-time password for local extraction; it is cleared after each attempt and never stored or sent to the AI provider. Scanned documents, image input, legacy DOC/XLS, encrypted Office files/archives, and external document references are unsupported. Original files are never sent to the model; provider-side retention policies still apply to approved text. See AI-assisted import for limits and provider requirements.
Position-tracked investment accounts instead use strict Investment History v1 JSON or dedicated holdings, trades, cash, and price CSV templates. The current preview shows row outcomes and a summary, with detailed results in its downloadable report; it does not render every API projection as a UI table. Confirmation commits the complete interdependent sequence atomically. Optional JSON event groups represent one dividend plus its same-date reinvestment buys.
Exports contain no credentials, AI provider settings or usage, login attempts, session data, idempotency records, or another user's rows. Restore downloads a pre-restore user export, validates the archive, rejects owner fields and invalid relationships, remaps record IDs, and rolls back completely on failure. Current exports use version 8 and include transaction external IDs, institutions, goal milestones, reminder dismissals, estate plans, retained estate summaries, instruments, ordered position events, prices, reconciliations, grouped cash links, conversion provenance, and freshness settings. Versions 2 through 8 remain restorable; version 7 position data upgrades deterministically, legacy institution names are normalized, legacy transactions receive null external IDs, and pre-v6 archives restore with an empty estate plan and no inferred positions.
Account History Import v1 CSV uses this exact header:
external_id,type,amount,date,description,notesThe selected account supplies ownership and currency; import files contain no
account or user fields. Every row requires a stable, case-sensitive
external_id. Supported types are
deposit, withdrawal, interest, dividend, capital_gain,
capital_loss, fee, purchase, sale, manual_adjustment,
liability_payment, and liability_increase. Amount is a decimal string in the
selected account currency and date is YYYY-MM-DD. Opening balances and
transfers use their dedicated workflows. Files are previewed without writes;
identical external IDs are skipped, conflicts are never overwritten, and all
accepted rows commit atomically before one balance replay. Go supports skipping
invalid rows, but the current browser disables confirmation if any row failed.
A conflict with an existing external ID aborts the whole commit.
A database archive contains every user's password hash, identity mappings, personal API-key hashes and metadata, encrypted remembered AI keys, and financial records. It is never available through an authenticated HTTP route.
Create a consistent operator backup:
mkdir -p backups
make backup BACKUP_FILE="$PWD/backups/wealthboard-$(date -u +%Y%m%dT%H%M%SZ).dump"The explicit destination directory must already exist and the file must not.
The command uses pg_dump --format=custom, omits ownership/privileges, verifies
a nonempty regular file, and sets mode 0600.
Restore is destructive and requires maintenance mode: stop every application replica and other writer, then run:
make restore RESTORE_FILE="$PWD/backups/wealthboard-20260920T120000Z.dump"The command validates the custom archive, creates a timestamped
wealthboard-pre-restore-*.dump beside it, and only then runs a clean,
single-transaction restore. It validates readiness and foreign keys afterward
and retains the safety dump on success or failure. Start Wealthboard only after
the command completes successfully, and test recovery regularly in a disposable
database. The maintenance flag acknowledges that you stopped writers; it does
not stop them for you. A failed post-restore validation leaves the safety dump
available for operator recovery, not an automatic rollback.
Production registers the service worker. It precaches only the standalone
offline page, manifest, and application icons. Failed navigation shows that
offline page; API requests are never intercepted, authenticated financial
responses are never cached, and no background-sync handler queues mutations.
Previously viewed dashboards and records are therefore not guaranteed to work
offline. The client displays connection state, blocks forms marked as financial
mutations while navigator.onLine is false, and offers Reload when a replacement
worker is waiting. It provides no mutation queue or in-app install prompt; use
the browser's install action where available and retry writes only after
connectivity returns.
Install both npm dependency sets first. For migration/integration checks, set
DATABASE_URL to a disposable PostgreSQL test database, never a production
database. Those Make targets pass it as TEST_DATABASE_URL; the test role needs
schema-creation privileges and CREATEDB for native backup/restore tests.
Install PostgreSQL 18 pg_dump and pg_restore on the test host and ensure
those binaries are first on PATH. An older pg_dump cannot back up a newer
server. CI installs the versioned client package explicitly rather than using
the runner's bundled PostgreSQL tools.
npm ci
npm --prefix web ci
make generate
make lint
make typecheck
make test
make migrate-check
make go-test-integration
make security
npx playwright install chromium
make test-e2e-go
make build
npm run docs:buildGo integration tests create and migrate disposable PostgreSQL schemas or
databases themselves, so they work against an empty test database without a
prior make migrate. Service fixtures use a separate schema for each test and
clean it up afterward. They exercise two-user isolation and portability attacks. Without
TEST_DATABASE_URL, database-backed cases in ordinary go test runs are
skipped. Playwright owns a separate disposable PostgreSQL Compose project,
builds Go/Vite, starts mock providers, and verifies layouts at 360, 390, 768,
1024, and 1440 px. Docker must be running for browser tests and screenshot
capture; do not run them against a real portfolio database.
- Use a unique, high-entropy
SESSION_SECRETand strong user passwords. - Keep
OIDC_CLIENT_SECRETandOIDC_TRANSACTION_SECRETin deployment secret storage, never an image or committed manifest. - Production session cookies are Secure, HTTP-only, SameSite=Strict, and explicitly expiring. OIDC transaction cookies are separate, callback-scoped, Secure, HTTP-only, SameSite=Lax, and expire within ten minutes.
- Restrict PostgreSQL access and filesystem access to backups and exports.
- Never publish raw backups or user exports to public object storage.
- Keep TLS, PostgreSQL, Go, Node.js build tooling, the base image, and dependencies updated.
- Users are independent; Wealthboard has no roles, organizations, invitations, shared portfolios, or cross-user transfers.
- Database errors are logged by class or name without submitted secrets.
See LICENSE.