@atproto/pds 0.4.59 → 0.4.61
Sign up to get free protection for your applications and to get access to all the features.
- package/CHANGELOG.md +48 -0
- package/dist/account-manager/helpers/account.d.ts +1 -0
- package/dist/account-manager/helpers/account.d.ts.map +1 -1
- package/dist/account-manager/helpers/account.js +15 -1
- package/dist/account-manager/helpers/account.js.map +1 -1
- package/dist/account-manager/helpers/invite.d.ts +1 -1
- package/dist/account-manager/helpers/invite.d.ts.map +1 -1
- package/dist/account-manager/helpers/invite.js +20 -9
- package/dist/account-manager/helpers/invite.js.map +1 -1
- package/dist/account-manager/index.d.ts +2 -0
- package/dist/account-manager/index.d.ts.map +1 -1
- package/dist/account-manager/index.js +8 -1
- package/dist/account-manager/index.js.map +1 -1
- package/dist/api/app/bsky/actor/getProfile.d.ts.map +1 -1
- package/dist/api/app/bsky/actor/getProfile.js +2 -9
- package/dist/api/app/bsky/actor/getProfile.js.map +1 -1
- package/dist/api/app/bsky/actor/getProfiles.d.ts.map +1 -1
- package/dist/api/app/bsky/actor/getProfiles.js +2 -6
- package/dist/api/app/bsky/actor/getProfiles.js.map +1 -1
- package/dist/api/app/bsky/feed/getActorLikes.d.ts.map +1 -1
- package/dist/api/app/bsky/feed/getActorLikes.js +2 -9
- package/dist/api/app/bsky/feed/getActorLikes.js.map +1 -1
- package/dist/api/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
- package/dist/api/app/bsky/feed/getAuthorFeed.js +2 -9
- package/dist/api/app/bsky/feed/getAuthorFeed.js.map +1 -1
- package/dist/api/app/bsky/feed/getFeed.d.ts.map +1 -1
- package/dist/api/app/bsky/feed/getFeed.js +2 -1
- package/dist/api/app/bsky/feed/getFeed.js.map +1 -1
- package/dist/api/app/bsky/feed/getPostThread.d.ts.map +1 -1
- package/dist/api/app/bsky/feed/getPostThread.js +12 -14
- package/dist/api/app/bsky/feed/getPostThread.js.map +1 -1
- package/dist/api/app/bsky/feed/getTimeline.d.ts.map +1 -1
- package/dist/api/app/bsky/feed/getTimeline.js +2 -6
- package/dist/api/app/bsky/feed/getTimeline.js.map +1 -1
- package/dist/api/com/atproto/admin/getAccountInfo.d.ts.map +1 -1
- package/dist/api/com/atproto/admin/getAccountInfo.js +6 -14
- package/dist/api/com/atproto/admin/getAccountInfo.js.map +1 -1
- package/dist/api/com/atproto/admin/getAccountInfos.d.ts +4 -0
- package/dist/api/com/atproto/admin/getAccountInfos.d.ts.map +1 -0
- package/dist/api/com/atproto/admin/getAccountInfos.js +32 -0
- package/dist/api/com/atproto/admin/getAccountInfos.js.map +1 -0
- package/dist/api/com/atproto/admin/index.d.ts.map +1 -1
- package/dist/api/com/atproto/admin/index.js +2 -0
- package/dist/api/com/atproto/admin/index.js.map +1 -1
- package/dist/api/com/atproto/admin/util.d.ts +17 -0
- package/dist/api/com/atproto/admin/util.d.ts.map +1 -1
- package/dist/api/com/atproto/admin/util.js +27 -1
- package/dist/api/com/atproto/admin/util.js.map +1 -1
- package/dist/api/com/atproto/repo/getRecord.d.ts.map +1 -1
- package/dist/api/com/atproto/repo/getRecord.js +2 -2
- package/dist/api/com/atproto/repo/getRecord.js.map +1 -1
- package/dist/api/com/atproto/server/requestPasswordReset.js +1 -1
- package/dist/api/com/atproto/server/requestPasswordReset.js.map +1 -1
- package/dist/config/config.d.ts +17 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +11 -1
- package/dist/config/config.js.map +1 -1
- package/dist/config/env.d.ts +7 -1
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +9 -1
- package/dist/config/env.js.map +1 -1
- package/dist/context.d.ts +6 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +55 -11
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lexicon/index.d.ts +4 -0
- package/dist/lexicon/index.d.ts.map +1 -1
- package/dist/lexicon/index.js +8 -0
- package/dist/lexicon/index.js.map +1 -1
- package/dist/lexicon/lexicons.d.ts +118 -0
- package/dist/lexicon/lexicons.d.ts.map +1 -1
- package/dist/lexicon/lexicons.js +135 -3
- package/dist/lexicon/lexicons.js.map +1 -1
- package/dist/lexicon/types/app/bsky/actor/defs.d.ts +2 -0
- package/dist/lexicon/types/app/bsky/actor/defs.d.ts.map +1 -1
- package/dist/lexicon/types/app/bsky/actor/defs.js.map +1 -1
- package/dist/lexicon/types/app/bsky/actor/profile.d.ts +1 -0
- package/dist/lexicon/types/app/bsky/actor/profile.d.ts.map +1 -1
- package/dist/lexicon/types/app/bsky/actor/profile.js.map +1 -1
- package/dist/lexicon/types/app/bsky/feed/defs.d.ts +13 -2
- package/dist/lexicon/types/app/bsky/feed/defs.d.ts.map +1 -1
- package/dist/lexicon/types/app/bsky/feed/defs.js +21 -1
- package/dist/lexicon/types/app/bsky/feed/defs.js.map +1 -1
- package/dist/lexicon/types/app/bsky/feed/getAuthorFeed.d.ts +1 -0
- package/dist/lexicon/types/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
- package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts +2 -0
- package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts.map +1 -1
- package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts +2 -0
- package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts.map +1 -1
- package/dist/lexicon/types/com/atproto/repo/getRecord.d.ts +1 -0
- package/dist/lexicon/types/com/atproto/repo/getRecord.d.ts.map +1 -1
- package/dist/lexicon/types/tools/ozone/moderation/getRecords.d.ts +39 -0
- package/dist/lexicon/types/tools/ozone/moderation/getRecords.d.ts.map +1 -0
- package/dist/lexicon/types/tools/ozone/moderation/getRecords.js +3 -0
- package/dist/lexicon/types/tools/ozone/moderation/getRecords.js.map +1 -0
- package/dist/lexicon/types/tools/ozone/moderation/getRepos.d.ts +39 -0
- package/dist/lexicon/types/tools/ozone/moderation/getRepos.d.ts.map +1 -0
- package/dist/lexicon/types/tools/ozone/moderation/getRepos.js +3 -0
- package/dist/lexicon/types/tools/ozone/moderation/getRepos.js.map +1 -0
- package/dist/mailer/index.d.ts +1 -1
- package/dist/mailer/index.d.ts.map +1 -1
- package/dist/mailer/index.js.map +1 -1
- package/dist/mailer/templates/confirm-email.js +1 -1
- package/dist/mailer/templates/confirm-email.js.map +2 -2
- package/dist/mailer/templates/delete-account.js +1 -1
- package/dist/mailer/templates/delete-account.js.map +2 -2
- package/dist/mailer/templates/plc-operation.js +1 -1
- package/dist/mailer/templates/plc-operation.js.map +2 -2
- package/dist/mailer/templates/reset-password.js +1 -1
- package/dist/mailer/templates/reset-password.js.map +2 -2
- package/dist/mailer/templates/update-email.js +1 -1
- package/dist/mailer/templates/update-email.js.map +2 -2
- package/dist/pipethrough.d.ts +26 -26
- package/dist/pipethrough.d.ts.map +1 -1
- package/dist/pipethrough.js +360 -228
- package/dist/pipethrough.js.map +1 -1
- package/dist/read-after-write/util.d.ts +13 -5
- package/dist/read-after-write/util.d.ts.map +1 -1
- package/dist/read-after-write/util.js +37 -22
- package/dist/read-after-write/util.js.map +1 -1
- package/package.json +15 -14
- package/src/account-manager/helpers/account.ts +22 -0
- package/src/account-manager/helpers/invite.ts +19 -9
- package/src/account-manager/index.ts +13 -1
- package/src/api/app/bsky/actor/getProfile.ts +3 -17
- package/src/api/app/bsky/actor/getProfiles.ts +3 -15
- package/src/api/app/bsky/feed/getActorLikes.ts +3 -19
- package/src/api/app/bsky/feed/getAuthorFeed.ts +3 -17
- package/src/api/app/bsky/feed/getFeed.ts +3 -1
- package/src/api/app/bsky/feed/getPostThread.ts +16 -23
- package/src/api/app/bsky/feed/getTimeline.ts +3 -14
- package/src/api/com/atproto/admin/getAccountInfo.ts +6 -13
- package/src/api/com/atproto/admin/getAccountInfos.ts +33 -0
- package/src/api/com/atproto/admin/index.ts +2 -0
- package/src/api/com/atproto/admin/util.ts +38 -0
- package/src/api/com/atproto/repo/getRecord.ts +5 -2
- package/src/api/com/atproto/server/requestPasswordReset.ts +1 -1
- package/src/config/config.ts +31 -1
- package/src/config/env.ts +22 -2
- package/src/context.ts +62 -17
- package/src/index.ts +1 -0
- package/src/lexicon/index.ts +24 -0
- package/src/lexicon/lexicons.ts +137 -3
- package/src/lexicon/types/app/bsky/actor/defs.ts +2 -0
- package/src/lexicon/types/app/bsky/actor/profile.ts +1 -0
- package/src/lexicon/types/app/bsky/feed/defs.ts +38 -2
- package/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts +1 -0
- package/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts +2 -0
- package/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts +2 -0
- package/src/lexicon/types/com/atproto/repo/getRecord.ts +1 -0
- package/src/lexicon/types/tools/ozone/moderation/getRecords.ts +50 -0
- package/src/lexicon/types/tools/ozone/moderation/getRepos.ts +50 -0
- package/src/mailer/index.ts +1 -1
- package/src/mailer/templates/confirm-email.hbs +106 -336
- package/src/mailer/templates/delete-account.hbs +110 -346
- package/src/mailer/templates/plc-operation.hbs +107 -338
- package/src/mailer/templates/reset-password.d.ts +1 -1
- package/src/mailer/templates/reset-password.hbs +108 -344
- package/src/mailer/templates/update-email.hbs +107 -337
- package/src/pipethrough.ts +528 -233
- package/src/read-after-write/util.ts +58 -32
- package/tests/account-deletion.test.ts +1 -1
- package/tests/account.test.ts +2 -2
- package/tests/email-confirmation.test.ts +2 -2
- package/tests/plc-operations.test.ts +1 -1
- package/tests/proxied/proxy-catchall.test.ts +255 -0
- package/tests/proxied/proxy-header.test.ts +31 -1
- package/tests/proxied/read-after-write.test.ts +77 -0
@@ -1,14 +1,26 @@
|
|
1
|
-
import {
|
2
|
-
import {
|
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:
|
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
|
38
|
+
export const pipethroughReadAfterWrite = async <T>(
|
27
39
|
ctx: AppContext,
|
28
|
-
|
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
|
-
|
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
|
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('
|
81
|
+
expect(mail.html).toContain('To permanently delete your account')
|
82
82
|
|
83
83
|
token = getTokenFromMail(mail)
|
84
84
|
if (!token) {
|
package/tests/account.test.ts
CHANGED
@@ -403,7 +403,7 @@ describe('account', () => {
|
|
403
403
|
)
|
404
404
|
|
405
405
|
expect(mail.to).toEqual(email)
|
406
|
-
expect(mail.html).toContain('Reset
|
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
|
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
|
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
|
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
|
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
|
})
|