Home Doh Ref
Dohballs
  • 📁 doh_chat
    • 📁 chat_extensions
  • 📁 doh_modules
    • 📦 dataforge
    • 📦 express
    • 📁 sso
    • 📁 user

Collaborative Documents

Real-time collaborative code editing with Monaco, Yjs CRDTs, and room-based permissions.

Overview

collab_docs provides persistent, multi-user document editing. Documents are attached to chat rooms or standalone, edited with the Monaco code editor in the browser, and synchronized in real-time using Yjs CRDTs over Socket.IO. The server maintains in-memory Yjs documents with periodic persistence to SQLite. A full permission system controls access at owner/editor/viewer/room-member levels.

Architecture

collab_docs.doh.yaml
├── collab_docs_server    (nodejs)
│   ├── Yjs document lifecycle (in-memory + periodic persistence)
│   ├── Socket.IO sync protocol
│   ├── Permission system (4 groups)
│   ├── REST API (CRUD + page routes)
│   └── Programmatic API (createDoc, readDocContent, etc.)
└── collab_docs_client    (browser)
    ├── CollabDocManager         (full-page doc list + editor)
    ├── CollabDocManagerToolbar  (compact toolbar for chat rooms)
    ├── CollabDocManagerEditor   (Monaco + Yjs binding)
    └── collab_shared            (transport provider)

Depends on: express_router, user_host, collab_shared (browser) Loaded by: chat.doh.yaml Depended on by: collab_richtext

File Structure

File Purpose
collab_docs.doh.yaml Package manifest
collab_docs_server.js Server: Yjs lifecycle, permissions, routes, socket events (~1061 lines)
collab_docs_client.js Client: Monaco editor, doc manager UI (~1830 lines)
collab_docs.css Editor and manager styles

Configuration

Constant Value Description
PERSIST_DEBOUNCE_MS 3000 Debounce interval for saving Yjs state to DB
GC_TIMEOUT_MS 300000 (5 min) Unload in-memory docs with no active clients

Database Schema

Table Type Purpose
collab_docs.documents Idea Document metadata
collab_docs.doc_state Idea Yjs CRDT state (base64 encoded)

Document metadata shape:

{
  id: string,              // UUID
  title: string,
  room_id: string | null,  // Chat room ID or null for standalone
  owner: string,           // Username
  editors: string[],       // Usernames with write access
  viewers: string[],       // Usernames with read-only access
  type: string,            // 'plaintext' | 'richtext'
  language: string,        // Monaco language ID ('markdown', 'javascript', etc.)
  created_at: number,
  updated_at: number
}

Document state shape:

{
  id: string,      // Document ID (matches documents table)
  state: string    // Base64-encoded Yjs state
}

Permissions

Contexts

Context Condition
collab_doc ctx?.documentId exists

Groups

Group Condition Permissions Description
collab_doc_owner 2-arg: user.username === ctx.owner read:collab_doc, write:collab_doc, delete:collab_doc, share:collab_doc Document creator/owner
collab_doc_editor 2-arg: ctx.editors?.includes(user.username) read:collab_doc, write:collab_doc Explicitly shared editors
collab_doc_viewer 2-arg: ctx.viewers?.includes(user.username) read:collab_doc Read-only viewers
collab_doc_room_member 2-arg: room membership check read:collab_doc, write:collab_doc All members of the document's room

Permission Flow

  1. Build context: { documentId, owner, editors, viewers, roomMembers }
  2. Check Doh.permit(user, action, context) against groups
  3. Cascade: owner → editor → room member → viewer

REST API Reference

Method Path Auth Description
POST /api/collab/docs Yes Create document
GET /api/collab/docs Yes List documents (optional room_id filter)
GET /api/collab/docs/:id Yes Get document by ID
PUT /api/collab/docs/:id Yes Update metadata
DELETE /api/collab/docs/:id Yes Delete document

POST /api/collab/docs

// Request
{ title: string, room_id?: string, editors?: string[], viewers?: string[], type?: string, language?: string }
// Response
{ doc: { id, title, room_id, owner, editors, viewers, type, language, created_at, updated_at } }

Page Routes

Method Path Auth Description
GET /docs Yes Document manager page
GET /collab/:id Yes Fullscreen editor page

Socket Events Reference

Document Operations

Event Direction Payload Response
collab:doc:open Client → Server { doc_id } cb({ doc_meta, readOnly, syncStep1, syncStep2, awarenessStates, clientCount })
collab:doc:create Client → Server { title, room_id?, language?, type?, content? } cb({ doc })
collab:doc:list Client → Server { room_id? } cb({ docs[] })
collab:doc:update_meta Client → Server { doc_id, updates: { title?, language?, editors?, viewers? } } cb({ doc })
collab:doc:delete Client → Server { doc_id } cb({ success })
collab:doc:close Client → Server { doc_id } --

collab:doc:open response detail:

{
  doc_meta: { id, title, room_id, owner, editors, viewers, type, language, created_at, updated_at },
  readOnly: boolean,          // User has read-only access
  syncStep1: number[],        // Yjs state vector (Uint8Array as array)
  syncStep2: number[],        // Full document state
  awarenessStates: number[],  // Remote cursor/presence states
  clientCount: number         // Active editors
}

Sync Protocol

Event Direction Payload Description
collab:doc:sync Bidirectional { doc_id, message: number[] } Yjs sync protocol messages
collab:doc:awareness Bidirectional { doc_id, message: number[] } Cursor/presence updates

Server Broadcasts

Event Payload Trigger
collab:doc:client_count { doc_id, count } Client joins/leaves document
collab:doc:meta_updated { doc } Metadata changed
collab:doc:deleted { doc_id } Document deleted

Lobby Events

Event Direction Payload
collab:docs:join_lobby Client → Server --
collab:docs:leave_lobby Client → Server --

For real-time document list updates on the /docs manager page.

IMPORTANT: Always use socket.emit('collab:doc:list', ...) for listing documents, not Doh.ajaxPromise(). The socket handler routes correctly to the list operation; ajaxPromise may trigger creation instead.

Programmatic API

Exported via Doh.Globals.CollabDocs for use by other modules (e.g., bot integrations):

Doh.Module('my_module', ['collab_docs_server'], function() {
  const docs = Doh.Globals.CollabDocs;

  // Create a document
  const doc = await docs.createDoc({
    title: 'Meeting Notes',
    content: '# Notes\n',
    room_id: 'room-123',
    owner: 'alice'
  });

  // Read/write content
  const content = docs.readDocContent(doc.id);
  const previous = docs.writeDocContent(doc.id, '# Updated\n');

  // Undo/redo
  docs.undoDocEdit(doc.id, 'system');
  docs.redoDocEdit(doc.id, 'system');

  // Query
  const roomDocs = docs.listRoomDocs('room-123');
  const single = docs.getDocById(doc.id);
  const canEdit = docs.canAccess(user, doc, 'write');
});

Exported Functions

Function Params Returns Description
getDocById(docId) string doc | null Fetch document metadata
canAccess(user, doc, action) user, doc, string boolean Permission check
createDoc(opts) { title, content?, room_id?, owner } doc Create document with optional content
readDocContent(docId) string string | null Extract text content from Yjs doc
writeDocContent(docId, content, origin?) string, string, any string Replace content, returns previous
undoDocEdit(docId, origin) string, any boolean Undo last edit
redoDocEdit(docId, origin) string, any boolean Redo last undo
listRoomDocs(roomId) string doc[] All docs attached to a room

Client Patterns

Pattern Parent Purpose
CollabDocManager html Full-page document list with create/delete/open
CollabDocManagerToolbar html Compact toolbar with doc tiles (used in chat room headers)
CollabDocManagerEditor html Monaco editor with Yjs binding, language selector, title editing
CollabDocEditor html Standalone editor wrapper
CollabDocToolbar html Editor toolbar with title, language, share, delete, fullscreen
CollabDocStatusBar html Sync state indicator, collaborator count, connection status
CollabDocList html Document listing with type icons and active editor count
CollabDocTile html Individual document tile in toolbar

Usage in Chat

When a user enters a room:

  1. chat_client.js calls docToolbar.loadDocs()
  2. Client emits collab:doc:list with room_id
  3. Server filters by room and permissions
  4. Client renders doc tiles in toolbar
  5. Clicking a tile opens the Monaco editor

Lobby Welcome Document

On server startup, a "Lobby Welcome" document is automatically created if it doesn't exist:

  • ID: lobby-welcome-doc
  • Room: lobby
  • Owner: system
  • Pre-populated with welcome markdown

All authenticated users can read and edit the lobby document.

Testing

  1. Open /docs — verify document list loads
  2. Create a new document — verify it appears in the list
  3. Open document in two browsers — verify real-time sync (typing appears in both)
  4. Verify cursor awareness (colored cursors from other users)
  5. Test permissions: share doc with viewer, verify read-only mode
  6. Open /collab/<doc-id> — verify fullscreen editor loads
  7. In chat room, verify doc toolbar shows room documents
Last updated: 2/21/2026