Toast
ContributorPatterns

Config-Driven Capability Boundary

Config-Driven Capability Boundary

This guide defines a reusable architecture pattern for features that depend on runtime configuration.

Use this pattern when a feature should be enabled only if a complete config contract exists, and disabled safely otherwise.

Pattern Goal

Keep capability logic deterministic and centralized:

  1. Read config in one place.
  2. Compute typed capability state once.
  3. Expose capability through one stable API boundary.
  4. Consume capability state from one shared frontend capability client.
  5. Keep non-capable mode fully functional.

This avoids scattered env checks, partial-state bugs, and UI/command crashes.

Pattern Overview

Layer 1: Config Layer (server)

Server config reads env and derives enabled booleans from complete config requirements.

Rules:

  • Parse env in config modules only.
  • Keep typed config immutable.
  • Capability booleans must represent full readiness (not partial presence).

Layer 2: Capability API Layer (server)

Expose capability state from controllers/routes, not from ad hoc client env reads.

Rules:

  • Use a single endpoint: GET /api/capabilities.
  • Return a consistent shape per capability: { enabled, config }.
  • config contains static public setup only (app IDs, public keys), never secrets or session tokens.
  • Token/feature endpoints must gate on capability state and return clear 503 errors when disabled.

Layer 3: Shared Capability Client Layer (frontend)

Frontend fetches capability state once, caches, and exposes a typed in-memory view.

Rules:

  • Use one shared capabilities module per frontend app.
  • Support in-flight request deduplication.
  • Provide failure-safe fallback behavior and explicit refresh/retry behavior.

Layer 4: Runtime Boundary Layer (contexts/components)

Contexts/components decide whether to initialize providers, extensions, or commands based on capabilities.

Rules:

  • Do not initialize feature providers/extensions when capability is disabled.
  • Preserve core experience when disabled (readiness, local save flows, basic editing, etc.).
  • Guard command calls so missing extensions never throw.

Zod Validation Layer

All API environment variables are validated at startup through a single Zod schema (apiEnvSchema in packages/config/env/schemas.ts). This is the single source of truth for env var parsing.

How it works

  1. Schema definition: apiEnvSchema covers every env var the API reads, with transforms for type coercion (string→boolean, string→number), empty/whitespace→undefined mapping, and defaults.
  2. Startup validation: validateEnv(apiEnvSchema) runs at import time via the config singleton in apps/api/src/config/app-config.ts. Invalid env fails fast with clear error messages and process.exit(1).
  3. Dependency injection path: buildConfig(env) also accepts a pre-validated ApiEnv object for DI and testing. When an ApiEnv is injected, Zod validation is skipped because TypeScript already enforces the coerced shape. This is the path ADR-014 (modular dependency injection) will use once the singleton is eliminated.
  4. Typed output: The validated result (ApiEnv) is passed to sub-config builders (buildAuthConfig, buildDatabaseConfig, etc.) which receive typed slices via Pick<ApiEnv, ...>. Fields that are .optional() in the schema remain optional in the Pick — no Partial needed. Fields with .default() become required in ApiEnv; use Partial only when the builder must accept partial input (e.g. for testing with subset env).
  5. Singleton: export const config = buildConfig() provides the frozen, immutable config object that the rest of the app imports.

Adding a new env var

  1. Add the field to apiEnvSchema in packages/config/env/schemas.ts using the appropriate helper (optionalString, envBoolean, positiveNumber, or a Zod primitive).
  2. Add schema-level tests in packages/config/env/index.test.ts covering valid, invalid, empty, and default cases.
  3. Add the field to the relevant sub-config builder's Pick<ApiEnv, ...> parameter. Pick preserves optionality from the schema — only use Partial when the builder must accept partial input for fields that have schema defaults.
  4. Add sub-config tests in the builder's test file using typed values (not raw strings).
  5. Rebuild: pnpm --filter @toast/config build (the ./env export points to dist/).

Rules

  • Never read process.env outside config modules.
  • Never add hand-rolled readString/readBoolean helpers — use Zod transforms.
  • Empty and whitespace-only strings must be treated as absent (the optionalString helper does this).
  • Required vars fail validation at startup; optional vars become undefined after parsing.

Non-negotiable Rules

  1. No direct feature env reads outside config modules.
  2. No client-side capability checks using raw build env for runtime features.
  3. Partial config must evaluate to disabled.
  4. UI controls must hide/disable when capability is unavailable.
  5. Command execution must be optional-safe when an extension/provider is absent.
  6. Docs/CI/Docker must reflect build-time vs runtime boundaries consistently.

Change Checklist

When implementing or changing a config-driven capability boundary:

  1. Add/update typed server config derivation.
  2. Add/update shared capability API contract (/api/capabilities).
  3. Implement/update shared frontend capabilities client/cache.
  4. Gate context/provider/extension initialization from capability state.
  5. Gate UI and command execution paths.
  6. Update tests for full/partial/missing config permutations.
  7. Update docs and deployment contracts.

Testing Requirements

At minimum:

  1. Config tests: full, partial, empty, and whitespace config values.
  2. API tests: capabilities endpoint and gated token/action endpoints.
  3. Frontend tests: capabilities client fetch/cache/failure behavior.
  4. Runtime tests: disabled-mode readiness and no provider/extension crashes.
  5. UI tests: controls hidden/disabled when unavailable.

Run:

pnpm check

Anti-patterns

Do not:

  1. Gate runtime behavior with scattered import.meta.env checks.
  2. Read process.env in service/controller code for feature capability.
  3. Treat appId-only or secret-only as enabled.
  4. Assume extension commands are always available.
  5. Create per-domain status endpoints (/api/x/status) and duplicate frontend cache clients.

Concrete Example in Toast (TipTap)

This pattern is currently implemented for TipTap Collaboration/Comments/AI (issue #468):

  • Server config: apps/api/src/config/tiptap.ts
  • Capability API: apps/api/src/routes/capabilities/routes.ts
  • Frontend capability client: apps/admin/src/lib/capabilities.ts
  • Runtime boundaries:
    • apps/admin/src/stores/scoped/EditorSessionProvider.tsx (scoped session store + capability-gated runtime setup)
  • UI/command gating:
    • apps/admin/src/components/tiptap-ui/comment-button/comment-button.tsx
    • apps/admin/src/components/tiptap-ui/comments-panel/comments-panel.tsx
    • apps/admin/src/components/tiptap-ui/ai-ask-button/use-ai-ask.ts
    • apps/admin/src/components/tiptap-ui/improve-dropdown/use-improve-dropdown.ts

Build/runtime contract examples:

  • Build/install only: TIPTAP_PRO_TOKEN
  • Runtime capability inputs: VITE_TIPTAP_* + TIPTAP_*_SECRET (evaluated on API side)
  • docs/decisions/010-config-and-capabilities.md
  • docs/decisions/004-service-repository-architecture.md
  • docs/patterns/testing.md
  • docs/patterns/docker.md
  • docs/railway-setup.md

On this page