@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.
@@ -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
- * `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
- 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 comment and
27
- * thread authorship from the session's identity so nothing can be posted, resolved, or deleted
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 from the session on create (anonymous creates are
31
- * rejected) and immutable afterwards; only the author may update. `threadId` and `createdAt`
32
- * are immutable too — a comment can't be re-parented or back-dated after the fact.
33
- * - `comment-thread`: `createdBy` and `createdAt` are stamped/fixed on create. Anyone with access
34
- * may resolve/reopen, but a non-null `resolved.by` must be the session's own user.
35
- * - `comment-reaction`: `userId` is stamped on create and immutable; a create must land at the
36
- * canonical id for its (comment, user, emoji) triple, everything identity-bearing is immutable
37
- * on update, and only the reactor may delete their own reaction.
38
- * - Deletion is soft for comments and threads: a write-once `isDeleted` flag that only the
39
- * record's owner may set, never cleared, never set at create. Client hard-deletes are always
40
- * rejected record removals are server-side only.
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 { getUserId } = opts
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
- /** Adapt a rule to the authorizer signature, resolving the session's user id exactly once. */
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) => rule(getUserId(args.session), 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`: deletion is a write-once `isDeleted`
104
- * flag set exactly once, never cleared, only by the record's owner (`ownerOf`), never on
105
- * create and clients never hard-delete these records at all. Record removals are
106
- * server-initiated only (server-side deletes don't run authorizers), so once the server
107
- * 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`.
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' && args.next.isDeleted) return null
119
- if (args.type === 'update') {
120
- const { prev, next } = args
121
- if (prev.isDeleted !== next.isDeleted) {
122
- if (prev.isDeleted) return null // write-once: never cleared
123
- if (userId !== ownerOf(prev)) return null // only the owner deletes
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`, set at create or changed by update, must be the
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`. Used for the structural fields an update must
154
- * never touch: a comment's parent thread and its creation time. `threadId` is what ties a
155
- * comment to its conversation (and, downstream, to a file), so letting an author re-parent an
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 (see
181
- * `createCommentReactionId`), which is what makes reaction identity structural. The base rule
182
- * already stamps `userId` from the session and lets only the owner change a reaction — but the
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 the canonical id for `commentId` + the session's user +
187
- * `next.emoji`. Without this a forged client could create a record at another user's id slot
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 identity-bearing is immutable: `commentId`, `threadId`, `pageId`,
192
- * and `emoji` all feed the id (directly or by denormalization), so a re-react is a
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', { ownerOnlyUpdate: true })
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
- // A reaction is one user's own record, so the standard attribution rules mostly cover it:
242
- // `userId` is stamped from the session and only the reactor can change their reaction, and
243
- // the wrapper's id check ties the record to its (comment, user, emoji) slot so no one can
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 { 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,