Local Development
Run the Chilarai stack locally: infra in Docker, apps via Nx.
Prerequisites
- Node.js 22 LTS+
- pnpm 10+
- Docker Desktop
1. Install
cd chilarai
pnpm install
2. Start infrastructure
Postgres, Redis, Keycloak, MinIO, plus the telemetry pipeline (OTel Collector + Grafana/LGTM) run in Docker.
cp infra/env/local.env.example infra/env/local.env # fill in passwords
pnpm infra:up # start | infra:down, infra:logs, infra:ps
| Service | URL |
|---|---|
| Postgres | localhost:5432 |
| Keycloak | http://localhost:8080 (realm chilarai) |
| MinIO console | http://localhost:9001 |
| OTel Collector | OTLP localhost:4317 (gRPC) / 4318 (HTTP) |
| Grafana (LGTM) | http://localhost:4000 |
3. Configure the API env
Nx auto-loads api/.env when serving. It is gitignored; copy the template:
cp api/.env.example api/.env
Defaults point at local infra (DATABASE_URL, AUTH_PROVIDER=keycloak, KEYCLOAK_*). The API listens on port 3333 (PORT) to stay clear of the web app on 3000.
Then the web app env (also gitignored):
cp web/.env.local.example web/.env.local # then set AUTH_SECRET (npx auth secret)
The BFF proxy uses API_BASE_URL (http://localhost:3333/api) to reach the API server-side.
4. Database
pnpm db:migrate # apply migrations
pnpm db:seed # roles, permissions, scopes (idempotent)
Drizzle helpers: db:generate (new migration), db:push, db:studio.
5. Run the apps
Start both together:
pnpm dev # API (:3333) + web (:3000) in parallel
Or run them individually:
pnpm api:serve # API -> http://localhost:3333/api
pnpm web:dev # Web -> http://localhost:3000
Quick check (no auth): GET http://localhost:3333/api → {"message":"Hello API"}.
Bootstrap the first platform administrator
Provision admin@ujanlabs.com in the chilarai Keycloak realm first, then copy
that user's immutable Keycloak ID (sub). Bootstrap application access with:
$env:DATABASE_URL="postgres://<user>:<password>@localhost:5432/chilarai"
$env:BOOTSTRAP_ADMIN_SUB="<keycloak-user-id>"
$env:BOOTSTRAP_ADMIN_EMAIL="admin@ujanlabs.com"
pnpm db:bootstrap:platform-admin
BOOTSTRAP_ADMIN_FIRST_NAME and BOOTSTRAP_ADMIN_LAST_NAME optionally override
the default display name. The command is idempotent for the same Keycloak user and
does not create a school membership. Use platform-user management for additional
platform administrators.
6. Authenticate and test /me
/me requires a Keycloak bearer token, and the caller must be provisioned in the DB.
Use the platform administrator bootstrapped above.
-
Get a token from the
chilarairealm and decode itssub:function Decode-Jwt($t){ $p=$t.Split('.')[1].Replace('-','+').Replace('_','/'); switch($p.Length%4){2{$p+='=='}3{$p+='='}}; [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) | ConvertFrom-Json | Select-Object -ExpandProperty sub }Decode-Jwt "PASTE_TOKEN" -
Call
/me:$h = @{ Authorization = "Bearer <token>" }Invoke-RestMethod http://localhost:3333/api/me -Headers $h | ConvertTo-Json -Depth 6Before any tenants are provisioned, the platform administrator has a platform role but no school memberships. Tenant-scoped calls become available after a tenant, school, and membership are created through platform administration.
7. Sign in via the web app
The web app uses NextAuth (Auth.js) with Keycloak and a BFF proxy — the browser never holds the token.
- Ensure the API (3333) and web (3000) are running and
web/.env.localis set. - Open
http://localhost:3000→ Sign in with Keycloak → log in. - The home page shows you signed in. Data screens call the API through the BFF
(
/bff/*), which attaches the bearer token server-side.
The browser only ever calls the Next.js origin (/bff/*); Next forwards to the
API with the bearer token attached server-side.
8. Telemetry (OpenTelemetry → Grafana)
Both apps are instrumented with OpenTelemetry and export OTLP to the local Collector, which redacts secrets and forwards to the Grafana/LGTM stack.
pnpm infra:upstarts the Collector and Grafana (see the infra table).- Run the apps (
pnpm dev) and generate some traffic (open the app, sign in). - Open Grafana at
http://localhost:4000→ Explore:- Tempo — traces (a request spans
chilarai-web→chilarai-api→ Postgres) - Loki — logs (correlated to traces by
trace_id) - Mimir/Prometheus — metrics
- Tempo — traces (a request spans
Config: apps read OTEL_* env (endpoint http://localhost:4318); the Collector
config is infra/otel/collector-config.yaml. Only the Collector's exporter
changes between environments — the app instrumentation stays the same.
Scripts reference
| Command | Purpose |
|---|---|
pnpm infra:up / :down / :logs / :ps | Manage Docker infra |
pnpm db:migrate / db:seed | Migrate schema / seed authorization records |
pnpm db:bootstrap:platform-admin | Grant the first Keycloak user platform-admin access |
pnpm db:generate / db:push / db:studio | Drizzle tooling |
pnpm api:serve / api:build / api:test / api:lint | API tasks |
pnpm web:dev / web:build / web:test / web:lint | Web tasks |
pnpm dev | Run API + web together |
Troubleshooting
| Symptom | Cause / Fix |
|---|---|
401 Unauthorized on /me | Missing/expired/invalid token. Send Authorization: Bearer <token>; check the API log for the JwtAuthGuard reason. |
jwt issuer invalid. expected: …/realms/chilarai | Token minted from the wrong realm/host. Get it from the chilarai realm on localhost:8080, or set KEYCLOAK_ISSUER_URL to match the token's iss. |
/me returns user: null | Token is valid but the identity is not provisioned. Bootstrap the first platform admin or create the user through platform administration. |
400 X-Tenant-Code header is required | A tenant-scoped endpoint was called without the header. |
403 No active membership for tenant "…" | Caller has no active membership in that tenant. Verify the user's school membership in platform administration. |
Keycloak login Invalid redirect_uri | Open the web app on http://localhost:3000; the chilarai-web client allows http://localhost:3000/*. |
401 from /bff/* in the browser | Not signed in or the session expired — sign in again. |
| DB connection errors | Ensure pnpm infra:up is running and DATABASE_URL matches infra/env/local.env. |