Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/revamp/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.*

Expand Down
5 changes: 3 additions & 2 deletions docs/revamp/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 `<Select>` + remove; a non-PM sees a read-only roster) opened from a new `ManageMembersButton` on the cloud project card (`CloudControls`), the dialog + members logic **lazy-loaded** so the projects route stays lean. Deviations (noted): built on the designated session branch, not `claude/revamp-phase-5-1`; the RLS/trigger/RPC enforcement is server-side, proven by the unit suite's injected-client mirror + the `docs/cloud.md` §11 manual matrix (vitest never hits real Supabase). Verified: flag-OFF `dist` has **zero** `GoTrueClient` (the members code is a lazy `members-*.js` chunk never fetched when the cloud is off / the button renders null). `docs/cloud.md` (§3 migration list + §11 manual verification). Tests: `cloud.members` (listMembers join+self+sort, empty roster short-circuit; inviteMember invited/not_found/forbidden-propagates; changeMemberRole in place; removeMember; **≥1-manager trigger error surfaces as a throw**). Green: 661 unit (+8) / build (flag-off Supabase-free). No new e2e (ROADMAP §5.1 asks none; the PM/role/RLS DoD needs two real accounts, per the §11 manual matrix).
3 changes: 2 additions & 1 deletion src/features/projects/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Badge } from '@/components/ui/badge'
import type { StatusCounts } from '@/storage/repositories/segmentRepo'
import type { Project } from '@/core/types'
import { useProjectDialogsStore, type ProjectDialogKind } from './useProjectDialogsStore'
import { CloudBadge, PublishCloudButton } from './cloud/CloudControls'
import { CloudBadge, PublishCloudButton, ManageMembersButton } from './cloud/CloudControls'

export function formatRelative(iso: string): string {
const then = new Date(iso).getTime()
Expand Down Expand Up @@ -103,6 +103,7 @@ export function ProjectCard({ project, counts }: { project: Project; counts?: St

<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5">
<PublishCloudButton project={project} />
<ManageMembersButton project={project} />
<ActionButton label="Edit project" onClick={() => act('edit')}>
<Pencil size={16} />
</ActionButton>
Expand Down
Loading
Loading