@tldraw/sync-collaboration 0.0.0-bootstrap → 5.3.0-next.2fa9c61a8de6
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 +5 -2
- package/dist-cjs/comment-authorizers.js.map +2 -2
- package/dist-cjs/index.d.ts +16 -0
- package/dist-cjs/index.js +1 -1
- package/dist-esm/comment-authorizers.mjs +5 -2
- package/dist-esm/comment-authorizers.mjs.map +2 -2
- package/dist-esm/index.d.mts +16 -0
- package/dist-esm/index.mjs +1 -1
- package/package.json +5 -5
- package/src/comment-authorizers.test.ts +159 -2
- package/src/comment-authorizers.ts +22 -4
|
@@ -23,9 +23,12 @@ __export(comment_authorizers_exports, {
|
|
|
23
23
|
module.exports = __toCommonJS(comment_authorizers_exports);
|
|
24
24
|
var import_tlschema = require("@tldraw/tlschema");
|
|
25
25
|
function createCommentAuthorizers(opts) {
|
|
26
|
-
const { getUserId } = opts;
|
|
26
|
+
const { getUserId, canComment = ({ isReadonly }) => !isReadonly } = opts;
|
|
27
27
|
function withUserId(rule) {
|
|
28
|
-
return (args) =>
|
|
28
|
+
return (args) => {
|
|
29
|
+
if (!canComment(args.session)) return null;
|
|
30
|
+
return rule(getUserId(args.session), args);
|
|
31
|
+
};
|
|
29
32
|
}
|
|
30
33
|
function authorizeAuthored(field, { ownerOnlyUpdate = false } = {}) {
|
|
31
34
|
return (userId, { type, prev, next }) => {
|
|
@@ -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'\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; 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 on\n\t * every create, update, and delete. Defaults to `({ isReadonly }) => !isReadonly`: comment\n\t * writes follow canvas access, so read-only viewers can read threads but not post, edit,\n\t * resolve, or react. Override to decouple the lanes \u2014 `() => true` allows commenting on a\n\t * read-only canvas (comment-only setups) \u2014 or to enforce custom criteria from the session.\n\t */\n\tcanComment?(session: { sessionId: string; isReadonly: boolean; meta: 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 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 * - `canComment` gates every create, update, and delete above, before the per-type rules run.\n * By default it mirrors the session's canvas access (`!isReadonly`), so read-only viewers can\n * read threads but not write to them; override it to decouple commenting from canvas access.\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, canComment = ({ isReadonly }: { isReadonly: boolean }) => !isReadonly } = 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`: deletion is a write-once `isDeleted`\n\t * flag \u2014 set exactly once, never cleared, only by the record's owner (`ownerOf`), never on\n\t * create \u2014 and clients never hard-delete these records at all. Record removals are\n\t * server-initiated only (server-side deletes don't run authorizers), so once the server\n\t * prunes a flagged record there is no un-delete.\n\t */\n\tfunction authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(\n\t\townerOf: (rec: Rec) => string,\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'delete') return null\n\t\t\tconst result = base(userId, args)\n\t\t\tif (!result) return null\n\t\t\t// A record can't be born deleted \u2014 that would smuggle a deletion past the update checks.\n\t\t\tif (args.type === 'create' && args.next.isDeleted) return null\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tif (prev.isDeleted !== next.isDeleted) {\n\t\t\t\t\tif (prev.isDeleted) return null // write-once: never cleared\n\t\t\t\t\tif (userId !== ownerOf(prev)) return null // only the owner deletes\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn result\n\t\t}\n\t}\n\n\t/**\n\t * Threads stay editable by anyone with access (resolve/reopen), but resolution is itself an\n\t * attribution: a non-null `resolved.by`, set at create or changed by update, must be the\n\t * session's own user.\n\t */\n\tconst authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {\n\t\tconst result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Delete + re-put could otherwise smuggle in a resolution forged in someone else's name.\n\t\t\tconst { next } = args\n\t\t\tif (next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tconst changed =\n\t\t\t\tprev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by\n\t\t\tif (changed && next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\treturn result\n\t}\n\n\t/**\n\t * Reject an update that changes any of `fields`. Used for the structural fields an update must\n\t * never touch: a comment's parent thread and its creation time. `threadId` is what ties a\n\t * comment to its conversation (and, downstream, to a file), so letting an author re-parent an\n\t * existing comment would move it between threads \u2014 and, where threads span files, between\n\t * files. `createdAt` orders threads and bounds the notification feed, so a mutable one lets a\n\t * comment be re-sorted after the fact.\n\t */\n\tfunction immutableFields<Rec extends UnknownRecord>(\n\t\tfields: readonly (keyof Rec & string)[],\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tfor (const field of fields) {\n\t\t\t\t\tif (next[field] !== prev[field]) return null\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn base(userId, args)\n\t\t}\n\t}\n\n\tconst authorizeReactionBase = authorizeAuthored<TLCommentReaction>('userId', {\n\t\townerOnlyUpdate: true,\n\t})\n\n\t/**\n\t * A reaction's id is derived from its (comment, user, emoji) triple (see\n\t * `createCommentReactionId`), which is what makes reaction identity structural. The base rule\n\t * already stamps `userId` from the session and lets only the owner change a reaction \u2014 but the\n\t * id, the comment it points at, and the emoji are all client-supplied, so this wrapper adds two\n\t * things:\n\t *\n\t * - On **create**, the id must be the canonical id for `commentId` + the session's user +\n\t * `next.emoji`. Without this a forged client could create a record at another user's id slot\n\t * (locking them out of that reaction), or push a mismatched id that lands two records on one\n\t * (comment, user, emoji) \u2014 an invariant any persistence layer keyed on the triple relies on.\n\t *\n\t * - On **update**, everything identity-bearing is immutable: `commentId`, `threadId`, `pageId`,\n\t * and `emoji` all feed the id (directly or by denormalization), so a re-react is a\n\t * create/delete, not an update. The only thing an update may touch is `createdAt`/`meta`.\n\t * So the id and the fields it is derived from can never drift apart.\n\t */\n\tconst authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {\n\t\t// Only the reactor may remove their own reaction. Cascades still sweep every reactor's\n\t\t// records because server-initiated writes carry no session and so skip authorizers\n\t\t// entirely \u2014 an open client delete was never what made the sweep work.\n\t\tif (args.type === 'delete') {\n\t\t\treturn userId && userId === args.prev.userId ? args.prev : null\n\t\t}\n\t\tconst result = authorizeReactionBase(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Unreachable: the base rule already rejected identity-less creates. Checked to narrow.\n\t\t\tif (!userId) return null\n\t\t\tconst { next } = args\n\t\t\tif (next.id !== createCommentReactionId(next.commentId, userId, next.emoji)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tif (next.commentId !== prev.commentId) return null\n\t\t\tif (next.threadId !== prev.threadId) return null\n\t\t\tif (next.pageId !== prev.pageId) return null\n\t\t\tif (next.emoji !== prev.emoji) return null\n\t\t}\n\t\treturn result\n\t}\n\n\treturn {\n\t\tcomment: withUserId(\n\t\t\tauthorizeSoftDeleted<TLComment>(\n\t\t\t\t(comment) => comment.authorId,\n\t\t\t\t// `pageId` stays mutable: it's denormalized from the thread, and moving an anchored\n\t\t\t\t// thread between pages rewrites it on every comment in the thread.\n\t\t\t\timmutableFields<TLComment>(\n\t\t\t\t\t['threadId', 'createdAt'],\n\t\t\t\t\tauthorizeAuthored<TLComment>('authorId', { ownerOnlyUpdate: true })\n\t\t\t\t)\n\t\t\t)\n\t\t),\n\t\t'comment-thread': withUserId(\n\t\t\tauthorizeSoftDeleted<TLCommentThread>(\n\t\t\t\t(thread) => thread.createdBy,\n\t\t\t\timmutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)\n\t\t\t)\n\t\t),\n\t\t// A reaction is one user's own record, so the standard attribution rules mostly cover it:\n\t\t// `userId` is stamped from the session and only the reactor can change their reaction, and\n\t\t// the wrapper's id check ties the record to its (comment, user, emoji) slot \u2014 so no one can\n\t\t// forge or hijack another user's reaction. Deletion, though, is deliberately open: anyone\n\t\t// with access to the room may hard-delete any reaction. Reactions have no soft-delete /\n\t\t// `isDeleted` flag (unlike comments) on purpose \u2014 a reaction is a toggle, so removing one is\n\t\t// a plain record delete, and a host cascading a comment or thread deletion must sweep every\n\t\t// reactor's records, not just the caller's own.\n\t\t'comment-reaction': withUserId(authorizeReaction),\n\t}\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist-cjs/index.d.ts
CHANGED
|
@@ -15,9 +15,22 @@ export declare interface CommentAuthorizerOptions<SessionMeta> {
|
|
|
15
15
|
* any owner-only action. Called exactly once per authorized write.
|
|
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 on
|
|
24
|
+
* every create, update, and delete. Defaults to `({ isReadonly }) => !isReadonly`: comment
|
|
25
|
+
* writes follow canvas access, so read-only viewers can read threads but not post, edit,
|
|
26
|
+
* resolve, or react. Override to decouple the lanes — `() => true` allows commenting on a
|
|
27
|
+
* read-only canvas (comment-only setups) — or to enforce custom criteria from the session.
|
|
28
|
+
*/
|
|
29
|
+
canComment?(session: {
|
|
30
|
+
isReadonly: boolean;
|
|
31
|
+
meta: SessionMeta;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
}): boolean;
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
/**
|
|
@@ -37,6 +50,9 @@ export declare interface CommentAuthorizerOptions<SessionMeta> {
|
|
|
37
50
|
* - Deletion is soft for comments and threads: a write-once `isDeleted` flag that only the
|
|
38
51
|
* record's owner may set, never cleared, never set at create. Client hard-deletes are always
|
|
39
52
|
* rejected — record removals are server-side only.
|
|
53
|
+
* - `canComment` gates every create, update, and delete above, before the per-type rules run.
|
|
54
|
+
* By default it mirrors the session's canvas access (`!isReadonly`), so read-only viewers can
|
|
55
|
+
* read threads but not write to them; override it to decouple commenting from canvas access.
|
|
40
56
|
*
|
|
41
57
|
* Comment records ride alongside your document records, so widen the room's record union to
|
|
42
58
|
* 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-next.2fa9c61a8de6",
|
|
29
29
|
"cjs"
|
|
30
30
|
);
|
|
31
31
|
//# sourceMappingURL=index.js.map
|
|
@@ -2,9 +2,12 @@ import {
|
|
|
2
2
|
createCommentReactionId
|
|
3
3
|
} from "@tldraw/tlschema";
|
|
4
4
|
function createCommentAuthorizers(opts) {
|
|
5
|
-
const { getUserId } = opts;
|
|
5
|
+
const { getUserId, canComment = ({ isReadonly }) => !isReadonly } = opts;
|
|
6
6
|
function withUserId(rule) {
|
|
7
|
-
return (args) =>
|
|
7
|
+
return (args) => {
|
|
8
|
+
if (!canComment(args.session)) return null;
|
|
9
|
+
return rule(getUserId(args.session), args);
|
|
10
|
+
};
|
|
8
11
|
}
|
|
9
12
|
function authorizeAuthored(field, { ownerOnlyUpdate = false } = {}) {
|
|
10
13
|
return (userId, { type, prev, next }) => {
|
|
@@ -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'\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; 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 on\n\t * every create, update, and delete. Defaults to `({ isReadonly }) => !isReadonly`: comment\n\t * writes follow canvas access, so read-only viewers can read threads but not post, edit,\n\t * resolve, or react. Override to decouple the lanes \u2014 `() => true` allows commenting on a\n\t * read-only canvas (comment-only setups) \u2014 or to enforce custom criteria from the session.\n\t */\n\tcanComment?(session: { sessionId: string; isReadonly: boolean; meta: 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 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 * - `canComment` gates every create, update, and delete above, before the per-type rules run.\n * By default it mirrors the session's canvas access (`!isReadonly`), so read-only viewers can\n * read threads but not write to them; override it to decouple commenting from canvas access.\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, canComment = ({ isReadonly }: { isReadonly: boolean }) => !isReadonly } = 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`: deletion is a write-once `isDeleted`\n\t * flag \u2014 set exactly once, never cleared, only by the record's owner (`ownerOf`), never on\n\t * create \u2014 and clients never hard-delete these records at all. Record removals are\n\t * server-initiated only (server-side deletes don't run authorizers), so once the server\n\t * prunes a flagged record there is no un-delete.\n\t */\n\tfunction authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(\n\t\townerOf: (rec: Rec) => string,\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'delete') return null\n\t\t\tconst result = base(userId, args)\n\t\t\tif (!result) return null\n\t\t\t// A record can't be born deleted \u2014 that would smuggle a deletion past the update checks.\n\t\t\tif (args.type === 'create' && args.next.isDeleted) return null\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tif (prev.isDeleted !== next.isDeleted) {\n\t\t\t\t\tif (prev.isDeleted) return null // write-once: never cleared\n\t\t\t\t\tif (userId !== ownerOf(prev)) return null // only the owner deletes\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn result\n\t\t}\n\t}\n\n\t/**\n\t * Threads stay editable by anyone with access (resolve/reopen), but resolution is itself an\n\t * attribution: a non-null `resolved.by`, set at create or changed by update, must be the\n\t * session's own user.\n\t */\n\tconst authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {\n\t\tconst result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Delete + re-put could otherwise smuggle in a resolution forged in someone else's name.\n\t\t\tconst { next } = args\n\t\t\tif (next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tconst changed =\n\t\t\t\tprev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by\n\t\t\tif (changed && next.resolved && next.resolved.by !== userId) return null\n\t\t}\n\t\treturn result\n\t}\n\n\t/**\n\t * Reject an update that changes any of `fields`. Used for the structural fields an update must\n\t * never touch: a comment's parent thread and its creation time. `threadId` is what ties a\n\t * comment to its conversation (and, downstream, to a file), so letting an author re-parent an\n\t * existing comment would move it between threads \u2014 and, where threads span files, between\n\t * files. `createdAt` orders threads and bounds the notification feed, so a mutable one lets a\n\t * comment be re-sorted after the fact.\n\t */\n\tfunction immutableFields<Rec extends UnknownRecord>(\n\t\tfields: readonly (keyof Rec & string)[],\n\t\tbase: Rule<Rec>\n\t): Rule<Rec> {\n\t\treturn (userId, args) => {\n\t\t\tif (args.type === 'update') {\n\t\t\t\tconst { prev, next } = args\n\t\t\t\tfor (const field of fields) {\n\t\t\t\t\tif (next[field] !== prev[field]) return null\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn base(userId, args)\n\t\t}\n\t}\n\n\tconst authorizeReactionBase = authorizeAuthored<TLCommentReaction>('userId', {\n\t\townerOnlyUpdate: true,\n\t})\n\n\t/**\n\t * A reaction's id is derived from its (comment, user, emoji) triple (see\n\t * `createCommentReactionId`), which is what makes reaction identity structural. The base rule\n\t * already stamps `userId` from the session and lets only the owner change a reaction \u2014 but the\n\t * id, the comment it points at, and the emoji are all client-supplied, so this wrapper adds two\n\t * things:\n\t *\n\t * - On **create**, the id must be the canonical id for `commentId` + the session's user +\n\t * `next.emoji`. Without this a forged client could create a record at another user's id slot\n\t * (locking them out of that reaction), or push a mismatched id that lands two records on one\n\t * (comment, user, emoji) \u2014 an invariant any persistence layer keyed on the triple relies on.\n\t *\n\t * - On **update**, everything identity-bearing is immutable: `commentId`, `threadId`, `pageId`,\n\t * and `emoji` all feed the id (directly or by denormalization), so a re-react is a\n\t * create/delete, not an update. The only thing an update may touch is `createdAt`/`meta`.\n\t * So the id and the fields it is derived from can never drift apart.\n\t */\n\tconst authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {\n\t\t// Only the reactor may remove their own reaction. Cascades still sweep every reactor's\n\t\t// records because server-initiated writes carry no session and so skip authorizers\n\t\t// entirely \u2014 an open client delete was never what made the sweep work.\n\t\tif (args.type === 'delete') {\n\t\t\treturn userId && userId === args.prev.userId ? args.prev : null\n\t\t}\n\t\tconst result = authorizeReactionBase(userId, args)\n\t\tif (!result) return null\n\t\tif (args.type === 'create') {\n\t\t\t// Unreachable: the base rule already rejected identity-less creates. Checked to narrow.\n\t\t\tif (!userId) return null\n\t\t\tconst { next } = args\n\t\t\tif (next.id !== createCommentReactionId(next.commentId, userId, next.emoji)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t}\n\t\tif (args.type === 'update') {\n\t\t\tconst { prev, next } = args\n\t\t\tif (next.commentId !== prev.commentId) return null\n\t\t\tif (next.threadId !== prev.threadId) return null\n\t\t\tif (next.pageId !== prev.pageId) return null\n\t\t\tif (next.emoji !== prev.emoji) return null\n\t\t}\n\t\treturn result\n\t}\n\n\treturn {\n\t\tcomment: withUserId(\n\t\t\tauthorizeSoftDeleted<TLComment>(\n\t\t\t\t(comment) => comment.authorId,\n\t\t\t\t// `pageId` stays mutable: it's denormalized from the thread, and moving an anchored\n\t\t\t\t// thread between pages rewrites it on every comment in the thread.\n\t\t\t\timmutableFields<TLComment>(\n\t\t\t\t\t['threadId', 'createdAt'],\n\t\t\t\t\tauthorizeAuthored<TLComment>('authorId', { ownerOnlyUpdate: true })\n\t\t\t\t)\n\t\t\t)\n\t\t),\n\t\t'comment-thread': withUserId(\n\t\t\tauthorizeSoftDeleted<TLCommentThread>(\n\t\t\t\t(thread) => thread.createdBy,\n\t\t\t\timmutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)\n\t\t\t)\n\t\t),\n\t\t// A reaction is one user's own record, so the standard attribution rules mostly cover it:\n\t\t// `userId` is stamped from the session and only the reactor can change their reaction, and\n\t\t// the wrapper's id check ties the record to its (comment, user, emoji) slot \u2014 so no one can\n\t\t// forge or hijack another user's reaction. Deletion, though, is deliberately open: anyone\n\t\t// with access to the room may hard-delete any reaction. Reactions have no soft-delete /\n\t\t// `isDeleted` flag (unlike comments) on purpose \u2014 a reaction is a toggle, so removing one is\n\t\t// a plain record delete, and a host cascading a comment or thread deletion must sweep every\n\t\t// reactor's records, not just the caller's own.\n\t\t'comment-reaction': withUserId(authorizeReaction),\n\t}\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist-esm/index.d.mts
CHANGED
|
@@ -15,9 +15,22 @@ export declare interface CommentAuthorizerOptions<SessionMeta> {
|
|
|
15
15
|
* any owner-only action. Called exactly once per authorized write.
|
|
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 on
|
|
24
|
+
* every create, update, and delete. Defaults to `({ isReadonly }) => !isReadonly`: comment
|
|
25
|
+
* writes follow canvas access, so read-only viewers can read threads but not post, edit,
|
|
26
|
+
* resolve, or react. Override to decouple the lanes — `() => true` allows commenting on a
|
|
27
|
+
* read-only canvas (comment-only setups) — or to enforce custom criteria from the session.
|
|
28
|
+
*/
|
|
29
|
+
canComment?(session: {
|
|
30
|
+
isReadonly: boolean;
|
|
31
|
+
meta: SessionMeta;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
}): boolean;
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
/**
|
|
@@ -37,6 +50,9 @@ export declare interface CommentAuthorizerOptions<SessionMeta> {
|
|
|
37
50
|
* - Deletion is soft for comments and threads: a write-once `isDeleted` flag that only the
|
|
38
51
|
* record's owner may set, never cleared, never set at create. Client hard-deletes are always
|
|
39
52
|
* rejected — record removals are server-side only.
|
|
53
|
+
* - `canComment` gates every create, update, and delete above, before the per-type rules run.
|
|
54
|
+
* By default it mirrors the session's canvas access (`!isReadonly`), so read-only viewers can
|
|
55
|
+
* read threads but not write to them; override it to decouple commenting from canvas access.
|
|
40
56
|
*
|
|
41
57
|
* Comment records ride alongside your document records, so widen the room's record union to
|
|
42
58
|
* include them, then spread the result into the authorizer map alongside your own entries:
|
package/dist-esm/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { registerTldrawLibraryVersion } from "@tldraw/utils";
|
|
|
2
2
|
import { createCommentAuthorizers } from "./comment-authorizers.mjs";
|
|
3
3
|
registerTldrawLibraryVersion(
|
|
4
4
|
"@tldraw/sync-collaboration",
|
|
5
|
-
"
|
|
5
|
+
"5.3.0-next.2fa9c61a8de6",
|
|
6
6
|
"esm"
|
|
7
7
|
);
|
|
8
8
|
export {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/sync-collaboration",
|
|
3
3
|
"description": "tldraw sync collaboration: server-side write authorization for collaboration features.",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "5.3.0-next.2fa9c61a8de6",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"node": ">=22.12.0"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@tldraw/store": "5.
|
|
35
|
-
"@tldraw/sync-core": "5.
|
|
36
|
-
"@tldraw/tlschema": "5.
|
|
37
|
-
"@tldraw/utils": "5.
|
|
34
|
+
"@tldraw/store": "5.3.0-next.2fa9c61a8de6",
|
|
35
|
+
"@tldraw/sync-core": "5.3.0-next.2fa9c61a8de6",
|
|
36
|
+
"@tldraw/tlschema": "5.3.0-next.2fa9c61a8de6",
|
|
37
|
+
"@tldraw/utils": "5.3.0-next.2fa9c61a8de6"
|
|
38
38
|
},
|
|
39
39
|
"module": "dist-esm/index.mjs",
|
|
40
40
|
"source": "src/index.ts",
|
|
@@ -28,8 +28,15 @@ const thread = createCommentThread({
|
|
|
28
28
|
createdBy: 'client-claims-alice',
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
function session(
|
|
32
|
-
|
|
31
|
+
function session(
|
|
32
|
+
userId: string | null,
|
|
33
|
+
isReadonly = false
|
|
34
|
+
): {
|
|
35
|
+
sessionId: string
|
|
36
|
+
isReadonly: boolean
|
|
37
|
+
meta: TestMeta
|
|
38
|
+
} {
|
|
39
|
+
return { sessionId: 's1', isReadonly, meta: { userId } }
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
describe('createCommentAuthorizers', () => {
|
|
@@ -405,4 +412,154 @@ describe('createCommentAuthorizers', () => {
|
|
|
405
412
|
).toBeNull()
|
|
406
413
|
})
|
|
407
414
|
})
|
|
415
|
+
|
|
416
|
+
describe('canComment', () => {
|
|
417
|
+
const comment = (authorId: string) =>
|
|
418
|
+
createComment({ threadId: thread.id, pageId, authorId, body: toRichText('hi') })
|
|
419
|
+
const makeThread = (createdBy: string) =>
|
|
420
|
+
createCommentThread({ pageId, anchor: { type: 'page' }, createdBy })
|
|
421
|
+
const makeReaction = (userId: string, emoji = '👍') =>
|
|
422
|
+
createCommentReaction({
|
|
423
|
+
commentId: createCommentId('c1'),
|
|
424
|
+
threadId: thread.id,
|
|
425
|
+
pageId,
|
|
426
|
+
userId,
|
|
427
|
+
emoji,
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('blocks all comment-record writes from canvas read-only sessions by default', () => {
|
|
431
|
+
const readonly = session('real-bob', true)
|
|
432
|
+
|
|
433
|
+
const commentPrev = comment('real-bob')
|
|
434
|
+
expect(
|
|
435
|
+
authorizers.comment!({
|
|
436
|
+
session: readonly,
|
|
437
|
+
type: 'create',
|
|
438
|
+
prev: null,
|
|
439
|
+
next: comment('real-bob'),
|
|
440
|
+
})
|
|
441
|
+
).toBeNull()
|
|
442
|
+
expect(
|
|
443
|
+
authorizers.comment!({
|
|
444
|
+
session: readonly,
|
|
445
|
+
type: 'update',
|
|
446
|
+
prev: commentPrev,
|
|
447
|
+
next: { ...commentPrev, body: toRichText('edited') },
|
|
448
|
+
})
|
|
449
|
+
).toBeNull()
|
|
450
|
+
expect(
|
|
451
|
+
authorizers.comment!({ session: readonly, type: 'delete', prev: commentPrev, next: null })
|
|
452
|
+
).toBeNull()
|
|
453
|
+
|
|
454
|
+
const threadPrev = makeThread('real-bob')
|
|
455
|
+
expect(
|
|
456
|
+
authorizers['comment-thread']!({
|
|
457
|
+
session: readonly,
|
|
458
|
+
type: 'create',
|
|
459
|
+
prev: null,
|
|
460
|
+
next: makeThread('real-bob'),
|
|
461
|
+
})
|
|
462
|
+
).toBeNull()
|
|
463
|
+
expect(
|
|
464
|
+
authorizers['comment-thread']!({
|
|
465
|
+
session: readonly,
|
|
466
|
+
type: 'update',
|
|
467
|
+
prev: threadPrev,
|
|
468
|
+
next: { ...threadPrev, resolved: { at: 1, by: 'real-bob' } },
|
|
469
|
+
})
|
|
470
|
+
).toBeNull()
|
|
471
|
+
expect(
|
|
472
|
+
authorizers['comment-thread']!({
|
|
473
|
+
session: readonly,
|
|
474
|
+
type: 'delete',
|
|
475
|
+
prev: threadPrev,
|
|
476
|
+
next: null,
|
|
477
|
+
})
|
|
478
|
+
).toBeNull()
|
|
479
|
+
|
|
480
|
+
const reactionPrev = makeReaction('real-bob')
|
|
481
|
+
expect(
|
|
482
|
+
authorizers['comment-reaction']!({
|
|
483
|
+
session: readonly,
|
|
484
|
+
type: 'create',
|
|
485
|
+
prev: null,
|
|
486
|
+
next: makeReaction('real-bob'),
|
|
487
|
+
})
|
|
488
|
+
).toBeNull()
|
|
489
|
+
expect(
|
|
490
|
+
authorizers['comment-reaction']!({
|
|
491
|
+
session: readonly,
|
|
492
|
+
type: 'update',
|
|
493
|
+
prev: reactionPrev,
|
|
494
|
+
next: { ...reactionPrev, createdAt: reactionPrev.createdAt + 1 },
|
|
495
|
+
})
|
|
496
|
+
).toBeNull()
|
|
497
|
+
expect(
|
|
498
|
+
authorizers['comment-reaction']!({
|
|
499
|
+
session: readonly,
|
|
500
|
+
type: 'delete',
|
|
501
|
+
prev: reactionPrev,
|
|
502
|
+
next: null,
|
|
503
|
+
})
|
|
504
|
+
).toBeNull()
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it('still applies the per-type rules to read-write sessions', () => {
|
|
508
|
+
const result = authorizers.comment!({
|
|
509
|
+
session: session('real-bob', false),
|
|
510
|
+
type: 'create',
|
|
511
|
+
prev: null,
|
|
512
|
+
next: comment('client-claims-alice'),
|
|
513
|
+
}) as TLComment
|
|
514
|
+
expect(result.authorId).toBe('real-bob')
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it('lets a custom canComment allow read-only sessions (comment-only setups)', () => {
|
|
518
|
+
const commentOnly = createCommentAuthorizers<TestMeta>({
|
|
519
|
+
getUserId: (session) => session.meta.userId,
|
|
520
|
+
canComment: () => true,
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
const result = commentOnly.comment!({
|
|
524
|
+
session: session('real-bob', true),
|
|
525
|
+
type: 'create',
|
|
526
|
+
prev: null,
|
|
527
|
+
next: comment('client-claims-alice'),
|
|
528
|
+
}) as TLComment
|
|
529
|
+
expect(result.authorId).toBe('real-bob')
|
|
530
|
+
|
|
531
|
+
expect(
|
|
532
|
+
commentOnly.comment!({
|
|
533
|
+
session: session(null, true),
|
|
534
|
+
type: 'create',
|
|
535
|
+
prev: null,
|
|
536
|
+
next: comment('anon'),
|
|
537
|
+
})
|
|
538
|
+
).toBeNull()
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('lets a custom canComment block sessions on its own criteria', () => {
|
|
542
|
+
const bannable = createCommentAuthorizers<TestMeta>({
|
|
543
|
+
getUserId: (session) => session.meta.userId,
|
|
544
|
+
canComment: ({ meta }) => meta.userId !== 'banned',
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
expect(
|
|
548
|
+
bannable.comment!({
|
|
549
|
+
session: session('banned', false),
|
|
550
|
+
type: 'create',
|
|
551
|
+
prev: null,
|
|
552
|
+
next: comment('banned'),
|
|
553
|
+
})
|
|
554
|
+
).toBeNull()
|
|
555
|
+
|
|
556
|
+
const result = bannable.comment!({
|
|
557
|
+
session: session('real-bob', false),
|
|
558
|
+
type: 'create',
|
|
559
|
+
prev: null,
|
|
560
|
+
next: comment('client-claims-alice'),
|
|
561
|
+
}) as TLComment
|
|
562
|
+
expect(result.authorId).toBe('real-bob')
|
|
563
|
+
})
|
|
564
|
+
})
|
|
408
565
|
})
|
|
@@ -18,7 +18,16 @@ export interface CommentAuthorizerOptions<SessionMeta> {
|
|
|
18
18
|
* `null` for anonymous sessions — they can't create comments or threads, and can't perform
|
|
19
19
|
* any owner-only action. Called exactly once per authorized write.
|
|
20
20
|
*/
|
|
21
|
-
getUserId(session: { sessionId: string; meta: SessionMeta }): string | null
|
|
21
|
+
getUserId(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): string | null
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a session may write comment records at all — checked before the per-type rules on
|
|
25
|
+
* every create, update, and delete. Defaults to `({ isReadonly }) => !isReadonly`: comment
|
|
26
|
+
* writes follow canvas access, so read-only viewers can read threads but not post, edit,
|
|
27
|
+
* resolve, or react. Override to decouple the lanes — `() => true` allows commenting on a
|
|
28
|
+
* read-only canvas (comment-only setups) — or to enforce custom criteria from the session.
|
|
29
|
+
*/
|
|
30
|
+
canComment?(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): boolean
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
/**
|
|
@@ -38,6 +47,9 @@ export interface CommentAuthorizerOptions<SessionMeta> {
|
|
|
38
47
|
* - Deletion is soft for comments and threads: a write-once `isDeleted` flag that only the
|
|
39
48
|
* record's owner may set, never cleared, never set at create. Client hard-deletes are always
|
|
40
49
|
* rejected — record removals are server-side only.
|
|
50
|
+
* - `canComment` gates every create, update, and delete above, before the per-type rules run.
|
|
51
|
+
* By default it mirrors the session's canvas access (`!isReadonly`), so read-only viewers can
|
|
52
|
+
* read threads but not write to them; override it to decouple commenting from canvas access.
|
|
41
53
|
*
|
|
42
54
|
* Comment records ride alongside your document records, so widen the room's record union to
|
|
43
55
|
* include them, then spread the result into the authorizer map alongside your own entries:
|
|
@@ -62,7 +74,7 @@ export interface CommentAuthorizerOptions<SessionMeta> {
|
|
|
62
74
|
export function createCommentAuthorizers<SessionMeta>(
|
|
63
75
|
opts: CommentAuthorizerOptions<SessionMeta>
|
|
64
76
|
): TLRecordAuthorizers<TLComment | TLCommentThread | TLCommentReaction, SessionMeta> {
|
|
65
|
-
const { getUserId } = opts
|
|
77
|
+
const { getUserId, canComment = ({ isReadonly }: { isReadonly: boolean }) => !isReadonly } = opts
|
|
66
78
|
|
|
67
79
|
/** A rule is an authorizer that receives the session's user id, resolved for it exactly once. */
|
|
68
80
|
type Rule<Rec extends UnknownRecord> = (
|
|
@@ -70,11 +82,17 @@ export function createCommentAuthorizers<SessionMeta>(
|
|
|
70
82
|
args: Parameters<TLRecordAuthorizer<Rec, SessionMeta>>[0]
|
|
71
83
|
) => Rec | null
|
|
72
84
|
|
|
73
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Adapt a rule to the authorizer signature: gate on `canComment` first, then resolve the
|
|
87
|
+
* session's user id exactly once.
|
|
88
|
+
*/
|
|
74
89
|
function withUserId<Rec extends UnknownRecord>(
|
|
75
90
|
rule: Rule<Rec>
|
|
76
91
|
): TLRecordAuthorizer<Rec, SessionMeta> {
|
|
77
|
-
return (args) =>
|
|
92
|
+
return (args) => {
|
|
93
|
+
if (!canComment(args.session)) return null
|
|
94
|
+
return rule(getUserId(args.session), args)
|
|
95
|
+
}
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
/**
|