@tldraw/sync-collaboration 0.0.0-bootstrap

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @tldraw/sync-collaboration
2
+
3
+ Server-side logic for tldraw's collaboration features: write authorization for comment, comment-thread, and comment-reaction records, for use with a sync server's `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). This package has no react or client-editor dependencies, so it is safe to import from workers and node servers.
4
+
5
+ See [tldraw.dev](https://tldraw.dev) for docs. License: see `LICENSE.md`.
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var comment_authorizers_exports = {};
20
+ __export(comment_authorizers_exports, {
21
+ createCommentAuthorizers: () => createCommentAuthorizers
22
+ });
23
+ module.exports = __toCommonJS(comment_authorizers_exports);
24
+ var import_tlschema = require("@tldraw/tlschema");
25
+ function createCommentAuthorizers(opts) {
26
+ const { getUserId } = opts;
27
+ function withUserId(rule) {
28
+ return (args) => rule(getUserId(args.session), args);
29
+ }
30
+ function authorizeAuthored(field, { ownerOnlyUpdate = false } = {}) {
31
+ return (userId, { type, prev, next }) => {
32
+ if (type === "create") {
33
+ if (!userId) return null;
34
+ return { ...next, [field]: userId };
35
+ }
36
+ if (type === "update") {
37
+ if (next[field] !== prev[field]) return null;
38
+ if (ownerOnlyUpdate && userId !== prev[field]) return null;
39
+ return next;
40
+ }
41
+ return prev;
42
+ };
43
+ }
44
+ function authorizeSoftDeleted(ownerOf, base) {
45
+ return (userId, args) => {
46
+ if (args.type === "delete") return null;
47
+ const result = base(userId, args);
48
+ 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
+ return result;
58
+ };
59
+ }
60
+ const authorizeThreadResolution = (userId, args) => {
61
+ const result = authorizeAuthored("createdBy")(userId, args);
62
+ if (!result) return null;
63
+ if (args.type === "create") {
64
+ const { next } = args;
65
+ if (next.resolved && next.resolved.by !== userId) return null;
66
+ }
67
+ if (args.type === "update") {
68
+ const { prev, next } = args;
69
+ const changed = prev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by;
70
+ if (changed && next.resolved && next.resolved.by !== userId) return null;
71
+ }
72
+ return result;
73
+ };
74
+ function immutableFields(fields, base) {
75
+ return (userId, args) => {
76
+ if (args.type === "update") {
77
+ const { prev, next } = args;
78
+ for (const field of fields) {
79
+ if (next[field] !== prev[field]) return null;
80
+ }
81
+ }
82
+ return base(userId, args);
83
+ };
84
+ }
85
+ const authorizeReactionBase = authorizeAuthored("userId", {
86
+ ownerOnlyUpdate: true
87
+ });
88
+ const authorizeReaction = (userId, args) => {
89
+ if (args.type === "delete") {
90
+ return userId && userId === args.prev.userId ? args.prev : null;
91
+ }
92
+ const result = authorizeReactionBase(userId, args);
93
+ if (!result) return null;
94
+ if (args.type === "create") {
95
+ if (!userId) return null;
96
+ const { next } = args;
97
+ if (next.id !== (0, import_tlschema.createCommentReactionId)(next.commentId, userId, next.emoji)) {
98
+ return null;
99
+ }
100
+ }
101
+ if (args.type === "update") {
102
+ const { prev, next } = args;
103
+ if (next.commentId !== prev.commentId) return null;
104
+ if (next.threadId !== prev.threadId) return null;
105
+ if (next.pageId !== prev.pageId) return null;
106
+ if (next.emoji !== prev.emoji) return null;
107
+ }
108
+ return result;
109
+ };
110
+ return {
111
+ comment: withUserId(
112
+ authorizeSoftDeleted(
113
+ (comment) => comment.authorId,
114
+ // `pageId` stays mutable: it's denormalized from the thread, and moving an anchored
115
+ // thread between pages rewrites it on every comment in the thread.
116
+ immutableFields(
117
+ ["threadId", "createdAt"],
118
+ authorizeAuthored("authorId", { ownerOnlyUpdate: true })
119
+ )
120
+ )
121
+ ),
122
+ "comment-thread": withUserId(
123
+ authorizeSoftDeleted(
124
+ (thread) => thread.createdBy,
125
+ immutableFields(["createdAt"], authorizeThreadResolution)
126
+ )
127
+ ),
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.
136
+ "comment-reaction": withUserId(authorizeReaction)
137
+ };
138
+ }
139
+ //# sourceMappingURL=comment-authorizers.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 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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",
6
+ "names": []
7
+ }
@@ -0,0 +1,63 @@
1
+ import { TLComment } from '@tldraw/tlschema';
2
+ import { TLCommentReaction } from '@tldraw/tlschema';
3
+ import { TLCommentThread } from '@tldraw/tlschema';
4
+ import type { TLRecordAuthorizers } from '@tldraw/sync-core';
5
+
6
+ /**
7
+ * Options for {@link createCommentAuthorizers}.
8
+ *
9
+ * @public
10
+ */
11
+ export declare interface CommentAuthorizerOptions<SessionMeta> {
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.
16
+ */
17
+ getUserId(session: {
18
+ meta: SessionMeta;
19
+ sessionId: string;
20
+ }): null | string;
21
+ }
22
+
23
+ /**
24
+ * 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.
40
+ *
41
+ * Comment records ride alongside your document records, so widen the room's record union to
42
+ * include them, then spread the result into the authorizer map alongside your own entries:
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * interface SessionMeta {
47
+ * userId: string | null
48
+ * }
49
+ *
50
+ * type MyRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction
51
+ *
52
+ * new TLSocketRoom<MyRecord, SessionMeta>({
53
+ * authorizeRecord: {
54
+ * ...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),
55
+ * },
56
+ * })
57
+ * ```
58
+ *
59
+ * @public
60
+ */
61
+ export declare function createCommentAuthorizers<SessionMeta>(opts: CommentAuthorizerOptions<SessionMeta>): TLRecordAuthorizers<TLComment | TLCommentReaction | TLCommentThread, SessionMeta>;
62
+
63
+ export { }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var index_exports = {};
20
+ __export(index_exports, {
21
+ createCommentAuthorizers: () => import_comment_authorizers.createCommentAuthorizers
22
+ });
23
+ module.exports = __toCommonJS(index_exports);
24
+ var import_utils = require("@tldraw/utils");
25
+ var import_comment_authorizers = require("./comment-authorizers");
26
+ (0, import_utils.registerTldrawLibraryVersion)(
27
+ "@tldraw/sync-collaboration",
28
+ "0.0.0-bootstrap",
29
+ "cjs"
30
+ );
31
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 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;",
6
+ "names": []
7
+ }
@@ -0,0 +1,121 @@
1
+ import {
2
+ createCommentReactionId
3
+ } from "@tldraw/tlschema";
4
+ function createCommentAuthorizers(opts) {
5
+ const { getUserId } = opts;
6
+ function withUserId(rule) {
7
+ return (args) => rule(getUserId(args.session), args);
8
+ }
9
+ function authorizeAuthored(field, { ownerOnlyUpdate = false } = {}) {
10
+ return (userId, { type, prev, next }) => {
11
+ if (type === "create") {
12
+ if (!userId) return null;
13
+ return { ...next, [field]: userId };
14
+ }
15
+ if (type === "update") {
16
+ if (next[field] !== prev[field]) return null;
17
+ if (ownerOnlyUpdate && userId !== prev[field]) return null;
18
+ return next;
19
+ }
20
+ return prev;
21
+ };
22
+ }
23
+ function authorizeSoftDeleted(ownerOf, base) {
24
+ return (userId, args) => {
25
+ if (args.type === "delete") return null;
26
+ const result = base(userId, args);
27
+ 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
+ return result;
37
+ };
38
+ }
39
+ const authorizeThreadResolution = (userId, args) => {
40
+ const result = authorizeAuthored("createdBy")(userId, args);
41
+ if (!result) return null;
42
+ if (args.type === "create") {
43
+ const { next } = args;
44
+ if (next.resolved && next.resolved.by !== userId) return null;
45
+ }
46
+ if (args.type === "update") {
47
+ const { prev, next } = args;
48
+ const changed = prev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by;
49
+ if (changed && next.resolved && next.resolved.by !== userId) return null;
50
+ }
51
+ return result;
52
+ };
53
+ function immutableFields(fields, base) {
54
+ return (userId, args) => {
55
+ if (args.type === "update") {
56
+ const { prev, next } = args;
57
+ for (const field of fields) {
58
+ if (next[field] !== prev[field]) return null;
59
+ }
60
+ }
61
+ return base(userId, args);
62
+ };
63
+ }
64
+ const authorizeReactionBase = authorizeAuthored("userId", {
65
+ ownerOnlyUpdate: true
66
+ });
67
+ const authorizeReaction = (userId, args) => {
68
+ if (args.type === "delete") {
69
+ return userId && userId === args.prev.userId ? args.prev : null;
70
+ }
71
+ const result = authorizeReactionBase(userId, args);
72
+ if (!result) return null;
73
+ if (args.type === "create") {
74
+ if (!userId) return null;
75
+ const { next } = args;
76
+ if (next.id !== createCommentReactionId(next.commentId, userId, next.emoji)) {
77
+ return null;
78
+ }
79
+ }
80
+ if (args.type === "update") {
81
+ const { prev, next } = args;
82
+ if (next.commentId !== prev.commentId) return null;
83
+ if (next.threadId !== prev.threadId) return null;
84
+ if (next.pageId !== prev.pageId) return null;
85
+ if (next.emoji !== prev.emoji) return null;
86
+ }
87
+ return result;
88
+ };
89
+ return {
90
+ comment: withUserId(
91
+ authorizeSoftDeleted(
92
+ (comment) => comment.authorId,
93
+ // `pageId` stays mutable: it's denormalized from the thread, and moving an anchored
94
+ // thread between pages rewrites it on every comment in the thread.
95
+ immutableFields(
96
+ ["threadId", "createdAt"],
97
+ authorizeAuthored("authorId", { ownerOnlyUpdate: true })
98
+ )
99
+ )
100
+ ),
101
+ "comment-thread": withUserId(
102
+ authorizeSoftDeleted(
103
+ (thread) => thread.createdBy,
104
+ immutableFields(["createdAt"], authorizeThreadResolution)
105
+ )
106
+ ),
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.
115
+ "comment-reaction": withUserId(authorizeReaction)
116
+ };
117
+ }
118
+ export {
119
+ createCommentAuthorizers
120
+ };
121
+ //# sourceMappingURL=comment-authorizers.mjs.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 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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",
6
+ "names": []
7
+ }
@@ -0,0 +1,63 @@
1
+ import { TLComment } from '@tldraw/tlschema';
2
+ import { TLCommentReaction } from '@tldraw/tlschema';
3
+ import { TLCommentThread } from '@tldraw/tlschema';
4
+ import type { TLRecordAuthorizers } from '@tldraw/sync-core';
5
+
6
+ /**
7
+ * Options for {@link createCommentAuthorizers}.
8
+ *
9
+ * @public
10
+ */
11
+ export declare interface CommentAuthorizerOptions<SessionMeta> {
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.
16
+ */
17
+ getUserId(session: {
18
+ meta: SessionMeta;
19
+ sessionId: string;
20
+ }): null | string;
21
+ }
22
+
23
+ /**
24
+ * 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.
40
+ *
41
+ * Comment records ride alongside your document records, so widen the room's record union to
42
+ * include them, then spread the result into the authorizer map alongside your own entries:
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * interface SessionMeta {
47
+ * userId: string | null
48
+ * }
49
+ *
50
+ * type MyRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction
51
+ *
52
+ * new TLSocketRoom<MyRecord, SessionMeta>({
53
+ * authorizeRecord: {
54
+ * ...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),
55
+ * },
56
+ * })
57
+ * ```
58
+ *
59
+ * @public
60
+ */
61
+ export declare function createCommentAuthorizers<SessionMeta>(opts: CommentAuthorizerOptions<SessionMeta>): TLRecordAuthorizers<TLComment | TLCommentReaction | TLCommentThread, SessionMeta>;
62
+
63
+ export { }
@@ -0,0 +1,11 @@
1
+ import { registerTldrawLibraryVersion } from "@tldraw/utils";
2
+ import { createCommentAuthorizers } from "./comment-authorizers.mjs";
3
+ registerTldrawLibraryVersion(
4
+ "@tldraw/sync-collaboration",
5
+ "0.0.0-bootstrap",
6
+ "esm"
7
+ );
8
+ export {
9
+ createCommentAuthorizers
10
+ };
11
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 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,SAAS,oCAAoC;AAI7C,SAAwC,gCAAgC;AAExE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AACF;",
6
+ "names": []
7
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@tldraw/sync-collaboration",
3
+ "description": "tldraw sync collaboration: server-side write authorization for collaboration features.",
4
+ "version": "0.0.0-bootstrap",
5
+ "author": {
6
+ "name": "tldraw Inc.",
7
+ "email": "hello@tldraw.com"
8
+ },
9
+ "homepage": "https://tldraw.dev",
10
+ "license": "SEE LICENSE IN LICENSE.md",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/tldraw/tldraw"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/tldraw/tldraw/issues"
17
+ },
18
+ "main": "dist-cjs/index.js",
19
+ "scripts": {
20
+ "test-ci": "yarn run -T vitest run --passWithNoTests",
21
+ "test": "yarn run -T vitest --passWithNoTests",
22
+ "test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
23
+ "build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
24
+ "build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
25
+ "prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
26
+ "postpack": "../../internal/scripts/postpack.sh",
27
+ "pack-tarball": "yarn pack",
28
+ "lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.12.0"
32
+ },
33
+ "dependencies": {
34
+ "@tldraw/store": "5.2.5",
35
+ "@tldraw/sync-core": "5.2.5",
36
+ "@tldraw/tlschema": "5.2.5",
37
+ "@tldraw/utils": "5.2.5"
38
+ },
39
+ "module": "dist-esm/index.mjs",
40
+ "source": "src/index.ts",
41
+ "exports": {
42
+ ".": {
43
+ "import": "./dist-esm/index.mjs",
44
+ "require": "./dist-cjs/index.js"
45
+ }
46
+ },
47
+ "files": [
48
+ "dist-esm",
49
+ "dist-cjs",
50
+ "src"
51
+ ]
52
+ }
@@ -0,0 +1,408 @@
1
+ import {
2
+ TLComment,
3
+ TLCommentReaction,
4
+ TLCommentThread,
5
+ TLPageId,
6
+ createComment,
7
+ createCommentId,
8
+ createCommentReaction,
9
+ createCommentReactionId,
10
+ createCommentThread,
11
+ toRichText,
12
+ } from '@tldraw/tlschema'
13
+ import { describe, expect, it } from 'vitest'
14
+ import { createCommentAuthorizers } from './comment-authorizers'
15
+
16
+ interface TestMeta {
17
+ userId: string | null
18
+ }
19
+
20
+ const authorizers = createCommentAuthorizers<TestMeta>({
21
+ getUserId: (session) => session.meta.userId,
22
+ })
23
+
24
+ const pageId = 'page:test' as TLPageId
25
+ const thread = createCommentThread({
26
+ pageId,
27
+ anchor: { type: 'page' },
28
+ createdBy: 'client-claims-alice',
29
+ })
30
+
31
+ function session(userId: string | null): { sessionId: string; meta: TestMeta } {
32
+ return { sessionId: 's1', meta: { userId } }
33
+ }
34
+
35
+ describe('createCommentAuthorizers', () => {
36
+ // getUserId is documented as called exactly once per write. More than once would let an
37
+ // impure host callback split identity across checks — e.g. a reaction create where the
38
+ // stamped userId and the canonical-id check disagree about who is reacting.
39
+ it('resolves the session user exactly once per authorization', () => {
40
+ let calls = 0
41
+ const counted = createCommentAuthorizers<TestMeta>({
42
+ getUserId: (s) => {
43
+ calls++
44
+ return s.meta.userId
45
+ },
46
+ })
47
+ // the worst former offender: a thread update touching both resolution and the soft-delete flag
48
+ const prev = createCommentThread({ pageId, anchor: { type: 'page' }, createdBy: 'real-bob' })
49
+ const next = { ...prev, resolved: { at: 1, by: 'real-bob' }, isDeleted: true }
50
+ expect(
51
+ counted['comment-thread']!({ session: session('real-bob'), type: 'update', prev, next })
52
+ ).toBe(next)
53
+ expect(calls).toBe(1)
54
+ })
55
+
56
+ describe('comment', () => {
57
+ const authorize = authorizers.comment!
58
+ const comment = (authorId: string) =>
59
+ createComment({ threadId: thread.id, pageId, authorId, body: toRichText('hi') })
60
+
61
+ it('stamps authorId from the session on create, overriding the client value', () => {
62
+ const result = authorize({
63
+ session: session('real-bob'),
64
+ type: 'create',
65
+ prev: null,
66
+ next: comment('client-claims-alice'),
67
+ }) as TLComment
68
+ expect(result.authorId).toBe('real-bob')
69
+ })
70
+
71
+ it('rejects a create with no authenticated user', () => {
72
+ expect(
73
+ authorize({ session: session(null), type: 'create', prev: null, next: comment('anon') })
74
+ ).toBeNull()
75
+ })
76
+
77
+ it('allows the author to edit their own comment', () => {
78
+ const prev = comment('real-bob')
79
+ const next = { ...prev, body: toRichText('edited') }
80
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
81
+ })
82
+
83
+ it('vetoes an edit from someone who is not the author', () => {
84
+ const prev = comment('real-bob')
85
+ const next = { ...prev, body: toRichText('sneakily rewritten') }
86
+ expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
87
+ })
88
+
89
+ it('vetoes an update that changes the author', () => {
90
+ const prev = comment('real-bob')
91
+ const next = { ...prev, authorId: 'someone-else' }
92
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
93
+ })
94
+
95
+ it('vetoes every client hard-delete, even from the author (deletion is soft)', () => {
96
+ const prev = comment('real-bob')
97
+ expect(
98
+ authorize({ session: session('real-bob'), type: 'delete', prev, next: null })
99
+ ).toBeNull()
100
+ expect(
101
+ authorize({ session: session('real-mallory'), type: 'delete', prev, next: null })
102
+ ).toBeNull()
103
+ expect(authorize({ session: session(null), type: 'delete', prev, next: null })).toBeNull()
104
+ })
105
+
106
+ it('lets the author soft-delete their own comment', () => {
107
+ const prev = comment('real-bob')
108
+ const next = { ...prev, isDeleted: true }
109
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
110
+ })
111
+
112
+ it('vetoes a non-author soft-deleting the comment', () => {
113
+ const prev = comment('real-bob')
114
+ const next = { ...prev, isDeleted: true }
115
+ expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
116
+ expect(authorize({ session: session(null), type: 'update', prev, next })).toBeNull()
117
+ })
118
+
119
+ it('vetoes clearing a soft-delete, even by the author (write-once)', () => {
120
+ const prev = { ...comment('real-bob'), isDeleted: true }
121
+ const next = { ...prev, isDeleted: false }
122
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
123
+ })
124
+
125
+ it('allows an update that leaves an existing soft-delete untouched', () => {
126
+ const prev = { ...comment('real-bob'), isDeleted: true }
127
+ const next = { ...prev, body: toRichText('edited') }
128
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
129
+ })
130
+
131
+ it('vetoes a create with the soft-delete flag already set', () => {
132
+ const next = { ...comment('real-bob'), isDeleted: true }
133
+ expect(
134
+ authorize({ session: session('real-bob'), type: 'create', prev: null, next })
135
+ ).toBeNull()
136
+ })
137
+
138
+ // threadId ties a comment to its conversation (and downstream to a file), so re-parenting an
139
+ // existing comment must not be an update — even by its own author.
140
+ it('vetoes re-parenting a comment to another thread', () => {
141
+ const prev = comment('real-bob')
142
+ const next = { ...prev, threadId: 'comment-thread:other' as TLCommentThread['id'] }
143
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
144
+ })
145
+
146
+ // createdAt orders threads and bounds the notification feed, so a mutable one lets a comment
147
+ // be re-sorted (or pinned to the top of the feed) after the fact.
148
+ it('vetoes back-dating a comment', () => {
149
+ const prev = comment('real-bob')
150
+ const next = { ...prev, createdAt: 1 }
151
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
152
+ })
153
+
154
+ // the anchor lifecycle rewrites pageId on every comment when a thread moves pages
155
+ it('allows a pageId update (threads can move between pages)', () => {
156
+ const prev = comment('real-bob')
157
+ const next = { ...prev, pageId: 'page:other' as TLPageId }
158
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
159
+ })
160
+ })
161
+
162
+ describe('comment-thread', () => {
163
+ const authorize = authorizers['comment-thread']!
164
+ const makeThread = (createdBy: string) =>
165
+ createCommentThread({ pageId, anchor: { type: 'page' }, createdBy })
166
+
167
+ it('stamps createdBy from the session on create, overriding the client value', () => {
168
+ const result = authorize({
169
+ session: session('real-bob'),
170
+ type: 'create',
171
+ prev: null,
172
+ next: makeThread('client-claims-alice'),
173
+ }) as TLCommentThread
174
+ expect(result.createdBy).toBe('real-bob')
175
+ })
176
+
177
+ it('vetoes an update that changes the creator', () => {
178
+ const prev = makeThread('real-bob')
179
+ const next = { ...prev, createdBy: 'someone-else' }
180
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
181
+ })
182
+
183
+ it('lets a non-creator resolve the thread as themselves', () => {
184
+ const prev = makeThread('real-bob')
185
+ const next = { ...prev, resolved: { at: 1, by: 'real-mallory' } }
186
+ expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBe(next)
187
+ })
188
+
189
+ it('lets a non-creator reopen a resolved thread', () => {
190
+ const prev = { ...makeThread('real-bob'), resolved: { at: 1, by: 'real-alice' } }
191
+ const next = { ...prev, resolved: null }
192
+ expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBe(next)
193
+ })
194
+
195
+ it('vetoes a resolution attributed to someone else', () => {
196
+ const prev = makeThread('real-bob')
197
+ const next = { ...prev, resolved: { at: 1, by: 'real-alice' } }
198
+ expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
199
+ })
200
+
201
+ it('allows an update that leaves an existing resolution untouched', () => {
202
+ const prev = { ...makeThread('real-bob'), resolved: { at: 1, by: 'real-alice' } }
203
+ // new object reference, same value — must not be treated as a change
204
+ const next = { ...prev, resolved: { ...prev.resolved } }
205
+ expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBe(next)
206
+ })
207
+
208
+ it('vetoes a create with a resolution attributed to someone else', () => {
209
+ const next = { ...makeThread('real-mallory'), resolved: { at: 1, by: 'real-alice' } }
210
+ expect(
211
+ authorize({ session: session('real-mallory'), type: 'create', prev: null, next })
212
+ ).toBeNull()
213
+ })
214
+
215
+ it('allows a create resolved by the creator themselves', () => {
216
+ const next = { ...makeThread('real-bob'), resolved: { at: 1, by: 'real-bob' } }
217
+ const result = authorize({
218
+ session: session('real-bob'),
219
+ type: 'create',
220
+ prev: null,
221
+ next,
222
+ }) as TLCommentThread
223
+ expect(result.resolved).toEqual({ at: 1, by: 'real-bob' })
224
+ })
225
+
226
+ it('vetoes every client hard-delete, even from the creator (deletion is soft)', () => {
227
+ const prev = makeThread('real-bob')
228
+ expect(
229
+ authorize({ session: session('real-bob'), type: 'delete', prev, next: null })
230
+ ).toBeNull()
231
+ expect(
232
+ authorize({ session: session('real-mallory'), type: 'delete', prev, next: null })
233
+ ).toBeNull()
234
+ })
235
+
236
+ it('lets the creator soft-delete their own thread', () => {
237
+ const prev = makeThread('real-bob')
238
+ const next = { ...prev, isDeleted: true }
239
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
240
+ })
241
+
242
+ it('vetoes a non-creator soft-deleting the thread', () => {
243
+ const prev = makeThread('real-bob')
244
+ const next = { ...prev, isDeleted: true }
245
+ expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
246
+ expect(authorize({ session: session(null), type: 'update', prev, next })).toBeNull()
247
+ })
248
+
249
+ it('vetoes clearing a soft-delete, even by the creator (write-once)', () => {
250
+ const prev = { ...makeThread('real-bob'), isDeleted: true }
251
+ const next = { ...prev, isDeleted: false }
252
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
253
+ })
254
+
255
+ it('allows an update that leaves an existing soft-delete untouched', () => {
256
+ const prev = { ...makeThread('real-bob'), isDeleted: true }
257
+ const next = { ...prev, resolved: { at: 2, by: 'real-mallory' } }
258
+ expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBe(next)
259
+ })
260
+
261
+ it('vetoes a create with the soft-delete flag already set', () => {
262
+ const next = { ...makeThread('real-bob'), isDeleted: true }
263
+ expect(
264
+ authorize({ session: session('real-bob'), type: 'create', prev: null, next })
265
+ ).toBeNull()
266
+ })
267
+ })
268
+
269
+ // A reaction is one user's own record, so it needs no bespoke rule: the standard attribution
270
+ // guards cover forging (userId is stamped from the session) and tampering (owner-only update).
271
+ // Crucially there is no shared field, so one person's write can't reach another's reaction.
272
+ describe('comment-reaction', () => {
273
+ const authorize = authorizers['comment-reaction']!
274
+ const makeReaction = (userId: string, emoji = '👍') =>
275
+ createCommentReaction({
276
+ commentId: createCommentId('c1'),
277
+ threadId: thread.id,
278
+ pageId,
279
+ userId,
280
+ emoji,
281
+ })
282
+
283
+ it('stamps userId from the session on create, overriding the client value', () => {
284
+ // id is bob's canonical slot (so it passes the id check), but the userId field claims
285
+ // someone else — the server stamps it back to the session user
286
+ const next: TLCommentReaction = { ...makeReaction('real-bob'), userId: 'client-claims-alice' }
287
+ const result = authorize({
288
+ session: session('real-bob'),
289
+ type: 'create',
290
+ prev: null,
291
+ next,
292
+ }) as TLCommentReaction
293
+ expect(result.userId).toBe('real-bob')
294
+ })
295
+
296
+ it('rejects a create from a session with no identity', () => {
297
+ expect(
298
+ authorize({
299
+ session: session(null),
300
+ type: 'create',
301
+ prev: null,
302
+ next: makeReaction('anyone'),
303
+ })
304
+ ).toBeNull()
305
+ })
306
+
307
+ it('vetoes an update that changes the reacting user', () => {
308
+ const prev = makeReaction('real-bob')
309
+ const next = { ...prev, userId: 'real-alice' }
310
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
311
+ })
312
+
313
+ // emoji feeds the id now, so switching emoji is a delete+create, never an update — an update
314
+ // that changes emoji is rejected (its id would no longer match its emoji)
315
+ it('vetoes an update that changes the emoji', () => {
316
+ const prev = makeReaction('real-bob', '👍')
317
+ const next = { ...prev, emoji: '🎉' }
318
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
319
+ })
320
+
321
+ it('vetoes changing someone else’s reaction', () => {
322
+ const prev = makeReaction('real-alice')
323
+ const next = { ...prev, emoji: '💩' }
324
+ expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
325
+ })
326
+
327
+ it('lets the reactor remove their own reaction', () => {
328
+ const prev = makeReaction('real-bob')
329
+ expect(authorize({ session: session('real-bob'), type: 'delete', prev, next: null })).toBe(
330
+ prev
331
+ )
332
+ })
333
+
334
+ it('vetoes deleting someone else’s reaction', () => {
335
+ // cascades still sweep every reactor's records: server-initiated writes carry no session
336
+ // and skip authorizers entirely, so this doesn't need to be open to clients
337
+ const prev = makeReaction('real-alice')
338
+ expect(
339
+ authorize({ session: session('real-mallory'), type: 'delete', prev, next: null })
340
+ ).toBeNull()
341
+ expect(authorize({ session: session(null), type: 'delete', prev, next: null })).toBeNull()
342
+ })
343
+
344
+ // The id is derived from (comment, user, emoji). A create must land at the session user's own
345
+ // canonical slot, or a forger could occupy someone else's slot (locking them out) or push a
346
+ // mismatched id that wedges the table's unique constraint at drain time.
347
+ it('vetoes a create whose id is not the session user’s canonical slot', () => {
348
+ // mallory forges a reaction at alice's id slot on the same comment + emoji
349
+ const next: TLCommentReaction = {
350
+ ...makeReaction('real-mallory', '👍'),
351
+ id: createCommentReactionId(createCommentId('c1'), 'real-alice', '👍'),
352
+ }
353
+ expect(
354
+ authorize({ session: session('real-mallory'), type: 'create', prev: null, next })
355
+ ).toBeNull()
356
+ })
357
+
358
+ // the id also encodes the emoji, so an id that doesn't match the record's own emoji field
359
+ // (e.g. id says 👍 but the field says 🎉) is a mismatch and rejected
360
+ it('vetoes a create whose id emoji disagrees with its emoji field', () => {
361
+ const next: TLCommentReaction = {
362
+ ...makeReaction('real-mallory', '🎉'),
363
+ id: createCommentReactionId(createCommentId('c1'), 'real-mallory', '👍'),
364
+ }
365
+ expect(
366
+ authorize({ session: session('real-mallory'), type: 'create', prev: null, next })
367
+ ).toBeNull()
368
+ })
369
+
370
+ it('allows a create whose id is the session user’s canonical slot', () => {
371
+ const next = makeReaction('real-mallory')
372
+ expect(
373
+ authorize({ session: session('real-mallory'), type: 'create', prev: null, next })
374
+ ).not.toBeNull()
375
+ })
376
+
377
+ // the reaction's comment is fixed by its id; an update must not move it onto another comment,
378
+ // or the id would disagree with commentId and two rows could collide on (commentId, userId)
379
+ it('vetoes an update that moves the reaction to a different comment', () => {
380
+ const prev = makeReaction('real-bob')
381
+ const next = { ...prev, commentId: createCommentId('c2') }
382
+ expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
383
+ })
384
+
385
+ it('vetoes an update that changes the denormalized threadId or pageId', () => {
386
+ const prev = makeReaction('real-bob')
387
+ expect(
388
+ authorize({
389
+ session: session('real-bob'),
390
+ type: 'update',
391
+ prev,
392
+ next: {
393
+ ...prev,
394
+ threadId: createCommentThread({ pageId, anchor: { type: 'page' }, createdBy: 'x' }).id,
395
+ },
396
+ })
397
+ ).toBeNull()
398
+ expect(
399
+ authorize({
400
+ session: session('real-bob'),
401
+ type: 'update',
402
+ prev,
403
+ next: { ...prev, pageId: 'page:other' as typeof prev.pageId },
404
+ })
405
+ ).toBeNull()
406
+ })
407
+ })
408
+ })
@@ -0,0 +1,251 @@
1
+ import type { UnknownRecord } from '@tldraw/store'
2
+ import type { TLRecordAuthorizer, TLRecordAuthorizers } from '@tldraw/sync-core'
3
+ import {
4
+ createCommentReactionId,
5
+ type TLComment,
6
+ type TLCommentReaction,
7
+ type TLCommentThread,
8
+ } from '@tldraw/tlschema'
9
+
10
+ /**
11
+ * Options for {@link createCommentAuthorizers}.
12
+ *
13
+ * @public
14
+ */
15
+ export interface CommentAuthorizerOptions<SessionMeta> {
16
+ /**
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.
20
+ */
21
+ getUserId(session: { sessionId: string; meta: SessionMeta }): string | null
22
+ }
23
+
24
+ /**
25
+ * 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:
29
+ *
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.
41
+ *
42
+ * Comment records ride alongside your document records, so widen the room's record union to
43
+ * include them, then spread the result into the authorizer map alongside your own entries:
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * interface SessionMeta {
48
+ * userId: string | null
49
+ * }
50
+ *
51
+ * type MyRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction
52
+ *
53
+ * new TLSocketRoom<MyRecord, SessionMeta>({
54
+ * authorizeRecord: {
55
+ * ...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),
56
+ * },
57
+ * })
58
+ * ```
59
+ *
60
+ * @public
61
+ */
62
+ export function createCommentAuthorizers<SessionMeta>(
63
+ opts: CommentAuthorizerOptions<SessionMeta>
64
+ ): TLRecordAuthorizers<TLComment | TLCommentThread | TLCommentReaction, SessionMeta> {
65
+ const { getUserId } = opts
66
+
67
+ /** A rule is an authorizer that receives the session's user id, resolved for it exactly once. */
68
+ type Rule<Rec extends UnknownRecord> = (
69
+ userId: string | null,
70
+ args: Parameters<TLRecordAuthorizer<Rec, SessionMeta>>[0]
71
+ ) => Rec | null
72
+
73
+ /** Adapt a rule to the authorizer signature, resolving the session's user id exactly once. */
74
+ function withUserId<Rec extends UnknownRecord>(
75
+ rule: Rule<Rec>
76
+ ): TLRecordAuthorizer<Rec, SessionMeta> {
77
+ return (args) => rule(getUserId(args.session), args)
78
+ }
79
+
80
+ /**
81
+ * Authorize a record whose attribution lives in `field`: stamped from the session on create,
82
+ * immutable on update. With `ownerOnlyUpdate`, only the author may update it at all.
83
+ */
84
+ function authorizeAuthored<Rec extends UnknownRecord>(
85
+ field: keyof Rec & string,
86
+ { ownerOnlyUpdate = false } = {}
87
+ ): Rule<Rec> {
88
+ return (userId, { type, prev, next }) => {
89
+ if (type === 'create') {
90
+ if (!userId) return null // no identity to attribute → reject
91
+ return { ...next, [field]: userId } as Rec
92
+ }
93
+ if (type === 'update') {
94
+ if (next[field] !== prev[field]) return null // attribution is immutable
95
+ if (ownerOnlyUpdate && userId !== prev[field]) return null // only the author edits
96
+ return next
97
+ }
98
+ return prev
99
+ }
100
+ }
101
+
102
+ /**
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.
108
+ */
109
+ function authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(
110
+ ownerOf: (rec: Rec) => string,
111
+ base: Rule<Rec>
112
+ ): Rule<Rec> {
113
+ return (userId, args) => {
114
+ if (args.type === 'delete') return null
115
+ const result = base(userId, args)
116
+ if (!result) return null
117
+ // 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
+ }
125
+ }
126
+ return result
127
+ }
128
+ }
129
+
130
+ /**
131
+ * 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.
134
+ */
135
+ const authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {
136
+ const result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)
137
+ if (!result) return null
138
+ if (args.type === 'create') {
139
+ // Delete + re-put could otherwise smuggle in a resolution forged in someone else's name.
140
+ const { next } = args
141
+ if (next.resolved && next.resolved.by !== userId) return null
142
+ }
143
+ if (args.type === 'update') {
144
+ const { prev, next } = args
145
+ const changed =
146
+ prev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by
147
+ if (changed && next.resolved && next.resolved.by !== userId) return null
148
+ }
149
+ return result
150
+ }
151
+
152
+ /**
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.
159
+ */
160
+ function immutableFields<Rec extends UnknownRecord>(
161
+ fields: readonly (keyof Rec & string)[],
162
+ base: Rule<Rec>
163
+ ): Rule<Rec> {
164
+ return (userId, args) => {
165
+ if (args.type === 'update') {
166
+ const { prev, next } = args
167
+ for (const field of fields) {
168
+ if (next[field] !== prev[field]) return null
169
+ }
170
+ }
171
+ return base(userId, args)
172
+ }
173
+ }
174
+
175
+ const authorizeReactionBase = authorizeAuthored<TLCommentReaction>('userId', {
176
+ ownerOnlyUpdate: true,
177
+ })
178
+
179
+ /**
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:
185
+ *
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.
190
+ *
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.
195
+ */
196
+ const authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {
197
+ // Only the reactor may remove their own reaction. Cascades still sweep every reactor's
198
+ // records because server-initiated writes carry no session and so skip authorizers
199
+ // entirely — an open client delete was never what made the sweep work.
200
+ if (args.type === 'delete') {
201
+ return userId && userId === args.prev.userId ? args.prev : null
202
+ }
203
+ const result = authorizeReactionBase(userId, args)
204
+ if (!result) return null
205
+ if (args.type === 'create') {
206
+ // Unreachable: the base rule already rejected identity-less creates. Checked to narrow.
207
+ if (!userId) return null
208
+ const { next } = args
209
+ if (next.id !== createCommentReactionId(next.commentId, userId, next.emoji)) {
210
+ return null
211
+ }
212
+ }
213
+ if (args.type === 'update') {
214
+ const { prev, next } = args
215
+ if (next.commentId !== prev.commentId) return null
216
+ if (next.threadId !== prev.threadId) return null
217
+ if (next.pageId !== prev.pageId) return null
218
+ if (next.emoji !== prev.emoji) return null
219
+ }
220
+ return result
221
+ }
222
+
223
+ return {
224
+ comment: withUserId(
225
+ authorizeSoftDeleted<TLComment>(
226
+ (comment) => comment.authorId,
227
+ // `pageId` stays mutable: it's denormalized from the thread, and moving an anchored
228
+ // thread between pages rewrites it on every comment in the thread.
229
+ immutableFields<TLComment>(
230
+ ['threadId', 'createdAt'],
231
+ authorizeAuthored<TLComment>('authorId', { ownerOnlyUpdate: true })
232
+ )
233
+ )
234
+ ),
235
+ 'comment-thread': withUserId(
236
+ authorizeSoftDeleted<TLCommentThread>(
237
+ (thread) => thread.createdBy,
238
+ immutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)
239
+ )
240
+ ),
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.
249
+ 'comment-reaction': withUserId(authorizeReaction),
250
+ }
251
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { registerTldrawLibraryVersion } from '@tldraw/utils'
2
+
3
+ // Server-side logic for tldraw's collaboration features, safe to import from any sync
4
+ // server — no react or client-editor dependencies.
5
+ export { type CommentAuthorizerOptions, createCommentAuthorizers } from './comment-authorizers'
6
+
7
+ registerTldrawLibraryVersion(
8
+ (globalThis as any).TLDRAW_LIBRARY_NAME,
9
+ (globalThis as any).TLDRAW_LIBRARY_VERSION,
10
+ (globalThis as any).TLDRAW_LIBRARY_MODULES
11
+ )