diff --git a/README.md b/README.md index 9a69cc75a..b547b50a0 100644 --- a/README.md +++ b/README.md @@ -58,14 +58,7 @@ That's it! šŸŽ‰ ## 🟢 Why urBackend? -While tools like PocketBase are great for small single-server apps (SQLite), **urBackend** is built for scale. - -| Feature | urBackend | Firebase | PocketBase | -| :--- | :--- | :--- | :--- | -| **Database** | **MongoDB (Scalable)** | Proprietary NoSQL | SQLite (Single Server) | -| **Caching** | **Redis Built-in** | None | None | -| **Hosting** | **Self-Hosted / Cloud** | Cloud Only | Self-Hosted | -| **Source** | **Open-Source** | Closed-Source | Open-Source | +**urBackend** is built for developer velocity and horizontal scale, combining the simplicity of a modern BaaS with the power of MongoDB and Redis. ### Core Features: - **Instant NoSQL:** Create collections and push JSON data instantly. diff --git a/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js b/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js index 17f0bac8a..e96dadd14 100644 --- a/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js +++ b/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js @@ -8,6 +8,7 @@ jest.mock('@urbackend/common', () => { const Project = { countDocuments: jest.fn(), + findById: jest.fn(), findOne: jest.fn(), }; @@ -16,12 +17,12 @@ jest.mock('@urbackend/common', () => { Project, sanitizeObjectId: jest.fn((value) => value || null), resolveEffectivePlan: jest.fn(() => 'free'), - getPlanLimits: jest.fn(() => ({ maxProjects: 1, maxCollections: 5 })), + getPlanLimits: jest.fn(() => ({ maxProjects: 1, maxCollections: 5, webhooksLimit: 3 })), }; }); const { Project, AppError } = require('@urbackend/common'); -const { checkDeveloperCapability, checkProjectLimit, checkCollectionLimit } = require('../middlewares/planEnforcement'); +const { checkDeveloperCapability, checkProjectLimit, checkCollectionLimit, checkWebhookGate } = require('../middlewares/planEnforcement'); const makeReq = (overrides = {}) => ({ user: { _id: 'dev_1', email: 'dev@example.com', isVerified: false }, @@ -131,4 +132,39 @@ describe('planEnforcement capability checks', () => { expect(next).toHaveBeenCalledWith(); }); + + test('passes the finite webhook quota to the create controller without pre-counting', async () => { + Project.findById.mockReturnValue({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue({ customLimits: null }), + }); + const req = makeReq({ + method: 'POST', + params: { projectId: 'project_1' }, + user: { _id: 'dev_1', email: 'dev@example.com', isVerified: true }, + developer: { _id: 'dev_1', isVerified: true }, + }); + const next = jest.fn(); + + await checkWebhookGate(req, {}, next); + + expect(req.webhookQuotaLimit).toBe(3); + expect(next).toHaveBeenCalledWith(); + }); + + test('preserves the admin webhook quota bypass', async () => { + const req = makeReq({ + method: 'POST', + params: { projectId: 'project_1' }, + user: { _id: 'admin_1', email: 'admin@example.com', isAdmin: true, isVerified: true }, + developer: { _id: 'admin_1', isVerified: true }, + }); + const next = jest.fn(); + + await checkWebhookGate(req, {}, next); + + expect(req.webhookQuotaLimit).toBeUndefined(); + expect(Project.findById).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); }); diff --git a/apps/dashboard-api/src/__tests__/webhook.controller.test.js b/apps/dashboard-api/src/__tests__/webhook.controller.test.js index 723f2c4a1..a5666853b 100644 --- a/apps/dashboard-api/src/__tests__/webhook.controller.test.js +++ b/apps/dashboard-api/src/__tests__/webhook.controller.test.js @@ -15,6 +15,7 @@ jest.mock('@urbackend/common', () => { return { Webhook: { create: jest.fn(), + countDocuments: jest.fn(), find: jest.fn(), findOne: jest.fn(), findOneAndUpdate: jest.fn(), @@ -26,6 +27,7 @@ jest.mock('@urbackend/common', () => { }, Project: { findOne: jest.fn(), + updateOne: jest.fn(), }, AppError, encrypt: jest.fn((val) => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })), @@ -95,6 +97,10 @@ describe('webhook.controller', () => { next = jest.fn(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('createWebhook', () => { test('creates webhook with valid input', async () => { Project.findOne.mockResolvedValue({ _id: validProjectId }); @@ -145,6 +151,90 @@ describe('webhook.controller', () => { ); }); + test('checks finite quota and creates the webhook in one transaction', async () => { + const session = { + withTransaction: jest.fn(async (operation) => operation()), + endSession: jest.fn(), + }; + jest.spyOn(mongoose, 'startSession').mockResolvedValue(session); + Project.findOne.mockResolvedValue({ _id: validProjectId }); + Project.updateOne.mockResolvedValue({ matchedCount: 1 }); + Webhook.countDocuments.mockResolvedValue(2); + createWebhookSchema.safeParse.mockReturnValue({ + success: true, + data: { + name: 'Test Webhook', + url: 'https://example.com/hook', + secret: 'whsec_testsecret12345678', + events: {}, + }, + }); + Webhook.create.mockResolvedValue([{ + _id: validWebhookId, + projectId: validProjectId, + name: 'Test Webhook', + url: 'https://example.com/hook', + events: new Map(), + enabled: true, + }]); + req.body = { + name: 'Test Webhook', + url: 'https://example.com/hook', + secret: 'whsec_testsecret12345678', + }; + req.webhookQuotaLimit = 3; + + await createWebhook(req, res, next); + + expect(Project.updateOne).toHaveBeenCalledWith( + { _id: validProjectId, owner: 'user123' }, + { $inc: { webhookQuotaVersion: 1 } }, + { session } + ); + expect(Webhook.countDocuments).toHaveBeenCalledWith( + { projectId: validProjectId }, + { session } + ); + expect(Webhook.create).toHaveBeenCalledWith( + [expect.objectContaining({ projectId: validProjectId, name: 'Test Webhook' })], + { session } + ); + expect(session.endSession).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + }); + + test('does not create a webhook when the transactional quota reservation fails', async () => { + const session = { + withTransaction: jest.fn(async (operation) => operation()), + endSession: jest.fn(), + }; + jest.spyOn(mongoose, 'startSession').mockResolvedValue(session); + Project.findOne.mockResolvedValue({ _id: validProjectId }); + Project.updateOne.mockResolvedValue({ matchedCount: 1 }); + Webhook.countDocuments.mockResolvedValue(3); + createWebhookSchema.safeParse.mockReturnValue({ + success: true, + data: { + name: 'Test Webhook', + url: 'https://example.com/hook', + secret: 'whsec_testsecret12345678', + }, + }); + req.body = { + name: 'Test Webhook', + url: 'https://example.com/hook', + secret: 'whsec_testsecret12345678', + }; + req.webhookQuotaLimit = 3; + + await createWebhook(req, res, next); + + expect(Webhook.create).not.toHaveBeenCalled(); + expect(session.endSession).toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + }); + test('returns 404 if project not found', async () => { Project.findOne.mockResolvedValue(null); diff --git a/apps/dashboard-api/src/controllers/webhook.controller.js b/apps/dashboard-api/src/controllers/webhook.controller.js index e09777627..019757c71 100644 --- a/apps/dashboard-api/src/controllers/webhook.controller.js +++ b/apps/dashboard-api/src/controllers/webhook.controller.js @@ -47,15 +47,48 @@ module.exports.createWebhook = async (req, res, next) => { // Encrypt the secret const encryptedSecret = encrypt(secret); - - const webhook = await Webhook.create({ + const webhookData = { projectId, name, url, secret: encryptedSecret, events: events || {}, enabled: enabled !== false, - }); + }; + + let webhook; + const quotaLimit = req.webhookQuotaLimit; + if (Number.isInteger(quotaLimit) && quotaLimit >= 0) { + const session = await mongoose.startSession(); + try { + await session.withTransaction(async () => { + // Updating one shared project document serializes concurrent quota + // checks for this project. The update rolls back with the insert. + const reservation = await Project.updateOne( + { _id: projectId, ...getProjectAccessQuery(req.user._id) }, + { $inc: { webhookQuotaVersion: 1 } }, + { session } + ); + if (reservation.matchedCount !== 1) { + throw new AppError(404, "Project not found"); + } + + const currentCount = await Webhook.countDocuments( + { projectId }, + { session } + ); + if (currentCount >= quotaLimit) { + throw new AppError(403, `Webhook limit reached (${quotaLimit}). Please upgrade your plan for unlimited webhooks.`); + } + + [webhook] = await Webhook.create([webhookData], { session }); + }); + } finally { + await session.endSession(); + } + } else { + webhook = await Webhook.create(webhookData); + } // Return without secret return new ApiResponse({ diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js index 70cd25cb6..d33220a97 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -186,8 +186,15 @@ exports.checkWebhookGate = async function(req, res, next) { return next(new AppError(403, 'Verify your email to create or test webhooks.')); } + // Only enforce count limit on new webhook creation (POST without webhookId) + const isCreate = req.method === 'POST' && !req.params.webhookId; + if (!isCreate) { + return next(); + } + const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId; const cleanProjectId = sanitizeObjectId(rawProjectId); + if (!cleanProjectId) return next(new AppError(400, 'Invalid or missing projectId')); let customLimits = null; if (cleanProjectId) { @@ -198,9 +205,9 @@ exports.checkWebhookGate = async function(req, res, next) { const effectivePlan = resolveEffectivePlan(req.developer); const limits = getPlanLimits({ plan: effectivePlan, customLimits }); - if (limits.webhooksLimit === 0) { - return next(new AppError(403, 'Webhooks are a Pro feature. Please upgrade to create integrations.')); - } + // The controller performs the authoritative quota check and insert in + // one transaction. Passing the limit avoids a count-then-create race. + req.webhookQuotaLimit = limits.webhooksLimit; next(); } catch (err) { diff --git a/apps/landing/src/pages/pricing.astro b/apps/landing/src/pages/pricing.astro index 05ab5babd..d890cfc85 100644 --- a/apps/landing/src/pages/pricing.astro +++ b/apps/landing/src/pages/pricing.astro @@ -5,29 +5,27 @@ import Footer from '../components/Footer.astro'; import { Check, ChevronDown, HelpCircle, ArrowRight } from 'lucide-react'; const FREE_FEATURES = [ - '1 Project', - '5 Collections per project', + '1 Project, 5 Collections', '2,000 API requests / day', - '10MB File Storage', '200 Auth Users', - '25 Emails / month', - 'Global email templates', + '3 Active Webhooks', + '50 Emails / month (Sends & Broadcasts)', + 'Custom HTML Email Templates', + 'Bring Your Own MongoDB (BYOM)', + 'Bring Your Own Storage (S3 / R2)', + 'Bring Your Own Keys (Resend, OAuth, AI)', + 'Team Collaboration (up to 2 members)', + 'Analytics Pro & Deep Insights', 'Community support', ]; const PRO_FEATURES = [ - '10 Projects', - 'Unlimited collections', - 'Unlimited API requests', + '10 Projects, Unlimited Collections', + 'Unlimited API requests (600 req/min burst)', 'Unlimited Auth Users', - 'Unlimited Webhooks', - 'External Database (BYOM)', - 'Bring your own Storage (S3/R2)', + 'Unlimited Webhooks + Priority Delivery', '1,000 Emails / month', - 'Custom HTML email templates', - 'BYOK — own API keys', - 'Analytics Pro', - 'AI integrations (OpenAI, Groq)', + 'Up to 6 Team Members', 'Priority support', ]; @@ -38,7 +36,7 @@ const PRICING_FAQS = [ }, { q: 'Can I bring my own MongoDB Atlas cluster on the Free tier?', - a: 'The Free tier includes urBackend managed database storage. Bring Your Own MongoDB (BYOM) is available on the Pro tier during beta.' + a: 'Yes! Both Free and Pro tiers support Bring Your Own MongoDB (BYOM) and Bring Your Own Storage (BYOS). Connect your Atlas cluster or S3 bucket directly on any plan.' }, { q: 'How does the free 1-month Pro trial work?', @@ -46,7 +44,7 @@ const PRICING_FAQS = [ }, { q: 'Do you offer team collaboration?', - a: 'Yes. Pro plans support inviting team members with granular project access controls and role-based permissions.' + a: 'Yes. Free plans allow up to 2 team members (owner + 1 collaborator). Pro plans support up to 6 members with role-based access control.' } ]; --- diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index 6fae46f00..be6c1dc1f 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -727,12 +727,12 @@ module.exports.deleteContact = async (req, res, next) => { } }; -// --- BROADCASTS (BYOK + Pro Gate) --- +// --- BROADCASTS (BYOK Gate + Quota Tracking) --- const requireBroadcastGate = async (req) => { const { resend, usingByok } = await resolveResendClient(req); if (!usingByok || !req.planLimits?.byokEnabled) { - const err = new Error("Broadcasts require both a BYOK Resend key and a Pro plan."); + const err = new Error("Broadcasts require a configured BYOK Resend key."); err.statusCode = 403; throw err; } @@ -766,17 +766,34 @@ module.exports.createBroadcast = async (req, res, next) => { }; module.exports.sendBroadcast = async (req, res, next) => { + let consumedQuotaKey = null; try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { return next(new AppError(400, "Invalid broadcast ID format.")); } const resend = await requireBroadcastGate(req); + + const projectId = req.project?._id?.toString() || req.project?._id; + if (projectId) { + const limit = getMonthlyMailLimit(req.project, req.planLimits); + const { count, key } = await reserveMonthlyMailSlot(projectId, limit); + consumedQuotaKey = key; + } + const { data, error } = await resend.broadcasts.send(id); - if (error) return next(new AppError(error.statusCode || 500, error.message)); + if (error) { + if (consumedQuotaKey) { + await redis.decr(consumedQuotaKey).catch(() => {}); + } + return next(new AppError(error.statusCode || 500, error.message)); + } return new ApiResponse(data).send(res, 200); } catch (err) { + if (consumedQuotaKey) { + await redis.decr(consumedQuotaKey).catch(() => {}); + } return next(new AppError(err.statusCode || 500, err.message)); } }; diff --git a/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx b/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx index add9b3fa6..2b138f19b 100644 --- a/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx +++ b/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx @@ -1,8 +1,12 @@ import React from 'react'; import { Folder, Activity, Zap } from 'lucide-react'; import StatCard from './StatCard'; +import { useAuth } from '../../context/AuthContext'; const StatsRow = ({ projectsCount }) => { + const { user } = useAuth(); + const isPro = user?.plan === 'pro'; + return (
{ />
); diff --git a/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx b/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx index 161f45563..a19bd269c 100644 --- a/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx +++ b/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx @@ -67,7 +67,7 @@ const UsageQuota = () => { { limit={limits?.storageBytes ?? 10485760} formatValue={formatBytes} unit="" - unlimited={limits?.storageBytes === -1} - tooltip={limits?.storageBytes === -1 ? "Bring Your Own Storage (BYOS) enabled: Connect an external storage provider for unlimited storage." : ""} + unlimited={limits?.storageBytes === -1 || limits?.byosEnabled} + tooltip={limits?.byosEnabled ? "Bring Your Own Storage (BYOS) enabled: Connect an external storage provider for unlimited storage." : (limits?.storageBytes === -1 ? "Unlimited managed file storage" : "")} /> { {!isPro && (