Projects were single-owner by design. Collaboration adds two layers on top, independently:
owner / admin / editor / viewer). Everyone always works the latest version.Both are additive: a project with no collaborators and MEMO_REALTIME_PROVIDER=off behaves exactly as the original single-user editor.
project_members — resolved collaborators. Graded roles now ship — owner / admin / editor / viewer (src/lib/auth/roles.ts); new invites default to editor. The DB column default stays "owner" for backward-compat with rows created before RBAC.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
project_id | uuid → projects.id ON DELETE CASCADE | |
user_id | uuid → users.id ON DELETE CASCADE | |
role | text default "owner" | 'owner' DB default (backward-compat). Graded RBAC ships: owner / admin / editor / viewer; new invites default to editor |
invited_by_user_id | uuid? → users.id ON DELETE SET NULL | |
created_at | timestamptz |
Unique on (project_id, user_id); indexed on user_id.
project_invites — pending invites for an email with no account yet.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
project_id | uuid → projects.id ON DELETE CASCADE | |
email | citext | Case-insensitive |
role | text default "owner" | |
token | text | Opaque; reserved for direct accept-links |
invited_by_user_id | uuid? → users.id ON DELETE SET NULL | |
created_at, accepted_at | timestamptz (accepted_at nullable) |
Unique on (project_id, email) and on (token).
projects.owner_idstays the creator (it still drives the URL namespace + cascade). Access is owner OR member — the creator is not duplicated as a member row.
requireProjectAccess#lib/api/ownership.ts exposes the membership-aware gate that replaced the single-owner check across ~30 routes + project pages:
// The project iff userId is the creator OR a project_members collaborator, else null.
requireProjectAccess(projectId, userId): Promise<Project | null>
It left-joins project_members (filtered to userId) and matches projects.owner_id = userId OR project_members.user_id IS NOT NULL. Two companions:
accessibleProjectIds(userId) — owned ∪ member project IDs; the dashboard listings (index, projects, activity, people, per-person timeline) scope to this so shared projects appear under "shared with me."listProjectMembers(projectId) — members joined to users, for the management UI.No URL or assertOwner change was needed: every user opens a project under their own namespace — /u/<you>/projects/<id> — assertOwner keeps [username] = you, and requireProjectAccess grants entry if you own or collaborate on it. So A opens it at /u/A/projects/X, B opens the same project at /u/B/projects/X.
POST /api/owner/projects/[id]/members { email } (any collaborator can invite):
project_members row immediately → instant full access; the project shows up in their dashboard.project_invites row. On first sign-up, acceptPendingInvites() in lib/auth/owner.ts (called from provisionUser) converts every pending invite for that email into a membership and stamps accepted_at. Best-effort — it never blocks sign-in.A best-effort notification email goes out via sendProjectInviteEmail (Resend).
Behind withOwner, gated by requireProjectAccess:
| Method + path | Purpose |
|---|---|
POST /api/owner/projects/[id]/members | Invite by email (→ member row or pending invite) |
DELETE /api/owner/projects/[id]/members | Remove a member ({ userId }) or revoke an invite ({ inviteId }) |
The creator (projects.owner_id) is not a member row and so can't be removed here.
A Collaborators tab (in ProjectTabs) → /u/[username]/projects/[projectId]/collaborators — invite form (declarative data-async), the people-with-access table (owner + members, each removable / "Leave"), and the pending-invites list (revocable).
Scope note: spaces/data-rooms stay owner-scoped (no membership), so a collaborator sees a shared project's link visits but not the owner's space-room visits. An
accessibleSpaceIdshelper would extend collaboration to spaces if ever wanted.
Built on Yjs (a CRDT), so the editor binding, presence, and persistence are identical regardless of how updates travel — only the transport differs. This mirrors the RecordingProvider pattern used for session recording.
lib/collab/ defines a thin CollabSession interface and a factory that lazy-imports the chosen adapter (so the realtime SDKs only enter the bundle when collaboration is on):
createCollabSession(config): Promise<CollabSession | null>
// CollabSession = { doc: Y.Doc; awareness; whenSynced; destroy() }
| Adapter | File | Backed by |
|---|---|---|
| Liveblocks (hosted) | lib/collab/liveblocks.ts | getYjsProviderForRoom (@liveblocks/yjs) |
| Durable Objects (self-hosted) | lib/collab/durable.ts | y-partyserver YProvider → the realtime Worker |
The useCollab hook (components/editor/useCollab.ts) opens one session per project and binds the active file's Y.Text (file:<path>) to the Monaco model via y-monaco's MonacoBinding — which also renders remote cursors from awareness. It's wired into EditorShell additively + gated on the realtime prop: with provider:"off" the editor is byte-for-byte its single-user self; presence avatars appear in the toolbar when peers join.
Persistence is unchanged. Edits (local or remote) mutate the Monaco model → the editor's existing onChange → buffer → debounced save → publish pipeline. Yjs is the live concurrency authority between clients; the server save is the durable snapshot. Durable file storage stays in R2 — the realtime layer only brokers live sync.
| Endpoint | Transport | What it does |
|---|---|---|
POST /api/owner/realtime/liveblocks-auth | Liveblocks | Mints a token scoped to project:<id> after requireProjectAccess; the Liveblocks secret never leaves the server |
GET /api/owner/realtime/durable-token | Durable Objects | Mints a short HS256 JWT (signed with MEMO_REALTIME_SECRET) the browser hands the Worker; gated by requireProjectAccess |
apps/memo/workers/realtime/ is a standalone Cloudflare Worker (not in the app build or pnpm workspace), deployed via its own wrangler. A y-partyserver YServer Durable Object hosts the Yjs doc per project:<id> room over WebSocket Hibernation and persists it to DO storage. SQLite-backed DOs + Hibernation run on the free Workers plan — no new R2, no paid plan. The Worker verifies the durable-token JWT (same MEMO_REALTIME_SECRET) and that its projectId matches the room before accepting the socket.
| Env var | Used when | Notes |
|---|---|---|
MEMO_REALTIME_PROVIDER | always | off (default) · liveblocks · durable |
LIVEBLOCKS_SECRET_KEY | liveblocks | Server-only secret from liveblocks.io |
MEMO_REALTIME_DURABLE_HOST | durable | The deployed Worker host (memo-realtime.*.workers.dev or a custom domain) |
MEMO_REALTIME_SECRET | durable | HS256 secret shared between the app and the Worker (wrangler secret put) — must match |
Flipping transports is a config change, not a rewrite — the editor code is identical. Deploy + ops steps live in apps/memo/workers/realtime/README.md.