Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.default
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ POSTGRES_STATEMENT_TIMEOUT=100000
DELETE_AFTER_DAYS=30

YJS_ENABLED=true
AI_ENABLED=false

DEV_BUILD_CONTEXT=

Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ services:
POSTGRES_QUERY_TIMEOUT: 100000
POSTGRES_STATEMENT_TIMEOUT: 100000
DELETE_AFTER_DAYS: 30
YJS_ENABLED: true
AI_ENABLED: false
ports:
- 80:3000
depends_on:
Expand Down Expand Up @@ -233,8 +235,19 @@ The settings are conceptually divided into:

The settings configuration includes the following feature flags:

- `featureFlagPictograms`: Disables/Enables the pictogram feature (default: disabled). Note: You have to set this flag before build time!
- `featureFlagAI`: Disables/Enables AI functionality like generating mindmaps with AI
- `pictograms`: Disables/Enables the pictogram feature (default: disabled). Note: You have to set this flag before build time in the JSON config!
- `ai`: Disables/Enables AI functionality like generating mindmaps with AI. Can be controlled via the `AI_ENABLED` environment variable (default: `false`).
- `yjs`: Disables/Enables the Yjs-based real-time collaboration. Can be controlled via the `YJS_ENABLED` environment variable (default: `false`).

#### Environment variable overrides

The following environment variables override the feature flags from the JSON config:

| Variable | Description | Default |
| --- | --- | --- |
| `AI_ENABLED` | Enable AI features (mindmap generation) | `false` |
| `YJS_ENABLED` | Enable Yjs-based real-time collaboration | `false` |
| `DELETE_AFTER_DAYS` | Number of days before mindmaps are deleted | `30` |

### Further details

Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/yjs-auth-read-write-access/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-18
101 changes: 101 additions & 0 deletions openspec/changes/yjs-auth-read-write-access/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
## Context

When a client connects to a TeamMapper map, it needs to know whether it has write access (correct modification secret) or is read-only. The current implementation sends this as a custom WebSocket binary message (type 4) before the Yjs sync messages. The client must hack y-websocket's `messageHandlers` array to prevent console errors and attach a raw WebSocket listener to parse the message.

The `GET /api/maps/:id` HTTP call already happens before the WebSocket connection is established. The modification secret is already available in `MapSyncService.modificationSecret` before `fetchMapFromServer` is called (set in `prepareExistingMap`). This makes the HTTP response the natural place to communicate write-access status.

Server-side enforcement is completely independent: `processReadOnlySyncMessage` in the Yjs gateway drops SyncStep2/Update messages from read-only clients regardless of how the client learns its permission level. This is the real security boundary and is unchanged.

## Goals / Non-Goals

**Goals:**
- Eliminate custom WebSocket message type 4
- Remove y-websocket `messageHandlers` mutation
- Remove raw WebSocket `addEventListener` hack
- Communicate write-access via existing HTTP endpoint
- Keep server-side enforcement unchanged

**Non-Goals:**
- Changing how the secret is stored or transmitted to the WebSocket (stays as query param)
- Implementing timing-safe comparison (low risk, deferred)
- Modifying the `HttpService` class (secret appended to URL string directly)

## Decisions

### 1. Secret passed as query parameter on HTTP GET

**Decision:** Append `?secret=<encoded_secret>` to the `GET /api/maps/:id` URL when a modification secret is available. Use `encodeURIComponent()` for safety.

**Rationale:** The secret is already passed as a query parameter in the WebSocket URL (`params: { secret: ... }`). Using the same pattern for the HTTP call is consistent. The `HttpService.get()` method only accepts `(apiUrl, endpoint)` — no headers/options support — so appending to the URL string is the simplest approach without modifying the service.

**Alternative considered:** Adding headers support to `HttpService`. Rejected — over-engineering for this use case, and inconsistent with the WebSocket path which also uses query params.

### 2. `writable` computed in the controller, not the service

**Decision:** The `maps.controller.ts` `findOne` endpoint accepts the `@Query('secret')` parameter, calls `mapsService.findMap()` to get the `modificationSecret`, and uses `checkWriteAccess()` to compute `writable`. The result is spread into the response: `{ ...map, writable }`.

**Rationale:** The `MapsService.exportMapToClient()` method is a generic export function used by multiple endpoints (create, duplicate, etc.). Adding secret-awareness to it would conflate concerns. The controller is the right place to handle request-specific parameters.

**Trade-off:** `findMap()` is called separately from `exportMapToClient()` (which calls it internally via `updateLastAccessed`). This means two DB lookups for the same map. Acceptable because it's a simple PK lookup and avoids modifying the service interface.

### 3. `writable` defaults to `true` when absent

**Decision:** Frontend treats `serverMap.writable !== false` as writable. This means if the field is `undefined` (e.g., from a cached response or older server), the client defaults to writable and relies on server-side enforcement.

**Rationale:** Fail-open on the client is safe because the server enforces read-only at the WebSocket level. A client that thinks it's writable but isn't will simply have its writes silently dropped. This is better than fail-closed (defaulting to read-only), which would lock out users unnecessarily.

### 4. Removal scope: all custom message type 4 code

**Decision:** Remove all code related to `MESSAGE_WRITE_ACCESS` (type 4) from both frontend and backend:

**Backend removals:**
- `yjsProtocol.ts`: `MESSAGE_WRITE_ACCESS` constant, `encodeWriteAccessMessage()` function
- `yjs-gateway.service.ts`: `this.send(ws, encodeWriteAccessMessage(writable))` line in `setupSync()`

**Frontend removals:**
- `yjs-utils.ts`: `MESSAGE_WRITE_ACCESS` constant, `parseWriteAccessBytes()` function
- `map-sync.service.ts`: `MESSAGE_WRITE_ACCESS` constant (line 64), `setupYjsWriteAccessListener()`, `attachWriteAccessListener()`, `parseWriteAccessMessage()`, and the call site `this.setupYjsWriteAccessListener()` in `setupYjsConnection`

**Kept:**
- `checkWriteAccess()` in `yjsProtocol.ts` — reused by both the gateway (server-side enforcement) and the controller (HTTP response)
- `processReadOnlySyncMessage()` — server-side enforcement unchanged
- WebSocket `secret` query param — still used for server-side write enforcement

## Data Flow

```
BEFORE (custom WebSocket message):
┌────────┐ GET /api/maps/:id ┌────────┐
│ Client │─────────────────────▶│ Server │ (no write-access info)
│ │◀─────────────────────│ │
│ │ │ │
│ │ WS connect │ │
│ │─────────────────────▶│ │
│ │◀── msg type 4 ───── │ │ encodeWriteAccessMessage(writable)
│ │◀── SyncStep1 ────── │ │
│ │◀── SyncStep2 ────── │ │
│ │ messageHandlers[4] │ │ (client hacks y-websocket)
│ │ ws.addEventListener │ │ (client attaches raw listener)
└────────┘ └────────┘

AFTER (HTTP-based):
┌────────┐ GET /api/maps/:id?secret=... ┌────────┐
│ Client │────────────────────────────────▶│ Server │
│ │◀── { ...map, writable } ───────│ │ checkWriteAccess()
│ │ │ │
│ │ WS connect │ │
│ │────────────────────────────────▶│ │
│ │◀── SyncStep1 ───────────────── │ │ (no custom message)
│ │◀── SyncStep2 ───────────────── │ │
│ │ (no messageHandlers hack) │ │
│ │ (no raw WS listener) │ │
└────────┘ └────────┘
```

## Risks / Trade-offs

**[Double DB lookup in findOne]** The controller calls both `exportMapToClient()` (which internally calls `findMap()`) and `findMap()` directly. This is two PK lookups for the same row. Mitigation: PK lookups are fast and cached by the DB. Can be optimized later by extracting the map entity from `exportMapToClient` if needed.

**[Secret in query string]** The secret appears in URL query parameters, which may be logged by proxies or appear in browser history. Mitigation: Already the case for the WebSocket URL. Can be moved to a header in a future iteration.

**[Race between HTTP and WebSocket]** If the map's `modificationSecret` changes between the HTTP call and WebSocket connection, the client could have stale permission info. Mitigation: The server enforces permissions on every WebSocket message via `processReadOnlySyncMessage`. The client's `writable` flag only affects UI state (edit mode), not actual security.
47 changes: 47 additions & 0 deletions openspec/changes/yjs-auth-read-write-access/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## Why

The current implementation uses a custom WebSocket message type (`MESSAGE_WRITE_ACCESS = 4`) to communicate write permissions from server to client. This causes three problems:

1. **Direct mutation of y-websocket internals**: The client sets `wsProvider.messageHandlers[4]` as a no-op to suppress `console.error('Unable to compute message')` for the unrecognized type.
2. **Fragile raw WebSocket listener**: A second `ws.addEventListener('message', ...)` is attached to parse the custom binary message, duplicating protocol handling outside y-websocket's message pipeline.
3. **Not within standard y-websocket types**: Types 0-3 are defined by y-websocket (sync, awareness, auth, queryAwareness). Type 4 is custom and could conflict with future y-websocket versions.

The client already makes an HTTP `GET /api/maps/:id` call before establishing the WebSocket connection. The modification secret is already stored in `MapSyncService` before this call. Moving write-access determination to the HTTP response eliminates the custom message type entirely.

## What Changes

- **Backend `GET /api/maps/:id`**: Accept an optional `?secret=` query parameter. Look up the map's `modificationSecret`, compute `writable` using the existing `checkWriteAccess()` utility, and return it in the response.
- **Backend Yjs gateway**: Remove the `encodeWriteAccessMessage(writable)` send from `setupSync()`. Server-side enforcement (`processReadOnlySyncMessage`) is unchanged.
- **Backend protocol utils**: Remove `MESSAGE_WRITE_ACCESS` constant and `encodeWriteAccessMessage()` function. Keep `checkWriteAccess()` (reused by both gateway and controller).
- **Frontend `fetchMapFromServer`**: Append `?secret=...` to the HTTP request URL when a modification secret is set.
- **Frontend `prepareExistingMap`**: Set `yjsWritable` from the HTTP response's `writable` field instead of waiting for a WebSocket message.
- **Frontend cleanup**: Remove `setupYjsWriteAccessListener()`, `attachWriteAccessListener()`, `parseWriteAccessMessage()`, the `MESSAGE_WRITE_ACCESS` constant, and `parseWriteAccessBytes()` from yjs-utils.

## Non-goals

- Changing the server-side enforcement mechanism (`processReadOnlySyncMessage` silently drops writes from read-only clients — this is unchanged)
- Moving the secret to an HTTP header (consistent with existing WebSocket URL pattern; can be improved later)
- Timing-safe secret comparison (low risk — the secret is a shareable edit link token, not a cryptographic credential)

## Yjs Logic (Unchanged)

- If the secret is provided and correct: allow all modifications (writable WebSocket sync)
- If no / wrong secret is provided: silently drop all write operations on the server side, but allow the normal connection for reading
- The HTTP call handles communicating the permission level to the client UI

## Capabilities

### New Capabilities
- `yjs-write-access`: HTTP-based write-access determination replacing custom WebSocket message type 4

## Impact

- **Backend `maps.controller.ts`**: Add `@Query('secret')` parameter, compute and return `writable`
- **Backend `types.ts`**: Add `writable?: boolean` to `IMmpClientMap`
- **Backend `yjs-gateway.service.ts`**: Remove `encodeWriteAccessMessage` send
- **Backend `yjsProtocol.ts`**: Remove `MESSAGE_WRITE_ACCESS`, `encodeWriteAccessMessage`
- **Frontend `server-types.ts`**: Add `writable?: boolean` to `ServerMap`
- **Frontend `map-sync.service.ts`**: Update `fetchMapFromServer`, `prepareExistingMap`; remove 3 write-access listener methods + constant
- **Frontend `yjs-utils.ts`**: Remove `MESSAGE_WRITE_ACCESS`, `parseWriteAccessBytes`
- **No new dependencies**
- **No breaking changes**: Custom message type 4 is internal to this branch
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
## ADDED Requirements

### Requirement: HTTP-based write-access determination
The `GET /api/maps/:id` endpoint SHALL accept an optional `secret` query parameter. When provided, the server SHALL compare it against the map's `modificationSecret` using the existing `checkWriteAccess()` utility and return a `writable` boolean field in the response.

#### Scenario: Map with no modification secret
- **WHEN** a client requests `GET /api/maps/:id` for a map that has no `modificationSecret`
- **THEN** the response SHALL include `writable: true`
- **AND** this SHALL be the case regardless of whether `?secret=` is provided

#### Scenario: Map with correct secret
- **WHEN** a client requests `GET /api/maps/:id?secret=<correct_secret>`
- **AND** the map has a `modificationSecret` that matches the provided secret
- **THEN** the response SHALL include `writable: true`

#### Scenario: Map with wrong or missing secret
- **WHEN** a client requests `GET /api/maps/:id` without a `secret` parameter (or with an incorrect one)
- **AND** the map has a `modificationSecret`
- **THEN** the response SHALL include `writable: false`

### Requirement: Frontend reads write-access from HTTP response
When the frontend fetches a map via `fetchMapFromServer`, it SHALL pass the stored `modificationSecret` as a `?secret=` query parameter (URL-encoded). The `prepareExistingMap` method SHALL set `yjsWritable` from the response's `writable` field before the WebSocket connection is established.

#### Scenario: Secret passed in HTTP request
- **WHEN** `modificationSecret` is set in `MapSyncService`
- **AND** `fetchMapFromServer` is called
- **THEN** the HTTP request URL SHALL include `?secret=<encoded_secret>`

#### Scenario: No secret omits query parameter
- **WHEN** `modificationSecret` is empty or null
- **AND** `fetchMapFromServer` is called
- **THEN** the HTTP request URL SHALL NOT include a `secret` query parameter

#### Scenario: writable set from HTTP response
- **WHEN** `prepareExistingMap` receives the server response
- **THEN** `yjsWritable` SHALL be set to `serverMap.writable !== false`
- **AND** `yjsWritable` SHALL be set BEFORE the WebSocket connection is established

#### Scenario: writable defaults to true when absent
- **WHEN** the server response does not include a `writable` field
- **THEN** `yjsWritable` SHALL default to `true`
- **AND** server-side enforcement SHALL still apply (client writes silently dropped if unauthorized)

### Requirement: Removal of custom WebSocket message type 4
The custom `MESSAGE_WRITE_ACCESS` (type 4) WebSocket message SHALL be removed from both frontend and backend. The server SHALL NOT send write-access messages over WebSocket. The client SHALL NOT register custom message handlers or raw WebSocket listeners for write-access.

#### Scenario: Server does not send write-access message
- **WHEN** a WebSocket connection is established in `setupSync()`
- **THEN** the server SHALL NOT call `encodeWriteAccessMessage()` or send a type 4 message
- **AND** the server SHALL still send SyncStep1, SyncStep2, and awareness messages

#### Scenario: Client does not hack messageHandlers
- **WHEN** the `WebsocketProvider` is created
- **THEN** the client SHALL NOT modify `wsProvider.messageHandlers[4]`
- **AND** the client SHALL NOT attach raw `ws.addEventListener('message', ...)` listeners for write-access parsing

#### Scenario: Protocol utils cleaned up
- **WHEN** the codebase is updated
- **THEN** `MESSAGE_WRITE_ACCESS` and `encodeWriteAccessMessage()` SHALL NOT exist in `yjsProtocol.ts`
- **AND** `MESSAGE_WRITE_ACCESS` and `parseWriteAccessBytes()` SHALL NOT exist in `yjs-utils.ts`
- **AND** `checkWriteAccess()` SHALL remain in `yjsProtocol.ts` (used by gateway and controller)

### Requirement: Server-side enforcement unchanged
The `processReadOnlySyncMessage` function in the Yjs gateway SHALL continue to silently drop SyncStep2 and Update messages from read-only clients. This is the security boundary and is NOT affected by the move from WebSocket to HTTP for permission signaling.

#### Scenario: Read-only client writes are dropped
- **WHEN** a client connected without a valid secret sends a Y.Doc update
- **THEN** the server SHALL silently drop the update via `processReadOnlySyncMessage`
- **AND** no error SHALL be sent to the client (silent drop allows continued reading)

## REMOVED Requirements

### Requirement: WebSocket write-access message
**Reason**: Replaced by HTTP-based `writable` field in `GET /api/maps/:id` response. The custom WebSocket message type 4 (`MESSAGE_WRITE_ACCESS`) was a non-standard extension that required hacking y-websocket internals on the client side.
**Migration**: Write-access is now determined from the HTTP response before the WebSocket connection. Server-side enforcement is unchanged.
Loading
Loading