02 — Running it locally
Goal: clone → boot the API → hit a real endpoint → understand what just happened. ~20 minutes hands-on.
Prerequisites
| Tool | Version | Why |
|---|---|---|
| Node.js | 20+ | NestJS API + Next.js apps run on Node |
| pnpm | 8+ | corepack enable && corepack prepare pnpm@8 --activate |
| Docker + Compose | recent | Postgres, Redis, Redpanda |
| Go | 1.22+ | Only if you'll touch services/*. The API works without it. |
| psql client | any | For poking at the DB |
Optional but useful:
bufCLI — only if you'll regenerate gRPC stubs (go install github.com/bufbuild/buf/cmd/buf@latest)grpcurl— for hitting the ledger gRPC service if/when it boots
Step 1: clone and install
git clone <repository-url> Demoz-Pay
cd Demoz-Pay
pnpm install # installs all workspace deps + hoists where possible
pnpm install also runs Prisma's postinstall which generates the
TypeScript client. If you see Prisma errors here, your Postgres
isn't required yet — the client is generated against the
schema.prisma file, not a live DB. The error is most likely
missing node_modules permissions; re-run.
Step 2: start Postgres
The repo ships a docker-compose.yml at the root with Postgres
on 5432.
⚠️ Port conflict warning. This shared dev host may already have other Postgres instances. If
5432is taken, change the port indocker-compose.ymland update yourDATABASE_URLto match. The verification harness for the ledger uses5434to avoid this exact situation (seeservices/ledger/test/docker-compose.test.yml).
pnpm docker:up # equivalent to: docker compose up -d postgres
Confirm it's healthy:
docker compose ps
# postgres should be "running (healthy)"
Step 3: configure the env
Copy the template:
cp .env.example .env.development
Then edit .env.development and set, at minimum:
DATABASE_URL="postgresql://demoz_pay:demoz_pay@localhost:5432/demoz_pay_dev?schema=public"
JWT_SECRET="dev-secret-must-be-at-least-16-characters"
BETTER_AUTH_SECRET="dev-secret-also-at-least-16-chars-long-too"
# Optional but recommended:
NODE_ENV="development"
PORT=8000
LOG_LEVEL="info"
# Leave these unset for local dev — fallbacks kick in:
# REDIS_URL=
# KAFKA_BROKERS=
# OUTBOX_PUBLISHER_ENABLED=false
# SMS for phone-OTP login. 'logger' = print code to console.
SMS_PROVIDER="logger"
What happens when these are missing:
- Missing
DATABASE_URL→ boot fails fast with a clear error. - Missing
JWT_SECRETorBETTER_AUTH_SECRET→ boot fails fast. - Missing
REDIS_URL→ EWA eligibility cache falls back to in- memory. Health probe reportsskipped. Safe for dev. - Missing
KAFKA_BROKERS→ outbox publisher disabled. Health probe reportsskipped. Safe for dev.
The full env schema lives in packages/shared/config/src/lib/ config.ts — read it; every variable is Zod-validated at boot.
Step 4: apply migrations
pnpm prisma:migrate
# = prisma migrate deploy --schema=apps/api/prisma/schema.prisma
You should see 9 migrations apply in sequence. The last few are the interesting ones:
20260526000000_canonical_financial_infra—ewa_request,loan,outbox_event,idempotency_record,audit_entry20260526030000_apply_tenant_rls— enables RLS, installs thetenant_isolationpolicy on all 5 financial tables, runs a verify guard at the end that raises if any expected table is missing the policy. If this migration succeeds, tenant isolation is in place.20260526040000_better_auth_schema—Session,Account,Verification,TwoFactor,Organization,Member,Invitation20260526050000_bootstrap_organizations— for any existing Business rows (none on a fresh DB), backfill matching Organization rows. Verifies at end that the 1:1 invariant holds.
If any migration fails:
- Most likely cause: DB isn't fresh.
pnpm prisma:resetto wipe and re-apply. - Less likely: schema drift. Read the error; do not bypass.
Step 5: (optional) the BYPASSRLS publisher role
Only needed if you're going to run the outbox publisher. For just "boot the API and hit an endpoint", skip this.
psql "$DATABASE_URL" -v publisher_password="'devpass'" \
-f infra/sql/00_create_outbox_publisher_role.sql
Then set:
OUTBOX_DATABASE_URL="postgresql://demozpay_outbox_publisher:devpass@localhost:5432/demoz_pay_dev?schema=public"
OUTBOX_PUBLISHER_ENABLED=true
Without this, with RLS active, the publisher would silently drain zero rows (the API role can't see other tenants' outbox rows). There's a loud WARN log at boot if you enable the publisher without the dedicated role.
Step 6: boot the API
pnpm dev:api
You should see a structured log stream. The interesting lines:
{"context":"BetterAuth","msg":"better-auth initialized at http://localhost:8000/api/auth"}
{"context":"StartupChecksService","msg":"running startup dependency checks…"}
{"context":"StartupChecksService","msg":"startup check postgres: {\"status\":\"up\",\"latencyMs\":0}"}
{"context":"StartupChecksService","msg":"startup check redis: {\"status\":\"skipped (REDIS_URL unset)\"}"}
{"context":"StartupChecksService","msg":"startup check kafka: {\"status\":\"skipped (KAFKA_BROKERS unset)\"}"}
{"context":"StartupChecksService","msg":"startup check ledger: {\"status\":\"up\",\"reachable\":false,\"advisory\":true,\"message\":\"connect ECONNREFUSED 127.0.0.1:50051\"}"}
{"context":"NestApplication","msg":"Nest application successfully started"}
{"context":"Bootstrap","msg":"Server listening on http://localhost:8000/api"}
What these mean:
postgres up— DB connection works. 0ms is normal for localhost.redis skipped—REDIS_URLunset; EWA cache uses in-memory.kafka skipped—KAFKA_BROKERSunset; outbox publisher disabled.ledger advisory failure— this is expected and correct. The Go ledger service isn't running locally. The probe is marked advisory (not fatal) because the API can serve everything except money-moving operations without it.
If you want to also boot the Go ledger, see
services/ledger/README.md. It requires Go 1.22+ on your machine
and a separate Postgres for the ledger (different from the API's DB,
per ADR-006).
Step 7: hit some endpoints
/api/healthz — process is up
curl http://localhost:8000/api/healthz
# {"status":"ok","timestamp":"...","uptime":12.3}
Returns 200 as long as the Node process is alive. Use for k8s livenessProbe.
/api/readyz — deps are reachable
curl http://localhost:8000/api/readyz
# {"status":"ok","info":{"postgres":{"status":"up","latencyMs":1}, ...}}
Returns 200 if all critical deps are up. Use for k8s readinessProbe.
/api/metrics — Prometheus scrape
curl http://localhost:8000/api/metrics | head -40
You'll see process_* defaults (Node CPU/memory) and custom
demozpay_* metrics (HTTP latency histograms, dependency status,
outbox backlog). This is what Prometheus would scrape.
/api/ewa/eligibility — auth gate
curl 'http://localhost:8000/api/ewa/eligibility?employeeId=x&payPeriodId=y'
# {"message":"Authentication required","error":"Unauthorized","statusCode":401}
This is the correct response. All financial routes are protected
by default (AuthGuard registered as APP_GUARD with @Public()
opt-out on health/metrics/root).
/api/auth/sign-up/email — better-auth sign-up
curl -i -X POST http://localhost:8000/api/auth/sign-up/email \
-H 'content-type: application/json' \
-d '{"email":"alice@dev.test","password":"correct-horse-battery","name":"Alice"}'
You'll get:
- HTTP 200
Set-Cookie: better-auth.session_token=...— save this- JSON body with
{token, user: {...}}
Now try the auth'd route with the cookie:
COOKIE='better-auth.session_token=...'
curl -H "Cookie: $COOKIE" \
'http://localhost:8000/api/ewa/eligibility?employeeId=x&payPeriodId=y'
You'll get past auth (no longer 401) but will likely 500 — Alice has no Organization membership yet, so the tenant context is empty. This is expected at this stage of the platform. The 401→500 transition proves the entire auth+session+tenant pipeline is working.
Step 8: peek at the DB
pnpm prisma:studio # web UI at localhost:5555
# or
psql "$DATABASE_URL"
After your sign-up, you should see rows in:
User(Alice)Session(her active session token)Account(provider=credential, holds her bcrypt password)
Try this — it'll return zero rows even though there's data, because you didn't set the tenant GUC:
SELECT * FROM ewa_request; -- 0 rows; RLS hides everything
That's the fail-closed isolation working. The runner sets the GUC inside the txn for every legitimate API path; raw queries through psql don't.
To bypass for local exploration only:
SET LOCAL app.tenant_id = 'some-business-id';
SELECT * FROM ewa_request; -- this tenant's rows
Step 9: shut down cleanly
# Stop the API (Ctrl-C in the dev:api terminal)
pnpm docker:down # stops Postgres
If you were running the test stack for the ledger:
docker compose -p demoz-pay-ledger-test \
-f services/ledger/test/docker-compose.test.yml down -v
What didn't happen and why
If you went through all of the above and felt like "wait, where's the real product?":
- No payroll engine. Step 1 of the product model isn't built.
- No money actually moved. The ledger gRPC server is
Compile- onlyon most hosts. The DB invariants are runtime-proven but the Go binary requires a Go-equipped machine +buf generate. - No real SMS in
sign-up's phone flow.SMS_PROVIDER=loggerprints OTPs to the console; production needs an AfricasTalking adapter (Planned). - No frontends connected. All web apps under
apps/*-webare UI shells with hardcoded mock arrays. None call the API.
This is all documented in
06-status-matrix.md. It's the truth.
Continue reading
Next: 03-the-codebase-tour.md — walk
every folder and understand what's in it.