// 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
}