Yuki Takei 2 هفته پیش
والد
کامیت
cdca265535

+ 468 - 0
.kiro/specs/migrate-to-y-websocket/design.md

@@ -0,0 +1,468 @@
+# Design Document: migrate-to-y-websocket
+
+## Overview
+
+**Purpose**: This feature replaces the `y-socket.io` Yjs transport layer with `y-websocket` to eliminate a critical race condition in document initialization that causes collaborative editing clients to permanently desynchronize.
+
+**Users**: All GROWI users who use real-time collaborative page editing. System operators benefit from switching to an actively maintained library.
+
+**Impact**: Replaces the internal transport layer for Yjs document synchronization. External behavior (editor UI, awareness indicators, draft detection, save flow) remains unchanged. Socket.IO continues to serve non-Yjs real-time events.
+
+### Goals
+- Eliminate the `initDocument` race condition that causes client desynchronization
+- Maintain all existing collaborative editing functionality (sync, awareness, persistence, draft detection)
+- Use `y-websocket@2.x` which is compatible with the current `yjs@^13` stack
+- Coexist with the existing Socket.IO infrastructure without disruption
+
+### Non-Goals
+- Upgrading to yjs v14 (separate future effort)
+- Changing the Yjs document model, CodeMirror integration, or page save/revision logic
+- Migrating Socket.IO-based UI events (page room broadcasts) to WebSocket
+- Changing the `yjs-writings` MongoDB collection schema or data format
+
+## Architecture
+
+### Existing Architecture Analysis
+
+The current system uses two transport layers on the same HTTP server:
+
+1. **Socket.IO** (`/socket.io/` path): Handles general app events (page join/leave, notifications) and Yjs document sync via y-socket.io's dynamic namespaces (`/yjs|{pageId}`)
+2. **Express HTTP** (all other paths): REST API, SSR pages
+
+y-socket.io creates Socket.IO namespaces dynamically for each page's Yjs document. Authentication piggybacks on Socket.IO's middleware chain (express-session + passport). The `YjsService` singleton wraps `YSocketIO` and integrates persistence, access control, and awareness event bridging.
+
+**Key constraint**: Socket.IO rooms (`page:{pageId}`) are used by non-editor UI components to receive awareness state size updates and draft status notifications. This Socket.IO room broadcast mechanism must be preserved.
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph Client
+        CM[CodeMirror Editor]
+        WP[WebsocketProvider]
+        GS[Global Socket.IO Client]
+    end
+
+    subgraph Server
+        subgraph HTTP Server
+            Express[Express App]
+            SIO[Socket.IO Server]
+            WSS[WebSocket Server - ws]
+        end
+
+        subgraph YjsService
+            UpgradeHandler[Upgrade Handler - Auth]
+            ConnHandler[Connection Handler]
+            DocManager[Document Manager - getYDoc]
+            AwarenessBridge[Awareness Bridge]
+        end
+
+        MDB[(MongoDB - yjs-writings)]
+        SessionStore[(Session Store)]
+    end
+
+    CM --> WP
+    WP -->|ws path yjs pageId| WSS
+    GS -->|socket.io| SIO
+
+    WSS -->|upgrade auth| UpgradeHandler
+    UpgradeHandler -->|parse cookie| SessionStore
+    WSS -->|connection| ConnHandler
+    ConnHandler --> DocManager
+    DocManager --> MDB
+
+    AwarenessBridge -->|io.in room .emit| SIO
+
+    DocManager -->|awareness events| AwarenessBridge
+```
+
+**Architecture Integration**:
+- **Selected pattern**: Replace Socket.IO-based Yjs transport with native WebSocket, keeping Socket.IO for non-Yjs events
+- **Domain boundaries**: YjsService encapsulates all Yjs document management; Socket.IO only receives bridged awareness events
+- **Existing patterns preserved**: Singleton YjsService, MongoDB persistence via y-mongodb-provider, session-based authentication
+- **New components rationale**: WebSocket upgrade handler needed because raw ws does not have Socket.IO's middleware chain
+- **Steering compliance**: Server-client boundary enforced; `ws` already in dependencies
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Client Provider | `y-websocket@^2.0.4` (WebsocketProvider) | Yjs document sync over WebSocket | Replaces `y-socket.io` SocketIOProvider; yjs v13 compatible |
+| Server WebSocket | `ws@^8.17.1` (WebSocket.Server) | Native WebSocket server for Yjs | Already installed; `noServer: true` mode for HTTP upgrade sharing |
+| Server Yjs Utils | `y-websocket@^2.0.4` (`bin/utils`) | `setupWSConnection`, `getYDoc`, `WSSharedDoc` | Bundled server utilities; atomic document management |
+| Persistence | `y-mongodb-provider` (existing) | Yjs document persistence to MongoDB | No changes; same `yjs-writings` collection |
+| Event Bridge | Socket.IO `io` instance (existing) | Awareness state broadcasting to page rooms | Bridged from y-websocket awareness events |
+| Auth | express-session + passport (existing) | WebSocket upgrade authentication | Cookie-based session parsing on upgrade request |
+
+## System Flows
+
+### Client Connection Flow
+
+```mermaid
+sequenceDiagram
+    participant C as Client Browser
+    participant WSS as WebSocket Server
+    participant UH as Upgrade Handler
+    participant SS as Session Store
+    participant DM as Document Manager
+    participant MDB as MongoDB
+
+    C->>WSS: HTTP Upgrade GET /yjs/pageId
+    WSS->>UH: upgrade event
+    UH->>SS: Parse cookie, load session
+    SS-->>UH: Session with user
+    UH->>UH: Check page access
+    alt Unauthorized
+        UH-->>C: 401/403, destroy socket
+    else Authorized
+        UH->>WSS: handleUpgrade
+        WSS->>DM: setupWSConnection
+        DM->>DM: getYDoc - atomic get or create
+        alt New document
+            DM->>MDB: bindState - load persisted state
+            MDB-->>DM: Y.Doc state
+        end
+        DM-->>C: Sync Step 1 - state vector
+        C-->>DM: Sync Step 2 - diff
+        DM-->>C: Awareness states
+    end
+```
+
+Key decisions: Authentication happens before `handleUpgrade`, so unauthorized connections never reach the Yjs layer. Document creation uses `getYDoc`'s atomic `map.setIfUndefined` pattern — no race condition window.
+
+### Document Lifecycle
+
+```mermaid
+stateDiagram-v2
+    [*] --> Created: First client connects
+    Created --> Active: bindState completes
+    Active --> Active: Clients connect/disconnect
+    Active --> Flushing: Last client disconnects
+    Flushing --> [*]: writeState completes, doc destroyed
+    Flushing --> Active: New client connects before destroy
+```
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1, 1.2 | Single Y.Doc per page | DocumentManager | getYDoc | Connection Flow |
+| 1.3, 1.4, 1.5 | Sync integrity on reconnect | DocumentManager, WebsocketProvider | setupWSConnection | Connection Flow |
+| 2.1 | y-websocket server transport | YjsService, DocumentManager | setupWSConnection, setPersistence | Connection Flow |
+| 2.2 | y-websocket client provider | WebsocketProvider (use-collaborative-editor-mode) | WebsocketProvider constructor | Connection Flow |
+| 2.3 | Coexist with Socket.IO | UpgradeRouter | server.on upgrade | Connection Flow |
+| 2.4 | resyncInterval | WebsocketProvider | resyncInterval option | — |
+| 3.1, 3.2, 3.3 | Auth on upgrade | UpgradeHandler | authenticateUpgrade, checkPageAccess | Connection Flow |
+| 3.4 | Guest access | UpgradeHandler | checkPageAccess | Connection Flow |
+| 4.1, 4.2 | MongoDB persistence | PersistenceAdapter | bindState, writeState | Document Lifecycle |
+| 4.3 | Load before sync | PersistenceAdapter | bindState | Connection Flow |
+| 4.4 | Persist updates | PersistenceAdapter | doc.on update | — |
+| 4.5 | Flush on disconnect | PersistenceAdapter | writeState | Document Lifecycle |
+| 5.1 | Client awareness broadcast | WebsocketProvider | awareness.setLocalStateField | — |
+| 5.2, 5.3 | Awareness bridge to Socket.IO | AwarenessBridge | awareness.on update, io.in.emit | — |
+| 5.4 | Display editor list | use-collaborative-editor-mode | awareness.on update | — |
+| 6.1, 6.2 | YDoc status API | YjsService | getYDocStatus, getCurrentYdoc | — |
+| 6.3 | Sync on document load | YjsService | contentInitializor / bindState | Connection Flow |
+| 6.4 | Force sync API | YjsService | syncWithTheLatestRevisionForce | — |
+| 7.1, 7.2 | Dev environment | ViteDevConfig | — | — |
+| 8.1, 8.2, 8.3 | Dependency cleanup | package.json changes | — | — |
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|-------------|--------|-------------|-----------------|-----------|
+| YjsService | Server / Service | Orchestrates Yjs document lifecycle, exposes public API | 1.1-1.5, 2.1, 6.1-6.4 | ws (P0), y-websocket/bin/utils (P0), MongodbPersistence (P0) | Service |
+| UpgradeHandler | Server / Auth | Authenticates and authorizes WebSocket upgrade requests | 3.1-3.4, 2.3 | express-session (P0), passport (P0), Page model (P0) | Service |
+| PersistenceAdapter | Server / Data | Bridges MongodbPersistence to y-websocket persistence interface; handles sync-on-load and awareness registration | 4.1-4.5, 6.3, 5.2, 5.3 | MongodbPersistence (P0), syncYDoc (P0), Socket.IO io (P1) | Service, Event |
+| AwarenessBridge | Server / Events | Bridges y-websocket awareness events to Socket.IO rooms | 5.2, 5.3 | Socket.IO io (P0), docs Map (P1) | Event |
+| use-collaborative-editor-mode | Client / Hook | Manages WebsocketProvider lifecycle and awareness | 2.2, 2.4, 5.1, 5.4 | y-websocket (P0), yjs (P0) | State |
+| ViteDevConfig | Dev / Config | Configures dev server WebSocket proxy/setup | 7.1, 7.2 | — | — |
+
+### Server / Service Layer
+
+#### YjsService
+
+| Field | Detail |
+|-------|--------|
+| Intent | Manages Yjs document lifecycle, WebSocket server setup, and public API for page save/status integration |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 6.1, 6.2, 6.3, 6.4 |
+
+**Responsibilities & Constraints**
+- Owns the `ws.WebSocketServer` instance and the y-websocket `docs` Map
+- Initializes persistence and content initialization via y-websocket's `setPersistence` and `setContentInitializor`
+- Registers the HTTP `upgrade` handler (delegating auth to UpgradeHandler)
+- Exposes the same public interface as the current `IYjsService` for downstream consumers
+- Must attach to the existing `httpServer` without interfering with Socket.IO's upgrade handling
+
+**Dependencies**
+- Inbound: crowi/index.ts — initialization (P0)
+- Inbound: PageService, API routes — getYDocStatus, syncWithTheLatestRevisionForce (P0)
+- Outbound: UpgradeHandler — authentication (P0)
+- Outbound: PersistenceAdapter — document persistence (P0)
+- Outbound: AwarenessBridge — awareness event fan-out (P1)
+- External: y-websocket `bin/utils` — getYDoc, setupWSConnection, docs, WSSharedDoc (P0)
+- External: ws — WebSocket.Server (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+interface IYjsService {
+  getYDocStatus(pageId: string): Promise<YDocStatus>;
+  syncWithTheLatestRevisionForce(
+    pageId: string,
+    editingMarkdownLength?: number,
+  ): Promise<SyncLatestRevisionBody>;
+  getCurrentYdoc(pageId: string): Y.Doc | undefined;
+}
+```
+
+- Preconditions: Service initialized with httpServer and io instances
+- Postconditions: Public API behavior identical to current implementation
+- Invariants: At most one Y.Doc per pageId in the docs Map at any time
+
+**Implementation Notes**
+- Constructor changes: Accept `httpServer: http.Server` and `io: Server` instead of just `io: Server`
+- Replace `new YSocketIO(io)` with `new WebSocket.Server({ noServer: true })` + y-websocket utils setup
+- Replace `ysocketio.documents.get(pageId)` with `docs.get(pageId)` from y-websocket utils
+- Replace `ysocketio['persistence'] = ...` with `setPersistence(...)` public API
+- Do NOT use `setContentInitializor` — instead, place sync-on-load logic (`syncYDoc`) inside `bindState` after persisted state is applied, to guarantee correct ordering (persistence load → YDocStatus check → syncYDoc)
+- Use `httpServer.on('upgrade', ...)` with path check for `/yjs/`
+- Socket.IO's internal upgrade handling for `/socket.io/` is not affected because Socket.IO only intercepts its own path
+
+#### UpgradeHandler
+
+| Field | Detail |
+|-------|--------|
+| Intent | Authenticates WebSocket upgrade requests using session cookies and verifies page access |
+| Requirements | 3.1, 3.2, 3.3, 3.4 |
+
+**Responsibilities & Constraints**
+- Parses session cookie from the HTTP upgrade request
+- Loads session from the session store (Redis or MongoDB)
+- Deserializes the user via passport
+- Checks page access using `Page.isAccessiblePageByViewer`
+- Extracts `pageId` from the URL path (`/yjs/{pageId}`)
+- Rejects unauthorized requests before `wss.handleUpgrade`
+
+**Dependencies**
+- Inbound: YjsService — called on upgrade event (P0)
+- Outbound: Session Store (Redis/MongoDB) — session lookup (P0)
+- Outbound: Page model — access check (P0)
+- External: cookie (npm) — cookie parsing (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+type AuthenticatedRequest = IncomingMessage & {
+  user: IUserHasId | null;
+};
+
+type UpgradeResult =
+  | { authorized: true; request: AuthenticatedRequest; pageId: string }
+  | { authorized: false; statusCode: number };
+
+interface IUpgradeHandler {
+  handleUpgrade(
+    request: IncomingMessage,
+    socket: Duplex,
+    head: Buffer,
+  ): Promise<UpgradeResult>;
+}
+```
+
+- Preconditions: Request has valid URL matching `/yjs/{pageId}`
+- Postconditions: Returns authorized result with deserialized user and pageId, or rejection with HTTP status
+- Invariants: Never calls `wss.handleUpgrade` for unauthorized requests
+
+**Implementation Notes**
+- Use `cookie` package to parse `request.headers.cookie`
+- Use the session store's `get(sessionId, callback)` to load session data
+- Attach `user` to `request` object for downstream use in `setupWSConnection`
+- Guest access: if `user` is null but page allows guest access, proceed with authorization
+
+#### PersistenceAdapter
+
+| Field | Detail |
+|-------|--------|
+| Intent | Adapts the existing MongodbPersistence to y-websocket's persistence interface |
+| Requirements | 4.1, 4.2, 4.3, 4.4, 4.5 |
+
+**Responsibilities & Constraints**
+- Implements the y-websocket persistence interface (`bindState`, `writeState`)
+- Loads persisted Y.Doc state from MongoDB on document creation
+- After applying persisted state, determines YDocStatus and calls `syncYDoc` to synchronize with the latest revision — this guarantees correct ordering (persistence first, then sync)
+- Persists incremental updates on every document change
+- Registers awareness event listeners for the AwarenessBridge on document creation
+- Flushes document state on last-client disconnect
+- Maintains the `updatedAt` metadata for draft detection
+
+**Dependencies**
+- Inbound: y-websocket utils — called on document lifecycle events (P0)
+- Outbound: MongodbPersistence (extended y-mongodb-provider) — data access (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+interface YWebsocketPersistence {
+  bindState: (docName: string, ydoc: Y.Doc) => void;
+  writeState: (docName: string, ydoc: Y.Doc) => Promise<void>;
+  provider: MongodbPersistence;
+}
+```
+
+- Preconditions: MongoDB connection established, `yjs-writings` collection accessible
+- Postconditions: Document state persisted; `updatedAt` metadata updated
+- Invariants: Same persistence behavior as current `createMongoDBPersistence`
+
+**Implementation Notes**
+- Extends the current `createMongoDBPersistence` pattern with additional responsibilities: after applying persisted state, `bindState` also runs `syncYDoc` and registers the awareness event bridge
+- This consolidation into `bindState` is intentional: y-websocket does NOT await `contentInitializor` or `bindState`, but within `bindState` itself the ordering is guaranteed (load → sync → awareness registration)
+- The `doc.on('update', ...)` handler for incremental persistence remains unchanged
+- Accepts `io` (Socket.IO server) and `syncYDoc` as dependencies via closure or factory parameters
+
+### Server / Events Layer
+
+#### AwarenessBridge
+
+| Field | Detail |
+|-------|--------|
+| Intent | Bridges y-websocket per-document awareness events to Socket.IO room broadcasts |
+| Requirements | 5.2, 5.3 |
+
+**Responsibilities & Constraints**
+- Listens to awareness update events on each WSSharedDoc
+- Emits `YjsAwarenessStateSizeUpdated` to the page's Socket.IO room on awareness changes
+- Emits `YjsHasYdocsNewerThanLatestRevisionUpdated` when the last editor disconnects
+
+**Dependencies**
+- Inbound: y-websocket document awareness — awareness update events (P0)
+- Outbound: Socket.IO io instance — room broadcast (P0)
+
+**Contracts**: Event [x]
+
+##### Event Contract
+- Published events (to Socket.IO rooms):
+  - `YjsAwarenessStateSizeUpdated` with `awarenessStateSize: number`
+  - `YjsHasYdocsNewerThanLatestRevisionUpdated` with `hasNewerYdocs: boolean`
+- Subscribed events (from y-websocket):
+  - `WSSharedDoc.awareness.on('update', ...)` — per-document awareness changes
+- Ordering: Best-effort delivery via Socket.IO; eventual consistency acceptable
+
+**Implementation Notes**
+- Awareness listener is registered inside `bindState` of the PersistenceAdapter (not in `setContentInitializor`), ensuring it runs after persistence is loaded
+- In y-websocket, awareness state count is `doc.awareness.getStates().size` (same API as y-socket.io's `doc.awareness.states.size`)
+- When awareness size drops to 0 (last editor leaves), check YDoc status and emit draft notification
+
+### Client / Hook Layer
+
+#### use-collaborative-editor-mode
+
+| Field | Detail |
+|-------|--------|
+| Intent | Manages WebsocketProvider lifecycle, awareness state, and CodeMirror extensions |
+| Requirements | 2.2, 2.4, 5.1, 5.4 |
+
+**Responsibilities & Constraints**
+- Creates `WebsocketProvider` with the correct WebSocket URL and room name
+- Sets local awareness state with editor metadata (name, avatar, color)
+- Handles provider lifecycle (create on mount, destroy on unmount/deps change)
+- Provides CodeMirror extensions (yCollab, yUndoManagerKeymap) bound to the active Y.Doc
+
+**Dependencies**
+- Outbound: WebSocket server at `/yjs/{pageId}` — document sync (P0)
+- External: y-websocket `WebsocketProvider` — client provider (P0)
+- External: y-codemirror.next — CodeMirror binding (P0)
+
+**Contracts**: State [x]
+
+##### State Management
+- State model: `provider: WebsocketProvider | undefined` (local React state)
+- Persistence: None (provider is ephemeral, tied to component lifecycle)
+- Concurrency: Single provider per page; cleanup on deps change prevents duplicates
+
+**Implementation Notes**
+- Replace `SocketIOProvider` import with `WebsocketProvider` from `y-websocket`
+- Construct WebSocket URL: `${wsProtocol}//${window.location.host}/yjs` where `wsProtocol` is `wss:` or `ws:` based on `window.location.protocol`
+- Room name: `pageId` (same as current)
+- Options mapping: `autoConnect: true` → `connect: true`; `resyncInterval: 3000` unchanged
+- Awareness API is identical (`provider.awareness.setLocalStateField`, `.on('update', ...)`)
+- Event API mapping: `.on('sync', handler)` is the same
+
+### Dev / Config Layer
+
+#### ViteDevConfig
+
+| Field | Detail |
+|-------|--------|
+| Intent | Configures Vite dev server to support y-websocket collaborative editing |
+| Requirements | 7.1, 7.2 |
+
+**Implementation Notes**
+- Replace `YSocketIO` import with y-websocket server utils (`setupWSConnection`, `getYDoc`)
+- Create `ws.WebSocketServer` in Vite's `configureServer` hook
+- Handle WebSocket upgrade on dev server's `httpServer`
+
+## Data Models
+
+No changes to data models. The `yjs-writings` MongoDB collection schema, indexes, and the `MongodbPersistence` extended class remain unchanged. The persistence interface (`bindState` / `writeState`) is compatible between y-socket.io and y-websocket.
+
+## Error Handling
+
+### Error Strategy
+
+| Error Type | Scenario | Response |
+|------------|----------|----------|
+| Auth Failure | Invalid/expired session cookie | 401 Unauthorized on upgrade, socket destroyed |
+| Access Denied | User lacks page access | 403 Forbidden on upgrade, socket destroyed |
+| Persistence Error | MongoDB read failure in bindState | Log error, serve empty doc (clients will sync from each other) |
+| WebSocket Close | Client network failure | Automatic reconnect with exponential backoff (built into WebsocketProvider) |
+| Document Not Found | getCurrentYdoc for non-active doc | Return undefined (existing behavior) |
+
+### Monitoring
+
+- Log WebSocket upgrade auth failures at `warn` level
+- Log document lifecycle events (create, destroy) at `debug` level
+- Log persistence errors at `error` level
+- Existing Socket.IO event monitoring unchanged
+
+## Testing Strategy
+
+### Unit Tests
+- UpgradeHandler: cookie parsing, session loading, access check for authorized/unauthorized/guest users
+- PersistenceAdapter: bindState loads and applies persisted state, writeState flushes document
+- AwarenessBridge: awareness event triggers correct Socket.IO room emission
+- WebSocket URL construction in use-collaborative-editor-mode
+
+### Integration Tests
+- Full connection flow: WebSocket upgrade → auth → document creation → sync step 1/2
+- Multi-client sync: Two clients connect, both receive each other's updates via same Y.Doc
+- Reconnection: Client disconnects and reconnects, receives updates missed during disconnection
+- Persistence round-trip: Document persisted on disconnect, restored on next connection
+
+### Concurrency Tests
+- Simultaneous connections: Multiple clients connect at the same instant — verify single Y.Doc instance (the race condition fix)
+- Disconnect during connect: Client disconnects while another is initializing — verify no document corruption
+
+## Security Considerations
+
+- **Authentication boundary**: Auth check happens in the HTTP upgrade handler BEFORE WebSocket connection is established — unauthorized clients never receive any Yjs data
+- **Session fixation**: Uses same session mechanism as the rest of GROWI; no new attack surface
+- **Data leakage**: PageId extracted from URL path is validated against `Page.isAccessiblePageByViewer` — same check as current y-socket.io middleware
+- **DoS**: WebSocket connections are subject to the same connection limits as Socket.IO (enforced at HTTP level)
+
+## Migration Strategy
+
+This is a code-level replacement, not a data migration. No changes to the `yjs-writings` MongoDB collection.
+
+**Phase 1**: Implement server-side changes (YjsService, UpgradeHandler, PersistenceAdapter, AwarenessBridge)
+**Phase 2**: Implement client-side changes (use-collaborative-editor-mode, ViteDevConfig)
+**Phase 3**: Remove y-socket.io dependency, update package.json classifications
+**Phase 4**: Test all collaborative editing scenarios
+
+Rollback: Revert the code changes; no data migration to undo.

+ 100 - 0
.kiro/specs/migrate-to-y-websocket/requirements.md

@@ -0,0 +1,100 @@
+# Requirements Document
+
+## Introduction
+
+GROWI's collaborative editing system currently uses `y-socket.io` (v1.1.3) as the Yjs transport layer for real-time document synchronization. A critical race condition in `y-socket.io`'s `initDocument()` method causes clients to occasionally split into isolated Y.Doc instances on the server, resulting in permanent desynchronization until browser reload. The `y-socket.io` library has been unmaintained since September 2023.
+
+This specification defines the requirements for migrating the collaborative editing transport layer from `y-socket.io` to `y-websocket`, the official Yjs WebSocket provider maintained by the Yjs core team. The migration resolves the document initialization race condition while maintaining all existing collaborative editing functionality.
+
+**Scope**: Server-side Yjs document management, client-side Yjs provider, WebSocket authentication, MongoDB persistence integration, and awareness/presence tracking.
+
+**Out of Scope**: Changes to the Yjs document model itself, CodeMirror editor integration, page save/revision logic, or the global Socket.IO infrastructure used for non-Yjs events.
+
+## Requirements
+
+### Requirement 1: Document Synchronization Integrity
+
+**Objective:** As a wiki user editing collaboratively, I want all clients editing the same page to always share a single server-side Y.Doc instance, so that edits are never lost due to document desynchronization.
+
+#### Acceptance Criteria
+
+1. When multiple clients connect to the same page simultaneously, the Yjs Service shall ensure that exactly one Y.Doc instance exists on the server for that page.
+2. When a client connects while another client's document initialization is in progress, the Yjs Service shall return the same Y.Doc instance to both clients without creating a duplicate.
+3. When a client reconnects after a brief network disconnection, the Yjs Service shall synchronize the client with the existing server-side Y.Doc containing all other clients' changes.
+4. While multiple clients are editing the same page, the Yjs Service shall propagate each client's changes to all other connected clients in real time.
+5. If a client's WebSocket connection drops and reconnects, the Yjs Service shall not destroy the server-side Y.Doc while other clients remain connected.
+
+### Requirement 2: WebSocket Transport Layer
+
+**Objective:** As a system operator, I want the collaborative editing transport to use y-websocket instead of y-socket.io, so that the system benefits from active maintenance and the race-condition-free document initialization pattern.
+
+#### Acceptance Criteria
+
+1. The Yjs Service shall use `y-websocket` (or `@y/websocket-server`) as the server-side Yjs transport, replacing `y-socket.io`.
+2. The Editor Client shall use `y-websocket`'s `WebsocketProvider` as the client-side Yjs provider, replacing `y-socket.io`'s `SocketIOProvider`.
+3. The WebSocket server shall coexist with the existing Socket.IO server on the same HTTP server instance without port conflicts.
+4. The Yjs Service shall support the `resyncInterval` capability (periodic state re-synchronization) to recover from any missed updates.
+
+### Requirement 3: Authentication and Authorization
+
+**Objective:** As a system administrator, I want WebSocket connections for collaborative editing to be authenticated and authorized, so that only permitted users can access page content via the Yjs channel.
+
+#### Acceptance Criteria
+
+1. When a WebSocket upgrade request is received for collaborative editing, the Yjs Service shall authenticate the user using the existing session/passport mechanism.
+2. When an authenticated user attempts to connect to a page's Yjs document, the Yjs Service shall verify that the user has read access to that page before allowing the connection.
+3. If an unauthenticated or unauthorized WebSocket upgrade request is received, the Yjs Service shall reject the connection with an appropriate HTTP error status.
+4. Where guest access is enabled for a page, the Yjs Service shall allow guest users to connect to that page's collaborative editing session.
+
+### Requirement 4: MongoDB Persistence Compatibility
+
+**Objective:** As a system operator, I want the Yjs persistence layer to continue using the existing MongoDB storage, so that no data migration is required and existing drafts are preserved.
+
+#### Acceptance Criteria
+
+1. The Yjs Service shall continue to use the `yjs-writings` MongoDB collection for document persistence.
+2. The Yjs Service shall maintain compatibility with the existing `MongodbPersistence` implementation (extended `y-mongodb-provider`).
+3. When a Y.Doc is loaded from persistence, the Yjs Service shall apply the persisted state before sending sync messages to connecting clients.
+4. When a Y.Doc receives updates, the Yjs Service shall persist each update to MongoDB with an `updatedAt` timestamp.
+5. When all clients disconnect from a document, the Yjs Service shall flush the document state to MongoDB before destroying the in-memory instance.
+
+### Requirement 5: Awareness and Presence Tracking
+
+**Objective:** As a wiki user, I want to see which other users are currently editing the same page, so that I can coordinate edits and avoid conflicts.
+
+#### Acceptance Criteria
+
+1. While a user is editing a page, the Editor Client shall broadcast the user's presence information (name, username, avatar, cursor color) via the Yjs awareness protocol.
+2. When a user connects or disconnects from a collaborative editing session, the Yjs Service shall emit awareness state size updates to the page's Socket.IO room (`page:{pageId}`).
+3. When the last user disconnects from a document, the Yjs Service shall emit a draft status notification (`YjsHasYdocsNewerThanLatestRevisionUpdated`) to the page's Socket.IO room.
+4. The Editor Client shall display the list of active editors based on awareness state updates from the Yjs provider.
+
+### Requirement 6: YDoc Status and Sync Integration
+
+**Objective:** As a system component, I want the YDoc status detection and force-sync mechanisms to continue functioning, so that draft detection, save operations, and revision synchronization work correctly.
+
+#### Acceptance Criteria
+
+1. The Yjs Service shall continue to expose `getYDocStatus(pageId)` returning the correct status (ISOLATED, NEW, DRAFT, SYNCED, OUTDATED).
+2. The Yjs Service shall continue to expose `getCurrentYdoc(pageId)` returning the in-memory Y.Doc instance if one exists.
+3. When the `document-loaded` event fires (or equivalent), the Yjs Service shall call `syncYDoc` to synchronize the document with the latest revision based on YDoc status.
+4. The Yjs Service shall continue to expose `syncWithTheLatestRevisionForce(pageId)` for API-triggered force synchronization.
+
+### Requirement 7: Development Environment Support
+
+**Objective:** As a developer, I want the collaborative editing to work in the local development environment, so that I can develop and test collaborative features.
+
+#### Acceptance Criteria
+
+1. The Vite dev server configuration (`packages/editor/vite.config.ts`) shall support the y-websocket-based collaborative editing setup.
+2. When running `turbo run dev`, the WebSocket endpoint for collaborative editing shall be available alongside the existing Socket.IO endpoints.
+
+### Requirement 8: Dependency Cleanup
+
+**Objective:** As a maintainer, I want the y-socket.io dependency to be completely removed after migration, so that the codebase has no dead dependencies.
+
+#### Acceptance Criteria
+
+1. When the migration is complete, the `y-socket.io` package shall be removed from all `package.json` files in the monorepo.
+2. The system shall have no remaining imports or type references to `y-socket.io` modules.
+3. The `y-websocket` (and/or `@y/websocket-server`) package shall be listed in the appropriate `dependencies` or `devDependencies` section based on the Turbopack externalisation rule.

+ 132 - 0
.kiro/specs/migrate-to-y-websocket/research.md

@@ -0,0 +1,132 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `migrate-to-y-websocket`
+- **Discovery Scope**: Complex Integration
+- **Key Findings**:
+  - y-socket.io's `initDocument()` has a TOCTOU race condition due to `await` between Map get and set — y-websocket uses atomic `map.setIfUndefined` which eliminates this class of bug
+  - `@y/websocket-server@0.1.5` requires `yjs@^14` (incompatible), but `y-websocket@2.0.4` bundles server utils with `yjs@^13` support
+  - The `ws` package is already installed in GROWI (`ws@^8.17.1`); Express HTTP server supports adding a WebSocket upgrade handler alongside Socket.IO
+
+## Research Log
+
+### y-socket.io Race Condition Root Cause
+- **Context**: Clients occasionally desynchronize — some see edits, others don't
+- **Sources Consulted**: `node_modules/y-socket.io/dist/server/server.js` (minified source)
+- **Findings**:
+  - `initDocument()` does `_documents.get(name)`, then `await persistence.bindState(name, doc)`, then `_documents.set(name, doc)`
+  - The `await` yields to the event loop, allowing a concurrent `initDocument` call to create a second Y.Doc for the same name
+  - Each socket's sync listeners are bound to the doc returned by its `initDocument` call
+  - Namespace-level broadcasts partially mask the issue, but resync intervals and disconnect handlers operate on the wrong doc instance
+- **Implications**: The only fix is replacing the transport layer; patching y-socket.io is fragile since the library is unmaintained
+
+### y-websocket Document Initialization Safety
+- **Context**: Verify y-websocket does not have the same race condition
+- **Sources Consulted**: `@y/websocket-server/src/utils.js`, y-websocket v2 `bin/utils.cjs`
+- **Findings**:
+  - Uses `map.setIfUndefined(docs, docname, () => { ... })` from lib0 — synchronous atomic get-or-create
+  - Document is registered in the Map before any async operation (persistence, contentInitializor)
+  - `persistence.bindState` is called but NOT awaited inline — the document is already in the Map
+  - Concurrent connections calling `getYDoc` with the same name receive the same WSSharedDoc instance
+- **Implications**: The primary race condition is eliminated by design
+
+### y-websocket Package Version Compatibility
+- **Context**: Choose correct package versions for GROWI's yjs v13 stack
+- **Sources Consulted**: npm registry for y-websocket, @y/websocket-server
+- **Findings**:
+  - `y-websocket@3.0.0` (Apr 2025): Client-only, peers on `yjs@^13.5.6` — compatible
+  - `y-websocket@3.0.0`: Removed server utils (moved to separate package)
+  - `@y/websocket-server@0.1.5` (Feb 2026): Requires `yjs@^14.0.0-7` — **incompatible**
+  - `y-websocket@2.0.4` (Jul 2024): Includes both client and server utils (`./bin/utils`), peers on `yjs@^13.5.6` — compatible
+  - `y-websocket@2.1.0` (Dec 2024): Also includes server utils, peers on `yjs@^13.5.6` — compatible
+- **Implications**: Must use y-websocket v2.x for server utils, or vendor/adapt server code from @y/websocket-server
+
+### HTTP Server and WebSocket Coexistence
+- **Context**: Determine how to add raw WebSocket alongside existing Socket.IO
+- **Sources Consulted**: `apps/app/src/server/crowi/index.ts`, `apps/app/src/server/service/socket-io/socket-io.ts`
+- **Findings**:
+  - HTTP server created via `http.createServer(app)` at crowi/index.ts:582
+  - Socket.IO attaches to this server and handles its own `upgrade` events for `/socket.io/` path
+  - `ws@^8.17.1` already installed in apps/app
+  - WebSocket.Server with `noServer: true` can coexist by handling `upgrade` events for a different path prefix
+  - Socket.IO only intercepts upgrade requests matching its path (`/socket.io/`)
+- **Implications**: Safe to add `ws.Server` with path prefix `/yjs/` on the same HTTP server
+
+### Authentication for Raw WebSocket
+- **Context**: y-socket.io piggybacks on Socket.IO middleware for session/passport; raw WebSocket needs custom auth
+- **Sources Consulted**: `apps/app/src/server/crowi/express-init.js`, `apps/app/src/server/service/socket-io/socket-io.ts`
+- **Findings**:
+  - Socket.IO attaches session middleware via `io.engine.use(expressSession(...))`
+  - Express session uses cookie-based session ID (`connect.sid` or configured name)
+  - Raw WebSocket `upgrade` request carries the same HTTP cookies
+  - Can reconstruct session by: (1) parsing cookie from upgrade request, (2) loading session from store (Redis or MongoDB)
+  - Passport user is stored in `req.session.passport.user`, deserialized via `passport.deserializeUser`
+- **Implications**: Authentication requires manual session parsing in the `upgrade` handler, but uses the same session store and cookie
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| A: y-websocket@2.x (client + server) | Use v2.x which bundles both client and server utils | Single package, proven server code, yjs v13 compatible | Older client (missing v3 SyncStatus), v2 may stop receiving updates | Simplest migration path |
+| B: y-websocket@3.x client + custom server | Use v3 client + inline server adapter based on @y/websocket-server | Latest client features, full control over server code | Must maintain custom server code (~200 lines) | Recommended if v3 features needed |
+| C: y-websocket@3.x + @y/websocket-server | Use v3 client + official server package | Official packages for both sides | Requires yjs v14 upgrade (breaking change) | Too risky for this migration scope |
+
+## Design Decisions
+
+### Decision: Use y-websocket@2.x for both client and server
+
+- **Context**: Need yjs v13 compatibility on both client and server sides
+- **Alternatives Considered**:
+  1. y-websocket@3.x client + custom server — more work, v3 SyncStatus not needed now
+  2. y-websocket@3.x + @y/websocket-server — requires yjs v14 migration (out of scope)
+  3. y-websocket@2.x for everything — simplest path, proven code
+- **Selected Approach**: Option A — use `y-websocket@2.x` (specifically 2.0.4 or 2.1.0)
+- **Rationale**: Minimizes custom code, proven server utils, compatible with existing yjs v13, clear upgrade path to v3 + @y/websocket-server when yjs v14 migration happens separately
+- **Trade-offs**: Miss v3 SyncStatus feature, but current `sync` event + `resyncInterval` meets all requirements
+- **Follow-up**: Plan separate yjs v14 migration in future, then upgrade to y-websocket v3 + @y/websocket-server
+
+### Decision: WebSocket path prefix `/yjs/`
+
+- **Context**: Need URL pattern for Yjs WebSocket connections that doesn't conflict with Socket.IO
+- **Alternatives Considered**:
+  1. `/yjs/{pageId}` — clean, matches existing `/yjs|{pageId}` pattern semantics
+  2. `/ws/yjs/{pageId}` — more explicit WebSocket prefix
+  3. `/api/v3/yjs/{pageId}` — matches API convention
+- **Selected Approach**: `/yjs/{pageId}` path prefix
+- **Rationale**: Simple, semantic, no conflict with Socket.IO's `/socket.io/` path or Express routes
+- **Trade-offs**: None significant
+
+### Decision: Session-based authentication on WebSocket upgrade
+
+- **Context**: Must authenticate WebSocket connections without Socket.IO middleware
+- **Alternatives Considered**:
+  1. Parse session cookie from upgrade request, load session from store — reuses existing session infrastructure
+  2. Token-based auth via query params — simpler but requires generating/managing tokens
+  3. Separate auth endpoint + upgrade — adds complexity
+- **Selected Approach**: Parse session cookie from the HTTP upgrade request and deserialize the user from the session store
+- **Rationale**: Reuses existing session infrastructure (same cookie, same store, same passport serialization), no client-side auth changes needed
+- **Trade-offs**: Couples to express-session internals, but GROWI already has this coupling throughout
+
+### Decision: Keep Socket.IO for awareness event fan-out
+
+- **Context**: GROWI uses Socket.IO rooms (`page:{pageId}`) to broadcast awareness updates to non-editor components (page viewers, sidebar, etc.)
+- **Selected Approach**: Continue using Socket.IO `io.in(roomName).emit()` for awareness size events and draft status notifications. Hook into y-websocket's per-document awareness events and bridge to Socket.IO.
+- **Rationale**: Non-editor UI components already listen on Socket.IO rooms; changing this is out of scope
+- **Trade-offs**: Two transport layers (WebSocket for Yjs sync, Socket.IO for UI events) — acceptable given the separation of concerns
+
+## Risks & Mitigations
+- **Risk**: y-websocket server `persistence.bindState` is not awaited before first sync → client may briefly see empty doc
+  - **Mitigation**: Override `setupWSConnection` to await `doc.whenInitialized` before sending sync step 1, or ensure `bindState` completes fast (MongoDB read is typically <50ms)
+- **Risk**: Socket.IO and ws competing for HTTP upgrade events
+  - **Mitigation**: Socket.IO only handles `/socket.io/` path; register ws handler for `/yjs/` path with explicit path check before `handleUpgrade`
+- **Risk**: Session cookie parsing edge cases (SameSite, Secure flags, proxy headers)
+  - **Mitigation**: Reuse existing express-session cookie parser and session store; test with the same proxy configuration
+- **Risk**: Document cleanup race when last client disconnects and a new client immediately connects
+  - **Mitigation**: y-websocket's `getYDoc` atomic pattern handles this — new client gets a fresh doc if cleanup completed, or the existing doc if not yet cleaned up
+
+## References
+- [y-websocket GitHub](https://github.com/yjs/y-websocket) — official Yjs WebSocket provider
+- [y-websocket-server GitHub](https://github.com/yjs/y-websocket-server) — server-side utilities (yjs v14)
+- [y-socket.io npm](https://www.npmjs.com/package/y-socket.io) — current library (unmaintained since Sep 2023)
+- [ws npm](https://www.npmjs.com/package/ws) — WebSocket implementation for Node.js
+- [y-mongodb-provider](https://github.com/MaxNoetzold/y-mongodb-provider) — MongoDB persistence for Yjs

+ 22 - 0
.kiro/specs/migrate-to-y-websocket/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "migrate-to-y-websocket",
+  "created_at": "2026-03-19T00:00:00.000Z",
+  "updated_at": "2026-03-19T00:00:00.000Z",
+  "language": "en",
+  "phase": "tasks-approved",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 101 - 0
.kiro/specs/migrate-to-y-websocket/tasks.md

@@ -0,0 +1,101 @@
+# Implementation Plan
+
+- [ ] 1. Add y-websocket dependency and adapt persistence layer
+- [ ] 1.1 (P) Add y-websocket package to apps/app and packages/editor
+  - Add `y-websocket@^2.0.4` to both `apps/app/package.json` and `packages/editor/package.json`
+  - Classify as `dependencies` in apps/app (server-side `bin/utils` is used at runtime) and verify Turbopack externalisation after build
+  - Run `pnpm install` to update lockfile
+  - _Requirements: 2.1, 8.3_
+
+- [ ] 1.2 (P) Adapt the MongoDB persistence layer to the y-websocket persistence interface
+  - Update `create-mongodb-persistence.ts` to return an object matching y-websocket's `setPersistence` shape (`bindState`, `writeState`, `provider`)
+  - The `bindState` implementation extends the current logic: load persisted Y.Doc, compute diff, store update, apply persisted state, register incremental update handler with `updatedAt` metadata
+  - After applying persisted state within `bindState`, determine `YDocStatus` and call `syncYDoc` to synchronize with the latest revision — this guarantees correct ordering (persistence load completes before sync runs)
+  - Also within `bindState`, register the awareness event listener on the document for the AwarenessBridge (emit awareness state size and draft status to Socket.IO rooms)
+  - Accept `io` (Socket.IO server instance) and sync dependencies via factory parameters
+  - The `writeState` implementation calls `flushDocument` — same as current
+  - Update the `Persistence` type import from y-websocket's `bin/utils` instead of y-socket.io
+  - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 6.3, 5.2, 5.3_
+
+- [ ] 2. Implement WebSocket upgrade authentication handler
+- [ ] 2.1 Create the upgrade handler that authenticates WebSocket connections using session cookies
+  - Parse the `cookie` header from the HTTP upgrade request to extract the session ID
+  - Load the session from the session store (Redis or MongoDB, matching GROWI's express-session configuration)
+  - Deserialize the user from the session via passport's `deserializeUser`
+  - Extract `pageId` from the URL path (`/yjs/{pageId}`)
+  - Verify page access using `Page.isAccessiblePageByViewer(pageId, user)`
+  - Allow guest access when the page permits it and the user is null
+  - Reject unauthorized requests with `401 Unauthorized` or `403 Forbidden` by writing HTTP response headers and destroying the socket — before `handleUpgrade` is called
+  - Attach the authenticated user to the request object for downstream use
+  - _Requirements: 3.1, 3.2, 3.3, 3.4_
+
+- [ ] 3. Rewrite YjsService to use y-websocket server utilities
+- [ ] 3.1 Replace YSocketIO with ws.WebSocketServer and y-websocket document management
+  - Change the constructor to accept both `httpServer` and `io` (instead of only `io`)
+  - Create a `WebSocket.Server` with `noServer: true` mode
+  - Call y-websocket's `setPersistence` with the adapted persistence layer from task 1.2
+  - Register the HTTP `upgrade` event handler on `httpServer`, routing requests with path prefix `/yjs/` to the upgrade handler from task 2.1, then to `wss.handleUpgrade`, and finally to y-websocket's `setupWSConnection` with the extracted `pageId` as `docName`
+  - Ensure Socket.IO's upgrade handling for `/socket.io/` is not affected by checking the URL path before intercepting
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.3_
+
+- [ ] 3.2 Integrate document status API and force-sync
+  - Replace `ysocketio.documents.get(pageId)` with y-websocket's `docs.get(pageId)` for `getCurrentYdoc` and `syncWithTheLatestRevisionForce`
+  - Preserve all public API behavior of `IYjsService` (getYDocStatus, getCurrentYdoc, syncWithTheLatestRevisionForce)
+  - Update `sync-ydoc.ts` type imports: change `Document` from y-socket.io to y-websocket's `WSSharedDoc` (or `Y.Doc`)
+  - Note: sync-on-load (`syncYDoc`) and awareness bridging are handled inside `bindState` of the PersistenceAdapter (task 1.2), not via `setContentInitializor`
+  - _Requirements: 6.1, 6.2, 6.3, 6.4_
+
+- [ ] 4. Update server initialization flow
+- [ ] 4.1 Pass httpServer to YjsService initialization
+  - Update `initializeYjsService` to accept both `httpServer` and `io` parameters
+  - Update the call site in `crowi/index.ts` to pass `httpServer` alongside `socketIoService.io`
+  - Verify the initialization order: httpServer created → Socket.IO attached → YjsService initialized with both references
+  - _Requirements: 2.3_
+
+- [ ] 5. Migrate client-side provider to WebsocketProvider
+- [ ] 5.1 (P) Replace SocketIOProvider with WebsocketProvider in the collaborative editor hook
+  - Change the import from `y-socket.io` to `y-websocket`
+  - Construct the WebSocket URL dynamically: use `wss://` when the page is served over HTTPS, `ws://` otherwise, appending `/yjs` as the base path
+  - Use `pageId` as the `roomname` parameter (same as current)
+  - Map options: `autoConnect: true` → `connect: true`; keep `resyncInterval: 3000`
+  - Awareness API calls remain identical: `provider.awareness.setLocalStateField`, `.getStates()`, `.on('update', ...)`
+  - Sync event listener remains identical: `provider.on('sync', handler)`
+  - Lifecycle cleanup remains identical: `provider.disconnect()`, `provider.destroy()`
+  - _Requirements: 2.2, 2.4, 5.1, 5.4_
+
+- [ ] 6. Update Vite dev server configuration
+- [ ] 6.1 (P) Configure the packages/editor Vite dev server to use y-websocket
+  - Replace the `YSocketIO` import with y-websocket server utilities (`setupWSConnection`, `setPersistence`)
+  - Create a `WebSocket.Server` with `noServer: true` in Vite's `configureServer` hook
+  - Handle WebSocket upgrade events on the dev server's `httpServer` for the `/yjs/` path prefix
+  - Ensure the Vite HMR WebSocket and the Yjs WebSocket do not conflict (different paths)
+  - _Requirements: 7.1, 7.2_
+
+- [ ] 7. Remove y-socket.io and finalize dependencies
+- [ ] 7.1 Remove all y-socket.io references from the codebase
+  - Remove `y-socket.io` from `apps/app/package.json` and `packages/editor/package.json`
+  - Verify no remaining imports or type references to `y-socket.io` modules across the monorepo
+  - Run `pnpm install` to update the lockfile
+  - Verify `y-websocket` is classified correctly (`dependencies` vs `devDependencies`) by checking Turbopack externalisation: run `turbo run build --filter @growi/app` and check `apps/app/.next/node_modules/` for y-websocket
+  - _Requirements: 8.1, 8.2, 8.3_
+
+- [ ] 8. Integration and concurrency tests
+- [ ] 8.1 Add integration tests for the WebSocket connection and sync flow
+  - Test the full connection flow: WebSocket upgrade → authentication → document creation → sync step 1/2
+  - Test multi-client sync: two clients connect to the same page, verify both receive each other's edits via the same server-side Y.Doc
+  - Test reconnection: client disconnects and reconnects, verify it receives updates that occurred during disconnection
+  - Test persistence round-trip: document persisted when all clients disconnect, state restored when a new client connects
+  - _Requirements: 1.3, 1.4, 4.3, 4.5_
+
+- [ ] 8.2 Add concurrency tests for document initialization safety
+  - Test simultaneous connections: multiple clients connect to the same page at the exact same time, verify that exactly one Y.Doc instance exists on the server (the core race condition fix)
+  - Test disconnect-during-connect: one client disconnects while another is connecting, verify no document corruption or data loss
+  - _Requirements: 1.1, 1.2, 1.5_
+
+- [ ] 8.3 Add unit tests for the upgrade authentication handler
+  - Test valid session cookie → user deserialized → page access granted → upgrade proceeds
+  - Test expired/invalid session → 401 response → socket destroyed
+  - Test valid user but no page access → 403 response → socket destroyed
+  - Test guest user with guest-accessible page → upgrade proceeds
+  - Test missing or malformed URL path → rejection
+  - _Requirements: 3.1, 3.2, 3.3, 3.4_