Local Development
How to run VERA on your machine, including the CSS/design-token build step.
Prerequisites
- Node.js (the version Railway runs; an LTS ≥ 18 is safe) and npm.
- PostgreSQL — a local instance or a Supabase/hosted connection string. VERA is Postgres-only; there is no SQLite/in-memory fallback for running the app (tests mock the DB, so tests need no database).
- An Anthropic API key (only needed if you want box-2 / model-backed replies to work; box-1 deterministic paths and the console run without it).
1. Install
bash
git clone https://github.com/talktalkmake/VERA.git
cd VERA
npm install2. Environment variables
Create a .env file in the repo root. The full list and meaning of every variable is in CLAUDE.md under Required env vars; the minimum to boot locally:
bash
ANTHROPIC_API_KEY=sk-ant-... # optional locally; required for model replies
PORT=3000
ADMIN_SECRET=some-long-random-string # gates /admin and /status
TENANTS_FILE=./tenants.dev.json # default; the dev tenant config
DATABASE_URL=postgres://... # runtime role (vera_app in prod)
MIGRATION_DATABASE_URL=postgres://... # privileged role for migrations; in dev set = DATABASE_URL
# Web console (/console) — required or /console* returns 503:
CONSOLE_SESSION_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
CONSOLE_BASE_URL=http://localhost:3000
# OAuth login (optional locally; without it the console login page has no provider button):
# GOOGLE_OAUTH_CLIENT_ID=...
# GOOGLE_OAUTH_CLIENT_SECRET=...Do not set
ERROR_REPORTER_ENABLEDin dev — it must stay off outside production (it files GitHub issues).NODE_ENV=testshort-circuits it during tests regardless.
Tenant config (Slack tokens, audit channel, timezone per tenant) lives in tenants.dev.json. See TENANTS.md for the shape; you can edit tenants in-browser at /admin once running.
Rotating any of these secrets (on compromise, offboarding, or a periodic cadence)? Follow the secret rotation runbook.
3. Local database
VERA needs a real Postgres to run against or to seed/smoke locally (tests mock the DB and need none — see step 6). Two paths — check which one applies to you, in this order:
Path 1: already have Postgres running locally?
This is the common case (a Homebrew/system Postgres already listening on localhost:5432). Check with pg_isready. If it answers, create a scratch database and point both URLs at it:
bash
createdb vera_dev
export DATABASE_URL="postgres://$(whoami)@localhost:5432/vera_dev"
export MIGRATION_DATABASE_URL="$DATABASE_URL"
npm run migrate:upAdd the same two lines to your .env file (see step 2) so they persist across shells instead of exporting them each time.
Reset: dropdb vera_dev && createdb vera_dev, then npm run migrate:up again.
Path 2: no Postgres locally?
Bring one up with the committed docker-compose.yml (repo root) — it pins Postgres to the same major version CI uses (postgres:16, see .github/workflows/ci.yml):
bash
docker compose up -dThen point both URLs at the compose credentials and migrate the same way:
bash
export DATABASE_URL="postgres://vera:vera@localhost:5432/vera_dev"
export MIGRATION_DATABASE_URL="$DATABASE_URL"
npm run migrate:upReset: the database lives in a named Docker volume, not a bind-mounted directory, so there's nothing to clean up in the working tree — docker compose down -v drops the volume, then docker compose up -d recreates an empty one.
Gotchas
migrations/must contain nothing but migration files. A stray non-.jsfile in that directory (e.g. an editor's.bakbackup) makesnode-pg-migrateabort withERR_UNKNOWN_FILE_EXTENSIONbefore running anything. Ifmigrate:upfails immediately with that error, check for and remove the stray file.- The migrate output is noisy but harmless.
npm run migrate:upprints aCan't determine timestamp for NNNNwarning per integer-prefixed migration file, plus a dotenv load banner.scripts/start.jsfilters both in production (issue #977); locally they're cosmetic and expected — not a bug.
Applying and re-running migrations
bash
npm run migrate:up # applies pending migrations against MIGRATION_DATABASE_URL
npm run migrate:down # roll back the last migration
npm run migrate:create my_migration # scaffold a new migration filemigrate:up is idempotent — running it again against an already-current schema prints No migrations to run! and exits zero. In production, npm start (via scripts/start.js) runs it automatically before booting. Locally, run it yourself after pulling new migrations.
Why two database URLs?
migrations/0016_app_role.jssplits roles: the privileged role (MIGRATION_DATABASE_URL) can create tables and manage migration bookkeeping; the runtime role (DATABASE_URL→vera_app,NOBYPASSRLS) only hasSELECT/INSERT/UPDATE/DELETE. With a single local role (both paths above), point both at the same connection string.
4. Run the app
bash
npm run dev # nodemon — auto-reloads index.js on change
# or
npm start # production entrypoint: runs migrations (if MIGRATION_DATABASE_URL set) then index.jsThe app serves Slack (Socket Mode), the internal MCP server, the web console at http://localhost:3000/console, /admin, and /status.
/mcp is internal only. It carries every financial endpoint and authenticates callers by Slack user ID alone, which is not a secret, so it is gated (lib/internal-only.js) to accept only requests that arrive over a loopback socket with no x-forwarded-* header. In local dev this is invisible — your own requests to http://localhost:3000/mcp/... are loopback, so they pass — but a request to /mcp from another machine on the LAN, or through a reverse proxy, returns a bare 404.
5. CSS / design tokens (build step)
public/tokens.css is a generated, committed artifact — never edit it by hand. It is produced from the single source of truth, docs/design/design-tokens.md:
bash
npm run build:tokens # docs/design/design-tokens.md → public/tokens.cssHow it flows:
docs/design/design-tokens.mdis a markdown table of tokens (color.brand,font.headline,radius.lg, …). It is the only file you edit to change the palette/typography/spacing.scripts/build-tokens.jsparses that table and regeneratespublic/tokens.css, emitting each token as a--vera-*CSS custom property. Colors are emitted twice:--vera-color-x-hsl(theH, S%, L%channels, for composing translucency) and--vera-color-x(the opaquehsla(...)).- The web console links the generated file via
<link rel="stylesheet" href="/static/tokens.css">(served frompublic/), so console styles readvar(--vera-*). - getvera.site (the VitePress docs site) imports the same file via
docs/.vitepress/theme/tokens.css, which maps a subset of VERA tokens onto VitePress's brand variables.
Workflow whenever you change a token: edit docs/design/design-tokens.md → run npm run build:tokens → commit both the .md and the regenerated public/tokens.css. There is a test (tests/design-tokens.test.js) that fails if public/tokens.css is out of sync with the markdown, so a stale build is caught by npm test.
6. Tests
bash
npm test # full Jest suite (~5s, no external services — the DB and Slack are mocked)Tests require no database or API keys. CI/local note: a couple of model-backed simulator tests can flake on the live model; "no non-flaky failures" is the green bar.
Every pull request now runs .github/workflows/ci.yml: a test job (npm test), a migrations job that applies every Postgres migration from zero against a throwaway database, then runs the migrate step a second time to confirm a boot against an already-current schema is a clean no-op (it does not prove individual migrations are re-runnable), and a smoke job (needs: migrations) that boots the real app against its own freshly-migrated throwaway Postgres and requests every console route — see below. Fix a local failure before pushing — it will fail the same way on the PR.
Console route smoke test
npm test mocks the DAL for every console route test, which is exactly why a missing column, a reader selecting a column that doesn't exist, or a renderer throwing on a real data shape can slip through green (the 2026-08-02 proposals incident — see issue #1866). tests/smoke/console-routes.smoke.js closes that gap: it boots mcp-server.js + lib/console-routes.js in-process (no Slack, no Socket Mode), enumerates every GET route the console router actually registers, and requests each one against a real, migrated Postgres — asserting any status under 500. A 404 on a made-up id is a pass; it proves the route ran and rendered rather than throwing.
It is deliberately named *.smoke.js, not *.test.js — jest's testMatch never picks it up, so it does not run as part of npm test and needs no database there. Run it directly against a local Postgres:
bash
createdb vera_smoke_test
DATABASE_URL=postgres://localhost/vera_smoke_test npm run migrate:up
DATABASE_URL=postgres://localhost/vera_smoke_test \
CONSOLE_SESSION_SECRET=local-smoke-secret \
TENANTS_FILE=./tenants.ci.json \
node tests/smoke/console-routes.smoke.jstenants.ci.json is a minimal, Slack-token-free fixture tenant; the script inserts a single owner-role fixture user directly with SQL and mints its own session cookie via signToken (lib/console/auth.js) — no login flow, no seeded demo data. Because the console calls back into the MCP layer over real HTTP (lib/console/mcp-call.js hits http://localhost:$PORT/mcp/...), the script actually binds a real port rather than leaving supertest to bind an ephemeral one per request — supertest is still what drives every request and assertion.
DAL reader contract smoke test
tests/smoke/dal-contract.smoke.js is a sibling to the route smoke above (issue #1906): it inserts one real row per table (via SQL, not through the DAL) with every column a reader's callers depend on populated, calls the reader against a real migrated Postgres, and asserts every value comes back matching what was actually inserted. This is the layer that would have caught issue #1903 — readProjects() silently dropping start_date from its SELECT — which every other test layer (mocked-DAL route tests, a mocked-withTenant mapper test, and the route smoke's status-code check) missed. Same fixture tenant, same "not part of npm test" reasoning as the route smoke above:
bash
DATABASE_URL=postgres://localhost/vera_smoke_test \
TENANTS_FILE=./tenants.ci.json \
node tests/smoke/dal-contract.smoke.js7. Docs site (getvera.site)
The public docs site is VitePress under docs/:
bash
npm run docs:dev # local preview of getvera.site with hot reload
npm run docs:build # production build (also syncs the book outline)
npm run docs:preview # serve the production build locallyDocs deploy automatically to https://getvera.site when changes under docs/** land on main.
The homepage's JSON-LD structured data (Organization/WebSite/SoftwareApplication, issue #2029) lives in docs/.vitepress/config.mjs's head array, not a separate file — edit it there.
8. Verifying a deploy landed
GET /version is unauthenticated and answers "which build is actually running?" in one curl — no round of code archaeology, no guessing from log timestamps:
bash
curl -s https://app.getvera.site/version | jq -r .started_atCompare against the merge time. If started_at is earlier than your merge, the container is behind and nothing else is worth investigating yet. To confirm the exact commit running, compare commit (when present — a best-effort read of RAILWAY_GIT_COMMIT_SHA) directly against the commit you expect:
bash
diff <(curl -s https://app.getvera.site/version | jq -r .commit) <(git rev-parse origin/main)Silence means the container is running that commit; any output means it is behind. See the "Version endpoint" entry in CLAUDE.md for the full field list.
Script reference
| Command | What it does |
|---|---|
npm run dev | Run the app with nodemon auto-reload |
npm start | Production entrypoint (migrations + app) |
npm test | Jest suite |
npm run migrate:up / :down / :create | Postgres migrations |
npm run build:tokens | Regenerate public/tokens.css from design-tokens.md |
npm run docs:dev / :build / :preview | VitePress docs site |
npm run sync:book | Sync the book outline into the docs build (/about chapters) |