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:
@@ -0,0 +1,117 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Tables } from '@/types/database'
|
||||
import type { ZodSchema } from 'zod'
|
||||
import { ACTIVE_STATUSES, hasRole, type Role } from '@/lib/permissions'
|
||||
|
||||
/**
|
||||
* Loose Supabase client type for shared helpers.
|
||||
*
|
||||
* The fully-typed client (`Awaited<ReturnType<typeof createClient>>` from
|
||||
* `lib/supabase/server.ts`) has a 3-generic shape that the supabase-js
|
||||
* version drift makes painful to assign across module boundaries. We use
|
||||
* a wide type at the helper boundary so callers with the precise client
|
||||
* can pass it through; inside the helper, schema-level type checking on
|
||||
* `.from(table).insert(row)` is relaxed. Helpers stay tiny and unit-tested,
|
||||
* so the precision loss is acceptable. Action bodies keep full typing
|
||||
* because they use the client returned by `createClient()` directly.
|
||||
*/
|
||||
export type ServerSupabase = SupabaseClient<any, any, any>
|
||||
|
||||
/** Projection of `space_members` that server actions consume. */
|
||||
export type Member = Pick<
|
||||
Tables<'space_members'>,
|
||||
'id' | 'space_id' | 'user_id' | 'role' | 'display_name' | 'handle'
|
||||
>
|
||||
|
||||
/**
|
||||
* Result types: a `ok: boolean` discriminator narrows reliably under
|
||||
* strict TypeScript. Always `if (!result.ok) return { error: result.error }`.
|
||||
*/
|
||||
export type Result<T> = { ok: true; data: T } | { ok: false; error: string }
|
||||
export type MemberResult = { ok: true; member: Member } | { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Returns the active member row for the current authenticated user, or
|
||||
* `null` if the user is not authenticated or has no active membership.
|
||||
*
|
||||
* "Active" means one of ACTIVE_STATUSES (current, unverified, late).
|
||||
* Inactive members are blocked from all server actions.
|
||||
*/
|
||||
export async function getAuthMember(supabase: ServerSupabase): Promise<Member | null> {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) return null
|
||||
|
||||
const { data } = await supabase
|
||||
.from('space_members')
|
||||
.select('id, space_id, user_id, role, display_name, handle')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ACTIVE_STATUSES as unknown as string[])
|
||||
.single()
|
||||
|
||||
return (data as Member | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Like getAuthMember but in MemberResult shape so callers can early-return
|
||||
* on `!result.ok`.
|
||||
*/
|
||||
export async function requireMember(supabase: ServerSupabase): Promise<MemberResult> {
|
||||
const member = await getAuthMember(supabase)
|
||||
if (!member) return { ok: false, error: 'Not authenticated or no active membership' }
|
||||
return { ok: true, member }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the member iff they hold one of the allowed roles.
|
||||
* Otherwise an error result.
|
||||
*/
|
||||
export async function requireMemberWithRole(
|
||||
supabase: ServerSupabase,
|
||||
allowed: readonly Role[],
|
||||
errorLabel = 'Insufficient permissions',
|
||||
): Promise<MemberResult> {
|
||||
const r = await requireMember(supabase)
|
||||
if (!r.ok) return r
|
||||
if (!hasRole(r.member.role, allowed)) {
|
||||
return { ok: false, error: errorLabel }
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a Zod schema against an input. Returns a discriminated Result
|
||||
* so callers can `if (!v.ok) return { error: v.error }` and have `v.data`
|
||||
* narrowed to T.
|
||||
*/
|
||||
export function parseInput<T>(schema: ZodSchema<T>, input: unknown): Result<T> {
|
||||
const parsed = schema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.errors[0]?.message ?? 'Invalid input' }
|
||||
}
|
||||
return { ok: true, data: parsed.data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row into activity_log. Errors are swallowed: the log is
|
||||
* advisory and should never block the primary action.
|
||||
*/
|
||||
export async function logActivity(
|
||||
supabase: ServerSupabase,
|
||||
member: Pick<Member, 'space_id' | 'user_id' | 'display_name'>,
|
||||
action: string,
|
||||
entityType: string,
|
||||
entityId: string | null,
|
||||
details?: string | null,
|
||||
): Promise<void> {
|
||||
await supabase.from('activity_log').insert({
|
||||
space_id: member.space_id,
|
||||
user_id: member.user_id,
|
||||
display_name: member.display_name,
|
||||
action,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
details: details ?? null,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user