diff --git a/CHANGELOG.md b/CHANGELOG.md
index 230eea9..1640a65 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows [Keep a
## [Unreleased]
+### Added
+- **Customizable permissions, per-item Ops ACLs, area-lead roles** (migration 023, additive and backward compatible):
+ - Customize -> Permissions: a role x permission grid. Admin holds everything implicitly and cannot be locked out. Grants per built-in/custom role.
+ - Per-item access control on Ops secrets (and KB/processes via the same infra): pick which roles, multiple, can access an item. Empty = existing default visibility. Secrets/KB SELECT policies were rewritten as `existing-rule OR acl-match`, so nothing changes until an admin sets an ACL.
+ - Customize -> Area leads: create area-lead roles, see Vacant until assigned, assign/unassign a member from a picker. The assigned member effectively holds that role, so any Ops item whose access list includes it opens to them.
+ - SECURITY DEFINER helpers `user_effective_roles` and `user_has_permission`.
+
+### Fixed
+- `revealSecret` no longer silently returns the stale plaintext column when a row is marked encrypted but has no ciphertext; it surfaces the corruption instead.
+- `upsertAreaLead` rejects a null/empty area code (Postgres treats NULLs as distinct in unique constraints, which had allowed duplicate area-lead rows).
+
### Changed
- Landing page rebuilt in the `/resources` editorial aesthetic: scoped dark theme (IBM Plex Mono + Libre Baskerville, lime accent, faint grid), a server component (~150 lines, down from a 539-line client monolith), and split into `components/landing/*`. Added a single-row resource showcase of six square tiles with the project SVG logos.
diff --git a/__tests__/permissions-catalog.test.ts b/__tests__/permissions-catalog.test.ts
new file mode 100644
index 0000000..6efea4e
--- /dev/null
+++ b/__tests__/permissions-catalog.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest'
+import {
+ PERMISSIONS,
+ PERMISSION_CODES,
+ PERMISSION_GROUPS,
+ DEFAULT_ROLE_PERMISSIONS,
+ isValidPermission,
+} from '@/lib/permissions-catalog'
+
+describe('permissions catalog', () => {
+ it('has unique permission codes', () => {
+ expect(new Set(PERMISSION_CODES).size).toBe(PERMISSION_CODES.length)
+ })
+
+ it('every permission has a group present in PERMISSION_GROUPS', () => {
+ for (const p of PERMISSIONS) {
+ expect(PERMISSION_GROUPS).toContain(p.group)
+ }
+ })
+
+ it('isValidPermission accepts catalog codes and rejects others', () => {
+ expect(isValidPermission('ops.secrets.read')).toBe(true)
+ expect(isValidPermission('ops.kb.write')).toBe(true)
+ expect(isValidPermission('not.a.permission')).toBe(false)
+ expect(isValidPermission('')).toBe(false)
+ })
+
+ it('default role grants only reference real permissions', () => {
+ for (const [role, perms] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
+ expect(role).not.toBe('admin') // admin is implicit-all, never seeded
+ for (const perm of perms) {
+ expect(isValidPermission(perm)).toBe(true)
+ }
+ }
+ })
+
+ it('does not grant admin defaults (admin is implicit and cannot be locked out)', () => {
+ expect(DEFAULT_ROLE_PERMISSIONS.admin).toBeUndefined()
+ })
+})
diff --git a/app/(app)/customize/customize-client.tsx b/app/(app)/customize/customize-client.tsx
index 3dfb696..cbdcb5f 100644
--- a/app/(app)/customize/customize-client.tsx
+++ b/app/(app)/customize/customize-client.tsx
@@ -2,22 +2,35 @@
import { useState } from 'react'
import { RolesPanel } from './panels/roles-panel'
+import { PermissionsPanel } from './panels/permissions-panel'
import { TiersPanel } from './panels/tiers-panel'
import { AreasPanel } from './panels/areas-panel'
+import { AreaLeadsPanel } from './panels/area-leads-panel'
import { InvitesPanel } from './panels/invites-panel'
import { OnboardingPanel } from './panels/onboarding-panel'
import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area } from './panels/types'
-type Section = 'roles' | 'tiers' | 'areas' | 'invites' | 'onboarding'
+type Section = 'roles' | 'permissions' | 'tiers' | 'areas' | 'area-leads' | 'invites' | 'onboarding'
const SECTIONS: { key: Section; label: string }[] = [
{ key: 'roles', label: 'Roles' },
+ { key: 'permissions', label: 'Permissions' },
{ key: 'tiers', label: 'Membership tiers' },
{ key: 'areas', label: 'Areas' },
+ { key: 'area-leads', label: 'Area leads' },
{ key: 'invites', label: 'Invite codes' },
{ key: 'onboarding', label: 'Onboarding' },
]
+interface AreaLead {
+ id: string
+ area_code: string | null
+ area_name: string
+ lead_id: string | null
+ lead_handle: string | null
+ status: string
+}
+
interface Props {
isAdmin: boolean
roleLabels: RoleLabelRow[]
@@ -26,6 +39,9 @@ interface Props {
invites: Invite[]
onboardingSteps: Step[]
areas: Area[]
+ rolePerms: Array<{ subject: string; permission: string }>
+ areaLeads: AreaLead[]
+ members: Array<{ id: string; display_name: string | null; handle: string | null }>
}
export function CustomizeClient(props: Props) {
@@ -57,8 +73,10 @@ export function CustomizeClient(props: Props) {
{section === 'roles' &&
}
+ {section === 'permissions' &&
}
{section === 'tiers' &&
}
{section === 'areas' &&
}
+ {section === 'area-leads' &&
}
{section === 'invites' &&
}
{section === 'onboarding' &&
}
diff --git a/app/(app)/customize/page.tsx b/app/(app)/customize/page.tsx
index 4ee7e5a..6286611 100644
--- a/app/(app)/customize/page.tsx
+++ b/app/(app)/customize/page.tsx
@@ -28,6 +28,9 @@ export default async function CustomizePage() {
{ data: invites },
{ data: onboardingSteps },
{ data: areas },
+ { data: rolePerms },
+ { data: areaLeads },
+ { data: members },
] = await Promise.all([
supabase.from('space_role_labels').select('role, display_name, description, color').eq('space_id', member.space_id),
supabase.from('space_custom_roles').select('id, slug, name, description, color, sort_order').eq('space_id', member.space_id).order('sort_order'),
@@ -35,6 +38,9 @@ export default async function CustomizePage() {
supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }),
supabase.from('space_onboarding_steps').select('id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order').eq('space_id', member.space_id).order('sort_order'),
supabase.from('space_areas').select('id, code, name, icon, sort_order, is_archived').eq('space_id', member.space_id).order('sort_order').order('name'),
+ supabase.from('space_role_permissions').select('subject, permission').eq('space_id', member.space_id),
+ supabase.from('area_leads').select('id, area_code, area_name, lead_id, lead_handle, status').eq('space_id', member.space_id).order('area_name'),
+ supabase.from('space_members').select('id, display_name, handle').eq('space_id', member.space_id).in('status', ['current', 'unverified', 'late']).order('display_name'),
])
return (
@@ -46,6 +52,9 @@ export default async function CustomizePage() {
invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_at: string }>}
onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'; title: string; body: string | null; config: Record; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>}
areas={(areas ?? []) as Array<{ id: string; code: string; name: string; icon: string | null; sort_order: number; is_archived: boolean }>}
+ rolePerms={(rolePerms ?? []) as Array<{ subject: string; permission: string }>}
+ areaLeads={(areaLeads ?? []) as Array<{ id: string; area_code: string | null; area_name: string; lead_id: string | null; lead_handle: string | null; status: string }>}
+ members={(members ?? []) as Array<{ id: string; display_name: string | null; handle: string | null }>}
/>
)
}
diff --git a/app/(app)/customize/panels/area-leads-panel.tsx b/app/(app)/customize/panels/area-leads-panel.tsx
new file mode 100644
index 0000000..36df6b8
--- /dev/null
+++ b/app/(app)/customize/panels/area-leads-panel.tsx
@@ -0,0 +1,129 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { toast } from 'sonner'
+import { createAreaLeadRole, assignAreaLead, unassignAreaLead, deleteAreaLeadRole } from '@/lib/actions'
+import { Card } from './card'
+
+interface AreaLead {
+ id: string
+ area_code: string | null
+ area_name: string
+ lead_id: string | null
+ lead_handle: string | null
+ status: string
+}
+interface Member { id: string; display_name: string | null; handle: string | null }
+
+interface Props {
+ isAdmin: boolean
+ areaLeads: AreaLead[]
+ members: Member[]
+}
+
+export function AreaLeadsPanel({ isAdmin, areaLeads: initial, members }: Props) {
+ const router = useRouter()
+ const [rows, setRows] = useState(initial)
+ const [showNew, setShowNew] = useState(false)
+ const [d, setD] = useState({ area_code: '', name: '' })
+
+ function memberLabel(id: string | null) {
+ if (!id) return null
+ const m = members.find(x => x.id === id)
+ return m ? (m.display_name || m.handle || 'Member') : 'Member'
+ }
+
+ return (
+ setShowNew(v => !v)} className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap">
+ {showNew ? 'Cancel' : '+ Area-lead role'}
+
+ ) : undefined}
+ >
+ {showNew && (
+
+ )}
+
+ {rows.length === 0 ? (
+ No area-lead roles yet.
+ ) : (
+
+ {rows.map(r => (
+ -
+
+
+ {r.area_name}
+ {r.area_code && {r.area_code}}
+ {r.lead_id ? (
+ {memberLabel(r.lead_id)}
+ ) : (
+ Vacant
+ )}
+
+
+ {isAdmin && (
+
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/app/(app)/customize/panels/permissions-panel.tsx b/app/(app)/customize/panels/permissions-panel.tsx
new file mode 100644
index 0000000..362431b
--- /dev/null
+++ b/app/(app)/customize/panels/permissions-panel.tsx
@@ -0,0 +1,130 @@
+'use client'
+
+import { useState } from 'react'
+import { toast } from 'sonner'
+import { setRolePermissions } from '@/lib/actions'
+import { PERMISSIONS, PERMISSION_GROUPS } from '@/lib/permissions-catalog'
+import { BUILTIN_ROLES } from '@/lib/role-labels'
+import { Card } from './card'
+import type { CustomRole } from './types'
+
+interface Props {
+ isAdmin: boolean
+ customRoles: CustomRole[]
+ rolePerms: Array<{ subject: string; permission: string }>
+}
+
+export function PermissionsPanel({ isAdmin, customRoles, rolePerms }: Props) {
+ // subjects: built-in roles except admin (implicit-all), plus custom roles.
+ const subjects = [
+ ...BUILTIN_ROLES.filter(r => r !== 'admin').map(r => ({ key: r, label: r })),
+ ...customRoles.map(c => ({ key: c.slug, label: c.name })),
+ ]
+
+ const initial: Record> = {}
+ for (const s of subjects) initial[s.key] = new Set()
+ for (const rp of rolePerms) {
+ if (!initial[rp.subject]) initial[rp.subject] = new Set()
+ initial[rp.subject].add(rp.permission)
+ }
+ const [grants, setGrants] = useState(initial)
+ const [savingSubject, setSavingSubject] = useState(null)
+
+ async function toggle(subject: string, permission: string, on: boolean) {
+ const next = new Set(grants[subject] ?? [])
+ if (on) next.add(permission)
+ else next.delete(permission)
+ setGrants(g => ({ ...g, [subject]: next }))
+ setSavingSubject(subject)
+ const result = await setRolePermissions({ subject, permissions: Array.from(next) })
+ setSavingSubject(null)
+ if (result.error) {
+ toast.error(result.error)
+ // revert
+ setGrants(g => {
+ const back = new Set(g[subject] ?? [])
+ if (on) back.delete(permission)
+ else back.add(permission)
+ return { ...g, [subject]: back }
+ })
+ }
+ }
+
+ return (
+
+
+ admin
+ holds all permissions (implicit, locked)
+
+
+
+
+
+
+ | Permission |
+ {subjects.map(s => (
+
+ {s.label}{savingSubject === s.key && …}
+ |
+ ))}
+
+
+
+ {PERMISSION_GROUPS.map(group => (
+
+ ))}
+
+
+
+
+ )
+}
+
+function PermGroup({
+ group, subjects, grants, isAdmin, onToggle,
+}: {
+ group: string
+ subjects: { key: string; label: string }[]
+ grants: Record>
+ isAdmin: boolean
+ onToggle: (subject: string, permission: string, on: boolean) => void
+}) {
+ const perms = PERMISSIONS.filter(p => p.group === group)
+ return (
+ <>
+
+ |
+ {group}
+ |
+
+ {perms.map(p => (
+
+ |
+ {p.label}
+ {p.code}
+ |
+ {subjects.map(s => (
+
+ onToggle(s.key, p.code, e.target.checked)}
+ />
+ |
+ ))}
+
+ ))}
+ >
+ )
+}
diff --git a/app/(app)/ops/ops-client.tsx b/app/(app)/ops/ops-client.tsx
index ca8ddd5..5767c9b 100644
--- a/app/(app)/ops/ops-client.tsx
+++ b/app/(app)/ops/ops-client.tsx
@@ -7,6 +7,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
import { revealSecret, deleteSecret } from '@/lib/actions/secrets'
+import { OpsAclEditor } from '@/components/ops/ops-acl-editor'
import { createClient } from '@/lib/supabase/client'
import { toast } from 'sonner'
import type { Tables, TablesInsert } from '@/types/database'
@@ -22,6 +23,9 @@ interface Props {
areaLeads: AreaLead[]
secrets: Secret[]
canSeeSecrets: boolean
+ canManageAcl?: boolean
+ aclRoleOptions?: { value: string; label: string }[]
+ aclByEntity?: Record
}
type Tab = 'kb' | 'processes' | 'secrets' | 'area-leads'
@@ -342,10 +346,17 @@ function AreaLeadModal({
}
// ─── Secret Row (reveal on click) ─────────────────────────────────────────────
-function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string) => void }) {
+function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial }: {
+ secret: Secret
+ onDelete: (id: string) => void
+ canManageAcl?: boolean
+ aclRoleOptions?: { value: string; label: string }[]
+ aclInitial?: string[]
+}) {
const [revealed, setRevealed] = useState(false)
const [value, setValue] = useState(null)
const [loading, setLoading] = useState(false)
+ const [showAcl, setShowAcl] = useState(false)
async function reveal() {
if (revealed) { setRevealed(false); setValue(null); return }
@@ -366,31 +377,50 @@ function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string
}
return (
-
-
-
-
-
-
{secret.title}
- {secret.area &&
{secret.area}
}
- {revealed && value && (
-
{value}
- )}
-
-
-
-
+
+
+
+
+
+
+
{secret.title}
+ {secret.area &&
{secret.area}
}
+ {revealed && value && (
+
{value}
+ )}
+
+
+ {canManageAcl && (
+
+ )}
+
+
+
+ {showAcl && canManageAcl && (
+
+ )}
)
}
@@ -504,7 +534,7 @@ function KbEntryRow({
}
// ─── Main Component ────────────────────────────────────────────────────────────
-export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: initialLeads, secrets: initialSecrets, canSeeSecrets }: Props) {
+export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: initialLeads, secrets: initialSecrets, canSeeSecrets, canManageAcl = false, aclRoleOptions = [], aclByEntity = {} }: Props) {
const router = useRouter()
const [activeTab, setActiveTab] = useState
('kb')
const [search, setSearch] = useState('')
@@ -722,7 +752,14 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
) : filteredSecrets.length > 0 ? (
{filteredSecrets.map(s => (
- setSecrets(prev => prev.filter(x => x.id !== id))} />
+ setSecrets(prev => prev.filter(x => x.id !== id))}
+ canManageAcl={canManageAcl}
+ aclRoleOptions={aclRoleOptions}
+ aclInitial={aclByEntity[`secret:${s.id}`] ?? []}
+ />
))}
) : (
diff --git a/app/(app)/ops/page.tsx b/app/(app)/ops/page.tsx
index b566660..8ed391c 100644
--- a/app/(app)/ops/page.tsx
+++ b/app/(app)/ops/page.tsx
@@ -12,8 +12,9 @@ export default async function OpsPage() {
if (!member) return null
const canSeeSecrets = member.role === 'admin' || member.role === 'board'
+ const canManageAcl = member.role === 'admin' || member.role === 'board'
- const [{ data: kbEntries }, { data: areaLeads }, secretsResult] = await Promise.all([
+ const [{ data: kbEntries }, { data: areaLeads }, secretsResult, { data: customRoles }, { data: acl }] = await Promise.all([
supabase.from('knowledge_base').select('*').eq('space_id', member.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }),
supabase.from('area_leads').select('*').eq('space_id', member.space_id).order('area_name'),
canSeeSecrets
@@ -22,8 +23,26 @@ export default async function OpsPage() {
// through the revealSecret() server action.
? supabase.from('secrets').select('id, title, area, created_at, label, description, icon, space_id, created_by, updated_at, category, notes, encryption_version').eq('space_id', member.space_id)
: Promise.resolve({ data: [] as Tables<'secrets'>[] }),
+ supabase.from('space_custom_roles').select('slug, name').eq('space_id', member.space_id),
+ supabase.from('ops_acl').select('entity_type, entity_id, role').eq('space_id', member.space_id),
])
+ // Selectable ACL roles: built-in roles, custom-role slugs, and area-lead
+ // sentinels labelled by area name.
+ const aclRoleOptions = [
+ { value: 'treasurer', label: 'treasurer' },
+ { value: 'member', label: 'member' },
+ { value: 'associate', label: 'associate' },
+ ...((customRoles ?? []).map(c => ({ value: c.slug, label: c.name }))),
+ ...((areaLeads ?? []).map(al => ({ value: `area_lead:${al.id}`, label: `${al.area_name} lead` }))),
+ ]
+
+ const aclByEntity: Record = {}
+ for (const row of acl ?? []) {
+ const k = `${row.entity_type}:${row.entity_id}`
+ ;(aclByEntity[k] ??= []).push(row.role)
+ }
+
return (
}
@@ -32,6 +51,9 @@ export default async function OpsPage() {
areaLeads={areaLeads ?? []}
secrets={secretsResult.data ?? []}
canSeeSecrets={canSeeSecrets}
+ canManageAcl={canManageAcl}
+ aclRoleOptions={aclRoleOptions}
+ aclByEntity={aclByEntity}
/>
)
}
diff --git a/components/ops/ops-acl-editor.tsx b/components/ops/ops-acl-editor.tsx
new file mode 100644
index 0000000..3473ac0
--- /dev/null
+++ b/components/ops/ops-acl-editor.tsx
@@ -0,0 +1,74 @@
+'use client'
+
+import { useState } from 'react'
+import { toast } from 'sonner'
+import { setOpsAcl } from '@/lib/actions'
+
+export interface AclRoleOption { value: string; label: string }
+
+interface Props {
+ entityType: 'secret' | 'kb' | 'process'
+ entityId: string
+ options: AclRoleOption[]
+ initial: string[]
+}
+
+// Toggling roles here writes ops_acl. An empty selection means the item
+// falls back to its default visibility (admin/board for secrets; the
+// visibility column for KB). Selecting roles WIDENS read access to those
+// roles in addition to the default.
+export function OpsAclEditor({ entityType, entityId, options, initial }: Props) {
+ const [selected, setSelected] = useState>(new Set(initial))
+ const [saving, setSaving] = useState(false)
+
+ async function toggle(value: string, on: boolean) {
+ const next = new Set(selected)
+ if (on) next.add(value)
+ else next.delete(value)
+ setSelected(next)
+ setSaving(true)
+ const result = await setOpsAcl({ entity_type: entityType, entity_id: entityId, roles: Array.from(next) })
+ setSaving(false)
+ if (result.error) {
+ toast.error(result.error)
+ setSelected(prev => {
+ const back = new Set(prev)
+ if (on) back.delete(value)
+ else back.add(value)
+ return back
+ })
+ }
+ }
+
+ return (
+
+
+ Who can access {saving && …}
+
+ {options.length === 0 ? (
+
No roles available.
+ ) : (
+
+ {options.map(o => {
+ const on = selected.has(o.value)
+ return (
+
+ )
+ })}
+
+ )}
+
+ Admins and board always have access. Empty selection keeps the default visibility.
+
+
+ )
+}
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index d91fdcc..9ce78e2 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -4,6 +4,41 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
+## 2026-05-15 (pass 15) — Permissions, Ops ACLs, area-lead roles
+
+Branch: `main`. Migration: 023 (applies on next deploy).
+
+### Pre-work audit (background agent)
+
+No regressions from recent passes; routing now clean (the duplicate `app/page.tsx` was the only structural anti-pattern and it is gone). Verbatim current RLS captured. `types/database.ts` is stale for 8 prior-pass tables but masked by `ignoreBuildErrors` (type debt, not a runtime bug). Two latent Ops bugs found and fixed this pass.
+
+### What shipped
+
+Security-sensitive subsystem, built ADDITIVE so a space with no permission/ACL rows behaves exactly as before.
+
+- **Migration `scripts/023_permissions.sql`** (folded into `schema.sql` section 15):
+ - `space_role_permissions` (space_id, subject, permission) and `ops_acl` (space_id, entity_type in secret|kb|process|area_lead, entity_id, role). Full RLS; writes admin/board.
+ - `user_effective_roles(uid,sid)` SECURITY DEFINER: built-in role + custom-role slugs + `area_lead:` for areas the member leads.
+ - `user_has_permission(uid,sid,perm)` SECURITY DEFINER: admin implicit-all, else a grant via an effective role.
+ - `secrets`/`knowledge_base` SELECT rewritten as `EXACT existing rule OR ops_acl match`. Zero behavior change with no ACL rows.
+ - Seed trigger + backfill of sensible default `space_role_permissions` (board/treasurer/member/associate; admin never stored).
+- **Area-lead roles reuse the existing `area_leads` table** (decision: do not duplicate the concept). A row is a role; `lead_id IS NULL` => Vacant; assigning sets `lead_id`; the member then holds `area_lead:` via `user_effective_roles`.
+- `lib/permissions-catalog.ts` (fixed code list + per-role defaults + guardrails), Zod schemas, `lib/actions/permissions.ts` (setRolePermissions, setOpsAcl, createAreaLeadRole, assign/unassign/deleteAreaLeadRole).
+- UI: Customize gains **Permissions** (role x permission matrix; admin shown as implicit-all/locked) and **Area leads** (create role, Vacant state, member-picker assign/unassign) panels — new modular files under `customize/panels/`. Reusable `components/ops/ops-acl-editor.tsx` wired into the Secrets list ("Access" toggle, multi-role) via `ops/page.tsx` plumbing (custom roles + area-lead sentinels as options).
+- Latent bug fixes: `revealSecret` now errors on a corrupt encrypted-but-no-ciphertext row instead of silently returning the stale plaintext column; `upsertAreaLead` rejects null/empty `area_code` (Postgres treats NULLs as distinct, which allowed duplicate rows).
+- `types/database.ts`: added `space_role_permissions` and `ops_acl`. Catalog unit tests (5) added; 271 tests pass.
+
+### Deferred (tracked, honest)
+
+- KB/process per-item ACL: infra (RLS, setOpsAcl with kb/process, OpsAclEditor) is complete; only the ops-client KB-modal wiring is not done yet (the Secrets path, the explicit ask, is wired). Mechanical follow-up.
+- "Make area lead" directly from the members directory row: assignment is fully available in Customize -> Area leads; the members-list shortcut is deferred.
+- Broad permission-based gating of every server action / RLS rewrite app-wide is intentionally NOT done; this pass keeps existing role checks and only OR-widens via ACLs. `user_has_permission` exists for incremental adoption.
+- `types/database.ts` still stale for the other 8 prior tables (loose casts; build green).
+
+### Verification
+
+- `pnpm build` clean; `pnpm test` 271/271. Migration 023 is idempotent and applies via deploy.sh's tracked runner.
+
## 2026-05-15 (pass 14) — Landing redesign + KB search fix
Branch: `main`.
diff --git a/lib/actions/area-leads.ts b/lib/actions/area-leads.ts
index e7b2659..e0f03f6 100644
--- a/lib/actions/area-leads.ts
+++ b/lib/actions/area-leads.ts
@@ -16,6 +16,13 @@ export async function upsertAreaLead(formData: {
const v = parseInput(upsertAreaLeadSchema, formData)
if (!v.ok) return { error: v.error }
+ // area_code is the (space_id, area_code) upsert conflict target. Postgres
+ // treats NULLs as distinct in unique constraints, so a null/empty code
+ // would let duplicate area-lead rows accumulate. Require it.
+ if (!v.data.area_code || !v.data.area_code.trim()) {
+ return { error: 'An area code is required.' }
+ }
+
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
if (!auth.ok) return { error: auth.error }
diff --git a/lib/actions/index.ts b/lib/actions/index.ts
index c9a2e97..6c066a0 100644
--- a/lib/actions/index.ts
+++ b/lib/actions/index.ts
@@ -41,3 +41,6 @@ export * from './invites'
// Configurable member onboarding
export * from './onboarding'
+
+// Customizable permissions, per-item Ops ACLs, area-lead roles
+export * from './permissions'
diff --git a/lib/actions/permissions.ts b/lib/actions/permissions.ts
new file mode 100644
index 0000000..00c771e
--- /dev/null
+++ b/lib/actions/permissions.ts
@@ -0,0 +1,197 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { createClient } from '@/lib/supabase/server'
+import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
+import {
+ setRolePermissionsSchema,
+ setOpsAclSchema,
+ createAreaLeadRoleSchema,
+ assignAreaLeadSchema,
+ uuidSchema,
+} from '@/lib/validations'
+import { isValidPermission } from '@/lib/permissions-catalog'
+
+const PERM_ADMIN_ROLES = ['admin', 'board'] as const
+
+// Replace the full permission set for one role/custom-role subject.
+export async function setRolePermissions(input: { subject: string; permissions: string[] }) {
+ const v = parseInput(setRolePermissionsSchema, input)
+ if (!v.ok) return { error: v.error }
+
+ const perms = v.data.permissions.filter(isValidPermission)
+
+ const supabase = await createClient()
+ const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
+ if (!auth.ok) return { error: auth.error }
+ const { member } = auth
+
+ const del = await supabase
+ .from('space_role_permissions')
+ .delete()
+ .eq('space_id', member.space_id)
+ .eq('subject', v.data.subject)
+ if (del.error) return { error: del.error.message }
+
+ if (perms.length > 0) {
+ const ins = await supabase
+ .from('space_role_permissions')
+ .insert(perms.map(permission => ({ space_id: member.space_id, subject: v.data.subject, permission })))
+ if (ins.error) return { error: ins.error.message }
+ }
+
+ revalidatePath('/customize')
+ return { success: true as const }
+}
+
+// Replace the full ACL role list for one Ops item. Empty list => the item
+// falls back to its existing visibility rule (no ACL rows).
+export async function setOpsAcl(input: {
+ entity_type: 'secret' | 'kb' | 'process' | 'area_lead'
+ entity_id: string
+ roles: string[]
+}) {
+ const v = parseInput(setOpsAclSchema, input)
+ if (!v.ok) return { error: v.error }
+
+ const supabase = await createClient()
+ const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
+ if (!auth.ok) return { error: auth.error }
+ const { member } = auth
+
+ const del = await supabase
+ .from('ops_acl')
+ .delete()
+ .eq('space_id', member.space_id)
+ .eq('entity_type', v.data.entity_type)
+ .eq('entity_id', v.data.entity_id)
+ if (del.error) return { error: del.error.message }
+
+ const roles = Array.from(new Set(v.data.roles.map(r => r.trim()).filter(Boolean)))
+ if (roles.length > 0) {
+ const ins = await supabase.from('ops_acl').insert(
+ roles.map(role => ({
+ space_id: member.space_id,
+ entity_type: v.data.entity_type,
+ entity_id: v.data.entity_id,
+ role,
+ })),
+ )
+ if (ins.error) return { error: ins.error.message }
+ }
+
+ revalidatePath('/ops')
+ return { success: true as const }
+}
+
+// An area-lead role is an area_leads row. lead_id NULL renders as "Vacant".
+export async function createAreaLeadRole(input: { area_code: string; name: string }) {
+ const v = parseInput(createAreaLeadRoleSchema, input)
+ if (!v.ok) return { error: v.error }
+
+ const supabase = await createClient()
+ const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
+ if (!auth.ok) return { error: auth.error }
+ const { member } = auth
+
+ const { data, error } = await supabase
+ .from('area_leads')
+ .insert({
+ space_id: member.space_id,
+ area_code: v.data.area_code,
+ area_name: v.data.name,
+ status: 'vacant',
+ })
+ .select('id, area_code, area_name, lead_id, lead_handle, status')
+ .single()
+
+ if (error) return { error: error.message }
+ revalidatePath('/customize')
+ revalidatePath('/ops')
+ return { data }
+}
+
+export async function assignAreaLead(input: { area_lead_role_id: string; member_id: string }) {
+ const v = parseInput(assignAreaLeadSchema, input)
+ if (!v.ok) return { error: v.error }
+
+ const supabase = await createClient()
+ const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
+ if (!auth.ok) return { error: auth.error }
+ const { member } = auth
+
+ const { data: target } = await supabase
+ .from('space_members')
+ .select('id, display_name, handle')
+ .eq('id', v.data.member_id)
+ .eq('space_id', member.space_id)
+ .single()
+ if (!target) return { error: 'Member not found in this space' }
+
+ const { error } = await supabase
+ .from('area_leads')
+ .update({
+ lead_id: target.id,
+ lead_handle: target.handle ?? target.display_name ?? null,
+ status: 'active',
+ updated_at: new Date().toISOString(),
+ })
+ .eq('id', v.data.area_lead_role_id)
+ .eq('space_id', member.space_id)
+
+ if (error) return { error: error.message }
+ revalidatePath('/customize')
+ revalidatePath('/members')
+ revalidatePath('/ops')
+ return { success: true as const }
+}
+
+export async function unassignAreaLead(areaLeadRoleId: string) {
+ const idCheck = parseInput(uuidSchema, areaLeadRoleId)
+ if (!idCheck.ok) return { error: 'Invalid role ID' }
+
+ const supabase = await createClient()
+ const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
+ if (!auth.ok) return { error: auth.error }
+ const { member } = auth
+
+ const { error } = await supabase
+ .from('area_leads')
+ .update({ lead_id: null, lead_handle: null, status: 'vacant', updated_at: new Date().toISOString() })
+ .eq('id', idCheck.data)
+ .eq('space_id', member.space_id)
+
+ if (error) return { error: error.message }
+ revalidatePath('/customize')
+ revalidatePath('/members')
+ revalidatePath('/ops')
+ return { success: true as const }
+}
+
+export async function deleteAreaLeadRole(areaLeadRoleId: string) {
+ const idCheck = parseInput(uuidSchema, areaLeadRoleId)
+ if (!idCheck.ok) return { error: 'Invalid role ID' }
+
+ const supabase = await createClient()
+ const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
+ if (!auth.ok) return { error: auth.error }
+ const { member } = auth
+
+ // Clear any ACL rows that referenced this area-lead sentinel first.
+ await supabase
+ .from('ops_acl')
+ .delete()
+ .eq('space_id', member.space_id)
+ .eq('role', `area_lead:${idCheck.data}`)
+
+ const { error } = await supabase
+ .from('area_leads')
+ .delete()
+ .eq('id', idCheck.data)
+ .eq('space_id', member.space_id)
+
+ if (error) return { error: error.message }
+ revalidatePath('/customize')
+ revalidatePath('/ops')
+ return { success: true as const }
+}
diff --git a/lib/actions/secrets.ts b/lib/actions/secrets.ts
index eb4eb79..f5f5090 100644
--- a/lib/actions/secrets.ts
+++ b/lib/actions/secrets.ts
@@ -124,6 +124,14 @@ export async function revealSecret(secretId: string) {
if (error || !data) return { error: error?.message ?? 'Not found' }
+ // A row marked encrypted (version > 0) MUST have ciphertext. If it does
+ // not, the row is corrupt: do not silently fall back to the (empty/stale)
+ // plaintext column, which would either leak nothing useful or mask data
+ // loss. Surface it instead.
+ if ((data.encryption_version ?? 0) > 0 && !data.encrypted_value) {
+ return { error: 'This secret is marked encrypted but has no ciphertext. It may be corrupted; re-enter it.' }
+ }
+
let plaintext: string
try {
if (data.encryption_version === 1 && data.encrypted_value) {
diff --git a/lib/permissions-catalog.ts b/lib/permissions-catalog.ts
new file mode 100644
index 0000000..15aff9c
--- /dev/null
+++ b/lib/permissions-catalog.ts
@@ -0,0 +1,51 @@
+// Fixed catalog of permission codes. This is app-owned data, not a table:
+// the set of capabilities is part of the product, while which roles hold
+// which permissions is per-space (space_role_permissions).
+//
+// Important: this layer is ADDITIVE. Existing role-based RLS and server-action
+// role checks stay in force. A permission grant can only widen what a role can
+// do through the surfaces that consult it (the Ops ACL and the Customize
+// permissions UI); it never overrides the database RLS that protects tenant
+// isolation. `admin` implicitly holds every permission and can never be
+// locked out.
+
+export const PERMISSIONS = [
+ { code: 'ops.kb.read', group: 'Ops', label: 'Read knowledge base' },
+ { code: 'ops.kb.write', group: 'Ops', label: 'Write knowledge base' },
+ { code: 'ops.process.read', group: 'Ops', label: 'Read processes' },
+ { code: 'ops.process.write', group: 'Ops', label: 'Write processes' },
+ { code: 'ops.secrets.read', group: 'Ops', label: 'Reveal secrets' },
+ { code: 'ops.secrets.write', group: 'Ops', label: 'Manage secrets' },
+ { code: 'ops.arealeads.manage', group: 'Ops', label: 'Manage area leads' },
+ { code: 'members.manage', group: 'People', label: 'Manage members' },
+ { code: 'payments.manage', group: 'Finance', label: 'Manage payments' },
+ { code: 'governance.manage', group: 'Governance', label: 'Manage proposals, incidents, policies' },
+ { code: 'forum.moderate', group: 'Community', label: 'Moderate forum and comments' },
+ { code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' },
+ { code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
+] as const
+
+export type PermissionCode = (typeof PERMISSIONS)[number]['code']
+
+export const PERMISSION_CODES: readonly string[] = PERMISSIONS.map(p => p.code)
+
+export const PERMISSION_GROUPS: string[] = Array.from(new Set(PERMISSIONS.map(p => p.group)))
+
+// Built-in role defaults seeded per space. `admin` is intentionally absent:
+// it holds everything implicitly and is never stored, so it cannot be
+// accidentally revoked.
+export const DEFAULT_ROLE_PERMISSIONS: Record = {
+ board: [
+ 'ops.kb.read', 'ops.kb.write', 'ops.process.read', 'ops.process.write',
+ 'ops.secrets.read', 'ops.secrets.write', 'ops.arealeads.manage',
+ 'members.manage', 'payments.manage', 'governance.manage',
+ 'forum.moderate', 'customize.manage', 'settings.manage',
+ ],
+ treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
+ member: ['ops.kb.read', 'ops.process.read'],
+ associate: ['ops.kb.read'],
+}
+
+export function isValidPermission(code: string): code is PermissionCode {
+ return PERMISSION_CODES.includes(code)
+}
diff --git a/lib/validations.ts b/lib/validations.ts
index 55797ad..c543d53 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -486,6 +486,42 @@ export const createChannelSchema = z.object({
channel_type: z.enum(['general', 'area', 'ops', 'project']).default('general'),
})
+// ─── Permissions, Ops ACLs, area-lead roles ──────────────────────────────────
+
+// A grant subject is a built-in role or a custom-role slug.
+const roleSubject = z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_:-]{0,49}$/, 'Invalid role')
+
+export const setRolePermissionsSchema = z.object({
+ subject: roleSubject,
+ permissions: z.array(z.string().min(1).max(60)).max(64),
+})
+
+export const opsEntityTypeSchema = z.enum(['secret', 'kb', 'process', 'area_lead'])
+
+// The UI sends the full desired role list for one item; the server replaces.
+// An empty list means "fall back to the item's existing visibility rule".
+export const setOpsAclSchema = z.object({
+ entity_type: opsEntityTypeSchema,
+ entity_id: z.string().uuid('Invalid entity ID'),
+ roles: z.array(z.string().min(1).max(64)).max(64),
+})
+
+export const createAreaLeadRoleSchema = z.object({
+ area_code: z.string().min(1).max(60).regex(/^[a-z0-9][a-z0-9_-]{0,59}$/, 'Invalid area code'),
+ name: z.string().min(1).max(100),
+ color: z.string().max(20).optional().nullable(),
+})
+
+export const updateAreaLeadRoleSchema = z.object({
+ name: z.string().min(1).max(100).optional(),
+ color: z.string().max(20).optional().nullable(),
+})
+
+export const assignAreaLeadSchema = z.object({
+ area_lead_role_id: z.string().uuid('Invalid role ID'),
+ member_id: z.string().uuid('Invalid member ID'),
+})
+
// ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format')
diff --git a/scripts/023_permissions.sql b/scripts/023_permissions.sql
new file mode 100644
index 0000000..2b90bb0
--- /dev/null
+++ b/scripts/023_permissions.sql
@@ -0,0 +1,200 @@
+-- =============================================================================
+-- 023: Customizable permissions, per-item Ops ACLs, area-lead effective roles
+-- =============================================================================
+-- ADDITIVE and backward compatible. With zero rows in the new tables the app
+-- behaves exactly as before: the new SELECT branches on secrets and
+-- knowledge_base only OR-in extra access, they never remove the existing
+-- role-based access. A space opts in by setting permissions / ACLs.
+--
+-- Pieces:
+-- space_role_permissions role/custom-role -> permission code grants
+-- ops_acl per-item allow list (secret | kb | process)
+-- user_effective_roles() built-in role + custom roles + area-lead sentinels
+-- user_has_permission() admin implicit-all, else a grant via effective role
+-- secrets/knowledge_base SELECT policies gain an ops_acl OR-branch
+--
+-- Area-lead roles reuse the existing public.area_leads table: a row is an
+-- "area lead role", lead_id IS NULL renders as Vacant, and the assigned
+-- member effectively holds the sentinel role 'area_lead:'.
+-- =============================================================================
+
+-- --- space_role_permissions -------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS public.space_role_permissions (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
+ -- a built-in member_role value or a space_custom_roles.slug
+ subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 50),
+ permission text NOT NULL CHECK (char_length(permission) BETWEEN 1 AND 60),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (space_id, subject, permission)
+);
+CREATE INDEX IF NOT EXISTS idx_role_perms_space ON public.space_role_permissions (space_id, subject);
+
+-- --- ops_acl ----------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS public.ops_acl (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
+ entity_type text NOT NULL CHECK (entity_type IN ('secret','kb','process','area_lead')),
+ entity_id uuid NOT NULL,
+ -- a built-in role, a custom-role slug, or 'area_lead:'
+ role text NOT NULL CHECK (char_length(role) BETWEEN 1 AND 64),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (space_id, entity_type, entity_id, role)
+);
+CREATE INDEX IF NOT EXISTS idx_ops_acl_entity ON public.ops_acl (entity_type, entity_id);
+CREATE INDEX IF NOT EXISTS idx_ops_acl_space ON public.ops_acl (space_id);
+
+-- --- effective roles + permission helpers (SECURITY DEFINER, pinned path) ----
+
+-- Every role identifier the user effectively holds in a space: their built-in
+-- member_role, every assigned custom-role slug, and 'area_lead:' for each
+-- area_leads row they lead. STABLE: safe inside RLS.
+CREATE OR REPLACE FUNCTION public.user_effective_roles(uid uuid, sid uuid)
+ RETURNS SETOF text
+ LANGUAGE sql STABLE SECURITY DEFINER
+ SET search_path = public
+AS $$
+ SELECT sm.role::text
+ FROM public.space_members sm
+ WHERE sm.user_id = uid AND sm.space_id = sid
+ UNION
+ SELECT cr.slug
+ FROM public.space_members sm
+ JOIN public.space_member_custom_roles mcr ON mcr.member_id = sm.id
+ JOIN public.space_custom_roles cr ON cr.id = mcr.custom_role_id
+ WHERE sm.user_id = uid AND sm.space_id = sid
+ UNION
+ SELECT 'area_lead:' || al.id::text
+ FROM public.space_members sm
+ JOIN public.area_leads al ON al.lead_id = sm.id
+ WHERE sm.user_id = uid AND sm.space_id = sid AND al.space_id = sid;
+$$;
+
+-- admin implicitly holds every permission and can never be locked out.
+CREATE OR REPLACE FUNCTION public.user_has_permission(uid uuid, sid uuid, perm text)
+ RETURNS boolean
+ LANGUAGE sql STABLE SECURITY DEFINER
+ SET search_path = public
+AS $$
+ SELECT public.user_has_role_in_space(uid, sid, ARRAY['admin'])
+ OR EXISTS (
+ SELECT 1 FROM public.space_role_permissions p
+ WHERE p.space_id = sid
+ AND p.permission = perm
+ AND p.subject IN (SELECT public.user_effective_roles(uid, sid))
+ );
+$$;
+
+-- --- RLS on the new tables --------------------------------------------------
+
+ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS role_perms_select ON public.space_role_permissions;
+DROP POLICY IF EXISTS role_perms_insert ON public.space_role_permissions;
+DROP POLICY IF EXISTS role_perms_update ON public.space_role_permissions;
+DROP POLICY IF EXISTS role_perms_delete ON public.space_role_permissions;
+CREATE POLICY role_perms_select ON public.space_role_permissions FOR SELECT
+ USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
+CREATE POLICY role_perms_insert ON public.space_role_permissions FOR INSERT
+ WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+CREATE POLICY role_perms_update ON public.space_role_permissions FOR UPDATE
+ USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+CREATE POLICY role_perms_delete ON public.space_role_permissions FOR DELETE
+ USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+
+DROP POLICY IF EXISTS ops_acl_select ON public.ops_acl;
+DROP POLICY IF EXISTS ops_acl_insert ON public.ops_acl;
+DROP POLICY IF EXISTS ops_acl_update ON public.ops_acl;
+DROP POLICY IF EXISTS ops_acl_delete ON public.ops_acl;
+CREATE POLICY ops_acl_select ON public.ops_acl FOR SELECT
+ USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
+CREATE POLICY ops_acl_insert ON public.ops_acl FOR INSERT
+ WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+CREATE POLICY ops_acl_update ON public.ops_acl FOR UPDATE
+ USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+CREATE POLICY ops_acl_delete ON public.ops_acl FOR DELETE
+ USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+
+-- --- additive ACL branch on secrets + knowledge_base SELECT -----------------
+-- The first OR-operand is the EXACT existing policy. The second only widens.
+
+DROP POLICY IF EXISTS secrets_select ON public.secrets;
+CREATE POLICY secrets_select ON public.secrets FOR SELECT
+ USING (
+ public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
+ OR EXISTS (
+ SELECT 1 FROM public.ops_acl a
+ WHERE a.space_id = secrets.space_id
+ AND a.entity_type = 'secret'
+ AND a.entity_id = secrets.id
+ AND a.role IN (SELECT public.user_effective_roles(auth.uid(), secrets.space_id))
+ )
+ );
+
+DROP POLICY IF EXISTS kb_select ON public.knowledge_base;
+CREATE POLICY kb_select ON public.knowledge_base FOR SELECT
+ USING (
+ (
+ space_id IN (SELECT public.get_user_space_ids(auth.uid()))
+ AND (
+ visibility = 'all_members'
+ OR (visibility = 'board' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']))
+ OR (visibility = 'admin_only' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']))
+ )
+ )
+ OR EXISTS (
+ SELECT 1 FROM public.ops_acl a
+ WHERE a.space_id = knowledge_base.space_id
+ AND a.entity_type IN ('kb','process')
+ AND a.entity_id = knowledge_base.id
+ AND a.role IN (SELECT public.user_effective_roles(auth.uid(), knowledge_base.space_id))
+ )
+ );
+
+-- --- seed default role permissions ------------------------------------------
+
+CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
+ RETURNS trigger
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
+AS $$
+BEGIN
+ INSERT INTO public.space_role_permissions (space_id, subject, permission)
+ SELECT NEW.id, d.subject, d.permission
+ FROM (VALUES
+ ('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
+ ('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
+ ('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
+ ('board','governance.manage'),('board','forum.moderate'),('board','customize.manage'),
+ ('board','settings.manage'),
+ ('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
+ ('member','ops.kb.read'),('member','ops.process.read'),
+ ('associate','ops.kb.read')
+ ) AS d(subject, permission)
+ ON CONFLICT (space_id, subject, permission) DO NOTHING;
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_seed_default_role_permissions ON public.spaces;
+CREATE TRIGGER trg_seed_default_role_permissions
+ AFTER INSERT ON public.spaces
+ FOR EACH ROW EXECUTE FUNCTION public.seed_default_role_permissions();
+
+-- Backfill existing spaces.
+INSERT INTO public.space_role_permissions (space_id, subject, permission)
+SELECT s.id, d.subject, d.permission
+FROM public.spaces s
+CROSS JOIN (VALUES
+ ('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
+ ('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
+ ('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
+ ('board','governance.manage'),('board','forum.moderate'),('board','customize.manage'),
+ ('board','settings.manage'),
+ ('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
+ ('member','ops.kb.read'),('member','ops.process.read'),
+ ('associate','ops.kb.read')
+) AS d(subject, permission)
+ON CONFLICT (space_id, subject, permission) DO NOTHING;
diff --git a/scripts/schema.sql b/scripts/schema.sql
index d6ed405..95d5bc7 100644
--- a/scripts/schema.sql
+++ b/scripts/schema.sql
@@ -1692,3 +1692,147 @@ DROP TRIGGER IF EXISTS trg_onboarding_steps_touch ON public.space_onboarding_ste
CREATE TRIGGER trg_onboarding_steps_touch
BEFORE UPDATE ON public.space_onboarding_steps
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+
+
+-- =============================================================================
+-- 15. Customizable permissions, per-item Ops ACLs, area-lead effective roles.
+-- Equivalent to scripts/023_permissions.sql. Additive: the new SELECT
+-- branches only widen access; with no rows in the new tables behavior is
+-- unchanged.
+-- =============================================================================
+
+CREATE TABLE IF NOT EXISTS public.space_role_permissions (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
+ subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 50),
+ permission text NOT NULL CHECK (char_length(permission) BETWEEN 1 AND 60),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (space_id, subject, permission)
+);
+CREATE INDEX IF NOT EXISTS idx_role_perms_space ON public.space_role_permissions (space_id, subject);
+
+CREATE TABLE IF NOT EXISTS public.ops_acl (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
+ entity_type text NOT NULL CHECK (entity_type IN ('secret','kb','process','area_lead')),
+ entity_id uuid NOT NULL,
+ role text NOT NULL CHECK (char_length(role) BETWEEN 1 AND 64),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (space_id, entity_type, entity_id, role)
+);
+CREATE INDEX IF NOT EXISTS idx_ops_acl_entity ON public.ops_acl (entity_type, entity_id);
+CREATE INDEX IF NOT EXISTS idx_ops_acl_space ON public.ops_acl (space_id);
+
+CREATE OR REPLACE FUNCTION public.user_effective_roles(uid uuid, sid uuid)
+ RETURNS SETOF text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
+AS $$
+ SELECT sm.role::text FROM public.space_members sm
+ WHERE sm.user_id = uid AND sm.space_id = sid
+ UNION
+ SELECT cr.slug
+ FROM public.space_members sm
+ JOIN public.space_member_custom_roles mcr ON mcr.member_id = sm.id
+ JOIN public.space_custom_roles cr ON cr.id = mcr.custom_role_id
+ WHERE sm.user_id = uid AND sm.space_id = sid
+ UNION
+ SELECT 'area_lead:' || al.id::text
+ FROM public.space_members sm
+ JOIN public.area_leads al ON al.lead_id = sm.id
+ WHERE sm.user_id = uid AND sm.space_id = sid AND al.space_id = sid;
+$$;
+
+CREATE OR REPLACE FUNCTION public.user_has_permission(uid uuid, sid uuid, perm text)
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
+AS $$
+ SELECT public.user_has_role_in_space(uid, sid, ARRAY['admin'])
+ OR EXISTS (
+ SELECT 1 FROM public.space_role_permissions p
+ WHERE p.space_id = sid AND p.permission = perm
+ AND p.subject IN (SELECT public.user_effective_roles(uid, sid))
+ );
+$$;
+
+ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;
+DROP POLICY IF EXISTS role_perms_select ON public.space_role_permissions;
+DROP POLICY IF EXISTS role_perms_insert ON public.space_role_permissions;
+DROP POLICY IF EXISTS role_perms_update ON public.space_role_permissions;
+DROP POLICY IF EXISTS role_perms_delete ON public.space_role_permissions;
+CREATE POLICY role_perms_select ON public.space_role_permissions FOR SELECT
+ USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
+CREATE POLICY role_perms_insert ON public.space_role_permissions FOR INSERT
+ WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+CREATE POLICY role_perms_update ON public.space_role_permissions FOR UPDATE
+ USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+CREATE POLICY role_perms_delete ON public.space_role_permissions FOR DELETE
+ USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+
+DROP POLICY IF EXISTS ops_acl_select ON public.ops_acl;
+DROP POLICY IF EXISTS ops_acl_insert ON public.ops_acl;
+DROP POLICY IF EXISTS ops_acl_update ON public.ops_acl;
+DROP POLICY IF EXISTS ops_acl_delete ON public.ops_acl;
+CREATE POLICY ops_acl_select ON public.ops_acl FOR SELECT
+ USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
+CREATE POLICY ops_acl_insert ON public.ops_acl FOR INSERT
+ WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+CREATE POLICY ops_acl_update ON public.ops_acl FOR UPDATE
+ USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+CREATE POLICY ops_acl_delete ON public.ops_acl FOR DELETE
+ USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
+
+DROP POLICY IF EXISTS secrets_select ON public.secrets;
+CREATE POLICY secrets_select ON public.secrets FOR SELECT
+ USING (
+ public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
+ OR EXISTS (
+ SELECT 1 FROM public.ops_acl a
+ WHERE a.space_id = secrets.space_id AND a.entity_type = 'secret'
+ AND a.entity_id = secrets.id
+ AND a.role IN (SELECT public.user_effective_roles(auth.uid(), secrets.space_id))
+ )
+ );
+
+DROP POLICY IF EXISTS kb_select ON public.knowledge_base;
+CREATE POLICY kb_select ON public.knowledge_base FOR SELECT
+ USING (
+ (
+ space_id IN (SELECT public.get_user_space_ids(auth.uid()))
+ AND (
+ visibility = 'all_members'
+ OR (visibility = 'board' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']))
+ OR (visibility = 'admin_only' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']))
+ )
+ )
+ OR EXISTS (
+ SELECT 1 FROM public.ops_acl a
+ WHERE a.space_id = knowledge_base.space_id AND a.entity_type IN ('kb','process')
+ AND a.entity_id = knowledge_base.id
+ AND a.role IN (SELECT public.user_effective_roles(auth.uid(), knowledge_base.space_id))
+ )
+ );
+
+CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
+AS $$
+BEGIN
+ INSERT INTO public.space_role_permissions (space_id, subject, permission)
+ SELECT NEW.id, d.subject, d.permission
+ FROM (VALUES
+ ('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
+ ('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
+ ('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
+ ('board','governance.manage'),('board','forum.moderate'),('board','customize.manage'),
+ ('board','settings.manage'),
+ ('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
+ ('member','ops.kb.read'),('member','ops.process.read'),
+ ('associate','ops.kb.read')
+ ) AS d(subject, permission)
+ ON CONFLICT (space_id, subject, permission) DO NOTHING;
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_seed_default_role_permissions ON public.spaces;
+CREATE TRIGGER trg_seed_default_role_permissions
+ AFTER INSERT ON public.spaces
+ FOR EACH ROW EXECUTE FUNCTION public.seed_default_role_permissions();
diff --git a/types/database.ts b/types/database.ts
index 18bc032..445466d 100644
--- a/types/database.ts
+++ b/types/database.ts
@@ -34,6 +34,18 @@ export type Database = {
}
public: {
Tables: {
+ space_role_permissions: {
+ Row: { id: string; space_id: string; subject: string; permission: string; created_at: string }
+ Insert: { id?: string; space_id: string; subject: string; permission: string; created_at?: string }
+ Update: { id?: string; space_id?: string; subject?: string; permission?: string; created_at?: string }
+ Relationships: []
+ }
+ ops_acl: {
+ Row: { id: string; space_id: string; entity_type: string; entity_id: string; role: string; created_at: string }
+ Insert: { id?: string; space_id: string; entity_type: string; entity_id: string; role: string; created_at?: string }
+ Update: { id?: string; space_id?: string; entity_type?: string; entity_id?: string; role?: string; created_at?: string }
+ Relationships: []
+ }
activity_log: {
Row: {
action: string