OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 198c70a

0.3.83: the roster becomes a record, so every device of an identity agrees
WHICH identities a node subscribes to was node-local, and the two nodes disagreed: tabertron pins FAVELA_ROSTER in apps.json, a handset derives its own from hosted + tracked. Same DID, two sources of truth — ashatron indexed on one and invisible on the other, surfacing as a profile frozen two weeks back rather than as a missing subscription (#53). An app.bsky.graph.list in the owner's own repo is the replicated answer, and it was already built: lib/roster.js resolveRoster() turns a list into member DIDs, and the FAVELA_LIST branch has always been able to drive the roster from one. Nobody set it. 1. DISCOVERY. With no FAVELA_LIST, look for a list at a well-known rkey (`subscriptions`) on the DID we host, read from our OWN record store. Found => it drives the roster. No new substrate, no new env var, and a second device inherits the roster purely by replication. 2. MIGRATION, ONCE. A node whose roster came from an explicit FAVELA_ROSTER writes that set into the list and subscribes to the members IMMEDIATELY via addPeer — a migration that needed a restart to take effect would be the same defect this release fixes. Afterwards the pin is dead weight and can be deleted. Gated on the pin being SET IN THE ENV, which is the pinned case by definition: a handset derives its roster and so never migrates, which is also what stops two devices racing to author the same list. Gated again on writer.state().did matching the identity — never write to a repo this node does not sign for. HOSTED DIDs ARE NOT OPTIONAL. `rosterDids = r.members` alone drops this node's own identity from the roster the instant a list resolves — it stops indexing itself, and that stays invisible until every one of your own posts has vanished. Hosted first, because addPeer runs in roster order and apProjected() asks about ROSTER[0]. The migration deliberately sits AFTER `writer` is defined. Beside the roster resolve 150 lines earlier, `writer` is still in its temporal dead zone — a ReferenceError at boot on every node. Caught by reading the file, not by a log. Verified: boots clean with a pinned roster and no hpp — joined 2 DID topics, gateway up, both new branches correctly skipping because nothing is hosted. Suite green. NOT verified: the migration write itself, which needs a hosted DID and a live writer (tabertron); what this exercised are the guards around it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198c70ab498b20e84f12d9aabe0d34fa3a754d7b
by plankatron <fleeky@gmail.com> · 2026-08-25T17:32:01.000Z

Changed files (2) +93 -6

bin/favela-web.js +92 -5
……@@ -747,18 +747,52 @@ async function addPeer (did) {
747747 return peer
748748}
749749
750// ── THE SUBSCRIPTION LIST, AND WHY IT IS NOT CONFIGURATION ───────────────────────────────────────────
751// WHICH identities a node subscribes to was node-local: tabertron pinned FAVELA_ROSTER in apps.json
752// while a handset derived its own from hosted + locally tracked DIDs. Same DID, two sources of truth,
753// and they disagreed — ashatron was indexed on one and invisible on the other, which surfaced as a
754// profile frozen two weeks in the past rather than as a missing subscription (hyper-social #53).
755//
756// A list is the fix that already exists: an app.bsky.graph.list in the owner's OWN repo, membership as
757// listitems, resolved by lib/roster.js. It replicates with the identity, so every device hosting that
758// DID converges on the same subscriptions with nothing configured anywhere.
759//
760// So look for one at a well-known rkey on the DID we host, when no FAVELA_LIST is set explicitly. No new
761// substrate, no new env var to remember, and a second device inherits the roster by replication.
762const SUBSCRIPTIONS_RKEY = 'subscriptions'
763let listUri = LIST
764if (!listUri) {
765 const selfDid = (HOSTED_DIDS || [])[0] || null
766 if (selfDid) {
767 const candidate = `at://${selfDid}/${LIST_COLLECTION}/${SUBSCRIPTIONS_RKEY}`
768 try {
769 const store = await getRecordStore(selfDid)
770 if (await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY)) {
771 listUri = candidate
772 console.log(JSON.stringify({ msg: 'roster: using the replicated subscriptions list', list: candidate }))
773 }
774 } catch (e) {
775 console.error(JSON.stringify({ msg: 'roster: could not read the subscriptions list (env roster still in force)', error: e.message }))
776 }
777 }
778}
779
750780// Resolve the roster: from a list (open the owner first to READ it, then use its member DIDs) or the
751781// env fallback. A list-resolve failure falls back to FAVELA_ROSTER rather than coming up empty.
752782let rosterDids = ROSTER
753if (LIST) {
783if (listUri) {
754784 try {
755 const { owner } = parseListUri(LIST)
785 const { owner } = parseListUri(listUri)
756786 const ownerPeer = await addPeer(owner)
757787 if (!ownerPeer) throw new Error('could not open the list owner peer')
758788 await ownerPeer.ready({ timeout: 60000 })
759 const r = await resolveRoster(ownerPeer, LIST)
760 rosterDids = r.members
761 console.log(`[favela] list "${r.name || LIST}" -> ${rosterDids.length} member(s)`)
789 const r = await resolveRoster(ownerPeer, listUri)
790 // ⛔ HOSTED DIDs ARE NOT OPTIONAL. `rosterDids = r.members` alone would drop this node's OWN identity
791 // out of the roster the moment a list resolves — it would stop indexing itself, which is invisible
792 // until every one of your own posts is missing. Hosted FIRST, because addPeer runs in roster order
793 // and apProjected() asks about ROSTER[0] specifically.
794 rosterDids = [...(HOSTED_DIDS || []), ...r.members].filter((d, i, a) => a.indexOf(d) === i)
795 console.log(`[favela] list "${r.name || listUri}" -> ${r.members.length} member(s), ${rosterDids.length} in roster with hosted`)
762796 } catch (e) { console.error('[favela] list resolve failed:', e.message, '- falling back to FAVELA_ROSTER'); rosterDids = ROSTER }
763797}
764798for (const did of rosterDids) await addPeer(did)
……@@ -869,6 +903,59 @@ const writer = WRITE_TRANSPORT
869903 return () => Promise.reject(new Error('writes disabled: FAVELA_CONTROL_KEY is not set on this instance'))
870904 }
871905 })
906
907// ── ONE-TIME: TURN A HAND-PINNED ROSTER INTO A REPLICATED ONE ────────────────────────────────────────
908// A node whose subscriptions came from FAVELA_ROSTER in apps.json writes that set into an
909// app.bsky.graph.list in its own repo, ONCE. After that the list replicates with the identity, every
910// device hosting the DID resolves the same roster (see the discovery above), and the env pin is dead
911// weight that can be deleted.
912//
913// ⛔ ONLY FROM AN EXPLICIT PIN, AND ONLY WHERE WE SIGN. `ROSTER.length` is the pinned case by
914// definition — a handset DERIVES its roster from hosted + tracked and so never migrates, which is also
915// what stops two devices racing to author the same list. `writer.state().did` confirms this node signs
916// for the identity whose repo we are about to write to.
917//
918// Runs AFTER `writer` exists: this used to sit beside the roster resolve 150 lines up, where `writer`
919// is still in its temporal dead zone — a ReferenceError at boot on every node, caught before shipping
920// rather than in a log.
921//
922// The migrated members are subscribed IMMEDIATELY rather than at the next boot, using the same runtime
923// addPeer that following and viewing now use. A migration that needs a restart to take effect is the
924// same defect this release is fixing.
925if (!LIST && ROSTER.length && (HOSTED_DIDS || []).length) {
926 const selfDid = HOSTED_DIDS[0]
927 const uri = `at://${selfDid}/${LIST_COLLECTION}/${SUBSCRIPTIONS_RKEY}`
928 ;(async () => {
929 try {
930 const store = await getRecordStore(selfDid)
931 if (await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY)) return // already migrated; discovery picks it up
932 const st = await writer.state().catch(() => null)
933 if (!st || st.did !== selfDid) return // this node does not sign for the identity — not ours to write
934 const createdAt = new Date().toISOString()
935 await writer.putRecord(LIST_COLLECTION, SUBSCRIPTIONS_RKEY, {
936 $type: LIST_COLLECTION,
937 purpose: CURATE,
938 name: 'subscriptions',
939 description: 'Identities this account subscribes to. Migrated from FAVELA_ROSTER.',
940 createdAt
941 }, { action: 'create' })
942 const members = ROSTER.filter((d) => d !== selfDid) // hosted DIDs are subscribed regardless
943 for (const did of members) {
944 await writer.putRecord(LISTITEM_COLLECTION, tidNow(), {
945 $type: LISTITEM_COLLECTION, subject: did, list: uri, createdAt
946 }, { action: 'create' })
947 addPeer(did).catch(() => {})
948 }
949 console.log(JSON.stringify({
950 msg: 'roster: MIGRATED FAVELA_ROSTER into a replicated list — every device hosting this DID now inherits it, and the env pin can be removed',
951 list: uri, members: members.length
952 }))
953 } catch (e) {
954 console.error(JSON.stringify({ msg: 'roster: list migration failed (the env roster still applies)', error: e.message }))
955 }
956 })()
957}
958
872959const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (d) => { b += d }); req.on('end', () => resolve(b)) })
873960// BINARY-safe body reader. readBody above concatenates onto a STRING, which mangles any byte that is
874961// not valid UTF-8 — fine for JSON/form posts, silently corrupting for an image. Blob uploads MUST use
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.82",
3 "version": "0.3.83",
44 "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",