@atproto/pds 0.4.59 → 0.4.61

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 (171) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/account-manager/helpers/account.d.ts +1 -0
  3. package/dist/account-manager/helpers/account.d.ts.map +1 -1
  4. package/dist/account-manager/helpers/account.js +15 -1
  5. package/dist/account-manager/helpers/account.js.map +1 -1
  6. package/dist/account-manager/helpers/invite.d.ts +1 -1
  7. package/dist/account-manager/helpers/invite.d.ts.map +1 -1
  8. package/dist/account-manager/helpers/invite.js +20 -9
  9. package/dist/account-manager/helpers/invite.js.map +1 -1
  10. package/dist/account-manager/index.d.ts +2 -0
  11. package/dist/account-manager/index.d.ts.map +1 -1
  12. package/dist/account-manager/index.js +8 -1
  13. package/dist/account-manager/index.js.map +1 -1
  14. package/dist/api/app/bsky/actor/getProfile.d.ts.map +1 -1
  15. package/dist/api/app/bsky/actor/getProfile.js +2 -9
  16. package/dist/api/app/bsky/actor/getProfile.js.map +1 -1
  17. package/dist/api/app/bsky/actor/getProfiles.d.ts.map +1 -1
  18. package/dist/api/app/bsky/actor/getProfiles.js +2 -6
  19. package/dist/api/app/bsky/actor/getProfiles.js.map +1 -1
  20. package/dist/api/app/bsky/feed/getActorLikes.d.ts.map +1 -1
  21. package/dist/api/app/bsky/feed/getActorLikes.js +2 -9
  22. package/dist/api/app/bsky/feed/getActorLikes.js.map +1 -1
  23. package/dist/api/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
  24. package/dist/api/app/bsky/feed/getAuthorFeed.js +2 -9
  25. package/dist/api/app/bsky/feed/getAuthorFeed.js.map +1 -1
  26. package/dist/api/app/bsky/feed/getFeed.d.ts.map +1 -1
  27. package/dist/api/app/bsky/feed/getFeed.js +2 -1
  28. package/dist/api/app/bsky/feed/getFeed.js.map +1 -1
  29. package/dist/api/app/bsky/feed/getPostThread.d.ts.map +1 -1
  30. package/dist/api/app/bsky/feed/getPostThread.js +12 -14
  31. package/dist/api/app/bsky/feed/getPostThread.js.map +1 -1
  32. package/dist/api/app/bsky/feed/getTimeline.d.ts.map +1 -1
  33. package/dist/api/app/bsky/feed/getTimeline.js +2 -6
  34. package/dist/api/app/bsky/feed/getTimeline.js.map +1 -1
  35. package/dist/api/com/atproto/admin/getAccountInfo.d.ts.map +1 -1
  36. package/dist/api/com/atproto/admin/getAccountInfo.js +6 -14
  37. package/dist/api/com/atproto/admin/getAccountInfo.js.map +1 -1
  38. package/dist/api/com/atproto/admin/getAccountInfos.d.ts +4 -0
  39. package/dist/api/com/atproto/admin/getAccountInfos.d.ts.map +1 -0
  40. package/dist/api/com/atproto/admin/getAccountInfos.js +32 -0
  41. package/dist/api/com/atproto/admin/getAccountInfos.js.map +1 -0
  42. package/dist/api/com/atproto/admin/index.d.ts.map +1 -1
  43. package/dist/api/com/atproto/admin/index.js +2 -0
  44. package/dist/api/com/atproto/admin/index.js.map +1 -1
  45. package/dist/api/com/atproto/admin/util.d.ts +17 -0
  46. package/dist/api/com/atproto/admin/util.d.ts.map +1 -1
  47. package/dist/api/com/atproto/admin/util.js +27 -1
  48. package/dist/api/com/atproto/admin/util.js.map +1 -1
  49. package/dist/api/com/atproto/repo/getRecord.d.ts.map +1 -1
  50. package/dist/api/com/atproto/repo/getRecord.js +2 -2
  51. package/dist/api/com/atproto/repo/getRecord.js.map +1 -1
  52. package/dist/api/com/atproto/server/requestPasswordReset.js +1 -1
  53. package/dist/api/com/atproto/server/requestPasswordReset.js.map +1 -1
  54. package/dist/config/config.d.ts +17 -0
  55. package/dist/config/config.d.ts.map +1 -1
  56. package/dist/config/config.js +11 -1
  57. package/dist/config/config.js.map +1 -1
  58. package/dist/config/env.d.ts +7 -1
  59. package/dist/config/env.d.ts.map +1 -1
  60. package/dist/config/env.js +9 -1
  61. package/dist/config/env.js.map +1 -1
  62. package/dist/context.d.ts +6 -2
  63. package/dist/context.d.ts.map +1 -1
  64. package/dist/context.js +55 -11
  65. package/dist/context.js.map +1 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +1 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/lexicon/index.d.ts +4 -0
  70. package/dist/lexicon/index.d.ts.map +1 -1
  71. package/dist/lexicon/index.js +8 -0
  72. package/dist/lexicon/index.js.map +1 -1
  73. package/dist/lexicon/lexicons.d.ts +118 -0
  74. package/dist/lexicon/lexicons.d.ts.map +1 -1
  75. package/dist/lexicon/lexicons.js +135 -3
  76. package/dist/lexicon/lexicons.js.map +1 -1
  77. package/dist/lexicon/types/app/bsky/actor/defs.d.ts +2 -0
  78. package/dist/lexicon/types/app/bsky/actor/defs.d.ts.map +1 -1
  79. package/dist/lexicon/types/app/bsky/actor/defs.js.map +1 -1
  80. package/dist/lexicon/types/app/bsky/actor/profile.d.ts +1 -0
  81. package/dist/lexicon/types/app/bsky/actor/profile.d.ts.map +1 -1
  82. package/dist/lexicon/types/app/bsky/actor/profile.js.map +1 -1
  83. package/dist/lexicon/types/app/bsky/feed/defs.d.ts +13 -2
  84. package/dist/lexicon/types/app/bsky/feed/defs.d.ts.map +1 -1
  85. package/dist/lexicon/types/app/bsky/feed/defs.js +21 -1
  86. package/dist/lexicon/types/app/bsky/feed/defs.js.map +1 -1
  87. package/dist/lexicon/types/app/bsky/feed/getAuthorFeed.d.ts +1 -0
  88. package/dist/lexicon/types/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
  89. package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts +2 -0
  90. package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts.map +1 -1
  91. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts +2 -0
  92. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts.map +1 -1
  93. package/dist/lexicon/types/com/atproto/repo/getRecord.d.ts +1 -0
  94. package/dist/lexicon/types/com/atproto/repo/getRecord.d.ts.map +1 -1
  95. package/dist/lexicon/types/tools/ozone/moderation/getRecords.d.ts +39 -0
  96. package/dist/lexicon/types/tools/ozone/moderation/getRecords.d.ts.map +1 -0
  97. package/dist/lexicon/types/tools/ozone/moderation/getRecords.js +3 -0
  98. package/dist/lexicon/types/tools/ozone/moderation/getRecords.js.map +1 -0
  99. package/dist/lexicon/types/tools/ozone/moderation/getRepos.d.ts +39 -0
  100. package/dist/lexicon/types/tools/ozone/moderation/getRepos.d.ts.map +1 -0
  101. package/dist/lexicon/types/tools/ozone/moderation/getRepos.js +3 -0
  102. package/dist/lexicon/types/tools/ozone/moderation/getRepos.js.map +1 -0
  103. package/dist/mailer/index.d.ts +1 -1
  104. package/dist/mailer/index.d.ts.map +1 -1
  105. package/dist/mailer/index.js.map +1 -1
  106. package/dist/mailer/templates/confirm-email.js +1 -1
  107. package/dist/mailer/templates/confirm-email.js.map +2 -2
  108. package/dist/mailer/templates/delete-account.js +1 -1
  109. package/dist/mailer/templates/delete-account.js.map +2 -2
  110. package/dist/mailer/templates/plc-operation.js +1 -1
  111. package/dist/mailer/templates/plc-operation.js.map +2 -2
  112. package/dist/mailer/templates/reset-password.js +1 -1
  113. package/dist/mailer/templates/reset-password.js.map +2 -2
  114. package/dist/mailer/templates/update-email.js +1 -1
  115. package/dist/mailer/templates/update-email.js.map +2 -2
  116. package/dist/pipethrough.d.ts +26 -26
  117. package/dist/pipethrough.d.ts.map +1 -1
  118. package/dist/pipethrough.js +360 -228
  119. package/dist/pipethrough.js.map +1 -1
  120. package/dist/read-after-write/util.d.ts +13 -5
  121. package/dist/read-after-write/util.d.ts.map +1 -1
  122. package/dist/read-after-write/util.js +37 -22
  123. package/dist/read-after-write/util.js.map +1 -1
  124. package/package.json +15 -14
  125. package/src/account-manager/helpers/account.ts +22 -0
  126. package/src/account-manager/helpers/invite.ts +19 -9
  127. package/src/account-manager/index.ts +13 -1
  128. package/src/api/app/bsky/actor/getProfile.ts +3 -17
  129. package/src/api/app/bsky/actor/getProfiles.ts +3 -15
  130. package/src/api/app/bsky/feed/getActorLikes.ts +3 -19
  131. package/src/api/app/bsky/feed/getAuthorFeed.ts +3 -17
  132. package/src/api/app/bsky/feed/getFeed.ts +3 -1
  133. package/src/api/app/bsky/feed/getPostThread.ts +16 -23
  134. package/src/api/app/bsky/feed/getTimeline.ts +3 -14
  135. package/src/api/com/atproto/admin/getAccountInfo.ts +6 -13
  136. package/src/api/com/atproto/admin/getAccountInfos.ts +33 -0
  137. package/src/api/com/atproto/admin/index.ts +2 -0
  138. package/src/api/com/atproto/admin/util.ts +38 -0
  139. package/src/api/com/atproto/repo/getRecord.ts +5 -2
  140. package/src/api/com/atproto/server/requestPasswordReset.ts +1 -1
  141. package/src/config/config.ts +31 -1
  142. package/src/config/env.ts +22 -2
  143. package/src/context.ts +62 -17
  144. package/src/index.ts +1 -0
  145. package/src/lexicon/index.ts +24 -0
  146. package/src/lexicon/lexicons.ts +137 -3
  147. package/src/lexicon/types/app/bsky/actor/defs.ts +2 -0
  148. package/src/lexicon/types/app/bsky/actor/profile.ts +1 -0
  149. package/src/lexicon/types/app/bsky/feed/defs.ts +38 -2
  150. package/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts +1 -0
  151. package/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts +2 -0
  152. package/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts +2 -0
  153. package/src/lexicon/types/com/atproto/repo/getRecord.ts +1 -0
  154. package/src/lexicon/types/tools/ozone/moderation/getRecords.ts +50 -0
  155. package/src/lexicon/types/tools/ozone/moderation/getRepos.ts +50 -0
  156. package/src/mailer/index.ts +1 -1
  157. package/src/mailer/templates/confirm-email.hbs +106 -336
  158. package/src/mailer/templates/delete-account.hbs +110 -346
  159. package/src/mailer/templates/plc-operation.hbs +107 -338
  160. package/src/mailer/templates/reset-password.d.ts +1 -1
  161. package/src/mailer/templates/reset-password.hbs +108 -344
  162. package/src/mailer/templates/update-email.hbs +107 -337
  163. package/src/pipethrough.ts +528 -233
  164. package/src/read-after-write/util.ts +58 -32
  165. package/tests/account-deletion.test.ts +1 -1
  166. package/tests/account.test.ts +2 -2
  167. package/tests/email-confirmation.test.ts +2 -2
  168. package/tests/plc-operations.test.ts +1 -1
  169. package/tests/proxied/proxy-catchall.test.ts +255 -0
  170. package/tests/proxied/proxy-header.test.ts +31 -1
  171. package/tests/proxied/read-after-write.test.ts +77 -0
@@ -1,14 +1,26 @@
1
- import { Headers } from '@atproto/xrpc'
2
- import { readStickyLogger as log } from '../logger'
1
+ import { jsonToLex } from '@atproto/lexicon'
2
+ import { HeadersMap } from '@atproto/xrpc'
3
+ import {
4
+ HandlerPipeThrough,
5
+ HandlerPipeThroughBuffer,
6
+ parseReqNsid,
7
+ } from '@atproto/xrpc-server'
8
+ import express from 'express'
9
+
3
10
  import AppContext from '../context'
11
+ import { lexicons } from '../lexicon/lexicons'
12
+ import { readStickyLogger as log } from '../logger'
13
+ import {
14
+ asPipeThroughBuffer,
15
+ isJsonContentType,
16
+ pipethrough,
17
+ } from '../pipethrough'
4
18
  import { HandlerResponse, LocalRecords, MungeFn } from './types'
5
19
  import { getRecordsSinceRev } from './viewer'
6
- import { HandlerPipeThrough } from '@atproto/xrpc-server'
7
- import { parseRes } from '../pipethrough'
8
20
 
9
21
  const REPO_REV_HEADER = 'atproto-repo-rev'
10
22
 
11
- export const getRepoRev = (headers: Headers): string | undefined => {
23
+ export const getRepoRev = (headers: HeadersMap): string | undefined => {
12
24
  return headers[REPO_REV_HEADER]
13
25
  }
14
26
 
@@ -23,42 +35,56 @@ export const getLocalLag = (local: LocalRecords): number | undefined => {
23
35
  return Date.now() - new Date(oldest).getTime()
24
36
  }
25
37
 
26
- export const handleReadAfterWrite = async <T>(
38
+ export const pipethroughReadAfterWrite = async <T>(
27
39
  ctx: AppContext,
28
- nsid: string,
29
- requester: string,
30
- res: HandlerPipeThrough,
40
+ reqCtx: { req: express.Request; auth: { credentials: { did: string } } },
31
41
  munge: MungeFn<T>,
32
42
  ): Promise<HandlerResponse<T> | HandlerPipeThrough> => {
43
+ const { req, auth } = reqCtx
44
+ const requester = auth.credentials.did
45
+
46
+ const streamRes = await pipethrough(ctx, req, { iss: requester })
47
+
48
+ const rev = getRepoRev(streamRes.headers)
49
+ if (!rev) return streamRes
50
+
51
+ if (isJsonContentType(streamRes.headers['content-type']) === false) {
52
+ // content-type is present but not JSON, we can't munge this
53
+ return streamRes
54
+ }
55
+
56
+ // if the munging fails, we can't return the original response because the
57
+ // stream will already have been read. If we end-up buffering the response,
58
+ // we'll return the buffered response in case of an error.
59
+ let bufferRes: HandlerPipeThroughBuffer | undefined
60
+
33
61
  try {
34
- return await readAfterWriteInternal(ctx, nsid, requester, res, munge)
62
+ const lxm = parseReqNsid(req)
63
+
64
+ return await ctx.actorStore.read(requester, async (store) => {
65
+ const local = await getRecordsSinceRev(store, rev)
66
+ if (local.count === 0) return streamRes
67
+
68
+ const { buffer } = (bufferRes = await asPipeThroughBuffer(streamRes))
69
+
70
+ const lex = jsonToLex(JSON.parse(buffer.toString('utf8')))
71
+
72
+ const parsedRes = lexicons.assertValidXrpcOutput(lxm, lex) as T
73
+
74
+ const localViewer = ctx.localViewer(store)
75
+
76
+ const data = await munge(localViewer, parsedRes, local, requester)
77
+ return formatMungedResponse(data, getLocalLag(local))
78
+ })
35
79
  } catch (err) {
80
+ // The error occurred while reading the stream, this is non-recoverable
81
+ if (!bufferRes && !streamRes.stream.readable) throw err
82
+
36
83
  log.warn({ err, requester }, 'error in read after write munge')
37
- return res
84
+ return bufferRes ?? streamRes
38
85
  }
39
86
  }
40
87
 
41
- export const readAfterWriteInternal = async <T>(
42
- ctx: AppContext,
43
- nsid: string,
44
- requester: string,
45
- res: HandlerPipeThrough,
46
- munge: MungeFn<T>,
47
- ): Promise<HandlerResponse<T> | HandlerPipeThrough> => {
48
- const rev = getRepoRev(res.headers ?? {})
49
- if (!rev) return res
50
- return ctx.actorStore.read(requester, async (store) => {
51
- const local = await getRecordsSinceRev(store, rev)
52
- if (local.count === 0) {
53
- return res
54
- }
55
- const localViewer = ctx.localViewer(store)
56
- const parsedRes = parseRes<T>(nsid, res)
57
- const data = await munge(localViewer, parsedRes, local, requester)
58
- return formatMungedResponse(data, getLocalLag(local))
59
- })
60
- }
61
-
62
88
  export const formatMungedResponse = <T>(
63
89
  body: T,
64
90
  lag?: number,
@@ -78,7 +78,7 @@ describe('account deletion', () => {
78
78
  )
79
79
 
80
80
  expect(mail.to).toEqual(carol.email)
81
- expect(mail.html).toContain('Delete your Bluesky account')
81
+ expect(mail.html).toContain('To permanently delete your account')
82
82
 
83
83
  token = getTokenFromMail(mail)
84
84
  if (!token) {
@@ -403,7 +403,7 @@ describe('account', () => {
403
403
  )
404
404
 
405
405
  expect(mail.to).toEqual(email)
406
- expect(mail.html).toContain('Reset your password')
406
+ expect(mail.html).toContain('Reset password')
407
407
  expect(mail.html).toContain('alice.test')
408
408
 
409
409
  const token = getTokenFromMail(mail)
@@ -474,7 +474,7 @@ describe('account', () => {
474
474
  )
475
475
 
476
476
  expect(mail.to).toEqual(email)
477
- expect(mail.html).toContain('Reset your password')
477
+ expect(mail.html).toContain('Reset password')
478
478
  expect(mail.html).toContain('alice.test')
479
479
 
480
480
  const token = getTokenFromMail(mail)
@@ -92,7 +92,7 @@ describe('email confirmation', () => {
92
92
  }),
93
93
  )
94
94
  expect(mail.to).toEqual(alice.email)
95
- expect(mail.html).toContain('Confirm your Bluesky email')
95
+ expect(mail.html).toContain('Confirm your email')
96
96
  confirmToken = getTokenFromMail(mail)
97
97
  expect(confirmToken).toBeDefined()
98
98
  })
@@ -164,7 +164,7 @@ describe('email confirmation', () => {
164
164
  }
165
165
  const mail = await getMailFrom(reqUpdate())
166
166
  expect(mail.to).toEqual(alice.email)
167
- expect(mail.html).toContain('Update your Bluesky email')
167
+ expect(mail.html).toContain('Update your email')
168
168
  updateToken = getTokenFromMail(mail)
169
169
  expect(updateToken).toBeDefined()
170
170
  })
@@ -172,7 +172,7 @@ describe('plc operations', () => {
172
172
  )
173
173
 
174
174
  expect(mail.to).toEqual(sc.accounts[alice].email)
175
- expect(mail.html).toContain('PLC Update Requested')
175
+ expect(mail.html).toContain('PLC update requested')
176
176
 
177
177
  const gotToken = getTokenFromMail(mail)
178
178
  assert(gotToken)
@@ -0,0 +1,255 @@
1
+ import AtpAgent from '@atproto/api'
2
+ import { Keypair } from '@atproto/crypto'
3
+ import { TestNetworkNoAppView } from '@atproto/dev-env'
4
+ import { LexiconDoc } from '@atproto/lexicon'
5
+ import * as plc from '@did-plc/lib'
6
+ import express from 'express'
7
+ import getPort from 'get-port'
8
+ import { once } from 'node:events'
9
+ import http from 'node:http'
10
+ import { setTimeout as sleep } from 'node:timers/promises'
11
+
12
+ const lexicons = [
13
+ {
14
+ lexicon: 1,
15
+ id: 'com.example.ok',
16
+ defs: {
17
+ main: {
18
+ type: 'query',
19
+ output: {
20
+ encoding: 'application/json',
21
+ schema: { type: 'object', properties: { foo: { type: 'string' } } },
22
+ },
23
+ },
24
+ },
25
+ },
26
+ {
27
+ lexicon: 1,
28
+ id: 'com.example.slow',
29
+ defs: {
30
+ main: {
31
+ type: 'query',
32
+ output: {
33
+ encoding: 'application/json',
34
+ schema: { type: 'object', properties: { foo: { type: 'string' } } },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ {
40
+ lexicon: 1,
41
+ id: 'com.example.abort',
42
+ defs: {
43
+ main: {
44
+ type: 'query',
45
+ output: {
46
+ encoding: 'application/json',
47
+ schema: { type: 'object', properties: { foo: { type: 'string' } } },
48
+ },
49
+ },
50
+ },
51
+ },
52
+ {
53
+ lexicon: 1,
54
+ id: 'com.example.error',
55
+ defs: {
56
+ main: {
57
+ type: 'query',
58
+ output: {
59
+ encoding: 'application/json',
60
+ schema: { type: 'object', properties: { foo: { type: 'string' } } },
61
+ },
62
+ },
63
+ },
64
+ },
65
+ ] as const satisfies LexiconDoc[]
66
+
67
+ describe('proxy header', () => {
68
+ let network: TestNetworkNoAppView
69
+ let alice: AtpAgent
70
+
71
+ let proxyServer: ProxyServer
72
+
73
+ beforeAll(async () => {
74
+ network = await TestNetworkNoAppView.create({
75
+ dbPostgresSchema: 'proxy_catchall',
76
+ })
77
+
78
+ const serviceId = 'proxy_test'
79
+
80
+ proxyServer = await ProxyServer.create(
81
+ network.pds.ctx.plcClient,
82
+ network.pds.ctx.plcRotationKey,
83
+ serviceId,
84
+ )
85
+
86
+ alice = network.pds.getClient().withProxy(serviceId, proxyServer.did)
87
+
88
+ for (const lex of lexicons) alice.lex.add(lex)
89
+
90
+ await alice.createAccount({
91
+ email: 'alice@test.com',
92
+ handle: 'alice.test',
93
+ password: 'alice-pass',
94
+ })
95
+ await network.processAll()
96
+ })
97
+
98
+ afterAll(async () => {
99
+ await proxyServer?.close()
100
+ await network?.close()
101
+ })
102
+
103
+ it('rejects when upstream unavailable', async () => {
104
+ const serviceId = 'foo_bar'
105
+
106
+ const proxyServer = await ProxyServer.create(
107
+ network.pds.ctx.plcClient,
108
+ network.pds.ctx.plcRotationKey,
109
+ serviceId,
110
+ )
111
+
112
+ // Make sure the service is not available
113
+ await proxyServer.close()
114
+
115
+ const client = alice.withProxy(serviceId, proxyServer.did)
116
+ for (const lex of lexicons) client.lex.add(lex)
117
+
118
+ await expect(client.call('com.example.ok')).rejects.toThrow(
119
+ 'pipethrough network error',
120
+ )
121
+ })
122
+
123
+ it('successfully proxies requests', async () => {
124
+ await expect(alice.call('com.example.ok')).resolves.toMatchObject({
125
+ data: { foo: 'ok' },
126
+ success: true,
127
+ })
128
+ })
129
+
130
+ it('handles cancelled upstream requests', async () => {
131
+ await expect(alice.call('com.example.abort')).rejects.toThrow('terminated')
132
+ })
133
+
134
+ it('handles failing upstream requests', async () => {
135
+ await expect(alice.call('com.example.error')).rejects.toThrowError(
136
+ expect.objectContaining({
137
+ status: 502,
138
+ error: 'FooBar',
139
+ message: 'My message',
140
+ }),
141
+ )
142
+ })
143
+
144
+ it('handles cancelled downstream requests', async () => {
145
+ const ac = new AbortController()
146
+
147
+ setTimeout(() => ac.abort(), 20)
148
+
149
+ await expect(
150
+ alice.call('com.example.slow', {}, undefined, { signal: ac.signal }),
151
+ ).rejects.toThrow('This operation was aborted')
152
+
153
+ await expect(alice.call('com.example.slow')).resolves.toMatchObject({
154
+ data: { foo: 'slow' },
155
+ success: true,
156
+ })
157
+ })
158
+ })
159
+
160
+ class ProxyServer {
161
+ constructor(
162
+ private server: http.Server,
163
+ public did: string,
164
+ ) {}
165
+
166
+ static async create(
167
+ plcClient: plc.Client,
168
+ keypair: Keypair,
169
+ serviceId: string,
170
+ ): Promise<ProxyServer> {
171
+ const app = express()
172
+
173
+ app.get('/xrpc/com.example.ok', (req, res) => {
174
+ res.status(200)
175
+ res.setHeader('content-type', 'application/json')
176
+ res.send('{"foo":"ok"}')
177
+ })
178
+
179
+ app.get('/xrpc/com.example.slow', async (req, res) => {
180
+ const wait = async (ms: number) => {
181
+ if (res.destroyed) return
182
+ const ac = new AbortController()
183
+ const abort = () => ac.abort()
184
+ res.on('close', abort)
185
+ try {
186
+ await sleep(ms, undefined, { signal: ac.signal })
187
+ } finally {
188
+ res.off('close', abort)
189
+ }
190
+ }
191
+
192
+ await wait(50)
193
+
194
+ res.status(200)
195
+ res.setHeader('content-type', 'application/json')
196
+ res.flushHeaders()
197
+
198
+ await wait(50)
199
+
200
+ for (const char of '{"foo":"slow"}') {
201
+ res.write(char)
202
+ await wait(10)
203
+ }
204
+
205
+ res.end()
206
+ })
207
+
208
+ app.get('/xrpc/com.example.abort', async (req, res) => {
209
+ res.status(200)
210
+ res.setHeader('content-type', 'application/json')
211
+ res.write('{"foo"')
212
+ await sleep(50)
213
+ res.destroy(new Error('abort'))
214
+ })
215
+
216
+ app.get('/xrpc/com.example.error', async (req, res) => {
217
+ res.status(500).json({ error: 'FooBar', message: 'My message' })
218
+ })
219
+
220
+ const port = await getPort()
221
+ const server = app.listen(port)
222
+ server.keepAliveTimeout = 30 * 1000
223
+ server.headersTimeout = 35 * 1000
224
+ await once(server, 'listening')
225
+
226
+ const plcOp = await plc.signOperation(
227
+ {
228
+ type: 'plc_operation',
229
+ rotationKeys: [keypair.did()],
230
+ alsoKnownAs: [],
231
+ verificationMethods: {},
232
+ services: {
233
+ [serviceId]: {
234
+ type: 'TestAtprotoService',
235
+ endpoint: `http://localhost:${port}`,
236
+ },
237
+ },
238
+ prev: null,
239
+ },
240
+ keypair,
241
+ )
242
+ const did = await plc.didForCreateOp(plcOp)
243
+ await plcClient.sendOperation(did, plcOp)
244
+ return new ProxyServer(server, did)
245
+ }
246
+
247
+ async close() {
248
+ await new Promise<void>((resolve, reject) => {
249
+ this.server.close((err) => {
250
+ if (err) reject(err)
251
+ else resolve()
252
+ })
253
+ })
254
+ }
255
+ }
@@ -7,6 +7,7 @@ import { SeedClient, TestNetworkNoAppView, usersSeed } from '@atproto/dev-env'
7
7
  import getPort from 'get-port'
8
8
  import { Keypair } from '@atproto/crypto'
9
9
  import { verifyJwt } from '@atproto/xrpc-server'
10
+ import { parseProxyHeader } from '../../src/pipethrough'
10
11
 
11
12
  describe('proxy header', () => {
12
13
  let network: TestNetworkNoAppView
@@ -51,6 +52,35 @@ describe('proxy header', () => {
51
52
  throw new Error('no error thrown')
52
53
  }
53
54
 
55
+ it('parses proxy header', async () => {
56
+ expect(parseProxyHeader(network.pds.ctx, `#atproto_test`)).rejects.toThrow(
57
+ 'no did specified in proxy header',
58
+ )
59
+
60
+ expect(
61
+ parseProxyHeader(network.pds.ctx, `${proxyServer.did}#atproto_test#foo`),
62
+ ).rejects.toThrow('invalid proxy header format')
63
+
64
+ expect(
65
+ parseProxyHeader(network.pds.ctx, `${proxyServer.did}#atproto_test `),
66
+ ).rejects.toThrow('proxy header cannot contain spaces')
67
+
68
+ expect(
69
+ parseProxyHeader(network.pds.ctx, ` ${proxyServer.did}#atproto_test`),
70
+ ).rejects.toThrow('proxy header cannot contain spaces')
71
+
72
+ expect(parseProxyHeader(network.pds.ctx, `foo#bar`)).rejects.toThrow(
73
+ 'Poorly formatted DID: foo',
74
+ )
75
+
76
+ expect(
77
+ parseProxyHeader(network.pds.ctx, `${proxyServer.did}#atproto_test`),
78
+ ).resolves.toEqual({
79
+ did: proxyServer.did,
80
+ url: proxyServer.url,
81
+ })
82
+ })
83
+
54
84
  it('proxies requests based on header', async () => {
55
85
  const path = `/xrpc/app.bsky.actor.getProfile?actor=${alice}`
56
86
  await axios.get(`${network.pds.url}${path}`, {
@@ -93,7 +123,7 @@ describe('proxy header', () => {
93
123
  'atproto-proxy': proxyServer.did,
94
124
  },
95
125
  })
96
- await assertAxiosErr(attempt, 'no service id specified')
126
+ await assertAxiosErr(attempt, 'no service id specified in proxy header')
97
127
  expect(proxyServer.requests.length).toBe(1)
98
128
  })
99
129
 
@@ -1,6 +1,7 @@
1
1
  import util from 'node:util'
2
2
  import assert from 'node:assert'
3
3
  import { AtpAgent } from '@atproto/api'
4
+ import { request } from 'undici'
4
5
  import { TestNetwork, SeedClient, RecordRef } from '@atproto/dev-env'
5
6
  import basicSeed from '../seeds/basic'
6
7
  import { ThreadViewPost } from '../../src/lexicon/types/app/bsky/feed/defs'
@@ -266,4 +267,80 @@ describe('proxy read after write', () => {
266
267
  const parsed = parseInt(lag)
267
268
  expect(parsed > 0).toBe(true)
268
269
  })
270
+
271
+ it('negotiates encoding', async () => {
272
+ const identity = await agent.api.app.bsky.feed.getTimeline(
273
+ {},
274
+ { headers: { ...sc.getHeaders(alice), 'accept-encoding': 'identity' } },
275
+ )
276
+ expect(identity.headers['content-encoding']).toBeUndefined()
277
+
278
+ const gzip = await agent.api.app.bsky.feed.getTimeline(
279
+ {},
280
+ {
281
+ headers: { ...sc.getHeaders(alice), 'accept-encoding': 'gzip, *;q=0' },
282
+ },
283
+ )
284
+ expect(gzip.headers['content-encoding']).toBe('gzip')
285
+ })
286
+
287
+ it('defaults to identity encoding', async () => {
288
+ // Not using the "agent" because "fetch()" will add "accept-encoding: gzip,
289
+ // deflate" if not "accept-encoding" header is provided
290
+ const res = await request(
291
+ new URL(`/xrpc/app.bsky.feed.getTimeline`, agent.dispatchUrl),
292
+ {
293
+ headers: { ...sc.getHeaders(alice) },
294
+ },
295
+ )
296
+ expect(res.statusCode).toBe(200)
297
+ expect(res.headers['content-encoding']).toBeUndefined()
298
+ })
299
+
300
+ it('falls back to identity encoding', async () => {
301
+ const invalid = await agent.api.app.bsky.feed.getTimeline(
302
+ {},
303
+ { headers: { ...sc.getHeaders(alice), 'accept-encoding': 'invalid' } },
304
+ )
305
+
306
+ expect(invalid.headers['content-encoding']).toBeUndefined()
307
+ })
308
+
309
+ it('errors when failing to negotiate encoding', async () => {
310
+ await expect(
311
+ agent.api.app.bsky.feed.getTimeline(
312
+ {},
313
+ {
314
+ headers: {
315
+ ...sc.getHeaders(alice),
316
+ 'accept-encoding': 'invalid, *;q=0',
317
+ },
318
+ },
319
+ ),
320
+ ).rejects.toThrow(
321
+ expect.objectContaining({
322
+ status: 406,
323
+ message: 'this service does not support any of the requested encodings',
324
+ }),
325
+ )
326
+ })
327
+
328
+ it('errors on invalid content-encoding format', async () => {
329
+ await expect(
330
+ agent.api.app.bsky.feed.getTimeline(
331
+ {},
332
+ {
333
+ headers: {
334
+ ...sc.getHeaders(alice),
335
+ 'accept-encoding': ';q=1',
336
+ },
337
+ },
338
+ ),
339
+ ).rejects.toThrow(
340
+ expect.objectContaining({
341
+ status: 400,
342
+ message: 'Invalid accept-encoding: ";q=1"',
343
+ }),
344
+ )
345
+ })
269
346
  })