@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.
@@ -22,10 +22,18 @@ __export(comment_authorizers_exports, {
22
22
  });
23
23
  module.exports = __toCommonJS(comment_authorizers_exports);
24
24
  var import_tlschema = require("@tldraw/tlschema");
25
+ var import_utils = require("@tldraw/utils");
25
26
  function createCommentAuthorizers(opts) {
26
- const { getUserId } = opts;
27
+ const {
28
+ getUserId,
29
+ canComment = ({ isReadonly }) => !isReadonly,
30
+ canModifyComment = ({ userId, ownerId }) => userId === ownerId
31
+ } = opts;
27
32
  function withUserId(rule) {
28
- return (args) => rule(getUserId(args.session), args);
33
+ return (args) => {
34
+ if (!canComment(args.session)) return null;
35
+ return rule(getUserId(args.session), args);
36
+ };
29
37
  }
30
38
  function authorizeAuthored(field, { ownerOnlyUpdate = false } = {}) {
31
39
  return (userId, { type, prev, next }) => {
@@ -41,19 +49,22 @@ function createCommentAuthorizers(opts) {
41
49
  return prev;
42
50
  };
43
51
  }
44
- function authorizeSoftDeleted(ownerOf, base) {
52
+ function authorizeSoftDeleted(ownerOf, modificationFor, base) {
45
53
  return (userId, args) => {
46
54
  if (args.type === "delete") return null;
47
55
  const result = base(userId, args);
48
56
  if (!result) return null;
49
- if (args.type === "create" && args.next.isDeleted) return null;
50
- if (args.type === "update") {
51
- const { prev, next } = args;
52
- if (prev.isDeleted !== next.isDeleted) {
53
- if (prev.isDeleted) return null;
54
- if (userId !== ownerOf(prev)) return null;
55
- }
56
- }
57
+ if (args.type === "create") return args.next.isDeleted ? null : result;
58
+ const { prev, next, session } = args;
59
+ const mayModify = (write) => {
60
+ const modification = modificationFor(prev, write);
61
+ if (!modification) return true;
62
+ return canModifyComment({ session, userId, ownerId: ownerOf(prev), ...modification });
63
+ };
64
+ if (prev.isDeleted === next.isDeleted) return mayModify("edit") ? result : null;
65
+ if (prev.isDeleted) return null;
66
+ if (!mayModify("delete")) return null;
67
+ if (!(0, import_utils.isEqual)({ ...next, isDeleted: prev.isDeleted }, prev) && !mayModify("edit")) return null;
57
68
  return result;
58
69
  };
59
70
  }
@@ -111,28 +122,34 @@ function createCommentAuthorizers(opts) {
111
122
  comment: withUserId(
112
123
  authorizeSoftDeleted(
113
124
  (comment) => comment.authorId,
125
+ (comment, write) => ({
126
+ action: write === "delete" ? "delete-comment" : "edit-comment",
127
+ comment
128
+ }),
129
+ // The owner-only update check that used to sit here (`ownerOnlyUpdate`) is now
130
+ // `canModifyComment`'s to make, since it can tell an edit from a delete. Attribution
131
+ // is still stamped from the session and immutable either way.
132
+ //
114
133
  // `pageId` stays mutable: it's denormalized from the thread, and moving an anchored
115
134
  // thread between pages rewrites it on every comment in the thread.
116
135
  immutableFields(
117
136
  ["threadId", "createdAt"],
118
- authorizeAuthored("authorId", { ownerOnlyUpdate: true })
137
+ authorizeAuthored("authorId")
119
138
  )
120
139
  )
121
140
  ),
122
141
  "comment-thread": withUserId(
123
142
  authorizeSoftDeleted(
124
143
  (thread) => thread.createdBy,
144
+ // Resolving and reopening stay open to anyone with access, so a thread's "edit" isn't
145
+ // asked about — only its delete is.
146
+ (thread, write) => write === "delete" ? { action: "delete-thread", thread } : null,
125
147
  immutableFields(["createdAt"], authorizeThreadResolution)
126
148
  )
127
149
  ),
128
- // A reaction is one user's own record, so the standard attribution rules mostly cover it:
129
- // `userId` is stamped from the session and only the reactor can change their reaction, and
130
- // the wrapper's id check ties the record to its (comment, user, emoji) slot so no one can
131
- // forge or hijack another user's reaction. Deletion, though, is deliberately open: anyone
132
- // with access to the room may hard-delete any reaction. Reactions have no soft-delete /
133
- // `isDeleted` flag (unlike comments) on purpose — a reaction is a toggle, so removing one is
134
- // a plain record delete, and a host cascading a comment or thread deletion must sweep every
135
- // reactor's records, not just the caller's own.
150
+ // Deletion is deliberately open: anyone with room access may hard-delete any reaction. Reactions
151
+ // have no soft-delete flag on purpose a reaction is a toggle, and a host cascading a comment or
152
+ // thread deletion must sweep every reactor's records, not just the caller's own.
136
153
  "comment-reaction": withUserId(authorizeReaction)
137
154
  };
138
155
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/comment-authorizers.ts"],
4
- "sourcesContent": ["import type { UnknownRecord } from '@tldraw/store'\nimport type { TLRecordAuthorizer, TLRecordAuthorizers } from '@tldraw/sync-core'\nimport {\n\tcreateCommentReactionId,\n\ttype TLComment,\n\ttype TLCommentReaction,\n\ttype TLCommentThread,\n} from '@tldraw/tlschema'\n\n/**\n * Options for {@link createCommentAuthorizers}.\n *\n * @public\n */\nexport interface CommentAuthorizerOptions<SessionMeta> {\n\t/**\n\t * Resolve the authenticated user id for a session from its host-provided `meta`. Return\n\t * `null` for anonymous sessions \u2014 they can't create comments or threads, and can't perform\n\t * any owner-only action. Called exactly once per authorized write.\n\t */\n\tgetUserId(session: { sessionId: string; meta: SessionMeta }): string | null\n}\n\n/**\n * Server-side write authorization for comment records, for use with a sync server's\n * `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces comment and\n * thread authorship from the session's identity so nothing can be posted, resolved, or deleted\n * in someone else's name:\n *\n * - `comment`: `authorId` is stamped from the session on create (anonymous creates are\n * rejected) and immutable afterwards; only the author may update. `threadId` and `createdAt`\n * are immutable too \u2014 a comment can't be re-parented or back-dated after the fact.\n * - `comment-thread`: `createdBy` and `createdAt` are stamped/fixed on create. Anyone with access\n * may resolve/reopen, but a non-null `resolved.by` must be the session's own user.\n * - `comment-reaction`: `userId` is stamped on create and immutable; a create must land at the\n * canonical id for its (comment, user, emoji) triple, everything identity-bearing is immutable\n * on update, and only the reactor may delete their own reaction.\n * - Deletion is soft for comments and threads: a write-once `isDeleted` flag that only the\n * record's owner may set, never cleared, never set at create. Client hard-deletes are always\n * rejected \u2014 record removals are server-side only.\n *\n * Comment records ride alongside your document records, so widen the room's record union to\n * include them, then spread the result into the authorizer map alongside your own entries:\n *\n * @example\n * ```ts\n * interface SessionMeta {\n * \tuserId: string | null\n * }\n *\n * type MyRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction\n *\n * new TLSocketRoom<MyRecord, SessionMeta>({\n * \tauthorizeRecord: {\n * \t\t...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),\n * \t},\n * })\n * ```\n *\n * @public\n */\nexport function createCommentAuthorizers<SessionMeta>(\n\topts: CommentAuthorizerOptions<SessionMeta>\n): TLRecordAuthorizers<TLComment | TLCommentThread | TLCommentReaction, SessionMeta> {\n\tconst { getUserId } = opts\n\n\t/** A rule is an authorizer that receives the session's user id, resolved for it exactly once. */\n\ttype Rule<Rec extends UnknownRecord> = (\n\t\tuserId: string | null,\n\t\targs: Parameters<TLRecordAuthorizer<Rec, SessionMeta>>[0]\n\t) => Rec | null\n\n\t/** Adapt a rule to the authorizer signature, resolving the session's user id exactly once. */\n\tfunction withUserId<Rec extends UnknownRecord>(\n\t\trule: Rule<Rec>\n\t): TLRecordAuthorizer<Rec, SessionMeta> {\n\t\treturn (args) => rule(getUserId(args.session), args)\n\t}\n\n\t/**\n\t * Authorize a record whose attribution lives in `field`: stamped from the session on create,\n\t * immutable on update. With `ownerOnlyUpdate`, only the author may update it at all.\n\t */\n\tfunction authorizeAuthored<Rec extends UnknownRecord>(\n\t\tfield: keyof Rec & string,\n\t\t{ ownerOnlyUpdate = false } = {}\n\t): Rule<Rec> {\n\t\treturn (userId, { type, prev, next }) => {\n\t\t\tif (type === 'create') {\n\t\t\t\tif (!userId) return null // no identity to attribute \u2192 reject\n\t\t\t\treturn { ...next, [field]: userId } as Rec\n\t\t\t}\n\t\t\tif (type === 'update') {\n\t\t\t\tif (next[field] !== prev[field]) return null // attribution is immutable\n\t\t\t\tif (ownerOnlyUpdate && userId !== prev[field]) return null // only the author edits\n\t\t\t\treturn next\n\t\t\t}\n\t\t\treturn prev\n\t\t}\n\t}\n\n\t/**\n\t * Police a soft-deleted record type on top of `base`: deletion is a write-once `isDeleted`\n\t * flag \u2014 set exactly once, never cleared, only by the record's owner (`ownerOf`), never on\n\t * create \u2014 and clients never hard-delete these records at all. Record removals are\n\t * server-initiated only (server-side deletes don't run authorizers), so once the server\n\t * prunes a flagged record there is no un-delete.\n\t */\n\tfunction authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(\n\t\townerOf: (rec: Rec) => string,\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'delete') return null\n\t\t\tconst result = base(userId, args)\n\t\t\tif (!result) return null\n\t\t\t// A record can't be born deleted \u2014 that would smuggle a deletion past the update checks.\n\t\t\tif (args.type === 'create' && args.next.isDeleted) return null\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tif (prev.isDeleted !== next.isDeleted) {\n\t\t\t\t\tif (prev.isDeleted) return null // write-once: never cleared\n\t\t\t\t\tif (userId !== ownerOf(prev)) return null // only the owner deletes\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn result\n\t\t}\n\t}\n\n\t/**\n\t * Threads stay editable by anyone with access (resolve/reopen), but resolution is itself an\n\t * attribution: a non-null `resolved.by`, set at create or changed by update, must be the\n\t * session's own user.\n\t */\n\tconst authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {\n\t\tconst result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Delete + re-put could otherwise smuggle in a resolution forged in someone else's name.\n\t\t\tconst { next } = args\n\t\t\tif (next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tconst changed =\n\t\t\t\tprev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by\n\t\t\tif (changed && next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\treturn result\n\t}\n\n\t/**\n\t * Reject an update that changes any of `fields`. Used for the structural fields an update must\n\t * never touch: a comment's parent thread and its creation time. `threadId` is what ties a\n\t * comment to its conversation (and, downstream, to a file), so letting an author re-parent an\n\t * existing comment would move it between threads \u2014 and, where threads span files, between\n\t * files. `createdAt` orders threads and bounds the notification feed, so a mutable one lets a\n\t * comment be re-sorted after the fact.\n\t */\n\tfunction immutableFields<Rec extends UnknownRecord>(\n\t\tfields: readonly (keyof Rec & string)[],\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tfor (const field of fields) {\n\t\t\t\t\tif (next[field] !== prev[field]) return null\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn base(userId, args)\n\t\t}\n\t}\n\n\tconst authorizeReactionBase = authorizeAuthored<TLCommentReaction>('userId', {\n\t\townerOnlyUpdate: true,\n\t})\n\n\t/**\n\t * A reaction's id is derived from its (comment, user, emoji) triple (see\n\t * `createCommentReactionId`), which is what makes reaction identity structural. The base rule\n\t * already stamps `userId` from the session and lets only the owner change a reaction \u2014 but the\n\t * id, the comment it points at, and the emoji are all client-supplied, so this wrapper adds two\n\t * things:\n\t *\n\t * - On **create**, the id must be the canonical id for `commentId` + the session's user +\n\t * `next.emoji`. Without this a forged client could create a record at another user's id slot\n\t * (locking them out of that reaction), or push a mismatched id that lands two records on one\n\t * (comment, user, emoji) \u2014 an invariant any persistence layer keyed on the triple relies on.\n\t *\n\t * - On **update**, everything identity-bearing is immutable: `commentId`, `threadId`, `pageId`,\n\t * and `emoji` all feed the id (directly or by denormalization), so a re-react is a\n\t * create/delete, not an update. The only thing an update may touch is `createdAt`/`meta`.\n\t * So the id and the fields it is derived from can never drift apart.\n\t */\n\tconst authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {\n\t\t// Only the reactor may remove their own reaction. Cascades still sweep every reactor's\n\t\t// records because server-initiated writes carry no session and so skip authorizers\n\t\t// entirely \u2014 an open client delete was never what made the sweep work.\n\t\tif (args.type === 'delete') {\n\t\t\treturn userId && userId === args.prev.userId ? args.prev : null\n\t\t}\n\t\tconst result = authorizeReactionBase(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Unreachable: the base rule already rejected identity-less creates. Checked to narrow.\n\t\t\tif (!userId) return null\n\t\t\tconst { next } = args\n\t\t\tif (next.id !== createCommentReactionId(next.commentId, userId, next.emoji)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tif (next.commentId !== prev.commentId) return null\n\t\t\tif (next.threadId !== prev.threadId) return null\n\t\t\tif (next.pageId !== prev.pageId) return null\n\t\t\tif (next.emoji !== prev.emoji) return null\n\t\t}\n\t\treturn result\n\t}\n\n\treturn {\n\t\tcomment: withUserId(\n\t\t\tauthorizeSoftDeleted<TLComment>(\n\t\t\t\t(comment) => comment.authorId,\n\t\t\t\t// `pageId` stays mutable: it's denormalized from the thread, and moving an anchored\n\t\t\t\t// thread between pages rewrites it on every comment in the thread.\n\t\t\t\timmutableFields<TLComment>(\n\t\t\t\t\t['threadId', 'createdAt'],\n\t\t\t\t\tauthorizeAuthored<TLComment>('authorId', { ownerOnlyUpdate: true })\n\t\t\t\t)\n\t\t\t)\n\t\t),\n\t\t'comment-thread': withUserId(\n\t\t\tauthorizeSoftDeleted<TLCommentThread>(\n\t\t\t\t(thread) => thread.createdBy,\n\t\t\t\timmutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)\n\t\t\t)\n\t\t),\n\t\t// A reaction is one user's own record, so the standard attribution rules mostly cover it:\n\t\t// `userId` is stamped from the session and only the reactor can change their reaction, and\n\t\t// the wrapper's id check ties the record to its (comment, user, emoji) slot \u2014 so no one can\n\t\t// forge or hijack another user's reaction. Deletion, though, is deliberately open: anyone\n\t\t// with access to the room may hard-delete any reaction. Reactions have no soft-delete /\n\t\t// `isDeleted` flag (unlike comments) on purpose \u2014 a reaction is a toggle, so removing one is\n\t\t// a plain record delete, and a host cascading a comment or thread deletion must sweep every\n\t\t// reactor's records, not just the caller's own.\n\t\t'comment-reaction': withUserId(authorizeReaction),\n\t}\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { UnknownRecord } from '@tldraw/store'\nimport type { TLRecordAuthorizer, TLRecordAuthorizers } from '@tldraw/sync-core'\nimport {\n\tcreateCommentReactionId,\n\ttype TLComment,\n\ttype TLCommentReaction,\n\ttype TLCommentThread,\n} from '@tldraw/tlschema'\nimport { isEqual } from '@tldraw/utils'\n\n/**\n * A comment write that belongs to someone in particular, and the stored record it targets \u2014 the\n * argument to {@link CommentAuthorizerOptions.canModifyComment}, and the server-side mirror of\n * `CommentModification` in `@tldraw/commenting`.\n *\n * The record is the one the room holds, never the client's version of it: the incoming record is\n * the thing being authorized, so a rule that read it would be asking the writer who owns what\n * they're writing to.\n *\n * Resolving, reopening, and reacting aren't here, matching the client option: none of them is\n * anyone's in particular, so {@link CommentAuthorizerOptions.canComment} is the only gate on them.\n *\n * @public\n */\nexport type CommentModification =\n\t| { readonly action: 'edit-comment'; readonly comment: TLComment }\n\t| { readonly action: 'delete-comment'; readonly comment: TLComment }\n\t| { readonly action: 'delete-thread'; readonly thread: TLCommentThread }\n\n/**\n * The argument to {@link CommentAuthorizerOptions.canModifyComment}: which write, against which\n * stored record, by which session.\n *\n * `ownerId` is that record's owner \u2014 a comment's `authorId`, a thread's `createdBy` \u2014 so a callback\n * widening the default doesn't have to know which field each record keeps it in.\n *\n * @public\n */\nexport type CommentModificationAuthContext<SessionMeta> = {\n\treadonly session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }\n\treadonly userId: string | null\n\treadonly ownerId: string\n} & CommentModification\n\n/**\n * Options for {@link createCommentAuthorizers}.\n *\n * @public\n */\nexport interface CommentAuthorizerOptions<SessionMeta> {\n\t/**\n\t * Resolve the authenticated user id for a session from its host-provided `meta`. Return `null`\n\t * for anonymous sessions \u2014 they can't create records, and own none, so the default\n\t * {@link CommentAuthorizerOptions.canModifyComment} grants them no edits or deletes either.\n\t */\n\tgetUserId(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): string | null\n\n\t/**\n\t * Whether a session may write comment records at all \u2014 checked before the per-type rules.\n\t * Defaults to `({ isReadonly }) => !isReadonly`, so read-only viewers can read threads but not\n\t * post. Override to decouple the lanes: `() => true` allows commenting on a read-only canvas.\n\t */\n\tcanComment?(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): boolean\n\n\t/**\n\t * Whether a session may make a particular write against a particular stored record: editing or\n\t * deleting a comment, or deleting a thread. Defaults to\n\t * `({ userId, ownerId }) => userId === ownerId` \u2014 the owner-only rule enforced up to now.\n\t * Override to widen it (a workspace admin or moderator who may take down anyone's comment) or\n\t * to narrow it (no edits after an hour).\n\t *\n\t * The counterpart to `canModifyComment` in `@tldraw/commenting`, which decides which\n\t * affordances the UI offers. This one is the real rule, and the two want widening together: a\n\t * delete the client offers and this rejects is applied locally, vetoed, and rebased away \u2014 the\n\t * comment comes back with nothing to explain it.\n\t *\n\t * Asked after {@link CommentAuthorizerOptions.canComment} and after the structural rules, so it\n\t * can only widen *who* may write, never *what* a write may contain. However permissive the\n\t * callback, attribution is still stamped from the session and immutable, `isDeleted` is still\n\t * write-once and never set at create, `threadId` and `createdAt` are still frozen, a\n\t * resolution is still the resolver's own, and clients still can't hard-delete.\n\t *\n\t * A soft delete that changes anything besides the flag is asked about twice \u2014 once as the\n\t * delete, once as an edit \u2014 so granting deletes alone can't be talked into an edit.\n\t *\n\t * Called at most once per authorized write, twice for that combined case.\n\t *\n\t * @example\n\t * ```ts\n\t * createCommentAuthorizers<SessionMeta>({\n\t * \tgetUserId: (session) => session.meta.userId,\n\t * \t// Moderators may take anything down. Editing stays the author's, whoever you are.\n\t * \tcanModifyComment: (ctx) =>\n\t * \t\t(ctx.action !== 'edit-comment' && isModerator(ctx.session.meta)) ||\n\t * \t\tctx.userId === ctx.ownerId,\n\t * })\n\t * ```\n\t */\n\tcanModifyComment?(ctx: CommentModificationAuthContext<SessionMeta>): boolean\n}\n\n/**\n * Server-side write authorization for comment records, for use with a sync server's\n * `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces authorship from the\n * session's identity so nothing can be posted, resolved, or deleted in someone else's name:\n *\n * - `comment`: `authorId` is stamped on create (anonymous creates rejected) and immutable after,\n * and who may update is `canModifyComment`'s call \u2014 the author's, unless widened. `threadId` and\n * `createdAt` are immutable too.\n * - `comment-thread`: `createdBy` and `createdAt` are fixed on create. Anyone with access may\n * resolve/reopen, but a non-null `resolved.by` must be the session's own user.\n * - `comment-reaction`: `userId` is stamped and immutable, a create must land at the canonical id\n * for its (comment, user, emoji) triple, and only the reactor may delete their own.\n * - Deletion is soft for comments and threads: a write-once `isDeleted` flag, never set at create.\n * Client hard-deletes are always rejected \u2014 record removals are server-side only.\n * - `canComment` gates every write before the per-type rules, defaulting to `!isReadonly`.\n * - `canModifyComment` decides who may edit a comment, delete a comment, or delete a thread. It\n * defaults to the record's owner, and is asked after the structural rules above, so widening it\n * grants no more than those three writes on records the session doesn't own.\n *\n * Comment records ride alongside your document records, so widen the room's record union to\n * include them, then spread the result into the authorizer map alongside your own entries:\n *\n * @example\n * ```ts\n * interface SessionMeta {\n * \tuserId: string | null\n * }\n *\n * type MyRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction\n *\n * new TLSocketRoom<MyRecord, SessionMeta>({\n * \tauthorizeRecord: {\n * \t\t...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),\n * \t},\n * })\n * ```\n *\n * @public\n */\nexport function createCommentAuthorizers<SessionMeta>(\n\topts: CommentAuthorizerOptions<SessionMeta>\n): TLRecordAuthorizers<TLComment | TLCommentThread | TLCommentReaction, SessionMeta> {\n\tconst {\n\t\tgetUserId,\n\t\tcanComment = ({ isReadonly }: { isReadonly: boolean }) => !isReadonly,\n\t\tcanModifyComment = ({ userId, ownerId }: CommentModificationAuthContext<SessionMeta>) =>\n\t\t\tuserId === ownerId,\n\t} = opts\n\n\t/** A rule is an authorizer that receives the session's user id, resolved for it exactly once. */\n\ttype Rule<Rec extends UnknownRecord> = (\n\t\tuserId: string | null,\n\t\targs: Parameters<TLRecordAuthorizer<Rec, SessionMeta>>[0]\n\t) => Rec | null\n\n\t/**\n\t * Adapt a rule to the authorizer signature: gate on `canComment` first, then resolve the\n\t * session's user id exactly once.\n\t */\n\tfunction withUserId<Rec extends UnknownRecord>(\n\t\trule: Rule<Rec>\n\t): TLRecordAuthorizer<Rec, SessionMeta> {\n\t\treturn (args) => {\n\t\t\tif (!canComment(args.session)) return null\n\t\t\treturn rule(getUserId(args.session), args)\n\t\t}\n\t}\n\n\t/**\n\t * Authorize a record whose attribution lives in `field`: stamped from the session on create,\n\t * immutable on update. With `ownerOnlyUpdate`, only the author may update it at all.\n\t */\n\tfunction authorizeAuthored<Rec extends UnknownRecord>(\n\t\tfield: keyof Rec & string,\n\t\t{ ownerOnlyUpdate = false } = {}\n\t): Rule<Rec> {\n\t\treturn (userId, { type, prev, next }) => {\n\t\t\tif (type === 'create') {\n\t\t\t\tif (!userId) return null // no identity to attribute \u2192 reject\n\t\t\t\treturn { ...next, [field]: userId } as Rec\n\t\t\t}\n\t\t\tif (type === 'update') {\n\t\t\t\tif (next[field] !== prev[field]) return null // attribution is immutable\n\t\t\t\tif (ownerOnlyUpdate && userId !== prev[field]) return null // only the author edits\n\t\t\t\treturn next\n\t\t\t}\n\t\t\treturn prev\n\t\t}\n\t}\n\n\t/**\n\t * Police a soft-deleted record type on top of `base`, asking `canModifyComment` who may make the\n\t * write: `isDeleted` is write-once, never set at create, and clients never hard-delete these\n\t * records. Removals are server-initiated only, so once the server prunes a flagged record there\n\t * is no un-delete.\n\t *\n\t * An update here is one of two writes, asked about separately: flipping `isDeleted` is a delete,\n\t * anything else is an edit. Telling them apart is what lets a host grant deletes without granting\n\t * edits \u2014 and an update that does both has to clear both gates, so a delete can't carry an edit\n\t * out with it.\n\t *\n\t * `modificationFor` returns null for a write `canModifyComment` isn't asked about: a thread's\n\t * \"edit\" is a resolve or reopen, which is open to anyone with access and policed by `base`.\n\t */\n\tfunction authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(\n\t\townerOf: (rec: Rec) => string,\n\t\tmodificationFor: (rec: Rec, write: 'edit' | 'delete') => CommentModification | null,\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'delete') return null\n\t\t\tconst result = base(userId, args)\n\t\t\tif (!result) return null\n\t\t\t// A record can't be born deleted \u2014 that would smuggle a deletion past the update checks.\n\t\t\tif (args.type === 'create') return args.next.isDeleted ? null : result\n\n\t\t\tconst { prev, next, session } = args\n\t\t\tconst mayModify = (write: 'edit' | 'delete') => {\n\t\t\t\tconst modification = modificationFor(prev, write)\n\t\t\t\tif (!modification) return true\n\t\t\t\treturn canModifyComment({ session, userId, ownerId: ownerOf(prev), ...modification })\n\t\t\t}\n\n\t\t\tif (prev.isDeleted === next.isDeleted) return mayModify('edit') ? result : null\n\n\t\t\tif (prev.isDeleted) return null // write-once: never cleared\n\t\t\tif (!mayModify('delete')) return null\n\t\t\t// The built-in client deletes by setting the flag and nothing else. An update carrying\n\t\t\t// more than that is also an edit, and has to be allowed as one \u2014 otherwise a delete-only\n\t\t\t// permission could rewrite a comment on its way out.\n\t\t\tif (!isEqual({ ...next, isDeleted: prev.isDeleted }, prev) && !mayModify('edit')) return null\n\t\t\treturn result\n\t\t}\n\t}\n\n\t/**\n\t * Threads stay editable by anyone with access (resolve/reopen), but resolution is itself an\n\t * attribution: a non-null `resolved.by` must be the session's own user.\n\t */\n\tconst authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {\n\t\tconst result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Delete + re-put could otherwise smuggle in a resolution forged in someone else's name.\n\t\t\tconst { next } = args\n\t\t\tif (next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tconst changed =\n\t\t\t\tprev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by\n\t\t\tif (changed && next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\treturn result\n\t}\n\n\t/**\n\t * Reject an update that changes any of `fields` \u2014 the structural ones an update must never touch.\n\t * A mutable `threadId` would let an author re-parent a comment between conversations (and, where\n\t * threads span files, between files); a mutable `createdAt` would let it be re-sorted after the fact.\n\t */\n\tfunction immutableFields<Rec extends UnknownRecord>(\n\t\tfields: readonly (keyof Rec & string)[],\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tfor (const field of fields) {\n\t\t\t\t\tif (next[field] !== prev[field]) return null\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn base(userId, args)\n\t\t}\n\t}\n\n\tconst authorizeReactionBase = authorizeAuthored<TLCommentReaction>('userId', {\n\t\townerOnlyUpdate: true,\n\t})\n\n\t/**\n\t * A reaction's id is derived from its (comment, user, emoji) triple. The base rule already stamps\n\t * `userId` and lets only the owner change a reaction, but the id, comment, and emoji are all\n\t * client-supplied, so this adds:\n\t *\n\t * - On **create**, the id must be canonical for `commentId` + the session's user + `next.emoji`.\n\t * Otherwise a forged client could squat another user's id slot, or land two records on one triple.\n\t *\n\t * - On **update**, everything feeding the id is immutable, so a re-react is a create/delete rather\n\t * than an update and the id can never drift from its fields.\n\t */\n\tconst authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {\n\t\t// Only the reactor may remove their own reaction. Cascades still sweep every reactor's\n\t\t// records because server-initiated writes carry no session and so skip authorizers\n\t\t// entirely \u2014 an open client delete was never what made the sweep work.\n\t\tif (args.type === 'delete') {\n\t\t\treturn userId && userId === args.prev.userId ? args.prev : null\n\t\t}\n\t\tconst result = authorizeReactionBase(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Unreachable: the base rule already rejected identity-less creates. Checked to narrow.\n\t\t\tif (!userId) return null\n\t\t\tconst { next } = args\n\t\t\tif (next.id !== createCommentReactionId(next.commentId, userId, next.emoji)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tif (next.commentId !== prev.commentId) return null\n\t\t\tif (next.threadId !== prev.threadId) return null\n\t\t\tif (next.pageId !== prev.pageId) return null\n\t\t\tif (next.emoji !== prev.emoji) return null\n\t\t}\n\t\treturn result\n\t}\n\n\treturn {\n\t\tcomment: withUserId(\n\t\t\tauthorizeSoftDeleted<TLComment>(\n\t\t\t\t(comment) => comment.authorId,\n\t\t\t\t(comment, write) => ({\n\t\t\t\t\taction: write === 'delete' ? 'delete-comment' : 'edit-comment',\n\t\t\t\t\tcomment,\n\t\t\t\t}),\n\t\t\t\t// The owner-only update check that used to sit here (`ownerOnlyUpdate`) is now\n\t\t\t\t// `canModifyComment`'s to make, since it can tell an edit from a delete. Attribution\n\t\t\t\t// is still stamped from the session and immutable either way.\n\t\t\t\t//\n\t\t\t\t// `pageId` stays mutable: it's denormalized from the thread, and moving an anchored\n\t\t\t\t// thread between pages rewrites it on every comment in the thread.\n\t\t\t\timmutableFields<TLComment>(\n\t\t\t\t\t['threadId', 'createdAt'],\n\t\t\t\t\tauthorizeAuthored<TLComment>('authorId')\n\t\t\t\t)\n\t\t\t)\n\t\t),\n\t\t'comment-thread': withUserId(\n\t\t\tauthorizeSoftDeleted<TLCommentThread>(\n\t\t\t\t(thread) => thread.createdBy,\n\t\t\t\t// Resolving and reopening stay open to anyone with access, so a thread's \"edit\" isn't\n\t\t\t\t// asked about \u2014 only its delete is.\n\t\t\t\t(thread, write) => (write === 'delete' ? { action: 'delete-thread', thread } : null),\n\t\t\t\timmutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)\n\t\t\t)\n\t\t),\n\t\t// Deletion is deliberately open: anyone with room access may hard-delete any reaction. Reactions\n\t\t// have no soft-delete flag on purpose \u2014 a reaction is a toggle, and a host cascading a comment or\n\t\t// thread deletion must sweep every reactor's records, not just the caller's own.\n\t\t'comment-reaction': withUserId(authorizeReaction),\n\t}\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -10,33 +10,124 @@ import type { TLRecordAuthorizers } from '@tldraw/sync-core';
10
10
  */
11
11
  export declare interface CommentAuthorizerOptions<SessionMeta> {
12
12
  /**
13
- * Resolve the authenticated user id for a session from its host-provided `meta`. Return
14
- * `null` for anonymous sessions — they can't create comments or threads, and can't perform
15
- * any owner-only action. Called exactly once per authorized write.
13
+ * Resolve the authenticated user id for a session from its host-provided `meta`. Return `null`
14
+ * for anonymous sessions — they can't create records, and own none, so the default
15
+ * {@link CommentAuthorizerOptions.canModifyComment} grants them no edits or deletes either.
16
16
  */
17
17
  getUserId(session: {
18
+ isReadonly: boolean;
18
19
  meta: SessionMeta;
19
20
  sessionId: string;
20
21
  }): null | string;
22
+ /**
23
+ * Whether a session may write comment records at all — checked before the per-type rules.
24
+ * Defaults to `({ isReadonly }) => !isReadonly`, so read-only viewers can read threads but not
25
+ * post. Override to decouple the lanes: `() => true` allows commenting on a read-only canvas.
26
+ */
27
+ canComment?(session: {
28
+ isReadonly: boolean;
29
+ meta: SessionMeta;
30
+ sessionId: string;
31
+ }): boolean;
32
+ /**
33
+ * Whether a session may make a particular write against a particular stored record: editing or
34
+ * deleting a comment, or deleting a thread. Defaults to
35
+ * `({ userId, ownerId }) => userId === ownerId` — the owner-only rule enforced up to now.
36
+ * Override to widen it (a workspace admin or moderator who may take down anyone's comment) or
37
+ * to narrow it (no edits after an hour).
38
+ *
39
+ * The counterpart to `canModifyComment` in `@tldraw/commenting`, which decides which
40
+ * affordances the UI offers. This one is the real rule, and the two want widening together: a
41
+ * delete the client offers and this rejects is applied locally, vetoed, and rebased away — the
42
+ * comment comes back with nothing to explain it.
43
+ *
44
+ * Asked after {@link CommentAuthorizerOptions.canComment} and after the structural rules, so it
45
+ * can only widen *who* may write, never *what* a write may contain. However permissive the
46
+ * callback, attribution is still stamped from the session and immutable, `isDeleted` is still
47
+ * write-once and never set at create, `threadId` and `createdAt` are still frozen, a
48
+ * resolution is still the resolver's own, and clients still can't hard-delete.
49
+ *
50
+ * A soft delete that changes anything besides the flag is asked about twice — once as the
51
+ * delete, once as an edit — so granting deletes alone can't be talked into an edit.
52
+ *
53
+ * Called at most once per authorized write, twice for that combined case.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * createCommentAuthorizers<SessionMeta>({
58
+ * getUserId: (session) => session.meta.userId,
59
+ * // Moderators may take anything down. Editing stays the author's, whoever you are.
60
+ * canModifyComment: (ctx) =>
61
+ * (ctx.action !== 'edit-comment' && isModerator(ctx.session.meta)) ||
62
+ * ctx.userId === ctx.ownerId,
63
+ * })
64
+ * ```
65
+ */
66
+ canModifyComment?(ctx: CommentModificationAuthContext<SessionMeta>): boolean;
21
67
  }
22
68
 
69
+ /**
70
+ * A comment write that belongs to someone in particular, and the stored record it targets — the
71
+ * argument to {@link CommentAuthorizerOptions.canModifyComment}, and the server-side mirror of
72
+ * `CommentModification` in `@tldraw/commenting`.
73
+ *
74
+ * The record is the one the room holds, never the client's version of it: the incoming record is
75
+ * the thing being authorized, so a rule that read it would be asking the writer who owns what
76
+ * they're writing to.
77
+ *
78
+ * Resolving, reopening, and reacting aren't here, matching the client option: none of them is
79
+ * anyone's in particular, so {@link CommentAuthorizerOptions.canComment} is the only gate on them.
80
+ *
81
+ * @public
82
+ */
83
+ export declare type CommentModification = {
84
+ readonly action: 'delete-comment';
85
+ readonly comment: TLComment;
86
+ } | {
87
+ readonly action: 'delete-thread';
88
+ readonly thread: TLCommentThread;
89
+ } | {
90
+ readonly action: 'edit-comment';
91
+ readonly comment: TLComment;
92
+ };
93
+
94
+ /**
95
+ * The argument to {@link CommentAuthorizerOptions.canModifyComment}: which write, against which
96
+ * stored record, by which session.
97
+ *
98
+ * `ownerId` is that record's owner — a comment's `authorId`, a thread's `createdBy` — so a callback
99
+ * widening the default doesn't have to know which field each record keeps it in.
100
+ *
101
+ * @public
102
+ */
103
+ export declare type CommentModificationAuthContext<SessionMeta> = {
104
+ readonly ownerId: string;
105
+ readonly session: {
106
+ isReadonly: boolean;
107
+ meta: SessionMeta;
108
+ sessionId: string;
109
+ };
110
+ readonly userId: null | string;
111
+ } & CommentModification;
112
+
23
113
  /**
24
114
  * Server-side write authorization for comment records, for use with a sync server's
25
- * `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces comment and
26
- * thread authorship from the session's identity so nothing can be posted, resolved, or deleted
27
- * in someone else's name:
28
- *
29
- * - `comment`: `authorId` is stamped from the session on create (anonymous creates are
30
- * rejected) and immutable afterwards; only the author may update. `threadId` and `createdAt`
31
- * are immutable too a comment can't be re-parented or back-dated after the fact.
32
- * - `comment-thread`: `createdBy` and `createdAt` are stamped/fixed on create. Anyone with access
33
- * may resolve/reopen, but a non-null `resolved.by` must be the session's own user.
34
- * - `comment-reaction`: `userId` is stamped on create and immutable; a create must land at the
35
- * canonical id for its (comment, user, emoji) triple, everything identity-bearing is immutable
36
- * on update, and only the reactor may delete their own reaction.
37
- * - Deletion is soft for comments and threads: a write-once `isDeleted` flag that only the
38
- * record's owner may set, never cleared, never set at create. Client hard-deletes are always
39
- * rejected record removals are server-side only.
115
+ * `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces authorship from the
116
+ * session's identity so nothing can be posted, resolved, or deleted in someone else's name:
117
+ *
118
+ * - `comment`: `authorId` is stamped on create (anonymous creates rejected) and immutable after,
119
+ * and who may update is `canModifyComment`'s call the author's, unless widened. `threadId` and
120
+ * `createdAt` are immutable too.
121
+ * - `comment-thread`: `createdBy` and `createdAt` are fixed on create. Anyone with access may
122
+ * resolve/reopen, but a non-null `resolved.by` must be the session's own user.
123
+ * - `comment-reaction`: `userId` is stamped and immutable, a create must land at the canonical id
124
+ * for its (comment, user, emoji) triple, and only the reactor may delete their own.
125
+ * - Deletion is soft for comments and threads: a write-once `isDeleted` flag, never set at create.
126
+ * Client hard-deletes are always rejected record removals are server-side only.
127
+ * - `canComment` gates every write before the per-type rules, defaulting to `!isReadonly`.
128
+ * - `canModifyComment` decides who may edit a comment, delete a comment, or delete a thread. It
129
+ * defaults to the record's owner, and is asked after the structural rules above, so widening it
130
+ * grants no more than those three writes on records the session doesn't own.
40
131
  *
41
132
  * Comment records ride alongside your document records, so widen the room's record union to
42
133
  * include them, then spread the result into the authorizer map alongside your own entries:
package/dist-cjs/index.js CHANGED
@@ -25,7 +25,7 @@ var import_utils = require("@tldraw/utils");
25
25
  var import_comment_authorizers = require("./comment-authorizers");
26
26
  (0, import_utils.registerTldrawLibraryVersion)(
27
27
  "@tldraw/sync-collaboration",
28
- "0.0.0-bootstrap",
28
+ "5.3.0-canary.04044ed9e96d",
29
29
  "cjs"
30
30
  );
31
31
  //# sourceMappingURL=index.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["import { registerTldrawLibraryVersion } from '@tldraw/utils'\n\n// Server-side logic for tldraw's collaboration features, safe to import from any sync\n// server \u2014 no react or client-editor dependencies.\nexport { type CommentAuthorizerOptions, createCommentAuthorizers } from './comment-authorizers'\n\nregisterTldrawLibraryVersion(\n\t(globalThis as any).TLDRAW_LIBRARY_NAME,\n\t(globalThis as any).TLDRAW_LIBRARY_VERSION,\n\t(globalThis as any).TLDRAW_LIBRARY_MODULES\n)\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA6C;AAI7C,iCAAwE;AAAA,IAExE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AACF;",
4
+ "sourcesContent": ["import { registerTldrawLibraryVersion } from '@tldraw/utils'\n\n// Server-side logic for tldraw's collaboration features, safe to import from any sync\n// server \u2014 no react or client-editor dependencies.\nexport {\n\ttype CommentAuthorizerOptions,\n\ttype CommentModification,\n\ttype CommentModificationAuthContext,\n\tcreateCommentAuthorizers,\n} from './comment-authorizers'\n\nregisterTldrawLibraryVersion(\n\t(globalThis as any).TLDRAW_LIBRARY_NAME,\n\t(globalThis as any).TLDRAW_LIBRARY_VERSION,\n\t(globalThis as any).TLDRAW_LIBRARY_MODULES\n)\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA6C;AAI7C,iCAKO;AAAA,IAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AACF;",
6
6
  "names": []
7
7
  }
@@ -1,10 +1,18 @@
1
1
  import {
2
2
  createCommentReactionId
3
3
  } from "@tldraw/tlschema";
4
+ import { isEqual } from "@tldraw/utils";
4
5
  function createCommentAuthorizers(opts) {
5
- const { getUserId } = opts;
6
+ const {
7
+ getUserId,
8
+ canComment = ({ isReadonly }) => !isReadonly,
9
+ canModifyComment = ({ userId, ownerId }) => userId === ownerId
10
+ } = opts;
6
11
  function withUserId(rule) {
7
- return (args) => rule(getUserId(args.session), args);
12
+ return (args) => {
13
+ if (!canComment(args.session)) return null;
14
+ return rule(getUserId(args.session), args);
15
+ };
8
16
  }
9
17
  function authorizeAuthored(field, { ownerOnlyUpdate = false } = {}) {
10
18
  return (userId, { type, prev, next }) => {
@@ -20,19 +28,22 @@ function createCommentAuthorizers(opts) {
20
28
  return prev;
21
29
  };
22
30
  }
23
- function authorizeSoftDeleted(ownerOf, base) {
31
+ function authorizeSoftDeleted(ownerOf, modificationFor, base) {
24
32
  return (userId, args) => {
25
33
  if (args.type === "delete") return null;
26
34
  const result = base(userId, args);
27
35
  if (!result) return null;
28
- if (args.type === "create" && args.next.isDeleted) return null;
29
- if (args.type === "update") {
30
- const { prev, next } = args;
31
- if (prev.isDeleted !== next.isDeleted) {
32
- if (prev.isDeleted) return null;
33
- if (userId !== ownerOf(prev)) return null;
34
- }
35
- }
36
+ if (args.type === "create") return args.next.isDeleted ? null : result;
37
+ const { prev, next, session } = args;
38
+ const mayModify = (write) => {
39
+ const modification = modificationFor(prev, write);
40
+ if (!modification) return true;
41
+ return canModifyComment({ session, userId, ownerId: ownerOf(prev), ...modification });
42
+ };
43
+ if (prev.isDeleted === next.isDeleted) return mayModify("edit") ? result : null;
44
+ if (prev.isDeleted) return null;
45
+ if (!mayModify("delete")) return null;
46
+ if (!isEqual({ ...next, isDeleted: prev.isDeleted }, prev) && !mayModify("edit")) return null;
36
47
  return result;
37
48
  };
38
49
  }
@@ -90,28 +101,34 @@ function createCommentAuthorizers(opts) {
90
101
  comment: withUserId(
91
102
  authorizeSoftDeleted(
92
103
  (comment) => comment.authorId,
104
+ (comment, write) => ({
105
+ action: write === "delete" ? "delete-comment" : "edit-comment",
106
+ comment
107
+ }),
108
+ // The owner-only update check that used to sit here (`ownerOnlyUpdate`) is now
109
+ // `canModifyComment`'s to make, since it can tell an edit from a delete. Attribution
110
+ // is still stamped from the session and immutable either way.
111
+ //
93
112
  // `pageId` stays mutable: it's denormalized from the thread, and moving an anchored
94
113
  // thread between pages rewrites it on every comment in the thread.
95
114
  immutableFields(
96
115
  ["threadId", "createdAt"],
97
- authorizeAuthored("authorId", { ownerOnlyUpdate: true })
116
+ authorizeAuthored("authorId")
98
117
  )
99
118
  )
100
119
  ),
101
120
  "comment-thread": withUserId(
102
121
  authorizeSoftDeleted(
103
122
  (thread) => thread.createdBy,
123
+ // Resolving and reopening stay open to anyone with access, so a thread's "edit" isn't
124
+ // asked about — only its delete is.
125
+ (thread, write) => write === "delete" ? { action: "delete-thread", thread } : null,
104
126
  immutableFields(["createdAt"], authorizeThreadResolution)
105
127
  )
106
128
  ),
107
- // A reaction is one user's own record, so the standard attribution rules mostly cover it:
108
- // `userId` is stamped from the session and only the reactor can change their reaction, and
109
- // the wrapper's id check ties the record to its (comment, user, emoji) slot so no one can
110
- // forge or hijack another user's reaction. Deletion, though, is deliberately open: anyone
111
- // with access to the room may hard-delete any reaction. Reactions have no soft-delete /
112
- // `isDeleted` flag (unlike comments) on purpose — a reaction is a toggle, so removing one is
113
- // a plain record delete, and a host cascading a comment or thread deletion must sweep every
114
- // reactor's records, not just the caller's own.
129
+ // Deletion is deliberately open: anyone with room access may hard-delete any reaction. Reactions
130
+ // have no soft-delete flag on purpose a reaction is a toggle, and a host cascading a comment or
131
+ // thread deletion must sweep every reactor's records, not just the caller's own.
115
132
  "comment-reaction": withUserId(authorizeReaction)
116
133
  };
117
134
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/comment-authorizers.ts"],
4
- "sourcesContent": ["import type { UnknownRecord } from '@tldraw/store'\nimport type { TLRecordAuthorizer, TLRecordAuthorizers } from '@tldraw/sync-core'\nimport {\n\tcreateCommentReactionId,\n\ttype TLComment,\n\ttype TLCommentReaction,\n\ttype TLCommentThread,\n} from '@tldraw/tlschema'\n\n/**\n * Options for {@link createCommentAuthorizers}.\n *\n * @public\n */\nexport interface CommentAuthorizerOptions<SessionMeta> {\n\t/**\n\t * Resolve the authenticated user id for a session from its host-provided `meta`. Return\n\t * `null` for anonymous sessions \u2014 they can't create comments or threads, and can't perform\n\t * any owner-only action. Called exactly once per authorized write.\n\t */\n\tgetUserId(session: { sessionId: string; meta: SessionMeta }): string | null\n}\n\n/**\n * Server-side write authorization for comment records, for use with a sync server's\n * `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces comment and\n * thread authorship from the session's identity so nothing can be posted, resolved, or deleted\n * in someone else's name:\n *\n * - `comment`: `authorId` is stamped from the session on create (anonymous creates are\n * rejected) and immutable afterwards; only the author may update. `threadId` and `createdAt`\n * are immutable too \u2014 a comment can't be re-parented or back-dated after the fact.\n * - `comment-thread`: `createdBy` and `createdAt` are stamped/fixed on create. Anyone with access\n * may resolve/reopen, but a non-null `resolved.by` must be the session's own user.\n * - `comment-reaction`: `userId` is stamped on create and immutable; a create must land at the\n * canonical id for its (comment, user, emoji) triple, everything identity-bearing is immutable\n * on update, and only the reactor may delete their own reaction.\n * - Deletion is soft for comments and threads: a write-once `isDeleted` flag that only the\n * record's owner may set, never cleared, never set at create. Client hard-deletes are always\n * rejected \u2014 record removals are server-side only.\n *\n * Comment records ride alongside your document records, so widen the room's record union to\n * include them, then spread the result into the authorizer map alongside your own entries:\n *\n * @example\n * ```ts\n * interface SessionMeta {\n * \tuserId: string | null\n * }\n *\n * type MyRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction\n *\n * new TLSocketRoom<MyRecord, SessionMeta>({\n * \tauthorizeRecord: {\n * \t\t...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),\n * \t},\n * })\n * ```\n *\n * @public\n */\nexport function createCommentAuthorizers<SessionMeta>(\n\topts: CommentAuthorizerOptions<SessionMeta>\n): TLRecordAuthorizers<TLComment | TLCommentThread | TLCommentReaction, SessionMeta> {\n\tconst { getUserId } = opts\n\n\t/** A rule is an authorizer that receives the session's user id, resolved for it exactly once. */\n\ttype Rule<Rec extends UnknownRecord> = (\n\t\tuserId: string | null,\n\t\targs: Parameters<TLRecordAuthorizer<Rec, SessionMeta>>[0]\n\t) => Rec | null\n\n\t/** Adapt a rule to the authorizer signature, resolving the session's user id exactly once. */\n\tfunction withUserId<Rec extends UnknownRecord>(\n\t\trule: Rule<Rec>\n\t): TLRecordAuthorizer<Rec, SessionMeta> {\n\t\treturn (args) => rule(getUserId(args.session), args)\n\t}\n\n\t/**\n\t * Authorize a record whose attribution lives in `field`: stamped from the session on create,\n\t * immutable on update. With `ownerOnlyUpdate`, only the author may update it at all.\n\t */\n\tfunction authorizeAuthored<Rec extends UnknownRecord>(\n\t\tfield: keyof Rec & string,\n\t\t{ ownerOnlyUpdate = false } = {}\n\t): Rule<Rec> {\n\t\treturn (userId, { type, prev, next }) => {\n\t\t\tif (type === 'create') {\n\t\t\t\tif (!userId) return null // no identity to attribute \u2192 reject\n\t\t\t\treturn { ...next, [field]: userId } as Rec\n\t\t\t}\n\t\t\tif (type === 'update') {\n\t\t\t\tif (next[field] !== prev[field]) return null // attribution is immutable\n\t\t\t\tif (ownerOnlyUpdate && userId !== prev[field]) return null // only the author edits\n\t\t\t\treturn next\n\t\t\t}\n\t\t\treturn prev\n\t\t}\n\t}\n\n\t/**\n\t * Police a soft-deleted record type on top of `base`: deletion is a write-once `isDeleted`\n\t * flag \u2014 set exactly once, never cleared, only by the record's owner (`ownerOf`), never on\n\t * create \u2014 and clients never hard-delete these records at all. Record removals are\n\t * server-initiated only (server-side deletes don't run authorizers), so once the server\n\t * prunes a flagged record there is no un-delete.\n\t */\n\tfunction authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(\n\t\townerOf: (rec: Rec) => string,\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'delete') return null\n\t\t\tconst result = base(userId, args)\n\t\t\tif (!result) return null\n\t\t\t// A record can't be born deleted \u2014 that would smuggle a deletion past the update checks.\n\t\t\tif (args.type === 'create' && args.next.isDeleted) return null\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tif (prev.isDeleted !== next.isDeleted) {\n\t\t\t\t\tif (prev.isDeleted) return null // write-once: never cleared\n\t\t\t\t\tif (userId !== ownerOf(prev)) return null // only the owner deletes\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn result\n\t\t}\n\t}\n\n\t/**\n\t * Threads stay editable by anyone with access (resolve/reopen), but resolution is itself an\n\t * attribution: a non-null `resolved.by`, set at create or changed by update, must be the\n\t * session's own user.\n\t */\n\tconst authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {\n\t\tconst result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Delete + re-put could otherwise smuggle in a resolution forged in someone else's name.\n\t\t\tconst { next } = args\n\t\t\tif (next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tconst changed =\n\t\t\t\tprev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by\n\t\t\tif (changed && next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\treturn result\n\t}\n\n\t/**\n\t * Reject an update that changes any of `fields`. Used for the structural fields an update must\n\t * never touch: a comment's parent thread and its creation time. `threadId` is what ties a\n\t * comment to its conversation (and, downstream, to a file), so letting an author re-parent an\n\t * existing comment would move it between threads \u2014 and, where threads span files, between\n\t * files. `createdAt` orders threads and bounds the notification feed, so a mutable one lets a\n\t * comment be re-sorted after the fact.\n\t */\n\tfunction immutableFields<Rec extends UnknownRecord>(\n\t\tfields: readonly (keyof Rec & string)[],\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tfor (const field of fields) {\n\t\t\t\t\tif (next[field] !== prev[field]) return null\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn base(userId, args)\n\t\t}\n\t}\n\n\tconst authorizeReactionBase = authorizeAuthored<TLCommentReaction>('userId', {\n\t\townerOnlyUpdate: true,\n\t})\n\n\t/**\n\t * A reaction's id is derived from its (comment, user, emoji) triple (see\n\t * `createCommentReactionId`), which is what makes reaction identity structural. The base rule\n\t * already stamps `userId` from the session and lets only the owner change a reaction \u2014 but the\n\t * id, the comment it points at, and the emoji are all client-supplied, so this wrapper adds two\n\t * things:\n\t *\n\t * - On **create**, the id must be the canonical id for `commentId` + the session's user +\n\t * `next.emoji`. Without this a forged client could create a record at another user's id slot\n\t * (locking them out of that reaction), or push a mismatched id that lands two records on one\n\t * (comment, user, emoji) \u2014 an invariant any persistence layer keyed on the triple relies on.\n\t *\n\t * - On **update**, everything identity-bearing is immutable: `commentId`, `threadId`, `pageId`,\n\t * and `emoji` all feed the id (directly or by denormalization), so a re-react is a\n\t * create/delete, not an update. The only thing an update may touch is `createdAt`/`meta`.\n\t * So the id and the fields it is derived from can never drift apart.\n\t */\n\tconst authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {\n\t\t// Only the reactor may remove their own reaction. Cascades still sweep every reactor's\n\t\t// records because server-initiated writes carry no session and so skip authorizers\n\t\t// entirely \u2014 an open client delete was never what made the sweep work.\n\t\tif (args.type === 'delete') {\n\t\t\treturn userId && userId === args.prev.userId ? args.prev : null\n\t\t}\n\t\tconst result = authorizeReactionBase(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Unreachable: the base rule already rejected identity-less creates. Checked to narrow.\n\t\t\tif (!userId) return null\n\t\t\tconst { next } = args\n\t\t\tif (next.id !== createCommentReactionId(next.commentId, userId, next.emoji)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tif (next.commentId !== prev.commentId) return null\n\t\t\tif (next.threadId !== prev.threadId) return null\n\t\t\tif (next.pageId !== prev.pageId) return null\n\t\t\tif (next.emoji !== prev.emoji) return null\n\t\t}\n\t\treturn result\n\t}\n\n\treturn {\n\t\tcomment: withUserId(\n\t\t\tauthorizeSoftDeleted<TLComment>(\n\t\t\t\t(comment) => comment.authorId,\n\t\t\t\t// `pageId` stays mutable: it's denormalized from the thread, and moving an anchored\n\t\t\t\t// thread between pages rewrites it on every comment in the thread.\n\t\t\t\timmutableFields<TLComment>(\n\t\t\t\t\t['threadId', 'createdAt'],\n\t\t\t\t\tauthorizeAuthored<TLComment>('authorId', { ownerOnlyUpdate: true })\n\t\t\t\t)\n\t\t\t)\n\t\t),\n\t\t'comment-thread': withUserId(\n\t\t\tauthorizeSoftDeleted<TLCommentThread>(\n\t\t\t\t(thread) => thread.createdBy,\n\t\t\t\timmutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)\n\t\t\t)\n\t\t),\n\t\t// A reaction is one user's own record, so the standard attribution rules mostly cover it:\n\t\t// `userId` is stamped from the session and only the reactor can change their reaction, and\n\t\t// the wrapper's id check ties the record to its (comment, user, emoji) slot \u2014 so no one can\n\t\t// forge or hijack another user's reaction. Deletion, though, is deliberately open: anyone\n\t\t// with access to the room may hard-delete any reaction. Reactions have no soft-delete /\n\t\t// `isDeleted` flag (unlike comments) on purpose \u2014 a reaction is a toggle, so removing one is\n\t\t// a plain record delete, and a host cascading a comment or thread deletion must sweep every\n\t\t// reactor's records, not just the caller's own.\n\t\t'comment-reaction': withUserId(authorizeReaction),\n\t}\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { UnknownRecord } from '@tldraw/store'\nimport type { TLRecordAuthorizer, TLRecordAuthorizers } from '@tldraw/sync-core'\nimport {\n\tcreateCommentReactionId,\n\ttype TLComment,\n\ttype TLCommentReaction,\n\ttype TLCommentThread,\n} from '@tldraw/tlschema'\nimport { isEqual } from '@tldraw/utils'\n\n/**\n * A comment write that belongs to someone in particular, and the stored record it targets \u2014 the\n * argument to {@link CommentAuthorizerOptions.canModifyComment}, and the server-side mirror of\n * `CommentModification` in `@tldraw/commenting`.\n *\n * The record is the one the room holds, never the client's version of it: the incoming record is\n * the thing being authorized, so a rule that read it would be asking the writer who owns what\n * they're writing to.\n *\n * Resolving, reopening, and reacting aren't here, matching the client option: none of them is\n * anyone's in particular, so {@link CommentAuthorizerOptions.canComment} is the only gate on them.\n *\n * @public\n */\nexport type CommentModification =\n\t| { readonly action: 'edit-comment'; readonly comment: TLComment }\n\t| { readonly action: 'delete-comment'; readonly comment: TLComment }\n\t| { readonly action: 'delete-thread'; readonly thread: TLCommentThread }\n\n/**\n * The argument to {@link CommentAuthorizerOptions.canModifyComment}: which write, against which\n * stored record, by which session.\n *\n * `ownerId` is that record's owner \u2014 a comment's `authorId`, a thread's `createdBy` \u2014 so a callback\n * widening the default doesn't have to know which field each record keeps it in.\n *\n * @public\n */\nexport type CommentModificationAuthContext<SessionMeta> = {\n\treadonly session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }\n\treadonly userId: string | null\n\treadonly ownerId: string\n} & CommentModification\n\n/**\n * Options for {@link createCommentAuthorizers}.\n *\n * @public\n */\nexport interface CommentAuthorizerOptions<SessionMeta> {\n\t/**\n\t * Resolve the authenticated user id for a session from its host-provided `meta`. Return `null`\n\t * for anonymous sessions \u2014 they can't create records, and own none, so the default\n\t * {@link CommentAuthorizerOptions.canModifyComment} grants them no edits or deletes either.\n\t */\n\tgetUserId(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): string | null\n\n\t/**\n\t * Whether a session may write comment records at all \u2014 checked before the per-type rules.\n\t * Defaults to `({ isReadonly }) => !isReadonly`, so read-only viewers can read threads but not\n\t * post. Override to decouple the lanes: `() => true` allows commenting on a read-only canvas.\n\t */\n\tcanComment?(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): boolean\n\n\t/**\n\t * Whether a session may make a particular write against a particular stored record: editing or\n\t * deleting a comment, or deleting a thread. Defaults to\n\t * `({ userId, ownerId }) => userId === ownerId` \u2014 the owner-only rule enforced up to now.\n\t * Override to widen it (a workspace admin or moderator who may take down anyone's comment) or\n\t * to narrow it (no edits after an hour).\n\t *\n\t * The counterpart to `canModifyComment` in `@tldraw/commenting`, which decides which\n\t * affordances the UI offers. This one is the real rule, and the two want widening together: a\n\t * delete the client offers and this rejects is applied locally, vetoed, and rebased away \u2014 the\n\t * comment comes back with nothing to explain it.\n\t *\n\t * Asked after {@link CommentAuthorizerOptions.canComment} and after the structural rules, so it\n\t * can only widen *who* may write, never *what* a write may contain. However permissive the\n\t * callback, attribution is still stamped from the session and immutable, `isDeleted` is still\n\t * write-once and never set at create, `threadId` and `createdAt` are still frozen, a\n\t * resolution is still the resolver's own, and clients still can't hard-delete.\n\t *\n\t * A soft delete that changes anything besides the flag is asked about twice \u2014 once as the\n\t * delete, once as an edit \u2014 so granting deletes alone can't be talked into an edit.\n\t *\n\t * Called at most once per authorized write, twice for that combined case.\n\t *\n\t * @example\n\t * ```ts\n\t * createCommentAuthorizers<SessionMeta>({\n\t * \tgetUserId: (session) => session.meta.userId,\n\t * \t// Moderators may take anything down. Editing stays the author's, whoever you are.\n\t * \tcanModifyComment: (ctx) =>\n\t * \t\t(ctx.action !== 'edit-comment' && isModerator(ctx.session.meta)) ||\n\t * \t\tctx.userId === ctx.ownerId,\n\t * })\n\t * ```\n\t */\n\tcanModifyComment?(ctx: CommentModificationAuthContext<SessionMeta>): boolean\n}\n\n/**\n * Server-side write authorization for comment records, for use with a sync server's\n * `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces authorship from the\n * session's identity so nothing can be posted, resolved, or deleted in someone else's name:\n *\n * - `comment`: `authorId` is stamped on create (anonymous creates rejected) and immutable after,\n * and who may update is `canModifyComment`'s call \u2014 the author's, unless widened. `threadId` and\n * `createdAt` are immutable too.\n * - `comment-thread`: `createdBy` and `createdAt` are fixed on create. Anyone with access may\n * resolve/reopen, but a non-null `resolved.by` must be the session's own user.\n * - `comment-reaction`: `userId` is stamped and immutable, a create must land at the canonical id\n * for its (comment, user, emoji) triple, and only the reactor may delete their own.\n * - Deletion is soft for comments and threads: a write-once `isDeleted` flag, never set at create.\n * Client hard-deletes are always rejected \u2014 record removals are server-side only.\n * - `canComment` gates every write before the per-type rules, defaulting to `!isReadonly`.\n * - `canModifyComment` decides who may edit a comment, delete a comment, or delete a thread. It\n * defaults to the record's owner, and is asked after the structural rules above, so widening it\n * grants no more than those three writes on records the session doesn't own.\n *\n * Comment records ride alongside your document records, so widen the room's record union to\n * include them, then spread the result into the authorizer map alongside your own entries:\n *\n * @example\n * ```ts\n * interface SessionMeta {\n * \tuserId: string | null\n * }\n *\n * type MyRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction\n *\n * new TLSocketRoom<MyRecord, SessionMeta>({\n * \tauthorizeRecord: {\n * \t\t...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),\n * \t},\n * })\n * ```\n *\n * @public\n */\nexport function createCommentAuthorizers<SessionMeta>(\n\topts: CommentAuthorizerOptions<SessionMeta>\n): TLRecordAuthorizers<TLComment | TLCommentThread | TLCommentReaction, SessionMeta> {\n\tconst {\n\t\tgetUserId,\n\t\tcanComment = ({ isReadonly }: { isReadonly: boolean }) => !isReadonly,\n\t\tcanModifyComment = ({ userId, ownerId }: CommentModificationAuthContext<SessionMeta>) =>\n\t\t\tuserId === ownerId,\n\t} = opts\n\n\t/** A rule is an authorizer that receives the session's user id, resolved for it exactly once. */\n\ttype Rule<Rec extends UnknownRecord> = (\n\t\tuserId: string | null,\n\t\targs: Parameters<TLRecordAuthorizer<Rec, SessionMeta>>[0]\n\t) => Rec | null\n\n\t/**\n\t * Adapt a rule to the authorizer signature: gate on `canComment` first, then resolve the\n\t * session's user id exactly once.\n\t */\n\tfunction withUserId<Rec extends UnknownRecord>(\n\t\trule: Rule<Rec>\n\t): TLRecordAuthorizer<Rec, SessionMeta> {\n\t\treturn (args) => {\n\t\t\tif (!canComment(args.session)) return null\n\t\t\treturn rule(getUserId(args.session), args)\n\t\t}\n\t}\n\n\t/**\n\t * Authorize a record whose attribution lives in `field`: stamped from the session on create,\n\t * immutable on update. With `ownerOnlyUpdate`, only the author may update it at all.\n\t */\n\tfunction authorizeAuthored<Rec extends UnknownRecord>(\n\t\tfield: keyof Rec & string,\n\t\t{ ownerOnlyUpdate = false } = {}\n\t): Rule<Rec> {\n\t\treturn (userId, { type, prev, next }) => {\n\t\t\tif (type === 'create') {\n\t\t\t\tif (!userId) return null // no identity to attribute \u2192 reject\n\t\t\t\treturn { ...next, [field]: userId } as Rec\n\t\t\t}\n\t\t\tif (type === 'update') {\n\t\t\t\tif (next[field] !== prev[field]) return null // attribution is immutable\n\t\t\t\tif (ownerOnlyUpdate && userId !== prev[field]) return null // only the author edits\n\t\t\t\treturn next\n\t\t\t}\n\t\t\treturn prev\n\t\t}\n\t}\n\n\t/**\n\t * Police a soft-deleted record type on top of `base`, asking `canModifyComment` who may make the\n\t * write: `isDeleted` is write-once, never set at create, and clients never hard-delete these\n\t * records. Removals are server-initiated only, so once the server prunes a flagged record there\n\t * is no un-delete.\n\t *\n\t * An update here is one of two writes, asked about separately: flipping `isDeleted` is a delete,\n\t * anything else is an edit. Telling them apart is what lets a host grant deletes without granting\n\t * edits \u2014 and an update that does both has to clear both gates, so a delete can't carry an edit\n\t * out with it.\n\t *\n\t * `modificationFor` returns null for a write `canModifyComment` isn't asked about: a thread's\n\t * \"edit\" is a resolve or reopen, which is open to anyone with access and policed by `base`.\n\t */\n\tfunction authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(\n\t\townerOf: (rec: Rec) => string,\n\t\tmodificationFor: (rec: Rec, write: 'edit' | 'delete') => CommentModification | null,\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'delete') return null\n\t\t\tconst result = base(userId, args)\n\t\t\tif (!result) return null\n\t\t\t// A record can't be born deleted \u2014 that would smuggle a deletion past the update checks.\n\t\t\tif (args.type === 'create') return args.next.isDeleted ? null : result\n\n\t\t\tconst { prev, next, session } = args\n\t\t\tconst mayModify = (write: 'edit' | 'delete') => {\n\t\t\t\tconst modification = modificationFor(prev, write)\n\t\t\t\tif (!modification) return true\n\t\t\t\treturn canModifyComment({ session, userId, ownerId: ownerOf(prev), ...modification })\n\t\t\t}\n\n\t\t\tif (prev.isDeleted === next.isDeleted) return mayModify('edit') ? result : null\n\n\t\t\tif (prev.isDeleted) return null // write-once: never cleared\n\t\t\tif (!mayModify('delete')) return null\n\t\t\t// The built-in client deletes by setting the flag and nothing else. An update carrying\n\t\t\t// more than that is also an edit, and has to be allowed as one \u2014 otherwise a delete-only\n\t\t\t// permission could rewrite a comment on its way out.\n\t\t\tif (!isEqual({ ...next, isDeleted: prev.isDeleted }, prev) && !mayModify('edit')) return null\n\t\t\treturn result\n\t\t}\n\t}\n\n\t/**\n\t * Threads stay editable by anyone with access (resolve/reopen), but resolution is itself an\n\t * attribution: a non-null `resolved.by` must be the session's own user.\n\t */\n\tconst authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {\n\t\tconst result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Delete + re-put could otherwise smuggle in a resolution forged in someone else's name.\n\t\t\tconst { next } = args\n\t\t\tif (next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tconst changed =\n\t\t\t\tprev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by\n\t\t\tif (changed && next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\treturn result\n\t}\n\n\t/**\n\t * Reject an update that changes any of `fields` \u2014 the structural ones an update must never touch.\n\t * A mutable `threadId` would let an author re-parent a comment between conversations (and, where\n\t * threads span files, between files); a mutable `createdAt` would let it be re-sorted after the fact.\n\t */\n\tfunction immutableFields<Rec extends UnknownRecord>(\n\t\tfields: readonly (keyof Rec & string)[],\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tfor (const field of fields) {\n\t\t\t\t\tif (next[field] !== prev[field]) return null\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn base(userId, args)\n\t\t}\n\t}\n\n\tconst authorizeReactionBase = authorizeAuthored<TLCommentReaction>('userId', {\n\t\townerOnlyUpdate: true,\n\t})\n\n\t/**\n\t * A reaction's id is derived from its (comment, user, emoji) triple. The base rule already stamps\n\t * `userId` and lets only the owner change a reaction, but the id, comment, and emoji are all\n\t * client-supplied, so this adds:\n\t *\n\t * - On **create**, the id must be canonical for `commentId` + the session's user + `next.emoji`.\n\t * Otherwise a forged client could squat another user's id slot, or land two records on one triple.\n\t *\n\t * - On **update**, everything feeding the id is immutable, so a re-react is a create/delete rather\n\t * than an update and the id can never drift from its fields.\n\t */\n\tconst authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {\n\t\t// Only the reactor may remove their own reaction. Cascades still sweep every reactor's\n\t\t// records because server-initiated writes carry no session and so skip authorizers\n\t\t// entirely \u2014 an open client delete was never what made the sweep work.\n\t\tif (args.type === 'delete') {\n\t\t\treturn userId && userId === args.prev.userId ? args.prev : null\n\t\t}\n\t\tconst result = authorizeReactionBase(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Unreachable: the base rule already rejected identity-less creates. Checked to narrow.\n\t\t\tif (!userId) return null\n\t\t\tconst { next } = args\n\t\t\tif (next.id !== createCommentReactionId(next.commentId, userId, next.emoji)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tif (next.commentId !== prev.commentId) return null\n\t\t\tif (next.threadId !== prev.threadId) return null\n\t\t\tif (next.pageId !== prev.pageId) return null\n\t\t\tif (next.emoji !== prev.emoji) return null\n\t\t}\n\t\treturn result\n\t}\n\n\treturn {\n\t\tcomment: withUserId(\n\t\t\tauthorizeSoftDeleted<TLComment>(\n\t\t\t\t(comment) => comment.authorId,\n\t\t\t\t(comment, write) => ({\n\t\t\t\t\taction: write === 'delete' ? 'delete-comment' : 'edit-comment',\n\t\t\t\t\tcomment,\n\t\t\t\t}),\n\t\t\t\t// The owner-only update check that used to sit here (`ownerOnlyUpdate`) is now\n\t\t\t\t// `canModifyComment`'s to make, since it can tell an edit from a delete. Attribution\n\t\t\t\t// is still stamped from the session and immutable either way.\n\t\t\t\t//\n\t\t\t\t// `pageId` stays mutable: it's denormalized from the thread, and moving an anchored\n\t\t\t\t// thread between pages rewrites it on every comment in the thread.\n\t\t\t\timmutableFields<TLComment>(\n\t\t\t\t\t['threadId', 'createdAt'],\n\t\t\t\t\tauthorizeAuthored<TLComment>('authorId')\n\t\t\t\t)\n\t\t\t)\n\t\t),\n\t\t'comment-thread': withUserId(\n\t\t\tauthorizeSoftDeleted<TLCommentThread>(\n\t\t\t\t(thread) => thread.createdBy,\n\t\t\t\t// Resolving and reopening stay open to anyone with access, so a thread's \"edit\" isn't\n\t\t\t\t// asked about \u2014 only its delete is.\n\t\t\t\t(thread, write) => (write === 'delete' ? { action: 'delete-thread', thread } : null),\n\t\t\t\timmutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)\n\t\t\t)\n\t\t),\n\t\t// Deletion is deliberately open: anyone with room access may hard-delete any reaction. Reactions\n\t\t// have no soft-delete flag on purpose \u2014 a reaction is a toggle, and a host cascading a comment or\n\t\t// thread deletion must sweep every reactor's records, not just the caller's own.\n\t\t'comment-reaction': withUserId(authorizeReaction),\n\t}\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }