@atproto/api 0.0.8 → 0.1.0

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/src/types.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Used by the PersistSessionHandler to indicate what change occurred
3
+ */
4
+ export type AtpSessionEvent = 'create' | 'create-failed' | 'update' | 'expired'
5
+
6
+ /**
7
+ * Used by AtpAgent to store active sessions
8
+ */
9
+ export interface AtpSessionData {
10
+ refreshJwt: string
11
+ accessJwt: string
12
+ handle: string
13
+ did: string
14
+ }
15
+
16
+ /**
17
+ * Handler signature passed to AtpAgent to store session data
18
+ */
19
+ export type AtpPersistSessionHandler = (
20
+ evt: AtpSessionEvent,
21
+ session: AtpSessionData | undefined,
22
+ ) => void | Promise<void>
23
+
24
+ /**
25
+ * AtpAgent constructor() opts
26
+ */
27
+ export interface AtpAgentOpts {
28
+ service: string | URL
29
+ persistSession?: AtpPersistSessionHandler
30
+ }
31
+
32
+ /**
33
+ * AtpAgent createAccount() opts
34
+ */
35
+ export interface AtpAgentCreateAccountOpts {
36
+ email: string
37
+ password: string
38
+ handle: string
39
+ inviteCode?: string
40
+ }
41
+
42
+ /**
43
+ * AtpAgent login() opts
44
+ */
45
+ export interface AtpAgentLoginOpts {
46
+ identifier: string
47
+ password: string
48
+ }
49
+
50
+ /**
51
+ * AtpAgent global fetch handler
52
+ */
53
+ type AtpAgentFetchHeaders = Record<string, string>
54
+ export interface AtpAgentFetchHandlerResponse {
55
+ status: number
56
+ headers: Record<string, string>
57
+ body: any
58
+ }
59
+ export type AptAgentFetchHandler = (
60
+ httpUri: string,
61
+ httpMethod: string,
62
+ httpHeaders: AtpAgentFetchHeaders,
63
+ httpReqBody: any,
64
+ ) => Promise<AtpAgentFetchHandlerResponse>
65
+
66
+ /**
67
+ * AtpAgent global config opts
68
+ */
69
+ export interface AtpAgentGlobalOpts {
70
+ fetch: AptAgentFetchHandler
71
+ }
package/tests/_util.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { AtpAgentFetchHandlerResponse } from '..'
2
+
3
+ export async function fetchHandler(
4
+ httpUri: string,
5
+ httpMethod: string,
6
+ httpHeaders: Record<string, string>,
7
+ httpReqBody: unknown,
8
+ ): Promise<AtpAgentFetchHandlerResponse> {
9
+ // The duplex field is now required for streaming bodies, but not yet reflected
10
+ // anywhere in docs or types. See whatwg/fetch#1438, nodejs/node#46221.
11
+ const reqInit: RequestInit & { duplex: string } = {
12
+ method: httpMethod,
13
+ headers: httpHeaders,
14
+ body: httpReqBody
15
+ ? new TextEncoder().encode(JSON.stringify(httpReqBody))
16
+ : undefined,
17
+ duplex: 'half',
18
+ }
19
+ const res = await fetch(httpUri, reqInit)
20
+ const resBody = await res.arrayBuffer()
21
+ return {
22
+ status: res.status,
23
+ headers: Object.fromEntries(res.headers.entries()),
24
+ body: resBody ? JSON.parse(new TextDecoder().decode(resBody)) : undefined,
25
+ }
26
+ }
@@ -0,0 +1,391 @@
1
+ import { defaultFetchHandler } from '@atproto/xrpc'
2
+ import {
3
+ CloseFn,
4
+ runTestServer,
5
+ TestServerInfo,
6
+ } from '@atproto/pds/tests/_util'
7
+ import {
8
+ AtpAgent,
9
+ AtpAgentFetchHandlerResponse,
10
+ AtpSessionEvent,
11
+ AtpSessionData,
12
+ } from '..'
13
+
14
+ describe('agent', () => {
15
+ let server: TestServerInfo
16
+ let close: CloseFn
17
+
18
+ beforeAll(async () => {
19
+ server = await runTestServer({
20
+ dbPostgresSchema: 'session',
21
+ })
22
+ close = server.close
23
+ })
24
+
25
+ afterAll(async () => {
26
+ await close()
27
+ })
28
+
29
+ it('creates a new session on account creation.', async () => {
30
+ const events: string[] = []
31
+ const sessions: (AtpSessionData | undefined)[] = []
32
+ const persistSession = (evt: AtpSessionEvent, sess?: AtpSessionData) => {
33
+ events.push(evt)
34
+ sessions.push(sess)
35
+ }
36
+
37
+ const agent = new AtpAgent({ service: server.url, persistSession })
38
+
39
+ const res = await agent.createAccount({
40
+ handle: 'user1.test',
41
+ email: 'user1@test.com',
42
+ password: 'password',
43
+ })
44
+
45
+ expect(agent.hasSession).toEqual(true)
46
+ expect(agent.session?.accessJwt).toEqual(res.data.accessJwt)
47
+ expect(agent.session?.refreshJwt).toEqual(res.data.refreshJwt)
48
+ expect(agent.session?.handle).toEqual(res.data.handle)
49
+ expect(agent.session?.did).toEqual(res.data.did)
50
+
51
+ const { data: sessionInfo } = await agent.api.com.atproto.session.get({})
52
+ expect(sessionInfo).toEqual({
53
+ did: res.data.did,
54
+ handle: res.data.handle,
55
+ })
56
+
57
+ expect(events.length).toEqual(1)
58
+ expect(events[0]).toEqual('create')
59
+ expect(sessions.length).toEqual(1)
60
+ expect(sessions[0]?.accessJwt).toEqual(agent.session?.accessJwt)
61
+ })
62
+
63
+ it('creates a new session on login.', async () => {
64
+ const events: string[] = []
65
+ const sessions: (AtpSessionData | undefined)[] = []
66
+ const persistSession = (evt: AtpSessionEvent, sess?: AtpSessionData) => {
67
+ events.push(evt)
68
+ sessions.push(sess)
69
+ }
70
+
71
+ const agent1 = new AtpAgent({ service: server.url, persistSession })
72
+
73
+ await agent1.createAccount({
74
+ handle: 'user2.test',
75
+ email: 'user2@test.com',
76
+ password: 'password',
77
+ })
78
+
79
+ const agent2 = new AtpAgent({ service: server.url, persistSession })
80
+ const res1 = await agent2.login({
81
+ identifier: 'user2.test',
82
+ password: 'password',
83
+ })
84
+
85
+ expect(agent2.hasSession).toEqual(true)
86
+ expect(agent2.session?.accessJwt).toEqual(res1.data.accessJwt)
87
+ expect(agent2.session?.refreshJwt).toEqual(res1.data.refreshJwt)
88
+ expect(agent2.session?.handle).toEqual(res1.data.handle)
89
+ expect(agent2.session?.did).toEqual(res1.data.did)
90
+
91
+ const { data: sessionInfo } = await agent2.api.com.atproto.session.get({})
92
+ expect(sessionInfo).toEqual({
93
+ did: res1.data.did,
94
+ handle: res1.data.handle,
95
+ })
96
+
97
+ expect(events.length).toEqual(2)
98
+ expect(events[0]).toEqual('create')
99
+ expect(events[1]).toEqual('create')
100
+ expect(sessions.length).toEqual(2)
101
+ expect(sessions[0]?.accessJwt).toEqual(agent1.session?.accessJwt)
102
+ expect(sessions[1]?.accessJwt).toEqual(agent2.session?.accessJwt)
103
+ })
104
+
105
+ it('resumes an existing session.', async () => {
106
+ const events: string[] = []
107
+ const sessions: (AtpSessionData | undefined)[] = []
108
+ const persistSession = (evt: AtpSessionEvent, sess?: AtpSessionData) => {
109
+ events.push(evt)
110
+ sessions.push(sess)
111
+ }
112
+
113
+ const agent1 = new AtpAgent({ service: server.url, persistSession })
114
+
115
+ await agent1.createAccount({
116
+ handle: 'user3.test',
117
+ email: 'user3@test.com',
118
+ password: 'password',
119
+ })
120
+ if (!agent1.session) {
121
+ throw new Error('No session created')
122
+ }
123
+
124
+ const agent2 = new AtpAgent({ service: server.url, persistSession })
125
+ const res1 = await agent2.resumeSession(agent1.session)
126
+
127
+ expect(agent2.hasSession).toEqual(true)
128
+ expect(agent2.session?.handle).toEqual(res1.data.handle)
129
+ expect(agent2.session?.did).toEqual(res1.data.did)
130
+
131
+ const { data: sessionInfo } = await agent2.api.com.atproto.session.get({})
132
+ expect(sessionInfo).toEqual({
133
+ did: res1.data.did,
134
+ handle: res1.data.handle,
135
+ })
136
+
137
+ expect(events.length).toEqual(2)
138
+ expect(events[0]).toEqual('create')
139
+ expect(events[1]).toEqual('create')
140
+ expect(sessions.length).toEqual(2)
141
+ expect(sessions[0]?.accessJwt).toEqual(agent1.session?.accessJwt)
142
+ expect(sessions[1]?.accessJwt).toEqual(agent2.session?.accessJwt)
143
+ })
144
+
145
+ it('refreshes existing session.', async () => {
146
+ const events: string[] = []
147
+ const sessions: (AtpSessionData | undefined)[] = []
148
+ const persistSession = (evt: AtpSessionEvent, sess?: AtpSessionData) => {
149
+ events.push(evt)
150
+ sessions.push(sess)
151
+ }
152
+
153
+ const agent = new AtpAgent({ service: server.url, persistSession })
154
+
155
+ // create an account and a session with it
156
+ await agent.createAccount({
157
+ handle: 'user4.test',
158
+ email: 'user4@test.com',
159
+ password: 'password',
160
+ })
161
+ if (!agent.session) {
162
+ throw new Error('No session created')
163
+ }
164
+ const session1 = agent.session
165
+ const origAccessJwt = session1.accessJwt
166
+
167
+ // wait 1 second so that a token refresh will issue a new access token
168
+ // (if the timestamp, which has 1 second resolution, is the same -- then the access token won't change)
169
+ await new Promise((r) => setTimeout(r, 1000))
170
+
171
+ // patch the fetch handler to fake an expired token error on the next request
172
+ const tokenExpiredFetchHandler = async function (
173
+ httpUri: string,
174
+ httpMethod: string,
175
+ httpHeaders: Record<string, string>,
176
+ httpReqBody: unknown,
177
+ ): Promise<AtpAgentFetchHandlerResponse> {
178
+ if (httpHeaders.authorization === `Bearer ${origAccessJwt}`) {
179
+ return {
180
+ status: 400,
181
+ headers: {},
182
+ body: { error: 'ExpiredToken' },
183
+ }
184
+ }
185
+ return defaultFetchHandler(httpUri, httpMethod, httpHeaders, httpReqBody)
186
+ }
187
+
188
+ // put the agent through the auth flow
189
+ AtpAgent.configure({ fetch: tokenExpiredFetchHandler })
190
+ const res1 = await agent.api.app.bsky.feed.getTimeline()
191
+ AtpAgent.configure({ fetch: defaultFetchHandler })
192
+
193
+ expect(res1.success).toEqual(true)
194
+ expect(agent.hasSession).toEqual(true)
195
+ expect(agent.session?.accessJwt).not.toEqual(session1.accessJwt)
196
+ expect(agent.session?.refreshJwt).not.toEqual(session1.refreshJwt)
197
+ expect(agent.session?.handle).toEqual(session1.handle)
198
+ expect(agent.session?.did).toEqual(session1.did)
199
+
200
+ expect(events.length).toEqual(2)
201
+ expect(events[0]).toEqual('create')
202
+ expect(events[1]).toEqual('update')
203
+ expect(sessions.length).toEqual(2)
204
+ expect(sessions[0]?.accessJwt).toEqual(origAccessJwt)
205
+ expect(sessions[1]?.accessJwt).toEqual(agent.session?.accessJwt)
206
+ })
207
+
208
+ it('dedupes session refreshes.', async () => {
209
+ const events: string[] = []
210
+ const sessions: (AtpSessionData | undefined)[] = []
211
+ const persistSession = (evt: AtpSessionEvent, sess?: AtpSessionData) => {
212
+ events.push(evt)
213
+ sessions.push(sess)
214
+ }
215
+
216
+ const agent = new AtpAgent({ service: server.url, persistSession })
217
+
218
+ // create an account and a session with it
219
+ await agent.createAccount({
220
+ handle: 'user5.test',
221
+ email: 'user5@test.com',
222
+ password: 'password',
223
+ })
224
+ if (!agent.session) {
225
+ throw new Error('No session created')
226
+ }
227
+ const session1 = agent.session
228
+ const origAccessJwt = session1.accessJwt
229
+
230
+ // wait 1 second so that a token refresh will issue a new access token
231
+ // (if the timestamp, which has 1 second resolution, is the same -- then the access token won't change)
232
+ await new Promise((r) => setTimeout(r, 1000))
233
+
234
+ // patch the fetch handler to fake an expired token error on the next request
235
+ let expiredCalls = 0
236
+ let refreshCalls = 0
237
+ const tokenExpiredFetchHandler = async function (
238
+ httpUri: string,
239
+ httpMethod: string,
240
+ httpHeaders: Record<string, string>,
241
+ httpReqBody: unknown,
242
+ ): Promise<AtpAgentFetchHandlerResponse> {
243
+ if (httpHeaders.authorization === `Bearer ${origAccessJwt}`) {
244
+ expiredCalls++
245
+ return {
246
+ status: 400,
247
+ headers: {},
248
+ body: { error: 'ExpiredToken' },
249
+ }
250
+ }
251
+ if (httpUri.includes('com.atproto.session.refresh')) {
252
+ refreshCalls++
253
+ }
254
+ return defaultFetchHandler(httpUri, httpMethod, httpHeaders, httpReqBody)
255
+ }
256
+
257
+ // put the agent through the auth flow
258
+ AtpAgent.configure({ fetch: tokenExpiredFetchHandler })
259
+ const [res1, res2, res3] = await Promise.all([
260
+ agent.api.app.bsky.feed.getTimeline(),
261
+ agent.api.app.bsky.feed.getTimeline(),
262
+ agent.api.app.bsky.feed.getTimeline(),
263
+ ])
264
+ AtpAgent.configure({ fetch: defaultFetchHandler })
265
+
266
+ expect(expiredCalls).toEqual(3)
267
+ expect(refreshCalls).toEqual(1)
268
+ expect(res1.success).toEqual(true)
269
+ expect(res2.success).toEqual(true)
270
+ expect(res3.success).toEqual(true)
271
+ expect(agent.hasSession).toEqual(true)
272
+ expect(agent.session?.accessJwt).not.toEqual(session1.accessJwt)
273
+ expect(agent.session?.refreshJwt).not.toEqual(session1.refreshJwt)
274
+ expect(agent.session?.handle).toEqual(session1.handle)
275
+ expect(agent.session?.did).toEqual(session1.did)
276
+
277
+ expect(events.length).toEqual(2)
278
+ expect(events[0]).toEqual('create')
279
+ expect(events[1]).toEqual('update')
280
+ expect(sessions.length).toEqual(2)
281
+ expect(sessions[0]?.accessJwt).toEqual(origAccessJwt)
282
+ expect(sessions[1]?.accessJwt).toEqual(agent.session?.accessJwt)
283
+ })
284
+
285
+ it('persists an empty session on login and resumeSession failures', async () => {
286
+ const events: string[] = []
287
+ const sessions: (AtpSessionData | undefined)[] = []
288
+ const persistSession = (evt: AtpSessionEvent, sess?: AtpSessionData) => {
289
+ events.push(evt)
290
+ sessions.push(sess)
291
+ }
292
+
293
+ const agent = new AtpAgent({ service: server.url, persistSession })
294
+
295
+ try {
296
+ await agent.login({
297
+ identifier: 'baduser.test',
298
+ password: 'password',
299
+ })
300
+ } catch (_e: any) {
301
+ // ignore
302
+ }
303
+ expect(agent.hasSession).toEqual(false)
304
+
305
+ try {
306
+ await agent.resumeSession({
307
+ accessJwt: 'bad',
308
+ refreshJwt: 'bad',
309
+ did: 'bad',
310
+ handle: 'bad',
311
+ })
312
+ } catch (_e: any) {
313
+ // ignore
314
+ }
315
+ expect(agent.hasSession).toEqual(false)
316
+
317
+ expect(events.length).toEqual(2)
318
+ expect(events[0]).toEqual('create-failed')
319
+ expect(events[1]).toEqual('create-failed')
320
+ expect(sessions.length).toEqual(2)
321
+ expect(typeof sessions[0]).toEqual('undefined')
322
+ expect(typeof sessions[1]).toEqual('undefined')
323
+ })
324
+
325
+ it('does not modify or persist the session on a failed refresh', async () => {
326
+ const events: string[] = []
327
+ const sessions: (AtpSessionData | undefined)[] = []
328
+ const persistSession = (evt: AtpSessionEvent, sess?: AtpSessionData) => {
329
+ events.push(evt)
330
+ sessions.push(sess)
331
+ }
332
+
333
+ const agent = new AtpAgent({ service: server.url, persistSession })
334
+
335
+ // create an account and a session with it
336
+ await agent.createAccount({
337
+ handle: 'user6.test',
338
+ email: 'user6@test.com',
339
+ password: 'password',
340
+ })
341
+ if (!agent.session) {
342
+ throw new Error('No session created')
343
+ }
344
+ const session1 = agent.session
345
+ const origAccessJwt = session1.accessJwt
346
+
347
+ // patch the fetch handler to fake an expired token error on the next request
348
+ const tokenExpiredFetchHandler = async function (
349
+ httpUri: string,
350
+ httpMethod: string,
351
+ httpHeaders: Record<string, string>,
352
+ httpReqBody: unknown,
353
+ ): Promise<AtpAgentFetchHandlerResponse> {
354
+ if (httpHeaders.authorization === `Bearer ${origAccessJwt}`) {
355
+ return {
356
+ status: 400,
357
+ headers: {},
358
+ body: { error: 'ExpiredToken' },
359
+ }
360
+ }
361
+ if (httpUri.includes('com.atproto.session.refresh')) {
362
+ return {
363
+ status: 500,
364
+ headers: {},
365
+ body: undefined,
366
+ }
367
+ }
368
+ return defaultFetchHandler(httpUri, httpMethod, httpHeaders, httpReqBody)
369
+ }
370
+
371
+ // put the agent through the auth flow
372
+ AtpAgent.configure({ fetch: tokenExpiredFetchHandler })
373
+ try {
374
+ await agent.api.app.bsky.feed.getTimeline()
375
+ throw new Error('Should have failed')
376
+ } catch (e: any) {
377
+ // the original error passes through
378
+ expect(e.status).toEqual(400)
379
+ expect(e.error).toEqual('ExpiredToken')
380
+ }
381
+ AtpAgent.configure({ fetch: defaultFetchHandler })
382
+
383
+ // still has session because it wasn't invalidated
384
+ expect(agent.hasSession).toEqual(true)
385
+
386
+ expect(events.length).toEqual(1)
387
+ expect(events[0]).toEqual('create')
388
+ expect(sessions.length).toEqual(1)
389
+ expect(sessions[0]?.accessJwt).toEqual(origAccessJwt)
390
+ })
391
+ })
@@ -3,22 +3,18 @@ import {
3
3
  runTestServer,
4
4
  TestServerInfo,
5
5
  } from '@atproto/pds/tests/_util'
6
- import {
7
- sessionClient,
8
- SessionServiceClient,
9
- ComAtprotoAccountCreate,
10
- } from '..'
6
+ import { AtpAgent, ComAtprotoAccountCreate } from '..'
11
7
 
12
8
  describe('errors', () => {
13
9
  let server: TestServerInfo
14
- let client: SessionServiceClient
10
+ let client: AtpAgent
15
11
  let close: CloseFn
16
12
 
17
13
  beforeAll(async () => {
18
14
  server = await runTestServer({
19
15
  dbPostgresSchema: 'known_errors',
20
16
  })
21
- client = sessionClient.service(server.url)
17
+ client = new AtpAgent({ service: server.url })
22
18
  close = server.close
23
19
  })
24
20
 
@@ -27,7 +23,7 @@ describe('errors', () => {
27
23
  })
28
24
 
29
25
  it('constructs the correct error instance', async () => {
30
- const res = client.com.atproto.account.create({
26
+ const res = client.api.com.atproto.account.create({
31
27
  handle: 'admin',
32
28
  email: 'admin@test.com',
33
29
  password: 'password',