@atproto/lex-client 0.0.10 → 0.0.12

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/agent.d.ts +72 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +46 -1
  5. package/dist/agent.js.map +1 -1
  6. package/dist/client.d.ts +442 -46
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +145 -1
  9. package/dist/client.js.map +1 -1
  10. package/dist/errors.d.ts +202 -48
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +208 -65
  13. package/dist/errors.js.map +1 -1
  14. package/dist/lexicons/com/atproto/repo/createRecord.defs.d.ts +20 -20
  15. package/dist/lexicons/com/atproto/repo/deleteRecord.defs.d.ts +12 -12
  16. package/dist/lexicons/com/atproto/repo/getRecord.defs.d.ts +6 -6
  17. package/dist/lexicons/com/atproto/repo/listRecords.defs.d.ts +6 -6
  18. package/dist/lexicons/com/atproto/repo/putRecord.defs.d.ts +22 -22
  19. package/dist/lexicons/com/atproto/repo/uploadBlob.defs.d.ts +2 -2
  20. package/dist/response.d.ts +17 -6
  21. package/dist/response.d.ts.map +1 -1
  22. package/dist/response.js +45 -32
  23. package/dist/response.js.map +1 -1
  24. package/dist/types.d.ts +51 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js.map +1 -1
  27. package/dist/util.d.ts +40 -5
  28. package/dist/util.d.ts.map +1 -1
  29. package/dist/util.js +22 -0
  30. package/dist/util.js.map +1 -1
  31. package/dist/www-authenticate.d.ts +35 -0
  32. package/dist/www-authenticate.d.ts.map +1 -0
  33. package/dist/www-authenticate.js +57 -0
  34. package/dist/www-authenticate.js.map +1 -0
  35. package/dist/xrpc.d.ts +82 -10
  36. package/dist/xrpc.d.ts.map +1 -1
  37. package/dist/xrpc.js +15 -28
  38. package/dist/xrpc.js.map +1 -1
  39. package/package.json +7 -7
  40. package/src/agent.ts +101 -1
  41. package/src/client.ts +428 -15
  42. package/src/errors.ts +308 -120
  43. package/src/response.ts +68 -63
  44. package/src/types.ts +52 -0
  45. package/src/util.ts +50 -5
  46. package/src/www-authenticate.test.ts +227 -0
  47. package/src/www-authenticate.ts +101 -0
  48. package/src/xrpc.ts +100 -53
package/src/response.ts CHANGED
@@ -1,32 +1,29 @@
1
1
  import { lexParse } from '@atproto/lex-json'
2
2
  import {
3
- InferMethodOutputBody,
4
3
  InferMethodOutputEncoding,
5
4
  Procedure,
6
5
  Query,
7
6
  ResultSuccess,
8
7
  } from '@atproto/lex-schema'
9
8
  import {
9
+ XrpcAuthenticationError,
10
10
  XrpcResponseError,
11
11
  XrpcUpstreamError,
12
12
  isXrpcErrorPayload,
13
13
  } from './errors.js'
14
- import { XrpcPayload } from './util.js'
14
+ import { XrpcResponseBody, XrpcResponsePayload } from './util.js'
15
15
 
16
- export type XrpcResponseBody<M extends Procedure | Query> =
17
- InferMethodOutputBody<M, Uint8Array>
16
+ const CONTENT_TYPE_BINARY = 'application/octet-stream'
17
+ const CONTENT_TYPE_JSON = 'application/json'
18
18
 
19
- export type XrpcResponsePayload<M extends Procedure | Query> =
20
- InferMethodOutputEncoding<M> extends infer E extends string
21
- ? XrpcPayload<XrpcResponseBody<M>, E>
22
- : null
19
+ export type { XrpcResponseBody, XrpcResponsePayload }
23
20
 
24
21
  /**
25
22
  * Small container for XRPC response data.
26
23
  *
27
24
  * @implements {ResultSuccess<XrpcResponse<M>>} for convenience in result handling contexts.
28
25
  */
29
- export class XrpcResponse<const M extends Procedure | Query>
26
+ export class XrpcResponse<M extends Procedure | Query>
30
27
  implements ResultSuccess<XrpcResponse<M>>
31
28
  {
32
29
  /** @see {@link ResultSuccess.success} */
@@ -49,13 +46,24 @@ export class XrpcResponse<const M extends Procedure | Query>
49
46
  * in binary form {@link Uint8Array} (`false`).
50
47
  */
51
48
  get isParsed() {
52
- return this.encoding === 'application/json' && shouldParse(this.method)
49
+ return this.method.output.encoding === CONTENT_TYPE_JSON
53
50
  }
54
51
 
52
+ /**
53
+ * The Content-Type encoding of the response (e.g., 'application/json').
54
+ * Returns `undefined` if the response has no body.
55
+ */
55
56
  get encoding() {
56
57
  return this.payload?.encoding as InferMethodOutputEncoding<M>
57
58
  }
58
59
 
60
+ /**
61
+ * The parsed response body.
62
+ *
63
+ * For 'application/json' responses, this is the parsed and validated LexValue.
64
+ * For binary responses, this is a Uint8Array.
65
+ * Returns `undefined` if the response has no body.
66
+ */
59
67
  get body() {
60
68
  return this.payload?.body as XrpcResponseBody<M>
61
69
  }
@@ -63,7 +71,8 @@ export class XrpcResponse<const M extends Procedure | Query>
63
71
  /**
64
72
  * @throws {XrpcResponseError} in case of (valid) XRPC error responses. Use
65
73
  * {@link XrpcResponseError.matchesSchema} to narrow the error type based on
66
- * the method's declared error schema.
74
+ * the method's declared error schema. This can be narrowed further as a
75
+ * {@link XrpcAuthenticationError} if the error is an authentication error.
67
76
  * @throws {XrpcUpstreamError} when the response is not a valid XRPC
68
77
  * response, or if the response does not conform to the method's schema.
69
78
  */
@@ -79,39 +88,49 @@ export class XrpcResponse<const M extends Procedure | Query>
79
88
  // @NOTE redirect is set to 'follow', so we shouldn't get 3xx responses here
80
89
  if (response.status < 200 || response.status >= 300) {
81
90
  // Always parse json for error responses
82
- const payload = await readPayload(response, { parse: true })
91
+ const payload = await readPayload(response, { parse: true }).catch(
92
+ (cause) => {
93
+ throw new XrpcUpstreamError(
94
+ method,
95
+ response,
96
+ null,
97
+ 'Unable to parse response payload',
98
+ { cause },
99
+ )
100
+ },
101
+ )
83
102
 
103
+ // Properly formatted XRPC error response ?
84
104
  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
- )
105
+ throw response.status === 401
106
+ ? new XrpcAuthenticationError<M>(method, response, payload)
107
+ : new XrpcResponseError<M>(method, response, payload)
100
108
  }
101
109
 
110
+ // Invalid XRPC response (we probably did not hit an XRPC implementation)
102
111
  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`,
112
+ method,
107
113
  response,
108
114
  payload,
115
+ response.status >= 500
116
+ ? 'Upstream server encountered an error'
117
+ : response.status >= 400
118
+ ? 'Invalid response payload'
119
+ : 'Invalid response status code',
109
120
  )
110
121
  }
111
122
 
112
123
  // Only parse json if the schema expects it
113
124
  const payload = await readPayload(response, {
114
- parse: shouldParse(method),
125
+ parse: method.output.encoding === CONTENT_TYPE_JSON,
126
+ }).catch((cause) => {
127
+ throw new XrpcUpstreamError(
128
+ method,
129
+ response,
130
+ null,
131
+ 'Unable to parse response payload',
132
+ { cause },
133
+ )
115
134
  })
116
135
 
117
136
  // Response is successful (2xx). Validate payload (data and encoding) against schema.
@@ -119,22 +138,22 @@ export class XrpcResponse<const M extends Procedure | Query>
119
138
  // Schema expects no payload
120
139
  if (payload) {
121
140
  throw new XrpcUpstreamError(
122
- 'InvalidResponse',
123
- `Expected response with no body, got ${payload.encoding}`,
141
+ method,
124
142
  response,
125
143
  payload,
144
+ `Expected response with no body, got ${payload.encoding}`,
126
145
  )
127
146
  }
128
147
  } else {
129
148
  // Schema expects a payload
130
149
  if (!payload || !method.output.matchesEncoding(payload.encoding)) {
131
150
  throw new XrpcUpstreamError(
132
- 'InvalidResponse',
151
+ method,
152
+ response,
153
+ payload,
133
154
  payload
134
155
  ? `Expected ${method.output.encoding} response, got ${payload.encoding}`
135
156
  : `Expected non-empty response with content-type ${method.output.encoding}`,
136
- response,
137
- payload,
138
157
  )
139
158
  }
140
159
 
@@ -144,10 +163,10 @@ export class XrpcResponse<const M extends Procedure | Query>
144
163
 
145
164
  if (!result.success) {
146
165
  throw new XrpcUpstreamError(
147
- 'InvalidResponse',
148
- `Response validation failed: ${result.reason.message}`,
166
+ method,
149
167
  response,
150
168
  payload,
169
+ `Response validation failed: ${result.reason.message}`,
151
170
  { cause: result.reason },
152
171
  )
153
172
  }
@@ -163,17 +182,13 @@ export class XrpcResponse<const M extends Procedure | Query>
163
182
  }
164
183
  }
165
184
 
166
- function shouldParse(method: Procedure | Query) {
167
- return method.output.encoding === 'application/json'
168
- }
169
-
170
185
  /**
171
186
  * @note this function always consumes the response body
172
187
  */
173
188
  async function readPayload(
174
189
  response: Response,
175
190
  options?: { parse?: boolean },
176
- ): Promise<XrpcPayload | null> {
191
+ ): Promise<XrpcResponsePayload> {
177
192
  // @TODO Should we limit the maximum response size here (this could also be
178
193
  // done by the FetchHandler)?
179
194
 
@@ -185,18 +200,18 @@ async function readPayload(
185
200
 
186
201
  // Response content-type is undefined
187
202
  if (!encoding) {
188
- // If the body is empty, return null (= no payload)
203
+ // If the body is empty, return undefined (= no payload)
189
204
  const body = await response.arrayBuffer()
190
- if (body.byteLength === 0) return null
205
+ if (body.byteLength === 0) return undefined
191
206
 
192
207
  // If we got data despite no content-type, treat it as binary
193
208
  return {
194
- encoding: 'application/octet-stream',
209
+ encoding: CONTENT_TYPE_BINARY,
195
210
  body: new Uint8Array(body),
196
211
  }
197
212
  }
198
213
 
199
- if (options?.parse && encoding === 'application/json') {
214
+ if (options?.parse && encoding === CONTENT_TYPE_JSON) {
200
215
  // @NOTE It might be worth returning the raw bytes here (Uint8Array) and
201
216
  // perform the lex parsing using cborg/json, allowing to do
202
217
  // bytes->LexValue in one step instead of bytes->text->JSON->LexValue.
@@ -204,22 +219,12 @@ async function readPayload(
204
219
  // to @ipld/dag-json)
205
220
  const text = await response.text()
206
221
 
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 (?)
222
+ // @NOTE Using `lexParse(text)` (instead of `jsonToLex(json)`) here as
223
+ // using a reviver function during JSON.parse should be faster than
224
+ // parsing to JSON then converting to Lex (?)
211
225
 
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
- }
226
+ // @TODO verify statement above
227
+ return { encoding, body: lexParse(text) }
223
228
  }
224
229
 
225
230
  return { encoding, body: new Uint8Array(await response.arrayBuffer()) }
package/src/types.ts CHANGED
@@ -2,13 +2,40 @@ import { DidString, UnknownString } from '@atproto/lex-schema'
2
2
 
3
3
  export type { DidString, UnknownString }
4
4
 
5
+ /**
6
+ * Service identifier fragment for DID service endpoints.
7
+ *
8
+ * Common values include 'atproto_labeler' for labeling services,
9
+ * or custom service identifiers.
10
+ */
5
11
  export type DidServiceIdentifier = 'atproto_labeler' | UnknownString
12
+
13
+ /**
14
+ * A full service proxy identifier combining a DID with a service fragment.
15
+ *
16
+ * Used to route requests through a specific service endpoint.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const service: Service = 'did:web:api.bsky.app#bsky_appview'
21
+ * ```
22
+ */
6
23
  export type Service = `${DidString}#${DidServiceIdentifier}`
7
24
 
25
+ /**
26
+ * Common options available for all XRPC calls.
27
+ *
28
+ * These options can be passed to any method that makes XRPC requests,
29
+ * including `xrpc()`, `call()`, and record operations.
30
+ */
8
31
  export type CallOptions = {
32
+ /** Labeler DIDs to request labels from for content moderation. */
9
33
  labelers?: Iterable<DidString>
34
+ /** AbortSignal to cancel the request. */
10
35
  signal?: AbortSignal
36
+ /** Additional HTTP headers to include in the request. */
11
37
  headers?: HeadersInit
38
+ /** Service proxy identifier for routing requests through a specific service. */
12
39
  service?: Service
13
40
 
14
41
  /**
@@ -33,6 +60,31 @@ export type CallOptions = {
33
60
  validateResponse?: boolean
34
61
  }
35
62
 
63
+ /**
64
+ * Valid input types for binary request bodies.
65
+ *
66
+ * These types can be used as the body for procedures that expect
67
+ * non-JSON content (e.g., blob uploads, binary data).
68
+ *
69
+ * @example Uploading a blob
70
+ * ```typescript
71
+ * const imageData: BinaryBodyInit = new Uint8Array(buffer)
72
+ * await client.uploadBlob(imageData, { encoding: 'image/png' })
73
+ * ```
74
+ *
75
+ * @example Streaming upload
76
+ * ```typescript
77
+ * const stream: BinaryBodyInit = someReadableStream
78
+ * await client.xrpc(uploadMethod, { body: stream })
79
+ * ```
80
+ *
81
+ * @example File upload in browser
82
+ * ```typescript
83
+ * const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
84
+ * const file: BinaryBodyInit = fileInput.files[0]
85
+ * await client.xrpc(uploadMethod, { body: file })
86
+ * ```
87
+ */
36
88
  export type BinaryBodyInit =
37
89
  | Uint8Array
38
90
  | ArrayBuffer
package/src/util.ts CHANGED
@@ -1,10 +1,42 @@
1
- import { DidString } from '@atproto/lex-schema'
1
+ import {
2
+ DidString,
3
+ InferMethodOutput,
4
+ InferMethodOutputBody,
5
+ Procedure,
6
+ Query,
7
+ } from '@atproto/lex-schema'
2
8
 
3
- export type XrpcPayload<B = unknown, E extends string = string> = {
4
- body: B
5
- encoding: E
6
- }
9
+ /**
10
+ * The body type of an XRPC response, inferred from the method's output schema.
11
+ *
12
+ * For JSON responses, this is the parsed LexValue. For binary responses,
13
+ * this is a Uint8Array.
14
+ *
15
+ * @typeParam M - The XRPC method type (Procedure or Query)
16
+ */
17
+ export type XrpcResponseBody<M extends Procedure | Query = Procedure | Query> =
18
+ InferMethodOutputBody<M, Uint8Array>
19
+
20
+ /**
21
+ * The full payload type of an XRPC response, including body and encoding.
22
+ *
23
+ * Returns `null` for methods that have no output.
24
+ *
25
+ * @typeParam M - The XRPC method type (Procedure or Query)
26
+ */
27
+ export type XrpcResponsePayload<
28
+ M extends Procedure | Query = Procedure | Query,
29
+ > = InferMethodOutput<M, Uint8Array>
7
30
 
31
+ /**
32
+ * Type guard to check if a value is {@link Blob}-like.
33
+ *
34
+ * Handles both native Blobs and polyfilled Blob implementations
35
+ * (e.g., fetch-blob from node-fetch).
36
+ *
37
+ * @param value - The value to check
38
+ * @returns `true` if the value is a Blob or Blob-like object
39
+ */
8
40
  export function isBlobLike(value: unknown): value is Blob {
9
41
  if (value == null) return false
10
42
  if (typeof value !== 'object') return false
@@ -32,6 +64,19 @@ export function isAsyncIterable<T>(
32
64
  )
33
65
  }
34
66
 
67
+ /**
68
+ * Builds HTTP headers for AT Protocol requests.
69
+ *
70
+ * Adds the following headers when applicable:
71
+ * - `atproto-proxy`: Service routing header (if service is specified)
72
+ * - `atproto-accept-labelers`: Comma-separated list of labeler DIDs
73
+ *
74
+ * @param options - Header building options
75
+ * @param options.headers - Base headers to include
76
+ * @param options.service - Service proxy identifier
77
+ * @param options.labelers - Labeler DIDs to request labels from
78
+ * @returns A new Headers object with AT Protocol headers added
79
+ */
35
80
  export function buildAtprotoHeaders(options: {
36
81
  headers?: HeadersInit
37
82
  service?: `${DidString}#${string}`
@@ -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
+ })