> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brainstormer.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Config SSOT

> Single source of truth for platform configuration: the platform_config table, the shared registry + accessor, encrypted secret values, org scoping, and the superadmin config API.

## Overview

Platform configuration -- API keys and provider credentials such as `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, `STRIPE_SECRET_KEY` -- lives in the `platform_config` Postgres table, **not** in per-service env files. Superadmins edit it from the admin UI at `/admin`; every backend service reads it through one shared accessor. There is one place to read a value and one place to write it.

<Note>
  Managed keys are read **DB-only at runtime, with no env fallback**. Env is consulted exactly once, by a one-time bootstrap seed (below), to copy existing values into the DB during cutover. After that, `.env` is ignored for managed keys.
</Note>

## Two Tiers of Config

The registry (`packages/shared/src/platform-config/registry.ts`) classifies every key into one of two tiers:

| Tier        | Read from                                                         | Examples                                                                                                                 |
| ----------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `bootstrap` | **env only** -- needed before the DB/accessor is usable           | `DATABASE_URL`, `JWT_SECRET`, `REDIS_HOST`, `RESEND_API_KEY`, `SENTRY_DSN`, `LANGSMITH_API_KEY`                          |
| `managed`   | **`platform_config` DB only** -- editable at runtime via `/admin` | `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, `PINECONE_API_KEY`, `ELEVENLABS_API_KEY`, `STRIPE_SECRET_KEY`, `CLOUD_STORAGE_*` |

Bootstrap keys stay in the environment because they are required to stand the service up. Managed keys live in the DB.

## The Registry (Migration 077)

`registry.ts` is the in-code catalog: each `PlatformKeyDef` declares `tier`, `category`, `importance` (`required` / `optional`), `isSecret`, `label`, `description`, an optional `capability` it gates, the `ownerServices` that read it, and an optional `liveness` probe.

Migration `077_config_ssot_catalog.sql` seeds a matching catalog row in `platform_config` for every managed key (idempotent `ON CONFLICT (key) DO NOTHING`), sets importance flags (`GEMINI_API_KEY` -> required, `OPENAI_API_KEY` -> optional), and removes dead keys. A CI contract test (`packages/shared/src/platform-config/__tests__/contract.test.ts`) keeps the registry and the migration seeds in parity; each seed is a single-line `INSERT`.

Derived sets are computed from the registry: `MANAGED_KEYS`, `REQUIRED_KEYS`, `CAPABILITIES`, and `keysForCapability()`.

## Org Scoping (Migration 078)

Migration `078_platform_config_org_scope.sql` adds a nullable `organization_id` column. A **NULL `organization_id` is the GLOBAL sentinel** -- the platform-wide default that serves every org unless that org has its own override row.

Resolution is **org -> global**: `get(key, { orgId })` prefers the row where `organization_id = orgId`, else the row where `organization_id IS NULL`.

Uniqueness is enforced by two partial unique indexes (Postgres treats NULLs as distinct in a plain UNIQUE):

* `platform_config_global_key_uniq` -- one global row per `key` where `organization_id IS NULL`
* `platform_config_org_key_uniq` -- one row per `(organization_id, key)` where org is set

The migration drops the single-column primary key on `key` so a key can repeat across orgs; existing global rows are preserved untouched.

## Encrypted Values

Secret values are stored in `platform_config.encrypted_value`, encrypted with **AES-256-GCM** via `encrypt()` / `decrypt()` in `packages/shared/src/crypto.ts`. The encryption key is the platform `JWT_SECRET`, run through `scrypt` to derive a 32-byte key; the stored format is `iv:authTag:ciphertext` (all hex).

On a decrypt failure the accessor logs an error with the key name and returns `null`.

## The Shared Accessor

`PlatformConfigService` (`packages/shared/src/platform-config/index.ts`) is the only runtime read path for managed keys. It is a process-wide singleton obtained via `getPlatformConfig({ pool, encryptionKey, redisSub })` and is wired into the bot, knowledge, realtime-audio, and auth services.

```typescript theme={null}
const config = getPlatformConfig({ pool, encryptionKey: JWT_SECRET, redisSub });

const key = await config.get("OPENROUTER_API_KEY"); // global
const orgKey = await config.get("STRIPE_SECRET_KEY", { orgId }); // org -> global
const present = await config.has("PINECONE_API_KEY"); // boolean
const live = await config.isCapabilityAvailable("voice_calls"); // all gating keys set
```

Behaviour:

* **DB-only.** Reads `SELECT key, encrypted_value, organization_id FROM platform_config ...` then `decrypt`. No `process.env` read, no `process.env` mutation, no env fallback.
* **TTL cache.** Values cache for 60s per `(orgId:key)`; `getMany()` batches misses.
* **Pub/sub invalidation.** Writes publish on the Redis channel `platform-config:invalidate`; every subscriber drops the affected cache entry so a key set in `/admin` takes effect across all instances within seconds, no restart.

### Capability Gating

A **capability** is available iff every registry key tagged with it is configured. `isCapabilityAvailable(cap, scope)` powers a typed `503 CAPABILITY_UNAVAILABLE` response (`capabilityUnavailableBody()`) for routes whose provider is not set up, and feeds the public `/capabilities` surface consumed by the frontend feature gate.

## The Config API (Auth Service)

The auth service owns config writes. The routes are under `/auth/admin/config`, all **superadmin-only**, gateway-proxied via the `/auth/*` catch-all (so the public path is `/api/auth/admin/config`):

| Route                               | Method | Purpose                                                               |
| ----------------------------------- | ------ | --------------------------------------------------------------------- |
| `/auth/admin/config`                | GET    | List all config entries (secrets masked)                              |
| `/auth/admin/config`                | POST   | Set a value (`{ key, value, orgId? }`) -- encrypted, then invalidated |
| `/auth/admin/config/:key`           | DELETE | Clear a value (`?orgId=` scopes to one org's override)                |
| `/auth/admin/config/health`         | GET    | Per-category health + bootstrap check + liveness verdicts             |
| `/auth/admin/config/setup-status`   | GET    | First-time platform setup status                                      |
| `/auth/admin/config/complete-setup` | POST   | Mark platform setup complete                                          |

A write (`POST`) encrypts the value, upserts the row (global, or per-org when `orgId` is supplied), publishes a Redis invalidation, drops the cached liveness verdict, and records an `admin_audit_log` entry (`action: 'config.update'`).

## Read & Write Flow

```mermaid theme={null}
graph LR
    UI["Admin UI /admin"] -->|POST /api/auth/admin/config| GW["Gateway"]
    GW -->|/auth/* proxy| AUTH["Auth service"]
    AUTH -->|encrypt + upsert| DB[("platform_config")]
    AUTH -->|publish| R[["Redis<br/>platform-config:invalidate"]]
    R -.invalidate.-> BOT["bot accessor"]
    R -.invalidate.-> KN["knowledge accessor"]
    R -.invalidate.-> RA["realtime-audio accessor"]
    BOT -->|get / decrypt| DB
    KN -->|get / decrypt| DB
    RA -->|get / decrypt| DB
```

## Cutover Bootstrap Seed

Because runtime is DB-only, the DB must hold every managed key before services resolve them. `seedFromEnv()` (`packages/shared/src/platform-config/seed.ts`) is an idempotent helper: for each global managed key whose `platform_config.encrypted_value IS NULL`, if `process.env[key]` is set, it encrypts and writes it. It **never overwrites** an existing DB value, so it is safe to run on every deploy.

## What Ships vs What Is Planned

**Shipped:**

* `platform_config` table with the `077` catalog and `078` org-scope column + indexes.
* The in-code `registry.ts` (managed + bootstrap tiers, capabilities, liveness defs) with a CI parity test.
* `PlatformConfigService` -- DB-only reads, TTL cache, Redis invalidation, `has()` / `getMany()` / `isCapabilityAvailable()`, org -> global resolution -- wired into bot, knowledge, realtime-audio, and auth.
* AES-256-GCM encryption of secret values via `crypto.ts`.
* The superadmin `/auth/admin/config` API (get / set / delete / health / setup-status) with audit logging.
* `seedFromEnv()` bootstrap helper.

**Planned / partial:**

* Some services still carry a **legacy `ConfigRepository`** (`services/auth/src/repositories/config.repository.ts`, `services/knowledge/src/services/config.repository.ts`) with an env-precedence `getConfigValue()`. These duplicates are slated for deletion once every consumer reads through the shared accessor; until then both paths coexist.
* The full cutover — **zero** managed-key `process.env` reads across all services, plus a lint rule that fails a managed read outside the accessor — is **complete** (2026-07-23). All straggler call sites in knowledge, bot, and auth services have been migrated to `platformConfig.get()`. See BSIO-216.
* Per-org overrides are **plumbed end to end** (column, indexes, accessor scope, `orgId` on the write/delete routes), but the org-scoped admin surface is a later phase.

Spec: `docs/superpowers/specs/2026-06-12-platform-config-ssot-design.md`.
