From 450ce883b6ffa6c8d5eea090aa0826259b20e4e4 Mon Sep 17 00:00:00 2001 From: Ayush Date: Tue, 2 Jun 2026 03:04:15 +0530 Subject: [PATCH 1/2] FIxed the repose type of all Middleware & controller all over the project --- .../src/__tests__/auth.controller.test.js | 120 +- .../src/__tests__/authMiddleware.test.js | 50 +- .../src/__tests__/auth_limiter.test.js | 50 +- .../src/__tests__/billing.controller.test.js | 8 +- .../src/__tests__/userAuth.controller.test.js | 104 +- .../src/__tests__/webhook.controller.test.js | 113 +- apps/dashboard-api/src/app.js | 19 +- .../controllers/admin.metrics.controller.js | 145 +- .../src/controllers/analytics.controller.js | 114 +- .../src/controllers/auth.controller.js | 137 +- .../src/controllers/billing.controller.js | 6 +- .../src/controllers/events.controller.js | 14 +- .../src/controllers/project.controller.js | 410 ++-- .../src/controllers/release.controller.js | 15 +- .../src/controllers/userAuth.controller.js | 144 +- .../src/controllers/webhook.controller.js | 86 +- .../src/middlewares/authMiddleware.js | 5 +- .../src/middlewares/auth_limiter.js | 5 +- .../src/middlewares/loadProjectForAdmin.js | 7 +- .../src/__tests__/aggregation.test.js | 36 +- .../__tests__/authorizeReadOperation.test.js | 13 +- .../__tests__/authorizeWriteOperation.test.js | 38 +- .../__tests__/data.controller.read.test.js | 46 +- .../src/__tests__/health.route.test.js | 26 +- .../src/__tests__/mail.controller.test.js | 57 +- .../src/__tests__/softDelete.test.js | 25 +- .../src/__tests__/storage.controller.test.js | 203 +- .../src/__tests__/userAuth.email.test.js | 113 +- .../src/__tests__/userAuth.refresh.test.js | 47 +- .../src/__tests__/userAuth.social.test.js | 64 +- .../src/__tests__/verifyApiKey.test.js | 19 +- apps/public-api/src/app.js | 2 +- .../src/controllers/data.controller.js | 115 +- .../src/controllers/health.controller.js | 15 +- .../src/controllers/mail.controller.js | 290 ++- .../src/controllers/schema.controller.js | 32 +- .../src/controllers/storage.controller.js | 97 +- .../src/controllers/userAuth.controller.js | 1722 +++++++++++++++++ .../src/middlewares/authorizeReadOperation.js | 12 +- .../middlewares/authorizeWriteOperation.js | 57 +- .../blockUsersCollectionDataAccess.js | 6 +- .../src/middlewares/projectRateLimiter.js | 6 +- .../src/middlewares/requireSecretKey.js | 5 +- .../src/middlewares/verifyApiKey.js | 21 +- docker-compose.yml | 4 + packages/common/src/index.js | 2 + .../common/src/middleware/checkAuthEnabled.js | 17 +- .../src/middleware/loadProjectForAdmin.js | 7 +- .../src/middleware/standardizeApiResponse.js | 18 +- packages/common/src/middleware/verifyEmail.js | 4 +- packages/common/src/utils/ApiResponse.js | 26 + packages/common/src/utils/AppError.js | 3 +- 52 files changed, 3178 insertions(+), 1522 deletions(-) create mode 100644 packages/common/src/utils/ApiResponse.js diff --git a/apps/dashboard-api/src/__tests__/auth.controller.test.js b/apps/dashboard-api/src/__tests__/auth.controller.test.js index 40c70c5b7..7c965836a 100644 --- a/apps/dashboard-api/src/__tests__/auth.controller.test.js +++ b/apps/dashboard-api/src/__tests__/auth.controller.test.js @@ -59,11 +59,30 @@ jest.mock('@urbackend/common', () => { create: jest.fn().mockResolvedValue(undefined), }; + class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } + } + return { Developer, Otp, Project, PlatformEvent, + AppError, + ApiResponse: class ApiResponse { + constructor(data = {}, message = 'Success') { + this.data = data; + this.message = message; + this.success = true; + } + send(res, statusCode = 200) { + return res.status(statusCode).json({ success: this.success, data: this.data, message: this.message }); + } + }, sendOtp: jest.fn().mockResolvedValue(undefined), // Use real zod shapes so validation logic is exercised. loginSchema: z.object({ @@ -98,7 +117,7 @@ jest.mock('@urbackend/common', () => { const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); -const { Developer, Otp, Project, sendOtp } = require('@urbackend/common'); +const { Developer, Otp, Project, sendOtp, AppError } = require('@urbackend/common'); const authController = require('../controllers/auth.controller'); // --------------------------------------------------------------------------- @@ -124,6 +143,8 @@ const makeReq = (body = {}, user = null, cookies = {}) => ({ cookies, }); +const next = jest.fn(); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -149,7 +170,7 @@ describe('auth.controller', () => { const req = makeReq({ email: 'new@example.com', password: 'password123' }); const res = makeRes(); - await authController.register(req, res); + await authController.register(req, res, next); expect(Developer.findOne).toHaveBeenCalledWith({ email: 'new@example.com' }); expect(res.status).toHaveBeenCalledWith(201); @@ -162,19 +183,20 @@ describe('auth.controller', () => { const req = makeReq({ email: 'existing@example.com', password: 'password123' }); const res = makeRes(); - await authController.register(req, res); + await authController.register(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Email already exists' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns 400 on Zod validation error (invalid email)', async () => { const req = makeReq({ email: 'not-an-email', password: 'password123' }); const res = makeRes(); - await authController.register(req, res); + await authController.register(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); @@ -199,7 +221,7 @@ describe('auth.controller', () => { const req = makeReq({ email: 'test@example.com', password: 'correctpass' }); const res = makeRes(); - await authController.login(req, res); + await authController.login(req, res, next); expect(bcrypt.compare).toHaveBeenCalledWith('correctpass', 'hashed_password'); expect(res.status).toHaveBeenCalledWith(200); @@ -216,10 +238,11 @@ describe('auth.controller', () => { const req = makeReq({ email: 'noone@example.com', password: 'pass' }); const res = makeRes(); - await authController.login(req, res); + await authController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('User not found'); }); test('returns 400 on invalid password', async () => { @@ -229,19 +252,21 @@ describe('auth.controller', () => { const req = makeReq({ email: 'test@example.com', password: 'wrongpass' }); const res = makeRes(); - await authController.login(req, res); + await authController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid password' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Invalid password'); }); test('returns 400 on Zod validation error (missing password)', async () => { const req = makeReq({ email: 'test@example.com' }); const res = makeRes(); - await authController.login(req, res); + await authController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); @@ -266,7 +291,7 @@ describe('auth.controller', () => { const req = makeReq({}, null, { refreshToken: 'valid_refresh_token' }); const res = makeRes(); - await authController.refreshToken(req, res); + await authController.refreshToken(req, res, next); expect(jwt.verify).toHaveBeenCalledWith('valid_refresh_token', 'refresh-secret'); expect(res.status).toHaveBeenCalledWith(200); @@ -276,10 +301,11 @@ describe('auth.controller', () => { const req = makeReq({}, null, {}); const res = makeRes(); - await authController.refreshToken(req, res); + await authController.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'No refresh token provided' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); + expect(next.mock.calls[0][0].message).toBe('No refresh token provided'); }); test('returns 403 when refresh token is invalid (jwt.verify throws)', async () => { @@ -288,9 +314,10 @@ describe('auth.controller', () => { const req = makeReq({}, null, { refreshToken: 'bad_token' }); const res = makeRes(); - await authController.refreshToken(req, res); + await authController.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); }); test('returns 403 when stored refresh token does not match', async () => { @@ -300,10 +327,11 @@ describe('auth.controller', () => { const req = makeReq({}, null, { refreshToken: 'valid_refresh_token' }); const res = makeRes(); - await authController.refreshToken(req, res); + await authController.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid refresh token' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + expect(next.mock.calls[0][0].message).toBe('Invalid refresh token'); }); }); @@ -320,7 +348,7 @@ describe('auth.controller', () => { const req = makeReq({}, { _id: 'dev_id_1' }); const res = makeRes(); - await authController.logout(req, res); + await authController.logout(req, res, next); expect(mockUser.save).toHaveBeenCalled(); expect(mockUser.refreshToken).toBeNull(); @@ -335,7 +363,7 @@ describe('auth.controller', () => { const req = makeReq(); const res = makeRes(); - await authController.logout(req, res); + await authController.logout(req, res, next); expect(res.status).toHaveBeenCalledWith(200); }); @@ -353,7 +381,7 @@ describe('auth.controller', () => { const req = makeReq({}, { _id: 'dev_id_1' }); const res = makeRes(); - await authController.getMe(req, res); + await authController.getMe(req, res, next); expect(Developer.findById).toHaveBeenCalledWith('dev_id_1'); expect(mockSelect).toHaveBeenCalledWith('-password -refreshToken'); @@ -370,10 +398,11 @@ describe('auth.controller', () => { const req = makeReq({}, { _id: 'missing_id' }); const res = makeRes(); - await authController.getMe(req, res); + await authController.getMe(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); + expect(next.mock.calls[0][0].message).toBe('User not found'); }); }); @@ -396,7 +425,7 @@ describe('auth.controller', () => { ); const res = makeRes(); - await authController.changePassword(req, res); + await authController.changePassword(req, res, next); expect(bcrypt.compare).toHaveBeenCalledWith('oldpass', 'old_hashed'); expect(mockUser.save).toHaveBeenCalled(); @@ -418,10 +447,11 @@ describe('auth.controller', () => { ); const res = makeRes(); - await authController.changePassword(req, res); + await authController.changePassword(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Incorrect current password' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Incorrect current password'); }); }); @@ -433,10 +463,11 @@ describe('auth.controller', () => { const req = makeReq({ email: 'noone@example.com' }); const res = makeRes(); - await authController.sendOtp(req, res); + await authController.sendOtp(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'User not found. Ensure you are using the correct email.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('User not found. Ensure you are using the correct email.'); }); test('returns 400 when user is already verified', async () => { @@ -445,10 +476,11 @@ describe('auth.controller', () => { const req = makeReq({ email: 'verified@example.com' }); const res = makeRes(); - await authController.sendOtp(req, res); + await authController.sendOtp(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Account is already verified. Please login.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Account is already verified. Please login.'); }); test('sends OTP and returns success for unverified user', async () => { @@ -464,7 +496,7 @@ describe('auth.controller', () => { const req = makeReq({ email: 'unverified@example.com' }); const res = makeRes(); - await authController.sendOtp(req, res); + await authController.sendOtp(req, res, next); expect(sendOtp).toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith({ message: 'OTP sent successfully' }); @@ -479,7 +511,7 @@ describe('auth.controller', () => { const req = makeReq({ email: 'ghost@example.com' }); const res = makeRes(); - await authController.forgotPassword(req, res); + await authController.forgotPassword(req, res, next); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith( @@ -518,7 +550,7 @@ describe('auth.controller', () => { }); const res = makeRes(); - await authController.resetPassword(req, res); + await authController.resetPassword(req, res, next); expect(mockUser.refreshToken).toBeNull(); expect(mockUser.save).toHaveBeenCalled(); diff --git a/apps/dashboard-api/src/__tests__/authMiddleware.test.js b/apps/dashboard-api/src/__tests__/authMiddleware.test.js index 67bab1f0f..eb944862e 100644 --- a/apps/dashboard-api/src/__tests__/authMiddleware.test.js +++ b/apps/dashboard-api/src/__tests__/authMiddleware.test.js @@ -1,5 +1,17 @@ 'use strict'; +class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } +} + +jest.mock('@urbackend/common', () => ({ + AppError, +})); + jest.mock('jsonwebtoken'); const jwt = require('jsonwebtoken'); @@ -29,7 +41,7 @@ describe('authMiddleware', () => { expect(jwt.verify).toHaveBeenCalledWith('cookietoken', 'test-secret'); expect(req.user).toEqual({ _id: 'user1' }); expect(next).toHaveBeenCalledTimes(1); - expect(res.status).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); }); }); @@ -93,11 +105,9 @@ describe('authMiddleware', () => { authMiddleware(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ - error: 'Access Denied: No Token Provided', - }); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); + expect(next.mock.calls[0][0].message).toBe('Access Denied: No Token Provided'); }); test('rejects header with wrong scheme (Token)', () => { @@ -105,8 +115,8 @@ describe('authMiddleware', () => { authMiddleware(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); test('rejects header with no scheme (bare token only)', () => { @@ -114,8 +124,8 @@ describe('authMiddleware', () => { authMiddleware(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); test('rejects empty Authorization header', () => { @@ -123,8 +133,8 @@ describe('authMiddleware', () => { authMiddleware(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); test('rejects header with extra whitespace but no token value', () => { @@ -134,8 +144,8 @@ describe('authMiddleware', () => { // After trimming and splitting on whitespace, only the scheme 'Bearer' // remains and there is no token part, so it's treated as missing token. - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); }); @@ -150,9 +160,9 @@ describe('authMiddleware', () => { authMiddleware(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid Token' }); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Invalid Token'); }); test('rejects a malformed / invalid JWT', () => { @@ -165,9 +175,9 @@ describe('authMiddleware', () => { authMiddleware(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid Token' }); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Invalid Token'); }); test('attaches decoded payload to req.user on success', () => { diff --git a/apps/dashboard-api/src/__tests__/auth_limiter.test.js b/apps/dashboard-api/src/__tests__/auth_limiter.test.js index a41020822..542292588 100644 --- a/apps/dashboard-api/src/__tests__/auth_limiter.test.js +++ b/apps/dashboard-api/src/__tests__/auth_limiter.test.js @@ -1,9 +1,43 @@ 'use strict'; +class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } +} + +jest.mock('@urbackend/common', () => ({ + AppError, +})); + const express = require('express'); const request = require('supertest'); const { authLimiter } = require('../middlewares/auth_limiter'); +// Helper: creates a test app with the limiter and a global error handler +// that mirrors the real dashboard-api error handler behavior. +const createTestApp = () => { + const app = express(); + app.use(authLimiter); + app.get('/test', (_req, res) => res.json({ ok: true })); + + // Global error handler (mirrors dashboard-api app.js) + app.use((err, _req, res, _next) => { + if (err.isOperational && err.statusCode) { + return res.status(err.statusCode).json({ + success: false, + error: 'Error', + message: err.message, + }); + } + res.status(500).json({ error: 'Internal Server Error' }); + }); + + return app; +}; + describe('authLimiter', () => { test('exports authLimiter as a middleware function', () => { expect(typeof authLimiter).toBe('function'); @@ -14,9 +48,7 @@ describe('authLimiter', () => { try { process.env.NODE_ENV = 'development'; - const app = express(); - app.use(authLimiter); - app.get('/test', (_req, res) => res.json({ ok: true })); + const app = createTestApp(); // Exceed the configured max (10) — all should still succeed because // the limiter is skipped in development. @@ -34,9 +66,7 @@ describe('authLimiter', () => { try { process.env.NODE_ENV = 'production'; - const app = express(); - app.use(authLimiter); - app.get('/test', (_req, res) => res.json({ ok: true })); + const app = createTestApp(); // Exhaust the 10-request window. for (let i = 0; i < 10; i++) { @@ -47,9 +77,7 @@ describe('authLimiter', () => { // The 11th request should be rate-limited. const blocked = await request(app).get('/test'); expect(blocked.status).toBe(429); - expect(blocked.body).toEqual({ - error: 'Too many attempts. Please try again in 15 minutes.', - }); + expect(blocked.body.message).toBe('Too many attempts. Please try again in 15 minutes.'); } finally { process.env.NODE_ENV = originalEnv; } @@ -60,9 +88,7 @@ describe('authLimiter', () => { try { process.env.NODE_ENV = 'production'; - const app = express(); - app.use(authLimiter); - app.get('/test', (_req, res) => res.json({ ok: true })); + const app = createTestApp(); const res = await request(app).get('/test'); // standardHeaders:true → RateLimit-* headers should be present. diff --git a/apps/dashboard-api/src/__tests__/billing.controller.test.js b/apps/dashboard-api/src/__tests__/billing.controller.test.js index a8a670b03..5c676fbdb 100644 --- a/apps/dashboard-api/src/__tests__/billing.controller.test.js +++ b/apps/dashboard-api/src/__tests__/billing.controller.test.js @@ -175,8 +175,9 @@ describe('Billing Controller', () => { describe('handleWebhook', () => { test('returns 401 if signature is missing', async () => { await controller.handleWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: false })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); + expect(next.mock.calls[0][0].message).toBe('Missing webhook signature.'); }); test('returns 401 if signature is invalid', async () => { @@ -184,7 +185,8 @@ describe('Billing Controller', () => { req.body = { event: 'subscription.activated' }; await controller.handleWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); test('processes valid webhook signature and upgrades plan', async () => { diff --git a/apps/dashboard-api/src/__tests__/userAuth.controller.test.js b/apps/dashboard-api/src/__tests__/userAuth.controller.test.js index 13950525e..7657f1d66 100644 --- a/apps/dashboard-api/src/__tests__/userAuth.controller.test.js +++ b/apps/dashboard-api/src/__tests__/userAuth.controller.test.js @@ -22,6 +22,14 @@ jest.mock('@urbackend/common', () => { updateOne: jest.fn(), }; + class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } + } + return { Project: { findOne: jest.fn() }, redis: { @@ -32,6 +40,7 @@ jest.mock('@urbackend/common', () => { authEmailQueue: { add: jest.fn().mockResolvedValue(undefined), }, + AppError, loginSchema: z.object({ email: z.string().email(), password: z.string().min(1), @@ -74,7 +83,7 @@ jest.mock('@urbackend/common', () => { const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); -const { redis, authEmailQueue, getConnection, getCompiledModel, __mockModel: mockModel } = +const { redis, authEmailQueue, getConnection, getCompiledModel, __mockModel: mockModel, AppError } = require('@urbackend/common'); const userAuthController = require('../controllers/userAuth.controller'); @@ -82,6 +91,8 @@ const userAuthController = require('../controllers/userAuth.controller'); // Helpers // --------------------------------------------------------------------------- +const next = jest.fn(); + const makeRes = () => { const res = { status: jest.fn(), json: jest.fn(), header: jest.fn() }; res.status.mockReturnValue(res); @@ -114,6 +125,7 @@ const makeProject = (overrides = {}) => ({ describe('userAuth.controller', () => { beforeEach(() => { jest.clearAllMocks(); + next.mockClear(); process.env.NODE_ENV = 'test'; }); @@ -132,7 +144,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.signup(req, res); + await userAuthController.signup(req, res, next); expect(mockModel.create).toHaveBeenCalled(); expect(authEmailQueue.add).toHaveBeenCalledWith( @@ -154,12 +166,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.signup(req, res); + await userAuthController.signup(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - error: 'User already exists with this email.', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns 404 when users collection is not configured', async () => { @@ -169,10 +179,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.signup(req, res); + await userAuthController.signup(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Auth collection not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); test('returns 400 on Zod validation error (short password)', async () => { @@ -182,9 +192,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.signup(req, res); + await userAuthController.signup(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); @@ -207,7 +218,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.login(req, res); + await userAuthController.login(req, res, next); expect(bcrypt.compare).toHaveBeenCalledWith('correct', 'hashed_pw'); expect(res.json).toHaveBeenCalledWith({ token: 'user_token' }); @@ -222,10 +233,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.login(req, res); + await userAuthController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid email or password' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns 400 when password is wrong', async () => { @@ -238,10 +249,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.login(req, res); + await userAuthController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid email or password' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns 404 when users collection is missing', async () => { @@ -251,9 +262,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.login(req, res); + await userAuthController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); }); @@ -266,12 +278,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.me(req, res); + await userAuthController.me(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ - error: 'Access Denied: No Token Provided', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); test('returns 401 when JWT is invalid', async () => { @@ -283,10 +293,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.me(req, res); + await userAuthController.me(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or Expired Token' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); test('returns 404 when user is not found in database', async () => { @@ -300,10 +310,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.me(req, res); + await userAuthController.me(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); test('returns user data on valid token', async () => { @@ -318,7 +328,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.me(req, res); + await userAuthController.me(req, res, next); expect(res.json).toHaveBeenCalledWith(userData); }); @@ -335,10 +345,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.verifyEmail(req, res); + await userAuthController.verifyEmail(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired OTP' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns success when OTP matches and user is found', async () => { @@ -351,7 +361,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.verifyEmail(req, res); + await userAuthController.verifyEmail(req, res, next); expect(redis.del).toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith({ message: 'Email verified successfully' }); @@ -367,10 +377,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.verifyEmail(req, res); + await userAuthController.verifyEmail(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); }); @@ -385,7 +395,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.requestPasswordReset(req, res); + await userAuthController.requestPasswordReset(req, res, next); expect(authEmailQueue.add).not.toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith( @@ -402,7 +412,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.requestPasswordReset(req, res); + await userAuthController.requestPasswordReset(req, res, next); expect(redis.set).toHaveBeenCalled(); expect(authEmailQueue.add).toHaveBeenCalledWith( @@ -429,7 +439,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.createAdminUser(req, res); + await userAuthController.createAdminUser(req, res, next); expect(mockModel.create).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(201); @@ -447,12 +457,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.createAdminUser(req, res); + await userAuthController.createAdminUser(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - error: 'User already exists with this email.', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); }); diff --git a/apps/dashboard-api/src/__tests__/webhook.controller.test.js b/apps/dashboard-api/src/__tests__/webhook.controller.test.js index 46484b493..401db0878 100644 --- a/apps/dashboard-api/src/__tests__/webhook.controller.test.js +++ b/apps/dashboard-api/src/__tests__/webhook.controller.test.js @@ -3,31 +3,42 @@ const mongoose = require('mongoose'); // Mock @urbackend/common -jest.mock('@urbackend/common', () => ({ - Webhook: { - create: jest.fn(), - find: jest.fn(), - findOne: jest.fn(), - findOneAndUpdate: jest.fn(), - findOneAndDelete: jest.fn(), - }, - WebhookDelivery: { - find: jest.fn(), - countDocuments: jest.fn(), - }, - Project: { - findOne: jest.fn(), - }, - encrypt: jest.fn((val) => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })), - decrypt: jest.fn(() => 'decrypted-secret'), - createWebhookSchema: { - safeParse: jest.fn(), - }, - updateWebhookSchema: { - safeParse: jest.fn(), - }, - generateSignature: jest.fn(() => 'sha256=test-signature'), -})); +jest.mock('@urbackend/common', () => { + class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } + } + + return { + Webhook: { + create: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + findOneAndDelete: jest.fn(), + }, + WebhookDelivery: { + find: jest.fn(), + countDocuments: jest.fn(), + }, + Project: { + findOne: jest.fn(), + }, + AppError, + encrypt: jest.fn((val) => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })), + decrypt: jest.fn(() => 'decrypted-secret'), + createWebhookSchema: { + safeParse: jest.fn(), + }, + updateWebhookSchema: { + safeParse: jest.fn(), + }, + generateSignature: jest.fn(() => 'sha256=test-signature'), + }; +}); const { createWebhook, @@ -43,12 +54,13 @@ const { Webhook, WebhookDelivery, Project, + AppError, createWebhookSchema, updateWebhookSchema, } = require('@urbackend/common'); describe('webhook.controller', () => { - let req, res; + let req, res, next; // Use valid MongoDB ObjectId format const validProjectId = new mongoose.Types.ObjectId().toString(); const validWebhookId = new mongoose.Types.ObjectId().toString(); @@ -65,6 +77,7 @@ describe('webhook.controller', () => { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), }; + next = jest.fn(); }); describe('createWebhook', () => { @@ -99,7 +112,7 @@ describe('webhook.controller', () => { events: { posts: { insert: true } }, }; - await createWebhook(req, res); + await createWebhook(req, res, next); expect(Project.findOne).toHaveBeenCalledWith({ _id: validProjectId, @@ -120,10 +133,10 @@ describe('webhook.controller', () => { test('returns 404 if project not found', async () => { Project.findOne.mockResolvedValue(null); - await createWebhook(req, res); + await createWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Project not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); test('returns 400 on validation failure', async () => { @@ -133,12 +146,10 @@ describe('webhook.controller', () => { error: { errors: [{ message: 'Invalid URL' }] }, }); - await createWebhook(req, res); + await createWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'Validation failed' }) - ); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); @@ -152,7 +163,7 @@ describe('webhook.controller', () => { ]), }); - await getWebhooks(req, res); + await getWebhooks(req, res, next); expect(Webhook.find).toHaveBeenCalledWith({ projectId: validProjectId }); expect(res.json).toHaveBeenCalledWith({ @@ -177,7 +188,7 @@ describe('webhook.controller', () => { }), }); - await getWebhook(req, res); + await getWebhook(req, res, next); expect(res.json).toHaveBeenCalledWith({ data: expect.objectContaining({ name: 'Test Hook' }), @@ -191,10 +202,10 @@ describe('webhook.controller', () => { lean: jest.fn().mockResolvedValue(null), }); - await getWebhook(req, res); + await getWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Webhook not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); }); @@ -217,7 +228,7 @@ describe('webhook.controller', () => { }), }); - await updateWebhook(req, res); + await updateWebhook(req, res, next); expect(Webhook.findOneAndUpdate).toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith( @@ -235,7 +246,7 @@ describe('webhook.controller', () => { Project.findOne.mockResolvedValue({ _id: validProjectId }); Webhook.findOneAndDelete.mockResolvedValue({ _id: validWebhookId }); - await deleteWebhook(req, res); + await deleteWebhook(req, res, next); expect(Webhook.findOneAndDelete).toHaveBeenCalledWith({ _id: validWebhookId, @@ -249,10 +260,10 @@ describe('webhook.controller', () => { Project.findOne.mockResolvedValue({ _id: validProjectId }); Webhook.findOneAndDelete.mockResolvedValue(null); - await deleteWebhook(req, res); + await deleteWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Webhook not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); }); @@ -277,7 +288,7 @@ describe('webhook.controller', () => { }); WebhookDelivery.countDocuments.mockResolvedValue(2); - await getDeliveries(req, res); + await getDeliveries(req, res, next); expect(res.json).toHaveBeenCalledWith({ data: mockDeliveries, @@ -320,7 +331,7 @@ describe('webhook.controller', () => { text: jest.fn().mockResolvedValue('{"received": true}'), }); - await testWebhook(req, res); + await testWebhook(req, res, next); expect(global.fetch).toHaveBeenCalledWith( 'https://example.com/webhook', @@ -347,10 +358,10 @@ describe('webhook.controller', () => { select: jest.fn().mockResolvedValue(null), }); - await testWebhook(req, res); + await testWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Webhook not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); test('handles fetch failure gracefully', async () => { @@ -371,7 +382,7 @@ describe('webhook.controller', () => { global.fetch.mockRejectedValue(new Error('Network error')); - await testWebhook(req, res); + await testWebhook(req, res, next); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, diff --git a/apps/dashboard-api/src/app.js b/apps/dashboard-api/src/app.js index d7646c3f5..41f48fe8f 100644 --- a/apps/dashboard-api/src/app.js +++ b/apps/dashboard-api/src/app.js @@ -147,18 +147,19 @@ app.use((err, req, res, next) => { }); } - const statusCode = err.statusCode || 500; - const message = err.message || "Something went wrong!"; - - // Only log actual server errors (500), not expected operational errors (4xx) - if (statusCode >= 500) { - console.error("🔥 Server Error:", err.stack); + if (err.isOperational && err.statusCode) { + return res.status(err.statusCode).json({ + success: false, + error: err.error || "Error", + message: err.message + }); } - res.status(statusCode).json({ + console.error("🔥 Unhandled Server Error:", err.stack); + res.status(500).json({ success: false, - error: statusCode >= 500 ? "Internal Server Error" : message, - message: message + error: "Something went wrong!", + message: err.message }); }); diff --git a/apps/dashboard-api/src/controllers/admin.metrics.controller.js b/apps/dashboard-api/src/controllers/admin.metrics.controller.js index 902745f64..9c6d2ab83 100644 --- a/apps/dashboard-api/src/controllers/admin.metrics.controller.js +++ b/apps/dashboard-api/src/controllers/admin.metrics.controller.js @@ -1,4 +1,4 @@ -const { Developer, Project, Log, ApiAnalytics, PlatformEvent, DeveloperActivity } = require('@urbackend/common'); +const { Developer, Project, Log, ApiAnalytics, PlatformEvent, DeveloperActivity, AppError, ApiResponse } = require('@urbackend/common'); /** * Guard: only callable by the platform admin. @@ -7,7 +7,7 @@ const { Developer, Project, Log, ApiAnalytics, PlatformEvent, DeveloperActivity */ function requireAdmin(req, res, next) { if (!req.user?.isAdmin) { - return res.status(403).json({ success: false, data: {}, message: 'Admin access required.' }); + return next(new AppError(403, 'Admin access required.')); } return next(); } @@ -18,7 +18,7 @@ module.exports.requireAdmin = requireAdmin; // GET /api/admin/metrics/overview // Platform-wide snapshot: signups, verified devs, active projects, total calls. // --------------------------------------------------------------------------- -module.exports.getOverview = async (req, res) => { +module.exports.getOverview = async (req, res, next) => { try { const sevenDaysAgo = new Date(); sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); @@ -40,20 +40,15 @@ module.exports.getOverview = async (req, res) => { }), ]); - return res.json({ - success: true, - data: { - totalDevelopers, - verifiedDevelopers, - totalProjects, - totalApiCalls, - activeProjectsLast7d: northStarProjects.length, - }, - message: '', - }); + return new ApiResponse({ + totalDevelopers, + verifiedDevelopers, + totalProjects, + totalApiCalls, + activeProjectsLast7d: northStarProjects.length, + }).send(res); } catch (err) { - console.error('[admin.metrics] getOverview error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -61,7 +56,7 @@ module.exports.getOverview = async (req, res) => { // GET /api/admin/metrics/activation-funnel // Platform-wide funnel: count of devs who completed each activation step. // --------------------------------------------------------------------------- -module.exports.getActivationFunnel = async (req, res) => { +module.exports.getActivationFunnel = async (req, res, next) => { try { const FUNNEL_STEPS = [ 'signup_completed', @@ -104,10 +99,9 @@ module.exports.getActivationFunnel = async (req, res) => { uniqueDevs: countMap[step] || 0, })); - return res.json({ success: true, data: { steps }, message: '' }); + return new ApiResponse({ steps }).send(res); } catch (err) { - console.error('[admin.metrics] getActivationFunnel error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -115,15 +109,11 @@ module.exports.getActivationFunnel = async (req, res) => { // GET /api/admin/metrics/cohorts?month=2026-05 // D1/D7/D30 retention for developers who signed up in a given month. // --------------------------------------------------------------------------- -module.exports.getCohorts = async (req, res) => { +module.exports.getCohorts = async (req, res, next) => { try { const { month } = req.query; // e.g. "2026-05" if (!month || !/^\d{4}-\d{2}$/.test(month)) { - return res.status(400).json({ - success: false, - data: {}, - message: 'Provide month as YYYY-MM (e.g. 2026-05)', - }); + return next(new AppError(400, 'Provide month as YYYY-MM (e.g. 2026-05)')); } const [year, mo] = month.split('-').map(Number); @@ -150,11 +140,7 @@ module.exports.getCohorts = async (req, res) => { const cohortSize = signups.length; if (cohortSize === 0) { - return res.json({ - success: true, - data: { month, cohortSize: 0, d1: 0, d7: 0, d30: 0 }, - message: '', - }); + return new ApiResponse({ month, cohortSize: 0, d1: 0, d7: 0, d30: 0 }).send(res); } const DAY_MS = 24 * 60 * 60 * 1000; @@ -210,23 +196,18 @@ module.exports.getCohorts = async (req, res) => { const d7 = countRetained(7); const d30 = countRetained(30); - return res.json({ - success: true, - data: { - month, - cohortSize, - d1, - d7, - d30, - d1Pct: Math.round((d1 / cohortSize) * 100), - d7Pct: Math.round((d7 / cohortSize) * 100), - d30Pct: Math.round((d30 / cohortSize) * 100), - }, - message: '', - }); + return new ApiResponse({ + month, + cohortSize, + d1, + d7, + d30, + d1Pct: Math.round((d1 / cohortSize) * 100), + d7Pct: Math.round((d7 / cohortSize) * 100), + d30Pct: Math.round((d30 / cohortSize) * 100), + }).send(res); } catch (err) { - console.error('[admin.metrics] getCohorts error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -234,7 +215,7 @@ module.exports.getCohorts = async (req, res) => { // GET /api/admin/metrics/feature-usage // Platform-wide feature breakdown from DeveloperActivity (last 30 days). // --------------------------------------------------------------------------- -module.exports.getFeatureUsage = async (req, res) => { +module.exports.getFeatureUsage = async (req, res, next) => { try { const thirtyDaysAgo = new Date(); thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); @@ -270,21 +251,16 @@ module.exports.getFeatureUsage = async (req, res) => { }; const activeDeveloperCount = agg[0]?.activeDevelopers?.[0]?.count || 0; - return res.json({ - success: true, - data: { - window: '30d', - totalApiCalls: result.totalApiCalls, - totalMailSent: result.totalMailSent, - totalStorageUploads: result.totalStorageUploads, - totalWebhooksFired: result.totalWebhooksFired, - activeDevelopers: activeDeveloperCount, - }, - message: '', - }); + return new ApiResponse({ + window: '30d', + totalApiCalls: result.totalApiCalls, + totalMailSent: result.totalMailSent, + totalStorageUploads: result.totalStorageUploads, + totalWebhooksFired: result.totalWebhooksFired, + activeDevelopers: activeDeveloperCount, + }).send(res); } catch (err) { - console.error('[admin.metrics] getFeatureUsage error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -292,7 +268,7 @@ module.exports.getFeatureUsage = async (req, res) => { // GET /api/admin/metrics/reliability // Global error rate and latency across all projects (last 24h from ApiAnalytics). // --------------------------------------------------------------------------- -module.exports.getReliability = async (req, res) => { +module.exports.getReliability = async (req, res, next) => { try { const { ApiAnalytics } = require('@urbackend/common'); const since = new Date(Date.now() - 24 * 60 * 60 * 1000); @@ -313,22 +289,17 @@ module.exports.getReliability = async (req, res) => { const r = agg[0] || { total: 0, errors: 0, p50: [0], p95: [0], p99: [0] }; - return res.json({ - success: true, - data: { - window: '24h', - totalRequests: r.total, - errorCount: r.errors, - errorRate: r.total > 0 ? ((r.errors / r.total) * 100).toFixed(2) : '0.00', - p50Ms: r.p50?.[0]?.toFixed(1) ?? null, - p95Ms: r.p95?.[0]?.toFixed(1) ?? null, - p99Ms: r.p99?.[0]?.toFixed(1) ?? null, - }, - message: '', - }); + return new ApiResponse({ + window: '24h', + totalRequests: r.total, + errorCount: r.errors, + errorRate: r.total > 0 ? ((r.errors / r.total) * 100).toFixed(2) : '0.00', + p50Ms: r.p50?.[0]?.toFixed(1) ?? null, + p95Ms: r.p95?.[0]?.toFixed(1) ?? null, + p99Ms: r.p99?.[0]?.toFixed(1) ?? null, + }).send(res); } catch (err) { - console.error('[admin.metrics] getReliability error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -336,7 +307,7 @@ module.exports.getReliability = async (req, res) => { // GET /api/admin/metrics/top-projects // Most active projects by API calls in the last 7 days. // --------------------------------------------------------------------------- -module.exports.getTopProjects = async (req, res) => { +module.exports.getTopProjects = async (req, res, next) => { try { const sevenDaysAgo = new Date(); sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); @@ -364,10 +335,9 @@ module.exports.getTopProjects = async (req, res) => { }, ]); - return res.json({ success: true, data: { projects: agg }, message: '' }); + return new ApiResponse({ projects: agg }).send(res); } catch (err) { - console.error('[admin.metrics] getTopProjects error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -375,7 +345,7 @@ module.exports.getTopProjects = async (req, res) => { // GET /api/admin/metrics/churn-signals // Projects with zero API calls in the last 14 days that had prior activity. // --------------------------------------------------------------------------- -module.exports.getChurnSignals = async (req, res) => { +module.exports.getChurnSignals = async (req, res, next) => { try { const fourteenDaysAgo = new Date(); fourteenDaysAgo.setUTCDate(fourteenDaysAgo.getUTCDate() - 14); @@ -401,13 +371,8 @@ module.exports.getChurnSignals = async (req, res) => { .limit(50) .lean(); - return res.json({ - success: true, - data: { churnSignals: churnedIds.length, projects }, - message: '', - }); + return new ApiResponse({ churnSignals: churnedIds.length, projects }).send(res); } catch (err) { - console.error('[admin.metrics] getChurnSignals error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; diff --git a/apps/dashboard-api/src/controllers/analytics.controller.js b/apps/dashboard-api/src/controllers/analytics.controller.js index c46eb8e86..3689379d1 100644 --- a/apps/dashboard-api/src/controllers/analytics.controller.js +++ b/apps/dashboard-api/src/controllers/analytics.controller.js @@ -1,10 +1,10 @@ -const { Project, Log, Developer, Webhook, getConnection, resolveEffectivePlan, getPlanLimits, PlatformEvent, DeveloperActivity } = require("@urbackend/common"); +const { Project, Log, Developer, Webhook, getConnection, resolveEffectivePlan, getPlanLimits, PlatformEvent, DeveloperActivity, AppError, ApiResponse } = require("@urbackend/common"); const mongoose = require("mongoose"); /** * Aggregates global usage metrics across all user projects. */ -module.exports.getGlobalStats = async (req, res) => { +module.exports.getGlobalStats = async (req, res, next) => { try { const user_id = req.user._id; const userId = new mongoose.Types.ObjectId(user_id); @@ -65,34 +65,29 @@ module.exports.getGlobalStats = async (req, res) => { } }); - res.json({ - success: true, - data: { - plan: effectivePlan, - planExpiresAt: dev?.planExpiresAt || null, - limits, - usage: { - totalProjects: globalStats.totalProjects, - totalCollections: globalStats.totalCollections, - totalStorageUsed: globalStats.totalStorageUsed, - totalDatabaseUsed: globalStats.totalDatabaseUsed, - totalRequests, - totalWebhooks, - totalUsers - } - }, - message: "" - }); + return new ApiResponse({ + plan: effectivePlan, + planExpiresAt: dev?.planExpiresAt || null, + limits, + usage: { + totalProjects: globalStats.totalProjects, + totalCollections: globalStats.totalCollections, + totalStorageUsed: globalStats.totalStorageUsed, + totalDatabaseUsed: globalStats.totalDatabaseUsed, + totalRequests, + totalWebhooks, + totalUsers + } + }).send(res); } catch (err) { - console.error('[analytics] getGlobalStats error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; /** * Fetches the most recent activity across all user projects. */ -module.exports.getRecentActivity = async (req, res) => { +module.exports.getRecentActivity = async (req, res, next) => { try { const userId = req.user._id; const projectIds = await Project.find({ owner: userId }).distinct("_id"); @@ -113,10 +108,9 @@ module.exports.getRecentActivity = async (req, res) => { timestamp: log.timestamp })); - res.json(formattedLogs); + return new ApiResponse(formattedLogs).send(res); } catch (err) { - console.error('[analytics] getRecentActivity error:', err); - res.status(500).json({ error: 'Internal server error' }); + next(err); } }; @@ -124,7 +118,7 @@ module.exports.getRecentActivity = async (req, res) => { // ACTIVATION FUNNEL // Returns step-by-step conversion rates for the current developer. // --------------------------------------------------------------------------- -module.exports.getActivationFunnel = async (req, res) => { +module.exports.getActivationFunnel = async (req, res, next) => { try { const developerId = req.user._id; @@ -157,10 +151,9 @@ module.exports.getActivationFunnel = async (req, res) => { completedAt: completed[step] || null, })); - return res.json({ success: true, data: { steps }, message: '' }); + return new ApiResponse({ steps }).send(res); } catch (err) { - console.error('[analytics] getActivationFunnel error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -168,7 +161,7 @@ module.exports.getActivationFunnel = async (req, res) => { // RETENTION (D1 / D7 / D30) // Checks whether the developer was active on Day+1, Day+7, Day+30 after signup. // --------------------------------------------------------------------------- -module.exports.getRetention = async (req, res) => { +module.exports.getRetention = async (req, res, next) => { try { const developerId = req.user._id; @@ -179,11 +172,7 @@ module.exports.getRetention = async (req, res) => { }).sort({ timestamp: 1 }).lean(); if (!signupEvent) { - return res.json({ - success: true, - data: { d1: false, d7: false, d30: false, signupDate: null }, - message: '', - }); + return new ApiResponse({ d1: false, d7: false, d30: false, signupDate: null }).send(res); } const signupDate = new Date(signupEvent.timestamp); @@ -208,14 +197,9 @@ module.exports.getRetention = async (req, res) => { checkDay(30), ]); - return res.json({ - success: true, - data: { d1, d7, d30, signupDate }, - message: '', - }); + return new ApiResponse({ d1, d7, d30, signupDate }).send(res); } catch (err) { - console.error('[analytics] getRetention error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -223,7 +207,7 @@ module.exports.getRetention = async (req, res) => { // FEATURE ENGAGEMENT (trailing 30 days) // Returns per-feature usage totals across all projects for the developer. // --------------------------------------------------------------------------- -module.exports.getEngagement = async (req, res) => { +module.exports.getEngagement = async (req, res, next) => { try { const developerId = req.user._id; @@ -262,22 +246,17 @@ module.exports.getEngagement = async (req, res) => { const flatProjectIds = (result.allProjectIds || []).flat(); const uniqueActiveProjects = new Set(flatProjectIds.map(String)).size; - return res.json({ - success: true, - data: { - window: '30d', - totalApiCalls: result.totalApiCalls, - totalMailSent: result.totalMailSent, - totalStorageUploads: result.totalStorageUploads, - totalWebhooksFired: result.totalWebhooksFired, - activeDays: result.activeDays, - uniqueActiveProjects, - }, - message: '', - }); + return new ApiResponse({ + window: '30d', + totalApiCalls: result.totalApiCalls, + totalMailSent: result.totalMailSent, + totalStorageUploads: result.totalStorageUploads, + totalWebhooksFired: result.totalWebhooksFired, + activeDays: result.activeDays, + uniqueActiveProjects, + }).send(res); } catch (err) { - console.error('[analytics] getEngagement error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -285,7 +264,7 @@ module.exports.getEngagement = async (req, res) => { // NORTH STAR METRIC // "Projects making successful API calls in the last 7 days" // --------------------------------------------------------------------------- -module.exports.getNorthStar = async (req, res) => { +module.exports.getNorthStar = async (req, res, next) => { try { const developerId = req.user._id; @@ -298,11 +277,7 @@ module.exports.getNorthStar = async (req, res) => { const totalProjects = projectIds.length; if (totalProjects === 0) { - return res.json({ - success: true, - data: { activeProjects: 0, totalProjects: 0, percentage: 0 }, - message: '', - }); + return new ApiResponse({ activeProjects: 0, totalProjects: 0, percentage: 0 }).send(res); } // Projects with at least one 2xx log in the last 7 days @@ -315,13 +290,8 @@ module.exports.getNorthStar = async (req, res) => { const activeProjects = activeProjectIds.length; const percentage = totalProjects > 0 ? Math.round((activeProjects / totalProjects) * 100) : 0; - return res.json({ - success: true, - data: { activeProjects, totalProjects, percentage }, - message: '', - }); + return new ApiResponse({ activeProjects, totalProjects, percentage }).send(res); } catch (err) { - console.error('[analytics] getNorthStar error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; diff --git a/apps/dashboard-api/src/controllers/auth.controller.js b/apps/dashboard-api/src/controllers/auth.controller.js index 88e48d1b5..b4c3f096a 100644 --- a/apps/dashboard-api/src/controllers/auth.controller.js +++ b/apps/dashboard-api/src/controllers/auth.controller.js @@ -13,7 +13,8 @@ const { onlyEmailSchema, resetPasswordSchema, verifyOtpSchema, - AppError + AppError, + ApiResponse } = require("@urbackend/common"); const { emitEvent } = require('../utils/emitEvent'); @@ -108,13 +109,13 @@ const fetchJson = async (url, options, defaultMessage) => { payload?.error || payload?.message || defaultMessage; - throw new AppError(message, response.status || 502); + throw new AppError(response.status || 502, message); } return payload; } catch (err) { if (err.name === 'AbortError') { - throw new AppError('OAuth request timed out.', 504); + throw new AppError(504, 'OAuth request timed out.'); } throw err; } finally { @@ -160,7 +161,7 @@ const fetchGithubProfile = async (accessToken) => { : null; if (!primaryEmail?.email || !primaryEmail.verified) { - throw new Error('GitHub account must have a verified email address.'); + throw new AppError(422, 'GitHub account must have a verified email address.'); } return { @@ -262,11 +263,11 @@ async function createAndStoreOtp(userId) { async function validateOtp(userId, passedOtp) { const otpDoc = await Otp.findOne({ userId }); - if (!otpDoc) throw { status: 400, message: "No OTP found. Please request a new one." }; + if (!otpDoc) throw new AppError(400, "No OTP found. Please request a new one."); if (otpDoc.attempts >= OTP_MAX_ATTEMPTS) { await otpDoc.deleteOne(); - throw { status: 429, message: "Too many incorrect attempts. Please request a new OTP." }; + throw new AppError(429, "Too many incorrect attempts. Please request a new OTP."); } const isMatch = await bcrypt.compare(passedOtp.toString(), otpDoc.otp); @@ -274,7 +275,7 @@ async function validateOtp(userId, passedOtp) { otpDoc.attempts += 1; await otpDoc.save(); const remaining = OTP_MAX_ATTEMPTS - otpDoc.attempts; - throw { status: 400, message: `Incorrect OTP. ${remaining} attempt(s) remaining.` }; + throw new AppError(400, `Incorrect OTP. ${remaining} attempt(s) remaining.`); } return otpDoc; @@ -286,17 +287,17 @@ async function checkOtpCooldown(userId) { const secondsSinceCreated = (Date.now() - existingOtp.createdAt.getTime()) / 1000; if (secondsSinceCreated < 60) { const waitTime = Math.ceil(60 - secondsSinceCreated); - throw { status: 429, message: `Please wait ${waitTime} seconds before requesting a new OTP.` }; + throw new AppError(429, `Please wait ${waitTime} seconds before requesting a new OTP.`); } } } -module.exports.register = async (req, res) => { +module.exports.register = async (req, res, next) => { try { const { email, password } = loginSchema.parse(req.body); const existingUser = await Developer.findOne({ email }); - if (existingUser) return res.status(400).json({ error: "Email already exists" }); + if (existingUser) return next(new AppError(400, "Email already exists")); const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(password, salt); @@ -309,39 +310,34 @@ module.exports.register = async (req, res) => { res.status(201).json({ message: "Registered successfully" }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } -module.exports.login = async (req, res) => { +module.exports.login = async (req, res, next) => { try { const { email, password } = loginSchema.parse(req.body); const dev = await Developer.findOne({ email: email.toLowerCase().trim() }).select('+password'); - if (!dev) return res.status(400).json({ error: "User not found" }); + if (!dev) return next(new AppError(400, "User not found")); const validPass = await bcrypt.compare(password, dev.password); - if (!validPass) return res.status(400).json({ error: "Invalid password" }); + if (!validPass) return next(new AppError(400, "Invalid password")); await sendTokenResponse(dev, 200, res); } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ - error: "Validation Failed", - details: err.issues - }); + return next(new AppError(400, err.issues[0]?.message || 'Validation Failed')); } - console.error("Server Error:", err); - res.status(500).json({ error: "Internal Server Error" }); + next(err); } } -module.exports.startGithubAuth = async (req, res) => { +module.exports.startGithubAuth = async (req, res, next) => { if (!process.env.DASHBOARD_GITHUB_CLIENT_ID || !process.env.DASHBOARD_GITHUB_CLIENT_SECRET) { - return res.status(503).json({ error: 'GitHub login is not configured.' }); + return next(new AppError(503, 'GitHub login is not configured.')); } const state = crypto.randomBytes(24).toString('hex'); @@ -406,14 +402,14 @@ module.exports.handleGithubCallback = async (req, res) => { }; -module.exports.changePassword = async (req, res) => { +module.exports.changePassword = async (req, res, next) => { try { const { currentPassword, newPassword } = changePasswordSchema.parse(req.body); const dev = await Developer.findById(req.user._id).select('+password'); const validPass = await bcrypt.compare(currentPassword, dev.password); - if (!validPass) return res.status(400).json({ error: "Incorrect current password" }); + if (!validPass) return next(new AppError(400, "Incorrect current password")); const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(newPassword, salt); @@ -423,55 +419,49 @@ module.exports.changePassword = async (req, res) => { res.json({ message: "Password updated successfully" }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } -module.exports.deleteAccount = async (req, res) => { +module.exports.deleteAccount = async (req, res, next) => { try { const { password } = deleteAccountSchema.parse(req.body); const dev = await Developer.findById(req.user._id).select('+password'); const validPass = await bcrypt.compare(password, dev.password); - if (!validPass) return res.status(400).json({ error: "Incorrect password. Cannot delete account." }); + if (!validPass) return next(new AppError(400, "Incorrect password. Cannot delete account.")); await Project.deleteMany({ owner: req.user._id }); await Developer.findByIdAndDelete(req.user._id); res.json({ message: "Account and all projects deleted." }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } -module.exports.sendOtp = async (req, res) => { +module.exports.sendOtp = async (req, res, next) => { try { const { email } = onlyEmailSchema.parse(req.body); const normalizedEmail = email.toLowerCase().trim(); const existingUser = await Developer.findOne({ email: normalizedEmail }); if (!existingUser) { - return res.status(400).json({ error: "User not found. Ensure you are using the correct email." }); + return next(new AppError(400, "User not found. Ensure you are using the correct email.")); } if (existingUser.isVerified) { - return res.status(400).json({ error: "Account is already verified. Please login." }); + return next(new AppError(400, "Account is already verified. Please login.")); } - // Check 60s cooldown - try { - await checkOtpCooldown(existingUser._id); - } catch (cooldownErr) { - return res.status(cooldownErr.status || 429).json({ error: cooldownErr.message }); - } + // Check 60s cooldown — checkOtpCooldown now throws AppError directly + await checkOtpCooldown(existingUser._id); const otp = await createAndStoreOtp(existingUser._id); @@ -479,29 +469,19 @@ module.exports.sendOtp = async (req, res) => { res.json({ message: "OTP sent successfully" }); } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ - error: "Invalid email format", - details: err.errors - }); + return next(new AppError(400, 'Invalid email format')); } - - console.error("🔥 Dashboard OTP Send Error:", { - email: req.body?.email, - error: err.message, - stack: err.stack - }); - - res.status(500).json({ error: "Failed to send OTP. Please try again later." }); + next(err); } } -module.exports.verifyOtp = async (req, res) => { +module.exports.verifyOtp = async (req, res, next) => { try { const { email, otp } = verifyOtpSchema.parse(req.body); const existingUser = await Developer.findOne({ email }).select('+password'); - if (!existingUser) return res.status(400).json({ error: "User not found" }); + if (!existingUser) return next(new AppError(400, "User not found")); const otpDoc = await validateOtp(existingUser._id, otp); @@ -514,16 +494,14 @@ module.exports.verifyOtp = async (req, res) => { await sendTokenResponse(existingUser, 200, res); } catch (err) { - if (err.status) return res.status(err.status).json({ error: err.message }); - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } // FORGOT PASSWORD -module.exports.forgotPassword = async (req, res) => { +module.exports.forgotPassword = async (req, res, next) => { try { const { email } = onlyEmailSchema.parse(req.body); @@ -535,20 +513,19 @@ module.exports.forgotPassword = async (req, res) => { await sendOtp(email, otp, { subject: "Password Reset OTP \u2014 urBackend" }); res.status(200).json({ message: "If this email is registered, an OTP has been sent." }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } // RESET PASSWORD -module.exports.resetPassword = async (req, res) => { +module.exports.resetPassword = async (req, res, next) => { try { const { email, otp, newPassword } = resetPasswordSchema.parse(req.body); const dev = await Developer.findOne({ email }).select('+password'); - if (!dev) return res.status(400).json({ error: "User not found" }); + if (!dev) return next(new AppError(400, "User not found")); const otpDoc = await validateOtp(dev._id, otp); @@ -575,15 +552,13 @@ module.exports.resetPassword = async (req, res) => { }) .json({ message: "Password reset successfully. Please log in with your new password." }); } catch (err) { - if (err.status) return res.status(err.status).json({ error: err.message }); - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } // LOGOUT -module.exports.logout = async (req, res) => { +module.exports.logout = async (req, res, next) => { try { if (req.user) { const user = await Developer.findById(req.user._id); @@ -608,43 +583,41 @@ module.exports.logout = async (req, res) => { res.status(200).json({ success: true, message: "Logged out successfully" }); } catch (err) { - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + next(err); } }; // REFRESH TOKEN -module.exports.refreshToken = async (req, res) => { +module.exports.refreshToken = async (req, res, next) => { try { const refreshToken = req.cookies.refreshToken; if (!refreshToken) { - return res.status(401).json({ error: "No refresh token provided" }); + return next(new AppError(401, "No refresh token provided")); } const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET); const user = await Developer.findById(decoded._id).select('+refreshToken'); if (!user || user.refreshToken !== refreshToken) { - return res.status(403).json({ error: "Invalid refresh token" }); + return next(new AppError(403, "Invalid refresh token")); } await sendTokenResponse(user, 200, res); } catch (err) { - res.status(403).json({ error: "Invalid or expired refresh token" }); + next(new AppError(403, "Invalid or expired refresh token")); } }; // GET ME -module.exports.getMe = async (req, res) => { +module.exports.getMe = async (req, res, next) => { try { const user = await Developer.findById(req.user._id).select("-password -refreshToken"); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); const userData = typeof user.toObject === 'function' ? user.toObject() : { ...user }; userData.isAdmin = userData.email === process.env.ADMIN_EMAIL; res.json({ success: true, user: userData }); } catch (err) { - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + next(err); } }; diff --git a/apps/dashboard-api/src/controllers/billing.controller.js b/apps/dashboard-api/src/controllers/billing.controller.js index ab1ace87b..8eedeaf42 100644 --- a/apps/dashboard-api/src/controllers/billing.controller.js +++ b/apps/dashboard-api/src/controllers/billing.controller.js @@ -1,6 +1,6 @@ const Razorpay = require('razorpay'); const crypto = require('crypto'); -const { Developer, ProRequest, AppError, sendProRequestConfirmationEmail, sanitizeNonEmptyString, sanitizeObjectId } = require('@urbackend/common'); +const { Developer, ProRequest, AppError, ApiResponse, sendProRequestConfirmationEmail, sanitizeNonEmptyString, sanitizeObjectId } = require('@urbackend/common'); const getRazorpayInstance = () => { const keyId = process.env.RAZORPAY_KEY_ID; @@ -182,7 +182,7 @@ module.exports.handleWebhook = async (req, res, next) => { if (webhookSecret) { const signature = req.headers['x-razorpay-signature']; if (!signature) { - return res.status(401).json({ success: false, message: 'Missing webhook signature.' }); + return next(new AppError(401, 'Missing webhook signature.')); } const rawBody = req.rawBody || JSON.stringify(req.body); @@ -192,7 +192,7 @@ module.exports.handleWebhook = async (req, res, next) => { .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { - return res.status(401).json({ success: false, message: 'Invalid webhook signature.' }); + return next(new AppError(401, 'Invalid webhook signature.')); } } diff --git a/apps/dashboard-api/src/controllers/events.controller.js b/apps/dashboard-api/src/controllers/events.controller.js index bf7d7c508..4dca49a3c 100644 --- a/apps/dashboard-api/src/controllers/events.controller.js +++ b/apps/dashboard-api/src/controllers/events.controller.js @@ -1,3 +1,4 @@ +const { AppError } = require('@urbackend/common'); const { emitEvent } = require('../utils/emitEvent'); // Allowed frontend-emitted event names (whitelist prevents garbage in DB) @@ -18,22 +19,18 @@ const ALLOWED_FRONTEND_EVENTS = new Set([ * Receives frontend-emitted tracking events and writes them as PlatformEvents. * Only whitelisted event names are accepted to prevent junk data. */ -module.exports.track = async (req, res) => { +module.exports.track = async (req, res, next) => { try { const { event, properties = {}, projectId } = req.body; if (!event || typeof event !== 'string') { - return res.status(400).json({ success: false, data: {}, message: 'event name is required' }); + return next(new AppError(400, 'event name is required')); } const normalizedEvent = event.trim().toLowerCase().replace(/\s+/g, '_'); if (!ALLOWED_FRONTEND_EVENTS.has(normalizedEvent)) { - return res.status(400).json({ - success: false, - data: {}, - message: `Unknown event: "${normalizedEvent}". Allowed: ${[...ALLOWED_FRONTEND_EVENTS].join(', ')}`, - }); + return next(new AppError(400, `Unknown event: "${normalizedEvent}". Allowed: ${[...ALLOWED_FRONTEND_EVENTS].join(', ')}`)); } // emitEvent is fire-and-forget — responds 200 immediately @@ -46,7 +43,6 @@ module.exports.track = async (req, res) => { return res.json({ success: true, data: {}, message: 'Event queued' }); } catch (err) { - console.error('[events.controller] track error:', err); - return res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index ae2f21c79..45023d6e6 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -242,7 +242,7 @@ const bestEffortDeleteUploadedObject = async (project, filePath) => { } }; -module.exports.createProject = async (req, res) => { +module.exports.createProject = async (req, res, next) => { const executeOperation = async (session) => { const { name, description, siteUrl } = createProjectSchema.parse(req.body); @@ -314,17 +314,17 @@ module.exports.createProject = async (req, res) => { emitEvent(req.user._id, 'project_created', { projectName: projectObj.name }, newProject._id); return res.status(201).json(projectObj); } catch (retryErr) { - if (retryErr instanceof z.ZodError) return res.status(400).json({ error: retryErr.issues }); - return res.status(retryErr.status || 500).json({ error: retryErr.message }); + if (retryErr instanceof z.ZodError) return next(new AppError(400, retryErr.issues?.[0]?.message || 'Validation failed')); + return next(retryErr); } } - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues }); - return res.status(err.status || 500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + return next(err); } }; -module.exports.getAllProject = async (req, res) => { +module.exports.getAllProject = async (req, res, next) => { try { const projects = await Project.find({ owner: req.user._id }) .select("name description databaseUsed databaseLimit storageUsed storageLimit updatedAt isAuthEnabled collections") @@ -384,11 +384,11 @@ module.exports.getAllProject = async (req, res) => { res.status(200).json(enrichedProjects); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; -module.exports.getSingleProject = async (req, res) => { +module.exports.getSingleProject = async (req, res, next) => { try { let projectObj = await getProjectById(req.params.projectId); @@ -408,29 +408,32 @@ module.exports.getSingleProject = async (req, res) => { "+resendApiKey.iv " + "+resendApiKey.tag", ); +<<<<<<< Updated upstream if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); +======= + if (!project) + return next(new AppError(404, "Project not found.")); +>>>>>>> Stashed changes projectObj = project.toObject(); await setProjectById(req.params.projectId, projectObj); } if (projectObj.owner.toString() !== req.user._id.toString()) { - return res.status(403).json({ error: "Access denied." }); + return next(new AppError(403, "Access denied.")); } res.json(sanitizeProjectResponse(projectObj)); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; -module.exports.regenerateApiKey = async (req, res) => { +module.exports.regenerateApiKey = async (req, res, next) => { try { const { keyType } = req.body; if (keyType !== "publishable" && keyType !== "secret") { - return res - .status(400) - .json({ error: "Invalid keyType. Must be 'publishable' or 'secret'." }); + return next(new AppError(400, "Invalid keyType. Must be 'publishable' or 'secret'.")); } const prefix = keyType === "publishable" ? "pk_live_" : "sk_live_"; @@ -442,7 +445,7 @@ module.exports.regenerateApiKey = async (req, res) => { owner: req.user._id, }).select("publishableKey secretKey"); if (!oldApiProj) - return res.status(404).json({ error: "Project not found." }); + return next(new AppError(404, "Project not found.")); await deleteProjectByApiKeyCache(oldApiProj.publishableKey); await deleteProjectByApiKeyCache(oldApiProj.secretKey); @@ -457,7 +460,7 @@ module.exports.regenerateApiKey = async (req, res) => { { $set: updateField }, { new: true }, ); - if (!project) return res.status(404).json({ error: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); const projectObj = project.toObject(); delete projectObj.publishableKey; @@ -465,7 +468,7 @@ module.exports.regenerateApiKey = async (req, res) => { delete projectObj.jwtSecret; res.json({ apiKey: newApiKey, keyType, project: projectObj }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; @@ -494,7 +497,7 @@ const isSafeUri = (uri) => { } }; -module.exports.updateExternalConfig = async (req, res) => { +module.exports.updateExternalConfig = async (req, res, next) => { try { const { projectId } = req.params; @@ -505,10 +508,7 @@ module.exports.updateExternalConfig = async (req, res) => { if (dbUri) { if (!isSafeUri(dbUri)) - return res.status(400).json({ - error: - "DB URI is pointing to a restricted host (localhost/internal).", - }); + return next(new AppError(400, "DB URI is pointing to a restricted host (localhost/internal).")); updateData["resources.db.config"] = encrypt(JSON.stringify({ dbUri })); updateData["resources.db.isExternal"] = true; @@ -534,7 +534,7 @@ module.exports.updateExternalConfig = async (req, res) => { errorMsg += " " + connErr.message; } - return res.status(400).json({ error: errorMsg }); + return next(new AppError(400, errorMsg)); } } @@ -566,15 +566,15 @@ module.exports.updateExternalConfig = async (req, res) => { .json({ message: "External configuration updated successfully." }); } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ error: err.issues }); + return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); } console.error("External Config Error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; -module.exports.deleteExternalDbConfig = async (req, res) => { +module.exports.deleteExternalDbConfig = async (req, res, next) => { try { const parsedBody = z .object({ @@ -600,11 +600,11 @@ module.exports.deleteExternalDbConfig = async (req, res) => { .status(200) .json({ message: "External configuration deleted successfully." }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; -module.exports.deleteExternalStorageConfig = async (req, res) => { +module.exports.deleteExternalStorageConfig = async (req, res, next) => { try { const parsedBody = z .object({ @@ -633,11 +633,16 @@ module.exports.deleteExternalStorageConfig = async (req, res) => { .status(200) .json({ message: "External configuration deleted successfully." }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; +<<<<<<< Updated upstream module.exports.createCollection = async (req, res) => { +======= +// POST REQ FOR CREATE COLLECTION +module.exports.createCollection = async (req, res, next) => { +>>>>>>> Stashed changes const executeOperation = async (session) => { const { projectId, collectionName, schema } = createCollectionSchema.parse(req.body); @@ -760,16 +765,17 @@ module.exports.createCollection = async (req, res) => { return res.status(201).json(projectObj); } catch (retryErr) { - if (retryErr instanceof z.ZodError) return res.status(400).json({ error: retryErr.issues }); - return res.status(retryErr.status || 400).json({ error: retryErr.message }); + if (retryErr instanceof z.ZodError) return next(new AppError(400, retryErr.issues?.[0]?.message || 'Validation failed')); + return next(retryErr); } } - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues }); - return res.status(err.status || 400).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + return next(err); } }; +<<<<<<< Updated upstream // GET DOC BY ID — FIXED: added limitFields(), populate(), cursor pagination, count support, structured response module.exports.getData = async (req, res) => { try { @@ -782,6 +788,18 @@ module.exports.getData = async (req, res) => { const collectionConfig = project.collections.find(c => c.name === collectionName); if (!collectionConfig) { return res.status(404).json({ success: false, data: {}, message: `Collection ${collectionName} not found.` }); +======= +// GET DOC BY ID +module.exports.getData = async (req, res, next) => { + try { + const { projectId, collectionName } = req.params; + const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + if (!project) return next(new AppError(404, "Project not found.")); + + const collectionConfig = project.collections.find(c => c.name === collectionName); + if (!collectionConfig) { + return next(new AppError(404, "Collection not found")); +>>>>>>> Stashed changes } const connection = await getConnection(projectId); @@ -829,6 +847,7 @@ module.exports.getData = async (req, res) => { const data = await features.query.lean(); +<<<<<<< Updated upstream // Cursor: slice to limit and generate next cursor token let items = data; let nextCursor = null; @@ -867,6 +886,15 @@ module.exports.getData = async (req, res) => { }; }; module.exports.deleteCollection = async (req, res) => { +======= + res.json(data); + } catch (err) { + next(err); + } +} + +module.exports.deleteCollection = async (req, res, next) => { +>>>>>>> Stashed changes try { const { projectId, collectionName } = req.params; @@ -875,16 +903,14 @@ module.exports.deleteCollection = async (req, res) => { owner: req.user._id, }); if (!project) { - return res - .status(404) - .json({ error: "Project not found or access denied." }); + return next(new AppError(404, "Project not found or access denied.")); } const collectionIndex = project.collections.findIndex( (c) => c.name === collectionName, ); if (collectionIndex === -1) { - return res.status(404).json({ error: "Collection not found." }); + return next(new AppError(404, "Collection not found.")); } const isExternal = project.resources?.db?.isExternal; @@ -910,11 +936,11 @@ module.exports.deleteCollection = async (req, res) => { }); } catch (err) { console.error("Delete Collection Error:", err); - return res.status(500).json({ error: err.message }); + return next(err); } }; -module.exports.insertData = async (req, res) => { +module.exports.insertData = async (req, res, next) => { try { console.time("insert data"); const { projectId, collectionName } = req.params; @@ -922,13 +948,10 @@ module.exports.insertData = async (req, res) => { _id: projectId, owner: req.user._id, }); - if (!project) return res.status(404).json({ error: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); if (collectionName === "users") { - return res.status(400).json({ - error: - "Direct inserts into 'users' collection are not allowed. Please use the Auth signup or admin endpoints.", - }); + return next(new AppError(400, "Direct inserts into 'users' collection are not allowed. Please use the Auth signup or admin endpoints.")); } const incomingData = req.body; @@ -937,8 +960,13 @@ module.exports.insertData = async (req, res) => { (c) => c.name === collectionName, ); if (!collectionConfig) { +<<<<<<< Updated upstream return res.status(404).json({ success: false, data: {}, message: `Collection ${collectionName} not found.` }); } +======= + return next(new AppError(404, "Collection configuration not found.")); + } +>>>>>>> Stashed changes // Prevent manual injection of soft-delete fields delete incomingData.isDeleted; @@ -951,9 +979,7 @@ module.exports.insertData = async (req, res) => { const limit = project.databaseLimit || 20 * 1024 * 1024; if ((project.databaseUsed || 0) + docSize > limit) { - return res - .status(403) - .json({ error: "Database limit exceeded. Delete some data." }); + return next(new AppError(403, "Database limit exceeded. Delete some data.")); } } @@ -975,13 +1001,10 @@ module.exports.insertData = async (req, res) => { res.json(result); } catch (err) { if (err && err.code === 11000) { - return res.status(409).json({ - error: "Duplicate value violates unique constraint.", - details: err.message, - }); + return next(new AppError(409, "Duplicate value violates unique constraint.")); } - res.status(500).json({ error: err.message }); + next(err); } }; @@ -1129,7 +1152,7 @@ module.exports.recoverRow = async (req, res, next) => { } }; -module.exports.editRow = async (req, res) => { +module.exports.editRow = async (req, res, next) => { try { const { projectId, collectionName, id } = req.params; @@ -1137,13 +1160,13 @@ module.exports.editRow = async (req, res) => { _id: projectId, owner: req.user._id, }); - if (!project) return res.status(404).json({ error: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); if (!collectionConfig) { - return res.status(404).json({ error: "Collection not found." }); + return next(new AppError(404, "Collection not found.")); } const connection = await getConnection(projectId); @@ -1163,7 +1186,7 @@ module.exports.editRow = async (req, res) => { const docToEdit = await Model.findOne({ _id: id, isDeleted: { $ne: true } }); if (!docToEdit) { - return res.status(404).json({ error: "Document not found." }); + return next(new AppError(404, "Document not found.")); } // Prevent manual injection of soft-delete fields @@ -1182,7 +1205,7 @@ module.exports.editRow = async (req, res) => { const currentUsed = project.databaseUsed || 0; if (currentUsed + sizeDiff > limit) { - return res.status(403).json({ error: "Database limit exceeded." }); + return next(new AppError(403, "Database limit exceeded.")); } } @@ -1208,17 +1231,14 @@ module.exports.editRow = async (req, res) => { console.error("Edit Error:", err); if (err && err.code === 11000) { - return res.status(409).json({ - error: "Duplicate value violates unique constraint.", - details: err.message, - }); + return next(new AppError(409, "Duplicate value violates unique constraint.")); } - res.status(500).json({ error: err.message }); + next(err); } }; -module.exports.listFiles = async (req, res) => { +module.exports.listFiles = async (req, res, next) => { try { const { projectId } = req.params; @@ -1228,7 +1248,7 @@ module.exports.listFiles = async (req, res) => { }).select( "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", ); - if (!project) return res.status(404).json({ error: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const supabase = await getStorage(project); const bucket = getBucket(project); @@ -1257,19 +1277,16 @@ module.exports.listFiles = async (req, res) => { res.json(files); } catch (err) { console.error(err); - res.status(500).json({ - error: "Something Went Wrong", - try: "Try checking docs or contact support - urbackend@bitbros.in", - }); + next(err); } }; -module.exports.deleteFile = async (req, res) => { +module.exports.deleteFile = async (req, res, next) => { try { const { projectId } = req.params; const { path } = req.body; - if (!path) return res.status(400).json({ error: "Path required" }); + if (!path) return next(new AppError(400, "Path required")); const project = await Project.findOne({ _id: projectId, @@ -1277,10 +1294,10 @@ module.exports.deleteFile = async (req, res) => { }).select( "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", ); - if (!project) return res.status(404).json({ error: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); if (!path.startsWith(`${projectId}/`)) { - return res.status(403).json({ error: "Access denied" }); + return next(new AppError(403, "Access denied")); } const supabase = await getStorage(project); @@ -1309,11 +1326,11 @@ module.exports.deleteFile = async (req, res) => { res.json({ success: true }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; -module.exports.deleteAllFiles = async (req, res) => { +module.exports.deleteAllFiles = async (req, res, next) => { try { const { projectId } = req.params; @@ -1323,7 +1340,7 @@ module.exports.deleteAllFiles = async (req, res) => { }).select( "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", ); - if (!project) return res.status(404).json({ error: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const supabase = await getStorage(project); const bucket = getBucket(project); @@ -1354,7 +1371,7 @@ module.exports.deleteAllFiles = async (req, res) => { res.json({ success: true, deleted }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; @@ -1532,24 +1549,24 @@ module.exports.confirmUpload = async (req, res, next) => { } }; -module.exports.updateProject = async (req, res) => { +module.exports.updateProject = async (req, res, next) => { try { const { name, siteUrl, resendApiKey, resendFromEmail } = req.body; const updateFields = {}; if (name !== undefined) { if (typeof name !== "string" || !name.trim()) { - return res.status(400).json({ error: "name must be a non-empty string." }); + return next(new AppError(400, "name must be a non-empty string.")); } updateFields.name = name.trim(); } if (resendFromEmail !== undefined) { if (typeof resendFromEmail !== "string") { - return res.status(400).json({ error: "resendFromEmail must be a string." }); + return next(new AppError(400, "resendFromEmail must be a string.")); } const trimmedFrom = resendFromEmail.trim(); if (trimmedFrom !== "") { if (trimmedFrom.length > 255) { - return res.status(400).json({ error: "resendFromEmail is too long." }); + return next(new AppError(400, "resendFromEmail is too long.")); } let addressToValidate = trimmedFrom; const bracketMatch = trimmedFrom.match(/<([^>]+)>$/); @@ -1558,14 +1575,14 @@ module.exports.updateProject = async (req, res) => { } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(addressToValidate)) { - return res.status(400).json({ error: "resendFromEmail must be a valid format (e.g., 'me@domain.com' or 'App ')." }); + return next(new AppError(400, "resendFromEmail must be a valid format (e.g., 'me@domain.com' or 'App ').")); } } updateFields.resendFromEmail = trimmedFrom; } if (siteUrl !== undefined) { if (siteUrl !== "" && typeof siteUrl !== "string") { - return res.status(400).json({ error: "siteUrl must be a string." }); + return next(new AppError(400, "siteUrl must be a string.")); } if (siteUrl) { try { @@ -1577,13 +1594,10 @@ module.exports.updateProject = async (req, res) => { ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname) ) ) { - return res.status(400).json({ - error: - "Site URL must use HTTPS (or http://localhost for local development).", - }); + return next(new AppError(400, "Site URL must use HTTPS (or http://localhost for local development).")); } } catch { - return res.status(400).json({ error: "Invalid Site URL format." }); + return next(new AppError(400, "Invalid Site URL format.")); } } updateFields.siteUrl = siteUrl || ""; @@ -1591,13 +1605,11 @@ module.exports.updateProject = async (req, res) => { if (resendApiKey !== undefined) { const trimmedKey = typeof resendApiKey === "string" ? resendApiKey.trim() : ""; if (!trimmedKey) { - return res - .status(400) - .json({ error: "resendApiKey must be a non-empty string." }); + return next(new AppError(400, "resendApiKey must be a non-empty string.")); } if (!/^re_[A-Za-z0-9_]+$/.test(trimmedKey)) { - return res.status(400).json({ error: "Invalid Resend API Key format." }); + return next(new AppError(400, "Invalid Resend API Key format.")); } updateFields.resendApiKey = encrypt(trimmedKey); @@ -1612,7 +1624,7 @@ module.exports.updateProject = async (req, res) => { "+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag", }, ); - if (!project) return res.status(404).json({ error: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); await deleteProjectById(project._id.toString()); await deleteProjectByApiKeyCache(project.publishableKey); @@ -1621,7 +1633,7 @@ module.exports.updateProject = async (req, res) => { res.json(sanitizeProjectResponse(project.toObject())); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; @@ -1646,7 +1658,7 @@ module.exports.listMailTemplates = async (req, res, next) => { const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+mailTemplates"); - if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); const legacy = Array.isArray(project.mailTemplates) ? project.mailTemplates : []; @@ -1766,7 +1778,7 @@ module.exports.listGlobalMailTemplates = async (req, res, next) => { .select("_id") .lean(); - if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); const templates = await MailTemplate.find({ projectId: null, isSystem: true }) .sort({ keyLower: 1 }) @@ -1797,14 +1809,14 @@ module.exports.getMailTemplate = async (req, res, next) => { try { const { projectId, templateId } = req.params; if (!mongoose.isValidObjectId(templateId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid template id" }); + return next(new AppError(400, "Invalid template id")); } const project = await Project.findOne({ _id: projectId, owner: req.user._id }) .select("+mailTemplates") .lean(); - if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); let template = await MailTemplate.findOne({ _id: templateId, @@ -1831,7 +1843,7 @@ module.exports.getMailTemplate = async (req, res, next) => { } } - if (!template) return res.status(404).json({ success: false, data: {}, message: "Template not found." }); + if (!template) return next(new AppError(404, "Template not found.")); return res.json({ success: true, @@ -1881,7 +1893,7 @@ module.exports.createMailTemplate = async (req, res, next) => { .select("_id") .lean(); - if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); const key = (payload.key !== undefined && payload.key.trim() !== "") ? toSlug(payload.key) : toSlug(payload.name); @@ -1910,11 +1922,11 @@ module.exports.createMailTemplate = async (req, res, next) => { }); } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ success: false, data: {}, message: err.issues?.[0]?.message || "Invalid payload." }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid payload.")); } if (err && err.code === 11000) { - return res.status(409).json({ success: false, data: {}, message: "Template name/key already exists." }); + return next(new AppError(409, "Template name/key already exists.")); } return next(new AppError(500, "Internal server error")); @@ -1925,7 +1937,7 @@ module.exports.updateMailTemplate = async (req, res, next) => { try { const { projectId, templateId } = req.params; if (!mongoose.isValidObjectId(templateId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid template id" }); + return next(new AppError(400, "Invalid template id")); } const schema = z @@ -1944,7 +1956,7 @@ module.exports.updateMailTemplate = async (req, res, next) => { .select("_id") .lean(); - if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); const update = {}; if (payload.key !== undefined) { @@ -1962,7 +1974,7 @@ module.exports.updateMailTemplate = async (req, res, next) => { const templateFilter = { _id: templateId, projectId: project._id, isSystem: { $ne: true } }; const existing = await MailTemplate.findOne(templateFilter).lean(); - if (!existing) return res.status(404).json({ success: false, data: {}, message: "Template not found." }); + if (!existing) return next(new AppError(404, "Template not found.")); const nextHtml = payload.html !== undefined ? update.html : existing.html; const nextText = payload.text !== undefined ? update.text : existing.text; @@ -1970,7 +1982,7 @@ module.exports.updateMailTemplate = async (req, res, next) => { (typeof nextHtml === "string" && nextHtml.trim().length > 0) || (typeof nextText === "string" && nextText.trim().length > 0); if (!hasBody) { - return res.status(400).json({ success: false, data: {}, message: "Template must contain at least one of html or text." }); + return next(new AppError(400, "Template must contain at least one of html or text.")); } const updated = await MailTemplate.findOneAndUpdate( @@ -1979,7 +1991,7 @@ module.exports.updateMailTemplate = async (req, res, next) => { { new: true }, ).lean(); - if (!updated) return res.status(404).json({ success: false, data: {}, message: "Template not found." }); + if (!updated) return next(new AppError(404, "Template not found.")); return res.json({ success: true, @@ -1996,11 +2008,11 @@ module.exports.updateMailTemplate = async (req, res, next) => { }); } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ success: false, data: {}, message: err.issues?.[0]?.message || "Invalid payload." }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid payload.")); } if (err && err.code === 11000) { - return res.status(409).json({ success: false, data: {}, message: "Template name/key already exists." }); + return next(new AppError(409, "Template name/key already exists.")); } return next(new AppError(500, "Internal server error")); @@ -2011,14 +2023,14 @@ module.exports.deleteMailTemplate = async (req, res, next) => { try { const { projectId, templateId } = req.params; if (!mongoose.isValidObjectId(templateId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid template id" }); + return next(new AppError(400, "Invalid template id")); } const project = await Project.findOne({ _id: projectId, owner: req.user._id }) .select("_id") .lean(); - if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); + if (!project) return next(new AppError(404, "Project not found.")); const deleted = await MailTemplate.findOneAndDelete({ _id: templateId, @@ -2026,7 +2038,7 @@ module.exports.deleteMailTemplate = async (req, res, next) => { isSystem: { $ne: true }, }).lean(); - if (!deleted) return res.status(404).json({ success: false, data: {}, message: "Template not found." }); + if (!deleted) return next(new AppError(404, "Template not found.")); return res.json({ success: true, data: {}, message: "Mail template deleted." }); } catch (err) { @@ -2037,7 +2049,7 @@ module.exports.deleteMailTemplate = async (req, res, next) => { // ------------------------------------------------------------------------- -module.exports.updateAllowedDomains = async (req, res) => { +module.exports.updateAllowedDomains = async (req, res, next) => { try { const { domains } = req.body; if ( @@ -2073,11 +2085,11 @@ module.exports.updateAllowedDomains = async (req, res) => { allowedDomains: project.allowedDomains, }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; -module.exports.deleteProject = async (req, res) => { +module.exports.deleteProject = async (req, res, next) => { try { const projectId = req.params.projectId; @@ -2140,7 +2152,7 @@ module.exports.deleteProject = async (req, res) => { }); } catch (err) { console.error(err); - res.status(500).json({ error: err.message }); + next(err); } }; @@ -2151,20 +2163,12 @@ module.exports.analytics = async (req, res, next) => { const project = await Project.findOne({ _id: projectId, owner: req.user._id }); if (!project) { - return res.status(404).json({ - success: false, - data: {}, - message: "Project not found or access denied.", - }); + return next(new AppError(404, "Project not found or access denied.")); } const VALID_RANGES = new Set(['last1h', 'last24h', 'last7d', 'last30d', 'allTime']); if (!VALID_RANGES.has(range)) { - return res.status(400).json({ - success: false, - data: {}, - message: `Invalid range. Allowed values: ${[...VALID_RANGES].join(', ')}.`, - }); + return next(new AppError(400, `Invalid range. Allowed values: ${[...VALID_RANGES].join(', ')}.`)); } let startDate = new Date(); @@ -2304,7 +2308,12 @@ module.exports.analytics = async (req, res, next) => { } }; +<<<<<<< Updated upstream module.exports.toggleAuth = async (req, res) => { +======= +// FUNCTION - TOGGLE AUTH +module.exports.toggleAuth = async (req, res, next) => { +>>>>>>> Stashed changes try { const { projectId } = req.params; const { enable } = req.body; @@ -2320,24 +2329,16 @@ module.exports.toggleAuth = async (req, res) => { "+authProviders.google.clientSecret.iv " + "+authProviders.google.clientSecret.tag" ); - if (!project) return res.status(404).json({ error: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); if (enable) { const usersCol = project.collections.find((c) => c.name === "users"); if (!usersCol) { - return res.status(422).json({ - error: "Users Collection Missing", - message: - "The 'users' collection must be created and configured with required 'email' and 'password' fields before enabling Authentication.", - }); + return next(new AppError(422, "The 'users' collection must be created and configured with required 'email' and 'password' fields before enabling Authentication.")); } if (!validateUsersSchema(usersCol.model)) { - return res.status(422).json({ - error: "Invalid Users Schema", - message: - "The 'users' collection must have required 'email' and 'password' string fields. Please fix the schema before enabling Auth.", - }); + return next(new AppError(422, "The 'users' collection must have required 'email' and 'password' string fields. Please fix the schema before enabling Auth.")); } } @@ -2356,11 +2357,20 @@ module.exports.toggleAuth = async (req, res) => { project: projectObj, }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; +<<<<<<< Updated upstream module.exports.updateAuthProviders = async (req, res) => { +======= +/** + * Updates GitHub/Google OAuth provider settings for a project. + * Preserves existing encrypted client secrets when not provided in the update. + * @route PUT /api/projects/:projectId/auth-providers + */ +module.exports.updateAuthProviders = async (req, res, next) => { +>>>>>>> Stashed changes try { const { projectId } = req.params; const parsed = updateAuthProvidersSchema.parse(req.body || {}); @@ -2377,7 +2387,7 @@ module.exports.updateAuthProviders = async (req, res) => { "+authProviders.google.clientSecret.tag", ); - if (!project) return res.status(404).json({ error: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); project.authProviders = project.authProviders || {}; @@ -2396,17 +2406,11 @@ module.exports.updateAuthProviders = async (req, res) => { : (current.clientSecret || null); if (nextEnabled && (!nextClientId || !nextClientSecret)) { - return res.status(422).json({ - error: "Incomplete provider config", - message: `${provider} requires clientId and clientSecret before it can be enabled.`, - }); + return next(new AppError(422, `${provider} requires clientId and clientSecret before it can be enabled.`)); } if (nextEnabled && !project.siteUrl?.trim()) { - return res.status(422).json({ - error: "siteUrl required", - message: `You must configure a Site URL in Project Settings before enabling ${provider} OAuth. The Site URL is used to redirect users after authentication.`, - }); + return next(new AppError(422, `You must configure a Site URL in Project Settings before enabling ${provider} OAuth. The Site URL is used to redirect users after authentication.`)); } project.authProviders[provider] = { @@ -2429,27 +2433,33 @@ module.exports.updateAuthProviders = async (req, res) => { }); } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ error: err.issues }); + return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); } - return res.status(500).json({ error: err.message }); + return next(err); } }; +<<<<<<< Updated upstream module.exports.updateCollectionRls = async (req, res) => { +======= + +// PATCH FOR UPDATING COLLECTION RLS +module.exports.updateCollectionRls = async (req, res, next) => { +>>>>>>> Stashed changes try { const { projectId, collectionName } = req.params; const { enabled, mode, ownerField, requireAuthForWrite } = req.body || {}; const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const collection = project.collections.find(c => c.name === collectionName); - if (!collection) return res.status(404).json({ error: "Collection not found" }); + if (!collection) return next(new AppError(404, "Collection not found")); const validMode = mode || collection?.rls?.mode || 'public-read'; const allowedModes = new Set(['public-read', 'private', 'owner-write-only']); if (!allowedModes.has(validMode)) { - return res.status(400).json({ error: "Unsupported RLS mode. Allowed: public-read, private, owner-write-only (legacy)." }); + return next(new AppError(400, "Unsupported RLS mode. Allowed: public-read, private, owner-write-only (legacy).")); } const modelKeys = (collection.model || []) @@ -2466,17 +2476,11 @@ module.exports.updateCollectionRls = async (req, res) => { const nextOwnerField = requestedOwnerRaw === '_id' ? '_id' : (canonicalOwnerField || requestedOwnerRaw); if (nextOwnerField !== '_id' && !modelKeySet.has(nextOwnerField)) { - return res.status(400).json({ - error: "Invalid owner field", - message: `ownerField '${nextOwnerField}' not found in collection schema` - }); + return next(new AppError(400, `ownerField '${nextOwnerField}' not found in collection schema`)); } if (nextOwnerField === '_id' && collection.name !== 'users') { - return res.status(400).json({ - error: "Invalid owner field", - message: "ownerField '_id' is only allowed for the 'users' collection" - }); + return next(new AppError(400, "ownerField '_id' is only allowed for the 'users' collection")); } collection.rls = { @@ -2502,7 +2506,7 @@ module.exports.updateCollectionRls = async (req, res) => { } }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; @@ -2520,11 +2524,11 @@ const getResolvedResendKey = (project) => { return { key: process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY, isByok: false }; }; -module.exports.getMailLogs = async (req, res) => { +module.exports.getMailLogs = async (req, res, next) => { try { const { projectId } = req.params; const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ success: false, message: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const logs = await MailLog.find({ projectId: project._id }) .sort({ sentAt: -1 }) @@ -2533,27 +2537,27 @@ module.exports.getMailLogs = async (req, res) => { return res.json({ success: true, data: { logs } }); } catch (err) { - return res.status(500).json({ success: false, message: err.message }); + return next(err); } }; -module.exports.getResendLiveStatus = async (req, res) => { +module.exports.getResendLiveStatus = async (req, res, next) => { try { const { projectId, resendId } = req.params; if (!/^[A-Za-z0-9_-]{1,128}$/.test(resendId)) { - return res.status(400).json({ success: false, message: "Invalid resendId format." }); + return next(new AppError(400, "Invalid resendId format.")); } const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); - if (!project) return res.status(404).json({ success: false, message: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const logEntry = await MailLog.findOne({ resendEmailId: resendId, projectId: project._id }).lean(); if (!logEntry) { - return res.status(404).json({ success: false, message: "Mail log entry not found for this project." }); + return next(new AppError(404, "Mail log entry not found for this project.")); } const { key } = getResolvedResendKey(project); - if (!key) return res.status(400).json({ success: false, message: "Resend API Key is missing." }); + if (!key) return next(new AppError(400, "Resend API Key is missing.")); const safeResendId = encodeURIComponent(resendId); const response = await axios.get(`https://api.resend.com/emails/${safeResendId}`, { @@ -2564,30 +2568,22 @@ module.exports.getResendLiveStatus = async (req, res) => { } catch (err) { const { resendId } = req.params; if (err.response?.status === 404) { - return res.status(404).json({ - success: false, - data: { - id: resendId, - last_event: "unknown", - providerStatus: "not_found", - }, - message: "Email status not found on Resend for this id." - }); + return next(new AppError(404, "Email status not found on Resend for this id.")); } const errorMsg = err.response?.data?.message || err.message; - return res.status(err.response?.status || 500).json({ success: false, message: errorMsg }); + return next(new AppError(err.response?.status || 500, errorMsg)); } }; -module.exports.manageAudiences = async (req, res) => { +module.exports.manageAudiences = async (req, res, next) => { try { const { projectId } = req.params; const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); - if (!project) return res.status(404).json({ success: false, message: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const { key, isByok } = getResolvedResendKey(project); if (!isByok || !key) { - return res.status(403).json({ success: false, message: "Audiences require a custom Resend API Key (BYOK) configured in Project Settings." }); + return next(new AppError(403, "Audiences require a custom Resend API Key (BYOK) configured in Project Settings.")); } if (req.method === "GET") { @@ -2599,7 +2595,7 @@ module.exports.manageAudiences = async (req, res) => { if (req.method === "POST") { const { name } = req.body; - if (!name) return res.status(400).json({ success: false, message: "Audience name required" }); + if (!name) return next(new AppError(400, "Audience name required")); const response = await axios.post("https://api.resend.com/audiences", { name }, { headers: { Authorization: `Bearer ${key}` } @@ -2607,26 +2603,26 @@ module.exports.manageAudiences = async (req, res) => { return res.json({ success: true, data: response.data }); } - return res.status(405).json({ success: false, message: "Method not allowed" }); + return next(new AppError(405, "Method not allowed")); } catch (err) { const errorMsg = err.response?.data?.message || err.message; - return res.status(err.response?.status || 500).json({ success: false, message: errorMsg }); + return next(new AppError(err.response?.status || 500, errorMsg)); } }; -module.exports.deleteAudience = async (req, res) => { +module.exports.deleteAudience = async (req, res, next) => { try { const { projectId, audienceId } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(audienceId)) { - return res.status(400).json({ success: false, message: "Invalid audienceId format" }); + return next(new AppError(400, "Invalid audienceId format")); } const safeAudienceId = encodeURIComponent(audienceId); const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); - if (!project) return res.status(404).json({ success: false, message: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const { key, isByok } = getResolvedResendKey(project); if (!isByok || !key) { - return res.status(403).json({ success: false, message: "Audiences require a custom Resend API Key (BYOK)." }); + return next(new AppError(403, "Audiences require a custom Resend API Key (BYOK).")); } await axios.delete(`https://api.resend.com/audiences/${safeAudienceId}`, { @@ -2636,23 +2632,23 @@ module.exports.deleteAudience = async (req, res) => { return res.json({ success: true, message: "Audience deleted successfully" }); } catch (err) { const errorMsg = err.response?.data?.message || err.message; - return res.status(err.response?.status || 500).json({ success: false, message: errorMsg }); + return next(new AppError(err.response?.status || 500, errorMsg)); } }; -module.exports.manageContacts = async (req, res) => { +module.exports.manageContacts = async (req, res, next) => { try { const { projectId, audienceId } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(audienceId)) { - return res.status(400).json({ success: false, message: "Invalid audienceId format" }); + return next(new AppError(400, "Invalid audienceId format")); } const safeAudienceId = encodeURIComponent(audienceId); const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); - if (!project) return res.status(404).json({ success: false, message: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const { key, isByok } = getResolvedResendKey(project); if (!isByok || !key) { - return res.status(403).json({ success: false, message: "Contacts require a custom Resend API Key (BYOK)." }); + return next(new AppError(403, "Contacts require a custom Resend API Key (BYOK).")); } if (req.method === "GET") { @@ -2664,7 +2660,7 @@ module.exports.manageContacts = async (req, res) => { if (req.method === "POST") { const { email, firstName, lastName, unsubscribed } = req.body; - if (!email) return res.status(400).json({ success: false, message: "Contact email required" }); + if (!email) return next(new AppError(400, "Contact email required")); const payload = { email, first_name: firstName, last_name: lastName, unsubscribed }; const response = await axios.post(`https://api.resend.com/audiences/${safeAudienceId}/contacts`, payload, { @@ -2673,27 +2669,27 @@ module.exports.manageContacts = async (req, res) => { return res.json({ success: true, data: response.data }); } - return res.status(405).json({ success: false, message: "Method not allowed" }); + return next(new AppError(405, "Method not allowed")); } catch (err) { const errorMsg = err.response?.data?.message || err.message; - return res.status(err.response?.status || 500).json({ success: false, message: errorMsg }); + return next(new AppError(err.response?.status || 500, errorMsg)); } }; -module.exports.deleteContact = async (req, res) => { +module.exports.deleteContact = async (req, res, next) => { try { const { projectId, audienceId, contactId } = req.params; const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); - if (!project) return res.status(404).json({ success: false, message: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const { key, isByok } = getResolvedResendKey(project); if (!isByok || !key) { - return res.status(403).json({ success: false, message: "Contacts require a custom Resend API Key (BYOK)." }); + return next(new AppError(403, "Contacts require a custom Resend API Key (BYOK).")); } const resendIdPattern = /^[A-Za-z0-9_-]+$/; if (!resendIdPattern.test(audienceId) || !resendIdPattern.test(contactId)) { - return res.status(400).json({ success: false, message: "Invalid audienceId or contactId format." }); + return next(new AppError(400, "Invalid audienceId or contactId format.")); } const safeAudienceId = encodeURIComponent(audienceId); @@ -2706,31 +2702,31 @@ module.exports.deleteContact = async (req, res) => { return res.json({ success: true, message: "Contact removed successfully" }); } catch (err) { const errorMsg = err.response?.data?.message || err.message; - return res.status(err.response?.status || 500).json({ success: false, message: errorMsg }); + return next(new AppError(err.response?.status || 500, errorMsg)); } }; -module.exports.sendMarketingBroadcast = async (req, res) => { +module.exports.sendMarketingBroadcast = async (req, res, next) => { try { const { projectId } = req.params; const { audienceId, subject, html, from } = req.body; const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); - if (!project) return res.status(404).json({ success: false, message: "Project not found" }); + if (!project) return next(new AppError(404, "Project not found")); const { key, isByok } = getResolvedResendKey(project); if (!isByok || !key) { - return res.status(403).json({ success: false, message: "Marketing Broadcasts require a custom Resend API Key (BYOK)." }); + return next(new AppError(403, "Marketing Broadcasts require a custom Resend API Key (BYOK).")); } const dev = await Developer.findById(req.user._id); const effectivePlan = resolveEffectivePlan(dev); if (effectivePlan !== "pro") { - return res.status(403).json({ success: false, message: "Marketing Broadcasts are a premium feature requiring the Pro tier." }); + return next(new AppError(403, "Marketing Broadcasts are a premium feature requiring the Pro tier.")); } if (!audienceId || !subject || !html) { - return res.status(400).json({ success: false, message: "Audience ID, subject, and html content are required." }); + return next(new AppError(400, "Audience ID, subject, and html content are required.")); } const payload = { @@ -2747,6 +2743,6 @@ module.exports.sendMarketingBroadcast = async (req, res) => { return res.json({ success: true, data: response.data, message: "Broadcast dispatched successfully!" }); } catch (err) { const errorMsg = err.response?.data?.message || err.message; - return res.status(err.response?.status || 500).json({ success: false, message: errorMsg }); + return next(new AppError(err.response?.status || 500, errorMsg)); } }; diff --git a/apps/dashboard-api/src/controllers/release.controller.js b/apps/dashboard-api/src/controllers/release.controller.js index a121b5e60..7d0eb804d 100644 --- a/apps/dashboard-api/src/controllers/release.controller.js +++ b/apps/dashboard-api/src/controllers/release.controller.js @@ -1,6 +1,7 @@ const {Release} = require("@urbackend/common"); const {Developer} = require("@urbackend/common"); const { emailQueue } = require("@urbackend/common"); +const { AppError } = require("@urbackend/common"); const ADMIN_EMAIL = process.env.ADMIN_EMAIL; @@ -65,29 +66,28 @@ const extractReleaseLinkFromContent = (content) => { }; // GET FOR - ALL RELEASES -exports.getAllReleases = async (req, res) => { +exports.getAllReleases = async (req, res, next) => { try { const releases = await Release.find().sort({ createdAt: -1 }); res.json(releases); } catch (err) { - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + next(err); } }; // POST FOR - CREATE RELEASE -exports.createRelease = async (req, res) => { +exports.createRelease = async (req, res, next) => { try { const { version, title, content } = req.body; const changelogUrlFromContent = extractReleaseLinkFromContent(content); const dev = await Developer.findById(req.user._id); if (!dev || dev.email !== ADMIN_EMAIL) { - return res.status(403).json({ error: "Access denied. Admin only." }); + return next(new AppError(403, "Access denied. Admin only.")); } if (!version || !title || !content) { - return res.status(400).json({ error: "Missing version, title, or content" }); + return next(new AppError(400, "Missing version, title, or content")); } const newRelease = new Release({ @@ -117,7 +117,6 @@ exports.createRelease = async (req, res) => { }); } catch (err) { - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + next(err); } }; diff --git a/apps/dashboard-api/src/controllers/userAuth.controller.js b/apps/dashboard-api/src/controllers/userAuth.controller.js index 90ede0a38..7ac77d6a9 100644 --- a/apps/dashboard-api/src/controllers/userAuth.controller.js +++ b/apps/dashboard-api/src/controllers/userAuth.controller.js @@ -5,7 +5,7 @@ const mongoose = require('mongoose'); const {Project} = require('@urbackend/common'); const {redis} = require('@urbackend/common'); const { authEmailQueue } = require('@urbackend/common'); -const { loginSchema, signupSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common'); +const { loginSchema, signupSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize, AppError } = require('@urbackend/common'); const { getConnection } = require('@urbackend/common'); const { getCompiledModel } = require('@urbackend/common'); const { getUserActiveSessions, getRefreshSession, revokeSessionChain } = require('@urbackend/common'); @@ -61,7 +61,7 @@ const buildAuthUserPayload = (usersColConfig, parsedData, hashedPassword, verifi // POST REQ FOR SIGNUP -module.exports.signup = async (req, res) => { +module.exports.signup = async (req, res, next) => { try { const project = req.project; @@ -69,14 +69,14 @@ module.exports.signup = async (req, res) => { // Get Mongoose Model const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); const existingUser = await Model.findOne({ email }); if (existingUser) { - return res.status(400).json({ error: "User already exists with this email." }); + return next(new AppError(400, "User already exists with this email.")); } const salt = await bcrypt.genSalt(10); @@ -117,30 +117,29 @@ module.exports.signup = async (req, res) => { } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ error: err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed" }); + return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); } - res.status(500).json({ error: err.message }); - console.log(err) + next(err); } } // POST REQ FOR LOGIN -module.exports.login = async (req, res) => { +module.exports.login = async (req, res, next) => { try { const project = req.project; const { email, password } = loginSchema.parse(req.body); const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); const user = await Model.findOne({ email }); - if (!user) return res.status(400).json({ error: "Invalid email or password" }); + if (!user) return next(new AppError(400, "Invalid email or password")); const validPass = await bcrypt.compare(password, user.password); - if (!validPass) return res.status(400).json({ error: "Invalid email or password" }); + if (!validPass) return next(new AppError(400, "Invalid email or password")); const token = jwt.sign( { userId: user._id, projectId: project._id }, @@ -151,25 +150,25 @@ module.exports.login = async (req, res) => { res.json({ token }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - res.status(500).json({ error: err.message }); // Fixed: .json() + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } // FUNCTION - GET CURRENT USER -module.exports.me = async (req, res) => { +module.exports.me = async (req, res, next) => { try { const project = req.project; const tokenHeader = req.header('Authorization'); - if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); + if (!tokenHeader) return next(new AppError(401, "Access Denied: No Token Provided")); const token = tokenHeader.replace("Bearer ", ""); try { const decoded = jwt.verify(token, project.jwtSecret); const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -179,21 +178,21 @@ module.exports.me = async (req, res) => { { password: 0 } ).lean(); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); res.json(user); } catch (err) { - return res.status(401).json({ error: "Invalid or Expired Token" }); + return next(new AppError(401, "Invalid or Expired Token")); } } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } } // POST REQ FOR ADMIN CREATE USER -module.exports.createAdminUser = async (req, res) => { +module.exports.createAdminUser = async (req, res, next) => { try { const project = req.project; @@ -202,14 +201,14 @@ module.exports.createAdminUser = async (req, res) => { // Get Mongoose Model const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); const existingUser = await Model.findOne({ email }); if (existingUser) { - return res.status(400).json({ error: "User already exists with this email." }); + return next(new AppError(400, "User already exists with this email.")); } const salt = await bcrypt.genSalt(10); @@ -231,15 +230,14 @@ module.exports.createAdminUser = async (req, res) => { } catch (err) { if (err instanceof z.ZodError) { - console.error(err); - return res.status(400).json({ error: err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed" }); + return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); } - res.status(500).json({ error: err.message }); + next(err); } } // PATCH REQ FOR ADMIN RESET PASSWORD -module.exports.resetPassword = async (req, res) => { +module.exports.resetPassword = async (req, res, next) => { try { const project = req.project; const { userId } = req.params; @@ -247,11 +245,11 @@ module.exports.resetPassword = async (req, res) => { const { newPassword } = req.body; if (!newPassword || newPassword.length < 6) { - return res.status(400).json({ error: "Password must be at least 6 characters" }); + return next(new AppError(400, "Password must be at least 6 characters")); } const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -265,18 +263,18 @@ module.exports.resetPassword = async (req, res) => { ); if (result.matchedCount === 0) { - return res.status(404).json({ error: "User not found" }); + return next(new AppError(404, "User not found")); } res.json({ message: "Password updated successfully" }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } } // POST REQ FOR EMAIL VERIFICATION -module.exports.verifyEmail = async (req, res) => { +module.exports.verifyEmail = async (req, res, next) => { try { const project = req.project; const { email, otp } = verifyOtpSchema.parse(req.body); @@ -285,43 +283,43 @@ module.exports.verifyEmail = async (req, res) => { const storedOtp = await redis.get(redisKey); if (!storedOtp || storedOtp !== otp) { - return res.status(400).json({ error: "Invalid or expired OTP" }); + return next(new AppError(400, "Invalid or expired OTP")); } const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); const verificationField = getVerificationField(usersColConfig); if (!verificationField) { - return res.status(500).json({ error: "No verification field found in users schema" }); + return next(new AppError(500, "No verification field found in users schema")); } const result = await Model.updateOne( { email }, { $set: { [verificationField]: true } } ); - if (result.matchedCount === 0) return res.status(404).json({ error: "User not found" }); + if (result.matchedCount === 0) return next(new AppError(404, "User not found")); await redis.del(redisKey); res.json({ message: "Email verified successfully" }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + next(err); } }; // POST REQ FOR PASSWORD RESET REQUEST -module.exports.requestPasswordReset = async (req, res) => { +module.exports.requestPasswordReset = async (req, res, next) => { try { const project = req.project; const { email } = onlyEmailSchema.parse(req.body); const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -338,13 +336,13 @@ module.exports.requestPasswordReset = async (req, res) => { res.json({ message: "If that email exists, a reset code has been sent." }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + next(err); } }; // POST REQ FOR PASSWORD RESET CONFIRMATION -module.exports.resetPasswordUser = async (req, res) => { +module.exports.resetPasswordUser = async (req, res, next) => { try { const project = req.project; const { email, otp, newPassword } = resetPasswordSchema.parse(req.body); @@ -353,7 +351,7 @@ module.exports.resetPasswordUser = async (req, res) => { const storedOtp = await redis.get(redisKey); if (!storedOtp || storedOtp !== otp) { - return res.status(400).json({ error: "Invalid or expired OTP" }); + return next(new AppError(400, "Invalid or expired OTP")); } const salt = await bcrypt.genSalt(10); @@ -366,36 +364,36 @@ module.exports.resetPasswordUser = async (req, res) => { { $set: { password: hashedPassword } } ); - if (result.matchedCount === 0) return res.status(404).json({ error: "User not found" }); + if (result.matchedCount === 0) return next(new AppError(404, "User not found")); await redis.del(redisKey); res.json({ message: "Password updated successfully" }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + next(err); } }; // PATCH REQ FOR UPDATE PROFILE -module.exports.updateProfile = async (req, res) => { +module.exports.updateProfile = async (req, res, next) => { try { const project = req.project; const tokenHeader = req.header('Authorization'); - if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); + if (!tokenHeader) return next(new AppError(401, "Access Denied: No Token Provided")); const token = tokenHeader.replace("Bearer ", ""); let decoded; try { decoded = jwt.verify(token, project.jwtSecret); } catch (err) { - return res.status(401).json({ error: "Access Denied: Invalid or expired token" }); + return next(new AppError(401, "Access Denied: Invalid or expired token")); } const username = req.body.username; if (!username || username.length < 3 || username.length > 50) { - return res.status(400).json({ error: "Username must be between 3 and 50 characters." }); + return next(new AppError(400, "Username must be between 3 and 50 characters.")); } const collection = await getAuthCollection(project); @@ -408,39 +406,39 @@ module.exports.updateProfile = async (req, res) => { res.json({ message: "Profile updated successfully" }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; // POST REQ FOR CHANGE PASSWORD -module.exports.changePasswordUser = async (req, res) => { +module.exports.changePasswordUser = async (req, res, next) => { try { const project = req.project; const tokenHeader = req.header('Authorization'); - if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); + if (!tokenHeader) return next(new AppError(401, "Access Denied: No Token Provided")); const token = tokenHeader.replace("Bearer ", ""); let decoded; try { decoded = jwt.verify(token, project.jwtSecret); } catch (err) { - return res.status(401).json({ error: "Access Denied: Invalid or expired token" }); + return next(new AppError(401, "Access Denied: Invalid or expired token")); } const { currentPassword, newPassword } = changePasswordSchema.parse(req.body); const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); const user = await Model.findOne({ _id: new mongoose.Types.ObjectId(decoded.userId) }); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); const validPass = await bcrypt.compare(currentPassword, user.password); - if (!validPass) return res.status(400).json({ error: "Invalid current password" }); + if (!validPass) return next(new AppError(400, "Invalid current password")); const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(newPassword, salt); @@ -453,19 +451,19 @@ module.exports.changePasswordUser = async (req, res) => { res.json({ message: "Password changed successfully" }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + next(err); } }; // FUNCTION - GET USER DETAILS (ADMIN) -module.exports.getUserDetails = async (req, res) => { +module.exports.getUserDetails = async (req, res, next) => { try { const project = req.project; const { userId } = req.params; const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -474,16 +472,16 @@ module.exports.getUserDetails = async (req, res) => { { _id: new mongoose.Types.ObjectId(userId) }, { password: 0 } ).lean(); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); res.json(user); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; // PUT REQ FOR UPDATE ADMIN USER -module.exports.updateAdminUser = async (req, res) => { +module.exports.updateAdminUser = async (req, res, next) => { try { const project = req.project; const { userId } = req.params; @@ -496,7 +494,7 @@ module.exports.updateAdminUser = async (req, res) => { // Get Mongoose Model const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -508,17 +506,17 @@ module.exports.updateAdminUser = async (req, res) => { ); if (result.matchedCount === 0) { - return res.status(404).json({ error: "User not found" }); + return next(new AppError(404, "User not found")); } res.json({ message: "User updated successfully" }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; // GET ACTIVE SESSIONS FOR A USER (Admin) -module.exports.listUserSessions = async (req, res) => { +module.exports.listUserSessions = async (req, res, next) => { try { const project = req.project; const { userId } = req.params; @@ -533,19 +531,19 @@ module.exports.listUserSessions = async (req, res) => { { _id: 1 } ).lean(); if (!userExists) { - return res.status(404).json({ error: 'User not found in this project' }); + return next(new AppError(404, 'User not found in this project')); } } const sessions = await getUserActiveSessions(project._id, userId); res.json({ sessions }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; // REVOKE A SPECIFIC SESSION FOR A USER (Admin) -module.exports.revokeUserSession = async (req, res) => { +module.exports.revokeUserSession = async (req, res, next) => { try { const projectId = String(req.project._id); const { userId, tokenId } = req.params; @@ -556,7 +554,7 @@ module.exports.revokeUserSession = async (req, res) => { if (!session || String(session.projectId) !== projectId || String(session.userId) !== String(userId)) { - return res.status(404).json({ error: 'Session not found or does not belong to this user' }); + return next(new AppError(404, 'Session not found or does not belong to this user')); } // Revoke the entire chain starting from this token, cleaning up the user sessions set @@ -564,6 +562,6 @@ module.exports.revokeUserSession = async (req, res) => { res.json({ message: 'Session revoked successfully' }); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; diff --git a/apps/dashboard-api/src/controllers/webhook.controller.js b/apps/dashboard-api/src/controllers/webhook.controller.js index 2727a744c..04169221e 100644 --- a/apps/dashboard-api/src/controllers/webhook.controller.js +++ b/apps/dashboard-api/src/controllers/webhook.controller.js @@ -3,6 +3,7 @@ const { Webhook, WebhookDelivery, Project, + AppError, encrypt, decrypt, createWebhookSchema, @@ -17,12 +18,12 @@ const isValidId = (id) => mongoose.Types.ObjectId.isValid(id); /** * Create a new webhook for a project */ -module.exports.createWebhook = async (req, res) => { +module.exports.createWebhook = async (req, res, next) => { try { const { projectId } = req.params; if (!isValidId(projectId)) { - return res.status(400).json({ error: "Invalid project ID" }); + return next(new AppError(400, "Invalid project ID")); } // Verify project ownership @@ -31,16 +32,13 @@ module.exports.createWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } // Validate input const validation = createWebhookSchema.safeParse(req.body); if (!validation.success) { - return res.status(400).json({ - error: "Validation failed", - details: validation.error.errors, - }); + return next(new AppError(400, validation.error.errors?.[0]?.message || "Validation failed")); } const { name, url, secret, events, enabled } = validation.data; @@ -71,20 +69,19 @@ module.exports.createWebhook = async (req, res) => { }, }); } catch (err) { - console.error("[Webhook] Create error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Get all webhooks for a project */ -module.exports.getWebhooks = async (req, res) => { +module.exports.getWebhooks = async (req, res, next) => { try { const { projectId } = req.params; if (!isValidId(projectId)) { - return res.status(400).json({ error: "Invalid project ID" }); + return next(new AppError(400, "Invalid project ID")); } // Verify project ownership @@ -93,7 +90,7 @@ module.exports.getWebhooks = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } const webhooks = await Webhook.find({ projectId }).lean(); @@ -112,20 +109,19 @@ module.exports.getWebhooks = async (req, res) => { res.json({ data }); } catch (err) { - console.error("[Webhook] List error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Get a single webhook */ -module.exports.getWebhook = async (req, res) => { +module.exports.getWebhook = async (req, res, next) => { try { const { projectId, webhookId } = req.params; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -134,7 +130,7 @@ module.exports.getWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } const webhook = await Webhook.findOne({ @@ -143,7 +139,7 @@ module.exports.getWebhook = async (req, res) => { }).lean(); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } res.json({ @@ -159,20 +155,19 @@ module.exports.getWebhook = async (req, res) => { }, }); } catch (err) { - console.error("[Webhook] Get error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Update a webhook */ -module.exports.updateWebhook = async (req, res) => { +module.exports.updateWebhook = async (req, res, next) => { try { const { projectId, webhookId } = req.params; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -181,16 +176,13 @@ module.exports.updateWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } // Validate input const validation = updateWebhookSchema.safeParse(req.body); if (!validation.success) { - return res.status(400).json({ - error: "Validation failed", - details: validation.error.errors, - }); + return next(new AppError(400, validation.error.errors?.[0]?.message || "Validation failed")); } const { name, url, secret, events, enabled } = validation.data; @@ -213,7 +205,7 @@ module.exports.updateWebhook = async (req, res) => { ).lean(); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } res.json({ @@ -230,20 +222,19 @@ module.exports.updateWebhook = async (req, res) => { }, }); } catch (err) { - console.error("[Webhook] Update error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Delete a webhook */ -module.exports.deleteWebhook = async (req, res) => { +module.exports.deleteWebhook = async (req, res, next) => { try { const { projectId, webhookId } = req.params; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -252,7 +243,7 @@ module.exports.deleteWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } const webhook = await Webhook.findOneAndDelete({ @@ -261,7 +252,7 @@ module.exports.deleteWebhook = async (req, res) => { }); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } // Optionally clean up delivery logs (or keep for audit) @@ -269,21 +260,20 @@ module.exports.deleteWebhook = async (req, res) => { res.json({ message: "Webhook deleted" }); } catch (err) { - console.error("[Webhook] Delete error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Get delivery history for a webhook */ -module.exports.getDeliveries = async (req, res) => { +module.exports.getDeliveries = async (req, res, next) => { try { const { projectId, webhookId } = req.params; const { limit = 50, page = 1 } = req.query; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -292,13 +282,13 @@ module.exports.getDeliveries = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } // Verify webhook belongs to project const webhook = await Webhook.findOne({ _id: webhookId, projectId }); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } const pageNum = Math.max(1, parseInt(page) || 1); @@ -324,20 +314,19 @@ module.exports.getDeliveries = async (req, res) => { }, }); } catch (err) { - console.error("[Webhook] Get deliveries error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Send a test webhook */ -module.exports.testWebhook = async (req, res) => { +module.exports.testWebhook = async (req, res, next) => { try { const { projectId, webhookId } = req.params; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -346,7 +335,7 @@ module.exports.testWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } // Load webhook with secret @@ -355,7 +344,7 @@ module.exports.testWebhook = async (req, res) => { ); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } // Decrypt secret @@ -364,7 +353,7 @@ module.exports.testWebhook = async (req, res) => { secret = decrypt(webhook.secret); if (!secret) throw new Error("Decryption failed"); } catch (err) { - return res.status(500).json({ error: "Failed to decrypt webhook secret" }); + return next(new AppError(500, "Failed to decrypt webhook secret")); } // Create test payload @@ -431,7 +420,6 @@ module.exports.testWebhook = async (req, res) => { durationMs, }); } catch (err) { - console.error("[Webhook] Test error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; diff --git a/apps/dashboard-api/src/middlewares/authMiddleware.js b/apps/dashboard-api/src/middlewares/authMiddleware.js index 01a9343cb..8880c15d8 100644 --- a/apps/dashboard-api/src/middlewares/authMiddleware.js +++ b/apps/dashboard-api/src/middlewares/authMiddleware.js @@ -1,4 +1,5 @@ const jwt = require('jsonwebtoken'); +const { AppError } = require('@urbackend/common'); module.exports = function (req, res, next) { // Check for token in cookies (Primary for Web) @@ -17,7 +18,7 @@ module.exports = function (req, res, next) { // Check if any token was provided if (!token) { - return res.status(401).json({ error: 'Access Denied: No Token Provided' }); + return next(new AppError(401, 'Access Denied: No Token Provided')); } try { @@ -35,6 +36,6 @@ module.exports = function (req, res, next) { console.error(err); } - res.status(400).json({ error: 'Invalid Token' }); + return next(new AppError(400, 'Invalid Token')); } }; diff --git a/apps/dashboard-api/src/middlewares/auth_limiter.js b/apps/dashboard-api/src/middlewares/auth_limiter.js index 887345486..df249fef0 100644 --- a/apps/dashboard-api/src/middlewares/auth_limiter.js +++ b/apps/dashboard-api/src/middlewares/auth_limiter.js @@ -1,10 +1,11 @@ const rateLimit = require('express-rate-limit'); +const { AppError } = require('@urbackend/common'); // limiter for sensitive auth endpoints (login, register) const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, - message: { error: "Too many attempts. Please try again in 15 minutes." }, + handler: (req, res, next) => next(new AppError(429, "Too many attempts. Please try again in 15 minutes.")), skip: (req) => process.env.NODE_ENV === 'development', standardHeaders: true, legacyHeaders: false, @@ -13,7 +14,7 @@ const authLimiter = rateLimit({ const publicLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 500, - message: { error: "Too many requests. Please try again later." }, + handler: (req, res, next) => next(new AppError(429, "Too many requests. Please try again later.")), skip: (req) => process.env.NODE_ENV === 'development', standardHeaders: true, legacyHeaders: false, diff --git a/apps/dashboard-api/src/middlewares/loadProjectForAdmin.js b/apps/dashboard-api/src/middlewares/loadProjectForAdmin.js index 514c8afc7..7ac5a053c 100644 --- a/apps/dashboard-api/src/middlewares/loadProjectForAdmin.js +++ b/apps/dashboard-api/src/middlewares/loadProjectForAdmin.js @@ -1,19 +1,20 @@ // FUNCTION - LOAD PROJECT FOR ADMIN (MIDDLEWARE) const Project = require('../models/Project'); +const { AppError } = require('@urbackend/common'); module.exports = async (req, res, next) => { try { const { projectId } = req.params; - if (!projectId) return res.status(400).json({ error: "Project ID is required" }); + if (!projectId) return next(new AppError(400, "Project ID is required")); const project = await Project.findOne({ _id: projectId, owner: req.user._id }); if (!project) { - return res.status(404).json({ error: "Project not found or access denied" }); + return next(new AppError(404, "Project not found or access denied")); } req.project = project; next(); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; diff --git a/apps/public-api/src/__tests__/aggregation.test.js b/apps/public-api/src/__tests__/aggregation.test.js index 150d66a91..4fd6a82a1 100644 --- a/apps/public-api/src/__tests__/aggregation.test.js +++ b/apps/public-api/src/__tests__/aggregation.test.js @@ -3,7 +3,9 @@ const mockAggregate = jest.fn(); jest.mock('@urbackend/common', () => ({ - sanitize: (v) => v, + AppError: class AppError extends Error { constructor(code, msg, errTitle) { super(msg); this.statusCode=code; this.error=errTitle||'Error'; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, +sanitize: (v) => v, Project: {}, getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => ({ @@ -15,6 +17,7 @@ jest.mock('@urbackend/common', () => ({ aggregateSchema: require('../../../../packages/common/src/utils/input.validation').aggregateSchema, })); +const { AppError } = require('@urbackend/common'); const { aggregateData } = require('../controllers/data.controller'); function makeReq(overrides = {}) { @@ -50,16 +53,19 @@ function makeRes() { } describe('aggregateData controller', () => { + let next; + beforeEach(() => { jest.clearAllMocks(); mockAggregate.mockResolvedValue([{ _id: 'published', count: 2 }]); + next = jest.fn(); }); test('executes a valid aggregation pipeline', async () => { const req = makeReq({ query: {} }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).toHaveBeenCalledWith([ { $match: { isDeleted: { $ne: true } } }, @@ -86,7 +92,7 @@ describe('aggregateData controller', () => { }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).toHaveBeenCalledWith([ { $match: { userId: 'user_1', isDeleted: { $ne: true } } }, @@ -103,7 +109,7 @@ describe('aggregateData controller', () => { }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).toHaveBeenCalledWith([ { $match: {} }, // softDeleteFilter should be empty @@ -118,15 +124,12 @@ describe('aggregateData controller', () => { }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).not.toHaveBeenCalled(); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ - success: false, - data: {}, - message: 'Aggregation pipeline contains blocked stage.', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Aggregation pipeline contains blocked stage.'); }); test('rejects invalid pipeline payloads', async () => { @@ -135,14 +138,11 @@ describe('aggregateData controller', () => { }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).not.toHaveBeenCalled(); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ - success: false, - data: {}, - message: 'Invalid input: expected array, received object', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Invalid input: expected array, received object'); }); }); diff --git a/apps/public-api/src/__tests__/authorizeReadOperation.test.js b/apps/public-api/src/__tests__/authorizeReadOperation.test.js index 6f9a8bd73..6ee2189ad 100644 --- a/apps/public-api/src/__tests__/authorizeReadOperation.test.js +++ b/apps/public-api/src/__tests__/authorizeReadOperation.test.js @@ -1,4 +1,9 @@ 'use strict'; +process.env.REDIS_URL = 'redis://localhost:6379'; + +jest.mock('@urbackend/common', () => ({ + AppError: class AppError extends Error { constructor(code, msg, title) { super(msg); this.statusCode=code; this.error=title||'Error'; } }, +})); const authorizeReadOperation = require('../middlewares/authorizeReadOperation'); @@ -80,9 +85,7 @@ describe('authorizeReadOperation middleware', () => { await authorizeReadOperation(req, res, next); - expect(res.statusCode).toBe(404); - expect(res.body.error).toBe('Collection not found'); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404, message: 'Collection not found' })); }); test('rls disabled allows public read', async () => { @@ -121,9 +124,7 @@ describe('authorizeReadOperation middleware', () => { await authorizeReadOperation(req, res, next); - expect(res.statusCode).toBe(401); - expect(res.body.error).toBe('Authentication required'); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401, error: 'Authentication required' })); }); test('private mode sets owner filter when authed', async () => { diff --git a/apps/public-api/src/__tests__/authorizeWriteOperation.test.js b/apps/public-api/src/__tests__/authorizeWriteOperation.test.js index 1201c6bc6..8eb935606 100644 --- a/apps/public-api/src/__tests__/authorizeWriteOperation.test.js +++ b/apps/public-api/src/__tests__/authorizeWriteOperation.test.js @@ -9,7 +9,8 @@ const mockSelect = jest.fn(); const mockLean = jest.fn(); jest.mock('@urbackend/common', () => ({ - getConnection: jest.fn().mockResolvedValue({}), + AppError: class AppError extends Error { constructor(code, msg, errTitle) { super(msg); this.statusCode=code; this.error=errTitle||'Error'; } }, +getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn().mockReturnValue({ findById: (...args) => { mockFindById(...args); @@ -138,10 +139,7 @@ describe('authorizeWriteOperation middleware', () => { await authorizeWriteOperation(req, res, next); - expect(res.statusCode).toBe(403); - expect(res.body.success).toBe(false); - expect(res.body.error).toBe('Write blocked for publishable key'); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, error: 'Write blocked for publishable key' })); }); test('blocked with 401 when RLS enabled but no auth token provided', async () => { @@ -150,10 +148,7 @@ describe('authorizeWriteOperation middleware', () => { await authorizeWriteOperation(req, res, next); - expect(res.statusCode).toBe(401); - expect(res.body.success).toBe(false); - expect(res.body.error).toBe('Authentication required'); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401, error: 'Authentication required' })); }); }); @@ -171,10 +166,7 @@ describe('authorizeWriteOperation middleware', () => { await authorizeWriteOperation(req, res, next); - expect(res.statusCode).toBe(403); - expect(res.body.success).toBe(false); - expect(res.body.error).toBe('RLS owner mismatch'); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, error: 'RLS owner mismatch' })); }); // In the new atomic design, the middleware does not block based on document owner @@ -293,10 +285,7 @@ describe('authorizeWriteOperation middleware', () => { await authorizeWriteOperation(req, res, next); - expect(res.statusCode).toBe(404); - expect(res.body.success).toBe(false); - expect(res.body.error).toBe('Collection not found'); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404, error: 'Collection not found' })); }); test('returns 403 when RLS ownerField is _id for a POST insert', async () => { @@ -325,10 +314,7 @@ describe('authorizeWriteOperation middleware', () => { await authorizeWriteOperation(req, res, next); - expect(res.statusCode).toBe(403); - expect(res.body.success).toBe(false); - expect(res.body.error).toBe('Insert denied'); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, error: 'Insert denied' })); }); test('returns 400 for an invalid document id on PUT', async () => { @@ -342,10 +328,7 @@ describe('authorizeWriteOperation middleware', () => { await authorizeWriteOperation(req, res, next); - expect(res.statusCode).toBe(400); - expect(res.body.success).toBe(false); - expect(res.body.error).toBe('Invalid ID format.'); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 400, message: 'The provided document ID is not valid.' })); }); @@ -361,10 +344,7 @@ describe('authorizeWriteOperation middleware', () => { await authorizeWriteOperation(req, res, next); - expect(res.statusCode).toBe(403); - expect(res.body.success).toBe(false); - expect(res.body.error).toBe('Owner field immutable'); - expect(next).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, error: 'Owner field immutable' })); }); }); }); diff --git a/apps/public-api/src/__tests__/data.controller.read.test.js b/apps/public-api/src/__tests__/data.controller.read.test.js index dada8a21f..22a7e3c31 100644 --- a/apps/public-api/src/__tests__/data.controller.read.test.js +++ b/apps/public-api/src/__tests__/data.controller.read.test.js @@ -24,7 +24,9 @@ const mockQueryEngine = jest.fn((query) => { }); jest.mock('@urbackend/common', () => ({ - sanitize: (v) => v, + AppError: class AppError extends Error { constructor(code, msg, errTitle) { super(msg); this.statusCode=code; this.error=errTitle||'Error'; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, +sanitize: (v) => v, Project: {}, getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn((connection, collectionConfig, projectId, isExternal) => ({ @@ -57,6 +59,7 @@ jest.mock('@urbackend/common', () => ({ validateUpdateData: jest.fn(), })); +const { AppError } = require('@urbackend/common'); const { getAllData, getSingleDoc } = require('../controllers/data.controller'); function makeReq(overrides = {}) { @@ -92,16 +95,19 @@ function makeRes() { } describe('data.controller read RLS filters', () => { + let next; + beforeEach(() => { jest.clearAllMocks(); mockEnginePopulate.mockClear(); + next = jest.fn(); }); test('getAllData applies rlsFilter to find()', async () => { const req = makeReq({ rlsFilter: { userId: 'user_1' } }); const res = makeRes(); - await getAllData(req, res); + await getAllData(req, res, next); expect(mockFind).toHaveBeenCalledWith(); expect(mockAnd).toHaveBeenCalledWith([{ userId: 'user_1' }]); @@ -111,8 +117,7 @@ describe('data.controller read RLS filters', () => { test('getSingleDoc applies rlsFilter to findOne()', async () => { const req = makeReq({ rlsFilter: { userId: 'user_1' } }); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockFindOne).toHaveBeenCalledWith({ $and: [ @@ -127,8 +132,7 @@ describe('data.controller read RLS filters', () => { test('getAllData calls engine.populate() when populate param is provided', async () => { const req = makeReq({ query: { populate: 'author,comments' } }); const res = makeRes(); - - await getAllData(req, res); + await getAllData(req, res, next); expect(mockQueryEngine).toHaveBeenCalled(); expect(mockEnginePopulate).toHaveBeenCalled(); @@ -137,15 +141,14 @@ describe('data.controller read RLS filters', () => { test('getAllData does not forward populate/expand to Mongo filter', async () => { const req = makeReq({ query: { populate: 'author', expand: 'category', title: 'hello' } }); const res = makeRes(); - - await getAllData(req, res); + await getAllData(req, res, next); // mockFind is called with the raw Mongoose query (no args for Model.find()) // The real filter exclusion is tested via the QueryEngine directly, // but we confirm find() was invoked and the request did not error out. expect(mockFind).toHaveBeenCalledWith(); expect(res.json).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalledWith(500); + expect(next).not.toHaveBeenCalled(); }); test('getAllData returns 400 when QueryEngine throws query validation error', async () => { @@ -165,21 +168,17 @@ describe('data.controller read RLS filters', () => { const req = makeReq({ query: { name_regex: '[' } }); const res = makeRes(); - await getAllData(req, res); + await getAllData(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - success: false, - data: {}, - message: 'Invalid regex pattern for "name_regex".', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toContain('Invalid regex pattern for "name_regex".'); }); test('getSingleDoc calls populate on the query', async () => { const req = makeReq({ query: { populate: 'author' } }); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockPopulate).toHaveBeenCalledWith('author'); expect(res.json).toHaveBeenCalled(); @@ -188,20 +187,18 @@ describe('data.controller read RLS filters', () => { test('getSingleDoc handles array-format populate param without crashing', async () => { const req = makeReq({ query: { populate: ['author', 'category'] } }); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockPopulate).toHaveBeenCalledWith('author'); expect(mockPopulate).toHaveBeenCalledWith('category'); expect(res.json).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalledWith(500); + expect(next).not.toHaveBeenCalled(); }); test('getSingleDoc excludes soft-deleted documents by default', async () => { const req = makeReq(); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockFindOne).toHaveBeenCalled(); const findOneArgs = mockFindOne.mock.calls[0][0]; @@ -219,8 +216,7 @@ describe('data.controller read RLS filters', () => { rlsFilter: { ownerId: 'user_123' } }); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockFindOne).toHaveBeenCalled(); const findOneArgs = mockFindOne.mock.calls[0][0]; diff --git a/apps/public-api/src/__tests__/health.route.test.js b/apps/public-api/src/__tests__/health.route.test.js index c846204dc..819207540 100644 --- a/apps/public-api/src/__tests__/health.route.test.js +++ b/apps/public-api/src/__tests__/health.route.test.js @@ -12,6 +12,8 @@ jest.mock('mongoose', () => ({ })); jest.mock('@urbackend/common', () => ({ + AppError: class AppError extends Error { constructor(code, msg, errTitle) { super(msg); this.statusCode=code; this.error=errTitle||'Error'; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, redis: mockRedis, })); @@ -31,18 +33,22 @@ describe('health route', () => { app = express(); app.use('/api/health', healthRoute); + app.use((err, req, res, next) => { + res.status(err.statusCode || 500).json({ success: false, error: err.error, message: err.message }); + }); }); test('returns ok when mongodb and redis are connected', async () => { const res = await request(app).get('/api/health'); expect(res.status).toBe(200); - expect(res.body.status).toBe('ok'); - expect(res.body.dependencies).toEqual({ + expect(res.body.success).toBe(true); + expect(res.body.data.status).toBe('ok'); + expect(res.body.data.dependencies).toEqual({ mongodb: 'connected', redis: 'connected', }); - expect(typeof res.body.timestamp).toBe('string'); + expect(typeof res.body.data.timestamp).toBe('string'); expect(mockRedis.ping).toHaveBeenCalledTimes(1); }); @@ -52,11 +58,8 @@ describe('health route', () => { const res = await request(app).get('/api/health'); expect(res.status).toBe(503); - expect(res.body.status).toBe('error'); - expect(res.body.dependencies).toEqual({ - mongodb: 'disconnected', - redis: 'connected', - }); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Service unavailable'); }); test('returns error when redis is not responsive', async () => { @@ -65,10 +68,7 @@ describe('health route', () => { const res = await request(app).get('/api/health'); expect(res.status).toBe(503); - expect(res.body.status).toBe('error'); - expect(res.body.dependencies).toEqual({ - mongodb: 'connected', - redis: 'disconnected', - }); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Service unavailable'); }); }); diff --git a/apps/public-api/src/__tests__/mail.controller.test.js b/apps/public-api/src/__tests__/mail.controller.test.js index 275704391..09c48e1d8 100644 --- a/apps/public-api/src/__tests__/mail.controller.test.js +++ b/apps/public-api/src/__tests__/mail.controller.test.js @@ -67,10 +67,17 @@ jest.mock('@urbackend/common', () => { updateOne: jest.fn(), insertMany: jest.fn(), }, + AppError: class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } + }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, }; }); -const { Project, decrypt, redis, publicEmailQueue, MailTemplate, MailLog } = require('@urbackend/common'); +const { Project, decrypt, redis, publicEmailQueue, MailTemplate, MailLog, AppError } = require('@urbackend/common'); const mailController = require('../controllers/mail.controller'); const originalResendApiKey2 = process.env.RESEND_API_KEY_2; @@ -97,8 +104,11 @@ const mockProjectConfig = (payload) => { }; describe('mail.controller', () => { + let next; + beforeEach(() => { jest.clearAllMocks(); + next = jest.fn(); process.env.RESEND_API_KEY = 'default-key'; delete process.env.RESEND_API_KEY_2; process.env.EMAIL_FROM = 'mail@urbackend.app'; @@ -121,7 +131,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue('byok-key'); redis.eval.mockResolvedValue(1); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); expect(redis.eval).toHaveBeenCalledTimes(1); expect(res.status).toHaveBeenCalledWith(200); @@ -151,7 +161,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(2); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ @@ -179,14 +189,12 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(101); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); expect(redis.decr).toHaveBeenCalledTimes(1); - expect(res.status).toHaveBeenCalledWith(429); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: false, - message: 'Monthly mail limit exceeded.', - })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(429); + expect(next.mock.calls[0][0].message).toBe('Monthly mail limit exceeded.'); }); test('renders and sends a mail template with variables', async () => { @@ -214,7 +222,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(1); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ @@ -260,7 +268,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(1); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); // Assert project-scope query was made first expect(MailTemplate.findOne).toHaveBeenCalledWith(expect.objectContaining({ @@ -324,7 +332,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(1); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); // Assert project-scope was queried first, then global fallback expect(MailTemplate.findOne).toHaveBeenNthCalledWith(1, expect.objectContaining({ @@ -434,9 +442,11 @@ describe('mail.controller', () => { throw new Error('invalid signature'); }); - await mailController.handleResendWebhook(req, res); + await mailController.handleResendWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Webhook signature verification failed.'); expect(MailLog.updateOne).not.toHaveBeenCalled(); }); @@ -451,7 +461,7 @@ describe('mail.controller', () => { data: { email_id: 're_123' } }); - await mailController.handleResendWebhook(req, res); + await mailController.handleResendWebhook(req, res, next); expect(MailLog.updateOne).toHaveBeenCalledWith( { resendEmailId: 're_123' }, @@ -474,10 +484,11 @@ describe('mail.controller', () => { error: { statusCode: 503, message: 'Provider unavailable' } }); - await mailController.sendBatchMail(req, res); + await mailController.sendBatchMail(req, res, next); expect(redis.decr).toHaveBeenCalledWith(expect.stringContaining('project:mail:count:proj_1:')); - expect(res.status).toHaveBeenCalledWith(503); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(503); }); test('enforces BYOK gate for audience creation', async () => { @@ -488,9 +499,10 @@ describe('mail.controller', () => { mockProjectConfig({ _id: 'proj_1', resendApiKey: null }); decrypt.mockReturnValue(null); - await mailController.createAudience(req, res); + await mailController.createAudience(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); expect(mockResendClient.audiences.create).not.toHaveBeenCalled(); }); @@ -503,9 +515,10 @@ describe('mail.controller', () => { mockProjectConfig({ _id: 'proj_1', resendApiKey: { encrypted: 'x', iv: 'y', tag: 'z' } }); decrypt.mockReturnValue('byok-key'); - await mailController.createBroadcast(req, res); + await mailController.createBroadcast(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); expect(mockResendClient.broadcasts.create).not.toHaveBeenCalled(); }); @@ -519,7 +532,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue('byok-key'); mockResendClient.broadcasts.create.mockResolvedValue({ data: { id: 'b_1' }, error: null }); - await mailController.createBroadcast(req, res); + await mailController.createBroadcast(req, res, next); expect(mockResendClient.broadcasts.create).toHaveBeenCalledWith(expect.objectContaining({ audienceId: 'aud_123' })); expect(res.status).toHaveBeenCalledWith(200); diff --git a/apps/public-api/src/__tests__/softDelete.test.js b/apps/public-api/src/__tests__/softDelete.test.js index 39e06f2ed..c67596b68 100644 --- a/apps/public-api/src/__tests__/softDelete.test.js +++ b/apps/public-api/src/__tests__/softDelete.test.js @@ -30,10 +30,13 @@ jest.mock('@urbackend/common', () => { super(message); this.statusCode = statusCode; } - } + }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, }; }); +const { AppError } = require('@urbackend/common'); + const { deleteSingleDoc, recoverSingleDoc } = require('../controllers/data.controller'); function makeReq(overrides = {}) { @@ -60,8 +63,11 @@ function makeRes() { } describe('Soft Delete in data.controller', () => { + let next; + beforeEach(() => { jest.clearAllMocks(); + next = jest.fn(); }); test('deleteSingleDoc sets isDeleted: true instead of hard deleting', async () => { @@ -73,8 +79,7 @@ describe('Soft Delete in data.controller', () => { mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(doc) }); - - await deleteSingleDoc(req, res); + await deleteSingleDoc(req, res, next); expect(mockFindOneAndUpdate).toHaveBeenCalledWith( expect.objectContaining({ _id: '507f1f77bcf86cd799439011', isDeleted: { $ne: true } }), @@ -102,11 +107,11 @@ describe('Soft Delete in data.controller', () => { mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); + await deleteSingleDoc(req, res, next); - await deleteSingleDoc(req, res); - - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Document not found.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); + expect(next.mock.calls[0][0].message).toBe("Document not found."); }); test('recoverSingleDoc restores a soft-deleted document', async () => { @@ -155,8 +160,6 @@ describe('Soft Delete in data.controller', () => { test('recoverSingleDoc returns 404 if document is not in trash', async () => { const req = makeReq(); const res = makeRes(); - const next = jest.fn(); - mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); @@ -172,8 +175,6 @@ describe('Soft Delete in data.controller', () => { test('recoverSingleDoc returns 409 if document restoration causes a unique field conflict', async () => { const req = makeReq(); const res = makeRes(); - const next = jest.fn(); - const error = new Error('Duplicate key'); error.code = 11000; mockFindOneAndUpdate.mockReturnValue({ @@ -191,8 +192,6 @@ describe('Soft Delete in data.controller', () => { test('recoverSingleDoc returns 400 if ID is invalid', async () => { const req = makeReq({ params: { collectionName: 'posts', id: 'invalid-id' } }); const res = makeRes(); - const next = jest.fn(); - await recoverSingleDoc(req, res, next); expect(next).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/public-api/src/__tests__/storage.controller.test.js b/apps/public-api/src/__tests__/storage.controller.test.js index e3d36abab..59f08aae1 100644 --- a/apps/public-api/src/__tests__/storage.controller.test.js +++ b/apps/public-api/src/__tests__/storage.controller.test.js @@ -36,6 +36,14 @@ jest.mock('@urbackend/common', () => { incrWithTtlAtomic: jest.fn().mockResolvedValue(true), redis: { status: 'ready', eval: jest.fn() }, __mockStorageFrom: mockStorageFrom, // expose for assertions + AppError: class AppError extends Error { + constructor(statusCode, message, type) { + super(message); + this.statusCode = statusCode; + if (type) this.type = type; + } + }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, }; }); @@ -43,7 +51,7 @@ jest.mock('@urbackend/common', () => { // Import module under test after mocks // --------------------------------------------------------------------------- -const { getStorage, Project, isProjectStorageExternal, getBucket, getPresignedUploadUrl, verifyUploadedFile, __mockStorageFrom: mockStorageFrom } = require('@urbackend/common'); +const { getStorage, Project, isProjectStorageExternal, getBucket, getPresignedUploadUrl, verifyUploadedFile, __mockStorageFrom: mockStorageFrom, AppError } = require('@urbackend/common'); const storageController = require('../controllers/storage.controller'); // --------------------------------------------------------------------------- @@ -78,9 +86,11 @@ const makeFile = (overrides = {}) => ({ // --------------------------------------------------------------------------- describe('storage.controller', () => { + let next; beforeEach(() => { jest.clearAllMocks(); process.env.NODE_ENV = 'test'; + next = jest.fn(); }); describe('uploadFile', () => { @@ -88,10 +98,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: null }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'No file uploaded.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('No file uploaded.'); }); test('returns 413 when file exceeds MAX_FILE_SIZE', async () => { @@ -101,10 +112,11 @@ describe('storage.controller', () => { }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(413); - expect(res.json).toHaveBeenCalledWith({ error: 'File size exceeds limit.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(413); + expect(next.mock.calls[0][0].message).toBe('File size exceeds limit.'); }); test('returns 403 when internal storage quota is exceeded', async () => { @@ -114,11 +126,12 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: makeFile() }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); expect(Project.updateOne).toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Storage limit exceeded. Please upgrade your plan or delete some files.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + expect(next.mock.calls[0][0].message).toBe('Storage limit exceeded. Please upgrade your plan or delete some files.'); }); test('returns 201 and public URL on successful internal upload', async () => { @@ -130,7 +143,7 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: makeFile() }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); expect(Project.updateOne).toHaveBeenCalledTimes(1); // Quota reservation expect(mockStorageFrom.upload).toHaveBeenCalledWith( @@ -140,10 +153,13 @@ describe('storage.controller', () => { ); expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith({ + success: true, message: 'File uploaded successfully', - url: 'https://mock.supabase.co/mocked-path', - path: 'project_id_1/mocked-uuid_test_file.txt', - provider: 'internal' + data: { + url: 'https://mock.supabase.co/mocked-path', + path: 'project_id_1/mocked-uuid_test_file.txt', + provider: 'internal' + } }); }); @@ -155,11 +171,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: makeFile() }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); expect(Project.updateOne).not.toHaveBeenCalled(); // No quota reservation expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ provider: 'external' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ provider: 'external' }) })); }); test('rolls back quota and returns 500 when upload fails', async () => { @@ -171,11 +187,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: makeFile() }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); expect(Project.updateOne).toHaveBeenCalledTimes(2); // One for reservation, one for rollback - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'File upload failed' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); }); @@ -184,30 +200,33 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: {} }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'File path is required.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('File path is required.'); }); test('returns 403 when trying to delete file belonging to different project', async () => { const req = { project: makeProject(), body: { path: 'wrong_project_id/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Access denied.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + expect(next.mock.calls[0][0].message).toBe('Access denied.'); }); test('returns 403 when trying to delete file using path traversal', async () => { const req = { project: makeProject(), body: { path: 'project_id_1/../other_project/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Access denied.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + expect(next.mock.calls[0][0].message).toBe('Access denied.'); }); test('returns 200 on successful internal deletion', async () => { @@ -218,7 +237,7 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); expect(mockStorageFrom.list).toHaveBeenCalledWith('project_id_1', { search: 'file.txt', limit: 1 }); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); @@ -226,7 +245,7 @@ describe('storage.controller', () => { { _id: 'project_id_1' }, { $inc: { storageUsed: -1024 } } ); - expect(res.json).toHaveBeenCalledWith({ message: 'File deleted successfully' }); + expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: null }); }); test('returns 200 on successful external deletion and skips internal storageUsed update', async () => { @@ -237,11 +256,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); expect(mockStorageFrom.list).toHaveBeenCalledWith('project_id_1', { search: 'file.txt', limit: 1 }); expect(Project.updateOne).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ message: 'File deleted successfully' }); + expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: null }); }); test('falls back to zero fileSize when Supabase list fails', async () => { @@ -252,10 +271,10 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); expect(Project.updateOne).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ message: 'File deleted successfully' }); + expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: null }); }); test('returns 500 when Supabase remove fails', async () => { @@ -266,10 +285,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); expect(Project.updateOne).not.toHaveBeenCalled(); // Storage used shouldn't decrement - expect(res.status).toHaveBeenCalledWith(500); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); }); @@ -278,10 +298,11 @@ describe('storage.controller', () => { const req = { project: null }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Project not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); + expect(next.mock.calls[0][0].message).toBe('Project not found'); }); test('deletes paginated files and resets internal storage to 0', async () => { @@ -297,18 +318,20 @@ describe('storage.controller', () => { const req = { project: makeProject() }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file1.txt', 'project_id_1/file2.txt']); expect(Project.updateOne).toHaveBeenCalledWith( { _id: 'project_id_1' }, { $set: { storageUsed: 0 } } ); - expect(res.json).toHaveBeenCalledWith({ + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, - deleted: 2, - provider: 'internal' - }); + data: { + deleted: 2, + provider: 'internal' + } + })); }); test('skips storageUsed reset for external provider during deleteAllFiles', async () => { @@ -323,10 +346,10 @@ describe('storage.controller', () => { const req = { project: makeProject() }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); expect(Project.updateOne).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ provider: 'external' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ provider: 'external' }) })); }); test('returns 500 when Supabase remove fails during deleteAllFiles', async () => { @@ -337,10 +360,10 @@ describe('storage.controller', () => { const req = { project: makeProject() }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Failed to delete files' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); test('returns 500 if pagination fetch fails', async () => { @@ -350,10 +373,10 @@ describe('storage.controller', () => { const req = { project: makeProject() }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Failed to delete files' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); }); @@ -362,10 +385,10 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filename: 'file.txt', contentType: 'text/plain', size: 'abc' } }; const res = makeRes(); - await storageController.requestUpload(req, res); + await storageController.requestUpload(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'filename, contentType, and size are required.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns signed URL for requestUpload on valid input', async () => { @@ -375,11 +398,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filename: 'my..file.txt', contentType: 'text/plain', size: 1024 } }; const res = makeRes(); - await storageController.requestUpload(req, res); + await storageController.requestUpload(req, res, next); expect(getPresignedUploadUrl).toHaveBeenCalledWith(req.project, 'project_id_1/mocked-uuid_my..file.txt', 'text/plain', 1024); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ signedUrl: 'https://signed.example/upload', token: 'token-1', filePath: 'project_id_1/mocked-uuid_my..file.txt' }); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: { signedUrl: 'https://signed.example/upload', token: 'token-1', filePath: 'project_id_1/mocked-uuid_my..file.txt' } })); }); test('confirmUpload charges the verified size and rejects mismatches', async () => { @@ -391,7 +414,7 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(verifyUploadedFile).toHaveBeenCalledWith(req.project, 'project_id_1/file.txt'); expect(Project.updateOne).toHaveBeenCalledWith( @@ -406,7 +429,7 @@ describe('storage.controller', () => { ); expect(mockStorageFrom.getPublicUrl).toHaveBeenCalledWith('project_id_1/file.txt'); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' }) })); }); test('confirmUpload succeeds for unlimited storage plans', async () => { @@ -418,7 +441,7 @@ describe('storage.controller', () => { const req = { project: makeProject({ storageLimit: -1 }), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(Project.updateOne).toHaveBeenCalledWith( { @@ -433,9 +456,11 @@ describe('storage.controller', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: 'Upload confirmed', - path: 'project_id_1/file.txt', - provider: 'internal', - url: 'https://mock.supabase.co/project_id_1/file.txt' + data: expect.objectContaining({ + path: 'project_id_1/file.txt', + provider: 'internal', + url: 'https://mock.supabase.co/project_id_1/file.txt' + }) })); }); @@ -447,10 +472,10 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 1024 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Declared file size does not match uploaded file size.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); }); @@ -463,7 +488,7 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2100 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(Project.updateOne).toHaveBeenCalledWith( { @@ -476,7 +501,7 @@ describe('storage.controller', () => { { $inc: { storageUsed: 2048 } } ); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' }) })); }); test('confirmUpload removes uploaded object when quota reservation fails', async () => { @@ -488,11 +513,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Internal storage limit exceeded.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); }); test('confirmUpload removes uploaded object when verification fails', async () => { @@ -503,11 +528,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Upload confirmation failed' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); test('confirmUpload returns a retryable conflict when the uploaded object is not yet visible', async () => { @@ -518,14 +543,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); - expect(res.status).toHaveBeenCalledWith(409); - expect(res.json).toHaveBeenCalledWith({ - error: 'UPLOAD_NOT_READY', - message: 'Uploaded file is not visible yet. Please retry confirmation.' - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(409); }); test('confirmUpload still returns 500 for generic verification errors', async () => { @@ -535,10 +557,10 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Upload confirmation failed' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); test('confirmUpload swallows cleanup failures during compensating delete', async () => { @@ -550,10 +572,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); - expect(res.status).toHaveBeenCalledWith(403); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); }); test('confirmUpload returns a warning when public URL is unavailable', async () => { @@ -564,13 +587,15 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - url: null, - warning: 'Cloudflare R2 requires a Public URL Host.', - provider: 'external' + data: expect.objectContaining({ + url: null, + warning: 'Cloudflare R2 requires a Public URL Host.', + provider: 'external' + }) })); }); }); diff --git a/apps/public-api/src/__tests__/userAuth.email.test.js b/apps/public-api/src/__tests__/userAuth.email.test.js index 79e5f1f4f..2041faa2a 100644 --- a/apps/public-api/src/__tests__/userAuth.email.test.js +++ b/apps/public-api/src/__tests__/userAuth.email.test.js @@ -85,6 +85,8 @@ jest.mock('@urbackend/common', () => { getRefreshSession: jest.fn(), persistRefreshSession: jest.fn().mockResolvedValue(undefined), revokeSessionChain: jest.fn().mockResolvedValue(undefined), + AppError: class AppError extends Error { constructor(statusCode, message) { super(message); this.statusCode = statusCode; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, checkLockout: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0 }), recordFailedAttempt: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0, attempts: 1 }), clearLockout: jest.fn().mockResolvedValue(undefined), @@ -102,7 +104,7 @@ jest.mock('../utils/refreshToken', () => ({ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); -const { redis, authEmailQueue, __mockModel: mockModel, checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); +const { AppError, redis, authEmailQueue, __mockModel: mockModel, checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); const { issueAuthTokens } = require('../utils/refreshToken'); const controller = require('../controllers/userAuth.controller'); @@ -143,7 +145,9 @@ const makeRes = () => { describe('OTP generation uses CSPRNG', () => { let randomIntSpy; - beforeEach(() => { + let next; +beforeEach(() => { +next = jest.fn(); jest.clearAllMocks(); randomIntSpy = jest.spyOn(crypto, 'randomInt'); }); @@ -159,7 +163,7 @@ describe('OTP generation uses CSPRNG', () => { model.findOne.mockResolvedValueOnce(null); model.create.mockResolvedValueOnce({ _id: 'u1' }); - await controller.signup(req, res); + await controller.signup(req, res, next); expect(randomIntSpy).toHaveBeenCalledWith(100000, 1000000); const storedOtp = require('@urbackend/common').redis.set.mock.calls.find( @@ -175,7 +179,7 @@ describe('OTP generation uses CSPRNG', () => { model.findOne.mockResolvedValueOnce({ _id: 'u1', isVerified: false }); redis.get.mockResolvedValueOnce(null); - await controller.signup(req, res); + await controller.signup(req, res, next); expect(randomIntSpy).toHaveBeenCalledWith(100000, 1000000); const otpCall = redis.set.mock.calls.find(c => c[0].includes('otp:verification')); @@ -189,7 +193,7 @@ describe('OTP generation uses CSPRNG', () => { model.findOne.mockResolvedValueOnce({ _id: 'u1', isVerified: false }); redis.get.mockResolvedValueOnce(null); - await controller.resendVerificationOtp(req, res); + await controller.resendVerificationOtp(req, res, next); expect(randomIntSpy).toHaveBeenCalledWith(100000, 1000000); }); @@ -199,14 +203,16 @@ describe('OTP generation uses CSPRNG', () => { const res = makeRes(); require('@urbackend/common').__mockModel.findOne.mockResolvedValueOnce({ _id: 'u1' }); - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); expect(randomIntSpy).toHaveBeenCalledWith(100000, 1000000); }); }); describe('Email Authentication Flow', () => { - beforeEach(() => { + let next; +beforeEach(() => { +next = jest.fn(); jest.clearAllMocks(); }); @@ -220,7 +226,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce(null); // User does not exist mockModel.create.mockResolvedValueOnce({ _id: 'user_123' }); - await controller.signup(req, res); + await controller.signup(req, res, next); expect(mockModel.findOne).toHaveBeenCalled(); expect(mockModel.create).toHaveBeenCalledWith(expect.objectContaining({ @@ -244,11 +250,13 @@ describe('Email Authentication Flow', () => { expect(clearLockout).toHaveBeenCalledWith('project_1', 'new@user.com'); expect(issueAuthTokens).toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ token: 'signed_access_token', userId: 'user_123' - })); + }) + ); }); test('returns 400 if user already exists', async () => { @@ -259,12 +267,11 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123', email: 'existing@user.com', isVerified: true }); - await controller.signup(req, res); + await controller.signup(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - error: expect.stringContaining('already exists') - })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); + }); test('resends verification if user exists but unverified', async () => { @@ -276,11 +283,11 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123', email: 'unverified@user.com', isVerified: false }); redis.get.mockResolvedValueOnce(null); // No cooldown - await controller.signup(req, res); + await controller.signup(req, res, next); expect(mockModel.create).not.toHaveBeenCalled(); expect(authEmailQueue.add).toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(200); + }); }); @@ -294,7 +301,7 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce('123456'); mockModel.updateOne.mockResolvedValueOnce({ matchedCount: 1 }); - await controller.verifyEmail(req, res); + await controller.verifyEmail(req, res, next); expect(redis.get).toHaveBeenCalledWith(expect.stringContaining('test@user.com')); expect(mockModel.updateOne).toHaveBeenCalledWith( @@ -302,7 +309,9 @@ describe('Email Authentication Flow', () => { { $set: { isVerified: true } } ); expect(redis.del).toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.any(String) }) + ); }); test('returns 400 for invalid OTP', async () => { @@ -313,10 +322,11 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce('123456'); - await controller.verifyEmail(req, res); + await controller.verifyEmail(req, res, next); expect(mockModel.updateOne).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); }); }); @@ -336,15 +346,17 @@ describe('Email Authentication Flow', () => { }); bcrypt.compare.mockResolvedValueOnce(true); - await controller.login(req, res); + await controller.login(req, res, next); expect(checkLockout).toHaveBeenCalledWith('project_1', 'test@user.com'); expect(bcrypt.compare).toHaveBeenCalledWith('password123', 'hashed_pw'); expect(clearLockout).toHaveBeenCalledWith('project_1', 'test@user.com'); expect(issueAuthTokens).toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ token: 'signed_access_token' - })); + }) + ); }); test('returns 400 for incorrect password', async () => { @@ -361,10 +373,11 @@ describe('Email Authentication Flow', () => { }); bcrypt.compare.mockResolvedValueOnce(false); - await controller.login(req, res); + await controller.login(req, res, next); expect(recordFailedAttempt).toHaveBeenCalledWith('project_1', 'test@user.com'); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); }); test('returns 423 when account is already locked', async () => { @@ -375,11 +388,12 @@ describe('Email Authentication Flow', () => { checkLockout.mockResolvedValueOnce({ locked: true, retryAfterSeconds: 900 }); - await controller.login(req, res); + await controller.login(req, res, next); expect(bcrypt.compare).not.toHaveBeenCalled(); expect(recordFailedAttempt).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(423); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(423); }); test('returns 423 when failures reach lockout threshold', async () => { @@ -397,9 +411,10 @@ describe('Email Authentication Flow', () => { bcrypt.compare.mockResolvedValueOnce(false); recordFailedAttempt.mockResolvedValueOnce({ locked: true, retryAfterSeconds: 900, attempts: 5 }); - await controller.login(req, res); + await controller.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(423); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(423); }); test('user-not-found branch records attempt and returns 423 when lockout is reached', async () => { @@ -414,10 +429,11 @@ describe('Email Authentication Flow', () => { }); recordFailedAttempt.mockResolvedValueOnce({ locked: true, retryAfterSeconds: 900, attempts: 5 }); - await controller.login(req, res); + await controller.login(req, res, next); expect(recordFailedAttempt).toHaveBeenCalledWith('project_1', 'missing@user.com'); - expect(res.status).toHaveBeenCalledWith(423); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(423); }); }); @@ -431,7 +447,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123' }); redis.get.mockResolvedValueOnce(null); // no cooldown - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); expect(redis.set).toHaveBeenCalledWith(expect.stringContaining('otp:reset:reset@user.com'), expect.any(String), 'EX', 300); expect(authEmailQueue.add).toHaveBeenCalledWith('send-reset-email', expect.objectContaining({ @@ -447,15 +463,13 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce('1'); // cooldown active — checked before findOne redis.ttl.mockResolvedValueOnce(45); - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); expect(mockModel.findOne).not.toHaveBeenCalled(); // DB never queried expect(authEmailQueue.add).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(429); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: false, - message: expect.stringContaining('45 seconds') - })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(429); + }); test('sets cooldown for unknown email to prevent account enumeration', async () => { @@ -465,12 +479,14 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce(null); // no cooldown mockModel.findOne.mockResolvedValueOnce(null); // user does not exist - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); const cooldownCall = redis.set.mock.calls.find(c => c[0].includes('otp:cooldown:reset')); expect(cooldownCall).toBeDefined(); // cooldown set even for unknown email expect(authEmailQueue.add).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.any(String) }) + ); }); test('sets cooldown after sending reset OTP', async () => { @@ -480,7 +496,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123' }); redis.get.mockResolvedValueOnce(null); - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); const cooldownCall = redis.set.mock.calls.find(c => c[0].includes('otp:cooldown:reset')); expect(cooldownCall).toBeDefined(); @@ -495,7 +511,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123' }); redis.get.mockResolvedValueOnce(null); - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); const otpCall = redis.set.mock.calls.find(c => c[0].includes('otp:reset')); expect(otpCall[0]).toContain('reset@user.com'); @@ -512,7 +528,7 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce('123456'); mockModel.updateOne.mockResolvedValueOnce({ matchedCount: 1 }); - await controller.resetPasswordUser(req, res); + await controller.resetPasswordUser(req, res, next); expect(bcrypt.hash).toHaveBeenCalledWith('newpassword123', 'salt'); expect(mockModel.updateOne).toHaveBeenCalledWith( @@ -537,7 +553,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123', password: 'old_hashed_pw' }); bcrypt.compare.mockResolvedValueOnce(true); - await controller.changePasswordUser(req, res); + await controller.changePasswordUser(req, res, next); expect(bcrypt.compare).toHaveBeenCalledWith('oldpassword', 'old_hashed_pw'); expect(bcrypt.hash).toHaveBeenCalledWith('newpassword123', 'salt'); @@ -559,10 +575,11 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123', password: 'old_hashed_pw' }); bcrypt.compare.mockResolvedValueOnce(false); - await controller.changePasswordUser(req, res); + await controller.changePasswordUser(req, res, next); expect(mockModel.updateOne).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); }); }); }); diff --git a/apps/public-api/src/__tests__/userAuth.refresh.test.js b/apps/public-api/src/__tests__/userAuth.refresh.test.js index 09927d46a..be611f020 100644 --- a/apps/public-api/src/__tests__/userAuth.refresh.test.js +++ b/apps/public-api/src/__tests__/userAuth.refresh.test.js @@ -73,6 +73,8 @@ jest.mock('@urbackend/common', () => { getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => mockModel), __mockModel: mockModel, + AppError: class AppError extends Error { constructor(statusCode, message) { super(message); this.statusCode = statusCode; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, checkLockout: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0 }), recordFailedAttempt: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0, attempts: 1 }), clearLockout: jest.fn().mockResolvedValue(undefined), @@ -87,7 +89,7 @@ jest.mock('@urbackend/common', () => { }); const bcrypt = require('bcryptjs'); -const { Project, redis, getRefreshSession, persistRefreshSession, __mockModel: mockModel } = require('@urbackend/common'); +const { AppError, Project, redis, getRefreshSession, persistRefreshSession, __mockModel: mockModel } = require('@urbackend/common'); const controller = require('../controllers/userAuth.controller'); const makeProject = () => ({ @@ -124,7 +126,9 @@ const makeRes = () => { const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex'); describe('public userAuth refresh flow', () => { - beforeEach(() => { + let next; +beforeEach(() => { +next = jest.fn(); jest.clearAllMocks(); process.env.NODE_ENV = 'test'; }); @@ -142,7 +146,7 @@ describe('public userAuth refresh flow', () => { }); const res = makeRes(); - await controller.login(req, res); + await controller.login(req, res, next); expect(res.cookie).toHaveBeenCalledWith( 'refreshToken', @@ -150,25 +154,26 @@ describe('public userAuth refresh flow', () => { expect.objectContaining({ httpOnly: true }) ); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ + expect.objectContaining({ token: 'signed_access_token', accessToken: 'signed_access_token', expiresIn: expect.any(String), }) - ); + ); }); test('refresh-token returns 401 when token is missing', async () => { const req = makeReq({ headers: {} }); const res = makeRes(); - await controller.refreshToken(req, res); + await controller.refreshToken(req, res, next); expect(res.clearCookie).toHaveBeenCalledWith( 'refreshToken', expect.objectContaining({ httpOnly: true }) ); - expect(res.status).toHaveBeenCalledWith(401); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(401); }); test('refresh-token rotates and returns new access token', async () => { @@ -201,16 +206,16 @@ describe('public userAuth refresh flow', () => { }); const res = makeRes(); - await controller.refreshToken(req, res); + await controller.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ + expect.objectContaining({ token: 'signed_access_token', accessToken: 'signed_access_token', refreshToken: expect.any(String), }) - ); + ); }); test('refresh-token returns 403 when user is soft-deleted', async () => { @@ -262,13 +267,13 @@ describe('public userAuth refresh flow', () => { }); const res = makeRes(); - await controller.logout(req, res); + await controller.logout(req, res, next); expect(res.clearCookie).toHaveBeenCalledWith( 'refreshToken', expect.objectContaining({ httpOnly: true }) ); - expect(res.status).toHaveBeenCalledWith(200); + }); test('public profile returns only safe fields', async () => { @@ -303,17 +308,21 @@ describe('public userAuth refresh flow', () => { req.params = { username: 'yash' }; const res = makeRes(); - await controller.publicProfile(req, res); + await controller.publicProfile(req, res, next); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ + expect.objectContaining({ data: expect.objectContaining({ _id: 'user_1', username: 'yash', name: 'Yash', bio: 'builder', - }) - ); - expect(res.json).not.toHaveBeenCalledWith(expect.objectContaining({ email: 'private@example.com' })); - expect(res.json).not.toHaveBeenCalledWith(expect.objectContaining({ password: 'hashed_secret' })); + }) }) + ); + expect(res.json).not.toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ email: 'private@example.com' }) }) + ); + expect(res.json).not.toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ password: 'hashed_secret' }) }) + ); }); }); diff --git a/apps/public-api/src/__tests__/userAuth.social.test.js b/apps/public-api/src/__tests__/userAuth.social.test.js index 5b78db782..fed537019 100644 --- a/apps/public-api/src/__tests__/userAuth.social.test.js +++ b/apps/public-api/src/__tests__/userAuth.social.test.js @@ -70,6 +70,8 @@ jest.mock('@urbackend/common', () => { sanitize: jest.fn((value) => value), getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => mockUsersModel), + AppError: class AppError extends Error { constructor(statusCode, message) { super(message); this.statusCode = statusCode; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, checkLockout: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0 }), recordFailedAttempt: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0, attempts: 1 }), clearLockout: jest.fn().mockResolvedValue(undefined), @@ -82,7 +84,7 @@ jest.mock('@urbackend/common', () => { }; }); -const { redis } = require('@urbackend/common'); +const { AppError, redis } = require('@urbackend/common'); const { issueAuthTokens } = require('../utils/refreshToken'); const controller = require('../controllers/userAuth.controller'); @@ -185,7 +187,9 @@ const signGoogleIdToken = (claims = {}) => { }; describe('public userAuth social auth', () => { - beforeEach(() => { + let next; +beforeEach(() => { +next = jest.fn(); jest.clearAllMocks(); global.fetch = jest.fn(); process.env.FRONTEND_URL = 'http://localhost:5173'; @@ -197,7 +201,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' } }); const res = makeRes(); - await controller.startSocialAuth(req, res); + await controller.startSocialAuth(req, res, next); expect(redis.set).toHaveBeenCalled(); expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('https://github.com/login/oauth/authorize?')); @@ -208,7 +212,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' } }); const res = makeRes(); - await controller.startSocialAuth(req, res); + await controller.startSocialAuth(req, res, next); expect(redis.set).toHaveBeenCalled(); expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('https://accounts.google.com/o/oauth2/v2/auth?')); @@ -219,10 +223,11 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'missing' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Invalid or expired OAuth state' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); + }); test('handleSocialAuthCallback rejects when GitHub returns no email', async () => { @@ -245,7 +250,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // P2: errors now redirect to frontend instead of JSON expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); @@ -277,7 +282,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(mockUsersModel.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'alice@example.com', @@ -317,7 +322,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(mockUsersModel.updateOne).toHaveBeenCalledWith( { _id: 'user_existing_1' }, @@ -350,7 +355,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_google', state: 'state_google' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(mockUsersModel.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'alice@example.com', @@ -381,7 +386,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_google', state: 'state_google' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(mockUsersModel.updateOne).toHaveBeenCalledWith( { _id: 'user_existing_google' }, @@ -410,7 +415,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_google', state: 'state_google' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // P2: errors now redirect to frontend instead of JSON expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); @@ -434,7 +439,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_google', state: 'state_google' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // P2: errors now redirect to frontend instead of JSON expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); @@ -454,10 +459,16 @@ describe('public userAuth social auth', () => { }; const res = makeRes(); - await controller.exchangeSocialRefreshToken(req, res); + await controller.exchangeSocialRefreshToken(req, res, next); +<<<<<<< Updated upstream expect(redis.getdel).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123'); expect(res.status).toHaveBeenCalledWith(200); +======= + expect(redis.get).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123'); + expect(redis.del).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123'); + +>>>>>>> Stashed changes expect(res.json).toHaveBeenCalledWith({ success: true, data: { @@ -477,14 +488,20 @@ describe('public userAuth social auth', () => { }; const res = makeRes(); - await controller.exchangeSocialRefreshToken(req, res); + await controller.exchangeSocialRefreshToken(req, res, next); +<<<<<<< Updated upstream expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ success: false, data: {}, message: 'Invalid or expired refresh token exchange code', }); +======= + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); + +>>>>>>> Stashed changes }); test('exchangeSocialRefreshToken rejects mismatched token and deletes exchange code', async () => { @@ -500,6 +517,7 @@ describe('public userAuth social auth', () => { }; const res = makeRes(); +<<<<<<< Updated upstream await controller.exchangeSocialRefreshToken(req, res); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ @@ -507,6 +525,14 @@ describe('public userAuth social auth', () => { data: {}, message: 'Invalid refresh token exchange payload', }); +======= + await controller.exchangeSocialRefreshToken(req, res, next); + + expect(redis.del).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_456'); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(403); + +>>>>>>> Stashed changes }); // P2: Provider error forwarding @@ -527,7 +553,7 @@ describe('public userAuth social auth', () => { }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('The+user+denied')); @@ -570,7 +596,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // With verified=true, should successfully link and redirect expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('rtCode=')); @@ -605,7 +631,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // Should redirect with error because email exists but not verified for linking expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); diff --git a/apps/public-api/src/__tests__/verifyApiKey.test.js b/apps/public-api/src/__tests__/verifyApiKey.test.js index 44404f4bc..6c8779867 100644 --- a/apps/public-api/src/__tests__/verifyApiKey.test.js +++ b/apps/public-api/src/__tests__/verifyApiKey.test.js @@ -6,7 +6,8 @@ const mockPopulate = jest.fn().mockReturnThis(); const mockLean = jest.fn(); jest.mock('@urbackend/common', () => ({ - Project: { + AppError: class AppError extends Error { constructor(code, msg, errTitle) { super(msg); this.statusCode=code; this.error=errTitle||'Error'; } }, +Project: { findOne: jest.fn(() => ({ select: mockSelect, populate: mockPopulate, @@ -100,9 +101,7 @@ describe('verifyApiKey middleware', () => { await verifyApiKey(req, res, next); - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'API key not found' })); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401, message: 'API key not found' })); }); test('header takes precedence when both x-api-key header and ?key= query param are present', async () => { @@ -139,9 +138,7 @@ describe('verifyApiKey middleware', () => { await verifyApiKey(req, res, next); - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'API key not found' })); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401, message: 'API key not found' })); }); test('returns 401 when project is not found in DB', async () => { @@ -151,9 +148,7 @@ describe('verifyApiKey middleware', () => { await verifyApiKey(req, res, next); - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'API key is expired or invalid.' })); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401, error: 'API key is expired or invalid.' })); }); test('returns 401 when owner is not verified', async () => { @@ -163,9 +158,7 @@ describe('verifyApiKey middleware', () => { await verifyApiKey(req, res, next); - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Owner not verified' })); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401, error: 'Owner not verified' })); }); test('uses cache when available and does not query DB', async () => { diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index c4d77ae03..906d8bd7b 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -103,7 +103,7 @@ app.use((err, req, res, next) => { if (err.isOperational && err.statusCode) { return res.status(err.statusCode).json({ success: false, - data: {}, + error: err.error || "Error", message: err.message }); } diff --git a/apps/public-api/src/controllers/data.controller.js b/apps/public-api/src/controllers/data.controller.js index 2ca42079f..4e532c718 100644 --- a/apps/public-api/src/controllers/data.controller.js +++ b/apps/public-api/src/controllers/data.controller.js @@ -9,6 +9,7 @@ const { performance } = require('perf_hooks'); const { z } = require("zod"); const { AppError, + ApiResponse, enqueueCollectionCleanup, syncCollectionCleanup } = require("@urbackend/common"); @@ -30,7 +31,7 @@ const containsBlockedAggregationStage = (pipeline = []) => { }; // INSERT DATA -module.exports.insertData = async (req, res) => { +module.exports.insertData = async (req, res, next) => { try { let start; if (isDebug) start = performance.now(); @@ -41,13 +42,13 @@ module.exports.insertData = async (req, res) => { (c) => c.name === collectionName, ); if (!collectionConfig) - return res.status(404).json({ error: "Collection not found" }); + return next(new AppError(404, "Collection not found")); const schemaRules = collectionConfig.model; const incomingData = req.body; const { error, cleanData } = validateData(incomingData, schemaRules); - if (error) return res.status(400).json({ error }); + if (error) return next(new AppError(400, error)); // Prevent manual injection of soft-delete fields delete cleanData.isDeleted; @@ -62,7 +63,7 @@ module.exports.insertData = async (req, res) => { : { ...safeData, _id: new mongoose.Types.ObjectId() }; docSize = mongoose.mongo.BSON.calculateObjectSize(docForSize); if ((project.databaseUsed || 0) + docSize > project.databaseLimit) { - return res.status(403).json({ error: "Database limit exceeded." }); + return next(new AppError(403, "Database limit exceeded.")); } } @@ -91,20 +92,17 @@ module.exports.insertData = async (req, res) => { }, { removeOnComplete: true }); if (isDebug) console.log(`[DEBUG] insert data took ${(performance.now() - start).toFixed(2)}ms`); - res.status(201).json(result); + return new ApiResponse(result).send(res, 201); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } if (isDuplicateKeyError(err)) { - return res.status(409).json({ - error: "Duplicate value violates unique constraint.", - details: err.message, - }); + return next(new AppError(409, "Duplicate value violates unique constraint.", err.message)); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; @@ -207,7 +205,7 @@ module.exports.bulkInsertData = async (req, res, next) => { // GET ALL DATA -module.exports.getAllData = async (req, res) => { +module.exports.getAllData = async (req, res, next) => { try { let start; if (isDebug) start = performance.now(); @@ -217,6 +215,7 @@ module.exports.getAllData = async (req, res) => { const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); +<<<<<<< Updated upstream if (!collectionConfig) { return res.status(404).json({ @@ -225,6 +224,10 @@ module.exports.getAllData = async (req, res) => { message: "Collection not found", }); } +======= + if (!collectionConfig) + return next(new AppError(404, "Collection not found")); +>>>>>>> Stashed changes const connection = await getConnection(project._id); const Model = getCompiledModel( @@ -251,11 +254,7 @@ module.exports.getAllData = async (req, res) => { const count = await countQuery; - return res.status(200).json({ - success: true, - data: { count }, - message: "Count fetched successfully.", - }); + return new ApiResponse({ count }, "Count fetched successfully.").send(res, 200); } const features = new QueryEngine(Model.find(), req.query).filter(); @@ -303,48 +302,36 @@ module.exports.getAllData = async (req, res) => { limit, }; - res.json({ - success: true, - data: { - items, - ...responseMeta, - }, - message: "Data fetched successfully", - }); + return new ApiResponse({ + items, + ...responseMeta, + }, "Data fetched successfully").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } if (err && (err.statusCode === 400 || err.name === 'QueryFilterError')) { - return res.status(400).json({ - success: false, - data: {}, - message: err.message || "Invalid query filter.", - }); + return next(new AppError(400, err.message || "Invalid query filter.", "Query Filter Error")); } - res.status(500).json({ - success: false, - data: {}, - message: "Failed to fetch data.", - }); + return next(new AppError(500, "Failed to fetch data.")); } }; // GET SINGLE DOC -module.exports.getSingleDoc = async (req, res) => { +module.exports.getSingleDoc = async (req, res, next) => { try { const { collectionName, id } = req.params; const project = req.project; if (!isValidId(id)) - return res.status(400).json({ error: "Invalid ID format." }); + return next(new AppError(400, "Invalid ID format.")); const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); if (!collectionConfig) - return res.status(404).json({ error: "Collection not found" }); + return next(new AppError(404, "Collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel( @@ -387,19 +374,19 @@ module.exports.getSingleDoc = async (req, res) => { } const doc = await query.lean(); - if (!doc) return res.status(404).json({ error: "Document not found." }); + if (!doc) return next(new AppError(404, "Document not found.")); - res.json(doc); + return new ApiResponse(doc).send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; // AGGREGATE DATA -module.exports.aggregateData = async (req, res) => { +module.exports.aggregateData = async (req, res, next) => { try { let start; if (isDebug) start = performance.now(); @@ -411,21 +398,13 @@ module.exports.aggregateData = async (req, res) => { ); if (!collectionConfig) { - return res.status(404).json({ - success: false, - data: {}, - message: "Collection not found", - }); + return next(new AppError(404, "Collection not found")); } const { pipeline } = aggregateSchema.parse(req.body || {}); if (containsBlockedAggregationStage(pipeline)) { - return res.status(400).json({ - success: false, - data: {}, - message: "Aggregation pipeline contains blocked stage.", - }); + return next(new AppError(400, "Aggregation pipeline contains blocked stage.", "Validation Error")); } const connection = await getConnection(project._id); @@ -465,29 +444,17 @@ module.exports.aggregateData = async (req, res) => { if (isDebug) console.log(`[DEBUG] aggregate took ${(performance.now() - start).toFixed(2)}ms`); - return res.status(200).json({ - success: true, - data, - message: "Aggregation executed successfully.", - }); + return new ApiResponse(data, "Aggregation executed successfully.").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } if (err instanceof z.ZodError) { - return res.status(400).json({ - success: false, - data: {}, - message: err.issues?.[0]?.message || "Invalid aggregation payload.", - }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid aggregation payload.", "Validation Error")); } - return res.status(500).json({ - success: false, - data: {}, - message: err.message || "Failed to execute aggregation.", - }); + return next(new AppError(500, err.message || "Failed to execute aggregation.")); } }; @@ -586,7 +553,7 @@ module.exports.updateSingleData = async (req, res, next) => { payload: result }, { removeOnComplete: true }); - res.json({ success: true, data: result, message: "Updated" }); + return new ApiResponse(result, "Updated").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); @@ -605,19 +572,19 @@ module.exports.updateSingleData = async (req, res, next) => { * @param {import('express').Request} req - Express request object. * @param {import('express').Response} res - Express response object. */ -module.exports.deleteSingleDoc = async (req, res) => { +module.exports.deleteSingleDoc = async (req, res, next) => { try { const { collectionName, id } = req.params; const project = req.project; if (!isValidId(id)) - return res.status(400).json({ error: "Invalid ID format." }); + return next(new AppError(400, "Invalid ID format.")); const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); if (!collectionConfig) - return res.status(404).json({ error: "Collection not found" }); + return next(new AppError(404, "Collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel( @@ -639,7 +606,7 @@ module.exports.deleteSingleDoc = async (req, res) => { ).lean(); if (!result) - return res.status(404).json({ error: "Document not found." }); + return next(new AppError(404, "Document not found.")); // We don't decrement databaseUsed here because the document still occupies space. // It will be decremented during hard delete in the background worker. @@ -656,12 +623,12 @@ module.exports.deleteSingleDoc = async (req, res) => { payload: result }, { removeOnComplete: true }); - res.json({ success: true, data: { id }, message: "Document moved to trash" }); + return new ApiResponse({ id }, "Document moved to trash").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; @@ -730,7 +697,7 @@ module.exports.recoverSingleDoc = async (req, res, next) => { console.error("Failed to sync trash cleanup job after recovery", { projectId: String(project._id), collectionName, err }); } - res.json({ success: true, data: result, message: "Document recovered from trash" }); + return new ApiResponse(result, "Document recovered from trash").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); diff --git a/apps/public-api/src/controllers/health.controller.js b/apps/public-api/src/controllers/health.controller.js index 750711f6c..220f2a364 100644 --- a/apps/public-api/src/controllers/health.controller.js +++ b/apps/public-api/src/controllers/health.controller.js @@ -1,8 +1,8 @@ const mongoose = require('mongoose'); -const { redis } = require('@urbackend/common'); +const { redis, ApiResponse, AppError } = require('@urbackend/common'); const REDIS_PING_TIMEOUT_MS = 500; -const getHealth = async (req, res) => { +const getHealth = async (req, res, next) => { const isMongoConnected = mongoose.connection.readyState === 1; let isRedisConnected = false; @@ -19,15 +19,20 @@ const getHealth = async (req, res) => { } const status = isMongoConnected && isRedisConnected ? 'ok' : 'error'; - - return res.status(status === 'ok' ? 200 : 503).json({ + const payload = { status, timestamp: new Date().toISOString(), dependencies: { mongodb: isMongoConnected ? 'connected' : 'disconnected', redis: isRedisConnected ? 'connected' : 'disconnected', }, - }); + }; + + if (status === 'ok') { + return new ApiResponse(payload, "Health Check Passed").send(res, 200); + } else { + return next(new AppError(503, "Service unavailable", "Health Check Failed")); + } }; module.exports = { diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index 57c179798..dfa9aff42 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -1,5 +1,5 @@ const { z } = require("zod"); -const { Project, MailTemplate, decrypt, redis, sendMailSchema, publicEmailQueue, MailLog } = require("@urbackend/common"); +const { Project, MailTemplate, decrypt, redis, sendMailSchema, publicEmailQueue, MailLog, AppError, ApiResponse } = require("@urbackend/common"); const { Resend } = require("resend"); const { getMonthKey, @@ -128,15 +128,11 @@ const renderTemplateString = (template, vars, { mode }) => { return out; }; -module.exports.sendMail = async (req, res) => { +module.exports.sendMail = async (req, res, next) => { let consumedQuotaKey = null; try { if (req.keyRole !== "secret") { - return res.status(403).json({ - success: false, - data: {}, - message: "Forbidden. This action requires a Secret Key (sk_live_...).", - }); + return next(new AppError(403, "Forbidden. This action requires a Secret Key (sk_live_...).")); } const { @@ -151,12 +147,12 @@ module.exports.sendMail = async (req, res) => { const projectId = req.project?._id; if (!projectId) { - return res.status(401).json({ success: false, data: {}, message: "Project context missing." }); + return next(new AppError(401, "Project context missing.")); } const project = await loadProjectMailConfig(projectId); if (!project) { - return res.status(404).json({ success: false, data: {}, message: "Project not found." }); + return next(new AppError(404, "Project not found.")); } const vars = @@ -224,17 +220,13 @@ module.exports.sendMail = async (req, res) => { } if (!t) { - return res.status(400).json({ success: false, data: {}, message: "Template not found." }); + return next(new AppError(400, "Template not found.")); } // Enforce Pro feature limit only for custom (project-owned) templates. if (t.projectId) { if (!req.planLimits || req.planLimits.mailTemplatesEnabled !== true) { - return res.status(403).json({ - success: false, - data: {}, - message: "Custom Email Templates are a Pro feature. Please upgrade to use this functionality." - }); + return next(new AppError(403, "Custom Email Templates are a Pro feature. Please upgrade to use this functionality.")); } } @@ -251,14 +243,14 @@ module.exports.sendMail = async (req, res) => { } if (!resolvedSubject || !resolvedSubject.trim()) { - return res.status(400).json({ success: false, data: {}, message: "Subject is required." }); + return next(new AppError(400, "Subject is required.")); } const hasBody = (typeof resolvedHtml === "string" && resolvedHtml.trim().length > 0) || (typeof resolvedText === "string" && resolvedText.trim().length > 0); if (!hasBody) { - return res.status(400).json({ success: false, data: {}, message: "Provide at least one of html or text content." }); + return next(new AppError(400, "Provide at least one of html or text content.")); } resolvedSubject = renderTemplateString(resolvedSubject, vars, { mode: "text" }); @@ -270,14 +262,14 @@ module.exports.sendMail = async (req, res) => { } if (!resolvedSubject || !resolvedSubject.trim()) { - return res.status(400).json({ success: false, data: {}, message: "Subject is required." }); + return next(new AppError(400, "Subject is required.")); } const hasRenderedBody = (typeof resolvedHtml === "string" && resolvedHtml.trim().length > 0) || (typeof resolvedText === "string" && resolvedText.trim().length > 0); if (!hasRenderedBody) { - return res.status(400).json({ success: false, data: {}, message: "Provide at least one of html or text content." }); + return next(new AppError(400, "Provide at least one of html or text content.")); } const encryptedByokKey = @@ -292,7 +284,7 @@ module.exports.sendMail = async (req, res) => { : process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY; if (!clientKey) { - return res.status(500).json({ success: false, data: {}, message: "Resend API key is not configured." }); + return next(new AppError(500, "Resend API key is not configured.")); } const limit = getMonthlyMailLimit(req.project, req.planLimits); @@ -317,36 +309,25 @@ module.exports.sendMail = async (req, res) => { backoff: { type: 'exponential', delay: 5000 } }); - return res.status(200).json({ - success: true, - data: { - id: job.id ? String(job.id) : null, - provider: usingByok ? "byok" : "default", - monthlyUsage: count, - monthlyLimit: limit, - ...(templateUsed ? { templateUsed } : {}), - }, - message: "Mail queued successfully.", - }); + return new ApiResponse({ + id: job.id ? String(job.id) : null, + provider: usingByok ? "byok" : "default", + monthlyUsage: count, + monthlyLimit: limit, + ...(templateUsed ? { templateUsed } : {}), + }, "Mail queued successfully.").send(res, 200); } catch (err) { if (consumedQuotaKey) { await redis.decr(consumedQuotaKey).catch(() => {}); } if (err instanceof z.ZodError) { - return res.status(400).json({ - success: false, - data: {}, - message: err.issues?.[0]?.message || "Invalid mail payload.", - }); - } - - return res.status(err.statusCode || 500).json({ - success: false, - data: {}, - message: err.message || "Failed to send mail.", - ...(typeof err.limit === "number" ? { limit: err.limit } : {}), - }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid mail payload.", "Validation Error")); + } + + const appErr = new AppError(err.statusCode || 500, err.message || "Failed to send mail."); + if (typeof err.limit === "number") appErr.limit = err.limit; + return next(appErr); } }; @@ -391,11 +372,11 @@ const requireByokGate = async (req) => { }; // GET /api/mail/logs -module.exports.getMailLogs = async (req, res) => { +module.exports.getMailLogs = async (req, res, next) => { try { const projectId = req.project?._id; if (!projectId) { - return res.status(401).json({ success: false, data: {}, message: "Project context missing." }); + return next(new AppError(401, "Project context missing.")); } const logs = await MailLog.find({ projectId }) @@ -403,58 +384,50 @@ module.exports.getMailLogs = async (req, res) => { .limit(50) .lean(); - return res.status(200).json({ - success: true, - data: logs, - message: "Mail logs retrieved successfully." - }); + return new ApiResponse(logs, "Mail logs retrieved successfully.").send(res, 200); } catch (err) { - return res.status(500).json({ success: false, data: {}, message: err.message || "Failed to retrieve mail logs." }); + return next(new AppError(500, err.message || "Failed to retrieve mail logs.")); } }; // GET /api/mail/logs/:resendId -module.exports.getMailStatus = async (req, res) => { +module.exports.getMailStatus = async (req, res, next) => { try { const { resendId } = req.params; - if (!resendId) return res.status(400).json({ success: false, data: {}, message: "resendId is required." }); + if (!resendId) return next(new AppError(400, "resendId is required.")); if (!/^[A-Za-z0-9_-]{1,128}$/.test(resendId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid resendId format." }); + return next(new AppError(400, "Invalid resendId format.")); } const projectId = req.project?._id; const logEntry = await MailLog.findOne({ resendEmailId: resendId, projectId }).lean(); if (!logEntry) { - return res.status(404).json({ success: false, data: {}, message: "Mail log entry not found for this project." }); + return next(new AppError(404, "Mail log entry not found for this project.")); } const { resend } = await resolveResendClient(req); const { data, error } = await resend.emails.get(resendId); if (error) { - return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message || "Failed to fetch email status from Resend." }); + return next(new AppError(error.statusCode || 500, error.message || "Failed to fetch email status from Resend.")); } - return res.status(200).json({ - success: true, - data: { - dbLog: logEntry, - last_event: data?.last_event || logEntry.status, - resendStatus: data - }, - message: "Mail status retrieved successfully." - }); + return new ApiResponse({ + dbLog: logEntry, + last_event: data?.last_event || logEntry.status, + resendStatus: data + }, "Mail status retrieved successfully.").send(res, 200); } catch (err) { - return res.status(500).json({ success: false, data: {}, message: err.message || "Failed to fetch mail status." }); + return next(new AppError(500, err.message || "Failed to fetch mail status.")); } }; // POST /api/mail/webhook (No auth required) const { Webhook } = require("svix"); -module.exports.handleResendWebhook = async (req, res) => { +module.exports.handleResendWebhook = async (req, res, next) => { const secret = process.env.RESEND_WEBHOOK_SECRET; if (!secret) { - return res.status(200).json({ success: true, message: "Webhook ignored: secret not configured." }); + return new ApiResponse(null, "Webhook ignored: secret not configured.").send(res, 200); } const payload = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : (typeof req.body === 'string' ? req.body : JSON.stringify(req.body)); @@ -465,7 +438,7 @@ module.exports.handleResendWebhook = async (req, res) => { try { evt = wh.verify(payload, headers); } catch (err) { - return res.status(400).json({ success: false, message: "Webhook signature verification failed." }); + return next(new AppError(400, "Webhook signature verification failed.")); } const { type, data } = evt; @@ -485,7 +458,7 @@ module.exports.handleResendWebhook = async (req, res) => { } } - return res.status(200).json({ success: true }); + return new ApiResponse(null).send(res, 200); }; // POST /api/mail/send-batch @@ -498,21 +471,17 @@ const sendBatchSchema = z.array( }) ).min(1).max(100); -module.exports.sendBatchMail = async (req, res) => { +module.exports.sendBatchMail = async (req, res, next) => { const reservedKeys = []; try { if (req.keyRole !== "secret") { - return res.status(403).json({ - success: false, - data: {}, - message: "Forbidden. This action requires a Secret Key (sk_live_...).", - }); + return next(new AppError(403, "Forbidden. This action requires a Secret Key (sk_live_...).")); } const batch = sendBatchSchema.parse(req.body); const projectId = req.project?._id; if (!projectId) { - return res.status(401).json({ success: false, data: {}, message: "Project context missing." }); + return next(new AppError(401, "Project context missing.")); } const { resend, usingByok, fromAddress } = await resolveResendClient(req); @@ -536,7 +505,7 @@ module.exports.sendBatchMail = async (req, res) => { for (const k of reservedKeys) { await redis.decr(k).catch(() => {}); } - return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message || "Batch send failed." }); + return next(new AppError(error.statusCode || 500, error.message || "Batch send failed.")); } const results = data?.data || data || []; @@ -557,105 +526,94 @@ module.exports.sendBatchMail = async (req, res) => { await MailLog.insertMany(logDocs).catch(e => console.error("Batch log insertion error:", e)); } - return res.status(200).json({ - success: true, - data: results, - message: `Successfully dispatched batch of ${results.length} emails.` - }); + return new ApiResponse(results, `Successfully dispatched batch of ${results.length} emails.`).send(res, 200); } catch (err) { for (const k of reservedKeys) { await redis.decr(k).catch(() => {}); } if (err instanceof z.ZodError) { - return res.status(400).json({ - success: false, - data: {}, - message: err.issues?.[0]?.message || "Invalid batch mail payload.", - }); - } - - return res.status(err.statusCode || 500).json({ - success: false, - data: {}, - message: err.message || "Failed to send batch mail.", - ...(typeof err.limit === "number" ? { limit: err.limit } : {}), - }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid batch mail payload.", "Validation Error")); + } + + const appErr = new AppError(err.statusCode || 500, err.message || "Failed to send batch mail."); + if (typeof err.limit === "number") appErr.limit = err.limit; + return next(appErr); } }; // --- AUDIENCES (BYOK Gate) --- -module.exports.createAudience = async (req, res) => { +module.exports.createAudience = async (req, res, next) => { try { const resend = await requireByokGate(req); const { name } = req.body; - if (!name) return res.status(400).json({ success: false, data: {}, message: "Audience name is required." }); + if (!name) return next(new AppError(400, "Audience name is required.")); const { data, error } = await resend.audiences.create({ name }); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getAudiences = async (req, res) => { +module.exports.getAudiences = async (req, res, next) => { try { const resend = await requireByokGate(req); const { data, error } = await resend.audiences.list(); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getAudienceById = async (req, res) => { +module.exports.getAudienceById = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience ID format." }); + return next(new AppError(400, "Invalid audience ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.audiences.get(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.deleteAudience = async (req, res) => { +module.exports.deleteAudience = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience ID format." }); + return next(new AppError(400, "Invalid audience ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.audiences.remove(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; // --- CONTACTS (BYOK Gate) --- -module.exports.addContact = async (req, res) => { +module.exports.addContact = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience ID format." }); + return next(new AppError(400, "Invalid audience ID format.")); } const resend = await requireByokGate(req); const { email, firstName, lastName, unsubscribed } = req.body; - if (!email) return res.status(400).json({ success: false, data: {}, message: "Contact email is required." }); + if (!email) return next(new AppError(400, "Contact email is required.")); const payload = { audienceId: id, email }; if (firstName) payload.firstName = firstName; @@ -663,51 +621,51 @@ module.exports.addContact = async (req, res) => { if (unsubscribed !== undefined) payload.unsubscribed = unsubscribed; const { data, error } = await resend.contacts.create(payload); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getContacts = async (req, res) => { +module.exports.getContacts = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience ID format." }); + return next(new AppError(400, "Invalid audience ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.contacts.list({ audienceId: id }); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getContactById = async (req, res) => { +module.exports.getContactById = async (req, res, next) => { try { const { id, contactId } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id) || !/^[A-Za-z0-9_-]+$/.test(contactId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience or contact ID format." }); + return next(new AppError(400, "Invalid audience or contact ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.contacts.get({ audienceId: id, id: contactId }); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.updateContact = async (req, res) => { +module.exports.updateContact = async (req, res, next) => { try { const { id, contactId } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id) || !/^[A-Za-z0-9_-]+$/.test(contactId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience or contact ID format." }); + return next(new AppError(400, "Invalid audience or contact ID format.")); } const resend = await requireByokGate(req); const { firstName, lastName, unsubscribed } = req.body; @@ -718,27 +676,27 @@ module.exports.updateContact = async (req, res) => { if (unsubscribed !== undefined) payload.unsubscribed = unsubscribed; const { data, error } = await resend.contacts.update(payload); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.deleteContact = async (req, res) => { +module.exports.deleteContact = async (req, res, next) => { try { const { id, contactId } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id) || !/^[A-Za-z0-9_-]+$/.test(contactId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience or contact ID format." }); + return next(new AppError(400, "Invalid audience or contact ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.contacts.remove({ audienceId: id, id: contactId }); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; @@ -754,13 +712,13 @@ const requireBroadcastGate = async (req) => { return resend; }; -module.exports.createBroadcast = async (req, res) => { +module.exports.createBroadcast = async (req, res, next) => { try { const resend = await requireBroadcastGate(req); const { audienceId, segmentId, from, subject, html, scheduledAt } = req.body; const resolvedAudienceId = audienceId || segmentId; if (!resolvedAudienceId || !subject || !html) { - return res.status(400).json({ success: false, data: {}, message: "audienceId, subject, and html are required." }); + return next(new AppError(400, "audienceId, subject, and html are required.")); } const payload = { @@ -772,70 +730,70 @@ module.exports.createBroadcast = async (req, res) => { if (scheduledAt) payload.scheduledAt = scheduledAt; const { data, error } = await resend.broadcasts.create(payload); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.sendBroadcast = async (req, res) => { +module.exports.sendBroadcast = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid broadcast ID format." }); + return next(new AppError(400, "Invalid broadcast ID format.")); } const resend = await requireBroadcastGate(req); const { data, error } = await resend.broadcasts.send(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getBroadcasts = async (req, res) => { +module.exports.getBroadcasts = async (req, res, next) => { try { const resend = await requireBroadcastGate(req); const { data, error } = await resend.broadcasts.list(); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getBroadcastById = async (req, res) => { +module.exports.getBroadcastById = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid broadcast ID format." }); + return next(new AppError(400, "Invalid broadcast ID format.")); } const resend = await requireBroadcastGate(req); const { data, error } = await resend.broadcasts.get(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.deleteBroadcast = async (req, res) => { +module.exports.deleteBroadcast = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid broadcast ID format." }); + return next(new AppError(400, "Invalid broadcast ID format.")); } const resend = await requireBroadcastGate(req); const { data, error } = await resend.broadcasts.remove(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; diff --git a/apps/public-api/src/controllers/schema.controller.js b/apps/public-api/src/controllers/schema.controller.js index a8f7c0473..e927f7027 100644 --- a/apps/public-api/src/controllers/schema.controller.js +++ b/apps/public-api/src/controllers/schema.controller.js @@ -9,6 +9,8 @@ const { clearCompiledModel, createUniqueIndexes, generateApiKey, + AppError, + ApiResponse, } = require("@urbackend/common"); const { z } = require("zod"); @@ -26,13 +28,13 @@ const dropCollectionIfExists = async (connection, collectionName) => { } }; -module.exports.checkSchema = async (req, res) => { +module.exports.checkSchema = async (req, res, next) => { try { const { collectionName } = req.params; const project = req.project; if (!project) { - return res.status(401).json({ error: "Project missing from request." }); + return next(new AppError(401, "Project missing from request.")); } const collectionConfig = project.collections.find( @@ -40,19 +42,17 @@ module.exports.checkSchema = async (req, res) => { ); if (!collectionConfig) { - return res.status(404).json({ error: "Schema/Collection not found" }); + return next(new AppError(404, "Schema/Collection not found")); } - res - .status(200) - .json({ message: "Schema exists", collection: collectionConfig }); + return new ApiResponse({ collection: collectionConfig }, "Schema exists").send(res, 200); } catch (err) { console.error(err); - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; -module.exports.createSchema = async (req, res) => { +module.exports.createSchema = async (req, res, next) => { let fullProject; let connection; let compiledCollectionName; @@ -65,20 +65,18 @@ module.exports.createSchema = async (req, res) => { collectionNameForRollback = name; const project = req.project; if (!project) { - return res.status(401).json({ error: "Project missing from request." }); + return next(new AppError(401, "Project missing from request.")); } const projectId = project._id; fullProject = await Project.findById(projectId); if (!fullProject) - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); const exists = fullProject.collections.find((c) => c.name === name); if (exists) - return res - .status(400) - .json({ error: "Collection/Schema already exists" }); + return next(new AppError(400, "Collection/Schema already exists")); if (!fullProject.jwtSecret) { fullProject.jwtSecret = generateApiKey("jwt_"); @@ -179,9 +177,7 @@ module.exports.createSchema = async (req, res) => { delete projectObj.secretKey; delete projectObj.jwtSecret; - return res - .status(201) - .json({ message: "Schema created successfully", project: projectObj }); + return new ApiResponse({ project: projectObj }, "Schema created successfully").send(res, 201); } catch (err) { try { if (fullProject && collectionWasPersisted) { @@ -203,10 +199,10 @@ module.exports.createSchema = async (req, res) => { } if (err instanceof z.ZodError) { - return res.status(400).json({ error: err.issues }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid schema payload.")); } console.error(err); - return res.status(400).json({ error: err.message }); + return next(new AppError(400, err.message)); } }; diff --git a/apps/public-api/src/controllers/storage.controller.js b/apps/public-api/src/controllers/storage.controller.js index 9e34ecf02..8ce9803cc 100644 --- a/apps/public-api/src/controllers/storage.controller.js +++ b/apps/public-api/src/controllers/storage.controller.js @@ -1,4 +1,4 @@ -const { getStorage, getPresignedUploadUrl, verifyUploadedFile, Project, isProjectStorageExternal, getBucket, redis, getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("@urbackend/common"); +const { getStorage, getPresignedUploadUrl, verifyUploadedFile, Project, isProjectStorageExternal, getBucket, redis, getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic, AppError, ApiResponse } = require("@urbackend/common"); const { randomUUID } = require("crypto"); const path = require("path"); @@ -79,15 +79,15 @@ const bestEffortDeleteUploadedObject = async (project, filePath) => { // Upload File -module.exports.uploadFile = async (req, res) => { +module.exports.uploadFile = async (req, res, next) => { try { const file = req.file; if (!file) { - return res.status(400).json({ error: "No file uploaded." }); + return next(new AppError(400, "No file uploaded.")); } if (file.size > MAX_FILE_SIZE) { - return res.status(413).json({ error: "File size exceeds limit." }); + return next(new AppError(413, "File size exceeds limit.")); } const project = req.project; @@ -110,7 +110,7 @@ module.exports.uploadFile = async (req, res) => { ); if (result.matchedCount === 0) { - return res.status(403).json({ error: "Storage limit exceeded. Please upgrade your plan or delete some files." }); + return next(new AppError(403, "Storage limit exceeded. Please upgrade your plan or delete some files.")); } } else { const result = await Project.updateOne( @@ -122,7 +122,7 @@ module.exports.uploadFile = async (req, res) => { ); if (result.matchedCount === 0) { - return res.status(403).json({ error: "Storage limit exceeded. Please upgrade your plan or delete some files." }); + return next(new AppError(403, "Storage limit exceeded. Please upgrade your plan or delete some files.")); } } } @@ -156,31 +156,24 @@ module.exports.uploadFile = async (req, res) => { updateMonthlyUsageCounter(project._id, "storage:uploadedBytes", file.size); - return res.status(201).json({ - message: "File uploaded successfully", + return new ApiResponse({ url: publicUrlData.publicUrl, path: filePath, provider: external ? "external" : "internal" - }); + }, "File uploaded successfully").send(res, 201); } catch (err) { - return res.status(500).json({ - error: "File upload failed", - details: - process.env.NODE_ENV === "development" - ? err.message - : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "File upload failed") : "File upload failed")); } }; /** * Delete File */ -module.exports.deleteFile = async (req, res) => { +module.exports.deleteFile = async (req, res, next) => { try { const { path } = req.body; if (!path) { - return res.status(400).json({ error: "File path is required." }); + return next(new AppError(400, "File path is required.")); } const project = req.project; @@ -189,7 +182,7 @@ module.exports.deleteFile = async (req, res) => { const normalizedPath = normalizeProjectPath(project._id, path); if (!normalizedPath) { - return res.status(403).json({ error: "Access denied." }); + return next(new AppError(403, "Access denied.")); } const supabase = await getStorage(project); @@ -229,23 +222,17 @@ module.exports.deleteFile = async (req, res) => { updateMonthlyUsageCounter(project._id, "storage:deletedBytes", fileSize); - return res.json({ message: "File deleted successfully" }); + return new ApiResponse(null, "File deleted successfully").send(res, 200); } catch (err) { - return res.status(500).json({ - error: "File deletion failed", - details: - process.env.NODE_ENV === "development" - ? err.message - : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "File deletion failed") : "File deletion failed")); } }; -module.exports.deleteAllFiles = async (req, res) => { +module.exports.deleteAllFiles = async (req, res, next) => { try { const project = req.project; // assuming middleware attaches project if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } const supabase = await getStorage(project); @@ -285,35 +272,28 @@ module.exports.deleteAllFiles = async (req, res) => { ); } - res.json({ - success: true, + return new ApiResponse({ deleted: deletedCount, provider: isProjectStorageExternal(project) ? "external" : "internal" - }); + }).send(res, 200); } catch (err) { - res.status(500).json({ - error: "Failed to delete files", - details: - process.env.NODE_ENV === "development" - ? err.message - : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "Failed to delete files") : "Failed to delete files")); } }; // REQUEST UPLOAD - generates presigned URL for direct browser upload -module.exports.requestUpload = async (req, res) => { +module.exports.requestUpload = async (req, res, next) => { try { const { filename, contentType, size } = req.body; const numericSize = parsePositiveSize(size); if (!filename || !contentType || numericSize === null) - return res.status(400).json({ error: "filename, contentType, and size are required." }); + return next(new AppError(400, "filename, contentType, and size are required.")); if (numericSize > MAX_FILE_SIZE) - return res.status(413).json({ error: "File size exceeds limit." }); + return next(new AppError(413, "File size exceeds limit.")); const project = req.project; const external = isProjectStorageExternal(project); @@ -323,7 +303,7 @@ module.exports.requestUpload = async (req, res) => { if (!external) { const quotaLimit = effectiveLimit === -1 ? SAFETY_MAX_BYTES : effectiveLimit; if (project.storageUsed + numericSize > quotaLimit) - return res.status(403).json({ error: "Internal storage limit exceeded." }); + return next(new AppError(403, "Internal storage limit exceeded.")); } const safeName = filename.replace(/\s+/g, "_"); @@ -331,23 +311,20 @@ module.exports.requestUpload = async (req, res) => { const { signedUrl, token } = await getPresignedUploadUrl(project, filePath, contentType, numericSize); - return res.status(200).json({ signedUrl, token, filePath }); + return new ApiResponse({ signedUrl, token, filePath }).send(res, 200); } catch (err) { - return res.status(500).json({ - error: "Could not generate upload URL", - details: process.env.NODE_ENV === "development" ? err.message : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "Could not generate upload URL") : "Could not generate upload URL")); } }; // CONFIRM UPLOAD - verifies file landed on cloud, then charges quota -module.exports.confirmUpload = async (req, res) => { +module.exports.confirmUpload = async (req, res, next) => { try { const { filePath, size } = req.body; const declaredSize = parsePositiveSize(size); if (!filePath || declaredSize === null) - return res.status(400).json({ error: "filePath and size are required." }); + return next(new AppError(400, "filePath and size are required.")); const project = req.project; const external = isProjectStorageExternal(project); @@ -355,7 +332,7 @@ module.exports.confirmUpload = async (req, res) => { // make sure client isn't confirming someone else's file if (!normalizedPath) - return res.status(403).json({ error: "Access denied." }); + return next(new AppError(403, "Access denied.")); // verify file actually exists on cloud before touching quota let actualSize; @@ -364,22 +341,19 @@ module.exports.confirmUpload = async (req, res) => { } catch (err) { if (err?.message === "File not found after upload") { await bestEffortDeleteUploadedObject(project, normalizedPath); - return res.status(409).json({ - error: "UPLOAD_NOT_READY", - message: "Uploaded file is not visible yet. Please retry confirmation." - }); + return next(new AppError(409, "Uploaded file is not visible yet. Please retry confirmation.", "UPLOAD_NOT_READY")); } throw err; } if (!Number.isFinite(actualSize) || actualSize <= 0) { await bestEffortDeleteUploadedObject(project, normalizedPath); - return res.status(500).json({ error: "Upload confirmation failed", details: process.env.NODE_ENV === "development" ? "Uploaded file size could not be determined" : undefined }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? "Uploaded file size could not be determined" : "Upload confirmation failed")); } if (Math.abs(actualSize - declaredSize) > CONFIRM_UPLOAD_SIZE_TOLERANCE_BYTES) { await bestEffortDeleteUploadedObject(project, normalizedPath); - return res.status(400).json({ error: "Declared file size does not match uploaded file size." }); + return next(new AppError(400, "Declared file size does not match uploaded file size.")); } // now it's safe to charge quota @@ -396,7 +370,7 @@ module.exports.confirmUpload = async (req, res) => { ); if (result.matchedCount === 0) { await bestEffortDeleteUploadedObject(project, normalizedPath); - return res.status(403).json({ error: "Internal storage limit exceeded." }); + return next(new AppError(403, "Internal storage limit exceeded.")); } } @@ -419,11 +393,8 @@ module.exports.confirmUpload = async (req, res) => { response.warning = publicUrlData?.error || "Upload confirmed, but a public URL is unavailable."; } - return res.status(200).json(response); + return new ApiResponse(response, response.message).send(res, 200); } catch (err) { - return res.status(500).json({ - error: "Upload confirmation failed", - details: process.env.NODE_ENV === "development" ? err.message : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "Upload confirmation failed") : "Upload confirmation failed")); } }; diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index 55d275710..8bca04697 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const { z } = require('zod'); @@ -1814,3 +1815,1724 @@ module.exports.updateAdminUser = async (req, res) => { res.status(500).json({ error: err.message }); } }; +======= +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const { z } = require('zod'); +const mongoose = require('mongoose'); +const crypto = require('crypto'); +const {redis} = require('@urbackend/common'); +const {Project} = require('@urbackend/common'); +const { authEmailQueue } = require('@urbackend/common'); +const { checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); +const { AppError, ApiResponse } = require('@urbackend/common'); +const { getRefreshSession, persistRefreshSession, revokeSessionChain } = require('@urbackend/common'); +const { loginSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common'); +const { getConnection } = require('@urbackend/common'); +const { getCompiledModel } = require('@urbackend/common'); +const { decrypt } = require('@urbackend/common'); +const { + assertRefreshRateLimits, + clearRefreshCookie, + hashRefreshToken, + issueAuthTokens, + parseRefreshToken, + readRefreshTokenFromRequest, + shouldExposeRefreshToken +} = require('../utils/refreshToken'); + +const SOCIAL_PROVIDER_KEYS = ['github', 'google']; +const SOCIAL_STATE_TTL_SECONDS = 600; +const SOCIAL_REFRESH_EXCHANGE_TTL_SECONDS = 60; + +/** + * Checks if a public OTP cooldown is active for this email. + * @param {string} projectId + * @param {string} email + * @param {string} type + */ +const checkPublicOtpCooldown = async (projectId, email, type = 'verification') => { + const cooldownKey = `project:${projectId}:otp:cooldown:${type}:${email}`; + const exists = await redis.get(cooldownKey); + if (exists) { + const ttl = await redis.ttl(cooldownKey); + throw new AppError(429, `Please wait ${ttl} seconds before requesting another code.`); + } +}; + +/** + * Sets a 60s cooldown for OTP requests to prevent spam. + */ +const setPublicOtpCooldown = async (projectId, email, type = 'verification') => { + const cooldownKey = `project:${projectId}:otp:cooldown:${type}:${email}`; + await redis.set(cooldownKey, '1', 'EX', 60); +}; + + +/** + * Returns the base URL for the public API, used for redirect URI construction. + * @returns {string} + */ +const getPublicApiBaseUrl = () => { + const configured = process.env.PUBLIC_API_URL?.trim(); + if (configured) return configured.replace(/\/$/, ''); + const port = process.env.USER_PORT || 1235; + return `http://localhost:${port}`; +}; + +/** + * Returns the Redis key for storing OAuth state. + * @param {string} state - CSRF state token + * @returns {string} + */ +const getSocialStateKey = (state) => `project:auth:oauth:state:${state}`; + +/** + * Returns the Redis key for storing the temporary social refresh exchange code. + * @param {string} rtCode - Exchange code + * @returns {string} + */ +const getSocialRefreshExchangeKey = (rtCode) => `project:social-auth:refresh-exchange:${rtCode}`; + +/** + * Returns the frontend OAuth callback URL for a project. + * Falls back to FRONTEND_URL env or localhost when siteUrl is not set. + * @param {Object} project - Project document + * @returns {string} + */ +const getFrontendCallbackBaseUrl = (project) => { + const configured = String(project?.siteUrl || '').trim(); + const base = configured || process.env.FRONTEND_URL || ''; + if (!base) { + console.warn('[social-auth] getFrontendCallbackBaseUrl: siteUrl is not set on the project and FRONTEND_URL env is not configured. Falling back to http://localhost:5173. Set siteUrl in Project Settings or configure FRONTEND_URL.'); + } + return `${(base || 'http://localhost:5173').replace(/\/$/, '')}/auth/callback`; +}; + +/** + * Decodes a base64url-encoded string into a Buffer. + * @param {string} input - Base64url encoded string + * @returns {Buffer} + */ +const toBase64UrlBuffer = (input) => Buffer.from(input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '='), 'base64'); + +/** + * In-memory cache for Google's public JWK keys. + * Keys are refreshed when the cache expires (based on Cache-Control max-age). + * An in-flight promise is stored to prevent redundant concurrent fetches (single-flight). + */ +const googleJwkCache = { keys: null, expiresAt: 0, inflight: null }; + +/** + * Fetches Google's public JWK keys, using an in-memory cache keyed by Cache-Control max-age. + * Uses a single-flight pattern so that concurrent requests share one fetch instead of many. + * @returns {Promise} Array of JWK key objects + */ +const getGooglePublicKeys = async () => { + const now = Date.now(); + if (googleJwkCache.keys && now < googleJwkCache.expiresAt) { + return googleJwkCache.keys; + } + + // Single-flight: reuse in-flight promise if a fetch is already in progress. + if (googleJwkCache.inflight) { + return googleJwkCache.inflight; + } + + googleJwkCache.inflight = (async () => { + try { + const certsResponse = await fetch('https://www.googleapis.com/oauth2/v3/certs'); + if (!certsResponse.ok) { + throw new AppError(502, 'Unable to fetch Google JWK keys from upstream'); + } + + const certsPayload = await certsResponse.json(); + const keys = certsPayload.keys || []; + + // Parse Cache-Control max-age from response headers to determine TTL. + const cacheControl = (typeof certsResponse.headers?.get === 'function') + ? (certsResponse.headers.get('cache-control') || '') + : ''; + const maxAgeMatch = cacheControl.match(/max-age=(\d+)/); + const ttlMs = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) * 1000 : 3600 * 1000; + + googleJwkCache.keys = keys; + googleJwkCache.expiresAt = Date.now() + ttlMs; + + return keys; + } finally { + googleJwkCache.inflight = null; + } + })(); + + return googleJwkCache.inflight; +}; + +/** + * Asserts that a project has auth enabled and a valid users collection schema. + * Throws with an appropriate statusCode on failure. + * @param {Object} project - Lean project document + * @returns {Object} The users collection config + */ +const assertAuthProjectReady = (project) => { + if (!project?.isAuthEnabled) { + throw new AppError(403, 'Authentication service is disabled'); + } + + const usersCollection = project.collections?.find(c => c.name === 'users'); + if (!usersCollection) { + throw new AppError(403, "Authentication is enabled, but the 'users' collection has not been defined."); + } + + const hasEmail = usersCollection.model.find(f => f.key === 'email' && f.type === 'String' && f.required); + const hasPassword = usersCollection.model.find(f => f.key === 'password' && f.type === 'String' && f.required); + if (!hasEmail || !hasPassword) { + throw new AppError(422, "The 'users' collection must contain required 'email' and 'password' String fields."); + } + + return usersCollection; +}; + +/** + * Loads and decrypts a social provider's config for a project. + * @param {string} projectId - Project ObjectId + * @param {string} provider - 'github' or 'google' + * @returns {Promise<{project: Object, providerConfig: Object|null}|null>} + */ +const getSocialProviderConfig = async (projectId, provider) => { + const selectClause = [ + 'name', + 'resources', + 'collections', + 'jwtSecret', + 'isAuthEnabled', + `authProviders.${provider}.enabled`, + `authProviders.${provider}.clientId`, + `authProviders.${provider}.redirectUri`, + `+authProviders.${provider}.clientSecret.encrypted`, + `+authProviders.${provider}.clientSecret.iv`, + `+authProviders.${provider}.clientSecret.tag`, + ].join(' '); + const project = await Project.findById(projectId).select(selectClause).lean(); + if (!project) return null; + + const providerConfig = project.authProviders?.[provider]; + if (!providerConfig?.enabled || !providerConfig.clientId || !providerConfig.clientSecret) { + return { project, providerConfig: null }; + } + + const decryptedSecret = decrypt(providerConfig.clientSecret); + if (!decryptedSecret) { + return { project, providerConfig: null }; + } + + return { + project, + providerConfig: { + enabled: true, + clientId: providerConfig.clientId, + clientSecret: decryptedSecret, + redirectUri: `${getPublicApiBaseUrl()}/api/userAuth/social/${provider}/callback` + } + }; +}; + +/** + * Builds the GitHub OAuth authorization URL. + * @param {Object} params + * @param {string} params.clientId + * @param {string} params.redirectUri + * @param {string} params.state - CSRF state token + * @returns {string} + */ +const buildGithubAuthorizeUrl = ({ clientId, redirectUri, state }) => { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope: 'read:user user:email', + state, + }); + return `https://github.com/login/oauth/authorize?${params.toString()}`; +}; + +/** + * Builds the Google OAuth authorization URL. + * @param {Object} params + * @param {string} params.clientId + * @param {string} params.redirectUri + * @param {string} params.state - CSRF state token + * @returns {string} + */ +const buildGoogleAuthorizeUrl = ({ clientId, redirectUri, state }) => { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'offline', + prompt: 'consent', + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; +}; + +/** + * Exchanges a GitHub OAuth authorization code for an access token. + * @param {Object} params + * @param {string} params.code + * @param {string} params.clientId + * @param {string} params.clientSecret + * @param {string} params.redirectUri + * @returns {Promise<{accessToken: string, tokenType: string}>} + */ +const exchangeGithubCodeForToken = async ({ code, clientId, clientSecret, redirectUri }) => { + const response = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + + const payload = await response.json(); + if (!response.ok || payload.error || !payload.access_token) { + throw new AppError(502, payload.error_description || payload.error || 'GitHub token exchange failed'); + } + + return { + accessToken: payload.access_token, + tokenType: payload.token_type || 'bearer', + }; +}; + +/** + * Exchanges a Google OAuth authorization code for tokens including an id_token. + * @param {Object} params + * @param {string} params.code + * @param {string} params.clientId + * @param {string} params.clientSecret + * @param {string} params.redirectUri + * @returns {Promise} Google token response including id_token + */ +const exchangeGoogleCodeForToken = async ({ code, clientId, clientSecret, redirectUri }) => { + const params = new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }); + + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + const payload = await response.json(); + if (!response.ok || payload.error || !payload.id_token) { + throw new AppError(502, payload.error_description || payload.error || 'Google token exchange failed'); + } + + return payload; +}; + +/** + * Fetches the user profile from GitHub using an access token. + * Includes user email fetching as a secondary step. + * @param {string} accessToken + * @returns {Promise} Normalized profile + */ +const fetchGithubProfile = async (accessToken) => { + const headers = { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'urBackend-social-auth', + }; + + const [profileResponse, emailsResponse] = await Promise.all([ + fetch('https://api.github.com/user', { headers }), + fetch('https://api.github.com/user/emails', { headers }), + ]); + + const profile = await profileResponse.json(); + const emails = await emailsResponse.json(); + + if (!profileResponse.ok) { + throw new AppError(502, profile.message || 'Failed to fetch GitHub profile'); + } + if (!emailsResponse.ok || !Array.isArray(emails)) { + throw new AppError(502, 'Failed to fetch GitHub email addresses'); + } + + const verifiedEmail = emails.find((entry) => entry.primary && entry.verified) || emails.find((entry) => entry.verified); + return { + providerUserId: String(profile.id || ''), + email: verifiedEmail?.email || profile.email || '', + emailVerified: !!verifiedEmail?.verified, + username: profile.login || '', + name: profile.name || profile.login || '', + avatarUrl: profile.avatar_url || '', + rawProfile: profile, + }; +}; + +/** + * Verifies a Google id_token using Google's public JWK keys. + * @param {Object} params + * @param {string} params.idToken - Google JWT id_token + * @param {string} params.clientId - OAuth client ID for audience validation + * @returns {Promise} Decoded JWT claims + */ +const verifyGoogleIdToken = async ({ idToken, clientId }) => { + const parts = String(idToken || '').split('.'); + if (parts.length !== 3) { + throw new AppError(400, 'Invalid Google id_token format'); + } + + const [encodedHeader, encodedPayload, encodedSignature] = parts; + const header = JSON.parse(toBase64UrlBuffer(encodedHeader).toString('utf8')); + const payload = JSON.parse(toBase64UrlBuffer(encodedPayload).toString('utf8')); + + if (header.alg !== 'RS256' || !header.kid) { + throw new AppError(400, 'Unsupported Google id_token signature'); + } + + const certsKeys = await getGooglePublicKeys(); + const signingKey = certsKeys.find((key) => key.kid === header.kid); + if (!signingKey) { + throw new AppError(401, 'Unable to verify Google id_token signing key'); + } + + const publicKey = crypto.createPublicKey({ key: signingKey, format: 'jwk' }); + const verified = crypto.verify( + 'RSA-SHA256', + Buffer.from(`${encodedHeader}.${encodedPayload}`), + publicKey, + toBase64UrlBuffer(encodedSignature) + ); + + if (!verified) { + throw new AppError(401, 'Invalid Google id_token signature'); + } + + const validIssuers = new Set(['accounts.google.com', 'https://accounts.google.com']); + const nowSeconds = Math.floor(Date.now() / 1000); + const audienceMatches = Array.isArray(payload.aud) + ? payload.aud.includes(clientId) + : payload.aud === clientId; + + if (!audienceMatches) { + throw new AppError(400, 'Google id_token audience mismatch'); + } + if (!validIssuers.has(payload.iss)) { + throw new AppError(400, 'Google id_token issuer mismatch'); + } + if (!payload.exp || Number(payload.exp) <= nowSeconds) { + throw new AppError(401, 'Google id_token has expired'); + } + + return payload; +}; + +/** + * Verifies a Google ID token and returns the normalized user profile. + * @param {Object} params + * @param {string} params.idToken + * @param {string} params.clientId + * @returns {Promise} Normalized profile + */ +const fetchGoogleProfile = async ({ idToken, clientId }) => { + const claims = await verifyGoogleIdToken({ idToken, clientId }); + return { + providerUserId: String(claims.sub || ''), + email: claims.email || '', + emailVerified: !!claims.email_verified, + username: claims.email ? String(claims.email).split('@')[0] : '', + name: claims.name || '', + avatarUrl: claims.picture || '', + rawProfile: claims, + }; +}; + +const socialProviders = { + github: { + buildAuthorizeUrl: buildGithubAuthorizeUrl, + exchangeCodeForToken: exchangeGithubCodeForToken, + fetchProfile: async ({ tokenResponse }) => fetchGithubProfile(tokenResponse.accessToken), + }, + google: { + buildAuthorizeUrl: buildGoogleAuthorizeUrl, + exchangeCodeForToken: exchangeGoogleCodeForToken, + fetchProfile: async ({ tokenResponse, providerConfig }) => fetchGoogleProfile({ + idToken: tokenResponse.id_token, + clientId: providerConfig.clientId, + }), + }, +}; + +/** + * Builds a new user document payload for a social-auth-created user. + * Generates a random hashed password to satisfy the users schema contract. + * @param {Object} usersColConfig - Users collection config from project + * @param {Object} profile - Normalized social profile + * @returns {Promise} User document fields + */ +const buildSocialAuthUserPayload = async (usersColConfig, profile) => { + const randomPassword = crypto.randomBytes(24).toString('hex'); + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(randomPassword, salt); + + return buildAuthUserPayload( + usersColConfig, + { + email: profile.email, + password: randomPassword, + username: profile.username, + name: profile.name, + avatarUrl: profile.avatarUrl, + }, + hashedPassword, + profile.emailVerified + ); +}; + +/** + * Finds an existing user by provider ID or verified email, or creates a new user. + * Only links by email when profile.emailVerified is true to prevent account takeover. + * @param {Object} params + * @param {Object} params.project - Project document + * @param {Object} params.usersColConfig - Users collection config + * @param {Object} params.Model - Mongoose model for the users collection + * @param {string} params.provider - 'github' or 'google' + * @param {Object} params.profile - Normalized social profile + * @returns {Promise<{user: Object, isNewUser: boolean, linkedByEmail: boolean}>} + */ +const findOrCreateSocialUser = async ({ project, usersColConfig, Model, provider, profile }) => { + const providerIdField = `${provider}Id`; + const providerName = provider; + + let user = await Model.findOne({ [providerIdField]: profile.providerUserId }); + if (user) { + return { user, isNewUser: false, linkedByEmail: false }; + } + + if (!profile.email) { + throw new AppError(422, `${providerName} did not return an email address for this account.`); + } + + user = await Model.findOne({ email: profile.email }); + if (user) { + // P1: Only link if provider email is verified; reject if unverified to prevent account takeover + if (!profile.emailVerified) { + throw new AppError(403, `Cannot link ${providerName} account: the provider email is not verified. Please verify your email with ${providerName} first.`, 'SOCIAL_AUTH_EMAIL_NOT_VERIFIED'); + } + + const update = { + $set: { + [providerIdField]: profile.providerUserId, + ...(profile.avatarUrl ? { avatarUrl: profile.avatarUrl } : {}), + }, + $addToSet: { authProviders: providerName }, + }; + + const verificationField = getVerificationField(usersColConfig); + if (verificationField) { + update.$set[verificationField] = true; + } + + await Model.updateOne({ _id: user._id }, update); + user = await Model.findOne({ _id: user._id }); + return { user, isNewUser: false, linkedByEmail: true }; + } + + const newUserPayload = await buildSocialAuthUserPayload(usersColConfig, profile); + newUserPayload[providerIdField] = profile.providerUserId; + newUserPayload.authProviders = [providerName]; + if (profile.avatarUrl && newUserPayload.avatarUrl === undefined) { + newUserPayload.avatarUrl = profile.avatarUrl; + } + + user = await Model.create(newUserPayload); + return { user, isNewUser: true, linkedByEmail: false }; +}; + +/** + * Loads the compiled Mongoose model for the users collection. + * @param {Object} project - Lean project document + * @returns {Promise<{usersColConfig: Object|null, Model: Object|null}>} + */ +const getUsersModel = async (project) => { + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return { usersColConfig: null, Model: null }; + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + return { usersColConfig, Model }; +}; + +/** + * Checks whether a required field key exists in the users collection schema. + * @param {Object} usersColConfig - Users collection config + * @param {string} fieldKey - Field key to check + * @returns {boolean} + */ +const hasRequiredField = (usersColConfig, fieldKey) => { + const model = usersColConfig?.model || []; + return model.some((f) => f?.key === fieldKey && !!f?.required); +}; + +/** + * Returns the name of the email verification field in the users schema, if it exists. + * @param {Object} usersColConfig - Users collection config + * @returns {string|null} + */ +const getVerificationField = (usersColConfig) => { + const modelKeys = (usersColConfig?.model || []).map((f) => f?.key); + if (modelKeys.includes('emailVerified')) return 'emailVerified'; + if (modelKeys.includes('isVerified')) return 'isVerified'; + if (modelKeys.includes('isverified')) return 'isverified'; + return null; +}; + +/** + * Builds a user payload for registration or social login, mapping flat data to the users collection schema. + * @param {Object} usersColConfig - Users collection config + * @param {Object} parsedData - Raw user data (email, name, etc.) + * @param {string} hashedPassword - Hashed password string + * @param {boolean} verifiedValue - Default value for the verification field + * @returns {Object} Mongoose-ready user document payload + */ +const buildAuthUserPayload = (usersColConfig, parsedData, hashedPassword, verifiedValue) => { + const { email, password: _password, username, ...otherData } = parsedData; + + const payload = { + email, + password: hashedPassword, + ...otherData, + createdAt: new Date() + }; + + if (username !== undefined) { + payload.username = username; + } + + const verificationField = getVerificationField(usersColConfig); + if (verificationField !== null) { + payload[verificationField] = verifiedValue; + } + + if (hasRequiredField(usersColConfig, 'name') && (payload.name === undefined || payload.name === null || payload.name === '')) { + const generatedName = username || email.split('@')[0]; + payload.name = generatedName.length >= 3 ? generatedName : generatedName.padEnd(3, '0'); + } + + if (hasRequiredField(usersColConfig, 'username') && (payload.username === undefined || payload.username === null || payload.username === '')) { + const baseUsername = typeof payload.name === 'string' ? payload.name : email.split('@')[0]; + const generatedUsername = baseUsername; + payload.username = generatedUsername.length >= 3 ? generatedUsername : generatedUsername.padEnd(3, '0'); + } + + return payload; +}; + +const SENSITIVE_PROFILE_KEYS = [ + 'password', + 'email', + 'token', + 'otp', + 'secret', + 'session', + 'refresh' +]; + +/** + * Strips sensitive fields (password, provider secrets) from a user document for API responses. + * @param {Object} userDoc - Raw user document + * @param {Object} usersColConfig - Users collection config + * @returns {Object} Sanitized user object safe for public exposure + */ +const sanitizePublicProfile = (userDoc, usersColConfig) => { + const result = { _id: userDoc._id }; + const schemaKeys = (usersColConfig?.model || []).map((f) => f?.key).filter(Boolean); + + for (const key of schemaKeys) { + const lowered = String(key).toLowerCase(); + const isSensitive = SENSITIVE_PROFILE_KEYS.some((needle) => lowered.includes(needle)); + if (isSensitive) continue; + if (userDoc[key] !== undefined) { + result[key] = userDoc[key]; + } + } + + if (userDoc.createdAt) result.createdAt = userDoc.createdAt; + if (userDoc.updatedAt) result.updatedAt = userDoc.updatedAt; + return result; +}; + +/** + * Initiates the social authentication flow for a given provider. + * Generates a secure state, stores it in Redis, and redirects the user to the provider's OAuth page. + * Requires x-api-key header or ?key query param and auth enabled on the project. + * @route GET /api/userAuth/social/:provider/start + */ +module.exports.startSocialAuth = async (req, res, next) => { + try { + const provider = String(req.params.provider || '').trim().toLowerCase(); + if (!SOCIAL_PROVIDER_KEYS.includes(provider)) { + return next(new AppError(404, 'Unsupported social auth provider')); + } + + assertAuthProjectReady(req.project); + + const { project, providerConfig } = await getSocialProviderConfig(req.project._id, provider); + if (!project || !providerConfig) { + return next(new AppError(422, 'Provider not configured', `${provider} social auth is disabled or incomplete for this project.`)); + } + + const state = crypto.randomBytes(24).toString('hex'); + await redis.set( + getSocialStateKey(state), + JSON.stringify({ + projectId: String(project._id), + provider, + callbackUrl: getFrontendCallbackBaseUrl(project), + }), + 'EX', + SOCIAL_STATE_TTL_SECONDS + ); + + const authUrl = socialProviders[provider].buildAuthorizeUrl({ + clientId: providerConfig.clientId, + redirectUri: providerConfig.redirectUri, + state, + }); + + return res.redirect(authUrl); + } catch (err) { + return next(new AppError(err.statusCode || 500, err.publicMessage || err.message)); + } +}; + +/** + * Handles the OAuth provider callback. Validates state, exchanges code, resolves/creates user, + * issues auth tokens, and redirects to the frontend callback URL. + * @route GET /api/userAuth/social/:provider/callback + */ +module.exports.handleSocialAuthCallback = async (req, res, next) => { + // Helper to redirect error to frontend instead of returning JSON + const redirectWithError = (callbackUrl, errorMessage) => { + try { + const url = new URL(callbackUrl); + url.searchParams.set('error', errorMessage); + return res.redirect(url.toString()); + } catch { + // Fallback if URL is malformed + return next(new AppError(400, errorMessage)); + } + }; + + // P2: Check for provider-side OAuth errors first and forward them + const providerError = String(req.query.error || '').trim(); + const providerErrorDesc = String(req.query.error_description || '').trim(); + + let parsedState = null; + let callbackUrl = null; + + // Try to parse state to get callback URL (even if there's an error) + const state = String(req.query.state || '').trim(); + if (state) { + const stateKey = getSocialStateKey(state); + const rawState = await redis.get(stateKey); + if (rawState) { + try { + parsedState = JSON.parse(rawState); + callbackUrl = parsedState.callbackUrl || getFrontendCallbackBaseUrl({ siteUrl: '' }); + } catch { + // Ignore parse errors here + } + // Cleanup state even on error + await redis.del(stateKey); + } + } + + // If provider returned an error, redirect it to frontend + if (providerError) { + const errorMsg = providerErrorDesc || providerError || 'OAuth provider returned an error'; + if (callbackUrl) { + return redirectWithError(callbackUrl, errorMsg); + } + // No callback URL available - return JSON as fallback + return next(new AppError(400, errorMsg)); + } + + try { + const provider = String(req.params.provider || '').trim().toLowerCase(); + if (!SOCIAL_PROVIDER_KEYS.includes(provider)) { + return next(new AppError(404, 'Unsupported social auth provider')); + } + + const code = String(req.query.code || '').trim(); + if (!code || !state) { + const errorMsg = 'Missing code or state'; + if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); + return next(new AppError(400, errorMsg)); + } + + if (!parsedState) { + return next(new AppError(400, 'Invalid or expired OAuth state')); + } + + if (parsedState.provider !== provider || !parsedState.projectId) { + const errorMsg = 'OAuth state mismatch'; + if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); + return next(new AppError(400, errorMsg)); + } + + // Update callbackUrl from parsed state + callbackUrl = parsedState.callbackUrl || callbackUrl; + + const { project, providerConfig } = await getSocialProviderConfig(parsedState.projectId, provider); + if (!project || !providerConfig) { + const errorMsg = `${provider} social auth is disabled or incomplete for this project.`; + if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); + return next(new AppError(422, 'Provider not configured', errorMsg)); + } + + // Use project's callback URL + callbackUrl = parsedState.callbackUrl || getFrontendCallbackBaseUrl(project); + + const usersColConfig = assertAuthProjectReady(project); + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const tokenResponse = await socialProviders[provider].exchangeCodeForToken({ + code, + clientId: providerConfig.clientId, + clientSecret: providerConfig.clientSecret, + redirectUri: providerConfig.redirectUri, + }); + + const profile = await socialProviders[provider].fetchProfile({ + tokenResponse, + providerConfig, + }); + const { user, isNewUser, linkedByEmail } = await findOrCreateSocialUser({ + project, + usersColConfig, + Model, + provider, + profile, + }); + + const issuedTokens = await issueAuthTokens({ + project, + userId: user._id, + req, + res, + }); + const rtCode = crypto.randomBytes(16).toString('hex'); + await redis.set( + getSocialRefreshExchangeKey(rtCode), + JSON.stringify({ + token: issuedTokens.accessToken, + refreshToken: issuedTokens.refreshToken, + }), + 'EX', + SOCIAL_REFRESH_EXCHANGE_TTL_SECONDS + ); + + const successUrl = new URL(callbackUrl); + const fragmentParams = new URLSearchParams( + successUrl.hash.startsWith('#') ? successUrl.hash.slice(1) : successUrl.hash + ); + fragmentParams.set('token', issuedTokens.accessToken); + + successUrl.searchParams.set('rtCode', rtCode); + successUrl.searchParams.set('provider', provider); + successUrl.searchParams.set('userId', String(user._id)); + successUrl.searchParams.set('projectId', String(project._id)); + successUrl.searchParams.set('isNewUser', String(isNewUser)); + successUrl.searchParams.set('linkedByEmail', String(linkedByEmail)); + successUrl.hash = fragmentParams.toString(); + + return res.redirect(successUrl.toString()); + } catch (err) { + const errorMsg = err.publicMessage || err.message || 'Social authentication failed'; + if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); + return next(new AppError(err.statusCode || 500, errorMsg)); + } +}; + +/** + * Exchanges a one-time rtCode (from social auth callback) for a refresh token. + * Completes the OAuth token flow initiated by handleSocialAuthCallback. + * @route POST /api/userAuth/social/exchange + */ +module.exports.exchangeSocialRefreshToken = async (req, res, next) => { + try { + const rtCode = String(req.body?.rtCode || '').trim(); + const token = String(req.body?.token || '').trim(); + + if (!rtCode || !token) { + return next(new AppError(400, 'rtCode and token are required')); + } + + const exchangeKey = getSocialRefreshExchangeKey(rtCode); + const rawExchange = await redis.get(exchangeKey); + if (!rawExchange) { + return next(new AppError(400, 'Invalid or expired refresh token exchange code')); + } + + let parsedExchange; + try { + parsedExchange = JSON.parse(rawExchange); + } catch (err) { + await redis.del(exchangeKey); + return next(new AppError(400, 'Invalid or expired refresh token exchange code')); + } + + if (parsedExchange.token !== token || !parsedExchange.refreshToken) { + await redis.del(exchangeKey); + return next(new AppError(403, 'Invalid refresh token exchange payload')); + } + + await redis.del(exchangeKey); + return new ApiResponse({ + refreshToken: parsedExchange.refreshToken, + }, 'Refresh token exchanged successfully',).send(res, 200); + } catch (err) { + return res.status(500).json({ + success: false, + message: err.message || 'Failed to exchange refresh token', + }); + } +}; + + +/** + * Handles traditional email/password user registration. + * Hashes the password and triggers a verification email if mandatory. + * @route POST /api/userAuth/signup + */ +module.exports.signup = async (req, res, next) => { + try { + const project = req.project; + + const { email, password, username, ...otherData } = userSignupSchema.parse(req.body); + const normalizedEmail = email.toLowerCase().trim(); + + // Get Mongoose Model + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const existingUser = await Model.findOne({ email: normalizedEmail }); + + if (existingUser) { + // Check if user is unverified. If so, we can trigger a resend instead of a hard error. + const verificationField = getVerificationField(usersColConfig); + const isVerified = verificationField ? !!existingUser[verificationField] : true; + + if (!isVerified) { + // Check cooldown + try { + await checkPublicOtpCooldown(project._id, normalizedEmail, 'verification'); + } catch (cooldownErr) { + return next(new AppError(cooldownErr.statusCode || 429, cooldownErr.message)); + } + + const otp = crypto.randomInt(100000, 1000000).toString(); + await redis.set(`project:${project._id}:otp:verification:${normalizedEmail}`, otp, 'EX', 300); + await setPublicOtpCooldown(project._id, normalizedEmail, 'verification'); + + await authEmailQueue.add('send-verification-email', { + email, + otp, + type: 'verification', + pname: project.name, + projectId: String(project._id) + }); + + return res.status(200).json({ + success: true, + message: "User already exists but is unverified. A new verification code has been sent.", + userId: existingUser._id + }); + } + + return next(new AppError(400, "User already exists with this email.")); + } + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + const otp = crypto.randomInt(100000, 1000000).toString(); + + const newUserPayload = buildAuthUserPayload( + usersColConfig, + { email, password, username, ...otherData }, + hashedPassword, + false + ); + + // Model.create handles validation and default values + const result = await Model.create(newUserPayload); + + try { + // Fail-open: if Redis is unavailable, do not block successful signup. + await clearLockout(String(project._id), normalizedEmail); + } catch (lockErr) { + console.error('[login-lockout] clearLockout failed after signup:', lockErr?.message || lockErr); + } + + await redis.set(`project:${project._id}:otp:verification:${normalizedEmail}`, otp, 'EX', 300); + await setPublicOtpCooldown(project._id, normalizedEmail, 'verification'); + + await authEmailQueue.add('send-verification-email', { + email, + otp, + type: 'verification', + pname: project.name, + projectId: String(project._id) + }); + + + const issuedTokens = await issueAuthTokens({ + project, + userId: result._id, + req, + res + }); + + res.status(201).json({ + message: "User registered successfully. Please verify your email.", + token: issuedTokens.accessToken, + accessToken: issuedTokens.accessToken, + expiresIn: issuedTokens.expiresIn, + ...(shouldExposeRefreshToken(req) ? { refreshToken: issuedTokens.refreshToken } : {}), + userId: result._id + }); + + } catch (err) { + if (err instanceof z.ZodError) { + return next(new AppError(400, err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed")); + } + return next(new AppError(500, err.message)); + console.log(err) + } +} + +/** + * Authenticates a user with email and password. + * Issues access and refresh tokens upon successful authentication. + * @route POST /api/userAuth/login + */ +module.exports.login = async (req, res, next) => { + const sendAuthError = (statusCode, message) => { + if (typeof next === 'function') { + return next(new AppError(statusCode, message)); + } + // Fallback for direct responses: use the project's standard API envelope + return next(new AppError(statusCode, message)); + }; + + const sendLockoutServiceError = (message = 'Login lockout service unavailable') => sendAuthError(503, message); + + try { + const project = req.project; + const { email, password } = loginSchema.parse(req.body); + const normalizedEmail = email.toLowerCase().trim(); + const projectId = String(project._id); + + let lockStatus = { locked: false, retryAfterSeconds: 0 }; + try { + lockStatus = await checkLockout(projectId, normalizedEmail); + } catch (lockErr) { + console.error('[login-lockout] checkLockout failed:', lockErr?.message || lockErr); + return sendLockoutServiceError(); + } + + if (lockStatus.locked) { + return sendAuthError(423, `Account temporarily locked. Try again in ${lockStatus.retryAfterSeconds} seconds.`); + } + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const user = await Model.findOne({ email: normalizedEmail }).select('+password'); + + if (!user) { + let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; + try { + failedStatus = await recordFailedAttempt(projectId, normalizedEmail); + } catch (attemptErr) { + console.error('[login-lockout] recordFailedAttempt failed (user missing):', attemptErr?.message || attemptErr); + return sendLockoutServiceError(); + } + + if (failedStatus.locked) { + return sendAuthError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`); + } + return sendAuthError(400, 'Invalid email or password'); + } + + const validPass = await bcrypt.compare(password, user.password); + if (!validPass) { + let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; + try { + failedStatus = await recordFailedAttempt(projectId, normalizedEmail); + } catch (attemptErr) { + console.error('[login-lockout] recordFailedAttempt failed (invalid password):', attemptErr?.message || attemptErr); + return sendLockoutServiceError(); + } + + if (failedStatus.locked) { + return sendAuthError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`); + } + return sendAuthError(400, 'Invalid email or password'); + } + + try { + // Fail-open: if Redis is unavailable, do not block successful login. + await clearLockout(projectId, normalizedEmail); + } catch (clearErr) { + console.error('[login-lockout] clearLockout failed:', clearErr?.message || clearErr); + } + + const issuedTokens = await issueAuthTokens({ + project, + userId: user._id, + req, + res + }); + + res.json({ + token: issuedTokens.accessToken, + accessToken: issuedTokens.accessToken, + expiresIn: issuedTokens.expiresIn, + ...(shouldExposeRefreshToken(req) ? { refreshToken: issuedTokens.refreshToken } : {}) + }); + + } catch (err) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors)); + return next(new AppError(500, err.message)); // Fixed: .json() + } +} + +/** + * Returns the currently authenticated user's profile based on the JWT token. + * @route GET /api/userAuth/me + */ +module.exports.me = async (req, res, next) => { + try { + const project = req.project; + const tokenHeader = req.header('Authorization'); + + if (!tokenHeader) return next(new AppError(401, "Access Denied: No Token Provided")); + + const token = tokenHeader.replace("Bearer ", ""); + + try { + const decoded = jwt.verify(token, project.jwtSecret); + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const user = await Model.findOne( + { _id: new mongoose.Types.ObjectId(decoded.userId) }, + { password: 0 } + ).lean(); + + if (!user) return next(new AppError(404, "User not found")); + + return new ApiResponse({ user }).send(res, 200); + + } catch (err) { + return next(new AppError(401, "Invalid or Expired Token")); + } + + } catch (err) { + return next(new AppError(500, err.message)); + } +} + +// GET PUBLIC PROFILE BY USERNAME +module.exports.publicProfile = async (req, res, next) => { + try { + const project = req.project; + const username = String(req.params.username || '').trim(); + if (!username) return next(new AppError(400, "Username is required")); + + const { usersColConfig, Model } = await getUsersModel(project); + if (!usersColConfig || !Model) return next(new AppError(404, "Auth collection not found")); + + const hasUsernameField = (usersColConfig.model || []).some((f) => String(f?.key || '').trim() === 'username'); + if (!hasUsernameField) { + return next(new AppError(400, "Public profile requires a 'username' field in users schema")); + } + + const user = await Model.findOne({ username }, { password: 0 }).lean(); + if (!user) return next(new AppError(404, "User not found")); + + const profile = sanitizePublicProfile(user, usersColConfig); + return new ApiResponse(profile).send(res, 200); + } catch (err) { + return next(new AppError(500, err.message)); + } +} + +// POST REQ FOR ADMIN CREATE USER +module.exports.createAdminUser = async (req, res, next) => { + try { + const project = req.project; + + const parsedData = userSignupSchema.parse(req.body); + const { email, password, username, ...otherData } = parsedData; + + // Get Mongoose Model + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const existingUser = await Model.findOne({ email }); + if (existingUser) { + return next(new AppError(400, "User already exists with this email.")); + } + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + const newUserPayload = buildAuthUserPayload( + usersColConfig, + { email, password, username, ...otherData }, + hashedPassword, + true + ); + + const result = await Model.create(newUserPayload); + + return new ApiResponse({ user: { _id: result._id, email, username, createdAt: newUserPayload.createdAt } }, "User created successfully").send(res, 201); + + } catch (err) { + if (err instanceof z.ZodError) { + console.error(err); + return next(new AppError(400, err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed")); + } + return next(new AppError(500, err.message)); + } +} + +// PATCH REQ FOR ADMIN RESET PASSWORD +module.exports.resetPassword = async (req, res, next) => { + try { + const project = req.project; + const { userId } = req.params; + + const { newPassword } = req.body; + + if (!newPassword || newPassword.length < 6) { + return next(new AppError(400, "Password must be at least 6 characters")); + } + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(newPassword, salt); + + const result = await Model.updateOne( + { _id: new mongoose.Types.ObjectId(userId) }, + { $set: { password: hashedPassword } } + ); + + if (result.matchedCount === 0) { + return next(new AppError(404, "User not found")); + } + + return new ApiResponse(null, "Password updated successfully").send(res, 200); + + } catch (err) { + return next(new AppError(500, err.message)); + } +} + +// POST REQ FOR EMAIL VERIFICATION +module.exports.verifyEmail = async (req, res, next) => { + try { + const project = req.project; + const { email, otp } = verifyOtpSchema.parse(req.body); + const normalizedEmail = email.toLowerCase().trim(); + + const redisKey = `project:${project._id}:otp:verification:${normalizedEmail}`; + const storedOtp = await redis.get(redisKey); + + if (!storedOtp || storedOtp !== otp) { + + return next(new AppError(400, "Invalid or expired OTP")); + } + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const verificationField = getVerificationField(usersColConfig); + if (!verificationField) { + return next(new AppError(500, "No verification field found in users schema")); + } + const result = await Model.updateOne( + { email }, + { $set: { [verificationField]: true } } + ); + + if (result.matchedCount === 0) return next(new AppError(404, "User not found")); + + await redis.del(redisKey); + return new ApiResponse(null, "Email verified successfully").send(res, 200); + + } catch (err) { + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + return next(new AppError(500, err.message)); + } +}; + +// RESEND VERIFICATION OTP +module.exports.resendVerificationOtp = async (req, res, next) => { + try { + const project = req.project; + const { email } = onlyEmailSchema.parse(req.body); + const normalizedEmail = email.toLowerCase().trim(); + + const { usersColConfig, Model } = await getUsersModel(project); + if (!Model) return next(new AppError(404, "Auth collection not found")); + + const user = await Model.findOne({ email: normalizedEmail }); + + if (!user) { + return next(new AppError(400, "User not found with this email.")); + } + + const verificationField = getVerificationField(usersColConfig); + const isVerified = verificationField ? !!user[verificationField] : true; + + if (isVerified) { + return next(new AppError(400, "Email is already verified.")); + } + + // Check cooldown + try { + await checkPublicOtpCooldown(project._id, normalizedEmail, 'verification'); + } catch (cooldownErr) { + return next(new AppError(cooldownErr.statusCode || 429, cooldownErr.message)); + } + + const otp = crypto.randomInt(100000, 1000000).toString(); + await redis.set(`project:${project._id}:otp:verification:${normalizedEmail}`, otp, 'EX', 300); + await setPublicOtpCooldown(project._id, normalizedEmail, 'verification'); + + await authEmailQueue.add('send-verification-email', { + email, + otp, + type: 'verification', + pname: project.name, + projectId: String(project._id) + }); + + return new ApiResponse(null, "Verification code sent successfully.").send(res, 200); + + } catch (err) { + if (err instanceof z.ZodError) return next(new AppError(400, err.errors)); + return next(new AppError(500, err.message)); + } +}; + + +// POST REQ FOR PASSWORD RESET REQUEST +module.exports.requestPasswordReset = async (req, res, next) => { + try { + const project = req.project; + const { email } = onlyEmailSchema.parse(req.body); + const normalizedEmail = email.toLowerCase().trim(); + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + try { + await checkPublicOtpCooldown(project._id, normalizedEmail, 'reset'); + } catch (cooldownErr) { + return next(new AppError(cooldownErr.statusCode || 429, cooldownErr.message)); + } + + const user = await Model.findOne({ email: normalizedEmail }); + if (!user) { + await setPublicOtpCooldown(project._id, normalizedEmail, 'reset'); + return new ApiResponse(null, "If that email exists, a reset code has been sent.").send(res, 200); + } + + const otp = crypto.randomInt(100000, 1000000).toString(); + await redis.set(`project:${project._id}:otp:reset:${normalizedEmail}`, otp, 'EX', 300); + await setPublicOtpCooldown(project._id, normalizedEmail, 'reset'); + + await authEmailQueue.add('send-reset-email', { email, otp, type: 'password_reset', pname: project.name, projectId: String(project._id) }); + + return new ApiResponse(null, "If that email exists, a reset code has been sent.").send(res, 200); + } catch (err) { + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + return next(new AppError(500, err.message)); + } +}; + +// POST REQ FOR PASSWORD RESET CONFIRMATION +module.exports.resetPasswordUser = async (req, res, next) => { + try { + const project = req.project; + const { email, otp, newPassword } = resetPasswordSchema.parse(req.body); + const normalizedEmail = email.toLowerCase().trim(); + + const redisKey = `project:${project._id}:otp:reset:${normalizedEmail}`; + const storedOtp = await redis.get(redisKey); + + if (!storedOtp || storedOtp !== otp) { + return next(new AppError(400, "Invalid or expired OTP")); + } + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(newPassword, salt); + + const { Model: collection } = await getUsersModel(project); + if (!collection) return next(new AppError(404, "Auth collection not found")); + + const result = await collection.updateOne( + { email: normalizedEmail }, + { $set: { password: hashedPassword } } + ); + + if (result.matchedCount === 0) return next(new AppError(404, "User not found")); + + try { + // Fail-open: if Redis is unavailable, do not block password recovery success. + await clearLockout(String(project._id), normalizedEmail); + } catch (lockErr) { + console.error('[login-lockout] clearLockout failed after password reset:', lockErr?.message || lockErr); + } + + await redis.del(redisKey); + return new ApiResponse(null, "Password updated successfully").send(res, 200); + + } catch (err) { + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + return next(new AppError(500, err.message)); + } +}; + +// PATCH REQ FOR UPDATE PROFILE +module.exports.updateProfile = async (req, res, next) => { + try { + const project = req.project; + + const tokenHeader = req.header('Authorization'); + if (!tokenHeader) return next(new AppError(401, "Access Denied: No Token Provided")); + const token = tokenHeader.replace("Bearer ", ""); + + let decoded; + try { + decoded = jwt.verify(token, project.jwtSecret); + } catch (err) { + return next(new AppError(401, "Access Denied: Invalid or expired token")); + } + + const updateData = { ...req.body }; + delete updateData.password; + delete updateData.email; + delete updateData._id; + + if (updateData.username !== undefined) { + const username = updateData.username; + if (typeof username !== 'string' || username.length < 3 || username.length > 50) { + return next(new AppError(400, "Username must be between 3 and 50 characters.")); + } + } + + const sanitizedUpdateData = sanitize(updateData); + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const result = await Model.updateOne( + { _id: new mongoose.Types.ObjectId(decoded.userId) }, + { $set: sanitizedUpdateData }, + { runValidators: true } + ); + + return new ApiResponse(null, "Profile updated successfully").send(res, 200); + + } catch (err) { + return next(new AppError(500, err.message)); + } +}; + +// POST REQ FOR CHANGE PASSWORD +module.exports.changePasswordUser = async (req, res, next) => { + try { + const project = req.project; + + const tokenHeader = req.header('Authorization'); + if (!tokenHeader) return next(new AppError(401, "Access Denied: No Token Provided")); + const token = tokenHeader.replace("Bearer ", ""); + + let decoded; + try { + decoded = jwt.verify(token, project.jwtSecret); + } catch (err) { + return next(new AppError(401, "Access Denied: Invalid or expired token")); + } + + const { currentPassword, newPassword } = changePasswordSchema.parse(req.body); + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const user = await Model.findOne({ _id: new mongoose.Types.ObjectId(decoded.userId) }); + if (!user) return next(new AppError(404, "User not found")); + + const validPass = await bcrypt.compare(currentPassword, user.password); + if (!validPass) return next(new AppError(400, "Invalid current password")); + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(newPassword, salt); + + await Model.updateOne( + { _id: new mongoose.Types.ObjectId(decoded.userId) }, + { $set: { password: hashedPassword } } + ); + + return new ApiResponse(null, "Password changed successfully").send(res, 200); + + } catch (err) { + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + return next(new AppError(500, err.message)); + } +}; + +module.exports.refreshToken = async (req, res, next) => { + try { + const rawRefreshToken = readRefreshTokenFromRequest(req); + if (!rawRefreshToken) { + clearRefreshCookie(res); + return next(new AppError(401, 'Refresh token missing')); + } + + const parsedToken = parseRefreshToken(rawRefreshToken); + if (!parsedToken) { + clearRefreshCookie(res); + return next(new AppError(401, 'Invalid refresh token format')); + } + + const earlyRateLimit = await assertRefreshRateLimits({ req, tokenId: parsedToken.tokenId }); + if (earlyRateLimit.limited) { + clearRefreshCookie(res); + return next(new AppError(429, earlyRateLimit.message)); + } + + const session = await getRefreshSession(parsedToken.tokenId); + if (!session) { + clearRefreshCookie(res); + return next(new AppError(401, 'Refresh session not found')); + } + + if (String(req.project._id) !== String(session.projectId)) { + return next(new AppError(403, 'Refresh token does not belong to this project')); + } + + const rateResult = await assertRefreshRateLimits({ req, tokenId: session.tokenId, userId: session.userId }); + if (rateResult.limited) { + clearRefreshCookie(res); + return next(new AppError(429, rateResult.message)); + } + + const now = Date.now(); + const isExpired = new Date(session.expiresAt).getTime() <= now; + if (session.revokedAt || session.isUsed || isExpired) { + await revokeSessionChain(session.tokenId); + clearRefreshCookie(res); + return next(new AppError(403, 'Refresh token is invalid or already used')); + } + + if (hashRefreshToken(rawRefreshToken) !== session.tokenHash) { + await revokeSessionChain(session.tokenId); + clearRefreshCookie(res); + return next(new AppError(403, 'Refresh token mismatch')); + } + + session.isUsed = true; + session.lastUsedAt = new Date().toISOString(); + await persistRefreshSession(session); + + const project = await Project.findById(session.projectId) + .select('name resources collections jwtSecret isAuthEnabled') + .lean(); + + if (!project || !project.isAuthEnabled) { + await revokeSessionChain(session.tokenId); + clearRefreshCookie(res); + return next(new AppError(401, 'Project auth is unavailable')); + } + + const { Model } = await getUsersModel(project); + if (!Model) { + await revokeSessionChain(session.tokenId); + clearRefreshCookie(res); + return next(new AppError(404, 'Auth collection not found')); + } + + const user = await Model.findOne( + { _id: new mongoose.Types.ObjectId(session.userId) }, + { _id: 1 } + ).lean(); + if (!user) { + await revokeSessionChain(session.tokenId); + clearRefreshCookie(res); + return next(new AppError(401, 'User not found for refresh token')); + } + + const newTokens = await issueAuthTokens({ + project, + userId: user._id, + req, + res, + rotatedFrom: session.tokenId + }); + + session.rotatedTo = newTokens.tokenId; + session.lastUsedAt = new Date().toISOString(); + await persistRefreshSession(session); + + const usedHeaderToken = !!req.header('x-refresh-token'); + return res.status(200).json({ + token: newTokens.accessToken, + accessToken: newTokens.accessToken, + expiresIn: newTokens.expiresIn, + ...(usedHeaderToken ? { refreshToken: newTokens.refreshToken } : {}) + }); + } catch (err) { + clearRefreshCookie(res); + return next(new AppError(500, err.message)); + } +}; + +module.exports.logout = async (req, res, next) => { + try { + const rawRefreshToken = readRefreshTokenFromRequest(req); + if (rawRefreshToken) { + const parsedToken = parseRefreshToken(rawRefreshToken); + if (parsedToken) { + const session = await getRefreshSession(parsedToken.tokenId); + if (session && hashRefreshToken(rawRefreshToken) === session.tokenHash) { + if (String(req.project._id) !== String(session.projectId)) { + return next(new AppError(403, 'Refresh token does not belong to this project')); + } + session.revokedAt = new Date().toISOString(); + session.isUsed = true; + session.lastUsedAt = new Date().toISOString(); + await persistRefreshSession(session); + } + } + } + + clearRefreshCookie(res); + return new ApiResponse(null, 'Logged out successfully').send(res, 200); + } catch (err) { + clearRefreshCookie(res); + return next(new AppError(500, err.message)); + } +}; + +// FUNCTION - GET USER DETAILS (ADMIN) +module.exports.getUserDetails = async (req, res, next) => { + try { + const project = req.project; + const { userId } = req.params; + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const user = await Model.findOne( + { _id: new mongoose.Types.ObjectId(userId) }, + { password: 0 } + ).lean(); + if (!user) return next(new AppError(404, "User not found")); + + return new ApiResponse({ user }).send(res, 200); + } catch (err) { + return next(new AppError(500, err.message)); + } +}; + +// PUT REQ FOR UPDATE ADMIN USER +module.exports.updateAdminUser = async (req, res, next) => { + try { + const project = req.project; + const { userId } = req.params; + const updateData = req.body; + + delete updateData.password; + delete updateData._id; + + const sanitizedUpdateData = sanitize(updateData); + + // Get Mongoose Model + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const result = await Model.updateOne( + { _id: new mongoose.Types.ObjectId(userId) }, + { $set: sanitizedUpdateData }, + { runValidators: true } + ); + + if (result.matchedCount === 0) { + return next(new AppError(404, "User not found")); + } + + return new ApiResponse(null, "User updated successfully").send(res, 200); + } catch (err) { + return next(new AppError(500, err.message)); + } +}; +>>>>>>> Stashed changes diff --git a/apps/public-api/src/middlewares/authorizeReadOperation.js b/apps/public-api/src/middlewares/authorizeReadOperation.js index 0554887ac..09c84dcba 100644 --- a/apps/public-api/src/middlewares/authorizeReadOperation.js +++ b/apps/public-api/src/middlewares/authorizeReadOperation.js @@ -1,3 +1,4 @@ +const { AppError } = require('@urbackend/common'); module.exports = async (req, res, next) => { try { if (req.keyRole === 'secret') { @@ -10,7 +11,7 @@ module.exports = async (req, res, next) => { const collectionConfig = project.collections.find(c => c.name === collectionName); if (!collectionConfig) { - return res.status(404).json({ error: 'Collection not found' }); + return next(new AppError(404, 'Collection not found')); } const rls = collectionConfig.rls || {}; @@ -24,10 +25,7 @@ module.exports = async (req, res, next) => { if (mode === 'private') { if (!req.authUser?.userId) { - return res.status(401).json({ - error: 'Authentication required', - message: 'Provide a valid user Bearer token for private reads.' - }); + return next(new AppError(401, 'Provide a valid user Bearer token for private reads.', 'Authentication required')); } const ownerField = rls.ownerField || 'userId'; @@ -40,8 +38,8 @@ module.exports = async (req, res, next) => { return next(); } - return res.status(403).json({ error: 'Unsupported RLS mode' }); + return next(new AppError(403, 'Unsupported RLS mode')); } catch (err) { - return res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message, 'Internal Server Error')); } }; diff --git a/apps/public-api/src/middlewares/authorizeWriteOperation.js b/apps/public-api/src/middlewares/authorizeWriteOperation.js index 2078541f4..0f92c7ded 100644 --- a/apps/public-api/src/middlewares/authorizeWriteOperation.js +++ b/apps/public-api/src/middlewares/authorizeWriteOperation.js @@ -1,3 +1,4 @@ +const { AppError } = require('@urbackend/common'); const { getConnection } = require('@urbackend/common'); const { getCompiledModel } = require('@urbackend/common'); const mongoose = require('mongoose'); @@ -13,40 +14,28 @@ module.exports = async (req, res, next) => { const collectionConfig = project.collections.find(c => c.name === collectionName); if (!collectionConfig) { - return res.status(404).json({ success: false, error: 'Collection not found', message: 'The requested collection does not exist.' }); + return next(new AppError(404, 'The requested collection does not exist.', 'Collection not found')); } const rls = collectionConfig.rls || {}; if (!rls.enabled) { - return res.status(403).json({ - success: false, - error: 'Write blocked for publishable key', - message: 'Enable RLS for this collection to allow publishable-key writes.' - }); + return next(new AppError(403, 'Enable RLS for this collection to allow publishable-key writes.', 'Write blocked for publishable key')); } if (rls.requireAuthForWrite && !req.authUser?.userId) { - return res.status(401).json({ - success: false, - error: 'Authentication required', - message: 'Provide a valid user Bearer token for write operations.' - }); + return next(new AppError(401, 'Provide a valid user Bearer token for write operations.', 'Authentication required')); } const modeRaw = rls.mode || 'public-read'; const allowedModes = new Set(['public-read', 'private', 'owner-write-only']); if (!allowedModes.has(modeRaw)) { - return res.status(403).json({ success: false, error: 'Unsupported RLS mode', message: 'The collection RLS mode is invalid.' }); + return next(new AppError(403, 'The collection RLS mode is invalid.', 'Unsupported RLS mode')); } const ownerField = rls.ownerField || 'userId'; if (!req.authUser?.userId) { - return res.status(401).json({ - success: false, - error: 'Authentication required', - message: 'Provide a valid user Bearer token for write operations.' - }); + return next(new AppError(401, 'Provide a valid user Bearer token for write operations.', 'Authentication required')); } const authUserId = String(req.authUser.userId); @@ -54,32 +43,20 @@ module.exports = async (req, res, next) => { if (method === 'POST') { if (ownerField === '_id') { - return res.status(403).json({ - success: false, - error: 'Insert denied', - message: "RLS ownerField '_id' is not valid for insert ownership checks." - }); + return next(new AppError(403, "RLS ownerField '_id' is not valid for insert ownership checks.", 'Insert denied')); } const bodyItems = Array.isArray(req.body) ? req.body : [req.body]; if (bodyItems.length === 0) { - return res.status(400).json({ - success: false, - error: 'Invalid request body', - message: 'Request body cannot be an empty array.' - }); + return next(new AppError(400, 'Request body cannot be an empty array.', 'Invalid request body')); } for (let i = 0; i < bodyItems.length; i++) { const item = bodyItems[i]; if (!item || typeof item !== 'object' || Array.isArray(item)) { - return res.status(400).json({ - success: false, - error: 'Invalid request body', - message: `Item at index ${i} must be a valid object` - }); + return next(new AppError(400, `Item at index ${i} must be a valid object`, 'Invalid request body')); } const incomingOwner = item?.[ownerField]; @@ -90,11 +67,7 @@ for (let i = 0; i < bodyItems.length; i++) { } if (String(incomingOwner) !== authUserId) { - return res.status(403).json({ - success: false, - error: 'RLS owner mismatch', - message: `Item at index ${i} must have ${ownerField} equal to your user id` - }); + return next(new AppError(403, `Item at index ${i} must have ${ownerField} equal to your user id`, 'RLS owner mismatch')); } } @@ -103,7 +76,7 @@ for (let i = 0; i < bodyItems.length; i++) { if (method === 'PUT' || method === 'PATCH' || method === 'DELETE') { if (!id || !mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ success: false, error: 'Invalid ID format.', message: 'The provided document ID is not valid.' }); + return next(new AppError(400, 'The provided document ID is not valid.', 'Invalid ID format')); } req.rlsFilter = { [ownerField]: authUserId }; @@ -114,11 +87,7 @@ for (let i = 0; i < bodyItems.length; i++) { Object.prototype.hasOwnProperty.call(req.body, ownerField) && String(req.body[ownerField]) !== authUserId ) { - return res.status(403).json({ - success: false, - error: 'Owner field immutable', - message: `${ownerField} cannot be changed under RLS.` - }); + return next(new AppError(403, `${ownerField} cannot be changed under RLS.`, 'Owner field immutable')); } } @@ -127,6 +96,6 @@ for (let i = 0; i < bodyItems.length; i++) { return next(); } catch (err) { - return res.status(500).json({ success: false, error: 'Internal Server Error', message: err.message }); + return next(new AppError(500, err.message, 'Internal Server Error')); } }; \ No newline at end of file diff --git a/apps/public-api/src/middlewares/blockUsersCollectionDataAccess.js b/apps/public-api/src/middlewares/blockUsersCollectionDataAccess.js index bad9e24bb..79a9e914c 100644 --- a/apps/public-api/src/middlewares/blockUsersCollectionDataAccess.js +++ b/apps/public-api/src/middlewares/blockUsersCollectionDataAccess.js @@ -1,11 +1,9 @@ +const { AppError } = require('@urbackend/common'); module.exports = (req, res, next) => { const collectionName = String(req.params?.collectionName || '').trim().toLowerCase(); if (collectionName === 'users') { - return res.status(403).json({ - error: "Users collection is protected", - message: "Use /api/userAuth endpoints for users collection operations." - }); + return next(new AppError(403, 'Use /api/userAuth endpoints for users collection operations.', 'Users collection is protected')); } return next(); diff --git a/apps/public-api/src/middlewares/projectRateLimiter.js b/apps/public-api/src/middlewares/projectRateLimiter.js index 25823849a..2abbf0a7f 100644 --- a/apps/public-api/src/middlewares/projectRateLimiter.js +++ b/apps/public-api/src/middlewares/projectRateLimiter.js @@ -1,4 +1,5 @@ const rateLimit = require('express-rate-limit'); +const { AppError } = require('@urbackend/common'); const projectRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, @@ -12,10 +13,7 @@ const projectRateLimiter = rateLimit({ }, handler: (req, res, next, options) => { - res.status(options.statusCode).json({ - error: "Too Many Requests", - message: "Project Rate limit exceeded. Please try again later." - }); + next(new AppError(options.statusCode, "Project Rate limit exceeded. Please try again later.", "Too Many Requests")); }, limit: async (req, res) => { diff --git a/apps/public-api/src/middlewares/requireSecretKey.js b/apps/public-api/src/middlewares/requireSecretKey.js index 4cb925c93..14b5632fd 100644 --- a/apps/public-api/src/middlewares/requireSecretKey.js +++ b/apps/public-api/src/middlewares/requireSecretKey.js @@ -1,8 +1,7 @@ +const { AppError } = require('@urbackend/common'); module.exports = (req, res, next) => { if (req.keyRole !== 'secret') { - return res.status(403).json({ - error: "Forbidden. This action requires a Secret Key (sk_live_...)." - }); + return next(new AppError(403, 'Forbidden. This action requires a Secret Key (sk_live_...).')); } next(); }; diff --git a/apps/public-api/src/middlewares/verifyApiKey.js b/apps/public-api/src/middlewares/verifyApiKey.js index 1e97cb191..a2929828a 100644 --- a/apps/public-api/src/middlewares/verifyApiKey.js +++ b/apps/public-api/src/middlewares/verifyApiKey.js @@ -1,3 +1,4 @@ +const { AppError } = require('@urbackend/common'); const {Project} = require('@urbackend/common'); const { hashApiKey } = require('@urbackend/common'); const { @@ -22,7 +23,7 @@ module.exports = async (req, res, next) => { } if (!apiKey) { - return res.status(401).json({ error: 'API key not found' }); + return next(new AppError(401, 'API key not found')); } const isSecret = apiKey.startsWith('sk_live_'); @@ -51,20 +52,14 @@ module.exports = async (req, res, next) => { .lean(); if (!project) { - return res.status(401).json({ - error: "API key is expired or invalid.", - action: "Please use a valid API key or regenerate a new one from the dashboard." - }); + return next(new AppError(401, 'Please use a valid API key or regenerate a new one from the dashboard.', 'API key is expired or invalid.')); } await setProjectByApiKeyCache(hashedApi, project); } if (!project.owner.isVerified) { - return res.status(401).json({ - error: 'Owner not verified', - fix: 'Verify your account on https://urbackend.bitbros.in/dashboard' - }); + return next(new AppError(401, 'Verify your account on https://urbackend.bitbros.in/dashboard', 'Owner not verified')); } if (!project.resources) project.resources = {}; @@ -77,7 +72,7 @@ module.exports = async (req, res, next) => { if (!allowedDomains.includes('*')) { if (!origin) { - return res.status(403).json({ error: "Forbidden: Origin header missing and this key is restricted to specific domains." }); + return next(new AppError(403, 'Forbidden: Origin header missing and this key is restricted to specific domains.')); } try { @@ -100,10 +95,10 @@ module.exports = async (req, res, next) => { }); if (!isAllowed) { - return res.status(403).json({ error: `Forbidden: Origin ${originUrl} is not allowed by this project's CORS policy.` }); + return next(new AppError(403, `Forbidden: Origin ${originUrl} is not allowed by this project's CORS policy.`)); } } catch (err) { - return res.status(400).json({ error: "Invalid Origin header format." }); + return next(new AppError(400, 'Invalid Origin header format.')); } } } @@ -113,6 +108,6 @@ module.exports = async (req, res, next) => { req.keyRole = isSecret ? 'secret' : 'publishable'; next(); } catch (err) { - res.status(500).json({ error: err.message }); + next(new AppError(500, err.message, 'Internal Server Error')); } }; diff --git a/docker-compose.yml b/docker-compose.yml index ce26f0305..b75a80a3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: image: mongo:7 container_name: urbackend_mongo restart: unless-stopped + ports: + - '27017:27017' volumes: - mongo_data:/data/db @@ -13,6 +15,8 @@ services: image: redis:latest container_name: urbackend_redis restart: unless-stopped + ports: + - '6379:6379' volumes: - redis_data:/data diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 62ff043b4..f0507f275 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -114,6 +114,7 @@ const { validateData, validateUpdateData } = require("./utils/validateData"); const sessionManager = require("./utils/session.manager"); const planLimits = require("./utils/planLimits"); const AppError = require("./utils/AppError"); +const ApiResponse = require("./utils/ApiResponse"); const { checkLockout, recordFailedAttempt, clearLockout } = require("./utils/loginLockout"); const { dispatchWebhooks } = require("./utils/webhookDispatcher"); const { getDayKey, getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("./utils/usageCounter"); @@ -200,6 +201,7 @@ module.exports = { ...sessionManager, ...planLimits, AppError, + ApiResponse, getPresignedUploadUrl, verifyUploadedFile, PlatformEvent, diff --git a/packages/common/src/middleware/checkAuthEnabled.js b/packages/common/src/middleware/checkAuthEnabled.js index c1d74aa8c..3481fa792 100644 --- a/packages/common/src/middleware/checkAuthEnabled.js +++ b/packages/common/src/middleware/checkAuthEnabled.js @@ -1,31 +1,24 @@ // FUNCTION - CHECK AUTH ENABLED (MIDDLEWARE) +const AppError = require('../utils/AppError'); + module.exports = (req, res, next) => { const project = req.project; if (!project.isAuthEnabled) { - return res.status(403).json({ - error: "Authentication service is disabled", - message: "Please enable Auth in the urBackend dashboard for this project to use this endpoint." - }); + return next(new AppError(403, "Please enable Auth in the urBackend dashboard for this project to use this endpoint.", "Authentication service is disabled")); } const usersCollection = project.collections?.find(c => c.name === 'users'); if (!usersCollection) { - return res.status(403).json({ - error: "User Schema Missing", - message: "Authentication is enabled, but the 'users' collection schema has not been defined. Please create a 'users' collection in the dashboard to define your custom user fields." - }); + return next(new AppError(403, "Authentication is enabled, but the 'users' collection schema has not been defined. Please create a 'users' collection in the dashboard to define your custom user fields.", "User Schema Missing")); } const hasEmail = usersCollection.model.find(f => f.key === 'email' && f.type === 'String' && f.required); const hasPassword = usersCollection.model.find(f => f.key === 'password' && f.type === 'String' && f.required); if (!hasEmail || !hasPassword) { - return res.status(422).json({ - error: "Invalid Users Schema", - message: "The 'users' collection is missing required 'email' and 'password' string fields. Please fix the schema in the dashboard." - }); + return next(new AppError(422, "The 'users' collection is missing required 'email' and 'password' string fields. Please fix the schema in the dashboard.", "Invalid Users Schema")); } req.usersSchema = usersCollection.model; diff --git a/packages/common/src/middleware/loadProjectForAdmin.js b/packages/common/src/middleware/loadProjectForAdmin.js index 514c8afc7..b7ecead9b 100644 --- a/packages/common/src/middleware/loadProjectForAdmin.js +++ b/packages/common/src/middleware/loadProjectForAdmin.js @@ -1,19 +1,20 @@ // FUNCTION - LOAD PROJECT FOR ADMIN (MIDDLEWARE) const Project = require('../models/Project'); +const AppError = require('../utils/AppError'); module.exports = async (req, res, next) => { try { const { projectId } = req.params; - if (!projectId) return res.status(400).json({ error: "Project ID is required" }); + if (!projectId) return next(new AppError(400, "Project ID is required")); const project = await Project.findOne({ _id: projectId, owner: req.user._id }); if (!project) { - return res.status(404).json({ error: "Project not found or access denied" }); + return next(new AppError(404, "Project not found or access denied")); } req.project = project; next(); } catch (err) { - res.status(500).json({ error: err.message }); + next(new AppError(500, err.message, "Internal Server Error")); } }; diff --git a/packages/common/src/middleware/standardizeApiResponse.js b/packages/common/src/middleware/standardizeApiResponse.js index 292bc16ff..3eee129bf 100644 --- a/packages/common/src/middleware/standardizeApiResponse.js +++ b/packages/common/src/middleware/standardizeApiResponse.js @@ -64,10 +64,24 @@ module.exports = (req, res, next) => { } if (code >= 400) { + let errorTitle = 'Error'; + let errorMessage = toErrorMessage(body); + + if (body && typeof body === 'object') { + if (body.error && typeof body.error === 'string') errorTitle = body.error; + if (body.message && typeof body.message === 'string') errorMessage = body.message; + + // If it only had error and no message, use error as message + if (body.error && !body.message) { + errorTitle = 'Error'; + errorMessage = body.error; + } + } + return originalJson({ success: false, - error: toErrorMessage(body), - code, + error: errorTitle, + message: errorMessage }); } diff --git a/packages/common/src/middleware/verifyEmail.js b/packages/common/src/middleware/verifyEmail.js index b98462830..01fb9e69a 100644 --- a/packages/common/src/middleware/verifyEmail.js +++ b/packages/common/src/middleware/verifyEmail.js @@ -1,5 +1,7 @@ +const AppError = require('../utils/AppError'); + module.exports = function (req, res, next) { const { isVerified } = req.user; - if (!isVerified) return res.status(401).json({ error: "Email not verified" }); + if (!isVerified) return next(new AppError(401, "Email not verified")); next(); }; diff --git a/packages/common/src/utils/ApiResponse.js b/packages/common/src/utils/ApiResponse.js new file mode 100644 index 000000000..7cb5cb6ca --- /dev/null +++ b/packages/common/src/utils/ApiResponse.js @@ -0,0 +1,26 @@ +/** + * Standard utility for consistent API success responses across the monorepo. + * Ensures structure: { success: true, data: {}, message: "" } + */ +class ApiResponse { + constructor(data = {}, message = "Success") { + this.data = data; + this.message = message; + this.success = true; + } + + /** + * Sends a standardized success response + * @param {Object} res - Express response object + * @param {number} statusCode - HTTP status code + */ + send(res, statusCode = 200) { + return res.status(statusCode).json({ + success: this.success, + data: this.data, + message: this.message + }); + } +} + +module.exports = ApiResponse; diff --git a/packages/common/src/utils/AppError.js b/packages/common/src/utils/AppError.js index 8fa9f3406..dccd24f96 100644 --- a/packages/common/src/utils/AppError.js +++ b/packages/common/src/utils/AppError.js @@ -3,10 +3,11 @@ * Ensures consistent error structure: { success: false, data: {}, message: "" } */ class AppError extends Error { - constructor(statusCode, message) { + constructor(statusCode, message, error = null) { super(message); this.statusCode = statusCode; this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; + this.error = error || (statusCode >= 500 ? "Internal Server Error" : "Error"); this.isOperational = true; Error.captureStackTrace(this, this.constructor); From ae6d2a148aa7f6de0c21223d63eb475827e07f6f Mon Sep 17 00:00:00 2001 From: Ayush Date: Tue, 2 Jun 2026 11:56:51 +0530 Subject: [PATCH 2/2] All test are passed --- .../src/__tests__/billing.controller.test.js | 2 +- .../src/controllers/project.controller.js | 81 - .../src/__tests__/mail.controller.test.js | 4 +- .../src/__tests__/userAuth.refresh.test.js | 8 +- .../src/__tests__/userAuth.social.test.js | 37 +- .../src/controllers/data.controller.js | 11 - .../src/controllers/userAuth.controller.js | 1833 +---------------- 7 files changed, 26 insertions(+), 1950 deletions(-) diff --git a/apps/dashboard-api/src/__tests__/billing.controller.test.js b/apps/dashboard-api/src/__tests__/billing.controller.test.js index 5c676fbdb..cdaa37b52 100644 --- a/apps/dashboard-api/src/__tests__/billing.controller.test.js +++ b/apps/dashboard-api/src/__tests__/billing.controller.test.js @@ -36,7 +36,7 @@ jest.mock('razorpay', () => { })); }); -const { Developer, ProRequest, sendProRequestConfirmationEmail } = require('@urbackend/common'); +const { Developer, ProRequest, sendProRequestConfirmationEmail, AppError } = require('@urbackend/common'); const controller = require('../controllers/billing.controller'); describe('Billing Controller', () => { diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 45023d6e6..93380f778 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -408,12 +408,8 @@ module.exports.getSingleProject = async (req, res, next) => { "+resendApiKey.iv " + "+resendApiKey.tag", ); -<<<<<<< Updated upstream - if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); -======= if (!project) return next(new AppError(404, "Project not found.")); ->>>>>>> Stashed changes projectObj = project.toObject(); await setProjectById(req.params.projectId, projectObj); } @@ -637,12 +633,8 @@ module.exports.deleteExternalStorageConfig = async (req, res, next) => { } }; -<<<<<<< Updated upstream -module.exports.createCollection = async (req, res) => { -======= // POST REQ FOR CREATE COLLECTION module.exports.createCollection = async (req, res, next) => { ->>>>>>> Stashed changes const executeOperation = async (session) => { const { projectId, collectionName, schema } = createCollectionSchema.parse(req.body); @@ -775,20 +767,6 @@ module.exports.createCollection = async (req, res, next) => { } }; -<<<<<<< Updated upstream -// GET DOC BY ID — FIXED: added limitFields(), populate(), cursor pagination, count support, structured response -module.exports.getData = async (req, res) => { - try { - const { projectId, collectionName } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); - - - - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) { - return res.status(404).json({ success: false, data: {}, message: `Collection ${collectionName} not found.` }); -======= // GET DOC BY ID module.exports.getData = async (req, res, next) => { try { @@ -799,7 +777,6 @@ module.exports.getData = async (req, res, next) => { const collectionConfig = project.collections.find(c => c.name === collectionName); if (!collectionConfig) { return next(new AppError(404, "Collection not found")); ->>>>>>> Stashed changes } const connection = await getConnection(projectId); @@ -847,46 +824,6 @@ module.exports.getData = async (req, res, next) => { const data = await features.query.lean(); -<<<<<<< Updated upstream - // Cursor: slice to limit and generate next cursor token - let items = data; - let nextCursor = null; - if (useCursor) { - const limit = Math.min(parseInt(req.query.limit, 10) || 100, 100); - features.generateNextCursor(data, limit); - items = data.slice(0, limit); - nextCursor = features.nextCursor; - } - - const responseMeta = useCursor - ? { - total, - cursor: req.query.cursor || null, - nextCursor, - limit: Math.max(1, Math.min(parseInt(req.query.limit, 10) || 100, 100)), - } - : { - total, - page: parseInt(req.query.page, 10) || 1, - limit: Math.max(1, Math.min(parseInt(req.query.limit, 10) || 100, 100)), - }; - - res.json({ - success: true, - data: { - items, - ...responseMeta, - }, - message: "Data fetched successfully.", - }); - } catch (err) { - if (err?.statusCode === 400 || err?.name === 'QueryFilterError') { - return res.status(400).json({ success: false, data: {}, message: err.message || "Invalid query filter." }); - } -}; -}; -module.exports.deleteCollection = async (req, res) => { -======= res.json(data); } catch (err) { next(err); @@ -894,7 +831,6 @@ module.exports.deleteCollection = async (req, res) => { } module.exports.deleteCollection = async (req, res, next) => { ->>>>>>> Stashed changes try { const { projectId, collectionName } = req.params; @@ -960,13 +896,8 @@ module.exports.insertData = async (req, res, next) => { (c) => c.name === collectionName, ); if (!collectionConfig) { -<<<<<<< Updated upstream - return res.status(404).json({ success: false, data: {}, message: `Collection ${collectionName} not found.` }); -} -======= return next(new AppError(404, "Collection configuration not found.")); } ->>>>>>> Stashed changes // Prevent manual injection of soft-delete fields delete incomingData.isDeleted; @@ -2308,12 +2239,8 @@ module.exports.analytics = async (req, res, next) => { } }; -<<<<<<< Updated upstream -module.exports.toggleAuth = async (req, res) => { -======= // FUNCTION - TOGGLE AUTH module.exports.toggleAuth = async (req, res, next) => { ->>>>>>> Stashed changes try { const { projectId } = req.params; const { enable } = req.body; @@ -2361,16 +2288,12 @@ module.exports.toggleAuth = async (req, res, next) => { } }; -<<<<<<< Updated upstream -module.exports.updateAuthProviders = async (req, res) => { -======= /** * Updates GitHub/Google OAuth provider settings for a project. * Preserves existing encrypted client secrets when not provided in the update. * @route PUT /api/projects/:projectId/auth-providers */ module.exports.updateAuthProviders = async (req, res, next) => { ->>>>>>> Stashed changes try { const { projectId } = req.params; const parsed = updateAuthProvidersSchema.parse(req.body || {}); @@ -2439,13 +2362,9 @@ module.exports.updateAuthProviders = async (req, res, next) => { } }; -<<<<<<< Updated upstream -module.exports.updateCollectionRls = async (req, res) => { -======= // PATCH FOR UPDATING COLLECTION RLS module.exports.updateCollectionRls = async (req, res, next) => { ->>>>>>> Stashed changes try { const { projectId, collectionName } = req.params; const { enabled, mode, ownerField, requireAuthForWrite } = req.body || {}; diff --git a/apps/public-api/src/__tests__/mail.controller.test.js b/apps/public-api/src/__tests__/mail.controller.test.js index 09c48e1d8..a1dbb3431 100644 --- a/apps/public-api/src/__tests__/mail.controller.test.js +++ b/apps/public-api/src/__tests__/mail.controller.test.js @@ -377,7 +377,7 @@ describe('mail.controller', () => { removeAllListeners: jest.fn(), close: jest.fn() })) - })); + }), { virtual: true }); const mockRedis = { eval: jest.fn().mockResolvedValue(0) }; jest.doMock('../../../../packages/common/src/config/redis', () => mockRedis); @@ -413,7 +413,7 @@ describe('mail.controller', () => { removeAllListeners: jest.fn(), close: jest.fn() })) - })); + }), { virtual: true }); const mockRedis = { eval: jest.fn().mockResolvedValue(0) }; jest.doMock('../../../../packages/common/src/config/redis', () => mockRedis); diff --git a/apps/public-api/src/__tests__/userAuth.refresh.test.js b/apps/public-api/src/__tests__/userAuth.refresh.test.js index be611f020..93bade40e 100644 --- a/apps/public-api/src/__tests__/userAuth.refresh.test.js +++ b/apps/public-api/src/__tests__/userAuth.refresh.test.js @@ -241,10 +241,12 @@ next = jest.fn(); }); const res = makeRes(); - await controller.refreshToken(req, res); + const next = jest.fn(); + await controller.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ success: false, data: {}, message: expect.stringContaining('deletion') }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(403); + expect(next.mock.calls[next.mock.calls.length - 1][0].message).toMatch(/deletion/); expect(res.clearCookie).toHaveBeenCalledWith('refreshToken', expect.any(Object)); }); diff --git a/apps/public-api/src/__tests__/userAuth.social.test.js b/apps/public-api/src/__tests__/userAuth.social.test.js index fed537019..8b99b800d 100644 --- a/apps/public-api/src/__tests__/userAuth.social.test.js +++ b/apps/public-api/src/__tests__/userAuth.social.test.js @@ -447,7 +447,7 @@ next = jest.fn(); }); test('exchangeSocialRefreshToken returns refresh token and deletes exchange code', async () => { - redis.getdel.mockResolvedValueOnce(JSON.stringify({ + redis.get.mockResolvedValueOnce(JSON.stringify({ token: 'issued_access_token', refreshToken: 'issued_refresh_token', })); @@ -461,14 +461,8 @@ next = jest.fn(); await controller.exchangeSocialRefreshToken(req, res, next); -<<<<<<< Updated upstream - expect(redis.getdel).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123'); - expect(res.status).toHaveBeenCalledWith(200); -======= expect(redis.get).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123'); expect(redis.del).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123'); - ->>>>>>> Stashed changes expect(res.json).toHaveBeenCalledWith({ success: true, data: { @@ -479,7 +473,7 @@ next = jest.fn(); }); test('exchangeSocialRefreshToken rejects invalid or expired code', async () => { - redis.getdel.mockResolvedValueOnce(null); + redis.get.mockResolvedValueOnce(null); const req = makeReq(); req.body = { @@ -490,22 +484,12 @@ next = jest.fn(); await controller.exchangeSocialRefreshToken(req, res, next); -<<<<<<< Updated upstream - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - success: false, - data: {}, - message: 'Invalid or expired refresh token exchange code', - }); -======= expect(next).toHaveBeenCalledWith(expect.any(AppError)); - expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); - ->>>>>>> Stashed changes + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); }); test('exchangeSocialRefreshToken rejects mismatched token and deletes exchange code', async () => { - redis.getdel.mockResolvedValueOnce(JSON.stringify({ + redis.get.mockResolvedValueOnce(JSON.stringify({ token: 'expected_access_token', refreshToken: 'issued_refresh_token', })); @@ -517,22 +501,11 @@ next = jest.fn(); }; const res = makeRes(); -<<<<<<< Updated upstream - await controller.exchangeSocialRefreshToken(req, res); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ - success: false, - data: {}, - message: 'Invalid refresh token exchange payload', - }); -======= await controller.exchangeSocialRefreshToken(req, res, next); expect(redis.del).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_456'); expect(next).toHaveBeenCalledWith(expect.any(AppError)); - expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(403); - ->>>>>>> Stashed changes + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(403); }); // P2: Provider error forwarding diff --git a/apps/public-api/src/controllers/data.controller.js b/apps/public-api/src/controllers/data.controller.js index 4e532c718..f8af26b2a 100644 --- a/apps/public-api/src/controllers/data.controller.js +++ b/apps/public-api/src/controllers/data.controller.js @@ -215,19 +215,8 @@ module.exports.getAllData = async (req, res, next) => { const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); -<<<<<<< Updated upstream - - if (!collectionConfig) { - return res.status(404).json({ - success: false, - data: {}, - message: "Collection not found", - }); - } -======= if (!collectionConfig) return next(new AppError(404, "Collection not found")); ->>>>>>> Stashed changes const connection = await getConnection(project._id); const Model = getCompiledModel( diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index 8bca04697..4bad4da09 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -1,1821 +1,3 @@ -<<<<<<< Updated upstream -const jwt = require('jsonwebtoken'); -const bcrypt = require('bcryptjs'); -const { z } = require('zod'); -const mongoose = require('mongoose'); -const crypto = require('crypto'); -const {redis} = require('@urbackend/common'); -const {Project} = require('@urbackend/common'); -const { authEmailQueue } = require('@urbackend/common'); -const { checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); -const { AppError } = require('@urbackend/common'); -const { getRefreshSession, persistRefreshSession, revokeSessionChain } = require('@urbackend/common'); -const { loginSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common'); -const { getConnection } = require('@urbackend/common'); -const { getCompiledModel } = require('@urbackend/common'); -const { decrypt } = require('@urbackend/common'); -const { - assertRefreshRateLimits, - clearRefreshCookie, - hashRefreshToken, - issueAuthTokens, - parseRefreshToken, - readRefreshTokenFromRequest, - shouldExposeRefreshToken -} = require('../utils/refreshToken'); - -const checkUserSoftDeleted = (user) => { - if (user && user.isDeleted) { - const dateStr = user.deletedAt - ? new Date(new Date(user.deletedAt).getTime() + 30 * 24 * 60 * 60 * 1000).toDateString() - : 'soon'; - return `Your account is scheduled for deletion on ${dateStr}. Please contact the administrator to recover it.`; - } - return null; -}; - -const SOCIAL_PROVIDER_KEYS = ['github', 'google']; -const SOCIAL_STATE_TTL_SECONDS = 600; -const SOCIAL_REFRESH_EXCHANGE_TTL_SECONDS = 60; - -/** - * Checks if a public OTP cooldown is active for this email. - * @param {string} projectId - * @param {string} email - * @param {string} type - */ -const checkPublicOtpCooldown = async (projectId, email, type = 'verification') => { - const cooldownKey = `project:${projectId}:otp:cooldown:${type}:${email}`; - const exists = await redis.get(cooldownKey); - if (exists) { - const ttl = await redis.ttl(cooldownKey); - const err = new Error(`Please wait ${ttl} seconds before requesting another code.`); - err.statusCode = 429; - throw err; - } -}; - -/** - * Sets a 60s cooldown for OTP requests to prevent spam. - */ -const setPublicOtpCooldown = async (projectId, email, type = 'verification') => { - const cooldownKey = `project:${projectId}:otp:cooldown:${type}:${email}`; - await redis.set(cooldownKey, '1', 'EX', 60); -}; - - -/** - * Returns the base URL for the public API, used for redirect URI construction. - * @returns {string} - */ -const getPublicApiBaseUrl = () => { - const configured = process.env.PUBLIC_API_URL?.trim(); - if (configured) return configured.replace(/\/$/, ''); - const port = process.env.USER_PORT || 1235; - return `http://localhost:${port}`; -}; - -/** - * Returns the Redis key for storing OAuth state. - * @param {string} state - CSRF state token - * @returns {string} - */ -const getSocialStateKey = (state) => `project:auth:oauth:state:${state}`; - -/** - * Returns the Redis key for storing the temporary social refresh exchange code. - * @param {string} rtCode - Exchange code - * @returns {string} - */ -const getSocialRefreshExchangeKey = (rtCode) => `project:social-auth:refresh-exchange:${rtCode}`; - -/** - * Returns the frontend OAuth callback URL for a project. - * Falls back to FRONTEND_URL env or localhost when siteUrl is not set. - * @param {Object} project - Project document - * @returns {string} - */ -const getFrontendCallbackBaseUrl = (project) => { - const configured = String(project?.siteUrl || '').trim(); - const base = configured || process.env.FRONTEND_URL || ''; - if (!base) { - console.warn('[social-auth] getFrontendCallbackBaseUrl: siteUrl is not set on the project and FRONTEND_URL env is not configured. Falling back to http://localhost:5173. Set siteUrl in Project Settings or configure FRONTEND_URL.'); - } - return `${(base || 'http://localhost:5173').replace(/\/$/, '')}/auth/callback`; -}; - -/** - * Decodes a base64url-encoded string into a Buffer. - * @param {string} input - Base64url encoded string - * @returns {Buffer} - */ -const toBase64UrlBuffer = (input) => Buffer.from(input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '='), 'base64'); - -/** - * In-memory cache for Google's public JWK keys. - * Keys are refreshed when the cache expires (based on Cache-Control max-age). - * An in-flight promise is stored to prevent redundant concurrent fetches (single-flight). - */ -const googleJwkCache = { keys: null, expiresAt: 0, inflight: null }; - -/** - * Fetches Google's public JWK keys, using an in-memory cache keyed by Cache-Control max-age. - * Uses a single-flight pattern so that concurrent requests share one fetch instead of many. - * @returns {Promise} Array of JWK key objects - */ -const getGooglePublicKeys = async () => { - const now = Date.now(); - if (googleJwkCache.keys && now < googleJwkCache.expiresAt) { - return googleJwkCache.keys; - } - - // Single-flight: reuse in-flight promise if a fetch is already in progress. - if (googleJwkCache.inflight) { - return googleJwkCache.inflight; - } - - googleJwkCache.inflight = (async () => { - try { - const certsResponse = await fetch('https://www.googleapis.com/oauth2/v3/certs'); - if (!certsResponse.ok) { - throw new Error('Unable to fetch Google JWK keys'); - } - - const certsPayload = await certsResponse.json(); - const keys = certsPayload.keys || []; - - // Parse Cache-Control max-age from response headers to determine TTL. - const cacheControl = (typeof certsResponse.headers?.get === 'function') - ? (certsResponse.headers.get('cache-control') || '') - : ''; - const maxAgeMatch = cacheControl.match(/max-age=(\d+)/); - const ttlMs = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) * 1000 : 3600 * 1000; - - googleJwkCache.keys = keys; - googleJwkCache.expiresAt = Date.now() + ttlMs; - - return keys; - } finally { - googleJwkCache.inflight = null; - } - })(); - - return googleJwkCache.inflight; -}; - -/** - * Asserts that a project has auth enabled and a valid users collection schema. - * Throws with an appropriate statusCode on failure. - * @param {Object} project - Lean project document - * @returns {Object} The users collection config - */ -const assertAuthProjectReady = (project) => { - if (!project?.isAuthEnabled) { - const err = new Error('Authentication service is disabled'); - err.statusCode = 403; - throw err; - } - - const usersCollection = project.collections?.find(c => c.name === 'users'); - if (!usersCollection) { - const err = new Error("User Schema Missing"); - err.statusCode = 403; - err.publicMessage = "Authentication is enabled, but the 'users' collection has not been defined."; - throw err; - } - - const hasEmail = usersCollection.model.find(f => f.key === 'email' && f.type === 'String' && f.required); - const hasPassword = usersCollection.model.find(f => f.key === 'password' && f.type === 'String' && f.required); - if (!hasEmail || !hasPassword) { - const err = new Error('Invalid Users Schema'); - err.statusCode = 422; - err.publicMessage = "The 'users' collection must contain required 'email' and 'password' String fields."; - throw err; - } - - return usersCollection; -}; - -/** - * Loads and decrypts a social provider's config for a project. - * @param {string} projectId - Project ObjectId - * @param {string} provider - 'github' or 'google' - * @returns {Promise<{project: Object, providerConfig: Object|null}|null>} - */ -const getSocialProviderConfig = async (projectId, provider) => { - const selectClause = [ - 'name', - 'resources', - 'collections', - 'jwtSecret', - 'isAuthEnabled', - `authProviders.${provider}.enabled`, - `authProviders.${provider}.clientId`, - `authProviders.${provider}.redirectUri`, - `+authProviders.${provider}.clientSecret.encrypted`, - `+authProviders.${provider}.clientSecret.iv`, - `+authProviders.${provider}.clientSecret.tag`, - ].join(' '); - const project = await Project.findById(projectId).select(selectClause).lean(); - if (!project) return null; - - const providerConfig = project.authProviders?.[provider]; - if (!providerConfig?.enabled || !providerConfig.clientId || !providerConfig.clientSecret) { - return { project, providerConfig: null }; - } - - const decryptedSecret = decrypt(providerConfig.clientSecret); - if (!decryptedSecret) { - return { project, providerConfig: null }; - } - - return { - project, - providerConfig: { - enabled: true, - clientId: providerConfig.clientId, - clientSecret: decryptedSecret, - redirectUri: `${getPublicApiBaseUrl()}/api/userAuth/social/${provider}/callback` - } - }; -}; - -/** - * Builds the GitHub OAuth authorization URL. - * @param {Object} params - * @param {string} params.clientId - * @param {string} params.redirectUri - * @param {string} params.state - CSRF state token - * @returns {string} - */ -const buildGithubAuthorizeUrl = ({ clientId, redirectUri, state }) => { - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - scope: 'read:user user:email', - state, - }); - return `https://github.com/login/oauth/authorize?${params.toString()}`; -}; - -/** - * Builds the Google OAuth authorization URL. - * @param {Object} params - * @param {string} params.clientId - * @param {string} params.redirectUri - * @param {string} params.state - CSRF state token - * @returns {string} - */ -const buildGoogleAuthorizeUrl = ({ clientId, redirectUri, state }) => { - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - response_type: 'code', - scope: 'openid email profile', - state, - access_type: 'offline', - prompt: 'consent', - }); - return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; -}; - -/** - * Exchanges a GitHub OAuth authorization code for an access token. - * @param {Object} params - * @param {string} params.code - * @param {string} params.clientId - * @param {string} params.clientSecret - * @param {string} params.redirectUri - * @returns {Promise<{accessToken: string, tokenType: string}>} - */ -const exchangeGithubCodeForToken = async ({ code, clientId, clientSecret, redirectUri }) => { - const response = await fetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri: redirectUri, - }), - }); - - const payload = await response.json(); - if (!response.ok || payload.error || !payload.access_token) { - throw new Error(payload.error_description || payload.error || 'GitHub token exchange failed'); - } - - return { - accessToken: payload.access_token, - tokenType: payload.token_type || 'bearer', - }; -}; - -/** - * Exchanges a Google OAuth authorization code for tokens including an id_token. - * @param {Object} params - * @param {string} params.code - * @param {string} params.clientId - * @param {string} params.clientSecret - * @param {string} params.redirectUri - * @returns {Promise} Google token response including id_token - */ -const exchangeGoogleCodeForToken = async ({ code, clientId, clientSecret, redirectUri }) => { - const params = new URLSearchParams({ - code, - client_id: clientId, - client_secret: clientSecret, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }); - - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: params.toString(), - }); - - const payload = await response.json(); - if (!response.ok || payload.error || !payload.id_token) { - throw new Error(payload.error_description || payload.error || 'Google token exchange failed'); - } - - return payload; -}; - -/** - * Fetches the user profile from GitHub using an access token. - * Includes user email fetching as a secondary step. - * @param {string} accessToken - * @returns {Promise} Normalized profile - */ -const fetchGithubProfile = async (accessToken) => { - const headers = { - 'Authorization': `Bearer ${accessToken}`, - 'Accept': 'application/vnd.github+json', - 'User-Agent': 'urBackend-social-auth', - }; - - const [profileResponse, emailsResponse] = await Promise.all([ - fetch('https://api.github.com/user', { headers }), - fetch('https://api.github.com/user/emails', { headers }), - ]); - - const profile = await profileResponse.json(); - const emails = await emailsResponse.json(); - - if (!profileResponse.ok) { - throw new Error(profile.message || 'Failed to fetch GitHub profile'); - } - if (!emailsResponse.ok || !Array.isArray(emails)) { - throw new Error('Failed to fetch GitHub email addresses'); - } - - const verifiedEmail = emails.find((entry) => entry.primary && entry.verified) || emails.find((entry) => entry.verified); - return { - providerUserId: String(profile.id || ''), - email: verifiedEmail?.email || profile.email || '', - emailVerified: !!verifiedEmail?.verified, - username: profile.login || '', - name: profile.name || profile.login || '', - avatarUrl: profile.avatar_url || '', - rawProfile: profile, - }; -}; - -/** - * Verifies a Google id_token using Google's public JWK keys. - * @param {Object} params - * @param {string} params.idToken - Google JWT id_token - * @param {string} params.clientId - OAuth client ID for audience validation - * @returns {Promise} Decoded JWT claims - */ -const verifyGoogleIdToken = async ({ idToken, clientId }) => { - const parts = String(idToken || '').split('.'); - if (parts.length !== 3) { - throw new Error('Invalid Google id_token format'); - } - - const [encodedHeader, encodedPayload, encodedSignature] = parts; - const header = JSON.parse(toBase64UrlBuffer(encodedHeader).toString('utf8')); - const payload = JSON.parse(toBase64UrlBuffer(encodedPayload).toString('utf8')); - - if (header.alg !== 'RS256' || !header.kid) { - throw new Error('Unsupported Google id_token signature'); - } - - const certsKeys = await getGooglePublicKeys(); - const signingKey = certsKeys.find((key) => key.kid === header.kid); - if (!signingKey) { - throw new Error('Unable to verify Google id_token signing key'); - } - - const publicKey = crypto.createPublicKey({ key: signingKey, format: 'jwk' }); - const verified = crypto.verify( - 'RSA-SHA256', - Buffer.from(`${encodedHeader}.${encodedPayload}`), - publicKey, - toBase64UrlBuffer(encodedSignature) - ); - - if (!verified) { - throw new Error('Invalid Google id_token signature'); - } - - const validIssuers = new Set(['accounts.google.com', 'https://accounts.google.com']); - const nowSeconds = Math.floor(Date.now() / 1000); - const audienceMatches = Array.isArray(payload.aud) - ? payload.aud.includes(clientId) - : payload.aud === clientId; - - if (!audienceMatches) { - throw new Error('Google id_token audience mismatch'); - } - if (!validIssuers.has(payload.iss)) { - throw new Error('Google id_token issuer mismatch'); - } - if (!payload.exp || Number(payload.exp) <= nowSeconds) { - throw new Error('Google id_token has expired'); - } - - return payload; -}; - -/** - * Verifies a Google ID token and returns the normalized user profile. - * @param {Object} params - * @param {string} params.idToken - * @param {string} params.clientId - * @returns {Promise} Normalized profile - */ -const fetchGoogleProfile = async ({ idToken, clientId }) => { - const claims = await verifyGoogleIdToken({ idToken, clientId }); - return { - providerUserId: String(claims.sub || ''), - email: claims.email || '', - emailVerified: !!claims.email_verified, - username: claims.email ? String(claims.email).split('@')[0] : '', - name: claims.name || '', - avatarUrl: claims.picture || '', - rawProfile: claims, - }; -}; - -const socialProviders = { - github: { - buildAuthorizeUrl: buildGithubAuthorizeUrl, - exchangeCodeForToken: exchangeGithubCodeForToken, - fetchProfile: async ({ tokenResponse }) => fetchGithubProfile(tokenResponse.accessToken), - }, - google: { - buildAuthorizeUrl: buildGoogleAuthorizeUrl, - exchangeCodeForToken: exchangeGoogleCodeForToken, - fetchProfile: async ({ tokenResponse, providerConfig }) => fetchGoogleProfile({ - idToken: tokenResponse.id_token, - clientId: providerConfig.clientId, - }), - }, -}; - -/** - * Builds a new user document payload for a social-auth-created user. - * Generates a random hashed password to satisfy the users schema contract. - * @param {Object} usersColConfig - Users collection config from project - * @param {Object} profile - Normalized social profile - * @returns {Promise} User document fields - */ -const buildSocialAuthUserPayload = async (usersColConfig, profile) => { - const randomPassword = crypto.randomBytes(24).toString('hex'); - const salt = await bcrypt.genSalt(10); - const hashedPassword = await bcrypt.hash(randomPassword, salt); - - return buildAuthUserPayload( - usersColConfig, - { - email: profile.email, - password: randomPassword, - username: profile.username, - name: profile.name, - avatarUrl: profile.avatarUrl, - }, - hashedPassword, - profile.emailVerified - ); -}; - -/** - * Finds an existing user by provider ID or verified email, or creates a new user. - * Only links by email when profile.emailVerified is true to prevent account takeover. - * @param {Object} params - * @param {Object} params.project - Project document - * @param {Object} params.usersColConfig - Users collection config - * @param {Object} params.Model - Mongoose model for the users collection - * @param {string} params.provider - 'github' or 'google' - * @param {Object} params.profile - Normalized social profile - * @returns {Promise<{user: Object, isNewUser: boolean, linkedByEmail: boolean}>} - */ -const findOrCreateSocialUser = async ({ project, usersColConfig, Model, provider, profile }) => { - const providerIdField = `${provider}Id`; - const providerName = provider; - - let user = await Model.findOne({ [providerIdField]: profile.providerUserId }); - if (user) { - const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) { - const err = new Error(deletedMsg); - err.statusCode = 403; - throw err; - } - return { user, isNewUser: false, linkedByEmail: false }; - } - - if (!profile.email) { - const err = new Error(`${providerName} did not return an email address for this account.`); - err.statusCode = 422; - throw err; - } - - user = await Model.findOne({ email: profile.email }); - if (user) { - const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) { - const err = new Error(deletedMsg); - err.statusCode = 403; - throw err; - } - // P1: Only link if provider email is verified; reject if unverified to prevent account takeover - if (!profile.emailVerified) { - const err = new Error(`Cannot link ${providerName} account: the provider email is not verified. Please verify your email with ${providerName} first.`); - err.statusCode = 403; - err.code = 'SOCIAL_AUTH_EMAIL_NOT_VERIFIED'; - throw err; - } - - const update = { - $set: { - [providerIdField]: profile.providerUserId, - ...(profile.avatarUrl ? { avatarUrl: profile.avatarUrl } : {}), - }, - $addToSet: { authProviders: providerName }, - }; - - const verificationField = getVerificationField(usersColConfig); - if (verificationField) { - update.$set[verificationField] = true; - } - - await Model.updateOne({ _id: user._id }, update); - user = await Model.findOne({ _id: user._id }); - return { user, isNewUser: false, linkedByEmail: true }; - } - - const newUserPayload = await buildSocialAuthUserPayload(usersColConfig, profile); - newUserPayload[providerIdField] = profile.providerUserId; - newUserPayload.authProviders = [providerName]; - if (profile.avatarUrl && newUserPayload.avatarUrl === undefined) { - newUserPayload.avatarUrl = profile.avatarUrl; - } - - user = await Model.create(newUserPayload); - return { user, isNewUser: true, linkedByEmail: false }; -}; - -/** - * Loads the compiled Mongoose model for the users collection. - * @param {Object} project - Lean project document - * @returns {Promise<{usersColConfig: Object|null, Model: Object|null}>} - */ -const getUsersModel = async (project) => { - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return { usersColConfig: null, Model: null }; - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - return { usersColConfig, Model }; -}; - -/** - * Checks whether a required field key exists in the users collection schema. - * @param {Object} usersColConfig - Users collection config - * @param {string} fieldKey - Field key to check - * @returns {boolean} - */ -const hasRequiredField = (usersColConfig, fieldKey) => { - const model = usersColConfig?.model || []; - return model.some((f) => f?.key === fieldKey && !!f?.required); -}; - -/** - * Returns the name of the email verification field in the users schema, if it exists. - * @param {Object} usersColConfig - Users collection config - * @returns {string|null} - */ -const getVerificationField = (usersColConfig) => { - const modelKeys = (usersColConfig?.model || []).map((f) => f?.key); - if (modelKeys.includes('emailVerified')) return 'emailVerified'; - if (modelKeys.includes('isVerified')) return 'isVerified'; - if (modelKeys.includes('isverified')) return 'isverified'; - return null; -}; - -/** - * Builds a user payload for registration or social login, mapping flat data to the users collection schema. - * @param {Object} usersColConfig - Users collection config - * @param {Object} parsedData - Raw user data (email, name, etc.) - * @param {string} hashedPassword - Hashed password string - * @param {boolean} verifiedValue - Default value for the verification field - * @returns {Object} Mongoose-ready user document payload - */ -const buildAuthUserPayload = (usersColConfig, parsedData, hashedPassword, verifiedValue) => { - const { email, password: _password, username, ...otherData } = parsedData; - - const payload = { - email, - password: hashedPassword, - ...otherData, - createdAt: new Date() - }; - - if (username !== undefined) { - payload.username = username; - } - - const verificationField = getVerificationField(usersColConfig); - if (verificationField !== null) { - payload[verificationField] = verifiedValue; - } - - if (hasRequiredField(usersColConfig, 'name') && (payload.name === undefined || payload.name === null || payload.name === '')) { - const generatedName = username || email.split('@')[0]; - payload.name = generatedName.length >= 3 ? generatedName : generatedName.padEnd(3, '0'); - } - - if (hasRequiredField(usersColConfig, 'username') && (payload.username === undefined || payload.username === null || payload.username === '')) { - const baseUsername = typeof payload.name === 'string' ? payload.name : email.split('@')[0]; - const generatedUsername = baseUsername; - payload.username = generatedUsername.length >= 3 ? generatedUsername : generatedUsername.padEnd(3, '0'); - } - - return payload; -}; - -const SENSITIVE_PROFILE_KEYS = [ - 'password', - 'email', - 'token', - 'otp', - 'secret', - 'session', - 'refresh' -]; - -/** - * Strips sensitive fields (password, provider secrets) from a user document for API responses. - * @param {Object} userDoc - Raw user document - * @param {Object} usersColConfig - Users collection config - * @returns {Object} Sanitized user object safe for public exposure - */ -const sanitizePublicProfile = (userDoc, usersColConfig) => { - const result = { _id: userDoc._id }; - const schemaKeys = (usersColConfig?.model || []).map((f) => f?.key).filter(Boolean); - - for (const key of schemaKeys) { - const lowered = String(key).toLowerCase(); - const isSensitive = SENSITIVE_PROFILE_KEYS.some((needle) => lowered.includes(needle)); - if (isSensitive) continue; - if (userDoc[key] !== undefined) { - result[key] = userDoc[key]; - } - } - - if (userDoc.createdAt) result.createdAt = userDoc.createdAt; - if (userDoc.updatedAt) result.updatedAt = userDoc.updatedAt; - return result; -}; - -/** - * Initiates the social authentication flow for a given provider. - * Generates a secure state, stores it in Redis, and redirects the user to the provider's OAuth page. - * Requires x-api-key header or ?key query param and auth enabled on the project. - * @route GET /api/userAuth/social/:provider/start - */ -module.exports.startSocialAuth = async (req, res) => { - try { - const provider = String(req.params.provider || '').trim().toLowerCase(); - if (!SOCIAL_PROVIDER_KEYS.includes(provider)) { - return res.status(404).json({ error: 'Unsupported social auth provider' }); - } - - assertAuthProjectReady(req.project); - - const { project, providerConfig } = await getSocialProviderConfig(req.project._id, provider); - if (!project || !providerConfig) { - return res.status(422).json({ - error: 'Provider not configured', - message: `${provider} social auth is disabled or incomplete for this project.`, - }); - } - - const state = crypto.randomBytes(24).toString('hex'); - await redis.set( - getSocialStateKey(state), - JSON.stringify({ - projectId: String(project._id), - provider, - callbackUrl: getFrontendCallbackBaseUrl(project), - }), - 'EX', - SOCIAL_STATE_TTL_SECONDS - ); - - const authUrl = socialProviders[provider].buildAuthorizeUrl({ - clientId: providerConfig.clientId, - redirectUri: providerConfig.redirectUri, - state, - }); - - return res.redirect(authUrl); - } catch (err) { - return res.status(err.statusCode || 500).json({ - error: err.publicMessage || err.message, - }); - } -}; - -/** - * Handles the OAuth provider callback. Validates state, exchanges code, resolves/creates user, - * issues auth tokens, and redirects to the frontend callback URL. - * @route GET /api/userAuth/social/:provider/callback - */ -module.exports.handleSocialAuthCallback = async (req, res) => { - // Helper to redirect error to frontend instead of returning JSON - const redirectWithError = (callbackUrl, errorMessage) => { - try { - const url = new URL(callbackUrl); - url.searchParams.set('error', errorMessage); - return res.redirect(url.toString()); - } catch { - // Fallback if URL is malformed - return res.status(400).json({ error: errorMessage }); - } - }; - - // P2: Check for provider-side OAuth errors first and forward them - const providerError = String(req.query.error || '').trim(); - const providerErrorDesc = String(req.query.error_description || '').trim(); - - let parsedState = null; - let callbackUrl = null; - - // Try to parse state to get callback URL (even if there's an error) - const state = String(req.query.state || '').trim(); - if (state) { - const stateKey = getSocialStateKey(state); - const rawState = await redis.get(stateKey); - if (rawState) { - try { - parsedState = JSON.parse(rawState); - callbackUrl = parsedState.callbackUrl || getFrontendCallbackBaseUrl({ siteUrl: '' }); - } catch { - // Ignore parse errors here - } - // Cleanup state even on error - await redis.del(stateKey); - } - } - - // If provider returned an error, redirect it to frontend - if (providerError) { - const errorMsg = providerErrorDesc || providerError || 'OAuth provider returned an error'; - if (callbackUrl) { - return redirectWithError(callbackUrl, errorMsg); - } - // No callback URL available - return JSON as fallback - return res.status(400).json({ error: errorMsg }); - } - - try { - const provider = String(req.params.provider || '').trim().toLowerCase(); - if (!SOCIAL_PROVIDER_KEYS.includes(provider)) { - return res.status(404).json({ error: 'Unsupported social auth provider' }); - } - - const code = String(req.query.code || '').trim(); - if (!code || !state) { - const errorMsg = 'Missing code or state'; - if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); - return res.status(400).json({ error: errorMsg }); - } - - if (!parsedState) { - return res.status(400).json({ error: 'Invalid or expired OAuth state' }); - } - - if (parsedState.provider !== provider || !parsedState.projectId) { - const errorMsg = 'OAuth state mismatch'; - if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); - return res.status(400).json({ error: errorMsg }); - } - - // Update callbackUrl from parsed state - callbackUrl = parsedState.callbackUrl || callbackUrl; - - const { project, providerConfig } = await getSocialProviderConfig(parsedState.projectId, provider); - if (!project || !providerConfig) { - const errorMsg = `${provider} social auth is disabled or incomplete for this project.`; - if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); - return res.status(422).json({ error: 'Provider not configured', message: errorMsg }); - } - - // Use project's callback URL - callbackUrl = parsedState.callbackUrl || getFrontendCallbackBaseUrl(project); - - const usersColConfig = assertAuthProjectReady(project); - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const tokenResponse = await socialProviders[provider].exchangeCodeForToken({ - code, - clientId: providerConfig.clientId, - clientSecret: providerConfig.clientSecret, - redirectUri: providerConfig.redirectUri, - }); - - const profile = await socialProviders[provider].fetchProfile({ - tokenResponse, - providerConfig, - }); - const { user, isNewUser, linkedByEmail } = await findOrCreateSocialUser({ - project, - usersColConfig, - Model, - provider, - profile, - }); - - const issuedTokens = await issueAuthTokens({ - project, - userId: user._id, - req, - res, - }); - const rtCode = crypto.randomBytes(16).toString('hex'); - await redis.set( - getSocialRefreshExchangeKey(rtCode), - JSON.stringify({ - token: issuedTokens.accessToken, - refreshToken: issuedTokens.refreshToken, - }), - 'EX', - SOCIAL_REFRESH_EXCHANGE_TTL_SECONDS - ); - - const successUrl = new URL(callbackUrl); - const fragmentParams = new URLSearchParams( - successUrl.hash.startsWith('#') ? successUrl.hash.slice(1) : successUrl.hash - ); - fragmentParams.set('token', issuedTokens.accessToken); - - successUrl.searchParams.set('rtCode', rtCode); - successUrl.searchParams.set('provider', provider); - successUrl.searchParams.set('userId', String(user._id)); - successUrl.searchParams.set('projectId', String(project._id)); - successUrl.searchParams.set('isNewUser', String(isNewUser)); - successUrl.searchParams.set('linkedByEmail', String(linkedByEmail)); - successUrl.hash = fragmentParams.toString(); - - return res.redirect(successUrl.toString()); - } catch (err) { - const errorMsg = err.publicMessage || err.message || 'Social authentication failed'; - if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); - return res.status(err.statusCode || 500).json({ error: errorMsg }); - } -}; - -/** - * Exchanges a one-time rtCode (from social auth callback) for a refresh token. - * Completes the OAuth token flow initiated by handleSocialAuthCallback. - * @route POST /api/userAuth/social/exchange - */ -module.exports.exchangeSocialRefreshToken = async (req, res) => { - try { - const rtCode = String(req.body?.rtCode || '').trim(); - const token = String(req.body?.token || '').trim(); - - if (!rtCode || !token) { - return res.status(400).json({ - success: false, - data: {}, - message: 'rtCode and token are required', - }); - } - - const exchangeKey = getSocialRefreshExchangeKey(rtCode); - const rawExchange = await redis.getdel(exchangeKey); - if (!rawExchange) { - return res.status(400).json({ - success: false, - data: {}, - message: 'Invalid or expired refresh token exchange code', - }); - } - - let parsedExchange; - try { - parsedExchange = JSON.parse(rawExchange); - } catch (err) { - return res.status(400).json({ - success: false, - data: {}, - message: 'Invalid or expired refresh token exchange code', - }); - } - - if (parsedExchange.token !== token || !parsedExchange.refreshToken) { - return res.status(403).json({ - success: false, - data: {}, - message: 'Invalid refresh token exchange payload', - }); - } - - return res.status(200).json({ - success: true, - data: { - refreshToken: parsedExchange.refreshToken, - }, - message: 'Refresh token exchanged successfully', - }); - } catch (err) { - return res.status(500).json({ - success: false, - data: {}, - message: 'Internal server error', - }); - } -}; - - -/** - * Handles traditional email/password user registration. - * Hashes the password and triggers a verification email if mandatory. - * @route POST /api/userAuth/signup - */ -module.exports.signup = async (req, res) => { - try { - const project = req.project; - - const { email, password, username, ...otherData } = userSignupSchema.parse(req.body); - const normalizedEmail = email.toLowerCase().trim(); - - // Get Mongoose Model - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const existingUser = await Model.findOne({ email: normalizedEmail }); - - if (existingUser) { - const deletedMsg = checkUserSoftDeleted(existingUser); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); - - // Check if user is unverified. If so, we can trigger a resend instead of a hard error. - const verificationField = getVerificationField(usersColConfig); - const isVerified = verificationField ? !!existingUser[verificationField] : true; - - if (!isVerified) { - // Check cooldown - try { - await checkPublicOtpCooldown(project._id, normalizedEmail, 'verification'); - } catch (cooldownErr) { - return res.status(cooldownErr.statusCode || 429).json({ success: false, data: {}, message: cooldownErr.message }); - } - - const otp = crypto.randomInt(100000, 1000000).toString(); - await redis.set(`project:${project._id}:otp:verification:${normalizedEmail}`, otp, 'EX', 300); - await setPublicOtpCooldown(project._id, normalizedEmail, 'verification'); - - await authEmailQueue.add('send-verification-email', { - email, - otp, - type: 'verification', - pname: project.name, - projectId: String(project._id) - }); - - return res.status(200).json({ - success: true, - message: "User already exists but is unverified. A new verification code has been sent.", - userId: existingUser._id - }); - } - - return res.status(400).json({ error: "User already exists with this email." }); - } - - const salt = await bcrypt.genSalt(10); - const hashedPassword = await bcrypt.hash(password, salt); - - const otp = crypto.randomInt(100000, 1000000).toString(); - - const newUserPayload = buildAuthUserPayload( - usersColConfig, - { email, password, username, ...otherData }, - hashedPassword, - false - ); - - // Model.create handles validation and default values - const result = await Model.create(newUserPayload); - - try { - // Fail-open: if Redis is unavailable, do not block successful signup. - await clearLockout(String(project._id), normalizedEmail); - } catch (lockErr) { - console.error('[login-lockout] clearLockout failed after signup:', lockErr?.message || lockErr); - } - - await redis.set(`project:${project._id}:otp:verification:${normalizedEmail}`, otp, 'EX', 300); - await setPublicOtpCooldown(project._id, normalizedEmail, 'verification'); - - await authEmailQueue.add('send-verification-email', { - email, - otp, - type: 'verification', - pname: project.name, - projectId: String(project._id) - }); - - - const issuedTokens = await issueAuthTokens({ - project, - userId: result._id, - req, - res - }); - - res.status(201).json({ - message: "User registered successfully. Please verify your email.", - token: issuedTokens.accessToken, - accessToken: issuedTokens.accessToken, - expiresIn: issuedTokens.expiresIn, - ...(shouldExposeRefreshToken(req) ? { refreshToken: issuedTokens.refreshToken } : {}), - userId: result._id - }); - - } catch (err) { - if (err instanceof z.ZodError) { - return res.status(400).json({ error: err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed" }); - } - res.status(500).json({ error: err.message }); - console.log(err) - } -} - -/** - * Authenticates a user with email and password. - * Issues access and refresh tokens upon successful authentication. - * @route POST /api/userAuth/login - */ -module.exports.login = async (req, res, next) => { - const sendAuthError = (statusCode, message) => { - if (typeof next === 'function') { - return next(new AppError(statusCode, message)); - } - // Fallback for direct responses: use the project's standard API envelope - return res.status(statusCode).json({ success: false, data: {}, message }); - }; - - const sendLockoutServiceError = (message = 'Login lockout service unavailable') => sendAuthError(503, message); - - try { - const project = req.project; - const { email, password } = loginSchema.parse(req.body); - const normalizedEmail = email.toLowerCase().trim(); - const projectId = String(project._id); - - let lockStatus = { locked: false, retryAfterSeconds: 0 }; - try { - lockStatus = await checkLockout(projectId, normalizedEmail); - } catch (lockErr) { - console.error('[login-lockout] checkLockout failed:', lockErr?.message || lockErr); - return sendLockoutServiceError(); - } - - if (lockStatus.locked) { - return sendAuthError(423, `Account temporarily locked. Try again in ${lockStatus.retryAfterSeconds} seconds.`); - } - - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const user = await Model.findOne({ email: normalizedEmail }).select('+password'); - - if (user && user.isDeleted) { - return sendAuthError(403, checkUserSoftDeleted(user)); - } - - if (!user) { - let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; - try { - failedStatus = await recordFailedAttempt(projectId, normalizedEmail); - } catch (attemptErr) { - console.error('[login-lockout] recordFailedAttempt failed (user missing):', attemptErr?.message || attemptErr); - return sendLockoutServiceError(); - } - - if (failedStatus.locked) { - return sendAuthError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`); - } - return sendAuthError(400, 'Invalid email or password'); - } - - const validPass = await bcrypt.compare(password, user.password); - if (!validPass) { - let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; - try { - failedStatus = await recordFailedAttempt(projectId, normalizedEmail); - } catch (attemptErr) { - console.error('[login-lockout] recordFailedAttempt failed (invalid password):', attemptErr?.message || attemptErr); - return sendLockoutServiceError(); - } - - if (failedStatus.locked) { - return sendAuthError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`); - } - return sendAuthError(400, 'Invalid email or password'); - } - - try { - // Fail-open: if Redis is unavailable, do not block successful login. - await clearLockout(projectId, normalizedEmail); - } catch (clearErr) { - console.error('[login-lockout] clearLockout failed:', clearErr?.message || clearErr); - } - - const issuedTokens = await issueAuthTokens({ - project, - userId: user._id, - req, - res - }); - - res.json({ - token: issuedTokens.accessToken, - accessToken: issuedTokens.accessToken, - expiresIn: issuedTokens.expiresIn, - ...(shouldExposeRefreshToken(req) ? { refreshToken: issuedTokens.refreshToken } : {}) - }); - - } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - res.status(500).json({ error: err.message }); // Fixed: .json() - } -} - -/** - * Returns the currently authenticated user's profile based on the JWT token. - * @route GET /api/userAuth/me - */ -module.exports.me = async (req, res) => { - try { - const project = req.project; - const tokenHeader = req.header('Authorization'); - - if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); - - const token = tokenHeader.replace("Bearer ", ""); - - try { - const decoded = jwt.verify(token, project.jwtSecret); - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const user = await Model.findOne( - { _id: new mongoose.Types.ObjectId(decoded.userId) }, - { password: 0 } - ).lean(); - - if (!user) return res.status(404).json({ error: "User not found" }); - - const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); - - res.json(user); - - } catch (err) { - return res.status(401).json({ error: "Invalid or Expired Token" }); - } - - } catch (err) { - res.status(500).json({ error: err.message }); - } -} - -// GET PUBLIC PROFILE BY USERNAME -module.exports.publicProfile = async (req, res) => { - try { - const project = req.project; - const username = String(req.params.username || '').trim(); - if (!username) return res.status(400).json({ error: "Username is required" }); - - const { usersColConfig, Model } = await getUsersModel(project); - if (!usersColConfig || !Model) return res.status(404).json({ error: "Auth collection not found" }); - - const hasUsernameField = (usersColConfig.model || []).some((f) => String(f?.key || '').trim() === 'username'); - if (!hasUsernameField) { - return res.status(400).json({ error: "Public profile requires a 'username' field in users schema" }); - } - - const user = await Model.findOne({ username }, { password: 0 }).lean(); - if (!user) return res.status(404).json({ error: "User not found" }); - - const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); - - const profile = sanitizePublicProfile(user, usersColConfig); - return res.json(profile); - } catch (err) { - return res.status(500).json({ error: err.message }); - } -} - -// POST REQ FOR ADMIN CREATE USER -module.exports.createAdminUser = async (req, res) => { - try { - const project = req.project; - - const parsedData = userSignupSchema.parse(req.body); - const { email, password, username, ...otherData } = parsedData; - - // Get Mongoose Model - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const existingUser = await Model.findOne({ email }); - if (existingUser) { - return res.status(400).json({ error: "User already exists with this email." }); - } - - const salt = await bcrypt.genSalt(10); - const hashedPassword = await bcrypt.hash(password, salt); - - const newUserPayload = buildAuthUserPayload( - usersColConfig, - { email, password, username, ...otherData }, - hashedPassword, - true - ); - - const result = await Model.create(newUserPayload); - - res.status(201).json({ - message: "User created successfully", - user: { _id: result._id, email, username, createdAt: newUserPayload.createdAt } - }); - - } catch (err) { - if (err instanceof z.ZodError) { - console.error(err); - return res.status(400).json({ error: err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed" }); - } - res.status(500).json({ error: err.message }); - } -} - -// PATCH REQ FOR ADMIN RESET PASSWORD -module.exports.resetPassword = async (req, res) => { - try { - const project = req.project; - const { userId } = req.params; - - const { newPassword } = req.body; - - if (!newPassword || newPassword.length < 6) { - return res.status(400).json({ error: "Password must be at least 6 characters" }); - } - - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const salt = await bcrypt.genSalt(10); - const hashedPassword = await bcrypt.hash(newPassword, salt); - - const result = await Model.updateOne( - { _id: new mongoose.Types.ObjectId(userId) }, - { $set: { password: hashedPassword } } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ error: "User not found" }); - } - - res.json({ message: "Password updated successfully" }); - - } catch (err) { - res.status(500).json({ error: err.message }); - } -} - -// POST REQ FOR EMAIL VERIFICATION -module.exports.verifyEmail = async (req, res) => { - try { - const project = req.project; - const { email, otp } = verifyOtpSchema.parse(req.body); - const normalizedEmail = email.toLowerCase().trim(); - - const redisKey = `project:${project._id}:otp:verification:${normalizedEmail}`; - const storedOtp = await redis.get(redisKey); - - if (!storedOtp || storedOtp !== otp) { - - return res.status(400).json({ error: "Invalid or expired OTP" }); - } - - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const verificationField = getVerificationField(usersColConfig); - if (!verificationField) { - return res.status(500).json({ error: "No verification field found in users schema" }); - } - const result = await Model.updateOne( - { email }, - { $set: { [verificationField]: true } } - ); - - if (result.matchedCount === 0) return res.status(404).json({ error: "User not found" }); - - await redis.del(redisKey); - res.json({ message: "Email verified successfully" }); - - } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); - } -}; - -// RESEND VERIFICATION OTP -module.exports.resendVerificationOtp = async (req, res) => { - try { - const project = req.project; - const { email } = onlyEmailSchema.parse(req.body); - const normalizedEmail = email.toLowerCase().trim(); - - const { usersColConfig, Model } = await getUsersModel(project); - if (!Model) return res.status(404).json({ error: "Auth collection not found" }); - - const user = await Model.findOne({ email: normalizedEmail }); - - if (!user) { - return res.status(400).json({ error: "User not found with this email." }); - } - - const verificationField = getVerificationField(usersColConfig); - const isVerified = verificationField ? !!user[verificationField] : true; - - if (isVerified) { - return res.status(400).json({ error: "Email is already verified." }); - } - - // Check cooldown - try { - await checkPublicOtpCooldown(project._id, normalizedEmail, 'verification'); - } catch (cooldownErr) { - return res.status(cooldownErr.statusCode || 429).json({ success: false, data: {}, message: cooldownErr.message }); - } - - const otp = crypto.randomInt(100000, 1000000).toString(); - await redis.set(`project:${project._id}:otp:verification:${normalizedEmail}`, otp, 'EX', 300); - await setPublicOtpCooldown(project._id, normalizedEmail, 'verification'); - - await authEmailQueue.add('send-verification-email', { - email, - otp, - type: 'verification', - pname: project.name, - projectId: String(project._id) - }); - - res.json({ message: "Verification code sent successfully." }); - - } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - res.status(500).json({ error: err.message }); - } -}; - - -// POST REQ FOR PASSWORD RESET REQUEST -module.exports.requestPasswordReset = async (req, res) => { - try { - const project = req.project; - const { email } = onlyEmailSchema.parse(req.body); - const normalizedEmail = email.toLowerCase().trim(); - - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - try { - await checkPublicOtpCooldown(project._id, normalizedEmail, 'reset'); - } catch (cooldownErr) { - return res.status(cooldownErr.statusCode || 429).json({ success: false, data: {}, message: cooldownErr.message }); - } - - const user = await Model.findOne({ email: normalizedEmail }); - - if (!user || (user && user.isDeleted)) { - await setPublicOtpCooldown(project._id, normalizedEmail, 'reset'); - return res.json({ message: "If that email exists, a reset code has been sent." }); - } - - const otp = crypto.randomInt(100000, 1000000).toString(); - await redis.set(`project:${project._id}:otp:reset:${normalizedEmail}`, otp, 'EX', 300); - await setPublicOtpCooldown(project._id, normalizedEmail, 'reset'); - - await authEmailQueue.add('send-reset-email', { email, otp, type: 'password_reset', pname: project.name, projectId: String(project._id) }); - - res.json({ message: "If that email exists, a reset code has been sent." }); - } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); - } -}; - -// POST REQ FOR PASSWORD RESET CONFIRMATION -module.exports.resetPasswordUser = async (req, res) => { - try { - const project = req.project; - const { email, otp, newPassword } = resetPasswordSchema.parse(req.body); - const normalizedEmail = email.toLowerCase().trim(); - - const redisKey = `project:${project._id}:otp:reset:${normalizedEmail}`; - const storedOtp = await redis.get(redisKey); - - if (!storedOtp || storedOtp !== otp) { - return res.status(400).json({ error: "Invalid or expired OTP" }); - } - - const salt = await bcrypt.genSalt(10); - const hashedPassword = await bcrypt.hash(newPassword, salt); - - const { Model: collection } = await getUsersModel(project); - if (!collection) return res.status(404).json({ error: "Auth collection not found" }); - - const user = await collection.findOne({ email: normalizedEmail }); - if (!user) return res.status(404).json({ error: "User not found" }); - - const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); - - await collection.updateOne( - { email: normalizedEmail }, - { $set: { password: hashedPassword } } - ); - - try { - // Fail-open: if Redis is unavailable, do not block password recovery success. - await clearLockout(String(project._id), normalizedEmail); - } catch (lockErr) { - console.error('[login-lockout] clearLockout failed after password reset:', lockErr?.message || lockErr); - } - - await redis.del(redisKey); - res.json({ message: "Password updated successfully" }); - - } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); - } -}; - -// PATCH REQ FOR UPDATE PROFILE -module.exports.updateProfile = async (req, res) => { - try { - const project = req.project; - - const tokenHeader = req.header('Authorization'); - if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); - const token = tokenHeader.replace("Bearer ", ""); - - let decoded; - try { - decoded = jwt.verify(token, project.jwtSecret); - } catch (err) { - return res.status(401).json({ error: "Access Denied: Invalid or expired token" }); - } - - const updateData = { ...req.body }; - delete updateData.password; - delete updateData.email; - delete updateData._id; - - if (updateData.username !== undefined) { - const username = updateData.username; - if (typeof username !== 'string' || username.length < 3 || username.length > 50) { - return res.status(400).json({ error: "Username must be between 3 and 50 characters." }); - } - } - - const sanitizedUpdateData = sanitize(updateData); - - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const user = await Model.findOne({ _id: new mongoose.Types.ObjectId(decoded.userId) }); - if (!user) return res.status(404).json({ error: "User not found" }); - - const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); - - await Model.updateOne( - { _id: new mongoose.Types.ObjectId(decoded.userId) }, - { $set: sanitizedUpdateData }, - { runValidators: true } - ); - - res.json({ message: "Profile updated successfully" }); - - } catch (err) { - res.status(500).json({ error: err.message }); - } -}; - -// POST REQ FOR CHANGE PASSWORD -module.exports.changePasswordUser = async (req, res) => { - try { - const project = req.project; - - const tokenHeader = req.header('Authorization'); - if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); - const token = tokenHeader.replace("Bearer ", ""); - - let decoded; - try { - decoded = jwt.verify(token, project.jwtSecret); - } catch (err) { - return res.status(401).json({ error: "Access Denied: Invalid or expired token" }); - } - - const { currentPassword, newPassword } = changePasswordSchema.parse(req.body); - - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const user = await Model.findOne({ _id: new mongoose.Types.ObjectId(decoded.userId) }); - if (!user) return res.status(404).json({ error: "User not found" }); - - const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); - - const validPass = await bcrypt.compare(currentPassword, user.password); - if (!validPass) return res.status(400).json({ error: "Invalid current password" }); - - const salt = await bcrypt.genSalt(10); - const hashedPassword = await bcrypt.hash(newPassword, salt); - - await Model.updateOne( - { _id: new mongoose.Types.ObjectId(decoded.userId) }, - { $set: { password: hashedPassword } } - ); - - res.json({ message: "Password changed successfully" }); - - } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); - } -}; - -module.exports.refreshToken = async (req, res) => { - try { - const rawRefreshToken = readRefreshTokenFromRequest(req); - if (!rawRefreshToken) { - clearRefreshCookie(res); - return res.status(401).json({ error: 'Refresh token missing' }); - } - - const parsedToken = parseRefreshToken(rawRefreshToken); - if (!parsedToken) { - clearRefreshCookie(res); - return res.status(401).json({ error: 'Invalid refresh token format' }); - } - - const earlyRateLimit = await assertRefreshRateLimits({ req, tokenId: parsedToken.tokenId }); - if (earlyRateLimit.limited) { - clearRefreshCookie(res); - return res.status(429).json({ error: earlyRateLimit.message }); - } - - const session = await getRefreshSession(parsedToken.tokenId); - if (!session) { - clearRefreshCookie(res); - return res.status(401).json({ error: 'Refresh session not found' }); - } - - if (String(req.project._id) !== String(session.projectId)) { - return res.status(403).json({ error: 'Refresh token does not belong to this project' }); - } - - const rateResult = await assertRefreshRateLimits({ req, tokenId: session.tokenId, userId: session.userId }); - if (rateResult.limited) { - clearRefreshCookie(res); - return res.status(429).json({ error: rateResult.message }); - } - - const now = Date.now(); - const isExpired = new Date(session.expiresAt).getTime() <= now; - if (session.revokedAt || session.isUsed || isExpired) { - await revokeSessionChain(session.tokenId); - clearRefreshCookie(res); - return res.status(403).json({ error: 'Refresh token is invalid or already used' }); - } - - if (hashRefreshToken(rawRefreshToken) !== session.tokenHash) { - await revokeSessionChain(session.tokenId); - clearRefreshCookie(res); - return res.status(403).json({ error: 'Refresh token mismatch' }); - } - - session.isUsed = true; - session.lastUsedAt = new Date().toISOString(); - await persistRefreshSession(session); - - const project = await Project.findById(session.projectId) - .select('name resources collections jwtSecret isAuthEnabled') - .lean(); - - if (!project || !project.isAuthEnabled) { - await revokeSessionChain(session.tokenId); - clearRefreshCookie(res); - return res.status(401).json({ error: 'Project auth is unavailable' }); - } - - const { Model } = await getUsersModel(project); - if (!Model) { - await revokeSessionChain(session.tokenId); - clearRefreshCookie(res); - return res.status(404).json({ error: 'Auth collection not found' }); - } - - const user = await Model.findOne( - { _id: new mongoose.Types.ObjectId(session.userId) }, - { _id: 1, isDeleted: 1, deletedAt: 1 } - ).lean(); - if (!user) { - await revokeSessionChain(session.tokenId); - clearRefreshCookie(res); - return res.status(401).json({ success: false, data: {}, message: 'User not found for refresh token' }); - } - - const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) { - await revokeSessionChain(session.tokenId); - clearRefreshCookie(res); - return res.status(403).json({ success: false, data: {}, message: deletedMsg }); - } - - const newTokens = await issueAuthTokens({ - project, - userId: user._id, - req, - res, - rotatedFrom: session.tokenId - }); - - session.rotatedTo = newTokens.tokenId; - session.lastUsedAt = new Date().toISOString(); - await persistRefreshSession(session); - - const usedHeaderToken = !!req.header('x-refresh-token'); - return res.status(200).json({ - token: newTokens.accessToken, - accessToken: newTokens.accessToken, - expiresIn: newTokens.expiresIn, - ...(usedHeaderToken ? { refreshToken: newTokens.refreshToken } : {}) - }); - } catch (err) { - clearRefreshCookie(res); - return res.status(500).json({ error: err.message }); - } -}; - -module.exports.logout = async (req, res) => { - try { - const rawRefreshToken = readRefreshTokenFromRequest(req); - if (rawRefreshToken) { - const parsedToken = parseRefreshToken(rawRefreshToken); - if (parsedToken) { - const session = await getRefreshSession(parsedToken.tokenId); - if (session && hashRefreshToken(rawRefreshToken) === session.tokenHash) { - if (String(req.project._id) !== String(session.projectId)) { - return res.status(403).json({ error: 'Refresh token does not belong to this project' }); - } - session.revokedAt = new Date().toISOString(); - session.isUsed = true; - session.lastUsedAt = new Date().toISOString(); - await persistRefreshSession(session); - } - } - } - - clearRefreshCookie(res); - return res.status(200).json({ success: true, message: 'Logged out successfully' }); - } catch (err) { - clearRefreshCookie(res); - return res.status(500).json({ error: err.message }); - } -}; - -// FUNCTION - GET USER DETAILS (ADMIN) -module.exports.getUserDetails = async (req, res) => { - try { - const project = req.project; - const { userId } = req.params; - - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const user = await Model.findOne( - { _id: new mongoose.Types.ObjectId(userId) }, - { password: 0 } - ).lean(); - if (!user) return res.status(404).json({ error: "User not found" }); - - res.json(user); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}; - -// PUT REQ FOR UPDATE ADMIN USER -module.exports.updateAdminUser = async (req, res) => { - try { - const project = req.project; - const { userId } = req.params; - const updateData = req.body; - - delete updateData.password; - delete updateData._id; - - const sanitizedUpdateData = sanitize(updateData); - - // Get Mongoose Model - const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - - const result = await Model.updateOne( - { _id: new mongoose.Types.ObjectId(userId) }, - { $set: sanitizedUpdateData }, - { runValidators: true } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ error: "User not found" }); - } - - res.json({ message: "User updated successfully" }); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}; -======= const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const { z } = require('zod'); @@ -2322,6 +504,9 @@ const findOrCreateSocialUser = async ({ project, usersColConfig, Model, provider let user = await Model.findOne({ [providerIdField]: profile.providerUserId }); if (user) { + if (user.isDeleted) { + throw new AppError(403, 'Account is scheduled for deletion'); + } return { user, isNewUser: false, linkedByEmail: false }; } @@ -2331,6 +516,9 @@ const findOrCreateSocialUser = async ({ project, usersColConfig, Model, provider user = await Model.findOne({ email: profile.email }); if (user) { + if (user.isDeleted) { + throw new AppError(403, 'Account is scheduled for deletion'); + } // P1: Only link if provider email is verified; reject if unverified to prevent account takeover if (!profile.emailVerified) { throw new AppError(403, `Cannot link ${providerName} account: the provider email is not verified. Please verify your email with ${providerName} first.`, 'SOCIAL_AUTH_EMAIL_NOT_VERIFIED'); @@ -3417,13 +1605,19 @@ module.exports.refreshToken = async (req, res, next) => { const user = await Model.findOne( { _id: new mongoose.Types.ObjectId(session.userId) }, - { _id: 1 } + { _id: 1, isDeleted: 1 } ).lean(); if (!user) { await revokeSessionChain(session.tokenId); clearRefreshCookie(res); return next(new AppError(401, 'User not found for refresh token')); } + + if (user.isDeleted) { + await revokeSessionChain(session.tokenId); + clearRefreshCookie(res); + return next(new AppError(403, 'Account is scheduled for deletion')); + } const newTokens = await issueAuthTokens({ project, @@ -3535,4 +1729,3 @@ module.exports.updateAdminUser = async (req, res, next) => { return next(new AppError(500, err.message)); } }; ->>>>>>> Stashed changes