CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix(billing): site admins are never metered by their own platform #5477

Merged⚡ AI-generatedXSccantynz wants to mergefix/admin-quota-exemptionmainopened 22d ago
2 changed files+51−0
Modifiedsrc/__tests__/billing.test.ts+24−0View fileUnifiedSplit
138138 expect(mod.DEFAULT_PLAN_SLUG).toBe("free");
139139 });
140140});
141
142describe("site admins are never metered by their own billing", () => {
143 // 2026-08-10: the owner hit "Your Free is limited to 10 repos. Upgrade
144 // for more." creating a repo on the platform they operate. The plan
145 // system had no concept of "this account IS the platform". The exemption
146 // lives at the billing chokepoints so every gate inherits it — pin that
147 // BOTH quota gates consult it before consulting any plan.
148 it("wouldExceedRepoLimit and checkQuota both consult the admin exemption first", async () => {
149 const src = await Bun.file("src/lib/billing.ts").text();
150 for (const fn of ["wouldExceedRepoLimit", "checkQuota"]) {
151 const body = src.slice(
152 src.indexOf(`export async function ${fn}`),
153 src.indexOf("export async function", src.indexOf(`export async function ${fn}`) + 1)
154 );
155 const exempt = body.indexOf("isExemptSiteAdmin");
156 const plan = body.indexOf("getUserQuota");
157 expect(exempt).toBeGreaterThan(-1);
158 expect(plan).toBeGreaterThan(exempt);
159 }
160 // Fail-closed the right way round: a DB error meters, never unmeters.
161 const helper = src.slice(src.indexOf("async function isExemptSiteAdmin"));
162 expect(helper.slice(0, helper.indexOf("}\n}"))).toContain("return false;");
163 });
164});
Modifiedsrc/lib/billing.ts+27−0View fileUnifiedSplit
2424 billingPlans,
2525 userQuotas,
2626 repositories,
27 users,
2728 type BillingPlan,
2829 type UserQuota,
2930} from "../db/schema";
3031
32/**
33 * Site admins are never metered by their own platform's billing.
34 *
35 * Found the hard way 2026-08-10: the owner hit "Your Free is limited to
36 * 10 repos. Upgrade for more." while creating a repo on the platform they
37 * operate. The quota gates only ever consulted the plan row, and the plan
38 * system has no concept of "this account IS the platform" — checked here,
39 * at the chokepoint, so every quota gate below inherits it. Fail-closed
40 * to "not admin" so a DB hiccup meters rather than unmeters.
41 */
42async function isExemptSiteAdmin(userId: string): Promise<boolean> {
43 if (!userId) return false;
44 try {
45 const [u] = await db
46 .select({ isAdmin: users.isAdmin })
47 .from(users)
48 .where(eq(users.id, userId))
49 .limit(1);
50 return u?.isAdmin === true;
51 } catch {
52 return false;
53 }
54}
55
3156export const DEFAULT_PLAN_SLUG = "free";
3257
3358/** Mirrors the seed rows in migration 0020 so billing works even pre-migration. */
248273 amount: number = 1
249274): Promise<boolean> {
250275 try {
276 if (await isExemptSiteAdmin(userId)) return true;
251277 const { plan, usage } = await getUserQuota(userId);
252278 if (field === "storageMbUsed")
253279 return usage.storageMbUsed + amount <= plan.storageMbLimit;
276302/** True if creating another repo would exceed the plan's repoLimit. */
277303export async function wouldExceedRepoLimit(userId: string): Promise<boolean> {
278304 try {
305 if (await isExemptSiteAdmin(userId)) return false;
279306 const [quota, count] = await Promise.all([
280307 getUserQuota(userId),
281308 repoCountForUser(userId),
282309
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts