-
Notifications
You must be signed in to change notification settings - Fork 35
feat: implement multi-tier plan system and usage enforcement (Free/Pro) #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
7bc2975
feat: implement multi-tier plan system and usage enforcement (Free/Pro)
yash-pouranik ff8d46f
chore: remove unused plan enums (team/enterprise)
yash-pouranik 56de139
fix: return 400 early in limit middleware if projectId is missing
yash-pouranik dc94f2e
fix: CodeQL High - prevent nosql injection on projectId in limit midd…
yash-pouranik f4b5c37
test: update hardcoded storage limit assert string
yash-pouranik e272023
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] 02e2e50
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] 64b09b3
test: mock getPlanLimits dependency in mail tests to fix 500 err
yash-pouranik b67f78e
Merge branch 'feature/plan-enforcement' of https://github.com/geturba…
yash-pouranik 1a0ba2d
fix: resolve Copilot flagged issues (fail-open template check, missin…
yash-pouranik e0fbd4c
feat: make system mail templates free and restrict only custom templa…
yash-pouranik d4d36f6
fix: pass customLimits to feature gates defensively for BYOM/BYOK
yash-pouranik 967f2bd
Potential fix for pull request finding 'CodeQL / Database query built…
yash-pouranik 26a4567
chore: remove unused mongoose import and clarify legacy limit comment
yash-pouranik File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.')); | ||
| } | ||
|
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.')); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| next(); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.