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
18 changes: 13 additions & 5 deletions src/app/api/auth/invite-anon/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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
Expand All @@ -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 };
}
28 changes: 28 additions & 0 deletions src/app/api/auth/invite-anon/route.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
Loading