OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / records.js

195 lines · javascript ref:
// records.js — the keyless read side. ap-bridge fronts a DID exactly as atproto-bridge and
// favela do: replicate the peer's cores over Hyperswarm, read records, project. No key involved.
//
// ★ WHY THIS IS NOT A NEW SUBSYSTEM. The obvious-looking move was to build ap-bridge its own
// hyper-overlay reader. It doesn't need one: records live in the shared content-addressed
// OverlayRepoStore and `atproto-did-peer` ALREADY reads through it (did-peer.js:59), plus it
// carries the peer's application RPC for blob relay (did-peer.js:31,387). Reusing it means the
// AP projection reads the same bytes the atproto projection does — which is the entire "one
// representation on disk" claim, made operational rather than asserted.
//
// The only thing ap-bridge cannot get keylessly is a SIGNATURE at delivery time: in atproto the
// signature travels in the firehose so a membrane re-serves one it never made, but HTTP Signatures
// are computed per-request over live headers and must be made fresh. That is lib/signer.js, and it
// is the sole reason a keyholder is involved at all.

import { DidPeer } from 'atproto-did-peer'
import { projectNote, standaloneNote, projectLike, projectAnnounce } from './as2-project.js'
import { projectProfile, projectAttachments, PROFILE_COLLECTION } from './blobs.js'

export const POST_COLLECTION = 'app.bsky.feed.post'
export const LIKE_COLLECTION = 'app.bsky.feed.like'
export const REPOST_COLLECTION = 'app.bsky.feed.repost'

// ⛔ DERIVED FROM THE RKEY, NEVER A COUNTER. An Undo must name the exact activity it reverses, so
// the same record has to yield the same activity id on every boot — a random id would make an
// un-like unexpressible the moment the process restarted.
export const activityIdFor = ({ actorId, kind, rkey }) =>
  `${String(actorId).replace(/\/actor$/, '')}/activities/${kind}-${rkey}`

// ★ For a SINGLE fronted DID and collection the id-map is a PURE FUNCTION, not stored state:
// an rkey is a TID, unique within its collection, so `at://<did>/<coll>/<rkey>` and
// `https://<host>/ap/objects/<rkey>` determine each other. That is why M3 needs no id-map table.
//
// ⚠ It stops being a function the moment ap-bridge fronts more than one DID or projects more than
// one collection — then two records can share an rkey and the mapping must be stored. DESIGN.md §3
// calls the id-map "the one irreducible persistent state"; this is that claim scoped down to the
// single-actor case, NOT a refutation of it. Do not let multi-actor land without replacing this.
// ⚠ Lives in as2-project.js now: it is AS2 vocabulary, and the favela source needs it WITHOUT
// dragging this module's atproto imports in behind it. Re-exported so callers are unaffected.
import { objectIdFor } from './as2-project.js'
export { objectIdFor }
export const rkeyFromObjectId = (id) => {
  const m = /\/ap\/objects\/([^/?#]+)$/.exec(String(id || ''))
  return m ? m[1] : null
}
export const atUriFor = ({ did, collection, rkey }) => `at://${did}/${collection}/${rkey}`

export class RecordSource {
  // peer: an atproto-did-peer DidPeer (already ready()). Injected so the router stays testable
  // offline against a fake with the same three methods.
  constructor ({ peer, did, host, actorId, followersId }) {
    this.peer = peer
    this.did = did
    this.host = host
    this.actorId = actorId
    this.followersId = followersId
  }

  // Newest-first, which is what an outbox is expected to be.
  async listNotes ({ limit = 20, cursor = null } = {}) {
    const res = await this.peer.listRecords(POST_COLLECTION, { limit, reverse: true, cursor })
    const notes = []
    for (const r of res.records || []) {
      const note = this._project(r)
      if (note) notes.push(note)
    }
    return { notes, cursor: res.cursor }
  }

  // ★ THE ID-MAP, as the pure function it still is. An at-uri can be named in AP terms only when it
  // is OUR post: that is the one binding `objectIdFor` establishes. Anything else — a bsky post, a
  // reply parent we never published — returns null and its interaction is not projected, rather than
  // pointing at a URI on a host we do not control (as2-project.js explains why).
  //
  // ⚠ Same caveat as objectIdFor: this stops being a function the moment we front more than one DID.
  ourObjectUri (atUri) {
    const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/?#]+)$/.exec(String(atUri || ''))
    if (!m) return null
    const [, did, collection, rkey] = m
    if (did !== this.did || collection !== POST_COLLECTION) return null
    return objectIdFor({ host: this.host, rkey })
  }

  // Likes and reposts, projected. Returns only what can be TRUTHFULLY named, plus a count of what
  // could not — because "0 interactions" and "12 interactions we cannot address" are different
  // states and an operator needs to tell them apart.
  async listInteractions ({ limit = 50 } = {}) {
    const resolveObjectUri = (u) => this.ourObjectUri(u)
    const kinds = [
      { kind: 'like', collection: LIKE_COLLECTION, project: projectLike },
      { kind: 'announce', collection: REPOST_COLLECTION, project: projectAnnounce }
    ]

    const interactions = []
    // ⛔ THE REFUSED ONES, NAMED RATHER THAN COUNTED. A count told an operator that six acts existed
    // and nothing about what they were; measured, all six are likes of Bluesky posts, which have no
    // ActivityPub identity by protocol. The projection stays refused — this list is for OUR surfaces,
    // which can honestly link to where the post lives.
    const unprojectable = []
    let unaddressable = 0
    for (const { kind, collection, project } of kinds) {
      let res
      // A collection that does not exist yet is not an error: a repo with no reposts simply has no
      // app.bsky.feed.repost records.
      try { res = await this.peer.listRecords(collection, { limit, reverse: true }) } catch { continue }
      for (const r of res.records || []) {
        const rkey = rkeyFromAtUri(r.uri) || r.rkey
        if (!rkey) continue
        const activity = project({
          record: r.value || r.record,
          activityId: activityIdFor({ actorId: this.actorId, kind, rkey }),
          actorId: this.actorId,
          followersId: this.followersId,
          resolveObjectUri
        })
        // Namespaced key: a like and a post can share an rkey (TIDs are unique per COLLECTION, not
        // per repo), and the delivered-map is keyed by string.
        // ⚠ THE RECORD'S TIME, not the projection's. projectLike carries no `published` at all
        // (projectAnnounce does), so a consumer ordering by the activity alone would put every like
        // at the bottom of a timeline for ever.
        const value = r.value || r.record || {}
        if (activity) interactions.push({ kind, rkey, key: kind + ':' + rkey, at: value.createdAt || null, activity })
        else {
          unaddressable++
          const subject = value.subject && typeof value.subject === 'object' ? value.subject.uri : null
          unprojectable.push({ kind, rkey, at: value.createdAt || null, subject: subject || null })
        }
      }
    }
    return { interactions, unaddressable, unprojectable }
  }

  // The actor's avatar/banner/displayName. Cached briefly: it is read on every actor fetch and
  // remote servers poll actors regularly, but it must not go stale for long after an edit.
  async getProfile ({ ttlMs = 60_000 } = {}) {
    const now = Date.now()
    if (this._profile && this._profileAt && (now - this._profileAt) < ttlMs) return this._profile
    try {
      const r = await this.peer.getRecord(PROFILE_COLLECTION, 'self')
      this._profile = projectProfile({ profile: r?.value || r?.record || null, host: this.host })
    } catch {
      this._profile = this._profile || {} // keep the last good projection rather than dropping the avatar
    }
    this._profileAt = now
    return this._profile
  }

  async getNote (rkey) {
    const r = await this.peer.getRecord(POST_COLLECTION, rkey)
    if (!r) return null
    const note = this._project(r, rkey)
    return note ? standaloneNote(note) : null
  }

  // A record we cannot represent is SKIPPED, not faked. An outbox that silently invents content
  // for a record shape it didn't understand is worse than one with a gap.
  _project (r, rkeyHint = null) {
    const record = r.value || r.record
    if (!record || typeof record.text !== 'string') return null

    const rkey = rkeyHint || rkeyFromAtUri(r.uri) || r.rkey
    if (!rkey) return null

    try {
      return projectNote({
        record,
        uri: r.uri || atUriFor({ did: this.did, collection: POST_COLLECTION, rkey }),
        cid: r.cid,
        actorId: this.actorId,
        objectId: objectIdFor({ host: this.host, rkey }),
        followersId: this.followersId,
        attachments: projectAttachments({ record, host: this.host })
        // resolveObjectUri intentionally omitted: a reply parent we cannot name in AP terms is
        // left unset rather than guessed (see as2-project.js).
      })
    } catch {
      return null
    }
  }
}

export function rkeyFromAtUri (uri) {
  const m = /^at:\/\/[^/]+\/[^/]+\/([^/?#]+)$/.exec(String(uri || ''))
  return m ? m[1] : null
}

// Open a keyless reader for a DID. verifyKey gates it — atproto-did-peer refuses to front a repo
// whose signed commit doesn't verify against the DID's published key, which is what makes reading
// keylessly SAFE rather than merely convenient.
export async function openPeer ({ corestore, did, verifyKey, timeout = 20000 }) {
  const peer = new DidPeer(corestore, { did, verifyKey })
  await peer.ready({ timeout })
  return peer
}