@atproto/lex-client 0.0.10 → 0.0.11

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/response.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  ResultSuccess,
8
8
  } from '@atproto/lex-schema'
9
9
  import {
10
+ XrpcAuthenticationError,
10
11
  XrpcResponseError,
11
12
  XrpcUpstreamError,
12
13
  isXrpcErrorPayload,
@@ -26,7 +27,7 @@ export type XrpcResponsePayload<M extends Procedure | Query> =
26
27
  *
27
28
  * @implements {ResultSuccess<XrpcResponse<M>>} for convenience in result handling contexts.
28
29
  */
29
- export class XrpcResponse<const M extends Procedure | Query>
30
+ export class XrpcResponse<M extends Procedure | Query>
30
31
  implements ResultSuccess<XrpcResponse<M>>
31
32
  {
32
33
  /** @see {@link ResultSuccess.success} */
@@ -63,7 +64,8 @@ export class XrpcResponse<const M extends Procedure | Query>
63
64
  /**
64
65
  * @throws {XrpcResponseError} in case of (valid) XRPC error responses. Use
65
66
  * {@link XrpcResponseError.matchesSchema} to narrow the error type based on
66
- * the method's declared error schema.
67
+ * the method's declared error schema. This can be narrowed further as a
68
+ * {@link XrpcAuthenticationError} if the error is an authentication error.
67
69
  * @throws {XrpcUpstreamError} when the response is not a valid XRPC
68
70
  * response, or if the response does not conform to the method's schema.
69
71
  */
@@ -79,39 +81,49 @@ export class XrpcResponse<const M extends Procedure | Query>
79
81
  // @NOTE redirect is set to 'follow', so we shouldn't get 3xx responses here
80
82
  if (response.status < 200 || response.status >= 300) {
81
83
  // Always parse json for error responses
82
- const payload = await readPayload(response, { parse: true })
84
+ const payload = await readPayload(response, { parse: true }).catch(
85
+ (cause) => {
86
+ throw new XrpcUpstreamError(
87
+ method,
88
+ response,
89
+ null,
90
+ 'Unable to parse response payload',
91
+ { cause },
92
+ )
93
+ },
94
+ )
83
95
 
96
+ // Properly formatted XRPC error response ?
84
97
  if (response.status >= 400 && isXrpcErrorPayload(payload)) {
85
- throw new XrpcResponseError(
86
- method,
87
- response.status,
88
- response.headers,
89
- payload,
90
- )
91
- }
92
-
93
- if (response.status >= 500) {
94
- throw new XrpcUpstreamError(
95
- 'UpstreamFailure',
96
- `Upstream server encountered an error`,
97
- response,
98
- payload,
99
- )
98
+ throw response.status === 401
99
+ ? new XrpcAuthenticationError<M>(method, response, payload)
100
+ : new XrpcResponseError<M>(method, response, payload)
100
101
  }
101
102
 
103
+ // Invalid XRPC response (we probably did not hit an XRPC implementation)
102
104
  throw new XrpcUpstreamError(
103
- 'InvalidResponse',
104
- response.status >= 400
105
- ? `Upstream server returned an invalid response payload`
106
- : `Upstream server returned an invalid status code`,
105
+ method,
107
106
  response,
108
107
  payload,
108
+ response.status >= 500
109
+ ? 'Upstream server encountered an error'
110
+ : response.status >= 400
111
+ ? 'Invalid response payload'
112
+ : 'Invalid response status code',
109
113
  )
110
114
  }
111
115
 
112
116
  // Only parse json if the schema expects it
113
117
  const payload = await readPayload(response, {
114
118
  parse: shouldParse(method),
119
+ }).catch((cause) => {
120
+ throw new XrpcUpstreamError(
121
+ method,
122
+ response,
123
+ null,
124
+ 'Unable to parse response payload',
125
+ { cause },
126
+ )
115
127
  })
116
128
 
117
129
  // Response is successful (2xx). Validate payload (data and encoding) against schema.
@@ -119,22 +131,22 @@ export class XrpcResponse<const M extends Procedure | Query>
119
131
  // Schema expects no payload
120
132
  if (payload) {
121
133
  throw new XrpcUpstreamError(
122
- 'InvalidResponse',
123
- `Expected response with no body, got ${payload.encoding}`,
134
+ method,
124
135
  response,
125
136
  payload,
137
+ `Expected response with no body, got ${payload.encoding}`,
126
138
  )
127
139
  }
128
140
  } else {
129
141
  // Schema expects a payload
130
142
  if (!payload || !method.output.matchesEncoding(payload.encoding)) {
131
143
  throw new XrpcUpstreamError(
132
- 'InvalidResponse',
144
+ method,
145
+ response,
146
+ payload,
133
147
  payload
134
148
  ? `Expected ${method.output.encoding} response, got ${payload.encoding}`
135
149
  : `Expected non-empty response with content-type ${method.output.encoding}`,
136
- response,
137
- payload,
138
150
  )
139
151
  }
140
152
 
@@ -144,10 +156,10 @@ export class XrpcResponse<const M extends Procedure | Query>
144
156
 
145
157
  if (!result.success) {
146
158
  throw new XrpcUpstreamError(
147
- 'InvalidResponse',
148
- `Response validation failed: ${result.reason.message}`,
159
+ method,
149
160
  response,
150
161
  payload,
162
+ `Response validation failed: ${result.reason.message}`,
151
163
  { cause: result.reason },
152
164
  )
153
165
  }
@@ -204,22 +216,12 @@ async function readPayload(
204
216
  // to @ipld/dag-json)
205
217
  const text = await response.text()
206
218
 
207
- try {
208
- // @NOTE Using `lexParse(text)` (instead of `jsonToLex(json)`) here as
209
- // using a reviver function during JSON.parse should be faster than
210
- // parsing to JSON then converting to Lex (?)
219
+ // @NOTE Using `lexParse(text)` (instead of `jsonToLex(json)`) here as
220
+ // using a reviver function during JSON.parse should be faster than
221
+ // parsing to JSON then converting to Lex (?)
211
222
 
212
- // @TODO verify statement above
213
- return { encoding, body: lexParse(text) }
214
- } catch (cause) {
215
- throw new XrpcUpstreamError(
216
- 'InvalidResponse',
217
- 'Invalid JSON response body',
218
- response,
219
- null,
220
- { cause },
221
- )
222
- }
223
+ // @TODO verify statement above
224
+ return { encoding, body: lexParse(text) }
223
225
  }
224
226
 
225
227
  return { encoding, body: new Uint8Array(await response.arrayBuffer()) }
@@ -0,0 +1,227 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseWWWAuthenticateHeader } from './www-authenticate.js'
3
+
4
+ describe(parseWWWAuthenticateHeader, () => {
5
+ describe('auth-params', () => {
6
+ it('parses single unquoted auth param', () => {
7
+ expect(parseWWWAuthenticateHeader('Bearer realm=example.com')).toEqual({
8
+ Bearer: { realm: 'example.com' },
9
+ })
10
+ })
11
+
12
+ it('parses single quoted auth param', () => {
13
+ expect(parseWWWAuthenticateHeader('Bearer realm="example.com"')).toEqual({
14
+ Bearer: { realm: 'example.com' },
15
+ })
16
+ })
17
+
18
+ it('parses quoted values with spaces', () => {
19
+ expect(parseWWWAuthenticateHeader('Bearer realm="my realm"')).toEqual({
20
+ Bearer: { realm: 'my realm' },
21
+ })
22
+ })
23
+
24
+ it('parses quoted values with escaped double quotes', () => {
25
+ expect(
26
+ parseWWWAuthenticateHeader('Bearer realm="example\\"quoted\\""'),
27
+ ).toEqual({
28
+ Bearer: { realm: 'example"quoted"' },
29
+ })
30
+ })
31
+
32
+ it('parses quoted values with escaped backslash', () => {
33
+ expect(
34
+ parseWWWAuthenticateHeader('Bearer realm="path\\\\to\\\\file"'),
35
+ ).toEqual({
36
+ Bearer: { realm: 'path\\to\\file' },
37
+ })
38
+ })
39
+
40
+ it('parses param names with hyphens', () => {
41
+ expect(
42
+ parseWWWAuthenticateHeader('Bearer error-uri="https://example.com"'),
43
+ ).toEqual({
44
+ Bearer: { 'error-uri': 'https://example.com' },
45
+ })
46
+ })
47
+
48
+ it('parses param names with underscores', () => {
49
+ expect(
50
+ parseWWWAuthenticateHeader('Bearer error_description="test"'),
51
+ ).toEqual({
52
+ Bearer: { error_description: 'test' },
53
+ })
54
+ })
55
+
56
+ it('parses param with numeric value', () => {
57
+ expect(parseWWWAuthenticateHeader('Bearer max-age=3600')).toEqual({
58
+ Bearer: { 'max-age': '3600' },
59
+ })
60
+ })
61
+
62
+ it('parses empty quoted value', () => {
63
+ expect(parseWWWAuthenticateHeader('Bearer realm=""')).toEqual({
64
+ Bearer: { realm: '' },
65
+ })
66
+ })
67
+
68
+ it('parses Basic auth challenge', () => {
69
+ expect(
70
+ parseWWWAuthenticateHeader('Basic realm="Access to staging site"'),
71
+ ).toEqual({
72
+ Basic: { realm: 'Access to staging site' },
73
+ })
74
+ })
75
+
76
+ it('parses Bearer with realm', () => {
77
+ expect(
78
+ parseWWWAuthenticateHeader('Bearer realm="https://auth.example.com"'),
79
+ ).toEqual({
80
+ Bearer: { realm: 'https://auth.example.com' },
81
+ })
82
+ })
83
+
84
+ it('parses DPoP with algs param', () => {
85
+ expect(parseWWWAuthenticateHeader('DPoP algs="ES256 RS256"')).toEqual({
86
+ DPoP: { algs: 'ES256 RS256' },
87
+ })
88
+ })
89
+
90
+ it('parses Digest auth challenge', () => {
91
+ const result = parseWWWAuthenticateHeader(
92
+ 'Digest realm="digest-realm", nonce="abc123"',
93
+ )
94
+ expect(result).toEqual({
95
+ Digest: { realm: 'digest-realm', nonce: 'abc123' },
96
+ })
97
+ })
98
+
99
+ it('handle empty unquoted params', () => {
100
+ const result = parseWWWAuthenticateHeader('Bearer realm=')
101
+ expect(result).toEqual({ Bearer: { realm: '' } })
102
+ })
103
+
104
+ it('handle empty params', () => {
105
+ const result = parseWWWAuthenticateHeader('Bearer realm=""')
106
+ expect(result).toEqual({ Bearer: { realm: '' } })
107
+ })
108
+
109
+ it('treats scheme-only header as scheme with itself as token68', () => {
110
+ const result = parseWWWAuthenticateHeader('Basic')
111
+ expect(result).toEqual({ Basic: {} })
112
+ })
113
+
114
+ it('parses multiple challenges with commas and escaped quotes', () => {
115
+ const result = parseWWWAuthenticateHeader(
116
+ `Newauth realm="apps", type=1,\n\t title="Login to \\"apps\\"", Basic realm="simple"`,
117
+ )
118
+ expect(result).toEqual({
119
+ Newauth: {
120
+ realm: 'apps',
121
+ type: '1',
122
+ title: 'Login to "apps"',
123
+ },
124
+ Basic: { realm: 'simple' },
125
+ })
126
+ })
127
+
128
+ it('parses first challenge before comma', () => {
129
+ const result = parseWWWAuthenticateHeader(
130
+ 'Basic realm="foo", Bearer realm="bar"',
131
+ )
132
+ expect(result).toEqual({
133
+ Basic: { realm: 'foo' },
134
+ Bearer: { realm: 'bar' },
135
+ })
136
+ })
137
+ })
138
+
139
+ describe('edge cases', () => {
140
+ it('handles empty string', () => {
141
+ expect(parseWWWAuthenticateHeader('')).toEqual({})
142
+ })
143
+
144
+ it('handles whitespace-only string', () => {
145
+ expect(parseWWWAuthenticateHeader(' ')).toEqual({})
146
+ })
147
+
148
+ it('trims whitespace from header', () => {
149
+ expect(parseWWWAuthenticateHeader(' Bearer realm="test" ')).toEqual({
150
+ Bearer: { realm: 'test' },
151
+ })
152
+ })
153
+
154
+ it('handles commas as quoted param value', () => {
155
+ expect(
156
+ parseWWWAuthenticateHeader('Bearer realm="example, with, commas"'),
157
+ ).toEqual({ Bearer: { realm: 'example, with, commas' } })
158
+ })
159
+
160
+ it('handles multiple challenges with varying whitespace', () => {
161
+ expect(
162
+ parseWWWAuthenticateHeader(
163
+ ' Bearer realm="test" , Basic rr= ',
164
+ ),
165
+ ).toEqual({
166
+ Bearer: { realm: 'test' },
167
+ Basic: { rr: '' },
168
+ })
169
+ })
170
+ })
171
+
172
+ describe('invalid challenges', () => {
173
+ it('parses single challenge with no comma correctly', () => {
174
+ expect(
175
+ parseWWWAuthenticateHeader('Bearer realm="oauth" error="invalid"'),
176
+ ).toEqual(undefined)
177
+ })
178
+
179
+ it('ignores invalid challenges', () => {
180
+ expect(parseWWWAuthenticateHeader('Bearer realm="unclosed')).toEqual(
181
+ undefined,
182
+ )
183
+ })
184
+
185
+ it('handles random text without equals sign as token68', () => {
186
+ expect(parseWWWAuthenticateHeader('Bearer sometoken')).toEqual({
187
+ Bearer: 'sometoken',
188
+ })
189
+ })
190
+
191
+ it('ignores trailing whitespace after scheme', () => {
192
+ expect(parseWWWAuthenticateHeader('Bearer ')).toEqual({ Bearer: {} })
193
+ })
194
+
195
+ it('handles duplicate params (last wins)', () => {
196
+ expect(
197
+ parseWWWAuthenticateHeader('Bearer realm="first", realm="second"'),
198
+ ).toEqual({ Bearer: { realm: 'second' } })
199
+ })
200
+
201
+ it('extracts valid param after invalid characters', () => {
202
+ expect(parseWWWAuthenticateHeader('Bearer realm@foo="bar"')).toEqual({
203
+ Bearer: { 'realm@foo': 'bar' },
204
+ })
205
+ })
206
+
207
+ it('ignores param with empty name', () => {
208
+ expect(parseWWWAuthenticateHeader('Bearer ="value"')).toEqual(undefined)
209
+ })
210
+
211
+ it('handles completely malformed input gracefully', () => {
212
+ expect(parseWWWAuthenticateHeader('!@#$%')).toEqual({ '!@#$%': {} })
213
+ })
214
+
215
+ it('handles duplicate schemes as invalid', () => {
216
+ expect(
217
+ parseWWWAuthenticateHeader('Basic realm="first", Basic realm="second"'),
218
+ ).toEqual(undefined)
219
+ })
220
+
221
+ it('handles params without scheme as invalid', () => {
222
+ expect(parseWWWAuthenticateHeader('Bearer, realm="foo"')).toEqual(
223
+ undefined,
224
+ )
225
+ })
226
+ })
227
+ })
@@ -0,0 +1,77 @@
1
+ type WWWAuthenticateParams = { [authParam in string]: string }
2
+ export type WWWAuthenticate = {
3
+ [authScheme in string]:
4
+ | string // token68
5
+ | WWWAuthenticateParams
6
+ }
7
+
8
+ /**
9
+ * Returns `undefined` if the header is malformed.
10
+ */
11
+ export function parseWWWAuthenticateHeader(
12
+ header?: unknown,
13
+ ): undefined | WWWAuthenticate {
14
+ if (typeof header !== 'string') return undefined
15
+
16
+ const wwwAuthenticate: WWWAuthenticate = {}
17
+
18
+ // Split over commas, not within quoted strings
19
+ const trimmedHeader = header.trim()
20
+ if (!trimmedHeader) return wwwAuthenticate
21
+
22
+ const parts = trimmedHeader.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
23
+
24
+ let currentParams: WWWAuthenticateParams | null = null
25
+
26
+ for (let part of parts) {
27
+ // Check if the part starts with an auth scheme
28
+ const schemeMatch = part.trim().match(/^([^"=\s]+)(\s+.*)?$/)
29
+ if (schemeMatch) {
30
+ const scheme = schemeMatch[1]
31
+
32
+ // Duplicate scheme
33
+ if (Object.hasOwn(wwwAuthenticate, scheme)) return undefined
34
+
35
+ const rest = schemeMatch[2]?.trim()
36
+ if (!rest) {
37
+ // Scheme only (no params or token68)
38
+ currentParams = null
39
+ wwwAuthenticate[scheme] = Object.create(null)
40
+ continue
41
+ }
42
+
43
+ if (!rest.includes('=')) {
44
+ // Scheme with token68
45
+ currentParams = null
46
+ wwwAuthenticate[scheme] = rest
47
+ continue
48
+ }
49
+
50
+ // Scheme with params
51
+
52
+ currentParams = Object.create(null) as WWWAuthenticateParams
53
+ wwwAuthenticate[scheme] = currentParams
54
+
55
+ // Fall through to parse params
56
+ part = rest
57
+ }
58
+
59
+ // Invalid header
60
+ if (!currentParams) return undefined
61
+
62
+ const param = part.match(
63
+ /^\s*([^"\s=]+)=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([^\s,"]*))\s*$/,
64
+ )
65
+
66
+ // invalid param
67
+ if (!param) return undefined
68
+
69
+ const paramName = param[1]
70
+ const paramValue =
71
+ param[3] ?? param[2]!.slice(1, -1).replaceAll(/\\(.)/g, '$1')
72
+
73
+ currentParams[paramName] = paramValue
74
+ }
75
+
76
+ return wwwAuthenticate
77
+ }
package/src/xrpc.ts CHANGED
@@ -13,11 +13,7 @@ import {
13
13
  getMain,
14
14
  } from '@atproto/lex-schema'
15
15
  import { Agent } from './agent.js'
16
- import {
17
- XrpcResponseError,
18
- XrpcUnexpectedError,
19
- XrpcUpstreamError,
20
- } from './errors.js'
16
+ import { XrpcFailure, asXrpcFailure } from './errors.js'
21
17
  import { XrpcResponse } from './response.js'
22
18
  import { BinaryBodyInit, CallOptions } from './types.js'
23
19
  import {
@@ -49,32 +45,6 @@ export type XrpcOptions<M extends Procedure | Query = Procedure | Query> =
49
45
  XrpcInputOptions<XrpcRequestPayload<M>> &
50
46
  XrpcParamsOptions<XrpcRequestParams<M>>
51
47
 
52
- export type XrpcFailure<M extends Procedure | Query> =
53
- // The server returned a valid XRPC error response
54
- | XrpcResponseError<M>
55
- // The response was not a valid XRPC response, or it does not match the schema
56
- | XrpcUpstreamError
57
- // Something went wrong (network error, etc.)
58
- | XrpcUnexpectedError
59
-
60
- export type XrpcResult<M extends Procedure | Query> =
61
- | XrpcResponse<M>
62
- | XrpcFailure<M>
63
-
64
- /**
65
- * Utility method to type cast the error thrown by {@link xrpc} to an
66
- * {@link XrpcFailure} matching the provided method. Only use this function
67
- * inside a catch block right after calling {@link xrpc}, and use the same
68
- * method type parameter as used in the {@link xrpc} call.
69
- */
70
- export function asXrpcFailure<M extends Procedure | Query = Procedure | Query>(
71
- err: unknown,
72
- ): XrpcFailure<M> {
73
- if (err instanceof XrpcResponseError) return err
74
- if (err instanceof XrpcUpstreamError) return err
75
- return XrpcUnexpectedError.from(err)
76
- }
77
-
78
48
  /**
79
49
  * @throws XrpcFailure<M>
80
50
  */
@@ -94,13 +64,15 @@ export async function xrpc<const M extends Query | Procedure>(
94
64
  ns: Main<M>,
95
65
  options: XrpcOptions<M> = {} as XrpcOptions<M>,
96
66
  ): Promise<XrpcResponse<M>> {
97
- try {
98
- return await lexRpcRequest<M>(agent, ns, options)
99
- } catch (err) {
100
- throw asXrpcFailure<M>(err)
101
- }
67
+ const response = await xrpcSafe<M>(agent, ns, options)
68
+ if (response.success) return response
69
+ else throw response
102
70
  }
103
71
 
72
+ export type XrpcResult<M extends Procedure | Query> =
73
+ | XrpcResponse<M>
74
+ | XrpcFailure<M>
75
+
104
76
  export async function xrpcSafe<const M extends Query | Procedure>(
105
77
  agent: Agent,
106
78
  ns: NonNullable<unknown> extends XrpcOptions<M>
@@ -117,20 +89,16 @@ export async function xrpcSafe<const M extends Query | Procedure>(
117
89
  ns: Main<M>,
118
90
  options: XrpcOptions<M> = {} as XrpcOptions<M>,
119
91
  ): Promise<XrpcResult<M>> {
120
- return lexRpcRequest<M>(agent, ns, options).catch(asXrpcFailure<M>)
121
- }
122
-
123
- async function lexRpcRequest<const M extends Query | Procedure>(
124
- agent: Agent,
125
- ns: Main<M>,
126
- options: XrpcOptions<M> = {} as XrpcOptions<M>,
127
- ): Promise<XrpcResponse<M>> {
128
- const method = getMain(ns)
129
92
  options.signal?.throwIfAborted()
130
- const url = xrpcRequestUrl(method, options)
131
- const request = xrpcRequestInit(method, options)
132
- const response = await agent.fetchHandler(url, request)
133
- return XrpcResponse.fromFetchResponse<M>(method, response, options)
93
+ const method: M = getMain(ns)
94
+ try {
95
+ const url = xrpcRequestUrl(method, options)
96
+ const request = xrpcRequestInit(method, options)
97
+ const response = await agent.fetchHandler(url, request)
98
+ return await XrpcResponse.fromFetchResponse<M>(method, response, options)
99
+ } catch (cause) {
100
+ return asXrpcFailure(method, cause)
101
+ }
134
102
  }
135
103
 
136
104
  function xrpcRequestUrl<M extends Procedure | Query | Subscription>(