A real-time collaborative whiteboard (Miro / FigJam-class) — freehand drawing, shapes, sticky notes, text, live cursors, and presence, synced conflict-free across everyone on a board.
Status: foundation build (v0.1). This session establishes the architecture and boilerplate. The security session hardens it — every seam is already wired (see Security hand-off).
A pnpm + Turborepo monorepo. The Next.js app and the WebSocket server deploy independently but share one contract layer (types, Zod schemas, RBAC).
whiteboard/
├─ apps/
│ ├─ web/ Next.js 15 (App Router) — UI, Auth.js, REST route handlers
│ └─ realtime/ Standalone Yjs WebSocket server (Node) — deploys to Railway/Render/Docker
├─ packages/
│ ├─ shared/ Zod schemas, RBAC, error taxonomy, constants (used by BOTH apps)
│ ├─ db/ Prisma schema + client singleton
│ ├─ auth/ Auth.js v5 config (edge/node split) + realtime handshake tokens
│ └─ config/ Shared tsconfig / eslint / tailwind presets
├─ docker-compose.yml Postgres + Redis + MinIO (local dev)
└─ turbo.json
| Layer | Choice |
|---|---|
| Frontend | Next.js 15 + TypeScript + Tailwind + shadcn/ui |
| Canvas | Fabric.js (v6) |
| Sync | Yjs (CRDT) over WebSockets, awareness for presence |
| Auth | Auth.js v5 (OAuth now; credentials/passkeys/MFA next session) |
| Database | PostgreSQL + Prisma |
| Cache/PubSub | Redis |
| File storage | S3-compatible (Cloudflare R2 in prod, MinIO locally) |
- CRDT (Yjs) gives conflict-free concurrent editing and offline tolerance — the correct model for a multi-cursor whiteboard.
- Monorepo with a shared
packages/sharedmeans the API and the realtime server validate and authorize against one source of truth, so security rules can't drift between them. - Auth.js edge/node split keeps the middleware edge-safe (no DB) while the Node runtime gets the Prisma adapter and credential provider.
Prerequisites: Node 20+, pnpm 9+, Docker.
# 1. Install (also generates the Prisma client)
pnpm install
# 2. Start infrastructure (Postgres + Redis + MinIO)
pnpm infra:up
# 3. Create the schema + seed a demo board
pnpm db:migrate
pnpm db:seed
# 4. Run everything (web on :3000, realtime on :3001)
pnpm devOpen http://localhost:3000 and click Open the demo board. Open it in two browser windows to watch edits and cursors sync in real time.
Two gitignored env files come pre-filled with dev-only placeholder secrets so the app runs immediately:
.env(repo root) — read bydocker-composeand the realtime server.apps/web/.env.local— read by Next.js (its native location).
Both derive from .env.example, which documents every variable. Before doing
anything real, replace AUTH_SECRET and REALTIME_JWT_SECRET (must match across
the two files) with openssl rand -base64 32. ALLOW_DEV_ANON_REALTIME=true
lets the demo board work before credential auth exists.
| Command | What it does |
|---|---|
pnpm dev |
Run web + realtime in watch mode (Turbo) |
pnpm build |
Build all packages and apps |
pnpm typecheck |
Type-check the whole workspace |
pnpm lint |
Lint the whole workspace |
pnpm db:studio |
Open Prisma Studio |
pnpm db:migrate |
Create/apply a dev migration |
pnpm infra:up/down |
Start/stop local Postgres + Redis + MinIO |
- The browser calls
POST /api/realtime/tokenwith aboardId. - The web server verifies the session, resolves the user's board role
(
packages/sharedRBAC), and mints a 60-second, board-scoped JWT. - The browser opens
ws://…/<boardId>?token=…. The realtime server verifies the token at the HTTP upgrade — unauthenticated sockets never reach Yjs. - The role determines
canWrite; viewers receive updates but cannot mutate. - Document state is persisted (debounced) to
BoardSnapshotfor durability and version history.
The foundation is built so security drops into pre-wired seams without refactors. Each item below has a placeholder that is safe-by-default (fails closed) and a marked TODO in the code.
| Area | Where the seam lives |
|---|---|
| Password hashing | packages/auth/src/password.ts — install Argon2id via setPasswordHasher() (currently fails closed) |
| Credential login | packages/auth/src/credentials.ts — lockout, MFA, email-verified checks |
| RBAC enforcement | packages/shared/src/rbac.ts can() — already called in API authz + realtime |
| API pipeline | apps/web/src/server/api/handler.ts — auth → validate → rate-limit → handler |
| Rate limiting | apps/web/src/server/security/rate-limit.ts (Redis, fail-open in dev) |
| Security headers/CSP | apps/web/src/server/security/headers.ts — tighten CSP to nonce-based |
| Input validation | packages/shared/src/schemas/* — Zod schemas per endpoint/event |
| Realtime hardening | apps/realtime/src/collaboration/yjs-ws.ts — payload cap, write-gating, heartbeat, rate limit |
| Audit logging | AuditLog model in packages/db/prisma/schema.prisma |
| Refresh tokens | RefreshToken model (rotation + reuse detection) |
| Dev backdoor | ALLOW_DEV_ANON_REALTIME + the anon branch in apps/web/src/app/api/realtime/token/route.ts — delete for production |
See OVERVIEW.md for the full security checklist that guides that session.
- web → Vercel (
apps/web). - realtime → Railway / Render / Docker VPS. A
Dockerfileis provided atapps/realtime/Dockerfile. - Provide all secrets via the platform's secret manager (never
.envfiles in prod). Use managed Postgres/Redis with TLS.
- Prisma client is generated to a custom output (
packages/db/src/generated) and imported by concrete path. Under pnpm +moduleResolution: bundlerthis is the only layout that yields the full generated types (the default@prisma/clientoutput collapses toanyvia a stubbed exports-map entry). next buildon local Windows can fail with anEPERM: scandiron a protected home-directory junction (Cookies,Application Data): Prisma's engine/platform detection globs the user profile during the production compile, and those legacy junctions deny directory listing. It does not affectpnpm dev(verified working) and does not occur on Linux — build and deploy via CI / Vercel / a Linux container (or WSL) as intended.