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