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
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:
1
packages/sync-collaboration/LICENSE.md
Normal file
1
packages/sync-collaboration/LICENSE.md
Normal file
@@ -0,0 +1 @@
|
||||
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
||||
5
packages/sync-collaboration/README.md
Normal file
5
packages/sync-collaboration/README.md
Normal 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`.
|
||||
4
packages/sync-collaboration/api-extractor.json
Normal file
4
packages/sync-collaboration/api-extractor.json
Normal 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"
|
||||
}
|
||||
55
packages/sync-collaboration/api-report.api.md
Normal file
55
packages/sync-collaboration/api-report.api.md
Normal 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)
|
||||
|
||||
```
|
||||
52
packages/sync-collaboration/package.json
Normal file
52
packages/sync-collaboration/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
925
packages/sync-collaboration/src/comment-authorizers.test.ts
Normal file
925
packages/sync-collaboration/src/comment-authorizers.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
354
packages/sync-collaboration/src/comment-authorizers.ts
Normal file
354
packages/sync-collaboration/src/comment-authorizers.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
16
packages/sync-collaboration/src/index.ts
Normal file
16
packages/sync-collaboration/src/index.ts
Normal 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
|
||||
)
|
||||
23
packages/sync-collaboration/tsconfig.json
Normal file
23
packages/sync-collaboration/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/sync-collaboration/vitest.config.ts
Normal file
6
packages/sync-collaboration/vitest.config.ts
Normal 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, {})
|
||||
Reference in New Issue
Block a user