Skip to main content

Config Architecture

This document defines how configuration is modeled using the existing ports/adapters architecture.

Goal

Keep config cleanly separated into three layers:

  • EnvironmentConfig (process/runtime)
  • TenantConfig (database-driven, per tenant)
  • RequestConfig (resolved per request)

Use ports for app-facing capabilities and adapters for concrete IO.

Port vs Adapter

Follow the same rule as the rest of the repo:

  • Ports describe what the app needs.
  • Adapters describe how it is done.

For config:

  • config.port exposes config contracts and orchestration entrypoints.
  • config.adapter implements environment/dotenv loading and parsing behavior.

Config Layers

1) EnvironmentConfig

Process-level config loaded at startup.

Examples:

  • NODE_ENV, IS_PRODUCTION, PORT
  • MONGO_URL, AUTH_COOKIE_SECRET
  • VALKEY_URL (or REDIS_URL fallback, or REDIS_HOST + REDIS_PORT)
  • auth defaults and admin entrypoint values

Current convention: environment config keys are constant-style uppercase. Examples include:

  • LOCAL_AUTH_ENABLED, LOCAL_AUTH_DEFAULT_USER, LOCAL_AUTH_DEFAULT_PASSWORD
  • DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
  • DISCORD_INTERACTIONS_PUBLIC_KEY
  • DISCORD_BOT_TOKEN (optional; required for /notify DM/channel delivery)
  • ADMIN_AUTH_USE_LOCAL, ADMIN_AUTH_ENTRYPOINT
  • ADMIN_ROOT_PATH, API_BASE_PATH
  • DD_API_KEY, DD_AGENT_HOST, DD_SITE, DD_SERVICE, DD_ENV, DD_ENABLED (Datadog observability)
  • GOOGLE_ADDRESS_VALIDATION_API_KEY (optional; required only when the Google address validation adapter is active — used by the Ordering feature for shipping address verification)
  • EASYPOST_API_KEY (optional; when set, binds the EasyPost shipping adapter to ShippingPartnerPort. Enables /buy-label, the AdminJS "Buy label" action, and the tracker.updated webhook consumer. Missing key leaves the port unbound — the manual /ship mark-shipped path still works.)
  • EASYPOST_WEBHOOK_SECRET (optional; HMAC-SHA256 signing secret used by /api/easypost/webhook to verify tracker update callbacks. Required when EasyPost webhooks are configured.)
  • STRIPE_SECRET_KEY (optional; sk_live_* / sk_test_* Stripe API key. When set, the Stripe adapter is bound to PlatformBillingPort and powers tenant billing (customers, subscriptions, invoices, card + ACH payment methods). Missing key leaves the port unbound and disables platform billing.)
  • STRIPE_WEBHOOK_SECRET (optional; whsec_* signing secret used by /api/stripe/webhook to verify the Stripe-Signature header on incoming events. Required when Stripe is enabled in production — missing secret causes the endpoint to refuse every request with 500.)
  • STRIPE_PRICE_ID_MONTHLY_FLAT (optional; Stripe price_* id for the monthly_flat platform plan. Configured in the Stripe dashboard. Consumed by the platform-plan service when creating monthly subscriptions.)
  • STRIPE_PRICE_ID_ANNUAL_PREPAY (optional; Stripe price_* id for the annual_prepay platform plan. Configured in the Stripe dashboard. Consumed by the platform-plan service when creating annual subscriptions.)
  • RATE_LIMIT_STRIPE_PER_MIN (optional; integer — maximum Stripe webhook deliveries per minute per source IP. Default: 60.)
  • SENTRY_DSN (optional; Sentry Data Source Name. When set, the Sentry adapter is initialised on boot and production errors are captured. Already present in render.yaml as a dashboard secret. When absent in production a warning is logged; the app boots normally.)
  • SENTRY_TRACES_SAMPLE_RATE (optional; fraction 0–1 of requests sampled for Sentry performance monitoring. Default: 0.1 in production, 0 in dev/test. Committed to render.yaml as value: '0.1'; override per-environment in the Render dashboard. Per-route overrides (Discord interactions at 100%, admin at 20%, health/info at 1%) are applied via tracesSampler in the adapter regardless of this setting.)
  • RATE_LIMIT_DISCORD_PER_MIN (optional; integer — maximum Discord interaction requests per minute per (IP + user-id). Default: 30.)
  • RATE_LIMIT_EASYPOST_PER_MIN (optional; integer — maximum EasyPost webhook deliveries per minute per source IP. Default: 60.)
  • RATE_LIMIT_TENANT_PER_MIN (optional; integer — maximum requests per minute for the tenant-level global limiter. Default: 1000.)
  • RATE_LIMIT_DISABLED (optional; true disables all rate limiters (no cache writes, no 429 responses). Intended for test environments. Default: false.)
  • AUDIT_LOG_RETENTION_DAYS (optional; number of days before audit log entries are deleted by MongoDB TTL. Default: 365. The AuditLog collection's TTL index is set to AUDIT_LOG_RETENTION_DAYS * 86400 seconds at startup. Change requires re-running db.AuditLog.createIndex or dropping and recreating the index — see docs/runbooks/retention.md.)
  • WEBHOOK_EVENT_RETENTION_DAYS (optional; number of days before webhook event records are deleted by MongoDB TTL. Default: 90. The WebhookEvent collection's TTL index is set to WEBHOOK_EVENT_RETENTION_DAYS * 86400 seconds on receivedAt. See docs/runbooks/retention.md for change procedure.)
  • JOBS_ENABLED (optional; true/false. Controls whether background interval jobs start on boot. Jobs started when enabled: stale session cleanup (1h), EasyPost tracker poll (6h, only when EASYPOST_API_KEY is set), platform-billing metering/invoicing (24h), and order-session active-sessions gauge (1m). Default: true in all environments except test, where it is always false to prevent background timers in CI.)
  • EMAIL_PROVIDER (optional; set to sendgrid to enable transactional email delivery via SendGrid. When absent or any other value, a no-op email port is used and all email sends are silently skipped.)
  • SENDGRID_API_KEY (optional; required when EMAIL_PROVIDER=sendgrid. SendGrid API key used to authenticate HTTP calls to the SendGrid v3 Mail Send endpoint.)
  • EMAIL_FROM (optional; required when EMAIL_PROVIDER=sendgrid. Verified sender email address that appears in the from field of all outgoing transactional emails, e.g. orders@yourstore.com.)
  • INVOICE_LINK_SIGNING_SECRET (optional; HMAC-SHA256 key used to sign and verify pay-by-invoice link tokens. When absent, POST /api/orders/:id/invoice-link returns 503 and GET /orders/invoice/:token returns 404. Generate with openssl rand -hex 32.)
  • APP_BASE_URL (optional; public base URL for the app, e.g. https://app.mystore.com. Used to build the url field in pay-by-invoice link responses. Defaults to http://localhost:3000.)

Source:

  • process.env
  • .env
  • .env.<environment>

Supported environment suffixes:

  • local
  • dev
  • test
  • prod

Notes:

  • loadEnvironmentConfig should map runtime env names consistently (for example development -> dev, production -> prod) if needed.
  • Startup should fail fast when required production values are missing.
  • In production, if a configured cache URL points to localhost and REDIS_HOST/REDIS_PORT are available, host/port is preferred to avoid boot-time connection failures to 127.0.0.1:6379.
  • Platform-injected env vars (for example Render dashboard/service vars) take precedence over dotenv file defaults when resolving cache settings.

2) TenantConfig

Tenant-scoped config loaded at runtime (usually from DB).

Examples:

  • per-tenant auth provider overrides
  • feature flags
  • tenant metadata

Source:

  • repository/service layer through ports (not directly in endpoints)

3) RequestConfig (builder)

Per-request resolved config built from request metadata:

  • tenant id (header/query/body/session resolution rules)
  • user id/request id
  • optional merge of tenant overrides onto environment defaults

Source:

  • request + auth session + invocation context

Proposed Contracts

Types belong in src/types.

  • EnvironmentConfig
  • TenantConfig
  • RequestConfigBuilder
  • ConfigPort (environment + tenant + request builder surface)

EnvironmentConfig should use uppercase key names (for example NODE_ENV, IS_PRODUCTION, MONGO_URL) to reinforce that values are configuration constants.

Port surface in src/ports/config.port.ts:

  • createEnvironmentConfig(...)
  • loadEnvironmentConfig(...)
  • createTenantConfig(...) (or resolver entrypoint)
  • createBuildRequestContext(...)

Adapter surface in src/adapters/config.adapter.ts:

  • read .env and .env.<environment>
  • parse/coerce values from env objects
  • validate required keys (especially for prod)

Responsibilities by Layer

  • src/types/*: config contracts only
  • src/adapters/config.adapter.ts: dotenv/env parsing implementation
  • src/ports/config.port.ts: app-facing config capability
  • src/bootstrap/*: choose environment profile and wire adapters into ports
  • src/data / src/endpoints: consume port outputs, no adapter imports

Bootstrap Flow

  1. Bootstrap decides target environment profile (local/dev/test/prod).
  2. Config adapter loads env sources and returns raw/parsed config input.
  3. Config port exposes typed EnvironmentConfig.
  4. Runtime wiring builds dependencies/app context from bootstrap.
  5. Request pipeline uses request builder to produce RequestConfig.
  6. Tenant config is resolved by tenant id and merged where required.

Guardrails

  • No third-party config libs in ports directly.
  • No adapter imports from app code.
  • No request-specific mutable state in startup/global config objects.
  • Keep environment parsing deterministic and testable by injecting env objects.

Testing Expectations

  • Provider tests:
    • default values
    • required value validation
    • request builder behavior
  • Adapter tests:
    • .env + .env.<environment> precedence
    • parsing/coercion edge cases
    • missing key failures in prod profile