From 732ae3d87bc7dddb1fb40fcab514aa9563bd5928 Mon Sep 17 00:00:00 2001 From: Joaco2603 Date: Mon, 2 Mar 2026 07:43:53 -0600 Subject: [PATCH 1/6] chore: update dependency legacy --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8bfca82..1500a95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4996,9 +4996,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { From 5bd7e24bb3a6102b3673feec18a97e470e63dc3a Mon Sep 17 00:00:00 2001 From: Joaco2603 Date: Mon, 2 Mar 2026 07:47:39 -0600 Subject: [PATCH 2/6] chore: add npm of jsonwebtoken, bcryptjs, @types/jsonwebtoken and @types/bcryptjs --- package-lock.json | 139 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 ++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 1500a95..bca5eff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,17 +12,21 @@ "@anthropic-ai/sdk": "^0.78.0", "@prisma/client": "^5.22.0", "@stellar/stellar-sdk": "^14.5.0", + "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", "winston": "^3.19.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", "jest": "^30.2.0", "nodemon": "^3.1.14", @@ -1446,6 +1450,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1547,6 +1558,24 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", @@ -2222,6 +2251,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -2375,6 +2413,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2963,6 +3007,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4806,6 +4859,49 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -4842,6 +4938,42 @@ "node": ">=8" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -4849,6 +4981,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -5676,7 +5814,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index 243d679..dd2466c 100644 --- a/package.json +++ b/package.json @@ -29,17 +29,21 @@ "@anthropic-ai/sdk": "^0.78.0", "@prisma/client": "^5.22.0", "@stellar/stellar-sdk": "^14.5.0", + "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", "winston": "^3.19.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", "jest": "^30.2.0", "nodemon": "^3.1.14", From 419f0c3b83315cdc12c9ecc1f1c14c3166ebe7ed Mon Sep 17 00:00:00 2001 From: Joaco2603 Date: Mon, 2 Mar 2026 08:09:01 -0600 Subject: [PATCH 3/6] add docker-compose to local db and create firsts files of issue --- .gitignore | 4 ++++ docker-compose.yml | 14 ++++++++++++++ src/middleware/authenticate.ts | 2 ++ src/middleware/index.ts | 3 +++ src/routes/auth.ts | 0 5 files changed, 23 insertions(+) create mode 100644 docker-compose.yml create mode 100644 src/middleware/authenticate.ts create mode 100644 src/middleware/index.ts create mode 100644 src/routes/auth.ts diff --git a/.gitignore b/.gitignore index 178801b..bd3bca9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules .env dist logs/*.log + + +.agents/ +skills-lock.json \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..12e2f6e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3' + +services: + db: + image: postgres:14.4 + restart: always + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME} + container_name: anylistDB + volumes: + - ./postgres:/var/lib/postgresql/data \ No newline at end of file diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts new file mode 100644 index 0000000..139597f --- /dev/null +++ b/src/middleware/authenticate.ts @@ -0,0 +1,2 @@ + + diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..205c091 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,3 @@ +export { logger } from "../utils/logger"; +export { errorHandler } from "./errorHandler"; +export { rateLimiter } from "./rateLimiter"; \ No newline at end of file diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..e69de29 From ff98ae09a290037b9ac2ceecf6b31a6f7560bdf8 Mon Sep 17 00:00:00 2001 From: Joaco2603 Date: Mon, 2 Mar 2026 13:59:23 -0600 Subject: [PATCH 4/6] feature: add auth feature --- .env.example | 16 +- src/config/env.ts | 6 + src/config/index.ts | 2 + src/config/jwt-adapter.ts | 31 ++ src/controllers/__tests__/auth.test.ts | 388 ++++++++++++++++++++++ src/controllers/auth-controller.ts | 192 +++++++++++ src/db/index.ts | 18 + src/index.ts | 40 ++- src/jobs/sessionCleanup.ts | 40 +++ src/middleware/authenticate.ts | 72 ++++ src/routes/auth.ts | 25 ++ src/types/express.d.ts | 10 + src/utils/stellar/stellar-verification.ts | 69 ++++ tsconfig.json | 8 +- 14 files changed, 909 insertions(+), 8 deletions(-) create mode 100644 src/config/index.ts create mode 100644 src/config/jwt-adapter.ts create mode 100644 src/controllers/__tests__/auth.test.ts create mode 100644 src/controllers/auth-controller.ts create mode 100644 src/jobs/sessionCleanup.ts create mode 100644 src/types/express.d.ts create mode 100644 src/utils/stellar/stellar-verification.ts diff --git a/.env.example b/.env.example index 2622bb5..49991a3 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,21 @@ DATABASE_URL=postgresql://postgres:password@localhost:5432/neurowealth # Redis REDIS_URL=redis://localhost:6379 +# JWT +# Generate with: openssl rand -hex 64 +JWT_SEED=generate_with_openssl_rand_hex_64 + +# Wallet encryption +# Generate with: openssl rand -hex 32 +WALLET_ENCRYPTION_KEY=generate_with_openssl_rand_hex_32 + # WhatsApp (optional for local dev) TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= -WHATSAPP_FROM=whatsapp:+14155238886 \ No newline at end of file +WHATSAPP_FROM=whatsapp:+14155238886 + +# JWT +JWT_SEED=your_jwt_secret_seed_here +JWT_SESSION_TTL_HOURS=24 +JWT_NONCE_TTL_MS=300000 +JWT_CLEANUP_INTERVAL_MS=8 \ No newline at end of file diff --git a/src/config/env.ts b/src/config/env.ts index dd1f544..625ebfb 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -27,6 +27,12 @@ export const config = { redis: { url: process.env.REDIS_URL || 'redis://localhost:6379', }, + jwt: { + seed: requireEnv('JWT_SEED'), + session_ttl_hours: parseInt(requireEnv('JWT_SESSION_TTL_HOURS') || '24'), + nonce_ttl_ms: parseInt(requireEnv('JWT_NONCE_TTL_MS') || '300000'), // default to 5 minutes if not set + interval_ms: parseInt(requireEnv('JWT_CLEANUP_INTERVAL_MS') || '86400000') // default to 24 hours if not set + }, whatsapp: { twilioSid: process.env.TWILIO_ACCOUNT_SID || '', twilioToken: process.env.TWILIO_AUTH_TOKEN || '', diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..4ac53bb --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,2 @@ +export { JwtAdapter } from "./jwt-adapter"; +export { config } from "./env"; \ No newline at end of file diff --git a/src/config/jwt-adapter.ts b/src/config/jwt-adapter.ts new file mode 100644 index 0000000..c8919a2 --- /dev/null +++ b/src/config/jwt-adapter.ts @@ -0,0 +1,31 @@ +import jwt from 'jsonwebtoken'; +import { config } from './env'; + +const JWT_SEED = config.jwt.seed; + +export class JwtAdapter { + static async generateToken(payload: Object, durationInHours: number = 2): Promise { + + return new Promise((resolve) => { + jwt.sign(payload, JWT_SEED, { + expiresIn: `${durationInHours}h` + }, (error, token) => { + + if (error) return resolve(null); + + return resolve(token!); + }) + }); + }; + + static validateToken(token: string): Promise { + return new Promise((resolve) => { + jwt.verify(token, JWT_SEED, (error, decoded) => { + + if (error) return resolve(null); + + resolve(decoded as T); + }) + }); + }; +} \ No newline at end of file diff --git a/src/controllers/__tests__/auth.test.ts b/src/controllers/__tests__/auth.test.ts new file mode 100644 index 0000000..6281d5a --- /dev/null +++ b/src/controllers/__tests__/auth.test.ts @@ -0,0 +1,388 @@ +/** + * Auth system tests + * + * Covers: + * - Challenge endpoint + * - Verify endpoint (happy path, expired nonce, invalid signature, replay attack) + * - AuthMiddleware (missing token, invalid format, bad JWT, session not found, expired session, valid flow) + * - Logout endpoint + * + * Stellar signatures are produced with a real ephemeral keypair so we don't need + * to hard-code any secret. Prisma and the logger are mocked to avoid DB / I/O + * dependencies in unit tests. + */ + +import { Request, Response } from 'express'; +import { Keypair } from '@stellar/stellar-sdk'; + +// Prisma mock +const mockPrisma = { + user: { + findUnique: jest.fn(), + create: jest.fn(), + }, + session: { + findUnique: jest.fn(), + create: jest.fn(), + deleteMany: jest.fn(), + delete: jest.fn(), + }, +}; + +jest.mock('@prisma/client', () => ({ + PrismaClient: jest.fn().mockImplementation(() => mockPrisma), + Network: { MAINNET: 'MAINNET', TESTNET: 'TESTNET' }, +})); + +// Logger mock +jest.mock('../../utils/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +// JwtAdapter + config mock +jest.mock('../../config', () => ({ + JwtAdapter: { + generateToken: jest.fn().mockResolvedValue('mock.jwt.token'), + validateToken: jest.fn(), + }, + config: { + stellar: { network: 'testnet' }, + jwt: { + seed: 'test-seed', + session_ttl_hours: 24, + nonce_ttl_ms: 300000, + interval_ms: 86400000, + }, + }, +})); + +// env config mock (used by stellar-verification) +jest.mock('../../config/env', () => ({ + config: { + stellar: { network: 'testnet' }, + jwt: { + seed: 'test-seed', + session_ttl_hours: 24, + nonce_ttl_ms: 300000, + interval_ms: 86400000, + }, + }, +})); + +// Import after mocks +import { challenge, verify, logout, _nonceStoreForTests } from '../../controllers/auth-controller'; +import { AuthMiddleware } from '../../middleware/authenticate'; +import { JwtAdapter } from '../../config'; + +// Helpers + +function makeRes(): Response { + const res: Partial = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + return res as Response; +} + +function makeReq(overrides: Partial = {}): Request { + return { + body: {}, + header: jest.fn().mockReturnValue(undefined), + headers: {}, + ip: '127.0.0.1', + ...overrides, + } as unknown as Request; +} + +// Challenge endpoint + +describe('POST /api/auth/challenge', () => { + const keypair = Keypair.random(); + + beforeEach(() => { + _nonceStoreForTests.clear(); + }); + + it('returns 400 when stellarPubKey is missing', async () => { + const req = makeReq({ body: {} }); + const res = makeRes(); + await challenge(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) })); + }); + + it('returns 400 for an invalid Stellar public key', async () => { + const req = makeReq({ body: { stellarPubKey: 'not-a-valid-key' } }); + const res = makeRes(); + await challenge(req, res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 200 with a nonce and expiresAt for a valid public key', async () => { + const req = makeReq({ body: { stellarPubKey: keypair.publicKey() } }); + const res = makeRes(); + await challenge(req, res); + expect(res.status).toHaveBeenCalledWith(200); + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body).toHaveProperty('nonce'); + expect(body).toHaveProperty('expiresAt'); + expect(body.nonce).toMatch(/^nw-auth-/); + }); + + it('overwrites an existing nonce with a fresh one on a second 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); + }); +}); + +// Verify endpoint + +describe('POST /api/auth/verify', () => { + const keypair = Keypair.random(); + const pubKey = keypair.publicKey(); + + const mockUser = { + id: 'user-uuid-1', + walletAddress: pubKey, + network: 'TESTNET', + }; + + 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({}); + }); + + /** 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(); + await verify(req, res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 401 when no active challenge exists for the public key', async () => { + const sig = Buffer.from(keypair.sign(Buffer.from('irrelevant'))).toString('base64'); + const req = makeReq({ body: { stellarPubKey: pubKey, signature: sig } }); + const res = makeRes(); + await verify(req, res); + expect(res.status).toHaveBeenCalledWith(401); + expect((res.json as jest.Mock).mock.calls[0][0]).toMatchObject({ + error: expect.stringContaining('No active challenge'), + }); + }); + + it('returns 401 for an expired nonce', async () => { + _nonceStoreForTests.set(pubKey, { + nonce: 'old-nonce', + expiresAt: Date.now() - 1, // already expired + stellarPubKey: pubKey, + }); + const sig = Buffer.from(keypair.sign(Buffer.from('old-nonce'))).toString('base64'); + const req = makeReq({ body: { stellarPubKey: pubKey, signature: sig } }); + const res = makeRes(); + await verify(req, res); + expect(res.status).toHaveBeenCalledWith(401); + expect((res.json as jest.Mock).mock.calls[0][0]).toMatchObject({ + error: expect.stringContaining('expired'), + }); + }); + + it('returns 401 for an invalid (tampered) signature', async () => { + seedNonce(pubKey, keypair); + const badSig = Buffer.from('tampered-signature-bytes').toString('base64'); + const req = makeReq({ body: { stellarPubKey: pubKey, signature: badSig } }); + const res = makeRes(); + await verify(req, res); + expect(res.status).toHaveBeenCalledWith(401); + expect((res.json as jest.Mock).mock.calls[0][0]).toMatchObject({ + error: 'Invalid signature', + }); + }); + + it('returns 200 with a token for a valid signature (happy path)', async () => { + const nonce = seedNonce(pubKey, keypair); + const sigBytes = keypair.sign(Buffer.from(nonce, 'utf8')); + const sig = Buffer.from(sigBytes).toString('base64'); + + const req = makeReq({ body: { stellarPubKey: pubKey, signature: sig } }); + const res = makeRes(); + await verify(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body).toHaveProperty('token', 'mock.jwt.token'); + expect(body).toHaveProperty('userId'); + expect(body).toHaveProperty('expiresAt'); + }); + + it('prevents replay attack — second verify with same nonce returns 401', async () => { + const nonce = seedNonce(pubKey, keypair); + const sigBytes = keypair.sign(Buffer.from(nonce, 'utf8')); + const sig = Buffer.from(sigBytes).toString('base64'); + + // First call succeeds + await verify(makeReq({ body: { stellarPubKey: pubKey, signature: sig } }), makeRes()); + + // Second call with same public key — nonce was consumed + const res2 = makeRes(); + await verify(makeReq({ body: { stellarPubKey: pubKey, signature: sig } }), res2); + expect(res2.status).toHaveBeenCalledWith(401); + }); + + it('auto-creates a user when one does not exist', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + const nonce = seedNonce(pubKey, keypair); + const sig = Buffer.from(keypair.sign(Buffer.from(nonce, 'utf8'))).toString('base64'); + + await verify(makeReq({ body: { stellarPubKey: pubKey, signature: sig } }), makeRes()); + + expect(mockPrisma.user.create).toHaveBeenCalledTimes(1); + }); + + it('does not create a duplicate user when one already exists', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + + const nonce = seedNonce(pubKey, keypair); + const sig = Buffer.from(keypair.sign(Buffer.from(nonce, 'utf8'))).toString('base64'); + + await verify(makeReq({ body: { stellarPubKey: pubKey, signature: sig } }), makeRes()); + + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + }); +}); + +// AuthMiddleware + +describe('AuthMiddleware.validateJwt', () => { + const next = jest.fn(); + + const mockSession = { + token: 'valid.token', + expiresAt: new Date(Date.now() + 60_000), + walletAddress: 'GBTEST', + user: { id: 'user-1', walletAddress: 'GBTEST' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns 401 when Authorization header is missing', async () => { + const req = makeReq({ header: jest.fn().mockReturnValue(undefined) }); + const res = makeRes(); + await AuthMiddleware.validateJwt(req, res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 when Authorization header does not start with "Bearer "', async () => { + const req = makeReq({ header: jest.fn().mockReturnValue('Token abc123') }); + const res = makeRes(); + await AuthMiddleware.validateJwt(req, res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 when JWT signature is invalid', async () => { + (JwtAdapter.validateToken as jest.Mock).mockResolvedValue(null); + const req = makeReq({ header: jest.fn().mockReturnValue('Bearer bad.token') }); + const res = makeRes(); + await AuthMiddleware.validateJwt(req, res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 when session is not found in the database', async () => { + (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user-1' }); + mockPrisma.session.findUnique.mockResolvedValue(null); + + const req = makeReq({ header: jest.fn().mockReturnValue('Bearer valid.jwt') }); + const res = makeRes(); + await AuthMiddleware.validateJwt(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect((res.json as jest.Mock).mock.calls[0][0]).toMatchObject({ error: 'Session not found' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 and deletes the record when the session is expired', async () => { + (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user-1' }); + mockPrisma.session.findUnique.mockResolvedValue({ + ...mockSession, + expiresAt: new Date(Date.now() - 1000), // expired + }); + mockPrisma.session.delete.mockResolvedValue({}); + + const req = makeReq({ header: jest.fn().mockReturnValue('Bearer expired.token') }); + const res = makeRes(); + await AuthMiddleware.validateJwt(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect((res.json as jest.Mock).mock.calls[0][0]).toMatchObject({ error: 'Session expired' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() and attaches userId + stellarPubKey for a valid token', async () => { + (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user-1' }); + mockPrisma.session.findUnique.mockResolvedValue(mockSession); + + const req = makeReq({ header: jest.fn().mockReturnValue('Bearer valid.token') }); + const res = makeRes(); + await AuthMiddleware.validateJwt(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.userId).toBe('user-1'); + expect(req.stellarPubKey).toBe('GBTEST'); + }); +}); + +// Logout endpoint + +describe('POST /api/auth/logout', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.session.deleteMany.mockResolvedValue({ count: 1 }); + }); + + it('deletes the session and returns 200', async () => { + const req = makeReq({ + header: jest.fn().mockReturnValue('Bearer valid.token'), + userId: 'user-1', + }); + const res = makeRes(); + await logout(req, res); + + expect(mockPrisma.session.deleteMany).toHaveBeenCalledWith({ + where: { token: 'valid.token' }, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect((res.json as jest.Mock).mock.calls[0][0]).toMatchObject({ + message: 'Logged out successfully', + }); + }); +}); diff --git a/src/controllers/auth-controller.ts b/src/controllers/auth-controller.ts new file mode 100644 index 0000000..3cabb55 --- /dev/null +++ b/src/controllers/auth-controller.ts @@ -0,0 +1,192 @@ +import { Request, Response } from 'express'; +import { randomBytes } from 'crypto'; +import { Keypair } from '@stellar/stellar-sdk'; +import { PrismaClient } from '@prisma/client'; +import { JwtAdapter, config } from '../config'; +import { logger } from '../utils/logger'; +import { + stellarVerification, + _nonceStoreForTests as nonceStore, +} from '../utils/stellar/stellar-verification'; + +const prisma = new PrismaClient(); + +// Controllers + +/** + * POST /api/auth/challenge + * + * Body: { stellarPubKey: string } + * Returns: { nonce: string, expiresAt: ISO-8601 } + * + * 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. + */ +export async function challenge(req: Request, res: Response): Promise { + const { stellarPubKey } = req.body as { stellarPubKey?: string }; + + if (!stellarPubKey) { + res.status(400).json({ error: 'stellarPubKey is required' }); + return; + } + + // Validate the public key format + try { + Keypair.fromPublicKey(stellarPubKey); + } catch { + res.status(400).json({ error: 'Invalid Stellar public key' }); + return; + } + + stellarVerification.purgeExpiredNonces(); + + const nonce = `nw-auth-${randomBytes(32).toString('hex')}`; + const expiresAt = Date.now() + config.jwt.nonce_ttl_ms; + + nonceStore.set(stellarPubKey, { nonce, expiresAt, stellarPubKey }); + + logger.info(`[Auth] Challenge issued for ${stellarPubKey}`); + + res.status(200).json({ + nonce, + expiresAt: new Date(expiresAt).toISOString(), + }); +} + +/** + * POST /api/auth/verify + * + * Body: { stellarPubKey: string, signature: string (base64) } + * Returns: { token: string, userId: string, expiresAt: ISO-8601 } + * + * Steps: + * 1. Look up stored nonce for this public key. + * 2. Reject expired nonces (replay prevention). + * 3. Verify Stellar signature over the nonce. + * 4. Consume nonce to prevent reuse. + * 5. Create or retrieve user + portfolio position. + * 6. Issue JWT and store session in DB. + */ +export async function verify(req: Request, res: Response): Promise { + const { stellarPubKey, signature } = req.body as { + stellarPubKey?: string; + signature?: string; + }; + + if (!stellarPubKey || !signature) { + res.status(400).json({ error: 'stellarPubKey and signature are required' }); + return; + } + + // 1. Look up nonce + const stored = nonceStore.get(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); + res.status(401).json({ error: 'Challenge nonce has expired' }); + return; + } + + // 3. Verify Stellar signature + const isValid = stellarVerification.verifyStellarSignature( + stellarPubKey, + stored.nonce, + signature, + ); + if (!isValid) { + res.status(401).json({ error: 'Invalid signature' }); + return; + } + + // 4. Consume nonce — prevents replay attacks + nonceStore.delete(stellarPubKey); + + const network = stellarVerification.resolveNetwork(); + + try { + // 5. Create or fetch user + let user = await prisma.user.findUnique({ + where: { walletAddress: stellarPubKey }, + }); + + if (!user) { + // Auto-create user + empty portfolio position + user = await prisma.user.create({ + data: { + walletAddress: stellarPubKey, + network, + positions: { + create: { + protocolName: 'unassigned', + assetSymbol: 'USDC', + depositedAmount: 0, + currentValue: 0, + }, + }, + }, + }); + logger.info(`[Auth] New user created: ${user.id} (${stellarPubKey})`); + } + + // 6. Issue JWT + const expiresAt = new Date(Date.now() + config.jwt.session_ttl_hours * 60 * 60 * 1000); + const token = await JwtAdapter.generateToken({ id: user.id }, config.jwt.session_ttl_hours); + + if (!token) { + res.status(500).json({ error: 'Failed to generate token' }); + return; + } + + // 7. Persist session + await prisma.session.create({ + data: { + userId: user.id, + token, + walletAddress: stellarPubKey, + network, + expiresAt, + ipAddress: req.ip ?? null, + userAgent: req.headers['user-agent'] ?? null, + }, + }); + + logger.info(`[Auth] Session created for user ${user.id}`); + + res.status(200).json({ + token, + userId: user.id, + expiresAt: expiresAt.toISOString(), + }); + } catch (error) { + logger.error('[Auth] Verify error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +/** + * POST /api/auth/logout + * + * Requires a valid JWT (via AuthMiddleware.validateJwt). + * Deletes the session from the database so the token cannot be reused. + */ +export async function logout(req: Request, res: Response): Promise { + const authorization = req.header('Authorization') ?? ''; + const token = authorization.split(' ')[1] ?? ''; + + try { + await prisma.session.deleteMany({ where: { token } }); + logger.info(`[Auth] Session revoked for user ${req.userId}`); + res.status(200).json({ message: 'Logged out successfully' }); + } catch (error) { + logger.error('[Auth] Logout error:', error); + 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/db/index.ts b/src/db/index.ts index f50de87..59e43ac 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -2,6 +2,7 @@ // Prisma Client Singleton — prevents multiple instances in dev (hot-reload safe) import { PrismaClient } from '@prisma/client' +import { logger } from '../utils/logger' const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } @@ -18,4 +19,21 @@ if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = db } +/** + * Verify the database is reachable before the server accepts traffic. + * Calls process.exit(1) with a clear message if the connection fails. + */ +export async function connectDb(): Promise { + try { + await db.$connect() + logger.info('[DB] Connected to database') + } catch (error) { + logger.error('[DB] Cannot connect to database — server will not start') + logger.error(`[DB] ${error instanceof Error ? error.message : String(error)}`) + logger.error('[DB] Check that DATABASE_URL is correct and the database is running') + await db.$disconnect() + process.exit(1) + } +} + export default db \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 9f84228..e63973e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,8 +5,12 @@ import { config } from './config/env' import { errorHandler } from './middleware/errorHandler' import { requestLogger } from './middleware/logger' import { rateLimiter } from './middleware/rateLimiter' +import { AuthMiddleware } from './middleware/authenticate' import { logger } from './utils/logger' +import { connectDb } from './db' +import { scheduleSessionCleanup } from './jobs/sessionCleanup' import healthRouter from './routes/health' +import authRouter from './routes/auth' const app = express() @@ -19,17 +23,41 @@ app.use(express.json()) app.use(requestLogger) app.use(rateLimiter) -// Routes +// Public routes app.use('/health', healthRouter) +app.use('/api/auth', authRouter) + +// Protected routes (require valid JWT) +// All routes mounted below this line are automatically protected. +app.use('/api/portfolio', AuthMiddleware.validateJwt) +app.use('/api/transactions', AuthMiddleware.validateJwt) +app.use('/api/deposit', AuthMiddleware.validateJwt) +app.use('/api/withdraw', AuthMiddleware.validateJwt) + +// TODO: mount actual portfolio / transaction / deposit / withdraw routers here +// e.g. app.use('/api/portfolio', portfolioRouter) // Global error handler — must always be last app.use(errorHandler) -// Start server -app.listen(config.port, () => { - logger.info(`NeuroWealth backend running on port ${config.port}`) - logger.info(`Environment: ${config.nodeEnv}`) - logger.info(`Network: ${config.stellar.network}`) +async function main() { + // Database connectivity check + await connectDb() + + // Background jobs + scheduleSessionCleanup() + + // Start HTTP server + app.listen(config.port, () => { + logger.info(`NeuroWealth backend running on port ${config.port}`) + logger.info(`Environment: ${config.nodeEnv}`) + logger.info(`Network: ${config.stellar.network}`) + }) +} + +main().catch((error) => { + logger.error('[Startup] Unexpected error:', error) + process.exit(1) }) export default app \ No newline at end of file diff --git a/src/jobs/sessionCleanup.ts b/src/jobs/sessionCleanup.ts new file mode 100644 index 0000000..904145a --- /dev/null +++ b/src/jobs/sessionCleanup.ts @@ -0,0 +1,40 @@ +import { PrismaClient } from '@prisma/client'; +import { logger } from '../utils/logger'; +import { config } from '../config/env'; + +const prisma = new PrismaClient(); + +/** + * Delete all sessions whose expiration timestamp is in the past. + * Safe to call multiple times — it is idempotent. + */ +export async function cleanupExpiredSessions(): Promise { + try { + const result = await prisma.session.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + if (result.count > 0) { + logger.info(`[SessionCleanup] Removed ${result.count} expired session(s)`); + } + } catch (error) { + logger.error('[SessionCleanup] Failed to clean up sessions:', error); + } +} + +/** + * Schedule the session cleanup job to run once every 24 hours. + * Also runs immediately on startup to handle any sessions that expired + * while the server was offline. + * + * @returns A NodeJS.Timeout handle (call clearInterval to stop it). + */ +export function scheduleSessionCleanup(): NodeJS.Timeout { + // Run once at startup + cleanupExpiredSessions(); + + // Then run every 24 hours + const handle = setInterval(cleanupExpiredSessions, config.jwt.interval_ms); + + logger.info('[SessionCleanup] Daily cleanup scheduled'); + return handle; +} diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts index 139597f..b15e4bf 100644 --- a/src/middleware/authenticate.ts +++ b/src/middleware/authenticate.ts @@ -1,2 +1,74 @@ +import { NextFunction, Request, Response } from 'express'; +import { JwtAdapter } from '../config'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +export class AuthMiddleware { + /** + * Validate JWT from Authorization header, verify session exists in DB and + * has not expired, then attach userId and stellarPubKey to the request. + * + * Returns 401 for: + * - Missing / malformed token + * - Invalid JWT signature + * - Session not found + * - Session expired + */ + static validateJwt = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + const authorization = req.header('Authorization'); + + if (!authorization) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + if (!authorization.startsWith('Bearer ')) { + res.status(401).json({ error: 'Invalid Bearer token' }); + return; + } + + const token = authorization.split(' ')[1] ?? ''; + + try { + // 1. Verify JWT signature and decode payload + const payload = await JwtAdapter.validateToken<{ id: string }>(token); + if (!payload) { + res.status(401).json({ error: 'Invalid token' }); + return; + } + + // 2. Look up live session in the database (prevents session reuse after logout) + const session = await prisma.session.findUnique({ + where: { token }, + include: { user: true }, + }); + + if (!session) { + res.status(401).json({ error: 'Session not found' }); + return; + } + + // 3. Reject expired sessions + if (session.expiresAt < new Date()) { + // Clean up the stale session row + await prisma.session.delete({ where: { token } }).catch(() => undefined); + res.status(401).json({ error: 'Session expired' }); + return; + } + + // 4. Attach authenticated identity to request + req.userId = session.user.id; + req.stellarPubKey = session.walletAddress; + + next(); + } catch (error) { + console.error('[Auth] Middleware error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }; +} \ No newline at end of file diff --git a/src/routes/auth.ts b/src/routes/auth.ts index e69de29..d6924ed 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { challenge, verify, logout } from '../controllers/auth-controller'; +import { AuthMiddleware } from '../middleware/authenticate'; + +const router = Router(); + +/** + * POST /api/auth/challenge + * Returns a one-time nonce to be signed by the Stellar keypair. + */ +router.post('/challenge', challenge); + +/** + * POST /api/auth/verify + * Verifies Stellar signature, creates/fetches user, issues JWT. + */ +router.post('/verify', verify); + +/** + * POST /api/auth/logout + * Revokes the active session. Requires a valid Bearer token. + */ +router.post('/logout', AuthMiddleware.validateJwt, logout); + +export default router; diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 0000000..6562c1b --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,10 @@ +export {}; + +declare module 'express-serve-static-core' { + interface Request { + /** Authenticated user ID (UUID) */ + userId?: string; + /** Authenticated user's Stellar public key */ + stellarPubKey?: string; + } +} diff --git a/src/utils/stellar/stellar-verification.ts b/src/utils/stellar/stellar-verification.ts new file mode 100644 index 0000000..8054300 --- /dev/null +++ b/src/utils/stellar/stellar-verification.ts @@ -0,0 +1,69 @@ +import { Network } from '@prisma/client'; +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. + */ + verifyStellarSignature( + publicKey: string, + message: string, + signatureBase64: string, + ): boolean { + try { + const keypair = Keypair.fromPublicKey(publicKey); + const messageBytes = Buffer.from(message, 'utf8'); + const signatureBytes = Buffer.from(signatureBase64, 'base64'); + return keypair.verify(messageBytes, signatureBytes); + } catch { + return false; + } + } + + /** Map STELLAR_NETWORK env value to Prisma Network enum */ + resolveNetwork(): Network { + return config.stellar.network.toLowerCase() === 'mainnet' + ? Network.MAINNET + : Network.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 diff --git a/tsconfig.json b/tsconfig.json index d1b16ac..47ed1eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,12 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, + "files": [ + "src/types/express.d.ts" + ], "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist"], + "ts-node": { + "files": true + } } \ No newline at end of file From bf4d3250dcd88d1156260051580f31401b9dc6df Mon Sep 17 00:00:00 2001 From: Joaco2603 Date: Mon, 2 Mar 2026 16:16:10 -0600 Subject: [PATCH 5/6] feature: add migration and fix initialize bugs --- .env.example | 10 +- .gitignore | 3 +- docker-compose.yml | 2 +- .../20260302221454_init/migration.sql | 207 ++++++++++++++++++ prisma/migrations/migration_lock.toml | 3 + 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260302221454_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml diff --git a/.env.example b/.env.example index 49991a3..a23c178 100644 --- a/.env.example +++ b/.env.example @@ -36,4 +36,12 @@ WHATSAPP_FROM=whatsapp:+14155238886 JWT_SEED=your_jwt_secret_seed_here JWT_SESSION_TTL_HOURS=24 JWT_NONCE_TTL_MS=300000 -JWT_CLEANUP_INTERVAL_MS=8 \ No newline at end of file +JWT_CLEANUP_INTERVAL_MS=8 + +# Docker / Postgres (used by docker-compose.yml) +# Database name used by the Postgres container +DB_NAME=postgres +# Postgres password for the `postgres` user (set to a secure value in production) +DB_PASSWORD=password +# Name of the Postgres container (used for Docker networking) +DB_CONTAINER_NAME=neurowealth_db diff --git a/.gitignore b/.gitignore index bd3bca9..0ca0743 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ logs/*.log .agents/ -skills-lock.json \ No newline at end of file +skills-lock.json +postgres/ \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 12e2f6e..03a5830 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,6 @@ services: environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_NAME} - container_name: anylistDB + container_name: ${DB_CONTAINER_NAME} volumes: - ./postgres:/var/lib/postgresql/data \ No newline at end of file diff --git a/prisma/migrations/20260302221454_init/migration.sql b/prisma/migrations/20260302221454_init/migration.sql new file mode 100644 index 0000000..c92a550 --- /dev/null +++ b/prisma/migrations/20260302221454_init/migration.sql @@ -0,0 +1,207 @@ +-- CreateEnum +CREATE TYPE "Network" AS ENUM ('MAINNET', 'TESTNET', 'FUTURENET'); + +-- CreateEnum +CREATE TYPE "TransactionType" AS ENUM ('DEPOSIT', 'WITHDRAWAL', 'YIELD_CLAIM', 'REBALANCE', 'SWAP'); + +-- CreateEnum +CREATE TYPE "TransactionStatus" AS ENUM ('PENDING', 'CONFIRMED', 'FAILED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "PositionStatus" AS ENUM ('ACTIVE', 'CLOSED', 'LIQUIDATED'); + +-- CreateEnum +CREATE TYPE "AgentAction" AS ENUM ('DEPOSIT', 'WITHDRAW', 'REBALANCE', 'ANALYZE', 'ALERT', 'CLAIM_YIELD'); + +-- CreateEnum +CREATE TYPE "AgentStatus" AS ENUM ('SUCCESS', 'FAILED', 'SKIPPED'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "walletAddress" TEXT NOT NULL, + "network" "Network" NOT NULL DEFAULT 'MAINNET', + "displayName" TEXT, + "email" TEXT, + "avatarUrl" TEXT, + "riskTolerance" INTEGER NOT NULL DEFAULT 5, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "walletAddress" TEXT NOT NULL, + "network" "Network" NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "positions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "protocolName" TEXT NOT NULL, + "assetSymbol" TEXT NOT NULL, + "assetAddress" TEXT, + "depositedAmount" DECIMAL(36,18) NOT NULL, + "currentValue" DECIMAL(36,18) NOT NULL, + "yieldEarned" DECIMAL(36,18) NOT NULL DEFAULT 0, + "status" "PositionStatus" NOT NULL DEFAULT 'ACTIVE', + "openedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "closedAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "positions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "transactions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "positionId" TEXT, + "txHash" TEXT, + "type" "TransactionType" NOT NULL, + "status" "TransactionStatus" NOT NULL DEFAULT 'PENDING', + "assetSymbol" TEXT NOT NULL, + "amount" DECIMAL(36,18) NOT NULL, + "fee" DECIMAL(36,18), + "network" "Network" NOT NULL, + "protocolName" TEXT, + "memo" TEXT, + "confirmedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "transactions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "yield_snapshots" ( + "id" TEXT NOT NULL, + "positionId" TEXT NOT NULL, + "apy" DECIMAL(10,6) NOT NULL, + "yieldAmount" DECIMAL(36,18) NOT NULL, + "principalAmount" DECIMAL(36,18) NOT NULL, + "snapshotAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "yield_snapshots_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "protocol_rates" ( + "id" TEXT NOT NULL, + "protocolName" TEXT NOT NULL, + "assetSymbol" TEXT NOT NULL, + "supplyApy" DECIMAL(10,6) NOT NULL, + "borrowApy" DECIMAL(10,6), + "tvl" DECIMAL(36,2), + "network" "Network" NOT NULL, + "fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "protocol_rates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "agent_logs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "action" "AgentAction" NOT NULL, + "status" "AgentStatus" NOT NULL, + "reasoning" TEXT, + "inputData" JSONB, + "outputData" JSONB, + "errorMessage" TEXT, + "durationMs" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "agent_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_walletAddress_key" ON "users"("walletAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE INDEX "users_walletAddress_idx" ON "users"("walletAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token"); + +-- CreateIndex +CREATE INDEX "sessions_token_idx" ON "sessions"("token"); + +-- CreateIndex +CREATE INDEX "sessions_userId_idx" ON "sessions"("userId"); + +-- CreateIndex +CREATE INDEX "positions_userId_idx" ON "positions"("userId"); + +-- CreateIndex +CREATE INDEX "positions_protocolName_idx" ON "positions"("protocolName"); + +-- CreateIndex +CREATE UNIQUE INDEX "transactions_txHash_key" ON "transactions"("txHash"); + +-- CreateIndex +CREATE INDEX "transactions_userId_idx" ON "transactions"("userId"); + +-- CreateIndex +CREATE INDEX "transactions_txHash_idx" ON "transactions"("txHash"); + +-- CreateIndex +CREATE INDEX "transactions_positionId_idx" ON "transactions"("positionId"); + +-- CreateIndex +CREATE INDEX "yield_snapshots_positionId_idx" ON "yield_snapshots"("positionId"); + +-- CreateIndex +CREATE INDEX "yield_snapshots_snapshotAt_idx" ON "yield_snapshots"("snapshotAt"); + +-- CreateIndex +CREATE INDEX "protocol_rates_protocolName_assetSymbol_idx" ON "protocol_rates"("protocolName", "assetSymbol"); + +-- CreateIndex +CREATE INDEX "protocol_rates_fetchedAt_idx" ON "protocol_rates"("fetchedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "protocol_rates_protocolName_assetSymbol_network_fetchedAt_key" ON "protocol_rates"("protocolName", "assetSymbol", "network", "fetchedAt"); + +-- CreateIndex +CREATE INDEX "agent_logs_userId_idx" ON "agent_logs"("userId"); + +-- CreateIndex +CREATE INDEX "agent_logs_action_idx" ON "agent_logs"("action"); + +-- CreateIndex +CREATE INDEX "agent_logs_createdAt_idx" ON "agent_logs"("createdAt"); + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "positions" ADD CONSTRAINT "positions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_positionId_fkey" FOREIGN KEY ("positionId") REFERENCES "positions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "yield_snapshots" ADD CONSTRAINT "yield_snapshots_positionId_fkey" FOREIGN KEY ("positionId") REFERENCES "positions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_logs" ADD CONSTRAINT "agent_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file From b722e611c03cc81e414aa4ece210dcc1af2124aa Mon Sep 17 00:00:00 2001 From: Joaco2603 Date: Mon, 2 Mar 2026 16:19:18 -0600 Subject: [PATCH 6/6] chore: add readme --- readme.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 readme.md diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..6cba620 --- /dev/null +++ b/readme.md @@ -0,0 +1,115 @@ + +# NeuroWealth — Backend + +About +----- +NeuroWealth is an autonomous AI investment agent that automatically manages and grows users' crypto assets on the Stellar blockchain. Deposit once, the AI finds the best yield opportunities across Stellar's DeFi ecosystem; users can withdraw anytime with no lock-ups. + +This repository contains the backend API (Express + TypeScript), Stellar integration, Prisma schema and migrations, and utilities for authentication (Stellar signature challenge + JWT sessions). + +Quickstart +---------- +1. Copy the example environment file and adjust secrets: + +```powershell +copy .env.example .env +``` + +2. Edit `.env` and set secure values: +- `DATABASE_URL` — PostgreSQL connection string (see below) +- `DB_NAME`, `DB_PASSWORD` — used by `docker-compose.yml` when running Postgres locally +- `JWT_SEED` — 64-hex secret (generate with `openssl rand -hex 64`) +- `WALLET_ENCRYPTION_KEY` — 32-byte hex (generate with `openssl rand -hex 32`) + +Docker (Postgres) +------------------ +To run a local Postgres instance used by the project: + +```powershell +docker compose up -d +docker compose ps +docker compose logs anylistDB --tail 200 +``` + +The `docker-compose.yml` expects these env vars (set them in your `.env`): + +``` +DB_NAME=neurowealth +DB_PASSWORD=postgres_password_here +DATABASE_URL=postgresql://postgres:postgres_password_here@localhost:5432/neurowealth +``` + +Prisma & Database migrations +---------------------------- +Generate the Prisma client (run after any `schema.prisma` change): + +```bash +npx prisma generate +``` + +Create and apply a migration (development): + +```bash +npx prisma migrate dev --name init +``` + +Notes: +- `migrate dev` will create a new migration in `prisma/migrations/` and apply it to the database specified by `DATABASE_URL`. +- To reset a development database (WARNING: destroys data): + +```bash +npx prisma migrate reset +# or if your Prisma version requires preview option +npx prisma migrate reset --preview-feature +``` + +Apply migrations in production (use CI or a deployment task): + +```bash +npx prisma migrate deploy +``` + +Seeding +------- +If you have a seed script (see `prisma/seed.ts`), run: + +```bash +npx prisma db seed +``` + +Running the backend +------------------- +Development (with ts-node + nodemon): + +```bash +npm install +npm run dev +``` + +Build and run: + +```bash +npm run build +npm start +``` + +Testing +------- +Run unit tests (Jest): + +```bash +npm test +``` + +Auth overview (short) +--------------------- +- `POST /api/auth/challenge` — client posts `stellarPubKey`, server returns a one-time `nonce`. +- Client signs `nonce` with their Stellar key (Freighter) and sends signature to `POST /api/auth/verify`. +- Server verifies signature, creates user if missing, issues JWT (stored as a session in DB). +- Protected endpoints require `Authorization: Bearer ` and are validated against the `sessions` table; logout removes the session. + +Troubleshooting +--------------- +- If the app logs `Cannot connect to database`, check `DATABASE_URL`, and that Postgres is running (Docker or external). +- If migrating fails, confirm the DB user has permission to CREATE/ALTER tables. +- Ensure `JWT_SEED` and `WALLET_ENCRYPTION_KEY` are set when running the server.