Files
hackerspace.sh/lib/permissions.ts
T
Moheeb Zara 2239e7a92d 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.
2026-05-18 18:30:20 -07:00

49 lines
2.0 KiB
TypeScript

import type { Enums } from '@/types/database'
/**
* Single source of truth for role names and role-based permission sets.
* Every server action that gates writes should reference one of the named
* tuples below instead of writing role strings inline.
*/
export type Role = Enums<'member_role'>
export type MemberStatus = Enums<'member_status'>
/** Every defined role, in privilege order. */
export const ROLES = ['admin', 'board', 'treasurer', 'member', 'associate'] as const satisfies readonly Role[]
/** Roles considered "privileged" for write/admin operations. */
export const ADMIN_ROLES = ['admin', 'board'] as const satisfies readonly Role[]
/** Roles that can sign off on financial operations. */
export const TREASURER_ROLES = ['admin', 'board', 'treasurer'] as const satisfies readonly Role[]
/** Roles that can read but not necessarily write. */
export const ALL_ROLES = ROLES
/** Statuses a member may have and still be allowed to act in the app. */
export const ACTIVE_STATUSES = ['current', 'unverified', 'late'] as const satisfies readonly MemberStatus[]
/**
* Statuses that may exercise a ROLE or PERMISSION (privileged actions).
* Deliberately excludes 'unverified': a member of a `require_approval` space
* is 'unverified' until an admin approves them, and must hold NO privileged
* capability in that window even if they redeemed a role-bearing invite
* (otherwise `require_approval` is silently void). 'late' keeps its role
* (dues lapse is grace -> late, never an authz downgrade).
*/
export const PRIVILEGE_STATUSES = ['current', 'late'] as const satisfies readonly MemberStatus[]
export function isPrivilegeEligible(status: MemberStatus | null | undefined): boolean {
return status === 'current' || status === 'late'
}
/**
* Returns true if `role` is one of the `allowed` roles.
* Use this everywhere instead of `role === 'admin' || role === 'board'` chains.
*/
export function hasRole(role: Role | null | undefined, allowed: readonly Role[]): boolean {
if (!role) return false
return allowed.includes(role)
}