# Environment Configuration


The Primebrick Backend reads configuration from two sources:

1. **Environment variables** — see the strict split below
2. **`config_entries` database table** — runtime auth settings (OIDC, Redis, gateway)

Environment variables are loaded once at startup via `dotenv/config`. The
`config_entries` table is read at runtime and can be updated via the
admin API without restarting.

## The env contract — read this first (CRITICAL)

The `.env` file is a **closed set**: it may contain ONLY the database
bootstrap pair `DATABASE_URL` + `DATABASE_SCHEMA`. `assertDbEnv()`
crashes the boot on ANY other key in the file — including a misspelled
`DB_*`/`DATABASE_*` name (fail-loud, never silently ignored).

A closed set of **platform variables** (`NODE_ENV`, E2E/debug knobs) is
supplied at process launch or via Docker `ENV` — never in `.env`, never
hardcoded, never in `config_entries`. Everything else — URLs, ports,
secrets — lives in the `config_entries` table (`nats_url` included: the
BE connects inside `startServiceLifecycle()`, after `initDal()`).

## Quick start

Create `.env` with ONLY the DB pair:

```bash
# .env — the ONLY two keys allowed in this file
DATABASE_URL=postgres://primebrick:primebrick_dev@127.0.0.1:5432/primebrick
DATABASE_SCHEMA=public
```

The defaults work out-of-the-box with the dockerized infrastructure from
`infra/docker-compose.postgres.yml`.

## Required variables (.env — closed set)

| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | **(none — required)** |
| `DATABASE_SCHEMA` | Declared schema domain — CSV or JSON array (`public,system,custom`). `[0]` is the owned schema | **(none — required)** |

The backend crashes on startup if either is missing or empty, or if the
`.env` file contains any other key.

## Platform variables (process env — never `.env`, never config)

| Variable | Description | Impacts |
|----------|-------------|---------|
| `NODE_ENV` | `production` via Docker `ENV` or process launch — never `.env`, never hardcoded, never `config_entries` | `production` → session cookies carry `Secure` (HTTPS-only); `allowedTrust` accepts only `release`-signed DB patches. Any other value (or unset) → plain-HTTP dev cookies + `dev`/`release` patch trust. |

The HTTP port is fixed (`3001`, `src/index.ts`) — not configurable.
`nats_url`, `frontend_url`, OIDC/Casdoor values and every other runtime
setting live in `config_entries` (table below). UUID columns always
default to `gen_random_uuid()` (pgcrypto — hardcoded in the DAL entity
decorator, no override).

## Debug toggles (process env only — dev/test only, never `.env`)

`PB_<MODULE>_FORCE_EMPTY` / `PB_<MODULE>_FORCE_ERROR` (`1` to enable) —
consumed by `DomainService.list` to force empty/error states for E2E.
`E2E_ADMIN_USERNAME` / `E2E_ADMIN_PASSWORD` carry the ephemeral test
actor minted per run by the FE E2E harness. Do not enable in production.

(Demo customers seed via `pnpm run db:seed:customers`, not an env flag.)

## Database-stored configuration

Most auth-related configuration is stored in the `config_entries`
table, not in environment variables. This allows runtime updates via the
admin API without restarting the server.

Each config row has a `type` column that drives typed coercion at the
API boundary and Frontend widget selection. The type vocabulary includes
`string`, `text`, `boolean`, `bigint`, `number`, `money`, `badge`, `list`,
`url`, `secret`, `json`, `date`, `datetime`, and `time`. See the
[Config tables](/sdk/dev/config-tables) guide for the full vocabulary.

Config rows marked `reserved = true` cannot have their `type` or
`type_config` changed — only the `value` is editable. This protects
built-in settings from being re-typed in a way that would break the
system. Non-reserved rows allow `value`, `type`, and `type_config` updates.

| Config key | Description |
|------------|-------------|
| `auth_mode` | `STANDALONE` (API verifies JWTs via OIDC) or `GATEWAY` (trusted proxy injects identity headers) — validated at boot |
| `auth_roles_path` | Dotted JWT path used to extract roles from any token shape (e.g. `roles`, `realm_access.roles`) — mandatory in all modes |
| `idp_type`, `idp_endpoint`, `idp_organization`, `idp_client_id`, `idp_client_secret` | Casdoor™ IDP connection (login, org sync, user resolution) |
| `redis_url` | Redis connection URL for caching and sessions |
| `oidc_issuer_url` | OIDC issuer URL (defaults to Casdoor endpoint) |
| `oidc_client_id` | OIDC client ID |
| `oidc_client_secret` | OIDC client secret |
| `oidc_audience` | Optional OIDC audience check (`STANDALONE` mode) |
| `service_client_shield_secret` | Shared secret protecting service-to-service calls (client shield); value auto-rotated by the secret engine — this row is only the static fallback |
| `service_client_shield_secret_header`, `gateway_public_secret`, `gateway_public_secret_header`, `gateway_header_*` | GATEWAY mode: shield secrets + the user-identity header names injected by the trusted proxy — all mandatory when `auth_mode=GATEWAY` |
| `enable_mfa`, `enable_webauthn`, `passkey_required`, `enable_email_verification_check` | Auth feature flags (type: `boolean`) |
| `password_policy` | Password policy rules for self-service changes |
| `invitation_expiry_days` | Invitation expiry in days (type: `bigint`) |
| `mfa_challenge_token_ttl_seconds` | MFA token TTL in seconds (type: `bigint`) |
| `mfa_challenge_signing_secret` | HMAC secret signing MFA challenges — auto-generated if empty |

To update a config value, use the admin API:

```bash
curl -X PUT http://localhost:3001/api/v1/entities/config_entry/<uuid> \
  -H "Authorization: Bearer <admin-jwt>" \
  -H "Content-Type: application/json" \
  -d '{"value": "redis://redis:6379", "version": 1}'
```

## Local development setup

<Mermaid chart={`flowchart LR
  ENV[".env file"] -->|loaded by dotenv| BE[Backend API]
  DB["config_entries table"] -->|read at runtime| BE
  BE -->|connects| PG[(PostgreSQL)]
  BE -->|connects| NATS[NATS]
  BE -->|health check| CASDOOR[Casdoor IDP]
`} />

### Prerequisites

1. **Docker Desktop** running
2. **PostgreSQL + pgvector** — start with:
   ```bash
   docker compose -f infra/docker-compose.postgres.yml up -d
   ```
3. **Casdoor** — included in the same compose file, starts automatically

### Steps

1. Create `.env` with ONLY the DB pair (see "The env contract" above):
   ```bash
   DATABASE_URL=postgres://primebrick:primebrick_dev@127.0.0.1:5432/primebrick
   DATABASE_SCHEMA=public
   ```

2. Apply database migrations:
   ```bash
   pnpm run db:migrate
   ```

3. Set up Casdoor (creates the builtin client, admin user, and roles):
   ```bash
   pnpm run setup:casdoor
   ```

4. Start the dev server:
   ```bash
   pnpm run dev
   ```

5. Verify health:
   ```bash
   curl http://localhost:3001/api/v1/health
   ```

## Production checklist

- [ ] `NODE_ENV=production` via Docker `ENV` (image) or process launch — never `.env`
- [ ] Set `DATABASE_URL` + `DATABASE_SCHEMA` in `.env` (the only keys allowed there)
- [ ] Update `config_entries` with production values: `nats_url`, `idp_*`/`oidc_*` (production Casdoor), `frontend_url`, Redis, and gateway settings
- [ ] Remove or disable all debug toggles
