diff --git a/apps/consumer/src/index.js b/apps/consumer/src/index.js index c8d01138e..9707ed899 100644 --- a/apps/consumer/src/index.js +++ b/apps/consumer/src/index.js @@ -18,7 +18,14 @@ app.get('/', (_req, res) => { res.status(200).send('consumer worker running'); }); -const port = Number(process.env.PORT) || 3000; +let port = process.env.NODE_ENV === 'production' + ? (Number(process.env.PORT) || 3000) + : (Number(process.env.CONSUMER_PORT) || 1237); + +if (port < 1 || port > 65535 || isNaN(port)) { + console.warn(`[CONSUMER] Invalid port ${port} detected, defaulting to 1237`); + port = 1237; +} let worker; let server; diff --git a/apps/dashboard-api/src/__tests__/auth.controller.test.js b/apps/dashboard-api/src/__tests__/auth.controller.test.js index 2b68e68e1..7dee2af33 100644 --- a/apps/dashboard-api/src/__tests__/auth.controller.test.js +++ b/apps/dashboard-api/src/__tests__/auth.controller.test.js @@ -84,6 +84,18 @@ jest.mock('@urbackend/common', () => { } }, sendOtp: jest.fn().mockResolvedValue(undefined), + normalizeOnboarding: jest.fn((onboarding = {}) => { + const steps = onboarding.steps || {}; + return { + completed: Boolean(onboarding.completed), + steps: { + projectCreated: Boolean(steps.projectCreated), + collectionCreated: Boolean(steps.collectionCreated), + firstApiCall: Boolean(steps.firstApiCall), + }, + activationAt: onboarding.activationAt || null, + }; + }), // Use real zod shapes so validation logic is exercised. loginSchema: z.object({ email: z.string().email(), @@ -108,6 +120,26 @@ jest.mock('@urbackend/common', () => { otp: z.string(), newPassword: z.string().min(6), }), + updateOnboardingSchema: z.object({ + completed: z.boolean().optional(), + steps: z.object({ + projectCreated: z.boolean().optional(), + collectionCreated: z.boolean().optional(), + firstApiCall: z.boolean().optional(), + }).strict().optional(), + }).strict().refine( + (data) => data.completed !== undefined || data.steps !== undefined, + { message: 'At least one onboarding field must be provided.' } + ), + updateDeveloperOnboarding: jest.fn().mockResolvedValue({ + completed: false, + steps: { + projectCreated: true, + collectionCreated: false, + firstApiCall: false, + }, + activationAt: null, + }), }; }); @@ -117,7 +149,7 @@ jest.mock('@urbackend/common', () => { const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); -const { Developer, Otp, Project, sendOtp, AppError } = require('@urbackend/common'); +const { Developer, Otp, Project, sendOtp, AppError, updateDeveloperOnboarding } = require('@urbackend/common'); const authController = require('../controllers/auth.controller'); // --------------------------------------------------------------------------- @@ -159,22 +191,43 @@ describe('auth.controller', () => { // ----------------------------------------------------------------------- describe('register', () => { - test('returns 201 and success message on valid new-user registration', async () => { + test('returns 201 with session payload on valid new-user registration', async () => { Developer.findOne.mockResolvedValue(null); bcrypt.genSalt.mockResolvedValue('salt'); bcrypt.hash.mockResolvedValue('hashed_password'); + jwt.sign.mockReturnValue('signed_token'); + const createdUser = { + _id: 'new_dev_1', + email: 'new@example.com', + isVerified: false, + maxProjects: 1, + password: 'hashed_password', + save: jest.fn().mockResolvedValue(undefined), + }; const mockSave = jest.fn().mockResolvedValue(undefined); - Developer.mockImplementation(() => ({ save: mockSave })); + Developer.mockImplementation((data) => ({ ...createdUser, ...data, save: mockSave })); const req = makeReq({ email: 'new@example.com', password: 'password123' }); const res = makeRes(); await authController.register(req, res, next); + expect(Developer.findOne).toHaveBeenCalledWith({ email: 'new@example.com' }); expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith({ success: true, data: {}, message: 'Registered successfully' }); + expect(res.cookie).toHaveBeenCalledWith('accessToken', expect.any(String), expect.any(Object)); + expect(res.cookie).toHaveBeenCalledWith('refreshToken', expect.any(String), expect.any(Object)); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + success: true, + data: expect.objectContaining({ + redirectTo: '/onboarding', + user: expect.objectContaining({ + email: 'new@example.com', + isVerified: false, + }), + }), + })); }); test('returns 400 when email already exists', async () => { @@ -202,7 +255,7 @@ describe('auth.controller', () => { // ----------------------------------------------------------------------- describe('login', () => { - const mockUser = () => ({ + const mockUser = (overrides = {}) => ({ _id: 'dev_id_1', email: 'test@example.com', isVerified: true, @@ -210,6 +263,7 @@ describe('auth.controller', () => { refreshToken: null, password: 'hashed_password', save: jest.fn().mockResolvedValue(undefined), + ...overrides, }); test('returns 200 with cookie tokens on valid credentials', async () => { @@ -232,6 +286,79 @@ describe('auth.controller', () => { ); }); + test('returns onboarding redirect for authenticated users with incomplete onboarding', async () => { + const user = mockUser(); + Developer.findOne.mockReturnValue(Developer.__mockQuery(user)); + bcrypt.compare.mockResolvedValue(true); + jwt.sign.mockReturnValue('signed_token'); + + const req = makeReq({ email: 'test@example.com', password: 'correctpass' }); + const res = makeRes(); + + await authController.login(req, res, next); + + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + redirectTo: '/onboarding', + user: expect.objectContaining({ + onboarding: expect.objectContaining({ completed: false }), + }), + }), + })); + }); + + test('returns onboarding redirect for unverified users', async () => { + const user = mockUser({ isVerified: false }); + Developer.findOne.mockReturnValue(Developer.__mockQuery(user)); + bcrypt.compare.mockResolvedValue(true); + jwt.sign.mockReturnValue('signed_token'); + + const req = makeReq({ email: 'test@example.com', password: 'correctpass' }); + const res = makeRes(); + + await authController.login(req, res, next); + + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + redirectTo: '/onboarding', + user: expect.objectContaining({ + isVerified: false, + }), + }), + })); + }); + + test('returns dashboard redirect for authenticated users with completed onboarding', async () => { + const user = mockUser({ + onboarding: { + completed: true, + steps: { + projectCreated: true, + collectionCreated: true, + firstApiCall: true, + }, + activationAt: new Date('2026-06-08T00:00:00.000Z'), + }, + }); + Developer.findOne.mockReturnValue(Developer.__mockQuery(user)); + bcrypt.compare.mockResolvedValue(true); + jwt.sign.mockReturnValue('signed_token'); + + const req = makeReq({ email: 'test@example.com', password: 'correctpass' }); + const res = makeRes(); + + await authController.login(req, res, next); + + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + redirectTo: '/dashboard', + user: expect.objectContaining({ + onboarding: expect.objectContaining({ completed: true }), + }), + }), + })); + }); + test('returns 400 when user is not found', async () => { Developer.findOne.mockReturnValue(Developer.__mockQuery(null)); @@ -395,6 +522,37 @@ describe('auth.controller', () => { ); }); + test('returns default onboarding state for users without stored onboarding', async () => { + const mockSelect = jest.fn().mockResolvedValue({ + _id: 'dev_id_1', + email: 'test@example.com', + }); + Developer.findById.mockReturnValue({ select: mockSelect }); + + const req = makeReq({}, { _id: 'dev_id_1' }); + const res = makeRes(); + + await authController.getMe(req, res, next); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: { + user: expect.objectContaining({ + onboarding: { + completed: false, + steps: { + projectCreated: false, + collectionCreated: false, + firstApiCall: false, + }, + activationAt: null, + }, + }), + }, + message: 'Success', + }); + }); + test('returns 404 when user does not exist', async () => { Developer.findById.mockReturnValue({ select: jest.fn().mockResolvedValue(null), @@ -411,6 +569,68 @@ describe('auth.controller', () => { }); }); + // ----------------------------------------------------------------------- + describe('updateOnboarding', () => { + test('updates only the authenticated developer onboarding state', async () => { + const req = makeReq( + { completed: false, steps: { projectCreated: true } }, + { _id: 'dev_id_1' } + ); + const res = makeRes(); + + await authController.updateOnboarding(req, res, next); + + expect(updateDeveloperOnboarding).toHaveBeenCalledWith('dev_id_1', { + completed: false, + steps: { projectCreated: true }, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: { + onboarding: { + completed: false, + steps: { + projectCreated: true, + collectionCreated: false, + firstApiCall: false, + }, + activationAt: null, + }, + }, + message: 'Onboarding updated successfully', + }); + }); + + test('rejects activationAt updates', async () => { + const req = makeReq( + { activationAt: '2026-06-08T00:00:00.000Z' }, + { _id: 'dev_id_1' } + ); + const res = makeRes(); + + await authController.updateOnboarding(req, res, next); + + expect(updateDeveloperOnboarding).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + }); + + test('rejects malformed nested step objects', async () => { + const req = makeReq( + { steps: { firstApiCall: 'yes' } }, + { _id: 'dev_id_1' } + ); + const res = makeRes(); + + await authController.updateOnboarding(req, res, next); + + expect(updateDeveloperOnboarding).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + }); + }); + // ----------------------------------------------------------------------- describe('changePassword', () => { test('returns 200 on successful password change', async () => { diff --git a/apps/dashboard-api/src/__tests__/onboarding.common.test.js b/apps/dashboard-api/src/__tests__/onboarding.common.test.js new file mode 100644 index 000000000..a1e14ea4b --- /dev/null +++ b/apps/dashboard-api/src/__tests__/onboarding.common.test.js @@ -0,0 +1,126 @@ +'use strict'; + +jest.mock('../../../../packages/common/src/models/Developer', () => ({ + findById: jest.fn(), +})); + +const Developer = require('../../../../packages/common/src/models/Developer'); +const { + markDeveloperOnboardingStep, + updateDeveloperOnboarding, +} = require('../../../../packages/common/src/utils/onboarding'); + +const setByPath = (target, path, value) => { + const parts = path.split('.'); + if (parts.some((part) => part === '__proto__' || part === 'constructor' || part === 'prototype')) { + return; + } + let cursor = target; + for (let i = 0; i < parts.length - 1; i += 1) { + cursor[parts[i]] = cursor[parts[i]] || {}; + cursor = cursor[parts[i]]; + } + cursor[parts[parts.length - 1]] = value; +}; + +const makeDeveloper = (onboarding = {}) => ({ + onboarding: { + completed: false, + steps: { + projectCreated: false, + collectionCreated: false, + firstApiCall: false, + ...(onboarding.steps || {}), + }, + activationAt: onboarding.activationAt || null, + ...onboarding, + }, + set: jest.fn(function(path, value) { + setByPath(this, path, value); + }), + save: jest.fn().mockResolvedValue(undefined), +}); + +describe('common onboarding sequencing', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('allows the next sequential step', async () => { + const developer = makeDeveloper(); + Developer.findById.mockReturnValue({ select: jest.fn().mockResolvedValue(developer) }); + + const onboarding = await updateDeveloperOnboarding('dev_1', { + steps: { projectCreated: true }, + }); + + expect(developer.set).toHaveBeenCalledWith('onboarding.steps.projectCreated', true); + expect(developer.save).toHaveBeenCalled(); + expect(onboarding.steps.projectCreated).toBe(true); + expect(onboarding.completed).toBe(false); + }); + + test('rejects skipped steps', async () => { + const developer = makeDeveloper(); + Developer.findById.mockReturnValue({ select: jest.fn().mockResolvedValue(developer) }); + + await expect(updateDeveloperOnboarding('dev_1', { + steps: { collectionCreated: true }, + })).rejects.toMatchObject({ + message: 'Complete projectCreated before collectionCreated.', + statusCode: 409, + }); + + expect(developer.save).not.toHaveBeenCalled(); + }); + + test('allows forced completion (skip flow)', async () => { + const developer = makeDeveloper({ steps: { projectCreated: true } }); + Developer.findById.mockReturnValue({ select: jest.fn().mockResolvedValue(developer) }); + + // completed:true is now allowed even if steps are incomplete — user chose to skip + const onboarding = await updateDeveloperOnboarding('dev_1', { + completed: true, + }); + + expect(onboarding).toBeTruthy(); + expect(developer.save).toHaveBeenCalled(); + }); + + test('rejects reset attempts', async () => { + const developer = makeDeveloper({ steps: { projectCreated: true } }); + Developer.findById.mockReturnValue({ select: jest.fn().mockResolvedValue(developer) }); + + await expect(updateDeveloperOnboarding('dev_1', { + steps: { projectCreated: false }, + })).rejects.toMatchObject({ + message: 'Onboarding progress cannot be reset.', + statusCode: 409, + }); + }); + + test('rejects mutation after completion', async () => { + const developer = makeDeveloper({ + completed: true, + steps: { projectCreated: true, collectionCreated: true, firstApiCall: true }, + }); + Developer.findById.mockReturnValue({ select: jest.fn().mockResolvedValue(developer) }); + + await expect(updateDeveloperOnboarding('dev_1', { + steps: { projectCreated: true }, + })).rejects.toMatchObject({ + message: 'Onboarding is already completed.', + statusCode: 409, + }); + }); + + test('automatic first API call marker also enforces prerequisites', async () => { + const developer = makeDeveloper({ steps: { projectCreated: true } }); + Developer.findById.mockReturnValue({ select: jest.fn().mockResolvedValue(developer) }); + + await expect(markDeveloperOnboardingStep('dev_1', 'firstApiCall')).rejects.toMatchObject({ + message: 'Complete collectionCreated before firstApiCall.', + statusCode: 409, + }); + }); +}); diff --git a/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js b/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js new file mode 100644 index 000000000..17f0bac8a --- /dev/null +++ b/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js @@ -0,0 +1,134 @@ +jest.mock('@urbackend/common', () => { + class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } + } + + const Project = { + countDocuments: jest.fn(), + findOne: jest.fn(), + }; + + return { + AppError, + Project, + sanitizeObjectId: jest.fn((value) => value || null), + resolveEffectivePlan: jest.fn(() => 'free'), + getPlanLimits: jest.fn(() => ({ maxProjects: 1, maxCollections: 5 })), + }; +}); + +const { Project, AppError } = require('@urbackend/common'); +const { checkDeveloperCapability, checkProjectLimit, checkCollectionLimit } = require('../middlewares/planEnforcement'); + +const makeReq = (overrides = {}) => ({ + user: { _id: 'dev_1', email: 'dev@example.com', isVerified: false }, + developer: { _id: 'dev_1', isVerified: false }, + body: {}, + params: {}, + query: {}, + ...overrides, +}); + +describe('planEnforcement capability checks', () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.ADMIN_EMAIL; + }); + + test('allows an unverified developer to create their first project', async () => { + Project.countDocuments.mockResolvedValue(0); + const req = makeReq(); + const next = jest.fn(); + + await checkDeveloperCapability('createProject')(req, {}, next); + + expect(Project.countDocuments).toHaveBeenCalledWith({ owner: 'dev_1' }); + expect(req.projectLimit).toBe(1); + expect(next).toHaveBeenCalledWith(); + }); + + test('skips plan project limit for unverified developers so sandbox capability owns it', async () => { + const req = makeReq(); + const next = jest.fn(); + + await checkProjectLimit(req, {}, next); + + expect(req.projectLimit).toBeUndefined(); + expect(next).toHaveBeenCalledWith(); + }); + + test('blocks additional projects for unverified developers', async () => { + Project.countDocuments.mockResolvedValue(1); + const next = jest.fn(); + + await checkDeveloperCapability('createProject')(makeReq(), {}, next); + + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + expect(next.mock.calls[0][0].message).toContain('Verify your email'); + }); + + test('allows unverified developers to create up to three collections', async () => { + Project.findOne.mockReturnValue({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({ collections: [{ name: 'posts' }, { name: 'comments' }] }), + }); + const req = makeReq({ body: { projectId: 'project_1' } }); + const next = jest.fn(); + + await checkDeveloperCapability('createCollection')(req, {}, next); + + expect(req.collectionLimit).toBe(3); + expect(next).toHaveBeenCalledWith(); + }); + + test('skips plan collection limit for unverified developers so sandbox capability owns it', async () => { + const req = makeReq({ body: { projectId: 'project_1' } }); + const next = jest.fn(); + + await checkCollectionLimit(req, {}, next); + + expect(req.collectionLimit).toBeUndefined(); + expect(Project.findOne).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); + + test('blocks collection creation beyond the unverified sandbox limit', async () => { + Project.findOne.mockReturnValue({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({ + collections: [{ name: 'one' }, { name: 'two' }, { name: 'three' }], + }), + }); + const next = jest.fn(); + + await checkDeveloperCapability('createCollection')(makeReq({ body: { projectId: 'project_1' } }), {}, next); + + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + }); + + test('blocks key reveal for unverified developers', async () => { + const next = jest.fn(); + + await checkDeveloperCapability('revealApiKeys')(makeReq(), {}, next); + + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + }); + + test('allows verified developers to use protected capabilities', async () => { + const next = jest.fn(); + const req = makeReq({ + user: { _id: 'dev_1', email: 'dev@example.com', isVerified: true }, + developer: { _id: 'dev_1', isVerified: true }, + }); + + await checkDeveloperCapability('revealApiKeys')(req, {}, next); + + expect(next).toHaveBeenCalledWith(); + }); +}); diff --git a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js index 028601bf9..b16ef5531 100644 --- a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js +++ b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js @@ -11,6 +11,7 @@ jest.mock('../middlewares/planEnforcement', () => ({ attachDeveloper: jest.fn((_req, _res, next) => next()), checkProjectLimit: jest.fn((_req, _res, next) => next()), checkCollectionLimit: jest.fn((_req, _res, next) => next()), + checkDeveloperCapability: jest.fn(() => (_req, _res, next) => next()), checkByokGate: jest.fn((_req, _res, next) => next()), checkByodGate: jest.fn((_req, _res, next) => next()), checkWebhookGate: jest.fn((_req, _res, next) => next()), @@ -21,6 +22,7 @@ jest.mock('../middlewares/planEnforcement', () => ({ jest.mock('@urbackend/common', () => ({ verifyEmail: jest.fn((_req, _res, next) => next()), checkAuthEnabled: jest.fn((_req, _res, next) => next()), + loadProjectForAdmin: jest.fn((_req, _res, next) => next()), })); const mockAuthZ = jest.fn((req, res, next) => next()); @@ -83,6 +85,7 @@ jest.mock('../controllers/project.controller', () => { manageContacts: jest.fn(ok), deleteContact: jest.fn(ok), sendMarketingBroadcast: jest.fn(ok), + revealSecretKey: jest.fn(ok), getMembers: jest.fn(ok), inviteMember: jest.fn(ok), updateMemberRole: jest.fn(ok), @@ -108,6 +111,28 @@ beforeEach(() => { }); describe('projects storage presigned routes', () => { + test('project creation uses sandbox capabilities without email verification middleware', async () => { + const res = await request(app) + .post('/api/projects') + .send({ name: 'Sandbox Project' }); + + expect(res.status).toBe(200); + expect(authMiddleware).toHaveBeenCalled(); + expect(verifyEmail).not.toHaveBeenCalled(); + expect(projectController.createProject).toHaveBeenCalledTimes(1); + }); + + test('collection creation uses sandbox capabilities without email verification middleware', async () => { + const res = await request(app) + .post('/api/projects/project1/collections') + .send({ projectId: 'project1', collectionName: 'posts', schema: [] }); + + expect(res.status).toBe(200); + expect(authMiddleware).toHaveBeenCalled(); + expect(verifyEmail).not.toHaveBeenCalled(); + expect(projectController.createCollection).toHaveBeenCalledTimes(1); + }); + test('legacy proxy upload route is removed', async () => { const res = await request(app) .post('/api/projects/project1/storage/upload') diff --git a/apps/dashboard-api/src/__tests__/routes.user.test.js b/apps/dashboard-api/src/__tests__/routes.user.test.js new file mode 100644 index 000000000..75f7235a5 --- /dev/null +++ b/apps/dashboard-api/src/__tests__/routes.user.test.js @@ -0,0 +1,88 @@ +'use strict'; + +jest.mock('../middlewares/authMiddleware', () => + jest.fn((req, _res, next) => { + req.user = { _id: 'mock_user_id', email: 'test@example.com' }; + next(); + }) +); + +jest.mock('../controllers/auth.controller', () => ({ + getMe: jest.fn((_req, res) => res.json({ + success: true, + data: { + user: { + onboarding: { + completed: false, + steps: { + projectCreated: false, + collectionCreated: false, + firstApiCall: false, + }, + activationAt: null, + }, + }, + }, + message: 'Success', + })), + updateOnboarding: jest.fn((_req, res) => res.json({ + success: true, + data: { + onboarding: { + completed: false, + steps: { + projectCreated: true, + collectionCreated: false, + firstApiCall: false, + }, + activationAt: null, + }, + }, + message: 'Onboarding updated successfully', + })), +})); + +const express = require('express'); +const request = require('supertest'); +const userRouter = require('../routes/user'); +const authMiddleware = require('../middlewares/authMiddleware'); +const authController = require('../controllers/auth.controller'); + +describe('user routes', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use('/api/user', userRouter); + }); + + test('GET /api/user/me is wired to authenticated getMe', async () => { + const res = await request(app).get('/api/user/me'); + + expect(res.status).toBe(200); + expect(authMiddleware).toHaveBeenCalledTimes(1); + expect(authController.getMe).toHaveBeenCalledTimes(1); + expect(res.body.data.user.onboarding).toEqual({ + completed: false, + steps: { + projectCreated: false, + collectionCreated: false, + firstApiCall: false, + }, + activationAt: null, + }); + }); + + test('PATCH /api/user/onboarding is wired to authenticated updateOnboarding', async () => { + const res = await request(app) + .patch('/api/user/onboarding') + .send({ steps: { projectCreated: true } }); + + expect(res.status).toBe(200); + expect(authMiddleware).toHaveBeenCalledTimes(1); + expect(authController.updateOnboarding).toHaveBeenCalledTimes(1); + expect(res.body.data.onboarding.steps.projectCreated).toBe(true); + }); +}); diff --git a/apps/dashboard-api/src/app.js b/apps/dashboard-api/src/app.js index 06eb3ee0c..abbda5828 100644 --- a/apps/dashboard-api/src/app.js +++ b/apps/dashboard-api/src/app.js @@ -70,7 +70,8 @@ const csrfProtection = csurf({ cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' + sameSite: 'lax', + maxAge: 24 * 60 * 60 // 24 hours — persistent cookie, survives idle/focus loss } }); @@ -98,6 +99,7 @@ app.use(capture({ const authRoute = require('./routes/auth'); +const userRoute = require('./routes/user'); const projectRoute = require('./routes/projects'); const releaseRoute = require('./routes/releases'); const webhookRoute = require('./routes/webhooks'); @@ -109,6 +111,7 @@ const aiRoute = require('./routes/ai.routes'); const invitationsRoute = require('./routes/invitations'); app.use('/api/auth', authRoute); +app.use('/api/user', dashboardLimiter, userRoute); app.use('/api/projects', dashboardLimiter, projectRoute); app.use('/api/projects/:projectId/ai', dashboardLimiter, aiRoute); app.use('/api/projects', dashboardLimiter, webhookRoute); diff --git a/apps/dashboard-api/src/controllers/admin.metrics.controller.js b/apps/dashboard-api/src/controllers/admin.metrics.controller.js index 6c51bbd0c..179d0b81d 100644 --- a/apps/dashboard-api/src/controllers/admin.metrics.controller.js +++ b/apps/dashboard-api/src/controllers/admin.metrics.controller.js @@ -58,21 +58,36 @@ module.exports.getOverview = async (req, res, next) => { // --------------------------------------------------------------------------- module.exports.getActivationFunnel = async (req, res, next) => { try { - const FUNNEL_STEPS = [ - 'signup_completed', - 'email_verified', - 'project_created', - 'collection_created', - 'first_api_success', - ]; - - const counts = await PlatformEvent.aggregate([ - { $match: { event: { $in: FUNNEL_STEPS } } }, - { - $group: { - _id: { event: '$event', developerId: '$developerId' }, - }, - }, + const FUNNEL_STEPS = [ + 'signup_completed', + 'email_verified', + 'project_created', + 'collection_created', + 'first_api_call', + ]; + const EVENT_ALIASES = { + first_api_success: 'first_api_call', + }; + + const counts = await PlatformEvent.aggregate([ + { $match: { event: { $in: [...FUNNEL_STEPS, ...Object.keys(EVENT_ALIASES)] } } }, + { + $addFields: { + funnelEvent: { + $switch: { + branches: [ + { case: { $eq: ['$event', 'first_api_success'] }, then: 'first_api_call' }, + ], + default: '$event', + }, + }, + }, + }, + { + $group: { + _id: { event: '$funnelEvent', developerId: '$developerId' }, + }, + }, { $group: { _id: '$_id.event', diff --git a/apps/dashboard-api/src/controllers/analytics.controller.js b/apps/dashboard-api/src/controllers/analytics.controller.js index 2f388fe65..97dcd01d8 100644 --- a/apps/dashboard-api/src/controllers/analytics.controller.js +++ b/apps/dashboard-api/src/controllers/analytics.controller.js @@ -122,13 +122,16 @@ module.exports.getActivationFunnel = async (req, res, next) => { 'email_verified', 'project_created', 'collection_created', - 'first_api_success', + 'first_api_call', ]; + const EVENT_ALIASES = { + first_api_success: 'first_api_call', + }; // Fetch one event per step (we only need existence, not count) const events = await PlatformEvent.find({ developerId, - event: { $in: FUNNEL_STEPS }, + event: { $in: [...FUNNEL_STEPS, ...Object.keys(EVENT_ALIASES)] }, }) .sort({ timestamp: 1 }) .select('event timestamp') @@ -136,7 +139,8 @@ module.exports.getActivationFunnel = async (req, res, next) => { const completed = {}; for (const e of events) { - if (!completed[e.event]) completed[e.event] = e.timestamp; + const step = EVENT_ALIASES[e.event] || e.event; + if (!completed[step]) completed[step] = e.timestamp; } const steps = FUNNEL_STEPS.map((step, i) => ({ diff --git a/apps/dashboard-api/src/controllers/auth.controller.js b/apps/dashboard-api/src/controllers/auth.controller.js index 1bcc5255c..623d5b20d 100644 --- a/apps/dashboard-api/src/controllers/auth.controller.js +++ b/apps/dashboard-api/src/controllers/auth.controller.js @@ -14,7 +14,10 @@ const { resetPasswordSchema, verifyOtpSchema, AppError, - ApiResponse + ApiResponse, + normalizeOnboarding, + updateOnboardingSchema, + updateDeveloperOnboarding } = require("@urbackend/common"); const { emitEvent } = require('../utils/emitEvent'); @@ -88,6 +91,10 @@ const clearGithubStateCookie = (res) => { expires: new Date(Date.now() + 10 * 1000), }); }; +const getPostAuthRedirectPath = (user) => { + const onboarding = normalizeOnboarding(user?.onboarding); + return onboarding.completed ? '/dashboard' : '/onboarding'; +}; const OAUTH_FETCH_TIMEOUT_MS = 10000; @@ -236,6 +243,7 @@ const issueDashboardSession = async (user, res) => { const sendTokenResponse = async (user, statusCode, res) => { await issueDashboardSession(user, res); + const onboarding = normalizeOnboarding(user.onboarding); return new ApiResponse({ user: { @@ -243,8 +251,10 @@ const sendTokenResponse = async (user, statusCode, res) => { email: user.email, isVerified: user.isVerified, maxProjects: user.maxProjects, - isAdmin: user.email === process.env.ADMIN_EMAIL - } + isAdmin: user.email === process.env.ADMIN_EMAIL, + onboarding + }, + redirectTo: getPostAuthRedirectPath(user) }).send(res, statusCode); }; @@ -307,8 +317,9 @@ module.exports.register = async (req, res, next) => { // Activation funnel — signup completed emitEvent(newDev._id, 'signup_completed', { method: 'email' }); - return new ApiResponse({}, "Registered successfully").send(res, 201); + return sendTokenResponse(newDev, 201, res); } catch (err) { + console.error("REGISTER ERROR:", err); if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); next(err); } @@ -327,6 +338,7 @@ module.exports.login = async (req, res, next) => { await sendTokenResponse(dev, 200, res); } catch (err) { + console.error("LOGIN ERROR:", err); if (err instanceof z.ZodError) { return next(new AppError(400, err.issues[0]?.message || 'Validation Failed')); } @@ -466,7 +478,18 @@ module.exports.sendOtp = async (req, res, next) => { const otp = await createAndStoreOtp(existingUser._id); - await sendOtp(email, otp); // Send raw OTP to user's email + try { + await sendOtp(email, otp); // Send raw OTP to user's email + } catch (emailErr) { + if (process.env.NODE_ENV !== 'production' || emailErr.message?.includes('testing emails')) { + console.log(`\n==================================================`); + console.log(`[DEV MODE] Failed to send email via Resend sandbox.`); + console.log(`OTP code for ${email} was generated but could not be sent.`); + console.log(`==================================================\n`); + } else { + throw emailErr; + } + } return new ApiResponse({}, "OTP sent successfully").send(res); } catch (err) { if (err instanceof z.ZodError) { @@ -511,7 +534,18 @@ module.exports.forgotPassword = async (req, res, next) => { const otp = await createAndStoreOtp(dev._id); - await sendOtp(email, otp, { subject: "Password Reset OTP \u2014 urBackend" }); + try { + await sendOtp(email, otp, { subject: "Password Reset OTP \u2014 urBackend" }); + } catch (emailErr) { + if (process.env.NODE_ENV !== 'production' || emailErr.message?.includes('testing emails')) { + console.log(`\n==================================================`); + console.log(`[DEV MODE] Failed to send email via Resend sandbox.`); + console.log(`Password reset OTP code for ${email} was generated but could not be sent.`); + console.log(`==================================================\n`); + } else { + throw emailErr; + } + } return new ApiResponse({}, "If this email is registered, an OTP has been sent.").send(res, 200); } catch (err) { if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); @@ -618,9 +652,23 @@ module.exports.getMe = async (req, res, next) => { const user = await Developer.findById(req.user._id).select("-password -refreshToken"); if (!user) return next(new AppError(404, "User not found")); const userData = typeof user.toObject === 'function' ? user.toObject() : { ...user }; + userData.onboarding = normalizeOnboarding(userData.onboarding); userData.isAdmin = userData.email === process.env.ADMIN_EMAIL; return new ApiResponse({ user: userData }).send(res); } catch (err) { next(err); } }; + +// UPDATE ONBOARDING +module.exports.updateOnboarding = async (req, res, next) => { + try { + const parsedData = updateOnboardingSchema.parse(req.body); + const onboarding = await updateDeveloperOnboarding(req.user._id, parsedData); + return new ApiResponse({ onboarding }, "Onboarding updated successfully").send(res, 200); + } catch (err) { + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + if (err.statusCode) return next(new AppError(err.statusCode, err.message)); + next(err); + } +}; diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index c51be16a0..2ec940106 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -15,6 +15,7 @@ const { sanitizeNonEmptyString, } = require("@urbackend/common"); const { generateApiKey, hashApiKey } = require("@urbackend/common"); +const { markDeveloperOnboardingStep } = require("@urbackend/common"); const { z } = require("zod"); const { encrypt, decrypt } = require("@urbackend/common"); const { URL } = require("url"); @@ -165,8 +166,13 @@ const sanitizeAuthProviders = (authProviders = {}) => { }; const sanitizeProjectResponse = (projectObj) => { - delete projectObj.publishableKey; + if (projectObj.publishableKey && projectObj.publishableKey.startsWith('pk_live_')) { + // Keep plaintext public key + } else { + projectObj.publishableKey = 'pk_live_••••••••'; + } delete projectObj.secretKey; + delete projectObj.secretKeyEncrypted; delete projectObj.jwtSecret; const resendConfig = projectObj.resendApiKey; projectObj.hasResendApiKey = @@ -197,6 +203,20 @@ const sanitizeProjectResponse = (projectObj) => { return projectObj; }; +const prepareCreatedProjectResponse = (projectObj, user) => { + const canRevealKeys = Boolean(user?.isVerified); + delete projectObj.jwtSecret; + projectObj.authProviders = sanitizeAuthProviders(projectObj.authProviders); + + if (!canRevealKeys) { + delete projectObj.publishableKey; + delete projectObj.secretKey; + projectObj.apiKeysLocked = true; + } + + return projectObj; +}; + const parsePositiveSize = (size) => { const numericSize = Number(size); if (!Number.isFinite(numericSize) || numericSize <= 0) { @@ -249,6 +269,16 @@ module.exports.createProject = async (req, res) => { const executeOperation = async (session) => { const { name, description, siteUrl } = createProjectSchema.parse(req.body); + if (!req.user.onboarding?.completed) { + const queryOpts = session ? { session } : {}; + const existing = await Project.findOne({ owner: req.user._id }, null, queryOpts); + if (existing) { + const projectObj = existing.toObject(); + prepareCreatedProjectResponse(projectObj, req.user); + return { projectObj, newProject: existing }; + } + } + if (req.projectLimit !== undefined) { const queryOpts = session ? { session } : {}; const currentCount = await Project.countDocuments( @@ -264,10 +294,10 @@ module.exports.createProject = async (req, res) => { } const rawPublishableKey = generateApiKey("pk_live_"); - const hashedPublishableKey = hashApiKey(rawPublishableKey); const rawSecretKey = generateApiKey("sk_live_"); const hashedSecretKey = hashApiKey(rawSecretKey); + const encryptedSecretKey = encrypt(rawSecretKey); const rawJwtSecret = generateApiKey("jwt_"); @@ -275,8 +305,10 @@ module.exports.createProject = async (req, res) => { name, description, owner: req.user._id, - publishableKey: hashedPublishableKey, + publishableKey: rawPublishableKey, secretKey: hashedSecretKey, + secretKeyEncrypted: encryptedSecretKey, + secretKeyRevealed: false, jwtSecret: rawJwtSecret, siteUrl: siteUrl || "", }); @@ -287,12 +319,21 @@ module.exports.createProject = async (req, res) => { const projectObj = newProject.toObject(); projectObj.publishableKey = rawPublishableKey; projectObj.secretKey = rawSecretKey; - delete projectObj.jwtSecret; - projectObj.authProviders = sanitizeAuthProviders(projectObj.authProviders); + prepareCreatedProjectResponse(projectObj, req.user); return { projectObj, newProject }; }; + if (!req.user.onboarding?.completed) { + const existing = await Project.findOne({ owner: req.user._id }).lean(); + if (existing) { + await markDeveloperOnboardingStep(req.user._id, 'projectCreated', { projectId: existing._id }); + const projectObj = { ...existing }; + prepareCreatedProjectResponse(projectObj, req.user); + return res.status(201).json(projectObj); + } + } + let session = null; try { session = await mongoose.startSession(); @@ -303,6 +344,17 @@ module.exports.createProject = async (req, res) => { await session.commitTransaction(); session.endSession(); + markDeveloperOnboardingStep(req.user._id, 'projectCreated', { projectId: newProject._id }) + .then(() => { + // Also reset subsequent steps since this is a new project + return Promise.all([ + markDeveloperOnboardingStep(req.user._id, 'collectionCreated', { _reset: true }), + markDeveloperOnboardingStep(req.user._id, 'firstApiCall', { _reset: true }) + ]); + }) + .catch((err) => { + console.error('[onboarding] Failed to mark projectCreated:', err.message); + }); emitEvent(req.user._id, 'project_created', { projectName: projectObj.name }, newProject._id); return res.status(201).json(projectObj); } catch (err) { @@ -314,6 +366,9 @@ module.exports.createProject = async (req, res) => { if (err.message && (err.message.includes("Transaction numbers are only allowed") || err.message.includes("buffering timed out"))) { try { const { projectObj, newProject } = await executeOperation(null); + markDeveloperOnboardingStep(req.user._id, 'projectCreated', { projectId: newProject._id }).catch((err) => { + console.error('[onboarding] Failed to mark projectCreated:', err.message); + }); emitEvent(req.user._id, 'project_created', { projectName: projectObj.name }, newProject._id); return res.status(201).json(projectObj); } catch (retryErr) { @@ -452,8 +507,12 @@ module.exports.regenerateApiKey = async (req, res) => { const updateField = keyType === "publishable" - ? { publishableKey: hashed } - : { secretKey: hashed }; + ? { publishableKey: newApiKey } + : { + secretKey: hashed, + secretKeyEncrypted: encrypt(newApiKey), + secretKeyRevealed: false + }; const project = await Project.findOneAndUpdate( { _id: req.params.projectId, ...getProjectAccessQuery(req.user._id) }, @@ -462,10 +521,7 @@ module.exports.regenerateApiKey = async (req, res) => { ); if (!project) return res.status(404).json({ error: "Project not found." }); - const projectObj = project.toObject(); - delete projectObj.publishableKey; - delete projectObj.secretKey; - delete projectObj.jwtSecret; + const projectObj = sanitizeProjectResponse(project.toObject()); res.json({ apiKey: newApiKey, keyType, project: projectObj }); } catch (err) { res.status(500).json({ error: err.message }); @@ -818,6 +874,22 @@ module.exports.createCollection = async (req, res) => { return { project, connection, compiledCollectionName, collectionExistedBefore, projectId, collectionName }; }; + if (!req.user.onboarding?.completed) { + const projectId = sanitizeObjectId(req.body.projectId); + const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + if (project) { + const customCol = project.collections.find(c => c.name !== 'users'); + if (customCol) { + await markDeveloperOnboardingStep(req.user._id, 'collectionCreated', { collectionId: customCol._id }); + const projectObj = project.toObject(); + delete projectObj.publishableKey; + delete projectObj.secretKey; + delete projectObj.jwtSecret; + return res.status(201).json(projectObj); + } + } + } + let session = null; try { session = await mongoose.startSession(); @@ -838,6 +910,12 @@ module.exports.createCollection = async (req, res) => { delete projectObj.secretKey; delete projectObj.jwtSecret; + const createdCol = project.collections.find((c) => c.name === collectionName); + if (collectionName !== "users") { + markDeveloperOnboardingStep(req.user._id, 'collectionCreated', { collectionId: createdCol?._id }).catch((err) => { + console.error('[onboarding] Failed to mark collectionCreated:', err.message); + }); + } emitEvent(req.user._id, 'collection_created', { collectionName, isUsersCollection: collectionName === 'users' }, projectId); return res.status(201).json(projectObj); @@ -860,6 +938,12 @@ module.exports.createCollection = async (req, res) => { delete projectObj.secretKey; delete projectObj.jwtSecret; + const createdCol = project.collections.find((c) => c.name === collectionName); + if (collectionName !== "users") { + markDeveloperOnboardingStep(req.user._id, 'collectionCreated', { collectionId: createdCol?._id }).catch((err) => { + console.error('[onboarding] Failed to mark collectionCreated:', err.message); + }); + } emitEvent(req.user._id, 'collection_created', { collectionName, isUsersCollection: collectionName === 'users' }, projectId); return res.status(201).json(projectObj); @@ -2854,6 +2938,40 @@ module.exports.sendMarketingBroadcast = async (req, res) => { } }; +module.exports.revealSecretKey = async (req, res) => { + try { + const project = await Project.findOne({ + _id: req.params.projectId, + owner: req.user._id, + }).select("+secretKeyEncrypted secretKeyRevealed"); + + if (!project) { + return res.status(404).json({ success: false, data: {}, message: "Project not found." }); + } + + if (!req.user.isVerified) { + return res.status(403).json({ success: false, data: {}, message: "Account verification required to reveal secret key." }); + } + + if (project.secretKeyRevealed) { + return res.status(400).json({ success: false, data: {}, message: "Secret key has already been revealed once." }); + } + + if (!project.secretKeyEncrypted || !project.secretKeyEncrypted.encrypted) { + return res.status(404).json({ success: false, data: {}, message: "Secret key cannot be decrypted (it may have been rolled or cleared)." }); + } + + const decryptedKey = decrypt(project.secretKeyEncrypted); + + project.secretKeyRevealed = true; + project.secretKeyEncrypted = null; + await project.save(); + + res.json({ success: true, data: { secretKey: decryptedKey }, message: "Secret key revealed successfully." }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; // ───────────────────────────────────────────────────────────────────────────── // TEAM MEMBER MANAGEMENT // ───────────────────────────────────────────────────────────────────────────── @@ -3077,3 +3195,4 @@ module.exports.removeMember = async (req, res, next) => { } }; + diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js index 2fc6f4575..792bb7bbf 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -1,7 +1,12 @@ -console.log('DEBUG: planEnforcement.js loading...'); const mongoose = require('mongoose'); -console.log('DEBUG: planEnforcement.js exporting attachDeveloper'); +const UNVERIFIED_PROJECT_LIMIT = 1; +const UNVERIFIED_COLLECTION_LIMIT = 3; + +const isAdminRequest = (req) => { + return req.user?.isAdmin || req.user?.email?.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase(); +}; + exports.attachDeveloper = async function(req, res, next) { const { Developer, AppError, Project, sanitizeObjectId } = require('@urbackend/common'); try { @@ -29,11 +34,12 @@ exports.attachDeveloper = async function(req, res, next) { } } -console.log('DEBUG: planEnforcement.js exporting checkProjectLimit'); exports.checkProjectLimit = async function(req, res, next) { const { resolveEffectivePlan, getPlanLimits } = require('@urbackend/common'); try { - if (req.user?.isAdmin || req.user?.email?.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase()) return next(); + if (isAdminRequest(req)) return next(); + if (!req.developer?.isVerified) return next(); + const effectivePlan = resolveEffectivePlan(req.developer); const limits = getPlanLimits({ plan: effectivePlan }); if (limits.maxProjects === -1) return next(); @@ -44,11 +50,58 @@ exports.checkProjectLimit = async function(req, res, next) { } } -console.log('DEBUG: planEnforcement.js exporting checkCollectionLimit'); +exports.checkDeveloperCapability = function(capability) { + return async function(req, res, next) { + const { Project, AppError, sanitizeObjectId } = require('@urbackend/common'); + try { + if (isAdminRequest(req)) { + return next(); + } + + const isVerified = !!(req.developer?.isVerified ?? req.user?.isVerified); + + if (isVerified) { + return next(); + } + + if (capability === 'createProject') { + const currentCount = await Project.countDocuments({ owner: req.user._id }); + if (currentCount >= UNVERIFIED_PROJECT_LIMIT) { + return next(new AppError(403, 'Verify your email to create additional projects.')); + } + req.projectLimit = Math.min(req.projectLimit ?? UNVERIFIED_PROJECT_LIMIT, UNVERIFIED_PROJECT_LIMIT); + return next(); + } + + if (capability === 'createCollection') { + const cleanProjectId = sanitizeObjectId(req.body.projectId || req.params.projectId); + if (!cleanProjectId) return next(new AppError(400, 'Invalid or missing projectId')); + + const project = await Project.findOne({ _id: cleanProjectId, owner: req.user._id }) + .select('collections') + .lean(); + if (!project) return next(new AppError(404, 'Project not found')); + + if ((project.collections || []).length >= UNVERIFIED_COLLECTION_LIMIT) { + return next(new AppError(403, 'Verify your email to create more than 3 collections.')); + } + + req.collectionLimit = Math.min(req.collectionLimit ?? UNVERIFIED_COLLECTION_LIMIT, UNVERIFIED_COLLECTION_LIMIT); + return next(); + } + + return next(new AppError(403, 'Email verification is required for this action.')); + } catch (err) { + next(err); + } + }; +} + exports.checkCollectionLimit = async function(req, res, next) { const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId, getProjectAccessQuery } = require('@urbackend/common'); try { - if (req.user?.isAdmin || req.user?.email?.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase()) return next(); + if (isAdminRequest(req)) return next(); + if (!req.developer?.isVerified) return next(); const cleanProjectId = sanitizeObjectId(req.body.projectId); if (!cleanProjectId) return next(new AppError(400, 'Invalid or missing projectId')); @@ -69,11 +122,10 @@ exports.checkCollectionLimit = async function(req, res, next) { } } -console.log('DEBUG: planEnforcement.js exporting checkByodGate'); exports.checkByodGate = async function(req, res, next) { const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId } = require('@urbackend/common'); try { - if (req.user?.isAdmin || req.user?.email?.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase()) return next(); + if (isAdminRequest(req)) return next(); const { dbUri, storageUrl, storageKey } = req.body; if (!dbUri && !storageUrl && !storageKey) return next(); @@ -97,11 +149,10 @@ exports.checkByodGate = async function(req, res, next) { } } -console.log('DEBUG: planEnforcement.js exporting checkByokGate'); exports.checkByokGate = async function(req, res, next) { const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId } = require('@urbackend/common'); try { - if (req.user?.isAdmin || req.user?.email?.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase()) return next(); + if (isAdminRequest(req)) return next(); const { resendApiKey, github, google } = req.body; if (!resendApiKey && !github?.clientSecret && !google?.clientSecret) return next(); @@ -124,11 +175,15 @@ exports.checkByokGate = async function(req, res, next) { } } -console.log('DEBUG: planEnforcement.js exporting checkWebhookGate'); exports.checkWebhookGate = async function(req, res, next) { const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId } = require('@urbackend/common'); try { - if (req.user?.isAdmin || req.user?.email?.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase()) return next(); + if (isAdminRequest(req)) return next(); + + const isVerified = !!(req.developer?.isVerified ?? req.user?.isVerified); + if (!isVerified) { + return next(new AppError(403, 'Verify your email to create or test webhooks.')); + } const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId; const cleanProjectId = sanitizeObjectId(rawProjectId); @@ -152,11 +207,10 @@ exports.checkWebhookGate = async function(req, res, next) { } } -console.log('DEBUG: planEnforcement.js exporting checkMailTemplatesGate'); exports.checkMailTemplatesGate = async function(req, res, next) { const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId } = require('@urbackend/common'); try { - if (req.user?.isAdmin || req.user?.email?.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase()) return next(); + if (isAdminRequest(req)) return next(); const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId; const cleanProjectId = sanitizeObjectId(rawProjectId); @@ -212,4 +266,4 @@ exports.checkMemberLimit = async function(req, res, next) { } catch (err) { next(err); } -} \ No newline at end of file +} diff --git a/apps/dashboard-api/src/routes/projects.js b/apps/dashboard-api/src/routes/projects.js index fdcb8355e..45706716d 100644 --- a/apps/dashboard-api/src/routes/projects.js +++ b/apps/dashboard-api/src/routes/projects.js @@ -3,7 +3,7 @@ const router = express.Router(); const authMiddleware = require('../middlewares/authMiddleware'); const planEnforcement = require('../middlewares/planEnforcement'); const authorizeProject = require('../middlewares/authorizeProject'); -const { verifyEmail, checkAuthEnabled } = require('@urbackend/common'); +const { verifyEmail, checkAuthEnabled, loadProjectForAdmin } = require('@urbackend/common'); const multer = require('multer'); const storage = multer.memoryStorage(); @@ -12,6 +12,7 @@ const { getAllProject, getSingleProject, regenerateApiKey, + revealSecretKey, createCollection, deleteCollection, getData, @@ -58,12 +59,13 @@ const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listAdm const exportController = require('../controllers/dbExport.controller'); // POST REQ FOR CREATE PROJECT -router.post('/', authMiddleware, verifyEmail, planEnforcement.checkProjectLimit, createProject); +router.post('/', authMiddleware, planEnforcement.attachDeveloper, planEnforcement.checkProjectLimit, planEnforcement.checkDeveloperCapability('createProject'), createProject); router.get('/', authMiddleware, getAllProject); router.get('/:projectId', authMiddleware, authorizeProject(), getSingleProject); router.post('/:projectId/api-key', authMiddleware, authorizeProject('admin'), verifyEmail, regenerateApiKey); +router.post('/:projectId/reveal-secret-key', authMiddleware, authorizeProject('admin'), verifyEmail, revealSecretKey); -router.post('/:projectId/collections', authMiddleware, authorizeProject('admin'), verifyEmail, planEnforcement.attachDeveloper, planEnforcement.checkCollectionLimit, createCollection); +router.post('/:projectId/collections', authMiddleware, authorizeProject('admin'), planEnforcement.attachDeveloper, planEnforcement.checkCollectionLimit, createCollection); // DELETE REQ FOR COLLECTION router.delete('/:projectId/collections/:collectionName', authMiddleware, authorizeProject('admin'), verifyEmail, deleteCollection); @@ -154,16 +156,16 @@ router.delete('/:projectId/members/:memberId', authMiddleware, authorizeProject( // ADMIN AUTH ROUTES -router.post('/:projectId/admin/users', authMiddleware, authorizeProject('admin'), checkAuthEnabled, createAdminUser); -router.patch('/:projectId/admin/users/:userId/password', authMiddleware, authorizeProject('admin'), checkAuthEnabled, resetPassword); -router.get('/:projectId/admin/users', authMiddleware, authorizeProject(), checkAuthEnabled, listAdminUsers); -router.get('/:projectId/admin/users/:userId', authMiddleware, authorizeProject(), checkAuthEnabled, getUserDetails); -router.put('/:projectId/admin/users/:userId', authMiddleware, authorizeProject('admin'), checkAuthEnabled, updateAdminUser); -router.delete('/:projectId/admin/users/:userId', authMiddleware, authorizeProject('admin'), checkAuthEnabled, deleteAdminUser); +router.post('/:projectId/admin/users', authMiddleware, authorizeProject('admin'), loadProjectForAdmin, checkAuthEnabled, createAdminUser); +router.patch('/:projectId/admin/users/:userId/password', authMiddleware, authorizeProject('admin'), loadProjectForAdmin, checkAuthEnabled, resetPassword); +router.get('/:projectId/admin/users', authMiddleware, authorizeProject(), loadProjectForAdmin, checkAuthEnabled, listAdminUsers); +router.get('/:projectId/admin/users/:userId', authMiddleware, authorizeProject(), loadProjectForAdmin, checkAuthEnabled, getUserDetails); +router.put('/:projectId/admin/users/:userId', authMiddleware, authorizeProject('admin'), loadProjectForAdmin, checkAuthEnabled, updateAdminUser); +router.delete('/:projectId/admin/users/:userId', authMiddleware, authorizeProject('admin'), loadProjectForAdmin, checkAuthEnabled, deleteAdminUser); // SESSION MANAGEMENT (Admin) -router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, authorizeProject(), checkAuthEnabled, listUserSessions); -router.delete('/:projectId/admin/users/:userId/sessions/:tokenId', authMiddleware, authorizeProject('admin'), checkAuthEnabled, revokeUserSession); +router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, authorizeProject(), loadProjectForAdmin, checkAuthEnabled, listUserSessions); +router.delete('/:projectId/admin/users/:userId/sessions/:tokenId', authMiddleware, authorizeProject('admin'), loadProjectForAdmin, checkAuthEnabled, revokeUserSession); // POST req for DB EXPORT router.post('/:projectId/collections/:collectionName/export', authMiddleware, authorizeProject(), exportController.dbExportHandler); diff --git a/apps/dashboard-api/src/routes/user.js b/apps/dashboard-api/src/routes/user.js new file mode 100644 index 000000000..b64b697fe --- /dev/null +++ b/apps/dashboard-api/src/routes/user.js @@ -0,0 +1,9 @@ +const express = require('express'); +const router = express.Router(); +const authorization = require('../middlewares/authMiddleware'); +const { getMe, updateOnboarding } = require('../controllers/auth.controller'); + +router.get('/me', authorization, getMe); +router.patch('/onboarding', authorization, updateOnboarding); + +module.exports = router; diff --git a/apps/dashboard-api/src/routes/webhooks.js b/apps/dashboard-api/src/routes/webhooks.js index 728a42b0b..88e6f1405 100644 --- a/apps/dashboard-api/src/routes/webhooks.js +++ b/apps/dashboard-api/src/routes/webhooks.js @@ -1,7 +1,6 @@ const express = require("express"); const router = express.Router(); const authMiddleware = require("../middlewares/authMiddleware"); -const { verifyEmail } = require("@urbackend/common"); const { createWebhook, @@ -14,6 +13,7 @@ const { } = require("../controllers/webhook.controller"); const { attachDeveloper, checkWebhookGate } = require("../middlewares/planEnforcement"); const authorizeProject = require("../middlewares/authorizeProject"); +const { verifyEmail } = require('@urbackend/common'); // Create webhook router.post("/:projectId/webhooks", authMiddleware, verifyEmail, authorizeProject('admin'), attachDeveloper, checkWebhookGate, createWebhook); @@ -27,7 +27,7 @@ router.get("/:projectId/webhooks/:webhookId", authMiddleware, authorizeProject() router.patch("/:projectId/webhooks/:webhookId", authMiddleware, verifyEmail, authorizeProject('admin'), attachDeveloper, checkWebhookGate, updateWebhook); // Delete webhook -router.delete("/:projectId/webhooks/:webhookId", authMiddleware, verifyEmail, authorizeProject('admin'), deleteWebhook); +router.delete("/:projectId/webhooks/:webhookId", authMiddleware, verifyEmail, authorizeProject('admin'), attachDeveloper, checkWebhookGate, deleteWebhook); // Get delivery history router.get("/:projectId/webhooks/:webhookId/deliveries", authMiddleware, authorizeProject(), getDeliveries); diff --git a/apps/public-api/src/__tests__/api_usage.test.js b/apps/public-api/src/__tests__/api_usage.test.js index 0238d3e9a..7d8008572 100644 --- a/apps/public-api/src/__tests__/api_usage.test.js +++ b/apps/public-api/src/__tests__/api_usage.test.js @@ -3,6 +3,11 @@ const mockLogCreate = jest.fn(); const mockApiAnalyticsCreate = jest.fn(); const mockIncrWithTtlAtomic = jest.fn(); +const mockRedisSet = jest.fn(); +const mockProjectFindById = jest.fn(); +const mockPlatformEventCreate = jest.fn(); +const mockPlatformEventFindOne = jest.fn(); +const mockMarkDeveloperActivated = jest.fn(); jest.mock('@urbackend/common', () => ({ Log: { @@ -11,12 +16,20 @@ jest.mock('@urbackend/common', () => ({ ApiAnalytics: { create: (...args) => mockApiAnalyticsCreate(...args), }, + Project: { + findById: (...args) => mockProjectFindById(...args), + }, + PlatformEvent: { + create: (...args) => mockPlatformEventCreate(...args), + findOne: (...args) => mockPlatformEventFindOne(...args), + }, redis: { - set: jest.fn().mockResolvedValue(null), + set: (...args) => mockRedisSet(...args), }, getDayKey: () => '2026-06-08', DEFAULT_DAILY_TTL_SECONDS: 86400, incrWithTtlAtomic: (...args) => mockIncrWithTtlAtomic(...args), + markDeveloperActivated: (...args) => mockMarkDeveloperActivated(...args), })); const { logger } = require('../middlewares/api_usage'); @@ -27,8 +40,12 @@ describe('api_usage middleware', () => { beforeEach(() => { jest.clearAllMocks(); + finishCallback = null; + mockRedisSet.mockResolvedValue(null); + mockMarkDeveloperActivated.mockResolvedValue({ activated: false }); + mockPlatformEventFindOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); req = { - project: { _id: 'test_project_id' }, + project: { _id: 'test_project_id', owner: 'dev_id_1' }, method: 'GET', originalUrl: '/api/data/test-endpoint', ip: '127.0.0.1', @@ -139,4 +156,49 @@ describe('api_usage middleware', () => { consoleErrorSpy.mockRestore(); }); + + test('marks onboarding on first successful data API response', async () => { + mockLogCreate.mockResolvedValue({ _id: 'log_id' }); + mockApiAnalyticsCreate.mockResolvedValue({ _id: 'analytics_id' }); + mockMarkDeveloperActivated.mockResolvedValue({ activated: true }); + mockPlatformEventCreate.mockResolvedValue({ _id: 'event_id' }); + + logger(req, res, next); + await finishCallback(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockMarkDeveloperActivated).toHaveBeenCalledWith('dev_id_1'); + expect(mockPlatformEventCreate).toHaveBeenCalledTimes(1); + expect(mockPlatformEventCreate).toHaveBeenCalledWith(expect.objectContaining({ + developerId: 'dev_id_1', + projectId: 'test_project_id', + event: 'first_api_call', + })); + }); + + test('does not mark onboarding for successful non-data API responses', async () => { + req.originalUrl = '/api/userAuth/me'; + mockLogCreate.mockResolvedValue({ _id: 'log_id' }); + mockApiAnalyticsCreate.mockResolvedValue({ _id: 'analytics_id' }); + + logger(req, res, next); + await finishCallback(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockMarkDeveloperActivated).not.toHaveBeenCalled(); + expect(mockPlatformEventCreate).not.toHaveBeenCalled(); + }); + + test('does not emit first_api_call when developer was already activated', async () => { + mockLogCreate.mockResolvedValue({ _id: 'log_id' }); + mockApiAnalyticsCreate.mockResolvedValue({ _id: 'analytics_id' }); + mockMarkDeveloperActivated.mockResolvedValue({ activated: false }); + + logger(req, res, next); + await finishCallback(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockMarkDeveloperActivated).toHaveBeenCalledWith('dev_id_1'); + expect(mockPlatformEventCreate).not.toHaveBeenCalled(); + }); }); diff --git a/apps/public-api/src/middlewares/api_usage.js b/apps/public-api/src/middlewares/api_usage.js index 3d20b3b88..4df3d1150 100644 --- a/apps/public-api/src/middlewares/api_usage.js +++ b/apps/public-api/src/middlewares/api_usage.js @@ -1,6 +1,5 @@ const rateLimit = require('express-rate-limit'); const { Log, redis, ApiAnalytics, getDayKey, DEFAULT_DAILY_TTL_SECONDS, incrWithTtlAtomic } = require('@urbackend/common'); -const FIRST_API_SUCCESS_FLAG_TTL_SECONDS = 2 * 365 * 24 * 60 * 60; // Rate Limiter const limiter = rateLimit({ @@ -75,28 +74,29 @@ const logger = (req, res, next) => { }); } - // --- Activation funnel: first_api_success --- - // Fires only once per project lifetime, on the very first 2xx response. - // Uses a permanent Redis NX flag so we don't hit MongoDB on every request. - if (req.project && res.statusCode >= 200 && res.statusCode < 300) { + // --- Activation funnel: first_api_call --- + // Fires once per developer, only after a successful real data API call. + if ( + req.project && + req.originalUrl.startsWith('/api/data') && + res.statusCode >= 200 && + res.statusCode < 300 + ) { setImmediate(async () => { try { - const flagKey = `project:activation:first_api_success:${req.project._id}`; - const isFirst = await redis.set( - flagKey, - '1', - 'EX', - FIRST_API_SUCCESS_FLAG_TTL_SECONDS, - 'NX' - ); - if (isFirst) { - const { Project, PlatformEvent } = require('@urbackend/common'); - const proj = await Project.findById(req.project._id).select('owner').lean(); - if (proj?.owner) { + const { Project, PlatformEvent, markDeveloperActivated } = require('@urbackend/common'); + const projectOwner = req.project.owner?._id || req.project.owner; + const ownerId = projectOwner || (await Project.findById(req.project._id).select('owner').lean())?.owner; + if (!ownerId) return; + + const { activated } = await markDeveloperActivated(ownerId); + if (activated) { + const existingEvent = await PlatformEvent.findOne({ developerId: ownerId, event: 'first_api_call' }).lean(); + if (!existingEvent) { await PlatformEvent.create({ - developerId: proj.owner, + developerId: ownerId, projectId: req.project._id, - event: 'first_api_success', + event: 'first_api_call', properties: { method: req.method, path: req.originalUrl, @@ -107,7 +107,7 @@ const logger = (req, res, next) => { } } } catch (err) { - console.error('[activation] first_api_success check failed:', err.message); + console.error('[activation] first_api_call check failed:', err.message); } }); } diff --git a/apps/public-api/src/middlewares/verifyApiKey.js b/apps/public-api/src/middlewares/verifyApiKey.js index 895b47456..2334d0a5d 100644 --- a/apps/public-api/src/middlewares/verifyApiKey.js +++ b/apps/public-api/src/middlewares/verifyApiKey.js @@ -30,10 +30,17 @@ module.exports = async (req, res, next) => { const keyField = isSecret ? 'secretKey' : 'publishableKey'; const hashedApi = hashApiKey(apiKey); - let project = await getProjectByApiKeyCache(hashedApi); + let project = await getProjectByApiKeyCache(isSecret ? hashedApi : apiKey); + if (!project && !isSecret) { + project = await getProjectByApiKeyCache(hashedApi); + } if (!project) { - project = await Project.findOne({ [keyField]: hashedApi }) + const queryCondition = isSecret + ? { [keyField]: hashedApi } + : { $or: [{ [keyField]: apiKey }, { [keyField]: hashedApi }] }; + + project = await Project.findOne(queryCondition) .select(` name owner @@ -55,7 +62,8 @@ module.exports = async (req, res, next) => { 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); + const cacheKey = isSecret ? hashedApi : (project[keyField] === apiKey ? apiKey : hashedApi); + await setProjectByApiKeyCache(cacheKey, project); } if (!project.owner.isVerified) { diff --git a/apps/web-dashboard/src/App.jsx b/apps/web-dashboard/src/App.jsx index b67e2c638..b7fc41979 100644 --- a/apps/web-dashboard/src/App.jsx +++ b/apps/web-dashboard/src/App.jsx @@ -64,8 +64,8 @@ function AppContent() { } /> } /> } /> - + } /> @@ -81,7 +81,7 @@ function AppContent() { } /> + @@ -89,7 +89,7 @@ function AppContent() { } /> + @@ -97,7 +97,7 @@ function AppContent() { } /> + @@ -126,7 +126,7 @@ function AppContent() { } /> - } /> + } /> } /> @@ -134,7 +134,7 @@ function AppContent() { + diff --git a/apps/web-dashboard/src/components/Dashboard/UsageProgressBar.jsx b/apps/web-dashboard/src/components/Dashboard/UsageProgressBar.jsx index 62339cdb4..84a626c27 100644 --- a/apps/web-dashboard/src/components/Dashboard/UsageProgressBar.jsx +++ b/apps/web-dashboard/src/components/Dashboard/UsageProgressBar.jsx @@ -1,12 +1,14 @@ import React from 'react'; +import { getProgressWidth, getUsagePercentage } from '../../utils/quota'; const UsageProgressBar = ({ label, used, limit, unit = 'MB', formatValue: customFormat, unlimited = false, tooltip = '' }) => { const isUnlimited = unlimited || limit === -1; - const percentage = isUnlimited ? 0 : Math.min(Math.round((used / limit) * 100), 100); + const percentage = isUnlimited ? 0 : getUsagePercentage(used, limit); + const hasPercentage = Number.isFinite(percentage); let statusClass = ''; - if (percentage > 80) statusClass = 'danger'; - else if (percentage > 60) statusClass = 'warning'; + if (hasPercentage && percentage > 80) statusClass = 'danger'; + else if (hasPercentage && percentage > 60) statusClass = 'warning'; const format = (value) => { if (customFormat) return customFormat(value); @@ -21,13 +23,13 @@ const UsageProgressBar = ({ label, used, limit, unit = 'MB', formatValue: custom
{label} - {isUnlimited ? '∞' : `${percentage}%`} + {isUnlimited ? '∞' : (hasPercentage ? `${percentage}%` : '—')}
{!isUnlimited && (
)} diff --git a/apps/web-dashboard/src/components/Layout/Sidebar.jsx b/apps/web-dashboard/src/components/Layout/Sidebar.jsx index 264d2028e..4e7f6718f 100644 --- a/apps/web-dashboard/src/components/Layout/Sidebar.jsx +++ b/apps/web-dashboard/src/components/Layout/Sidebar.jsx @@ -17,11 +17,16 @@ function Sidebar({ logo, isOpen, onClose }) { if (window.innerWidth <= 768) onClose(); }; + const navA11yProps = (label) => ({ + 'aria-label': label, + title: label, + }); + return (