@atproto/ozone 0.0.12 → 0.0.13

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/api/label/queryLabels.d.ts +3 -0
  3. package/dist/api/label/subscribeLabels.d.ts +3 -0
  4. package/dist/config/config.d.ts +3 -0
  5. package/dist/config/env.d.ts +3 -0
  6. package/dist/context.d.ts +3 -0
  7. package/dist/db/index.js +3 -1
  8. package/dist/db/index.js.map +2 -2
  9. package/dist/db/schema/label.d.ts +4 -0
  10. package/dist/index.js +593 -244
  11. package/dist/index.js.map +3 -3
  12. package/dist/logger.d.ts +1 -0
  13. package/dist/mod-service/util.d.ts +3 -0
  14. package/dist/sequencer/index.d.ts +2 -0
  15. package/dist/sequencer/outbox.d.ts +16 -0
  16. package/dist/sequencer/sequencer.d.ts +33 -0
  17. package/package.json +10 -10
  18. package/src/api/admin/emitModerationEvent.ts +16 -10
  19. package/src/api/index.ts +4 -0
  20. package/src/api/label/queryLabels.ts +58 -0
  21. package/src/api/label/subscribeLabels.ts +25 -0
  22. package/src/api/temp/fetchLabels.ts +2 -4
  23. package/src/config/config.ts +6 -0
  24. package/src/config/env.ts +6 -0
  25. package/src/context.ts +12 -0
  26. package/src/db/migrations/20231219T205730722Z-init.ts +7 -1
  27. package/src/db/schema/label.ts +7 -0
  28. package/src/index.ts +2 -0
  29. package/src/lexicon/lexicons.ts +1 -1
  30. package/src/logger.ts +2 -0
  31. package/src/mod-service/index.ts +73 -72
  32. package/src/mod-service/status.ts +3 -0
  33. package/src/mod-service/util.ts +17 -0
  34. package/src/mod-service/views.ts +2 -5
  35. package/src/sequencer/index.ts +2 -0
  36. package/src/sequencer/outbox.ts +122 -0
  37. package/src/sequencer/sequencer.ts +143 -0
  38. package/tests/__snapshots__/moderation-events.test.ts.snap +53 -75
  39. package/tests/__snapshots__/moderation.test.ts.snap +4 -4
  40. package/tests/moderation-appeals.test.ts +19 -7
  41. package/tests/moderation-events.test.ts +7 -7
  42. package/tests/moderation-statuses.test.ts +2 -2
  43. package/tests/moderation.test.ts +14 -13
  44. package/tests/query-labels.test.ts +163 -0
  45. package/tests/repo-search.test.ts +0 -1
  46. package/tests/sequencer.test.ts +222 -0
  47. package/tests/server.test.ts +2 -0
@@ -607,7 +607,7 @@ describe('moderation', () => {
607
607
  },
608
608
  {
609
609
  encoding: 'application/json',
610
- headers: network.bsky.adminAuthHeaders('triage'),
610
+ headers: network.ozone.adminAuthHeaders('triage'),
611
611
  },
612
612
  )
613
613
  await expect(attemptLabel).rejects.toThrow(
@@ -748,7 +748,7 @@ describe('moderation', () => {
748
748
  },
749
749
  {
750
750
  encoding: 'application/json',
751
- headers: network.bsky.adminAuthHeaders('moderator'),
751
+ headers: network.ozone.adminAuthHeaders('moderator'),
752
752
  },
753
753
  )
754
754
  // cleanup
@@ -775,11 +775,11 @@ describe('moderation', () => {
775
775
  },
776
776
  {
777
777
  encoding: 'application/json',
778
- headers: network.bsky.adminAuthHeaders('triage'),
778
+ headers: network.ozone.adminAuthHeaders('triage'),
779
779
  },
780
780
  )
781
781
  await expect(attemptTakedownTriage).rejects.toThrow(
782
- 'Must be a full moderator to perform an account takedown',
782
+ 'Must be a full moderator to take this type of action',
783
783
  )
784
784
  })
785
785
 
@@ -800,7 +800,7 @@ describe('moderation', () => {
800
800
  const { data: statusesAfterTakedown } =
801
801
  await agent.api.com.atproto.admin.queryModerationStatuses(
802
802
  { subject: sc.dids.bob },
803
- { headers: network.bsky.adminAuthHeaders('moderator') },
803
+ { headers: network.ozone.adminAuthHeaders('moderator') },
804
804
  )
805
805
 
806
806
  expect(statusesAfterTakedown.subjectStatuses[0]).toMatchObject({
@@ -818,11 +818,11 @@ describe('moderation', () => {
818
818
  const [{ data: eventList }, { data: statuses }] = await Promise.all([
819
819
  agent.api.com.atproto.admin.queryModerationEvents(
820
820
  { subject: sc.dids.bob },
821
- { headers: network.bsky.adminAuthHeaders('moderator') },
821
+ { headers: network.ozone.adminAuthHeaders('moderator') },
822
822
  ),
823
823
  agent.api.com.atproto.admin.queryModerationStatuses(
824
824
  { subject: sc.dids.bob },
825
- { headers: network.bsky.adminAuthHeaders('moderator') },
825
+ { headers: network.ozone.adminAuthHeaders('moderator') },
826
826
  ),
827
827
  ])
828
828
 
@@ -879,7 +879,7 @@ describe('moderation', () => {
879
879
  },
880
880
  {
881
881
  encoding: 'application/json',
882
- headers: network.bsky.adminAuthHeaders(),
882
+ headers: network.ozone.adminAuthHeaders(),
883
883
  },
884
884
  )
885
885
  return result.data
@@ -901,7 +901,7 @@ describe('moderation', () => {
901
901
  },
902
902
  {
903
903
  encoding: 'application/json',
904
- headers: network.bsky.adminAuthHeaders(),
904
+ headers: network.ozone.adminAuthHeaders(),
905
905
  },
906
906
  )
907
907
  }
@@ -909,7 +909,7 @@ describe('moderation', () => {
909
909
  async function getRecordLabels(uri: string) {
910
910
  const result = await agent.api.com.atproto.admin.getRecord(
911
911
  { uri },
912
- { headers: network.bsky.adminAuthHeaders() },
912
+ { headers: network.ozone.adminAuthHeaders() },
913
913
  )
914
914
  const labels = result.data.labels ?? []
915
915
  return labels.map((l) => l.val)
@@ -918,7 +918,7 @@ describe('moderation', () => {
918
918
  async function getRepoLabels(did: string) {
919
919
  const result = await agent.api.com.atproto.admin.getRepo(
920
920
  { did },
921
- { headers: network.bsky.adminAuthHeaders() },
921
+ { headers: network.ozone.adminAuthHeaders() },
922
922
  )
923
923
  const labels = result.data.labels ?? []
924
924
  return labels.map((l) => l.val)
@@ -933,7 +933,7 @@ describe('moderation', () => {
933
933
  const { ctx } = network.bsky
934
934
  post = sc.posts[sc.dids.carol][0]
935
935
  blob = post.images[1]
936
- imageUri = ctx.imgUriBuilder
936
+ imageUri = ctx.views.imgUriBuilder
937
937
  .getPresetUri(
938
938
  'feed_thumbnail',
939
939
  sc.dids.carol,
@@ -974,7 +974,8 @@ describe('moderation', () => {
974
974
  })
975
975
  })
976
976
 
977
- it('prevents image blob from being served, even when cached.', async () => {
977
+ // @TODO add back in with image invalidation, see bluesky-social/atproto#2087
978
+ it.skip('prevents image blob from being served, even when cached.', async () => {
978
979
  const fetchImage = await fetch(imageUri)
979
980
  expect(fetchImage.status).toEqual(404)
980
981
  expect(await fetchImage.json()).toEqual({ message: 'Image not found' })
@@ -0,0 +1,163 @@
1
+ import AtpAgent from '@atproto/api'
2
+ import { TestNetwork } from '@atproto/dev-env'
3
+ import { DisconnectError, Subscription } from '@atproto/xrpc-server'
4
+ import { ids, lexicons } from '../src/lexicon/lexicons'
5
+ import { Label } from '../src/lexicon/types/com/atproto/label/defs'
6
+ import {
7
+ OutputSchema as LabelMessage,
8
+ isLabels,
9
+ } from '../src/lexicon/types/com/atproto/label/subscribeLabels'
10
+
11
+ describe('ozone query labels', () => {
12
+ let network: TestNetwork
13
+ let agent: AtpAgent
14
+
15
+ let labels: Label[]
16
+
17
+ beforeAll(async () => {
18
+ network = await TestNetwork.create({
19
+ dbPostgresSchema: 'ozone_query_labels',
20
+ })
21
+
22
+ agent = network.ozone.getClient()
23
+
24
+ labels = [
25
+ {
26
+ src: 'did:example:labeler',
27
+ uri: 'did:example:blah',
28
+ val: 'spam',
29
+ neg: false,
30
+ cts: new Date().toISOString(),
31
+ },
32
+ {
33
+ src: 'did:example:labeler',
34
+ uri: 'did:example:blah',
35
+ val: 'impersonation',
36
+ neg: false,
37
+ cts: new Date().toISOString(),
38
+ },
39
+ {
40
+ src: 'did:example:labeler',
41
+ uri: 'at://did:example:blah/app.bsky.feed.post/1234abcde',
42
+ val: 'spam',
43
+ neg: false,
44
+ cts: new Date().toISOString(),
45
+ },
46
+ {
47
+ src: 'did:example:labeler',
48
+ uri: 'at://did:example:blah/app.bsky.feed.post/1234abcfg',
49
+ val: 'spam',
50
+ neg: false,
51
+ cts: new Date().toISOString(),
52
+ },
53
+ {
54
+ src: 'did:example:labeler',
55
+ uri: 'at://did:example:blah/app.bsky.actor.profile/self',
56
+ val: 'spam',
57
+ neg: false,
58
+ cts: new Date().toISOString(),
59
+ },
60
+ {
61
+ src: 'did:example:labeler',
62
+ uri: 'did:example:thing',
63
+ val: 'spam',
64
+ neg: false,
65
+ cts: new Date().toISOString(),
66
+ },
67
+ ]
68
+
69
+ const modService = network.ozone.ctx.modService(network.ozone.ctx.db)
70
+ await modService.createLabels(labels)
71
+ })
72
+
73
+ afterAll(async () => {
74
+ await network.close()
75
+ })
76
+
77
+ it('returns all labels', async () => {
78
+ const res = await agent.api.com.atproto.label.queryLabels({
79
+ uriPatterns: ['*'],
80
+ })
81
+ expect(res.data.labels).toEqual(labels)
82
+ })
83
+
84
+ it('returns all labels even when an additional pattern is supplied', async () => {
85
+ const res = await agent.api.com.atproto.label.queryLabels({
86
+ uriPatterns: ['*', 'did:example:blah'],
87
+ })
88
+ expect(res.data.labels).toEqual(labels)
89
+ })
90
+
91
+ it('returns all labels that match an exact uri pattern', async () => {
92
+ const res = await agent.api.com.atproto.label.queryLabels({
93
+ uriPatterns: ['did:example:blah'],
94
+ })
95
+ expect(res.data.labels).toEqual(labels.slice(0, 2))
96
+ })
97
+
98
+ it('returns all labels that match one of multiple exact uris', async () => {
99
+ const res = await agent.api.com.atproto.label.queryLabels({
100
+ uriPatterns: [
101
+ 'at://did:example:blah/app.bsky.feed.post/1234abcfg',
102
+ 'at://did:example:blah/app.bsky.actor.profile/self',
103
+ ],
104
+ })
105
+ expect(res.data.labels).toEqual(labels.slice(3, 5))
106
+ })
107
+
108
+ it('returns all labels that match one of multiple uris, exact & glob', async () => {
109
+ const res = await agent.api.com.atproto.label.queryLabels({
110
+ uriPatterns: ['at://did:example:blah/app.bsky*', 'did:example:blah'],
111
+ })
112
+ expect(res.data.labels).toEqual(labels.slice(0, 5))
113
+ })
114
+
115
+ it('paginates', async () => {
116
+ const res1 = await agent.api.com.atproto.label.queryLabels({
117
+ uriPatterns: ['at://did:example:blah/app.bsky*', 'did:example:blah'],
118
+ limit: 3,
119
+ })
120
+ const res2 = await agent.api.com.atproto.label.queryLabels({
121
+ uriPatterns: ['at://did:example:blah/app.bsky*', 'did:example:blah'],
122
+ limit: 3,
123
+ cursor: res1.data.cursor,
124
+ })
125
+
126
+ expect([...res1.data.labels, ...res2.data.labels]).toEqual(
127
+ labels.slice(0, 5),
128
+ )
129
+ })
130
+
131
+ describe('subscribeLabels', () => {
132
+ it('streams all labels from initial cursor.', async () => {
133
+ const ac = new AbortController()
134
+ let doneTimer: NodeJS.Timeout
135
+ const resetDoneTimer = () => {
136
+ clearTimeout(doneTimer)
137
+ doneTimer = setTimeout(() => ac.abort(new DisconnectError()), 100)
138
+ }
139
+ const sub = new Subscription({
140
+ signal: ac.signal,
141
+ service: agent.service.origin.replace('http://', 'ws://'),
142
+ method: ids.ComAtprotoLabelSubscribeLabels,
143
+ getParams() {
144
+ return { cursor: 0 }
145
+ },
146
+ validate(obj) {
147
+ return lexicons.assertValidXrpcMessage<LabelMessage>(
148
+ ids.ComAtprotoLabelSubscribeLabels,
149
+ obj,
150
+ )
151
+ },
152
+ })
153
+ const streamedLabels: Label[] = []
154
+ for await (const message of sub) {
155
+ resetDoneTimer()
156
+ if (isLabels(message)) {
157
+ streamedLabels.push(...message.labels)
158
+ }
159
+ }
160
+ expect(streamedLabels).toEqual(labels)
161
+ })
162
+ })
163
+ })
@@ -117,7 +117,6 @@ describe('admin repo search view', () => {
117
117
  { headers },
118
118
  )
119
119
 
120
- expect(full.data.repos.length).toEqual(15)
121
120
  expect(results(paginatedAll)).toEqual(results([full.data]))
122
121
  })
123
122
  })
@@ -0,0 +1,222 @@
1
+ import { TestNetwork } from '@atproto/dev-env'
2
+ import { readFromGenerator, wait } from '@atproto/common'
3
+ import { LabelsEvt, Sequencer } from '../src/sequencer'
4
+ import Outbox from '../src/sequencer/outbox'
5
+ import { randomStr } from '@atproto/crypto'
6
+ import { Label } from '../src/lexicon/types/com/atproto/label/defs'
7
+
8
+ describe('sequencer', () => {
9
+ let network: TestNetwork
10
+ let sequencer: Sequencer
11
+
12
+ let totalEvts = 0
13
+ let lastSeen: number
14
+
15
+ beforeAll(async () => {
16
+ network = await TestNetwork.create({
17
+ dbPostgresSchema: 'ozone_sequencer',
18
+ })
19
+ sequencer = network.ozone.ctx.sequencer
20
+ })
21
+
22
+ afterAll(async () => {
23
+ await network.close()
24
+ })
25
+
26
+ const loadFromDb = (lastSeen: number) => {
27
+ return sequencer.db.db
28
+ .selectFrom('label')
29
+ .selectAll()
30
+ .where('id', '>', lastSeen)
31
+ .orderBy('id', 'asc')
32
+ .execute()
33
+ }
34
+
35
+ const evtToDbRow = (e: LabelsEvt) => {
36
+ const label = e.labels[0]
37
+ return {
38
+ id: e.seq,
39
+ ...label,
40
+ cid: label.cid ? label.cid : '',
41
+ }
42
+ }
43
+
44
+ const caughtUp = (outbox: Outbox): (() => Promise<boolean>) => {
45
+ return async () => {
46
+ const lastEvt = await outbox.sequencer.curr()
47
+ if (lastEvt === null) return true
48
+ return outbox.lastSeen >= (lastEvt ?? 0)
49
+ }
50
+ }
51
+
52
+ const createLabels = async (count: number): Promise<Label[]> => {
53
+ const labels: Label[] = []
54
+ for (let i = 0; i < count; i++) {
55
+ const did = `did:example:${randomStr(10, 'base32')}`
56
+ const label = {
57
+ src: 'did:example:labeler',
58
+ uri: did,
59
+ val: 'spam',
60
+ neg: false,
61
+ cts: new Date().toISOString(),
62
+ }
63
+ await network.ozone.ctx.db.transaction((dbTxn) =>
64
+ network.ozone.ctx.modService(dbTxn).createLabels([label]),
65
+ )
66
+ labels.push(label)
67
+ }
68
+ return labels
69
+ }
70
+
71
+ it('sends to outbox', async () => {
72
+ const count = 20
73
+ totalEvts += count
74
+ await createLabels(count)
75
+ const outbox = new Outbox(sequencer)
76
+ const evts = await readFromGenerator(outbox.events(-1), caughtUp(outbox))
77
+ expect(evts.length).toBe(totalEvts)
78
+
79
+ const fromDb = await loadFromDb(-1)
80
+ expect(evts.map(evtToDbRow)).toEqual(fromDb)
81
+
82
+ lastSeen = evts.at(-1)?.seq ?? lastSeen
83
+ })
84
+
85
+ it('sequences negative labels', async () => {
86
+ const count = 5
87
+ totalEvts += count
88
+ const created = await createLabels(count)
89
+ const toNegate = created
90
+ .slice(0, 2)
91
+ .map((l) => ({ ...l, neg: true, cts: new Date().toISOString() }))
92
+ await network.ozone.ctx
93
+ .modService(network.ozone.ctx.db)
94
+ .createLabels(toNegate)
95
+
96
+ const outbox = new Outbox(sequencer)
97
+ const evts = await readFromGenerator(
98
+ outbox.events(lastSeen),
99
+ caughtUp(outbox),
100
+ )
101
+ expect(evts.length).toBe(count)
102
+
103
+ const fromDb = await loadFromDb(lastSeen)
104
+ expect(evts.map(evtToDbRow)).toEqual(fromDb)
105
+ expect(evts[3].labels[0].uri).toEqual(toNegate[0].uri)
106
+ expect(evts[3].labels[0].neg).toBe(true)
107
+ expect(evts[4].labels[0].uri).toEqual(toNegate[1].uri)
108
+ expect(evts[4].labels[0].neg).toBe(true)
109
+
110
+ lastSeen = evts.at(-1)?.seq ?? lastSeen
111
+ })
112
+
113
+ it('handles cut over', async () => {
114
+ const count = 20
115
+ totalEvts += count
116
+ const outbox = new Outbox(sequencer)
117
+ const createPromise = createLabels(count)
118
+ const [evts] = await Promise.all([
119
+ readFromGenerator(outbox.events(-1), caughtUp(outbox), createPromise),
120
+ createPromise,
121
+ ])
122
+ expect(evts.length).toBe(totalEvts)
123
+
124
+ const fromDb = await loadFromDb(-1)
125
+ expect(evts.map(evtToDbRow)).toEqual(fromDb)
126
+
127
+ lastSeen = evts.at(-1)?.seq ?? lastSeen
128
+ })
129
+
130
+ it('only gets events after cursor', async () => {
131
+ const count = 20
132
+ totalEvts += count
133
+ const outbox = new Outbox(sequencer)
134
+ const createPromise = createLabels(count)
135
+ const [evts] = await Promise.all([
136
+ readFromGenerator(
137
+ outbox.events(lastSeen),
138
+ caughtUp(outbox),
139
+ createPromise,
140
+ ),
141
+ createPromise,
142
+ ])
143
+
144
+ // +1 because we send the lastSeen date as well
145
+ expect(evts.length).toBe(count)
146
+
147
+ const fromDb = await loadFromDb(lastSeen)
148
+ expect(evts.map(evtToDbRow)).toEqual(fromDb)
149
+
150
+ lastSeen = evts.at(-1)?.seq ?? lastSeen
151
+ })
152
+
153
+ it('buffers events that are not being read', async () => {
154
+ const count = 20
155
+ totalEvts += count
156
+ const outbox = new Outbox(sequencer)
157
+ const createPromise = createLabels(count)
158
+ const gen = outbox.events(lastSeen)
159
+ // read enough to start streaming then wait so that the rest go into the buffer,
160
+ // then stream out from buffer
161
+ const [firstPart] = await Promise.all([
162
+ readFromGenerator(gen, caughtUp(outbox), createPromise, 5),
163
+ createPromise,
164
+ ])
165
+ const secondPart = await readFromGenerator(
166
+ gen,
167
+ caughtUp(outbox),
168
+ createPromise,
169
+ )
170
+ const evts = [...firstPart, ...secondPart]
171
+ expect(evts.length).toBe(count)
172
+
173
+ const fromDb = await loadFromDb(lastSeen)
174
+ expect(evts.map(evtToDbRow)).toEqual(fromDb)
175
+
176
+ lastSeen = evts.at(-1)?.seq ?? lastSeen
177
+ })
178
+
179
+ it('errors when buffer is overloaded', async () => {
180
+ const count = 20
181
+ totalEvts += count
182
+ const outbox = new Outbox(sequencer, { maxBufferSize: 5 })
183
+ const gen = outbox.events(lastSeen)
184
+ const createPromise = createLabels(count)
185
+ // read enough to start streaming then wait to stream rest until buffer is overloaded
186
+ const overloadBuffer = async () => {
187
+ await Promise.all([
188
+ readFromGenerator(gen, caughtUp(outbox), createPromise, 5),
189
+ createPromise,
190
+ ])
191
+ await wait(500)
192
+ await readFromGenerator(gen, caughtUp(outbox), createPromise)
193
+ }
194
+ await expect(overloadBuffer).rejects.toThrow('Stream consumer too slow')
195
+
196
+ await createPromise
197
+
198
+ const fromDb = await loadFromDb(lastSeen)
199
+ lastSeen = fromDb.at(-1)?.id ?? lastSeen
200
+ })
201
+
202
+ it('handles many open connections', async () => {
203
+ const count = 20
204
+ const outboxes: Outbox[] = []
205
+ for (let i = 0; i < 50; i++) {
206
+ outboxes.push(new Outbox(sequencer))
207
+ }
208
+ const createPromise = createLabels(count)
209
+ const readOutboxes = Promise.all(
210
+ outboxes.map((o) =>
211
+ readFromGenerator(o.events(lastSeen), caughtUp(o), createPromise),
212
+ ),
213
+ )
214
+ const [results] = await Promise.all([readOutboxes, createPromise])
215
+ const fromDb = await loadFromDb(lastSeen)
216
+ for (const result of results) {
217
+ expect(result.length).toBe(count)
218
+ expect(result.map(evtToDbRow)).toEqual(fromDb)
219
+ }
220
+ lastSeen = results[0].at(-1)?.seq ?? lastSeen
221
+ })
222
+ })
@@ -55,6 +55,8 @@ describe('server', () => {
55
55
  })
56
56
 
57
57
  it('healthcheck fails when database is unavailable.', async () => {
58
+ // destory sequencer to release connection that would prevent the db from closing
59
+ await ozone.ctx.sequencer.destroy()
58
60
  await ozone.ctx.db.close()
59
61
  let error: AxiosError
60
62
  try {