From 7bc297562a53c57ec4c5ca4b6782cfaa90994fa3 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 01:25:33 +0530 Subject: [PATCH 01/13] feat: implement multi-tier plan system and usage enforcement (Free/Pro) --- .../src/controllers/analytics.controller.js | 15 ++- .../src/controllers/project.controller.js | 19 +-- .../src/middlewares/planEnforcement.js | 115 ++++++++++++++++++ apps/dashboard-api/src/routes/projects.js | 7 +- apps/public-api/src/app.js | 10 ++ .../src/controllers/mail.controller.js | 11 +- .../src/controllers/storage.controller.js | 29 +++-- apps/public-api/src/middlewares/usageGate.js | 94 ++++++++++++++ apps/public-api/src/routes/data.js | 16 +-- apps/public-api/src/routes/mail.js | 4 +- apps/public-api/src/routes/storage.js | 8 +- apps/public-api/src/utils/mailLimit.js | 20 ++- packages/common/src/index.js | 4 + packages/common/src/models/Developer.js | 17 +++ packages/common/src/models/Project.js | 8 ++ packages/common/src/redis/redisCaching.js | 39 +++++- packages/common/src/utils/AppError.js | 16 +++ packages/common/src/utils/planLimits.js | 113 +++++++++++++++++ tools/db-queries/backfill-developer-plans.js | 28 +++++ .../global-mail-templates.json | 0 20 files changed, 520 insertions(+), 53 deletions(-) create mode 100644 apps/dashboard-api/src/middlewares/planEnforcement.js create mode 100644 apps/public-api/src/middlewares/usageGate.js create mode 100644 packages/common/src/utils/AppError.js create mode 100644 packages/common/src/utils/planLimits.js create mode 100644 tools/db-queries/backfill-developer-plans.js rename tools/{db-import => db-queries}/global-mail-templates.json (100%) diff --git a/apps/dashboard-api/src/controllers/analytics.controller.js b/apps/dashboard-api/src/controllers/analytics.controller.js index 736b98c2c..0a6734ca7 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"); /** @@ -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 059b34db8..d8538df45 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -190,24 +190,7 @@ module.exports.createProject = async (req, res) => { // 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 }); - - 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, - }); - } - // --------------------------- + // Project limit is now handled by checkProjectLimit middleware const rawPublishableKey = generateApiKey("pk_live_"); const hashedPublishableKey = hashApiKey(rawPublishableKey); diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js new file mode 100644 index 000000000..ce6d6d49d --- /dev/null +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -0,0 +1,115 @@ +const { Developer, Project, resolveEffectivePlan, getPlanLimits, AppError } = require('@urbackend/common'); + +/** + * 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(); + + const currentProjectsCount = await Project.countDocuments({ owner: req.developer._id }); + + if (currentProjectsCount >= limits.maxProjects) { + return next(new AppError(403, `Project limit reached (${limits.maxProjects}). Please upgrade your plan to create more projects.`)); + } + + 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(); + + const effectivePlan = resolveEffectivePlan(req.developer); + const limits = getPlanLimits({ + plan: effectivePlan, + legacyLimits: { + maxProjects: req.developer.maxProjects ?? null, + maxCollections: req.developer.maxCollections ?? null + } + }); + + if (limits.maxCollections === -1) return next(); + + // For collection creation, the projectId is usually in req.body + const projectId = req.body.projectId; + if (!projectId) return next(); + + const project = await Project.findById(projectId); + if (!project) return next(new AppError(404, 'Project not found')); + + if (project.collections.length >= limits.maxCollections) { + return next(new AppError(403, `Collection limit reached (${limits.maxCollections}). Please upgrade your plan to create more collections.`)); + } + + next(); + } catch (err) { + next(err); + } +}; + +/** + * Middleware to block BYOK/BYOM 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(); + + const effectivePlan = resolveEffectivePlan(req.developer); + const limits = getPlanLimits({ plan: effectivePlan }); + + if (!limits.byokEnabled) { + return next(new AppError(403, 'External configuration (BYOK/BYOM) is a Pro feature. Please upgrade to connect your own resources.')); + } + + next(); + } catch (err) { + next(err); + } +}; diff --git a/apps/dashboard-api/src/routes/projects.js b/apps/dashboard-api/src/routes/projects.js index 0b3c1498e..301a45cb3 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/app.js b/apps/public-api/src/app.js index ba8f8cdbd..9040f6eaa 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 638381324..de2601b01 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -175,6 +175,15 @@ module.exports.sendMail = async (req, res) => { (typeof templateName === "string" && templateName.trim().length > 0); if (usingTemplate) { + const limits = req.planLimits || {}; + if (limits.mailTemplatesEnabled === false) { + return res.status(403).json({ + success: false, + data: {}, + message: "Pre-designed Email Templates are a Pro feature. Please upgrade to use this functionality." + }); + } + let t = null; if (templateId) { @@ -285,7 +294,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 de0ca7999..17196bc17 100644 --- a/apps/public-api/src/controllers/storage.controller.js +++ b/apps/public-api/src/controllers/storage.controller.js @@ -38,16 +38,27 @@ 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 || {}; + const effectiveLimit = limits.storageBytes || project.storageLimit || 20 * 1024 * 1024; + + // If plan allows unlimited internal storage (though Pro expects BYOS) + if (effectiveLimit === -1) { + await Project.updateOne( + { _id: project._id }, + { $inc: { storageUsed: file.size } } + ); + } 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: "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." }); + } } } diff --git a/apps/public-api/src/middlewares/usageGate.js b/apps/public-api/src/middlewares/usageGate.js new file mode 100644 index 000000000..38f0b7f5b --- /dev/null +++ b/apps/public-api/src/middlewares/usageGate.js @@ -0,0 +1,94 @@ +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 + }); + + // 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 + // Use existing key pattern from api_usage.js for consistency + const day = getDayKey(); + const reqCountKey = `project:usage:req:count:${req.project._id}:${day}`; + + // We use INCRBY 0 to just read the current count if we don't want to double count, + // BUT actually, we should increment it here and skip it in the logger to be precise. + // For now, let's just check the current value. + const currentDailyCount = parseInt(await redis.get(reqCountKey) || '0'); + + if (limits.reqPerDay !== -1 && currentDailyCount >= limits.reqPerDay) { + return next(new AppError(429, 'Daily request limit reached. Upgrade your plan to increase limits.')); + } + + // Attach limits to request for downstream usage if needed + req.planLimits = limits; + next(); + } catch (err) { + // Fallback to next if redis fails to avoid blocking all traffic + console.error("Usage limit check failed:", err); + next(); + } +}; diff --git a/apps/public-api/src/routes/data.js b/apps/public-api/src/routes/data.js index bc0501a11..08b4b86ec 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 ceab92ac8..21103e059 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, checkUsageLimits, requireSecretKey, sendMail); module.exports = router; diff --git a/apps/public-api/src/routes/storage.js b/apps/public-api/src/routes/storage.js index 935f45518..090388c07 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, checkUsageLimits, requireSecretKey, upload.single('file'), uploadFile); // DELETE REQ SINGLE FILE -router.delete('/file', verifyApiKey, projectRateLimiter, requireSecretKey, deleteFile); +router.delete('/file', verifyApiKey, checkUsageLimits, requireSecretKey, deleteFile); // DELETE REQ ALL FILES -router.delete('/all', verifyApiKey, projectRateLimiter, requireSecretKey, deleteAllFiles); +router.delete('/all', verifyApiKey, checkUsageLimits, requireSecretKey, 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 c96e2f353..e8b26bee4 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/packages/common/src/index.js b/packages/common/src/index.js index 886f7cc0f..67d214e8c 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -87,6 +87,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, @@ -161,4 +163,6 @@ module.exports = { userSignupSchema, initAuthEmailWorker, ...sessionManager, + ...planLimits, + AppError, }; diff --git a/packages/common/src/models/Developer.js b/packages/common/src/models/Developer.js index dbef40db9..d88a7629a 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', 'team', 'enterprise'], + 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 c935ef674..f39088df3 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 7d378cae1..1f04e3c55 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 000000000..8fa9f3406 --- /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 000000000..f30b4332a --- /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 or 20 must NOT override that). + */ +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 000000000..53c2f7a7e --- /dev/null +++ b/tools/db-queries/backfill-developer-plans.js @@ -0,0 +1,28 @@ +const mongoose = require('mongoose'); +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 From ff8d46fcfc8cb0ca8233119dc97ea81518c5f0df Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 01:29:19 +0530 Subject: [PATCH 02/13] chore: remove unused plan enums (team/enterprise) --- packages/common/src/models/Developer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/models/Developer.js b/packages/common/src/models/Developer.js index d88a7629a..fddf6c23a 100644 --- a/packages/common/src/models/Developer.js +++ b/packages/common/src/models/Developer.js @@ -25,7 +25,7 @@ const developerSchema = new mongoose.Schema({ }, plan: { type: String, - enum: ['free', 'pro', 'team', 'enterprise'], + enum: ['free', 'pro'], default: 'free' }, planActivatedAt: { From 56de139417b1b22c899fbfc1fb7bb6d2d86a6e0f Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 01:48:12 +0530 Subject: [PATCH 03/13] fix: return 400 early in limit middleware if projectId is missing --- apps/dashboard-api/src/middlewares/planEnforcement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js index ce6d6d49d..e6e41a59c 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -78,7 +78,7 @@ exports.checkCollectionLimit = async (req, res, next) => { // For collection creation, the projectId is usually in req.body const projectId = req.body.projectId; - if (!projectId) return next(); + if (!projectId) return next(new AppError(400, 'projectId is required')); const project = await Project.findById(projectId); if (!project) return next(new AppError(404, 'Project not found')); From dc94f2ec8e44a0e38086abd83af267a3dba00892 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 01:50:03 +0530 Subject: [PATCH 04/13] fix: CodeQL High - prevent nosql injection on projectId in limit middleware --- apps/dashboard-api/src/middlewares/planEnforcement.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js index e6e41a59c..59592d5ab 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -80,6 +80,11 @@ exports.checkCollectionLimit = async (req, res, next) => { 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')); + } + const project = await Project.findById(projectId); if (!project) return next(new AppError(404, 'Project not found')); From f4b5c37af6df6a7d3ee023cd21c739715d81ae19 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 02:07:59 +0530 Subject: [PATCH 05/13] test: update hardcoded storage limit assert string --- apps/public-api/src/__tests__/storage.controller.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/public-api/src/__tests__/storage.controller.test.js b/apps/public-api/src/__tests__/storage.controller.test.js index 86b473d0c..2d5012fe3 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 () => { From e272023e8fd283764f655089f3b491fa288ec50c Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:43:57 +0000 Subject: [PATCH 06/13] fix: apply CodeRabbit auto-fixes Fixed 7 file(s) based on 6 unresolved review comments. Co-authored-by: CodeRabbit --- .../src/middlewares/planEnforcement.js | 51 ++++++++++++++----- .../src/controllers/storage.controller.js | 36 ++++++++++--- apps/public-api/src/middlewares/api_usage.js | 11 ++-- apps/public-api/src/middlewares/usageGate.js | 28 +++++----- apps/public-api/src/routes/mail.js | 4 +- apps/public-api/src/routes/storage.js | 6 +-- package-lock.json | 21 +++++--- 7 files changed, 107 insertions(+), 50 deletions(-) diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js index 59592d5ab..24218c421 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -65,17 +65,6 @@ exports.checkCollectionLimit = async (req, res, next) => { // 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 - } - }); - - if (limits.maxCollections === -1) 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')); @@ -85,9 +74,22 @@ exports.checkCollectionLimit = async (req, res, next) => { return next(new AppError(400, 'Invalid projectId format')); } - const project = await Project.findById(projectId); + // 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(); + if (project.collections.length >= limits.maxCollections) { return next(new AppError(403, `Collection limit reached (${limits.maxCollections}). Please upgrade your plan to create more collections.`)); } @@ -99,7 +101,7 @@ exports.checkCollectionLimit = async (req, res, next) => { }; /** - * Middleware to block BYOK/BYOM features for Free tier users. + * Middleware to block BYOK features for Free tier users. */ exports.checkByokGate = async (req, res, next) => { try { @@ -110,7 +112,7 @@ exports.checkByokGate = async (req, res, next) => { const limits = getPlanLimits({ plan: effectivePlan }); if (!limits.byokEnabled) { - return next(new AppError(403, 'External configuration (BYOK/BYOM) is a Pro feature. Please upgrade to connect your own resources.')); + return next(new AppError(403, 'External configuration (BYOK) is a Pro feature. Please upgrade to connect your own resources.')); } next(); @@ -118,3 +120,24 @@ exports.checkByokGate = async (req, res, next) => { 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(); + + const effectivePlan = resolveEffectivePlan(req.developer); + const limits = getPlanLimits({ plan: effectivePlan }); + + 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/public-api/src/controllers/storage.controller.js b/apps/public-api/src/controllers/storage.controller.js index 17196bc17..34f1c3c55 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"; @@ -39,18 +40,39 @@ module.exports.uploadFile = async (req, res) => { // ATOMIC QUOTA RESERVATION if (!external) { const limits = req.planLimits || {}; - const effectiveLimit = limits.storageBytes || project.storageLimit || 20 * 1024 * 1024; - - // If plan allows unlimited internal storage (though Pro expects BYOS) - if (effectiveLimit === -1) { + // 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; + } + + // Only honor -1 (unlimited) for external storage; clamp internal to safety ceiling + if (effectiveLimit === -1 && isProjectStorageExternal(project)) { await Project.updateOne( { _id: project._id }, { $inc: { storageUsed: file.size } } ); + } else 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: "Storage limit exceeded. Please upgrade your plan or delete some files." }); + } } else { const result = await Project.updateOne( - { - _id: project._id, + { + _id: project._id, $expr: { $lte: [{ $add: ["$storageUsed", file.size] }, effectiveLimit] } }, { $inc: { storageUsed: file.size } } @@ -234,4 +256,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 5f432f671..e34ea2627 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 index 38f0b7f5b..574f88570 100644 --- a/apps/public-api/src/middlewares/usageGate.js +++ b/apps/public-api/src/middlewares/usageGate.js @@ -53,42 +53,46 @@ exports.checkUsageLimits = async (req, res, 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 + // 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}`; - - // We use INCRBY 0 to just read the current count if we don't want to double count, - // BUT actually, we should increment it here and skip it in the logger to be precise. - // For now, let's just check the current value. - const currentDailyCount = parseInt(await redis.get(reqCountKey) || '0'); - if (limits.reqPerDay !== -1 && currentDailyCount >= limits.reqPerDay) { + // 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.')); } - // Attach limits to request for downstream usage if needed - req.planLimits = 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/mail.js b/apps/public-api/src/routes/mail.js index 21103e059..67aacaa1f 100644 --- a/apps/public-api/src/routes/mail.js +++ b/apps/public-api/src/routes/mail.js @@ -5,6 +5,6 @@ const requireSecretKey = require("../middlewares/requireSecretKey"); const { checkUsageLimits } = require("../middlewares/usageGate"); const { sendMail } = require("../controllers/mail.controller"); -router.post("/send", verifyApiKey, checkUsageLimits, 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 090388c07..193976b65 100644 --- a/apps/public-api/src/routes/storage.js +++ b/apps/public-api/src/routes/storage.js @@ -13,12 +13,12 @@ const upload = multer({ }); // POST REQ UPLOAD FILE -router.post('/upload', verifyApiKey, checkUsageLimits, requireSecretKey, upload.single('file'), uploadFile); +router.post('/upload', verifyApiKey, requireSecretKey, checkUsageLimits, upload.single('file'), uploadFile); // DELETE REQ SINGLE FILE -router.delete('/file', verifyApiKey, checkUsageLimits, requireSecretKey, deleteFile); +router.delete('/file', verifyApiKey, requireSecretKey, checkUsageLimits, deleteFile); // DELETE REQ ALL FILES -router.delete('/all', verifyApiKey, checkUsageLimits, requireSecretKey, deleteAllFiles); +router.delete('/all', verifyApiKey, requireSecretKey, checkUsageLimits, deleteAllFiles); module.exports = router; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6184f34ec..51ee4c038 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 From 02e2e50dac35caaa38e10d4d919d94b19c4b61d3 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:57:07 +0000 Subject: [PATCH 07/13] fix: apply CodeRabbit auto-fixes Fixed 4 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit --- .../src/controllers/project.controller.js | 72 +++++++++++++++---- .../src/middlewares/planEnforcement.js | 13 ++-- .../src/controllers/storage.controller.js | 9 +-- 3 files changed, 65 insertions(+), 29 deletions(-) diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index d8538df45..1029dd15c 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -186,11 +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 is now handled by checkProjectLimit middleware + // Atomic limit enforcement: count and create within transaction + if (req.projectLimit !== undefined) { + const currentCount = await Project.countDocuments( + { owner: req.user._id }, + { session } + ); + + 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); @@ -209,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; @@ -219,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 }); } @@ -545,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( @@ -556,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_"); @@ -569,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.", @@ -587,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); @@ -605,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); @@ -617,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); @@ -1989,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 index 24218c421..9e09ecf88 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -1,4 +1,5 @@ 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. @@ -44,11 +45,8 @@ exports.checkProjectLimit = async (req, res, next) => { // -1 means unlimited if (limits.maxProjects === -1) return next(); - const currentProjectsCount = await Project.countDocuments({ owner: req.developer._id }); - - if (currentProjectsCount >= limits.maxProjects) { - return next(new AppError(403, `Project limit reached (${limits.maxProjects}). Please upgrade your plan to create more projects.`)); - } + // Store limits in req for atomic enforcement in controller + req.projectLimit = limits.maxProjects; next(); } catch (err) { @@ -90,9 +88,8 @@ exports.checkCollectionLimit = async (req, res, next) => { if (limits.maxCollections === -1) return next(); - if (project.collections.length >= limits.maxCollections) { - return next(new AppError(403, `Collection limit reached (${limits.maxCollections}). Please upgrade your plan to create more collections.`)); - } + // Store limit in req for atomic enforcement in controller + req.collectionLimit = limits.maxCollections; next(); } catch (err) { diff --git a/apps/public-api/src/controllers/storage.controller.js b/apps/public-api/src/controllers/storage.controller.js index 34f1c3c55..f84d75a95 100644 --- a/apps/public-api/src/controllers/storage.controller.js +++ b/apps/public-api/src/controllers/storage.controller.js @@ -50,13 +50,8 @@ module.exports.uploadFile = async (req, res) => { effectiveLimit = 20 * 1024 * 1024; } - // Only honor -1 (unlimited) for external storage; clamp internal to safety ceiling - if (effectiveLimit === -1 && isProjectStorageExternal(project)) { - await Project.updateOne( - { _id: project._id }, - { $inc: { storageUsed: file.size } } - ); - } else if (effectiveLimit === -1) { + // 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( { From 64b09b35ceca06fdce306f0c29d0780347e2f83b Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 02:28:45 +0530 Subject: [PATCH 08/13] test: mock getPlanLimits dependency in mail tests to fix 500 err --- apps/public-api/src/__tests__/mail.controller.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/public-api/src/__tests__/mail.controller.test.js b/apps/public-api/src/__tests__/mail.controller.test.js index 462ede77f..597e91f41 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 })), }; }); From 1a0ba2decfd183f4a84fd7a9fffdf2ebe53b4cc3 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 02:41:04 +0530 Subject: [PATCH 09/13] fix: resolve Copilot flagged issues (fail-open template check, missing exports, missing select properties) --- apps/dashboard-api/src/controllers/analytics.controller.js | 2 +- apps/public-api/src/__tests__/mail.controller.test.js | 1 + apps/public-api/src/controllers/mail.controller.js | 3 +-- packages/common/src/index.js | 5 ++++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/dashboard-api/src/controllers/analytics.controller.js b/apps/dashboard-api/src/controllers/analytics.controller.js index 0a6734ca7..865363f2f 100644 --- a/apps/dashboard-api/src/controllers/analytics.controller.js +++ b/apps/dashboard-api/src/controllers/analytics.controller.js @@ -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] || { diff --git a/apps/public-api/src/__tests__/mail.controller.test.js b/apps/public-api/src/__tests__/mail.controller.test.js index 597e91f41..7666918d6 100644 --- a/apps/public-api/src/__tests__/mail.controller.test.js +++ b/apps/public-api/src/__tests__/mail.controller.test.js @@ -45,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/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index de2601b01..1d11b61d8 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -175,8 +175,7 @@ module.exports.sendMail = async (req, res) => { (typeof templateName === "string" && templateName.trim().length > 0); if (usingTemplate) { - const limits = req.planLimits || {}; - if (limits.mailTemplatesEnabled === false) { + if (!req.planLimits || req.planLimits.mailTemplatesEnabled !== true) { return res.status(403).json({ success: false, data: {}, diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 67d214e8c..3b735bdd7 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 @@ -158,6 +159,8 @@ module.exports = { setProjectById, getProjectById, deleteProjectById, + getDeveloperPlanCache, + setDeveloperPlanCache, validateData, validateUpdateData, userSignupSchema, From e0fbd4c7ab84d76bb41571c75f3405727ad01199 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 02:54:04 +0530 Subject: [PATCH 10/13] feat: make system mail templates free and restrict only custom templates to pro --- .../src/controllers/mail.controller.js | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index 1d11b61d8..724607d70 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -175,14 +175,6 @@ module.exports.sendMail = async (req, res) => { (typeof templateName === "string" && templateName.trim().length > 0); if (usingTemplate) { - if (!req.planLimits || req.planLimits.mailTemplatesEnabled !== true) { - return res.status(403).json({ - success: false, - data: {}, - message: "Pre-designed Email Templates are a Pro feature. Please upgrade to use this functionality." - }); - } - let t = null; if (templateId) { @@ -233,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 = { From d4d36f63e5e54bafde1489c3282b681651829b44 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 02:55:40 +0530 Subject: [PATCH 11/13] fix: pass customLimits to feature gates defensively for BYOM/BYOK --- .../src/middlewares/planEnforcement.js | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js index 9e09ecf88..38ec65f8e 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -105,8 +105,18 @@ exports.checkByokGate = async (req, res, next) => { // Admin always has access to all features if (req.user?.isAdmin) return next(); + let customLimits = null; + const projectId = req.params.projectId || req.body.projectId || req.query.projectId; + + if (projectId && /^[a-fA-F0-9]{24}$/.test(projectId)) { + const project = await Project.findById(projectId).select('customLimits').lean(); + if (project) { + customLimits = project.customLimits; + } + } + const effectivePlan = resolveEffectivePlan(req.developer); - const limits = getPlanLimits({ plan: effectivePlan }); + 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.')); @@ -126,8 +136,18 @@ exports.checkByomGate = async (req, res, next) => { // Admin always has access to all features if (req.user?.isAdmin) return next(); + let customLimits = null; + const projectId = req.params.projectId || req.body.projectId || req.query.projectId; + + if (projectId && /^[a-fA-F0-9]{24}$/.test(projectId)) { + const project = await Project.findById(projectId).select('customLimits').lean(); + if (project) { + customLimits = project.customLimits; + } + } + const effectivePlan = resolveEffectivePlan(req.developer); - const limits = getPlanLimits({ plan: effectivePlan }); + 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.')); From 967f2bdae89a31cbee8cd001ecfa44e44e7403cb Mon Sep 17 00:00:00 2001 From: Yash Pouranik Date: Thu, 23 Apr 2026 03:00:59 +0530 Subject: [PATCH 12/13] Potential fix for pull request finding 'CodeQL / Database query built from user-controlled sources' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../src/middlewares/planEnforcement.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js index 38ec65f8e..245204a93 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -106,10 +106,11 @@ exports.checkByokGate = async (req, res, next) => { if (req.user?.isAdmin) return next(); let customLimits = null; - const projectId = req.params.projectId || req.body.projectId || req.query.projectId; + const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId; - if (projectId && /^[a-fA-F0-9]{24}$/.test(projectId)) { - const project = await Project.findById(projectId).select('customLimits').lean(); + 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; } @@ -137,10 +138,11 @@ exports.checkByomGate = async (req, res, next) => { if (req.user?.isAdmin) return next(); let customLimits = null; - const projectId = req.params.projectId || req.body.projectId || req.query.projectId; + const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId; - if (projectId && /^[a-fA-F0-9]{24}$/.test(projectId)) { - const project = await Project.findById(projectId).select('customLimits').lean(); + 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; } From 26a456792b94fb9ea1e4118d20340a82ffa38699 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Thu, 23 Apr 2026 03:08:57 +0530 Subject: [PATCH 13/13] chore: remove unused mongoose import and clarify legacy limit comment --- packages/common/src/utils/planLimits.js | 2 +- tools/db-queries/backfill-developer-plans.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/common/src/utils/planLimits.js b/packages/common/src/utils/planLimits.js index f30b4332a..00dcde9b1 100644 --- a/packages/common/src/utils/planLimits.js +++ b/packages/common/src/utils/planLimits.js @@ -72,7 +72,7 @@ function mergeNullableOverrides(base, overrides) { * 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 or 20 must NOT override that). + * 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; diff --git a/tools/db-queries/backfill-developer-plans.js b/tools/db-queries/backfill-developer-plans.js index 53c2f7a7e..b7dead845 100644 --- a/tools/db-queries/backfill-developer-plans.js +++ b/tools/db-queries/backfill-developer-plans.js @@ -1,4 +1,3 @@ -const mongoose = require('mongoose'); const dotenv = require('dotenv'); const path = require('path');