Something went wrong. Try again.
a tiny atproto lexicon validator
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637// This Source Code Form is subject to the terms of the Mozilla Public// License, v. 2.0. If a copy of the MPL was not distributed with this// file, You can obtain one at https://mozilla.org/MPL/2.0/.//// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/tinylex
// ---------------------------------------------------------------------------// String format names (see FORMATS validators below)// ---------------------------------------------------------------------------
/** * @typedef {"at-identifier" | "at-uri" | "cid" | "datetime" | "did" | "handle" * | "nsid" | "tid" | "record-key" | "uri" | "language"} FormatName */
// ---------------------------------------------------------------------------// Structural atproto types// ---------------------------------------------------------------------------
/** @typedef {{ readonly $bytes: string }} Bytes *//** @typedef {{ readonly $link: string }} CidLink *//** * @typedef {{ * readonly $type: "blob"; * readonly mimeType: string; * readonly size: number; * readonly ref: CidLink; * }} Blob */
// ---------------------------------------------------------------------------// Standard Schema v1 result types// ---------------------------------------------------------------------------
/** * @typedef {{ * readonly message: string; * readonly path?: ReadonlyArray<{ readonly key: string | number }>; * }} Issue */
/** * @template T * @typedef {{ readonly value: T; readonly issues?: undefined } | { readonly issues: ReadonlyArray<Issue> }} Result */
/** * @template T * @typedef {{ * readonly "~standard": { * readonly version: 1; * readonly vendor: "tinylex"; * readonly validate: (value: unknown) => Result<T>; * readonly types?: { readonly input: unknown; readonly output: T }; * }; * }} StandardSchema */
// ---------------------------------------------------------------------------// Flat catalog map: "nsid#defName" | "nsid" (alias for "nsid#main") -> type// ---------------------------------------------------------------------------
/** @typedef {Record<string, unknown>} CatalogMap */
// ---------------------------------------------------------------------------// Ref resolution — direct key lookup into flat map// ---------------------------------------------------------------------------
/** * @template {string} Ref * @template {CatalogMap} Catalog * @template {string} CurrentNsid * @typedef {Ref extends `#${infer Name}` * ? `${CurrentNsid}#${Name}` extends keyof Catalog ? Catalog[`${CurrentNsid}#${Name}`] : unknown * : Ref extends `${string}#${string}` * ? Ref extends keyof Catalog ? Catalog[Ref] : unknown * : `${Ref}#main` extends keyof Catalog * ? Catalog[`${Ref}#main`] * : Ref extends keyof Catalog ? Catalog[Ref] : unknown * } ResolveRef */
// ---------------------------------------------------------------------------// Core type inference// ---------------------------------------------------------------------------
/** * @template Def * @typedef {Def extends { const: infer C extends boolean } ? C : boolean} InferBoolean */
/** * @template Def * @typedef {Def extends { const: infer C extends number } ? C * : Def extends { enum: ReadonlyArray<infer E extends number> } ? E * : number * } InferInteger */
/** * @template Def * @typedef {Def extends { const: infer C extends string } ? C * : Def extends { enum: ReadonlyArray<infer E extends string> } ? E * : string * } InferString */
/** * @template Def * @template {CatalogMap} Catalog * @template {string} CurrentNsid * @typedef {Def extends { type: "null" } ? null * : Def extends { type: "boolean" } ? InferBoolean<Def> * : Def extends { type: "integer" } ? InferInteger<Def> * : Def extends { type: "string" } ? InferString<Def> * : Def extends { type: "bytes" } ? Bytes * : Def extends { type: "cid-link" } ? CidLink * : Def extends { type: "blob" } ? Blob * : Def extends { type: "array"; items: infer Items } ? Infer<Items, Catalog, CurrentNsid>[] * : Def extends { type: "object" | "params" } ? InferObject<Def, Catalog, CurrentNsid> * : Def extends { type: "record"; record: infer R } ? InferObject<R, Catalog, CurrentNsid> & { readonly $type: CurrentNsid } * : Def extends { type: "ref"; ref: infer R extends string } ? ResolveRef<R, Catalog, CurrentNsid> * : Def extends { type: "union"; refs: ReadonlyArray<infer R extends string> } ? ResolveRef<R, Catalog, CurrentNsid> * : Def extends { type: "unknown" } ? Record<string, unknown> * : unknown * } Infer */
// ---------------------------------------------------------------------------// Object inference// ---------------------------------------------------------------------------
/** * @template Props * @template Req * @typedef {Req extends ReadonlyArray<string> ? Extract<Req[number], keyof Props> : never} RequiredKeys */
/** * @template Props * @template Req * @typedef {Req extends ReadonlyArray<string> ? Exclude<keyof Props, Req[number]> : keyof Props} OptionalKeys */
/** * @template Nul * @typedef {Nul extends ReadonlyArray<string> ? Nul[number] : never} NullableKeys */
/** * @template K * @template Props * @template Nul * @template {CatalogMap} Catalog * @template {string} CurrentNsid * @typedef {K extends keyof Props * ? K extends NullableKeys<Nul> * ? Infer<Props[K], Catalog, CurrentNsid> | null * : Infer<Props[K], Catalog, CurrentNsid> * : never * } InferField */
/** * @template Def * @template {CatalogMap} Catalog * @template {string} CurrentNsid * @typedef {Def extends { properties: infer Props; required?: infer Req; nullable?: infer Nul } * ? { readonly [K in RequiredKeys<Props, Req>]: InferField<K, Props, Nul, Catalog, CurrentNsid> } * & { readonly [K in OptionalKeys<Props, Req>]?: InferField<K, Props, Nul, Catalog, CurrentNsid> } * : Record<string, unknown> * } InferObject */
// ---------------------------------------------------------------------------// Flatten a lexicon file's defs into the catalog's flat map format:// "nsid#defName" -> InferredType// "nsid" -> InferredType (alias for #main, if present)// ---------------------------------------------------------------------------
/** * @template {string} Nsid * @template Defs * @template {CatalogMap} Catalog * @typedef {{ readonly [K in keyof Defs as `${Nsid}#${string & K}`]: Infer<Defs[K], Catalog, Nsid> } * & ("main" extends keyof Defs ? { readonly [N in Nsid]: Infer<Defs["main"], Catalog, Nsid> } : {}) * } FlattenDefs */
// ---------------------------------------------------------------------------// Internal runtime types// ---------------------------------------------------------------------------
/** @typedef {any} SchemaDef */
/** * @typedef {Object} ValidateOptions * @property {(ref: string, currentNsid?: string) => { nsid: string, def: SchemaDef }} [resolveRef] * @property {string} [nsid] */
// ---------------------------------------------------------------------------// String format validators// ---------------------------------------------------------------------------
/** Module-level helpers — shared by FORMATS entries and other validators. */const isDid = (/** @type {string} */ v) => /^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/.test(v);
const isHandle = (/** @type {string} */ v) => v.length <= 253 && /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(v);
const isCid = (/** @type {string} */ v) => /^[a-zA-Z][a-zA-Z0-9+/=_-]+$/.test(v);
/** @type {Record<FormatName, (v: string) => boolean>} */const FORMATS = { did: isDid, handle: isHandle, "at-identifier": (v) => isDid(v) || isHandle(v), cid: isCid, nsid: (v) => v.length <= 317 && /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z0-9-]+){1,}\.[a-zA-Z][a-zA-Z0-9]*$/.test(v), "at-uri": (v) => /^at:\/\/[a-zA-Z0-9._:%-]+(\/[a-zA-Z0-9._~:@!$&'()*+,;=%-]*)*(\/[a-zA-Z0-9._~:@!$&'()*+,;=%-]*)?$/.test( v, ), datetime: (v) => /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test( v, ) && !v.endsWith("-00:00"), tid: (v) => /^[2-7a-z]{13}$/.test(v), "record-key": (v) => v.length > 0 && /^[a-zA-Z0-9_~.:-]{1,512}$/.test(v), uri: (v) => v.length <= 8192 && /^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(v), language: (v) => /^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$/.test(v),};
// ---------------------------------------------------------------------------// Internal helpers// ---------------------------------------------------------------------------
const TEXT_ENCODER = new TextEncoder();const SEGMENTER = typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter() : null;
/** @typedef {ReadonlyArray<{ key: string | number }>} Path */
/** * @param {Path} path * @param {string | number} key * @returns {Path} */const push = (path, key) => [...path, { key }];
/** * @param {string} message * @param {Path} path * @returns {{ issues: ReadonlyArray<Issue> }} */const fail = (message, path) => ({ issues: [{ message, ...(path.length ? { path } : {}) }] });
/** @param {unknown} x */const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
/** * Bound check helper — returns a failure when `value` falls outside [min, max], * or null when in range (or both bounds are undefined). The min/max labels are * passed explicitly so callers can match the lexicon spec's exact key names * (e.g. "minimum"/"maximum" vs "minLength"/"maxLength"). * * @param {number} value * @param {number | undefined} min * @param {number | undefined} max * @param {string} minLabel Name of the min bound (e.g. "minLength"). * @param {string} maxLabel Name of the max bound (e.g. "maxLength"). * @param {string} prefix Prefix for the error message (e.g. "Byte length", "Value"). * @param {Path} path * @returns {{ issues: ReadonlyArray<Issue> } | null} */function checkRange(value, min, max, minLabel, maxLabel, prefix, path) { if (min !== undefined && value < min) return fail(`${prefix} ${value} < ${minLabel} ${min}`, path); if (max !== undefined && value > max) return fail(`${prefix} ${value} > ${maxLabel} ${max}`, path); return null;}
// ---------------------------------------------------------------------------// Lexicon catalog — immutable builder// ---------------------------------------------------------------------------
/** * @template {CatalogMap} [Catalog={}] */export default class TinyLex { #files = new Map();
/** * @template {string} const Nsid * @template {Record<string, unknown>} const Defs * @param {{ readonly id: Nsid; readonly defs: Defs; readonly [k: string]: unknown }} file * @returns {TinyLex<Catalog & FlattenDefs<Nsid, Defs, Catalog & FlattenDefs<Nsid, Defs, Catalog>>>} */ add(file) { this.#files.set(file.id, file); return /** @type {any} */ (this); }
/** * @param {string} ref * @param {string} [currentNsid] * @returns {{ nsid: string, def: SchemaDef }} */ #resolve(ref, currentNsid) { let nsid, name; if (ref.startsWith("#")) { if (!currentNsid) throw new Error(`Cannot resolve local ref "${ref}" without a current NSID`); nsid = currentNsid; name = ref.slice(1); } else { const hash = ref.indexOf("#"); nsid = hash === -1 ? ref : ref.slice(0, hash); name = hash === -1 ? "main" : ref.slice(hash + 1); } const file = this.#files.get(nsid); if (!file) throw new Error(`Lexicon not found in catalog: "${nsid}"`); const def = file.defs[name]; if (!def) throw new Error(`Definition "${name}" not found in "${nsid}"`); return { nsid, def }; }
/** * Create a Standard Schema v1 schema for a ref in this catalog. * https://standardschema.dev * * @template {string} Ref * @param {Ref} ref * @returns {StandardSchema<ResolveRef<Ref, Catalog, never>>} */ schema(ref) { const { def, nsid } = this.#resolve(ref); const resolveRef = (/** @type {string} */ r, /** @type {string | undefined} */ n) => this.#resolve(r, n); return { "~standard": { version: 1, vendor: "tinylex", validate: (value) => /** @type {Result<ResolveRef<Ref, Catalog, never>>} */ ( validateType(value, def, { resolveRef, nsid }, []) ), }, }; }
/** @returns {string[]} */ nsids() { return [...this.#files.keys()]; }}
// ---------------------------------------------------------------------------// Internal type dispatch — not exported// ---------------------------------------------------------------------------
/** @typedef {(data: any, def: any, opts: ValidateOptions, path: Path) => Result<unknown>} Validator */
/** * @param {unknown} data * @param {SchemaDef} def * @param {ValidateOptions} opts * @param {Path} path * @returns {Result<unknown>} */function validateType(data, def, opts, path) { const fn = VALIDATORS[def.type]; return fn ? fn(data, def, opts, path) : fail(`Unknown schema type: "${def.type}"`, path);}
/** * Shared const/enum check. Returns failure on mismatch, null on success. * @param {any} data * @param {any} def * @param {Path} path * @returns {{ issues: ReadonlyArray<Issue> } | null} */function checkConstEnum(data, def, path) { if (def.const !== undefined && data !== def.const) return fail(`Expected const ${JSON.stringify(def.const)}`, path); if (def.enum !== undefined && !def.enum.includes(data)) return fail(`${JSON.stringify(data)} not in enum ${JSON.stringify(def.enum)}`, path); return null;}
/** * Canonicalize a union ref to its absolute "nsid#name" form. * @param {string} ref * @param {string | undefined} currentNsid * @returns {string} */function canonicalizeRef(ref, currentNsid) { if (ref.startsWith("#")) return `${currentNsid ?? ""}${ref}`; if (ref.includes("#")) return ref; return `${ref}#main`;}
/** @type {Validator} */const validateNull = (data, _def, _opts, path) => data === null ? { value: data } : fail("Expected null", path);
/** @type {Validator} */function validateBoolean(data, def, _opts, path) { if (typeof data !== "boolean") return fail(`Expected boolean, got ${typeof data}`, path); return checkConstEnum(data, def, path) ?? { value: data };}
/** @type {Validator} */function validateInteger(data, def, _opts, path) { if (typeof data !== "number" || !Number.isInteger(data)) return fail(`Expected integer, got ${typeof data}`, path); return ( checkConstEnum(data, def, path) ?? checkRange(data, def.minimum, def.maximum, "minimum", "maximum", "Value", path) ?? { value: data, } );}
/** @type {Validator} */function validateString(data, def, _opts, path) { if (typeof data !== "string") return fail(`Expected string, got ${typeof data}`, path); const ce = checkConstEnum(data, def, path); if (ce) return ce; const byteLen = TEXT_ENCODER.encode(data).length; const lenFail = checkRange( byteLen, def.minLength, def.maxLength, "minLength", "maxLength", "Byte length", path, ); if (lenFail) return lenFail; if (def.minGraphemes !== undefined || def.maxGraphemes !== undefined) { const count = SEGMENTER ? [...SEGMENTER.segment(data)].length : [...data].length; const gFail = checkRange( count, def.minGraphemes, def.maxGraphemes, "minGraphemes", "maxGraphemes", "Grapheme count", path, ); if (gFail) return gFail; } if (def.format !== undefined) { const fn = FORMATS[/** @type {FormatName} */ (def.format)]; if (!fn) return fail(`Unknown string format: "${def.format}"`, path); if (!fn(data)) return fail(`Does not match format "${def.format}": "${data}"`, path); } return { value: data };}
/** @type {Validator} */function validateBytes(data, _def, _opts, path) { if (!isPlainObject(data) || typeof data.$bytes !== "string") return fail(`Expected {$bytes: "<base64>"}, got ${JSON.stringify(data)}`, path); try { atob(data.$bytes); } catch { return fail("Invalid base64 in $bytes", path); } return { value: data };}
/** @type {Validator} */function validateCidLink(data, _def, _opts, path) { if (!isPlainObject(data) || typeof data.$link !== "string") return fail(`Expected {$link: "<cid>"}, got ${JSON.stringify(data)}`, path); if (!isCid(data.$link)) return fail(`Invalid CID: "${data.$link}"`, path); return { value: data };}
/** @type {Validator} */function validateBlob(data, def, _opts, path) { if (!data || typeof data !== "object") return fail(`Expected blob object`, path); if (data.$type !== "blob") return fail(`Blob must have $type "blob", got "${data.$type}"`, path); if (typeof data.mimeType !== "string") return fail(`Blob must have string "mimeType"`, path); if (def.accept?.length) { const ok = def.accept.some((/** @type {string} */ p) => { if (p === "*/*") return true; if (p.endsWith("/*")) return data.mimeType.startsWith(p.slice(0, -1)); return p === data.mimeType; }); if (!ok) return fail(`mimeType "${data.mimeType}" not in [${def.accept.join(", ")}]`, path); } if (def.maxSize !== undefined) { if (typeof data.size !== "number") return fail(`Blob must have numeric "size"`, path); if (data.size > def.maxSize) return fail(`Blob size ${data.size} > maxSize ${def.maxSize}`, path); } return { value: data };}
/** @type {Validator} */function validateArray(data, def, opts, path) { if (!Array.isArray(data)) return fail(`Expected array, got ${typeof data}`, path); const lenFail = checkRange( data.length, def.minLength, def.maxLength, "minLength", "maxLength", "Length", path, ); if (lenFail) return lenFail; if (!def.items) return fail(`Array schema missing "items"`, path); const issues = data.flatMap( (item, i) => validateType(item, def.items, opts, push(path, i)).issues ?? [], ); return issues.length ? { issues } : { value: data };}
/** * Handles both `object` and `params` — params skips `nullable` handling. * @type {Validator} */function validateObject(data, def, opts, path) { const isParams = def.type === "params"; if (!isPlainObject(data)) { return fail( isParams ? `Expected params object` : `Expected object, got ${Array.isArray(data) ? "array" : typeof data}`, path, ); } const required = new Set(def.required ?? []); const nullable = isParams ? null : new Set(def.nullable ?? []); const noun = isParams ? "param" : "field"; const issues = []; for (const field of required) if (!(field in data)) issues.push({ message: `Missing required ${noun} "${field}"`, path }); for (const [field, fieldDef] of Object.entries(def.properties ?? {})) { if (!(field in data)) continue; if (nullable && data[field] === null) { if (!nullable.has(field)) issues.push({ message: `Field "${field}" is not nullable`, path: push(path, field) }); continue; } const result = validateType(data[field], fieldDef, opts, push(path, field)); if (result.issues) issues.push(...result.issues); } return issues.length ? { issues } : { value: data };}
/** @type {Validator} */function validateRecord(data, def, opts, path) { if (!isPlainObject(data)) return fail(`Expected object, got ${Array.isArray(data) ? "array" : typeof data}`, path); if (opts.nsid) { if (typeof data.$type !== "string") return fail(`Record must have "$type"`, path); if (data.$type !== opts.nsid && data.$type !== `${opts.nsid}#main`) return fail(`$type "${data.$type}" does not match "${opts.nsid}"`, path); } return validateObject(data, def.record, opts, path);}
/** @type {Validator} */function validateRef(data, def, opts, path) { if (!def.ref) return fail(`ref schema missing "ref"`, path); if (!opts.resolveRef) return fail(`Cannot resolve ref "${def.ref}" without a catalog`, path); try { const { def: resolved, nsid } = opts.resolveRef(def.ref, opts.nsid); return validateType(data, resolved, { ...opts, nsid }, path); } catch (e) { return fail(e instanceof Error ? e.message : String(e), path); }}
/** @type {Validator} */function validateUnion(data, def, opts, path) { if (!isPlainObject(data)) return fail(`Union value must be an object`, path); const refs = def.refs ?? []; const closed = def.closed ?? false; if (closed && refs.length === 0) return fail(`Closed union with empty refs is invalid`, path); if (typeof data.$type !== "string") return fail(`Union value must include "$type"`, path); for (const ref of refs) { const full = canonicalizeRef(ref, opts.nsid); if (full !== data.$type && full !== `${data.$type}#main`) continue; if (opts.resolveRef) { try { const { def: resolved, nsid } = opts.resolveRef(ref, opts.nsid); return validateType(data, resolved, { ...opts, nsid }, path); } catch { /* catalog miss on open union */ } } return { value: data }; } return closed ? fail(`$type "${data.$type}" not in closed union: [${refs.join(", ")}]`, path) : { value: data };}
/** @type {Validator} */function validateUnknown(data, _def, _opts, path) { if (!isPlainObject(data)) return fail(`unknown type must be an object`, path); if ("$link" in data) return fail(`unknown type cannot have $link at top level`, path); if ("$bytes" in data) return fail(`unknown type cannot have $bytes at top level`, path); return { value: data };}
/** @type {Validator} */const validateToken = (_data, _def, _opts, path) => fail("token type has no data representation", path);
/** @type {Record<string, Validator>} */const VALIDATORS = { null: validateNull, boolean: validateBoolean, integer: validateInteger, string: validateString, bytes: validateBytes, "cid-link": validateCidLink, blob: validateBlob, array: validateArray, object: validateObject, params: validateObject, ref: validateRef, union: validateUnion, unknown: validateUnknown, record: validateRecord, token: validateToken,};