@atproto/syntax 0.5.2 → 0.5.4

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.
@@ -1,152 +1,321 @@
1
- import { AtIdentifierString, ensureValidAtIdentifier } from './at-identifier.js'
2
- import { ensureValidDidRegex } from './did.js'
3
- import { ensureValidHandleRegex } from './handle.js'
1
+ import { AtIdentifierString, isAtIdentifierString } from './at-identifier.js'
2
+ import { Result, failure, success } from './lib/result.js'
4
3
  import { NsidString, isValidNsid } from './nsid.js'
4
+ import { isValidRecordKey } from './recordkey.js'
5
5
 
6
- export type AtUriString =
6
+ export type AtUriStringBase =
7
7
  | `at://${AtIdentifierString}`
8
8
  | `at://${AtIdentifierString}/${NsidString}`
9
9
  | `at://${AtIdentifierString}/${NsidString}/${string}`
10
10
 
11
- // Human-readable constraints on ATURI:
12
- // - following regular URLs, a 8KByte hard total length limit
13
- // - follows ATURI docs on website
14
- // - all ASCII characters, no whitespace. non-ASCII could be URL-encoded
15
- // - starts "at://"
16
- // - "authority" is a valid DID or a valid handle
17
- // - optionally, follow "authority" with "/" and valid NSID as start of path
18
- // - optionally, if NSID given, follow that with "/" and rkey
19
- // - rkey path component can include URL-encoded ("percent encoded"), or:
20
- // ALPHA / DIGIT / "-" / "." / "_" / "~" / ":" / "@" / "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
21
- // [a-zA-Z0-9._~:@!$&'\(\)*+,;=-]
22
- // - rkey must have at least one char
23
- // - regardless of path component, a fragment can follow as "#" and then a JSON pointer (RFC-6901)
24
-
25
- export function ensureValidAtUri<I extends string>(
11
+ export type AtUriStringFragment = `#/${string}`
12
+
13
+ /**
14
+ * A URI string as used to point at resources in the AT Protocol
15
+ *
16
+ * The full, general structure of an AT URI is:
17
+ *
18
+ * ```bnf
19
+ * AT-URI = "at://" AUTHORITY [ PATH ] [ "?" QUERY ] [ "#" FRAGMENT ]
20
+ * ```
21
+ *
22
+ * The authority part of the URI can be either a handle or a DID, indicating the
23
+ * identity associated with the repository. In current atproto Lexicon use, the
24
+ * query and fragment parts are not yet supported, and only a fixed pattern of
25
+ * paths are allowed:
26
+ *
27
+ * ```bnf
28
+ * AT-URI = "at://" AUTHORITY [ "/" COLLECTION [ "/" RKEY ] ]
29
+ *
30
+ * AUTHORITY = HANDLE | DID
31
+ * COLLECTION = NSID
32
+ * RKEY = RECORD-KEY
33
+ * ```
34
+ *
35
+ * The authority section is required, and should be normalized.
36
+ *
37
+ * AT URI strings must respect the following syntax (as prescribed by the AT
38
+ * protocol specification):
39
+ *
40
+ * - The overall URI is restricted to a subset of ASCII characters
41
+ * - For reference below, the set of unreserved characters, as defined in [RFC-3986](https://www.rfc-editor.org/rfc/rfc3986), includes alphanumeric (`A-Za-z0-9`), period, hyphen, underscore, and tilde (`.-_~`)
42
+ * - Maximum overall length is 8 kilobytes (which may be shortened in the future)
43
+ * - Hex-encoding of characters is permitted (but in practice not necessary and should be avoided to keep the URI normalized and human-readable)
44
+ * - The URI scheme is `at`, and an authority part preceded with double slashes is always required. AT URIs always start with `at://`.
45
+ * - An authority section is required and must be non-empty. the authority can be either an atproto Handle, or a DID meeting the restrictions for use with atproto. The authority part can *not* be interpreted as a host:port pair, because of the use of colon characters (`:`) in DIDs. Colons and unreserved characters should not be escaped in DIDs, but other reserved characters (including `#`, `/`, `$`, `&`, `@`) must be escaped.
46
+ * - Note that none of the current "blessed" DID methods for atproto allow these characters in DID identifiers
47
+ * - An optional path section may follow the authority. The path may contain multiple segments separated by a single slash (`/`). Generic URI path normalization rules may be used.
48
+ * - An optional query part is allowed, following generic URI syntax restrictions
49
+ * - An optional fragment part is allowed, using JSON Path syntax
50
+ *
51
+ * @example "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.actor.profile/self"
52
+ *
53
+ * @see {@link https://atproto.com/specs/at-uri-scheme AT protocol - AT URI Scheme}
54
+ */
55
+ export type AtUriString =
56
+ | AtUriStringBase
57
+ | `${AtUriStringBase}${AtUriStringFragment}`
58
+
59
+ /**
60
+ * Type guard that checks if a value is a valid {@link AtUriString}
61
+ *
62
+ * @see {@link AtUriString}
63
+ */
64
+ export function isAtUriString<I>(
26
65
  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
- }
66
+ options?: Omit<ParseAtUriStringOptions, 'detailed'>,
67
+ ): input is I & AtUriString {
68
+ return parseAtUriString(input, options).success
69
+ }
36
70
 
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
- }
42
- }
71
+ /**
72
+ * Returns the input if it is a valid {@link AtUriString} format string, or
73
+ * `undefined` if it is not.
74
+ *
75
+ * @see {@link AtUriString}
76
+ */
77
+ export function ifAtUriString<I>(
78
+ input: I,
79
+ options?: Omit<ParseAtUriStringOptions, 'detailed'>,
80
+ ): undefined | (I & AtUriString) {
81
+ return isAtUriString(input, options) ? input : undefined
82
+ }
43
83
 
44
- const uri = fragmentIndex === -1 ? input : input.slice(0, fragmentIndex)
84
+ /**
85
+ * Casts a string to an {@link AtUriString} if it is a valid AT URI format
86
+ * string, throwing an error if it is not.
87
+ *
88
+ * @throws InvalidAtUriError if the input string does not meet the atproto AT URI format requirements.
89
+ * @see {@link AtUriString}
90
+ */
91
+ export function asAtUriString<I>(
92
+ input: I,
93
+ options?: ParseAtUriStringOptions,
94
+ ): I & AtUriString {
95
+ assertAtUriString(input, options)
96
+ return input
97
+ }
45
98
 
46
- if (uri.length > 8 * 1024) {
47
- throw new Error('ATURI is far too long')
99
+ /**
100
+ * Assert the validity of an {@link AtUriString}, throwing an error if the
101
+ * {@link input} is not a valid AT URI.
102
+ *
103
+ * @throws InvalidAtUriError if the {@link input} is not a valid {@link AtUriString}
104
+ */
105
+ export function assertAtUriString<I>(
106
+ input: I,
107
+ options?: ParseAtUriStringOptions,
108
+ ): asserts input is I & AtUriString {
109
+ // Optimistically use faster isAtUriString(), throwing a detailed error only
110
+ // in case of failure. This check, and the fact that the code after it always
111
+ // throws, also ensures that isAtUriString() and assertAtUriString()'s
112
+ // behavior are always consistent.
113
+ const result = parseAtUriString(input, options)
114
+ if (!result.success) {
115
+ throw new InvalidAtUriError(result.message)
48
116
  }
117
+ }
118
+
119
+ /**
120
+ * Assert the **non-strict** validity of an {@link AtUriString}, throwing a
121
+ * detailed error if the {@link input} is not a valid AT URI.
122
+ *
123
+ * @throws InvalidAtUriError if the {@link input} is not a valid {@link AtUriString}
124
+ * @deprecated use {@link assertAtUriString} with `{ strict: false }` option instead
125
+ */
126
+ export function ensureValidAtUri<I>(
127
+ input: I,
128
+ ): asserts input is I & AtUriString {
129
+ assertAtUriString(input, { strict: false, detailed: true })
130
+ }
131
+
132
+ /**
133
+ * Assert the (non-strict!) validity of an {@link AtUriString}, throwing an
134
+ * error if the {@link input} is not a valid AT URI.
135
+ *
136
+ * @throws InvalidAtUriError if the {@link input} is not a valid {@link AtUriString}
137
+ * @deprecated use {@link assertAtUriString} with `{ strict: false }` option instead
138
+ */
139
+ export function ensureValidAtUriRegex<I>(
140
+ input: I,
141
+ ): asserts input is I & AtUriString {
142
+ assertAtUriString(input, { strict: false, detailed: false })
143
+ }
144
+
145
+ /**
146
+ * Type guard that checks if a value is a valid {@link AtUriString} format
147
+ * string, without enforcing strict record key validation. This is useful for
148
+ * cases where you want to allow a wider range of valid ATURIs, such as when
149
+ * validating user input or when the record key is not relevant.
150
+ *
151
+ * @deprecated use {@link isAtUriString} with `{ strict: false }` option instead
152
+ */
153
+ export function isValidAtUri<I>(input: I): input is I & AtUriString {
154
+ return isAtUriString(input, { strict: false })
155
+ }
156
+
157
+ export class InvalidAtUriError extends Error {}
49
158
 
50
- if (!uri.startsWith('at://')) {
51
- throw new Error('ATURI must start with "at://"')
159
+ export type ParseAtUriStringOptions = {
160
+ /**
161
+ * If true, the parser will enforce that the record key (rkey) part of the URI
162
+ * is a valid record key (validated by {@link isValidRecordKey}). If false,
163
+ * any non-empty string of allowed chars will be accepted as a record key.
164
+ *
165
+ * @default true
166
+ */
167
+ strict?: boolean
168
+
169
+ /**
170
+ * If true, the parser will return detailed error messages for why a string is
171
+ * not a valid AT URI. This option has no effect on the behavior of
172
+ * {@link isAtUriString}, which will always return false for invalid strings
173
+ * regardless of this option.
174
+ *
175
+ * @default false
176
+ */
177
+ detailed?: boolean
178
+ }
179
+
180
+ export type AtUriParts = {
181
+ authority: AtIdentifierString
182
+ query?: string
183
+ hash?: string
184
+ } & (
185
+ | { collection?: NsidString; rkey?: undefined }
186
+ | { collection: NsidString; rkey?: string }
187
+ )
188
+
189
+ const INVALID_CHAR_REGEXP = /[^a-zA-Z0-9._~:@!$&'()*+,;=%/\\[\]#?-]/
190
+ const AT_URI_REGEXP =
191
+ /^(?<uri>at:\/\/(?<authority>[^/?#\s]+)(?:\/(?<collection>[^/?#\s]+)(?:\/(?<rkey>[^/?#\s]+))?)?(?<trailingSlash>\/)?)(?:\?(?<query>[^#\s]*))?(?:#(?<hash>[^\s]*))?$/
192
+
193
+ /**
194
+ * Parses a valid {@link AtUriString} into a {@link AtUriParts} object, or
195
+ * returns a failure with a detailed error message if the string is not a valid
196
+ * {@link AtUriString}.
197
+ */
198
+ export function parseAtUriString(
199
+ input: unknown,
200
+ options?: ParseAtUriStringOptions,
201
+ ): Result<AtUriParts> {
202
+ if (typeof input !== 'string') {
203
+ return failure('ATURI must be a string')
52
204
  }
53
205
 
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)')
206
+ if (input.length > 8192) {
207
+ return failure('ATURI exceeds maximum length')
57
208
  }
58
209
 
59
- const authorityEnd = uri.indexOf('/', 5)
60
- const authority =
61
- authorityEnd === -1 ? uri.slice(5) : uri.slice(5, authorityEnd)
62
- try {
63
- ensureValidAtIdentifier(authority)
64
- } catch (cause) {
65
- throw new Error('ATURI authority must be a valid handle or DID', { cause })
210
+ const invalidChar = input.match(INVALID_CHAR_REGEXP)
211
+ if (invalidChar) {
212
+ return failure('Disallowed characters in ATURI (ASCII)')
66
213
  }
67
214
 
68
- const collectionStart = authorityEnd === -1 ? -1 : authorityEnd + 1
69
- const collectionEnd =
70
- collectionStart === -1 ? -1 : uri.indexOf('/', collectionStart)
215
+ const match = input.match(AT_URI_REGEXP)
216
+ const groups = match?.groups
217
+ if (!groups) {
218
+ // Regex validation failed, but we don't know exactly why. Provide more
219
+ // detailed error messages if the "detailed" option is set, falling back to
220
+ // a generic error.
221
+ if (options?.detailed) {
222
+ if (!input.startsWith('at://')) {
223
+ return failure('ATURI must start with "at://"')
224
+ }
71
225
 
72
- if (collectionStart !== -1) {
73
- const collection =
74
- collectionEnd === -1
75
- ? uri.slice(collectionStart)
76
- : uri.slice(collectionStart, collectionEnd)
226
+ if (input.includes(' ')) {
227
+ return failure('ATURI can not contain spaces')
228
+ }
77
229
 
78
- if (collection.length === 0) {
79
- throw new Error(
80
- 'ATURI can not have a slash after authority without a path segment',
81
- )
82
- }
83
- if (!isValidNsid(collection)) {
84
- throw new Error(
85
- 'ATURI requires first path segment (if supplied) to be valid NSID',
86
- )
230
+ if (input.includes('//', 5)) {
231
+ return failure('ATURI can not have empty path segments')
232
+ }
233
+
234
+ const pathStart = input.indexOf('/', 5) // after "at://"
235
+ if (pathStart !== -1) {
236
+ const fragmentIndex = input.indexOf('#')
237
+ const pathEnd = fragmentIndex !== -1 ? fragmentIndex : input.length
238
+ const secondSlash = input.indexOf('/', pathStart + 1)
239
+ if (secondSlash !== -1 && secondSlash !== pathEnd - 1) {
240
+ return failure('ATURI can not have more than two path segments')
241
+ }
242
+ }
87
243
  }
244
+
245
+ return failure('ATURI does not match expected format')
88
246
  }
89
247
 
90
- const recordKeyStart = collectionEnd === -1 ? -1 : collectionEnd + 1
91
- const recordKeyEnd =
92
- recordKeyStart === -1 ? -1 : uri.indexOf('/', recordKeyStart)
248
+ // @NOTE Percent-encoding is allowed by the AT URI specification, but any
249
+ // percent-encoded characters appearing in the collection NSID or record key
250
+ // will effectively be rejected by the isValidNsid and isValidRecordKey
251
+ // validators. Since these values are defined to be plain ASCII identifiers,
252
+ // this legacy behavior is beneficial: it ensures that normalized
253
+ // (non-percent-encoded) values are always used, as prescribed by the spec.
93
254
 
94
- if (recordKeyStart !== -1) {
95
- if (recordKeyStart === uri.length) {
96
- throw new Error(
97
- 'ATURI can not have a slash after collection, unless record key is provided',
98
- )
99
- }
100
- // would validate rkey here, but there are basically no constraints!
255
+ if (!isAtIdentifierString(groups.authority)) {
256
+ return failure('ATURI has invalid authority')
101
257
  }
102
258
 
103
- if (recordKeyEnd !== -1) {
104
- throw new Error(
105
- 'ATURI path can have at most two parts, and no trailing slash',
106
- )
259
+ if (groups.collection != null && !isValidNsid(groups.collection)) {
260
+ return failure('ATURI has invalid collection')
107
261
  }
108
- }
109
262
 
110
- export function ensureValidAtUriRegex<I extends string>(
111
- input: I,
112
- ): asserts input is I & AtUriString {
113
- // simple regex to enforce most constraints via just regex and length.
114
- // hand wrote this regex based on above constraints. whew!
115
- const aturiRegex =
116
- /^at:\/\/(?<authority>[a-zA-Z0-9._:%-]+)(\/(?<collection>[a-zA-Z0-9-.]+)(\/(?<rkey>[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(#(?<fragment>\/[a-zA-Z0-9._~:@!$&%')(*+,;=\-[\]/\\]*))?$/
117
- const rm = input.match(aturiRegex)
118
- if (!rm || !rm.groups) {
119
- throw new Error("ATURI didn't validate via regex")
263
+ if (groups.hash != null) {
264
+ const result = parseJsonPointer(groups.hash, options)
265
+ if (result.success) {
266
+ groups.hash = result.value
267
+ } else {
268
+ return failure(`ATURI has invalid fragment (${result.message})`)
269
+ }
120
270
  }
121
- const groups = rm.groups
122
271
 
123
- try {
124
- ensureValidHandleRegex(groups.authority)
125
- } catch {
126
- try {
127
- ensureValidDidRegex(groups.authority)
128
- } catch {
129
- throw new Error('ATURI authority must be a valid handle or DID')
272
+ if (options?.strict !== false) {
273
+ if (groups.trailingSlash != null) {
274
+ return failure('ATURI can not have a trailing slash')
275
+ }
276
+
277
+ if (groups.query != null) {
278
+ return failure('ATURI query part is not allowed')
279
+ }
280
+
281
+ if (groups.rkey != null && !isValidRecordKey(groups.rkey)) {
282
+ return failure('ATURI has invalid record key')
130
283
  }
131
284
  }
132
285
 
133
- if (groups.collection && !isValidNsid(groups.collection)) {
134
- throw new Error('ATURI collection path segment must be a valid NSID')
286
+ return success(groups as AtUriParts)
287
+ }
288
+
289
+ const BASIC_JSON_POINTER_REGEXP = /^\/[a-zA-Z0-9._~:@!$&')(*+,;=%[\]/-]*$/
290
+
291
+ /**
292
+ * Checks if a string is a valid JSON pointer (RFC-6901) with the allowed chars
293
+ * for ATURI fragments. This is a very loose validation that only checks the
294
+ * basic syntax and charset.
295
+ */
296
+ function parseJsonPointer(
297
+ value: string,
298
+ options?: { strict?: boolean },
299
+ ): Result<string> {
300
+ if (!BASIC_JSON_POINTER_REGEXP.test(value)) {
301
+ return failure('Invalid JSON pointer')
135
302
  }
136
303
 
137
- if (input.length > 8 * 1024) {
138
- throw new Error('ATURI is far too long')
304
+ const result = parsePercentEncoding(value)
305
+
306
+ // In non-strict mode, we allow invalid percent-encoding in the fragment
307
+ if (!result.success && options?.strict === false) {
308
+ return success(value)
139
309
  }
310
+
311
+ return result
140
312
  }
141
313
 
142
- export function isValidAtUri<I extends string>(
143
- input: I,
144
- ): input is I & AtUriString {
314
+ function parsePercentEncoding(value: string): Result<string> {
145
315
  try {
146
- ensureValidAtUri(input)
316
+ return success(decodeURIComponent(value))
147
317
  } catch {
148
- return false
318
+ // decodeURIComponent throws if the percent-encoding is invalid (e.g. "%FF")
319
+ return failure('Invalid percent-encoding')
149
320
  }
150
-
151
- return true
152
321
  }
package/src/datetime.ts CHANGED
@@ -163,19 +163,52 @@ export function toDatetimeString(date: Date): DatetimeString {
163
163
  * One use-case is a consistent, sortable string. Another is to work with older
164
164
  * invalid createdAt datetimes.
165
165
  *
166
+ * @note This function might return different normalized strings for the same
167
+ * input depending on the timezone of the machine it is run on, since it will
168
+ * attempt to parse the input "as is" if it fails to parse with an explicit
169
+ * timezone.
170
+ *
166
171
  * @returns ISODatetimeString - a valid atproto datetime with millisecond precision (3 sub-second digits) and UTC timezone with trailing 'Z' syntax.
167
172
  * @throws InvalidDatetimeError - if the input string could not be parsed as a datetime, even with permissive parsing.
168
173
  */
169
174
  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()
174
- }
175
+ if (
176
+ // Explicit timezone offset
177
+ /[+-]\d\d:?\d\d/.test(dtStr) ||
178
+ // 'Z' timezone designator
179
+ /\dZ\b/.test(dtStr) ||
180
+ // Timezone abbreviation (eg. "EST", "PST", "UTC", "GMT", etc), as in:
181
+ // > Tue Mar 17 2026 16:38:44 PST (Pacific Standard Time)
182
+ /\b[A-Z]{3,4}\b/.test(dtStr)
183
+ ) {
184
+ // Since we do have a timezone designator, we can try parsing "as is" and
185
+ // should get consistent results regardless of local timezone.
186
+
187
+ // @NOTE NodeJS will reject dates with an un-recognized timezone designator
188
+ // (like "AFT"), even if we add a well-known timezone abbreviation like
189
+ // "UTC" or "Z".
190
+ const date = new Date(dtStr)
191
+ if (isAtprotoDate(date)) {
192
+ return date.toISOString()
193
+ }
194
+ } else {
195
+ // If there is no timezone information, try parsing as UTC using two
196
+ // different syntaxes, falling back to parsing "as is".
197
+
198
+ const dateZ = new Date(`${dtStr}Z`)
199
+ if (isAtprotoDate(dateZ)) {
200
+ return dateZ.toISOString()
201
+ }
202
+
203
+ const dateUTC = new Date(`${dtStr} UTC`)
204
+ if (isAtprotoDate(dateUTC)) {
205
+ return dateUTC.toISOString()
206
+ }
175
207
 
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`)
208
+ // Despite our best efforts to parse as a consistent value, appending "Z" or
209
+ // " UTC" did not work, so we will try parsing "as is", which may yield
210
+ // different results depending on the local timezone of the machine.
211
+ const date = new Date(dtStr)
179
212
  if (isAtprotoDate(date)) {
180
213
  return date.toISOString()
181
214
  }
@@ -0,0 +1,11 @@
1
+ export type Result<T> = Success<T> | Failure
2
+
3
+ export type Success<T> = { success: true; value: T }
4
+ export function success<T>(value: T): Success<T> {
5
+ return { success: true, value }
6
+ }
7
+
8
+ export type Failure = { success: false; message: string }
9
+ export function failure(message: string): Failure {
10
+ return { success: false, message }
11
+ }
package/src/nsid.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { Result, failure, success } from './lib/result.js'
2
+
1
3
  /*
2
4
  Grammar:
3
5
 
@@ -81,71 +83,42 @@ export function isValidNsid<I extends string>(
81
83
  return validateNsidRegex(input).success
82
84
  }
83
85
 
84
- type ValidateResult<T> =
85
- | { success: true; value: T }
86
- | { success: false; message: string }
87
-
88
86
  // Human readable constraints on NSID:
89
87
  // - a valid domain in reversed notation
90
88
  // - followed by an additional period-separated name, which is camel-case letters
91
- export function validateNsid(input: string): ValidateResult<string[]> {
89
+ export function validateNsid(input: string): Result<string[]> {
92
90
  if (input.length > 253 + 1 + 63) {
93
- return {
94
- success: false,
95
- message: 'NSID is too long (317 chars max)',
96
- }
91
+ return failure('NSID is too long (317 chars max)')
97
92
  }
98
93
  if (hasDisallowedCharacters(input)) {
99
- return {
100
- success: false,
101
- message:
102
- 'Disallowed characters in NSID (ASCII letters, digits, dashes, periods only)',
103
- }
94
+ return failure(
95
+ 'Disallowed characters in NSID (ASCII letters, digits, dashes, periods only)',
96
+ )
104
97
  }
105
98
  const segments = input.split('.')
106
99
  if (segments.length < 3) {
107
- return {
108
- success: false,
109
- message: 'NSID needs at least three parts',
110
- }
100
+ return failure('NSID needs at least three parts')
111
101
  }
112
102
  for (const l of segments) {
113
103
  if (l.length < 1) {
114
- return {
115
- success: false,
116
- message: 'NSID parts can not be empty',
117
- }
104
+ return failure('NSID parts can not be empty')
118
105
  }
119
106
  if (l.length > 63) {
120
- return {
121
- success: false,
122
- message: 'NSID part too long (max 63 chars)',
123
- }
107
+ return failure('NSID part too long (max 63 chars)')
124
108
  }
125
109
  if (startsWithHyphen(l) || endsWithHyphen(l)) {
126
- return {
127
- success: false,
128
- message: 'NSID parts can not start or end with hyphen',
129
- }
110
+ return failure('NSID parts can not start or end with hyphen')
130
111
  }
131
112
  }
132
113
  if (startsWithNumber(segments[0])) {
133
- return {
134
- success: false,
135
- message: 'NSID first part may not start with a digit',
136
- }
114
+ return failure('NSID first part may not start with a digit')
137
115
  }
138
116
  if (!isValidIdentifier(segments[segments.length - 1])) {
139
- return {
140
- success: false,
141
- message:
142
- 'NSID name part must be only letters and digits (and no leading digit)',
143
- }
144
- }
145
- return {
146
- success: true,
147
- value: segments,
117
+ return failure(
118
+ 'NSID name part must be only letters and digits (and no leading digit)',
119
+ )
148
120
  }
121
+ return success(segments)
149
122
  }
150
123
 
151
124
  function hasDisallowedCharacters(v: string) {
@@ -188,12 +161,9 @@ export function ensureValidNsidRegex(nsid: string): asserts nsid is NsidString {
188
161
  * Regexp based validation that behaves identically to the previous code but
189
162
  * provides less detailed error messages (while being 20% to 50% faster).
190
163
  */
191
- export function validateNsidRegex(value: string): ValidateResult<NsidString> {
164
+ export function validateNsidRegex(value: string): Result<NsidString> {
192
165
  if (value.length > 253 + 1 + 63) {
193
- return {
194
- success: false,
195
- message: 'NSID is too long (317 chars max)',
196
- }
166
+ return failure('NSID is too long (317 chars max)')
197
167
  }
198
168
 
199
169
  if (
@@ -203,16 +173,10 @@ export function validateNsidRegex(value: string): ValidateResult<NsidString> {
203
173
  value,
204
174
  )
205
175
  ) {
206
- return {
207
- success: false,
208
- message: "NSID didn't validate via regex",
209
- }
176
+ return failure("NSID didn't validate via regex")
210
177
  }
211
178
 
212
- return {
213
- success: true,
214
- value: value as NsidString,
215
- }
179
+ return success(value as NsidString)
216
180
  }
217
181
 
218
182
  export class InvalidNsidError extends Error {}