@bluesky-social/syntax
Version:
Validation for atproto identifiers and formats: DID, handle, NSID, AT URI, etc
114 lines (98 loc) • 3.36 kB
text/typescript
/*
Grammar:
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"
number = "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9" / "0"
delim = "."
segment = alpha *( alpha / number / "-" )
authority = segment *( delim segment )
name = alpha *( alpha )
nsid = authority delim name
*/
export class NSID {
segments: string[] = []
static parse(nsid: string): NSID {
return new NSID(nsid)
}
static create(authority: string, name: string): NSID {
const segments = [...authority.split('.').reverse(), name].join('.')
return new NSID(segments)
}
static isValid(nsid: string): boolean {
try {
NSID.parse(nsid)
return true
} catch (e) {
return false
}
}
constructor(nsid: string) {
ensureValidNsid(nsid)
this.segments = nsid.split('.')
}
get authority() {
return this.segments
.slice(0, this.segments.length - 1)
.reverse()
.join('.')
}
get name() {
return this.segments.at(this.segments.length - 1)
}
toString() {
return this.segments.join('.')
}
}
// Human readable constraints on NSID:
// - a valid domain in reversed notation
// - followed by an additional period-separated name, which is camel-case letters
export const ensureValidNsid = (nsid: string): void => {
const toCheck = nsid
// check that all chars are boring ASCII
if (!/^[a-zA-Z0-9.-]*$/.test(toCheck)) {
throw new InvalidNsidError(
'Disallowed characters in NSID (ASCII letters, digits, dashes, periods only)',
)
}
if (toCheck.length > 253 + 1 + 63) {
throw new InvalidNsidError('NSID is too long (317 chars max)')
}
const labels = toCheck.split('.')
if (labels.length < 3) {
throw new InvalidNsidError('NSID needs at least three parts')
}
for (let i = 0; i < labels.length; i++) {
const l = labels[i]
if (l.length < 1) {
throw new InvalidNsidError('NSID parts can not be empty')
}
if (l.length > 63) {
throw new InvalidNsidError('NSID part too long (max 63 chars)')
}
if (l.endsWith('-') || l.startsWith('-')) {
throw new InvalidNsidError('NSID parts can not start or end with hyphen')
}
if (/^[0-9]/.test(l) && i === 0) {
throw new InvalidNsidError('NSID first part may not start with a digit')
}
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(l) && i + 1 === labels.length) {
throw new InvalidNsidError(
'NSID name part must be only letters and digits (and no leading digit)',
)
}
}
}
export const ensureValidNsidRegex = (nsid: string): void => {
// simple regex to enforce most constraints via just regex and length.
// hand wrote this regex based on above constraints
if (
!/^[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(
nsid,
)
) {
throw new InvalidNsidError("NSID didn't validate via regex")
}
if (nsid.length > 253 + 1 + 63) {
throw new InvalidNsidError('NSID is too long (317 chars max)')
}
}
export class InvalidNsidError extends Error {}