@atproto/bsky 0.0.96 → 0.0.97

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/bsky",
3
- "version": "0.0.96",
3
+ "version": "0.0.97",
4
4
  "license": "MIT",
5
5
  "description": "Reference implementation of app.bsky App View (Bluesky API)",
6
6
  "keywords": [
@@ -42,10 +42,10 @@
42
42
  "structured-headers": "^1.0.1",
43
43
  "typed-emitter": "^2.1.0",
44
44
  "uint8arrays": "3.0.0",
45
- "@atproto/api": "^0.13.18",
46
- "@atproto/common": "^0.4.4",
45
+ "@atproto/api": "^0.13.19",
47
46
  "@atproto/crypto": "^0.4.2",
48
47
  "@atproto/identity": "^0.4.3",
48
+ "@atproto/common": "^0.4.4",
49
49
  "@atproto/lexicon": "^0.4.3",
50
50
  "@atproto/repo": "^0.5.5",
51
51
  "@atproto/sync": "^0.1.6",
@@ -66,9 +66,9 @@
66
66
  "jest": "^28.1.2",
67
67
  "ts-node": "^10.8.2",
68
68
  "typescript": "^5.6.3",
69
- "@atproto/api": "^0.13.18",
69
+ "@atproto/api": "^0.13.19",
70
+ "@atproto/pds": "^0.4.75",
70
71
  "@atproto/lex-cli": "^0.5.2",
71
- "@atproto/pds": "^0.4.73",
72
72
  "@atproto/xrpc": "^0.6.4"
73
73
  },
74
74
  "scripts": {
@@ -60,7 +60,9 @@ const hydration = async (input: {
60
60
  const { ctx, params, skeleton } = input
61
61
  return ctx.hydrator.hydrateProfilesDetailed(
62
62
  [skeleton.did],
63
- params.hydrateCtx.copy({ includeTakedowns: true }),
63
+ params.hydrateCtx.copy({
64
+ includeActorTakedowns: true,
65
+ }),
64
66
  )
65
67
  }
66
68
 
@@ -3,11 +3,12 @@ import { normalizeDatetimeAlways } from '@atproto/syntax'
3
3
  import { Server } from '../../../../lexicon'
4
4
  import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getLikes'
5
5
  import AppContext from '../../../../context'
6
- import { createPipeline } from '../../../../pipeline'
6
+ import { createPipeline, RulesFnInput } from '../../../../pipeline'
7
7
  import {
8
8
  HydrateCtx,
9
9
  HydrationState,
10
10
  Hydrator,
11
+ mergeStates,
11
12
  } from '../../../../hydration/hydrator'
12
13
  import { Views } from '../../../../views'
13
14
  import { parseString } from '../../../../hydration/util'
@@ -43,8 +44,10 @@ const skeleton = async (inputs: {
43
44
  params: Params
44
45
  }): Promise<Skeleton> => {
45
46
  const { ctx, params } = inputs
47
+ const authorDid = creatorFromUri(params.uri)
48
+
46
49
  if (clearlyBadCursor(params.cursor)) {
47
- return { likes: [] }
50
+ return { authorDid, likes: [] }
48
51
  }
49
52
  if (looksLikeNonSortedCursor(params.cursor)) {
50
53
  throw new InvalidRequestError(
@@ -57,6 +60,7 @@ const skeleton = async (inputs: {
57
60
  limit: params.limit,
58
61
  })
59
62
  return {
63
+ authorDid,
60
64
  likes: likesRes.uris,
61
65
  cursor: parseString(likesRes.cursor),
62
66
  }
@@ -68,18 +72,25 @@ const hydration = async (inputs: {
68
72
  skeleton: Skeleton
69
73
  }) => {
70
74
  const { ctx, params, skeleton } = inputs
71
- return await ctx.hydrator.hydrateLikes(skeleton.likes, params.hydrateCtx)
75
+ const likesState = await ctx.hydrator.hydrateLikes(
76
+ skeleton.authorDid,
77
+ skeleton.likes,
78
+ params.hydrateCtx,
79
+ )
80
+ return likesState
72
81
  }
73
82
 
74
- const noBlocks = (inputs: {
75
- ctx: Context
76
- skeleton: Skeleton
77
- hydration: HydrationState
78
- }) => {
79
- const { ctx, skeleton, hydration } = inputs
80
- skeleton.likes = skeleton.likes.filter((uri) => {
81
- const creator = creatorFromUri(uri)
82
- return !ctx.views.viewerBlockExists(creator, hydration)
83
+ const noBlocks = (input: RulesFnInput<Context, Params, Skeleton>) => {
84
+ const { ctx, skeleton, hydration } = input
85
+
86
+ skeleton.likes = skeleton.likes.filter((likeUri) => {
87
+ const like = hydration.likes?.get(likeUri)
88
+ if (!like) return false
89
+ const likerDid = creatorFromUri(likeUri)
90
+ return (
91
+ !hydration.likeBlocks?.get(likeUri) &&
92
+ !ctx.views.viewerBlockExists(likerDid, hydration)
93
+ )
83
94
  })
84
95
  return skeleton
85
96
  }
@@ -123,6 +134,7 @@ type Context = {
123
134
  type Params = QueryParams & { hydrateCtx: HydrateCtx }
124
135
 
125
136
  type Skeleton = {
137
+ authorDid: string
126
138
  likes: string[]
127
139
  cursor?: string
128
140
  }
@@ -18,14 +18,7 @@ import {
18
18
  unpackIdentityKeys,
19
19
  } from './data-plane'
20
20
  import { GetIdentityByDidResponse } from './proto/bsky_pb'
21
- import {
22
- extractMultikey,
23
- extractPrefixedBytes,
24
- hasPrefix,
25
- parseDidKey,
26
- SECP256K1_DID_PREFIX,
27
- SECP256K1_JWT_ALG,
28
- } from '@atproto/crypto'
21
+ import { parseDidKey, SECP256K1_JWT_ALG } from '@atproto/crypto'
29
22
 
30
23
  type ReqCtx = {
31
24
  req: express.Request
@@ -65,6 +65,7 @@ export class HydrateCtx {
65
65
  labelers = this.vals.labelers
66
66
  viewer = this.vals.viewer !== null ? serviceRefToDid(this.vals.viewer) : null
67
67
  includeTakedowns = this.vals.includeTakedowns
68
+ includeActorTakedowns = this.vals.includeActorTakedowns
68
69
  include3pBlocks = this.vals.include3pBlocks
69
70
  constructor(private vals: HydrateCtxVals) {}
70
71
  copy<V extends Partial<HydrateCtxVals>>(vals?: V): HydrateCtx & V {
@@ -76,6 +77,7 @@ export type HydrateCtxVals = {
76
77
  labelers: ParsedLabelers
77
78
  viewer: string | null
78
79
  includeTakedowns?: boolean
80
+ includeActorTakedowns?: boolean
79
81
  include3pBlocks?: boolean
80
82
  }
81
83
 
@@ -98,6 +100,7 @@ export type HydrationState = {
98
100
  listViewers?: ListViewerStates
99
101
  listItems?: ListItems
100
102
  likes?: Likes
103
+ likeBlocks?: LikeBlocks
101
104
  labels?: Labels
102
105
  feedgens?: FeedGens
103
106
  feedgenViewers?: FeedGenViewerStates
@@ -119,6 +122,9 @@ type PostBlockPairs = {
119
122
  root?: RelationshipPair
120
123
  }
121
124
 
125
+ export type LikeBlock = boolean
126
+ export type LikeBlocks = HydrationMap<LikeBlock>
127
+
122
128
  export type FollowBlock = boolean
123
129
  export type FollowBlocks = HydrationMap<FollowBlock>
124
130
 
@@ -179,12 +185,13 @@ export class Hydrator {
179
185
  dids: string[],
180
186
  ctx: HydrateCtx,
181
187
  ): Promise<HydrationState> {
188
+ const includeTakedowns = ctx.includeTakedowns || ctx.includeActorTakedowns
182
189
  const [actors, labels, profileViewersState] = await Promise.all([
183
- this.actor.getActors(dids, ctx.includeTakedowns),
190
+ this.actor.getActors(dids, includeTakedowns),
184
191
  this.label.getLabelsForSubjects(labelSubjectsForDid(dids), ctx.labelers),
185
192
  this.hydrateProfileViewers(dids, ctx),
186
193
  ])
187
- if (!ctx.includeTakedowns) {
194
+ if (!includeTakedowns) {
188
195
  actionTakedownLabels(dids, actors, labels)
189
196
  }
190
197
  return mergeStates(profileViewersState ?? {}, {
@@ -743,12 +750,33 @@ export class Hydrator {
743
750
  // - like
744
751
  // - profile
745
752
  // - list basic
746
- async hydrateLikes(uris: string[], ctx: HydrateCtx): Promise<HydrationState> {
753
+ async hydrateLikes(
754
+ authorDid: string,
755
+ uris: string[],
756
+ ctx: HydrateCtx,
757
+ ): Promise<HydrationState> {
747
758
  const [likes, profileState] = await Promise.all([
748
759
  this.feed.getLikes(uris, ctx.includeTakedowns),
749
760
  this.hydrateProfiles(uris.map(didFromUri), ctx),
750
761
  ])
751
- return mergeStates(profileState, { likes, ctx })
762
+
763
+ const pairs: RelationshipPair[] = []
764
+ for (const [uri, like] of likes) {
765
+ if (like) {
766
+ pairs.push([authorDid, didFromUri(uri)])
767
+ }
768
+ }
769
+ const blocks = await this.graph.getBidirectionalBlocks(pairs)
770
+ const likeBlocks = new HydrationMap<LikeBlock>()
771
+ for (const [uri, like] of likes) {
772
+ if (like) {
773
+ likeBlocks.set(uri, blocks.isBlocked(authorDid, didFromUri(uri)))
774
+ } else {
775
+ likeBlocks.set(uri, null)
776
+ }
777
+ }
778
+
779
+ return mergeStates(profileState, { likes, likeBlocks, ctx })
752
780
  }
753
781
 
754
782
  // app.bsky.feed.getRepostedBy#repostedBy
@@ -1151,6 +1179,7 @@ export const mergeStates = (
1151
1179
  listViewers: mergeMaps(stateA.listViewers, stateB.listViewers),
1152
1180
  listItems: mergeMaps(stateA.listItems, stateB.listItems),
1153
1181
  likes: mergeMaps(stateA.likes, stateB.likes),
1182
+ likeBlocks: mergeMaps(stateA.likeBlocks, stateB.likeBlocks),
1154
1183
  labels: mergeMaps(stateA.labels, stateB.labels),
1155
1184
  feedgens: mergeMaps(stateA.feedgens, stateB.feedgens),
1156
1185
  feedgenAggs: mergeMaps(stateA.feedgenAggs, stateB.feedgenAggs),
@@ -10728,8 +10728,9 @@ export const schemaDict = {
10728
10728
  },
10729
10729
  },
10730
10730
  },
10731
- }
10732
- export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[]
10731
+ } as const satisfies Record<string, LexiconDoc>
10732
+
10733
+ export const schemas = Object.values(schemaDict)
10733
10734
  export const lexicons: Lexicons = new Lexicons(schemas)
10734
10735
  export const ids = {
10735
10736
  ComAtprotoAdminDefs: 'com.atproto.admin.defs',
@@ -5,6 +5,7 @@ import { ids } from '../../src/lexicon/lexicons'
5
5
  describe('bsky takedown labels', () => {
6
6
  let network: TestNetwork
7
7
  let agent: AtpAgent
8
+ let pdsAgent: AtpAgent
8
9
  let sc: SeedClient
9
10
 
10
11
  let takendownSubjects: string[]
@@ -20,10 +21,21 @@ describe('bsky takedown labels', () => {
20
21
  dbPostgresSchema: 'bsky_views_takedown_labels',
21
22
  })
22
23
  agent = network.bsky.getClient()
24
+ pdsAgent = network.pds.getClient()
23
25
  sc = network.getSeedClient()
24
26
  await basicSeed(sc)
25
27
 
26
28
  aliceListRef = await sc.createList(sc.dids.alice, 'alice list', 'mod')
29
+ // carol blocks dan via alice's (takendown) list
30
+ await sc.addToList(sc.dids.alice, sc.dids.dan, aliceListRef)
31
+ await pdsAgent.app.bsky.graph.listblock.create(
32
+ { repo: sc.dids.carol },
33
+ {
34
+ subject: aliceListRef.uriStr,
35
+ createdAt: new Date().toISOString(),
36
+ },
37
+ sc.getHeaders(sc.dids.carol),
38
+ )
27
39
  carolListRef = await sc.createList(sc.dids.carol, 'carol list', 'mod')
28
40
  aliceGenRef = await sc.createFeedGen(
29
41
  sc.dids.alice,
@@ -159,6 +171,25 @@ describe('bsky takedown labels', () => {
159
171
  await expect(attempt2).rejects.toThrow('List not found')
160
172
  })
161
173
 
174
+ it('halts application of mod lists', async () => {
175
+ const { data: profile } = await agent.app.bsky.actor.getProfile(
176
+ {
177
+ actor: sc.dids.dan, // blocked via alice's takendown list
178
+ },
179
+ {
180
+ headers: await network.serviceHeaders(
181
+ sc.dids.carol,
182
+ ids.AppBskyActorGetProfile,
183
+ ),
184
+ },
185
+ )
186
+ expect(profile.did).toBe(sc.dids.dan)
187
+ expect(profile.viewer).not.toBeUndefined()
188
+ expect(profile.viewer?.blockedBy).toBe(false)
189
+ expect(profile.viewer?.blocking).toBeUndefined()
190
+ expect(profile.viewer?.blockingByList).toBeUndefined()
191
+ })
192
+
162
193
  it('takesdown feed generators', async () => {
163
194
  const res = await agent.api.app.bsky.feed.getFeedGenerators({
164
195
  feeds: [aliceGenRef.uriStr, bobGenRef.uriStr, carolGenRef.uriStr],
@@ -11,6 +11,8 @@ describe('pds like views', () => {
11
11
  // account dids, for convenience
12
12
  let alice: string
13
13
  let bob: string
14
+ let carol: string
15
+ let frankie: string
14
16
 
15
17
  beforeAll(async () => {
16
18
  network = await TestNetwork.create({
@@ -19,9 +21,17 @@ describe('pds like views', () => {
19
21
  agent = network.bsky.getClient()
20
22
  sc = network.getSeedClient()
21
23
  await likesSeed(sc)
24
+ await sc.createAccount('frankie', {
25
+ handle: 'frankie.test',
26
+ email: 'frankie@frankie.com',
27
+ password: 'password',
28
+ })
22
29
  await network.processAll()
30
+
23
31
  alice = sc.dids.alice
24
32
  bob = sc.dids.bob
33
+ carol = sc.dids.carol
34
+ frankie = sc.dids.frankie
25
35
  })
26
36
 
27
37
  afterAll(async () => {
@@ -108,4 +118,70 @@ describe('pds like views', () => {
108
118
  }),
109
119
  )
110
120
  })
121
+
122
+ it(`author viewer doesn't see likes by user the author blocked`, async () => {
123
+ await sc.like(frankie, sc.posts[alice][1].ref)
124
+ await network.processAll()
125
+
126
+ const beforeBlock = await agent.app.bsky.feed.getLikes(
127
+ { uri: sc.posts[alice][1].ref.uriStr },
128
+ { headers: await network.serviceHeaders(alice, ids.AppBskyFeedGetLikes) },
129
+ )
130
+
131
+ expect(beforeBlock.data.likes.map((like) => like.actor.did)).toStrictEqual([
132
+ sc.dids.frankie,
133
+ sc.dids.eve,
134
+ sc.dids.dan,
135
+ sc.dids.carol,
136
+ sc.dids.bob,
137
+ ])
138
+
139
+ await sc.block(alice, frankie)
140
+ await network.processAll()
141
+
142
+ const afterBlock = await agent.app.bsky.feed.getLikes(
143
+ { uri: sc.posts[alice][1].ref.uriStr },
144
+ { headers: await network.serviceHeaders(alice, ids.AppBskyFeedGetLikes) },
145
+ )
146
+
147
+ expect(afterBlock.data.likes.map((like) => like.actor.did)).toStrictEqual([
148
+ sc.dids.eve,
149
+ sc.dids.dan,
150
+ sc.dids.carol,
151
+ sc.dids.bob,
152
+ ])
153
+ })
154
+
155
+ it(`non-author viewer doesn't see likes by user the author blocked and by user the viewer blocked `, async () => {
156
+ await sc.unblock(alice, frankie)
157
+ await network.processAll()
158
+
159
+ const beforeBlock = await agent.app.bsky.feed.getLikes(
160
+ { uri: sc.posts[alice][1].ref.uriStr },
161
+ { headers: await network.serviceHeaders(bob, ids.AppBskyFeedGetLikes) },
162
+ )
163
+
164
+ expect(beforeBlock.data.likes.map((like) => like.actor.did)).toStrictEqual([
165
+ sc.dids.frankie,
166
+ sc.dids.eve,
167
+ sc.dids.dan,
168
+ sc.dids.carol,
169
+ sc.dids.bob,
170
+ ])
171
+
172
+ await sc.block(alice, frankie)
173
+ await sc.block(bob, carol)
174
+ await network.processAll()
175
+
176
+ const afterBlock = await agent.app.bsky.feed.getLikes(
177
+ { uri: sc.posts[alice][1].ref.uriStr },
178
+ { headers: await network.serviceHeaders(bob, ids.AppBskyFeedGetLikes) },
179
+ )
180
+
181
+ expect(afterBlock.data.likes.map((like) => like.actor.did)).toStrictEqual([
182
+ sc.dids.eve,
183
+ sc.dids.dan,
184
+ sc.dids.bob,
185
+ ])
186
+ })
111
187
  })