Skip to content

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 install

2. 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_ENABLED in dev — it must stay off outside production (it files GitHub issues). NODE_ENV=test short-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:up

Add 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 -d

Then 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:up

Reset: 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-.js file in that directory (e.g. an editor's .bak backup) makes node-pg-migrate abort with ERR_UNKNOWN_FILE_EXTENSION before running anything. If migrate:up fails immediately with that error, check for and remove the stray file.
  • The migrate output is noisy but harmless. npm run migrate:up prints a Can't determine timestamp for NNNN warning per integer-prefixed migration file, plus a dotenv load banner. scripts/start.js filters 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 file

migrate: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.js splits roles: the privileged role (MIGRATION_DATABASE_URL) can create tables and manage migration bookkeeping; the runtime role (DATABASE_URLvera_app, NOBYPASSRLS) only has SELECT/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.js

The 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.css

How it flows:

  • docs/design/design-tokens.md is 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.js parses that table and regenerates public/tokens.css, emitting each token as a --vera-* CSS custom property. Colors are emitted twice: --vera-color-x-hsl (the H, S%, L% channels, for composing translucency) and --vera-color-x (the opaque hsla(...)).
  • The web console links the generated file via <link rel="stylesheet" href="/static/tokens.css"> (served from public/), so console styles read var(--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.js

tenants.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.js

7. 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 locally

Docs 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_at

Compare 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

CommandWhat it does
npm run devRun the app with nodemon auto-reload
npm startProduction entrypoint (migrations + app)
npm testJest suite
npm run migrate:up / :down / :createPostgres migrations
npm run build:tokensRegenerate public/tokens.css from design-tokens.md
npm run docs:dev / :build / :previewVitePress docs site
npm run sync:bookSync the book outline into the docs build (/about chapters)