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