@tldraw/sync-collaboration 0.0.0-bootstrap → 5.3.0-canary.04044ed9e96d
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/comment-authorizers.js +37 -20
- package/dist-cjs/comment-authorizers.js.map +2 -2
- package/dist-cjs/index.d.ts +109 -18
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-esm/comment-authorizers.mjs +37 -20
- package/dist-esm/comment-authorizers.mjs.map +2 -2
- package/dist-esm/index.d.mts +109 -18
- package/dist-esm/index.mjs +4 -2
- package/dist-esm/index.mjs.map +2 -2
- package/package.json +5 -5
- package/src/comment-authorizers.test.ts +519 -2
- package/src/comment-authorizers.ts +166 -63
- package/src/index.ts +6 -1
|
@@ -6,6 +6,41 @@ import {
|
|
|
6
6
|
type TLCommentReaction,
|
|
7
7
|
type TLCommentThread,
|
|
8
8
|
} from '@tldraw/tlschema'
|
|
9
|
+
import { isEqual } from '@tldraw/utils'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A comment write that belongs to someone in particular, and the stored record it targets — the
|
|
13
|
+
* argument to {@link CommentAuthorizerOptions.canModifyComment}, and the server-side mirror of
|
|
14
|
+
* `CommentModification` in `@tldraw/commenting`.
|
|
15
|
+
*
|
|
16
|
+
* The record is the one the room holds, never the client's version of it: the incoming record is
|
|
17
|
+
* the thing being authorized, so a rule that read it would be asking the writer who owns what
|
|
18
|
+
* they're writing to.
|
|
19
|
+
*
|
|
20
|
+
* Resolving, reopening, and reacting aren't here, matching the client option: none of them is
|
|
21
|
+
* anyone's in particular, so {@link CommentAuthorizerOptions.canComment} is the only gate on them.
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
export type CommentModification =
|
|
26
|
+
| { readonly action: 'edit-comment'; readonly comment: TLComment }
|
|
27
|
+
| { readonly action: 'delete-comment'; readonly comment: TLComment }
|
|
28
|
+
| { readonly action: 'delete-thread'; readonly thread: TLCommentThread }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The argument to {@link CommentAuthorizerOptions.canModifyComment}: which write, against which
|
|
32
|
+
* stored record, by which session.
|
|
33
|
+
*
|
|
34
|
+
* `ownerId` is that record's owner — a comment's `authorId`, a thread's `createdBy` — so a callback
|
|
35
|
+
* widening the default doesn't have to know which field each record keeps it in.
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
export type CommentModificationAuthContext<SessionMeta> = {
|
|
40
|
+
readonly session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }
|
|
41
|
+
readonly userId: string | null
|
|
42
|
+
readonly ownerId: string
|
|
43
|
+
} & CommentModification
|
|
9
44
|
|
|
10
45
|
/**
|
|
11
46
|
* Options for {@link createCommentAuthorizers}.
|
|
@@ -14,30 +49,74 @@ import {
|
|
|
14
49
|
*/
|
|
15
50
|
export interface CommentAuthorizerOptions<SessionMeta> {
|
|
16
51
|
/**
|
|
17
|
-
* Resolve the authenticated user id for a session from its host-provided `meta`. Return
|
|
18
|
-
*
|
|
19
|
-
*
|
|
52
|
+
* Resolve the authenticated user id for a session from its host-provided `meta`. Return `null`
|
|
53
|
+
* for anonymous sessions — they can't create records, and own none, so the default
|
|
54
|
+
* {@link CommentAuthorizerOptions.canModifyComment} grants them no edits or deletes either.
|
|
20
55
|
*/
|
|
21
|
-
getUserId(session: { sessionId: string; meta: SessionMeta }): string | null
|
|
56
|
+
getUserId(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): string | null
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a session may write comment records at all — checked before the per-type rules.
|
|
60
|
+
* Defaults to `({ isReadonly }) => !isReadonly`, so read-only viewers can read threads but not
|
|
61
|
+
* post. Override to decouple the lanes: `() => true` allows commenting on a read-only canvas.
|
|
62
|
+
*/
|
|
63
|
+
canComment?(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): boolean
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a session may make a particular write against a particular stored record: editing or
|
|
67
|
+
* deleting a comment, or deleting a thread. Defaults to
|
|
68
|
+
* `({ userId, ownerId }) => userId === ownerId` — the owner-only rule enforced up to now.
|
|
69
|
+
* Override to widen it (a workspace admin or moderator who may take down anyone's comment) or
|
|
70
|
+
* to narrow it (no edits after an hour).
|
|
71
|
+
*
|
|
72
|
+
* The counterpart to `canModifyComment` in `@tldraw/commenting`, which decides which
|
|
73
|
+
* affordances the UI offers. This one is the real rule, and the two want widening together: a
|
|
74
|
+
* delete the client offers and this rejects is applied locally, vetoed, and rebased away — the
|
|
75
|
+
* comment comes back with nothing to explain it.
|
|
76
|
+
*
|
|
77
|
+
* Asked after {@link CommentAuthorizerOptions.canComment} and after the structural rules, so it
|
|
78
|
+
* can only widen *who* may write, never *what* a write may contain. However permissive the
|
|
79
|
+
* callback, attribution is still stamped from the session and immutable, `isDeleted` is still
|
|
80
|
+
* write-once and never set at create, `threadId` and `createdAt` are still frozen, a
|
|
81
|
+
* resolution is still the resolver's own, and clients still can't hard-delete.
|
|
82
|
+
*
|
|
83
|
+
* A soft delete that changes anything besides the flag is asked about twice — once as the
|
|
84
|
+
* delete, once as an edit — so granting deletes alone can't be talked into an edit.
|
|
85
|
+
*
|
|
86
|
+
* Called at most once per authorized write, twice for that combined case.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* createCommentAuthorizers<SessionMeta>({
|
|
91
|
+
* getUserId: (session) => session.meta.userId,
|
|
92
|
+
* // Moderators may take anything down. Editing stays the author's, whoever you are.
|
|
93
|
+
* canModifyComment: (ctx) =>
|
|
94
|
+
* (ctx.action !== 'edit-comment' && isModerator(ctx.session.meta)) ||
|
|
95
|
+
* ctx.userId === ctx.ownerId,
|
|
96
|
+
* })
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
canModifyComment?(ctx: CommentModificationAuthContext<SessionMeta>): boolean
|
|
22
100
|
}
|
|
23
101
|
|
|
24
102
|
/**
|
|
25
103
|
* Server-side write authorization for comment records, for use with a sync server's
|
|
26
|
-
* `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces
|
|
27
|
-
*
|
|
28
|
-
* in someone else's name:
|
|
104
|
+
* `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces authorship from the
|
|
105
|
+
* session's identity so nothing can be posted, resolved, or deleted in someone else's name:
|
|
29
106
|
*
|
|
30
|
-
* - `comment`: `authorId` is stamped
|
|
31
|
-
*
|
|
32
|
-
* are immutable too
|
|
33
|
-
* - `comment-thread`: `createdBy` and `createdAt` are
|
|
34
|
-
*
|
|
35
|
-
* - `comment-reaction`: `userId` is stamped
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* -
|
|
39
|
-
*
|
|
40
|
-
*
|
|
107
|
+
* - `comment`: `authorId` is stamped on create (anonymous creates rejected) and immutable after,
|
|
108
|
+
* and who may update is `canModifyComment`'s call — the author's, unless widened. `threadId` and
|
|
109
|
+
* `createdAt` are immutable too.
|
|
110
|
+
* - `comment-thread`: `createdBy` and `createdAt` are fixed on create. Anyone with access may
|
|
111
|
+
* resolve/reopen, but a non-null `resolved.by` must be the session's own user.
|
|
112
|
+
* - `comment-reaction`: `userId` is stamped and immutable, a create must land at the canonical id
|
|
113
|
+
* for its (comment, user, emoji) triple, and only the reactor may delete their own.
|
|
114
|
+
* - Deletion is soft for comments and threads: a write-once `isDeleted` flag, never set at create.
|
|
115
|
+
* Client hard-deletes are always rejected — record removals are server-side only.
|
|
116
|
+
* - `canComment` gates every write before the per-type rules, defaulting to `!isReadonly`.
|
|
117
|
+
* - `canModifyComment` decides who may edit a comment, delete a comment, or delete a thread. It
|
|
118
|
+
* defaults to the record's owner, and is asked after the structural rules above, so widening it
|
|
119
|
+
* grants no more than those three writes on records the session doesn't own.
|
|
41
120
|
*
|
|
42
121
|
* Comment records ride alongside your document records, so widen the room's record union to
|
|
43
122
|
* include them, then spread the result into the authorizer map alongside your own entries:
|
|
@@ -62,7 +141,12 @@ export interface CommentAuthorizerOptions<SessionMeta> {
|
|
|
62
141
|
export function createCommentAuthorizers<SessionMeta>(
|
|
63
142
|
opts: CommentAuthorizerOptions<SessionMeta>
|
|
64
143
|
): TLRecordAuthorizers<TLComment | TLCommentThread | TLCommentReaction, SessionMeta> {
|
|
65
|
-
const {
|
|
144
|
+
const {
|
|
145
|
+
getUserId,
|
|
146
|
+
canComment = ({ isReadonly }: { isReadonly: boolean }) => !isReadonly,
|
|
147
|
+
canModifyComment = ({ userId, ownerId }: CommentModificationAuthContext<SessionMeta>) =>
|
|
148
|
+
userId === ownerId,
|
|
149
|
+
} = opts
|
|
66
150
|
|
|
67
151
|
/** A rule is an authorizer that receives the session's user id, resolved for it exactly once. */
|
|
68
152
|
type Rule<Rec extends UnknownRecord> = (
|
|
@@ -70,11 +154,17 @@ export function createCommentAuthorizers<SessionMeta>(
|
|
|
70
154
|
args: Parameters<TLRecordAuthorizer<Rec, SessionMeta>>[0]
|
|
71
155
|
) => Rec | null
|
|
72
156
|
|
|
73
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Adapt a rule to the authorizer signature: gate on `canComment` first, then resolve the
|
|
159
|
+
* session's user id exactly once.
|
|
160
|
+
*/
|
|
74
161
|
function withUserId<Rec extends UnknownRecord>(
|
|
75
162
|
rule: Rule<Rec>
|
|
76
163
|
): TLRecordAuthorizer<Rec, SessionMeta> {
|
|
77
|
-
return (args) =>
|
|
164
|
+
return (args) => {
|
|
165
|
+
if (!canComment(args.session)) return null
|
|
166
|
+
return rule(getUserId(args.session), args)
|
|
167
|
+
}
|
|
78
168
|
}
|
|
79
169
|
|
|
80
170
|
/**
|
|
@@ -100,14 +190,22 @@ export function createCommentAuthorizers<SessionMeta>(
|
|
|
100
190
|
}
|
|
101
191
|
|
|
102
192
|
/**
|
|
103
|
-
* Police a soft-deleted record type on top of `base
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
193
|
+
* Police a soft-deleted record type on top of `base`, asking `canModifyComment` who may make the
|
|
194
|
+
* write: `isDeleted` is write-once, never set at create, and clients never hard-delete these
|
|
195
|
+
* records. Removals are server-initiated only, so once the server prunes a flagged record there
|
|
196
|
+
* is no un-delete.
|
|
197
|
+
*
|
|
198
|
+
* An update here is one of two writes, asked about separately: flipping `isDeleted` is a delete,
|
|
199
|
+
* anything else is an edit. Telling them apart is what lets a host grant deletes without granting
|
|
200
|
+
* edits — and an update that does both has to clear both gates, so a delete can't carry an edit
|
|
201
|
+
* out with it.
|
|
202
|
+
*
|
|
203
|
+
* `modificationFor` returns null for a write `canModifyComment` isn't asked about: a thread's
|
|
204
|
+
* "edit" is a resolve or reopen, which is open to anyone with access and policed by `base`.
|
|
108
205
|
*/
|
|
109
206
|
function authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(
|
|
110
207
|
ownerOf: (rec: Rec) => string,
|
|
208
|
+
modificationFor: (rec: Rec, write: 'edit' | 'delete') => CommentModification | null,
|
|
111
209
|
base: Rule<Rec>
|
|
112
210
|
): Rule<Rec> {
|
|
113
211
|
return (userId, args) => {
|
|
@@ -115,22 +213,30 @@ export function createCommentAuthorizers<SessionMeta>(
|
|
|
115
213
|
const result = base(userId, args)
|
|
116
214
|
if (!result) return null
|
|
117
215
|
// A record can't be born deleted — that would smuggle a deletion past the update checks.
|
|
118
|
-
if (args.type === 'create'
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
216
|
+
if (args.type === 'create') return args.next.isDeleted ? null : result
|
|
217
|
+
|
|
218
|
+
const { prev, next, session } = args
|
|
219
|
+
const mayModify = (write: 'edit' | 'delete') => {
|
|
220
|
+
const modification = modificationFor(prev, write)
|
|
221
|
+
if (!modification) return true
|
|
222
|
+
return canModifyComment({ session, userId, ownerId: ownerOf(prev), ...modification })
|
|
125
223
|
}
|
|
224
|
+
|
|
225
|
+
if (prev.isDeleted === next.isDeleted) return mayModify('edit') ? result : null
|
|
226
|
+
|
|
227
|
+
if (prev.isDeleted) return null // write-once: never cleared
|
|
228
|
+
if (!mayModify('delete')) return null
|
|
229
|
+
// The built-in client deletes by setting the flag and nothing else. An update carrying
|
|
230
|
+
// more than that is also an edit, and has to be allowed as one — otherwise a delete-only
|
|
231
|
+
// permission could rewrite a comment on its way out.
|
|
232
|
+
if (!isEqual({ ...next, isDeleted: prev.isDeleted }, prev) && !mayModify('edit')) return null
|
|
126
233
|
return result
|
|
127
234
|
}
|
|
128
235
|
}
|
|
129
236
|
|
|
130
237
|
/**
|
|
131
238
|
* Threads stay editable by anyone with access (resolve/reopen), but resolution is itself an
|
|
132
|
-
* attribution: a non-null `resolved.by
|
|
133
|
-
* session's own user.
|
|
239
|
+
* attribution: a non-null `resolved.by` must be the session's own user.
|
|
134
240
|
*/
|
|
135
241
|
const authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {
|
|
136
242
|
const result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)
|
|
@@ -150,12 +256,9 @@ export function createCommentAuthorizers<SessionMeta>(
|
|
|
150
256
|
}
|
|
151
257
|
|
|
152
258
|
/**
|
|
153
|
-
* Reject an update that changes any of `fields
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* existing comment would move it between threads — and, where threads span files, between
|
|
157
|
-
* files. `createdAt` orders threads and bounds the notification feed, so a mutable one lets a
|
|
158
|
-
* comment be re-sorted after the fact.
|
|
259
|
+
* Reject an update that changes any of `fields` — the structural ones an update must never touch.
|
|
260
|
+
* A mutable `threadId` would let an author re-parent a comment between conversations (and, where
|
|
261
|
+
* threads span files, between files); a mutable `createdAt` would let it be re-sorted after the fact.
|
|
159
262
|
*/
|
|
160
263
|
function immutableFields<Rec extends UnknownRecord>(
|
|
161
264
|
fields: readonly (keyof Rec & string)[],
|
|
@@ -177,21 +280,15 @@ export function createCommentAuthorizers<SessionMeta>(
|
|
|
177
280
|
})
|
|
178
281
|
|
|
179
282
|
/**
|
|
180
|
-
* A reaction's id is derived from its (comment, user, emoji) triple
|
|
181
|
-
* `
|
|
182
|
-
*
|
|
183
|
-
* id, the comment it points at, and the emoji are all client-supplied, so this wrapper adds two
|
|
184
|
-
* things:
|
|
283
|
+
* A reaction's id is derived from its (comment, user, emoji) triple. The base rule already stamps
|
|
284
|
+
* `userId` and lets only the owner change a reaction, but the id, comment, and emoji are all
|
|
285
|
+
* client-supplied, so this adds:
|
|
185
286
|
*
|
|
186
|
-
* - On **create**, the id must be
|
|
187
|
-
*
|
|
188
|
-
* (locking them out of that reaction), or push a mismatched id that lands two records on one
|
|
189
|
-
* (comment, user, emoji) — an invariant any persistence layer keyed on the triple relies on.
|
|
287
|
+
* - On **create**, the id must be canonical for `commentId` + the session's user + `next.emoji`.
|
|
288
|
+
* Otherwise a forged client could squat another user's id slot, or land two records on one triple.
|
|
190
289
|
*
|
|
191
|
-
* - On **update**, everything
|
|
192
|
-
*
|
|
193
|
-
* create/delete, not an update. The only thing an update may touch is `createdAt`/`meta`.
|
|
194
|
-
* So the id and the fields it is derived from can never drift apart.
|
|
290
|
+
* - On **update**, everything feeding the id is immutable, so a re-react is a create/delete rather
|
|
291
|
+
* than an update and the id can never drift from its fields.
|
|
195
292
|
*/
|
|
196
293
|
const authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {
|
|
197
294
|
// Only the reactor may remove their own reaction. Cascades still sweep every reactor's
|
|
@@ -224,28 +321,34 @@ export function createCommentAuthorizers<SessionMeta>(
|
|
|
224
321
|
comment: withUserId(
|
|
225
322
|
authorizeSoftDeleted<TLComment>(
|
|
226
323
|
(comment) => comment.authorId,
|
|
324
|
+
(comment, write) => ({
|
|
325
|
+
action: write === 'delete' ? 'delete-comment' : 'edit-comment',
|
|
326
|
+
comment,
|
|
327
|
+
}),
|
|
328
|
+
// The owner-only update check that used to sit here (`ownerOnlyUpdate`) is now
|
|
329
|
+
// `canModifyComment`'s to make, since it can tell an edit from a delete. Attribution
|
|
330
|
+
// is still stamped from the session and immutable either way.
|
|
331
|
+
//
|
|
227
332
|
// `pageId` stays mutable: it's denormalized from the thread, and moving an anchored
|
|
228
333
|
// thread between pages rewrites it on every comment in the thread.
|
|
229
334
|
immutableFields<TLComment>(
|
|
230
335
|
['threadId', 'createdAt'],
|
|
231
|
-
authorizeAuthored<TLComment>('authorId'
|
|
336
|
+
authorizeAuthored<TLComment>('authorId')
|
|
232
337
|
)
|
|
233
338
|
)
|
|
234
339
|
),
|
|
235
340
|
'comment-thread': withUserId(
|
|
236
341
|
authorizeSoftDeleted<TLCommentThread>(
|
|
237
342
|
(thread) => thread.createdBy,
|
|
343
|
+
// Resolving and reopening stay open to anyone with access, so a thread's "edit" isn't
|
|
344
|
+
// asked about — only its delete is.
|
|
345
|
+
(thread, write) => (write === 'delete' ? { action: 'delete-thread', thread } : null),
|
|
238
346
|
immutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)
|
|
239
347
|
)
|
|
240
348
|
),
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// forge or hijack another user's reaction. Deletion, though, is deliberately open: anyone
|
|
245
|
-
// with access to the room may hard-delete any reaction. Reactions have no soft-delete /
|
|
246
|
-
// `isDeleted` flag (unlike comments) on purpose — a reaction is a toggle, so removing one is
|
|
247
|
-
// a plain record delete, and a host cascading a comment or thread deletion must sweep every
|
|
248
|
-
// reactor's records, not just the caller's own.
|
|
349
|
+
// Deletion is deliberately open: anyone with room access may hard-delete any reaction. Reactions
|
|
350
|
+
// have no soft-delete flag on purpose — a reaction is a toggle, and a host cascading a comment or
|
|
351
|
+
// thread deletion must sweep every reactor's records, not just the caller's own.
|
|
249
352
|
'comment-reaction': withUserId(authorizeReaction),
|
|
250
353
|
}
|
|
251
354
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { registerTldrawLibraryVersion } from '@tldraw/utils'
|
|
|
2
2
|
|
|
3
3
|
// Server-side logic for tldraw's collaboration features, safe to import from any sync
|
|
4
4
|
// server — no react or client-editor dependencies.
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
type CommentAuthorizerOptions,
|
|
7
|
+
type CommentModification,
|
|
8
|
+
type CommentModificationAuthContext,
|
|
9
|
+
createCommentAuthorizers,
|
|
10
|
+
} from './comment-authorizers'
|
|
6
11
|
|
|
7
12
|
registerTldrawLibraryVersion(
|
|
8
13
|
(globalThis as any).TLDRAW_LIBRARY_NAME,
|