tangled
alpha
login
or
join now
diffdown.com
/
diffdown-app
0
fork
atom
Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
0
fork
atom
overview
issues
10
pulls
pipelines
Add collaboration feature design spec
John Luther
3 weeks ago
84319b66
7daaa4fa
+245
1 changed file
expand all
collapse all
unified
split
docs
superpowers
specs
2026-03-11-collaboration-design.md
+245
docs/superpowers/specs/2026-03-11-collaboration-design.md
reviewed
···
1
1
+
# Collaboration Feature Design
2
2
+
3
3
+
**Date:** 2026-03-11
4
4
+
**Status:** Approved
5
5
+
6
6
+
## 1. Architecture Overview
7
7
+
8
8
+
```
9
9
+
┌─────────────┐ WebSocket ┌─────────────┐
10
10
+
│ Collaborator│◄──────────────────►│ Diffdown │
11
11
+
│ Browser │ Auth (ATProto) │ Server │
12
12
+
└─────────────┘ └──────┬──────┘
13
13
+
│
14
14
+
┌────────────┴────────────┐
15
15
+
│ │
16
16
+
┌─────▼─────┐ ┌──────▼──────┐
17
17
+
│ WebSocket │ │ ATProto │
18
18
+
│ Hub │ │ PDS (Creator│
19
19
+
└───────────┘ └─────────────┘
20
20
+
```
21
21
+
22
22
+
The Diffdown server acts as the collaboration hub — receives edits from all collaborators, maintains canonical state, pushes to Creator's PDS.
23
23
+
24
24
+
## 2. Data Model
25
25
+
26
26
+
### Updated `com.diffdown.document`
27
27
+
28
28
+
```json
29
29
+
{
30
30
+
"$type": "com.diffdown.document",
31
31
+
"title": "My Doc",
32
32
+
"content": { "$type": "at.markpub.markdown", "flavor": "gfm", "text": { "rawMarkdown": "..." } },
33
33
+
"textContent": "...",
34
34
+
"collaborators": ["did:plc:abc123", "did:plc:def456"],
35
35
+
"createdAt": "2026-03-11T...",
36
36
+
"updatedAt": "2026-03-11T..."
37
37
+
}
38
38
+
```
39
39
+
40
40
+
### New `com.diffdown.comment` Lexicon
41
41
+
42
42
+
```json
43
43
+
{
44
44
+
"$type": "com.diffdown.comment",
45
45
+
"documentURI": "at://did:plc:creator/com.diffdown.document/abc123",
46
46
+
"paragraphId": "p-3",
47
47
+
"text": "Great point here!",
48
48
+
"author": "did:plc:collaborator",
49
49
+
"createdAt": "2026-03-11T..."
50
50
+
}
51
51
+
```
52
52
+
53
53
+
**Note:** The paragraph ID is generated client-side (e.g., from the ProseMirror/CodeMirror node ID).
54
54
+
55
55
+
## 3. Invite System
56
56
+
57
57
+
- Creator clicks "Share" → generates invite link: `diffdown.app/doc/{rkey}?invite={token}`
58
58
+
- Token stored in SQLite with: `document_rkey`, `token`, `created_by_did`, `created_at`
59
59
+
- Collaborator opens link → server validates token, adds their DID to `collaborators` array in document record via XRPC `app.bsky.feed.post` (actually custom `com.diffdown.document` PUT)
60
60
+
- Collaborator authenticates via ATProto OAuth if not already logged in
61
61
+
62
62
+
## 4. WebSocket Protocol
63
63
+
64
64
+
### Message Types (JSON over WS)
65
65
+
66
66
+
**Client → Server:**
67
67
+
68
68
+
```json
69
69
+
{ "type": "join", "rkey": "abc123", "did": "did:plc:..." }
70
70
+
```
71
71
+
72
72
+
```json
73
73
+
{ "type": "edit", "delta": { "from": 10, "to": 15, "insert": "new text" } }
74
74
+
```
75
75
+
76
76
+
```json
77
77
+
{ "type": "cursor", "position": 42, "selectionEnd": 55 }
78
78
+
```
79
79
+
80
80
+
```json
81
81
+
{ "type": "comment", "paragraphId": "p-3", "text": "Nice paragraph!" }
82
82
+
```
83
83
+
84
84
+
**Server → Client:**
85
85
+
86
86
+
```json
87
87
+
{ "type": "presence", "users": [{ "did": "did:plc:...", "name": "Alice", "color": "#ff0000" }] }
88
88
+
```
89
89
+
90
90
+
```json
91
91
+
{ "type": "edit", "delta": { "from": 10, "to": 15, "insert": "new text" }, "author": "did:plc:..." }
92
92
+
```
93
93
+
94
94
+
```json
95
95
+
{ "type": "comment", "paragraphId": "p-3", "author": { "did": "...", "name": "Alice" }, "text": "Nice!" }
96
96
+
```
97
97
+
98
98
+
```json
99
99
+
{ "type": "sync", "content": "full document text" }
100
100
+
```
101
101
+
102
102
+
### Flow
103
103
+
104
104
+
1. Collaborator connects to `WS /ws/doc/{rkey}` with ATProto session
105
105
+
2. Server validates session, fetches document, checks DID is in `collaborators` list
106
106
+
3. Server adds to WebSocket room for that document
107
107
+
4. On edit: server broadcasts to all other clients in room
108
108
+
5. Server debounces (2s) and pushes to ATProto PDS
109
109
+
6. On disconnect: remove from presence list
110
110
+
111
111
+
## 5. Operational Transform
112
112
+
113
113
+
Simplified OT approach:
114
114
+
115
115
+
- Server maintains canonical document text
116
116
+
- Edits are applied in order received
117
117
+
- If concurrent edits conflict, last-write-wins with log (acceptable for MVP)
118
118
+
- Full OT library (like ot-text) deferred to future
119
119
+
120
120
+
## 6. Authorization
121
121
+
122
122
+
- ATProto session passed via WebSocket subprotocol or query param
123
123
+
- Server uses existing `xrpcClient(r)` pattern for authenticated requests
124
124
+
- Document access check: `collaborators` array must contain requester's DID
125
125
+
126
126
+
## 7. Backend Components (Go)
127
127
+
128
128
+
### New Package: `internal/collaboration/`
129
129
+
130
130
+
| File | Purpose |
131
131
+
|------|---------|
132
132
+
| `hub.go` | WebSocket connection manager, room per document |
133
133
+
| `client.go` | Represents one connected collaborator |
134
134
+
| `invite.go` | Invite token generation (SHA256), validation |
135
135
+
| `ot.go` | Operational transform helpers |
136
136
+
137
137
+
### Extended `internal/handler/`
138
138
+
139
139
+
| Handler | Method | Purpose |
140
140
+
|---------|--------|---------|
141
141
+
| `DocumentInvite` | `POST /api/docs/{rkey}/invite` | Generate invite link |
142
142
+
| `DocumentCollaborators` | `GET /api/docs/{rkey}/collaborators` | List current collaborators |
143
143
+
| `CollaboratorWebSocket` | `GET /ws/doc/{rkey}` | WebSocket upgrade |
144
144
+
| `CommentCreate` | `POST /api/docs/{rkey}/comments` | Create comment |
145
145
+
| `CommentList` | `GET /api/docs/{rkey}/comments` | List comments for document |
146
146
+
147
147
+
### New Model Types
148
148
+
149
149
+
```go
150
150
+
type Invite struct {
151
151
+
ID string
152
152
+
DocumentRKey string
153
153
+
Token string
154
154
+
CreatedBy string // DID
155
155
+
CreatedAt time.Time
156
156
+
}
157
157
+
158
158
+
type Comment struct {
159
159
+
URI string
160
160
+
DocumentURI string
161
161
+
ParagraphID string
162
162
+
Text string
163
163
+
AuthorDID string
164
164
+
CreatedAt string
165
165
+
}
166
166
+
```
167
167
+
168
168
+
## 8. Frontend Changes
169
169
+
170
170
+
### `document_edit.html`
171
171
+
172
172
+
1. **WebSocket client** — connect on page load if collaborator
173
173
+
2. **Presence sidebar** — show active collaborators (name + color dot)
174
174
+
3. **Comment UI** — click paragraph → inline comment form
175
175
+
4. **Comment display** — show comment threads below paragraphs
176
176
+
5. **Visual feedback** — highlight own edits, indicate sync status
177
177
+
178
178
+
### CSS Updates
179
179
+
180
180
+
- `.collaborator-cursor` — colored vertical bar with name label
181
181
+
- `.comment-thread` — comment list below paragraph
182
182
+
- `.presence-avatar` — small colored circle in sidebar
183
183
+
184
184
+
## 9. ATProto Integration
185
185
+
186
186
+
### Document Record Update
187
187
+
188
188
+
When adding a collaborator, PUT to PDS:
189
189
+
```
190
190
+
POST /xrpc/com.atproto.repo.putRecord
191
191
+
{
192
192
+
"repo": creatorDID,
193
193
+
"collection": "com.diffdown.document",
194
194
+
"rkey": documentRKey,
195
195
+
"record": {
196
196
+
"$type": "com.diffdown.document",
197
197
+
"title": "...",
198
198
+
"content": { ... },
199
199
+
"textContent": "...",
200
200
+
"collaborators": ["did:plc:existing", "did:plc:new"]
201
201
+
}
202
202
+
}
203
203
+
```
204
204
+
205
205
+
### Comment Records
206
206
+
207
207
+
Create comment:
208
208
+
```
209
209
+
POST /xrpc/com.atproto.repo.createRecord
210
210
+
{
211
211
+
"repo": commenterDID,
212
212
+
"collection": "com.diffdown.comment",
213
213
+
"record": {
214
214
+
"$type": "com.diffdown.comment",
215
215
+
"documentURI": "at://...",
216
216
+
"paragraphId": "p-3",
217
217
+
"text": "...",
218
218
+
"author": commenterDID
219
219
+
}
220
220
+
}
221
221
+
```
222
222
+
223
223
+
## 10. Edge Cases
224
224
+
225
225
+
| Scenario | Handling |
226
226
+
|----------|----------|
227
227
+
| Creator deletes document | Close all WS connections, invalidate invites |
228
228
+
| Collaborator loses access (removed) | WS disconnects, shows "Access removed" |
229
229
+
| PDS unreachable | Show offline indicator, queue edits locally |
230
230
+
| Concurrent edits conflict | Last-write-wins, log conflict for debugging |
231
231
+
| Invite token expired | After 7 days, show "Invite expired" |
232
232
+
233
233
+
## 11. Limits
234
234
+
235
235
+
- Maximum 5 collaborators per document (enforced on invite)
236
236
+
- Maximum 100 comments per paragraph (soft limit)
237
237
+
- WebSocket message size: 64KB max
238
238
+
239
239
+
## 12. Future Considerations
240
240
+
241
241
+
- **Full CRDT**: Replace OT with Yjs or similar for better offline support
242
242
+
- **Threaded comments**: Add `replyTo` field for nested replies
243
243
+
- **Real-time cursor**: Show cursor positions and selections
244
244
+
- **Inline suggestions**: Track suggestion mode (like Google Docs)
245
245
+
- **Comment resolution**: Mark comments as resolved/dismissed