/

Developer Guide

Setting Up the Environment

Welcome to the CrowdVision developer setup guide. Because CrowdVision is a multi-service architecture with real-time sockets, push notifications, and AI capabilities, getting the local environment running requires setting up several databases, environment variables, and cryptographic secrets.

Don’t worry — we have provided scripts to automate the hardest parts.


1. Prerequisites

Install these two tools first — everything else depends on them:

Once just and mise are available, the remaining tools can be managed by mise automatically or installed manually:

Note

For a deep dive on how mise resolves tool versions (and why every script runs through mise exec), and how moon orchestrates per-project tasks with caching and affected-detection, see The Toolchain: mise & moon.


2. Installation

Clone the repository, then install toolchain versions and all language-specific dependencies:

git clone https://github.com/NickGhignatti/crowd-vision.git
cd crowd-vision
mise install   # installs Node 24, Python 3.12, Rust stable, moon, uv (requires mise)
just setup install

just setup install installs every language’s dependencies in parallel (via scripts/install.mjs): Node deps for tooling/eslint-config/ (the shared @crowdvision/eslint-config ESLint package), the frontend, every TypeScript backend service, and the sensor simulator; plus the Python and Rust services’ dependencies. The exact set of packages is derived from moon’s project registry (\.moon/workspace.yml, via moon query projects), so the install list can never drift from the rest of the tooling — see The Toolchain: mise & moon for the full picture. There is no root package.json — each service manages its own node_modules. tooling/ holds only the shared ESLint flat config, @crowdvision/eslint-config, at tooling/eslint-config/.

The JS install uses npm ci (not npm install): it installs exactly what each package-lock.json pins and never rewrites the lockfile, so running setup on Windows or macOS can’t accidentally change a Linux-resolved lock. Python uses uv sync --locked and Rust uses cargo fetch. Set JOBS=N to cap parallelism (default 4): JOBS=2 just setup install.

Go modules aren’t part of this step

scripts/install.mjs only buckets projects into JS/Python/Rust; the four language: 'go' projects (claims-gateway, tenancy-service, registry-service, provisioner) are skipped. This is usually harmless — go build/go test/go vet (what just test/just lint run for them) fetch missing modules on demand — but if you want them warmed up ahead of time, run go mod download inside each service directory yourself.

Lockfiles & Cross-Platform Regeneration

CrowdVision’s CI runs on Linux, so every committed package-lock.json must contain the Linux-specific optional native packages (e.g. @emnapi/*, @unrs/resolver-binding-*) that some dev tools pull in. A plain npm install run on Windows or macOS resolves the lock for your platform and strips those Linux entries — which makes npm ci fail in CI with errors like Missing: @emnapi/core@… from lock file.

To avoid this, never let a plain npm install rewrite a committed lockfile. Regenerate locks only through the dedicated recipe, which forces Linux resolution:

just setup clean-install   # wipes every node_modules + lock, regenerates for Linux, reinstalls

To regenerate just one service’s lockfile (e.g. after adding a dependency) without the full wipe:

npm install --prefix backend/twin-service --package-lock-only --cpu=x64 --os=linux

The --cpu=x64 --os=linux flags make npm record the Linux optional tree even when you run the command on another OS; --package-lock-only updates the lock without touching node_modules.

Warning

Never commit a Windows- or macOS-resolved lockfile. If git diff shows optional @emnapi/* / @unrs/* entries being removed from a package-lock.json, you ran a plain npm install — discard it and regenerate with just setup clean-install (or the per-service command above). Run just setup deps-check before pushing to confirm every lockfile still passes npm ci.


3. Environment Variables & Secrets

Microservices require secrets to communicate securely, encrypt tokens, and send push notifications. Never commit \.env files to version control.

Run the interactive setup script. It skips any variable already present in \.env, so it is safe to re-run at any time — e.g. after pulling a change that introduces a new secret:

just stack env

This chains eight small, idempotent Node scripts (scripts/env/*.js), each responsible for one concern. Understanding the split helps when only one thing needs regenerating (see the per-script commands below).

ScriptWhat it writes
create.jsCreates an empty \.env if none exists yet. Every other script appends to it.
config.jsCore gateway config (table below) plus the one interactive prompt, OPENROUTER_API_KEY.
token.jsEVAL_JWT_SECRET — a 256-bit hex secret.
vapid.jsVAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY — web push notification keys.
langfuse.jsSelf-hosted Langfuse (LLM trace UI) provisioning secrets and the agent’s OpenTelemetry exporter config.
keycloak.jsKeycloak admin credentials, its Postgres password, and the optional Google social-login client id/secret.
controlplane.jsShared secrets for the Go control plane (claims-gateway, tenancy-service, registry-service, provisioner).
gateway-key.jsA stable RSA signing key for claims-gateway, written to secrets/gateway-dev-key.pemnot \.env (see below).

Core gateway config

Written non-interactively by config.js (edit \.env to override):

VariableDefaultDescription
BACKEND_PORT80Host port the API gateway (Caddy) listens on.
FRONTEND_PORT8080Port the Vue frontend is served on.
DEV_URLhttp://localhostBase URL of the gateway; combined with the ports to build public-facing URLs.

The only interactive prompt in the whole just stack env chain is the optional OPENROUTER_API_KEY (leave empty to skip — the agent-service boots without it, but /ask fails on the first LLM/tool-calling hop; get a key at https://openrouter.ai/keys). Legacy GOOGLE_API_KEY / DEEPSEEK_API_KEY values already in \.env are still honoured as aliases.

Note

There is no JWT_SECRET/INTERNAL_ADMIN_SECRET anymore — those belonged to the now-deleted auth-service. See Identity & Tenancy Architecture for the current auth design.

Keycloak

keycloak.js generates KEYCLOAK_ADMIN_USER (admin), a random KEYCLOAK_ADMIN_PASSWORD, and KEYCLOAK_DB_PASSWORD, then appends two empty placeholders, GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET. Those two are optional — filling them in enables the “Continue with Google” button on the hosted login page (set up an OAuth 2.0 client in Google Cloud Console first; see keycloak/README-google.md for the exact steps). Keycloak’s admin console is reachable at http://localhost:8090 once the stack is up, using the generated admin credentials.

Langfuse (LLM observability)

langfuse.js generates the self-hosted Langfuse v3 provisioning secrets (LANGFUSE_PUBLIC_KEY/SECRET_KEY, LANGFUSE_INIT_*, salt/encryption keys) and the OpenTelemetry exporter config the agent-service uses to ship traces there. The project and API keys are provisioned headlessly on Langfuse’s first boot — nothing to click through. Once the stack is running, the UI is at http://localhost:3030 (or http://localhost/langfuse, which redirects there — see Docker Compose Orchestration, logging in with admin@crowd-vision.local / langfuse-admin.

Note

Unset OTEL_EXPORTER_OTLP_ENDPOINT to fall back to compact console span output if you don’t need a trace backend for local hacking.

Control plane

controlplane.js generates INTERNAL_SIGNING_SECRET — the shared HMAC key claims-gateway, tenancy-service, registry-service, and provisioner use to authenticate their system-to-system calls, so it must be the same value across all four — plus TENANCY_DB_PASSWORD and REGISTRY_DB_PASSWORD for their respective Postgres instances.

Gateway signing key

gateway-key.js is the one script that doesn’t touch \.env at all: it writes a 2048-bit RSA private key to secrets/gateway-dev-key.pem (gitignored, mounted into the claims-gateway container as a file, since a multi-line PEM can’t live in a \.env value). Without a stable key on disk, claims-gateway would generate a fresh ephemeral one on every restart, invalidating every existing session and churning the JWKS every other service caches. The write is atomic and skips silently if the file already exists.

Regenerating a single secret

Each script is safe to run on its own — they all skip already-present values:

node scripts/env/vapid.js       # just VAPID keys
node scripts/env/langfuse.js    # just Langfuse/OTEL config
node scripts/env/keycloak.js    # just Keycloak secrets

To force a full regeneration of one secret, delete its line(s) from \.env (or the file at secrets/gateway-dev-key.pem) first, then re-run just stack env or the specific script.


4. Running Locally with Docker Compose

With your \.env set up, start the full development stack:

just stack dev

This starts all services with hot-reloading. When you save a source file, the change is reflected inside the running container instantly — no manual restart needed.

Skip services you are not actively working on:

just stack dev exclude="simulator"   # skip the simulator containers
just stack dev exclude="frontend"      # skip the Vue frontend (e.g. run it separately via Vite)

See the Running the Application page for the full Docker Compose reference.

Verifying the setup

Once docker compose ps (or just stack logs) shows every container healthy, confirm the gateway and the auth path are actually wired up before moving on:

curl -i http://localhost/twin/health   # any service behind the gateway responds

Then open http://localhost:8080 (or http://localhost if FRONTEND_PORT/BACKEND_PORT weren’t changed) in a browser — the frontend should load and redirect to Keycloak’s hosted login page when you try to sign in. If it instead hangs or 502s, see the Debugging Tips in Running the Application.


5. Bootstrapping the System (First Login)

There is no seed script anymore — login and registration go through Keycloak’s hosted UI. Once just stack dev reports all services healthy, open the frontend and register normally; it redirects to Keycloak. To get a working domain, create one from the Domains page after logging in (POST /tenancy/domains) — the creator automatically becomes that domain’s business_admin. See Identity & Tenancy Architecture.


6. Kubernetes Deployment

If you are deploying CrowdVision to a local or remote Kubernetes cluster, follow the steps below. For ongoing day-to-day k8s operations, see the Kubernetes Configuration page.

Step 1: Create the k3d Cluster (run once per machine)

just k8s create

This runs k3d cluster create crowdvision -p "80:80@loadbalancer" -p "443:443@loadbalancer" --api-port 6550 --k3s-arg "--disable=traefik@server:0" --wait --timeout 120s — a single-server, single-agent cluster with the default Traefik ingress disabled (CrowdVision’s local k3d path routes through Istio’s Gateway API instead — see Kubernetes Configuration. --wait --timeout 120s blocks until the cluster is actually ready, so the next step never races a half-started API server.

Verify the cluster is reachable:

kubectl cluster-info
kubectl get nodes
# All nodes should show STATUS: Ready

Step 2: Configure Local DNS

just k8s hosts
# Prints: 127.0.0.1   crowdvision.local

Open your hosts file as Administrator and add that line:

Step 3: Add GHCR Credentials to \.env

The cluster needs credentials to pull images from GitHub Container Registry. Add to your \.env:

GHCR_USERNAME=nickghignatti
GHCR_TOKEN=<your GitHub PAT with read:packages scope>

Create a PAT at https://github.com/settings/tokens with read:packages scope.

Step 4: Full Bootstrap

just k8s setup

This single command runs in order:

  1. Creates the k3d cluster (just k8s create — skip if you already did Step 1).
  2. Installs the Gateway API CRDs, then Istio in ambient mode (istiod, ztunnel, istio-cni) — see Kubernetes Configuration for why ambient mode and the k3d-specific CNI flags matter.
  3. Creates the crowdvision namespace — already carrying the istio.io/dataplane-mode=ambient label declared in k8s/namespace.yml, so ztunnel enrollment happens the moment the namespace exists.
  4. Runs just k8s secrets — creates all Kubernetes Secret objects from your \.env.
  5. Applies ConfigMaps, StatefulSets (databases), Deployments, the Istio mesh manifests (istio-peer-authentication.yml, istio-gateway.yml, istio-request-authentication.yml — the Gateway/HTTPRoutes that make http://crowdvision.local work, STRICT mTLS, and edge JWT verification), and monitoring.

Warning

just k8s secrets must run after the namespace exists. just k8s setup handles this ordering automatically. If you run just k8s secrets manually before creating the namespace, it will fail with “namespaces not found”.

Accessing the Platform

After just k8s setup completes, the platform is available at http://crowdvision.localjust k8s apply (the last step of setup) already applies k8s/istio-gateway.yml, no separate manual step needed. It defines one HTTPRoute per service, deliberately mirroring the Docker Compose gateway’s paths (see Docker Compose Orchestration:

InterfaceURL
Vue Frontendhttp://crowdvision.local/
Claims Gateway (Keycloak token exchange, /me, JWKS)http://crowdvision.local/gateway/
Tenancy Servicehttp://crowdvision.local/tenancy/
Twin APIhttp://crowdvision.local/twin/
Sensor APIhttp://crowdvision.local/sensor/
Notification APIhttp://crowdvision.local/notification/
Agent APIhttp://crowdvision.local/agent/
Chat APIhttp://crowdvision.local/chat/
Contracts APIhttp://crowdvision.local/contracts/
Socket.IOhttp://crowdvision.local/socket.io/

Keycloak itself isn’t routed through the gateway — it isn’t deployed to this cluster at all (see the note in Kubernetes Configuration about claims-gateway being expected to fail locally unless Keycloak is separately reachable). Grafana and Prometheus likewise aren’t part of k8s/istio-gateway.yml yet; use just k8s logs / kubectl port-forward to reach them in the meantime.