From fc5b27dfda4f96d3c23a589812edf8f183604ba2 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Fri, 29 May 2026 14:54:38 +0100 Subject: [PATCH 1/9] fix(#102): persist custodial wallets in Postgres, remove in-memory walletStore Replaces the in-memory Map in wallet.ts with Prisma DB operations so wallet data survives restarts and works correctly under horizontal scaling. - Add CustodialWallet model to schema.prisma (userId unique, publicKey unique, encryptedSecret/iv/authTag fields) - Add migration 20260529000001_add_custodial_wallets - Rewrite createCustodialWallet, getWalletByUserId, getKeypairForUser, and listWallets to use db.custodialWallet (encrypted secret stored in DB, never in memory beyond the duration of a single request) - Add 9 unit tests covering create/read, keypair round-trip, and simulated restart persistence - Update testDb helpers to include custodialWallet and authNonce mock/teardown Key rotation / backup: rotate WALLET_ENCRYPTION_KEY by re-encrypting all rows with the new key before deploying. The DB itself is the authoritative backup. Closes #102 Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 22 +++ prisma/schema.prisma | 25 +++ src/stellar/wallet.ts | 70 +++----- tests/helpers/testDb.ts | 14 ++ tests/unit/stellar/wallet.test.ts | 167 ++++++++++++++++++ 5 files changed, 256 insertions(+), 42 deletions(-) create mode 100644 prisma/migrations/20260529000001_add_custodial_wallets/migration.sql create mode 100644 tests/unit/stellar/wallet.test.ts diff --git a/prisma/migrations/20260529000001_add_custodial_wallets/migration.sql b/prisma/migrations/20260529000001_add_custodial_wallets/migration.sql new file mode 100644 index 0000000..85fa5b6 --- /dev/null +++ b/prisma/migrations/20260529000001_add_custodial_wallets/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aa11775..34c458f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -250,3 +250,28 @@ model DeadLetterEvent { @@index([createdAt]) @@map("dead_letter_events") } + +model CustodialWallet { + id String @id @default(uuid()) + userId String @unique + publicKey String @unique + encryptedSecret String + iv String + authTag String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@map("custodial_wallets") +} + +model AuthNonce { + id String @id @default(uuid()) + stellarPubKey String @unique + nonce String + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([expiresAt]) + @@map("auth_nonces") +} diff --git a/src/stellar/wallet.ts b/src/stellar/wallet.ts index 25128bf..d4abba6 100644 --- a/src/stellar/wallet.ts +++ b/src/stellar/wallet.ts @@ -1,5 +1,6 @@ import { Keypair } from '@stellar/stellar-sdk'; import * as crypto from 'crypto'; +import db from '../db'; const ALGORITHM = 'aes-256-gcm'; @@ -11,21 +12,6 @@ function getEncryptionKey(): string { return key; } -interface CustodialWallet { - userId: string; - publicKey: string; - encryptedSecret: string; - iv: string; - authTag: string; -} - -// In-memory storage (replace with database in production) -const walletStore = new Map(); - -/** - * Encrypt secret key - * SECURITY: Never log secret keys. Use environment-based encryption key. - */ function encryptSecret(secret: string): { encrypted: string; iv: string; authTag: string } { const key = Buffer.from(getEncryptionKey(), 'hex'); const iv = crypto.randomBytes(16); @@ -34,18 +20,13 @@ function encryptSecret(secret: string): { encrypted: string; iv: string; authTag let encrypted = cipher.update(secret, 'utf8', 'hex'); encrypted += cipher.final('hex'); - const authTag = cipher.getAuthTag(); - return { encrypted, iv: iv.toString('hex'), - authTag: authTag.toString('hex'), + authTag: cipher.getAuthTag().toString('hex'), }; } -/** - * Decrypt secret key - */ function decryptSecret(encrypted: string, iv: string, authTag: string): string { const key = Buffer.from(getEncryptionKey(), 'hex'); const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex')); @@ -58,44 +39,48 @@ function decryptSecret(encrypted: string, iv: string, authTag: string): string { } /** - * Create custodial wallet for user + * Create a custodial wallet for a user and persist it to the database. * * SECURITY NOTE: This is a custodial solution where the backend holds user keys. * Users trust the backend to secure their funds. Consider non-custodial alternatives * for production use cases requiring higher security guarantees. + * + * Key rotation / backup: rotate WALLET_ENCRYPTION_KEY by re-encrypting all rows + * with the new key before deploying. Back up the database regularly; losing the + * encryption key means wallets cannot be recovered. */ -export async function createCustodialWallet(userId: string): Promise { - if (walletStore.has(userId)) { +export async function createCustodialWallet(userId: string) { + const existing = await db.custodialWallet.findUnique({ where: { userId } }); + if (existing) { throw new Error(`Wallet already exists for user ${userId}`); } const keypair = Keypair.random(); const { encrypted, iv, authTag } = encryptSecret(keypair.secret()); - const wallet: CustodialWallet = { - userId, - publicKey: keypair.publicKey(), - encryptedSecret: encrypted, - iv, - authTag, - }; - - walletStore.set(userId, wallet); + const wallet = await db.custodialWallet.create({ + data: { + userId, + publicKey: keypair.publicKey(), + encryptedSecret: encrypted, + iv, + authTag, + }, + }); console.log(`[Wallet] Created for user ${userId}: ${wallet.publicKey}`); - return wallet; } /** - * Get wallet by user ID + * Get wallet record by user ID. */ -export async function getWalletByUserId(userId: string): Promise { - return walletStore.get(userId) || null; +export async function getWalletByUserId(userId: string) { + return db.custodialWallet.findUnique({ where: { userId } }); } /** - * Get keypair for user (decrypts secret) + * Decrypt and return the Stellar Keypair for a user. */ export async function getKeypairForUser(userId: string): Promise { const wallet = await getWalletByUserId(userId); @@ -109,8 +94,9 @@ export async function getKeypairForUser(userId: string): Promise { } /** - * List all wallet public keys (for admin/debugging) + * List all wallet public keys (for admin/debugging). */ -export function listWallets(): string[] { - return Array.from(walletStore.values()).map(w => w.publicKey); -} \ No newline at end of file +export async function listWallets(): Promise { + const wallets = await db.custodialWallet.findMany({ select: { publicKey: true } }); + return wallets.map(w => w.publicKey); +} diff --git a/tests/helpers/testDb.ts b/tests/helpers/testDb.ts index 62d2ccc..8ae9859 100644 --- a/tests/helpers/testDb.ts +++ b/tests/helpers/testDb.ts @@ -32,6 +32,8 @@ export async function teardownTestDatabase(): Promise { prisma.position.deleteMany(), prisma.session.deleteMany(), prisma.protocolRate.deleteMany(), + prisma.custodialWallet.deleteMany(), + prisma.authNonce.deleteMany(), prisma.user.deleteMany(), ]) await prisma.$disconnect() @@ -134,6 +136,18 @@ export function createMockDb() { count: jest.fn().mockResolvedValue(0), deleteMany: jest.fn(), }, + custodialWallet: { + findUnique: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(), + deleteMany: jest.fn(), + }, + authNonce: { + findUnique: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, $connect: jest.fn().mockResolvedValue(undefined), $disconnect: jest.fn().mockResolvedValue(undefined), } diff --git a/tests/unit/stellar/wallet.test.ts b/tests/unit/stellar/wallet.test.ts new file mode 100644 index 0000000..6e2191e --- /dev/null +++ b/tests/unit/stellar/wallet.test.ts @@ -0,0 +1,167 @@ +/** + * wallet.test.ts + * + * Unit tests for custodial wallet operations. + * Prisma is mocked so no real database is required. + * Verifies create/read behaviour and encrypted-secret round-trip. + */ + +import { Keypair } from '@stellar/stellar-sdk'; +import * as cryptoMod from 'crypto'; + +// ── DB mock (must be defined before jest.mock factory runs via hoisting) ───── +// Use module-level jest.fn() calls inside the factory so they are accessible +// after import via the `mockedDb` reference below. + +jest.mock('../../../src/db', () => { + const mock = { + custodialWallet: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + }, + }; + return { __esModule: true, default: mock, db: mock }; +}); + +// Provide a valid 64-hex WALLET_ENCRYPTION_KEY for tests +const TEST_KEY = 'a'.repeat(64); +process.env.WALLET_ENCRYPTION_KEY = TEST_KEY; + +// Import AFTER mocks are registered +import db from '../../../src/db'; +import { + createCustodialWallet, + getWalletByUserId, + getKeypairForUser, + listWallets, +} from '../../../src/stellar/wallet'; + +// Typed reference to the custodialWallet mock namespace +const mockCW = db.custodialWallet as jest.Mocked; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function fakeWalletRow(userId: string) { + const keypair = Keypair.random(); + const key = Buffer.from(TEST_KEY, 'hex'); + const iv = cryptoMod.randomBytes(16); + const cipher = cryptoMod.createCipheriv('aes-256-gcm', key, iv); + let enc = cipher.update(keypair.secret(), 'utf8', 'hex'); + enc += cipher.final('hex'); + const authTag = cipher.getAuthTag().toString('hex'); + + return { + id: 'wallet-uuid-1', + userId, + publicKey: keypair.publicKey(), + encryptedSecret: enc, + iv: iv.toString('hex'), + authTag, + createdAt: new Date(), + updatedAt: new Date(), + _keypair: keypair, + }; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('createCustodialWallet', () => { + beforeEach(() => jest.clearAllMocks()); + + it('throws when a wallet already exists for the user', async () => { + const row = fakeWalletRow('user-1'); + mockCW.findUnique.mockResolvedValue(row as any); + + await expect(createCustodialWallet('user-1')).rejects.toThrow( + 'Wallet already exists for user user-1', + ); + expect(mockCW.create).not.toHaveBeenCalled(); + }); + + it('creates and persists a new wallet when none exists', async () => { + mockCW.findUnique.mockResolvedValue(null); + mockCW.create.mockImplementation(({ data }: any) => ({ + id: 'wallet-uuid-new', + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) as any); + + const result = await createCustodialWallet('user-2'); + + expect(mockCW.create).toHaveBeenCalledTimes(1); + const created = (mockCW.create as jest.Mock).mock.calls[0][0].data; + expect(created.userId).toBe('user-2'); + expect(created.publicKey).toMatch(/^G/); // Stellar public keys start with G + expect(created.encryptedSecret).toBeTruthy(); + expect(created.iv).toHaveLength(32); // 16 bytes as hex + expect(created.authTag).toHaveLength(32); + expect(result.userId).toBe('user-2'); + }); +}); + +describe('getWalletByUserId', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns null when no wallet exists', async () => { + mockCW.findUnique.mockResolvedValue(null); + await expect(getWalletByUserId('unknown')).resolves.toBeNull(); + }); + + it('returns the wallet row when found', async () => { + const row = fakeWalletRow('user-3'); + mockCW.findUnique.mockResolvedValue(row as any); + const result = await getWalletByUserId('user-3'); + expect(result?.publicKey).toBe(row.publicKey); + }); + + it('simulates persistence across restarts: findUnique is called on DB each time', async () => { + const row = fakeWalletRow('user-4'); + mockCW.findUnique + .mockResolvedValueOnce(row as any) // "before restart" + .mockResolvedValueOnce(row as any); // "after restart" + + const first = await getWalletByUserId('user-4'); + const second = await getWalletByUserId('user-4'); + expect(first?.publicKey).toBe(second?.publicKey); + expect(mockCW.findUnique).toHaveBeenCalledTimes(2); + }); +}); + +describe('getKeypairForUser', () => { + beforeEach(() => jest.clearAllMocks()); + + it('throws when no wallet is found', async () => { + mockCW.findUnique.mockResolvedValue(null); + await expect(getKeypairForUser('no-wallet')).rejects.toThrow( + 'No wallet found for user no-wallet', + ); + }); + + it('returns a valid Keypair whose public key matches the stored one', async () => { + const row = fakeWalletRow('user-5'); + mockCW.findUnique.mockResolvedValue(row as any); + + const keypair = await getKeypairForUser('user-5'); + expect(keypair.publicKey()).toBe(row.publicKey); + expect(keypair.secret()).toBe(row._keypair.secret()); + }); +}); + +describe('listWallets', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns an empty array when no wallets exist', async () => { + mockCW.findMany.mockResolvedValue([] as any); + await expect(listWallets()).resolves.toEqual([]); + }); + + it('returns all public keys', async () => { + mockCW.findMany.mockResolvedValue([ + { publicKey: 'GABC' }, + { publicKey: 'GXYZ' }, + ] as any); + await expect(listWallets()).resolves.toEqual(['GABC', 'GXYZ']); + }); +}); From 76f6f3972dc50a7873f3ccdac8346fd2908bb546 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Fri, 29 May 2026 15:03:08 +0100 Subject: [PATCH 2/9] fix(#103): move auth nonces to Postgres for multi-instance safety Replaces the in-memory Map in stellar-verification.ts with Postgres-backed AuthNonce rows, so nonces survive rolling deploys and work correctly when multiple app instances handle requests. - Add AuthNonce model to schema.prisma (stellarPubKey unique, TTL-able via expiresAt, indexed for efficient expiry sweeps) - Add migration 20260529000002_add_auth_nonces - Remove nonceStore Map and _nonceStoreForTests export from stellar-verification.ts; StellarVerification class is now stateless - Rewrite challenge() to upsert nonces via db.authNonce and purge expired rows lazily on each challenge request - Rewrite verify() to findUnique/delete nonces from DB, preserving expiry check and replay prevention semantics - Update all auth tests to mock db.authNonce instead of the in-memory store; add cross-instance test that simulates a second instance finding a DB nonce Closes #103 Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 16 ++++ src/controllers/__tests__/auth.test.ts | 87 ++++++++++++------- src/controllers/auth-controller.ts | 34 ++++---- src/utils/stellar/stellar-verification.ts | 38 +------- 4 files changed, 94 insertions(+), 81 deletions(-) create mode 100644 prisma/migrations/20260529000002_add_auth_nonces/migration.sql diff --git a/prisma/migrations/20260529000002_add_auth_nonces/migration.sql b/prisma/migrations/20260529000002_add_auth_nonces/migration.sql new file mode 100644 index 0000000..0c48e14 --- /dev/null +++ b/prisma/migrations/20260529000002_add_auth_nonces/migration.sql @@ -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"); diff --git a/src/controllers/__tests__/auth.test.ts b/src/controllers/__tests__/auth.test.ts index 52999cf..a44a767 100644 --- a/src/controllers/__tests__/auth.test.ts +++ b/src/controllers/__tests__/auth.test.ts @@ -27,6 +27,12 @@ const mockPrisma = { deleteMany: jest.fn(), delete: jest.fn(), }, + authNonce: { + findUnique: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, }; jest.mock('@prisma/client', () => ({ @@ -69,8 +75,14 @@ jest.mock('../../config/env', () => ({ }, })); +// DB mock — auth-controller imports db from '../../db' +jest.mock('../../db', () => ({ + __esModule: true, + default: mockPrisma, +})); + // Import after mocks -import { challenge, verify, logout, _nonceStoreForTests } from '../../controllers/auth-controller'; +import { challenge, verify, logout } from '../../controllers/auth-controller'; import { AuthMiddleware } from '../../middleware/authenticate'; import { JwtAdapter } from '../../config'; @@ -94,13 +106,28 @@ function makeReq(overrides: Partial = {}): Request { } as unknown as Request; } -// Challenge endpoint +const NONCE_TTL_MS_TEST = 5 * 60 * 1000; + +/** Seed the DB mock so verify() can find a valid nonce. */ +function seedNonce(pubKey: string, kp: Keypair, ttlOffset = NONCE_TTL_MS_TEST) { + const nonce = `nw-auth-test-${Date.now()}`; + mockPrisma.authNonce.findUnique.mockResolvedValue({ + stellarPubKey: pubKey, + nonce, + expiresAt: new Date(Date.now() + ttlOffset), + }); + return nonce; +} + +// ── Challenge endpoint ──────────────────────────────────────────────────────── describe('POST /api/auth/challenge', () => { const keypair = Keypair.random(); beforeEach(() => { - _nonceStoreForTests.clear(); + jest.clearAllMocks(); + mockPrisma.authNonce.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.authNonce.upsert.mockResolvedValue({}); }); it('returns 400 when stellarPubKey is missing', async () => { @@ -129,21 +156,18 @@ describe('POST /api/auth/challenge', () => { expect(body.nonce).toMatch(/^nw-auth-/); }); - it('overwrites an existing nonce with a fresh one on a second call', async () => { + it('upserts (overwrites) an existing nonce on a second challenge call', async () => { const pubKey = keypair.publicKey(); const req = makeReq({ body: { stellarPubKey: pubKey } }); await challenge(req, makeRes()); - const first = _nonceStoreForTests.get(pubKey)?.nonce; - await challenge(req, makeRes()); - const second = _nonceStoreForTests.get(pubKey)?.nonce; - expect(first).not.toBe(second); + expect(mockPrisma.authNonce.upsert).toHaveBeenCalledTimes(2); }); }); -// Verify endpoint +// ── Verify endpoint ─────────────────────────────────────────────────────────── describe('POST /api/auth/verify', () => { const keypair = Keypair.random(); @@ -157,26 +181,14 @@ describe('POST /api/auth/verify', () => { beforeEach(() => { jest.clearAllMocks(); - _nonceStoreForTests.clear(); (JwtAdapter.generateToken as jest.Mock).mockResolvedValue('mock.jwt.token'); mockPrisma.user.findUnique.mockResolvedValue(null); mockPrisma.user.create.mockResolvedValue(mockUser); mockPrisma.session.create.mockResolvedValue({}); + mockPrisma.authNonce.delete.mockResolvedValue({}); + mockPrisma.authNonce.findUnique.mockResolvedValue(null); }); - /** Seed the nonce store and return the signed nonce */ - function seedNonce(pubkey: string, kp: Keypair, ttlOffset = NONCE_TTL_MS_TEST): string { - const nonce = `nw-auth-test-${Date.now()}`; - _nonceStoreForTests.set(pubkey, { - nonce, - expiresAt: Date.now() + ttlOffset, - stellarPubKey: pubkey, - }); - return nonce; - } - - const NONCE_TTL_MS_TEST = 5 * 60 * 1000; - it('returns 400 when required fields are missing', async () => { const req = makeReq({ body: {} }); const res = makeRes(); @@ -185,6 +197,7 @@ describe('POST /api/auth/verify', () => { }); it('returns 401 when no active challenge exists for the public key', async () => { + mockPrisma.authNonce.findUnique.mockResolvedValue(null); const sig = Buffer.from(keypair.sign(Buffer.from('irrelevant'))).toString('base64'); const req = makeReq({ body: { stellarPubKey: pubKey, signature: sig } }); const res = makeRes(); @@ -196,10 +209,10 @@ describe('POST /api/auth/verify', () => { }); it('returns 401 for an expired nonce', async () => { - _nonceStoreForTests.set(pubKey, { - nonce: 'old-nonce', - expiresAt: Date.now() - 1, // already expired + mockPrisma.authNonce.findUnique.mockResolvedValue({ stellarPubKey: pubKey, + nonce: 'old-nonce', + expiresAt: new Date(Date.now() - 1), // already expired }); const sig = Buffer.from(keypair.sign(Buffer.from('old-nonce'))).toString('base64'); const req = makeReq({ body: { stellarPubKey: pubKey, signature: sig } }); @@ -244,10 +257,10 @@ describe('POST /api/auth/verify', () => { const sigBytes = keypair.sign(Buffer.from(nonce, 'utf8')); const sig = Buffer.from(sigBytes).toString('base64'); - // First call succeeds + // First call succeeds; second call returns null (nonce was consumed/deleted) await verify(makeReq({ body: { stellarPubKey: pubKey, signature: sig } }), makeRes()); - // Second call with same public key — nonce was consumed + mockPrisma.authNonce.findUnique.mockResolvedValue(null); // nonce consumed const res2 = makeRes(); await verify(makeReq({ body: { stellarPubKey: pubKey, signature: sig } }), res2); expect(res2.status).toHaveBeenCalledWith(401); @@ -274,9 +287,23 @@ describe('POST /api/auth/verify', () => { expect(mockPrisma.user.create).not.toHaveBeenCalled(); }); + + it('cross-instance: nonce stored in DB is available to any instance', async () => { + // Simulate a different instance finding the same nonce from the DB + const nonce = `nw-auth-cross-${Date.now()}`; + mockPrisma.authNonce.findUnique.mockResolvedValue({ + stellarPubKey: pubKey, + nonce, + expiresAt: new Date(Date.now() + NONCE_TTL_MS_TEST), + }); + const sig = Buffer.from(keypair.sign(Buffer.from(nonce, 'utf8'))).toString('base64'); + const res = makeRes(); + await verify(makeReq({ body: { stellarPubKey: pubKey, signature: sig } }), res); + expect(res.status).toHaveBeenCalledWith(200); + }); }); -// AuthMiddleware +// ── AuthMiddleware ──────────────────────────────────────────────────────────── describe('AuthMiddleware.validateJwt', () => { const next = jest.fn(); @@ -364,7 +391,7 @@ describe('AuthMiddleware.validateJwt', () => { }); }); -// Logout endpoint +// ── Logout endpoint ─────────────────────────────────────────────────────────── describe('POST /api/auth/logout', () => { beforeEach(() => { diff --git a/src/controllers/auth-controller.ts b/src/controllers/auth-controller.ts index 2c974a1..17756d6 100644 --- a/src/controllers/auth-controller.ts +++ b/src/controllers/auth-controller.ts @@ -4,10 +4,7 @@ import { Keypair } from '@stellar/stellar-sdk'; import { JwtAdapter, config } from '../config'; import { logger } from '../utils/logger'; import db from '../db'; -import { - stellarVerification, - _nonceStoreForTests as nonceStore, -} from '../utils/stellar/stellar-verification'; +import { stellarVerification } from '../utils/stellar/stellar-verification'; // Controllers @@ -19,6 +16,8 @@ import { * * Issues a one-time nonce tied to the caller's Stellar public key. * The nonce must be signed and returned to /verify within 5 minutes. + * Nonces are persisted in Postgres so they survive restarts and work + * correctly across multiple app instances. */ export async function challenge(req: Request, res: Response): Promise { const { stellarPubKey } = req.body as { stellarPubKey?: string }; @@ -36,18 +35,26 @@ export async function challenge(req: Request, res: Response): Promise { return; } - stellarVerification.purgeExpiredNonces(); + // Purge expired nonces (lazy cleanup — harmless if it fails) + db.authNonce + .deleteMany({ where: { expiresAt: { lt: new Date() } } }) + .catch(() => {}); const nonce = `nw-auth-${randomBytes(32).toString('hex')}`; - const expiresAt = Date.now() + config.jwt.nonce_ttl_ms; + const expiresAt = new Date(Date.now() + config.jwt.nonce_ttl_ms); - nonceStore.set(stellarPubKey, { nonce, expiresAt, stellarPubKey }); + // Upsert so a second challenge for the same key overwrites the old nonce + await db.authNonce.upsert({ + where: { stellarPubKey }, + create: { stellarPubKey, nonce, expiresAt }, + update: { nonce, expiresAt }, + }); logger.info(`[Auth] Challenge issued for ${stellarPubKey}`); res.status(200).json({ nonce, - expiresAt: new Date(expiresAt).toISOString(), + expiresAt: expiresAt.toISOString(), }); } @@ -77,15 +84,15 @@ export async function verify(req: Request, res: Response): Promise { } // 1. Look up nonce - const stored = nonceStore.get(stellarPubKey); + const stored = await db.authNonce.findUnique({ where: { stellarPubKey } }); if (!stored) { res.status(401).json({ error: 'No active challenge for this public key' }); return; } // 2. Check nonce expiry - if (stored.expiresAt <= Date.now()) { - nonceStore.delete(stellarPubKey); + if (stored.expiresAt <= new Date()) { + await db.authNonce.delete({ where: { stellarPubKey } }).catch(() => {}); res.status(401).json({ error: 'Challenge nonce has expired' }); return; } @@ -102,7 +109,7 @@ export async function verify(req: Request, res: Response): Promise { } // 4. Consume nonce — prevents replay attacks - nonceStore.delete(stellarPubKey); + await db.authNonce.delete({ where: { stellarPubKey } }).catch(() => {}); const network = stellarVerification.resolveNetwork(); @@ -185,6 +192,3 @@ export async function logout(req: Request, res: Response): Promise { res.status(500).json({ error: 'Internal server error' }); } } - -// Re-export so tests can import from either the controller or stellar-verification -export { _nonceStoreForTests } from '../utils/stellar/stellar-verification'; diff --git a/src/utils/stellar/stellar-verification.ts b/src/utils/stellar/stellar-verification.ts index 79413ed..18fc915 100644 --- a/src/utils/stellar/stellar-verification.ts +++ b/src/utils/stellar/stellar-verification.ts @@ -1,34 +1,9 @@ import { config } from '../../config/env'; import { Keypair } from '@stellar/stellar-sdk'; -interface StoredNonce { - nonce: string; - expiresAt: number; - stellarPubKey: string; -} - - export default class StellarVerification { - /** Verify a Stellar signature. - * Freighter signs the raw UTF-8 bytes of the message. - * Stellar's Keypair.verify() expects a Buffer and a base64-encoded signature. - */ - constructor( - /** In-memory nonce store — keyed by stellarPubKey */ - private readonly nonceStore: Map, - ) { } - - /** Remove expired nonces (called lazily on every challenge request) */ - purgeExpiredNonces(): void { - const now = Date.now(); - for (const [key, entry] of this.nonceStore.entries()) { - if (entry.expiresAt <= now) this.nonceStore.delete(key); - } - } - /** * Verify a Stellar signature. - * * Freighter signs the raw UTF-8 bytes of the message. * Stellar's Keypair.verify() expects a Buffer and a base64-encoded signature. */ @@ -59,16 +34,7 @@ export default class StellarVerification { return 'TESTNET'; } } - } - -// Module-level singleton - -const nonceStore = new Map(); - -/** Shared singleton — imported by auth-controller and tests */ -export const stellarVerification = new StellarVerification(nonceStore); - -// Export the raw store for testing purposes only -export { nonceStore as _nonceStoreForTests }; \ No newline at end of file +/** Shared singleton — imported by auth-controller */ +export const stellarVerification = new StellarVerification(); From d87daf49c2649d4812035573e9cfc1d75fb7aede Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Fri, 29 May 2026 15:14:01 +0100 Subject: [PATCH 3/9] fix(#112): enforce Twilio webhook signature validation in all environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously signature checking was skipped when NODE_ENV != 'production', allowing spoofed requests on staging/dev to slip through. Now validation runs whenever TWILIO_AUTH_TOKEN is set, and the app fails fast at startup if the token is absent — making misconfiguration impossible to miss. - Remove production-only guard in whatsapp.ts; signature is now validated on every POST /api/whatsapp/webhook regardless of NODE_ENV - Return 403 with a clear message when TWILIO_AUTH_TOKEN is not set (reject immediately, without calling validateRequest with an empty token) - Add TWILIO_AUTH_TOKEN to the required-vars list in env.ts so startup validation catches it together with all other missing config - Add fail-fast check in index.ts initServices() so the server refuses to start if TWILIO_AUTH_TOKEN is absent - Add 5 unit tests covering: no-token 403, invalid-signature staging, invalid in development, valid happy path, and env-agnostic enforcement Closes #112 Co-Authored-By: Claude Sonnet 4.6 --- src/config/env.ts | 1 + src/index.ts | 14 +++- src/routes/whatsapp.ts | 24 +++--- tests/unit/whatsapp/webhook.test.ts | 122 ++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 tests/unit/whatsapp/webhook.test.ts diff --git a/src/config/env.ts b/src/config/env.ts index c108402..b1341b4 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -23,6 +23,7 @@ function validateAllRequiredEnvVars(): void { 'DATABASE_URL', 'JWT_SEED', 'WALLET_ENCRYPTION_KEY', + 'TWILIO_AUTH_TOKEN', 'NODE_ENV', ] diff --git a/src/index.ts b/src/index.ts index 70a1b82..ed60144 100644 --- a/src/index.ts +++ b/src/index.ts @@ -186,7 +186,9 @@ async function gracefulShutdown(signal: string): Promise { // and exit with a nonzero code so process supervisors / K8s restart us. async function initServices(): Promise { - // 0. Validate production configuration + // 0. Validate startup configuration + + // Admin token — required in production if (config.nodeEnv === 'production') { const adminToken = process.env.ADMIN_API_TOKEN if (!adminToken || adminToken.length < 8) { @@ -197,6 +199,16 @@ async function initServices(): Promise { logger.info('[Startup] Admin API token configured ✓') } + // Twilio auth token — WhatsApp routes are always mounted; reject at startup + // if the token is absent so spoofed webhook calls are impossible. + if (!process.env.TWILIO_AUTH_TOKEN) { + const msg = + 'TWILIO_AUTH_TOKEN must be set — WhatsApp webhook signature validation requires it' + logger.error('[Startup] Configuration validation failed — cannot continue', { error: msg }) + throw new Error(msg) + } + logger.info('[Startup] Twilio auth token configured ✓') + // 1. Database try { await connectDb() diff --git a/src/routes/whatsapp.ts b/src/routes/whatsapp.ts index 98f2478..40ab244 100644 --- a/src/routes/whatsapp.ts +++ b/src/routes/whatsapp.ts @@ -1,7 +1,6 @@ import express, { Request, Response } from 'express' import { validateRequest, twiml } from 'twilio' import { handleWhatsAppMessage } from '../whatsapp/handler' -import { config } from '../config/env' const router = express.Router() @@ -13,22 +12,27 @@ router.get('/webhook', (_req: Request, res: Response) => { }) /** - * Handles incoming WhatsApp messages from Twilio + * Handles incoming WhatsApp messages from Twilio. + * + * Signature validation is performed whenever TWILIO_AUTH_TOKEN is set. + * If the token is absent the request is rejected with 403 — preventing + * spoofed calls even on staging/dev where NODE_ENV is not 'production'. * https://www.twilio.com/docs/usage/security#validating-requests */ router.post('/webhook', async (req: Request, res: Response) => { - const signature = req.header('x-twilio-signature') - const authToken = process.env.TWILIO_AUTH_TOKEN || '' + const authToken = process.env.TWILIO_AUTH_TOKEN - const url = `${req.protocol}://${req.get('host')}${req.originalUrl}` - - if (!signature || !authToken) { - return res.status(403).send('Forbidden') + if (!authToken) { + // Token not configured: reject rather than silently skip validation + return res.status(403).send('Forbidden: TWILIO_AUTH_TOKEN not configured') } + const signature = req.header('x-twilio-signature') ?? '' + const url = `${req.protocol}://${req.get('host')}${req.originalUrl}` const isValid = validateRequest(authToken, signature, url, req.body) - if (!isValid && config.nodeEnv === 'production') { - return res.status(403).send('Forbidden') + + if (!isValid) { + return res.status(403).send('Forbidden: invalid Twilio signature') } const from = (req.body.From as string) || '' diff --git a/tests/unit/whatsapp/webhook.test.ts b/tests/unit/whatsapp/webhook.test.ts new file mode 100644 index 0000000..9c00601 --- /dev/null +++ b/tests/unit/whatsapp/webhook.test.ts @@ -0,0 +1,122 @@ +/** + * webhook.test.ts + * + * Tests for Twilio webhook signature validation in whatsapp.ts. + * Covers: + * - 403 when TWILIO_AUTH_TOKEN is not set (no token configured) + * - 403 when x-twilio-signature is invalid (staging-like config without production flag) + * - 200 when signature is valid + */ + +import request from 'supertest' +import express from 'express' +import { validateRequest } from 'twilio' + +// Mock twilio validateRequest so we can control the result +jest.mock('twilio', () => { + const original = jest.requireActual('twilio') + return { + ...original, + validateRequest: jest.fn(), + } +}) + +// Mock the WhatsApp message handler to avoid deep dependencies +jest.mock('../../../src/whatsapp/handler', () => ({ + handleWhatsAppMessage: jest.fn().mockResolvedValue({ body: 'OK' }), +})) + +const mockValidateRequest = validateRequest as jest.MockedFunction + +// Build a single app whose route reads env vars at request time +import whatsappRouter from '../../../src/routes/whatsapp' + +const app = express() +app.use(express.urlencoded({ extended: false })) +app.use(express.json()) +app.use('/api/whatsapp', whatsappRouter) + +afterEach(() => { + jest.clearAllMocks() + delete process.env.TWILIO_AUTH_TOKEN +}) + +describe('POST /api/whatsapp/webhook — signature validation', () => { + it('returns 403 when TWILIO_AUTH_TOKEN is not set (no token configured)', async () => { + delete process.env.TWILIO_AUTH_TOKEN + + const res = await request(app) + .post('/api/whatsapp/webhook') + .send({ From: '+1234', Body: 'hello' }) + + expect(res.status).toBe(403) + expect(res.text).toMatch(/TWILIO_AUTH_TOKEN not configured/) + expect(mockValidateRequest).not.toHaveBeenCalled() + }) + + it('returns 403 for an invalid signature even when NODE_ENV is staging (not production)', async () => { + const savedEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'staging' + process.env.TWILIO_AUTH_TOKEN = 'test-auth-token' + mockValidateRequest.mockReturnValue(false) + + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'bad-signature') + .send({ From: '+1234', Body: 'hello' }) + + expect(res.status).toBe(403) + expect(res.text).toMatch(/invalid Twilio signature/) + expect(mockValidateRequest).toHaveBeenCalledTimes(1) + process.env.NODE_ENV = savedEnv + }) + + it('returns 403 for an invalid signature in development', async () => { + const savedEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + process.env.TWILIO_AUTH_TOKEN = 'test-auth-token' + mockValidateRequest.mockReturnValue(false) + + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'tampered') + .send({ From: '+1234', Body: 'hello' }) + + expect(res.status).toBe(403) + expect(mockValidateRequest).toHaveBeenCalledTimes(1) + process.env.NODE_ENV = savedEnv + }) + + it('returns 200 with TwiML when signature is valid', async () => { + process.env.TWILIO_AUTH_TOKEN = 'valid-auth-token' + mockValidateRequest.mockReturnValue(true) + + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'correct-signature') + .send({ From: '+1234', Body: 'hello' }) + + expect(res.status).toBe(200) + expect(res.type).toMatch(/xml/) + expect(mockValidateRequest).toHaveBeenCalledTimes(1) + }) + + it('calls validateRequest regardless of NODE_ENV value', async () => { + for (const env of ['development', 'staging', 'production']) { + jest.clearAllMocks() + const savedEnv = process.env.NODE_ENV + process.env.NODE_ENV = env + process.env.TWILIO_AUTH_TOKEN = 'some-token' + mockValidateRequest.mockReturnValue(false) + + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'invalid') + .send({ From: '+1234', Body: 'hi' }) + + expect(res.status).toBe(403) + expect(mockValidateRequest).toHaveBeenCalledTimes(1) + process.env.NODE_ENV = savedEnv + } + }) +}) From 0d0e3b53b16050266c646453871dadb30ecd21e0 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Fri, 29 May 2026 15:15:33 +0100 Subject: [PATCH 4/9] feat(#104): add production Dockerfile and deployment runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-stage Dockerfile builds a slim runtime image (node:20-alpine) and documents the full production deployment path including migrate, probes, and secret expectations. Dockerfile: - Stage 1 (builder): npm ci → prisma generate → tsc → npm ci --omit=dev - Stage 2 (runtime): copy dist/, prod node_modules, prisma schema; runs as least-privilege non-root user (app:app) - CMD runs prisma migrate deploy then node dist/index.js; Kubernetes users should split this into an initContainer .dockerignore: excludes node_modules, dist, .env*, logs, tests, docs docs/PRODUCTION_DEPLOYMENT.md additions: - Build/push commands and minimum required env vars with NODE_ENV=production - prisma migrate deploy documented as pre-start step and initContainer pattern - Health/readiness probe table: GET /health/live (liveness) and GET /health/ready (readiness — 200/503) with Kubernetes and ALB examples - Key rotation / backup expectations for WALLET_ENCRYPTION_KEY, JWT_SEED, and auth nonces Closes #104 Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 17 +++++ Dockerfile | 42 ++++++++++++ docs/PRODUCTION_DEPLOYMENT.md | 122 +++++++++++++++++++++++++++++++++- 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..636cc81 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..49e4c3f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docs/PRODUCTION_DEPLOYMENT.md b/docs/PRODUCTION_DEPLOYMENT.md index 9d55689..a3380d7 100644 --- a/docs/PRODUCTION_DEPLOYMENT.md +++ b/docs/PRODUCTION_DEPLOYMENT.md @@ -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_ACCOUNT_SID=AC... +WHATSAPP_FROM=whatsapp:+1234567890 +ADMIN_API_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) From aeef64ce4df51f59e778419d55eed9bad760cf12 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Fri, 29 May 2026 15:16:22 +0100 Subject: [PATCH 5/9] chore: add pr.md summarising all four production-hardening issues Co-Authored-By: Claude Sonnet 4.6 --- pr.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 pr.md diff --git a/pr.md b/pr.md new file mode 100644 index 0000000..9702e4f --- /dev/null +++ b/pr.md @@ -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) From bd30018d3a00ec82c3b65eefe65e6d635b29ec4e Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Fri, 29 May 2026 15:16:58 +0100 Subject: [PATCH 6/9] done --- package-lock.json | 27 +++++++++++++-------------- package.json | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index e1b4164..66b03da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,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" } @@ -4432,7 +4432,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4620,9 +4619,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6916,9 +6915,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7663,19 +7662,19 @@ } }, "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.3", + "semver": "^7.8.0", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -7692,7 +7691,7 @@ "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { diff --git a/package.json b/package.json index 426c97a..7478114 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,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" } From 46e6f90daf34f81343471a015a1250e5a2186a2d Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Fri, 29 May 2026 16:21:27 +0100 Subject: [PATCH 7/9] fix(ci): add TWILIO_AUTH_TOKEN stub to jest setup files to fix npm test Adding TWILIO_AUTH_TOKEN to requiredVars in env.ts (issue #112) caused all tests that transitively import src/config/env to throw at module load time because the jest setup files didn't provide the new required variable. - Add TWILIO_AUTH_TOKEN stub to tests/setupEnv.ts - Add TWILIO_AUTH_TOKEN stub to tests/unit/config/jest.setup.env.ts - Add TWILIO_AUTH_TOKEN to setValidEnv() in env.test.ts so all existing env validation tests continue to pass - Add test asserting TWILIO_AUTH_TOKEN is required by validateAllRequiredEnvVars Co-Authored-By: Claude Sonnet 4.6 --- tests/setupEnv.ts | 2 ++ tests/unit/config/env.test.ts | 10 ++++++++++ tests/unit/config/jest.setup.env.ts | 3 ++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/setupEnv.ts b/tests/setupEnv.ts index a0dd4e9..993f7b8 100644 --- a/tests/setupEnv.ts +++ b/tests/setupEnv.ts @@ -14,6 +14,8 @@ process.env.WALLET_ENCRYPTION_KEY = process.env.WALLET_ENCRYPTION_KEY ?? 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' +process.env.TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN ?? 'test-twilio-auth-token' + // Generous limits so integration tests are not blocked by rate limiters (#101) process.env.RATE_LIMIT_MAX = '100000' process.env.AUTH_RATE_LIMIT_MAX = '100000' diff --git a/tests/unit/config/env.test.ts b/tests/unit/config/env.test.ts index 4b4c525..2258c4e 100644 --- a/tests/unit/config/env.test.ts +++ b/tests/unit/config/env.test.ts @@ -17,6 +17,7 @@ function setValidEnv() { process.env.DATABASE_URL = 'postgresql://localhost/db' process.env.JWT_SEED = 'seed' process.env.WALLET_ENCRYPTION_KEY = VALID_WALLET_KEY + process.env.TWILIO_AUTH_TOKEN = 'test-twilio-auth-token' process.env.NODE_ENV = 'test' } @@ -86,6 +87,15 @@ describe('Environment Configuration', () => { require('../../../src/config/env') }).toThrow(/WALLET_ENCRYPTION_KEY is invalid/) }) + + it('throws error when TWILIO_AUTH_TOKEN is missing', () => { + setValidEnv() + delete process.env.TWILIO_AUTH_TOKEN + + expect(() => { + require('../../../src/config/env') + }).toThrow(/Missing required environment variable: TWILIO_AUTH_TOKEN/) + }) }) describe('Stellar network validation', () => { diff --git a/tests/unit/config/jest.setup.env.ts b/tests/unit/config/jest.setup.env.ts index 992910d..0e293f2 100644 --- a/tests/unit/config/jest.setup.env.ts +++ b/tests/unit/config/jest.setup.env.ts @@ -24,4 +24,5 @@ process.env.USDC_TOKEN_ADDRESS = process.env.USDC_TOKEN_ADDRESS ?? 'CUSDC_TEST' process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? 'test-anthropic-key' process.env.DATABASE_URL = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/test_db' -process.env.JWT_SEED = process.env.JWT_SEED ?? 'test-jwt-seed-value' \ No newline at end of file +process.env.JWT_SEED = process.env.JWT_SEED ?? 'test-jwt-seed-value' +process.env.TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN ?? 'test-twilio-auth-token' \ No newline at end of file From 479ef5010a36fe18065586f892b5f34d8fa8e137 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Sat, 30 May 2026 20:21:00 +0100 Subject: [PATCH 8/9] fix all --- src/controllers/auth-controller.ts | 15 +++----- src/routes/whatsapp.ts | 7 +++- src/utils/stellar/stellar-verification.ts | 22 +++++++++++ src/whatsapp/__tests__/whatsapp.test.ts | 23 +++++++++++ tests/integration/api/agent-status.test.ts | 11 ++++++ tests/integration/api/auth.test.ts | 45 ++++++++++------------ tests/integration/whatsapp/webhook.test.ts | 6 +-- 7 files changed, 89 insertions(+), 40 deletions(-) diff --git a/src/controllers/auth-controller.ts b/src/controllers/auth-controller.ts index 17756d6..ad7c2cf 100644 --- a/src/controllers/auth-controller.ts +++ b/src/controllers/auth-controller.ts @@ -35,19 +35,14 @@ export async function challenge(req: Request, res: Response): Promise { return; } - // Purge expired nonces (lazy cleanup — harmless if it fails) - db.authNonce - .deleteMany({ where: { expiresAt: { lt: new Date() } } }) - .catch(() => {}); - const nonce = `nw-auth-${randomBytes(32).toString('hex')}`; const expiresAt = new Date(Date.now() + config.jwt.nonce_ttl_ms); - // Upsert so a second challenge for the same key overwrites the old nonce + // Upsert so a second challenge call overwrites the previous nonce await db.authNonce.upsert({ where: { stellarPubKey }, - create: { stellarPubKey, nonce, expiresAt }, update: { nonce, expiresAt }, + create: { stellarPubKey, nonce, expiresAt }, }); logger.info(`[Auth] Challenge issued for ${stellarPubKey}`); @@ -83,7 +78,7 @@ export async function verify(req: Request, res: Response): Promise { return; } - // 1. Look up nonce + // 1. Look up nonce in DB const stored = await db.authNonce.findUnique({ where: { stellarPubKey } }); if (!stored) { res.status(401).json({ error: 'No active challenge for this public key' }); @@ -92,7 +87,7 @@ export async function verify(req: Request, res: Response): Promise { // 2. Check nonce expiry if (stored.expiresAt <= new Date()) { - await db.authNonce.delete({ where: { stellarPubKey } }).catch(() => {}); + await db.authNonce.delete({ where: { stellarPubKey } }); res.status(401).json({ error: 'Challenge nonce has expired' }); return; } @@ -109,7 +104,7 @@ export async function verify(req: Request, res: Response): Promise { } // 4. Consume nonce — prevents replay attacks - await db.authNonce.delete({ where: { stellarPubKey } }).catch(() => {}); + await db.authNonce.delete({ where: { stellarPubKey } }); const network = stellarVerification.resolveNetwork(); diff --git a/src/routes/whatsapp.ts b/src/routes/whatsapp.ts index a3f59c2..9519155 100644 --- a/src/routes/whatsapp.ts +++ b/src/routes/whatsapp.ts @@ -27,7 +27,12 @@ router.post('/webhook', async (req: Request, res: Response) => { return res.status(403).send('Forbidden: TWILIO_AUTH_TOKEN not configured') } - const signature = req.header('x-twilio-signature') ?? '' + const signature = req.header('x-twilio-signature') + + if (!signature) { + return res.status(403).send('Forbidden: x-twilio-signature header is required') + } + const url = `${req.protocol}://${req.get('host')}${req.originalUrl}` const isValid = validateRequest(authToken, signature, url, req.body) diff --git a/src/utils/stellar/stellar-verification.ts b/src/utils/stellar/stellar-verification.ts index 18fc915..d708721 100644 --- a/src/utils/stellar/stellar-verification.ts +++ b/src/utils/stellar/stellar-verification.ts @@ -1,6 +1,18 @@ import { config } from '../../config/env'; import { Keypair } from '@stellar/stellar-sdk'; +export interface NonceEntry { + nonce: string; + expiresAt: number; // ms since epoch + stellarPubKey: string; +} + +/** + * In-memory nonce store. Exported as _nonceStoreForTests so integration tests + * can inspect and pre-populate nonces without hitting the database. + */ +export const _nonceStoreForTests = new Map(); + export default class StellarVerification { /** * Verify a Stellar signature. @@ -22,6 +34,16 @@ export default class StellarVerification { } } + /** Remove all expired nonces (lazy cleanup called from challenge). */ + purgeExpiredNonces(): void { + const now = Date.now(); + for (const [key, entry] of _nonceStoreForTests.entries()) { + if (entry.expiresAt <= now) { + _nonceStoreForTests.delete(key); + } + } + } + /** Map STELLAR_NETWORK env value to Prisma Network enum */ resolveNetwork(): 'MAINNET' | 'TESTNET' | 'FUTURENET' { switch (config.stellar.network.toLowerCase()) { diff --git a/src/whatsapp/__tests__/whatsapp.test.ts b/src/whatsapp/__tests__/whatsapp.test.ts index f3c4fb6..1ac9138 100644 --- a/src/whatsapp/__tests__/whatsapp.test.ts +++ b/src/whatsapp/__tests__/whatsapp.test.ts @@ -1,3 +1,26 @@ +// Prevent Prisma binary from starting — no real DB needed in these tests +jest.mock('../../db', () => ({ __esModule: true, default: {} })) + +// Stub out custodial wallet creation to avoid Stellar network + DB dependency +jest.mock('../../stellar/wallet', () => ({ + createCustodialWallet: jest.fn().mockResolvedValue({ + publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), + getWalletByUserId: jest.fn().mockResolvedValue({ + publicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), +})) + +// Prevent Anthropic SDK from making any network connections on initialization +jest.mock('@anthropic-ai/sdk', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + messages: { + create: jest.fn().mockRejectedValue(new Error('Anthropic not available in tests')), + }, + })), +})) + import express from 'express' import request from 'supertest' import crypto from 'crypto' diff --git a/tests/integration/api/agent-status.test.ts b/tests/integration/api/agent-status.test.ts index 192a644..96b1867 100644 --- a/tests/integration/api/agent-status.test.ts +++ b/tests/integration/api/agent-status.test.ts @@ -47,6 +47,17 @@ import app from '../../../src/index'; describe('Agent Status Endpoint - Real Health Tracking', () => { beforeEach(() => { jest.clearAllMocks(); + // Re-apply default resolutions after clearAllMocks so the route handler + // always receives a resolved promise from the DB mock (Jest 30 clears + // mockResolvedValue implementations when clearAllMocks is called). + mockDb.agentLog.findFirst.mockResolvedValue({ + id: 'log-1', + status: 'SUCCESS', + action: 'ANALYZE', + details: { positionsChecked: 5, rebalancesTriggered: 1 }, + createdAt: new Date('2026-05-26T10:00:00Z'), + }); + mockDb.protocolRate.findMany.mockResolvedValue([]); }); describe('GET /api/protocols/agent/status', () => { diff --git a/tests/integration/api/auth.test.ts b/tests/integration/api/auth.test.ts index 9ac8036..ee63b91 100644 --- a/tests/integration/api/auth.test.ts +++ b/tests/integration/api/auth.test.ts @@ -18,11 +18,9 @@ jest.mock('../../../src/config/jwt-adapter', () => ({ // Mock stellar-verification so signature checks are fully controlled jest.mock('../../../src/utils/stellar/stellar-verification', () => ({ stellarVerification: { - purgeExpiredNonces: jest.fn(), verifyStellarSignature: jest.fn().mockReturnValue(true), resolveNetwork: jest.fn().mockReturnValue('TESTNET'), }, - _nonceStoreForTests: new Map(), })); // Mock Stellar SDK so Keypair.fromPublicKey never throws in challenge @@ -44,6 +42,7 @@ jest.mock('@stellar/stellar-sdk', () => ({ const mockDb = { session: { findUnique: jest.fn(), create: jest.fn(), deleteMany: jest.fn(), delete: jest.fn() }, user: { findUnique: jest.fn(), create: jest.fn() }, + authNonce: { findUnique: jest.fn(), upsert: jest.fn(), delete: jest.fn() }, position: { findMany: jest.fn() }, yieldSnapshot: { findMany: jest.fn() }, transaction: { count: jest.fn(), findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn() }, @@ -64,14 +63,6 @@ const PUB_KEY = 'GABC_VALID_PUBLIC_KEY_FOR_AUTH_TESTS'; const USER_ID = '550e8400-e29b-41d4-a716-446655440004'; const TOKEN = 'auth-test-token-valid'; -// Access the mock nonce store so tests can pre-populate nonces -function getNonceStore(): Map { - const mod = jest.requireMock( - '../../../src/utils/stellar/stellar-verification', - ) as { _nonceStoreForTests: Map }; - return mod._nonceStoreForTests; -} - // A valid session record (used by logout + auth middleware) const SESSION = { id: 'session-auth', @@ -85,13 +76,15 @@ const SESSION = { describe('Auth routes', () => { beforeEach(() => { jest.clearAllMocks(); - getNonceStore().clear(); // Default DB mocks mockDb.user.findUnique.mockResolvedValue({ id: USER_ID, walletAddress: PUB_KEY }); mockDb.user.create.mockResolvedValue({ id: USER_ID, walletAddress: PUB_KEY }); mockDb.session.create.mockResolvedValue({ token: TOKEN }); mockDb.session.findUnique.mockResolvedValue(SESSION); mockDb.session.deleteMany.mockResolvedValue({ count: 1 }); + mockDb.authNonce.findUnique.mockResolvedValue(null); // default: no active nonce + mockDb.authNonce.upsert.mockResolvedValue({}); + mockDb.authNonce.delete.mockResolvedValue({}); }); // ── POST /api/auth/challenge ─────────────────────────────────────────────── @@ -126,11 +119,13 @@ describe('Auth routes', () => { }); }); - it('stores the nonce in the nonce store', async () => { + it('stores the nonce in the DB via upsert', async () => { await request(app) .post('/api/auth/challenge') .send({ stellarPubKey: PUB_KEY }); - expect(getNonceStore().has(PUB_KEY)).toBe(true); + expect(mockDb.authNonce.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { stellarPubKey: PUB_KEY } }), + ); }); }); @@ -161,9 +156,9 @@ describe('Auth routes', () => { }); it('returns 401 when the nonce has expired', async () => { - getNonceStore().set(PUB_KEY, { + mockDb.authNonce.findUnique.mockResolvedValueOnce({ nonce: 'nw-auth-expired', - expiresAt: Date.now() - 1_000, // already expired + expiresAt: new Date(Date.now() - 1_000), // already expired stellarPubKey: PUB_KEY, }); const res = await request(app) @@ -178,9 +173,9 @@ describe('Auth routes', () => { '../../../src/utils/stellar/stellar-verification', ) as any; stellarVerification.verifyStellarSignature.mockReturnValueOnce(false); - getNonceStore().set(PUB_KEY, { + mockDb.authNonce.findUnique.mockResolvedValueOnce({ nonce: 'nw-auth-valid-nonce', - expiresAt: Date.now() + 60_000, + expiresAt: new Date(Date.now() + 60_000), stellarPubKey: PUB_KEY, }); const res = await request(app) @@ -191,9 +186,9 @@ describe('Auth routes', () => { }); it('returns 200 with token and userId on success', async () => { - getNonceStore().set(PUB_KEY, { + mockDb.authNonce.findUnique.mockResolvedValueOnce({ nonce: 'nw-auth-valid-nonce', - expiresAt: Date.now() + 60_000, + expiresAt: new Date(Date.now() + 60_000), stellarPubKey: PUB_KEY, }); const res = await request(app) @@ -209,9 +204,9 @@ describe('Auth routes', () => { it('creates a new user when none exists for this public key', async () => { mockDb.user.findUnique.mockResolvedValue(null); - getNonceStore().set(PUB_KEY, { + mockDb.authNonce.findUnique.mockResolvedValueOnce({ nonce: 'nw-auth-new-user', - expiresAt: Date.now() + 60_000, + expiresAt: new Date(Date.now() + 60_000), stellarPubKey: PUB_KEY, }); await request(app) @@ -221,16 +216,16 @@ describe('Auth routes', () => { }); it('consumes the nonce so it cannot be reused', async () => { - getNonceStore().set(PUB_KEY, { + mockDb.authNonce.findUnique.mockResolvedValueOnce({ nonce: 'nw-auth-one-time', - expiresAt: Date.now() + 60_000, + expiresAt: new Date(Date.now() + 60_000), stellarPubKey: PUB_KEY, }); await request(app) .post('/api/auth/verify') .send({ stellarPubKey: PUB_KEY, signature: 'valid-sig' }); - // nonce should have been deleted - expect(getNonceStore().has(PUB_KEY)).toBe(false); + // nonce should have been deleted from DB + expect(mockDb.authNonce.delete).toHaveBeenCalledWith({ where: { stellarPubKey: PUB_KEY } }); }); }); diff --git a/tests/integration/whatsapp/webhook.test.ts b/tests/integration/whatsapp/webhook.test.ts index 61e3019..c00540d 100644 --- a/tests/integration/whatsapp/webhook.test.ts +++ b/tests/integration/whatsapp/webhook.test.ts @@ -157,15 +157,13 @@ describe('WhatsApp webhook routes', () => { expect(mockHandleMessage).toHaveBeenCalledWith(TWILIO_PAYLOAD.From, ''); }); - it('passes with invalid Twilio signature in non-production environment', async () => { - // In test env (non-production), invalid signatures are still allowed + it('rejects invalid Twilio signature regardless of NODE_ENV', async () => { mockValidateRequest.mockReturnValue(false); const res = await request(app) .post('/api/whatsapp/webhook') .set('x-twilio-signature', 'bad-sig') .send(TWILIO_PAYLOAD); - // NODE_ENV=test → not production, so request still proceeds - expect(res.status).toBe(200); + expect(res.status).toBe(403); }); }); }); From cc5677500315d41ad8992e7d5bfa417915542284 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Sat, 30 May 2026 20:27:31 +0100 Subject: [PATCH 9/9] chore: clean up lint errors from metrics, wallet and whatsapp file --- src/routes/whatsapp.ts | 1 + src/stellar/wallet.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/whatsapp.ts b/src/routes/whatsapp.ts index 9519155..655cfd2 100644 --- a/src/routes/whatsapp.ts +++ b/src/routes/whatsapp.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from 'express' import { validateRequest, twiml } from 'twilio' import { handleWhatsAppMessage } from '../whatsapp/handler' +import { logger } from '../utils/logger' const router = express.Router() diff --git a/src/stellar/wallet.ts b/src/stellar/wallet.ts index d4abba6..22bb44d 100644 --- a/src/stellar/wallet.ts +++ b/src/stellar/wallet.ts @@ -1,6 +1,7 @@ import { Keypair } from '@stellar/stellar-sdk'; import * as crypto from 'crypto'; import db from '../db'; +import { logger } from '../utils/logger'; const ALGORITHM = 'aes-256-gcm'; @@ -68,7 +69,7 @@ export async function createCustodialWallet(userId: string) { }, }); - console.log(`[Wallet] Created for user ${userId}: ${wallet.publicKey}`); + logger.info(`[Wallet] Created for user ${userId}: ${wallet.publicKey}`); return wallet; }