Skip to content

Commit 63bee25

Browse files
Merge pull request #128 from geturbackend/feature/plan-enforcement
feat: implement multi-tier plan system and usage enforcement (Free/Pro)
2 parents b96bc40 + 26a4567 commit 63bee25

24 files changed

Lines changed: 675 additions & 82 deletions

apps/dashboard-api/src/controllers/analytics.controller.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { Project, Log, Developer } = require("@urbackend/common");
1+
const { Project, Log, Developer, resolveEffectivePlan, getPlanLimits } = require("@urbackend/common");
22
const mongoose = require("mongoose");
33

44
/**
@@ -30,7 +30,7 @@ module.exports.getGlobalStats = async (req, res) => {
3030
}
3131
}
3232
]),
33-
Developer.findById(user_id).select("maxProjects maxCollections")
33+
Developer.findById(user_id).select("maxProjects maxCollections plan planExpiresAt")
3434
]);
3535

3636
const globalStats = stats[0] || {
@@ -44,12 +44,21 @@ module.exports.getGlobalStats = async (req, res) => {
4444
const projectIds = await Project.find({ owner: user_id }).distinct("_id");
4545
const totalRequests = await Log.countDocuments({ projectId: { $in: projectIds } });
4646

47+
const effectivePlan = resolveEffectivePlan(dev);
48+
const limits = getPlanLimits({
49+
plan: effectivePlan,
50+
legacyLimits: {
51+
maxProjects: dev?.maxProjects,
52+
maxCollections: dev?.maxCollections
53+
}
54+
});
55+
4756
res.json({
4857
...globalStats,
4958
totalRequests,
5059
limits: {
51-
maxProjects: dev?.maxProjects || 1,
52-
maxCollections: dev?.maxCollections || 20
60+
maxProjects: limits.maxProjects,
61+
maxCollections: limits.maxCollections
5362
}
5463
});
5564
} catch (err) {

apps/dashboard-api/src/controllers/project.controller.js

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -186,28 +186,28 @@ const sanitizeProjectResponse = (projectObj) => {
186186
};
187187

188188
module.exports.createProject = async (req, res) => {
189+
const session = await mongoose.startSession();
190+
session.startTransaction();
191+
189192
try {
190193
// POST FOR - PROJECT CREATION
191194
const { name, description, siteUrl } = createProjectSchema.parse(req.body);
192195

193-
// --- PROJECT LIMIT CHECK ---
194-
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
195-
196-
// GET MAX PROJECTS
197-
const dev = await Developer.findById(req.user._id);
198-
const MAX_PROJECTS = dev?.maxProjects || 1;
199-
200-
const isUserAdmin = dev.email === ADMIN_EMAIL;
201-
const projectCount = await Project.countDocuments({ owner: req.user._id });
196+
// Atomic limit enforcement: count and create within transaction
197+
if (req.projectLimit !== undefined) {
198+
const currentCount = await Project.countDocuments(
199+
{ owner: req.user._id },
200+
{ session }
201+
);
202202

203-
if (!isUserAdmin && projectCount >= MAX_PROJECTS) {
204-
return res.status(403).json({
205-
error: `Project limit reached. Your current plan allows up to ${MAX_PROJECTS} projects.`,
206-
limit: MAX_PROJECTS,
207-
current: projectCount,
208-
});
203+
if (currentCount >= req.projectLimit) {
204+
await session.abortTransaction();
205+
session.endSession();
206+
return res.status(403).json({
207+
error: `Project limit reached (${req.projectLimit}). Please upgrade your plan to create more projects.`
208+
});
209+
}
209210
}
210-
// ---------------------------
211211

212212
const rawPublishableKey = generateApiKey("pk_live_");
213213
const hashedPublishableKey = hashApiKey(rawPublishableKey);
@@ -226,7 +226,10 @@ module.exports.createProject = async (req, res) => {
226226
jwtSecret: rawJwtSecret,
227227
siteUrl: siteUrl || "",
228228
});
229-
await newProject.save();
229+
await newProject.save({ session });
230+
231+
await session.commitTransaction();
232+
session.endSession();
230233

231234
const projectObj = newProject.toObject();
232235
projectObj.publishableKey = rawPublishableKey;
@@ -236,6 +239,9 @@ module.exports.createProject = async (req, res) => {
236239

237240
res.status(201).json(projectObj);
238241
} catch (err) {
242+
await session.abortTransaction();
243+
session.endSession();
244+
239245
if (err instanceof z.ZodError) {
240246
return res.status(400).json({ error: err.issues });
241247
}
@@ -562,6 +568,8 @@ module.exports.createCollection = async (req, res) => {
562568
let collectionWasPersisted = false;
563569
let collectionNameForRollback;
564570
let collectionExistedBefore = false;
571+
const session = await mongoose.startSession();
572+
session.startTransaction();
565573

566574
try {
567575
const { projectId, collectionName, schema } = createCollectionSchema.parse(
@@ -573,19 +581,39 @@ module.exports.createCollection = async (req, res) => {
573581
project = await Project.findOne({
574582
_id: projectId,
575583
owner: req.user._id,
576-
});
577-
if (!project) return res.status(404).json({ error: "Project not found" });
584+
}).session(session);
585+
if (!project) {
586+
await session.abortTransaction();
587+
session.endSession();
588+
return res.status(404).json({ error: "Project not found" });
589+
}
578590

579591
const exists = project.collections.find((c) => c.name === collectionName);
580-
if (exists)
592+
if (exists) {
593+
await session.abortTransaction();
594+
session.endSession();
581595
return res.status(400).json({ error: "Collection already exists" });
596+
}
597+
598+
// Atomic limit enforcement within transaction
599+
if (req.collectionLimit !== undefined) {
600+
if (project.collections.length >= req.collectionLimit) {
601+
await session.abortTransaction();
602+
session.endSession();
603+
return res.status(403).json({
604+
error: `Collection limit reached (${req.collectionLimit}). Please upgrade your plan to create more collections.`
605+
});
606+
}
607+
}
582608

583609
if (!project.jwtSecret) {
584610
project.jwtSecret = generateApiKey("jwt_");
585611
}
586612

587613
if (collectionName === "users") {
588614
if (!validateUsersSchema(schema)) {
615+
await session.abortTransaction();
616+
session.endSession();
589617
return res.status(422).json({
590618
error:
591619
"The 'users' collection must have required 'email' and 'password' string fields.",
@@ -604,7 +632,7 @@ module.exports.createCollection = async (req, res) => {
604632
};
605633

606634
project.collections.push(newCollectionConfig);
607-
await project.save();
635+
await project.save({ session });
608636
collectionWasPersisted = true;
609637

610638
connection = await getConnection(projectId);
@@ -622,6 +650,9 @@ module.exports.createCollection = async (req, res) => {
622650

623651
await createUniqueIndexes(Model, newCollectionConfig.model);
624652

653+
await session.commitTransaction();
654+
session.endSession();
655+
625656
await deleteProjectById(projectId);
626657
await setProjectById(projectId, project.toObject());
627658
await deleteProjectByApiKeyCache(project.publishableKey);
@@ -634,14 +665,10 @@ module.exports.createCollection = async (req, res) => {
634665

635666
return res.status(201).json(projectObj);
636667
} catch (err) {
637-
try {
638-
if (project && collectionWasPersisted) {
639-
project.collections = project.collections.filter(
640-
(c) => c.name !== collectionNameForRollback,
641-
);
642-
await project.save();
643-
}
668+
await session.abortTransaction();
669+
session.endSession();
644670

671+
try {
645672
if (connection && compiledCollectionName) {
646673
clearCompiledModel(connection, compiledCollectionName);
647674

@@ -2006,4 +2033,4 @@ module.exports.updateCollectionRls = async (req, res) => {
20062033
} catch (err) {
20072034
res.status(500).json({ error: err.message });
20082035
}
2009-
}
2036+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
const { Developer, Project, resolveEffectivePlan, getPlanLimits, AppError } = require('@urbackend/common');
2+
const mongoose = require('mongoose');
3+
4+
/**
5+
* Middleware to load the full Developer document and attach it to req.developer.
6+
* req.user (from authMiddleware) contains the decoded JWT data.
7+
*/
8+
exports.attachDeveloper = async (req, res, next) => {
9+
try {
10+
if (!req.user || !req.user._id) {
11+
return next(new AppError(401, 'Unauthorized: Developer context missing'));
12+
}
13+
14+
const developer = await Developer.findById(req.user._id);
15+
if (!developer) {
16+
return next(new AppError(404, 'Developer not found'));
17+
}
18+
19+
req.developer = developer;
20+
next();
21+
} catch (err) {
22+
next(err);
23+
}
24+
};
25+
26+
/**
27+
* Middleware to check if the developer has reached their project creation limit.
28+
* Admin bypass uses JWT isAdmin flag (set at login time by auth.controller.js)
29+
* instead of email string comparison — consistent with existing auth pattern.
30+
*/
31+
exports.checkProjectLimit = async (req, res, next) => {
32+
try {
33+
// isAdmin is embedded in the JWT at login time — no extra DB hit needed
34+
if (req.user?.isAdmin) return next();
35+
36+
const effectivePlan = resolveEffectivePlan(req.developer);
37+
const limits = getPlanLimits({
38+
plan: effectivePlan,
39+
legacyLimits: {
40+
maxProjects: req.developer.maxProjects ?? null,
41+
maxCollections: req.developer.maxCollections ?? null
42+
}
43+
});
44+
45+
// -1 means unlimited
46+
if (limits.maxProjects === -1) return next();
47+
48+
// Store limits in req for atomic enforcement in controller
49+
req.projectLimit = limits.maxProjects;
50+
51+
next();
52+
} catch (err) {
53+
next(err);
54+
}
55+
};
56+
57+
/**
58+
* Middleware to check collection limits within a project.
59+
* Admin bypass uses JWT isAdmin flag (set at login time by auth.controller.js).
60+
*/
61+
exports.checkCollectionLimit = async (req, res, next) => {
62+
try {
63+
// isAdmin is embedded in the JWT at login time — no extra DB hit needed
64+
if (req.user?.isAdmin) return next();
65+
66+
// For collection creation, the projectId is usually in req.body
67+
const projectId = req.body.projectId;
68+
if (!projectId) return next(new AppError(400, 'projectId is required'));
69+
70+
// Prevent NoSQL Injection (CodeQL Alert: Database query built from user-controlled sources)
71+
if (typeof projectId !== 'string' || !/^[a-fA-F0-9]{24}$/.test(projectId)) {
72+
return next(new AppError(400, 'Invalid projectId format'));
73+
}
74+
75+
// Load project scoped to the requesting developer to enforce ownership
76+
const project = await Project.findOne({ _id: projectId, owner: req.developer._id });
77+
if (!project) return next(new AppError(404, 'Project not found'));
78+
79+
const effectivePlan = resolveEffectivePlan(req.developer);
80+
const limits = getPlanLimits({
81+
plan: effectivePlan,
82+
customLimits: project.customLimits,
83+
legacyLimits: {
84+
maxProjects: req.developer.maxProjects ?? null,
85+
maxCollections: req.developer.maxCollections ?? null
86+
}
87+
});
88+
89+
if (limits.maxCollections === -1) return next();
90+
91+
// Store limit in req for atomic enforcement in controller
92+
req.collectionLimit = limits.maxCollections;
93+
94+
next();
95+
} catch (err) {
96+
next(err);
97+
}
98+
};
99+
100+
/**
101+
* Middleware to block BYOK features for Free tier users.
102+
*/
103+
exports.checkByokGate = async (req, res, next) => {
104+
try {
105+
// Admin always has access to all features
106+
if (req.user?.isAdmin) return next();
107+
108+
let customLimits = null;
109+
const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId;
110+
111+
if (typeof rawProjectId === 'string' && mongoose.Types.ObjectId.isValid(rawProjectId)) {
112+
const projectObjectId = new mongoose.Types.ObjectId(rawProjectId);
113+
const project = await Project.findById(projectObjectId).select('customLimits').lean();
114+
if (project) {
115+
customLimits = project.customLimits;
116+
}
117+
}
118+
119+
const effectivePlan = resolveEffectivePlan(req.developer);
120+
const limits = getPlanLimits({ plan: effectivePlan, customLimits });
121+
122+
if (!limits.byokEnabled) {
123+
return next(new AppError(403, 'External configuration (BYOK) is a Pro feature. Please upgrade to connect your own resources.'));
124+
}
125+
126+
next();
127+
} catch (err) {
128+
next(err);
129+
}
130+
};
131+
132+
/**
133+
* Middleware to block BYOM features for users without access.
134+
*/
135+
exports.checkByomGate = async (req, res, next) => {
136+
try {
137+
// Admin always has access to all features
138+
if (req.user?.isAdmin) return next();
139+
140+
let customLimits = null;
141+
const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId;
142+
143+
if (typeof rawProjectId === 'string' && mongoose.Types.ObjectId.isValid(rawProjectId)) {
144+
const projectObjectId = new mongoose.Types.ObjectId(rawProjectId);
145+
const project = await Project.findById(projectObjectId).select('customLimits').lean();
146+
if (project) {
147+
customLimits = project.customLimits;
148+
}
149+
}
150+
151+
const effectivePlan = resolveEffectivePlan(req.developer);
152+
const limits = getPlanLimits({ plan: effectivePlan, customLimits });
153+
154+
if (!limits.byomEnabled) {
155+
return next(new AppError(403, 'External configuration (BYOM) is a Pro feature. Please upgrade to connect your own resources.'));
156+
}
157+
158+
next();
159+
} catch (err) {
160+
next(err);
161+
}
162+
};

0 commit comments

Comments
 (0)