From caaee8229d5d5d5fd7097be546b21232040cb427 Mon Sep 17 00:00:00 2001 From: aiirvizionz Date: Wed, 29 Jul 2026 00:11:11 -0600 Subject: [PATCH] fix(auth): fail closed on invite quota count errors --- src/app/api/auth/invite-anon/route.js | 18 ++++++++++---- src/app/api/auth/invite-anon/route.test.js | 28 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/app/api/auth/invite-anon/route.js b/src/app/api/auth/invite-anon/route.js index 7fb399c..2546aa5 100644 --- a/src/app/api/auth/invite-anon/route.js +++ b/src/app/api/auth/invite-anon/route.js @@ -47,7 +47,11 @@ export async function GET(request) { if (auth.error) return auth.error; const { serviceSupabase, authUser } = auth; - const { quota, used } = await getQuota(serviceSupabase, authUser.id); + const { quota, used, quotaError } = await getQuota(serviceSupabase, authUser.id); + if (quotaError) { + console.error('invite-anon: failed to verify invite quota', quotaError); + return NextResponse.json({ error: 'Failed to verify invite quota' }, { status: 500 }); + } return NextResponse.json({ remaining: Math.max(0, quota - used), quota, used }); } @@ -85,7 +89,11 @@ export async function POST(request) { } // --- Enforce the per-account quota --- - const { quota, used, issuerDisabled } = await getQuota(serviceSupabase, authUser.id); + const { quota, used, issuerDisabled, quotaError } = await getQuota(serviceSupabase, authUser.id); + if (quotaError) { + console.error('invite-anon: failed to verify invite quota', quotaError); + return NextResponse.json({ error: 'Failed to verify invite quota' }, { status: 500 }); + } if (issuerDisabled) { return NextResponse.json({ error: 'Anonymous invites are disabled' }, { status: 503 }); } @@ -163,7 +171,7 @@ async function authenticate(request) { * Resolve the caller's quota and current usage. * @param {any} serviceSupabase * @param {string} authUserId - * @returns {Promise<{ quota: number, used: number, issuerDisabled: boolean }>} + * @returns {Promise<{ quota: number, used: number, issuerDisabled: boolean, quotaError: any }>} */ async function getQuota(serviceSupabase, authUserId) { const { data: issuer } = await serviceSupabase @@ -174,10 +182,10 @@ async function getQuota(serviceSupabase, authUserId) { const quota = issuer?.default_quota ?? DEFAULT_QUOTA; - const { count } = await serviceSupabase + const { count, error: countError } = await serviceSupabase .from('issued_invites') .select('jti', { count: 'exact', head: true }) .eq('issued_by', authUserId); - return { quota, used: count ?? 0, issuerDisabled: !!issuer?.disabled }; + return { quota, used: count ?? 0, issuerDisabled: !!issuer?.disabled, quotaError: countError }; } diff --git a/src/app/api/auth/invite-anon/route.test.js b/src/app/api/auth/invite-anon/route.test.js index 2034115..051942a 100644 --- a/src/app/api/auth/invite-anon/route.test.js +++ b/src/app/api/auth/invite-anon/route.test.js @@ -108,4 +108,32 @@ describe('GET /api/auth/invite-anon authentication', () => { expect(mocks.authGetUser).not.toHaveBeenCalled(); expect(mocks.createClient).not.toHaveBeenCalled(); }); + + it('fails closed when issued invite usage cannot be counted', async () => { + mocks.from.mockImplementation((table) => { + if (table === 'invite_issuers') { + return createSingleQuery({ + data: { default_quota: 5, disabled: false }, + error: null + }); + } + + if (table === 'issued_invites') { + return createCountQuery({ + count: null, + error: { message: 'count failed' } + }); + } + + throw new Error(`Unexpected table: ${table}`); + }); + + const { GET } = await import('./route.js'); + + const response = await GET(inviteRequest('Bearer access-token-123')); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ error: 'Failed to verify invite quota' }); + }); });