@atproto/xrpc-server 0.6.3 → 0.7.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/CHANGELOG.md +37 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +55 -78
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +73 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +97 -40
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +6 -2
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +85 -27
- package/dist/util.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +1 -1
- package/src/server.ts +78 -104
- package/src/types.ts +209 -41
- package/src/util.ts +103 -33
- package/tests/bodies.test.ts +120 -21
- package/tests/parsing.test.ts +89 -0
package/src/util.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import assert from 'assert'
|
|
2
|
-
import {
|
|
3
|
-
import { IncomingMessage } from 'http'
|
|
4
|
-
import { createDeflate, createGunzip } from 'zlib'
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { Duplex, pipeline, Readable } from 'node:stream'
|
|
3
|
+
import { IncomingMessage } from 'node:http'
|
|
5
4
|
import express from 'express'
|
|
6
5
|
import mime from 'mime-types'
|
|
7
6
|
import {
|
|
@@ -11,7 +10,9 @@ import {
|
|
|
11
10
|
LexXrpcQuery,
|
|
12
11
|
LexXrpcSubscription,
|
|
13
12
|
} from '@atproto/lexicon'
|
|
14
|
-
import {
|
|
13
|
+
import { createDecoders, MaxSizeChecker } from '@atproto/common'
|
|
14
|
+
import { ResponseType } from '@atproto/xrpc'
|
|
15
|
+
|
|
15
16
|
import {
|
|
16
17
|
UndecodedParams,
|
|
17
18
|
Params,
|
|
@@ -155,7 +156,7 @@ export function validateOutput(
|
|
|
155
156
|
def: LexXrpcProcedure | LexXrpcQuery,
|
|
156
157
|
output: HandlerSuccess | undefined,
|
|
157
158
|
lexicons: Lexicons,
|
|
158
|
-
):
|
|
159
|
+
): void {
|
|
159
160
|
// initial validation
|
|
160
161
|
if (output) {
|
|
161
162
|
handlerSuccess.parse(output)
|
|
@@ -195,8 +196,6 @@ export function validateOutput(
|
|
|
195
196
|
throw new InternalServerError(e instanceof Error ? e.message : String(e))
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
|
-
|
|
199
|
-
return output
|
|
200
199
|
}
|
|
201
200
|
|
|
202
201
|
export function normalizeMime(v: string) {
|
|
@@ -237,40 +236,52 @@ function decodeBodyStream(
|
|
|
237
236
|
req: express.Request,
|
|
238
237
|
maxSize: number | undefined,
|
|
239
238
|
): Readable {
|
|
240
|
-
let stream: Readable = req
|
|
241
239
|
const contentEncoding = req.headers['content-encoding']
|
|
242
240
|
const contentLength = req.headers['content-length']
|
|
243
241
|
|
|
242
|
+
const contentLengthParsed = contentLength
|
|
243
|
+
? parseInt(contentLength, 10)
|
|
244
|
+
: undefined
|
|
245
|
+
|
|
246
|
+
if (Number.isNaN(contentLengthParsed)) {
|
|
247
|
+
throw new XRPCError(ResponseType.InvalidRequest, 'invalid content-length')
|
|
248
|
+
}
|
|
249
|
+
|
|
244
250
|
if (
|
|
245
251
|
maxSize !== undefined &&
|
|
246
|
-
|
|
247
|
-
|
|
252
|
+
contentLengthParsed !== undefined &&
|
|
253
|
+
contentLengthParsed > maxSize
|
|
248
254
|
) {
|
|
249
|
-
throw new XRPCError(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (contentEncoding === 'gzip') {
|
|
254
|
-
decoder = createGunzip()
|
|
255
|
-
} else if (contentEncoding === 'deflate') {
|
|
256
|
-
decoder = createDeflate()
|
|
255
|
+
throw new XRPCError(
|
|
256
|
+
ResponseType.PayloadTooLarge,
|
|
257
|
+
'request entity too large',
|
|
258
|
+
)
|
|
257
259
|
}
|
|
258
260
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
let transforms: Duplex[]
|
|
262
|
+
try {
|
|
263
|
+
transforms = createDecoders(contentEncoding)
|
|
264
|
+
} catch (cause) {
|
|
265
|
+
throw new XRPCError(
|
|
266
|
+
ResponseType.UnsupportedMediaType,
|
|
267
|
+
'unsupported content-encoding',
|
|
268
|
+
undefined,
|
|
269
|
+
{ cause },
|
|
270
|
+
)
|
|
262
271
|
}
|
|
263
272
|
|
|
264
273
|
if (maxSize !== undefined) {
|
|
265
274
|
const maxSizeChecker = new MaxSizeChecker(
|
|
266
275
|
maxSize,
|
|
267
|
-
() =>
|
|
276
|
+
() =>
|
|
277
|
+
new XRPCError(ResponseType.PayloadTooLarge, 'request entity too large'),
|
|
268
278
|
)
|
|
269
|
-
|
|
270
|
-
stream = stream.pipe(maxSizeChecker)
|
|
279
|
+
transforms.push(maxSizeChecker)
|
|
271
280
|
}
|
|
272
281
|
|
|
273
|
-
return
|
|
282
|
+
return transforms.length > 0
|
|
283
|
+
? (pipeline([req, ...transforms], () => {}) as Duplex)
|
|
284
|
+
: req
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
export function serverTimingHeader(timings: ServerTiming[]) {
|
|
@@ -308,11 +319,70 @@ export interface ServerTiming {
|
|
|
308
319
|
description?: string
|
|
309
320
|
}
|
|
310
321
|
|
|
311
|
-
export const parseReqNsid = (
|
|
312
|
-
req:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
322
|
+
export const parseReqNsid = (req: express.Request | IncomingMessage) =>
|
|
323
|
+
parseUrlNsid('originalUrl' in req ? req.originalUrl : req.url || '/')
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Validates and extracts the nsid from an xrpc path
|
|
327
|
+
*/
|
|
328
|
+
export const parseUrlNsid = (url: string): string => {
|
|
329
|
+
// /!\ Hot path
|
|
330
|
+
|
|
331
|
+
if (
|
|
332
|
+
// Ordered by likelihood of failure
|
|
333
|
+
url.length <= 6 ||
|
|
334
|
+
url[5] !== '/' ||
|
|
335
|
+
url[4] !== 'c' ||
|
|
336
|
+
url[3] !== 'p' ||
|
|
337
|
+
url[2] !== 'r' ||
|
|
338
|
+
url[1] !== 'x' ||
|
|
339
|
+
url[0] !== '/'
|
|
340
|
+
) {
|
|
341
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const startOfNsid = 6
|
|
345
|
+
|
|
346
|
+
let curr = startOfNsid
|
|
347
|
+
let char: number
|
|
348
|
+
let alphaNumRequired = true
|
|
349
|
+
for (; curr < url.length; curr++) {
|
|
350
|
+
char = url.charCodeAt(curr)
|
|
351
|
+
if (
|
|
352
|
+
(char >= 48 && char <= 57) || // 0-9
|
|
353
|
+
(char >= 65 && char <= 90) || // A-Z
|
|
354
|
+
(char >= 97 && char <= 122) // a-z
|
|
355
|
+
) {
|
|
356
|
+
alphaNumRequired = false
|
|
357
|
+
} else if (char === 45 /* "-" */ || char === 46 /* "." */) {
|
|
358
|
+
if (alphaNumRequired) {
|
|
359
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
360
|
+
}
|
|
361
|
+
alphaNumRequired = true
|
|
362
|
+
} else if (char === 47 /* "/" */) {
|
|
363
|
+
// Allow trailing slash (next char is either EOS or "?")
|
|
364
|
+
if (curr === url.length - 1 || url.charCodeAt(curr + 1) === 63) {
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
368
|
+
} else if (char === 63 /* "?"" */) {
|
|
369
|
+
break
|
|
370
|
+
} else {
|
|
371
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// last char was one of: '-', '.', '/'
|
|
376
|
+
if (alphaNumRequired) {
|
|
377
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// A domain name consists of minimum two characters
|
|
381
|
+
if (curr - startOfNsid < 2) {
|
|
382
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// @TODO is there a max ?
|
|
386
|
+
|
|
387
|
+
return url.slice(startOfNsid, curr)
|
|
318
388
|
}
|
package/tests/bodies.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as http from 'http'
|
|
2
2
|
import { Readable } from 'stream'
|
|
3
|
-
import { gzipSync } from 'zlib'
|
|
3
|
+
import { brotliCompressSync, deflateSync, gzipSync } from 'zlib'
|
|
4
4
|
import getPort from 'get-port'
|
|
5
5
|
import { LexiconDoc } from '@atproto/lexicon'
|
|
6
|
-
import { XrpcClient } from '@atproto/xrpc'
|
|
6
|
+
import { ResponseType, XrpcClient } from '@atproto/xrpc'
|
|
7
7
|
import { cidForCbor } from '@atproto/common'
|
|
8
8
|
import { randomBytes } from '@atproto/crypto'
|
|
9
9
|
import { createServer, closeServer } from './_util'
|
|
@@ -91,13 +91,25 @@ const BLOB_LIMIT = 5000
|
|
|
91
91
|
async function consumeInput(
|
|
92
92
|
input: Readable | string | object,
|
|
93
93
|
): Promise<Buffer> {
|
|
94
|
-
if (
|
|
94
|
+
if (Buffer.isBuffer(input)) {
|
|
95
|
+
return input
|
|
96
|
+
}
|
|
97
|
+
if (typeof input === 'string') {
|
|
98
|
+
return Buffer.from(input)
|
|
99
|
+
}
|
|
95
100
|
if (input instanceof Readable) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
try {
|
|
102
|
+
return Buffer.concat(await input.toArray())
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err instanceof xrpcServer.XRPCError) {
|
|
105
|
+
throw err
|
|
106
|
+
} else {
|
|
107
|
+
throw new xrpcServer.XRPCError(
|
|
108
|
+
ResponseType.InvalidRequest,
|
|
109
|
+
'unable to read input',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
99
112
|
}
|
|
100
|
-
return Buffer.concat(buffers)
|
|
101
113
|
}
|
|
102
114
|
throw new Error('Invalid input')
|
|
103
115
|
}
|
|
@@ -111,10 +123,16 @@ describe('Bodies', () => {
|
|
|
111
123
|
})
|
|
112
124
|
server.method(
|
|
113
125
|
'io.example.validationTest',
|
|
114
|
-
(ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) =>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
(ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) => {
|
|
127
|
+
if (ctx.input?.body instanceof Readable) {
|
|
128
|
+
throw new Error('Input is readable')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
encoding: 'json',
|
|
133
|
+
body: ctx.input?.body ?? null,
|
|
134
|
+
}
|
|
135
|
+
},
|
|
118
136
|
)
|
|
119
137
|
server.method('io.example.validationTestTwo', () => ({
|
|
120
138
|
encoding: 'json',
|
|
@@ -338,24 +356,106 @@ describe('Bodies', () => {
|
|
|
338
356
|
expect(streamResponse.data.cid).toEqual(expectedCid.toString())
|
|
339
357
|
})
|
|
340
358
|
|
|
341
|
-
it('supports
|
|
359
|
+
it('supports blob uploads', async () => {
|
|
360
|
+
const bytes = randomBytes(1024)
|
|
361
|
+
const expectedCid = await cidForCbor(bytes)
|
|
362
|
+
|
|
363
|
+
const { data } = await client.call('io.example.blobTest', {}, bytes, {
|
|
364
|
+
encoding: 'application/octet-stream',
|
|
365
|
+
})
|
|
366
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it(`supports identity encoding`, async () => {
|
|
370
|
+
const bytes = randomBytes(1024)
|
|
371
|
+
const expectedCid = await cidForCbor(bytes)
|
|
372
|
+
|
|
373
|
+
const { data } = await client.call('io.example.blobTest', {}, bytes, {
|
|
374
|
+
encoding: 'application/octet-stream',
|
|
375
|
+
headers: { 'content-encoding': 'identity' },
|
|
376
|
+
})
|
|
377
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('supports gzip encoding', async () => {
|
|
342
381
|
const bytes = randomBytes(1024)
|
|
343
382
|
const expectedCid = await cidForCbor(bytes)
|
|
344
383
|
|
|
345
|
-
const { data
|
|
384
|
+
const { data } = await client.call(
|
|
346
385
|
'io.example.blobTest',
|
|
347
386
|
{},
|
|
348
|
-
bytes,
|
|
387
|
+
gzipSync(bytes),
|
|
349
388
|
{
|
|
350
389
|
encoding: 'application/octet-stream',
|
|
390
|
+
headers: {
|
|
391
|
+
'content-encoding': 'gzip',
|
|
392
|
+
},
|
|
351
393
|
},
|
|
352
394
|
)
|
|
353
|
-
expect(
|
|
395
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
396
|
+
})
|
|
354
397
|
|
|
355
|
-
|
|
398
|
+
it('supports deflate encoding', async () => {
|
|
399
|
+
const bytes = randomBytes(1024)
|
|
400
|
+
const expectedCid = await cidForCbor(bytes)
|
|
401
|
+
|
|
402
|
+
const { data } = await client.call(
|
|
356
403
|
'io.example.blobTest',
|
|
357
404
|
{},
|
|
358
|
-
|
|
405
|
+
deflateSync(bytes),
|
|
406
|
+
{
|
|
407
|
+
encoding: 'application/octet-stream',
|
|
408
|
+
headers: {
|
|
409
|
+
'content-encoding': 'deflate',
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('supports br encoding', async () => {
|
|
417
|
+
const bytes = randomBytes(1024)
|
|
418
|
+
const expectedCid = await cidForCbor(bytes)
|
|
419
|
+
|
|
420
|
+
const { data } = await client.call(
|
|
421
|
+
'io.example.blobTest',
|
|
422
|
+
{},
|
|
423
|
+
brotliCompressSync(bytes),
|
|
424
|
+
{
|
|
425
|
+
encoding: 'application/octet-stream',
|
|
426
|
+
headers: {
|
|
427
|
+
'content-encoding': 'br',
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('supports multiple encodings', async () => {
|
|
435
|
+
const bytes = randomBytes(1024)
|
|
436
|
+
const expectedCid = await cidForCbor(bytes)
|
|
437
|
+
|
|
438
|
+
const { data } = await client.call(
|
|
439
|
+
'io.example.blobTest',
|
|
440
|
+
{},
|
|
441
|
+
brotliCompressSync(deflateSync(gzipSync(bytes))),
|
|
442
|
+
{
|
|
443
|
+
encoding: 'application/octet-stream',
|
|
444
|
+
headers: {
|
|
445
|
+
'content-encoding': 'gzip, identity, deflate, identity, br, identity',
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
)
|
|
449
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('fails gracefully on invalid encodings', async () => {
|
|
453
|
+
const bytes = randomBytes(1024)
|
|
454
|
+
|
|
455
|
+
const promise = client.call(
|
|
456
|
+
'io.example.blobTest',
|
|
457
|
+
{},
|
|
458
|
+
brotliCompressSync(bytes),
|
|
359
459
|
{
|
|
360
460
|
encoding: 'application/octet-stream',
|
|
361
461
|
headers: {
|
|
@@ -363,7 +463,8 @@ describe('Bodies', () => {
|
|
|
363
463
|
},
|
|
364
464
|
},
|
|
365
465
|
)
|
|
366
|
-
|
|
466
|
+
|
|
467
|
+
await expect(promise).rejects.toThrow('unable to read input')
|
|
367
468
|
})
|
|
368
469
|
|
|
369
470
|
it('supports empty payload', async () => {
|
|
@@ -428,9 +529,7 @@ describe('Bodies', () => {
|
|
|
428
529
|
})
|
|
429
530
|
})
|
|
430
531
|
|
|
431
|
-
|
|
432
|
-
// https://github.com/bluesky-social/atproto/pull/550/files#r1106400413
|
|
433
|
-
it.skip('errors on an empty Content-type on blob upload', async () => {
|
|
532
|
+
it('errors on an empty Content-type on blob upload', async () => {
|
|
434
533
|
// empty mimetype, but correct syntax
|
|
435
534
|
const res = await fetch(`${url}/xrpc/io.example.blobTest`, {
|
|
436
535
|
method: 'post',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { parseUrlNsid } from '../src/util'
|
|
2
|
+
|
|
3
|
+
const testValid = (url: string, expected: string) => {
|
|
4
|
+
expect(parseUrlNsid(url)).toBe(expected)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const testInvalid = (url: string, errorMessage = 'invalid xrpc path') => {
|
|
8
|
+
expect(() => parseUrlNsid(url)).toThrow(errorMessage)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('parseUrlNsid', () => {
|
|
12
|
+
it('should extract the NSID from the URL', () => {
|
|
13
|
+
testValid('/xrpc/blee.blah.bloo', 'blee.blah.bloo')
|
|
14
|
+
testValid('/xrpc/blee.blah.bloo?foo[]', 'blee.blah.bloo')
|
|
15
|
+
testValid('/xrpc/blee.blah.bloo?foo=bar', 'blee.blah.bloo')
|
|
16
|
+
testValid('/xrpc/com.example.nsid', 'com.example.nsid')
|
|
17
|
+
testValid('/xrpc/com.example.nsid?foo=bar', 'com.example.nsid')
|
|
18
|
+
testValid('/xrpc/com.example-domain.nsid', 'com.example-domain.nsid')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should allow a trailing slash', () => {
|
|
22
|
+
testValid('/xrpc/blee.blah.bloo/?', 'blee.blah.bloo')
|
|
23
|
+
testValid('/xrpc/blee.blah.bloo/?foo=', 'blee.blah.bloo')
|
|
24
|
+
testValid('/xrpc/blee.blah.bloo/?bool', 'blee.blah.bloo')
|
|
25
|
+
testValid('/xrpc/com.example.nsid/', 'com.example.nsid')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should throw an error if the URL is too short', () => {
|
|
29
|
+
testInvalid('/xrpc/a')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should throw an error if the URL is empty', () => {
|
|
33
|
+
testInvalid('')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should throw an error if the URL is missing the NSID', () => {
|
|
37
|
+
testInvalid('/xrpc/')
|
|
38
|
+
testInvalid('/xrpc/?')
|
|
39
|
+
testInvalid('/xrpc/?foo=bar')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should throw an error if the URL contains extra path segments', () => {
|
|
43
|
+
testInvalid('/xrpc/123/extra')
|
|
44
|
+
testInvalid('/xrpc/123/extra?foo=bar')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should throw an error if the URL is missing the XRPC path prefix', () => {
|
|
48
|
+
testInvalid('/foo/123')
|
|
49
|
+
testInvalid('/foo/com.example.nsid')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should throw an error if the NSID starts with a dot', () => {
|
|
53
|
+
testInvalid('/xrpc/.')
|
|
54
|
+
testInvalid('/xrpc/..')
|
|
55
|
+
testInvalid('/xrpc/....')
|
|
56
|
+
testInvalid('/xrpc/.com.example.nsid')
|
|
57
|
+
testInvalid('/xrpc/com..example.nsid')
|
|
58
|
+
testInvalid('/xrpc/com.example..nsid')
|
|
59
|
+
testInvalid('/xrpc/com.example.nsid.')
|
|
60
|
+
testInvalid('/xrpc/com.example.nsid./')
|
|
61
|
+
testInvalid('/xrpc/com.example.nsid.?foo=bar')
|
|
62
|
+
testInvalid('/xrpc/com.example.nsid./?foo=bar')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should throw an error if the NSID contains a misplaced dash', () => {
|
|
66
|
+
testInvalid('/xrpc/-')
|
|
67
|
+
testInvalid('/xrpc/com.example.-nsid')
|
|
68
|
+
testInvalid('/xrpc/com.example-.nsid')
|
|
69
|
+
testInvalid('/xrpc/com.-example.nsid')
|
|
70
|
+
testInvalid('/xrpc/com.-example-.nsid')
|
|
71
|
+
testInvalid('/xrpc/com.example.nsid-')
|
|
72
|
+
testInvalid('/xrpc/-com.example.nsid')
|
|
73
|
+
testInvalid('/xrpc/com.example--domain.nsid')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should throw an error if the URL starts with a space', () => {
|
|
77
|
+
testInvalid(' /xrpc/com.example.nsid')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should throw an error if the NSID contains invalid characters', () => {
|
|
81
|
+
testInvalid('/xrpc/com.example.nsid#')
|
|
82
|
+
testInvalid('/xrpc/com.example.nsid!')
|
|
83
|
+
testInvalid('/xrpc/com.example#?nsid')
|
|
84
|
+
testInvalid('/xrpc/!com.example.nsid')
|
|
85
|
+
testInvalid('/xrpc/com.example.nsid ')
|
|
86
|
+
testInvalid('/xrpc/ com.example.nsid')
|
|
87
|
+
testInvalid('/xrpc/com. example.nsid')
|
|
88
|
+
})
|
|
89
|
+
})
|