OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / conformance.js

211 lines · javascript ref:
// conformance.js — does the network actually hold what we think we published?
//
// WHY THIS EXISTS. Both real interop defects found on 2026-07-31 were SILENT SUCCESSES: every local
// surface reported health while the network saw nothing.
//   - 4 of 6 likes carried a strongRef with no `cid`. Valid here, rejected by relay lexicon validation,
//     federated as nothing. Invisible for weeks.
//   - two records were re-encoded by a second writer, stopped hashing to their pointers, and silently
//     dropped out of the signed repo.
// Nothing compared WHAT WE HOLD against WHAT BSKY INDEXES, so nothing could see either. This does.
//
// TWO LAYERS, cheapest first:
//   1. STRUCTURAL (offline, deterministic) — is the record even valid atproto? This is where the
//      strongRef class lives, and it needs no network, so it can run on every deploy.
//   2. NETWORK (a real diff) — for each record we hold, does the network agree it exists? This is the
//      only layer that catches "we signed it and nobody indexed it".
//
// ⛔ ABSENCE FROM BSKY IS NOT PROOF OF A DEFECT. bsky's AppView is eventually consistent, its relay is
// non-archival, and a freshly-signed record legitimately takes time to appear. So a network miss is
// reported as `pending` when the record is recent and `missing` only past a grace window — and even then
// it is a FINDING, not a verdict. Reporting a propagation delay as corruption would be its own silent
// failure, in the opposite direction.
//
// PURE + INJECTED. `doFetch` and `now` are parameters; every check is testable with no network.

const APPVIEW = 'https://public.api.bsky.app'
// How long after createdAt a record may be absent from bsky before we call it missing rather than pending.
const GRACE_MS = 10 * 60 * 1000

// ── layer 1: structural ──────────────────────────────────────────────────────────────────────────

// Fields that MUST be a com.atproto.repo.strongRef ({uri, cid} — both required).
const STRONGREF_PATHS = {
  'app.bsky.feed.like': [['subject']],
  'app.bsky.feed.repost': [['subject']],
  'app.bsky.feed.post': [['reply', 'parent'], ['reply', 'root']]
}
const REQUIRED = {
  'app.bsky.feed.post': ['text', 'createdAt'],
  'app.bsky.feed.like': ['subject', 'createdAt'],
  'app.bsky.feed.repost': ['subject', 'createdAt'],
  'app.bsky.graph.follow': ['subject', 'createdAt']
}

const at = (o, p) => p.reduce((x, k) => (x == null ? x : x[k]), o)

// -> [{ code, field, detail }]. Empty means structurally valid as far as we can tell offline.
export function validateRecord (collection, record) {
  const out = []
  if (!record || typeof record !== 'object') return [{ code: 'not-an-object', field: null }]

  for (const f of REQUIRED[collection] || []) {
    if (record[f] === undefined || record[f] === null) out.push({ code: 'missing-required', field: f })
  }
  // The defect that federated 4 likes as nothing: uri present, cid absent.
  for (const path of STRONGREF_PATHS[collection] || []) {
    const ref = at(record, path)
    if (!ref || typeof ref !== 'object') continue
    const label = path.join('.')
    if (typeof ref.uri !== 'string') out.push({ code: 'strongref-no-uri', field: label })
    else if (!ref.cid) out.push({ code: 'strongref-no-cid', field: label, detail: ref.uri })
  }
  // A reply must carry BOTH sides; one alone is not a valid thread edge.
  if (collection === 'app.bsky.feed.post' && record.reply) {
    if (!record.reply.parent) out.push({ code: 'reply-no-parent', field: 'reply.parent' })
    if (!record.reply.root) out.push({ code: 'reply-no-root', field: 'reply.root' })
  }
  // langs is not required by lexicon, but a post without it is dropped by some custom feeds — a real
  // observed loss (bsky feed drops langs-less posts), so it is reported as a WARNING, not an error.
  if (collection === 'app.bsky.feed.post' && !Array.isArray(record.langs)) {
    out.push({ code: 'no-langs', field: 'langs', warning: true })
  }
  return out
}

// ── layer 2: the network diff ────────────────────────────────────────────────────────────────────

async function getJson (doFetch, url) {
  try {
    const res = await doFetch(url, { method: 'GET', headers: { accept: 'application/json' }, timeout: 10000 })
    if (!res || res.ok === false) return null
    return await res.json()
  } catch { return null }
}

// ⚠ GRACE IS KEYED ON createdAt, WHICH IS WRONG FOR A REPUBLISHED RECORD. A record REPAIRED today
// (repair-strongrefs rewrites it under its original rkey and createdAt) is freshly re-signed but reads
// as old, so it is classified `missing` while it is genuinely still propagating. Observed immediately:
// the 4 likes repaired on 2026-07-31 reported `missing` seconds after being rewritten. Pass
// `republishedAfter` (a timestamp) to treat anything re-signed since then as pending regardless of
// createdAt. Without it, read `missing` on a recently-repaired record as "check again later", not as a
// defect — the whole point of this file is to not report propagation as corruption.
const recent = (record, now, graceMs, republishedAfter = null) => {
  if (republishedAfter && now - republishedAfter < graceMs) return true
  const t = Date.parse((record && record.createdAt) || '')
  return Number.isFinite(t) && (now - t) < graceMs
}

// Does bsky's AppView hold this post? Absence is meaningful only outside the grace window.
export async function probePost (doFetch, did, rkey, record, { appview = APPVIEW, now = Date.now(), graceMs = GRACE_MS, republishedAfter = null } = {}) {
  const uri = `at://${did}/app.bsky.feed.post/${rkey}`
  const body = await getJson(doFetch, `${appview}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=0`)
  const found = !!(body && body.thread && body.thread.post)
  if (found) return { state: 'ok' }
  return { state: recent(record, now, graceMs, republishedAfter) ? 'pending' : 'missing', uri }
}

// Our follows, as bsky sees them. ONE call for the whole collection rather than one per record.
export async function networkFollows (doFetch, did, { appview = APPVIEW, limit = 100 } = {}) {
  const body = await getJson(doFetch, `${appview}/xrpc/app.bsky.graph.getFollows?actor=${encodeURIComponent(did)}&limit=${limit}`)
  if (!body || !Array.isArray(body.follows)) return null
  return new Set(body.follows.map((f) => f && f.did).filter(Boolean))
}

// Does bsky record US as having liked this subject? One call per SUBJECT, so likes are grouped first.
export async function likedBy (doFetch, subjectUri, did, { appview = APPVIEW, limit = 100 } = {}) {
  const body = await getJson(doFetch, `${appview}/xrpc/app.bsky.feed.getLikes?uri=${encodeURIComponent(subjectUri)}&limit=${limit}`)
  if (!body || !Array.isArray(body.likes)) return null
  return body.likes.some((l) => l && l.actor && l.actor.did === did)
}

// ── the check ────────────────────────────────────────────────────────────────────────────────────
//
// listRecords(collection) -> [{ rkey, record }]
// Returns { structural, network, summary } — findings, never a pass/fail verdict.
export async function checkConformance ({
  did,
  listRecords,
  doFetch = null, // omit to run the offline structural layer only
  collections = ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.feed.repost', 'app.bsky.graph.follow'],
  appview = APPVIEW,
  now = Date.now(),
  graceMs = GRACE_MS,
  maxProbes = 40, // bound the network layer; a full repo is not worth probing every run
  republishedAfter = null, // ms timestamp: records re-signed since then count as pending (see `recent`)
  onEvent = null
} = {}) {
  if (!did) throw new Error('checkConformance requires did')
  if (typeof listRecords !== 'function') throw new Error('checkConformance requires listRecords')
  const emit = (e, d) => { if (onEvent) onEvent(e, d) }

  const structural = []
  const network = []
  const byCollection = {}
  let checked = 0

  for (const collection of collections) {
    let recs = []
    try { recs = (await listRecords(collection)) || [] } catch (err) { emit('list-failed', { collection, error: err.message }); continue }
    byCollection[collection] = recs.length
    for (const { rkey, record } of recs) {
      checked++
      for (const p of validateRecord(collection, record)) {
        structural.push({ collection, rkey, ...p })
      }
    }
  }

  if (doFetch) {
    // posts — bounded, newest-last so a big repo still checks the interesting tail
    const posts = (await listRecords('app.bsky.feed.post').catch(() => [])) || []
    for (const { rkey, record } of posts.slice(-maxProbes)) {
      const r = await probePost(doFetch, did, rkey, record, { appview, now, graceMs, republishedAfter })
      if (r.state !== 'ok') network.push({ collection: 'app.bsky.feed.post', rkey, ...r })
    }

    // follows — one call, set difference
    const follows = (await listRecords('app.bsky.graph.follow').catch(() => [])) || []
    if (follows.length) {
      const net = await networkFollows(doFetch, did, { appview })
      if (net === null) emit('probe-unavailable', { collection: 'app.bsky.graph.follow' })
      else {
        for (const { rkey, record } of follows) {
          const subject = typeof record.subject === 'string' ? record.subject : (record.subject && record.subject.did)
          if (!subject || net.has(subject)) continue
          network.push({ collection: 'app.bsky.graph.follow', rkey, state: recent(record, now, graceMs, republishedAfter) ? 'pending' : 'missing', subject })
        }
      }
    }

    // likes — grouped by subject so N likes on one post cost ONE call
    const likes = (await listRecords('app.bsky.feed.like').catch(() => [])) || []
    const bySubject = new Map()
    for (const { rkey, record } of likes) {
      const uri = record && record.subject && record.subject.uri
      if (!uri) continue // structural layer already reported it
      if (!bySubject.has(uri)) bySubject.set(uri, [])
      bySubject.get(uri).push({ rkey, record })
    }
    for (const [uri, group] of [...bySubject].slice(0, maxProbes)) {
      const seen = await likedBy(doFetch, uri, did, { appview })
      if (seen === null || seen) continue
      for (const { rkey, record } of group) {
        network.push({ collection: 'app.bsky.feed.like', rkey, state: recent(record, now, graceMs, republishedAfter) ? 'pending' : 'missing', subject: uri })
      }
    }
  }

  const errors = structural.filter((s) => !s.warning)
  const summary = {
    did,
    checked,
    byCollection,
    structuralErrors: errors.length,
    structuralWarnings: structural.length - errors.length,
    networkMissing: network.filter((n) => n.state === 'missing').length,
    networkPending: network.filter((n) => n.state === 'pending').length,
    networkChecked: !!doFetch
  }
  return { structural, network, summary }
}