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
9 changes: 1 addition & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ jest.mock('@urbackend/common', () => {

const Project = {
countDocuments: jest.fn(),
findById: jest.fn(),
findOne: jest.fn(),
};

Expand All @@ -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 },
Expand Down Expand Up @@ -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();
});
});
90 changes: 90 additions & 0 deletions apps/dashboard-api/src/__tests__/webhook.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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' })),
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);

Expand Down
39 changes: 36 additions & 3 deletions apps/dashboard-api/src/controllers/webhook.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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({
Expand Down
13 changes: 10 additions & 3 deletions apps/dashboard-api/src/middlewares/planEnforcement.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
32 changes: 15 additions & 17 deletions apps/landing/src/pages/pricing.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand All @@ -38,15 +36,15 @@ 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?',
a: 'Click "Get 1 month Pro for free" to unlock Pro features on your account with zero credit card required.'
},
{
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.'
}
];
---
Expand Down
23 changes: 20 additions & 3 deletions apps/public-api/src/controllers/mail.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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));
}
};
Expand Down
Loading
Loading