a tiny atproto lexicon validator
1#!/bin/sh
2":" //# ; if command -v bun >/dev/null 2>&1; then exec bun "$0" "$@"; elif command -v deno >/dev/null 2>&1; then exec deno run -A "$0" "$@"; else exec node "$0" "$@"; fi
3
4// This Source Code Form is subject to the terms of the Mozilla Public
5// License, v. 2.0. If a copy of the MPL was not distributed with this
6// file, You can obtain one at https://mozilla.org/MPL/2.0/.
7//
8// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/tinylex
9
10import { writeFile, mkdir } from "node:fs/promises";
11import { dirname, join } from "node:path";
12
13async function resolveTxt(host) {
14 const r = await fetch(
15 `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(host)}&type=TXT`,
16 { headers: { accept: "application/dns-json" } }
17 );
18 if (!r.ok) throw new Error(`doh ${r.status}`);
19 const { Answer = [] } = await r.json();
20 return Answer.filter(a => a.type === 16).map(a => {
21 const chunks = [];
22 const re = /"((?:[^"\\]|\\.)*)"/g;
23 let m;
24 while ((m = re.exec(a.data))) chunks.push(m[1].replace(/\\(.)/g, "$1"));
25 return chunks;
26 });
27}
28
29const argv = typeof Deno !== "undefined" ? Deno.args : process.argv.slice(2);
30const nsids = [];
31let outDir = "lexicons";
32let asJs = false;
33
34for (let i = 0; i < argv.length; i++) {
35 const a = argv[i];
36 if (a === "--out" || a === "-o") outDir = argv[++i];
37 else if (a.startsWith("--out=")) outDir = a.slice(6);
38 else if (a === "--js") asJs = true;
39 else nsids.push(a);
40}
41if (!nsids.length) {
42 console.error("usage: ./install <nsid>... [--out <dir>] [--js]");
43 process.exit(1);
44}
45
46function authorityDomain(nsid) {
47 const segs = nsid.split(".");
48 if (segs.length < 3 || segs.some(s => !/^[a-z][a-z0-9-]*$/i.test(s))) {
49 throw new Error(`invalid nsid: ${nsid}`);
50 }
51 return segs.slice(0, -1).reverse().join(".");
52}
53
54async function resolveDid(nsid) {
55 const host = `_lexicon.${authorityDomain(nsid)}`;
56 const records = await resolveTxt(host);
57 for (const chunks of records) {
58 const m = chunks.join("").match(/^did=(.+)$/);
59 if (m) return m[1];
60 }
61 throw new Error(`no did= TXT at ${host}`);
62}
63
64async function resolvePds(did) {
65 let doc;
66 if (did.startsWith("did:plc:")) {
67 const r = await fetch(`https://plc.directory/${did}`);
68 if (!r.ok) throw new Error(`plc ${r.status}`);
69 doc = await r.json();
70 } else if (did.startsWith("did:web:")) {
71 const host = did.slice(8).replace(/:/g, "/");
72 const r = await fetch(`https://${host}/.well-known/did.json`);
73 if (!r.ok) throw new Error(`did:web ${r.status}`);
74 doc = await r.json();
75 } else {
76 throw new Error(`unsupported did method: ${did}`);
77 }
78 const svc = (doc.service || []).find(
79 s => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"
80 );
81 if (!svc) throw new Error("no atproto_pds service in did doc");
82 return svc.serviceEndpoint.replace(/\/$/, "");
83}
84
85async function fetchLexicon(nsid) {
86 const did = await resolveDid(nsid);
87 const pds = await resolvePds(did);
88 const url = `${pds}/xrpc/com.atproto.repo.getRecord`
89 + `?repo=${encodeURIComponent(did)}`
90 + `&collection=com.atproto.lexicon.schema`
91 + `&rkey=${encodeURIComponent(nsid)}`;
92 const r = await fetch(url);
93 if (!r.ok) throw new Error(`getRecord ${r.status}`);
94 const { value } = await r.json();
95 return value;
96}
97
98let failed = 0;
99for (const nsid of nsids) {
100 try {
101 const lex = await fetchLexicon(nsid);
102 const out = join(outDir, `${nsid}.${asJs ? "js" : "json"}`);
103 const body = asJs
104 ? `export default /** @type {const} */ (${JSON.stringify(lex, null, 2)});\n`
105 : JSON.stringify(lex, null, 2) + "\n";
106 await mkdir(dirname(out), { recursive: true });
107 await writeFile(out, body);
108 console.log(`ok ${nsid} -> ${out}`);
109 } catch (e) {
110 console.error(`fail ${nsid} (${e.message})`);
111 failed++;
112 }
113}
114process.exit(failed ? 1 : 0);