Files
hackerspace.sh/lib/actions/proposals.ts
T
Moheeb ZaraandClaude Opus 4.8 c7a6cd3853 fix(proposals): block unverified members from governance writes (L2)
createProposal/openProposal/withdrawProposal/castVote gated only with
requireMember, which admits status='unverified' — so a member pending approval
in a require_approval space could propose and cast counted votes before vetting.
Add the isPrivilegeEligible gate (same as requireMemberWithRole) to all four.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-01 21:31:51 -07:00

265 lines
8.6 KiB
TypeScript

'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import {
requireMember,
requireMemberWithRole,
logActivity,
parseInput,
} from '@/lib/auth-helpers'
import { ADMIN_ROLES, isPrivilegeEligible } from '@/lib/permissions'
import {
createProposalSchema,
openProposalSchema,
castVoteSchema,
decideProposalSchema,
withdrawProposalSchema,
uuidSchema,
} from '@/lib/validations'
/**
* Create a proposal. Defaults to draft status. Pass `open_immediately: true`
* to skip the draft step and go straight to voting (trigger computes quorum
* and voting window from space defaults).
*/
export async function createProposal(formData: {
title: string
body?: string
proposal_type?: string
threshold?: string
policy_ref_id?: string | null
parent_incident_id?: string | null
voting_opens_at?: string | null
voting_closes_at?: string | null
open_immediately?: boolean
}) {
const v = parseInput(createProposalSchema, formData)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
// require_approval gate: an 'unverified' member (pending admin approval)
// must not create or participate in governance. Mirrors requireMemberWithRole.
if (!isPrivilegeEligible(member.status)) {
return { error: 'Your membership is pending approval.' }
}
const status = v.data.open_immediately ? 'open' : 'draft'
const { data, error } = await supabase
.from('proposals')
.insert({
space_id: member.space_id,
proposer_id: member.id,
proposer_name: member.display_name,
title: v.data.title,
body: v.data.body,
proposal_type: v.data.proposal_type,
threshold: v.data.threshold ?? 'simple_majority',
policy_ref_id: v.data.policy_ref_id ?? null,
parent_incident_id: v.data.parent_incident_id ?? null,
voting_opens_at: v.data.voting_opens_at ?? null,
voting_closes_at: v.data.voting_closes_at ?? null,
status,
})
.select()
.single()
if (error) return { error: error.message }
await logActivity(supabase, member, 'created', 'proposal', data.id, v.data.title)
revalidatePath('/proposals')
return { data }
}
/**
* Move a draft proposal to open. Trigger computes quorum_required and the
* voting window from the space's defaults if voting_closes_at is null.
*/
export async function openProposal(proposalId: string, voting_closes_at?: string | null) {
const v = parseInput(openProposalSchema, { proposalId, voting_closes_at })
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
// require_approval gate: an 'unverified' member (pending admin approval)
// must not create or participate in governance. Mirrors requireMemberWithRole.
if (!isPrivilegeEligible(member.status)) {
return { error: 'Your membership is pending approval.' }
}
const updates: Record<string, unknown> = { status: 'open' }
if (v.data.voting_closes_at) updates.voting_closes_at = v.data.voting_closes_at
const { error } = await supabase
.from('proposals')
.update(updates)
.eq('id', v.data.proposalId)
.eq('space_id', member.space_id)
.eq('status', 'draft')
if (error) return { error: error.message }
await logActivity(supabase, member, 'opened', 'proposal', v.data.proposalId)
revalidatePath('/proposals')
revalidatePath(`/proposals/${v.data.proposalId}`)
return { success: true as const }
}
/**
* Withdraw a proposal. Proposer can withdraw their own (any status pre-decided);
* admin/board can withdraw any. Status becomes 'withdrawn'.
*/
export async function withdrawProposal(proposalId: string) {
const v = parseInput(withdrawProposalSchema, { proposalId })
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
// require_approval gate: an 'unverified' member (pending admin approval)
// must not create or participate in governance. Mirrors requireMemberWithRole.
if (!isPrivilegeEligible(member.status)) {
return { error: 'Your membership is pending approval.' }
}
const { error } = await supabase
.from('proposals')
.update({ status: 'withdrawn' })
.eq('id', v.data.proposalId)
.eq('space_id', member.space_id)
.in('status', ['draft', 'open'])
if (error) return { error: error.message }
await logActivity(supabase, member, 'withdrew', 'proposal', v.data.proposalId)
revalidatePath('/proposals')
revalidatePath(`/proposals/${v.data.proposalId}`)
return { success: true as const }
}
/**
* Cast or update a vote. Upserts on (proposal_id, member_id). The DB-level
* trigger refreshes the proposal's tally on every change. Voting window is
* enforced by RLS, not here.
*/
export async function castVote(formData: {
proposalId: string
position: string
recusal_reason?: string | null
comment?: string | null
}) {
const v = parseInput(castVoteSchema, formData)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
// require_approval gate: an 'unverified' member (pending admin approval)
// must not create or participate in governance. Mirrors requireMemberWithRole.
if (!isPrivilegeEligible(member.status)) {
return { error: 'Your membership is pending approval.' }
}
// Defense in depth: the votes RLS policies already enforce same-space,
// open status and the voting window, but this is a sensitive mutation, so
// validate at the action boundary too (a future RLS regression must not
// silently allow cross-space vote injection).
const { data: proposal } = await supabase
.from('proposals')
.select('id, status')
.eq('id', v.data.proposalId)
.eq('space_id', member.space_id)
.maybeSingle()
if (!proposal) return { error: 'Proposal not found' }
if (proposal.status !== 'open') return { error: 'Voting is closed for this proposal.' }
const { error } = await supabase
.from('proposal_votes')
.upsert(
{
proposal_id: v.data.proposalId,
member_id: member.id,
position: v.data.position,
recusal_reason: v.data.recusal_reason ?? null,
comment: v.data.comment ?? null,
voted_at: new Date().toISOString(),
},
{ onConflict: 'proposal_id,member_id' },
)
if (error) return { error: error.message }
await logActivity(supabase, member, 'voted', 'proposal', v.data.proposalId, v.data.position)
revalidatePath('/proposals')
revalidatePath(`/proposals/${v.data.proposalId}`)
return { success: true as const }
}
/**
* Decide a proposal: flip status to 'decided' and freeze the outcome. The
* tally is already maintained live by the refresh_proposal_tally trigger;
* this just commits the decision and stamps decided_at.
*/
export async function decideProposal(proposalId: string) {
const v = parseInput(decideProposalSchema, { proposalId })
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('proposals')
.update({ status: 'decided', decided_at: new Date().toISOString() })
.eq('id', v.data.proposalId)
.eq('space_id', member.space_id)
.eq('status', 'open')
if (error) return { error: error.message }
await logActivity(supabase, member, 'decided', 'proposal', v.data.proposalId)
revalidatePath('/proposals')
revalidatePath(`/proposals/${v.data.proposalId}`)
return { success: true as const }
}
/**
* Delete a proposal. Admin/board only. Use sparingly; prefer withdraw.
*/
export async function deleteProposal(proposalId: string) {
const v = parseInput(uuidSchema, proposalId)
if (!v.ok) return { error: 'Invalid proposal ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('proposals')
.delete()
.eq('id', v.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
await logActivity(supabase, member, 'deleted', 'proposal', v.data)
revalidatePath('/proposals')
return { success: true as const }
}