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.portexposes config contracts and orchestration entrypoints.config.adapterimplements environment/dotenv loading and parsing behavior.
Config Layers
1) EnvironmentConfig
Process-level config loaded at startup.
Examples:
NODE_ENV,IS_PRODUCTION,PORTMONGO_URL,AUTH_COOKIE_SECRETVALKEY_URL(orREDIS_URLfallback, orREDIS_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_PASSWORDDISCORD_CLIENT_ID,DISCORD_CLIENT_SECRETDISCORD_INTERACTIONS_PUBLIC_KEYDISCORD_BOT_TOKEN(optional; required for/notifyDM/channel delivery)ADMIN_AUTH_USE_LOCAL,ADMIN_AUTH_ENTRYPOINTADMIN_ROOT_PATH,API_BASE_PATHDD_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 toShippingPartnerPort. Enables/buy-label, the AdminJS "Buy label" action, and thetracker.updatedwebhook consumer. Missing key leaves the port unbound — the manual/shipmark-shipped path still works.)EASYPOST_WEBHOOK_SECRET(optional; HMAC-SHA256 signing secret used by/api/easypost/webhookto 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 toPlatformBillingPortand 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/webhookto verify theStripe-Signatureheader 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; Stripeprice_*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; Stripeprice_*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 inrender.yamlas 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.1in production,0in dev/test. Committed torender.yamlasvalue: '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 viatracesSamplerin 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;truedisables 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. TheAuditLogcollection's TTL index is set toAUDIT_LOG_RETENTION_DAYS * 86400seconds at startup. Change requires re-runningdb.AuditLog.createIndexor dropping and recreating the index — seedocs/runbooks/retention.md.)WEBHOOK_EVENT_RETENTION_DAYS(optional; number of days before webhook event records are deleted by MongoDB TTL. Default:90. TheWebhookEventcollection's TTL index is set toWEBHOOK_EVENT_RETENTION_DAYS * 86400seconds onreceivedAt. Seedocs/runbooks/retention.mdfor 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 whenEASYPOST_API_KEYis set), platform-billing metering/invoicing (24h), and order-session active-sessions gauge (1m). Default:truein all environments excepttest, where it is alwaysfalseto prevent background timers in CI.)EMAIL_PROVIDER(optional; set tosendgridto 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 whenEMAIL_PROVIDER=sendgrid. SendGrid API key used to authenticate HTTP calls to the SendGrid v3 Mail Send endpoint.)EMAIL_FROM(optional; required whenEMAIL_PROVIDER=sendgrid. Verified sender email address that appears in thefromfield 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-linkreturns 503 andGET /orders/invoice/:tokenreturns 404. Generate withopenssl rand -hex 32.)APP_BASE_URL(optional; public base URL for the app, e.g.https://app.mystore.com. Used to build theurlfield in pay-by-invoice link responses. Defaults tohttp://localhost:3000.)
Source:
process.env.env.env.<environment>
Supported environment suffixes:
localdevtestprod
Notes:
loadEnvironmentConfigshould map runtime env names consistently (for exampledevelopment -> 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_PORTare available, host/port is preferred to avoid boot-time connection failures to127.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.
EnvironmentConfigTenantConfigRequestConfigBuilderConfigPort(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
.envand.env.<environment> - parse/coerce values from env objects
- validate required keys (especially for prod)
Responsibilities by Layer
src/types/*: config contracts onlysrc/adapters/config.adapter.ts: dotenv/env parsing implementationsrc/ports/config.port.ts: app-facing config capabilitysrc/bootstrap/*: choose environment profile and wire adapters into portssrc/data/src/endpoints: consume port outputs, no adapter imports
Bootstrap Flow
- Bootstrap decides target environment profile (
local/dev/test/prod). - Config adapter loads env sources and returns raw/parsed config input.
- Config port exposes typed
EnvironmentConfig. - Runtime wiring builds dependencies/app context from bootstrap.
- Request pipeline uses request builder to produce
RequestConfig. - 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