Operational primitives wired into the monolith so we can scale without a rewrite. Everything here is opt-in via env vars — unset = dev behaviour.
Thresholds for when to unblock deferred tech (K8s, Kafka, Citus, microservices,
Meilisearch) live in CLAUDE.md.
This doc covers what's already wired and how to flip it on.
Three layers, in order of the request lifecycle:
- Auth + ALS context —
JwtAuthGuard+RolesGuard+TenantContextMiddlewarepopulatetenantStorage(AsyncLocalStorage) with{ tenantId, userId, role }. NoTenantGuardclass exists; the three collaborators above do the job together. tenantExtensionPrisma extension — wired globally inPrismaService.onModuleInitvia$extends. Every tx opened throughrunWithTenant(tenantId, mode, fn)inherits the extension, which auto-injectstenantIdintowhere/datafor tenant-scoped models. The mutation rule is factored into pureapplyTenantScope()and unit-tested inprisma.service.spec.ts.- Postgres RLS —
runWithTenantissuesSET LOCAL app.tenant_id = '<id>'+SET LOCAL ROLE app_user(NOSUPERUSER, NOBYPASSRLS). Policies are defined per-table in migrations.
Services that bypass runWithTenant (pre-auth path only):
auth/auth, auth/totp, sso/sso. These run BEFORE the JWT exists so
runWithTenant can't supply a tenantId, and RLS under app_user role would
block the tenant/user lookup itself. Each of these services:
- passes
tenantIdexplicitly into every query'swhere - logs failed attempts to the audit log for cross-tenant probe detection
- is documented at the top of the file with a "Multi-tenancy note" block
ai/deal-ai, ai/embedding, ai/search, reports/reports were previously
in this list. They now all use runWithTenant and get Layer 1 + Layer 2 +
Layer 3 coverage.
PrismaService.runWithTenant takes an optional 'ro' | 'rw' mode. Reads
routed with 'ro' go to a separate PrismaClient built from
DATABASE_REPLICA_URL when set; writes always hit the primary.
// read — routed to replica when DATABASE_REPLICA_URL is set
await this.prisma.runWithTenant(tenantId, 'ro', (tx) =>
tx.deal.findMany({ where: { stageId } }),
);
// write — always primary (default mode = 'rw')
await this.prisma.runWithTenant(tenantId, (tx) =>
tx.deal.update({ where: { id }, data: { value } }),
);Enable:
DATABASE_REPLICA_URL=postgresql://readonly:<pw>@replica.host:5432/amass_crm?schema=public
The transaction enables SET LOCAL transaction_read_only = on when mode is
ro — accidental writes throw immediately instead of silently fan-out.
The pgbouncer service lives in infra/docker-compose.yml under the prod
profile. Start it with:
docker compose --profile prod up -d pgbouncer
Point the app at it by setting DATABASE_URL to the PgBouncer DSN (port 6432
by default) and appending the Prisma-specific flags:
DATABASE_URL=postgresql://postgres:<pw>@pgbouncer:5432/amass_crm?pgbouncer=true&statement_cache_size=0
Prisma migrations still need a direct connection — point DATABASE_DIRECT_URL
at the primary Postgres host for prisma migrate.
buildRedisConnection() (in src/infra/redis/redis-connection.ts) upgrades
the BullMQ + RedisService connections to a Sentinel pool when both of these
are set:
REDIS_SENTINEL_HOSTS=sentinel-a:26379,sentinel-b:26379,sentinel-c:26379
REDIS_SENTINEL_MASTER=mymaster
REDIS_SENTINEL_PASSWORD=<optional>
Unset = single-node REDIS_URL (dev behaviour). No code changes in call
sites.
TenantThrottlerGuard (registered globally in app.module.ts) keys
throttler counters by t:<tenantId>:u:<userId> for authed requests, by
t:<tenantId> for pre-auth tenant-scoped routes (slug lookups), and falls
back to IP for unauthenticated paths.
Consequence: one noisy tenant can't starve others, but co-located employees behind a single NAT don't share a counter.
Verified by src/common/guards/tenant-throttler.guard.spec.ts.
src/common/resilience/circuit-breaker.ts — tiny dep-free breaker,
getBreaker(name, opts?) returns a singleton per name.
Currently wrapped:
| Name | Wraps | Where |
|---|---|---|
twilio |
client.calls.create |
modules/calls/twilio.client.ts |
anthropic |
messages.create |
modules/ai/deal-ai.service.ts, modules/ai/enrichment.service.ts |
gemini |
generateContent, embedContent |
modules/ai/enrichment.service.ts, modules/ai/embedding.service.ts |
openai |
embeddings.create |
modules/ai/embedding.service.ts |
stripe |
customers.create, checkout.sessions.create, billingPortal.sessions.create |
modules/billing/billing.service.ts |
anaf |
fetch(...) on token/upload/status |
modules/anaf/anaf.service.ts |
siem |
fire-and-forget webhook | modules/audit/audit.service.ts |
Defaults: failureThreshold: 5, resetAfterMs: 30_000. Override per-call
at first getBreaker('name', { ... }). State surfaced in
GET /api/v1/health/detailed.
GET /api/v1/health/detailed:
checks.db—SELECT 1round-tripchecks.redis—PINGround-tripchecks.breakers— current state of each registered breakerstatus—up|degraded(any breaker open) |down(DB or Redis down → HTTP 503)
Liveness (/health) and readiness (/health/ready) unchanged.
AuditService.log() writes the row, then fires a non-blocking webhook to:
tenants.siemWebhookUrl(per-tenant override), elseSIEM_WEBHOOK_URL(env fallback), else- nothing.
Breaker: siem with 10 failures / 60s cooldown. Failures are swallowed
(logged + counted in breaker state) so the audit write always commits.
Retention: MaintenanceScheduler.handleAuditRetentionSweep runs daily at
03:30 UTC and deletes entries older than tenants.auditRetentionDays (per
tenant), falling back to AUDIT_RETENTION_DAYS_DEFAULT (env, default 2555 ≈
7 years).
tenants.shardId (0..1023) is backfilled via
substr(md5(id), 1, 8)::bit(32)::int & 1023. New tenants get a value
immediately. Not read by anything today — it's present so a future Citus /
Vitess migration doesn't need to rewrite every query with a shard-routing
WHERE clause. Hooking it up is gated on the sharding threshold in
CLAUDE.md.
Wired in D2-PR1 (sprint 18). Sidecar container db-backup (Alpine +
postgresql16-client + mc) runs in the production compose stack and takes
nightly logical dumps of Postgres into an S3-compatible bucket. Restore
runbook lives in docs/INCIDENT_RESPONSE.md#database-restore.
- When: every day at 02:00 Europe/Bucharest (
TZbaked into the image). - Driver: busybox-crond inside the
db-backupcontainer. - Job:
/usr/local/bin/backup-db.sh—pg_dump --format=custom --compress=9 --no-owner --no-acl, thenmc cpto S3, then retention sweep.
S3-compatible object storage — endpoint configurable per environment so we don't hard-tie to one provider. Tested targets: MinIO (self-hosted), Cloudflare R2, Backblaze B2, AWS S3.
Configuration (set in .env.production):
BACKUP_S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
BACKUP_S3_ACCESS_KEY=...
BACKUP_S3_SECRET_KEY=...
BACKUP_BUCKET=amass-backups # default: amass-backups
BACKUP_RETENTION_DAYS=30 # default: 30
Dumps are written to s3://${BACKUP_BUCKET}/db/<YYYY-MM-DD_HHMMSS>.dump
(timestamp is UTC).
- Default: 30 days, tuned via
BACKUP_RETENTION_DAYS. - Mechanism:
mc rm --recursive --force --older-than <N>druns after every successful upload. Independent of object-storage lifecycle policies (those are belt-and-suspenders if the bucket provider supports them).
Useful before risky migrations or right before a planned destroy:
docker compose \
-f infra/docker-compose.yml \
-f infra/docker-compose.prod.yml \
--env-file .env.production \
run --rm db-backup /usr/local/bin/backup-db.sh
The script exits 0 on success, non-zero on any failure.
docker compose \
-f infra/docker-compose.yml \
-f infra/docker-compose.prod.yml \
logs db-backup --tail 200 -f
Each run logs starting backup …, the dump size, the upload destination,
the retention sweep result, and a final backup complete … line. Absence
of those lines after 02:15 Europe/Bucharest = ALERT.
Wiring this into Prometheus / Sentry as an actual alert is in a separate PR (Agent B owns business metrics).
Production deploys target a single Ubuntu VPS running the full Docker
Compose stack (infra/docker-compose.yml + infra/docker-compose.prod.yml)
behind Caddy for TLS termination + reverse proxy. The first-time setup
is automated by scripts/bootstrap-vps.sh; subsequent rolling updates
use scripts/update-vps.sh.
Pre-deploy sanity check:
scripts/check-prod-env.shThis script validates the production .env against the prod-only checks
in apps/api/src/config/env.ts (rejects default minioadmin creds,
zero-byte ENCRYPTION_KEY, wildcard CORS, etc.) and reports the env vars
that still need to be set.