diff --git a/apps/dashboard-api/src/controllers/analytics.controller.js b/apps/dashboard-api/src/controllers/analytics.controller.js index 736b98c2..865363f2 100644 --- a/apps/dashboard-api/src/controllers/analytics.controller.js +++ b/apps/dashboard-api/src/controllers/analytics.controller.js @@ -1,4 +1,4 @@ -const { Project, Log, Developer } = require("@urbackend/common"); +const { Project, Log, Developer, resolveEffectivePlan, getPlanLimits } = require("@urbackend/common"); const mongoose = require("mongoose"); /** @@ -30,7 +30,7 @@ module.exports.getGlobalStats = async (req, res) => { } } ]), - Developer.findById(user_id).select("maxProjects maxCollections") + Developer.findById(user_id).select("maxProjects maxCollections plan planExpiresAt") ]); const globalStats = stats[0] || { @@ -44,12 +44,21 @@ module.exports.getGlobalStats = async (req, res) => { const projectIds = await Project.find({ owner: user_id }).distinct("_id"); const totalRequests = await Log.countDocuments({ projectId: { $in: projectIds } }); + const effectivePlan = resolveEffectivePlan(dev); + const limits = getPlanLimits({ + plan: effectivePlan, + legacyLimits: { + maxProjects: dev?.maxProjects, + maxCollections: dev?.maxCollections + } + }); + res.json({ ...globalStats, totalRequests, limits: { - maxProjects: dev?.maxProjects || 1, - maxCollections: dev?.maxCollections || 20 + maxProjects: limits.maxProjects, + maxCollections: limits.maxCollections } }); } catch (err) { diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 059b34db..1029dd15 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -186,28 +186,28 @@ const sanitizeProjectResponse = (projectObj) => { }; module.exports.createProject = async (req, res) => { + const session = await mongoose.startSession(); + session.startTransaction(); + try { // POST FOR - PROJECT CREATION const { name, description, siteUrl } = createProjectSchema.parse(req.body); - // --- PROJECT LIMIT CHECK --- - const ADMIN_EMAIL = process.env.ADMIN_EMAIL; - - // GET MAX PROJECTS - const dev = await Developer.findById(req.user._id); - const MAX_PROJECTS = dev?.maxProjects || 1; - - const isUserAdmin = dev.email === ADMIN_EMAIL; - const projectCount = await Project.countDocuments({ owner: req.user._id }); + // Atomic limit enforcement: count and create within transaction + if (req.projectLimit !== undefined) { + const currentCount = await Project.countDocuments( + { owner: req.user._id }, + { session } + ); - if (!isUserAdmin && projectCount >= MAX_PROJECTS) { - return res.status(403).json({ - error: `Project limit reached. Your current plan allows up to ${MAX_PROJECTS} projects.`, - limit: MAX_PROJECTS, - current: projectCount, - }); + if (currentCount >= req.projectLimit) { + await session.abortTransaction(); + session.endSession(); + return res.status(403).json({ + error: `Project limit reached (${req.projectLimit}). Please upgrade your plan to create more projects.` + }); + } } - // --------------------------- const rawPublishableKey = generateApiKey("pk_live_"); const hashedPublishableKey = hashApiKey(rawPublishableKey); @@ -226,7 +226,10 @@ module.exports.createProject = async (req, res) => { jwtSecret: rawJwtSecret, siteUrl: siteUrl || "", }); - await newProject.save(); + await newProject.save({ session }); + + await session.commitTransaction(); + session.endSession(); const projectObj = newProject.toObject(); projectObj.publishableKey = rawPublishableKey; @@ -236,6 +239,9 @@ module.exports.createProject = async (req, res) => { res.status(201).json(projectObj); } catch (err) { + await session.abortTransaction(); + session.endSession(); + if (err instanceof z.ZodError) { return res.status(400).json({ error: err.issues }); } @@ -562,6 +568,8 @@ module.exports.createCollection = async (req, res) => { let collectionWasPersisted = false; let collectionNameForRollback; let collectionExistedBefore = false; + const session = await mongoose.startSession(); + session.startTransaction(); try { const { projectId, collectionName, schema } = createCollectionSchema.parse( @@ -573,12 +581,30 @@ module.exports.createCollection = async (req, res) => { project = await Project.findOne({ _id: projectId, owner: req.user._id, - }); - if (!project) return res.status(404).json({ error: "Project not found" }); + }).session(session); + if (!project) { + await session.abortTransaction(); + session.endSession(); + return res.status(404).json({ error: "Project not found" }); + } const exists = project.collections.find((c) => c.name === collectionName); - if (exists) + if (exists) { + await session.abortTransaction(); + session.endSession(); return res.status(400).json({ error: "Collection already exists" }); + } + + // Atomic limit enforcement within transaction + if (req.collectionLimit !== undefined) { + if (project.collections.length >= req.collectionLimit) { + await session.abortTransaction(); + session.endSession(); + return res.status(403).json({ + error: `Collection limit reached (${req.collectionLimit}). Please upgrade your plan to create more collections.` + }); + } + } if (!project.jwtSecret) { project.jwtSecret = generateApiKey("jwt_"); @@ -586,6 +612,8 @@ module.exports.createCollection = async (req, res) => { if (collectionName === "users") { if (!validateUsersSchema(schema)) { + await session.abortTransaction(); + session.endSession(); return res.status(422).json({ error: "The 'users' collection must have required 'email' and 'password' string fields.", @@ -604,7 +632,7 @@ module.exports.createCollection = async (req, res) => { }; project.collections.push(newCollectionConfig); - await project.save(); + await project.save({ session }); collectionWasPersisted = true; connection = await getConnection(projectId); @@ -622,6 +650,9 @@ module.exports.createCollection = async (req, res) => { await createUniqueIndexes(Model, newCollectionConfig.model); + await session.commitTransaction(); + session.endSession(); + await deleteProjectById(projectId); await setProjectById(projectId, project.toObject()); await deleteProjectByApiKeyCache(project.publishableKey); @@ -634,14 +665,10 @@ module.exports.createCollection = async (req, res) => { return res.status(201).json(projectObj); } catch (err) { - try { - if (project && collectionWasPersisted) { - project.collections = project.collections.filter( - (c) => c.name !== collectionNameForRollback, - ); - await project.save(); - } + await session.abortTransaction(); + session.endSession(); + try { if (connection && compiledCollectionName) { clearCompiledModel(connection, compiledCollectionName); @@ -2006,4 +2033,4 @@ module.exports.updateCollectionRls = async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } -} +} \ No newline at end of file diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js new file mode 100644 index 00000000..245204a9 --- /dev/null +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -0,0 +1,162 @@ +const { Developer, Project, resolveEffectivePlan, getPlanLimits, AppError } = require('@urbackend/common'); +const mongoose = require('mongoose'); + +/** + * Middleware to load the full Developer document and attach it to req.developer. + * req.user (from authMiddleware) contains the decoded JWT data. + */ +exports.attachDeveloper = async (req, res, next) => { + try { + if (!req.user || !req.user._id) { + return next(new AppError(401, 'Unauthorized: Developer context missing')); + } + + const developer = await Developer.findById(req.user._id); + if (!developer) { + return next(new AppError(404, 'Developer not found')); + } + + req.developer = developer; + next(); + } catch (err) { + next(err); + } +}; + +/** + * Middleware to check if the developer has reached their project creation limit. + * Admin bypass uses JWT isAdmin flag (set at login time by auth.controller.js) + * instead of email string comparison — consistent with existing auth pattern. + */ +exports.checkProjectLimit = async (req, res, next) => { + try { + // isAdmin is embedded in the JWT at login time — no extra DB hit needed + if (req.user?.isAdmin) return next(); + + const effectivePlan = resolveEffectivePlan(req.developer); + const limits = getPlanLimits({ + plan: effectivePlan, + legacyLimits: { + maxProjects: req.developer.maxProjects ?? null, + maxCollections: req.developer.maxCollections ?? null + } + }); + + // -1 means unlimited + if (limits.maxProjects === -1) return next(); + + // Store limits in req for atomic enforcement in controller + req.projectLimit = limits.maxProjects; + + next(); + } catch (err) { + next(err); + } +}; + +/** + * Middleware to check collection limits within a project. + * Admin bypass uses JWT isAdmin flag (set at login time by auth.controller.js). + */ +exports.checkCollectionLimit = async (req, res, next) => { + try { + // isAdmin is embedded in the JWT at login time — no extra DB hit needed + if (req.user?.isAdmin) return next(); + + // For collection creation, the projectId is usually in req.body + const projectId = req.body.projectId; + if (!projectId) return next(new AppError(400, 'projectId is required')); + + // Prevent NoSQL Injection (CodeQL Alert: Database query built from user-controlled sources) + if (typeof projectId !== 'string' || !/^[a-fA-F0-9]{24}$/.test(projectId)) { + return next(new AppError(400, 'Invalid projectId format')); + } + + // Load project scoped to the requesting developer to enforce ownership + const project = await Project.findOne({ _id: projectId, owner: req.developer._id }); + if (!project) return next(new AppError(404, 'Project not found')); + + const effectivePlan = resolveEffectivePlan(req.developer); + const limits = getPlanLimits({ + plan: effectivePlan, + customLimits: project.customLimits, + legacyLimits: { + maxProjects: req.developer.maxProjects ?? null, + maxCollections: req.developer.maxCollections ?? null + } + }); + + if (limits.maxCollections === -1) return next(); + + // Store limit in req for atomic enforcement in controller + req.collectionLimit = limits.maxCollections; + + next(); + } catch (err) { + next(err); + } +}; + +/** + * Middleware to block BYOK features for Free tier users. + */ +exports.checkByokGate = async (req, res, next) => { + try { + // Admin always has access to all features + if (req.user?.isAdmin) return next(); + + let customLimits = null; + const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId; + + if (typeof rawProjectId === 'string' && mongoose.Types.ObjectId.isValid(rawProjectId)) { + const projectObjectId = new mongoose.Types.ObjectId(rawProjectId); + const project = await Project.findById(projectObjectId).select('customLimits').lean(); + if (project) { + customLimits = project.customLimits; + } + } + + const effectivePlan = resolveEffectivePlan(req.developer); + const limits = getPlanLimits({ plan: effectivePlan, customLimits }); + + if (!limits.byokEnabled) { + return next(new AppError(403, 'External configuration (BYOK) is a Pro feature. Please upgrade to connect your own resources.')); + } + + next(); + } catch (err) { + next(err); + } +}; + +/** + * Middleware to block BYOM features for users without access. + */ +exports.checkByomGate = async (req, res, next) => { + try { + // Admin always has access to all features + if (req.user?.isAdmin) return next(); + + let customLimits = null; + const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId; + + if (typeof rawProjectId === 'string' && mongoose.Types.ObjectId.isValid(rawProjectId)) { + const projectObjectId = new mongoose.Types.ObjectId(rawProjectId); + const project = await Project.findById(projectObjectId).select('customLimits').lean(); + if (project) { + customLimits = project.customLimits; + } + } + + const effectivePlan = resolveEffectivePlan(req.developer); + const limits = getPlanLimits({ plan: effectivePlan, customLimits }); + + if (!limits.byomEnabled) { + return next(new AppError(403, 'External configuration (BYOM) is a Pro feature. Please upgrade to connect your own resources.')); + } + + 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 0b3c1498..301a45cb 100644 --- a/apps/dashboard-api/src/routes/projects.js +++ b/apps/dashboard-api/src/routes/projects.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const authMiddleware = require('../middlewares/authMiddleware'); +const { attachDeveloper, checkProjectLimit, checkCollectionLimit, checkByokGate } = require('../middlewares/planEnforcement'); const {verifyEmail} = require('@urbackend/common') const multer = require('multer'); const storage = multer.memoryStorage(); @@ -44,7 +45,7 @@ const upload = multer({ storage: storage, limits: { fileSize: 10 * 1024 * 1024 } // POST REQ FOR CREATE PROJECT -router.post('/', authMiddleware, verifyEmail, createProject); +router.post('/', authMiddleware, attachDeveloper, verifyEmail, checkProjectLimit, createProject); // GET REQ FOR ALL PROJECTS router.get('/', authMiddleware, getAllProject); @@ -56,7 +57,7 @@ router.get('/:projectId', authMiddleware, getSingleProject); router.patch('/:projectId/regenerate-key', authMiddleware, regenerateApiKey); // POST REQ FOR CREATE COLLECTION -router.post('/collection', authMiddleware, verifyEmail, createCollection); +router.post('/collection', authMiddleware, attachDeveloper, verifyEmail, checkCollectionLimit, createCollection); // DELETE REQ FOR COLLECTION router.delete('/:projectId/collections/:collectionName', authMiddleware, verifyEmail, deleteCollection); @@ -97,7 +98,7 @@ router.delete('/:projectId/mail/templates/:templateId', authMiddleware, verifyEm router.patch('/:projectId/allowed-domains', authMiddleware, verifyEmail, updateAllowedDomains); // PATCH REQ FOR BYOD CONFIG -router.patch('/:projectId/byod-config', authMiddleware, updateExternalConfig); +router.patch('/:projectId/byod-config', authMiddleware, attachDeveloper, checkByokGate, updateExternalConfig); // DELETE REQ FOR BYOD DB CONFIG router.delete('/:projectId/byod-config/db', authMiddleware, deleteExternalDbConfig); diff --git a/apps/public-api/src/__tests__/mail.controller.test.js b/apps/public-api/src/__tests__/mail.controller.test.js index 462ede77..7666918d 100644 --- a/apps/public-api/src/__tests__/mail.controller.test.js +++ b/apps/public-api/src/__tests__/mail.controller.test.js @@ -33,6 +33,7 @@ jest.mock('@urbackend/common', () => { }, decrypt: jest.fn(), redis: redisMock, + getPlanLimits: jest.fn(() => ({ mailPerMonth: 100 })), }; }); @@ -44,6 +45,7 @@ const makeReq = () => ({ keyRole: 'secret', project: { _id: 'proj_1' }, body: { to: 'user@example.com', subject: 'Hello', text: 'This is a message.' }, + planLimits: { mailTemplatesEnabled: true }, }); const makeRes = () => { diff --git a/apps/public-api/src/__tests__/storage.controller.test.js b/apps/public-api/src/__tests__/storage.controller.test.js index 86b473d0..2d5012fe 100644 --- a/apps/public-api/src/__tests__/storage.controller.test.js +++ b/apps/public-api/src/__tests__/storage.controller.test.js @@ -111,7 +111,7 @@ describe('storage.controller', () => { expect(Project.updateOne).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Internal storage limit exceeded.' }); + expect(res.json).toHaveBeenCalledWith({ error: 'Storage limit exceeded. Please upgrade your plan or delete some files.' }); }); test('returns 201 and public URL on successful internal upload', async () => { diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index ba8f8cdb..9040f6ea 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -100,6 +100,16 @@ app.use((err, req, res, next) => { }); } + // Operational errors (AppError) preserve their HTTP status — this is critical + // for quota/rate-limit 429s and plan-gate 403s to reach the client correctly. + if (err.isOperational && err.statusCode) { + return res.status(err.statusCode).json({ + success: false, + data: {}, + message: err.message + }); + } + console.error("🔥 Unhandled Error:", err.stack); res.status(500).json({ error: "Something went wrong!", diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index 63838132..724607d7 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -225,7 +225,18 @@ module.exports.sendMail = async (req, res) => { } if (!t) { - return res.status(404).json({ success: false, data: {}, message: "Mail template not found." }); + return res.status(400).json({ success: false, data: {}, message: "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." + }); + } } templateUsed = { @@ -285,7 +296,7 @@ module.exports.sendMail = async (req, res) => { return res.status(500).json({ success: false, data: {}, message: "Resend API key is not configured." }); } - const limit = getMonthlyMailLimit(req.project); + const limit = getMonthlyMailLimit(req.project, req.planLimits); const { count, key } = await reserveMonthlyMailSlot(projectId, limit); consumedQuotaKey = key; diff --git a/apps/public-api/src/controllers/storage.controller.js b/apps/public-api/src/controllers/storage.controller.js index de0ca799..f84d75a9 100644 --- a/apps/public-api/src/controllers/storage.controller.js +++ b/apps/public-api/src/controllers/storage.controller.js @@ -5,6 +5,7 @@ const { isProjectStorageExternal } = require("@urbackend/common"); const { getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("../utils/usageCounter"); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const SAFETY_MAX_BYTES = 100 * 1024 * 1024; // 100MB safety ceiling for internal storage const getBucket = (project) => isProjectStorageExternal(project) ? "files" : "dev-files"; @@ -38,16 +39,43 @@ module.exports.uploadFile = async (req, res) => { // ATOMIC QUOTA RESERVATION if (!external) { - const result = await Project.updateOne( - { - _id: project._id, - $expr: { $lte: [{ $add: ["$storageUsed", file.size] }, "$storageLimit"] } - }, - { $inc: { storageUsed: file.size } } - ); + const limits = req.planLimits || {}; + // Use explicit type checks instead of || to distinguish between undefined and 0 + let effectiveLimit; + if (typeof limits.storageBytes === 'number') { + effectiveLimit = limits.storageBytes; + } else if (typeof project.storageLimit === 'number') { + effectiveLimit = project.storageLimit; + } else { + effectiveLimit = 20 * 1024 * 1024; + } + + // For internal storage: honor -1 as unlimited but clamp to safety ceiling + if (effectiveLimit === -1) { + // Internal storage with -1: clamp to safety ceiling + const result = await Project.updateOne( + { + _id: project._id, + $expr: { $lte: [{ $add: ["$storageUsed", file.size] }, SAFETY_MAX_BYTES] } + }, + { $inc: { storageUsed: file.size } } + ); - if (result.matchedCount === 0) { - return res.status(403).json({ error: "Internal storage limit exceeded." }); + if (result.matchedCount === 0) { + return res.status(403).json({ error: "Storage limit exceeded. Please upgrade your plan or delete some files." }); + } + } else { + const result = await Project.updateOne( + { + _id: project._id, + $expr: { $lte: [{ $add: ["$storageUsed", file.size] }, effectiveLimit] } + }, + { $inc: { storageUsed: file.size } } + ); + + if (result.matchedCount === 0) { + return res.status(403).json({ error: "Storage limit exceeded. Please upgrade your plan or delete some files." }); + } } } @@ -223,4 +251,4 @@ module.exports.deleteAllFiles = async (req, res) => { : undefined }); } -}; +}; \ No newline at end of file diff --git a/apps/public-api/src/middlewares/api_usage.js b/apps/public-api/src/middlewares/api_usage.js index 5f432f67..e34ea262 100644 --- a/apps/public-api/src/middlewares/api_usage.js +++ b/apps/public-api/src/middlewares/api_usage.js @@ -37,9 +37,12 @@ const logger = (req, res, next) => { }); // Usage counter (Redis): daily API requests per project - const day = getDayKey(); - const reqCountKey = `project:usage:req:count:${req.project._id}:${day}`; - incrWithTtlAtomic(redis, reqCountKey, DEFAULT_DAILY_TTL_SECONDS).catch(() => {}); + // Skip increment if usageGate already incremented atomically + if (!req._dailyCountIncremented) { + const day = getDayKey(); + const reqCountKey = `project:usage:req:count:${req.project._id}:${day}`; + incrWithTtlAtomic(redis, reqCountKey, DEFAULT_DAILY_TTL_SECONDS).catch(() => {}); + } console.log(`📝 Logged: ${req.method} ${req.originalUrl} (${res.statusCode})`); } catch (e) { @@ -52,4 +55,4 @@ const logger = (req, res, next) => { next(); }; -module.exports = { limiter, logger }; +module.exports = { limiter, logger }; \ No newline at end of file diff --git a/apps/public-api/src/middlewares/usageGate.js b/apps/public-api/src/middlewares/usageGate.js new file mode 100644 index 00000000..574f8857 --- /dev/null +++ b/apps/public-api/src/middlewares/usageGate.js @@ -0,0 +1,98 @@ +const { + redis, + Developer, + resolveEffectivePlan, + getPlanLimits, + getDeveloperPlanCache, + setDeveloperPlanCache, + AppError +} = require('@urbackend/common'); +const { getDayKey, DEFAULT_DAILY_TTL_SECONDS, incrWithTtlAtomic } = require('../utils/usageCounter'); + +/** + * Resolves the plan context for the current project's owner. + * Uses Redis cache to avoid DB hits on every public API request. + */ +async function resolveDeveloperPlanContext(req) { + // Extract raw string ID — owner can be either a populated object or a raw ObjectId + const rawOwner = req.project.owner; + const developerId = (rawOwner && typeof rawOwner === 'object' && rawOwner._id) + ? rawOwner._id.toString() + : rawOwner.toString(); + + // Try cache first + let cached = await getDeveloperPlanCache(developerId); + if (cached) return cached; + + // Cache miss: Load from DB + const developer = await Developer.findById(developerId).select('plan planExpiresAt maxProjects maxCollections').lean(); + + const context = { + plan: developer?.plan || 'free', + planExpiresAt: developer?.planExpiresAt || null, + // Only store the raw DB values, do NOT apply defaults here — + // getPlanLimits handles merging with plan-tier defaults safely. + legacyLimits: { + maxProjects: developer?.maxProjects ?? null, + maxCollections: developer?.maxCollections ?? null + } + }; + + // Store in cache (5 mins) + await setDeveloperPlanCache(developerId, context); + return context; +} + +/** + * Middleware to check daily request limits and per-minute spikes. + */ +exports.checkUsageLimits = async (req, res, next) => { + try { + if (!req.project) return next(); + + // 1. Resolve Plan context + const planContext = await resolveDeveloperPlanContext(req); + const effectivePlan = resolveEffectivePlan(planContext); + + const limits = getPlanLimits({ + plan: effectivePlan, + customLimits: req.project.customLimits, + legacyLimits: planContext.legacyLimits + }); + + // Attach limits immediately so downstream sees them even if Redis fails + req.planLimits = limits; + + // 2. Per-Minute Limit (Server Protection) + // Key: project:min:req:{projectId}:{YYYY-MM-DD:HH:MM} + const minKey = `project:usage:min:${req.project._id}:${new Date().toISOString().substring(0, 16)}`; + const minCount = await incrWithTtlAtomic(redis, minKey, 65); // 65s TTL + + if (limits.reqPerMinute !== -1 && minCount > limits.reqPerMinute) { + return next(new AppError(429, 'Rate limit exceeded (per minute). Please slow down or upgrade your plan.')); + } + + // 3. Daily Limit Enforcement (Atomic) + // Use existing key pattern from api_usage.js for consistency + const day = getDayKey(); + const reqCountKey = `project:usage:req:count:${req.project._id}:${day}`; + + // Atomically increment and check + const newDailyCount = await incrWithTtlAtomic(redis, reqCountKey, DEFAULT_DAILY_TTL_SECONDS); + + if (limits.reqPerDay !== -1 && newDailyCount > limits.reqPerDay) { + // Rollback the increment + await redis.decr(reqCountKey); + return next(new AppError(429, 'Daily request limit reached. Upgrade your plan to increase limits.')); + } + + // Mark that we've already incremented so the logger skips duplicate increment + req._dailyCountIncremented = true; + + next(); + } catch (err) { + // Fallback to next if redis fails to avoid blocking all traffic + console.error("Usage limit check failed:", err); + next(); + } +}; \ No newline at end of file diff --git a/apps/public-api/src/routes/data.js b/apps/public-api/src/routes/data.js index bc0501a1..08b4b86e 100644 --- a/apps/public-api/src/routes/data.js +++ b/apps/public-api/src/routes/data.js @@ -4,36 +4,36 @@ const verifyApiKey = require('../middlewares/verifyApiKey'); const resolvePublicAuthContext = require('../middlewares/resolvePublicAuthContext'); const authorizeWriteOperation = require('../middlewares/authorizeWriteOperation'); const authorizeReadOperation = require('../middlewares/authorizeReadOperation'); -const projectRateLimiter = require('../middlewares/projectRateLimiter'); +const { checkUsageLimits } = require('../middlewares/usageGate'); const blockUsersCollectionDataAccess = require('../middlewares/blockUsersCollectionDataAccess'); const { insertData, getAllData, getSingleDoc, updateSingleData, deleteSingleDoc, aggregateData } = require("../controllers/data.controller") // POST REQ TO INSERT DATA -router.post('/:collectionName', verifyApiKey, blockUsersCollectionDataAccess, resolvePublicAuthContext, projectRateLimiter, authorizeWriteOperation, insertData); +router.post('/:collectionName', verifyApiKey, blockUsersCollectionDataAccess, checkUsageLimits, resolvePublicAuthContext, authorizeWriteOperation, insertData); // GET REQ ALL DATA -router.get('/:collectionName', verifyApiKey, blockUsersCollectionDataAccess, resolvePublicAuthContext, projectRateLimiter, authorizeReadOperation, getAllData); +router.get('/:collectionName', verifyApiKey, blockUsersCollectionDataAccess, checkUsageLimits, resolvePublicAuthContext, authorizeReadOperation, getAllData); // POST REQ AGGREGATION DATA -router.post('/:collectionName/aggregate', verifyApiKey, blockUsersCollectionDataAccess, resolvePublicAuthContext, projectRateLimiter, authorizeReadOperation, aggregateData); +router.post('/:collectionName/aggregate', verifyApiKey, blockUsersCollectionDataAccess, checkUsageLimits, resolvePublicAuthContext, authorizeReadOperation, aggregateData); // GET REQ SINGLE DATA -router.get('/:collectionName/:id', verifyApiKey, blockUsersCollectionDataAccess, resolvePublicAuthContext, projectRateLimiter, authorizeReadOperation, getSingleDoc); +router.get('/:collectionName/:id', verifyApiKey, blockUsersCollectionDataAccess, checkUsageLimits, resolvePublicAuthContext, authorizeReadOperation, getSingleDoc); // DELETE REQ SINGLE DATA -router.delete('/:collectionName/:id', verifyApiKey, blockUsersCollectionDataAccess, resolvePublicAuthContext, projectRateLimiter, authorizeWriteOperation, deleteSingleDoc); +router.delete('/:collectionName/:id', verifyApiKey, blockUsersCollectionDataAccess, checkUsageLimits, resolvePublicAuthContext, authorizeWriteOperation, deleteSingleDoc); // PUT REQ SINGLE DATA -router.put('/:collectionName/:id', verifyApiKey, blockUsersCollectionDataAccess, resolvePublicAuthContext, projectRateLimiter, authorizeWriteOperation, updateSingleData); +router.put('/:collectionName/:id', verifyApiKey, blockUsersCollectionDataAccess, checkUsageLimits, resolvePublicAuthContext, authorizeWriteOperation, updateSingleData); // PATCH REQ SINGLE DATA -router.patch('/:collectionName/:id', verifyApiKey, blockUsersCollectionDataAccess, resolvePublicAuthContext, projectRateLimiter, authorizeWriteOperation, updateSingleData); +router.patch('/:collectionName/:id', verifyApiKey, blockUsersCollectionDataAccess, checkUsageLimits, resolvePublicAuthContext, authorizeWriteOperation, updateSingleData); module.exports = router; diff --git a/apps/public-api/src/routes/mail.js b/apps/public-api/src/routes/mail.js index ceab92ac..67aacaa1 100644 --- a/apps/public-api/src/routes/mail.js +++ b/apps/public-api/src/routes/mail.js @@ -2,9 +2,9 @@ const express = require("express"); const router = express.Router(); const verifyApiKey = require("../middlewares/verifyApiKey"); const requireSecretKey = require("../middlewares/requireSecretKey"); -const projectRateLimiter = require("../middlewares/projectRateLimiter"); +const { checkUsageLimits } = require("../middlewares/usageGate"); const { sendMail } = require("../controllers/mail.controller"); -router.post("/send", verifyApiKey, projectRateLimiter, requireSecretKey, sendMail); +router.post("/send", verifyApiKey, requireSecretKey, checkUsageLimits, sendMail); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/apps/public-api/src/routes/storage.js b/apps/public-api/src/routes/storage.js index 935f4551..193976b6 100644 --- a/apps/public-api/src/routes/storage.js +++ b/apps/public-api/src/routes/storage.js @@ -3,7 +3,7 @@ const router = express.Router(); const multer = require('multer'); const verifyApiKey = require('../middlewares/verifyApiKey'); const requireSecretKey = require('../middlewares/requireSecretKey'); -const projectRateLimiter = require('../middlewares/projectRateLimiter'); +const { checkUsageLimits } = require('../middlewares/usageGate'); const { uploadFile, deleteFile, deleteAllFiles } = require("../controllers/storage.controller") const storage = multer.memoryStorage(); @@ -13,12 +13,12 @@ const upload = multer({ }); // POST REQ UPLOAD FILE -router.post('/upload', verifyApiKey, projectRateLimiter, requireSecretKey, upload.single('file'), uploadFile); +router.post('/upload', verifyApiKey, requireSecretKey, checkUsageLimits, upload.single('file'), uploadFile); // DELETE REQ SINGLE FILE -router.delete('/file', verifyApiKey, projectRateLimiter, requireSecretKey, deleteFile); +router.delete('/file', verifyApiKey, requireSecretKey, checkUsageLimits, deleteFile); // DELETE REQ ALL FILES -router.delete('/all', verifyApiKey, projectRateLimiter, requireSecretKey, deleteAllFiles); +router.delete('/all', verifyApiKey, requireSecretKey, checkUsageLimits, deleteAllFiles); module.exports = router; \ No newline at end of file diff --git a/apps/public-api/src/utils/mailLimit.js b/apps/public-api/src/utils/mailLimit.js index c96e2f35..e8b26bee 100644 --- a/apps/public-api/src/utils/mailLimit.js +++ b/apps/public-api/src/utils/mailLimit.js @@ -1,4 +1,4 @@ -const MONTHLY_FREE_MAIL_LIMIT = 100; +const { getPlanLimits } = require('@urbackend/common'); const padMonth = (month) => String(month).padStart(2, "0"); @@ -15,9 +15,21 @@ const getEndOfMonthTtlSeconds = (now = new Date()) => { return Math.max(1, Math.ceil((nextMonthStart.getTime() - now.getTime()) / 1000)); }; -const getMonthlyMailLimit = () => { - // v0.9.0 default: free tier limit for all projects. - return MONTHLY_FREE_MAIL_LIMIT; +const getMonthlyMailLimit = (project, planLimitsContext = null) => { + // Primary path: planLimitsContext provided by usageGate middleware (plan-aware) + if (planLimitsContext && planLimitsContext.mailPerMonth !== undefined) { + return planLimitsContext.mailPerMonth; + } + + // Fallback: project.owner is a raw ObjectId here, not a Developer doc, + // so we cannot call resolveEffectivePlan safely. Apply free-tier + any + // project-level customLimits as a safe default. + const limits = getPlanLimits({ + plan: 'free', + customLimits: project?.customLimits || null + }); + + return limits.mailPerMonth; }; module.exports = { diff --git a/package-lock.json b/package-lock.json index 6184f34e..51ee4c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "urbackend-monorepo", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "urbackend-monorepo", - "version": "0.9.0", + "version": "0.10.0", + "license": "AGPL-3.0-only", "workspaces": [ "apps/*", "packages/*", @@ -21,7 +22,8 @@ } }, "apps/dashboard-api": { - "version": "0.8.0", + "version": "0.10.0", + "license": "AGPL-3.0-only", "dependencies": { "@kiroo/sdk": "^0.1.2", "@supabase/supabase-js": "^2.84.0", @@ -48,7 +50,8 @@ } }, "apps/public-api": { - "version": "0.8.0", + "version": "0.10.0", + "license": "AGPL-3.0-only", "dependencies": { "@kiroo/sdk": "^0.1.2", "@supabase/supabase-js": "^2.84.0", @@ -74,7 +77,8 @@ } }, "apps/web-dashboard": { - "version": "0.8.0", + "version": "0.10.0", + "license": "AGPL-3.0-only", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -13938,7 +13942,8 @@ }, "packages/common": { "name": "@urbackend/common", - "version": "0.8.0", + "version": "0.10.0", + "license": "AGPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.1020.0", "@kiroo/sdk": "^0.1.2", @@ -13961,7 +13966,7 @@ }, "sdks/urbackend-sdk": { "name": "@urbackend/sdk", - "version": "0.2.8", + "version": "0.3.1", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", @@ -14180,4 +14185,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 886f7cc0..3b735bdd 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -2,7 +2,6 @@ const { connectDB } = require("./config/db"); const redis = require("./config/redis"); -// redis cache const { setProjectByApiKeyCache, getProjectByApiKeyCache, @@ -10,6 +9,8 @@ const { setProjectById, getProjectById, deleteProjectById, + getDeveloperPlanCache, + setDeveloperPlanCache, } = require("./redis/redisCaching"); // Models @@ -87,6 +88,8 @@ const { getStorage } = require("./utils/storage.manager"); const validateEnv = require("./utils/validateEnv"); const { validateData, validateUpdateData } = require("./utils/validateData"); const sessionManager = require("./utils/session.manager"); +const planLimits = require("./utils/planLimits"); +const AppError = require("./utils/AppError"); module.exports = { connectDB, @@ -156,9 +159,13 @@ module.exports = { setProjectById, getProjectById, deleteProjectById, + getDeveloperPlanCache, + setDeveloperPlanCache, validateData, validateUpdateData, userSignupSchema, initAuthEmailWorker, ...sessionManager, + ...planLimits, + AppError, }; diff --git a/packages/common/src/models/Developer.js b/packages/common/src/models/Developer.js index dbef40db..fddf6c23 100644 --- a/packages/common/src/models/Developer.js +++ b/packages/common/src/models/Developer.js @@ -23,6 +23,23 @@ const developerSchema = new mongoose.Schema({ type: Number, default: 20 }, + plan: { + type: String, + enum: ['free', 'pro'], + default: 'free' + }, + planActivatedAt: { + type: Date, + default: null + }, + planExpiresAt: { + type: Date, + default: null + }, + trialUsed: { + type: Boolean, + default: false + }, refreshToken: { type: String, default: null, diff --git a/packages/common/src/models/Project.js b/packages/common/src/models/Project.js index c935ef67..f39088df 100644 --- a/packages/common/src/models/Project.js +++ b/packages/common/src/models/Project.js @@ -140,6 +140,14 @@ const projectSchema = new mongoose.Schema( config: { type: resourceConfigSchema, default: null }, }, }, + + // CUSTOM OVERRIDES (Enterprise/Exceptions) + customLimits: { + reqPerDay: { type: Number, default: null }, + storageBytes: { type: Number, default: null }, + mailPerMonth: { type: Number, default: null }, + maxCollections: { type: Number, default: null } + }, }, { timestamps: true }, ); diff --git a/packages/common/src/redis/redisCaching.js b/packages/common/src/redis/redisCaching.js index 7d378cae..1f04e3c5 100644 --- a/packages/common/src/redis/redisCaching.js +++ b/packages/common/src/redis/redisCaching.js @@ -106,11 +106,48 @@ async function deleteProjectById(id) { } } +async function setDeveloperPlanCache(id, data) { + if (redis.status !== "ready") return; + try { + await redis.set( + `developer:plan:${id}`, + JSON.stringify(data), + 'EX', + 60 * 5 // 5 minutes TTL as per plan.plan.md + ); + } catch (err) { + console.log(err); + } +} + +async function getDeveloperPlanCache(id) { + if (redis.status !== "ready") return null; + try { + const data = await redis.get(`developer:plan:${id}`); + return data ? JSON.parse(data) : null; + } catch (err) { + console.log(err); + return null; + } +} + +async function deleteDeveloperPlanCache(id) { + if (redis.status !== "ready") return; + try { + await redis.del(`developer:plan:${id}`); + } catch (err) { + console.log(err); + } +} + module.exports = { setProjectByApiKeyCache, getProjectByApiKeyCache, deleteProjectByApiKeyCache, setProjectById, getProjectById, - deleteProjectById + deleteProjectById, + setDeveloperPlanCache, + getDeveloperPlanCache, + deleteDeveloperPlanCache }; \ No newline at end of file diff --git a/packages/common/src/utils/AppError.js b/packages/common/src/utils/AppError.js new file mode 100644 index 00000000..8fa9f340 --- /dev/null +++ b/packages/common/src/utils/AppError.js @@ -0,0 +1,16 @@ +/** + * Centralized Error class for urBackend. + * Ensures consistent error structure: { success: false, data: {}, message: "" } + */ +class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; + this.isOperational = true; + + Error.captureStackTrace(this, this.constructor); + } +} + +module.exports = AppError; diff --git a/packages/common/src/utils/planLimits.js b/packages/common/src/utils/planLimits.js new file mode 100644 index 00000000..00dcde9b --- /dev/null +++ b/packages/common/src/utils/planLimits.js @@ -0,0 +1,113 @@ +const PLAN_LIMITS = { + free: { + maxProjects: 1, + maxCollections: 10, + reqPerDay: 5000, // Advertised 5k, protected by Per-Minute limit + reqPerMinute: 60, // Strict protection for free tier + storageBytes: 20971520, // 20MB + mongoBytes: 52428800, // 50MB + mailPerMonth: 50, + authUsersLimit: 1000, + byokEnabled: false, + byomEnabled: true, // BYOM always free + analyticsProEnabled: false, + teamsEnabled: false, + aiByokEnabled: false, + webhooksLimit: 100, // 100/month + webhookRetryEnabled: false, + mailTemplatesEnabled: false + }, + pro: { + maxProjects: 10, + maxCollections: -1, // Unlimited + reqPerDay: 50000, + reqPerMinute: 600, // 10x higher than free + storageBytes: -1, // Expected to use BYOS + mongoBytes: -1, // Expected to use BYOM + mailPerMonth: 1000, + authUsersLimit: -1, + byokEnabled: true, + byomEnabled: true, + analyticsProEnabled: true, + teamsEnabled: false, + aiByokEnabled: true, // OpenAI, Groq, Gemini + webhooksLimit: 1000, // 1000/month + webhookRetryEnabled: true, + mailTemplatesEnabled: true + } +}; + +/** + * Resolves the effective plan string for a developer, + * handling expiry and defaulting to 'free'. + */ +function resolveEffectivePlan(developer) { + if (!developer) return 'free'; + + // If plan expires, degrade to free + if (developer.planExpiresAt && new Date(developer.planExpiresAt) < new Date()) { + return 'free'; + } + + return developer.plan || 'free'; +} + +/** + * Merges plan defaults with optional enterprise project overrides. + * Only fields explicitly set (non-null) on overrides take effect. + */ +function mergeNullableOverrides(base, overrides) { + if (!overrides) return base; + const out = { ...base }; + for (const [k, v] of Object.entries(overrides)) { + if (v !== null && v !== undefined) { + out[k] = v; + } + } + return out; +} + +/** + * Applies legacy developer-level overrides ONLY when they are more generous + * than the plan default. Legacy limits are admin-granted exceptions (e.g., + * "allow this user 5 projects even on free"). They must never reduce a paid + * plan's higher entitlement (e.g., Pro allows 10 projects — a legacy value + * of 1 must NOT override that, but a legacy value of 20 WILL override it to maintain the exception). + */ +function mergeLegacyOverrides(base, legacyLimits) { + if (!legacyLimits) return base; + const out = { ...base }; + for (const [k, v] of Object.entries(legacyLimits)) { + if (v === null || v === undefined) continue; + const baseVal = base[k]; + // -1 means unlimited in plan defaults; never downgrade from unlimited + if (baseVal === -1) continue; + // Only apply legacy value if it is strictly more generous than the plan default + if (typeof v === 'number' && (v === -1 || v > baseVal)) { + out[k] = v; + } + } + return out; +} + +/** + * Calculates the final active limits for a project. + * Priority: Enterprise customLimits > Plan tier defaults (with legacy exceptions applied safely). + */ +function getPlanLimits({ plan, customLimits = null, legacyLimits = null }) { + const base = PLAN_LIMITS[plan] || PLAN_LIMITS.free; + + // Apply legacy limits only when they INCREASE entitlement beyond plan defaults + const withLegacy = mergeLegacyOverrides(base, legacyLimits); + + // Apply project-level enterprise overrides unconditionally + const finalLimits = mergeNullableOverrides(withLegacy, customLimits); + + return finalLimits; +} + +module.exports = { + PLAN_LIMITS, + resolveEffectivePlan, + getPlanLimits +}; diff --git a/tools/db-queries/backfill-developer-plans.js b/tools/db-queries/backfill-developer-plans.js new file mode 100644 index 00000000..b7dead84 --- /dev/null +++ b/tools/db-queries/backfill-developer-plans.js @@ -0,0 +1,27 @@ +const dotenv = require('dotenv'); +const path = require('path'); + +// Load environment variables from repo root .env +dotenv.config({ path: path.join(__dirname, '../../.env') }); + +const { Developer, connectDB } = require('../../packages/common'); + +async function backfill() { + try { + console.log('🚀 Starting Developer Plan Backfill...'); + await connectDB(); + + const result = await Developer.updateMany( + { plan: { $exists: false } }, + { $set: { plan: 'free' } } + ); + + console.log(`✅ Success! Updated ${result.modifiedCount} developer documents.`); + process.exit(0); + } catch (err) { + console.error('❌ Backfill failed:', err); + process.exit(1); + } +} + +backfill(); diff --git a/tools/db-import/global-mail-templates.json b/tools/db-queries/global-mail-templates.json similarity index 100% rename from tools/db-import/global-mail-templates.json rename to tools/db-queries/global-mail-templates.json