- Conflict-free realtime editing β every keystroke is reconciled through a Yjs CRDT bound to Monaco via
MonacoBinding, so concurrent edits never clobber each other. - Multiplayer cursors & presence β remote selections and named carets render through Yjs awareness; user colours are deterministically hashed from the user id, so the same person stays the same colour across sessions.
- Live file tree β a
LiveList<LiveObject>directory backs the file explorer; create, rename, delete, and drag-to-reorder all sync as Liveblocks storage mutations. - Shared workspace storage β file contents live in a per-room Yjs document; nothing is stored on disk by the app.
- Threaded comments β Liveblocks Comments UI with @-mention resolution against room members.
- Four-role permission model β
owner,admin,editor,viewerwith server-enforced authorization on every change. - Project dashboard β create, browse, and delete Labs; upload an optional cover image (JPEG/PNG/WebP/GIF, β€2 MB) to Supabase Storage.
- GitHub OAuth β Auth.js v5 with the Supabase adapter; protected routes (
/dashboard,/room) gated by anauthorizedcallback wired intoproxy.ts. - Rate-limited writes β Upstash sliding-window limits on room create (10/h), room delete (20/h), and permission changes (30/h) per user.
- Hardened defaults β strict CSP (report-only),
X-Frame-Options: DENY, locked-downPermissions-Policy, narrowednext/imageremote patterns, andpoweredByHeader: false. - Multi-language Monaco β extension β language mapping for 25+ languages including TS, Python, Go, Rust, Java, C/C++, SQL, Dockerfile.
| Layer | Technology |
|---|---|
| Framework | Next.js 16.2.6 (App Router) β uses the new proxy.ts (formerly middleware) |
| Runtime | React 19.2.4 + React DOM 19.2.4 |
| Language | TypeScript 5 (strict) |
| Realtime transport | @liveblocks/client, @liveblocks/react, @liveblocks/react-ui, @liveblocks/node ^3.19.0 |
| CRDT engine | yjs, y-monaco, y-protocols, @liveblocks/yjs |
| Code editor | @monaco-editor/react |
| Auth | next-auth v5 beta + @auth/supabase-adapter (GitHub provider) |
| Database & storage | @supabase/supabase-js (Postgres next_auth schema + room-images bucket) |
| Rate limiting | @upstash/ratelimit + @upstash/redis (sliding window) |
| Styling | Tailwind CSS 4 + tw-animate-css + next-themes (default dark) |
| UI primitives | shadcn/ui, radix-ui, @base-ui/react, lucide-react, sonner |
| File tree | react-arborist |
| Layout | react-resizable-panels |
| Forms | react-hook-form + zod + @hookform/resolvers |
| Animation | gsap + @gsap/react (full plugin registration) |
| Lint | ESLint 9 + eslint-config-next |
flowchart TB
subgraph Browser["π₯οΈ Browser"]
direction TB
Monaco["Monaco Editor"]
YDoc["Yjs Doc<br/>(+ awareness)"]
UI["React UI<br/>/dashboard Β· /room"]
Monaco <-->|"MonacoBinding"| YDoc
end
subgraph Edge["β² Vercel Edge"]
Proxy["proxy.ts<br/>Auth.js gate<br/>(/dashboard, /room/*)"]
end
subgraph Server["βοΈ Next.js Route Handlers"]
direction TB
LBAuth["POST /api/liveblocks-auth"]
Rooms["GET Β· POST Β· DELETE<br/>/api/rooms"]
Perms["GET Β· POST<br/>/api/rooms/:id/permissions"]
Health["GET /api/health"]
end
subgraph LB["βοΈ Liveblocks Cloud"]
LBRooms["Rooms Β· Storage Β· Presence Β· Yjs Β· Comments"]
end
subgraph Supabase["π’ Supabase"]
SBAuth[("next_auth schema<br/>users Β· accounts")]
SBStorage[("Storage<br/>room-images bucket")]
end
subgraph Upstash["π΄ Upstash Redis"]
RL["Ratelimit<br/>rl:room:create (10/h)<br/>rl:room:delete (20/h)<br/>rl:room:permission (30/h)"]
end
YDoc <-.->|"WSS Β· storage + awareness"| LBRooms
UI -->|"fetch (JSON Β· FormData)"| Proxy
Proxy -->|"session ok"| Server
UI -->|"session cookie"| Proxy
LBAuth -->|"identifyUser()"| LBRooms
Rooms -->|"createRoom Β· getRooms Β· deleteRoom"| LBRooms
Perms -->|"getRoom Β· updateRoom"| LBRooms
LBAuth --> SBAuth
Rooms --> SBStorage
Perms --> SBAuth
Rooms --> RL
Perms --> RL
classDef browser fill:#1e293b,stroke:#38bdf8,color:#e2e8f0
classDef edge fill:#0f172a,stroke:#a855f7,color:#e2e8f0
classDef server fill:#0f172a,stroke:#22c55e,color:#e2e8f0
classDef lb fill:#1c0f0a,stroke:#f97316,color:#fed7aa
classDef sb fill:#0a1f15,stroke:#3ecf8e,color:#bbf7d0
classDef up fill:#1f0a0a,stroke:#ef4444,color:#fecaca
class Monaco,YDoc,UI browser
class Proxy edge
class LBAuth,Rooms,Perms,Health server
class LBRooms lb
class SBAuth,SBStorage sb
class RL up
Data ownership. Liveblocks is the only place collaborative state lives. Supabase holds users (managed by the Auth.js adapter, schema next_auth) and room cover images. Upstash holds nothing but rate-limit counters.
Auth flow. proxy.ts wraps lib/auth/index.ts's auth export and re-exports it as proxy. The matcher excludes api/auth, static assets, and image folders; everything else runs through the authorized callback, which gates /dashboard and /room behind a session.
Liveblocks identity. POST /api/liveblocks-auth calls liveblocks.identifyUser(...) with the user's email as userId, plus name, avatar, and a deterministic colour hashed from the email.
The model is implemented in lib/liveblocks/permissions.ts and enforced by the route handler at app/api/rooms/[roomId]/permissions/route.ts.
Roles are derived from two pieces of Liveblocks state per room:
room.metadata.owner(single email)room.metadata.admins(string list of emails)room.usersAccesses[email](Liveblocks access scopes)
| Role | Liveblocks scopes | Derivation rule |
|---|---|---|
| Owner | ["room:write"] |
email === metadata.owner β set at room creation, never reassigned |
| Admin | ["room:write"] |
listed in metadata.admins |
| Editor | ["room:write"] |
has room:write and is neither owner nor admin |
| Viewer | ["room:read", "room:presence:write"] |
does not have room:write |
| Caller | Action | Result |
|---|---|---|
| Not owner and not admin | any change | 403 Forbidden |
| Anyone | change the owner's role | 400 "Owner role cannot be changed" |
| Admin (not owner) | grant admin |
403 "Admins cannot grant admin role" |
| Owner | any other change | allowed |
| Admin | promote/demote between editor/viewer, add new users |
allowed |
Editor enforcement is mirrored on the client: currentUserPermission === "viewer" flips Monaco into readOnly + domReadOnly, and disables file-tree create/rename/delete buttons.
Room deletion (DELETE /api/rooms) is owner-only and best-effort cleans up the cover image from Supabase Storage.
- Node.js 18+ and npm
- A Liveblocks project (for the secret key)
- A Supabase project (Postgres + Storage)
- An Upstash Redis database (REST URL + token)
- A GitHub OAuth App
git clone https://github.com/your-username/synclabs.git
cd synclabs
npm installCreate .env.local in the project root:
# Supabase (server-only β service role key)
SUPABASE_URL=https://YOUR_PROJECT.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
# Liveblocks (server-only)
LIVEBLOCKS_SECRET_KEY=sk_...
# Upstash Redis (server-only)
UPSTASH_REDIS_REST_URL=https://YOUR.upstash.io
UPSTASH_REDIS_REST_TOKEN=...
# Auth.js v5 (auto-detected by next-auth)
AUTH_SECRET= # `openssl rand -base64 32`
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=Only the first five are referenced explicitly via
process.env. TheAUTH_*keys are read by Auth.js v5 by convention.
The Auth.js Supabase adapter writes to the next_auth schema. Install it once via the Supabase SQL editor following the adapter docs. The route handlers query next_auth.users directly to enrich room member lists.
Create a public Storage bucket named room-images (Storage β New bucket β public). The app uploads a UUID-named file per project image and stores the resulting public URL in room.metadata.image.
- GitHub β Settings β Developer settings β OAuth Apps β New OAuth App
- Authorization callback URL:
http://localhost:3000/api/auth/callback/github - Paste the Client ID / Secret into
AUTH_GITHUB_ID/AUTH_GITHUB_SECRET.
npm run dev # http://localhost:3000
npm run build # production build
npm run start # serve the production build
npm run lint # eslint
npm run typecheck # tsc --noEmitsynclabs/
βββ app/
β βββ (shared)/
β β βββ layout.tsx # Navbar wrapper for authed pages
β β βββ dashboard/page.tsx # Project grid, create/delete
β β βββ room/[roomId]/page.tsx # Liveblocks Room + IDE host
β βββ api/
β β βββ auth/[...nextauth]/route.ts # Auth.js GET/POST handlers
β β βββ health/route.ts # { ok: true, ts } liveness
β β βββ liveblocks-auth/route.ts # liveblocks.identifyUser()
β β βββ rooms/
β β βββ route.ts # GET list / POST create / DELETE
β β βββ [roomId]/permissions/route.ts# GET roster / POST upsert role
β βββ globals.css
β βββ layout.tsx # Root: ThemeProvider, GSAP, Toaster
β βββ page.tsx # Marketing landing
β
βββ components/
β βββ custom/
β β βββ auth-buttons/ # GitHub sign-in / sign-out
β β βββ comments/ # @liveblocks/react-ui Threads
β β βββ create-project-dialog/ # RHF + Zod, multipart upload
β β βββ cursors/ # Yjs awareness β CSS injection
β β βββ dashboard-link/
β β βββ editor/ # Monaco + MonacoBinding(yText)
β β βββ file-explorer/ # react-arborist over LiveList
β β βββ file-history/ # Open-file tab bar
β β βββ icons/
β β βββ ide/ # ide.tsx, ide-topbar, ide-sidebar, ide-workspace
β β βββ navbar/
β β βββ permissions-tab/ # Share dialog (RHF + Zod)
β β βββ project-card/ # Card + skeleton + delete confirm
β β βββ providers/ # gsap, liveblocks, theme
β β βββ room/ # <RoomProvider> with initial storage
β β βββ user-permission-display/ # Role pill row
β βββ reactbits/dot-grid/ # Animated background
β βββ ui/ # shadcn/ui primitives
β
βββ hooks/
β βββ use-dimensions.ts # ResizeObserver hook
β βββ use-file-tabs.ts # Tab history (max 7)
β βββ use-permissions.ts # Roster CRUD + sonner toasts
β βββ use-room-permission.ts # Caller's role for the room
β
βββ lib/
β βββ auth/
β β βββ index.ts # NextAuth(GitHub + SupabaseAdapter)
β β βββ actions.ts # "use server" login/logout
β βββ ide/
β β βββ constants.ts # extension β Monaco language
β β βββ icon-mappings.ts
β βββ liveblocks/
β β βββ index.ts # new Liveblocks({secret})
β β βββ actions.ts # resolveUsers, resolveMentions
β β βββ permissions.ts # role math + authorisation
β β βββ utils.ts # LiveList tree traversal
β βββ supabase/
β β βββ index.ts # createClient(URL, SERVICE_ROLE)
β β βββ room-image.ts # validate + upload + delete
β βββ upstash/
β β βββ index.ts # Redis singleton
β β βββ rate-limit.tsx # 3 sliding-window limiters
β βββ types.ts # All shared TS types
β βββ utils.ts # cn, getInitials, colorFromId, getExtension
β
βββ liveblocks.config.ts # global Liveblocks type augmentation
βββ proxy.ts # Next 16 proxy (replaces middleware)
βββ next.config.ts # CSP + image hosts + headers
βββ eslint.config.mjs
βββ tsconfig.json
βββ components.json # shadcn/ui registry
Every variable referenced via process.env in the codebase:
| Variable | Required | Used in | Purpose |
|---|---|---|---|
SUPABASE_URL |
yes | lib/supabase/index.ts, lib/auth/index.ts | Supabase project URL |
SUPABASE_SERVICE_ROLE_KEY |
yes | lib/supabase/index.ts, lib/auth/index.ts | Service role key β server-only |
LIVEBLOCKS_SECRET_KEY |
yes | lib/liveblocks/index.ts | Liveblocks server SDK secret |
UPSTASH_REDIS_REST_URL |
yes | lib/upstash/index.ts | Upstash Redis REST endpoint |
UPSTASH_REDIS_REST_TOKEN |
yes | lib/upstash/index.ts | Upstash Redis REST token |
AUTH_SECRET |
yes | Auth.js (auto) | Session/JWT signing secret |
AUTH_GITHUB_ID |
yes | Auth.js (auto) | GitHub OAuth client id |
AUTH_GITHUB_SECRET |
yes | Auth.js (auto) | GitHub OAuth client secret |
There is no test suite in this repository (no __tests__/, no *.test.*, no test runner in package.json). The currently shipping verification surface is:
npm run lint # ESLint 9 with eslint-config-next
npm run typecheck # tsc --noEmit (strict)The only runtime smoke endpoint is GET /api/health, which returns { ok: true, ts }.
Deployed on Vercel at https://synclabs-seven.vercel.app/.
Steps:
- Import the repo in the Vercel dashboard.
- Add every variable from the Environment Variables table to the project's environment settings.
- Update the GitHub OAuth App's callback URL to
https://<your-domain>/api/auth/callback/github. - Set Supabase Storage
room-imagesbucket to public, and ensure thenext_authschema is provisioned. - Deploy. Liveblocks WSS endpoints are pre-allowlisted in the
connect-srcdirective of next.config.ts.