Skip to main content

02 — Running it locally

Goal: clone → boot the API → hit a real endpoint → understand what just happened. ~20 minutes hands-on.

Prerequisites

ToolVersionWhy
Node.js20+NestJS API + Next.js apps run on Node
pnpm8+corepack enable && corepack prepare pnpm@8 --activate
Docker + ComposerecentPostgres, Redis, Redpanda
Go1.22+Only if you'll touch services/*. The API works without it.
psql clientanyFor poking at the DB

Optional but useful:

  • buf CLI — 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 5432 is taken, change the port in docker-compose.yml and update your DATABASE_URL to match. The verification harness for the ledger uses 5434 to avoid this exact situation (see services/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_SECRET or BETTER_AUTH_SECRET → boot fails fast.
  • Missing REDIS_URL → EWA eligibility cache falls back to in- memory. Health probe reports skipped. Safe for dev.
  • Missing KAFKA_BROKERS → outbox publisher disabled. Health probe reports skipped. 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_infraewa_request, loan, outbox_event, idempotency_record, audit_entry
  • 20260526030000_apply_tenant_rls — enables RLS, installs the tenant_isolation policy 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_schemaSession, Account, Verification, TwoFactor, Organization, Member, Invitation
  • 20260526050000_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:reset to 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 skippedREDIS_URL unset; EWA cache uses in-memory.
  • kafka skippedKAFKA_BROKERS unset; outbox publisher disabled.
  • ledger advisory failurethis 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- only on 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=logger prints OTPs to the console; production needs an AfricasTalking adapter (Planned).
  • No frontends connected. All web apps under apps/*-web are 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.