@atproto/syntax 0.4.1 → 0.4.3
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 +18 -0
- package/LICENSE.txt +1 -1
- package/dist/at-identifier.d.ts +6 -0
- package/dist/at-identifier.d.ts.map +1 -0
- package/dist/at-identifier.js +28 -0
- package/dist/at-identifier.js.map +1 -0
- package/dist/aturi.d.ts +8 -6
- package/dist/aturi.d.ts.map +1 -1
- package/dist/aturi.js +41 -83
- package/dist/aturi.js.map +1 -1
- package/dist/aturi_validation.d.ts +6 -2
- package/dist/aturi_validation.d.ts.map +1 -1
- package/dist/aturi_validation.js +66 -60
- package/dist/aturi_validation.js.map +1 -1
- package/dist/datetime.d.ts +11 -4
- package/dist/datetime.d.ts.map +1 -1
- package/dist/datetime.js +22 -25
- package/dist/datetime.js.map +1 -1
- package/dist/did.d.ts +4 -2
- package/dist/did.d.ts.map +1 -1
- package/dist/did.js +24 -19
- package/dist/did.js.map +1 -1
- package/dist/handle.d.ts +7 -6
- package/dist/handle.d.ts.map +1 -1
- package/dist/handle.js +32 -35
- package/dist/handle.js.map +1 -1
- package/dist/index.d.ts +10 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -21
- package/dist/index.js.map +1 -1
- package/dist/language.d.ts +18 -0
- package/dist/language.d.ts.map +1 -0
- package/dist/language.js +30 -0
- package/dist/language.js.map +1 -0
- package/dist/nsid.d.ts +6 -5
- package/dist/nsid.d.ts.map +1 -1
- package/dist/nsid.js +6 -11
- package/dist/nsid.js.map +1 -1
- package/dist/recordkey.d.ts +3 -2
- package/dist/recordkey.d.ts.map +1 -1
- package/dist/recordkey.js +33 -22
- package/dist/recordkey.js.map +1 -1
- package/dist/tid.d.ts +3 -2
- package/dist/tid.d.ts.map +1 -1
- package/dist/tid.js +10 -10
- package/dist/tid.js.map +1 -1
- package/dist/uri.d.ts +3 -0
- package/dist/uri.d.ts.map +1 -0
- package/dist/uri.js +7 -0
- package/dist/uri.js.map +1 -0
- package/package.json +7 -4
- package/src/at-identifier.ts +33 -0
- package/src/aturi.ts +59 -46
- package/src/aturi_validation.ts +78 -51
- package/src/datetime.ts +26 -14
- package/src/did.ts +28 -15
- package/src/handle.ts +29 -26
- package/src/index.ts +10 -7
- package/src/language.ts +39 -0
- package/src/nsid.ts +14 -8
- package/src/recordkey.ts +42 -17
- package/src/tid.ts +9 -5
- package/src/uri.ts +5 -0
- package/tests/aturi.test.ts +3 -2
- package/tests/datetime.test.ts +1 -0
- package/tests/did.test.ts +1 -0
- package/tests/handle.test.ts +1 -0
- package/tests/language.test.ts +88 -0
- package/tests/nsid.test.ts +1 -0
- package/tests/recordkey.test.ts +1 -0
- package/tests/tid.test.ts +1 -0
- package/tsconfig.build.json +6 -2
- package/tsconfig.build.tsbuildinfo +1 -1
- package/tsconfig.tests.json +6 -4
- package/vitest.config.ts +5 -0
- package/jest.config.js +0 -7
- package/tsconfig.tests.tsbuildinfo +0 -1
package/src/aturi_validation.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { AtIdentifierString, ensureValidAtIdentifier } from './at-identifier.js'
|
|
2
|
+
import { ensureValidDidRegex } from './did.js'
|
|
3
|
+
import { ensureValidHandleRegex } from './handle.js'
|
|
4
|
+
import { NsidString, isValidNsid } from './nsid.js'
|
|
5
|
+
|
|
6
|
+
export type AtUriString =
|
|
7
|
+
| `at://${AtIdentifierString}`
|
|
8
|
+
| `at://${AtIdentifierString}/${NsidString}`
|
|
9
|
+
| `at://${AtIdentifierString}/${NsidString}/${string}`
|
|
4
10
|
|
|
5
11
|
// Human-readable constraints on ATURI:
|
|
6
12
|
// - following regular URLs, a 8KByte hard total length limit
|
|
@@ -15,53 +21,78 @@ import { isValidNsid } from './nsid'
|
|
|
15
21
|
// [a-zA-Z0-9._~:@!$&'\(\)*+,;=-]
|
|
16
22
|
// - rkey must have at least one char
|
|
17
23
|
// - regardless of path component, a fragment can follow as "#" and then a JSON pointer (RFC-6901)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
|
|
25
|
+
export function ensureValidAtUri<I extends string>(
|
|
26
|
+
input: I,
|
|
27
|
+
): asserts input is I & AtUriString {
|
|
28
|
+
const fragmentIndex = input.indexOf('#')
|
|
29
|
+
if (fragmentIndex !== -1) {
|
|
30
|
+
if (input.charCodeAt(fragmentIndex + 1) !== 47) {
|
|
31
|
+
throw new Error('ATURI fragment must be non-empty and start with slash')
|
|
32
|
+
}
|
|
33
|
+
if (input.includes('#', fragmentIndex + 1)) {
|
|
34
|
+
throw new Error('ATURI can have at most one "#", separating fragment out')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// NOTE: enforcing *some* checks here for sanity. Eg, at least no whitespace
|
|
38
|
+
const fragment = input.slice(fragmentIndex + 1)
|
|
39
|
+
if (!/^\/[a-zA-Z0-9._~:@!$&')(*+,;=%[\]/-]*$/.test(fragment)) {
|
|
40
|
+
throw new Error('Disallowed characters in ATURI fragment (ASCII)')
|
|
41
|
+
}
|
|
23
42
|
}
|
|
24
|
-
const fragmentPart = uriParts[1] || null
|
|
25
|
-
uri = uriParts[0]
|
|
26
43
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
44
|
+
const uri = fragmentIndex === -1 ? input : input.slice(0, fragmentIndex)
|
|
45
|
+
|
|
46
|
+
if (uri.length > 8 * 1024) {
|
|
47
|
+
throw new Error('ATURI is far too long')
|
|
30
48
|
}
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
if (parts.length >= 3 && (parts[0] !== 'at:' || parts[1].length !== 0)) {
|
|
50
|
+
if (!uri.startsWith('at://')) {
|
|
34
51
|
throw new Error('ATURI must start with "at://"')
|
|
35
52
|
}
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
|
|
54
|
+
// check that all chars are boring ASCII
|
|
55
|
+
if (!/^[a-zA-Z0-9._~:@!$&')(*+,;=%/-]*$/.test(uri)) {
|
|
56
|
+
throw new Error('Disallowed characters in ATURI (ASCII)')
|
|
38
57
|
}
|
|
39
58
|
|
|
59
|
+
const authorityEnd = uri.indexOf('/', 5)
|
|
60
|
+
const authority =
|
|
61
|
+
authorityEnd === -1 ? uri.slice(5) : uri.slice(5, authorityEnd)
|
|
40
62
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
ensureValidHandle(parts[2])
|
|
45
|
-
}
|
|
46
|
-
} catch {
|
|
47
|
-
throw new Error('ATURI authority must be a valid handle or DID')
|
|
63
|
+
ensureValidAtIdentifier(authority)
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
throw new Error('ATURI authority must be a valid handle or DID', { cause })
|
|
48
66
|
}
|
|
49
67
|
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
const collectionStart = authorityEnd === -1 ? -1 : authorityEnd + 1
|
|
69
|
+
const collectionEnd =
|
|
70
|
+
collectionStart === -1 ? -1 : uri.indexOf('/', collectionStart)
|
|
71
|
+
|
|
72
|
+
if (collectionStart !== -1) {
|
|
73
|
+
const collection =
|
|
74
|
+
collectionEnd === -1
|
|
75
|
+
? uri.slice(collectionStart)
|
|
76
|
+
: uri.slice(collectionStart, collectionEnd)
|
|
77
|
+
|
|
78
|
+
if (collection.length === 0) {
|
|
52
79
|
throw new Error(
|
|
53
80
|
'ATURI can not have a slash after authority without a path segment',
|
|
54
81
|
)
|
|
55
82
|
}
|
|
56
|
-
if (!isValidNsid(
|
|
83
|
+
if (!isValidNsid(collection)) {
|
|
57
84
|
throw new Error(
|
|
58
85
|
'ATURI requires first path segment (if supplied) to be valid NSID',
|
|
59
86
|
)
|
|
60
87
|
}
|
|
61
88
|
}
|
|
62
89
|
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
const recordKeyStart = collectionEnd === -1 ? -1 : collectionEnd + 1
|
|
91
|
+
const recordKeyEnd =
|
|
92
|
+
recordKeyStart === -1 ? -1 : uri.indexOf('/', recordKeyStart)
|
|
93
|
+
|
|
94
|
+
if (recordKeyStart !== -1) {
|
|
95
|
+
if (recordKeyStart === uri.length) {
|
|
65
96
|
throw new Error(
|
|
66
97
|
'ATURI can not have a slash after collection, unless record key is provided',
|
|
67
98
|
)
|
|
@@ -69,37 +100,21 @@ export const ensureValidAtUri = (uri: string) => {
|
|
|
69
100
|
// would validate rkey here, but there are basically no constraints!
|
|
70
101
|
}
|
|
71
102
|
|
|
72
|
-
if (
|
|
103
|
+
if (recordKeyEnd !== -1) {
|
|
73
104
|
throw new Error(
|
|
74
105
|
'ATURI path can have at most two parts, and no trailing slash',
|
|
75
106
|
)
|
|
76
107
|
}
|
|
77
|
-
|
|
78
|
-
if (uriParts.length >= 2 && fragmentPart == null) {
|
|
79
|
-
throw new Error('ATURI fragment must be non-empty and start with slash')
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (fragmentPart != null) {
|
|
83
|
-
if (fragmentPart.length === 0 || fragmentPart[0] !== '/') {
|
|
84
|
-
throw new Error('ATURI fragment must be non-empty and start with slash')
|
|
85
|
-
}
|
|
86
|
-
// NOTE: enforcing *some* checks here for sanity. Eg, at least no whitespace
|
|
87
|
-
if (!/^\/[a-zA-Z0-9._~:@!$&')(*+,;=%[\]/-]*$/.test(fragmentPart)) {
|
|
88
|
-
throw new Error('Disallowed characters in ATURI fragment (ASCII)')
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (uri.length > 8 * 1024) {
|
|
93
|
-
throw new Error('ATURI is far too long')
|
|
94
|
-
}
|
|
95
108
|
}
|
|
96
109
|
|
|
97
|
-
export
|
|
110
|
+
export function ensureValidAtUriRegex<I extends string>(
|
|
111
|
+
input: I,
|
|
112
|
+
): asserts input is I & AtUriString {
|
|
98
113
|
// simple regex to enforce most constraints via just regex and length.
|
|
99
114
|
// hand wrote this regex based on above constraints. whew!
|
|
100
115
|
const aturiRegex =
|
|
101
116
|
/^at:\/\/(?<authority>[a-zA-Z0-9._:%-]+)(\/(?<collection>[a-zA-Z0-9-.]+)(\/(?<rkey>[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(#(?<fragment>\/[a-zA-Z0-9._~:@!$&%')(*+,;=\-[\]/\\]*))?$/
|
|
102
|
-
const rm =
|
|
117
|
+
const rm = input.match(aturiRegex)
|
|
103
118
|
if (!rm || !rm.groups) {
|
|
104
119
|
throw new Error("ATURI didn't validate via regex")
|
|
105
120
|
}
|
|
@@ -119,7 +134,19 @@ export const ensureValidAtUriRegex = (uri: string): void => {
|
|
|
119
134
|
throw new Error('ATURI collection path segment must be a valid NSID')
|
|
120
135
|
}
|
|
121
136
|
|
|
122
|
-
if (
|
|
137
|
+
if (input.length > 8 * 1024) {
|
|
123
138
|
throw new Error('ATURI is far too long')
|
|
124
139
|
}
|
|
125
140
|
}
|
|
141
|
+
|
|
142
|
+
export function isValidAtUri<I extends string>(
|
|
143
|
+
input: I,
|
|
144
|
+
): input is I & AtUriString {
|
|
145
|
+
try {
|
|
146
|
+
ensureValidAtUriRegex(input)
|
|
147
|
+
} catch {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return true
|
|
152
|
+
}
|
package/src/datetime.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
/** An ISO 8601 formatted datetime string (YYYY-MM-DDTHH:mm:ss.sssZ) */
|
|
2
|
+
export type DatetimeString =
|
|
3
|
+
`${string}-${string}-${string}T${string}:${string}:${string}${'Z' | `+${string}` | `-${string}`}`
|
|
4
|
+
|
|
5
|
+
// Allow date.toISOString() to be used where datetime format is expected
|
|
6
|
+
declare global {
|
|
7
|
+
interface Date {
|
|
8
|
+
toISOString(): `${string}-${string}-${string}T${string}:${string}:${string}Z`
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
1
12
|
/* Validates datetime string against atproto Lexicon 'datetime' format.
|
|
2
13
|
* Syntax is described at: https://atproto.com/specs/lexicon#datetime
|
|
3
14
|
*/
|
|
4
|
-
export
|
|
5
|
-
|
|
15
|
+
export function ensureValidDatetime<I extends string>(
|
|
16
|
+
input: I,
|
|
17
|
+
): asserts input is I & DatetimeString {
|
|
18
|
+
const date = new Date(input)
|
|
6
19
|
// must parse as ISO 8601; this also verifies semantics like month is not 13 or 00
|
|
7
20
|
if (isNaN(date.getTime())) {
|
|
8
21
|
throw new InvalidDatetimeError('datetime did not parse as ISO 8601')
|
|
@@ -13,34 +26,33 @@ export const ensureValidDatetime = (dtStr: string): void => {
|
|
|
13
26
|
// regex and other checks for RFC-3339
|
|
14
27
|
if (
|
|
15
28
|
!/^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$/.test(
|
|
16
|
-
|
|
29
|
+
input,
|
|
17
30
|
)
|
|
18
31
|
) {
|
|
19
32
|
throw new InvalidDatetimeError("datetime didn't validate via regex")
|
|
20
33
|
}
|
|
21
|
-
if (
|
|
34
|
+
if (input.length > 64) {
|
|
22
35
|
throw new InvalidDatetimeError('datetime is too long (64 chars max)')
|
|
23
36
|
}
|
|
24
|
-
if (
|
|
37
|
+
if (input.endsWith('-00:00')) {
|
|
25
38
|
throw new InvalidDatetimeError(
|
|
26
39
|
'datetime can not use "-00:00" for UTC timezone',
|
|
27
40
|
)
|
|
28
41
|
}
|
|
29
|
-
if (
|
|
42
|
+
if (input.startsWith('000')) {
|
|
30
43
|
throw new InvalidDatetimeError('datetime so close to year zero not allowed')
|
|
31
44
|
}
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
/* Same logic as ensureValidDatetime(), but returns a boolean instead of throwing an exception.
|
|
35
48
|
*/
|
|
36
|
-
export
|
|
49
|
+
export function isValidDatetime<I extends string>(
|
|
50
|
+
input: I,
|
|
51
|
+
): input is I & DatetimeString {
|
|
37
52
|
try {
|
|
38
|
-
ensureValidDatetime(
|
|
53
|
+
ensureValidDatetime(input)
|
|
39
54
|
} catch (err) {
|
|
40
|
-
|
|
41
|
-
return false
|
|
42
|
-
}
|
|
43
|
-
throw err
|
|
55
|
+
return false
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
return true
|
|
@@ -56,7 +68,7 @@ export const isValidDatetime = (dtStr: string): boolean => {
|
|
|
56
68
|
*
|
|
57
69
|
* Expected output format: YYYY-MM-DDTHH:mm:ss.sssZ
|
|
58
70
|
*/
|
|
59
|
-
export
|
|
71
|
+
export function normalizeDatetime(dtStr: string): DatetimeString {
|
|
60
72
|
if (isValidDatetime(dtStr)) {
|
|
61
73
|
const outStr = new Date(dtStr).toISOString()
|
|
62
74
|
if (isValidDatetime(outStr)) {
|
|
@@ -96,7 +108,7 @@ export const normalizeDatetime = (dtStr: string): string => {
|
|
|
96
108
|
*
|
|
97
109
|
* If a InvalidDatetimeError is encountered, returns the UNIX epoch time as a UTC datetime (1970-01-01T00:00:00.000Z).
|
|
98
110
|
*/
|
|
99
|
-
export const normalizeDatetimeAlways = (dtStr: string):
|
|
111
|
+
export const normalizeDatetimeAlways = (dtStr: string): DatetimeString => {
|
|
100
112
|
try {
|
|
101
113
|
return normalizeDatetime(dtStr)
|
|
102
114
|
} catch (err) {
|
package/src/did.ts
CHANGED
|
@@ -11,19 +11,32 @@
|
|
|
11
11
|
// - in current atproto, only allowing did:plc and did:web. But not *forcing* this at lexicon layer
|
|
12
12
|
// - hard length limit of 8KBytes
|
|
13
13
|
// - not going to validate "percent encoding" here
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
|
|
15
|
+
export type DidString<M extends string = string> = `did:${M}:${string}`
|
|
16
|
+
|
|
17
|
+
export function ensureValidDid<I extends string>(
|
|
18
|
+
input: I,
|
|
19
|
+
): asserts input is I & DidString {
|
|
20
|
+
if (!input.startsWith('did:')) {
|
|
16
21
|
throw new InvalidDidError('DID requires "did:" prefix')
|
|
17
22
|
}
|
|
18
23
|
|
|
24
|
+
if (input.length > 2048) {
|
|
25
|
+
throw new InvalidDidError('DID is too long (2048 chars max)')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (input.endsWith(':') || input.endsWith('%')) {
|
|
29
|
+
throw new InvalidDidError('DID can not end with ":" or "%"')
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
// check that all chars are boring ASCII
|
|
20
|
-
if (!/^[a-zA-Z0-9._:%-]*$/.test(
|
|
33
|
+
if (!/^[a-zA-Z0-9._:%-]*$/.test(input)) {
|
|
21
34
|
throw new InvalidDidError(
|
|
22
35
|
'Disallowed characters in DID (ASCII letters, digits, and a couple other characters only)',
|
|
23
36
|
)
|
|
24
37
|
}
|
|
25
38
|
|
|
26
|
-
const { length, 1: method } =
|
|
39
|
+
const { length, 1: method } = input.split(':')
|
|
27
40
|
if (length < 3) {
|
|
28
41
|
throw new InvalidDidError(
|
|
29
42
|
'DID requires prefix, method, and method-specific content',
|
|
@@ -33,26 +46,26 @@ export const ensureValidDid = (did: string): void => {
|
|
|
33
46
|
if (!/^[a-z]+$/.test(method)) {
|
|
34
47
|
throw new InvalidDidError('DID method must be lower-case letters')
|
|
35
48
|
}
|
|
36
|
-
|
|
37
|
-
if (did.endsWith(':') || did.endsWith('%')) {
|
|
38
|
-
throw new InvalidDidError('DID can not end with ":" or "%"')
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (did.length > 2 * 1024) {
|
|
42
|
-
throw new InvalidDidError('DID is too long (2048 chars max)')
|
|
43
|
-
}
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
const DID_REGEX = /^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/
|
|
52
|
+
|
|
53
|
+
export function ensureValidDidRegex<I extends string>(
|
|
54
|
+
input: I,
|
|
55
|
+
): asserts input is I & DidString {
|
|
47
56
|
// simple regex to enforce most constraints via just regex and length.
|
|
48
57
|
// hand wrote this regex based on above constraints
|
|
49
|
-
if (
|
|
58
|
+
if (!DID_REGEX.test(input)) {
|
|
50
59
|
throw new InvalidDidError("DID didn't validate via regex")
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
if (
|
|
62
|
+
if (input.length > 2048) {
|
|
54
63
|
throw new InvalidDidError('DID is too long (2048 chars max)')
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
export function isValidDid<I extends string>(input: I): input is I & DidString {
|
|
68
|
+
return input.length <= 2048 && DID_REGEX.test(input)
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
export class InvalidDidError extends Error {}
|
package/src/handle.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export const INVALID_HANDLE = 'handle.invalid'
|
|
2
2
|
|
|
3
|
+
export type HandleString = `${string}.${string}`
|
|
4
|
+
|
|
3
5
|
// Currently these are registration-time restrictions, not protocol-level
|
|
4
6
|
// restrictions. We have a couple accounts in the wild that we need to clean up
|
|
5
7
|
// before hard-disallow.
|
|
@@ -37,18 +39,20 @@ export const DISALLOWED_TLDS = [
|
|
|
37
39
|
// - does not validate whether domain or TLD exists, or is a reserved or
|
|
38
40
|
// special TLD (eg, .onion or .local)
|
|
39
41
|
// - does not validate punycode
|
|
40
|
-
export
|
|
42
|
+
export function ensureValidHandle<I extends string>(
|
|
43
|
+
input: I,
|
|
44
|
+
): asserts input is I & HandleString {
|
|
41
45
|
// check that all chars are boring ASCII
|
|
42
|
-
if (!/^[a-zA-Z0-9.-]*$/.test(
|
|
46
|
+
if (!/^[a-zA-Z0-9.-]*$/.test(input)) {
|
|
43
47
|
throw new InvalidHandleError(
|
|
44
48
|
'Disallowed characters in handle (ASCII letters, digits, dashes, periods only)',
|
|
45
49
|
)
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
if (
|
|
52
|
+
if (input.length > 253) {
|
|
49
53
|
throw new InvalidHandleError('Handle is too long (253 chars max)')
|
|
50
54
|
}
|
|
51
|
-
const labels =
|
|
55
|
+
const labels = input.split('.')
|
|
52
56
|
if (labels.length < 2) {
|
|
53
57
|
throw new InvalidHandleError('Handle domain needs at least two parts')
|
|
54
58
|
}
|
|
@@ -74,46 +78,45 @@ export const ensureValidHandle = (handle: string): void => {
|
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
// simple regex translation of above constraints
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
if (handle.length > 253) {
|
|
81
|
+
const HANDLE_REGEX =
|
|
82
|
+
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
|
83
|
+
|
|
84
|
+
export function ensureValidHandleRegex<I extends string>(
|
|
85
|
+
input: I,
|
|
86
|
+
): asserts input is I & HandleString {
|
|
87
|
+
if (input.length > 253) {
|
|
86
88
|
throw new InvalidHandleError('Handle is too long (253 chars max)')
|
|
87
89
|
}
|
|
90
|
+
if (!HANDLE_REGEX.test(input)) {
|
|
91
|
+
throw new InvalidHandleError("Handle didn't validate via regex")
|
|
92
|
+
}
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
export
|
|
95
|
+
export function normalizeHandle(handle: string): string {
|
|
91
96
|
return handle.toLowerCase()
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
export
|
|
99
|
+
export function normalizeAndEnsureValidHandle(handle: string): HandleString {
|
|
95
100
|
const normalized = normalizeHandle(handle)
|
|
96
101
|
ensureValidHandle(normalized)
|
|
97
102
|
return normalized
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
export
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
export function isValidHandle<I extends string>(
|
|
106
|
+
input: I,
|
|
107
|
+
): input is I & HandleString {
|
|
108
|
+
return input.length <= 253 && HANDLE_REGEX.test(input)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isValidTld(handle: string): boolean {
|
|
112
|
+
for (const tld of DISALLOWED_TLDS) {
|
|
113
|
+
if (handle.endsWith(tld)) {
|
|
105
114
|
return false
|
|
106
115
|
}
|
|
107
|
-
throw err
|
|
108
116
|
}
|
|
109
|
-
|
|
110
117
|
return true
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
export const isValidTld = (handle: string): boolean => {
|
|
114
|
-
return !DISALLOWED_TLDS.some((domain) => handle.endsWith(domain))
|
|
115
|
-
}
|
|
116
|
-
|
|
117
120
|
export class InvalidHandleError extends Error {}
|
|
118
121
|
/** @deprecated Never used */
|
|
119
122
|
export class ReservedHandleError extends Error {}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
export * from './
|
|
2
|
-
export * from './
|
|
3
|
-
export * from './
|
|
4
|
-
export * from './
|
|
5
|
-
export * from './
|
|
6
|
-
export * from './
|
|
7
|
-
export * from './
|
|
1
|
+
export * from './at-identifier.js'
|
|
2
|
+
export * from './aturi.js'
|
|
3
|
+
export * from './datetime.js'
|
|
4
|
+
export * from './did.js'
|
|
5
|
+
export * from './handle.js'
|
|
6
|
+
export * from './nsid.js'
|
|
7
|
+
export * from './language.js'
|
|
8
|
+
export * from './recordkey.js'
|
|
9
|
+
export * from './tid.js'
|
|
10
|
+
export * from './uri.js'
|
package/src/language.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const BCP47_REGEXP =
|
|
2
|
+
/^((?<grandfathered>(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?<language>([A-Za-z]{2,3}(-(?<extlang>[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-(?<script>[A-Za-z]{4}))?(-(?<region>[A-Za-z]{2}|[0-9]{3}))?(-(?<variant>[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-(?<extension>[0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(?<privateUseA>x(-[A-Za-z0-9]{1,8})+))?)|(?<privateUseB>x(-[A-Za-z0-9]{1,8})+))$/
|
|
3
|
+
|
|
4
|
+
export type LanguageTag = {
|
|
5
|
+
grandfathered?: string
|
|
6
|
+
language?: string
|
|
7
|
+
extlang?: string
|
|
8
|
+
script?: string
|
|
9
|
+
region?: string
|
|
10
|
+
variant?: string
|
|
11
|
+
extension?: string
|
|
12
|
+
privateUse?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseLanguageString(input: string): LanguageTag | null {
|
|
16
|
+
const parsed = input.match(BCP47_REGEXP)
|
|
17
|
+
if (!parsed?.groups) return null
|
|
18
|
+
|
|
19
|
+
const { groups } = parsed
|
|
20
|
+
return {
|
|
21
|
+
grandfathered: groups.grandfathered,
|
|
22
|
+
language: groups.language,
|
|
23
|
+
extlang: groups.extlang,
|
|
24
|
+
script: groups.script,
|
|
25
|
+
region: groups.region,
|
|
26
|
+
variant: groups.variant,
|
|
27
|
+
extension: groups.extension,
|
|
28
|
+
privateUse: groups.privateUseA || groups.privateUseB,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validates well-formed BCP 47 syntax
|
|
34
|
+
*
|
|
35
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc5646.html#section-2.1}
|
|
36
|
+
*/
|
|
37
|
+
export function isValidLanguage(input: string): boolean {
|
|
38
|
+
return BCP47_REGEXP.test(input)
|
|
39
|
+
}
|
package/src/nsid.ts
CHANGED
|
@@ -11,6 +11,8 @@ nsid = authority delim name
|
|
|
11
11
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
export type NsidString = `${string}.${string}.${string}`
|
|
15
|
+
|
|
14
16
|
export class NSID {
|
|
15
17
|
readonly segments: readonly string[]
|
|
16
18
|
|
|
@@ -58,8 +60,10 @@ export class NSID {
|
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
export function ensureValidNsid
|
|
62
|
-
|
|
63
|
+
export function ensureValidNsid<I extends string>(
|
|
64
|
+
input: I,
|
|
65
|
+
): asserts input is I & NsidString {
|
|
66
|
+
const result = validateNsid(input)
|
|
63
67
|
if (!result.success) throw new InvalidNsidError(result.message)
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -69,10 +73,12 @@ export function parseNsid(nsid: string): string[] {
|
|
|
69
73
|
return result.value
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
export function isValidNsid
|
|
76
|
+
export function isValidNsid<I extends string>(
|
|
77
|
+
input: I,
|
|
78
|
+
): input is I & NsidString {
|
|
73
79
|
// Since the regex version is more performant for valid NSIDs, we use it when
|
|
74
80
|
// we don't care about error details.
|
|
75
|
-
return validateNsidRegex(
|
|
81
|
+
return validateNsidRegex(input).success
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
type ValidateResult<T> =
|
|
@@ -142,7 +148,7 @@ export function validateNsid(input: string): ValidateResult<string[]> {
|
|
|
142
148
|
}
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
function hasDisallowedCharacters(v) {
|
|
151
|
+
function hasDisallowedCharacters(v: string) {
|
|
146
152
|
return !/^[a-zA-Z0-9.-]*$/.test(v)
|
|
147
153
|
}
|
|
148
154
|
|
|
@@ -173,7 +179,7 @@ function isValidIdentifier(v: string) {
|
|
|
173
179
|
* {@link parseNsid}/{@link NSID.parse} if you need the parsed segments, or
|
|
174
180
|
* {@link isValidNsid} if you just want a boolean.
|
|
175
181
|
*/
|
|
176
|
-
export function ensureValidNsidRegex(nsid: string):
|
|
182
|
+
export function ensureValidNsidRegex(nsid: string): asserts nsid is NsidString {
|
|
177
183
|
const result = validateNsidRegex(nsid)
|
|
178
184
|
if (!result.success) throw new InvalidNsidError(result.message)
|
|
179
185
|
}
|
|
@@ -182,7 +188,7 @@ export function ensureValidNsidRegex(nsid: string): void {
|
|
|
182
188
|
* Regexp based validation that behaves identically to the previous code but
|
|
183
189
|
* provides less detailed error messages (while being 20% to 50% faster).
|
|
184
190
|
*/
|
|
185
|
-
export function validateNsidRegex(value: string): ValidateResult<
|
|
191
|
+
export function validateNsidRegex(value: string): ValidateResult<NsidString> {
|
|
186
192
|
if (value.length > 253 + 1 + 63) {
|
|
187
193
|
return {
|
|
188
194
|
success: false,
|
|
@@ -203,7 +209,7 @@ export function validateNsidRegex(value: string): ValidateResult<string> {
|
|
|
203
209
|
|
|
204
210
|
return {
|
|
205
211
|
success: true,
|
|
206
|
-
value,
|
|
212
|
+
value: value as NsidString,
|
|
207
213
|
}
|
|
208
214
|
}
|
|
209
215
|
|
package/src/recordkey.ts
CHANGED
|
@@ -1,26 +1,51 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export type RecordKeyString = string
|
|
2
|
+
|
|
3
|
+
const RECORD_KEY_MAX_LENGTH = 512
|
|
4
|
+
const RECORD_KEY_MIN_LENGTH = 1
|
|
5
|
+
const RECORD_KEY_INVALID_VALUES = new Set(['.', '..'])
|
|
6
|
+
const RECORD_KEY_REGEX = /^[a-zA-Z0-9_~.:-]{1,512}$/
|
|
7
|
+
|
|
8
|
+
// https://atproto.com/specs/record-key#record-key-syntax
|
|
9
|
+
// Regardless of the type, Record Keys must fulfill some baseline syntax constraints:
|
|
10
|
+
// - restricted to a subset of ASCII characters -- the allowed characters are
|
|
11
|
+
// alphanumeric (A-Za-z0-9), period, dash, underscore, colon, or tilde (.-_:~)
|
|
12
|
+
// - must have at least 1 and at most 512 characters
|
|
13
|
+
// - the specific record key values . and .. are not allowed
|
|
14
|
+
// - must be a permissible part of repository MST path string (the above
|
|
15
|
+
// constraints satisfy this condition)
|
|
16
|
+
// - must be permissible to include in a path component of a URI (following
|
|
17
|
+
// RFC-3986, section 3.3). The above constraints satisfy this condition, by
|
|
18
|
+
// matching the "unreserved" characters allowed in generic URI paths.
|
|
19
|
+
|
|
20
|
+
export function ensureValidRecordKey<I extends string>(
|
|
21
|
+
input: I,
|
|
22
|
+
): asserts input is I & RecordKeyString {
|
|
23
|
+
if (
|
|
24
|
+
input.length > RECORD_KEY_MAX_LENGTH ||
|
|
25
|
+
input.length < RECORD_KEY_MIN_LENGTH
|
|
26
|
+
) {
|
|
27
|
+
throw new InvalidRecordKeyError(
|
|
28
|
+
`record key must be ${RECORD_KEY_MIN_LENGTH} to ${RECORD_KEY_MAX_LENGTH} characters`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
if (RECORD_KEY_INVALID_VALUES.has(input)) {
|
|
32
|
+
throw new InvalidRecordKeyError('record key can not be "." or ".."')
|
|
4
33
|
}
|
|
5
34
|
// simple regex to enforce most constraints via just regex and length.
|
|
6
|
-
if (
|
|
35
|
+
if (!RECORD_KEY_REGEX.test(input)) {
|
|
7
36
|
throw new InvalidRecordKeyError('record key syntax not valid (regex)')
|
|
8
37
|
}
|
|
9
|
-
if (rkey === '.' || rkey === '..')
|
|
10
|
-
throw new InvalidRecordKeyError('record key can not be "." or ".."')
|
|
11
38
|
}
|
|
12
39
|
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return true
|
|
40
|
+
export function isValidRecordKey<I extends string>(
|
|
41
|
+
input: I,
|
|
42
|
+
): input is I & RecordKeyString {
|
|
43
|
+
return (
|
|
44
|
+
input.length >= RECORD_KEY_MIN_LENGTH &&
|
|
45
|
+
input.length <= RECORD_KEY_MAX_LENGTH &&
|
|
46
|
+
RECORD_KEY_REGEX.test(input) &&
|
|
47
|
+
!RECORD_KEY_INVALID_VALUES.has(input)
|
|
48
|
+
)
|
|
24
49
|
}
|
|
25
50
|
|
|
26
51
|
export class InvalidRecordKeyError extends Error {}
|
package/src/tid.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
+
export type TidString = string
|
|
2
|
+
|
|
1
3
|
const TID_LENGTH = 13
|
|
2
4
|
const TID_REGEX = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/
|
|
3
5
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
+
export function ensureValidTid<I extends string>(
|
|
7
|
+
input: I,
|
|
8
|
+
): asserts input is I & TidString {
|
|
9
|
+
if (input.length !== TID_LENGTH) {
|
|
6
10
|
throw new InvalidTidError(`TID must be ${TID_LENGTH} characters`)
|
|
7
11
|
}
|
|
8
12
|
// simple regex to enforce most constraints via just regex and length.
|
|
9
|
-
if (!TID_REGEX.test(
|
|
13
|
+
if (!TID_REGEX.test(input)) {
|
|
10
14
|
throw new InvalidTidError('TID syntax not valid (regex)')
|
|
11
15
|
}
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
export
|
|
15
|
-
return
|
|
18
|
+
export function isValidTid<I extends string>(input: I): input is I & TidString {
|
|
19
|
+
return input.length === TID_LENGTH && TID_REGEX.test(input)
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export class InvalidTidError extends Error {}
|