@atcute/client 3.0.1 → 4.0.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.
@@ -1,65 +1,74 @@
1
- import type { At, ComAtprotoServerCreateSession } from './lexicons.js';
1
+ import { getPdsEndpoint, type DidDocument } from '@atcute/identity';
2
+ import type { Did } from '@atcute/lexicons';
3
+ import type { InferXRPCBodyOutput } from '@atcute/lexicons/validations';
2
4
 
5
+ import type { ComAtprotoServerCreateSession } from '@atcute/atproto';
6
+
7
+ import { Client, ClientResponseError, isXRPCErrorPayload, ok } from './client.js';
3
8
  import { simpleFetchHandler, type FetchHandlerObject } from './fetch-handler.js';
4
- import { XRPC, XRPCError } from './rpc.js';
5
9
 
6
- import { getPdsEndpoint, type DidDocument } from './utils/did.js';
7
10
  import { decodeJwt } from './utils/jwt.js';
8
11
 
9
- /** Interface for the decoded access token, for convenience */
12
+ /**
13
+ * represents the decoded access token, for convenience
14
+ * @deprecated
15
+ */
10
16
  export interface AtpAccessJwt {
11
- /** Access token scope, app password returns a different scope. */
17
+ /** access token scope */
12
18
  scope:
13
19
  | 'com.atproto.access'
14
20
  | 'com.atproto.appPass'
15
21
  | 'com.atproto.appPassPrivileged'
16
22
  | 'com.atproto.signupQueued'
17
23
  | 'com.atproto.takendown';
18
- /** Account DID */
19
- sub: At.Did;
20
- /** Expiration time */
24
+ /** account DID */
25
+ sub: Did;
26
+ /** expiration time in Unix seconds */
21
27
  exp: number;
22
- /** Creation/issued time */
28
+ /** token issued time in Unix seconds */
23
29
  iat: number;
24
30
  }
25
31
 
26
- /** Interface for the decoded refresh token, for convenience */
32
+ /**
33
+ * represents the the decoded refresh token, for convenience
34
+ * @deprecated
35
+ */
27
36
  export interface AtpRefreshJwt {
28
- /** Refresh token scope */
37
+ /** refresh token scope */
29
38
  scope: 'com.atproto.refresh';
30
- /** ID of this refresh token */
39
+ /** unique identifier for this session */
31
40
  jti: string;
32
- /** Account DID */
33
- sub: At.Did;
34
- /** Intended audience of this refresh token, in DID */
35
- aud: At.Did;
36
- /** Expiration time */
41
+ /** account DID */
42
+ sub: Did;
43
+ /** intended audience of this refresh token, in DID */
44
+ aud: Did;
45
+ /** token expiration time in seconds */
37
46
  exp: number;
38
- /** Creation/issued time */
47
+ /** token issued time in seconds */
39
48
  iat: number;
40
49
  }
41
50
 
42
- /** Saved session data, this can be reused again for next time. */
51
+ /** session data, can be persisted and reused */
43
52
  export interface AtpSessionData {
44
- /** Refresh token */
53
+ /** refresh token */
45
54
  refreshJwt: string;
46
- /** Access token */
55
+ /** access token */
47
56
  accessJwt: string;
48
- /** Account handle */
57
+ /** account handle */
49
58
  handle: string;
50
- /** Account DID */
51
- did: At.Did;
59
+ /** account DID */
60
+ did: Did;
52
61
  /** PDS endpoint found in the DID document, this will be used as the service URI if provided */
53
62
  pdsUri?: string;
54
- /** Email address of the account, might not be available if on app password */
63
+ /** email address of the account, might not be available if on app password */
55
64
  email?: string;
56
- /** If the email address has been confirmed or not */
65
+ /** whether the email address has been confirmed or not */
57
66
  emailConfirmed?: boolean;
58
- /** If the account has email-based two-factor authentication enabled */
67
+ /** whether the account has email-based two-factor authentication enabled */
59
68
  emailAuthFactor?: boolean;
60
- /** Whether the account is active (not deactivated, taken down, or suspended) */
69
+ /** whether the account is active (not deactivated, taken down, or suspended) */
61
70
  active: boolean;
62
- /** Possible reason for why the account is inactive */
71
+ /** possible reason for why the account is inactive */
63
72
  inactiveStatus?: string;
64
73
  }
65
74
 
@@ -67,29 +76,36 @@ export interface CredentialManagerOptions {
67
76
  /** PDS server URL */
68
77
  service: string;
69
78
 
70
- /** Custom fetch function */
71
- fetch?: typeof globalThis.fetch;
79
+ /** custom fetch function */
80
+ fetch?: typeof fetch;
72
81
 
73
- /** Function that gets called if the session turned out to have expired during an XRPC request */
82
+ /** function called when the session expires and can't be refreshed */
74
83
  onExpired?: (session: AtpSessionData) => void;
75
- /** Function that gets called if the session has been refreshed during an XRPC request */
84
+ /** function called after a successful session refresh */
76
85
  onRefresh?: (session: AtpSessionData) => void;
77
- /** Function that gets called if the session object has been refreshed */
86
+ /** function called whenever the session object is updated (login, resume, refresh) */
78
87
  onSessionUpdate?: (session: AtpSessionData) => void;
79
88
  }
80
89
 
81
90
  export class CredentialManager implements FetchHandlerObject {
91
+ /** service URL to make authentication requests with */
82
92
  readonly serviceUrl: string;
93
+ /** fetch implementation */
83
94
  fetch: typeof fetch;
84
95
 
85
- #server: XRPC;
96
+ /** internal client instance for making authentication requests */
97
+ #server: Client;
98
+ /** holds a promise for the current refresh operation, used for debouncing */
86
99
  #refreshSessionPromise: Promise<void> | undefined;
87
100
 
101
+ /** callback for session expiration */
88
102
  #onExpired: CredentialManagerOptions['onExpired'];
103
+ /** callback for successful session refresh */
89
104
  #onRefresh: CredentialManagerOptions['onRefresh'];
105
+ /** callback for session updates */
90
106
  #onSessionUpdate: CredentialManagerOptions['onSessionUpdate'];
91
107
 
92
- /** Current session state */
108
+ /** current active session, undefined if not authenticated */
93
109
  session?: AtpSessionData;
94
110
 
95
111
  constructor({
@@ -102,13 +118,14 @@ export class CredentialManager implements FetchHandlerObject {
102
118
  this.serviceUrl = service;
103
119
  this.fetch = _fetch;
104
120
 
105
- this.#server = new XRPC({ handler: simpleFetchHandler({ service: service, fetch: _fetch }) });
121
+ this.#server = new Client({ handler: simpleFetchHandler({ service, fetch: _fetch }) });
106
122
 
107
123
  this.#onRefresh = onRefresh;
108
124
  this.#onExpired = onExpired;
109
125
  this.#onSessionUpdate = onSessionUpdate;
110
126
  }
111
127
 
128
+ /** service URL to make actual API requests with */
112
129
  get dispatchUrl() {
113
130
  return this.session?.pdsUri ?? this.serviceUrl;
114
131
  }
@@ -138,13 +155,14 @@ export class CredentialManager implements FetchHandlerObject {
138
155
  return initialResponse;
139
156
  }
140
157
 
141
- // Return initial response if:
142
- // - refreshSession returns expired
143
- // - Body stream has been consumed
158
+ // return initial response if:
159
+ // - the above refreshSession failed and cleared the session
160
+ // - provided request body was a stream, which can't be resent once consumed
144
161
  if (!this.session || init.body instanceof ReadableStream) {
145
162
  return initialResponse;
146
163
  }
147
164
 
165
+ // set the new token and retry the request
148
166
  headers.set('authorization', `Bearer ${this.session.accessJwt}`);
149
167
 
150
168
  return await (0, this.fetch)(url, { ...init, headers });
@@ -158,33 +176,36 @@ export class CredentialManager implements FetchHandlerObject {
158
176
 
159
177
  async #refreshSessionInner(): Promise<void> {
160
178
  const currentSession = this.session;
161
-
162
179
  if (!currentSession) {
163
180
  return;
164
181
  }
165
182
 
166
- try {
167
- const { data } = await this.#server.call('com.atproto.server.refreshSession', {
168
- headers: {
169
- authorization: `Bearer ${currentSession.refreshJwt}`,
170
- },
171
- });
172
-
173
- this.#updateSession({ ...currentSession, ...data });
174
- this.#onRefresh?.(this.session!);
175
- } catch (err) {
176
- if (err instanceof XRPCError) {
177
- const kind = err.kind;
183
+ const response = await this.#server.post('com.atproto.server.refreshSession', {
184
+ headers: {
185
+ authorization: `Bearer ${currentSession.refreshJwt}`,
186
+ },
187
+ });
178
188
 
179
- if (kind === 'ExpiredToken' || kind === 'InvalidToken') {
189
+ if (!response.ok) {
190
+ switch (response.data.error) {
191
+ case 'ExpiredToken':
192
+ case 'InvalidToken': {
180
193
  this.session = undefined;
181
194
  this.#onExpired?.(currentSession);
195
+ break;
182
196
  }
183
197
  }
198
+
199
+ throw new ClientResponseError(response);
184
200
  }
201
+
202
+ this.#updateSession({ ...currentSession, ...response.data });
203
+ this.#onRefresh?.(this.session!);
185
204
  }
186
205
 
187
- #updateSession(raw: ComAtprotoServerCreateSession.Output): AtpSessionData {
206
+ #updateSession(
207
+ raw: InferXRPCBodyOutput<ComAtprotoServerCreateSession.mainSchema['output']>,
208
+ ): AtpSessionData {
188
209
  const didDoc = raw.didDoc as DidDocument | undefined;
189
210
 
190
211
  let pdsUri: string | undefined;
@@ -192,7 +213,7 @@ export class CredentialManager implements FetchHandlerObject {
192
213
  pdsUri = getPdsEndpoint(didDoc);
193
214
  }
194
215
 
195
- const newSession = {
216
+ const newSession: AtpSessionData = {
196
217
  accessJwt: raw.accessJwt,
197
218
  refreshJwt: raw.refreshJwt,
198
219
  handle: raw.handle,
@@ -200,7 +221,7 @@ export class CredentialManager implements FetchHandlerObject {
200
221
  pdsUri: pdsUri,
201
222
  email: raw.email,
202
223
  emailConfirmed: raw.emailConfirmed,
203
- emailAuthFactor: raw.emailConfirmed,
224
+ emailAuthFactor: raw.emailAuthFactor,
204
225
  active: raw.active ?? true,
205
226
  inactiveStatus: raw.status,
206
227
  };
@@ -212,16 +233,16 @@ export class CredentialManager implements FetchHandlerObject {
212
233
  }
213
234
 
214
235
  /**
215
- * Resume a saved session
216
- * @param session Session information, taken from `AtpAuth#session` after login
236
+ * resume from a persisted session
237
+ * @param session session data, taken from `AtpAuth#session` after login
217
238
  */
218
239
  async resume(session: AtpSessionData): Promise<AtpSessionData> {
219
- const now = Date.now() / 1000 + 60 * 5;
240
+ const now = Date.now() / 1_000 + 60 * 5;
220
241
 
221
242
  const refreshToken = decodeJwt(session.refreshJwt) as AtpRefreshJwt;
222
243
 
223
244
  if (now >= refreshToken.exp) {
224
- throw new XRPCError(401, { kind: 'InvalidToken' });
245
+ throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
225
246
  }
226
247
 
227
248
  const accessToken = decodeJwt(session.accessJwt) as AtpAccessJwt;
@@ -230,62 +251,69 @@ export class CredentialManager implements FetchHandlerObject {
230
251
  if (now >= accessToken.exp) {
231
252
  await this.#refreshSession();
232
253
  } else {
233
- const promise = this.#server.get('com.atproto.server.getSession', {
234
- headers: {
235
- authorization: `Bearer ${session.accessJwt}`,
254
+ const promise = ok(
255
+ this.#server.get('com.atproto.server.getSession', {
256
+ headers: {
257
+ authorization: `Bearer ${session.accessJwt}`,
258
+ },
259
+ }),
260
+ );
261
+
262
+ promise.then(
263
+ (next) => {
264
+ const existing = this.session;
265
+ if (!existing || existing.did !== next.did) {
266
+ return;
267
+ }
268
+
269
+ this.#updateSession({ ...existing, ...next });
236
270
  },
237
- });
238
-
239
- promise.then((response) => {
240
- const existing = this.session;
241
- const next = response.data;
242
-
243
- if (!existing) {
244
- return;
245
- }
246
-
247
- this.#updateSession({ ...existing, ...next });
248
- });
271
+ (_err) => {
272
+ // ignore error
273
+ },
274
+ );
249
275
  }
250
276
 
251
277
  if (!this.session) {
252
- throw new XRPCError(401, { kind: 'InvalidToken' });
278
+ throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
253
279
  }
254
280
 
255
281
  return this.session;
256
282
  }
257
283
 
258
284
  /**
259
- * Perform a login operation
260
- * @param options Login options
261
- * @returns Session data that can be saved for later
285
+ * sign in to an account
286
+ * @param options credential options
287
+ * @returns session data
262
288
  */
263
289
  async login(options: AuthLoginOptions): Promise<AtpSessionData> {
264
290
  // Reset the session
265
291
  this.session = undefined;
266
292
 
267
- const res = await this.#server.call('com.atproto.server.createSession', {
268
- data: {
269
- identifier: options.identifier,
270
- password: options.password,
271
- authFactorToken: options.code,
272
- allowTakendown: options.allowTakendown,
273
- },
274
- });
293
+ const session = await ok(
294
+ this.#server.post('com.atproto.server.createSession', {
295
+ input: {
296
+ identifier: options.identifier,
297
+ password: options.password,
298
+ authFactorToken: options.code,
299
+ allowTakendown: options.allowTakendown,
300
+ },
301
+ }),
302
+ );
275
303
 
276
- return this.#updateSession(res.data);
304
+ return this.#updateSession(session);
277
305
  }
278
306
  }
279
307
 
280
- /** Login options */
308
+ /** credentials */
281
309
  export interface AuthLoginOptions {
282
- /** What account to login as, this could be domain handle, DID, or email address */
310
+ /** what account to login as, this could be domain handle, DID, or email address */
283
311
  identifier: string;
284
- /** Account password */
312
+ /** account password */
285
313
  password: string;
286
- /** Two-factor authentication code */
314
+ /** two-factor authentication code, if email TOTP is enabled */
287
315
  code?: string;
288
- /** Allow signing in even if the account has been taken down, */
316
+ /** allow signing in even if the account has been taken down */
289
317
  allowTakendown?: boolean;
290
318
  }
291
319
 
@@ -298,6 +326,9 @@ const isExpiredTokenResponse = async (response: Response): Promise<boolean> => {
298
326
  return false;
299
327
  }
300
328
 
329
+ // this is nasty as it relies heavily on what the PDS returns, but avoiding
330
+ // cloning and reading the request as much as possible is better.
331
+
301
332
  // {"error":"ExpiredToken","message":"Token has expired"}
302
333
  // {"error":"ExpiredToken","message":"Token is expired"}
303
334
  if (extractContentLength(response.headers) > 54 * 1.5) {
@@ -305,8 +336,10 @@ const isExpiredTokenResponse = async (response: Response): Promise<boolean> => {
305
336
  }
306
337
 
307
338
  try {
308
- const { error, message } = await response.clone().json();
309
- return error === 'ExpiredToken' && (typeof message === 'string' || message === undefined);
339
+ const data = await response.clone().json();
340
+ if (isXRPCErrorPayload(data)) {
341
+ return data.error === 'ExpiredToken';
342
+ }
310
343
  } catch {}
311
344
 
312
345
  return false;
package/lib/env.d.ts ADDED
@@ -0,0 +1 @@
1
+ import type {} from '@atcute/atproto';
@@ -1,7 +1,7 @@
1
- /** Fetch handler function */
1
+ /** fetch handler function */
2
2
  export type FetchHandler = (pathname: string, init: RequestInit) => Promise<Response>;
3
3
 
4
- /** Fetch handler in an object */
4
+ /** fetch handler in an object */
5
5
  export interface FetchHandlerObject {
6
6
  handle(this: FetchHandlerObject, pathname: string, init: RequestInit): Promise<Response>;
7
7
  }
@@ -25,6 +25,6 @@ export const simpleFetchHandler = ({
25
25
  }: SimpleFetchHandlerOptions): FetchHandler => {
26
26
  return async (pathname, init) => {
27
27
  const url = new URL(pathname, service);
28
- return _fetch(url, init);
28
+ return await _fetch(url, init);
29
29
  };
30
30
  };
package/lib/index.ts CHANGED
@@ -1,3 +1,3 @@
1
- export * from './rpc.js';
1
+ export * from './client.js';
2
2
  export * from './fetch-handler.js';
3
3
  export * from './credential-manager.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@atcute/client",
4
- "version": "3.0.1",
4
+ "version": "4.0.0",
5
5
  "description": "lightweight and cute API client for AT Protocol",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -15,22 +15,23 @@
15
15
  "!lib/**/*.test.ts"
16
16
  ],
17
17
  "exports": {
18
- ".": "./dist/index.js",
19
- "./lexicons": "./dist/lexicons.js",
20
- "./utils/did": "./dist/utils/did.js",
21
- "./utils/http": "./dist/utils/http.js",
22
- "./utils/jwt": "./dist/utils/jwt.js"
18
+ ".": "./dist/index.js"
19
+ },
20
+ "dependencies": {
21
+ "@atcute/identity": "^1.0.0",
22
+ "@atcute/lexicons": "^1.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@vitest/coverage-v8": "^3.0.4",
26
- "vitest": "^3.0.4",
27
- "@atcute/internal-dev-env": "^1.0.1",
28
- "@atcute/lex-cli": "^1.1.0"
25
+ "@vitest/coverage-v8": "^3.1.3",
26
+ "vitest": "^3.1.3",
27
+ "@atcute/bluesky": "^3.0.0",
28
+ "@atcute/atproto": "^3.0.0",
29
+ "@atcute/internal-dev-env": "^1.0.1"
29
30
  },
30
31
  "scripts": {
31
32
  "build": "tsc --project tsconfig.build.json",
33
+ "postbuild": "node ./scripts/ensure-files.js",
32
34
  "test": "vitest run --coverage",
33
- "generate": "./scripts/generate-lexicons.sh",
34
35
  "prepublish": "rm -rf dist; pnpm run build"
35
36
  }
36
37
  }