Skip to main content

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.
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.

Two Tiers of Config

The registry (packages/shared/src/platform-config/registry.ts) classifies every key into one of two tiers: 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.
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): 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

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.