// Which DIDs this node SUBSCRIBES to — the value favela-web reads as FAVELA_ROSTER.
//
// ⛔ ROSTER ≠ SIGNING SET. The roster is what this node INDEXES: it joins each DID's topic, resolves its
// verifyKey, and follows the freshest verified co-owner's firehose (favela-web/bin/favela-web.js:565-687).
// Every entry is read-only and independently verified. What this node can SIGN for is a different, much
// smaller thing — one hpp control socket (favela-web/lib/writer.js:283, "favela's write path is
// single-DID (the UDS socket picks the device)"). Our own hosted DIDs appear here because we index them,
// NOT because we hold their keys, and nothing downstream may read authority out of roster membership.
//
// ⛔ WHY THIS EXISTS AT ALL. favela-web captures the roster at module top level, so it is frozen at
// import. The phone never set it, so the in-process favela-web joined zero topics and its index — the one
// every screen reads — could only ever contain what was already in it. See test/roster.test.mjs, whose
// first test replicates favela-web's parser verbatim against the value we used to hand it.
//
// Pure and IO-free on purpose: the phone's boot is expensive to stand up, and the part worth testing is
// this decision, not the boot.
// A DID we are willing to turn into a topic join and a verifyKey resolution.
//
// The comma exclusion is load-bearing rather than cosmetic: the env format is comma-separated, so a DID
// carrying a comma would arrive at favela-web as TWO entries, each garbage. Whitespace is excluded for
// the same reason the parser trims — an entry that only survives trimming is an entry we should not have
// emitted. `did:` alone, or any method with no identifier, is not a DID and must not reach
// resolveVerifyKey, where it would cost a network round-trip to learn what a regex already knows.
const DID = /^did:[a-z][a-z0-9]*:[A-Za-z0-9._%:-]+$/
function usable (v) {
return typeof v === 'string' && DID.test(v)
}
/**
* The subscription set, in the order favela-web will open it.
*
* Hosted first, deliberately: `addPeer()` runs in roster order, and `apProjected()` asks about
* `ROSTER[0]` specifically (bin/favela-web.js:351-352) — so a tracked stranger sorting ahead of our own
* DID would quietly change which actor the ActivityPub side reports on.
*
* ALL hosted DIDs, not the first: reads are already multi-DID (one appview, DID-keyed, `viewerDid` per
* query), so a phone housing two accounts is a read-path reality today. Taking `nodes[0]` here would
* half-subscribe the second account — visible as a timeline that is simply missing one person's posts,
* with nothing in the log to say so.
*
* @param {object} opts
* @param {string[]} [opts.hosted] DIDs whose repos this node co-owns (hpp nodes)
* @param {string[]} [opts.tracked] DIDs explicitly tracked by the user (tracked-dids.json)
* @param {string[]} [opts.following] DIDs the viewer follows, from the index
* @returns {string[]} deduped, ordered, every entry safe to hand to favela-web
*/
export function rosterFor ({ hosted = [], tracked = [], following = [] } = {}) {
const out = []
const seen = new Set()
for (const list of [hosted, tracked, following]) {
if (!Array.isArray(list)) continue
for (const did of list) {
if (!usable(did) || seen.has(did)) continue
seen.add(did)
out.push(did)
}
}
return out
}
/**
* The same set as the env string favela-web parses.
*
* Empty means empty: `String(undefined)` is the literal 'undefined', which favela-web would happily take
* for a DID and then fail to resolve, so a node with nothing to subscribe to must produce '' — the same
* silence as before, rather than one fabricated peer.
*/
export function rosterEnv (opts) {
return rosterFor(opts).join(',')
}