OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / per-user.js

170 lines · javascript ref:
// Per-user resources: records, followers, signer.
//
// ⛔ ISOLATION IS THE WHOLE POINT. Routing many actors through one process while they share a record
// source, a follower list and a key is worse than not doing it: the second user serves the first's
// posts, their followers are the same people, and one key signs for both. `withUser()` falls back to
// the shared instances when no resolver is supplied — correct while there is exactly one user, and
// wrong the moment there are two — so these exist to make the fallback unnecessary.
//
// ⚠ Memoised per username. A resolver called on every request would open a socket per request, and
// a follower store rebuilt per request would forget everything it was told between two of them.

import { join } from 'bare-path'
import { FollowerStore } from './followers.js'
import { FavelaSource } from './favela-source.js'
import { LocalSigner, RemoteSigner } from './signer.js'
import { blobFetcherFor } from './blobs.js'
import { FavelaPeer } from 'favela-identity/lib/peer-client.js'

export class PerUser {
  /**
   * @param {object} opts
   * @param {Users}  opts.users
   * @param {string} opts.dataDir   per-user state is filed beneath this
   * @param {object} [opts.shared]  { records, followerStore, signer } — what a single-actor node
   *                                already has. Used ONLY for the primary, and only when that user
   *                                declares nothing of its own.
   */
  constructor ({ users, dataDir, shared = {}, files = null, readFile = null, log = () => {} }) {
    this.log = log
    this.users = users
    this.dataDir = dataDir
    this.shared = shared
    // The overlay's Files, shared across every held feed — one physical copy of any blob, which is
    // what OverlayBlobStore already guarantees. `readFile` is injected so this stays testable.
    this.files = files
    this.readFile = readFile
    this._blobs = new Map()
    this._records = new Map()
    this._followers = new Map()
    this._signers = new Map()
  }

  // ⚠ The primary may inherit the process-wide instances; nobody else ever can. Inheriting for a
  // second user is precisely the cross-wiring this class exists to prevent.
  _inherits (user) { return user && this.users.primary === user }

  records (user) {
    if (!user) return null
    if (this._records.has(user.username)) return this._records.get(user.username)
    let r = null
    if (user.peer || user.favelaSocket) {
      const ids = this.users.ids(user)
      r = new FavelaSource({
        // A registered user's peer is a feed this bridge HOLDS; a configured one may still be a
        // co-located socket. FavelaSource cannot tell the difference and does not need to.
        peer: user.peer || null,
        sock: user.favelaSocket,
        // ⛔ THEIR root, so their blobs and objects hang off their own path rather than the
        // primary's flat one. `ids.base` is already `/ap` for the primary and `/ap/users/<name>`
        // for everyone else, so the incumbent's ids do not move.
        base: ids.base,
        host: this.users.host,
        actorId: ids.actor,
        followersId: ids.followers,
        roomType: user.roomType || 'public'
      })
    } else if (this._inherits(user)) {
      r = this.shared.records || null
    }
    this._records.set(user.username, r)
    return r
  }

  /**
   * Where THIS user's blobs come from.
   *
   * ⛔ A held user's avatar is a favela oid in the shared overlay, not a CID on the primary's PDS.
   * The process-wide fetcher asked the PDS, found nothing, and the actor advertised an icon that
   * 404d — which renders as no avatar, indistinguishable from having none.
   */
  blobs (user) {
    if (!user) return null
    if (this._blobs.has(user.username)) return this._blobs.get(user.username)
    let f = null
    if (user.peer && this.files && this.readFile) {
      f = blobFetcherFor({ files: this.files, readFile: this.readFile, log: this.log })
    } else if (this._inherits(user)) {
      f = this.shared.fetchBlob || null
    }
    this._blobs.set(user.username, f)
    return f
  }

  followers (user) {
    if (!user) return null
    if (this._followers.has(user.username)) return this._followers.get(user.username)
    let f = null
    if (this._inherits(user) && this.shared.followerStore) {
      // ⛔ The primary keeps the EXISTING file. Its followers are real people on other servers and a
      // fresh store would silently stop delivering to every one of them.
      f = this.shared.followerStore
    } else if (this.dataDir) {
      // Filed under the username, so two actors on one box never share a list.
      f = new FollowerStore(join(this.dataDir, 'users', user.username, 'followers.json'))
    }
    this._followers.set(user.username, f)
    return f
  }

  /**
   * The signer for one actor.
   *
   * ⛔ HTTP SIGNATURES SIGN THE TRANSPORT, per request, per actor — so each user needs their own
   * key material and one shared key would let any of them post as any other.
   *
   * ⛔ AND THE CUSTODY CHOICE IS CONFIGURATION, NOT A DEFAULT. `signer.js`'s header states the
   * trade: a bridge holding N keys yields N identities if it is compromised, while a RemoteSigner
   * "yields nothing but the ability to ask for signatures while the connection lasts". A bridge
   * that quietly minted keys for everyone it admitted would be choosing the first on their behalf.
   *
   * ⛔ NO KEY, NO SIGNER, NO DELIVERY — and that is the safe direction. An actor with no signer can
   * still be fetched, followed and read; it simply cannot push. Failing the other way would mean
   * signing with somebody else's key.
   */
  signer (user) {
    if (!user) return null
    if (this._signers.has(user.username)) return this._signers.get(user.username)
    const keyId = this.users.ids(user).actor + '#main-key'
    let s = null
    if (user.favelaSocket) {
      // ⛔ REMOTE, and this is the decision (user, 2026-09-07): the key stays on the peer. The
      // bridge asks for a signature over these exact bytes and holds nothing — compromising it
      // yields, in signer.js's own words, "nothing but the ability to ask for signatures while the
      // connection lasts". A bridge fronting N people with N keys is N identities in one basket.
      //
      // ⚠ It costs one round trip PER INBOX and cannot be batched: the signature covers `host` and
      // `(request-target)`, so fifty followers on fifty servers is fifty signing requests. That is
      // the price of the property, and it is the right way round.
      const peer = new FavelaPeer(user.favelaSocket)
      s = new RemoteSigner({ keyId, call: (method, params) => peer.call(method, params) })
    } else if (user.keys && user.keys.privateKeyPem) {
      // ⛔ A REGISTERED USER SIGNS AS ITSELF. Falling through to the shared signer would sign their
      // deliveries with the PRIMARY's key against their own key id, and every receiver would reject
      // the signature — the actor resolves, and nothing it sends is believed.
      s = new LocalSigner({ keyId, privateKeyPem: user.keys.privateKeyPem })
    } else if (user.privateKeyPem) {
      // ⚠ Local custody, only where it is explicitly configured. signer.js: a RemoteSigner to a peer
      // ON THE SAME BOX buys nothing, because compromising that box gets you the peer anyway — so
      // this stays available rather than being removed on principle.
      s = new LocalSigner({ keyId, privateKeyPem: user.privateKeyPem })
    } else if (this._inherits(user)) {
      s = this.shared.signer || null
    }
    this._signers.set(user.username, s)
    return s
  }

  /** Everything a request context needs, bound to one user. */
  resolvers () {
    return {
      recordsFor: (u) => this.records(u),
      followersFor: (u) => this.followers(u),
      signerFor: (u) => this.signer(u),
      blobsFor: (u) => this.blobs(u)
    }
  }
}

export default { PerUser }