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:
- Read config in one place.
- Compute typed capability state once.
- Expose capability through one stable API boundary.
- Consume capability state from one shared frontend capability client.
- 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 }. configcontains static public setup only (app IDs, public keys), never secrets or session tokens.- Token/feature endpoints must gate on capability state and return clear
503errors 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
- Schema definition:
apiEnvSchemacovers every env var the API reads, with transforms for type coercion (string→boolean, string→number), empty/whitespace→undefined mapping, and defaults. - Startup validation:
validateEnv(apiEnvSchema)runs at import time via theconfigsingleton inapps/api/src/config/app-config.ts. Invalid env fails fast with clear error messages andprocess.exit(1). - Dependency injection path:
buildConfig(env)also accepts a pre-validatedApiEnvobject for DI and testing. When anApiEnvis 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. - Typed output: The validated result (
ApiEnv) is passed to sub-config builders (buildAuthConfig,buildDatabaseConfig, etc.) which receive typed slices viaPick<ApiEnv, ...>. Fields that are.optional()in the schema remain optional in thePick— noPartialneeded. Fields with.default()become required inApiEnv; usePartialonly when the builder must accept partial input (e.g. for testing with subset env). - Singleton:
export const config = buildConfig()provides the frozen, immutable config object that the rest of the app imports.
Adding a new env var
- Add the field to
apiEnvSchemainpackages/config/env/schemas.tsusing the appropriate helper (optionalString,envBoolean,positiveNumber, or a Zod primitive). - Add schema-level tests in
packages/config/env/index.test.tscovering valid, invalid, empty, and default cases. - Add the field to the relevant sub-config builder's
Pick<ApiEnv, ...>parameter.Pickpreserves optionality from the schema — only usePartialwhen the builder must accept partial input for fields that have schema defaults. - Add sub-config tests in the builder's test file using typed values (not raw strings).
- Rebuild:
pnpm --filter @toast/config build(the./envexport points todist/).
Rules
- Never read
process.envoutside config modules. - Never add hand-rolled
readString/readBooleanhelpers — use Zod transforms. - Empty and whitespace-only strings must be treated as absent (the
optionalStringhelper does this). - Required vars fail validation at startup; optional vars become
undefinedafter parsing.
Non-negotiable Rules
- No direct feature env reads outside config modules.
- No client-side capability checks using raw build env for runtime features.
- Partial config must evaluate to disabled.
- UI controls must hide/disable when capability is unavailable.
- Command execution must be optional-safe when an extension/provider is absent.
- Docs/CI/Docker must reflect build-time vs runtime boundaries consistently.
Change Checklist
When implementing or changing a config-driven capability boundary:
- Add/update typed server config derivation.
- Add/update shared capability API contract (
/api/capabilities). - Implement/update shared frontend capabilities client/cache.
- Gate context/provider/extension initialization from capability state.
- Gate UI and command execution paths.
- Update tests for full/partial/missing config permutations.
- Update docs and deployment contracts.
Testing Requirements
At minimum:
- Config tests: full, partial, empty, and whitespace config values.
- API tests: capabilities endpoint and gated token/action endpoints.
- Frontend tests: capabilities client fetch/cache/failure behavior.
- Runtime tests: disabled-mode readiness and no provider/extension crashes.
- UI tests: controls hidden/disabled when unavailable.
Run:
pnpm checkAnti-patterns
Do not:
- Gate runtime behavior with scattered
import.meta.envchecks. - Read
process.envin service/controller code for feature capability. - Treat
appId-only orsecret-only as enabled. - Assume extension commands are always available.
- 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.tsxapps/admin/src/components/tiptap-ui/comments-panel/comments-panel.tsxapps/admin/src/components/tiptap-ui/ai-ask-button/use-ai-ask.tsapps/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)
Related Docs
docs/decisions/010-config-and-capabilities.mddocs/decisions/004-service-repository-architecture.mddocs/patterns/testing.mddocs/patterns/docker.mddocs/railway-setup.md