From 88e1c1686e54672a32e0768427e6194313c3ee81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 00:21:50 +0000 Subject: [PATCH] Revamp 5.1: Members & roles management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add member management for cloud projects (ROADMAP §5.1, D8) — start of Milestone 5. - 0008_member_policies.sql: profiles.email (backfilled + seeded) + a co-member profiles SELECT policy (private.shares_project); switches project_members update/delete to project_manager-only (insert still allows the owner's publish self-seed); an enforce_min_one_pm trigger that blocks removing/demoting a project's last manager; and the invite_member(project_id, email, role) RPC (SECURITY DEFINER, PM-checked) that resolves the email -> user id and upserts membership, returning NULL when no account matches (no enumeration surface). - members.ts: pure client-injected listMembers/inviteMember/ changeMemberRole/removeMember. - ManageMembersDialog (invite-by-email + role dropdowns + remove for a PM; read-only roster otherwise), opened from a ManageMembersButton on the cloud project card; dialog + members logic lazy-loaded. - Tests: cloud.members (join/self/sort, invite invited/not_found/forbidden, role change, remove, last-manager trigger surfaces as a throw). - Docs: cloud.md migration list + §11 manual RLS matrix. Numbering deviation: ships as 0008 (0006/0007 taken); invite resolves the email inside the RPC rather than a client profiles lookup. Green: 661 unit (+8) / typecheck / build (flag-off Supabase-free; members code is a lazy chunk). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015AEnTYBecWG64R3iP8GBiE --- docs/cloud.md | 35 +++ docs/revamp/ROADMAP.md | 2 +- docs/revamp/STATUS.md | 5 +- src/features/projects/ProjectCard.tsx | 3 +- src/features/projects/cloud/CloudControls.tsx | 36 ++- .../projects/cloud/ManageMembersDialog.tsx | 250 ++++++++++++++++++ src/storage/cloud/members.ts | 117 ++++++++ supabase/migrations/0008_member_policies.sql | 181 +++++++++++++ tests/unit/cloud.members.test.ts | 193 ++++++++++++++ 9 files changed, 816 insertions(+), 6 deletions(-) create mode 100644 src/features/projects/cloud/ManageMembersDialog.tsx create mode 100644 src/storage/cloud/members.ts create mode 100644 supabase/migrations/0008_member_policies.sql create mode 100644 tests/unit/cloud.members.test.ts diff --git a/docs/cloud.md b/docs/cloud.md index 75b54db..5e4e400 100644 --- a/docs/cloud.md +++ b/docs/cloud.md @@ -114,6 +114,17 @@ and safe to re-run: sketch's `(project_id, expected_seq)` to also carry the new snapshot + the pruned-through id, so the whole compaction is one atomic, RLS-safe transaction.)* +- `0008_member_policies.sql` — member management: adds `profiles.email` (backfilled + + seeded on signup) and a co-member `profiles` SELECT policy (`private.shares_project`); + switches `project_members` update/delete to **project_manager-only** (insert still + allows the owner's publish self-seed); a `enforce_min_one_pm` trigger that blocks + removing or demoting a project's **last** manager; and the `invite_member(project_id, + email, role)` RPC (SECURITY DEFINER, PM-checked) that resolves the email to a user id + and upserts the membership (returns NULL when no account has that email — no + enumeration surface). Backs Phase 5.1. Apply after `0007`. *(Numbering note: ROADMAP + §5.1 sketched `0006_member_policies.sql`, but `0006`/`0007` were taken, so it ships as + `0008`; invite resolves the email inside the RPC rather than via a client-side profiles + lookup, keeping emails/ids server-side.)* > Note: the linter's "Leaked Password Protection Disabled" warning is unrelated > to these migrations — it's an optional **Authentication → Policies** toggle @@ -258,3 +269,27 @@ opened the same published cloud project: - [ ] **Local-only unaffected**: a project with no `cloud` link makes no `ydoc_state`/`ydoc_updates` request; the BroadcastChannel/LAN path is unchanged. + +## 11. Manual verification (Phase 5.1 — Members & roles) + +Member management for a published cloud project via the **Members** dialog on the +project card (`ManageMembersDialog` → `src/storage/cloud/members.ts`). Apply +`0008_member_policies.sql` first. Needs the project owner (a project_manager) plus +a second signed-in account: + +- [ ] **PM invites**: as the manager, open Members → invite the second account's + email as *translator*, then again as *revisor* → they appear in the roster + with the chosen role, and can open the project. +- [ ] **Unknown email**: inviting an email with no Verbalis account yet shows the + "no account uses that email" notice (the RPC returns NULL) — no row added. +- [ ] **Non-PM read-only**: signed in as the translator, the Members dialog shows + the roster without role dropdowns / remove / invite. A hand-crafted + `update`/`delete`/`invite_member` against `project_members` is **rejected by + the server** (PM-only RLS + the RPC's PM check). +- [ ] **Change role / remove**: the manager changes a member's role and removes a + member → both reflect immediately for that member. +- [ ] **Last manager protected**: the manager tries to demote or remove the only + project_manager → the server raises "a project must keep at least one + project_manager" (surfaced as an error in the dialog). +- [ ] **Local-only unaffected**: a project with no `cloud` link shows no Members + button; a signed-out / unconfigured build never loads the members chunk. diff --git a/docs/revamp/ROADMAP.md b/docs/revamp/ROADMAP.md index 9358aff..3898505 100644 --- a/docs/revamp/ROADMAP.md +++ b/docs/revamp/ROADMAP.md @@ -82,7 +82,7 @@ Roadmap, status checklist, prompt pack, and vision docs committed to the repo. - **4.4.1 Remote caret overlay — S — deps: 4.4.** Report the local caret (Lexical selection → plain-text offset) through `setActiveSegment(id, caret)`, and render remote peers' carets/selections in each segment with a `RemoteCaretOverlay` reusing the offset→DOM mapping in `src/core/spell/offsets.ts` (as `SpellUnderlinePlugin` does). **DoD**: live remote cursors move as peers type; overlay never mutates the editor tree. *Shipped: `mapOffsetToNode` (core) + `richOffsets.ts` (`selectionToCaretRange`/`buildLeafSpans`, reproducing the plain projection incl. delete-mark → '') + `RemoteCaretOverlay`; the focused editor reports its caret throttled to 100ms. **Milestone 4 complete.*** ### Milestone 5 — Roles & workflow -- **5.1 Members & roles management — M — deps: 4.1.** `cloud/members.ts` (invite by email via profiles lookup, change role, remove), `MembersPanel`, `0006_member_policies.sql` (PM-only member writes; trigger keeps ≥1 PM). **DoD**: PM invites translator + revisor; non-PM read-only; server rejects non-PM writes. +- **5.1 Members & roles management — M — deps: 4.1.** `cloud/members.ts` (invite by email via profiles lookup, change role, remove), `MembersPanel`, `0006_member_policies.sql` (PM-only member writes; trigger keeps ≥1 PM). **DoD**: PM invites translator + revisor; non-PM read-only; server rejects non-PM writes. *Shipped as `0008_member_policies.sql` (0006/0007 taken) + `cloud/members.ts` + a `ManageMembersDialog` on the project card (lazy-loaded) instead of a sidebar `MembersPanel`; invite resolves the email inside the SECURITY DEFINER `invite_member` RPC (PM-checked, no enumeration surface) rather than a client-side profiles lookup; `profiles.email` + a co-member SELECT policy added; a `enforce_min_one_pm` trigger guards the last manager.* - **5.2 Role-gated editing workflow — M — deps: 5.1, 1.6.** Pure `core/workflow/rules.ts`: `(role, stage, status) → {canEditDirect, mustSuggest, canResolveChanges, canReview, canManage}`; `projects.stage (translation|review|final)` + `deadline` (`0007_workflow.sql`, PM-only update). Translator in review stage is forced into suggesting (toggle disabled); accept/reject visible only to revisor/PM; local-only projects = PM-of-self (all permissive, zero regression). **DoD**: full role matrix works; local-only unchanged. - **5.3 Approval + attribution in versioning — S — deps: 5.2.** `ProjectVersion.authorId?/approval?`; revisor/PM "Sign off" creates a labeled approval version; one stable author identity across marks, comments, versions, presence. **DoD**: sign-off flow; attribution unified. *Milestone 5 = roles shipped → **web v2 launch candidate**.* diff --git a/docs/revamp/STATUS.md b/docs/revamp/STATUS.md index ed425ab..3c476ff 100644 --- a/docs/revamp/STATUS.md +++ b/docs/revamp/STATUS.md @@ -32,8 +32,8 @@ Rules for autonomous sessions: | 4.2 | SupabaseRealtimeTransport + chunking | M | 4.1 | done (PR #56) | | 4.3 | Postgres persistence loop (catch-up, append, compaction) | M | 4.2 | done (PR #57) | | 4.4 | Live collab UX: cursors, leases, attribution | M | 4.3, 1.6 | done (PR #58) | -| 4.4.1 | Remote caret overlay (live cursors, deferred from 4.4) | S | 4.4 | in-review (PR #59) | -| 5.1 | Members & roles management | M | 4.1 | pending | +| 4.4.1 | Remote caret overlay (live cursors, deferred from 4.4) | S | 4.4 | done (PR #59) | +| 5.1 | Members & roles management | M | 4.1 | in-review (PR #60) | | 5.2 | Role-gated editing workflow | M | 5.1, 1.6 | pending | | 5.3 | Approval workflow + attribution in versioning | S | 5.2 | pending | | 6.1 | Extension registry + MT providers as built-in addons | M | 0.1 | pending | @@ -75,3 +75,4 @@ Rules for autonomous sessions: - 2026-07-20 — Phase 4.2 merged in PR #56 (STATUS was stale — recorded here). Phase 4.3 built: the Postgres persistence loop (Realtime is latency, Postgres is truth). `supabase/migrations/0007_compaction.sql` — the `claim_compaction(project_id, expected_seq, state, up_to_id)` RPC (SECURITY DEFINER, membership-checked): atomically bumps the `ydoc_state.seq` generation **only while it still matches** `expected_seq`, installs the new compacted snapshot, and prunes `ydoc_updates` with `id <= up_to_id`. Numbering deviation: ROADMAP §4.3 sketched `0005_compaction.sql`, but `0005`/`0006` were taken by the 4.1 project migrations, so it ships as `0007`; and the RPC signature is **widened** from the sketch's `(project_id, expected_seq)` to also carry the new snapshot + pruned-through id, so the whole compaction is one atomic RLS-safe transaction (the append log has no DELETE policy by design — D8 tamper-evidence — so pruning goes through this controlled definer path, membership re-checked inside). `src/storage/cloud/ydocPersistence.ts` — pure client-injected data layer (`fetchSnapshot`/`fetchUpdates`/`appendUpdate`/`countUpdates`/`claimCompaction`, hex-bytea via 4.1's `bytea.ts`) + the `createYdocPersistence({cloudId, doc, client})` loop: **catch-up** fetches `ydoc_state` + the whole `ydoc_updates` log, merges (`Y.mergeUpdates`) and applies as `ORIGIN_REMOTE` (so neither the transport nor this loop re-broadcasts/re-appends it), then pushes anything the local doc holds that the cloud lacks (offline edits / pre-publish history) by diffing state vectors (`Y.encodeStateAsUpdate(doc, remoteSV)` when `localAheadOf`); **append** buffers local `doc.on('update')` deltas (ignoring `ORIGIN_REMOTE`), debounce-merges them (500ms) into one attributed `ydoc_updates` row, and re-buffers/retries on a failed (offline) append; **compaction** triggers once the log passes 200 rows — it re-reads the *authoritative* snapshot+updates from Postgres (never the possibly-lagging local doc) to build the new snapshot, so it subsumes exactly the rows it prunes, then optimistically `claim_compaction`s (loser gets `null` and no-ops — the log is already bounded). Catch-up is re-runnable (idempotent Yjs replay), which is what converges an offline member on reconnect. Wiring: `syncManager.startProjectSync` starts the loop (dynamic `startYdocPersistence`, only when `cloudId` set + signed-in + `isCloudConfigured()`) alongside the realtime transport, kicks off a non-blocking `catchUp()`, stores the handle on the ref-counted session, and destroys it in `stopProjectSync`; local-only projects never touch it. Deviations (noted): built on the designated session branch, not `claude/revamp-phase-4-3`; the RPC signature widening above; live **reconnect re-sync** (re-`hello`/re-`catchUp` when Realtime drops-then-resubscribes) is left to 4.4, which owns the transport/auto-start UX — 4.3 converges on **reopen** (each open runs catch-up), the DoD proven at the persistence layer. `docs/cloud.md` (§3 migration list + §10 manual verification). Tests: `cloud.ydocPersistence` (catch-up converges a fresh doc from snapshot+log; append coalesces a burst into one row; applied-remote is never re-appended; **offline-A/online-B converge through Postgres with no edit lost** — the DoD, on a shared in-memory server; compaction folds+prunes past the threshold and bumps seq, and a stale optimistic claim returns `null`; `fetchSnapshot`/`fetchUpdates` bytea round-trips). Green: 636 unit (+7) / build (flag-off: **zero** `GoTrueClient`/`claim_compaction` — the loop tree-shakes out entirely when the cloud is unconfigured; flag-on: `GoTrueClient` and the persistence loop each in their own lazy chunk, out of the entry). No new e2e (ROADMAP §4 asks none for 4.3; the two-member live DoD needs a real backend, per the §10 manual matrix). - 2026-07-20 — Phase 4.3 merged in PR #57. Phase 4.4 built: live-collab UX — per-segment edit leases, attribution, and the stale-LWW guard (transport-agnostic, so it works for local BroadcastChannel peers and cloud Realtime peers identically). `src/storage/sync/presence.ts` gains a stable `userId` (Supabase user id when signed in, else the device-local `authorId` — so attribution survives a peer reconnecting under a fresh peerId, D8) and a `CaretRange` on both `PresenceWire` and `PeerPresence`; `syncSession` broadcasts them (`setActiveSegment(id, caret?)`) and `syncManager` resolves `userId` via `ensureLocalAuthor()`/auth. `src/storage/sync/lease.ts` — pure `segmentLease(segmentId, selfPeerId, selfActiveSegmentId, peers)`: among everyone editing a segment the **lowest peerId owns** (D4), the rest are viewers; symmetric + deterministic so two peers entering at once always agree, and the lease **releases the moment the owner blurs** (its `activeSegmentId` moves off) and the next-lowest peer takes over. Wiring: `usePresenceStore` carries `selfPeerId`; `useProjectSync` publishes it; `EditorPage` computes `leaseHolderFor(segmentId)` and passes `leaseLockedBy` to each `SegmentRow`, which renders the target read-only (a separate `editLocked = locked || leaseLockedBy`, kept distinct from the persistent segment lock) with a coloured "{name} is editing" chip. Attribution "everyone's changes tracked" falls out of M1 + sync for free — remote suggesting-mode edits already arrive as pre-attributed `ChangeMarkNode`s. `reverseBridge.ts` stale-LWW guard (risk #5): `target` (char-level Y.Text) and `targetRich` are separate CRDT fields, so a concurrent merge can land a `targetRich` that no longer derives to the merged plain `target`; plain is authoritative, so when `richStateToPlain(targetRich) !== target` the guard drops the stale rich and the editor rebuilds it from plain. Deviations (noted): built on the designated session branch, not `claude/revamp-phase-4-4`; **the live remote-caret overlay is deferred to Phase 4.4.1** (appended to the table + ROADMAP §4) — the presence model already carries `caret` as the seam, but rendering it needs editor→session caret reporting + offset→DOM measurement (a finicky, separable slice), so 4.4 ships the load-bearing lease/attribution/guard core (the "one editor + one viewer; lease releases on blur" DoD) and the per-segment "is editing" chip as the visible presence cue. Tests: `sync.lease` (alone→own; higher-peerId co-editor→own; lower-peerId→viewer names holder; not-on-segment reports the editor; symmetric one-owner; release; `peersOnSegment`), `sync.presence` (userId + caret round-trip), `sync.reverseBridge` (guard drops a stale targetRich once the merged plain diverges, no-op while they agree), and e2e `lan-collaboration.spec.ts` extended (**simultaneous entry → exactly one read-only viewer + one editable owner; owner blur releases the lease** via two BroadcastChannel tabs). Green: 645 unit (+9) / 28 e2e (+1) / build (flag-off has zero `GoTrueClient`; leases are local-capable so they ship in the entry as intended). - 2026-07-20 — Phase 4.4 merged in PR #58. Phase 4.4.1 built: the live remote-caret overlay deferred from 4.4 (**Milestone 4 fully complete**). `src/core/spell/offsets.ts` gains pure `mapOffsetToNode(spans, offset)` — the caret counterpart to `mapTokenToNode`, resolving a plain-text offset to a text node + local offset (a text/text boundary snaps to the earlier node's end; a tag boundary or past-the-end returns null → the overlay skips that frame). `src/features/editor/rich/richOffsets.ts` — `selectionToCaretRange(selection)` converts a live Lexical selection to plain anchor/focus offsets and `buildLeafSpans(paragraph)` produces the ordered leaf spans, both reproducing exactly the plain projection `richStateToPlain` derives (a tag contributes `{id}`, a **delete change-mark projects to '' and its subtree is skipped**) so a caret one peer reports lands on the same character in another peer's editor. `RemoteCaretOverlay.tsx` (a Lexical plugin mounted in `RichSegmentEditor` beside the spell overlay) paints a coloured caret + name label for each remote peer whose presence puts them on the segment with a caret, measured via `getElementByKey`+DOM range like `SpellUnderlinePlugin` — it never mutates the node tree and every measurement is guarded (renders nothing on failure). Reporting: the **focused** segment's editor (the only one given `onCaret`) computes `selectionToCaretRange` in its update listener and reports it through `EditorPage`→`useProjectSync`→`setActiveSegment(id, caret)`, throttled to one send per 100ms; `useProjectSync.setActiveSegment` now forwards the caret. Deviations (noted): built on the designated session branch, not `claude/revamp-phase-4-4-1`. Tests: `spell.offsets` extended (`mapOffsetToNode`: inside/boundary/after-tag/end/out-of-range), `richOffsets` (headless: caret offsets + leaf spans agree with the plain projection for text+tag, and a **delete change-mark projects to nothing** so a caret past it collapses correctly), and e2e `lan-collaboration.spec.ts` extended (**a peer's caret renders as a live remote cursor in the other tab**). Green: 653 unit (+8) / 29 e2e (+1) / build (flag-off zero `GoTrueClient`; the overlay is local-capable and ships in the entry, no Supabase). **Milestone 4 (cloud projects + real-time collaboration) complete.** +- 2026-07-20 — Phase 4.4.1 merged in PR #59. Phase 5.1 built: members & roles management (start of **Milestone 5**, roles & workflow). `supabase/migrations/0008_member_policies.sql` — adds `profiles.email` (backfilled from `auth.users` + seeded on signup) and a co-member `profiles` SELECT policy via a new `private.shares_project(other)` SECURITY DEFINER helper (the "broader read for member lookup" 0001 deferred to 5.1); switches `project_members` **update/delete to project_manager-only** (`private.has_project_role`), keeping `insert` reachable by the owner's publish self-seed OR a PM; a `enforce_min_one_pm` BEFORE UPDATE/DELETE trigger that blocks removing/demoting a project's **last** manager; and the `invite_member(project_id, email, role)` RPC (SECURITY DEFINER, PM-checked) that resolves the email → user id and upserts membership, returning NULL when no account matches (no id/email enumeration surface). Numbering deviation: ROADMAP §5.1 sketched `0006_member_policies.sql`, but `0006`/`0007` were taken (4.1 helpers + 4.3 compaction), so it ships as `0008`; and invite resolves the email **inside** the RPC rather than a client-side profiles lookup, keeping it server-side. `src/storage/cloud/members.ts` — pure client-injected `listMembers` (joins the roster to profiles for names/emails, marks self, sorts managers first), `inviteMember` (RPC → `{status:'invited'|'not_found'}`), `changeMemberRole`/`removeMember` (direct writes gated by PM-only RLS + the trigger, surfaced as thrown errors). UI: `features/projects/cloud/ManageMembersDialog.tsx` (roster; a PM gets an invite-by-email form + per-member role ` setInviteEmail(e.target.value)} + className="min-w-0 flex-1" + data-testid="invite-email" + /> + + + + )} + + {members === null ? ( +
+ Loading… +
+ ) : ( + + )} + + {notice ? ( +

+ {notice} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + + ) +} diff --git a/src/storage/cloud/members.ts b/src/storage/cloud/members.ts new file mode 100644 index 0000000..9787f0a --- /dev/null +++ b/src/storage/cloud/members.ts @@ -0,0 +1,117 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { ProjectRole } from '@/core/types' + +/** + * Member management for a cloud project (ROADMAP §5.1, D8). Pure client-injected + * data functions (vitest passes a mock) over the `project_members` + `profiles` + * tables and the `invite_member` RPC: + * + * - `listMembers` joins the roster to profiles for names/emails (member-scoped + * reads by RLS). + * - `inviteMember` calls the SECURITY DEFINER RPC, which resolves the email to a + * user id and upserts the membership — all behind a project_manager check, so + * no id/email enumeration surface is exposed and a non-PM is server-rejected. + * - `changeMemberRole` / `removeMember` are direct writes gated by PM-only RLS + * and the ≥1-project_manager trigger (both raise, surfaced as thrown errors). + * + * Strictly additive: loaded only when a signed-in user opens the Members panel. + */ + +export interface ProjectMember { + userId: string + role: ProjectRole + displayName: string | null + email: string | null + /** True for the signed-in user's own row (guards self-demotion / self-removal in the UI). */ + isSelf: boolean +} + +export type InviteResult = { status: 'invited'; userId: string } | { status: 'not_found' } + +const ROLE_ORDER: Record = { project_manager: 0, revisor: 1, translator: 2 } + +/** The roster for a cloud project, joined to profile names/emails. */ +export async function listMembers( + client: SupabaseClient, + cloudId: string, + selfUserId: string, +): Promise { + const membersRes = await client + .from('project_members') + .select('user_id, role') + .eq('project_id', cloudId) + if (membersRes.error) throw new Error(membersRes.error.message) + const rows = (membersRes.data as { user_id: string; role: ProjectRole }[] | null) ?? [] + if (rows.length === 0) return [] + + const ids = rows.map((r) => r.user_id) + const profRes = await client.from('profiles').select('id, display_name, email').in('id', ids) + if (profRes.error) throw new Error(profRes.error.message) + const profiles = new Map( + ((profRes.data as { id: string; display_name: string | null; email: string | null }[] | null) ?? []).map( + (p) => [p.id, p] as const, + ), + ) + + return rows + .map((r): ProjectMember => { + const p = profiles.get(r.user_id) + return { + userId: r.user_id, + role: r.role, + displayName: p?.display_name ?? null, + email: p?.email ?? null, + isSelf: r.user_id === selfUserId, + } + }) + .sort( + (a, b) => + ROLE_ORDER[a.role] - ROLE_ORDER[b.role] || + (a.displayName ?? a.email ?? '').localeCompare(b.displayName ?? b.email ?? ''), + ) +} + +/** Invite (or re-role) a user by email via the PM-only RPC. */ +export async function inviteMember( + client: SupabaseClient, + cloudId: string, + email: string, + role: ProjectRole, +): Promise { + const { data, error } = await client.rpc('invite_member', { + p_project_id: cloudId, + p_email: email, + p_role: role, + }) + if (error) throw new Error(error.message) + return data ? { status: 'invited', userId: data as string } : { status: 'not_found' } +} + +/** Change a member's role (PM-only RLS; the ≥1-PM trigger blocks the last demotion). */ +export async function changeMemberRole( + client: SupabaseClient, + cloudId: string, + userId: string, + role: ProjectRole, +): Promise { + const { error } = await client + .from('project_members') + .update({ role }) + .eq('project_id', cloudId) + .eq('user_id', userId) + if (error) throw new Error(error.message) +} + +/** Remove a member (PM-only RLS; the ≥1-PM trigger blocks removing the last manager). */ +export async function removeMember( + client: SupabaseClient, + cloudId: string, + userId: string, +): Promise { + const { error } = await client + .from('project_members') + .delete() + .eq('project_id', cloudId) + .eq('user_id', userId) + if (error) throw new Error(error.message) +} diff --git a/supabase/migrations/0008_member_policies.sql b/supabase/migrations/0008_member_policies.sql new file mode 100644 index 0000000..b94ffe3 --- /dev/null +++ b/supabase/migrations/0008_member_policies.sql @@ -0,0 +1,181 @@ +-- Verbalis cloud — 0008 member management: PM-only writes + invite-by-email +-- (ROADMAP §5.1, D8) +-- +-- Phase 4.1 (0005/0006) seeded `project_members` with owner-only write policies. +-- Roles need more: any project_manager (not just the owner) may invite, change +-- roles, and remove members, and the project must never lose its last manager. +-- Invite is by email, which means resolving an email to a user id — done inside +-- a SECURITY DEFINER RPC so no email/id enumeration surface is exposed to the API +-- (the PM never reads other users' rows directly). The Members panel still needs +-- to show co-members' names, so profiles SELECT is broadened to project peers — +-- the "broader read access for member lookup" that 0001 deferred to this phase. +-- Idempotent; apply after 0007. + +-- --------------------------------------------------------------------------- +-- profiles.email — needed to resolve an invite and to show who a member is. +-- --------------------------------------------------------------------------- + +alter table public.profiles add column if not exists email text; + +-- Backfill from auth.users, and keep it seeded on signup. +update public.profiles p + set email = u.email + from auth.users u + where u.id = p.id and p.email is distinct from u.email; + +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.profiles (id, display_name, avatar_url, email) + values ( + new.id, + coalesce(new.raw_user_meta_data ->> 'full_name', new.raw_user_meta_data ->> 'name'), + coalesce(new.raw_user_meta_data ->> 'avatar_url', new.raw_user_meta_data ->> 'picture'), + new.email + ) + on conflict (id) do nothing; + return new; +end; +$$; + +-- --------------------------------------------------------------------------- +-- Broaden profiles SELECT to project co-members (least-privilege: only peers who +-- already share a project with the caller). SECURITY DEFINER helper avoids a +-- recursive RLS evaluation on project_members. +-- --------------------------------------------------------------------------- + +create or replace function private.shares_project(other uuid) +returns boolean +language sql +security definer +set search_path = '' +stable +as $$ + select exists ( + select 1 + from public.project_members m1 + join public.project_members m2 on m1.project_id = m2.project_id + where m1.user_id = auth.uid() and m2.user_id = other + ); +$$; + +revoke execute on function private.shares_project(uuid) from anon, public; +grant execute on function private.shares_project(uuid) to authenticated; + +drop policy if exists profiles_select_comembers on public.profiles; +create policy profiles_select_comembers on public.profiles for select + using (private.shares_project(id)); + +-- --------------------------------------------------------------------------- +-- PM-only member management. Insert stays reachable by the project owner (the +-- publish self-seed, before they are a member) OR any project_manager; role +-- changes + removals require a project_manager. +-- --------------------------------------------------------------------------- + +drop policy if exists members_insert on public.project_members; +create policy members_insert on public.project_members for insert + with check ( + private.has_project_role(project_id, array['project_manager']::public.project_role[]) + or exists (select 1 from public.projects p where p.id = project_id and p.owner_id = auth.uid()) + ); + +drop policy if exists members_update on public.project_members; +create policy members_update on public.project_members for update + using (private.has_project_role(project_id, array['project_manager']::public.project_role[])) + with check (private.has_project_role(project_id, array['project_manager']::public.project_role[])); + +drop policy if exists members_delete on public.project_members; +create policy members_delete on public.project_members for delete + using (private.has_project_role(project_id, array['project_manager']::public.project_role[])); + +-- --------------------------------------------------------------------------- +-- Never leave a project without a manager. Fires on a demotion (update away from +-- project_manager) or a removal (delete) of the last remaining manager. +-- --------------------------------------------------------------------------- + +create or replace function public.enforce_min_one_pm() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_removing_pm boolean; + v_remaining int; +begin + if tg_op = 'DELETE' then + v_removing_pm := (old.role = 'project_manager'); + else + v_removing_pm := (old.role = 'project_manager' and new.role <> 'project_manager'); + end if; + + if v_removing_pm then + select count(*) into v_remaining + from public.project_members + where project_id = old.project_id + and role = 'project_manager' + and user_id <> old.user_id; + if v_remaining = 0 then + raise exception 'a project must keep at least one project_manager'; + end if; + end if; + + return case when tg_op = 'DELETE' then old else new end; +end; +$$; + +drop trigger if exists project_members_min_one_pm on public.project_members; +create trigger project_members_min_one_pm + before update or delete on public.project_members + for each row execute function public.enforce_min_one_pm(); + +-- --------------------------------------------------------------------------- +-- invite_member — PM invites a user by email. SECURITY DEFINER so the email→id +-- lookup and the membership upsert happen server-side with a PM check, exposing +-- no enumeration surface. Returns the invited user id, or NULL when no account +-- has that email; raises when the caller is not a project_manager. +-- --------------------------------------------------------------------------- + +create or replace function public.invite_member( + p_project_id uuid, + p_email text, + p_role public.project_role +) +returns uuid +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid; +begin + if not private.has_project_role(p_project_id, array['project_manager']::public.project_role[]) then + raise exception 'only a project_manager may invite members'; + end if; + + select id into v_user_id + from public.profiles + where lower(email) = lower(trim(p_email)) + limit 1; + + if v_user_id is null then + return null; -- no account with that email (yet) + end if; + + insert into public.project_members (project_id, user_id, role) + values (p_project_id, v_user_id, p_role) + on conflict (project_id, user_id) do update set role = excluded.role; + + return v_user_id; +end; +$$; + +revoke execute on function public.invite_member(uuid, text, public.project_role) from anon, public; +grant execute on function public.invite_member(uuid, text, public.project_role) to authenticated; + +comment on function public.invite_member(uuid, text, public.project_role) is + 'PM-only: resolve an email to a user and upsert their project membership; NULL when no such account (ROADMAP §5.1).'; diff --git a/tests/unit/cloud.members.test.ts b/tests/unit/cloud.members.test.ts new file mode 100644 index 0000000..88f7920 --- /dev/null +++ b/tests/unit/cloud.members.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { + listMembers, + inviteMember, + changeMemberRole, + removeMember, +} from '@/storage/cloud/members' + +type Row = Record +type Filter = ['eq', string, unknown] | ['in', string, unknown[]] + +function applyFilters(rows: Row[], filters: Filter[]): Row[] { + return rows.filter((r) => + filters.every((f) => (f[0] === 'eq' ? r[f[1]] === f[2] : (f[2] as unknown[]).includes(r[f[1]]))), + ) +} + +/** + * In-memory stand-in for the `project_members` + `profiles` tables and the + * `invite_member` RPC, mirroring the exact PostgREST chains members.ts uses. + * Optional hooks let a test force an error (an RLS denial or the ≥1-PM trigger). + */ +function makeClient( + seed: { project_members?: Row[]; profiles?: Row[] } = {}, + hooks: { + writeError?: string + invite?: (args: Row) => { data: unknown; error: { message: string } | null } + } = {}, +) { + const tables: Record = { + project_members: seed.project_members ?? [], + profiles: seed.profiles ?? [], + } + + function terminal(result: () => { data?: unknown; error: unknown }) { + return { + then(onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) { + return Promise.resolve(result()).then(onF, onR) + }, + } + } + + const client = { + from(table: string) { + return { + select() { + const filters: Filter[] = [] + const b = { + eq(c: string, v: unknown) { + filters.push(['eq', c, v]) + return b + }, + in(c: string, v: unknown[]) { + filters.push(['in', c, v]) + return b + }, + ...terminal(() => ({ data: applyFilters(tables[table], filters), error: null })), + } + return b + }, + update(patch: Row) { + const filters: Filter[] = [] + const b = { + eq(c: string, v: unknown) { + filters.push(['eq', c, v]) + return b + }, + ...terminal(() => { + if (hooks.writeError) return { error: { message: hooks.writeError } } + for (const r of applyFilters(tables[table], filters)) Object.assign(r, patch) + return { error: null } + }), + } + return b + }, + delete() { + const filters: Filter[] = [] + const b = { + eq(c: string, v: unknown) { + filters.push(['eq', c, v]) + return b + }, + ...terminal(() => { + if (hooks.writeError) return { error: { message: hooks.writeError } } + const doomed = applyFilters(tables[table], filters) + tables[table] = tables[table].filter((r) => !doomed.includes(r)) + return { error: null } + }), + } + return b + }, + } + }, + rpc(name: string, args: Row) { + if (name !== 'invite_member') throw new Error(`unexpected rpc ${name}`) + if (hooks.invite) return Promise.resolve(hooks.invite(args)) + const email = String(args.p_email).trim().toLowerCase() + const profile = tables.profiles.find((p) => String(p.email).toLowerCase() === email) + if (!profile) return Promise.resolve({ data: null, error: null }) + const uid = profile.id as string + const existing = tables.project_members.find( + (m) => m.project_id === args.p_project_id && m.user_id === uid, + ) + if (existing) existing.role = args.p_role + else tables.project_members.push({ project_id: args.p_project_id, user_id: uid, role: args.p_role }) + return Promise.resolve({ data: uid, error: null }) + }, + } + return { client: client as unknown as SupabaseClient, tables } +} + +const PID = 'proj-1' + +describe('listMembers', () => { + it('joins the roster to profiles, marks self, and sorts managers first', async () => { + const { client } = makeClient({ + project_members: [ + { project_id: PID, user_id: 'u2', role: 'translator' }, + { project_id: PID, user_id: 'u1', role: 'project_manager' }, + ], + profiles: [ + { id: 'u1', display_name: 'Ana', email: 'ana@x.com' }, + { id: 'u2', display_name: 'Bo', email: 'bo@x.com' }, + ], + }) + const members = await listMembers(client, PID, 'u2') + expect(members.map((m) => [m.userId, m.role, m.isSelf])).toEqual([ + ['u1', 'project_manager', false], + ['u2', 'translator', true], + ]) + expect(members[0]).toMatchObject({ displayName: 'Ana', email: 'ana@x.com' }) + }) + + it('returns an empty roster without querying profiles', async () => { + const { client } = makeClient() + expect(await listMembers(client, PID, 'u1')).toEqual([]) + }) +}) + +describe('inviteMember', () => { + it('invites an existing account and upserts the membership', async () => { + const { client, tables } = makeClient({ + profiles: [{ id: 'u9', display_name: 'Cid', email: 'cid@x.com' }], + }) + const res = await inviteMember(client, PID, 'Cid@x.com ', 'revisor') + expect(res).toEqual({ status: 'invited', userId: 'u9' }) + expect(tables.project_members).toContainEqual({ project_id: PID, user_id: 'u9', role: 'revisor' }) + }) + + it('reports not_found when no account uses that email', async () => { + const { client } = makeClient({ profiles: [] }) + expect(await inviteMember(client, PID, 'ghost@x.com', 'translator')).toEqual({ status: 'not_found' }) + }) + + it('propagates a forbidden error from the RPC', async () => { + const { client } = makeClient( + {}, + { invite: () => ({ data: null, error: { message: 'only a project_manager may invite members' } }) }, + ) + await expect(inviteMember(client, PID, 'x@x.com', 'translator')).rejects.toThrow(/project_manager/) + }) +}) + +describe('changeMemberRole / removeMember', () => { + it('updates a member role in place', async () => { + const { client, tables } = makeClient({ + project_members: [{ project_id: PID, user_id: 'u1', role: 'translator' }], + }) + await changeMemberRole(client, PID, 'u1', 'revisor') + expect(tables.project_members[0].role).toBe('revisor') + }) + + it('removes a member', async () => { + const { client, tables } = makeClient({ + project_members: [ + { project_id: PID, user_id: 'u1', role: 'project_manager' }, + { project_id: PID, user_id: 'u2', role: 'translator' }, + ], + }) + await removeMember(client, PID, 'u2') + expect(tables.project_members.map((m) => m.user_id)).toEqual(['u1']) + }) + + it('surfaces the ≥1-manager trigger error as a thrown error', async () => { + const { client } = makeClient( + { project_members: [{ project_id: PID, user_id: 'u1', role: 'project_manager' }] }, + { writeError: 'a project must keep at least one project_manager' }, + ) + await expect(removeMember(client, PID, 'u1')).rejects.toThrow(/at least one project_manager/) + await expect(changeMemberRole(client, PID, 'u1', 'translator')).rejects.toThrow(/at least one/) + }) +})