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
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
node_modules
dist
.env
.env.*
!.env.example
logs
postgres
coverage
*.log
.git
.github
docs
tests
scripts
README.md
readme.md
docker-compose.yml
42 changes: 42 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ── Stage 1: build ────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app

# Install dependencies (including devDependencies needed for tsc + prisma generate)
COPY package*.json ./
RUN npm ci

# Generate Prisma client (requires schema but not a live DB)
COPY prisma ./prisma
RUN npx prisma generate

# Compile TypeScript
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# Prune dev dependencies — only production deps go into the runtime image
RUN npm ci --omit=dev

# ── Stage 2: runtime ──────────────────────────────────────────────────────────
FROM node:20-alpine AS runtime

# Least-privilege user
RUN addgroup -S app && adduser -S app -G app

WORKDIR /app

# Copy compiled output, production node_modules, and Prisma artefacts
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma
COPY package.json ./

USER app

EXPOSE 3001

# Run migrations then start the server.
# In Kubernetes use an initContainer for the migrate step so rollout is atomic.
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]
122 changes: 121 additions & 1 deletion docs/PRODUCTION_DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,126 @@
# Production deployment, secrets, and migrations

This guide covers secret management, CI/CD injection, database migrations, health/readiness checks, and rollback for the NeuroWealth backend.
This guide covers container image build, secret management, CI/CD injection, database migrations, health/readiness checks, and rollback for the NeuroWealth backend.

## Container image (Dockerfile)

The repo ships a multi-stage `Dockerfile`:

| Stage | Base | Purpose |
|-------|------|---------|
| `builder` | `node:20-alpine` | `npm ci` → `prisma generate` → `tsc` |
| `runtime` | `node:20-alpine` | Slim image; only `dist/`, production `node_modules`, and Prisma artefacts |

### Build

```bash
docker build -t neurowealth-backend:latest .
```

Push to your registry:

```bash
docker tag neurowealth-backend:latest registry.example.com/neurowealth-backend:$(git rev-parse --short HEAD)
docker push registry.example.com/neurowealth-backend:$(git rev-parse --short HEAD)
```

### Environment variables (production minimum)

```bash
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@db-host:5432/neurowealth
JWT_SEED=<64 hex chars>
WALLET_ENCRYPTION_KEY=<64 hex chars>
STELLAR_NETWORK=mainnet
STELLAR_RPC_URL=https://soroban-mainnet.stellar.org
STELLAR_AGENT_SECRET_KEY=S...
VAULT_CONTRACT_ID=C...
USDC_TOKEN_ADDRESS=C...
ANTHROPIC_API_KEY=sk-ant-...
TWILIO_AUTH_TOKEN=<Twilio auth token>
TWILIO_ACCOUNT_SID=AC...
WHATSAPP_FROM=whatsapp:+1234567890
ADMIN_API_TOKEN=<strong random token>
CORS_ORIGINS=https://app.neurowealth.io
```

Generate secrets locally (never commit raw values):

```bash
openssl rand -hex 64 # JWT_SEED
openssl rand -hex 32 # WALLET_ENCRYPTION_KEY
openssl rand -hex 32 # ADMIN_API_TOKEN
```

### Database migrations (pre-start / init container)

Run `prisma migrate deploy` **before** starting the app. The container `CMD`
does this automatically for simple single-instance deploys:

```
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]
```

For Kubernetes, use a dedicated `initContainer` so the migration completes
before any app replica starts:

```yaml
initContainers:
- name: migrate
image: registry.example.com/neurowealth-backend:$(TAG)
command: ["npx", "prisma", "migrate", "deploy"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: neurowealth-secrets
key: DATABASE_URL
```

### Health and readiness probes for load balancers / Kubernetes

| Endpoint | HTTP method | Expected status | Use |
|----------|-------------|-----------------|-----|
| `GET /health/live` | GET | 200 always | Liveness — is the process running? |
| `GET /health/ready` | GET | 200 ready / 503 not ready | Readiness — are DB, event listener, and agent loop healthy? |
| `GET /health` | GET | 200 | Legacy; returns subsystem map from `readiness.ts` |

Kubernetes example:

```yaml
livenessProbe:
httpGet:
path: /health/live
port: 3001
initialDelaySeconds: 10
periodSeconds: 15

readinessProbe:
httpGet:
path: /health/ready
port: 3001
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
```

AWS ALB / Nginx: configure the target group health check to `GET /health/ready`
and mark targets unhealthy at HTTP 5xx. During rolling deploys new instances
will return 503 until all three subsystems (`database`, `eventListener`,
`agentLoop`) are ready.

### Key rotation / backup expectations

- **WALLET_ENCRYPTION_KEY** — custodial wallet secrets are stored AES-256-GCM
encrypted in the `custodial_wallets` table. Rotate by running a migration
job that decrypts with the old key and re-encrypts with the new one before
swapping the env var. Back up the database; losing the encryption key makes
wallets unrecoverable.
- **JWT_SEED** — rotate every 90 days. All active sessions are invalidated;
users re-authenticate. Use a maintenance window.
- **Auth nonces** — stored in `auth_nonces` table with a 5-minute TTL. No
special rotation needed; expired rows are pruned lazily on each challenge
request.

## Secret managers (recommended)

Expand Down
26 changes: 13 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"prettier": "^3.8.3",
"prisma": "^5.22.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
Expand Down
76 changes: 76 additions & 0 deletions pr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Production Hardening: Wallet/Nonce Persistence, Twilio Security, and Dockerfile

## Summary

- closes #102 — Persist custodial wallets in Postgres (removes in-memory `walletStore`)
- closes #103 — Move auth nonces to Postgres for multi-instance auth
- closes #112 — Enforce Twilio webhook signature validation in all environments
- closes #104 — Add production Dockerfile and deployment runbook

## Changes per issue

### closes #102 — Custodial wallet DB persistence

**Problem:** `src/stellar/wallet.ts` stored encrypted secrets in a module-level `Map`. Restarts wiped all wallets; horizontal scaling was impossible.

**Fix:**
- Added `CustodialWallet` Prisma model (`userId` unique, `publicKey` unique, `encryptedSecret`/`iv`/`authTag` columns)
- New migration: `prisma/migrations/20260529000001_add_custodial_wallets/`
- Rewrote `createCustodialWallet`, `getWalletByUserId`, `getKeypairForUser`, and `listWallets` to read/write `db.custodialWallet`
- 9 unit tests covering create, duplicate prevention, read, keypair decrypt round-trip, and simulated restart persistence

**Key rotation / backup:** rotate `WALLET_ENCRYPTION_KEY` by re-encrypting all `custodial_wallets` rows with the new key before swapping the env var. The database is the authoritative backup — losing the key makes wallets unrecoverable.

---

### closes #103 — Auth nonces in Postgres

**Problem:** `stellar-verification.ts` stored challenge nonces in an in-memory `Map`. Rolling deploys and multiple app instances broke `/api/auth/verify`.

**Fix:**
- Added `AuthNonce` Prisma model (`stellarPubKey` unique, `expiresAt` indexed for cleanup)
- New migration: `prisma/migrations/20260529000002_add_auth_nonces/`
- `StellarVerification` class is now stateless (no nonce map)
- `challenge()` upserts nonces via `db.authNonce`; expired rows are pruned lazily
- `verify()` reads/deletes nonces from DB — expiry check and replay prevention are preserved
- Auth unit tests updated to mock `db.authNonce` instead of the in-memory store; added cross-instance test

---

### closes #112 — Twilio webhook signature validation

**Problem:** `src/routes/whatsapp.ts` skipped `validateRequest` when `NODE_ENV !== 'production'`, allowing spoofed requests on staging/dev.

**Fix:**
- Signature validation now runs whenever `TWILIO_AUTH_TOKEN` is set, regardless of `NODE_ENV`
- Returns `403` immediately if `TWILIO_AUTH_TOKEN` is absent — no silent skip
- Added `TWILIO_AUTH_TOKEN` to the required-vars list in `src/config/env.ts`
- Added fail-fast check in `src/index.ts` `initServices()` so the server refuses to start without the token
- 5 unit tests: no-token 403, invalid-signature staging, invalid in development, valid happy path, env-agnostic enforcement

---

### closes #104 — Production Dockerfile and deployment runbook

**Added:**
- **`Dockerfile`** — multi-stage build: `node:20-alpine` builder (`npm ci` → `prisma generate` → `tsc` → prod-only deps), then slim runtime image running as non-root `app` user; CMD runs `prisma migrate deploy && node dist/index.js`
- **`.dockerignore`** — excludes `node_modules`, `dist`, `.env*`, logs, tests, docs
- **`docs/PRODUCTION_DEPLOYMENT.md`** — new sections covering:
- Build/push commands
- Minimum required env vars (`NODE_ENV=production`, `CORS_ORIGINS`, `WALLET_ENCRYPTION_KEY`, `ADMIN_API_TOKEN`, `TWILIO_AUTH_TOKEN`, etc.)
- `prisma migrate deploy` as pre-start step; Kubernetes initContainer pattern
- Health/readiness probe table (`GET /health/live` liveness, `GET /health/ready` readiness 200/503) with Kubernetes and ALB examples
- Key rotation and backup expectations for `WALLET_ENCRYPTION_KEY`, `JWT_SEED`, and auth nonces

## Test plan

- [ ] `npx jest tests/unit/stellar/wallet.test.ts` — 9 tests pass
- [ ] `npx jest src/controllers/__tests__/auth.test.ts` — all auth tests pass
- [ ] `npx jest tests/unit/whatsapp/webhook.test.ts` — 5 tests pass
- [ ] `docker build -t neurowealth-backend .` completes without error
- [ ] `GET /health/live` returns 200 after startup
- [ ] `GET /health/ready` returns 503 before DB connects, 200 after all services ready
- [ ] Starting without `TWILIO_AUTH_TOKEN` set fails fast with a clear error message
- [ ] POST `/api/whatsapp/webhook` with a bad signature returns 403 in all `NODE_ENV` values

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "custodial_wallets" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"publicKey" TEXT NOT NULL,
"encryptedSecret" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"authTag" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "custodial_wallets_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "custodial_wallets_userId_key" ON "custodial_wallets"("userId");

-- CreateIndex
CREATE UNIQUE INDEX "custodial_wallets_publicKey_key" ON "custodial_wallets"("publicKey");

-- CreateIndex
CREATE INDEX "custodial_wallets_userId_idx" ON "custodial_wallets"("userId");
16 changes: 16 additions & 0 deletions prisma/migrations/20260529000002_add_auth_nonces/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "auth_nonces" (
"id" TEXT NOT NULL,
"stellarPubKey" TEXT NOT NULL,
"nonce" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "auth_nonces_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "auth_nonces_stellarPubKey_key" ON "auth_nonces"("stellarPubKey");

-- CreateIndex
CREATE INDEX "auth_nonces_expiresAt_idx" ON "auth_nonces"("expiresAt");
Loading
Loading