@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.
@@ -0,0 +1,33 @@
1
+ export declare type AtpSessionEvent = 'create' | 'create-failed' | 'update' | 'expired';
2
+ export interface AtpSessionData {
3
+ refreshJwt: string;
4
+ accessJwt: string;
5
+ handle: string;
6
+ did: string;
7
+ }
8
+ export declare type AtpPersistSessionHandler = (evt: AtpSessionEvent, session: AtpSessionData | undefined) => void | Promise<void>;
9
+ export interface AtpAgentOpts {
10
+ service: string | URL;
11
+ persistSession?: AtpPersistSessionHandler;
12
+ }
13
+ export interface AtpAgentCreateAccountOpts {
14
+ email: string;
15
+ password: string;
16
+ handle: string;
17
+ inviteCode?: string;
18
+ }
19
+ export interface AtpAgentLoginOpts {
20
+ identifier: string;
21
+ password: string;
22
+ }
23
+ declare type AtpAgentFetchHeaders = Record<string, string>;
24
+ export interface AtpAgentFetchHandlerResponse {
25
+ status: number;
26
+ headers: Record<string, string>;
27
+ body: any;
28
+ }
29
+ export declare type AptAgentFetchHandler = (httpUri: string, httpMethod: string, httpHeaders: AtpAgentFetchHeaders, httpReqBody: any) => Promise<AtpAgentFetchHandlerResponse>;
30
+ export interface AtpAgentGlobalOpts {
31
+ fetch: AptAgentFetchHandler;
32
+ }
33
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/api",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "codegen": "lex gen-api ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/*",
package/src/agent.ts ADDED
@@ -0,0 +1,305 @@
1
+ import { ErrorResponseBody, errorResponseBody } from '@atproto/xrpc'
2
+ import { defaultFetchHandler } from '@atproto/xrpc'
3
+ import {
4
+ AtpBaseClient,
5
+ AtpServiceClient,
6
+ ComAtprotoAccountCreate,
7
+ ComAtprotoSessionCreate,
8
+ ComAtprotoSessionGet,
9
+ ComAtprotoSessionRefresh,
10
+ } from './client'
11
+ import {
12
+ AtpSessionData,
13
+ AtpAgentCreateAccountOpts,
14
+ AtpAgentLoginOpts,
15
+ AptAgentFetchHandler,
16
+ AtpAgentFetchHandlerResponse,
17
+ AtpAgentGlobalOpts,
18
+ AtpPersistSessionHandler,
19
+ AtpAgentOpts,
20
+ } from './types'
21
+
22
+ const REFRESH_SESSION = 'com.atproto.session.refresh'
23
+
24
+ /**
25
+ * An ATP "Agent"
26
+ * Manages session token lifecycles and provides convenience methods.
27
+ */
28
+ export class AtpAgent {
29
+ service: URL
30
+ api: AtpServiceClient
31
+ session?: AtpSessionData
32
+
33
+ private _baseClient: AtpBaseClient
34
+ private _persistSession?: AtpPersistSessionHandler
35
+ private _refreshSessionPromise: Promise<void> | undefined
36
+
37
+ /**
38
+ * The `fetch` implementation; must be implemented for your platform.
39
+ */
40
+ static fetch: AptAgentFetchHandler | undefined = defaultFetchHandler
41
+
42
+ /**
43
+ * Configures the API globally.
44
+ */
45
+ static configure(opts: AtpAgentGlobalOpts) {
46
+ AtpAgent.fetch = opts.fetch
47
+ }
48
+
49
+ constructor(opts: AtpAgentOpts) {
50
+ this.service =
51
+ opts.service instanceof URL ? opts.service : new URL(opts.service)
52
+ this._persistSession = opts.persistSession
53
+
54
+ // create an ATP client instance for this agent
55
+ this._baseClient = new AtpBaseClient()
56
+ this._baseClient.xrpc.fetch = this._fetch.bind(this) // patch its fetch implementation
57
+ this.api = this._baseClient.service(opts.service)
58
+ }
59
+
60
+ /**
61
+ * Is there any active session?
62
+ */
63
+ get hasSession() {
64
+ return !!this.session
65
+ }
66
+
67
+ /**
68
+ * Sets the "Persist Session" method which can be used to store access tokens
69
+ * as they change.
70
+ */
71
+ setPersistSessionHandler(handler?: AtpPersistSessionHandler) {
72
+ this._persistSession = handler
73
+ }
74
+
75
+ /**
76
+ * Create a new account and hydrate its session in this agent.
77
+ */
78
+ async createAccount(
79
+ opts: AtpAgentCreateAccountOpts,
80
+ ): Promise<ComAtprotoAccountCreate.Response> {
81
+ try {
82
+ const res = await this.api.com.atproto.account.create({
83
+ handle: opts.handle,
84
+ password: opts.password,
85
+ email: opts.email,
86
+ inviteCode: opts.inviteCode,
87
+ })
88
+ this.session = {
89
+ accessJwt: res.data.accessJwt,
90
+ refreshJwt: res.data.refreshJwt,
91
+ handle: res.data.handle,
92
+ did: res.data.did,
93
+ }
94
+ return res
95
+ } catch (e) {
96
+ this.session = undefined
97
+ throw e
98
+ } finally {
99
+ if (this.session) {
100
+ this._persistSession?.('create', this.session)
101
+ } else {
102
+ this._persistSession?.('create-failed', undefined)
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Start a new session with this agent.
109
+ */
110
+ async login(
111
+ opts: AtpAgentLoginOpts,
112
+ ): Promise<ComAtprotoSessionCreate.Response> {
113
+ try {
114
+ const res = await this.api.com.atproto.session.create({
115
+ identifier: opts.identifier,
116
+ password: opts.password,
117
+ })
118
+ this.session = {
119
+ accessJwt: res.data.accessJwt,
120
+ refreshJwt: res.data.refreshJwt,
121
+ handle: res.data.handle,
122
+ did: res.data.did,
123
+ }
124
+ return res
125
+ } catch (e) {
126
+ this.session = undefined
127
+ throw e
128
+ } finally {
129
+ if (this.session) {
130
+ this._persistSession?.('create', this.session)
131
+ } else {
132
+ this._persistSession?.('create-failed', undefined)
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Resume a pre-existing session with this agent.
139
+ */
140
+ async resumeSession(
141
+ session: AtpSessionData,
142
+ ): Promise<ComAtprotoSessionGet.Response> {
143
+ try {
144
+ this.session = session
145
+ const res = await this.api.com.atproto.session.get()
146
+ if (!res.success || res.data.did !== this.session.did) {
147
+ throw new Error('Invalid session')
148
+ }
149
+ return res
150
+ } catch (e) {
151
+ this.session = undefined
152
+ throw e
153
+ } finally {
154
+ if (this.session) {
155
+ this._persistSession?.('create', this.session)
156
+ } else {
157
+ this._persistSession?.('create-failed', undefined)
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Internal helper to add authorization headers to requests.
164
+ */
165
+ private _addAuthHeader(reqHeaders: Record<string, string>) {
166
+ if (!reqHeaders.authorization && this.session?.accessJwt) {
167
+ return {
168
+ ...reqHeaders,
169
+ authorization: `Bearer ${this.session.accessJwt}`,
170
+ }
171
+ }
172
+ return reqHeaders
173
+ }
174
+
175
+ /**
176
+ * Internal fetch handler which adds access-token management
177
+ */
178
+ private async _fetch(
179
+ reqUri: string,
180
+ reqMethod: string,
181
+ reqHeaders: Record<string, string>,
182
+ reqBody: any,
183
+ ): Promise<AtpAgentFetchHandlerResponse> {
184
+ if (!AtpAgent.fetch) {
185
+ throw new Error('AtpAgent fetch() method not configured')
186
+ }
187
+
188
+ // wait for any active session-refreshes to finish
189
+ await this._refreshSessionPromise
190
+
191
+ // send the request
192
+ let res = await AtpAgent.fetch(
193
+ reqUri,
194
+ reqMethod,
195
+ this._addAuthHeader(reqHeaders),
196
+ reqBody,
197
+ )
198
+
199
+ // handle session-refreshes as needed
200
+ if (isErrorResponse(res, ['ExpiredToken']) && this.session?.refreshJwt) {
201
+ // attempt refresh
202
+ await this._refreshSession()
203
+
204
+ // resend the request with the new access token
205
+ res = await AtpAgent.fetch(
206
+ reqUri,
207
+ reqMethod,
208
+ this._addAuthHeader(reqHeaders),
209
+ reqBody,
210
+ )
211
+ }
212
+
213
+ return res
214
+ }
215
+
216
+ /**
217
+ * Internal helper to refresh sessions
218
+ * - Wraps the actual implementation in a promise-guard to ensure only
219
+ * one refresh is attempted at a time.
220
+ */
221
+ private async _refreshSession() {
222
+ if (this._refreshSessionPromise) {
223
+ return this._refreshSessionPromise
224
+ }
225
+ this._refreshSessionPromise = this._refreshSessionInner()
226
+ try {
227
+ await this._refreshSessionPromise
228
+ } finally {
229
+ this._refreshSessionPromise = undefined
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Internal helper to refresh sessions (actual behavior)
235
+ */
236
+ private async _refreshSessionInner() {
237
+ if (!AtpAgent.fetch) {
238
+ throw new Error('AtpAgent fetch() method not configured')
239
+ }
240
+ if (!this.session?.refreshJwt) {
241
+ return
242
+ }
243
+
244
+ // send the refresh request
245
+ const url = new URL(this.service.origin)
246
+ url.pathname = `/xrpc/${REFRESH_SESSION}`
247
+ const res = await AtpAgent.fetch(
248
+ url.toString(),
249
+ 'POST',
250
+ {
251
+ authorization: `Bearer ${this.session.refreshJwt}`,
252
+ },
253
+ undefined,
254
+ )
255
+
256
+ if (isErrorResponse(res, ['ExpiredToken', 'InvalidToken'])) {
257
+ // failed due to a bad refresh token
258
+ this.session = undefined
259
+ this._persistSession?.('expired', undefined)
260
+ } else if (isNewSessionObject(this._baseClient, res.body)) {
261
+ // succeeded, update the session
262
+ this.session = {
263
+ accessJwt: res.body.accessJwt,
264
+ refreshJwt: res.body.refreshJwt,
265
+ handle: res.body.handle,
266
+ did: res.body.did,
267
+ }
268
+ this._persistSession?.('update', this.session)
269
+ }
270
+ // else: other failures should be ignored - the issue will
271
+ // propagate in the _fetch() handler's second attempt to run
272
+ // the request
273
+ }
274
+ }
275
+
276
+ function isErrorObject(v: unknown): v is ErrorResponseBody {
277
+ return errorResponseBody.safeParse(v).success
278
+ }
279
+
280
+ function isErrorResponse(
281
+ res: AtpAgentFetchHandlerResponse,
282
+ errorNames: string[],
283
+ ): boolean {
284
+ if (res.status !== 400) {
285
+ return false
286
+ }
287
+ if (!isErrorObject(res.body)) {
288
+ return false
289
+ }
290
+ return (
291
+ typeof res.body.error === 'string' && errorNames.includes(res.body.error)
292
+ )
293
+ }
294
+
295
+ function isNewSessionObject(
296
+ client: AtpBaseClient,
297
+ v: unknown,
298
+ ): v is ComAtprotoSessionRefresh.OutputSchema {
299
+ try {
300
+ client.xrpc.lex.assertValidXrpcOutput('com.atproto.session.refresh', v)
301
+ return true
302
+ } catch {
303
+ return false
304
+ }
305
+ }