···55 VariableDeclarationKind,
66} from "ts-morph";
77import { type LexiconDoc, Lexicons, type LexRecord } from "@atproto/lexicon";
88-import { NSID } from "@atproto/syntax";
88+import { NSID } from "@atp/syntax";
99import type { GeneratedAPI } from "../types.ts";
1010import { gen, lexiconsTs, utilTs } from "./common.ts";
1111import {
+1-1
lex-cli/codegen/server.ts
···55 VariableDeclarationKind,
66} from "ts-morph";
77import { type LexiconDoc, Lexicons } from "@atproto/lexicon";
88-import { NSID } from "@atproto/syntax";
88+import { NSID } from "@atp/syntax";
99import type { GeneratedAPI } from "../types.ts";
1010import { gen, lexiconsTs, utilTs } from "./common.ts";
1111import {
+1-1
lex-cli/codegen/util.ts
···11import type { LexiconDoc, LexUserType } from "@atproto/lexicon";
22-import { NSID } from "@atproto/syntax";
22+import { NSID } from "@atp/syntax";
3344export interface CodeGenOptions {
55 useJsExtension?: boolean;
···11+import { ensureValidDid, ensureValidDidRegex } from "./did.ts";
22+import { ensureValidHandle, ensureValidHandleRegex } from "./handle.ts";
33+import { isValidNsid } from "./nsid.ts";
44+import { ensureValidRecordKey } from "./recordkey.ts";
55+66+// Human-readable constraints on ATURI:
77+// - following regular URLs, a 8KByte hard total length limit
88+// - follows ATURI docs on website
99+// - all ASCII characters, no whitespace. non-ASCII could be URL-encoded
1010+// - starts "at://"
1111+// - "authority" is a valid DID or a valid handle
1212+// - optionally, follow "authority" with "/" and valid NSID as start of path
1313+// - optionally, if NSID given, follow that with "/" and rkey
1414+// - rkey path component can include URL-encoded ("percent encoded"), or:
1515+// ALPHA / DIGIT / "-" / "." / "_" / "~" / ":" / "@" / "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
1616+// [a-zA-Z0-9._~:@!$&'\(\)*+,;=-]
1717+// - rkey must have at least one char
1818+// - regardless of path component, a fragment can follow as "#" and then a JSON pointer (RFC-6901)
1919+export const ensureValidAtUri = (uri: string) => {
2020+ // JSON pointer is pretty different from rest of URI, so split that out first
2121+ const uriParts = uri.split("#");
2222+ if (uriParts.length > 2) {
2323+ throw new Error('ATURI can have at most one "#", separating fragment out');
2424+ }
2525+ const fragmentPart = uriParts[1] || null;
2626+ uri = uriParts[0];
2727+2828+ // check that all chars are boring ASCII
2929+ if (!/^[a-zA-Z0-9._~:@!$&')(*+,;=%/-]*$/.test(uri)) {
3030+ throw new Error("Disallowed characters in ATURI (ASCII)");
3131+ }
3232+3333+ const parts = uri.split("/");
3434+ if (parts.length >= 3 && (parts[0] !== "at:" || parts[1].length !== 0)) {
3535+ throw new Error('ATURI must start with "at://"');
3636+ }
3737+ if (parts.length < 3) {
3838+ throw new Error("ATURI requires at least method and authority sections");
3939+ }
4040+4141+ try {
4242+ if (parts[2].startsWith("did:")) {
4343+ ensureValidDid(parts[2]);
4444+ } else {
4545+ ensureValidHandle(parts[2]);
4646+ }
4747+ } catch {
4848+ throw new Error("ATURI authority must be a valid handle or DID");
4949+ }
5050+5151+ if (parts.length >= 4) {
5252+ if (parts[3].length === 0) {
5353+ throw new Error(
5454+ "ATURI can not have a slash after authority without a path segment",
5555+ );
5656+ }
5757+ if (!isValidNsid(parts[3])) {
5858+ throw new Error(
5959+ "ATURI requires first path segment (if supplied) to be valid NSID",
6060+ );
6161+ }
6262+ }
6363+6464+ if (parts.length >= 5) {
6565+ if (parts[4].length === 0) {
6666+ throw new Error(
6767+ "ATURI can not have a slash after collection, unless record key is provided",
6868+ );
6969+ }
7070+ try {
7171+ ensureValidRecordKey(parts[4]);
7272+ } catch {
7373+ throw new Error("ATURI record key must be a valid record key");
7474+ }
7575+ }
7676+7777+ if (parts.length >= 6) {
7878+ throw new Error(
7979+ "ATURI path can have at most two parts, and no trailing slash",
8080+ );
8181+ }
8282+8383+ if (uriParts.length >= 2 && fragmentPart == null) {
8484+ throw new Error("ATURI fragment must be non-empty and start with slash");
8585+ }
8686+8787+ if (fragmentPart != null) {
8888+ if (fragmentPart.length === 0 || fragmentPart[0] !== "/") {
8989+ throw new Error("ATURI fragment must be non-empty and start with slash");
9090+ }
9191+ // NOTE: enforcing *some* checks here for sanity. Eg, at least no whitespace
9292+ if (!/^\/[a-zA-Z0-9._~:@!$&')(*+,;=%[\]/-]*$/.test(fragmentPart)) {
9393+ throw new Error("Disallowed characters in ATURI fragment (ASCII)");
9494+ }
9595+ }
9696+9797+ if (uri.length > 8 * 1024) {
9898+ throw new Error("ATURI is far too long");
9999+ }
100100+};
101101+102102+export const ensureValidAtUriRegex = (uri: string): void => {
103103+ // simple regex to enforce most constraints via just regex and length.
104104+ // hand wrote this regex based on above constraints. whew!
105105+ const aturiRegex =
106106+ /^at:\/\/(?<authority>[a-zA-Z0-9._:%-]+)(\/(?<collection>[a-zA-Z0-9-.]+)(\/(?<rkey>[a-zA-Z0-9._~:-]+))?)?(#(?<fragment>\/[a-zA-Z0-9._~:@!$&%')(*+,;=\-[\]/\\]*))?$/;
107107+ const rm = uri.match(aturiRegex);
108108+ if (!rm || !rm.groups) {
109109+ throw new Error("ATURI didn't validate via regex");
110110+ }
111111+ const groups = rm.groups;
112112+113113+ try {
114114+ ensureValidHandleRegex(groups.authority);
115115+ } catch {
116116+ try {
117117+ ensureValidDidRegex(groups.authority);
118118+ } catch {
119119+ throw new Error("ATURI authority must be a valid handle or DID");
120120+ }
121121+ }
122122+123123+ if (groups.collection && !isValidNsid(groups.collection)) {
124124+ throw new Error("ATURI collection path segment must be a valid NSID");
125125+ }
126126+127127+ if (groups.rkey) {
128128+ try {
129129+ ensureValidRecordKey(groups.rkey);
130130+ } catch {
131131+ throw new Error("ATURI record key must be a valid record key");
132132+ }
133133+ }
134134+135135+ if (uri.length > 8 * 1024) {
136136+ throw new Error("ATURI is far too long");
137137+ }
138138+};
···11+/* Validates datetime string against atproto Lexicon 'datetime' format.
22+ * Syntax is described at: https://atproto.com/specs/lexicon#datetime
33+ */
44+export const ensureValidDatetime = (dtStr: string): void => {
55+ const date = new Date(dtStr);
66+ // must parse as ISO 8601; this also verifies semantics like month is not 13 or 00
77+ if (isNaN(date.getTime())) {
88+ throw new InvalidDatetimeError("datetime did not parse as ISO 8601");
99+ }
1010+ if (date.toISOString().startsWith("-")) {
1111+ throw new InvalidDatetimeError("datetime normalized to a negative time");
1212+ }
1313+ // regex and other checks for RFC-3339
1414+ if (
1515+ !/^[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]))$/
1616+ .test(
1717+ dtStr,
1818+ )
1919+ ) {
2020+ throw new InvalidDatetimeError("datetime didn't validate via regex");
2121+ }
2222+ if (dtStr.length > 64) {
2323+ throw new InvalidDatetimeError("datetime is too long (64 chars max)");
2424+ }
2525+ if (dtStr.endsWith("-00:00")) {
2626+ throw new InvalidDatetimeError(
2727+ 'datetime can not use "-00:00" for UTC timezone',
2828+ );
2929+ }
3030+ if (dtStr.startsWith("000")) {
3131+ throw new InvalidDatetimeError(
3232+ "datetime so close to year zero not allowed",
3333+ );
3434+ }
3535+};
3636+3737+/* Same logic as ensureValidDatetime(), but returns a boolean instead of throwing an exception.
3838+ */
3939+export const isValidDatetime = (dtStr: string): boolean => {
4040+ try {
4141+ ensureValidDatetime(dtStr);
4242+ } catch (err) {
4343+ if (err instanceof InvalidDatetimeError) {
4444+ return false;
4545+ }
4646+ throw err;
4747+ }
4848+4949+ return true;
5050+};
5151+5252+/* Takes a flexible datetime string and normalizes representation.
5353+ *
5454+ * This function will work with any valid atproto datetime (eg, anything which isValidDatetime() is true for). It *additionally* is more flexible about accepting datetimes that don't comply to RFC 3339, or are missing timezone information, and normalizing them to a valid datetime.
5555+ *
5656+ * One use-case is a consistent, sortable string. Another is to work with older invalid createdAt datetimes.
5757+ *
5858+ * Successful output will be a valid atproto datetime with millisecond precision (3 sub-second digits) and UTC timezone with trailing 'Z' syntax. Throws `InvalidDatetimeError` if the input string could not be parsed as a datetime, even with permissive parsing.
5959+ *
6060+ * Expected output format: YYYY-MM-DDTHH:mm:ss.sssZ
6161+ */
6262+export const normalizeDatetime = (dtStr: string): string => {
6363+ if (isValidDatetime(dtStr)) {
6464+ const outStr = new Date(dtStr).toISOString();
6565+ if (isValidDatetime(outStr)) {
6666+ return outStr;
6767+ }
6868+ }
6969+7070+ // check if this permissive datetime is missing a timezone
7171+ if (!/.*(([+-]\d\d:?\d\d)|[a-zA-Z])$/.test(dtStr)) {
7272+ const date = new Date(dtStr + "Z");
7373+ if (!isNaN(date.getTime())) {
7474+ const tzStr = date.toISOString();
7575+ if (isValidDatetime(tzStr)) {
7676+ return tzStr;
7777+ }
7878+ }
7979+ }
8080+8181+ // finally try parsing as simple datetime
8282+ const date = new Date(dtStr);
8383+ if (isNaN(date.getTime())) {
8484+ throw new InvalidDatetimeError(
8585+ "datetime did not parse as any timestamp format",
8686+ );
8787+ }
8888+ const isoStr = date.toISOString();
8989+ if (isValidDatetime(isoStr)) {
9090+ return isoStr;
9191+ } else {
9292+ throw new InvalidDatetimeError(
9393+ "datetime normalized to invalid timestamp string",
9494+ );
9595+ }
9696+};
9797+9898+/* Variant of normalizeDatetime() which always returns a valid datetime strings.
9999+ *
100100+ * If a InvalidDatetimeError is encountered, returns the UNIX epoch time as a UTC datetime (1970-01-01T00:00:00.000Z).
101101+ */
102102+export const normalizeDatetimeAlways = (dtStr: string): string => {
103103+ try {
104104+ return normalizeDatetime(dtStr);
105105+ } catch (err) {
106106+ if (err instanceof InvalidDatetimeError) {
107107+ return new Date(0).toISOString();
108108+ }
109109+ throw err;
110110+ }
111111+};
112112+113113+/* Indicates a datetime string did not pass full atproto Lexicon datetime string format checks.
114114+ */
115115+export class InvalidDatetimeError extends Error {}
···11+// Human-readable constraints:
22+// - valid W3C DID (https://www.w3.org/TR/did-core/#did-syntax)
33+// - entire URI is ASCII: [a-zA-Z0-9._:%-]
44+// - always starts "did:" (lower-case)
55+// - method name is one or more lower-case letters, followed by ":"
66+// - remaining identifier can have any of the above chars, but can not end in ":"
77+// - it seems that a bunch of ":" can be included, and don't need spaces between
88+// - "%" is used only for "percent encoding" and must be followed by two hex characters (and thus can't end in "%")
99+// - query ("?") and fragment ("#") stuff is defined for "DID URIs", but not as part of identifier itself
1010+// - "The current specification does not take a position on the maximum length of a DID"
1111+// - in current atproto, only allowing did:plc and did:web. But not *forcing* this at lexicon layer
1212+// - hard length limit of 8KBytes
1313+// - not going to validate "percent encoding" here
1414+export const ensureValidDid = (did: string): void => {
1515+ if (!did.startsWith("did:")) {
1616+ throw new InvalidDidError("DID requires 'did:' prefix");
1717+ }
1818+1919+ // check that all chars are boring ASCII
2020+ if (!/^[a-zA-Z0-9._:%-]*$/.test(did)) {
2121+ throw new InvalidDidError(
2222+ "Disallowed characters in DID (ASCII letters, digits, and a couple other characters only)",
2323+ );
2424+ }
2525+2626+ const { length, 1: method } = did.split(":");
2727+ if (length < 3) {
2828+ throw new InvalidDidError(
2929+ "DID requires prefix, method, and method-specific content",
3030+ );
3131+ }
3232+3333+ if (!/^[a-z]+$/.test(method)) {
3434+ throw new InvalidDidError("DID method must be lower-case letters");
3535+ }
3636+3737+ if (did.endsWith(":") || did.endsWith("%")) {
3838+ throw new InvalidDidError("DID can not end with ':' or '%'");
3939+ }
4040+4141+ if (did.length > 2 * 1024) {
4242+ throw new InvalidDidError("DID is too long (2048 chars max)");
4343+ }
4444+};
4545+4646+export const ensureValidDidRegex = (did: string): void => {
4747+ // simple regex to enforce most constraints via just regex and length.
4848+ // hand wrote this regex based on above constraints
4949+ if (!/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/.test(did)) {
5050+ throw new InvalidDidError("DID didn't validate via regex");
5151+ }
5252+5353+ if (did.length > 2 * 1024) {
5454+ throw new InvalidDidError("DID is too long (2048 chars max)");
5555+ }
5656+};
5757+5858+export class InvalidDidError extends Error {}
+124
syntax/handle.ts
···11+export const INVALID_HANDLE = "handle.invalid";
22+33+// Currently these are registration-time restrictions, not protocol-level
44+// restrictions. We have a couple accounts in the wild that we need to clean up
55+// before hard-disallow.
66+// See also: https://en.wikipedia.org/wiki/Top-level_domain#Reserved_domains
77+export const DISALLOWED_TLDS = [
88+ ".local",
99+ ".arpa",
1010+ ".invalid",
1111+ ".localhost",
1212+ ".internal",
1313+ ".example",
1414+ ".alt",
1515+ // policy could concievably change on ".onion" some day
1616+ ".onion",
1717+ // NOTE: .test is allowed in testing and devopment. In practical terms
1818+ // "should" "never" actually resolve and get registered in production
1919+];
2020+2121+// Handle constraints, in English:
2222+// - must be a possible domain name
2323+// - RFC-1035 is commonly referenced, but has been updated. eg, RFC-3696,
2424+// section 2. and RFC-3986, section 3. can now have leading numbers (eg,
2525+// 4chan.org)
2626+// - "labels" (sub-names) are made of ASCII letters, digits, hyphens
2727+// - can not start or end with a hyphen
2828+// - TLD (last component) should not start with a digit
2929+// - can't end with a hyphen (can end with digit)
3030+// - each segment must be between 1 and 63 characters (not including any periods)
3131+// - overall length can't be more than 253 characters
3232+// - separated by (ASCII) periods; does not start or end with period
3333+// - case insensitive
3434+// - domains (handles) are equal if they are the same lower-case
3535+// - punycode allowed for internationalization
3636+// - no whitespace, null bytes, joining chars, etc
3737+// - does not validate whether domain or TLD exists, or is a reserved or
3838+// special TLD (eg, .onion or .local)
3939+// - does not validate punycode
4040+export const ensureValidHandle = (handle: string): void => {
4141+ // check that all chars are boring ASCII
4242+ if (!/^[a-zA-Z0-9.-]*$/.test(handle)) {
4343+ throw new InvalidHandleError(
4444+ "Disallowed characters in handle (ASCII letters, digits, dashes, periods only)",
4545+ );
4646+ }
4747+4848+ if (handle.length > 253) {
4949+ throw new InvalidHandleError("Handle is too long (253 chars max)");
5050+ }
5151+ const labels = handle.split(".");
5252+ if (labels.length < 2) {
5353+ throw new InvalidHandleError("Handle domain needs at least two parts");
5454+ }
5555+ for (let i = 0; i < labels.length; i++) {
5656+ const l = labels[i];
5757+ if (l.length < 1) {
5858+ throw new InvalidHandleError("Handle parts can not be empty");
5959+ }
6060+ if (l.length > 63) {
6161+ throw new InvalidHandleError("Handle part too long (max 63 chars)");
6262+ }
6363+ if (l.endsWith("-") || l.startsWith("-")) {
6464+ throw new InvalidHandleError(
6565+ "Handle parts can not start or end with hyphens",
6666+ );
6767+ }
6868+ if (i + 1 === labels.length && !/^[a-zA-Z]/.test(l)) {
6969+ throw new InvalidHandleError(
7070+ "Handle final component (TLD) must start with ASCII letter",
7171+ );
7272+ }
7373+ }
7474+};
7575+7676+// simple regex translation of above constraints
7777+export const ensureValidHandleRegex = (handle: string): void => {
7878+ if (
7979+ !/^([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])?$/
8080+ .test(
8181+ handle,
8282+ )
8383+ ) {
8484+ throw new InvalidHandleError("Handle didn't validate via regex");
8585+ }
8686+ if (handle.length > 253) {
8787+ throw new InvalidHandleError("Handle is too long (253 chars max)");
8888+ }
8989+};
9090+9191+export const normalizeHandle = (handle: string): string => {
9292+ return handle.toLowerCase();
9393+};
9494+9595+export const normalizeAndEnsureValidHandle = (handle: string): string => {
9696+ const normalized = normalizeHandle(handle);
9797+ ensureValidHandle(normalized);
9898+ return normalized;
9999+};
100100+101101+export const isValidHandle = (handle: string): boolean => {
102102+ try {
103103+ ensureValidHandle(handle);
104104+ } catch (err) {
105105+ if (err instanceof InvalidHandleError) {
106106+ return false;
107107+ }
108108+ throw err;
109109+ }
110110+111111+ return true;
112112+};
113113+114114+export const isValidTld = (handle: string): boolean => {
115115+ return !DISALLOWED_TLDS.some((domain) => handle.endsWith(domain));
116116+};
117117+118118+export class InvalidHandleError extends Error {}
119119+/** @deprecated Never used */
120120+export class ReservedHandleError extends Error {}
121121+/** @deprecated Never used */
122122+export class UnsupportedDomainError extends Error {}
123123+/** @deprecated Never used */
124124+export class DisallowedDomainError extends Error {}
+7
syntax/mod.ts
···11+export * from "./handle.ts";
22+export * from "./did.ts";
33+export * from "./nsid.ts";
44+export * from "./aturi.ts";
55+export * from "./tid.ts";
66+export * from "./recordkey.ts";
77+export * from "./datetime.ts";
+211
syntax/nsid.ts
···11+/*
22+Grammar:
33+44+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"
55+number = "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9" / "0"
66+delim = "."
77+segment = alpha *( alpha / number / "-" )
88+authority = segment *( delim segment )
99+name = alpha *( alpha / number )
1010+nsid = authority delim name
1111+1212+*/
1313+1414+export class NSID {
1515+ readonly segments: readonly string[];
1616+1717+ static parse(input: string): NSID {
1818+ return new NSID(input);
1919+ }
2020+2121+ static create(authority: string, name: string): NSID {
2222+ const input = [...authority.split(".").reverse(), name].join(".");
2323+ return new NSID(input);
2424+ }
2525+2626+ static isValid(nsid: string) {
2727+ return isValidNsid(nsid);
2828+ }
2929+3030+ static from(input: { toString: () => string }): NSID {
3131+ if (input instanceof NSID) {
3232+ // No need to clone, NSID is immutable
3333+ return input;
3434+ }
3535+ if (Array.isArray(input)) {
3636+ return new NSID((input as string[]).join("."));
3737+ }
3838+ return new NSID(String(input));
3939+ }
4040+4141+ constructor(nsid: string) {
4242+ this.segments = parseNsid(nsid);
4343+ }
4444+4545+ get authority() {
4646+ return this.segments
4747+ .slice(0, this.segments.length - 1)
4848+ .reverse()
4949+ .join(".");
5050+ }
5151+5252+ get name() {
5353+ return this.segments.at(this.segments.length - 1);
5454+ }
5555+5656+ toString() {
5757+ return this.segments.join(".");
5858+ }
5959+}
6060+6161+export function ensureValidNsid(nsid: string): void {
6262+ const result = validateNsid(nsid);
6363+ if (!result.success) throw new InvalidNsidError(result.message);
6464+}
6565+6666+export function parseNsid(nsid: string): string[] {
6767+ const result = validateNsid(nsid);
6868+ if (!result.success) throw new InvalidNsidError(result.message);
6969+ return result.value;
7070+}
7171+7272+export function isValidNsid(nsid: string): boolean {
7373+ // Since the regex version is more performant for valid NSIDs, we use it when
7474+ // we don't care about error details.
7575+ return validateNsidRegex(nsid).success;
7676+}
7777+7878+type ValidateResult<T> =
7979+ | { success: true; value: T }
8080+ | { success: false; message: string };
8181+8282+// Human readable constraints on NSID:
8383+// - a valid domain in reversed notation
8484+// - followed by an additional period-separated name, which is camel-case letters
8585+export function validateNsid(input: string): ValidateResult<string[]> {
8686+ if (input.length > 253 + 1 + 63) {
8787+ return {
8888+ success: false,
8989+ message: "NSID is too long (317 chars max)",
9090+ };
9191+ }
9292+ if (hasDisallowedCharacters(input)) {
9393+ return {
9494+ success: false,
9595+ message:
9696+ "Disallowed characters in NSID (ASCII letters, digits, dashes, periods only)",
9797+ };
9898+ }
9999+ const segments = input.split(".");
100100+ if (segments.length < 3) {
101101+ return {
102102+ success: false,
103103+ message: "NSID needs at least three parts",
104104+ };
105105+ }
106106+ for (const l of segments) {
107107+ if (l.length < 1) {
108108+ return {
109109+ success: false,
110110+ message: "NSID parts can not be empty",
111111+ };
112112+ }
113113+ if (l.length > 63) {
114114+ return {
115115+ success: false,
116116+ message: "NSID part too long (max 63 chars)",
117117+ };
118118+ }
119119+ if (startsWithHyphen(l) || endsWithHyphen(l)) {
120120+ return {
121121+ success: false,
122122+ message: "NSID parts can not start or end with hyphen",
123123+ };
124124+ }
125125+ }
126126+ if (startsWithNumber(segments[0])) {
127127+ return {
128128+ success: false,
129129+ message: "NSID first part may not start with a digit",
130130+ };
131131+ }
132132+ if (!isValidIdentifier(segments[segments.length - 1])) {
133133+ return {
134134+ success: false,
135135+ message:
136136+ "NSID name part must be only letters and digits (and no leading digit)",
137137+ };
138138+ }
139139+ return {
140140+ success: true,
141141+ value: segments,
142142+ };
143143+}
144144+145145+function hasDisallowedCharacters(v: string) {
146146+ return !/^[a-zA-Z0-9.-]*$/.test(v);
147147+}
148148+149149+function startsWithNumber(v: string) {
150150+ const charCode = v.charCodeAt(0);
151151+ return charCode >= 48 && charCode <= 57;
152152+}
153153+154154+function startsWithHyphen(v: string) {
155155+ return v.charCodeAt(0) === 45; /* - */
156156+}
157157+158158+function endsWithHyphen(v: string) {
159159+ return v.charCodeAt(v.length - 1) === 45; /* - */
160160+}
161161+162162+function isValidIdentifier(v: string) {
163163+ // Note, since we already know that "v" only contains [a-zA-Z0-9-], we can
164164+ // simplify the following regex by checking only the first char and presence
165165+ // of "-".
166166+167167+ // return /^[a-zA-Z][a-zA-Z0-9]*$/.test(v)
168168+ return !startsWithNumber(v) && !v.includes("-");
169169+}
170170+171171+/**
172172+ * @deprecated Use {@link ensureValidNsid} if you care about error details,
173173+ * {@link parseNsid}/{@link NSID.parse} if you need the parsed segments, or
174174+ * {@link isValidNsid} if you just want a boolean.
175175+ */
176176+export function ensureValidNsidRegex(nsid: string): void {
177177+ const result = validateNsidRegex(nsid);
178178+ if (!result.success) throw new InvalidNsidError(result.message);
179179+}
180180+181181+/**
182182+ * Regexp based validation that behaves identically to the previous code but
183183+ * provides less detailed error messages (while being 20% to 50% faster).
184184+ */
185185+export function validateNsidRegex(value: string): ValidateResult<string> {
186186+ if (value.length > 253 + 1 + 63) {
187187+ return {
188188+ success: false,
189189+ message: "NSID is too long (317 chars max)",
190190+ };
191191+ }
192192+193193+ if (
194194+ !/^[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})?)$/
195195+ .test(
196196+ value,
197197+ )
198198+ ) {
199199+ return {
200200+ success: false,
201201+ message: "NSID didn't validate via regex",
202202+ };
203203+ }
204204+205205+ return {
206206+ success: true,
207207+ value,
208208+ };
209209+}
210210+211211+export class InvalidNsidError extends Error {}
+27
syntax/recordkey.ts
···11+export const ensureValidRecordKey = (rkey: string): void => {
22+ if (rkey.length > 512 || rkey.length < 1) {
33+ throw new InvalidRecordKeyError("record key must be 1 to 512 characters");
44+ }
55+ // simple regex to enforce most constraints via just regex and length.
66+ if (!/^[a-zA-Z0-9_~.:-]{1,512}$/.test(rkey)) {
77+ throw new InvalidRecordKeyError("record key syntax not valid (regex)");
88+ }
99+ if (rkey === "." || rkey === "..") {
1010+ throw new InvalidRecordKeyError("record key can not be '.' or '..'");
1111+ }
1212+};
1313+1414+export const isValidRecordKey = (rkey: string): boolean => {
1515+ try {
1616+ ensureValidRecordKey(rkey);
1717+ } catch (err) {
1818+ if (err instanceof InvalidRecordKeyError) {
1919+ return false;
2020+ }
2121+ throw err;
2222+ }
2323+2424+ return true;
2525+};
2626+2727+export class InvalidRecordKeyError extends Error {}
···11+22+# not base32
33+3jzfcijpj2z21
44+0000000000000
55+66+# too long/short
77+3jzfcijpj2z2aa
88+3jzfcijpj2z2
99+1010+# old dashes syntax not actually supported (TTTT-TTT-TTTT-CC)
1111+3jzf-cij-pj2z-2a
1212+1313+# high bit can't be high
1414+zzzzzzzzzzzzz
1515+kjzfcijpj2z2a
···11-import { createServer } from "./server.ts";
22-import type { HandlerContext, HandlerSuccess } from "./types.ts";
33-44-// Create a new XRPC server instance
55-const server = createServer();
66-77-// Add a simple query method
88-server.method("com.example.getStatus", {
99- handler: async (_ctx: HandlerContext): Promise<HandlerSuccess> => {
1010- return {
1111- encoding: "application/json",
1212- body: {
1313- status: "ok",
1414- timestamp: new Date().toISOString(),
1515- },
1616- };
1717- },
1818-});
1919-2020-// Add a simple procedure method
2121-server.method("com.example.createPost", {
2222- handler: (ctx: HandlerContext): HandlerSuccess => {
2323- const { input } = ctx;
2424-2525- return {
2626- encoding: "application/json",
2727- body: {
2828- success: true,
2929- message: "Post created successfully",
3030- data: input,
3131- },
3232- };
3333- },
3434-});
3535-3636-// Start the Deno server
3737-console.log("Starting XRPC server on port 8000...");
3838-3939-Deno.serve({
4040- port: 8000,
4141- handler: server.handler,
4242-});
+6-2
xrpc-server/stream/frames.ts
···11-import * as uint8arrays from "uint8arrays";
21import { cborDecodeMulti, cborEncode } from "@atp/common";
32import type {
43 ErrorFrameBody,
···3534 * @returns {Uint8Array} The serialized frame as bytes
3635 */
3736 toBytes(): Uint8Array {
3838- return uint8arrays.concat([cborEncode(this.header), cborEncode(this.body)]);
3737+ const headerBytes = cborEncode(this.header);
3838+ const bodyBytes = cborEncode(this.body);
3939+ const result = new Uint8Array(headerBytes.length + bodyBytes.length);
4040+ result.set(headerBytes, 0);
4141+ result.set(bodyBytes, headerBytes.length);
4242+ return result;
3943 }
40444145 /**
+1-1
xrpc-server/tests/ipld_test.ts
···11-import { CID } from "multiformats/cid";
11+import { CID } from "npm:multiformats/cid";
22import type { LexiconDoc } from "@atproto/lexicon";
33import { XrpcClient } from "@atproto/xrpc";
44import * as xrpcServer from "../mod.ts";