Skip to content

Commit 95bc164

Browse files
fix: enforce webhook quotas atomically and list analytics on the free plan
Serialize quota checks and webhook creation in a transaction, preserve admin bypass, and add regression tests.
1 parent e469e2c commit 95bc164

6 files changed

Lines changed: 172 additions & 13 deletions

File tree

‎apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ jest.mock('@urbackend/common', () => {
88

99
const Project = {
1010
countDocuments: jest.fn(),
11+
findById: jest.fn(),
1112
findOne: jest.fn(),
1213
};
1314

@@ -16,12 +17,12 @@ jest.mock('@urbackend/common', () => {
1617
Project,
1718
sanitizeObjectId: jest.fn((value) => value || null),
1819
resolveEffectivePlan: jest.fn(() => 'free'),
19-
getPlanLimits: jest.fn(() => ({ maxProjects: 1, maxCollections: 5 })),
20+
getPlanLimits: jest.fn(() => ({ maxProjects: 1, maxCollections: 5, webhooksLimit: 3 })),
2021
};
2122
});
2223

2324
const { Project, AppError } = require('@urbackend/common');
24-
const { checkDeveloperCapability, checkProjectLimit, checkCollectionLimit } = require('../middlewares/planEnforcement');
25+
const { checkDeveloperCapability, checkProjectLimit, checkCollectionLimit, checkWebhookGate } = require('../middlewares/planEnforcement');
2526

2627
const makeReq = (overrides = {}) => ({
2728
user: { _id: 'dev_1', email: 'dev@example.com', isVerified: false },
@@ -131,4 +132,39 @@ describe('planEnforcement capability checks', () => {
131132

132133
expect(next).toHaveBeenCalledWith();
133134
});
135+
136+
test('passes the finite webhook quota to the create controller without pre-counting', async () => {
137+
Project.findById.mockReturnValue({
138+
select: jest.fn().mockReturnThis(),
139+
lean: jest.fn().mockResolvedValue({ customLimits: null }),
140+
});
141+
const req = makeReq({
142+
method: 'POST',
143+
params: { projectId: 'project_1' },
144+
user: { _id: 'dev_1', email: 'dev@example.com', isVerified: true },
145+
developer: { _id: 'dev_1', isVerified: true },
146+
});
147+
const next = jest.fn();
148+
149+
await checkWebhookGate(req, {}, next);
150+
151+
expect(req.webhookQuotaLimit).toBe(3);
152+
expect(next).toHaveBeenCalledWith();
153+
});
154+
155+
test('preserves the admin webhook quota bypass', async () => {
156+
const req = makeReq({
157+
method: 'POST',
158+
params: { projectId: 'project_1' },
159+
user: { _id: 'admin_1', email: 'admin@example.com', isAdmin: true, isVerified: true },
160+
developer: { _id: 'admin_1', isVerified: true },
161+
});
162+
const next = jest.fn();
163+
164+
await checkWebhookGate(req, {}, next);
165+
166+
expect(req.webhookQuotaLimit).toBeUndefined();
167+
expect(Project.findById).not.toHaveBeenCalled();
168+
expect(next).toHaveBeenCalledWith();
169+
});
134170
});

‎apps/dashboard-api/src/__tests__/webhook.controller.test.js‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jest.mock('@urbackend/common', () => {
1515
return {
1616
Webhook: {
1717
create: jest.fn(),
18+
countDocuments: jest.fn(),
1819
find: jest.fn(),
1920
findOne: jest.fn(),
2021
findOneAndUpdate: jest.fn(),
@@ -26,6 +27,7 @@ jest.mock('@urbackend/common', () => {
2627
},
2728
Project: {
2829
findOne: jest.fn(),
30+
updateOne: jest.fn(),
2931
},
3032
AppError,
3133
encrypt: jest.fn((val) => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })),
@@ -95,6 +97,10 @@ describe('webhook.controller', () => {
9597
next = jest.fn();
9698
});
9799

100+
afterEach(() => {
101+
jest.restoreAllMocks();
102+
});
103+
98104
describe('createWebhook', () => {
99105
test('creates webhook with valid input', async () => {
100106
Project.findOne.mockResolvedValue({ _id: validProjectId });
@@ -145,6 +151,90 @@ describe('webhook.controller', () => {
145151
);
146152
});
147153

154+
test('checks finite quota and creates the webhook in one transaction', async () => {
155+
const session = {
156+
withTransaction: jest.fn(async (operation) => operation()),
157+
endSession: jest.fn(),
158+
};
159+
jest.spyOn(mongoose, 'startSession').mockResolvedValue(session);
160+
Project.findOne.mockResolvedValue({ _id: validProjectId });
161+
Project.updateOne.mockResolvedValue({ matchedCount: 1 });
162+
Webhook.countDocuments.mockResolvedValue(2);
163+
createWebhookSchema.safeParse.mockReturnValue({
164+
success: true,
165+
data: {
166+
name: 'Test Webhook',
167+
url: 'https://example.com/hook',
168+
secret: 'whsec_testsecret12345678',
169+
events: {},
170+
},
171+
});
172+
Webhook.create.mockResolvedValue([{
173+
_id: validWebhookId,
174+
projectId: validProjectId,
175+
name: 'Test Webhook',
176+
url: 'https://example.com/hook',
177+
events: new Map(),
178+
enabled: true,
179+
}]);
180+
req.body = {
181+
name: 'Test Webhook',
182+
url: 'https://example.com/hook',
183+
secret: 'whsec_testsecret12345678',
184+
};
185+
req.webhookQuotaLimit = 3;
186+
187+
await createWebhook(req, res, next);
188+
189+
expect(Project.updateOne).toHaveBeenCalledWith(
190+
{ _id: validProjectId, owner: 'user123' },
191+
{ $inc: { webhookQuotaVersion: 1 } },
192+
{ session }
193+
);
194+
expect(Webhook.countDocuments).toHaveBeenCalledWith(
195+
{ projectId: validProjectId },
196+
{ session }
197+
);
198+
expect(Webhook.create).toHaveBeenCalledWith(
199+
[expect.objectContaining({ projectId: validProjectId, name: 'Test Webhook' })],
200+
{ session }
201+
);
202+
expect(session.endSession).toHaveBeenCalled();
203+
expect(res.status).toHaveBeenCalledWith(201);
204+
});
205+
206+
test('does not create a webhook when the transactional quota reservation fails', async () => {
207+
const session = {
208+
withTransaction: jest.fn(async (operation) => operation()),
209+
endSession: jest.fn(),
210+
};
211+
jest.spyOn(mongoose, 'startSession').mockResolvedValue(session);
212+
Project.findOne.mockResolvedValue({ _id: validProjectId });
213+
Project.updateOne.mockResolvedValue({ matchedCount: 1 });
214+
Webhook.countDocuments.mockResolvedValue(3);
215+
createWebhookSchema.safeParse.mockReturnValue({
216+
success: true,
217+
data: {
218+
name: 'Test Webhook',
219+
url: 'https://example.com/hook',
220+
secret: 'whsec_testsecret12345678',
221+
},
222+
});
223+
req.body = {
224+
name: 'Test Webhook',
225+
url: 'https://example.com/hook',
226+
secret: 'whsec_testsecret12345678',
227+
};
228+
req.webhookQuotaLimit = 3;
229+
230+
await createWebhook(req, res, next);
231+
232+
expect(Webhook.create).not.toHaveBeenCalled();
233+
expect(session.endSession).toHaveBeenCalled();
234+
expect(next).toHaveBeenCalledWith(expect.any(AppError));
235+
expect(next.mock.calls[0][0].statusCode).toBe(403);
236+
});
237+
148238
test('returns 404 if project not found', async () => {
149239
Project.findOne.mockResolvedValue(null);
150240

‎apps/dashboard-api/src/controllers/webhook.controller.js‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,48 @@ module.exports.createWebhook = async (req, res, next) => {
4747

4848
// Encrypt the secret
4949
const encryptedSecret = encrypt(secret);
50-
51-
const webhook = await Webhook.create({
50+
const webhookData = {
5251
projectId,
5352
name,
5453
url,
5554
secret: encryptedSecret,
5655
events: events || {},
5756
enabled: enabled !== false,
58-
});
57+
};
58+
59+
let webhook;
60+
const quotaLimit = req.webhookQuotaLimit;
61+
if (Number.isInteger(quotaLimit) && quotaLimit >= 0) {
62+
const session = await mongoose.startSession();
63+
try {
64+
await session.withTransaction(async () => {
65+
// Updating one shared project document serializes concurrent quota
66+
// checks for this project. The update rolls back with the insert.
67+
const reservation = await Project.updateOne(
68+
{ _id: projectId, ...getProjectAccessQuery(req.user._id) },
69+
{ $inc: { webhookQuotaVersion: 1 } },
70+
{ session }
71+
);
72+
if (reservation.matchedCount !== 1) {
73+
throw new AppError(404, "Project not found");
74+
}
75+
76+
const currentCount = await Webhook.countDocuments(
77+
{ projectId },
78+
{ session }
79+
);
80+
if (currentCount >= quotaLimit) {
81+
throw new AppError(403, `Webhook limit reached (${quotaLimit}). Please upgrade your plan for unlimited webhooks.`);
82+
}
83+
84+
[webhook] = await Webhook.create([webhookData], { session });
85+
});
86+
} finally {
87+
await session.endSession();
88+
}
89+
} else {
90+
webhook = await Webhook.create(webhookData);
91+
}
5992

6093
// Return without secret
6194
return new ApiResponse({

‎apps/dashboard-api/src/middlewares/planEnforcement.js‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ exports.checkByokGate = async function(req, res, next) {
177177
}
178178

179179
exports.checkWebhookGate = async function(req, res, next) {
180-
const { Project, Webhook, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId } = require('@urbackend/common');
180+
const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId } = require('@urbackend/common');
181181
try {
182182
if (isAdminRequest(req)) return next();
183183

@@ -205,12 +205,9 @@ exports.checkWebhookGate = async function(req, res, next) {
205205
const effectivePlan = resolveEffectivePlan(req.developer);
206206
const limits = getPlanLimits({ plan: effectivePlan, customLimits });
207207

208-
if (limits.webhooksLimit !== -1) {
209-
const currentCount = await Webhook.countDocuments({ projectId: cleanProjectId });
210-
if (currentCount >= limits.webhooksLimit) {
211-
return next(new AppError(403, `Webhook limit reached (${limits.webhooksLimit}). Please upgrade your plan for unlimited webhooks.`));
212-
}
213-
}
208+
// The controller performs the authoritative quota check and insert in
209+
// one transaction. Passing the limit avoids a count-then-create race.
210+
req.webhookQuotaLimit = limits.webhooksLimit;
214211

215212
next();
216213
} catch (err) {

‎apps/landing/src/pages/pricing.astro‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const FREE_FEATURES = [
1515
'Bring Your Own Storage (S3 / R2)',
1616
'Bring Your Own Keys (Resend, OAuth, AI)',
1717
'Team Collaboration (up to 2 members)',
18+
'Analytics Pro & Deep Insights',
1819
'Community support',
1920
];
2021
@@ -25,7 +26,6 @@ const PRO_FEATURES = [
2526
'Unlimited Webhooks + Priority Delivery',
2627
'1,000 Emails / month',
2728
'Up to 6 Team Members',
28-
'Analytics Pro & Deep Insights',
2929
'Priority support',
3030
];
3131

‎packages/common/src/models/Project.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ const projectSchema = new mongoose.Schema(
140140
databaseUsed: { type: Number, default: 0 },
141141
databaseLimit: { type: Number, default: 20 * 1024 * 1024 },
142142

143+
// Internal write-lock version for atomic webhook quota reservations.
144+
webhookQuotaVersion: { type: Number, default: 0, select: false },
145+
143146
// Granular Resources Structure
144147
resources: {
145148
db: {

0 commit comments

Comments
 (0)