snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1 @@
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)

View File

@@ -0,0 +1,5 @@
# @tldraw/sync-collaboration
Server-side logic for tldraw's collaboration features: write authorization for comment, comment-thread, and comment-reaction records, for use with a sync server's `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). This package has no react or client-editor dependencies, so it is safe to import from workers and node servers.
See [tldraw.dev](https://tldraw.dev) for docs. License: see `LICENSE.md`.

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../internal/config/api-extractor.json"
}

View File

@@ -0,0 +1,55 @@
## API Report File for "@tldraw/sync-collaboration"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { TLComment } from '@tldraw/tlschema';
import { TLCommentReaction } from '@tldraw/tlschema';
import { TLCommentThread } from '@tldraw/tlschema';
import type { TLRecordAuthorizers } from '@tldraw/sync-core';
// @public
export interface CommentAuthorizerOptions<SessionMeta> {
canComment?(session: {
isReadonly: boolean;
meta: SessionMeta;
sessionId: string;
}): boolean;
canModifyComment?(ctx: CommentModificationAuthContext<SessionMeta>): boolean;
getUserId(session: {
isReadonly: boolean;
meta: SessionMeta;
sessionId: string;
}): null | string;
}
// @public
export type CommentModification = {
readonly action: 'delete-comment';
readonly comment: TLComment;
} | {
readonly action: 'delete-thread';
readonly thread: TLCommentThread;
} | {
readonly action: 'edit-comment';
readonly comment: TLComment;
};
// @public
export type CommentModificationAuthContext<SessionMeta> = {
readonly ownerId: string;
readonly session: {
isReadonly: boolean;
meta: SessionMeta;
sessionId: string;
};
readonly userId: null | string;
} & CommentModification;
// @public
export function createCommentAuthorizers<SessionMeta>(opts: CommentAuthorizerOptions<SessionMeta>): TLRecordAuthorizers<TLComment | TLCommentReaction | TLCommentThread, SessionMeta>;
// (No @packageDocumentation comment for this package)
```

View File

@@ -0,0 +1,52 @@
{
"name": "@tldraw/sync-collaboration",
"description": "tldraw sync collaboration: server-side write authorization for collaboration features.",
"version": "5.4.2",
"author": {
"name": "tldraw Inc.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "SEE LICENSE IN LICENSE.md",
"tldraw_product": {
"stableId": "tldraw:commenting",
"name": "Commenting",
"type": "feature",
"parent": "tldraw:collaboration",
"premium": true,
"licenseFlag": "FEAT_COMMENTING"
},
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"type": "module",
"scripts": {
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
"postpack": "../../internal/scripts/postpack.sh",
"pack-tarball": "yarn pack",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"devDependencies": {
"vitest": "^5.0.0"
},
"engines": {
"node": ">=22.12.0"
},
"dependencies": {
"@tldraw/store": "workspace:*",
"@tldraw/sync-core": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/utils": "workspace:*"
}
}

View File

@@ -0,0 +1,925 @@
import {
TLComment,
TLCommentReaction,
TLCommentThread,
TLPageId,
createComment,
createCommentId,
createCommentReaction,
createCommentReactionId,
createCommentThread,
toRichText,
} from '@tldraw/tlschema'
import { describe, expect, it } from 'vitest'
import { createCommentAuthorizers } from './comment-authorizers'
interface TestMeta {
userId: string | null
}
const authorizers = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
})
const pageId = 'page:test' as TLPageId
const thread = createCommentThread({
pageId,
anchor: { type: 'page' },
createdBy: 'client-claims-alice',
})
function session(
userId: string | null,
isReadonly = false
): {
sessionId: string
isReadonly: boolean
meta: TestMeta
} {
return { sessionId: 's1', isReadonly, meta: { userId } }
}
describe('createCommentAuthorizers', () => {
// getUserId is documented as called exactly once per write. More than once would let an
// impure host callback split identity across checks — e.g. a reaction create where the
// stamped userId and the canonical-id check disagree about who is reacting.
it('resolves the session user exactly once per authorization', () => {
let calls = 0
const counted = createCommentAuthorizers<TestMeta>({
getUserId: (s) => {
calls++
return s.meta.userId
},
})
// the worst former offender: a thread update touching both resolution and the soft-delete flag
const prev = createCommentThread({ pageId, anchor: { type: 'page' }, createdBy: 'real-bob' })
const next = { ...prev, resolved: { at: 1, by: 'real-bob' }, isDeleted: true }
expect(
counted['comment-thread']!({ session: session('real-bob'), type: 'update', prev, next })
).toBe(next)
expect(calls).toBe(1)
})
describe('comment', () => {
const authorize = authorizers.comment!
const comment = (authorId: string) =>
createComment({ threadId: thread.id, pageId, authorId, body: toRichText('hi') })
it('stamps authorId from the session on create, overriding the client value', () => {
const result = authorize({
session: session('real-bob'),
type: 'create',
prev: null,
next: comment('client-claims-alice'),
}) as TLComment
expect(result.authorId).toBe('real-bob')
})
it('rejects a create with no authenticated user', () => {
expect(
authorize({ session: session(null), type: 'create', prev: null, next: comment('anon') })
).toBeNull()
})
it('allows the author to edit their own comment', () => {
const prev = comment('real-bob')
const next = { ...prev, body: toRichText('edited') }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
})
it('vetoes an edit from someone who is not the author', () => {
const prev = comment('real-bob')
const next = { ...prev, body: toRichText('sneakily rewritten') }
expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
})
it('vetoes an update that changes the author', () => {
const prev = comment('real-bob')
const next = { ...prev, authorId: 'someone-else' }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
it('vetoes every client hard-delete, even from the author (deletion is soft)', () => {
const prev = comment('real-bob')
expect(
authorize({ session: session('real-bob'), type: 'delete', prev, next: null })
).toBeNull()
expect(
authorize({ session: session('real-mallory'), type: 'delete', prev, next: null })
).toBeNull()
expect(authorize({ session: session(null), type: 'delete', prev, next: null })).toBeNull()
})
it('lets the author soft-delete their own comment', () => {
const prev = comment('real-bob')
const next = { ...prev, isDeleted: true }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
})
it('vetoes a non-author soft-deleting the comment', () => {
const prev = comment('real-bob')
const next = { ...prev, isDeleted: true }
expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
expect(authorize({ session: session(null), type: 'update', prev, next })).toBeNull()
})
it('vetoes clearing a soft-delete, even by the author (write-once)', () => {
const prev = { ...comment('real-bob'), isDeleted: true }
const next = { ...prev, isDeleted: false }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
it('allows an update that leaves an existing soft-delete untouched', () => {
const prev = { ...comment('real-bob'), isDeleted: true }
const next = { ...prev, body: toRichText('edited') }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
})
it('vetoes a create with the soft-delete flag already set', () => {
const next = { ...comment('real-bob'), isDeleted: true }
expect(
authorize({ session: session('real-bob'), type: 'create', prev: null, next })
).toBeNull()
})
// threadId ties a comment to its conversation (and downstream to a file), so re-parenting an
// existing comment must not be an update — even by its own author.
it('vetoes re-parenting a comment to another thread', () => {
const prev = comment('real-bob')
const next = { ...prev, threadId: 'comment-thread:other' as TLCommentThread['id'] }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
// createdAt orders threads and bounds the notification feed, so a mutable one lets a comment
// be re-sorted (or pinned to the top of the feed) after the fact.
it('vetoes back-dating a comment', () => {
const prev = comment('real-bob')
const next = { ...prev, createdAt: 1 }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
// the anchor lifecycle rewrites pageId on every comment when a thread moves pages
it('allows a pageId update (threads can move between pages)', () => {
const prev = comment('real-bob')
const next = { ...prev, pageId: 'page:other' as TLPageId }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
})
})
describe('comment-thread', () => {
const authorize = authorizers['comment-thread']!
const makeThread = (createdBy: string) =>
createCommentThread({ pageId, anchor: { type: 'page' }, createdBy })
it('stamps createdBy from the session on create, overriding the client value', () => {
const result = authorize({
session: session('real-bob'),
type: 'create',
prev: null,
next: makeThread('client-claims-alice'),
}) as TLCommentThread
expect(result.createdBy).toBe('real-bob')
})
it('vetoes an update that changes the creator', () => {
const prev = makeThread('real-bob')
const next = { ...prev, createdBy: 'someone-else' }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
it('lets a non-creator resolve the thread as themselves', () => {
const prev = makeThread('real-bob')
const next = { ...prev, resolved: { at: 1, by: 'real-mallory' } }
expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBe(next)
})
it('lets a non-creator reopen a resolved thread', () => {
const prev = { ...makeThread('real-bob'), resolved: { at: 1, by: 'real-alice' } }
const next = { ...prev, resolved: null }
expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBe(next)
})
it('vetoes a resolution attributed to someone else', () => {
const prev = makeThread('real-bob')
const next = { ...prev, resolved: { at: 1, by: 'real-alice' } }
expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
})
it('allows an update that leaves an existing resolution untouched', () => {
const prev = { ...makeThread('real-bob'), resolved: { at: 1, by: 'real-alice' } }
// new object reference, same value — must not be treated as a change
const next = { ...prev, resolved: { ...prev.resolved } }
expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBe(next)
})
it('vetoes a create with a resolution attributed to someone else', () => {
const next = { ...makeThread('real-mallory'), resolved: { at: 1, by: 'real-alice' } }
expect(
authorize({ session: session('real-mallory'), type: 'create', prev: null, next })
).toBeNull()
})
it('allows a create resolved by the creator themselves', () => {
const next = { ...makeThread('real-bob'), resolved: { at: 1, by: 'real-bob' } }
const result = authorize({
session: session('real-bob'),
type: 'create',
prev: null,
next,
}) as TLCommentThread
expect(result.resolved).toEqual({ at: 1, by: 'real-bob' })
})
it('vetoes every client hard-delete, even from the creator (deletion is soft)', () => {
const prev = makeThread('real-bob')
expect(
authorize({ session: session('real-bob'), type: 'delete', prev, next: null })
).toBeNull()
expect(
authorize({ session: session('real-mallory'), type: 'delete', prev, next: null })
).toBeNull()
})
it('lets the creator soft-delete their own thread', () => {
const prev = makeThread('real-bob')
const next = { ...prev, isDeleted: true }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBe(next)
})
it('vetoes a non-creator soft-deleting the thread', () => {
const prev = makeThread('real-bob')
const next = { ...prev, isDeleted: true }
expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
expect(authorize({ session: session(null), type: 'update', prev, next })).toBeNull()
})
it('vetoes clearing a soft-delete, even by the creator (write-once)', () => {
const prev = { ...makeThread('real-bob'), isDeleted: true }
const next = { ...prev, isDeleted: false }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
it('allows an update that leaves an existing soft-delete untouched', () => {
const prev = { ...makeThread('real-bob'), isDeleted: true }
const next = { ...prev, resolved: { at: 2, by: 'real-mallory' } }
expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBe(next)
})
it('vetoes a create with the soft-delete flag already set', () => {
const next = { ...makeThread('real-bob'), isDeleted: true }
expect(
authorize({ session: session('real-bob'), type: 'create', prev: null, next })
).toBeNull()
})
})
// A reaction is one user's own record, so it needs no bespoke rule: the standard attribution
// guards cover forging (userId is stamped from the session) and tampering (owner-only update).
// Crucially there is no shared field, so one person's write can't reach another's reaction.
describe('comment-reaction', () => {
const authorize = authorizers['comment-reaction']!
const makeReaction = (userId: string, emoji = '👍') =>
createCommentReaction({
commentId: createCommentId('c1'),
threadId: thread.id,
pageId,
userId,
emoji,
})
it('stamps userId from the session on create, overriding the client value', () => {
// id is bob's canonical slot (so it passes the id check), but the userId field claims
// someone else — the server stamps it back to the session user
const next: TLCommentReaction = { ...makeReaction('real-bob'), userId: 'client-claims-alice' }
const result = authorize({
session: session('real-bob'),
type: 'create',
prev: null,
next,
}) as TLCommentReaction
expect(result.userId).toBe('real-bob')
})
it('rejects a create from a session with no identity', () => {
expect(
authorize({
session: session(null),
type: 'create',
prev: null,
next: makeReaction('anyone'),
})
).toBeNull()
})
it('vetoes an update that changes the reacting user', () => {
const prev = makeReaction('real-bob')
const next = { ...prev, userId: 'real-alice' }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
// emoji feeds the id now, so switching emoji is a delete+create, never an update — an update
// that changes emoji is rejected (its id would no longer match its emoji)
it('vetoes an update that changes the emoji', () => {
const prev = makeReaction('real-bob', '👍')
const next = { ...prev, emoji: '🎉' }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
it('vetoes changing someone else’s reaction', () => {
const prev = makeReaction('real-alice')
const next = { ...prev, emoji: '💩' }
expect(authorize({ session: session('real-mallory'), type: 'update', prev, next })).toBeNull()
})
it('lets the reactor remove their own reaction', () => {
const prev = makeReaction('real-bob')
expect(authorize({ session: session('real-bob'), type: 'delete', prev, next: null })).toBe(
prev
)
})
it('vetoes deleting someone else’s reaction', () => {
// cascades still sweep every reactor's records: server-initiated writes carry no session
// and skip authorizers entirely, so this doesn't need to be open to clients
const prev = makeReaction('real-alice')
expect(
authorize({ session: session('real-mallory'), type: 'delete', prev, next: null })
).toBeNull()
expect(authorize({ session: session(null), type: 'delete', prev, next: null })).toBeNull()
})
// The id is derived from (comment, user, emoji). A create must land at the session user's own
// canonical slot, or a forger could occupy someone else's slot (locking them out) or push a
// mismatched id that wedges the table's unique constraint at drain time.
it('vetoes a create whose id is not the session user’s canonical slot', () => {
// mallory forges a reaction at alice's id slot on the same comment + emoji
const next: TLCommentReaction = {
...makeReaction('real-mallory', '👍'),
id: createCommentReactionId(createCommentId('c1'), 'real-alice', '👍'),
}
expect(
authorize({ session: session('real-mallory'), type: 'create', prev: null, next })
).toBeNull()
})
// the id also encodes the emoji, so an id that doesn't match the record's own emoji field
// (e.g. id says 👍 but the field says 🎉) is a mismatch and rejected
it('vetoes a create whose id emoji disagrees with its emoji field', () => {
const next: TLCommentReaction = {
...makeReaction('real-mallory', '🎉'),
id: createCommentReactionId(createCommentId('c1'), 'real-mallory', '👍'),
}
expect(
authorize({ session: session('real-mallory'), type: 'create', prev: null, next })
).toBeNull()
})
it('allows a create whose id is the session user’s canonical slot', () => {
const next = makeReaction('real-mallory')
expect(
authorize({ session: session('real-mallory'), type: 'create', prev: null, next })
).not.toBeNull()
})
// the reaction's comment is fixed by its id; an update must not move it onto another comment,
// or the id would disagree with commentId and two rows could collide on (commentId, userId)
it('vetoes an update that moves the reaction to a different comment', () => {
const prev = makeReaction('real-bob')
const next = { ...prev, commentId: createCommentId('c2') }
expect(authorize({ session: session('real-bob'), type: 'update', prev, next })).toBeNull()
})
it('vetoes an update that changes the denormalized threadId or pageId', () => {
const prev = makeReaction('real-bob')
expect(
authorize({
session: session('real-bob'),
type: 'update',
prev,
next: {
...prev,
threadId: createCommentThread({ pageId, anchor: { type: 'page' }, createdBy: 'x' }).id,
},
})
).toBeNull()
expect(
authorize({
session: session('real-bob'),
type: 'update',
prev,
next: { ...prev, pageId: 'page:other' as typeof prev.pageId },
})
).toBeNull()
})
})
describe('canComment', () => {
const comment = (authorId: string) =>
createComment({ threadId: thread.id, pageId, authorId, body: toRichText('hi') })
const makeThread = (createdBy: string) =>
createCommentThread({ pageId, anchor: { type: 'page' }, createdBy })
const makeReaction = (userId: string, emoji = '👍') =>
createCommentReaction({
commentId: createCommentId('c1'),
threadId: thread.id,
pageId,
userId,
emoji,
})
it('blocks all comment-record writes from canvas read-only sessions by default', () => {
const readonly = session('real-bob', true)
const commentPrev = comment('real-bob')
expect(
authorizers.comment!({
session: readonly,
type: 'create',
prev: null,
next: comment('real-bob'),
})
).toBeNull()
expect(
authorizers.comment!({
session: readonly,
type: 'update',
prev: commentPrev,
next: { ...commentPrev, body: toRichText('edited') },
})
).toBeNull()
expect(
authorizers.comment!({ session: readonly, type: 'delete', prev: commentPrev, next: null })
).toBeNull()
const threadPrev = makeThread('real-bob')
expect(
authorizers['comment-thread']!({
session: readonly,
type: 'create',
prev: null,
next: makeThread('real-bob'),
})
).toBeNull()
expect(
authorizers['comment-thread']!({
session: readonly,
type: 'update',
prev: threadPrev,
next: { ...threadPrev, resolved: { at: 1, by: 'real-bob' } },
})
).toBeNull()
expect(
authorizers['comment-thread']!({
session: readonly,
type: 'delete',
prev: threadPrev,
next: null,
})
).toBeNull()
const reactionPrev = makeReaction('real-bob')
expect(
authorizers['comment-reaction']!({
session: readonly,
type: 'create',
prev: null,
next: makeReaction('real-bob'),
})
).toBeNull()
expect(
authorizers['comment-reaction']!({
session: readonly,
type: 'update',
prev: reactionPrev,
next: { ...reactionPrev, createdAt: reactionPrev.createdAt + 1 },
})
).toBeNull()
expect(
authorizers['comment-reaction']!({
session: readonly,
type: 'delete',
prev: reactionPrev,
next: null,
})
).toBeNull()
})
it('still applies the per-type rules to read-write sessions', () => {
const result = authorizers.comment!({
session: session('real-bob', false),
type: 'create',
prev: null,
next: comment('client-claims-alice'),
}) as TLComment
expect(result.authorId).toBe('real-bob')
})
it('lets a custom canComment allow read-only sessions (comment-only setups)', () => {
const commentOnly = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canComment: () => true,
})
const result = commentOnly.comment!({
session: session('real-bob', true),
type: 'create',
prev: null,
next: comment('client-claims-alice'),
}) as TLComment
expect(result.authorId).toBe('real-bob')
expect(
commentOnly.comment!({
session: session(null, true),
type: 'create',
prev: null,
next: comment('anon'),
})
).toBeNull()
})
it('is asked before canModifyComment, so a blocked session is never asked about a record', () => {
let asked = 0
const blocked = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canComment: () => false,
canModifyComment: () => {
asked++
return true
},
})
const prev = comment('real-bob')
expect(
blocked.comment!({
session: session('real-bob'),
type: 'update',
prev,
next: { ...prev, isDeleted: true },
})
).toBeNull()
expect(asked).toBe(0)
})
it('lets a custom canComment block sessions on its own criteria', () => {
const bannable = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canComment: ({ meta }) => meta.userId !== 'banned',
})
expect(
bannable.comment!({
session: session('banned', false),
type: 'create',
prev: null,
next: comment('banned'),
})
).toBeNull()
const result = bannable.comment!({
session: session('real-bob', false),
type: 'create',
prev: null,
next: comment('client-claims-alice'),
}) as TLComment
expect(result.authorId).toBe('real-bob')
})
})
// The default is the owner-only rule the per-type suites above already cover in full. These
// exercise the option itself: what a callback can widen, and what stays fixed underneath it.
describe('canModifyComment', () => {
const comment = (authorId: string) =>
createComment({ threadId: thread.id, pageId, authorId, body: toRichText('hi') })
const makeThread = (createdBy: string) =>
createCommentThread({ pageId, anchor: { type: 'page' }, createdBy })
// The motivating case: a moderator takes down someone else's comment, but takes over
// nobody's voice — editing stays the author's.
const moderated = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canModifyComment: (ctx) =>
(ctx.action !== 'edit-comment' && ctx.session.meta.userId === 'real-mod') ||
ctx.userId === ctx.ownerId,
})
it('lets a widened rule soft-delete another user’s comment', () => {
const prev = comment('real-bob')
const next = { ...prev, isDeleted: true }
expect(moderated.comment!({ session: session('real-mod'), type: 'update', prev, next })).toBe(
next
)
})
it('lets a widened rule soft-delete another user’s thread', () => {
const prev = makeThread('real-bob')
const next = { ...prev, isDeleted: true }
expect(
moderated['comment-thread']!({ session: session('real-mod'), type: 'update', prev, next })
).toBe(next)
})
it('keeps the edit and the delete separate: widening deletes grants no edits', () => {
const prev = comment('real-bob')
const next = { ...prev, body: toRichText('rewritten by the mod') }
expect(
moderated.comment!({ session: session('real-mod'), type: 'update', prev, next })
).toBeNull()
})
// The gap a delete-only permission would otherwise leave: flip the flag, rewrite the body,
// and the edit rides in on a write the delete gate allowed.
it('vetoes a delete that carries an edit past a delete-only permission', () => {
const prev = comment('real-bob')
const next = { ...prev, isDeleted: true, body: toRichText('rewritten on the way out') }
expect(
moderated.comment!({ session: session('real-mod'), type: 'update', prev, next })
).toBeNull()
})
it('still lets the owner delete and edit in one write', () => {
const prev = comment('real-bob')
const next = { ...prev, isDeleted: true, body: toRichText('last word') }
expect(moderated.comment!({ session: session('real-bob'), type: 'update', prev, next })).toBe(
next
)
})
it('leaves everyone else where the default left them', () => {
const prev = comment('real-bob')
expect(
moderated.comment!({
session: session('real-mallory'),
type: 'update',
prev,
next: { ...prev, isDeleted: true },
})
).toBeNull()
const threadPrev = makeThread('real-bob')
expect(
moderated['comment-thread']!({
session: session('real-mallory'),
type: 'update',
prev: threadPrev,
next: { ...threadPrev, isDeleted: true },
})
).toBeNull()
})
it('narrows as well as widens', () => {
const frozen = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canModifyComment: (ctx) => ctx.action !== 'edit-comment' && ctx.userId === ctx.ownerId,
})
const prev = comment('real-bob')
expect(
frozen.comment!({
session: session('real-bob'),
type: 'update',
prev,
next: { ...prev, body: toRichText('edited') },
})
).toBeNull()
// ...and the delete it didn't narrow still goes through
const deleted = { ...prev, isDeleted: true }
expect(
frozen.comment!({ session: session('real-bob'), type: 'update', prev, next: deleted })
).toBe(deleted)
})
it('is asked with the stored record, not the client’s version of it', () => {
const seen: unknown[] = []
const spying = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canModifyComment: (ctx) => {
seen.push(ctx)
return true
},
})
const prev = comment('real-bob')
// mallory's push claims the comment is hers and already deleted; the context must describe
// the record the room holds, or an ownership rule would be reading the attacker's claim
const next = { ...prev, authorId: 'real-mallory', body: toRichText('edited') }
spying.comment!({ session: session('real-mallory'), type: 'update', prev, next })
// the attribution guard rejects this one before the option is asked at all
expect(seen).toEqual([])
spying.comment!({
session: session('real-mallory'),
type: 'update',
prev,
next: { ...prev, body: toRichText('edited') },
})
expect(seen).toEqual([
{
session: session('real-mallory'),
userId: 'real-mallory',
ownerId: 'real-bob',
action: 'edit-comment',
comment: prev,
},
])
})
it('reports the thread’s creator as the owner of a thread delete', () => {
const seen: unknown[] = []
const spying = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canModifyComment: (ctx) => {
seen.push(ctx)
return true
},
})
const prev = makeThread('real-bob')
spying['comment-thread']!({
session: session('real-mallory'),
type: 'update',
prev,
next: { ...prev, isDeleted: true },
})
expect(seen).toEqual([
{
session: session('real-mallory'),
userId: 'real-mallory',
ownerId: 'real-bob',
action: 'delete-thread',
thread: prev,
},
])
})
// Resolving and reopening aren't anyone's in particular, matching the client option: they're
// `canComment`'s to gate, and the option is never asked about them.
it('is not asked about resolving or reopening a thread', () => {
let asked = 0
const counting = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canModifyComment: () => {
asked++
return true
},
})
const prev = makeThread('real-bob')
const resolved = { ...prev, resolved: { at: 1, by: 'real-mallory' } }
expect(
counting['comment-thread']!({
session: session('real-mallory'),
type: 'update',
prev,
next: resolved,
})
).toBe(resolved)
const reopened = { ...resolved, resolved: null }
expect(
counting['comment-thread']!({
session: session('real-mallory'),
type: 'update',
prev: resolved,
next: reopened,
})
).toBe(reopened)
expect(asked).toBe(0)
})
it('is not asked about reactions', () => {
let asked = 0
const counting = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canModifyComment: () => {
asked++
return true
},
})
const prev = createCommentReaction({
commentId: createCommentId('c1'),
threadId: thread.id,
pageId,
userId: 'real-bob',
emoji: '👍',
})
// a permissive callback doesn't hand mallory someone else's reaction
expect(
counting['comment-reaction']!({
session: session('real-mallory'),
type: 'delete',
prev,
next: null,
})
).toBeNull()
expect(
counting['comment-reaction']!({
session: session('real-bob'),
type: 'update',
prev,
next: { ...prev, createdAt: prev.createdAt + 1 },
})
).not.toBeNull()
expect(asked).toBe(0)
})
// The point of asking after the structural rules: a host can hand out these three writes
// without handing out the invariants underneath them.
describe('the structural rules hold however permissive the callback', () => {
const permissive = createCommentAuthorizers<TestMeta>({
getUserId: (session) => session.meta.userId,
canModifyComment: () => true,
})
it('still vetoes clearing a soft-delete', () => {
const prev = { ...comment('real-bob'), isDeleted: true }
expect(
permissive.comment!({
session: session('real-mod'),
type: 'update',
prev,
next: { ...prev, isDeleted: false },
})
).toBeNull()
})
it('still vetoes every client hard-delete', () => {
const prev = comment('real-bob')
expect(
permissive.comment!({ session: session('real-mod'), type: 'delete', prev, next: null })
).toBeNull()
const threadPrev = makeThread('real-bob')
expect(
permissive['comment-thread']!({
session: session('real-mod'),
type: 'delete',
prev: threadPrev,
next: null,
})
).toBeNull()
})
it('still vetoes changing a comment’s author', () => {
const prev = comment('real-bob')
expect(
permissive.comment!({
session: session('real-mod'),
type: 'update',
prev,
next: { ...prev, authorId: 'real-mod' },
})
).toBeNull()
})
it('still vetoes re-parenting and back-dating a comment', () => {
const prev = comment('real-bob')
expect(
permissive.comment!({
session: session('real-mod'),
type: 'update',
prev,
next: { ...prev, threadId: 'comment-thread:other' as TLCommentThread['id'] },
})
).toBeNull()
expect(
permissive.comment!({
session: session('real-mod'),
type: 'update',
prev,
next: { ...prev, createdAt: 1 },
})
).toBeNull()
})
it('still vetoes a create with the soft-delete flag already set', () => {
expect(
permissive.comment!({
session: session('real-mod'),
type: 'create',
prev: null,
next: { ...comment('real-mod'), isDeleted: true },
})
).toBeNull()
})
it('still vetoes a resolution attributed to someone else', () => {
const prev = makeThread('real-bob')
expect(
permissive['comment-thread']!({
session: session('real-mod'),
type: 'update',
prev,
next: { ...prev, resolved: { at: 1, by: 'real-alice' } },
})
).toBeNull()
})
// An anonymous session has no identity to check a record against, so the default withholds
// all three writes from it. A callback that returns true regardless is taken at its word —
// worth knowing before writing one that ignores `userId`.
it('takes a callback at its word about an anonymous session', () => {
const prev = comment('real-bob')
const next = { ...prev, isDeleted: true }
expect(permissive.comment!({ session: session(null), type: 'update', prev, next })).toBe(
next
)
expect(
authorizers.comment!({ session: session(null), type: 'update', prev, next })
).toBeNull()
})
})
})
})

View File

@@ -0,0 +1,354 @@
import type { UnknownRecord } from '@tldraw/store'
import type { TLRecordAuthorizer, TLRecordAuthorizers } from '@tldraw/sync-core'
import {
createCommentReactionId,
type TLComment,
type TLCommentReaction,
type TLCommentThread,
} from '@tldraw/tlschema'
import { isEqual } from '@tldraw/utils'
/**
* A comment write that belongs to someone in particular, and the stored record it targets — the
* argument to {@link CommentAuthorizerOptions.canModifyComment}, and the server-side mirror of
* `CommentModification` in `@tldraw/commenting`.
*
* The record is the one the room holds, never the client's version of it: the incoming record is
* the thing being authorized, so a rule that read it would be asking the writer who owns what
* they're writing to.
*
* Resolving, reopening, and reacting aren't here, matching the client option: none of them is
* anyone's in particular, so {@link CommentAuthorizerOptions.canComment} is the only gate on them.
*
* @public
*/
export type CommentModification =
| { readonly action: 'edit-comment'; readonly comment: TLComment }
| { readonly action: 'delete-comment'; readonly comment: TLComment }
| { readonly action: 'delete-thread'; readonly thread: TLCommentThread }
/**
* The argument to {@link CommentAuthorizerOptions.canModifyComment}: which write, against which
* stored record, by which session.
*
* `ownerId` is that record's owner — a comment's `authorId`, a thread's `createdBy` — so a callback
* widening the default doesn't have to know which field each record keeps it in.
*
* @public
*/
export type CommentModificationAuthContext<SessionMeta> = {
readonly session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }
readonly userId: string | null
readonly ownerId: string
} & CommentModification
/**
* Options for {@link createCommentAuthorizers}.
*
* @public
*/
export interface CommentAuthorizerOptions<SessionMeta> {
/**
* Resolve the authenticated user id for a session from its host-provided `meta`. Return `null`
* for anonymous sessions — they can't create records, and own none, so the default
* {@link CommentAuthorizerOptions.canModifyComment} grants them no edits or deletes either.
*/
getUserId(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): string | null
/**
* Whether a session may write comment records at all — checked before the per-type rules.
* Defaults to `({ isReadonly }) => !isReadonly`, so read-only viewers can read threads but not
* post. Override to decouple the lanes: `() => true` allows commenting on a read-only canvas.
*/
canComment?(session: { sessionId: string; isReadonly: boolean; meta: SessionMeta }): boolean
/**
* Whether a session may make a particular write against a particular stored record: editing or
* deleting a comment, or deleting a thread. Defaults to
* `({ userId, ownerId }) => userId === ownerId` — the owner-only rule enforced up to now.
* Override to widen it (a workspace admin or moderator who may take down anyone's comment) or
* to narrow it (no edits after an hour).
*
* The counterpart to `canModifyComment` in `@tldraw/commenting`, which decides which
* affordances the UI offers. This one is the real rule, and the two want widening together: a
* delete the client offers and this rejects is applied locally, vetoed, and rebased away — the
* comment comes back with nothing to explain it.
*
* Asked after {@link CommentAuthorizerOptions.canComment} and after the structural rules, so it
* can only widen *who* may write, never *what* a write may contain. However permissive the
* callback, attribution is still stamped from the session and immutable, `isDeleted` is still
* write-once and never set at create, `threadId` and `createdAt` are still frozen, a
* resolution is still the resolver's own, and clients still can't hard-delete.
*
* A soft delete that changes anything besides the flag is asked about twice — once as the
* delete, once as an edit — so granting deletes alone can't be talked into an edit.
*
* Called at most once per authorized write, twice for that combined case.
*
* @example
* ```ts
* createCommentAuthorizers<SessionMeta>({
* getUserId: (session) => session.meta.userId,
* // Moderators may take anything down. Editing stays the author's, whoever you are.
* canModifyComment: (ctx) =>
* (ctx.action !== 'edit-comment' && isModerator(ctx.session.meta)) ||
* ctx.userId === ctx.ownerId,
* })
* ```
*/
canModifyComment?(ctx: CommentModificationAuthContext<SessionMeta>): boolean
}
/**
* Server-side write authorization for comment records, for use with a sync server's
* `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces authorship from the
* session's identity so nothing can be posted, resolved, or deleted in someone else's name:
*
* - `comment`: `authorId` is stamped on create (anonymous creates rejected) and immutable after,
* and who may update is `canModifyComment`'s call — the author's, unless widened. `threadId` and
* `createdAt` are immutable too.
* - `comment-thread`: `createdBy` and `createdAt` are fixed on create. Anyone with access may
* resolve/reopen, but a non-null `resolved.by` must be the session's own user.
* - `comment-reaction`: `userId` is stamped and immutable, a create must land at the canonical id
* for its (comment, user, emoji) triple, and only the reactor may delete their own.
* - Deletion is soft for comments and threads: a write-once `isDeleted` flag, never set at create.
* Client hard-deletes are always rejected — record removals are server-side only.
* - `canComment` gates every write before the per-type rules, defaulting to `!isReadonly`.
* - `canModifyComment` decides who may edit a comment, delete a comment, or delete a thread. It
* defaults to the record's owner, and is asked after the structural rules above, so widening it
* grants no more than those three writes on records the session doesn't own.
*
* Comment records ride alongside your document records, so widen the room's record union to
* include them, then spread the result into the authorizer map alongside your own entries:
*
* @example
* ```ts
* interface SessionMeta {
* userId: string | null
* }
*
* type MyRecord = TLRecord | TLComment | TLCommentThread | TLCommentReaction
*
* new TLSocketRoom<MyRecord, SessionMeta>({
* authorizeRecord: {
* ...createCommentAuthorizers<SessionMeta>({ getUserId: (session) => session.meta.userId }),
* },
* })
* ```
*
* @public
*/
export function createCommentAuthorizers<SessionMeta>(
opts: CommentAuthorizerOptions<SessionMeta>
): TLRecordAuthorizers<TLComment | TLCommentThread | TLCommentReaction, SessionMeta> {
const {
getUserId,
canComment = ({ isReadonly }: { isReadonly: boolean }) => !isReadonly,
canModifyComment = ({ userId, ownerId }: CommentModificationAuthContext<SessionMeta>) =>
userId === ownerId,
} = opts
/** A rule is an authorizer that receives the session's user id, resolved for it exactly once. */
type Rule<Rec extends UnknownRecord> = (
userId: string | null,
args: Parameters<TLRecordAuthorizer<Rec, SessionMeta>>[0]
) => Rec | null
/**
* Adapt a rule to the authorizer signature: gate on `canComment` first, then resolve the
* session's user id exactly once.
*/
function withUserId<Rec extends UnknownRecord>(
rule: Rule<Rec>
): TLRecordAuthorizer<Rec, SessionMeta> {
return (args) => {
if (!canComment(args.session)) return null
return rule(getUserId(args.session), args)
}
}
/**
* Authorize a record whose attribution lives in `field`: stamped from the session on create,
* immutable on update. With `ownerOnlyUpdate`, only the author may update it at all.
*/
function authorizeAuthored<Rec extends UnknownRecord>(
field: keyof Rec & string,
{ ownerOnlyUpdate = false } = {}
): Rule<Rec> {
return (userId, { type, prev, next }) => {
if (type === 'create') {
if (!userId) return null // no identity to attribute → reject
return { ...next, [field]: userId } as Rec
}
if (type === 'update') {
if (next[field] !== prev[field]) return null // attribution is immutable
if (ownerOnlyUpdate && userId !== prev[field]) return null // only the author edits
return next
}
return prev
}
}
/**
* Police a soft-deleted record type on top of `base`, asking `canModifyComment` who may make the
* write: `isDeleted` is write-once, never set at create, and clients never hard-delete these
* records. Removals are server-initiated only, so once the server prunes a flagged record there
* is no un-delete.
*
* An update here is one of two writes, asked about separately: flipping `isDeleted` is a delete,
* anything else is an edit. Telling them apart is what lets a host grant deletes without granting
* edits — and an update that does both has to clear both gates, so a delete can't carry an edit
* out with it.
*
* `modificationFor` returns null for a write `canModifyComment` isn't asked about: a thread's
* "edit" is a resolve or reopen, which is open to anyone with access and policed by `base`.
*/
function authorizeSoftDeleted<Rec extends UnknownRecord & { isDeleted: boolean }>(
ownerOf: (rec: Rec) => string,
modificationFor: (rec: Rec, write: 'edit' | 'delete') => CommentModification | null,
base: Rule<Rec>
): Rule<Rec> {
return (userId, args) => {
if (args.type === 'delete') return null
const result = base(userId, args)
if (!result) return null
// A record can't be born deleted — that would smuggle a deletion past the update checks.
if (args.type === 'create') return args.next.isDeleted ? null : result
const { prev, next, session } = args
const mayModify = (write: 'edit' | 'delete') => {
const modification = modificationFor(prev, write)
if (!modification) return true
return canModifyComment({ session, userId, ownerId: ownerOf(prev), ...modification })
}
if (prev.isDeleted === next.isDeleted) return mayModify('edit') ? result : null
if (prev.isDeleted) return null // write-once: never cleared
if (!mayModify('delete')) return null
// The built-in client deletes by setting the flag and nothing else. An update carrying
// more than that is also an edit, and has to be allowed as one — otherwise a delete-only
// permission could rewrite a comment on its way out.
if (!isEqual({ ...next, isDeleted: prev.isDeleted }, prev) && !mayModify('edit')) return null
return result
}
}
/**
* Threads stay editable by anyone with access (resolve/reopen), but resolution is itself an
* attribution: a non-null `resolved.by` must be the session's own user.
*/
const authorizeThreadResolution: Rule<TLCommentThread> = (userId, args) => {
const result = authorizeAuthored<TLCommentThread>('createdBy')(userId, args)
if (!result) return null
if (args.type === 'create') {
// Delete + re-put could otherwise smuggle in a resolution forged in someone else's name.
const { next } = args
if (next.resolved && next.resolved.by !== userId) return null
}
if (args.type === 'update') {
const { prev, next } = args
const changed =
prev.resolved?.at !== next.resolved?.at || prev.resolved?.by !== next.resolved?.by
if (changed && next.resolved && next.resolved.by !== userId) return null
}
return result
}
/**
* Reject an update that changes any of `fields` — the structural ones an update must never touch.
* A mutable `threadId` would let an author re-parent a comment between conversations (and, where
* threads span files, between files); a mutable `createdAt` would let it be re-sorted after the fact.
*/
function immutableFields<Rec extends UnknownRecord>(
fields: readonly (keyof Rec & string)[],
base: Rule<Rec>
): Rule<Rec> {
return (userId, args) => {
if (args.type === 'update') {
const { prev, next } = args
for (const field of fields) {
if (next[field] !== prev[field]) return null
}
}
return base(userId, args)
}
}
const authorizeReactionBase = authorizeAuthored<TLCommentReaction>('userId', {
ownerOnlyUpdate: true,
})
/**
* A reaction's id is derived from its (comment, user, emoji) triple. The base rule already stamps
* `userId` and lets only the owner change a reaction, but the id, comment, and emoji are all
* client-supplied, so this adds:
*
* - On **create**, the id must be canonical for `commentId` + the session's user + `next.emoji`.
* Otherwise a forged client could squat another user's id slot, or land two records on one triple.
*
* - On **update**, everything feeding the id is immutable, so a re-react is a create/delete rather
* than an update and the id can never drift from its fields.
*/
const authorizeReaction: Rule<TLCommentReaction> = (userId, args) => {
// Only the reactor may remove their own reaction. Cascades still sweep every reactor's
// records because server-initiated writes carry no session and so skip authorizers
// entirely — an open client delete was never what made the sweep work.
if (args.type === 'delete') {
return userId && userId === args.prev.userId ? args.prev : null
}
const result = authorizeReactionBase(userId, args)
if (!result) return null
if (args.type === 'create') {
// Unreachable: the base rule already rejected identity-less creates. Checked to narrow.
if (!userId) return null
const { next } = args
if (next.id !== createCommentReactionId(next.commentId, userId, next.emoji)) {
return null
}
}
if (args.type === 'update') {
const { prev, next } = args
if (next.commentId !== prev.commentId) return null
if (next.threadId !== prev.threadId) return null
if (next.pageId !== prev.pageId) return null
if (next.emoji !== prev.emoji) return null
}
return result
}
return {
comment: withUserId(
authorizeSoftDeleted<TLComment>(
(comment) => comment.authorId,
(comment, write) => ({
action: write === 'delete' ? 'delete-comment' : 'edit-comment',
comment,
}),
// The owner-only update check that used to sit here (`ownerOnlyUpdate`) is now
// `canModifyComment`'s to make, since it can tell an edit from a delete. Attribution
// is still stamped from the session and immutable either way.
//
// `pageId` stays mutable: it's denormalized from the thread, and moving an anchored
// thread between pages rewrites it on every comment in the thread.
immutableFields<TLComment>(
['threadId', 'createdAt'],
authorizeAuthored<TLComment>('authorId')
)
)
),
'comment-thread': withUserId(
authorizeSoftDeleted<TLCommentThread>(
(thread) => thread.createdBy,
// Resolving and reopening stay open to anyone with access, so a thread's "edit" isn't
// asked about — only its delete is.
(thread, write) => (write === 'delete' ? { action: 'delete-thread', thread } : null),
immutableFields<TLCommentThread>(['createdAt'], authorizeThreadResolution)
)
),
// Deletion is deliberately open: anyone with room access may hard-delete any reaction. Reactions
// have no soft-delete flag on purpose — a reaction is a toggle, and a host cascading a comment or
// thread deletion must sweep every reactor's records, not just the caller's own.
'comment-reaction': withUserId(authorizeReaction),
}
}

View File

@@ -0,0 +1,16 @@
import { registerTldrawLibraryVersion } from '@tldraw/utils'
// Server-side logic for tldraw's collaboration features, safe to import from any sync
// server — no react or client-editor dependencies.
export {
type CommentAuthorizerOptions,
type CommentModification,
type CommentModificationAuthContext,
createCommentAuthorizers,
} from './comment-authorizers'
registerTldrawLibraryVersion(
(globalThis as any).TLDRAW_LIBRARY_NAME,
(globalThis as any).TLDRAW_LIBRARY_VERSION,
(globalThis as any).TLDRAW_LIBRARY_MODULES
)

View File

@@ -0,0 +1,23 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.css", ".tsbuild*"],
"compilerOptions": {
"outDir": "./.tsbuild",
"rootDir": "src"
},
"references": [
{
"path": "../store"
},
{
"path": "../sync-core"
},
{
"path": "../tlschema"
},
{
"path": "../utils"
}
]
}

View File

@@ -0,0 +1,6 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// Commenting-core package uses the standard node preset with no overrides
export default mergeConfig(baseConfig, {})