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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
424 lines · 15.5 KB Raw History
/** @param {string|null|undefined} iso @returns {string} */
export function day(iso) {
  return iso ? iso.slice(0, 10) : '';
}
/** @param {string|null|undefined} iso @returns {string} */
export function dateTime(iso) {
  if (!iso) return '';
  const parsed = new Date(iso);
  if (Number.isNaN(parsed.getTime())) return iso;
  return parsed.toLocaleString(undefined, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
  });
}

/** @param {string} scope @returns {string[]} */
export function scopeTokens(scope) {
  return (scope || 'atproto').split(/\s+/).filter(Boolean);
}

/** @param {number|null|undefined} n @returns {string} */
export function num(n) {
  return (n ?? 0).toLocaleString('en-US');
}

/** Human-readable bytes like "1.5 MB". @param {number|null|undefined} n */
export function bytes(n) {
  if (n == null) return '';
  if (n < 1024) return `${n} B`;
  const units = ['KB', 'MB', 'GB', 'TB'];
  let value = n;
  let unit = 0;
  do {
    value /= 1024;
    unit += 1;
  } while (value >= 1024 && unit < units.length);
  return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit - 1]}`;
}

/** A short relative time like "12m ago". @param {string|null} iso */
export function timeAgo(iso) {
  if (!iso) return '';
  const seconds = Math.max(
    0,
    Math.floor((Date.now() - Date.parse(iso)) / 1000),
  );
  if (seconds < 60) return 'just now';
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  const days = Math.floor(hours / 24);
  if (days < 30) return `${days}d ago`;
  const months = Math.floor(days / 30);
  if (months < 12) return `${months}mo ago`;
  return `${Math.floor(months / 12)}y ago`;
}

/**
 * The registrable-ish domain behind an NSID, for a favicon lookup: the first
 * two authority segments, reversed. `app.bsky.feed.post` -> `bsky.app`.
 * @param {string} nsid @returns {string}
 */
export function nsidDomain(nsid) {
  const parts = String(nsid).split('.');
  return parts.length < 2 ? String(nsid) : `${parts[1]}.${parts[0]}`;
}

// Tails that say nothing on their own, so the category before them is kept.
const GENERIC_TAIL = new Set([
  'item',
  'items',
  'record',
  'records',
  'entry',
  'entries',
  'object',
  'objects',
  'thing',
  'things',
  'def',
  'definition',
  'decl',
  'declaration',
]);

/**
 * A plain-language noun for a record type, from its NSID, so a person reads
 * "a post" rather than "app.bsky.feed.post". Normally the last segment carries
 * the meaning (`…feed.post` -> "post"); when it is a generic tail the category
 * before it comes along (`social.grain.gallery.item` -> "gallery item"), unless
 * that would only reach back into the authority.
 * @param {string} nsid @returns {string}
 */
export function recordNoun(nsid) {
  const parts = String(nsid).split('.');
  const humanize = (/** @type {string} */ s) =>
    s.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
  const last = parts[parts.length - 1] || String(nsid);
  if (GENERIC_TAIL.has(last.toLowerCase()) && parts.length >= 4) {
    return `${humanize(parts[parts.length - 2])} ${humanize(last)}`;
  }
  return humanize(last);
}

/**
 * "a" or "an" to fit the word that follows.
 * @param {string} word @returns {string}
 */
export function article(word) {
  return /^[aeiou]/i.test(word) ? 'an' : 'a';
}

/**
 * Naive English plural for a record noun: "gallery" -> "galleries",
 * "photo" -> "photos", "gallery item" -> "gallery items".
 * @param {string} noun @returns {string}
 */
export function pluralNoun(noun) {
  if (/[^aeiou]y$/.test(noun)) return `${noun.slice(0, -1)}ies`;
  if (/(s|x|z|ch|sh)$/.test(noun)) return `${noun}es`;
  return `${noun}s`;
}

/**
 * Pick the variant of a translated string that best fits the reader's
 * languages: exact locale, then bare language, then any region of the same
 * language, then the untagged base (English by lexicon convention).
 * @param {string|null} base
 * @param {Record<string, string>} [byLang]
 * @returns {string|null}
 */
export function pickLangString(base, byLang = {}) {
  const locales =
    typeof navigator === 'undefined' ? [] : navigator.languages || [];
  for (const locale of locales) {
    if (byLang[locale]) return byLang[locale];
    const lang = locale.split('-')[0];
    if (byLang[lang]) return byLang[lang];
    // The untagged base is English by lexicon convention, so it is the
    // English candidate at English's position in the reader's order. Without
    // this, a translation in any later-preferred language would beat it.
    if (lang === 'en' && base != null) return base;
    const sameLang = Object.keys(byLang).find(
      (key) => key.split('-')[0] === lang,
    );
    if (sameLang) return byLang[sameLang];
  }
  return base;
}

/**
 * Fold a repo's collections into app-like buckets keyed by their authority
 * domain, summing record counts and keeping each collection under its app.
 * Built straight from /overview's collections. Sorted by record count, most
 * first, and so are the collections within each bucket.
 * @param {{name: string, count: number}[]} collections
 * @returns {{domain: string, count: number, collections: {name: string, count: number}[]}[]}
 */
export function groupByApp(collections) {
  /** @type {Map<string, {domain: string, count: number, collections: {name: string, count: number}[]}>} */
  const map = new Map();
  for (const c of collections || []) {
    const domain = nsidDomain(c.name);
    const group = map.get(domain) || { domain, count: 0, collections: [] };
    group.count += c.count;
    group.collections.push(c);
    map.set(domain, group);
  }
  for (const group of map.values()) {
    group.collections.sort((a, b) => b.count - a.count);
  }
  return [...map.values()].sort((a, b) => b.count - a.count);
}

/**
 * Fold an overview's per-collection creation histogram into one series for an
 * app, by summing every collection under the app's domain. `recordActivity` is
 * the `recordActivity` block from /overview: `{ buckets, series }`, where each
 * series is bucket counts oldest to newest across a fixed window the server
 * fixed. Returns the app's summed counts, or null when it had no records
 * created in the window (so the row can skip the sparkline entirely). A row
 * that absorbed other domains' records passes them as `folded` so their
 * activity counts too.
 * @param {{buckets?: number, series?: Record<string, number[]>}|null|undefined} recordActivity
 * @param {string} domain
 * @param {string[]} [folded]
 * @returns {number[]|null}
 */
export function appActivitySeries(recordActivity, domain, folded = []) {
  const series = recordActivity?.series;
  const buckets = recordActivity?.buckets || 0;
  if (!series || !buckets) return null;
  let out = null;
  for (const [name, counts] of Object.entries(series)) {
    const owner = nsidDomain(name);
    if (owner !== domain && !folded.includes(owner)) continue;
    if (!out) out = new Array(buckets).fill(0);
    for (let i = 0; i < buckets; i++) out[i] += counts[i] || 0;
  }
  return out;
}

/** The host an OAuth client is identified by, for its name and favicon. */
/** @param {string} clientId @returns {string} */
export function clientHost(clientId) {
  try {
    return new URL(clientId).host;
  } catch {
    return clientId;
  }
}

/**
 * The NSIDs a session's granular scope tokens name: `repo:sh.tangled.repo`
 * and `rpc:sh.tangled.ci.triggerPipeline?aud=*` both name an `sh.tangled.*`
 * NSID, and the query forms `repo?collection=…` and `rpc?lxm=…` count the
 * same way. A generic grant (`atproto`, `transition:generic`) names none,
 * and a bare wildcard says nothing about an authority, so neither
 * contributes.
 * @param {string} scope @returns {string[]}
 */
export function scopeNsids(scope) {
  /** @type {string[]} */
  const out = [];
  const keep = (/** @type {string} */ raw) => {
    const nsid = raw.replace(/\.\*$/, '');
    if (nsid !== '*' && nsid.includes('.')) out.push(nsid);
  };
  for (const token of scopeTokens(scope)) {
    const path = /^(?:repo|rpc):([^?]+)/.exec(token);
    if (path) {
      keep(decodeURIComponent(path[1]));
      continue;
    }
    const query = /^(?:repo|rpc)\?(.+)$/.exec(token);
    if (!query) continue;
    const params = new URLSearchParams(query[1]);
    for (const name of [
      ...params.getAll('collection'),
      ...params.getAll('lxm'),
    ]) {
      keep(name);
    }
  }
  return out;
}

/**
 * The name an app goes by once its TLD is dropped — `tangled.sh` and
 * `tangled.org` both read "tangled". Approximate on purpose (no public-suffix
 * list): the segment left of the last one, which is where registrable app
 * domains carry their name.
 * @param {string} domain @returns {string}
 */
function appLabel(domain) {
  const parts = String(domain).toLowerCase().split('.');
  return parts.length >= 2 ? parts[parts.length - 2] : (parts[0] ?? '');
}

/**
 * Pairs of Apps rows that look like one app split by a rebrand: it signs in
 * from its new domain while its records keep the NSID authority of the old
 * one, so one row holds only sessions and the other only records. The
 * signature is deliberately narrow — that complement plus a shared name
 * label — so a look-alike domain the user once authorized is never folded
 * into a trusted row on its own say-so. A session that announces granular
 * scopes must actually cover the record row's authority, or the pair is
 * dropped: a third-party client writing another app's lexicons is not that
 * app. Pairs already combined or turned down are skipped. Each offer folds
 * `from` into `to`, the name the app is known by: a client that is just a
 * subdomain of the record authority folds into the apex (api.currents.is
 * into currents.is), while a rebrand keeps the live client host on top
 * (tangled.sh folds into tangled.org).
 * @param {{domain: string, count: number, sessions: {scope?: string}[]}[]} apps
 * @param {{aliases?: Record<string, string>, dismissedPairs?: string[]}} [prefs]
 * @returns {{from: string, to: string}[]}
 */
export function combineOffers(apps, prefs = {}) {
  const dismissed = new Set(prefs.dismissedPairs || []);
  const aliased = new Set(Object.keys(prefs.aliases || {}));
  const sessionOnly = apps.filter((a) => a.sessions.length > 0 && !a.count);
  const recordOnly = apps.filter((a) => a.count > 0 && !a.sessions.length);
  const offers = [];
  for (const sessionRow of sessionOnly) {
    for (const recordRow of recordOnly) {
      if (appLabel(recordRow.domain) !== appLabel(sessionRow.domain)) continue;
      const named = sessionRow.sessions.flatMap((s) =>
        scopeNsids(s.scope || ''),
      );
      if (
        named.length &&
        !named.some((n) => nsidDomain(n) === recordRow.domain)
      ) {
        continue;
      }
      const offer = sessionRow.domain.endsWith(`.${recordRow.domain}`)
        ? { from: sessionRow.domain, to: recordRow.domain }
        : { from: recordRow.domain, to: sessionRow.domain };
      if (aliased.has(offer.from)) continue;
      if (dismissed.has(`${offer.from}>${offer.to}`)) continue;
      offers.push(offer);
    }
  }
  return offers;
}

/**
 * One row per app, keyed by domain, joining what an app has stored (its
 * collections, from /overview) with the access it holds (its OAuth sessions,
 * from /apps). One app can hold several sessions at once, one per device or
 * reauthorization, so they are collected rather than overwritten. An app can
 * appear with data but no session, or the reverse. Rows sort by when the app
 * was last active — the newest of its session accesses, its record writes and
 * its writes to the spaces it manages — and fall back to record count when no
 * side has a time.
 * Rows the user combined (`aliases`, from-domain to to-domain) fold together:
 * the from row's records move under the to row, which remembers the fold in
 * `folded` so screens can say where its records really live.
 * @param {{name: string, count: number, lastRecordAt?: number|null}[]} collections
 * @param {{clientId: string, scope: string, authorizedAt: string|null, lastAccessedAt: string|null, sessionId: string|null}[]} [oauthApps]
 * @param {Record<string, string>} [aliases]
 * @param {{spaceType: string, collections?: {collection: string, lastRecordAt?: number|null}[]}[]} [spaces]
 * @returns {{domain: string, count: number, collections: {name: string, count: number, lastRecordAt?: number|null}[], sessions: object[], spaces: object[], folded?: string[]}[]}
 */
export function mergeApps(
  collections,
  oauthApps = [],
  aliases = {},
  spaces = [],
) {
  const map = new Map();
  for (const group of groupByApp(collections)) {
    map.set(group.domain, { ...group, sessions: [] });
  }
  for (const app of oauthApps) {
    const domain = clientHost(app.clientId);
    const entry = map.get(domain) || {
      domain,
      count: 0,
      collections: [],
      sessions: [],
    };
    entry.sessions.push(app);
    map.set(domain, entry);
  }
  for (const [from, to] of Object.entries(aliases || {})) {
    const source = map.get(from);
    if (!source || from === to) continue;
    const target = map.get(to) || {
      domain: to,
      count: 0,
      collections: [],
      sessions: [],
    };
    target.count += source.count;
    target.collections = [...target.collections, ...source.collections].sort(
      (a, b) => b.count - a.count,
    );
    target.sessions = [...target.sessions, ...source.sessions];
    target.folded = [...(target.folded || []), from];
    map.delete(from);
    map.set(to, target);
  }
  const rows = [...map.values()];
  for (const row of rows) {
    row.spaces = spacesForApp(spaces, [row.domain, ...(row.folded || [])]);
  }
  return rows.sort(
    (a, b) =>
      (Date.parse(lastActiveAt(b) ?? '') || 0) -
        (Date.parse(lastActiveAt(a) ?? '') || 0) || b.count - a.count,
  );
}

/**
 * The spaces one app manages, out of every space the account holds: a space
 * belongs to the app whose lexicon defines its type, or one of its permissioned
 * collections.
 * @template {{spaceType: string, collections?: {collection: string}[]}} T
 * @param {T[]|null|undefined} spaces
 * @param {string[]} domains - the app's domain, and any folded into it
 * @returns {T[]}
 */
export function spacesForApp(spaces, domains) {
  return (spaces || []).filter(
    (space) =>
      domains.includes(nsidDomain(space.spaceType)) ||
      (space.collections || []).some((collection) =>
        domains.includes(nsidDomain(collection.collection)),
      ),
  );
}

/**
 * The most recent time an app touched the account, as an ISO string, or null
 * when nothing is known: the newest of its OAuth sessions' access times, its
 * collections' latest record writes, and the latest writes to the spaces it
 * manages. So a data-only app reads as active by when it last wrote, not only
 * apps that hold a session, and an app that keeps its data in a shared space
 * reads as active by that. Used both to order the Apps list and to label each
 * row.
 * @param {{sessions?: {lastAccessedAt?: string|null}[], collections?: {lastRecordAt?: number|null}[], spaces?: {collections?: {lastRecordAt?: number|null}[]}[]}} app
 * @returns {string|null}
 */
export function lastActiveAt(app) {
  let newestMs = 0;
  for (const session of app.sessions || []) {
    const at = Date.parse(session.lastAccessedAt ?? '');
    if (at > newestMs) newestMs = at;
  }
  const collections = [
    ...(app.collections || []),
    ...(app.spaces || []).flatMap((space) => space.collections || []),
  ];
  for (const collection of collections) {
    const at = collection.lastRecordAt;
    if (typeof at === 'number' && at > newestMs) newestMs = at;
  }
  return newestMs ? new Date(newestMs).toISOString() : null;
}