fix(auth): unverified members cannot exercise roles; single-membership join (D2)

require_approval was silently void: redeeming an admin-role invite into
a require_approval space yielded role=admin,status=unverified and
requireMemberWithRole never checked status -> instant admin pre-approval.
- PRIVILEGE_STATUSES/isPrivilegeEligible (current|late only; excludes
  unverified/inactive). requireMemberWithRole now rejects non-privilege-
  eligible status with 'pending approval' before the role check; Member
  carries status (getAuthMember selects it). requireMember stays
  permissive so unverified members still see their own /me.
- joinSpace rejects a second active membership (single-space model;
  prevents the .single() self-DoS).
Deferred (HANDOFF): SECURITY DEFINER helpers user_has_role_in_space/
user_has_permission still ignore status (RLS defense-in-depth, own
careful pass). 524 tests; build clean.
This commit is contained in:
Moheeb Zara
2026-05-18 18:30:20 -07:00
parent a513ad0fab
commit 2239e7a92d
4 changed files with 56 additions and 3 deletions
+14
View File
@@ -4,6 +4,7 @@ import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { redirect } from 'next/navigation'
import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security'
import { ACTIVE_STATUSES } from '@/lib/permissions'
function generateInviteCode() {
// Cryptographically random, and wider (8 chars) so the code space is not
@@ -157,6 +158,19 @@ export async function joinSpace(formData: {
? invite.role
: 'member'
// Single active membership per user (the product's locked single-space
// model; getAuthMember's .single() fails closed on 2+). Reject a second
// join rather than letting it self-DoS the user out of every action.
const { data: existingMembership } = await admin
.from('space_members')
.select('id')
.eq('user_id', user.id)
.in('status', ACTIVE_STATUSES as unknown as string[])
.maybeSingle()
if (existingMembership) {
return { error: 'You are already a member of a space.' }
}
const { data: newMember, error: memberErr } = await admin
.from('space_members')
.insert({