@atproto/bsync 0.0.0
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/LICENSE.txt +7 -0
- package/README.md +15 -0
- package/babel.config.js +3 -0
- package/bin/migration-create.ts +38 -0
- package/buf.gen.yaml +13 -0
- package/build.js +18 -0
- package/dist/client.d.ts +6 -0
- package/dist/config.d.ts +36 -0
- package/dist/context.d.ts +19 -0
- package/dist/db/index.d.ts +31 -0
- package/dist/db/migrations/20240108T220751294Z-init.d.ts +3 -0
- package/dist/db/migrations/index.d.ts +1 -0
- package/dist/db/migrations/provider.d.ts +6 -0
- package/dist/db/schema/index.d.ts +6 -0
- package/dist/db/schema/mute_item.d.ts +11 -0
- package/dist/db/schema/mute_op.d.ts +15 -0
- package/dist/db/types.d.ts +12 -0
- package/dist/gen/bsync_connect.d.ts +25 -0
- package/dist/gen/bsync_pb.d.ts +90 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +76455 -0
- package/dist/index.js.map +7 -0
- package/dist/logger.d.ts +4 -0
- package/dist/routes/add-mute-operation.d.ts +5 -0
- package/dist/routes/auth.d.ts +3 -0
- package/dist/routes/index.d.ts +4 -0
- package/dist/routes/scan-mute-operations.d.ts +5 -0
- package/jest.config.js +6 -0
- package/package.json +49 -0
- package/proto/bsync.proto +55 -0
- package/src/client.ts +25 -0
- package/src/config.ts +90 -0
- package/src/context.ts +42 -0
- package/src/db/index.ts +194 -0
- package/src/db/migrations/20240108T220751294Z-init.ts +26 -0
- package/src/db/migrations/index.ts +5 -0
- package/src/db/migrations/provider.ts +8 -0
- package/src/db/schema/index.ts +9 -0
- package/src/db/schema/mute_item.ts +13 -0
- package/src/db/schema/mute_op.ts +18 -0
- package/src/db/types.ts +15 -0
- package/src/gen/bsync_connect.ts +54 -0
- package/src/gen/bsync_pb.ts +459 -0
- package/src/index.ts +91 -0
- package/src/logger.ts +22 -0
- package/src/routes/add-mute-operation.ts +173 -0
- package/src/routes/auth.ts +15 -0
- package/src/routes/index.ts +18 -0
- package/src/routes/scan-mute-operations.ts +69 -0
- package/tests/mutes.test.ts +350 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { sql } from 'kysely'
|
|
2
|
+
import {
|
|
3
|
+
AtUri,
|
|
4
|
+
InvalidDidError,
|
|
5
|
+
ensureValidAtUri,
|
|
6
|
+
ensureValidDid,
|
|
7
|
+
} from '@atproto/syntax'
|
|
8
|
+
import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect'
|
|
9
|
+
import { Service } from '../gen/bsync_connect'
|
|
10
|
+
import { AddMuteOperationResponse, MuteOperation_Type } from '../gen/bsync_pb'
|
|
11
|
+
import AppContext from '../context'
|
|
12
|
+
import { createMuteOpChannel } from '../db/schema/mute_op'
|
|
13
|
+
import { authWithApiKey } from './auth'
|
|
14
|
+
import Database from '../db'
|
|
15
|
+
|
|
16
|
+
export default (ctx: AppContext): Partial<ServiceImpl<typeof Service>> => ({
|
|
17
|
+
async addMuteOperation(req, handlerCtx) {
|
|
18
|
+
authWithApiKey(ctx, handlerCtx)
|
|
19
|
+
const { db } = ctx
|
|
20
|
+
const op = validMuteOp(req)
|
|
21
|
+
const id = await db.transaction(async (txn) => {
|
|
22
|
+
// create mute op
|
|
23
|
+
const id = await createMuteOp(txn, op)
|
|
24
|
+
// update mute state
|
|
25
|
+
if (op.type === MuteOperation_Type.ADD) {
|
|
26
|
+
await addMuteItem(txn, id, op)
|
|
27
|
+
} else if (op.type === MuteOperation_Type.REMOVE) {
|
|
28
|
+
await removeMuteItem(txn, op)
|
|
29
|
+
} else if (op.type === MuteOperation_Type.CLEAR) {
|
|
30
|
+
await clearMuteItems(txn, op)
|
|
31
|
+
} else {
|
|
32
|
+
const exhaustiveCheck: never = op.type
|
|
33
|
+
throw new Error(`unreachable: ${exhaustiveCheck}`)
|
|
34
|
+
}
|
|
35
|
+
return id
|
|
36
|
+
})
|
|
37
|
+
return new AddMuteOperationResponse({
|
|
38
|
+
operation: {
|
|
39
|
+
id: String(id),
|
|
40
|
+
type: op.type,
|
|
41
|
+
actorDid: op.actorDid,
|
|
42
|
+
subject: op.subject,
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const createMuteOp = async (db: Database, op: MuteOpInfo) => {
|
|
49
|
+
const { ref } = db.db.dynamic
|
|
50
|
+
const { id } = await db.db
|
|
51
|
+
.insertInto('mute_op')
|
|
52
|
+
.values({
|
|
53
|
+
type: op.type,
|
|
54
|
+
actorDid: op.actorDid,
|
|
55
|
+
subject: op.subject,
|
|
56
|
+
})
|
|
57
|
+
.returning('id')
|
|
58
|
+
.executeTakeFirstOrThrow()
|
|
59
|
+
await sql`notify ${ref(createMuteOpChannel)}`.execute(db.db) // emitted transactionally
|
|
60
|
+
return id
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const addMuteItem = async (db: Database, fromId: number, op: MuteOpInfo) => {
|
|
64
|
+
const { ref } = db.db.dynamic
|
|
65
|
+
await db.db
|
|
66
|
+
.insertInto('mute_item')
|
|
67
|
+
.values({
|
|
68
|
+
actorDid: op.actorDid,
|
|
69
|
+
subject: op.subject,
|
|
70
|
+
fromId,
|
|
71
|
+
})
|
|
72
|
+
.onConflict((oc) =>
|
|
73
|
+
oc
|
|
74
|
+
.constraint('mute_item_pkey')
|
|
75
|
+
.doUpdateSet({ fromId: sql`${ref('excluded.fromId')}` }),
|
|
76
|
+
)
|
|
77
|
+
.execute()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const removeMuteItem = async (db: Database, op: MuteOpInfo) => {
|
|
81
|
+
await db.db
|
|
82
|
+
.deleteFrom('mute_item')
|
|
83
|
+
.where('actorDid', '=', op.actorDid)
|
|
84
|
+
.where('subject', '=', op.subject)
|
|
85
|
+
.execute()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const clearMuteItems = async (db: Database, op: MuteOpInfo) => {
|
|
89
|
+
await db.db
|
|
90
|
+
.deleteFrom('mute_item')
|
|
91
|
+
.where('actorDid', '=', op.actorDid)
|
|
92
|
+
.execute()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const validMuteOp = (op: MuteOpInfo): MuteOpInfoValid => {
|
|
96
|
+
if (!Object.values(MuteOperation_Type).includes(op.type)) {
|
|
97
|
+
throw new ConnectError('bad mute operation type', Code.InvalidArgument)
|
|
98
|
+
}
|
|
99
|
+
if (op.type === MuteOperation_Type.UNSPECIFIED) {
|
|
100
|
+
throw new ConnectError(
|
|
101
|
+
'unspecified mute operation type',
|
|
102
|
+
Code.InvalidArgument,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
if (!isValidDid(op.actorDid)) {
|
|
106
|
+
throw new ConnectError(
|
|
107
|
+
'actor_did must be a valid did',
|
|
108
|
+
Code.InvalidArgument,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
if (op.type === MuteOperation_Type.CLEAR) {
|
|
112
|
+
if (op.subject !== '') {
|
|
113
|
+
throw new ConnectError(
|
|
114
|
+
'subject must not be set on a clear op',
|
|
115
|
+
Code.InvalidArgument,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
if (isValidDid(op.subject)) {
|
|
120
|
+
// all good
|
|
121
|
+
} else if (isValidAtUri(op.subject)) {
|
|
122
|
+
const uri = new AtUri(op.subject)
|
|
123
|
+
if (uri.collection !== 'app.bsky.graph.list') {
|
|
124
|
+
throw new ConnectError(
|
|
125
|
+
'subject aturis must reference a list record',
|
|
126
|
+
Code.InvalidArgument,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
throw new ConnectError(
|
|
131
|
+
'subject must be a did or aturi on add or remove op',
|
|
132
|
+
Code.InvalidArgument,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return op as MuteOpInfoValid // op.type has been checked
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isValidDid = (did: string) => {
|
|
140
|
+
try {
|
|
141
|
+
ensureValidDid(did)
|
|
142
|
+
return true
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err instanceof InvalidDidError) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
throw err
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const isValidAtUri = (uri: string) => {
|
|
152
|
+
try {
|
|
153
|
+
ensureValidAtUri(uri)
|
|
154
|
+
return true
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type MuteOpInfo = {
|
|
161
|
+
type: MuteOperation_Type
|
|
162
|
+
actorDid: string
|
|
163
|
+
subject: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type MuteOpInfoValid = {
|
|
167
|
+
type:
|
|
168
|
+
| MuteOperation_Type.ADD
|
|
169
|
+
| MuteOperation_Type.REMOVE
|
|
170
|
+
| MuteOperation_Type.CLEAR
|
|
171
|
+
actorDid: string
|
|
172
|
+
subject: string
|
|
173
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Code, ConnectError, HandlerContext } from '@connectrpc/connect'
|
|
2
|
+
import AppContext from '../context'
|
|
3
|
+
|
|
4
|
+
const BEARER = 'Bearer '
|
|
5
|
+
|
|
6
|
+
export const authWithApiKey = (ctx: AppContext, handlerCtx: HandlerContext) => {
|
|
7
|
+
const authorization = handlerCtx.requestHeader.get('authorization')
|
|
8
|
+
if (!authorization?.startsWith(BEARER)) {
|
|
9
|
+
throw new ConnectError('missing auth', Code.Unauthenticated)
|
|
10
|
+
}
|
|
11
|
+
const key = authorization.slice(BEARER.length)
|
|
12
|
+
if (!ctx.cfg.auth.apiKeys.has(key)) {
|
|
13
|
+
throw new ConnectError('invalid api key', Code.Unauthenticated)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { sql } from 'kysely'
|
|
2
|
+
import { ConnectRouter } from '@connectrpc/connect'
|
|
3
|
+
import { Service } from '../gen/bsync_connect'
|
|
4
|
+
import AppContext from '../context'
|
|
5
|
+
import addMuteOperation from './add-mute-operation'
|
|
6
|
+
import scanMuteOperations from './scan-mute-operations'
|
|
7
|
+
|
|
8
|
+
export default (ctx: AppContext) => (router: ConnectRouter) => {
|
|
9
|
+
return router.service(Service, {
|
|
10
|
+
...addMuteOperation(ctx),
|
|
11
|
+
...scanMuteOperations(ctx),
|
|
12
|
+
async ping() {
|
|
13
|
+
const { db } = ctx
|
|
14
|
+
await sql`select 1`.execute(db.db)
|
|
15
|
+
return {}
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { once } from 'node:events'
|
|
2
|
+
import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect'
|
|
3
|
+
import { Service } from '../gen/bsync_connect'
|
|
4
|
+
import { ScanMuteOperationsResponse } from '../gen/bsync_pb'
|
|
5
|
+
import AppContext from '../context'
|
|
6
|
+
import { createMuteOpChannel } from '../db/schema/mute_op'
|
|
7
|
+
import { authWithApiKey } from './auth'
|
|
8
|
+
|
|
9
|
+
export default (ctx: AppContext): Partial<ServiceImpl<typeof Service>> => ({
|
|
10
|
+
async scanMuteOperations(req, handlerCtx) {
|
|
11
|
+
authWithApiKey(ctx, handlerCtx)
|
|
12
|
+
const { db, events } = ctx
|
|
13
|
+
const limit = req.limit || 1000
|
|
14
|
+
const cursor = validCursor(req.cursor)
|
|
15
|
+
const nextMuteOpPromise = once(events, createMuteOpChannel, {
|
|
16
|
+
signal: AbortSignal.timeout(ctx.cfg.service.longPollTimeoutMs),
|
|
17
|
+
})
|
|
18
|
+
nextMuteOpPromise.catch(() => null) // ensure timeout is always handled
|
|
19
|
+
|
|
20
|
+
const nextMuteOpPageQb = db.db
|
|
21
|
+
.selectFrom('mute_op')
|
|
22
|
+
.selectAll()
|
|
23
|
+
.where('id', '>', cursor ?? -1)
|
|
24
|
+
.orderBy('id', 'asc')
|
|
25
|
+
.limit(limit)
|
|
26
|
+
|
|
27
|
+
let ops = await nextMuteOpPageQb.execute()
|
|
28
|
+
|
|
29
|
+
if (!ops.length) {
|
|
30
|
+
// if there were no ops on the page, wait for an event then try again.
|
|
31
|
+
try {
|
|
32
|
+
await nextMuteOpPromise
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return new ScanMuteOperationsResponse({
|
|
35
|
+
operations: [],
|
|
36
|
+
cursor: req.cursor,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
ops = await nextMuteOpPageQb.execute()
|
|
40
|
+
if (!ops.length) {
|
|
41
|
+
return new ScanMuteOperationsResponse({
|
|
42
|
+
operations: [],
|
|
43
|
+
cursor: req.cursor,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lastOp = ops[ops.length - 1]
|
|
49
|
+
|
|
50
|
+
return new ScanMuteOperationsResponse({
|
|
51
|
+
operations: ops.map((op) => ({
|
|
52
|
+
id: op.id.toString(),
|
|
53
|
+
type: op.type,
|
|
54
|
+
actorDid: op.actorDid,
|
|
55
|
+
subject: op.subject,
|
|
56
|
+
})),
|
|
57
|
+
cursor: lastOp.id.toString(),
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const validCursor = (cursor: string): number | null => {
|
|
63
|
+
if (cursor === '') return null
|
|
64
|
+
const int = parseInt(cursor, 10)
|
|
65
|
+
if (isNaN(int) || int < 0) {
|
|
66
|
+
throw new ConnectError('invalid cursor', Code.InvalidArgument)
|
|
67
|
+
}
|
|
68
|
+
return int
|
|
69
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { wait } from '@atproto/common'
|
|
2
|
+
import { Code, ConnectError } from '@connectrpc/connect'
|
|
3
|
+
import {
|
|
4
|
+
BsyncClient,
|
|
5
|
+
BsyncService,
|
|
6
|
+
Database,
|
|
7
|
+
authWithApiKey,
|
|
8
|
+
createClient,
|
|
9
|
+
envToCfg,
|
|
10
|
+
} from '../src'
|
|
11
|
+
import { MuteOperation, MuteOperation_Type } from '../src/gen/bsync_pb'
|
|
12
|
+
|
|
13
|
+
describe('mutes', () => {
|
|
14
|
+
let bsync: BsyncService
|
|
15
|
+
let client: BsyncClient
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
bsync = await BsyncService.create(
|
|
19
|
+
envToCfg({
|
|
20
|
+
dbUrl: process.env.DB_POSTGRES_URL,
|
|
21
|
+
dbSchema: 'bsync_mutes',
|
|
22
|
+
apiKeys: ['key-1'],
|
|
23
|
+
longPollTimeoutMs: 500,
|
|
24
|
+
}),
|
|
25
|
+
)
|
|
26
|
+
await bsync.ctx.db.migrateToLatestOrThrow()
|
|
27
|
+
await bsync.start()
|
|
28
|
+
client = createClient({
|
|
29
|
+
httpVersion: '1.1',
|
|
30
|
+
baseUrl: `http://localhost:${bsync.ctx.cfg.service.port}`,
|
|
31
|
+
interceptors: [authWithApiKey('key-1')],
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await bsync.destroy()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
await clearMutes(bsync.ctx.db)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('addMuteOperation', () => {
|
|
44
|
+
it('adds mute operations to add mutes.', async () => {
|
|
45
|
+
await client.addMuteOperation({
|
|
46
|
+
type: MuteOperation_Type.ADD,
|
|
47
|
+
actorDid: 'did:example:a',
|
|
48
|
+
subject: 'did:example:b',
|
|
49
|
+
})
|
|
50
|
+
await client.addMuteOperation({
|
|
51
|
+
type: MuteOperation_Type.ADD,
|
|
52
|
+
actorDid: 'did:example:a',
|
|
53
|
+
subject: 'did:example:c',
|
|
54
|
+
})
|
|
55
|
+
// dupe has no effect
|
|
56
|
+
await client.addMuteOperation({
|
|
57
|
+
type: MuteOperation_Type.ADD,
|
|
58
|
+
actorDid: 'did:example:a',
|
|
59
|
+
subject: 'did:example:c',
|
|
60
|
+
})
|
|
61
|
+
await client.addMuteOperation({
|
|
62
|
+
type: MuteOperation_Type.ADD,
|
|
63
|
+
actorDid: 'did:example:b',
|
|
64
|
+
subject: 'did:example:c',
|
|
65
|
+
})
|
|
66
|
+
await client.addMuteOperation({
|
|
67
|
+
type: MuteOperation_Type.ADD,
|
|
68
|
+
actorDid: 'did:example:c',
|
|
69
|
+
subject: 'at://did:example:d/app.bsky.graph.list/rkey1',
|
|
70
|
+
})
|
|
71
|
+
expect(await dumpMuteState(bsync.ctx.db)).toEqual({
|
|
72
|
+
'did:example:a': ['did:example:b', 'did:example:c'],
|
|
73
|
+
'did:example:b': ['did:example:c'],
|
|
74
|
+
'did:example:c': ['at://did:example:d/app.bsky.graph.list/rkey1'],
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('adds mute operations to remove mutes.', async () => {
|
|
79
|
+
await client.addMuteOperation({
|
|
80
|
+
type: MuteOperation_Type.ADD,
|
|
81
|
+
actorDid: 'did:example:a',
|
|
82
|
+
subject: 'did:example:b',
|
|
83
|
+
})
|
|
84
|
+
await client.addMuteOperation({
|
|
85
|
+
type: MuteOperation_Type.ADD,
|
|
86
|
+
actorDid: 'did:example:a',
|
|
87
|
+
subject: 'did:example:c',
|
|
88
|
+
})
|
|
89
|
+
await client.addMuteOperation({
|
|
90
|
+
type: MuteOperation_Type.ADD,
|
|
91
|
+
actorDid: 'did:example:b',
|
|
92
|
+
subject: 'did:example:c',
|
|
93
|
+
})
|
|
94
|
+
await client.addMuteOperation({
|
|
95
|
+
type: MuteOperation_Type.REMOVE,
|
|
96
|
+
actorDid: 'did:example:a',
|
|
97
|
+
subject: 'did:example:c',
|
|
98
|
+
})
|
|
99
|
+
// removes nothing
|
|
100
|
+
await client.addMuteOperation({
|
|
101
|
+
type: MuteOperation_Type.REMOVE,
|
|
102
|
+
actorDid: 'did:example:b',
|
|
103
|
+
subject: 'did:example:d',
|
|
104
|
+
})
|
|
105
|
+
expect(await dumpMuteState(bsync.ctx.db)).toEqual({
|
|
106
|
+
'did:example:a': ['did:example:b'],
|
|
107
|
+
'did:example:b': ['did:example:c'],
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('adds mute operations to clear mutes.', async () => {
|
|
112
|
+
await client.addMuteOperation({
|
|
113
|
+
type: MuteOperation_Type.ADD,
|
|
114
|
+
actorDid: 'did:example:a',
|
|
115
|
+
subject: 'did:example:b',
|
|
116
|
+
})
|
|
117
|
+
await client.addMuteOperation({
|
|
118
|
+
type: MuteOperation_Type.ADD,
|
|
119
|
+
actorDid: 'did:example:a',
|
|
120
|
+
subject: 'did:example:c',
|
|
121
|
+
})
|
|
122
|
+
await client.addMuteOperation({
|
|
123
|
+
type: MuteOperation_Type.ADD,
|
|
124
|
+
actorDid: 'did:example:b',
|
|
125
|
+
subject: 'did:example:c',
|
|
126
|
+
})
|
|
127
|
+
await client.addMuteOperation({
|
|
128
|
+
type: MuteOperation_Type.CLEAR,
|
|
129
|
+
actorDid: 'did:example:a',
|
|
130
|
+
})
|
|
131
|
+
expect(await dumpMuteState(bsync.ctx.db)).toEqual({
|
|
132
|
+
'did:example:b': ['did:example:c'],
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('fails on bad inputs', async () => {
|
|
137
|
+
await expect(
|
|
138
|
+
client.addMuteOperation({
|
|
139
|
+
type: MuteOperation_Type.ADD,
|
|
140
|
+
actorDid: 'did:example:a',
|
|
141
|
+
subject: 'invalid',
|
|
142
|
+
}),
|
|
143
|
+
).rejects.toEqual(
|
|
144
|
+
new ConnectError(
|
|
145
|
+
'subject must be a did or aturi on add or remove op',
|
|
146
|
+
Code.InvalidArgument,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
await expect(
|
|
150
|
+
client.addMuteOperation({
|
|
151
|
+
type: MuteOperation_Type.ADD,
|
|
152
|
+
actorDid: 'did:example:a',
|
|
153
|
+
}),
|
|
154
|
+
).rejects.toEqual(
|
|
155
|
+
new ConnectError(
|
|
156
|
+
'subject must be a did or aturi on add or remove op',
|
|
157
|
+
Code.InvalidArgument,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
await expect(
|
|
161
|
+
client.addMuteOperation({
|
|
162
|
+
type: MuteOperation_Type.ADD,
|
|
163
|
+
actorDid: 'did:example:a',
|
|
164
|
+
subject: 'at://did:example:b/bad.collection/rkey1',
|
|
165
|
+
}),
|
|
166
|
+
).rejects.toEqual(
|
|
167
|
+
new ConnectError(
|
|
168
|
+
'subject must be a did or aturi on add or remove op',
|
|
169
|
+
Code.InvalidArgument,
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
await expect(
|
|
173
|
+
client.addMuteOperation({
|
|
174
|
+
type: MuteOperation_Type.ADD,
|
|
175
|
+
actorDid: 'invalid',
|
|
176
|
+
subject: 'did:example:b',
|
|
177
|
+
}),
|
|
178
|
+
).rejects.toEqual(
|
|
179
|
+
new ConnectError('actor_did must be a valid did', Code.InvalidArgument),
|
|
180
|
+
)
|
|
181
|
+
await expect(
|
|
182
|
+
client.addMuteOperation({
|
|
183
|
+
type: MuteOperation_Type.REMOVE,
|
|
184
|
+
actorDid: 'did:example:a',
|
|
185
|
+
subject: 'invalid',
|
|
186
|
+
}),
|
|
187
|
+
).rejects.toEqual(
|
|
188
|
+
new ConnectError(
|
|
189
|
+
'subject must be a did or aturi on add or remove op',
|
|
190
|
+
Code.InvalidArgument,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
await expect(
|
|
194
|
+
client.addMuteOperation({
|
|
195
|
+
type: MuteOperation_Type.CLEAR,
|
|
196
|
+
actorDid: 'did:example:a',
|
|
197
|
+
subject: 'did:example:b',
|
|
198
|
+
}),
|
|
199
|
+
).rejects.toEqual(
|
|
200
|
+
new ConnectError(
|
|
201
|
+
'subject must not be set on a clear op',
|
|
202
|
+
Code.InvalidArgument,
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
await expect(
|
|
206
|
+
client.addMuteOperation({
|
|
207
|
+
type: MuteOperation_Type.CLEAR,
|
|
208
|
+
actorDid: 'invalid',
|
|
209
|
+
}),
|
|
210
|
+
).rejects.toEqual(
|
|
211
|
+
new ConnectError('actor_did must be a valid did', Code.InvalidArgument),
|
|
212
|
+
)
|
|
213
|
+
await expect(
|
|
214
|
+
client.addMuteOperation({
|
|
215
|
+
type: 100 as any,
|
|
216
|
+
actorDid: 'did:example:a',
|
|
217
|
+
subject: 'did:example:b',
|
|
218
|
+
}),
|
|
219
|
+
).rejects.toEqual(
|
|
220
|
+
new ConnectError('bad mute operation type', Code.InvalidArgument),
|
|
221
|
+
)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('requires auth', async () => {
|
|
225
|
+
// unauthed
|
|
226
|
+
const unauthedClient = createClient({
|
|
227
|
+
httpVersion: '1.1',
|
|
228
|
+
baseUrl: `http://localhost:${bsync.ctx.cfg.service.port}`,
|
|
229
|
+
})
|
|
230
|
+
const tryAddMuteOperation1 = unauthedClient.addMuteOperation({
|
|
231
|
+
type: MuteOperation_Type.ADD,
|
|
232
|
+
actorDid: 'did:example:a',
|
|
233
|
+
subject: 'did:example:b',
|
|
234
|
+
})
|
|
235
|
+
await expect(tryAddMuteOperation1).rejects.toEqual(
|
|
236
|
+
new ConnectError('missing auth', Code.Unauthenticated),
|
|
237
|
+
)
|
|
238
|
+
// bad auth
|
|
239
|
+
const badauthedClient = createClient({
|
|
240
|
+
httpVersion: '1.1',
|
|
241
|
+
baseUrl: `http://localhost:${bsync.ctx.cfg.service.port}`,
|
|
242
|
+
interceptors: [authWithApiKey('key-bad')],
|
|
243
|
+
})
|
|
244
|
+
const tryAddMuteOperation2 = badauthedClient.addMuteOperation({
|
|
245
|
+
type: MuteOperation_Type.ADD,
|
|
246
|
+
actorDid: 'did:example:a',
|
|
247
|
+
subject: 'did:example:b',
|
|
248
|
+
})
|
|
249
|
+
await expect(tryAddMuteOperation2).rejects.toEqual(
|
|
250
|
+
new ConnectError('invalid api key', Code.Unauthenticated),
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('scanMuteOperations', () => {
|
|
256
|
+
it('requires auth', async () => {
|
|
257
|
+
// unauthed
|
|
258
|
+
const unauthedClient = createClient({
|
|
259
|
+
httpVersion: '1.1',
|
|
260
|
+
baseUrl: `http://localhost:${bsync.ctx.cfg.service.port}`,
|
|
261
|
+
})
|
|
262
|
+
const tryScanMuteOperations1 = unauthedClient.scanMuteOperations({})
|
|
263
|
+
await expect(tryScanMuteOperations1).rejects.toEqual(
|
|
264
|
+
new ConnectError('missing auth', Code.Unauthenticated),
|
|
265
|
+
)
|
|
266
|
+
// bad auth
|
|
267
|
+
const badauthedClient = createClient({
|
|
268
|
+
httpVersion: '1.1',
|
|
269
|
+
baseUrl: `http://localhost:${bsync.ctx.cfg.service.port}`,
|
|
270
|
+
interceptors: [authWithApiKey('key-bad')],
|
|
271
|
+
})
|
|
272
|
+
const tryScanMuteOperations2 = badauthedClient.scanMuteOperations({})
|
|
273
|
+
await expect(tryScanMuteOperations2).rejects.toEqual(
|
|
274
|
+
new ConnectError('invalid api key', Code.Unauthenticated),
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('pages over created mute ops.', async () => {
|
|
279
|
+
// add 100 mute ops
|
|
280
|
+
for (let i = 0; i < 10; ++i) {
|
|
281
|
+
for (let j = 0; j < 8; ++j) {
|
|
282
|
+
await client.addMuteOperation({
|
|
283
|
+
type: MuteOperation_Type.ADD,
|
|
284
|
+
actorDid: `did:example:${i}`,
|
|
285
|
+
subject: `did:example:${j}`,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
for (let j = 0; j < 2; ++j) {
|
|
289
|
+
await client.addMuteOperation({
|
|
290
|
+
type: MuteOperation_Type.ADD,
|
|
291
|
+
actorDid: `did:example:${i}`,
|
|
292
|
+
subject: `at://did:example:0/app.bsky.graph.list/rkey${j}`,
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let cursor: string | undefined
|
|
298
|
+
const operations: MuteOperation[] = []
|
|
299
|
+
do {
|
|
300
|
+
const res = await client.scanMuteOperations({
|
|
301
|
+
cursor,
|
|
302
|
+
limit: 30,
|
|
303
|
+
})
|
|
304
|
+
operations.push(...res.operations)
|
|
305
|
+
cursor = res.operations.length ? res.cursor : undefined
|
|
306
|
+
} while (cursor)
|
|
307
|
+
|
|
308
|
+
expect(operations.length).toEqual(100)
|
|
309
|
+
const operationIds = operations.map((op) => parseInt(op.id, 10))
|
|
310
|
+
const ascending = (a: number, b: number) => a - b
|
|
311
|
+
expect(operationIds).toEqual([...operationIds].sort(ascending))
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('supports long-poll, finding an operation.', async () => {
|
|
315
|
+
const scanPromise = client.scanMuteOperations({})
|
|
316
|
+
await wait(100) // would be complete by now if it wasn't long-polling for an item
|
|
317
|
+
const { operation } = await client.addMuteOperation({
|
|
318
|
+
type: MuteOperation_Type.ADD,
|
|
319
|
+
actorDid: 'did:example:a',
|
|
320
|
+
subject: 'did:example:b',
|
|
321
|
+
})
|
|
322
|
+
const res = await scanPromise
|
|
323
|
+
expect(res.operations.length).toEqual(1)
|
|
324
|
+
expect(res.operations[0]).toEqual(operation)
|
|
325
|
+
expect(res.cursor).toEqual(operation?.id)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('supports long-poll, not finding an operation.', async () => {
|
|
329
|
+
const res = await client.scanMuteOperations({})
|
|
330
|
+
expect(res.cursor).toEqual('')
|
|
331
|
+
expect(res.operations).toEqual([])
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const dumpMuteState = async (db: Database) => {
|
|
337
|
+
const items = await db.db.selectFrom('mute_item').selectAll().execute()
|
|
338
|
+
const result: Record<string, string[]> = {}
|
|
339
|
+
items.forEach((item) => {
|
|
340
|
+
result[item.actorDid] ??= []
|
|
341
|
+
result[item.actorDid].push(item.subject)
|
|
342
|
+
})
|
|
343
|
+
Object.values(result).forEach((subjects) => subjects.sort())
|
|
344
|
+
return result
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const clearMutes = async (db: Database) => {
|
|
348
|
+
await db.db.deleteFrom('mute_item').execute()
|
|
349
|
+
await db.db.deleteFrom('mute_op').execute()
|
|
350
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "./src",
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"emitDeclarationOnly": true
|
|
7
|
+
},
|
|
8
|
+
"module": "nodenext",
|
|
9
|
+
"include": ["./src", "__tests__/**/**.ts"],
|
|
10
|
+
"references": [
|
|
11
|
+
{ "path": "../common/tsconfig.build.json" },
|
|
12
|
+
{ "path": "../common-web/tsconfig.build.json" }
|
|
13
|
+
]
|
|
14
|
+
}
|