@atproto/bsky 0.0.39 → 0.0.40

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 +7 -0
  2. package/dist/hydration/hydrator.d.ts +12 -2
  3. package/dist/hydration/label.d.ts +7 -3
  4. package/dist/hydration/util.d.ts +5 -2
  5. package/dist/index.js +187 -103
  6. package/dist/index.js.map +2 -2
  7. package/package.json +5 -5
  8. package/src/api/app/bsky/actor/getProfile.ts +10 -6
  9. package/src/api/app/bsky/actor/getProfiles.ts +2 -2
  10. package/src/api/app/bsky/actor/getSuggestions.ts +2 -2
  11. package/src/api/app/bsky/actor/searchActors.ts +7 -3
  12. package/src/api/app/bsky/actor/searchActorsTypeahead.ts +2 -2
  13. package/src/api/app/bsky/feed/getActorFeeds.ts +2 -2
  14. package/src/api/app/bsky/feed/getActorLikes.ts +2 -2
  15. package/src/api/app/bsky/feed/getAuthorFeed.ts +6 -2
  16. package/src/api/app/bsky/feed/getFeed.ts +2 -2
  17. package/src/api/app/bsky/feed/getFeedGenerator.ts +3 -6
  18. package/src/api/app/bsky/feed/getFeedGenerators.ts +2 -2
  19. package/src/api/app/bsky/feed/getLikes.ts +2 -2
  20. package/src/api/app/bsky/feed/getListFeed.ts +2 -2
  21. package/src/api/app/bsky/feed/getPostThread.ts +2 -2
  22. package/src/api/app/bsky/feed/getPosts.ts +2 -2
  23. package/src/api/app/bsky/feed/getRepostedBy.ts +2 -2
  24. package/src/api/app/bsky/feed/getSuggestedFeeds.ts +3 -5
  25. package/src/api/app/bsky/feed/getTimeline.ts +6 -3
  26. package/src/api/app/bsky/feed/searchPosts.ts +2 -2
  27. package/src/api/app/bsky/graph/getBlocks.ts +6 -3
  28. package/src/api/app/bsky/graph/getFollowers.ts +6 -2
  29. package/src/api/app/bsky/graph/getFollows.ts +6 -2
  30. package/src/api/app/bsky/graph/getList.ts +2 -2
  31. package/src/api/app/bsky/graph/getListBlocks.ts +6 -3
  32. package/src/api/app/bsky/graph/getListMutes.ts +6 -3
  33. package/src/api/app/bsky/graph/getLists.ts +2 -2
  34. package/src/api/app/bsky/graph/getMutes.ts +6 -3
  35. package/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +3 -3
  36. package/src/api/app/bsky/labeler/getServices.ts +3 -3
  37. package/src/api/app/bsky/notification/listNotifications.ts +6 -3
  38. package/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +2 -2
  39. package/src/data-plane/server/routes/labels.ts +24 -8
  40. package/src/hydration/hydrator.ts +55 -10
  41. package/src/hydration/label.ts +33 -7
  42. package/src/hydration/util.ts +6 -2
  43. package/src/index.ts +1 -1
  44. package/src/views/index.ts +7 -7
  45. package/tests/label-hydration.test.ts +31 -0
  46. package/tests/views/labeler-service.test.ts +2 -7
  47. package/tests/views/takedown-labels.test.ts +52 -0
@@ -3,28 +3,44 @@ import { noUndefinedVals } from '@atproto/common'
3
3
  import { ServiceImpl } from '@connectrpc/connect'
4
4
  import { Service } from '../../../proto/bsky_connect'
5
5
  import { Database } from '../db'
6
+ import { Selectable } from 'kysely'
7
+ import { Label } from '../db/tables/label'
8
+
9
+ type LabelRow = Selectable<Label>
6
10
 
7
11
  export default (db: Database): Partial<ServiceImpl<typeof Service>> => ({
8
12
  async getLabels(req) {
9
13
  const { subjects, issuers } = req
10
14
  if (subjects.length === 0 || issuers.length === 0) {
11
- return { records: [] }
15
+ return { labels: [] }
12
16
  }
13
- const res = await db.db
17
+ const res: LabelRow[] = await db.db
14
18
  .selectFrom('label')
15
19
  .where('uri', 'in', subjects)
16
20
  .where('src', 'in', issuers)
17
21
  .selectAll()
18
22
  .execute()
19
23
 
20
- const labels = res.map((l) => {
21
- const formatted = noUndefinedVals({
22
- ...l,
23
- cid: l.cid === '' ? undefined : l.cid,
24
- neg: l.neg === true ? true : undefined,
24
+ const labelsBySubject = new Map<string, LabelRow[]>()
25
+ res.forEach((l) => {
26
+ const labels = labelsBySubject.get(l.uri) ?? []
27
+ labels.push(l)
28
+ labelsBySubject.set(l.uri, labels)
29
+ })
30
+
31
+ // intentionally duplicate label results, appview frontend should be defensive to this
32
+ const labels = subjects.flatMap((sub) => {
33
+ const labelsForSub = labelsBySubject.get(sub) ?? []
34
+ return labelsForSub.map((l) => {
35
+ const formatted = noUndefinedVals({
36
+ ...l,
37
+ cid: l.cid === '' ? undefined : l.cid,
38
+ neg: l.neg === true ? true : undefined,
39
+ })
40
+ return ui8.fromString(JSON.stringify(formatted), 'utf8')
25
41
  })
26
- return ui8.fromString(JSON.stringify(formatted), 'utf8')
27
42
  })
43
+
28
44
  return { labels }
29
45
  },
30
46
  })
@@ -29,7 +29,13 @@ import {
29
29
  Labelers,
30
30
  Labels,
31
31
  } from './label'
32
- import { HydrationMap, RecordInfo, didFromUri, urisByCollection } from './util'
32
+ import {
33
+ HydrationMap,
34
+ Merges,
35
+ RecordInfo,
36
+ didFromUri,
37
+ urisByCollection,
38
+ } from './util'
33
39
  import {
34
40
  FeedGenAggs,
35
41
  FeedGens,
@@ -47,7 +53,17 @@ import {
47
53
  } from './feed'
48
54
  import { ParsedLabelers } from '../util'
49
55
 
50
- export type HydrateCtx = {
56
+ export class HydrateCtx {
57
+ labelers = this.vals.labelers
58
+ viewer = this.vals.viewer
59
+ includeTakedowns = this.vals.includeTakedowns
60
+ constructor(private vals: HydrateCtxVals) {}
61
+ copy<V extends Partial<HydrateCtxVals>>(vals?: V): HydrateCtx & V {
62
+ return new HydrateCtx({ ...this.vals, ...vals }) as HydrateCtx & V
63
+ }
64
+ }
65
+
66
+ export type HydrateCtxVals = {
51
67
  labelers: ParsedLabelers
52
68
  viewer: string | null
53
69
  includeTakedowns?: boolean
@@ -91,12 +107,17 @@ export class Hydrator {
91
107
  feed: FeedHydrator
92
108
  graph: GraphHydrator
93
109
  label: LabelHydrator
110
+ serviceLabelers: Set<string>
94
111
 
95
- constructor(public dataplane: DataPlaneClient) {
112
+ constructor(
113
+ public dataplane: DataPlaneClient,
114
+ serviceLabelers: string[] = [],
115
+ ) {
96
116
  this.actor = new ActorHydrator(dataplane)
97
117
  this.feed = new FeedHydrator(dataplane)
98
118
  this.graph = new GraphHydrator(dataplane)
99
119
  this.label = new LabelHydrator(dataplane)
120
+ this.serviceLabelers = new Set(serviceLabelers)
100
121
  }
101
122
 
102
123
  // app.bsky.actor.defs#profileView
@@ -549,13 +570,14 @@ export class Hydrator {
549
570
  ): Promise<HydrationState> {
550
571
  const [labelers, labelerAggs, labelerViewers, profileState] =
551
572
  await Promise.all([
552
- this.label.getLabelers(dids),
573
+ this.label.getLabelers(dids, ctx.includeTakedowns),
553
574
  this.label.getLabelerAggregates(dids),
554
575
  ctx.viewer
555
576
  ? this.label.getLabelerViewerStates(dids, ctx.viewer)
556
577
  : undefined,
557
- this.hydrateProfiles(dids.map(didFromUri), ctx),
578
+ this.hydrateProfiles(dids, ctx),
558
579
  ])
580
+ actionTakedownLabels(dids, labelers, profileState.labels ?? new Labels())
559
581
  return mergeStates(profileState, {
560
582
  labelers,
561
583
  labelerAggs,
@@ -613,8 +635,10 @@ export class Hydrator {
613
635
  undefined
614
636
  )
615
637
  } else if (collection === ids.AppBskyLabelerService) {
638
+ if (parsed.rkey !== 'self') return
639
+ const did = parsed.hostname
616
640
  return (
617
- (await this.label.getLabelers([uri], includeTakedowns)).get(uri) ??
641
+ (await this.label.getLabelers([did], includeTakedowns)).get(did) ??
618
642
  undefined
619
643
  )
620
644
  } else if (collection === ids.AppBskyActorProfile) {
@@ -631,6 +655,30 @@ export class Hydrator {
631
655
  }
632
656
  }
633
657
  }
658
+
659
+ async createContext(vals: HydrateCtxVals) {
660
+ // ensures we're only apply labelers that exist and are not taken down
661
+ const labelers = vals.labelers.dids
662
+ const nonServiceLabelers = labelers.filter(
663
+ (did) => !this.serviceLabelers.has(did),
664
+ )
665
+ const labelerActors = await this.actor.getActors(
666
+ nonServiceLabelers,
667
+ vals.includeTakedowns,
668
+ )
669
+ const availableDids = labelers.filter(
670
+ (did) => this.serviceLabelers.has(did) || !!labelerActors.get(did),
671
+ )
672
+ const availableLabelers = {
673
+ dids: availableDids,
674
+ redact: vals.labelers.redact,
675
+ }
676
+ return new HydrateCtx({
677
+ labelers: availableLabelers,
678
+ viewer: vals.viewer,
679
+ includeTakedowns: vals.includeTakedowns,
680
+ })
681
+ }
634
682
  }
635
683
 
636
684
  const listUrisFromProfileViewer = (item: ProfileViewerState | null) => {
@@ -770,10 +818,7 @@ export const mergeStates = (
770
818
  }
771
819
  }
772
820
 
773
- const mergeMaps = <T>(
774
- mapA?: HydrationMap<T>,
775
- mapB?: HydrationMap<T>,
776
- ): HydrationMap<T> | undefined => {
821
+ const mergeMaps = <M extends Merges>(mapA?: M, mapB?: M): M | undefined => {
777
822
  if (!mapA) return mapB
778
823
  if (!mapB) return mapA
779
824
  return mapA.merge(mapB)
@@ -3,6 +3,7 @@ import { Label } from '../lexicon/types/com/atproto/label/defs'
3
3
  import { Record as LabelerRecord } from '../lexicon/types/app/bsky/labeler/service'
4
4
  import {
5
5
  HydrationMap,
6
+ Merges,
6
7
  RecordInfo,
7
8
  parseJsonBytes,
8
9
  parseRecord,
@@ -16,10 +17,36 @@ export type { Label } from '../lexicon/types/com/atproto/label/defs'
16
17
 
17
18
  export type SubjectLabels = {
18
19
  isTakendown: boolean
19
- labels: Label[]
20
+ labels: HydrationMap<Label> // src + val -> label
20
21
  }
21
22
 
22
- export type Labels = HydrationMap<SubjectLabels>
23
+ export class Labels extends HydrationMap<SubjectLabels> implements Merges {
24
+ static key(label: Label) {
25
+ return `${label.src}::${label.val}`
26
+ }
27
+ merge(map: Labels): this {
28
+ map.forEach((theirs, key) => {
29
+ if (!theirs) return
30
+ const mine = this.get(key)
31
+ if (mine) {
32
+ mine.isTakendown = mine.isTakendown || theirs.isTakendown
33
+ mine.labels = mine.labels.merge(theirs.labels)
34
+ } else {
35
+ this.set(key, theirs)
36
+ }
37
+ })
38
+ return this
39
+ }
40
+ getBySubject(sub: string): Label[] {
41
+ const it = this.get(sub)?.labels.values()
42
+ if (!it) return []
43
+ const labels: Label[] = []
44
+ for (const label of it) {
45
+ if (label) labels.push(label)
46
+ }
47
+ return labels
48
+ }
49
+ }
23
50
 
24
51
  export type LabelerAgg = {
25
52
  likes: number
@@ -43,8 +70,7 @@ export class LabelHydrator {
43
70
  subjects: string[],
44
71
  labelers: ParsedLabelers,
45
72
  ): Promise<Labels> {
46
- if (!subjects.length || !labelers.dids.length)
47
- return new HydrationMap<SubjectLabels>()
73
+ if (!subjects.length || !labelers.dids.length) return new Labels()
48
74
  const res = await this.dataplane.getLabels({
49
75
  subjects,
50
76
  issuers: labelers.dids,
@@ -57,11 +83,11 @@ export class LabelHydrator {
57
83
  if (!entry) {
58
84
  entry = {
59
85
  isTakendown: false,
60
- labels: [],
86
+ labels: new HydrationMap(),
61
87
  }
62
88
  acc.set(label.uri, entry)
63
89
  }
64
- entry.labels.push(label)
90
+ entry.labels.set(Labels.key(label), label)
65
91
  if (
66
92
  TAKEDOWN_LABELS.includes(label.val) &&
67
93
  !label.neg &&
@@ -70,7 +96,7 @@ export class LabelHydrator {
70
96
  entry.isTakendown = true
71
97
  }
72
98
  return acc
73
- }, new HydrationMap<SubjectLabels>())
99
+ }, new Labels())
74
100
  }
75
101
 
76
102
  async getLabelers(
@@ -5,8 +5,8 @@ import * as ui8 from 'uint8arrays'
5
5
  import { lexicons } from '../lexicon/lexicons'
6
6
  import { Record } from '../proto/bsky_pb'
7
7
 
8
- export class HydrationMap<T> extends Map<string, T | null> {
9
- merge(map: HydrationMap<T>): HydrationMap<T> {
8
+ export class HydrationMap<T> extends Map<string, T | null> implements Merges {
9
+ merge(map: HydrationMap<T>): this {
10
10
  map.forEach((val, key) => {
11
11
  this.set(key, val)
12
12
  })
@@ -14,6 +14,10 @@ export class HydrationMap<T> extends Map<string, T | null> {
14
14
  }
15
15
  }
16
16
 
17
+ export interface Merges {
18
+ merge<T extends this>(map: T): this
19
+ }
20
+
17
21
  export type RecordInfo<T> = {
18
22
  record: T
19
23
  cid: string
package/src/index.ts CHANGED
@@ -77,7 +77,7 @@ export class BskyAppView {
77
77
  httpVersion: config.dataplaneHttpVersion,
78
78
  rejectUnauthorized: !config.dataplaneIgnoreBadTls,
79
79
  })
80
- const hydrator = new Hydrator(dataplane)
80
+ const hydrator = new Hydrator(dataplane, config.labelsFromIssuerDids)
81
81
  const views = new Views(imgUriBuilder)
82
82
 
83
83
  const bsyncClient = createBsyncClient({
@@ -136,8 +136,8 @@ export class Views {
136
136
  'self',
137
137
  ).toString()
138
138
  const labels = [
139
- ...(state.labels?.get(did)?.labels ?? []),
140
- ...(state.labels?.get(profileUri)?.labels ?? []),
139
+ ...(state.labels?.getBySubject(did) ?? []),
140
+ ...(state.labels?.getBySubject(profileUri) ?? []),
141
141
  ...this.selfLabels({
142
142
  uri: profileUri,
143
143
  cid: actor.profileCid?.toString(),
@@ -225,7 +225,7 @@ export class Views {
225
225
  return undefined
226
226
  }
227
227
  const listViewer = state.listViewers?.get(uri)
228
- const labels = state.labels?.get(uri)?.labels ?? []
228
+ const labels = state.labels?.getBySubject(uri) ?? []
229
229
  const creator = new AtUri(uri).hostname
230
230
  return {
231
231
  uri,
@@ -281,7 +281,7 @@ export class Views {
281
281
 
282
282
  const uri = AtUri.make(did, ids.AppBskyLabelerService, 'self').toString()
283
283
  const labels = [
284
- ...(state.labels?.get(uri)?.labels ?? []),
284
+ ...(state.labels?.getBySubject(uri) ?? []),
285
285
  ...this.selfLabels({
286
286
  uri,
287
287
  cid: labeler.cid.toString(),
@@ -351,7 +351,7 @@ export class Views {
351
351
  if (!creator) return
352
352
  const viewer = state.feedgenViewers?.get(uri)
353
353
  const aggs = state.feedgenAggs?.get(uri)
354
- const labels = state.labels?.get(uri)?.labels ?? []
354
+ const labels = state.labels?.getBySubject(uri) ?? []
355
355
 
356
356
  return {
357
357
  uri,
@@ -408,7 +408,7 @@ export class Views {
408
408
  parsedUri.rkey,
409
409
  ).toString()
410
410
  const labels = [
411
- ...(state.labels?.get(uri)?.labels ?? []),
411
+ ...(state.labels?.getBySubject(uri) ?? []),
412
412
  ...this.selfLabels({
413
413
  uri,
414
414
  cid: post.cid,
@@ -886,7 +886,7 @@ export class Views {
886
886
  recordInfo = state.follows?.get(notif.uri)
887
887
  }
888
888
  if (!recordInfo) return
889
- const labels = state.labels?.get(notif.uri)?.labels ?? []
889
+ const labels = state.labels?.getBySubject(notif.uri) ?? []
890
890
  const selfLabels = this.selfLabels({
891
891
  uri: notif.uri,
892
892
  cid: recordInfo.cid,
@@ -4,6 +4,7 @@ import axios from 'axios'
4
4
 
5
5
  describe('label hydration', () => {
6
6
  let network: TestNetwork
7
+ let agent: AtpAgent
7
8
  let pdsAgent: AtpAgent
8
9
  let sc: SeedClient
9
10
 
@@ -16,6 +17,7 @@ describe('label hydration', () => {
16
17
  network = await TestNetwork.create({
17
18
  dbPostgresSchema: 'bsky_label_hydration',
18
19
  })
20
+ agent = network.bsky.getClient()
19
21
  pdsAgent = network.pds.getClient()
20
22
  sc = network.getSeedClient()
21
23
  await basicSeed(sc)
@@ -93,6 +95,35 @@ describe('label hydration', () => {
93
95
  )
94
96
  })
95
97
 
98
+ it('hydrates labels without duplication', async () => {
99
+ AtpAgent.configure({ appLabelers: [alice] })
100
+ pdsAgent.configureLabelersHeader([])
101
+ const res = await pdsAgent.api.app.bsky.actor.getProfiles(
102
+ { actors: [carol, carol] },
103
+ { headers: sc.getHeaders(bob) },
104
+ )
105
+ const { labels = [] } = res.data.profiles[0]
106
+ expect(labels.map((l) => ({ val: l.val, src: l.src }))).toEqual([
107
+ { src: alice, val: 'spam' },
108
+ ])
109
+ })
110
+
111
+ it('does not hydrate labels from takendown labeler', async () => {
112
+ AtpAgent.configure({ appLabelers: [alice, sc.dids.dan] })
113
+ pdsAgent.configureLabelersHeader([])
114
+ await network.bsky.ctx.dataplane.takedownActor({ did: alice })
115
+ const res = await pdsAgent.api.app.bsky.actor.getProfile(
116
+ { actor: carol },
117
+ { headers: sc.getHeaders(bob) },
118
+ )
119
+ const { labels = [] } = res.data
120
+ expect(labels).toEqual([])
121
+ expect(res.headers['atproto-content-labelers']).toEqual(
122
+ `${sc.dids.dan};redact`, // does not include alice
123
+ )
124
+ await network.bsky.ctx.dataplane.untakedownActor({ did: alice })
125
+ })
126
+
96
127
  it('hydrates labels onto list views.', async () => {
97
128
  AtpAgent.configure({ appLabelers: [labelerDid] })
98
129
  pdsAgent.configureLabelersHeader([])
@@ -136,10 +136,7 @@ describe('labeler service views', () => {
136
136
  })
137
137
 
138
138
  it('blocked by labeler takedown', async () => {
139
- await network.bsky.ctx.dataplane.takedownRecord({
140
- recordUri: aliceService.uriStr,
141
- })
142
-
139
+ await network.bsky.ctx.dataplane.takedownActor({ did: alice })
143
140
  const res = await agent.api.app.bsky.labeler.getServices(
144
141
  { dids: [alice, bob] },
145
142
  { headers: await network.serviceHeaders(bob) },
@@ -149,8 +146,6 @@ describe('labeler service views', () => {
149
146
  expect(res.data.views[0].creator.did).toEqual(bob)
150
147
 
151
148
  // Cleanup
152
- await network.bsky.ctx.dataplane.untakedownRecord({
153
- recordUri: aliceService.uriStr,
154
- })
149
+ await network.bsky.ctx.dataplane.untakedownActor({ did: alice })
155
150
  })
156
151
  })
@@ -1,5 +1,6 @@
1
1
  import AtpAgent from '@atproto/api'
2
2
  import { TestNetwork, SeedClient, basicSeed, RecordRef } from '@atproto/dev-env'
3
+ import { ids } from '../../src/lexicon/lexicons'
3
4
 
4
5
  describe('bsky takedown labels', () => {
5
6
  let network: TestNetwork
@@ -40,6 +41,48 @@ describe('bsky takedown labels', () => {
40
41
  'carol generator',
41
42
  )
42
43
 
44
+ // labelers
45
+ await sc.createAccount('labeler1', {
46
+ email: 'lab1@test.com',
47
+ handle: 'lab1.test',
48
+ password: 'lab1',
49
+ })
50
+ await sc.agent.api.com.atproto.repo.createRecord(
51
+ {
52
+ repo: sc.dids.labeler1,
53
+ collection: ids.AppBskyLabelerService,
54
+ rkey: 'self',
55
+ record: {
56
+ policies: { labelValues: ['spam'] },
57
+ createdAt: new Date().toISOString(),
58
+ },
59
+ },
60
+ {
61
+ headers: sc.getHeaders(sc.dids.labeler1),
62
+ encoding: 'application/json',
63
+ },
64
+ )
65
+ await sc.createAccount('labeler2', {
66
+ email: 'lab2@test.com',
67
+ handle: 'lab2.test',
68
+ password: 'lab2',
69
+ })
70
+ await sc.agent.api.com.atproto.repo.createRecord(
71
+ {
72
+ repo: sc.dids.labeler2,
73
+ collection: ids.AppBskyLabelerService,
74
+ rkey: 'self',
75
+ record: {
76
+ policies: { labelValues: ['spam'] },
77
+ createdAt: new Date().toISOString(),
78
+ },
79
+ },
80
+ {
81
+ headers: sc.getHeaders(sc.dids.labeler2),
82
+ encoding: 'application/json',
83
+ },
84
+ )
85
+
43
86
  await network.processAll()
44
87
 
45
88
  takendownSubjects = [
@@ -47,6 +90,7 @@ describe('bsky takedown labels', () => {
47
90
  sc.dids.carol,
48
91
  aliceListRef.uriStr,
49
92
  aliceGenRef.uriStr,
93
+ sc.dids.labeler1,
50
94
  ]
51
95
  const src = network.ozone.ctx.cfg.service.did
52
96
  const cts = new Date().toISOString()
@@ -123,6 +167,14 @@ describe('bsky takedown labels', () => {
123
167
  expect(res.data.feeds.at(0)?.uri).toEqual(bobGenRef.uriStr)
124
168
  })
125
169
 
170
+ it('takesdown labelers', async () => {
171
+ const res = await agent.api.app.bsky.labeler.getServices({
172
+ dids: [sc.dids.labeler1, sc.dids.labeler2],
173
+ })
174
+ expect(res.data.views.length).toBe(1)
175
+ expect(res.data.views[0].creator?.['did']).toBe(sc.dids.labeler2)
176
+ })
177
+
126
178
  it('only applies if the relevant labeler is configured', async () => {
127
179
  AtpAgent.configure({ appLabelers: ['did:web:example.com'] })
128
180
  const res = await agent.api.app.bsky.actor.getProfile({