@atproto/pds 0.4.59 → 0.4.60

Sign up to get free protection for your applications and to get access to all the features.
Files changed (118) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/api/app/bsky/actor/getProfile.d.ts.map +1 -1
  3. package/dist/api/app/bsky/actor/getProfile.js +2 -9
  4. package/dist/api/app/bsky/actor/getProfile.js.map +1 -1
  5. package/dist/api/app/bsky/actor/getProfiles.d.ts.map +1 -1
  6. package/dist/api/app/bsky/actor/getProfiles.js +2 -6
  7. package/dist/api/app/bsky/actor/getProfiles.js.map +1 -1
  8. package/dist/api/app/bsky/feed/getActorLikes.d.ts.map +1 -1
  9. package/dist/api/app/bsky/feed/getActorLikes.js +2 -9
  10. package/dist/api/app/bsky/feed/getActorLikes.js.map +1 -1
  11. package/dist/api/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
  12. package/dist/api/app/bsky/feed/getAuthorFeed.js +2 -9
  13. package/dist/api/app/bsky/feed/getAuthorFeed.js.map +1 -1
  14. package/dist/api/app/bsky/feed/getFeed.d.ts.map +1 -1
  15. package/dist/api/app/bsky/feed/getFeed.js +2 -1
  16. package/dist/api/app/bsky/feed/getFeed.js.map +1 -1
  17. package/dist/api/app/bsky/feed/getPostThread.d.ts.map +1 -1
  18. package/dist/api/app/bsky/feed/getPostThread.js +12 -14
  19. package/dist/api/app/bsky/feed/getPostThread.js.map +1 -1
  20. package/dist/api/app/bsky/feed/getTimeline.d.ts.map +1 -1
  21. package/dist/api/app/bsky/feed/getTimeline.js +2 -6
  22. package/dist/api/app/bsky/feed/getTimeline.js.map +1 -1
  23. package/dist/api/com/atproto/repo/getRecord.js +1 -1
  24. package/dist/api/com/atproto/repo/getRecord.js.map +1 -1
  25. package/dist/api/com/atproto/server/requestPasswordReset.js +1 -1
  26. package/dist/api/com/atproto/server/requestPasswordReset.js.map +1 -1
  27. package/dist/config/config.d.ts +9 -0
  28. package/dist/config/config.d.ts.map +1 -1
  29. package/dist/config/config.js +10 -1
  30. package/dist/config/config.js.map +1 -1
  31. package/dist/config/env.d.ts +6 -1
  32. package/dist/config/env.d.ts.map +1 -1
  33. package/dist/config/env.js +8 -1
  34. package/dist/config/env.js.map +1 -1
  35. package/dist/context.d.ts +6 -2
  36. package/dist/context.d.ts.map +1 -1
  37. package/dist/context.js +55 -11
  38. package/dist/context.js.map +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/lexicon/lexicons.d.ts +33 -0
  43. package/dist/lexicon/lexicons.d.ts.map +1 -1
  44. package/dist/lexicon/lexicons.js +42 -3
  45. package/dist/lexicon/lexicons.js.map +1 -1
  46. package/dist/lexicon/types/app/bsky/actor/defs.d.ts +2 -0
  47. package/dist/lexicon/types/app/bsky/actor/defs.d.ts.map +1 -1
  48. package/dist/lexicon/types/app/bsky/actor/defs.js.map +1 -1
  49. package/dist/lexicon/types/app/bsky/actor/profile.d.ts +1 -0
  50. package/dist/lexicon/types/app/bsky/actor/profile.d.ts.map +1 -1
  51. package/dist/lexicon/types/app/bsky/actor/profile.js.map +1 -1
  52. package/dist/lexicon/types/app/bsky/feed/defs.d.ts +13 -2
  53. package/dist/lexicon/types/app/bsky/feed/defs.d.ts.map +1 -1
  54. package/dist/lexicon/types/app/bsky/feed/defs.js +21 -1
  55. package/dist/lexicon/types/app/bsky/feed/defs.js.map +1 -1
  56. package/dist/lexicon/types/app/bsky/feed/getAuthorFeed.d.ts +1 -0
  57. package/dist/lexicon/types/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
  58. package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts +2 -0
  59. package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts.map +1 -1
  60. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts +2 -0
  61. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts.map +1 -1
  62. package/dist/mailer/index.d.ts +1 -1
  63. package/dist/mailer/index.d.ts.map +1 -1
  64. package/dist/mailer/index.js.map +1 -1
  65. package/dist/mailer/templates/confirm-email.js +1 -1
  66. package/dist/mailer/templates/confirm-email.js.map +2 -2
  67. package/dist/mailer/templates/delete-account.js +1 -1
  68. package/dist/mailer/templates/delete-account.js.map +2 -2
  69. package/dist/mailer/templates/plc-operation.js +1 -1
  70. package/dist/mailer/templates/plc-operation.js.map +2 -2
  71. package/dist/mailer/templates/reset-password.js +1 -1
  72. package/dist/mailer/templates/reset-password.js.map +2 -2
  73. package/dist/mailer/templates/update-email.js +1 -1
  74. package/dist/mailer/templates/update-email.js.map +2 -2
  75. package/dist/pipethrough.d.ts +26 -26
  76. package/dist/pipethrough.d.ts.map +1 -1
  77. package/dist/pipethrough.js +328 -228
  78. package/dist/pipethrough.js.map +1 -1
  79. package/dist/read-after-write/util.d.ts +13 -5
  80. package/dist/read-after-write/util.d.ts.map +1 -1
  81. package/dist/read-after-write/util.js +37 -22
  82. package/dist/read-after-write/util.js.map +1 -1
  83. package/package.json +16 -15
  84. package/src/api/app/bsky/actor/getProfile.ts +3 -17
  85. package/src/api/app/bsky/actor/getProfiles.ts +3 -15
  86. package/src/api/app/bsky/feed/getActorLikes.ts +3 -19
  87. package/src/api/app/bsky/feed/getAuthorFeed.ts +3 -17
  88. package/src/api/app/bsky/feed/getFeed.ts +3 -1
  89. package/src/api/app/bsky/feed/getPostThread.ts +16 -23
  90. package/src/api/app/bsky/feed/getTimeline.ts +3 -14
  91. package/src/api/com/atproto/repo/getRecord.ts +1 -1
  92. package/src/api/com/atproto/server/requestPasswordReset.ts +1 -1
  93. package/src/config/config.ts +21 -1
  94. package/src/config/env.ts +20 -2
  95. package/src/context.ts +62 -17
  96. package/src/index.ts +1 -0
  97. package/src/lexicon/lexicons.ts +44 -3
  98. package/src/lexicon/types/app/bsky/actor/defs.ts +2 -0
  99. package/src/lexicon/types/app/bsky/actor/profile.ts +1 -0
  100. package/src/lexicon/types/app/bsky/feed/defs.ts +38 -2
  101. package/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts +1 -0
  102. package/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts +2 -0
  103. package/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts +2 -0
  104. package/src/mailer/index.ts +1 -1
  105. package/src/mailer/templates/confirm-email.hbs +106 -336
  106. package/src/mailer/templates/delete-account.hbs +110 -346
  107. package/src/mailer/templates/plc-operation.hbs +107 -338
  108. package/src/mailer/templates/reset-password.d.ts +1 -1
  109. package/src/mailer/templates/reset-password.hbs +108 -344
  110. package/src/mailer/templates/update-email.hbs +107 -337
  111. package/src/pipethrough.ts +489 -233
  112. package/src/read-after-write/util.ts +58 -32
  113. package/tests/account-deletion.test.ts +1 -1
  114. package/tests/account.test.ts +2 -2
  115. package/tests/email-confirmation.test.ts +2 -2
  116. package/tests/plc-operations.test.ts +1 -1
  117. package/tests/proxied/proxy-catchall.test.ts +255 -0
  118. package/tests/proxied/proxy-header.test.ts +31 -1
@@ -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