Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions apps/dashboard-api/src/controllers/analytics.controller.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { Project, Log, Developer } = require("@urbackend/common");
const { Project, Log, Developer, resolveEffectivePlan, getPlanLimits } = require("@urbackend/common");
const mongoose = require("mongoose");

/**
Expand Down Expand Up @@ -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] || {
Expand All @@ -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
}
});
Comment on lines +47 to +54
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getGlobalStats calculates plan-based limits, but the Developer query only selects maxProjects/maxCollections. Because plan/planExpiresAt aren’t selected, resolveEffectivePlan(dev) will always fall back to 'free' and return incorrect limits for Pro users. Include plan (and planExpiresAt if applicable) in the select, or remove the select restriction.

Copilot uses AI. Check for mistakes.

res.json({
...globalStats,
totalRequests,
limits: {
maxProjects: dev?.maxProjects || 1,
maxCollections: dev?.maxCollections || 20
maxProjects: limits.maxProjects,
maxCollections: limits.maxCollections
}
});
} catch (err) {
Expand Down
85 changes: 56 additions & 29 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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(
Expand All @@ -573,19 +581,39 @@ 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_");
}

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.",
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);

Expand Down Expand Up @@ -2006,4 +2033,4 @@ module.exports.updateCollectionRls = async (req, res) => {
} catch (err) {
res.status(500).json({ error: err.message });
}
}
}
162 changes: 162 additions & 0 deletions apps/dashboard-api/src/middlewares/planEnforcement.js
Original file line number Diff line number Diff line change
@@ -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.'));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.'));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

next();
} catch (err) {
next(err);
}
};
Loading
Loading