@tldraw/sync-collaboration 5.3.0-next.2fa9c61a8de6 → 5.3.0-next.7654e7ac2a02

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.
@@ -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,42 +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
- * `null` for anonymous sessions — they can't create comments or threads, and can't perform
19
- * any owner-only action. Called exactly once per authorized write.
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
56
  getUserId(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): string | null
22
57
 
23
58
  /**
24
- * Whether a session may write comment records at all — checked before the per-type rules on
25
- * every create, update, and delete. Defaults to `({ isReadonly }) => !isReadonly`: comment
26
- * writes follow canvas access, so read-only viewers can read threads but not post, edit,
27
- * resolve, or react. Override to decouple the lanes — `() => true` allows commenting on a
28
- * read-only canvas (comment-only setups) — or to enforce custom criteria from the session.
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.
29
62
  */
30
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
31
100
  }
32
101
 
33
102
  /**
34
103
  * Server-side write authorization for comment records, for use with a sync server's
35
- * `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces comment and
36
- * thread authorship from the session's identity so nothing can be posted, resolved, or deleted
37
- * 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:
38
106
  *
39
- * - `comment`: `authorId` is stamped from the session on create (anonymous creates are
40
- * rejected) and immutable afterwards; only the author may update. `threadId` and `createdAt`
41
- * are immutable too — a comment can't be re-parented or back-dated after the fact.
42
- * - `comment-thread`: `createdBy` and `createdAt` are stamped/fixed on create. Anyone with access
43
- * may resolve/reopen, but a non-null `resolved.by` must be the session's own user.
44
- * - `comment-reaction`: `userId` is stamped on create and immutable; a create must land at the
45
- * canonical id for its (comment, user, emoji) triple, everything identity-bearing is immutable
46
- * on update, and only the reactor may delete their own reaction.
47
- * - Deletion is soft for comments and threads: a write-once `isDeleted` flag that only the
48
- * record's owner may set, never cleared, never set at create. Client hard-deletes are always
49
- * rejected — record removals are server-side only.
50
- * - `canComment` gates every create, update, and delete above, before the per-type rules run.
51
- * By default it mirrors the session's canvas access (`!isReadonly`), so read-only viewers can
52
- * read threads but not write to them; override it to decouple commenting from canvas access.
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.
53
120
  *
54
121
  * Comment records ride alongside your document records, so widen the room's record union to
55
122
  * include them, then spread the result into the authorizer map alongside your own entries:
@@ -74,7 +141,12 @@ export interface CommentAuthorizerOptions<SessionMeta> {
74
141
  export function createCommentAuthorizers<SessionMeta>(
75
142
  opts: CommentAuthorizerOptions<SessionMeta>
76
143
  ): TLRecordAuthorizers<TLComment | TLCommentThread | TLCommentReaction, SessionMeta> {
77
- const { getUserId, canComment = ({ isReadonly }: { isReadonly: boolean }) => !isReadonly } = opts
144
+ const {
145
+ getUserId,
146
+ canComment = ({ isReadonly }: { isReadonly: boolean }) => !isReadonly,
147
+ canModifyComment = ({ userId, ownerId }: CommentModificationAuthContext<SessionMeta>) =>
148
+ userId === ownerId,
149
+ } = opts
78
150
 
79
151
  /** A rule is an authorizer that receives the session's user id, resolved for it exactly once. */
80
152
  type Rule<Rec extends UnknownRecord> = (
@@ -118,14 +190,22 @@ export function createCommentAuthorizers<SessionMeta>(
118
190
  }
119
191
 
120
192
  /**
121
- * Police a soft-deleted record type on top of `base`: deletion is a write-once `isDeleted`
122
- * flag — set exactly once, never cleared, only by the record's owner (`ownerOf`), never on
123
- * create — and clients never hard-delete these records at all. Record removals are
124
- * server-initiated only (server-side deletes don't run authorizers), so once the server
125
- * prunes a flagged record there is no un-delete.
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`.
126
205
  */
127
206
  function authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(
128
207
  ownerOf: (rec: Rec) => string,
208
+ modificationFor: (rec: Rec, write: 'edit' | 'delete') => CommentModification | null,
129
209
  base: Rule<Rec>
130
210
  ): Rule<Rec> {
131
211
  return (userId, args) => {
@@ -133,22 +213,30 @@ export function createCommentAuthorizers<SessionMeta>(
133
213
  const result = base(userId, args)
134
214
  if (!result) return null
135
215
  // A record can't be born deleted — that would smuggle a deletion past the update checks.
136
- if (args.type === 'create' && args.next.isDeleted) return null
137
- if (args.type === 'update') {
138
- const { prev, next } = args
139
- if (prev.isDeleted !== next.isDeleted) {
140
- if (prev.isDeleted) return null // write-once: never cleared
141
- if (userId !== ownerOf(prev)) return null // only the owner deletes
142
- }
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 })
143
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
144
233
  return result
145
234
  }
146
235
  }
147
236
 
148
237
  /**
149
238
  * Threads stay editable by anyone with access (resolve/reopen), but resolution is itself an
150
- * attribution: a non-null `resolved.by`, set at create or changed by update, must be the
151
- * session's own user.
239
+ * attribution: a non-null `resolved.by` must be the session's own user.
152
240
  */
153
241
  const authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {
154
242
  const result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)
@@ -168,12 +256,9 @@ export function createCommentAuthorizers<SessionMeta>(
168
256
  }
169
257
 
170
258
  /**
171
- * Reject an update that changes any of `fields`. Used for the structural fields an update must
172
- * never touch: a comment's parent thread and its creation time. `threadId` is what ties a
173
- * comment to its conversation (and, downstream, to a file), so letting an author re-parent an
174
- * existing comment would move it between threads — and, where threads span files, between
175
- * files. `createdAt` orders threads and bounds the notification feed, so a mutable one lets a
176
- * 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.
177
262
  */
178
263
  function immutableFields<Rec extends UnknownRecord>(
179
264
  fields: readonly (keyof Rec & string)[],
@@ -195,21 +280,15 @@ export function createCommentAuthorizers<SessionMeta>(
195
280
  })
196
281
 
197
282
  /**
198
- * A reaction's id is derived from its (comment, user, emoji) triple (see
199
- * `createCommentReactionId`), which is what makes reaction identity structural. The base rule
200
- * already stamps `userId` from the session and lets only the owner change a reaction — but the
201
- * id, the comment it points at, and the emoji are all client-supplied, so this wrapper adds two
202
- * 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:
203
286
  *
204
- * - On **create**, the id must be the canonical id for `commentId` + the session's user +
205
- * `next.emoji`. Without this a forged client could create a record at another user's id slot
206
- * (locking them out of that reaction), or push a mismatched id that lands two records on one
207
- * (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.
208
289
  *
209
- * - On **update**, everything identity-bearing is immutable: `commentId`, `threadId`, `pageId`,
210
- * and `emoji` all feed the id (directly or by denormalization), so a re-react is a
211
- * create/delete, not an update. The only thing an update may touch is `createdAt`/`meta`.
212
- * 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.
213
292
  */
214
293
  const authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {
215
294
  // Only the reactor may remove their own reaction. Cascades still sweep every reactor's
@@ -242,28 +321,34 @@ export function createCommentAuthorizers<SessionMeta>(
242
321
  comment: withUserId(
243
322
  authorizeSoftDeleted<TLComment>(
244
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
+ //
245
332
  // `pageId` stays mutable: it's denormalized from the thread, and moving an anchored
246
333
  // thread between pages rewrites it on every comment in the thread.
247
334
  immutableFields<TLComment>(
248
335
  ['threadId', 'createdAt'],
249
- authorizeAuthored<TLComment>('authorId', { ownerOnlyUpdate: true })
336
+ authorizeAuthored<TLComment>('authorId')
250
337
  )
251
338
  )
252
339
  ),
253
340
  'comment-thread': withUserId(
254
341
  authorizeSoftDeleted<TLCommentThread>(
255
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),
256
346
  immutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)
257
347
  )
258
348
  ),
259
- // A reaction is one user's own record, so the standard attribution rules mostly cover it:
260
- // `userId` is stamped from the session and only the reactor can change their reaction, and
261
- // the wrapper's id check ties the record to its (comment, user, emoji) slot — so no one can
262
- // forge or hijack another user's reaction. Deletion, though, is deliberately open: anyone
263
- // with access to the room may hard-delete any reaction. Reactions have no soft-delete /
264
- // `isDeleted` flag (unlike comments) on purpose — a reaction is a toggle, so removing one is
265
- // a plain record delete, and a host cascading a comment or thread deletion must sweep every
266
- // 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.
267
352
  'comment-reaction': withUserId(authorizeReaction),
268
353
  }
269
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 { type CommentAuthorizerOptions, createCommentAuthorizers } from './comment-authorizers'
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,