@atproto/syntax 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.
package/src/nsid.ts ADDED
@@ -0,0 +1,111 @@
1
+ /*
2
+ Grammar:
3
+
4
+ alpha = "a" / "b" / "c" / "d" / "e" / "f" / "g" / "h" / "i" / "j" / "k" / "l" / "m" / "n" / "o" / "p" / "q" / "r" / "s" / "t" / "u" / "v" / "w" / "x" / "y" / "z" / "A" / "B" / "C" / "D" / "E" / "F" / "G" / "H" / "I" / "J" / "K" / "L" / "M" / "N" / "O" / "P" / "Q" / "R" / "S" / "T" / "U" / "V" / "W" / "X" / "Y" / "Z"
5
+ number = "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9" / "0"
6
+ delim = "."
7
+ segment = alpha *( alpha / number / "-" )
8
+ authority = segment *( delim segment )
9
+ name = alpha *( alpha )
10
+ nsid = authority delim name
11
+
12
+ */
13
+
14
+ export class NSID {
15
+ segments: string[] = []
16
+
17
+ static parse(nsid: string): NSID {
18
+ return new NSID(nsid)
19
+ }
20
+
21
+ static create(authority: string, name: string): NSID {
22
+ const segments = [...authority.split('.').reverse(), name].join('.')
23
+ return new NSID(segments)
24
+ }
25
+
26
+ static isValid(nsid: string): boolean {
27
+ try {
28
+ NSID.parse(nsid)
29
+ return true
30
+ } catch (e) {
31
+ return false
32
+ }
33
+ }
34
+
35
+ constructor(nsid: string) {
36
+ ensureValidNsid(nsid)
37
+ this.segments = nsid.split('.')
38
+ }
39
+
40
+ get authority() {
41
+ return this.segments
42
+ .slice(0, this.segments.length - 1)
43
+ .reverse()
44
+ .join('.')
45
+ }
46
+
47
+ get name() {
48
+ return this.segments.at(this.segments.length - 1)
49
+ }
50
+
51
+ toString() {
52
+ return this.segments.join('.')
53
+ }
54
+ }
55
+
56
+ // Human readable constraints on NSID:
57
+ // - a valid domain in reversed notation
58
+ // - followed by an additional period-separated name, which is camel-case letters
59
+ export const ensureValidNsid = (nsid: string): void => {
60
+ const toCheck = nsid
61
+
62
+ // check that all chars are boring ASCII
63
+ if (!/^[a-zA-Z0-9.-]*$/.test(toCheck)) {
64
+ throw new InvalidNsidError(
65
+ 'Disallowed characters in NSID (ASCII letters, digits, dashes, periods only)',
66
+ )
67
+ }
68
+
69
+ if (toCheck.length > 253 + 1 + 63) {
70
+ throw new InvalidNsidError('NSID is too long (317 chars max)')
71
+ }
72
+ const labels = toCheck.split('.')
73
+ if (labels.length < 3) {
74
+ throw new InvalidNsidError('NSID needs at least three parts')
75
+ }
76
+ for (let i = 0; i < labels.length; i++) {
77
+ const l = labels[i]
78
+ if (l.length < 1) {
79
+ throw new InvalidNsidError('NSID parts can not be empty')
80
+ }
81
+ if (l.length > 63) {
82
+ throw new InvalidNsidError('NSID part too long (max 63 chars)')
83
+ }
84
+ if (l.endsWith('-') || l.startsWith('-')) {
85
+ throw new InvalidNsidError('NSID parts can not start or end with hyphen')
86
+ }
87
+ if (/^[0-9]/.test(l) && i == 0) {
88
+ throw new InvalidNsidError('NSID first part may not start with a digit')
89
+ }
90
+ if (!/^[a-zA-Z]+$/.test(l) && i + 1 == labels.length) {
91
+ throw new InvalidNsidError('NSID name part must be only letters')
92
+ }
93
+ }
94
+ }
95
+
96
+ export const ensureValidNsidRegex = (nsid: string): void => {
97
+ // simple regex to enforce most constraints via just regex and length.
98
+ // hand wrote this regex based on above constraints
99
+ if (
100
+ !/^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?)$/.test(
101
+ nsid,
102
+ )
103
+ ) {
104
+ throw new InvalidNsidError("NSID didn't validate via regex")
105
+ }
106
+ if (nsid.length > 253 + 1 + 63) {
107
+ throw new InvalidNsidError('NSID is too long (317 chars max)')
108
+ }
109
+ }
110
+
111
+ export class InvalidNsidError extends Error {}
@@ -0,0 +1,506 @@
1
+ import { AtUri, ensureValidAtUri, ensureValidAtUriRegex } from '../src/index'
2
+
3
+ describe('At Uris', () => {
4
+ it('parses valid at uris', () => {
5
+ // input host path query hash
6
+ type AtUriTest = [string, string, string, string, string]
7
+ const TESTS: AtUriTest[] = [
8
+ ['foo.com', 'foo.com', '', '', ''],
9
+ ['at://foo.com', 'foo.com', '', '', ''],
10
+ ['at://foo.com/', 'foo.com', '/', '', ''],
11
+ ['at://foo.com/foo', 'foo.com', '/foo', '', ''],
12
+ ['at://foo.com/foo/', 'foo.com', '/foo/', '', ''],
13
+ ['at://foo.com/foo/bar', 'foo.com', '/foo/bar', '', ''],
14
+ ['at://foo.com?foo=bar', 'foo.com', '', 'foo=bar', ''],
15
+ ['at://foo.com?foo=bar&baz=buux', 'foo.com', '', 'foo=bar&baz=buux', ''],
16
+ ['at://foo.com/?foo=bar', 'foo.com', '/', 'foo=bar', ''],
17
+ ['at://foo.com/foo?foo=bar', 'foo.com', '/foo', 'foo=bar', ''],
18
+ ['at://foo.com/foo/?foo=bar', 'foo.com', '/foo/', 'foo=bar', ''],
19
+ ['at://foo.com#hash', 'foo.com', '', '', '#hash'],
20
+ ['at://foo.com/#hash', 'foo.com', '/', '', '#hash'],
21
+ ['at://foo.com/foo#hash', 'foo.com', '/foo', '', '#hash'],
22
+ ['at://foo.com/foo/#hash', 'foo.com', '/foo/', '', '#hash'],
23
+ ['at://foo.com?foo=bar#hash', 'foo.com', '', 'foo=bar', '#hash'],
24
+
25
+ [
26
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
27
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
28
+ '',
29
+ '',
30
+ '',
31
+ ],
32
+ [
33
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
34
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
35
+ '',
36
+ '',
37
+ '',
38
+ ],
39
+ [
40
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/',
41
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
42
+ '/',
43
+ '',
44
+ '',
45
+ ],
46
+ [
47
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/foo',
48
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
49
+ '/foo',
50
+ '',
51
+ '',
52
+ ],
53
+ [
54
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/foo/',
55
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
56
+ '/foo/',
57
+ '',
58
+ '',
59
+ ],
60
+ [
61
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/foo/bar',
62
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
63
+ '/foo/bar',
64
+ '',
65
+ '',
66
+ ],
67
+ [
68
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw?foo=bar',
69
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
70
+ '',
71
+ 'foo=bar',
72
+ '',
73
+ ],
74
+ [
75
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw?foo=bar&baz=buux',
76
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
77
+ '',
78
+ 'foo=bar&baz=buux',
79
+ '',
80
+ ],
81
+ [
82
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/?foo=bar',
83
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
84
+ '/',
85
+ 'foo=bar',
86
+ '',
87
+ ],
88
+ [
89
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/foo?foo=bar',
90
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
91
+ '/foo',
92
+ 'foo=bar',
93
+ '',
94
+ ],
95
+ [
96
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/foo/?foo=bar',
97
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
98
+ '/foo/',
99
+ 'foo=bar',
100
+ '',
101
+ ],
102
+ [
103
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw#hash',
104
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
105
+ '',
106
+ '',
107
+ '#hash',
108
+ ],
109
+ [
110
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/#hash',
111
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
112
+ '/',
113
+ '',
114
+ '#hash',
115
+ ],
116
+ [
117
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/foo#hash',
118
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
119
+ '/foo',
120
+ '',
121
+ '#hash',
122
+ ],
123
+ [
124
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw/foo/#hash',
125
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
126
+ '/foo/',
127
+ '',
128
+ '#hash',
129
+ ],
130
+ [
131
+ 'at://did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw?foo=bar#hash',
132
+ 'did:example:EiAnKD8-jfdd0MDcZUjAbRgaThBrMxPTFOxcnfJhI7Ukaw',
133
+ '',
134
+ 'foo=bar',
135
+ '#hash',
136
+ ],
137
+
138
+ ['did:web:localhost%3A1234', 'did:web:localhost%3A1234', '', '', ''],
139
+ ['at://did:web:localhost%3A1234', 'did:web:localhost%3A1234', '', '', ''],
140
+ [
141
+ 'at://did:web:localhost%3A1234/',
142
+ 'did:web:localhost%3A1234',
143
+ '/',
144
+ '',
145
+ '',
146
+ ],
147
+ [
148
+ 'at://did:web:localhost%3A1234/foo',
149
+ 'did:web:localhost%3A1234',
150
+ '/foo',
151
+ '',
152
+ '',
153
+ ],
154
+ [
155
+ 'at://did:web:localhost%3A1234/foo/',
156
+ 'did:web:localhost%3A1234',
157
+ '/foo/',
158
+ '',
159
+ '',
160
+ ],
161
+ [
162
+ 'at://did:web:localhost%3A1234/foo/bar',
163
+ 'did:web:localhost%3A1234',
164
+ '/foo/bar',
165
+ '',
166
+ '',
167
+ ],
168
+ [
169
+ 'at://did:web:localhost%3A1234?foo=bar',
170
+ 'did:web:localhost%3A1234',
171
+ '',
172
+ 'foo=bar',
173
+ '',
174
+ ],
175
+ [
176
+ 'at://did:web:localhost%3A1234?foo=bar&baz=buux',
177
+ 'did:web:localhost%3A1234',
178
+ '',
179
+ 'foo=bar&baz=buux',
180
+ '',
181
+ ],
182
+ [
183
+ 'at://did:web:localhost%3A1234/?foo=bar',
184
+ 'did:web:localhost%3A1234',
185
+ '/',
186
+ 'foo=bar',
187
+ '',
188
+ ],
189
+ [
190
+ 'at://did:web:localhost%3A1234/foo?foo=bar',
191
+ 'did:web:localhost%3A1234',
192
+ '/foo',
193
+ 'foo=bar',
194
+ '',
195
+ ],
196
+ [
197
+ 'at://did:web:localhost%3A1234/foo/?foo=bar',
198
+ 'did:web:localhost%3A1234',
199
+ '/foo/',
200
+ 'foo=bar',
201
+ '',
202
+ ],
203
+ [
204
+ 'at://did:web:localhost%3A1234#hash',
205
+ 'did:web:localhost%3A1234',
206
+ '',
207
+ '',
208
+ '#hash',
209
+ ],
210
+ [
211
+ 'at://did:web:localhost%3A1234/#hash',
212
+ 'did:web:localhost%3A1234',
213
+ '/',
214
+ '',
215
+ '#hash',
216
+ ],
217
+ [
218
+ 'at://did:web:localhost%3A1234/foo#hash',
219
+ 'did:web:localhost%3A1234',
220
+ '/foo',
221
+ '',
222
+ '#hash',
223
+ ],
224
+ [
225
+ 'at://did:web:localhost%3A1234/foo/#hash',
226
+ 'did:web:localhost%3A1234',
227
+ '/foo/',
228
+ '',
229
+ '#hash',
230
+ ],
231
+ [
232
+ 'at://did:web:localhost%3A1234?foo=bar#hash',
233
+ 'did:web:localhost%3A1234',
234
+ '',
235
+ 'foo=bar',
236
+ '#hash',
237
+ ],
238
+ [
239
+ 'at://4513echo.bsky.social/app.bsky.feed.post/3jsrpdyf6ss23',
240
+ '4513echo.bsky.social',
241
+ '/app.bsky.feed.post/3jsrpdyf6ss23',
242
+ '',
243
+ '',
244
+ ],
245
+ ]
246
+ for (const [uri, hostname, pathname, search, hash] of TESTS) {
247
+ const urip = new AtUri(uri)
248
+ expect(urip.protocol).toBe('at:')
249
+ expect(urip.host).toBe(hostname)
250
+ expect(urip.hostname).toBe(hostname)
251
+ expect(urip.origin).toBe(`at://${hostname}`)
252
+ expect(urip.pathname).toBe(pathname)
253
+ expect(urip.search).toBe(search)
254
+ expect(urip.hash).toBe(hash)
255
+ }
256
+ })
257
+
258
+ it('handles ATP-specific parsing', () => {
259
+ {
260
+ const urip = new AtUri('at://foo.com')
261
+ expect(urip.collection).toBe('')
262
+ expect(urip.rkey).toBe('')
263
+ }
264
+ {
265
+ const urip = new AtUri('at://foo.com/com.example.foo')
266
+ expect(urip.collection).toBe('com.example.foo')
267
+ expect(urip.rkey).toBe('')
268
+ }
269
+ {
270
+ const urip = new AtUri('at://foo.com/com.example.foo/123')
271
+ expect(urip.collection).toBe('com.example.foo')
272
+ expect(urip.rkey).toBe('123')
273
+ }
274
+ })
275
+
276
+ it('supports modifications', () => {
277
+ const urip = new AtUri('at://foo.com')
278
+ expect(urip.toString()).toBe('at://foo.com/')
279
+
280
+ urip.host = 'bar.com'
281
+ expect(urip.toString()).toBe('at://bar.com/')
282
+ urip.host = 'did:web:localhost%3A1234'
283
+ expect(urip.toString()).toBe('at://did:web:localhost%3A1234/')
284
+ urip.host = 'foo.com'
285
+
286
+ urip.pathname = '/'
287
+ expect(urip.toString()).toBe('at://foo.com/')
288
+ urip.pathname = '/foo'
289
+ expect(urip.toString()).toBe('at://foo.com/foo')
290
+ urip.pathname = 'foo'
291
+ expect(urip.toString()).toBe('at://foo.com/foo')
292
+
293
+ urip.collection = 'com.example.foo'
294
+ urip.rkey = '123'
295
+ expect(urip.toString()).toBe('at://foo.com/com.example.foo/123')
296
+ urip.rkey = '124'
297
+ expect(urip.toString()).toBe('at://foo.com/com.example.foo/124')
298
+ urip.collection = 'com.other.foo'
299
+ expect(urip.toString()).toBe('at://foo.com/com.other.foo/124')
300
+ urip.pathname = ''
301
+ urip.rkey = '123'
302
+ expect(urip.toString()).toBe('at://foo.com/undefined/123')
303
+ urip.pathname = 'foo'
304
+
305
+ urip.search = '?foo=bar'
306
+ expect(urip.toString()).toBe('at://foo.com/foo?foo=bar')
307
+ urip.searchParams.set('baz', 'buux')
308
+ expect(urip.toString()).toBe('at://foo.com/foo?foo=bar&baz=buux')
309
+
310
+ urip.hash = '#hash'
311
+ expect(urip.toString()).toBe('at://foo.com/foo?foo=bar&baz=buux#hash')
312
+ urip.hash = 'hash'
313
+ expect(urip.toString()).toBe('at://foo.com/foo?foo=bar&baz=buux#hash')
314
+ })
315
+
316
+ it('supports relative URIs', () => {
317
+ // input path query hash
318
+ type AtUriTest = [string, string, string, string]
319
+ const TESTS: AtUriTest[] = [
320
+ // input hostname pathname query hash
321
+ ['', '', '', ''],
322
+ ['/', '/', '', ''],
323
+ ['/foo', '/foo', '', ''],
324
+ ['/foo/', '/foo/', '', ''],
325
+ ['/foo/bar', '/foo/bar', '', ''],
326
+ ['?foo=bar', '', 'foo=bar', ''],
327
+ ['?foo=bar&baz=buux', '', 'foo=bar&baz=buux', ''],
328
+ ['/?foo=bar', '/', 'foo=bar', ''],
329
+ ['/foo?foo=bar', '/foo', 'foo=bar', ''],
330
+ ['/foo/?foo=bar', '/foo/', 'foo=bar', ''],
331
+ ['#hash', '', '', '#hash'],
332
+ ['/#hash', '/', '', '#hash'],
333
+ ['/foo#hash', '/foo', '', '#hash'],
334
+ ['/foo/#hash', '/foo/', '', '#hash'],
335
+ ['?foo=bar#hash', '', 'foo=bar', '#hash'],
336
+ ]
337
+ const BASES: string[] = [
338
+ 'did:web:localhost%3A1234',
339
+ 'at://did:web:localhost%3A1234',
340
+ 'at://did:web:localhost%3A1234/foo/bar?foo=bar&baz=buux#hash',
341
+ 'did:web:localhost%3A1234',
342
+ 'at://did:web:localhost%3A1234',
343
+ 'at://did:web:localhost%3A1234/foo/bar?foo=bar&baz=buux#hash',
344
+ ]
345
+
346
+ for (const base of BASES) {
347
+ const basep = new AtUri(base)
348
+ for (const [relative, pathname, search, hash] of TESTS) {
349
+ const urip = new AtUri(relative, base)
350
+ expect(urip.protocol).toBe('at:')
351
+ expect(urip.host).toBe(basep.host)
352
+ expect(urip.hostname).toBe(basep.hostname)
353
+ expect(urip.origin).toBe(basep.origin)
354
+ expect(urip.pathname).toBe(pathname)
355
+ expect(urip.search).toBe(search)
356
+ expect(urip.hash).toBe(hash)
357
+ }
358
+ }
359
+ })
360
+ })
361
+
362
+ describe('AtUri validation', () => {
363
+ const expectValid = (h: string) => {
364
+ ensureValidAtUri(h)
365
+ ensureValidAtUriRegex(h)
366
+ }
367
+ const expectInvalid = (h: string) => {
368
+ expect(() => ensureValidAtUri(h)).toThrow()
369
+ expect(() => ensureValidAtUriRegex(h)).toThrow()
370
+ }
371
+
372
+ it('enforces spec basics', () => {
373
+ expectValid('at://did:plc:asdf123')
374
+ expectValid('at://user.bsky.social')
375
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post')
376
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/record')
377
+
378
+ expectValid('at://did:plc:asdf123#/frag')
379
+ expectValid('at://user.bsky.social#/frag')
380
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post#/frag')
381
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/record#/frag')
382
+
383
+ expectInvalid('a://did:plc:asdf123')
384
+ expectInvalid('at//did:plc:asdf123')
385
+ expectInvalid('at:/a/did:plc:asdf123')
386
+ expectInvalid('at:/did:plc:asdf123')
387
+ expectInvalid('AT://did:plc:asdf123')
388
+ expectInvalid('http://did:plc:asdf123')
389
+ expectInvalid('://did:plc:asdf123')
390
+ expectInvalid('at:did:plc:asdf123')
391
+ expectInvalid('at:/did:plc:asdf123')
392
+ expectInvalid('at:///did:plc:asdf123')
393
+ expectInvalid('at://:/did:plc:asdf123')
394
+ expectInvalid('at:/ /did:plc:asdf123')
395
+ expectInvalid('at://did:plc:asdf123 ')
396
+ expectInvalid('at://did:plc:asdf123/ ')
397
+ expectInvalid(' at://did:plc:asdf123')
398
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.post ')
399
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.post# ')
400
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.post#/ ')
401
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.post#/frag ')
402
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.post#fr ag')
403
+ expectInvalid('//did:plc:asdf123')
404
+ expectInvalid('at://name')
405
+ expectInvalid('at://name.0')
406
+ expectInvalid('at://diD:plc:asdf123')
407
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.p@st')
408
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.p$st')
409
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.p%st')
410
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.p&st')
411
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.p()t')
412
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed_post')
413
+ expectInvalid('at://did:plc:asdf123/-com.atproto.feed.post')
414
+ expectInvalid('at://did:plc:asdf@123/com.atproto.feed.post')
415
+
416
+ expectInvalid('at://DID:plc:asdf123')
417
+ expectInvalid('at://user.bsky.123')
418
+ expectInvalid('at://bsky')
419
+ expectInvalid('at://did:plc:')
420
+ expectInvalid('at://did:plc:')
421
+ expectInvalid('at://frag')
422
+
423
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/' + 'o'.repeat(800))
424
+ expectInvalid(
425
+ 'at://did:plc:asdf123/com.atproto.feed.post/' + 'o'.repeat(8200),
426
+ )
427
+ })
428
+
429
+ it('has specified behavior on edge cases', () => {
430
+ expectInvalid('at://user.bsky.social//')
431
+ expectInvalid('at://user.bsky.social//com.atproto.feed.post')
432
+ expectInvalid('at://user.bsky.social/com.atproto.feed.post//')
433
+ expectInvalid(
434
+ 'at://did:plc:asdf123/com.atproto.feed.post/asdf123/more/more',
435
+ )
436
+ expectInvalid('at://did:plc:asdf123/short/stuff')
437
+ expectInvalid('at://did:plc:asdf123/12345')
438
+ })
439
+
440
+ it('enforces no trailing slashes', () => {
441
+ expectValid('at://did:plc:asdf123')
442
+ expectInvalid('at://did:plc:asdf123/')
443
+
444
+ expectValid('at://user.bsky.social')
445
+ expectInvalid('at://user.bsky.social/')
446
+
447
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post')
448
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.post/')
449
+
450
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/record')
451
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.post/record/')
452
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.post/record/#/frag')
453
+ })
454
+
455
+ it('enforces strict paths', () => {
456
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/asdf123')
457
+ expectInvalid('at://did:plc:asdf123/com.atproto.feed.post/asdf123/asdf')
458
+ })
459
+
460
+ it('is very permissive about record keys', () => {
461
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/asdf123')
462
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/a')
463
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/%23')
464
+
465
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/$@!*)(:,;~.sdf123')
466
+ expectValid("at://did:plc:asdf123/com.atproto.feed.post/~'sdf123")
467
+
468
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/$')
469
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/@')
470
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/!')
471
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/*')
472
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/(')
473
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/,')
474
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/;')
475
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/abc%30123')
476
+ })
477
+
478
+ it('is probably too permissive about URL encoding', () => {
479
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/%30')
480
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/%3')
481
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/%')
482
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/%zz')
483
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post/%%%')
484
+ })
485
+
486
+ it('is very permissive about fragments', () => {
487
+ expectValid('at://did:plc:asdf123#/frac')
488
+
489
+ expectInvalid('at://did:plc:asdf123#')
490
+ expectInvalid('at://did:plc:asdf123##')
491
+ expectInvalid('#at://did:plc:asdf123')
492
+ expectInvalid('at://did:plc:asdf123#/asdf#/asdf')
493
+
494
+ expectValid('at://did:plc:asdf123#/com.atproto.feed.post')
495
+ expectValid('at://did:plc:asdf123#/com.atproto.feed.post/')
496
+ expectValid('at://did:plc:asdf123#/asdf/')
497
+
498
+ expectValid('at://did:plc:asdf123/com.atproto.feed.post#/$@!*():,;~.sdf123')
499
+ expectValid('at://did:plc:asdf123#/[asfd]')
500
+
501
+ expectValid('at://did:plc:asdf123#/$')
502
+ expectValid('at://did:plc:asdf123#/*')
503
+ expectValid('at://did:plc:asdf123#/;')
504
+ expectValid('at://did:plc:asdf123#/,')
505
+ })
506
+ })
@@ -0,0 +1,67 @@
1
+ import { ensureValidDid, ensureValidDidRegex, InvalidDidError } from '../src'
2
+
3
+ describe('DID permissive validation', () => {
4
+ const expectValid = (h: string) => {
5
+ ensureValidDid(h)
6
+ ensureValidDidRegex(h)
7
+ }
8
+ const expectInvalid = (h: string) => {
9
+ expect(() => ensureValidDid(h)).toThrow(InvalidDidError)
10
+ expect(() => ensureValidDidRegex(h)).toThrow(InvalidDidError)
11
+ }
12
+
13
+ it('enforces spec details', () => {
14
+ expectValid('did:method:val')
15
+ expectValid('did:method:VAL')
16
+ expectValid('did:method:val123')
17
+ expectValid('did:method:123')
18
+ expectValid('did:method:val-two')
19
+ expectValid('did:method:val_two')
20
+ expectValid('did:method:val.two')
21
+ expectValid('did:method:val:two')
22
+ expectValid('did:method:val%BB')
23
+
24
+ expectInvalid('did')
25
+ expectInvalid('didmethodval')
26
+ expectInvalid('method:did:val')
27
+ expectInvalid('did:method:')
28
+ expectInvalid('didmethod:val')
29
+ expectInvalid('did:methodval')
30
+ expectInvalid(':did:method:val')
31
+ expectInvalid('did.method.val')
32
+ expectInvalid('did:method:val:')
33
+ expectInvalid('did:method:val%')
34
+ expectInvalid('DID:method:val')
35
+ expectInvalid('did:METHOD:val')
36
+ expectInvalid('did:m123:val')
37
+
38
+ expectValid('did:method:' + 'v'.repeat(240))
39
+ expectInvalid('did:method:' + 'v'.repeat(8500))
40
+
41
+ expectValid('did:m:v')
42
+ expectValid('did:method::::val')
43
+ expectValid('did:method:-')
44
+ expectValid('did:method:-:_:.:%ab')
45
+ expectValid('did:method:.')
46
+ expectValid('did:method:_')
47
+ expectValid('did:method::.')
48
+
49
+ expectInvalid('did:method:val/two')
50
+ expectInvalid('did:method:val?two')
51
+ expectInvalid('did:method:val#two')
52
+ expectInvalid('did:method:val%')
53
+
54
+ expectValid(
55
+ 'did:onion:2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid',
56
+ )
57
+ })
58
+
59
+ it('allows some real DID values', () => {
60
+ expectValid('did:example:123456789abcdefghi')
61
+ expectValid('did:plc:7iza6de2dwap2sbkpav7c6c6')
62
+ expectValid('did:web:example.com')
63
+ expectValid('did:web:localhost%3A1234')
64
+ expectValid('did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N')
65
+ expectValid('did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a')
66
+ })
67
+ })