~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/steps

Shared steps for knotless workflows: Node, npm, Rust lint, coverage tools, SSH keys and waiting for checks

0
main
260 lines · 10.1 KB Raw History
// Publishes a repository's lexicons as atproto resolves them: a TXT record
// `_lexicon.<authority>` naming the account, for each authority
// (`_lexicon.example.com` for `com.example.*`), and in that account a
// com.atproto.lexicon.schema record for each lexicon, keyed by its NSID.
// Only the lexicons whose authority is in the zone are published: the rest
// of the directory (copies of app.bsky.*, say) are other publishers' and
// are left to them.
//
// It adds and updates what is new or changed, and deletes what the
// directory no longer has: a schema of its namespaces, and a `_lexicon`
// record naming the account for an authority none of them uses now. The
// rest of the account and the zone it leaves alone.
//
// It reads each schema as JSON and nothing more, so it publishes any
// lexicon a schema file holds, whatever its types.
//
// Run by the publish-lexicons step, in the checkout, given its inputs:
//
//   INPUT_DID=did:plc:… INPUT_ZONE=example.com node publish-lexicons.mjs
//   INPUT_DID=… INPUT_ZONE=… INPUT_WRITE=true node publish-lexicons.mjs
//
// INPUT_DIR is the directory of schemas, `lexicons` by default. Unless
// INPUT_WRITE is `true` (or `--write` is given), it says what it would
// change and changes nothing.
//
// LEX_PASSWORD, an app password of the account, writes the schemas, and
// CLOUDFLARE_API_TOKEN, with Zone:DNS:Edit on the zone, the DNS. Without
// the token it reads the DNS as resolvers see it, writes the schemas
// anyway, and then fails, naming each record still to make: a schema whose
// authority does not resolve yet is harmless, and one that does is fixed
// without waiting for the DNS.
import { readdir, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";

const COLLECTION = "com.atproto.lexicon.schema";
const {
  INPUT_DID: did,
  INPUT_ZONE: zone,
  INPUT_DIR: dir = "lexicons",
  INPUT_WRITE,
  LEX_PASSWORD,
  CLOUDFLARE_API_TOKEN: cfToken,
} = process.env;
const write = INPUT_WRITE === "true" || process.argv.includes("--write");
if (!did) throw new Error("INPUT_DID names the account to publish into");
if (!zone) throw new Error("INPUT_ZONE names the DNS zone the lexicons' authorities are in");
const root = resolve(dir || "lexicons");

async function* files(at) {
  for (const e of await readdir(at, { withFileTypes: true })) {
    const p = join(at, e.name);
    if (e.isDirectory()) yield* files(p);
    else if (e.name.endsWith(".json")) yield p;
  }
}

// DNS. An NSID's authority is all but its name, reversed into a domain:
// each has a `_lexicon` record of its own, since resolution never walks up.
const authority = (nsid) => nsid.split(".").slice(0, -1).reverse().join(".");
const inZone = (d) => d === zone || d.endsWith(`.${zone}`);

const shipped = new Map();
for await (const p of files(root)) {
  const doc = JSON.parse(await readFile(p, "utf8"));
  if (doc?.lexicon !== 1 || typeof doc.id !== "string") continue;
  if (inZone(authority(doc.id))) shipped.set(doc.id, doc);
}
if (!shipped.size) throw new Error(`no lexicon under ${dir} has an authority in ${zone}`);

async function json(url, init = {}) {
  const res = await fetch(url, init);
  if (!res.ok)
    throw new Error(
      `${init.method ?? "GET"} ${url.split("?")[0]}: ${res.status} ${await res.text()}`,
    );
  return res.json();
}

const domains = [...new Set([...shipped.keys()].map(authority))].sort();
const wanted = `did=${did}`;

// The zone's `_lexicon` TXT records, from Cloudflare with a token, else as
// a resolver sees them now, over DNS-over-HTTPS rather than through what
// this machine has cached.
const cf = (path, init = {}) =>
  json(`https://api.cloudflare.com/client/v4/${path}`, {
    ...init,
    headers: { authorization: `Bearer ${cfToken}`, "content-type": "application/json" },
  });
let zoneId;
const records = []; // { name, content, id? }
if (cfToken) {
  zoneId = (await cf(`zones?${new URLSearchParams({ name: zone })}`)).result[0]?.id;
  if (!zoneId) throw new Error(`the token sees no zone ${zone}`);
  for (let page = 1; ; page++) {
    const { result, result_info } = await cf(
      `zones/${zoneId}/dns_records?type=TXT&per_page=500&page=${page}`,
    );
    for (const r of result)
      if (r.name.startsWith("_lexicon."))
        records.push({ id: r.id, name: r.name, content: r.content.replace(/^"|"$/g, "") });
    if (page >= result_info.total_pages) break;
  }
} else {
  for (const domain of domains) {
    const name = `_lexicon.${domain}`;
    const { Answer = [] } = await json(
      `https://cloudflare-dns.com/dns-query?${new URLSearchParams({ name, type: "TXT" })}`,
      { headers: { accept: "application/dns-json" } },
    );
    // Each string of a record quoted, and a long one split into several.
    for (const a of Answer.filter((a) => a.type === 16))
      records.push({
        name,
        content: [...a.data.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]).join(""),
      });
  }
}
const dnsAdd = [];
const dnsFix = [];
for (const domain of domains) {
  const name = `_lexicon.${domain}`;
  const mine = records.filter((r) => r.name === name && r.content.startsWith("did="));
  if (mine.some((r) => r.content === wanted)) continue;
  if (mine.length) dnsFix.push(...mine);
  else dnsAdd.push(name);
}
// Only records naming this account: another's `_lexicon` in the zone is
// some other publisher's.
const dnsGone = records.filter(
  (r) => r.content === wanted && !domains.includes(r.name.slice("_lexicon.".length)),
);
for (const name of dnsAdd) console.log(`  ${name}  TXT "${wanted}"  new`);
for (const r of dnsFix) console.log(`  ${r.name}  TXT "${wanted}"  updated, from "${r.content}"`);
for (const r of dnsGone) console.log(`  ${r.name}  TXT  removed`);

// Schemas. What the account holds now of the namespaces published from
// here: the first two segments of every NSID shipped (`com.example`).
const { service = [] } = await json(
  did.startsWith("did:web:")
    ? `https://${did.slice(8)}/.well-known/did.json`
    : `https://plc.directory/${did}`,
);
const pds = service.find(
  (s) => s.id === "#atproto_pds" || s.id === `${did}#atproto_pds`,
)?.serviceEndpoint;
if (!pds) throw new Error(`${did} names no PDS`);
const xrpc = (method, { query, body, token } = {}) =>
  json(`${pds}/xrpc/${method}${query ? `?${new URLSearchParams(query)}` : ""}`, {
    method: body ? "POST" : "GET",
    headers: {
      ...(token && { authorization: `Bearer ${token}` }),
      ...(body && { "content-type": "application/json" }),
    },
    body: body && JSON.stringify(body),
  });

const roots = new Set([...shipped.keys()].map((id) => id.split(".").slice(0, 2).join(".")));
const ours = (nsid) => [...roots].some((r) => nsid.startsWith(`${r}.`));
const held = new Map();
let cursor;
do {
  const page = await xrpc("com.atproto.repo.listRecords", {
    query: { repo: did, collection: COLLECTION, limit: "100", ...(cursor && { cursor }) },
  });
  for (const r of page.records) {
    const rkey = r.uri.split("/").pop();
    if (ours(rkey)) held.set(rkey, r.value);
  }
  cursor = page.records.length ? page.cursor : undefined;
} while (cursor);

// JSON with its keys sorted, to compare a record with its file whatever
// order the PDS hands its keys back in.
const canon = (v) =>
  Array.isArray(v)
    ? v.map(canon)
    : v && typeof v === "object"
      ? Object.fromEntries(
          Object.keys(v)
            .filter((k) => k !== "$type" || v.$type !== COLLECTION)
            .sort()
            .map((k) => [k, canon(v[k])]),
        )
      : v;
const same = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b));
const put = [...shipped.keys()]
  .filter((id) => !held.has(id) || !same(held.get(id), shipped.get(id)))
  .sort();
const gone = [...held.keys()].filter((id) => !shipped.has(id)).sort();
for (const id of put) console.log(`  ${id}  ${held.has(id) ? "updated" : "new"}`);
for (const id of gone) console.log(`  ${id}  removed`);

const dnsChanges = dnsAdd.length + dnsFix.length + dnsGone.length;
console.log(
  `DNS in ${zone}: ${dnsChanges} to change. Schemas in ${did} at ${pds}: ` +
    `${put.length} to put, ${gone.length} to delete, ${shipped.size - put.length} unchanged.`,
);
if (!write) {
  console.log("planned only: nothing written");
  process.exit(0);
}
if (!dnsChanges && !put.length && !gone.length) process.exit(0);
if ((put.length || gone.length) && !LEX_PASSWORD)
  throw new Error("LEX_PASSWORD is needed to write the schemas");

// The DNS that names the account first, then the schemas, then what no
// longer resolves: a resolver never meets a record pointing at nothing new.
// Without a token the DNS is left for last, to be made by hand.
const txt = (name) => ({
  type: "TXT",
  name,
  content: `"${wanted}"`,
  ttl: 1,
  comment: "lexicon authority",
});
if (cfToken) {
  for (const name of dnsAdd)
    await cf(`zones/${zoneId}/dns_records`, { method: "POST", body: JSON.stringify(txt(name)) });
  for (const r of dnsFix)
    await cf(`zones/${zoneId}/dns_records/${r.id}`, {
      method: "PUT",
      body: JSON.stringify(txt(r.name)),
    });
}

if (put.length || gone.length) {
  const session = await xrpc("com.atproto.server.createSession", {
    body: { identifier: did, password: LEX_PASSWORD },
  });
  if (session.did !== did) throw new Error(`signed in as ${session.did}, not ${did}`);
  const token = session.accessJwt;
  for (const id of put)
    await xrpc("com.atproto.repo.putRecord", {
      token,
      body: {
        repo: did,
        collection: COLLECTION,
        rkey: id,
        record: { $type: COLLECTION, ...shipped.get(id) },
      },
    });
  for (const id of gone)
    await xrpc("com.atproto.repo.deleteRecord", {
      token,
      body: { repo: did, collection: COLLECTION, rkey: id },
    });
}

if (dnsChanges && !cfToken) {
  console.log(
    `schemas published; the DNS needs CLOUDFLARE_API_TOKEN, or these records made by hand in ${zone}:`,
  );
  for (const name of dnsAdd) console.log(`  ${name}  TXT "${wanted}"`);
  for (const r of dnsFix) console.log(`  ${r.name}  TXT "${wanted}"  (now "${r.content}")`);
  for (const r of dnsGone) console.log(`  ${r.name}  TXT  delete`);
  process.exit(1);
}
for (const r of dnsGone) await cf(`zones/${zoneId}/dns_records/${r.id}`, { method: "DELETE" });
console.log("published");