@atproto/syntax 0.4.3 → 0.5.1
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 +26 -0
- package/dist/at-identifier.d.ts +49 -2
- package/dist/at-identifier.d.ts.map +1 -1
- package/dist/at-identifier.js +68 -6
- package/dist/at-identifier.js.map +1 -1
- package/dist/aturi.d.ts +10 -1
- package/dist/aturi.d.ts.map +1 -1
- package/dist/aturi.js +25 -0
- package/dist/aturi.js.map +1 -1
- package/dist/datetime.d.ts +128 -11
- package/dist/datetime.d.ts.map +1 -1
- package/dist/datetime.js +205 -76
- package/dist/datetime.js.map +1 -1
- package/dist/handle.d.ts +1 -0
- package/dist/handle.d.ts.map +1 -1
- package/dist/handle.js.map +1 -1
- package/dist/nsid.d.ts +2 -2
- package/dist/nsid.d.ts.map +1 -1
- package/dist/nsid.js +4 -1
- package/dist/nsid.js.map +1 -1
- package/package.json +1 -1
- package/src/at-identifier.ts +77 -6
- package/src/aturi.ts +45 -3
- package/src/datetime.ts +268 -88
- package/src/handle.ts +2 -0
- package/src/nsid.ts +5 -3
- package/tests/aturi.test.ts +47 -0
- package/tests/datetime.test.ts +148 -62
package/src/aturi.ts
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AtIdentifierString,
|
|
3
|
+
ensureValidAtIdentifier,
|
|
4
|
+
isDidIdentifier,
|
|
5
|
+
} from './at-identifier.js'
|
|
2
6
|
import { AtUriString } from './aturi_validation.js'
|
|
3
|
-
import {
|
|
7
|
+
import { DidString, InvalidDidError } from './did.js'
|
|
8
|
+
import { NsidString, ensureValidNsid } from './nsid.js'
|
|
9
|
+
import { RecordKeyString, ensureValidRecordKey } from './recordkey.js'
|
|
4
10
|
|
|
5
11
|
export * from './aturi_validation.js'
|
|
6
12
|
|
|
13
|
+
// Re-export types used in public interface
|
|
14
|
+
export type {
|
|
15
|
+
AtIdentifierString,
|
|
16
|
+
AtUriString,
|
|
17
|
+
DidString,
|
|
18
|
+
NsidString,
|
|
19
|
+
RecordKeyString,
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
export const ATP_URI_REGEX =
|
|
8
23
|
// proto- --did-------------- --name---------------- --path---- --query-- --hash--
|
|
9
24
|
/^(at:\/\/)?((?:did:[a-z0-9:%-]+)|(?:[a-z0-9][a-z0-9.:-]*))(\/[^?#\s]*)?(\?[^#\s]+)?(#[^\s]+)?$/i
|
|
@@ -47,7 +62,13 @@ export class AtUri {
|
|
|
47
62
|
return `at://${this.host}` as const
|
|
48
63
|
}
|
|
49
64
|
|
|
50
|
-
get
|
|
65
|
+
get did(): DidString {
|
|
66
|
+
const { host } = this
|
|
67
|
+
if (isDidIdentifier(host)) return host
|
|
68
|
+
throw new InvalidDidError(`AtUri "${this}" does not have a DID hostname`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get hostname(): AtIdentifierString {
|
|
51
72
|
return this.host
|
|
52
73
|
}
|
|
53
74
|
|
|
@@ -68,8 +89,18 @@ export class AtUri {
|
|
|
68
89
|
return this.pathname.split('/').filter(Boolean)[0] || ''
|
|
69
90
|
}
|
|
70
91
|
|
|
92
|
+
get collectionSafe(): NsidString {
|
|
93
|
+
const { collection } = this
|
|
94
|
+
ensureValidNsid(collection)
|
|
95
|
+
return collection
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
set collection(v: string) {
|
|
72
99
|
ensureValidNsid(v)
|
|
100
|
+
this.unsafelySetCollection(v)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
unsafelySetCollection(v: string) {
|
|
73
104
|
const parts = this.pathname.split('/').filter(Boolean)
|
|
74
105
|
parts[0] = v
|
|
75
106
|
this.pathname = parts.join('/')
|
|
@@ -79,7 +110,18 @@ export class AtUri {
|
|
|
79
110
|
return this.pathname.split('/').filter(Boolean)[1] || ''
|
|
80
111
|
}
|
|
81
112
|
|
|
113
|
+
get rkeySafe(): RecordKeyString {
|
|
114
|
+
const { rkey } = this
|
|
115
|
+
ensureValidRecordKey(rkey)
|
|
116
|
+
return rkey
|
|
117
|
+
}
|
|
118
|
+
|
|
82
119
|
set rkey(v: string) {
|
|
120
|
+
ensureValidRecordKey(v)
|
|
121
|
+
this.unsafelySetRkey(v)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
unsafelySetRkey(v: string) {
|
|
83
125
|
const parts = this.pathname.split('/').filter(Boolean)
|
|
84
126
|
parts[0] ||= 'undefined'
|
|
85
127
|
parts[1] = v
|
package/src/datetime.ts
CHANGED
|
@@ -1,124 +1,304 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Indicates a date or string is not a valid representation of a datetime
|
|
3
|
+
* according to the atproto
|
|
4
|
+
* {@link https://atproto.com/specs/lexicon#datetime specification}.
|
|
5
|
+
*/
|
|
6
|
+
export class InvalidDatetimeError extends Error {}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A subset of {@link DatetimeString} that represent valid datetime strings with
|
|
10
|
+
* the format: `YYYY-MM-DDTHH:mm:ss.sssZ`, as returned by `Date.toISOString()
|
|
11
|
+
* for dates between the years 0000 and 9999.
|
|
12
|
+
*
|
|
13
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString}
|
|
14
|
+
*/
|
|
15
|
+
export type ISODatetimeString =
|
|
16
|
+
// @TODO Switch to branded types for more accurate type safety.
|
|
17
|
+
`${string}-${string}-${string}T${string}:${string}:${string}.${string}Z`
|
|
4
18
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Represents a {@link Date} that can be safely stringified into a valid atproto
|
|
21
|
+
* {@link DatetimeString} using the {@link Date.toISOString toISOString()}
|
|
22
|
+
* method.
|
|
23
|
+
*/
|
|
24
|
+
export interface AtprotoDate extends Date {
|
|
25
|
+
toISOString(): ISODatetimeString
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @see {@link AtprotoDate}
|
|
30
|
+
*/
|
|
31
|
+
export function assertAtprotoDate(date: Date): asserts date is AtprotoDate {
|
|
32
|
+
const res = parseDate(date)
|
|
33
|
+
if (!res.success) {
|
|
34
|
+
throw new InvalidDatetimeError(res.message)
|
|
9
35
|
}
|
|
10
36
|
}
|
|
11
37
|
|
|
12
|
-
|
|
13
|
-
*
|
|
38
|
+
/**
|
|
39
|
+
* @see {@link AtprotoDate}
|
|
14
40
|
*/
|
|
15
|
-
export function
|
|
41
|
+
export function asAtprotoDate(date: Date): AtprotoDate {
|
|
42
|
+
assertAtprotoDate(date)
|
|
43
|
+
return date
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @see {@link AtprotoDate}
|
|
48
|
+
*/
|
|
49
|
+
export function isAtprotoDate(date: Date): date is AtprotoDate {
|
|
50
|
+
return parseDate(date).success
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @see {@link AtprotoDate}
|
|
55
|
+
*/
|
|
56
|
+
export function ifAtprotoDate(date: Date): AtprotoDate | undefined {
|
|
57
|
+
return isAtprotoDate(date) ? date : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Datetime strings in atproto data structures and API calls should meet the
|
|
62
|
+
* {@link https://ijmacd.github.io/rfc3339-iso8601/ intersecting} requirements
|
|
63
|
+
* of the RFC 3339, ISO 8601, and WHATWG HTML datetime standards.
|
|
64
|
+
*
|
|
65
|
+
* @note This literal template type is not accurate enough to ensure that a
|
|
66
|
+
* string is a valid atproto datetime. The {@link DatetimeString} validation
|
|
67
|
+
* functions ({@link assertDatetimeString}, {@link isDatetimeString}, etc)
|
|
68
|
+
* should be used to validate that a string meets the atproto datetime
|
|
69
|
+
* requirements, and the {@link toDatetimeString} function should be used to
|
|
70
|
+
* convert a {@link Date} object into a valid {@link DatetimeString} string.
|
|
71
|
+
*
|
|
72
|
+
* @example "2024-01-15T12:30:00Z"
|
|
73
|
+
* @example "2024-01-15T12:30:00.000Z"
|
|
74
|
+
* @example "2024-01-15T12:30:00+00:00"
|
|
75
|
+
* @example "2024-01-15T11:30:00-01:00"
|
|
76
|
+
* @see {@link https://atproto.com/specs/lexicon#datetime atproto Lexicon datetime format}
|
|
77
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc3339 RFC 3339}
|
|
78
|
+
* @see {@link https://www.iso.org/iso-8601-date-and-time-format.html ISO 8601}
|
|
79
|
+
*/
|
|
80
|
+
export type DatetimeString =
|
|
81
|
+
// @TODO Switch to branded types for more accurate type safety?
|
|
82
|
+
| `${string}-${string}-${string}T${string}:${string}:${string}Z`
|
|
83
|
+
| `${string}-${string}-${string}T${string}:${string}:${string}${'+' | '-'}${string}:${string}`
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validates that a string is a valid {@link DatetimeString} format string,
|
|
87
|
+
* throwing an error if it is not.
|
|
88
|
+
*
|
|
89
|
+
* @throws InvalidDatetimeError if the input string does not meet the atproto 'datetime' format requirements.
|
|
90
|
+
* @see {@link DatetimeString}
|
|
91
|
+
*/
|
|
92
|
+
export function assertDatetimeString<I>(
|
|
16
93
|
input: I,
|
|
17
94
|
): asserts input is I & DatetimeString {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
throw new InvalidDatetimeError('datetime did not parse as ISO 8601')
|
|
22
|
-
}
|
|
23
|
-
if (date.toISOString().startsWith('-')) {
|
|
24
|
-
throw new InvalidDatetimeError('datetime normalized to a negative time')
|
|
25
|
-
}
|
|
26
|
-
// regex and other checks for RFC-3339
|
|
27
|
-
if (
|
|
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(
|
|
29
|
-
input,
|
|
30
|
-
)
|
|
31
|
-
) {
|
|
32
|
-
throw new InvalidDatetimeError("datetime didn't validate via regex")
|
|
33
|
-
}
|
|
34
|
-
if (input.length > 64) {
|
|
35
|
-
throw new InvalidDatetimeError('datetime is too long (64 chars max)')
|
|
36
|
-
}
|
|
37
|
-
if (input.endsWith('-00:00')) {
|
|
38
|
-
throw new InvalidDatetimeError(
|
|
39
|
-
'datetime can not use "-00:00" for UTC timezone',
|
|
40
|
-
)
|
|
41
|
-
}
|
|
42
|
-
if (input.startsWith('000')) {
|
|
43
|
-
throw new InvalidDatetimeError('datetime so close to year zero not allowed')
|
|
95
|
+
const result = parseString(input)
|
|
96
|
+
if (!result.success) {
|
|
97
|
+
throw new InvalidDatetimeError(result.message)
|
|
44
98
|
}
|
|
45
99
|
}
|
|
46
100
|
|
|
47
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Casts a string to a {@link DatetimeString} if it is a valid datetime format
|
|
103
|
+
* string, throwing an error if it is not.
|
|
104
|
+
*
|
|
105
|
+
* @throws InvalidDatetimeError if the input string does not meet the atproto 'datetime' format requirements.
|
|
106
|
+
* @see {@link DatetimeString}
|
|
107
|
+
*/
|
|
108
|
+
export function asDatetimeString<I>(input: I): I & DatetimeString {
|
|
109
|
+
assertDatetimeString(input)
|
|
110
|
+
return input
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Checks if a string is a valid {@link DatetimeString} format string.
|
|
115
|
+
*
|
|
116
|
+
* @see {@link DatetimeString}
|
|
48
117
|
*/
|
|
49
|
-
export function
|
|
118
|
+
export function isDatetimeString<I>(input: I): input is I & DatetimeString {
|
|
119
|
+
return parseString(input).success
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns the input if it is a valid {@link DatetimeString} format string, or
|
|
124
|
+
* `undefined` if it is not.
|
|
125
|
+
*
|
|
126
|
+
* @see {@link DatetimeString}
|
|
127
|
+
*/
|
|
128
|
+
export function ifDatetimeString<I>(
|
|
50
129
|
input: I,
|
|
51
|
-
):
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
} catch (err) {
|
|
55
|
-
return false
|
|
56
|
-
}
|
|
130
|
+
): undefined | (I & DatetimeString) {
|
|
131
|
+
return isDatetimeString(input) ? input : undefined
|
|
132
|
+
}
|
|
57
133
|
|
|
58
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Returns the current date and time as a {@link DatetimeString}.
|
|
136
|
+
*
|
|
137
|
+
* @see {@link DatetimeString}
|
|
138
|
+
*/
|
|
139
|
+
export function currentDatetimeString(): DatetimeString {
|
|
140
|
+
return toDatetimeString(new Date())
|
|
59
141
|
}
|
|
60
142
|
|
|
61
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Converts any {@link Date} into a {@link DatetimeString} if possible, throwing
|
|
145
|
+
* an error if the date is not a valid atproto datetime.
|
|
62
146
|
*
|
|
63
|
-
* This
|
|
147
|
+
* This is short-hand for `asAtprotoDate(date).toISOString()`.
|
|
64
148
|
*
|
|
65
|
-
*
|
|
149
|
+
* @throws InvalidDatetimeError if the input date is not a valid atproto datetime (eg, it is too far in the future or past, or it normalizes to a negative year).
|
|
150
|
+
* @see {@link DatetimeString}
|
|
151
|
+
*/
|
|
152
|
+
export function toDatetimeString(date: Date): DatetimeString {
|
|
153
|
+
return asAtprotoDate(date).toISOString()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Takes a flexible datetime string and normalizes its representation.
|
|
158
|
+
*
|
|
159
|
+
* This function will work with any valid value that can be parsed as a date. It
|
|
160
|
+
* *additionally* is more flexible about accepting datetimes that are missing
|
|
161
|
+
* timezone information, and normalizing them to a valid atproto datetime.
|
|
66
162
|
*
|
|
67
|
-
*
|
|
163
|
+
* One use-case is a consistent, sortable string. Another is to work with older
|
|
164
|
+
* invalid createdAt datetimes.
|
|
68
165
|
*
|
|
69
|
-
*
|
|
166
|
+
* @returns ISODatetimeString - a valid atproto datetime with millisecond precision (3 sub-second digits) and UTC timezone with trailing 'Z' syntax.
|
|
167
|
+
* @throws InvalidDatetimeError - if the input string could not be parsed as a datetime, even with permissive parsing.
|
|
70
168
|
*/
|
|
71
|
-
export function normalizeDatetime(dtStr: string):
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
169
|
+
export function normalizeDatetime(dtStr: string): ISODatetimeString {
|
|
170
|
+
// Parse the string as is
|
|
171
|
+
const date = new Date(dtStr)
|
|
172
|
+
if (isAtprotoDate(date)) {
|
|
173
|
+
return date.toISOString()
|
|
77
174
|
}
|
|
78
175
|
|
|
79
|
-
//
|
|
80
|
-
if (!/.*(([+-]\d\d:?\d\d)|[a-zA-Z])$/.test(dtStr)) {
|
|
81
|
-
const date = new Date(dtStr
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
if (isValidDatetime(tzStr)) {
|
|
85
|
-
return tzStr
|
|
86
|
-
}
|
|
176
|
+
// if dtStr is not a valid date, try parsing again with a timezone
|
|
177
|
+
if (isNaN(date.getTime()) && !/.*(([+-]\d\d:?\d\d)|[a-zA-Z])$/.test(dtStr)) {
|
|
178
|
+
const date = new Date(`${dtStr}Z`)
|
|
179
|
+
if (isAtprotoDate(date)) {
|
|
180
|
+
return date.toISOString()
|
|
87
181
|
}
|
|
88
182
|
}
|
|
89
183
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
throw new InvalidDatetimeError(
|
|
94
|
-
'datetime did not parse as any timestamp format',
|
|
95
|
-
)
|
|
96
|
-
}
|
|
97
|
-
const isoStr = date.toISOString()
|
|
98
|
-
if (isValidDatetime(isoStr)) {
|
|
99
|
-
return isoStr
|
|
100
|
-
} else {
|
|
101
|
-
throw new InvalidDatetimeError(
|
|
102
|
-
'datetime normalized to invalid timestamp string',
|
|
103
|
-
)
|
|
104
|
-
}
|
|
184
|
+
throw new InvalidDatetimeError(
|
|
185
|
+
'datetime did not parse as any timestamp format',
|
|
186
|
+
)
|
|
105
187
|
}
|
|
106
188
|
|
|
107
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Variant of {@link normalizeDatetime} which always returns a valid datetime
|
|
191
|
+
* string.
|
|
192
|
+
*
|
|
193
|
+
* If a {@link InvalidDatetimeError} is encountered, returns the UNIX epoch time
|
|
194
|
+
* as a UTC datetime (`1970-01-01T00:00:00.000Z`).
|
|
108
195
|
*
|
|
109
|
-
*
|
|
196
|
+
* @see {@link normalizeDatetime}
|
|
110
197
|
*/
|
|
111
|
-
export
|
|
198
|
+
export function normalizeDatetimeAlways(dtStr: string): ISODatetimeString {
|
|
112
199
|
try {
|
|
113
200
|
return normalizeDatetime(dtStr)
|
|
114
201
|
} catch (err) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
202
|
+
return '1970-01-01T00:00:00.000Z'
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Legacy exports (should we deprecate these ?)
|
|
207
|
+
export {
|
|
208
|
+
assertDatetimeString as ensureValidDatetime,
|
|
209
|
+
isDatetimeString as isValidDatetime,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// -----------------------------------------------------------------------------
|
|
213
|
+
// ------------------------- Internal validation logic -------------------------
|
|
214
|
+
// -----------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
// Validation utils that allow avoiding try/catch for control flow (performance
|
|
217
|
+
// optimization). Other syntax formats should also use this pattern to avoid
|
|
218
|
+
// try/catch in their validation logic, at which point these utils can be moved
|
|
219
|
+
// to a common internal utils.
|
|
220
|
+
type FailureResult = { success: false; message: string }
|
|
221
|
+
const failure = (m: string): FailureResult => ({ success: false, message: m })
|
|
222
|
+
type SuccessResult<V> = { success: true; value: V }
|
|
223
|
+
const success = <V>(v: V): SuccessResult<V> => ({ success: true, value: v })
|
|
224
|
+
type Result<V> = FailureResult | SuccessResult<V>
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc3339#section-5.6 Internet Date/Time Format}
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```abnf
|
|
231
|
+
* date-fullyear = 4DIGIT
|
|
232
|
+
* date-month = 2DIGIT ; 01-12
|
|
233
|
+
* date-mday = 2DIGIT ; 01-28, 01-29, 01-30, 01-31 based on
|
|
234
|
+
* ; month/year
|
|
235
|
+
* time-hour = 2DIGIT ; 00-23
|
|
236
|
+
* time-minute = 2DIGIT ; 00-59
|
|
237
|
+
* time-second = 2DIGIT ; 00-58, 00-59, 00-60 based on leap second
|
|
238
|
+
* ; rules
|
|
239
|
+
* time-secfrac = "." 1*DIGIT
|
|
240
|
+
* time-numoffset = ("+" / "-") time-hour ":" time-minute
|
|
241
|
+
* time-offset = "Z" / time-numoffset
|
|
242
|
+
* partial-time = time-hour ":" time-minute ":" time-second
|
|
243
|
+
* [time-secfrac]
|
|
244
|
+
* full-date = date-fullyear "-" date-month "-" date-mday
|
|
245
|
+
* full-time = partial-time time-offset
|
|
246
|
+
* date-time = full-date "T" full-time
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
const DATETIME_REGEX =
|
|
250
|
+
/^(?<full_year>[0-9]{4})-(?<date_month>0[1-9]|1[012])-(?<date_mday>[0-2][0-9]|3[01])T(?<time_hour>[0-1][0-9]|2[0-3]):(?<time_minute>[0-5][0-9]):(?<time_second>[0-5][0-9]|60)(?<time_secfrac>\.[0-9]+)?(?<time_offset>Z|(?<time_numoffset>[+-](?:[0-1][0-9]|2[0-3]):[0-5][0-9]))$/
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Validates that the input is a datetime string according to atproto Lexicon
|
|
254
|
+
* rules, and parses it into a Date object.
|
|
255
|
+
*/
|
|
256
|
+
function parseString(input: unknown): Result<AtprotoDate> {
|
|
257
|
+
// @NOTE Performing cheap tests first
|
|
258
|
+
if (typeof input !== 'string') {
|
|
259
|
+
return failure('datetime must be a string')
|
|
260
|
+
}
|
|
261
|
+
if (input.length > 64) {
|
|
262
|
+
return failure('datetime is too long (64 chars max)')
|
|
263
|
+
}
|
|
264
|
+
if (input.endsWith('-00:00')) {
|
|
265
|
+
return failure('datetime can not use "-00:00" for UTC timezone')
|
|
119
266
|
}
|
|
267
|
+
if (!DATETIME_REGEX.test(input)) {
|
|
268
|
+
return failure(
|
|
269
|
+
"datetime is not in a valid format (must match RFC 3339 & ISO 8601 with 'Z' or ±hh:mm timezone)",
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// must parse as ISO 8601; this also verifies semantics like leap seconds and
|
|
274
|
+
// correct number of days in month, which the regex does not check for
|
|
275
|
+
const date = new Date(input)
|
|
276
|
+
|
|
277
|
+
return parseDate(date)
|
|
120
278
|
}
|
|
121
279
|
|
|
122
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Ensures that a Date object represents a valid datetime according to atproto
|
|
282
|
+
* Lexicon rules. This ensures that `date.toISOString()` will produce a valid
|
|
283
|
+
* datetime string that can be used where {@link DatetimeString} is expected.
|
|
123
284
|
*/
|
|
124
|
-
|
|
285
|
+
function parseDate(date: Date): Result<AtprotoDate> {
|
|
286
|
+
const fullYear = date.getUTCFullYear()
|
|
287
|
+
// Ensures that the date is valid. We could check isNaN(date.getTime()) here
|
|
288
|
+
// but since we'll check the year anyway, we just use that for the validity
|
|
289
|
+
// check since an invalid date will have NaN year.
|
|
290
|
+
if (Number.isNaN(fullYear)) {
|
|
291
|
+
return failure('datetime did not parse as ISO 8601')
|
|
292
|
+
}
|
|
293
|
+
// Ensure that the ISO string representation does not start with ±YYYYYY
|
|
294
|
+
if (fullYear < 0) {
|
|
295
|
+
return failure('datetime normalized to a negative time')
|
|
296
|
+
}
|
|
297
|
+
if (fullYear > 9999) {
|
|
298
|
+
return failure('datetime year is too far in the future')
|
|
299
|
+
}
|
|
300
|
+
if (fullYear < 10) {
|
|
301
|
+
return failure('datetime so close to year zero not allowed')
|
|
302
|
+
}
|
|
303
|
+
return success(date as AtprotoDate)
|
|
304
|
+
}
|
package/src/handle.ts
CHANGED
|
@@ -92,6 +92,8 @@ export function ensureValidHandleRegex<I extends string>(
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
export function normalizeHandle(handle: HandleString): HandleString
|
|
96
|
+
export function normalizeHandle(handle: string): string
|
|
95
97
|
export function normalizeHandle(handle: string): string {
|
|
96
98
|
return handle.toLowerCase()
|
|
97
99
|
}
|
package/src/nsid.ts
CHANGED
|
@@ -48,15 +48,15 @@ export class NSID {
|
|
|
48
48
|
return this.segments
|
|
49
49
|
.slice(0, this.segments.length - 1)
|
|
50
50
|
.reverse()
|
|
51
|
-
.join('.')
|
|
51
|
+
.join('.') as `${string}.${string}`
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
get name() {
|
|
55
55
|
return this.segments.at(this.segments.length - 1)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
toString() {
|
|
59
|
-
return this.segments.join('.')
|
|
58
|
+
toString(): NsidString {
|
|
59
|
+
return this.segments.join('.') as NsidString
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -197,6 +197,8 @@ export function validateNsidRegex(value: string): ValidateResult<NsidString> {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
if (
|
|
200
|
+
// Fast check for small values
|
|
201
|
+
value.length < 5 ||
|
|
200
202
|
!/^[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-Z0-9]{0,62})?)$/.test(
|
|
201
203
|
value,
|
|
202
204
|
)
|
package/tests/aturi.test.ts
CHANGED
|
@@ -522,5 +522,52 @@ describe('AtUri validation', () => {
|
|
|
522
522
|
})
|
|
523
523
|
})
|
|
524
524
|
|
|
525
|
+
it('properly checks that the did property is a valid did', () => {
|
|
526
|
+
const urip = new AtUri('at://did:example:123')
|
|
527
|
+
expect(urip.did).toBe('did:example:123')
|
|
528
|
+
urip.host = 'did:example:456'
|
|
529
|
+
expect(urip.did).toBe('did:example:456')
|
|
530
|
+
urip.host = 'foo.com'
|
|
531
|
+
expect(() => urip.did).toThrow()
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('properly checks that the collection is a valid nsid', () => {
|
|
535
|
+
const urip = new AtUri('at://foo.com')
|
|
536
|
+
expect(urip.collection).toBe('')
|
|
537
|
+
expect(() => urip.collectionSafe).toThrow()
|
|
538
|
+
|
|
539
|
+
urip.collection = 'com.example.foo'
|
|
540
|
+
expect(urip.collection).toBe('com.example.foo')
|
|
541
|
+
expect(urip.collectionSafe).toBe('com.example.foo')
|
|
542
|
+
|
|
543
|
+
urip.collection = 'com.other.foo'
|
|
544
|
+
expect(urip.collection).toBe('com.other.foo')
|
|
545
|
+
expect(urip.collectionSafe).toBe('com.other.foo')
|
|
546
|
+
|
|
547
|
+
expect(() => (urip.collection = 'not a valid nsid')).toThrow()
|
|
548
|
+
expect(urip.collection).toBe('com.other.foo') // unchanged after failed set
|
|
549
|
+
|
|
550
|
+
urip.unsafelySetCollection('not-a-valid-nsid')
|
|
551
|
+
expect(urip.collection).toBe('not-a-valid-nsid')
|
|
552
|
+
expect(() => urip.collectionSafe).toThrow()
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('properly checks that the rkey is a valid record key', () => {
|
|
556
|
+
const urip = new AtUri('at://foo.com')
|
|
557
|
+
expect(urip.rkey).toBe('')
|
|
558
|
+
expect(() => urip.rkeySafe).toThrow()
|
|
559
|
+
|
|
560
|
+
urip.rkey = 'valid_rkey-123'
|
|
561
|
+
expect(urip.rkey).toBe('valid_rkey-123')
|
|
562
|
+
expect(urip.rkeySafe).toBe('valid_rkey-123')
|
|
563
|
+
|
|
564
|
+
expect(() => (urip.rkey = 'not a valid rkey')).toThrow()
|
|
565
|
+
expect(urip.rkey).toBe('valid_rkey-123') // unchanged after failed set
|
|
566
|
+
|
|
567
|
+
urip.unsafelySetRkey('not a valid rkey')
|
|
568
|
+
expect(urip.rkey).toBe('not a valid rkey')
|
|
569
|
+
expect(() => urip.rkeySafe).toThrow()
|
|
570
|
+
})
|
|
571
|
+
|
|
525
572
|
// NOTE: this package is currently more permissive than spec about AT URIs, so invalid cases are not errors
|
|
526
573
|
})
|