@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 +5 -0
- package/dist-cjs/comment-authorizers.js +139 -0
- package/dist-cjs/comment-authorizers.js.map +7 -0
- package/dist-cjs/index.d.ts +63 -0
- package/dist-cjs/index.js +31 -0
- package/dist-cjs/index.js.map +7 -0
- package/dist-esm/comment-authorizers.mjs +121 -0
- package/dist-esm/comment-authorizers.mjs.map +7 -0
- package/dist-esm/index.d.mts +63 -0
- package/dist-esm/index.mjs +11 -0
- package/dist-esm/index.mjs.map +7 -0
- package/package.json +52 -0
- package/src/comment-authorizers.test.ts +408 -0
- package/src/comment-authorizers.ts +251 -0
- package/src/index.ts +11 -0
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": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAEA,sBAKO;AAsDA,SAAS,yBACf,MACoF;AACpF,QAAM,EAAE,UAAU,IAAI;AAStB,WAAS,WACR,MACuC;AACvC,WAAO,CAAC,SAAS,KAAK,UAAU,KAAK,OAAO,GAAG,IAAI;AAAA,EACpD;AAMA,WAAS,kBACR,OACA,EAAE,kBAAkB,MAAM,IAAI,CAAC,GACnB;AACZ,WAAO,CAAC,QAAQ,EAAE,MAAM,MAAM,KAAK,MAAM;AACxC,UAAI,SAAS,UAAU;AACtB,YAAI,CAAC,OAAQ,QAAO;AACpB,eAAO,EAAE,GAAG,MAAM,CAAC,KAAK,GAAG,OAAO;AAAA,MACnC;AACA,UAAI,SAAS,UAAU;AACtB,YAAI,KAAK,KAAK,MAAM,KAAK,KAAK,EAAG,QAAO;AACxC,YAAI,mBAAmB,WAAW,KAAK,KAAK,EAAG,QAAO;AACtD,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,EACD;AASA,WAAS,qBACR,SACA,MACY;AACZ,WAAO,CAAC,QAAQ,SAAS;AACxB,UAAI,KAAK,SAAS,SAAU,QAAO;AACnC,YAAM,SAAS,KAAK,QAAQ,IAAI;AAChC,UAAI,CAAC,OAAQ,QAAO;AAEpB,UAAI,KAAK,SAAS,YAAY,KAAK,KAAK,UAAW,QAAO;AAC1D,UAAI,KAAK,SAAS,UAAU;AAC3B,cAAM,EAAE,MAAM,KAAK,IAAI;AACvB,YAAI,KAAK,cAAc,KAAK,WAAW;AACtC,cAAI,KAAK,UAAW,QAAO;AAC3B,cAAI,WAAW,QAAQ,IAAI,EAAG,QAAO;AAAA,QACtC;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AAOA,QAAM,4BAAmD,CAAC,QAAQ,SAAS;AAC1E,UAAM,SAAS,kBAAmC,WAAW,EAAE,QAAQ,IAAI;AAC3E,QAAI,CAAC,OAAQ,QAAO;AACpB,QAAI,KAAK,SAAS,UAAU;AAE3B,YAAM,EAAE,KAAK,IAAI;AACjB,UAAI,KAAK,YAAY,KAAK,SAAS,OAAO,OAAQ,QAAO;AAAA,IAC1D;AACA,QAAI,KAAK,SAAS,UAAU;AAC3B,YAAM,EAAE,MAAM,KAAK,IAAI;AACvB,YAAM,UACL,KAAK,UAAU,OAAO,KAAK,UAAU,MAAM,KAAK,UAAU,OAAO,KAAK,UAAU;AACjF,UAAI,WAAW,KAAK,YAAY,KAAK,SAAS,OAAO,OAAQ,QAAO;AAAA,IACrE;AACA,WAAO;AAAA,EACR;AAUA,WAAS,gBACR,QACA,MACY;AACZ,WAAO,CAAC,QAAQ,SAAS;AACxB,UAAI,KAAK,SAAS,UAAU;AAC3B,cAAM,EAAE,MAAM,KAAK,IAAI;AACvB,mBAAW,SAAS,QAAQ;AAC3B,cAAI,KAAK,KAAK,MAAM,KAAK,KAAK,EAAG,QAAO;AAAA,QACzC;AAAA,MACD;AACA,aAAO,KAAK,QAAQ,IAAI;AAAA,IACzB;AAAA,EACD;AAEA,QAAM,wBAAwB,kBAAqC,UAAU;AAAA,IAC5E,iBAAiB;AAAA,EAClB,CAAC;AAmBD,QAAM,oBAA6C,CAAC,QAAQ,SAAS;AAIpE,QAAI,KAAK,SAAS,UAAU;AAC3B,aAAO,UAAU,WAAW,KAAK,KAAK,SAAS,KAAK,OAAO;AAAA,IAC5D;AACA,UAAM,SAAS,sBAAsB,QAAQ,IAAI;AACjD,QAAI,CAAC,OAAQ,QAAO;AACpB,QAAI,KAAK,SAAS,UAAU;AAE3B,UAAI,CAAC,OAAQ,QAAO;AACpB,YAAM,EAAE,KAAK,IAAI;AACjB,UAAI,KAAK,WAAO,yCAAwB,KAAK,WAAW,QAAQ,KAAK,KAAK,GAAG;AAC5E,eAAO;AAAA,MACR;AAAA,IACD;AACA,QAAI,KAAK,SAAS,UAAU;AAC3B,YAAM,EAAE,MAAM,KAAK,IAAI;AACvB,UAAI,KAAK,cAAc,KAAK,UAAW,QAAO;AAC9C,UAAI,KAAK,aAAa,KAAK,SAAU,QAAO;AAC5C,UAAI,KAAK,WAAW,KAAK,OAAQ,QAAO;AACxC,UAAI,KAAK,UAAU,KAAK,MAAO,QAAO;AAAA,IACvC;AACA,WAAO;AAAA,EACR;AAEA,SAAO;AAAA,IACN,SAAS;AAAA,MACR;AAAA,QACC,CAAC,YAAY,QAAQ;AAAA;AAAA;AAAA,QAGrB;AAAA,UACC,CAAC,YAAY,WAAW;AAAA,UACxB,kBAA6B,YAAY,EAAE,iBAAiB,KAAK,CAAC;AAAA,QACnE;AAAA,MACD;AAAA,IACD;AAAA,IACA,kBAAkB;AAAA,MACjB;AAAA,QACC,CAAC,WAAW,OAAO;AAAA,QACnB,gBAAiC,CAAC,WAAW,GAAG,yBAAyB;AAAA,MAC1E;AAAA,IACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASA,oBAAoB,WAAW,iBAAiB;AAAA,EACjD;AACD;",
|
|
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": "AAEA;AAAA,EACC;AAAA,OAIM;AAsDA,SAAS,yBACf,MACoF;AACpF,QAAM,EAAE,UAAU,IAAI;AAStB,WAAS,WACR,MACuC;AACvC,WAAO,CAAC,SAAS,KAAK,UAAU,KAAK,OAAO,GAAG,IAAI;AAAA,EACpD;AAMA,WAAS,kBACR,OACA,EAAE,kBAAkB,MAAM,IAAI,CAAC,GACnB;AACZ,WAAO,CAAC,QAAQ,EAAE,MAAM,MAAM,KAAK,MAAM;AACxC,UAAI,SAAS,UAAU;AACtB,YAAI,CAAC,OAAQ,QAAO;AACpB,eAAO,EAAE,GAAG,MAAM,CAAC,KAAK,GAAG,OAAO;AAAA,MACnC;AACA,UAAI,SAAS,UAAU;AACtB,YAAI,KAAK,KAAK,MAAM,KAAK,KAAK,EAAG,QAAO;AACxC,YAAI,mBAAmB,WAAW,KAAK,KAAK,EAAG,QAAO;AACtD,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,EACD;AASA,WAAS,qBACR,SACA,MACY;AACZ,WAAO,CAAC,QAAQ,SAAS;AACxB,UAAI,KAAK,SAAS,SAAU,QAAO;AACnC,YAAM,SAAS,KAAK,QAAQ,IAAI;AAChC,UAAI,CAAC,OAAQ,QAAO;AAEpB,UAAI,KAAK,SAAS,YAAY,KAAK,KAAK,UAAW,QAAO;AAC1D,UAAI,KAAK,SAAS,UAAU;AAC3B,cAAM,EAAE,MAAM,KAAK,IAAI;AACvB,YAAI,KAAK,cAAc,KAAK,WAAW;AACtC,cAAI,KAAK,UAAW,QAAO;AAC3B,cAAI,WAAW,QAAQ,IAAI,EAAG,QAAO;AAAA,QACtC;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AAOA,QAAM,4BAAmD,CAAC,QAAQ,SAAS;AAC1E,UAAM,SAAS,kBAAmC,WAAW,EAAE,QAAQ,IAAI;AAC3E,QAAI,CAAC,OAAQ,QAAO;AACpB,QAAI,KAAK,SAAS,UAAU;AAE3B,YAAM,EAAE,KAAK,IAAI;AACjB,UAAI,KAAK,YAAY,KAAK,SAAS,OAAO,OAAQ,QAAO;AAAA,IAC1D;AACA,QAAI,KAAK,SAAS,UAAU;AAC3B,YAAM,EAAE,MAAM,KAAK,IAAI;AACvB,YAAM,UACL,KAAK,UAAU,OAAO,KAAK,UAAU,MAAM,KAAK,UAAU,OAAO,KAAK,UAAU;AACjF,UAAI,WAAW,KAAK,YAAY,KAAK,SAAS,OAAO,OAAQ,QAAO;AAAA,IACrE;AACA,WAAO;AAAA,EACR;AAUA,WAAS,gBACR,QACA,MACY;AACZ,WAAO,CAAC,QAAQ,SAAS;AACxB,UAAI,KAAK,SAAS,UAAU;AAC3B,cAAM,EAAE,MAAM,KAAK,IAAI;AACvB,mBAAW,SAAS,QAAQ;AAC3B,cAAI,KAAK,KAAK,MAAM,KAAK,KAAK,EAAG,QAAO;AAAA,QACzC;AAAA,MACD;AACA,aAAO,KAAK,QAAQ,IAAI;AAAA,IACzB;AAAA,EACD;AAEA,QAAM,wBAAwB,kBAAqC,UAAU;AAAA,IAC5E,iBAAiB;AAAA,EAClB,CAAC;AAmBD,QAAM,oBAA6C,CAAC,QAAQ,SAAS;AAIpE,QAAI,KAAK,SAAS,UAAU;AAC3B,aAAO,UAAU,WAAW,KAAK,KAAK,SAAS,KAAK,OAAO;AAAA,IAC5D;AACA,UAAM,SAAS,sBAAsB,QAAQ,IAAI;AACjD,QAAI,CAAC,OAAQ,QAAO;AACpB,QAAI,KAAK,SAAS,UAAU;AAE3B,UAAI,CAAC,OAAQ,QAAO;AACpB,YAAM,EAAE,KAAK,IAAI;AACjB,UAAI,KAAK,OAAO,wBAAwB,KAAK,WAAW,QAAQ,KAAK,KAAK,GAAG;AAC5E,eAAO;AAAA,MACR;AAAA,IACD;AACA,QAAI,KAAK,SAAS,UAAU;AAC3B,YAAM,EAAE,MAAM,KAAK,IAAI;AACvB,UAAI,KAAK,cAAc,KAAK,UAAW,QAAO;AAC9C,UAAI,KAAK,aAAa,KAAK,SAAU,QAAO;AAC5C,UAAI,KAAK,WAAW,KAAK,OAAQ,QAAO;AACxC,UAAI,KAAK,UAAU,KAAK,MAAO,QAAO;AAAA,IACvC;AACA,WAAO;AAAA,EACR;AAEA,SAAO;AAAA,IACN,SAAS;AAAA,MACR;AAAA,QACC,CAAC,YAAY,QAAQ;AAAA;AAAA;AAAA,QAGrB;AAAA,UACC,CAAC,YAAY,WAAW;AAAA,UACxB,kBAA6B,YAAY,EAAE,iBAAiB,KAAK,CAAC;AAAA,QACnE;AAAA,MACD;AAAA,IACD;AAAA,IACA,kBAAkB;AAAA,MACjB;AAAA,QACC,CAAC,WAAW,OAAO;AAAA,QACnB,gBAAiC,CAAC,WAAW,GAAG,yBAAyB;AAAA,MAC1E;AAAA,IACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASA,oBAAoB,WAAW,iBAAiB;AAAA,EACjD;AACD;",
|
|
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
|
+
)
|