feat: governance kernel, deployment hardening, local-dev stack

Adds proposals + proposal_votes + policies + incidents + incident_updates
tables with full RLS, async voting flow, dismissal-appeal pathway,
versioned policies with provenance, and an anonymous-incident token mode.
Adds per-space configurable areas, financial visibility settings, member
self-profile + COI disclosure, and a mission statement that renders in
the app footer.

Reorganises lib/actions.ts into per-domain files (tasks, projects,
members, payments, contacts, knowledge-base, secrets, area-leads,
settings, imports, proposals, incidents, policies, areas) plus a barrel.
Adds lib/permissions.ts and lib/auth-helpers.ts so action bodies stop
duplicating the user-to-member-to-role check. Wires every action that has
a Zod schema; introduces a flexibleDateTime preprocessor so HTML date
pickers no longer hit "Invalid datetime".

Hardens RLS: drops NOT NULL on space_members.user_id for offline members,
adds a privilege-escalation trigger that blocks self-edits to role / tier
/ status / approved / has_card_access / space_id, closes four
cross-tenant holes in the governance kernel, and adds a partial unique
index so two admins cannot concurrently activate two drafts of the same
policy slug.

Ships every deployment artifact: Dockerfile, docker-compose,
.do/app.yaml, .env.example, /api/health, plus a managed-Supabase guide
(docs/DEPLOYMENT.md), an end-to-end self-hosted Droplet guide
(docs/DEPLOY_DO_SELFHOSTED.md), and a Supabase-CLI local-dev quickstart
(docs/LOCAL_DEV.md). Replaces middleware.ts with proxy.ts for Next.js 16.
Adds scripts/schema.sql as the canonical idempotent full deploy plus
fourteen numbered incremental migrations.

Adds scripts/seed.sql so a fresh local DB renders something instead of
empty pages: a demo space, five members across all roles, a v1 code of
conduct, three proposals in different states with seven votes already
cast, an open incident, a project, three tasks, a KB entry, and matching
activity-log rows.

Replaces v0-generated test scaffolding with 266 real tests across
__tests__/{actions,auth-helpers,components,governance,permissions,
security,utils,validations}.test.ts. Adds react-markdown + remark-gfm
for proposal / incident / policy body rendering. Deletes four dead v0
fix-* scripts, the unused components/app-shell.tsx, and the duplicate
app/auth/login/page.tsx.

Routes (31 total): /api/health, /api/paypal/sync, /auth/callback, /,
/login, /signup, /signup/confirm, /dashboard, /tasks, /projects,
/members, /payments, /financials, /contacts, /ops, /ops/[id], /ops/new,
/proposals, /proposals/[id], /proposals/new, /incidents, /incidents/[id],
/incidents/new, /policies, /policies/[slug], /policies/new, /profile,
/recruitment, /settings, /comms, /import.

Full pass-by-pass history is in docs/HANDOFF.md; the roadmap is in
docs/GOVERNANCE_FEATURES.md.
This commit is contained in:
Moheeb Zara
2026-05-14 04:52:11 -07:00
parent 8b9128704b
commit ea8d7a5f91
99 changed files with 13823 additions and 1940 deletions
+64
View File
@@ -0,0 +1,64 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { discloseAffiliations } from '@/lib/actions'
import { ChipInput } from './chip-input'
export function AffiliationsForm({ initial }: { initial: { affiliations: string[] } }) {
const router = useRouter()
const [affiliations, setAffiliations] = useState<string[]>(initial.affiliations)
const [error, setError] = useState('')
const [status, setStatus] = useState<'idle' | 'saved'>('idle')
const [isPending, startTransition] = useTransition()
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setStatus('idle')
startTransition(async () => {
const r = await discloseAffiliations({ affiliations })
if ('error' in r && r.error) setError(r.error)
else {
setStatus('saved')
router.refresh()
}
})
}
return (
<form onSubmit={handleSubmit} className="bg-card rounded border border-border p-5 space-y-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-2">
Outside affiliations
</label>
<ChipInput
values={affiliations}
onChange={setAffiliations}
placeholder='add an affiliation and press enter, e.g. "ACME Robotics, contractor"'
maxLength={200}
maxItems={50}
/>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
Organizations, businesses, or roles outside this space that could intersect with space
decisions. Visible to all members. Submitting stamps your disclosure date.
</p>
</div>
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
{status === 'saved' && !error && (
<p className="font-mono text-xs text-primary">Disclosed. Your timestamp is updated.</p>
)}
<div className="flex items-center justify-end">
<button
type="submit"
disabled={isPending}
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{isPending ? 'Disclosing...' : 'Disclose now'}
</button>
</div>
</form>
)
}
+100
View File
@@ -0,0 +1,100 @@
'use client'
import { useState, type KeyboardEvent } from 'react'
type Props = {
values: string[]
onChange: (next: string[]) => void
placeholder?: string
maxLength?: number
maxItems?: number
suggestions?: string[]
}
/**
* Minimal chip / token input. Press Enter or comma to commit the current
* value. Backspace on an empty input removes the last chip. Optional
* suggestion list shows un-used suggestions as click-to-add chips below.
*/
export function ChipInput({
values,
onChange,
placeholder,
maxLength = 80,
maxItems = 50,
suggestions,
}: Props) {
const [draft, setDraft] = useState('')
function commit(raw: string) {
const v = raw.trim()
if (!v) return
if (values.includes(v)) return
if (values.length >= maxItems) return
onChange([...values, v.slice(0, maxLength)])
setDraft('')
}
function remove(idx: number) {
onChange(values.filter((_, i) => i !== idx))
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
commit(draft)
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
e.preventDefault()
onChange(values.slice(0, -1))
}
}
const remainingSuggestions = (suggestions ?? []).filter(s => !values.includes(s))
return (
<div>
<div className="flex flex-wrap items-center gap-1.5 bg-background border border-border rounded px-2 py-1.5 focus-within:border-primary">
{values.map((v, i) => (
<span
key={`${v}-${i}`}
className="inline-flex items-center gap-1 bg-muted rounded px-2 py-0.5 font-mono text-xs"
>
{v}
<button
type="button"
onClick={() => remove(i)}
className="text-muted-foreground hover:text-foreground"
aria-label={`Remove ${v}`}
>
×
</button>
</span>
))}
<input
type="text"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => commit(draft)}
placeholder={values.length === 0 ? placeholder : ''}
maxLength={maxLength}
className="flex-1 min-w-[120px] bg-transparent text-foreground font-sans text-sm py-1 px-1 focus:outline-none"
/>
</div>
{remainingSuggestions.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{remainingSuggestions.map(s => (
<button
key={s}
type="button"
onClick={() => commit(s)}
className="font-mono text-[10px] tracking-widest border border-border text-muted-foreground hover:text-foreground hover:border-primary rounded px-2 py-0.5"
>
+ {s}
</button>
))}
</div>
)}
</div>
)
}
+100
View File
@@ -0,0 +1,100 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { ProfileForm } from './profile-form'
import { AffiliationsForm } from './affiliations-form'
export const dynamic = 'force-dynamic'
export default async function ProfilePage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: memberRaw } = await supabase
.from('space_members')
.select('*')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.maybeSingle()
if (!memberRaw) redirect('/signup')
const member = memberRaw as unknown as {
id: string
display_name: string | null
handle: string | null
phone: string | null
role: string
tier: string
skills: string[]
interests: string[]
willing_to: string[]
affiliations: string[]
coi_last_disclosed_at: string | null
email: string | null
}
const privilegedRoles = ['admin', 'board', 'treasurer']
const isPrivileged = privilegedRoles.includes(member.role)
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-4 md:px-6 py-3">
<h1 className="text-white font-sans text-lg font-semibold">My profile</h1>
</div>
<div className="p-4 md:p-6 space-y-6 max-w-3xl">
<header className="bg-card rounded border border-border p-5">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-1">
Account
</p>
<p className="font-sans text-sm text-foreground">{member.email}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
role: {member.role} · tier: {member.tier}
</p>
<p className="font-mono text-[10px] text-muted-foreground mt-2">
Role and tier can only be changed by an admin or board member. The privilege-escalation
trigger blocks self-edits to those fields at the database level.
</p>
</header>
<section>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
Profile
</p>
<ProfileForm
initial={{
display_name: member.display_name ?? '',
handle: member.handle ?? '',
phone: member.phone ?? '',
skills: member.skills ?? [],
interests: member.interests ?? [],
willing_to: member.willing_to ?? [],
}}
/>
</section>
<section>
<div className="flex items-baseline justify-between mb-3">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Conflict-of-interest disclosure
</p>
{member.coi_last_disclosed_at && (
<p className="font-mono text-[10px] text-muted-foreground">
last disclosed {new Date(member.coi_last_disclosed_at).toLocaleDateString()}
</p>
)}
</div>
{isPrivileged && !member.coi_last_disclosed_at && (
<div className="bg-orange-50 border border-orange-200 rounded p-3 mb-3">
<p className="font-sans text-sm text-orange-900">
You hold a privileged role ({member.role}). Please disclose any outside affiliations
that could conflict with space governance. Disclosure is visible to other members.
</p>
</div>
)}
<AffiliationsForm initial={{ affiliations: member.affiliations ?? [] }} />
</section>
</div>
</div>
)
}
+155
View File
@@ -0,0 +1,155 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { updateMyProfile } from '@/lib/actions'
import { ChipInput } from './chip-input'
type Props = {
initial: {
display_name: string
handle: string
phone: string
skills: string[]
interests: string[]
willing_to: string[]
}
}
const WILLING_TO_SUGGESTIONS = [
'board_candidate',
'treasurer_candidate',
'host_volunteer',
'area_lead_candidate',
'event_organizer',
'safety_committee',
'docs_steward',
]
export function ProfileForm({ initial }: Props) {
const router = useRouter()
const [displayName, setDisplayName] = useState(initial.display_name)
const [handle, setHandle] = useState(initial.handle)
const [phone, setPhone] = useState(initial.phone)
const [skills, setSkills] = useState<string[]>(initial.skills)
const [interests, setInterests] = useState<string[]>(initial.interests)
const [willingTo, setWillingTo] = useState<string[]>(initial.willing_to)
const [error, setError] = useState('')
const [status, setStatus] = useState<'idle' | 'saved'>('idle')
const [isPending, startTransition] = useTransition()
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setStatus('idle')
startTransition(async () => {
const r = await updateMyProfile({
display_name: displayName,
handle: handle || null,
phone: phone || null,
skills,
interests,
willing_to: willingTo,
})
if ('error' in r && r.error) setError(r.error)
else {
setStatus('saved')
router.refresh()
}
})
}
return (
<form onSubmit={handleSubmit} className="bg-card rounded border border-border p-5 space-y-5">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Display name
</label>
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
maxLength={100}
required
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Handle
</label>
<input
type="text"
value={handle}
onChange={e => setHandle(e.target.value)}
maxLength={50}
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Phone
</label>
<input
type="tel"
value={phone}
onChange={e => setPhone(e.target.value)}
maxLength={20}
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Skills
</label>
<ChipInput values={skills} onChange={setSkills} placeholder="add a skill and press enter" maxLength={60} maxItems={40} />
<p className="font-mono text-[10px] text-muted-foreground mt-1">
Examples: woodworking, laser, electronics, accounting, grant writing.
</p>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Interests
</label>
<ChipInput values={interests} onChange={setInterests} placeholder="add an interest and press enter" maxLength={60} maxItems={40} />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Willing to do
</label>
<ChipInput
values={willingTo}
onChange={setWillingTo}
placeholder="add a role and press enter"
maxLength={60}
maxItems={20}
suggestions={WILLING_TO_SUGGESTIONS}
/>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
Used by the recruitment view (board only) when looking for members willing to take on a role.
</p>
</div>
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
{status === 'saved' && !error && (
<p className="font-mono text-xs text-primary">Saved.</p>
)}
<div className="flex items-center justify-end">
<button
type="submit"
disabled={isPending}
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{isPending ? 'Saving...' : 'Save profile'}
</button>
</div>
</form>
)
}