fix(invites): close single-use invite max_uses TOCTOU (H1)

Redeeming an invite read uses_count, checked the cap in JS, then did a
non-atomic uses_count = uses_count + 1 write AFTER creating the membership.
Two concurrent redemptions of a max_uses=1 invite both passed the check and
both created a member, and the counter under-counted.

Fix: claim a use ATOMICALLY before creating the membership via a
compare-and-swap on uses_count (lib/invite-claim.ts) — the UPDATE only writes
when uses_count still equals the value the cap decision was based on, so of two
racing claims exactly one wins. The loser re-reads and retries while capacity
remains (multi-use invites unaffected). On a failed membership insert the claim
is rolled back so a slot is not burned.

Extracted to lib/invite-claim.ts (out of the 'use server' surface, unit-tested).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Moheeb Zara
2026-08-01 21:16:12 -07:00
co-authored by Claude Opus 4.8
parent 624a708d8c
commit 2ee929658e
4 changed files with 162 additions and 10 deletions
+22 -9
View File
@@ -5,6 +5,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
import { redirect } from 'next/navigation'
import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security'
import { ACTIVE_STATUSES } from '@/lib/permissions'
import { claimInviteUse } from '@/lib/invite-claim'
function generateInviteCode() {
// Cryptographically random, and wider (8 chars) so the code space is not
@@ -176,6 +177,15 @@ export async function joinSpace(formData: {
return { error: 'You are already a member of a space.' }
}
// Claim an invite use ATOMICALLY before creating the membership, so a
// single-use invite can never be redeemed twice under concurrent joins.
// (The pre-check above is a fast UX reject; this is the authoritative guard.)
let claimedCount: number | null = null
if (invite) {
claimedCount = await claimInviteUse(admin, invite.id, invite.uses_count, invite.max_uses)
if (claimedCount === null) return { error: 'This invite has reached its use cap.' }
}
const { data: newMember, error: memberErr } = await admin
.from('space_members')
.insert({
@@ -191,7 +201,18 @@ export async function joinSpace(formData: {
.select('id')
.single()
if (memberErr) return { error: memberErr.message }
if (memberErr) {
// Roll back the claimed use (best-effort, CAS-guarded) so a failed join
// does not burn an invite slot.
if (invite && claimedCount !== null) {
await admin
.from('space_invites')
.update({ uses_count: claimedCount - 1 })
.eq('id', invite.id)
.eq('uses_count', claimedCount)
}
return { error: memberErr.message }
}
// Retro-link prior anonymous form/waiver submissions in this space to the
// new member, but only for a verified email (locked decision). Best-effort:
@@ -205,14 +226,6 @@ export async function joinSpace(formData: {
.eq('submitter_email', user.email.toLowerCase())
}
// Increment invite usage counter for the multi-code path.
if (invite) {
await admin
.from('space_invites')
.update({ uses_count: invite.uses_count + 1 })
.eq('id', invite.id)
}
return { success: true }
}