@tldraw/sync-collaboration 5.3.0-next.2fa9c61a8de6 → 5.3.0-next.7654e7ac2a02
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/comment-authorizers.js +33 -19
- package/dist-cjs/comment-authorizers.js.map +2 -2
- package/dist-cjs/index.d.ts +100 -25
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-esm/comment-authorizers.mjs +33 -19
- package/dist-esm/comment-authorizers.mjs.map +2 -2
- package/dist-esm/index.d.mts +100 -25
- package/dist-esm/index.mjs +4 -2
- package/dist-esm/index.mjs.map +2 -2
- package/package.json +5 -5
- package/src/comment-authorizers.test.ts +360 -0
- package/src/comment-authorizers.ts +153 -68
- package/src/index.ts +6 -1
package/dist-esm/index.d.mts
CHANGED
|
@@ -10,9 +10,9 @@ import type { TLRecordAuthorizers } from '@tldraw/sync-core';
|
|
|
10
10
|
*/
|
|
11
11
|
export declare interface CommentAuthorizerOptions<SessionMeta> {
|
|
12
12
|
/**
|
|
13
|
-
* Resolve the authenticated user id for a session from its host-provided `meta`. Return
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Resolve the authenticated user id for a session from its host-provided `meta`. Return `null`
|
|
14
|
+
* for anonymous sessions — they can't create records, and own none, so the default
|
|
15
|
+
* {@link CommentAuthorizerOptions.canModifyComment} grants them no edits or deletes either.
|
|
16
16
|
*/
|
|
17
17
|
getUserId(session: {
|
|
18
18
|
isReadonly: boolean;
|
|
@@ -20,39 +20,114 @@ export declare interface CommentAuthorizerOptions<SessionMeta> {
|
|
|
20
20
|
sessionId: string;
|
|
21
21
|
}): null | string;
|
|
22
22
|
/**
|
|
23
|
-
* Whether a session may write comment records at all — checked before the per-type rules
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* resolve, or react. Override to decouple the lanes — `() => true` allows commenting on a
|
|
27
|
-
* read-only canvas (comment-only setups) — or to enforce custom criteria from the session.
|
|
23
|
+
* Whether a session may write comment records at all — checked before the per-type rules.
|
|
24
|
+
* Defaults to `({ isReadonly }) => !isReadonly`, so read-only viewers can read threads but not
|
|
25
|
+
* post. Override to decouple the lanes: `() => true` allows commenting on a read-only canvas.
|
|
28
26
|
*/
|
|
29
27
|
canComment?(session: {
|
|
30
28
|
isReadonly: boolean;
|
|
31
29
|
meta: SessionMeta;
|
|
32
30
|
sessionId: string;
|
|
33
31
|
}): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Whether a session may make a particular write against a particular stored record: editing or
|
|
34
|
+
* deleting a comment, or deleting a thread. Defaults to
|
|
35
|
+
* `({ userId, ownerId }) => userId === ownerId` — the owner-only rule enforced up to now.
|
|
36
|
+
* Override to widen it (a workspace admin or moderator who may take down anyone's comment) or
|
|
37
|
+
* to narrow it (no edits after an hour).
|
|
38
|
+
*
|
|
39
|
+
* The counterpart to `canModifyComment` in `@tldraw/commenting`, which decides which
|
|
40
|
+
* affordances the UI offers. This one is the real rule, and the two want widening together: a
|
|
41
|
+
* delete the client offers and this rejects is applied locally, vetoed, and rebased away — the
|
|
42
|
+
* comment comes back with nothing to explain it.
|
|
43
|
+
*
|
|
44
|
+
* Asked after {@link CommentAuthorizerOptions.canComment} and after the structural rules, so it
|
|
45
|
+
* can only widen *who* may write, never *what* a write may contain. However permissive the
|
|
46
|
+
* callback, attribution is still stamped from the session and immutable, `isDeleted` is still
|
|
47
|
+
* write-once and never set at create, `threadId` and `createdAt` are still frozen, a
|
|
48
|
+
* resolution is still the resolver's own, and clients still can't hard-delete.
|
|
49
|
+
*
|
|
50
|
+
* A soft delete that changes anything besides the flag is asked about twice — once as the
|
|
51
|
+
* delete, once as an edit — so granting deletes alone can't be talked into an edit.
|
|
52
|
+
*
|
|
53
|
+
* Called at most once per authorized write, twice for that combined case.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* createCommentAuthorizers<SessionMeta>({
|
|
58
|
+
* getUserId: (session) => session.meta.userId,
|
|
59
|
+
* // Moderators may take anything down. Editing stays the author's, whoever you are.
|
|
60
|
+
* canModifyComment: (ctx) =>
|
|
61
|
+
* (ctx.action !== 'edit-comment' && isModerator(ctx.session.meta)) ||
|
|
62
|
+
* ctx.userId === ctx.ownerId,
|
|
63
|
+
* })
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
canModifyComment?(ctx: CommentModificationAuthContext<SessionMeta>): boolean;
|
|
34
67
|
}
|
|
35
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A comment write that belongs to someone in particular, and the stored record it targets — the
|
|
71
|
+
* argument to {@link CommentAuthorizerOptions.canModifyComment}, and the server-side mirror of
|
|
72
|
+
* `CommentModification` in `@tldraw/commenting`.
|
|
73
|
+
*
|
|
74
|
+
* The record is the one the room holds, never the client's version of it: the incoming record is
|
|
75
|
+
* the thing being authorized, so a rule that read it would be asking the writer who owns what
|
|
76
|
+
* they're writing to.
|
|
77
|
+
*
|
|
78
|
+
* Resolving, reopening, and reacting aren't here, matching the client option: none of them is
|
|
79
|
+
* anyone's in particular, so {@link CommentAuthorizerOptions.canComment} is the only gate on them.
|
|
80
|
+
*
|
|
81
|
+
* @public
|
|
82
|
+
*/
|
|
83
|
+
export declare type CommentModification = {
|
|
84
|
+
readonly action: 'delete-comment';
|
|
85
|
+
readonly comment: TLComment;
|
|
86
|
+
} | {
|
|
87
|
+
readonly action: 'delete-thread';
|
|
88
|
+
readonly thread: TLCommentThread;
|
|
89
|
+
} | {
|
|
90
|
+
readonly action: 'edit-comment';
|
|
91
|
+
readonly comment: TLComment;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The argument to {@link CommentAuthorizerOptions.canModifyComment}: which write, against which
|
|
96
|
+
* stored record, by which session.
|
|
97
|
+
*
|
|
98
|
+
* `ownerId` is that record's owner — a comment's `authorId`, a thread's `createdBy` — so a callback
|
|
99
|
+
* widening the default doesn't have to know which field each record keeps it in.
|
|
100
|
+
*
|
|
101
|
+
* @public
|
|
102
|
+
*/
|
|
103
|
+
export declare type CommentModificationAuthContext<SessionMeta> = {
|
|
104
|
+
readonly ownerId: string;
|
|
105
|
+
readonly session: {
|
|
106
|
+
isReadonly: boolean;
|
|
107
|
+
meta: SessionMeta;
|
|
108
|
+
sessionId: string;
|
|
109
|
+
};
|
|
110
|
+
readonly userId: null | string;
|
|
111
|
+
} & CommentModification;
|
|
112
|
+
|
|
36
113
|
/**
|
|
37
114
|
* Server-side write authorization for comment records, for use with a sync server's
|
|
38
|
-
* `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces
|
|
39
|
-
*
|
|
40
|
-
* in someone else's name:
|
|
115
|
+
* `authorizeRecord` option (see `TLSocketRoom` in `@tldraw/sync-core`). Forces authorship from the
|
|
116
|
+
* session's identity so nothing can be posted, resolved, or deleted in someone else's name:
|
|
41
117
|
*
|
|
42
|
-
* - `comment`: `authorId` is stamped
|
|
43
|
-
*
|
|
44
|
-
* are immutable too
|
|
45
|
-
* - `comment-thread`: `createdBy` and `createdAt` are
|
|
46
|
-
*
|
|
47
|
-
* - `comment-reaction`: `userId` is stamped
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* -
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* read threads but not write to them; override it to decouple commenting from canvas access.
|
|
118
|
+
* - `comment`: `authorId` is stamped on create (anonymous creates rejected) and immutable after,
|
|
119
|
+
* and who may update is `canModifyComment`'s call — the author's, unless widened. `threadId` and
|
|
120
|
+
* `createdAt` are immutable too.
|
|
121
|
+
* - `comment-thread`: `createdBy` and `createdAt` are fixed on create. Anyone with access may
|
|
122
|
+
* resolve/reopen, but a non-null `resolved.by` must be the session's own user.
|
|
123
|
+
* - `comment-reaction`: `userId` is stamped and immutable, a create must land at the canonical id
|
|
124
|
+
* for its (comment, user, emoji) triple, and only the reactor may delete their own.
|
|
125
|
+
* - Deletion is soft for comments and threads: a write-once `isDeleted` flag, never set at create.
|
|
126
|
+
* Client hard-deletes are always rejected — record removals are server-side only.
|
|
127
|
+
* - `canComment` gates every write before the per-type rules, defaulting to `!isReadonly`.
|
|
128
|
+
* - `canModifyComment` decides who may edit a comment, delete a comment, or delete a thread. It
|
|
129
|
+
* defaults to the record's owner, and is asked after the structural rules above, so widening it
|
|
130
|
+
* grants no more than those three writes on records the session doesn't own.
|
|
56
131
|
*
|
|
57
132
|
* Comment records ride alongside your document records, so widen the room's record union to
|
|
58
133
|
* include them, then spread the result into the authorizer map alongside your own entries:
|
package/dist-esm/index.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { registerTldrawLibraryVersion } from "@tldraw/utils";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createCommentAuthorizers
|
|
4
|
+
} from "./comment-authorizers.mjs";
|
|
3
5
|
registerTldrawLibraryVersion(
|
|
4
6
|
"@tldraw/sync-collaboration",
|
|
5
|
-
"5.3.0-next.
|
|
7
|
+
"5.3.0-next.7654e7ac2a02",
|
|
6
8
|
"esm"
|
|
7
9
|
);
|
|
8
10
|
export {
|
package/dist-esm/index.mjs.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/index.ts"],
|
|
4
|
-
"sourcesContent": ["import { registerTldrawLibraryVersion } from '@tldraw/utils'\n\n// Server-side logic for tldraw's collaboration features, safe to import from any sync\n// server \u2014 no react or client-editor dependencies.\nexport {
|
|
5
|
-
"mappings": "AAAA,SAAS,oCAAoC;AAI7C,
|
|
4
|
+
"sourcesContent": ["import { registerTldrawLibraryVersion } from '@tldraw/utils'\n\n// Server-side logic for tldraw's collaboration features, safe to import from any sync\n// server \u2014 no react or client-editor dependencies.\nexport {\n\ttype CommentAuthorizerOptions,\n\ttype CommentModification,\n\ttype CommentModificationAuthContext,\n\tcreateCommentAuthorizers,\n} from './comment-authorizers'\n\nregisterTldrawLibraryVersion(\n\t(globalThis as any).TLDRAW_LIBRARY_NAME,\n\t(globalThis as any).TLDRAW_LIBRARY_VERSION,\n\t(globalThis as any).TLDRAW_LIBRARY_MODULES\n)\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oCAAoC;AAI7C;AAAA,EAIC;AAAA,OACM;AAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/sync-collaboration",
|
|
3
3
|
"description": "tldraw sync collaboration: server-side write authorization for collaboration features.",
|
|
4
|
-
"version": "5.3.0-next.
|
|
4
|
+
"version": "5.3.0-next.7654e7ac2a02",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"node": ">=22.12.0"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@tldraw/store": "5.3.0-next.
|
|
35
|
-
"@tldraw/sync-core": "5.3.0-next.
|
|
36
|
-
"@tldraw/tlschema": "5.3.0-next.
|
|
37
|
-
"@tldraw/utils": "5.3.0-next.
|
|
34
|
+
"@tldraw/store": "5.3.0-next.7654e7ac2a02",
|
|
35
|
+
"@tldraw/sync-core": "5.3.0-next.7654e7ac2a02",
|
|
36
|
+
"@tldraw/tlschema": "5.3.0-next.7654e7ac2a02",
|
|
37
|
+
"@tldraw/utils": "5.3.0-next.7654e7ac2a02"
|
|
38
38
|
},
|
|
39
39
|
"module": "dist-esm/index.mjs",
|
|
40
40
|
"source": "src/index.ts",
|
|
@@ -538,6 +538,28 @@ describe('createCommentAuthorizers', () => {
|
|
|
538
538
|
).toBeNull()
|
|
539
539
|
})
|
|
540
540
|
|
|
541
|
+
it('is asked before canModifyComment, so a blocked session is never asked about a record', () => {
|
|
542
|
+
let asked = 0
|
|
543
|
+
const blocked = createCommentAuthorizers<TestMeta>({
|
|
544
|
+
getUserId: (session) => session.meta.userId,
|
|
545
|
+
canComment: () => false,
|
|
546
|
+
canModifyComment: () => {
|
|
547
|
+
asked++
|
|
548
|
+
return true
|
|
549
|
+
},
|
|
550
|
+
})
|
|
551
|
+
const prev = comment('real-bob')
|
|
552
|
+
expect(
|
|
553
|
+
blocked.comment!({
|
|
554
|
+
session: session('real-bob'),
|
|
555
|
+
type: 'update',
|
|
556
|
+
prev,
|
|
557
|
+
next: { ...prev, isDeleted: true },
|
|
558
|
+
})
|
|
559
|
+
).toBeNull()
|
|
560
|
+
expect(asked).toBe(0)
|
|
561
|
+
})
|
|
562
|
+
|
|
541
563
|
it('lets a custom canComment block sessions on its own criteria', () => {
|
|
542
564
|
const bannable = createCommentAuthorizers<TestMeta>({
|
|
543
565
|
getUserId: (session) => session.meta.userId,
|
|
@@ -562,4 +584,342 @@ describe('createCommentAuthorizers', () => {
|
|
|
562
584
|
expect(result.authorId).toBe('real-bob')
|
|
563
585
|
})
|
|
564
586
|
})
|
|
587
|
+
|
|
588
|
+
// The default is the owner-only rule the per-type suites above already cover in full. These
|
|
589
|
+
// exercise the option itself: what a callback can widen, and what stays fixed underneath it.
|
|
590
|
+
describe('canModifyComment', () => {
|
|
591
|
+
const comment = (authorId: string) =>
|
|
592
|
+
createComment({ threadId: thread.id, pageId, authorId, body: toRichText('hi') })
|
|
593
|
+
const makeThread = (createdBy: string) =>
|
|
594
|
+
createCommentThread({ pageId, anchor: { type: 'page' }, createdBy })
|
|
595
|
+
|
|
596
|
+
// The motivating case: a moderator takes down someone else's comment, but takes over
|
|
597
|
+
// nobody's voice — editing stays the author's.
|
|
598
|
+
const moderated = createCommentAuthorizers<TestMeta>({
|
|
599
|
+
getUserId: (session) => session.meta.userId,
|
|
600
|
+
canModifyComment: (ctx) =>
|
|
601
|
+
(ctx.action !== 'edit-comment' && ctx.session.meta.userId === 'real-mod') ||
|
|
602
|
+
ctx.userId === ctx.ownerId,
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('lets a widened rule soft-delete another user’s comment', () => {
|
|
606
|
+
const prev = comment('real-bob')
|
|
607
|
+
const next = { ...prev, isDeleted: true }
|
|
608
|
+
expect(moderated.comment!({ session: session('real-mod'), type: 'update', prev, next })).toBe(
|
|
609
|
+
next
|
|
610
|
+
)
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it('lets a widened rule soft-delete another user’s thread', () => {
|
|
614
|
+
const prev = makeThread('real-bob')
|
|
615
|
+
const next = { ...prev, isDeleted: true }
|
|
616
|
+
expect(
|
|
617
|
+
moderated['comment-thread']!({ session: session('real-mod'), type: 'update', prev, next })
|
|
618
|
+
).toBe(next)
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it('keeps the edit and the delete separate: widening deletes grants no edits', () => {
|
|
622
|
+
const prev = comment('real-bob')
|
|
623
|
+
const next = { ...prev, body: toRichText('rewritten by the mod') }
|
|
624
|
+
expect(
|
|
625
|
+
moderated.comment!({ session: session('real-mod'), type: 'update', prev, next })
|
|
626
|
+
).toBeNull()
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
// The gap a delete-only permission would otherwise leave: flip the flag, rewrite the body,
|
|
630
|
+
// and the edit rides in on a write the delete gate allowed.
|
|
631
|
+
it('vetoes a delete that carries an edit past a delete-only permission', () => {
|
|
632
|
+
const prev = comment('real-bob')
|
|
633
|
+
const next = { ...prev, isDeleted: true, body: toRichText('rewritten on the way out') }
|
|
634
|
+
expect(
|
|
635
|
+
moderated.comment!({ session: session('real-mod'), type: 'update', prev, next })
|
|
636
|
+
).toBeNull()
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it('still lets the owner delete and edit in one write', () => {
|
|
640
|
+
const prev = comment('real-bob')
|
|
641
|
+
const next = { ...prev, isDeleted: true, body: toRichText('last word') }
|
|
642
|
+
expect(moderated.comment!({ session: session('real-bob'), type: 'update', prev, next })).toBe(
|
|
643
|
+
next
|
|
644
|
+
)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('leaves everyone else where the default left them', () => {
|
|
648
|
+
const prev = comment('real-bob')
|
|
649
|
+
expect(
|
|
650
|
+
moderated.comment!({
|
|
651
|
+
session: session('real-mallory'),
|
|
652
|
+
type: 'update',
|
|
653
|
+
prev,
|
|
654
|
+
next: { ...prev, isDeleted: true },
|
|
655
|
+
})
|
|
656
|
+
).toBeNull()
|
|
657
|
+
const threadPrev = makeThread('real-bob')
|
|
658
|
+
expect(
|
|
659
|
+
moderated['comment-thread']!({
|
|
660
|
+
session: session('real-mallory'),
|
|
661
|
+
type: 'update',
|
|
662
|
+
prev: threadPrev,
|
|
663
|
+
next: { ...threadPrev, isDeleted: true },
|
|
664
|
+
})
|
|
665
|
+
).toBeNull()
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('narrows as well as widens', () => {
|
|
669
|
+
const frozen = createCommentAuthorizers<TestMeta>({
|
|
670
|
+
getUserId: (session) => session.meta.userId,
|
|
671
|
+
canModifyComment: (ctx) => ctx.action !== 'edit-comment' && ctx.userId === ctx.ownerId,
|
|
672
|
+
})
|
|
673
|
+
const prev = comment('real-bob')
|
|
674
|
+
expect(
|
|
675
|
+
frozen.comment!({
|
|
676
|
+
session: session('real-bob'),
|
|
677
|
+
type: 'update',
|
|
678
|
+
prev,
|
|
679
|
+
next: { ...prev, body: toRichText('edited') },
|
|
680
|
+
})
|
|
681
|
+
).toBeNull()
|
|
682
|
+
// ...and the delete it didn't narrow still goes through
|
|
683
|
+
const deleted = { ...prev, isDeleted: true }
|
|
684
|
+
expect(
|
|
685
|
+
frozen.comment!({ session: session('real-bob'), type: 'update', prev, next: deleted })
|
|
686
|
+
).toBe(deleted)
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('is asked with the stored record, not the client’s version of it', () => {
|
|
690
|
+
const seen: unknown[] = []
|
|
691
|
+
const spying = createCommentAuthorizers<TestMeta>({
|
|
692
|
+
getUserId: (session) => session.meta.userId,
|
|
693
|
+
canModifyComment: (ctx) => {
|
|
694
|
+
seen.push(ctx)
|
|
695
|
+
return true
|
|
696
|
+
},
|
|
697
|
+
})
|
|
698
|
+
const prev = comment('real-bob')
|
|
699
|
+
// mallory's push claims the comment is hers and already deleted; the context must describe
|
|
700
|
+
// the record the room holds, or an ownership rule would be reading the attacker's claim
|
|
701
|
+
const next = { ...prev, authorId: 'real-mallory', body: toRichText('edited') }
|
|
702
|
+
spying.comment!({ session: session('real-mallory'), type: 'update', prev, next })
|
|
703
|
+
// the attribution guard rejects this one before the option is asked at all
|
|
704
|
+
expect(seen).toEqual([])
|
|
705
|
+
|
|
706
|
+
spying.comment!({
|
|
707
|
+
session: session('real-mallory'),
|
|
708
|
+
type: 'update',
|
|
709
|
+
prev,
|
|
710
|
+
next: { ...prev, body: toRichText('edited') },
|
|
711
|
+
})
|
|
712
|
+
expect(seen).toEqual([
|
|
713
|
+
{
|
|
714
|
+
session: session('real-mallory'),
|
|
715
|
+
userId: 'real-mallory',
|
|
716
|
+
ownerId: 'real-bob',
|
|
717
|
+
action: 'edit-comment',
|
|
718
|
+
comment: prev,
|
|
719
|
+
},
|
|
720
|
+
])
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
it('reports the thread’s creator as the owner of a thread delete', () => {
|
|
724
|
+
const seen: unknown[] = []
|
|
725
|
+
const spying = createCommentAuthorizers<TestMeta>({
|
|
726
|
+
getUserId: (session) => session.meta.userId,
|
|
727
|
+
canModifyComment: (ctx) => {
|
|
728
|
+
seen.push(ctx)
|
|
729
|
+
return true
|
|
730
|
+
},
|
|
731
|
+
})
|
|
732
|
+
const prev = makeThread('real-bob')
|
|
733
|
+
spying['comment-thread']!({
|
|
734
|
+
session: session('real-mallory'),
|
|
735
|
+
type: 'update',
|
|
736
|
+
prev,
|
|
737
|
+
next: { ...prev, isDeleted: true },
|
|
738
|
+
})
|
|
739
|
+
expect(seen).toEqual([
|
|
740
|
+
{
|
|
741
|
+
session: session('real-mallory'),
|
|
742
|
+
userId: 'real-mallory',
|
|
743
|
+
ownerId: 'real-bob',
|
|
744
|
+
action: 'delete-thread',
|
|
745
|
+
thread: prev,
|
|
746
|
+
},
|
|
747
|
+
])
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
// Resolving and reopening aren't anyone's in particular, matching the client option: they're
|
|
751
|
+
// `canComment`'s to gate, and the option is never asked about them.
|
|
752
|
+
it('is not asked about resolving or reopening a thread', () => {
|
|
753
|
+
let asked = 0
|
|
754
|
+
const counting = createCommentAuthorizers<TestMeta>({
|
|
755
|
+
getUserId: (session) => session.meta.userId,
|
|
756
|
+
canModifyComment: () => {
|
|
757
|
+
asked++
|
|
758
|
+
return true
|
|
759
|
+
},
|
|
760
|
+
})
|
|
761
|
+
const prev = makeThread('real-bob')
|
|
762
|
+
const resolved = { ...prev, resolved: { at: 1, by: 'real-mallory' } }
|
|
763
|
+
expect(
|
|
764
|
+
counting['comment-thread']!({
|
|
765
|
+
session: session('real-mallory'),
|
|
766
|
+
type: 'update',
|
|
767
|
+
prev,
|
|
768
|
+
next: resolved,
|
|
769
|
+
})
|
|
770
|
+
).toBe(resolved)
|
|
771
|
+
const reopened = { ...resolved, resolved: null }
|
|
772
|
+
expect(
|
|
773
|
+
counting['comment-thread']!({
|
|
774
|
+
session: session('real-mallory'),
|
|
775
|
+
type: 'update',
|
|
776
|
+
prev: resolved,
|
|
777
|
+
next: reopened,
|
|
778
|
+
})
|
|
779
|
+
).toBe(reopened)
|
|
780
|
+
expect(asked).toBe(0)
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
it('is not asked about reactions', () => {
|
|
784
|
+
let asked = 0
|
|
785
|
+
const counting = createCommentAuthorizers<TestMeta>({
|
|
786
|
+
getUserId: (session) => session.meta.userId,
|
|
787
|
+
canModifyComment: () => {
|
|
788
|
+
asked++
|
|
789
|
+
return true
|
|
790
|
+
},
|
|
791
|
+
})
|
|
792
|
+
const prev = createCommentReaction({
|
|
793
|
+
commentId: createCommentId('c1'),
|
|
794
|
+
threadId: thread.id,
|
|
795
|
+
pageId,
|
|
796
|
+
userId: 'real-bob',
|
|
797
|
+
emoji: '👍',
|
|
798
|
+
})
|
|
799
|
+
// a permissive callback doesn't hand mallory someone else's reaction
|
|
800
|
+
expect(
|
|
801
|
+
counting['comment-reaction']!({
|
|
802
|
+
session: session('real-mallory'),
|
|
803
|
+
type: 'delete',
|
|
804
|
+
prev,
|
|
805
|
+
next: null,
|
|
806
|
+
})
|
|
807
|
+
).toBeNull()
|
|
808
|
+
expect(
|
|
809
|
+
counting['comment-reaction']!({
|
|
810
|
+
session: session('real-bob'),
|
|
811
|
+
type: 'update',
|
|
812
|
+
prev,
|
|
813
|
+
next: { ...prev, createdAt: prev.createdAt + 1 },
|
|
814
|
+
})
|
|
815
|
+
).not.toBeNull()
|
|
816
|
+
expect(asked).toBe(0)
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
// The point of asking after the structural rules: a host can hand out these three writes
|
|
820
|
+
// without handing out the invariants underneath them.
|
|
821
|
+
describe('the structural rules hold however permissive the callback', () => {
|
|
822
|
+
const permissive = createCommentAuthorizers<TestMeta>({
|
|
823
|
+
getUserId: (session) => session.meta.userId,
|
|
824
|
+
canModifyComment: () => true,
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
it('still vetoes clearing a soft-delete', () => {
|
|
828
|
+
const prev = { ...comment('real-bob'), isDeleted: true }
|
|
829
|
+
expect(
|
|
830
|
+
permissive.comment!({
|
|
831
|
+
session: session('real-mod'),
|
|
832
|
+
type: 'update',
|
|
833
|
+
prev,
|
|
834
|
+
next: { ...prev, isDeleted: false },
|
|
835
|
+
})
|
|
836
|
+
).toBeNull()
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
it('still vetoes every client hard-delete', () => {
|
|
840
|
+
const prev = comment('real-bob')
|
|
841
|
+
expect(
|
|
842
|
+
permissive.comment!({ session: session('real-mod'), type: 'delete', prev, next: null })
|
|
843
|
+
).toBeNull()
|
|
844
|
+
const threadPrev = makeThread('real-bob')
|
|
845
|
+
expect(
|
|
846
|
+
permissive['comment-thread']!({
|
|
847
|
+
session: session('real-mod'),
|
|
848
|
+
type: 'delete',
|
|
849
|
+
prev: threadPrev,
|
|
850
|
+
next: null,
|
|
851
|
+
})
|
|
852
|
+
).toBeNull()
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
it('still vetoes changing a comment’s author', () => {
|
|
856
|
+
const prev = comment('real-bob')
|
|
857
|
+
expect(
|
|
858
|
+
permissive.comment!({
|
|
859
|
+
session: session('real-mod'),
|
|
860
|
+
type: 'update',
|
|
861
|
+
prev,
|
|
862
|
+
next: { ...prev, authorId: 'real-mod' },
|
|
863
|
+
})
|
|
864
|
+
).toBeNull()
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
it('still vetoes re-parenting and back-dating a comment', () => {
|
|
868
|
+
const prev = comment('real-bob')
|
|
869
|
+
expect(
|
|
870
|
+
permissive.comment!({
|
|
871
|
+
session: session('real-mod'),
|
|
872
|
+
type: 'update',
|
|
873
|
+
prev,
|
|
874
|
+
next: { ...prev, threadId: 'comment-thread:other' as TLCommentThread['id'] },
|
|
875
|
+
})
|
|
876
|
+
).toBeNull()
|
|
877
|
+
expect(
|
|
878
|
+
permissive.comment!({
|
|
879
|
+
session: session('real-mod'),
|
|
880
|
+
type: 'update',
|
|
881
|
+
prev,
|
|
882
|
+
next: { ...prev, createdAt: 1 },
|
|
883
|
+
})
|
|
884
|
+
).toBeNull()
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it('still vetoes a create with the soft-delete flag already set', () => {
|
|
888
|
+
expect(
|
|
889
|
+
permissive.comment!({
|
|
890
|
+
session: session('real-mod'),
|
|
891
|
+
type: 'create',
|
|
892
|
+
prev: null,
|
|
893
|
+
next: { ...comment('real-mod'), isDeleted: true },
|
|
894
|
+
})
|
|
895
|
+
).toBeNull()
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
it('still vetoes a resolution attributed to someone else', () => {
|
|
899
|
+
const prev = makeThread('real-bob')
|
|
900
|
+
expect(
|
|
901
|
+
permissive['comment-thread']!({
|
|
902
|
+
session: session('real-mod'),
|
|
903
|
+
type: 'update',
|
|
904
|
+
prev,
|
|
905
|
+
next: { ...prev, resolved: { at: 1, by: 'real-alice' } },
|
|
906
|
+
})
|
|
907
|
+
).toBeNull()
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
// An anonymous session has no identity to check a record against, so the default withholds
|
|
911
|
+
// all three writes from it. A callback that returns true regardless is taken at its word —
|
|
912
|
+
// worth knowing before writing one that ignores `userId`.
|
|
913
|
+
it('takes a callback at its word about an anonymous session', () => {
|
|
914
|
+
const prev = comment('real-bob')
|
|
915
|
+
const next = { ...prev, isDeleted: true }
|
|
916
|
+
expect(permissive.comment!({ session: session(null), type: 'update', prev, next })).toBe(
|
|
917
|
+
next
|
|
918
|
+
)
|
|
919
|
+
expect(
|
|
920
|
+
authorizers.comment!({ session: session(null), type: 'update', prev, next })
|
|
921
|
+
).toBeNull()
|
|
922
|
+
})
|
|
923
|
+
})
|
|
924
|
+
})
|
|
565
925
|
})
|