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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
175 lines · 6.3 KB Raw History
/**
 * Matching a commit's author against the identities an account claims.
 *
 * A commit names its author as "Name <address>". That is what the author's
 * own git client wrote, and nothing signs it, so it identifies a person only
 * as far as the reader already trusts whoever published the commit. The
 * claims come from a dev.pdsjs.git.identity record, which an account writes
 * about itself in its own repo.
 *
 * Workers-clean: string handling only.
 */

/**
 * @typedef {Object} GitIdent
 * @property {string} name - author name, empty when the ident carries none
 * @property {string} email - author address, empty when the ident carries none
 */

/**
 * @typedef {Object} IdentClaim
 * @property {string} name
 * @property {string} email
 * @property {string} did - whose identity this is, empty for the account
 *   holding the record
 * @property {string} label - what to call an identity with no account
 * @property {string} avatarCid - the face to show for a labelled identity
 */

/**
 * Split a commit ident into its name and address.
 * @param {string} ident - "Name <address>", or a bare name
 * @returns {GitIdent}
 */
export function splitIdent(ident) {
  const text = String(ident ?? '').trim();
  const match = /^(.*?)\s*<([^>]*)>$/.exec(text);
  if (!match) return { name: text, email: '' };
  return { name: match[1].trim(), email: match[2].trim() };
}

/**
 * The people a commit message credits beside its author.
 *
 * Git carries them as `Co-authored-by:` trailers, one per line, in the same
 * "Name <address>" shape the author ident uses. A message may name the same
 * person twice, or name the author again; each identity is returned once, in
 * the order the message first gives it.
 *
 * Only trailers in the message's last paragraph count, which is where git
 * itself looks. A line further up quoting a trailer, in a revert or a quoted
 * patch, credits nobody.
 * @param {string} message - the whole commit message
 * @returns {string[]} idents, as written
 */
export function coAuthorsOf(message) {
  const text = String(message ?? '').trimEnd();
  if (!text) return [];
  const paragraph = text.slice(text.lastIndexOf('\n\n') + 1);
  /** @type {string[]} */
  const found = [];
  const seen = new Set();
  for (const line of paragraph.split('\n')) {
    const match = /^\s*co-authored-by:\s*(.+)$/i.exec(line);
    if (!match) continue;
    const ident = match[1].trim();
    const { name, email } = splitIdent(ident);
    if (!name && !email) continue;
    const key = `${name.toLowerCase()}\u0000${email.toLowerCase()}`;
    if (seen.has(key)) continue;
    seen.add(key);
    found.push(ident);
  }
  return found;
}

/**
 * The claims a dev.pdsjs.git.identity record carries. Anything malformed is
 * dropped rather than failing the read: the record is advisory, and a reader
 * that cannot parse an entry should show the commit's own name instead.
 * @param {unknown} value - a git identity record value
 * @returns {IdentClaim[]}
 */
export function parseIdentityRecord(value) {
  const list = /** @type {{idents?: unknown}} */ (value ?? {}).idents;
  if (!Array.isArray(list)) return [];
  /** @type {IdentClaim[]} */
  const idents = [];
  for (const entry of list) {
    const { email, name, did, label, avatar } =
      /** @type {{email?: unknown, name?: unknown, did?: unknown, label?: unknown, avatar?: {ref?: {$link?: unknown}}}} */ (
        entry ?? {}
      );
    const link = avatar?.ref?.$link;
    const claim = {
      name: typeof name === 'string' ? name.trim() : '',
      email: typeof email === 'string' ? email.trim().toLowerCase() : '',
      did: typeof did === 'string' && did.startsWith('did:') ? did : '',
      label: typeof label === 'string' ? label.trim() : '',
      avatarCid: typeof link === 'string' ? link : '',
    };
    if (claim.email || claim.name) idents.push(claim);
  }
  return idents;
}

/**
 * The claim a commit ident satisfies, or null when none does.
 *
 * An address matches on its own, because it is the part a person controls.
 * A claim carrying only a name matches only when the commit carries no
 * address, so claiming a common name cannot capture a commit that names
 * somebody else's address. An address claim wins over a name claim, since
 * the address is the stronger signal.
 *
 * The claim comes back as the caller's own shape, because a reader that
 * carries more than the record does still wants what it added.
 * @template {IdentClaim} T
 * @param {T[]} claims - from parseIdentityRecord
 * @param {string} ident - the commit's author ident
 * @returns {T|null}
 */
export function matchIdent(claims, ident) {
  const { name, email } = splitIdent(ident);
  const address = email.toLowerCase();
  return (
    claims.find((claim) => claim.email && address && claim.email === address) ??
    claims.find((claim) => !claim.email && !address && claim.name === name) ??
    null
  );
}

/**
 * @param {IdentClaim[]} claims
 * @param {string} ident
 * @returns {boolean}
 */
export function identMatches(claims, ident) {
  return matchIdent(claims, ident) !== null;
}

/**
 * @typedef {Object} CreditedAuthor
 * @property {string} ident - the ident the newest commit writes for them
 * @property {number} commits - how many commits credit them
 */

/**
 * Everyone a run of commits credits, most commits first.
 *
 * A commit credits its author and everyone its message names beside them.
 * One person holds one row, keyed on the address they control, because the
 * name beside that address is whatever their git client wrote that day. An
 * ident with no address has only its name to go on. A commit that names the
 * same person twice credits them once.
 * @param {Array<{author?: string, message?: string}>} commits - newest first
 * @returns {CreditedAuthor[]}
 */
export function creditAuthors(commits) {
  /** @type {Map<string, CreditedAuthor>} */
  const credited = new Map();
  for (const commit of commits ?? []) {
    const counted = new Set();
    for (const ident of [commit.author, ...coAuthorsOf(commit.message ?? '')]) {
      if (typeof ident !== 'string' || !ident) continue;
      const { name, email } = splitIdent(ident);
      const key = (email || name).toLowerCase();
      if (!key || counted.has(key)) continue;
      counted.add(key);
      const row = credited.get(key) ?? { ident, commits: 0 };
      row.commits++;
      credited.set(key, row);
    }
  }
  return [...credited.values()].sort((a, b) => b.commits - a.commits);
}