Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ API_HOST=

# ── Database ─────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://user:password@localhost:5432/dorisio
POSTGRES_PASSWORD=replace-with-a-local-development-password
# Comma-separated read-only PostgreSQL URLs; reads opt in per query.
DATABASE_READ_REPLICA_URLS=
DB_REPLICA_ENABLED=false
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ DATABASE_URL={{ DATABASE_URL }}
JWT_SECRET={{ JWT_SECRET }}
```

Production Kubernetes synchronizes AWS Secrets Manager versions through
External Secrets Operator; rotation, IAM scope, CloudTrail auditing, and
rollout steps are documented in [the secret-management runbook](docs/runbooks/secrets.md).

**Feature flags** toggle features per environment with `FEATURE_*` variables
and are read in code via `isFeatureEnabled('<name>')` from `src/config`:

Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ services:
container_name: dorisio-postgres
environment:
POSTGRES_USER: dorisio
POSTGRES_PASSWORD: dorisio_dev_password
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your untracked .env file}
POSTGRES_DB: dorisio
ports:
- '5432:5432'
Expand Down Expand Up @@ -38,11 +38,11 @@ services:
container_name: dorisio-backend
environment:
NODE_ENV: development
DATABASE_URL: postgresql://dorisio:dorisio_dev_password@postgres:5432/dorisio
DATABASE_URL: postgresql://dorisio:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your untracked .env file}@postgres:5432/dorisio
REDIS_URL: redis://redis:6379
PORT: 3000
LOG_LEVEL: debug
JWT_SECRET: dev-secret-key-change-in-production
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in your untracked .env file}
STELLAR_NETWORK: testnet
STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org
STELLAR_SERVER_SECRET_KEY: ${STELLAR_SERVER_SECRET_KEY}
Expand Down
5 changes: 3 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ JWT_SECRET={{ JWT_SECRET }} # resolved from the process environment
DATABASE_URL={{ DATABASE_URL }} # or an injected secrets provider
```

- Resolution order: process environment → optional `secretsProvider` map
(integration point for Vault / AWS Secrets Manager / SOPS).
- Resolution order: process environment → optional `secretsProvider` map.
Production Kubernetes uses External Secrets Operator with AWS Secrets
Manager; see the [secret rotation runbook](runbooks/secrets.md).
- An unresolved reference is a **startup failure**, never a literal
`{{ ... }}` string reaching the application.
- Secret values are redacted as `[REDACTED]` in the config audit log and in
Expand Down
63 changes: 63 additions & 0 deletions docs/runbooks/secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Secret management and rotation

Production Kubernetes secrets are synchronized from AWS Secrets Manager by
External Secrets Operator (ESO). The checked-in
[`ExternalSecret`](../../infra/kubernetes/external-secret.aws.yaml) maps the
JSON properties in `dorisio/production/backend` to the existing
`dorisio-backend-secrets` Kubernetes Secret consumed by the Deployment. The
[`ClusterSecretStore`](../../infra/kubernetes/secret-store.aws.yaml) uses
AWS web-identity credentials; no AWS access key or secret value belongs in
this repository.

## One-time platform setup

1. Install ESO and configure its `external-secrets` service account with an
IRSA/web-identity role. Restrict `secretsmanager:GetSecretValue` and
`secretsmanager:DescribeSecret` to the `dorisio/production/backend-*`
secret ARN; grant `kms:Decrypt` only for the customer-managed key used by
that secret, if applicable.
2. Create the JSON secret `dorisio/production/backend` in AWS Secrets Manager
with `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, and the optional Stellar
and Stripe keys used by the deployment.
3. Enable CloudTrail management/data-event auditing for Secrets Manager,
retain the events in the organization’s protected audit account, and alert
on denied reads, unexpected principals, and secret-version changes.
4. Apply the Kubernetes manifests and verify
`kubectl -n dorisio get externalsecret dorisio-backend-secrets` reports
`Ready=True`. Confirm the generated Secret contains the expected key names
without printing or decoding any values.

The sample store currently uses `us-east-1`; set the region and remote secret
path for each environment before applying. Use a separate secret and IAM role
per environment. The legacy `secret.example.yaml` is a local/manual template
only and is deliberately not included in the Kustomize production resources.

## Rotation procedure

1. Create a new AWS Secrets Manager version. For database/Redis credentials,
provision the new credential while the old credential remains valid.
2. Move the staging secret to the new version and verify ESO synchronization,
readiness, database/cache connectivity, and error-rate/latency dashboards.
3. Promote the new version in production. ESO polls hourly; force a sync with
`kubectl annotate es dorisio-backend-secrets -n dorisio force-sync=$(date +%s) --overwrite`
when the change must be picked up immediately.
4. Restart the backend rollout after synchronization. The application pins
connection credentials and JWT configuration at process startup; a restart
is required for those values to take effect.
5. Verify the rollout and authentication/payment health before revoking the
old credential. Keep the previous secret version available for rollback
until the observation window closes.

JWT key rotation invalidates tokens signed only by the old key. Coordinate
the rollout with clients or use the application’s supported token lifetime
and re-authentication policy. Record the change ticket, secret version IDs
(never values), approver, rollout revision, and rollback decision. ESO and
CloudTrail logs must not include secret payloads; application configuration
audit output redacts keys classified as secrets.

## Incident response

If a secret may have leaked, create a replacement version immediately, verify
that ESO synced it, roll the workload, revoke the exposed credential, and
review CloudTrail access events. Follow [incident response](../INCIDENT_RESPONSE.md)
and do not paste secret values into tickets, logs, or chat.
41 changes: 41 additions & 0 deletions infra/kubernetes/external-secret.aws.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: dorisio-backend-secrets
namespace: dorisio
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: dorisio-aws-secrets
target:
name: dorisio-backend-secrets
creationPolicy: Owner
data:
- secretKey: DATABASE_URL
remoteRef:
key: dorisio/production/backend
property: DATABASE_URL
- secretKey: REDIS_URL
remoteRef:
key: dorisio/production/backend
property: REDIS_URL
- secretKey: JWT_SECRET
remoteRef:
key: dorisio/production/backend
property: JWT_SECRET
- secretKey: STELLAR_SERVER_SECRET_KEY
remoteRef:
key: dorisio/production/backend
property: STELLAR_SERVER_SECRET_KEY
optional: true
- secretKey: STRIPE_SECRET_KEY
remoteRef:
key: dorisio/production/backend
property: STRIPE_SECRET_KEY
optional: true
- secretKey: STRIPE_WEBHOOK_SECRET
remoteRef:
key: dorisio/production/backend
property: STRIPE_WEBHOOK_SECRET
optional: true
3 changes: 2 additions & 1 deletion infra/kubernetes/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ namespace: dorisio
resources:
- namespace.yaml
- configmap.yaml
- secret.example.yaml
- secret-store.aws.yaml
- external-secret.aws.yaml
- service.yaml
- rollout.yaml
- hpa.yaml
Expand Down
14 changes: 14 additions & 0 deletions infra/kubernetes/secret-store.aws.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: dorisio-aws-secrets
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: external-secrets
namespace: external-secrets
192 changes: 7 additions & 185 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,196 +1,18 @@
/**
* Backwards-compatible configuration entry point.
* Back-compat shim for the pre-centralized configuration module (issue #60).
*
* The canonical schema and layered loader live in `schema.ts` and `loader.ts`.
* Older modules import from `config/env`, so this module intentionally re-exports
* the same validated snapshot and the small compatibility helper used by the
* security plugin.
* Keep existing `import { config } from '../config/env'` call sites working,
* but route all validation, secret resolution, and auditing through the single
* validated loader. Do not duplicate or bypass its schema here.
*/
import dotenv from 'dotenv';
import { z } from 'zod';
import { getConfig } from './loader';

dotenv.config();

const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).default('3000'),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
// Max time (ms) to wait for in-flight requests to drain and resources to
// close on SIGTERM/SIGINT before forcing exit (#23).
SHUTDOWN_TIMEOUT_MS: z.string().transform(Number).default('30000'),
DATABASE_URL: z.string().optional(),
DB_POOL_MIN: z.string().transform(Number).default('2'),
DB_POOL_MAX: z.string().transform(Number).default('20'),
DB_CONNECTION_TIMEOUT_MS: z.string().transform(Number).default('5000'),
DB_IDLE_TIMEOUT_MS: z.string().transform(Number).default('30000'),
DB_MAX_LIFETIME_MS: z.string().transform(Number).default('1800000'),
DB_STATEMENT_TIMEOUT_MS: z.string().transform(Number).default('10000'),
DB_SLOW_QUERY_THRESHOLD_MS: z.string().transform(Number).default('200'),
DB_LOG_QUERIES: z.string().transform((val) => val === 'true').default('false'),
// Read-query result cache (1-5 minutes, per issue #12)
DB_QUERY_CACHE_TTL_MS: z.string().transform(Number).default('60000'),
DB_QUERY_CACHE_MAX_TTL_MS: z.string().transform(Number).default('300000'),
DB_QUERY_CACHE_MAX_ENTRIES: z.string().transform(Number).default('1000'),
DB_QUERY_CACHE_ENABLED: z.string().transform((val) => val !== 'false').default('true'),
DB_LEAK_DETECTION_TIMEOUT_MS: z.string().transform(Number).default('30000'),
DB_CIRCUIT_BREAKER_FAILURES: z.string().transform(Number).default('5'),
DB_CIRCUIT_BREAKER_RESET_MS: z.string().transform(Number).default('10000'),
// External services circuit breaker (Stellar Horizon, webhooks, etc.)
CIRCUIT_BREAKER_FAILURE_THRESHOLD: z.string().transform(Number).default('0.5'),
CIRCUIT_BREAKER_SUCCESS_THRESHOLD: z.string().transform(Number).default('2'),
CIRCUIT_BREAKER_TIMEOUT_MS: z.string().transform(Number).default('60000'),
CIRCUIT_BREAKER_RESET_TIMEOUT_MS: z.string().transform(Number).default('30000'),
CIRCUIT_BREAKER_MIN_REQUESTS: z.string().transform(Number).default('5'),
CIRCUIT_BREAKER_ROLLING_WINDOW_MS: z.string().transform(Number).default('60000'),
CIRCUIT_BREAKER_VOLUME_THRESHOLD: z.string().transform(Number).default('5'),
STELLAR_CIRCUIT_BREAKER_ENABLED: z
.string()
.transform((val) => val === 'true')
.default('true'),
WEBHOOK_CIRCUIT_BREAKER_ENABLED: z
.string()
.transform((val) => val === 'true')
.default('true'),
REDIS_URL: z.string().default('redis://localhost:6379'),
REDIS_HOST: z.string().optional(),
REDIS_PORT: z.string().transform(Number).optional(),
REDIS_POOL_MIN: z.string().transform(Number).default('5'),
REDIS_POOL_MAX: z.string().transform(Number).default('20'),
REDIS_POOL_IDLE_TIMEOUT_MS: z.string().transform(Number).default(String(5 * 60 * 1000)),
REDIS_CONNECTION_TIMEOUT_MS: z.string().transform(Number).default(String(30 * 1000)),
REDIS_HEALTHCHECK_INTERVAL_MS: z.string().transform(Number).default(String(60 * 1000)),
CACHE_FALLBACK_MEMORY_SIZE: z.string().transform(Number).default('1000'),
// Background job processing
JOBS_WORKERS_ENABLED: z
.string()
.transform((val) => val === 'true')
.default('false'),
JOBS_CONCURRENCY: z.string().transform(Number).default('5'),
JOB_DEFAULT_ATTEMPTS: z.string().transform(Number).default('3'),
JOB_BACKOFF_MS: z.string().transform(Number).default('1000'),
JWT_SECRET: z.string().default('your-secret-key-change-in-production'),
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
// Error tracking (Sentry compatible)
SENTRY_DSN: z.string().optional(),
ERROR_TRACKING_ENABLED: z
.string()
.transform((val) => val !== 'false')
.default('true'),
ERROR_TRACKING_SAMPLE_RATE: z
.string()
.transform(Number)
.refine((val) => !Number.isNaN(val) && val >= 0 && val <= 1, {
message: 'ERROR_TRACKING_SAMPLE_RATE must be between 0 and 1',
})
.default('1'),
ERROR_TRACKING_TIMEOUT_MS: z.string().transform(Number).default('5000'),
STELLAR_NETWORK: z.enum(['testnet', 'mainnet', 'standalone']).default('testnet'),
STELLAR_HORIZON_URL: z.string().default('https://horizon-testnet.stellar.org'),
STELLAR_HORIZON_TIMEOUT_MS: z.string().transform(Number).default('60000'),
STELLAR_SERVER_SECRET_KEY: z.string().optional(),
USDC_CONTRACT_ID: z.string().optional(),
USDC_ISSUER: z.string().optional(),
// External payment processing
PAYMENTS_PROVIDER: z.enum(['stripe', 'none']).default('none'),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_API_BASE: z.string().default('https://api.stripe.com'),
PAYMENTS_WEBHOOK_TOLERANCE_SECONDS: z.string().transform(Number).default('300'),
WALLET_NONCE_EXPIRY: z.string().transform(Number).default('600'),
MIN_PAYOUT_AMOUNT: z.string().transform(Number).default('50'),
FRONTEND_URL: z.string().default('http://localhost:3000'),
SENDGRID_API_KEY: z.string().optional(),
EMAIL_FROM: z.string().email().optional(),
// Reverse proxy trust (see docs/RATE_LIMITING.md). Controls how request.ip
// is derived from X-Forwarded-For. `false` (default) ignores forwarded
// headers; a number trusts that many proxy hops; otherwise a comma
// separated list of trusted proxy IPs/CIDRs.
TRUST_PROXY: z.string().default('false'),
// API rate limiting (see src/config/rate-limit.ts for policies)
RATE_LIMIT_ENABLED: z
.string()
.transform((val) => val !== 'false')
.default('true'),
RATE_LIMIT_STORE: z.enum(['memory', 'redis']).default('memory'),
RATE_LIMIT_PUBLIC_MAX: z.string().transform(Number).default('100'),
RATE_LIMIT_PUBLIC_WINDOW_MS: z.string().transform(Number).default('60000'),
RATE_LIMIT_AUTHENTICATED_MAX: z.string().transform(Number).default('300'),
RATE_LIMIT_AUTHENTICATED_WINDOW_MS: z.string().transform(Number).default('60000'),
RATE_LIMIT_SENSITIVE_MAX: z.string().transform(Number).default('10'),
RATE_LIMIT_SENSITIVE_WINDOW_MS: z.string().transform(Number).default('60000'),
// Tip media uploads (#64). `MEDIA_STORAGE=local` keeps files on disk and
// `MEDIA_SCANNER=eicar` is the development scanner; production is expected to
// run `MEDIA_STORAGE=s3` with a clamd instance behind CLAMAV_HOST.
MEDIA_STORAGE: z.enum(['s3', 'local']).default('local'),
MEDIA_LOCAL_ROOT: z.string().default('var/media'),
MEDIA_STORAGE_TIMEOUT_MS: z.string().transform(Number).default('15000'),
MEDIA_S3_BUCKET: z.string().optional(),
MEDIA_S3_REGION: z.string().default('us-east-1'),
MEDIA_S3_ACCESS_KEY_ID: z.string().optional(),
MEDIA_S3_SECRET_ACCESS_KEY: z.string().optional(),
MEDIA_S3_SESSION_TOKEN: z.string().optional(),
MEDIA_S3_ENDPOINT: z.string().optional(),
MEDIA_S3_FORCE_PATH_STYLE: z
.string()
.transform((val) => val === 'true')
.default('false'),
MEDIA_CDN_BASE_URL: z.string().optional(),
// direct = presigned upload straight to storage; proxy = bytes through the API.
MEDIA_UPLOAD_MODE: z.enum(['direct', 'proxy']).default('direct'),
MEDIA_UPLOAD_TTL_SECONDS: z.string().transform(Number).default(String(15 * 60)),
MEDIA_MAX_IMAGE_BYTES: z.string().transform(Number).default(String(8 * 1024 * 1024)),
MEDIA_MAX_VIDEO_BYTES: z.string().transform(Number).default(String(64 * 1024 * 1024)),
MEDIA_DEFAULT_QUOTA_BYTES: z.string().transform(Number).default(String(256 * 1024 * 1024)),
MEDIA_SCANNER: z
.enum(['clamav', 'eicar', 'none'])
.default(process.env.NODE_ENV === 'production' ? 'clamav' : 'eicar'),
CLAMAV_HOST: z.string().optional(),
CLAMAV_PORT: z.string().transform(Number).default('3310'),
CLAMAV_TIMEOUT_MS: z.string().transform(Number).default('15000'),
// sharp is optional; ffmpeg/ffprobe are probed at runtime.
MEDIA_PROCESSOR: z.enum(['auto', 'sharp', 'ffmpeg', 'none']).default('auto'),
MEDIA_TRANSCODE_VIDEO: z
.string()
.transform((val) => val === 'true')
.default('false'),
MEDIA_FFMPEG_PATH: z.string().default('ffmpeg'),
MEDIA_FFPROBE_PATH: z.string().default('ffprobe'),
});

type Environment = z.infer<typeof EnvSchema>;

const validateEnv = (): Environment => {
const env = EnvSchema.safeParse(process.env);

if (!env.success) {
console.error('Invalid environment variables:', env.error.format());
process.exit(1);
}

return env.data;
};

export const config = validateEnv();

/**
* Convert the comma-separated CORS allowlist into the shape Fastify expects.
* An omitted or wildcard value intentionally permits any origin.
*/
export function getCorsOrigins(): true | string[] {
const raw = config.CORS_ORIGINS?.trim();
if (!raw || raw === '*') return true;

const origins = raw
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
export const config = getConfig();

/** Resolve CORS origins from the centralized configuration. */
export function getCorsOrigins(): string[] | true {
const raw = config.CORS_ORIGINS;
if (!raw) return config.NODE_ENV === 'development' || config.NODE_ENV === 'test' ? true : [];
const raw = config.CORS_ORIGINS?.trim();
if (!raw || raw === '*') return true;
const origins = raw.split(',').map((origin) => origin.trim()).filter(Boolean);
return origins.length > 0 ? origins : true;
}