OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / bdd20b1

0.3.82: following and viewing subscribe now, instead of at the next boot
An identity nobody tails does not render as absent. It renders as a complete, plausible profile that is silently months out of date — measured on a handset (#53): ashatron's posts 16 days behind, and their PREVIOUS avatar shown as current, because the stale copy includes the stale profile record and therefore the old avatar CID. That reads as corruption, and it was reported as such. The machinery to prevent it was already here and simply never called. addPeer() joins a DID's topic, resolves a verify key and starts the ingest loop, and /api/track has been calling it at RUNTIME all along. What was missing is that nothing else did: - FAVELA_ROSTER is captured at module import, so it can only change at boot. - favela-android's syncFollows runs only inside boot(). - search and profile view subscribed to nothing at all. So a follow changed nothing until a restart, and finding someone through search left a snapshot that then aged. 1. FOLLOWING SUBSCRIBES. /api/follow calls addPeer(subject) once the record is written. A follow is already the durable, replicating statement of interest; needing a restart to act on it was the bug. Detached and non-fatal deliberately: the follow RECORD is the durable half and is already written, so making the response wait on a DHT rendezvous would turn a local write into a network-latency operation — and every bsky-hosted account (which no peer serves) would report "follow failed" for a follow that succeeded. 2. VIEWING REFRESHES. Rendering a profile for a DID that is neither hosted nor in the roster starts the same runtime subscription, so a found identity is current rather than a snapshot. ⚠ GATED TO THE NODE'S OWN OPERATOR — viewer.did must equal the DID this node signs for, the same test /api/follow uses. Without it, any anonymous visitor to a public instance could make it join an arbitrary topic per URL: a page view must not be a remote "subscribe this node" button. On a handset the operator is the only viewer, which is the case this fixes. Suite green (exit 0). Still node-local: WHICH identities a node subscribes to. tabertron pins a roster in apps.json while the phone derives one, so the same DID indexes different things per device. lib/roster.js already has resolveRoster() reading an app.bsky.graph.list into member DIDs — that is the replicated source of truth, and moving the roster onto it is the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bdd20b1a394317405bab6c9443dc89c711ba64b5
by plankatron <fleeky@gmail.com> · 2026-08-25T17:17:36.000Z

Changed files (2) +48 -1

bin/favela-web.js +47 -0
……@@ -1660,6 +1660,35 @@ const server = http.createServer(async (req, res) => {
16601660 // Posts tab); the appview keeps the lexicon default. An unknown value degrades inside getAuthorFeed
16611661 // rather than erroring, so a hand-edited query string cannot 500 a profile.
16621662 const filter = url.searchParams.get('filter') || 'posts_no_replies'
1663 // ⛔ A PROFILE WE DO NOT SUBSCRIBE TO GOES STALE SILENTLY, AND LOOKS CURRENT. Nothing tails an
1664 // identity outside the roster, so an earlier one-shot view renders as a complete, plausible profile
1665 // that is simply months out of date — old posts, and the old avatar CID because the PROFILE RECORD
1666 // is old too. Measured on a handset (hyper-social #53): ashatron's posts 16 days behind and their
1667 // previous avatar shown as though it were current. That reads as corruption, not as "not
1668 // subscribed", which is the worst way for this to fail.
1669 //
1670 // So looking at someone refreshes them. addPeer is the same idempotent runtime path /api/track
1671 // uses — join the topic, resolve a verify key, start the ingest loop — and the loop then keeps the
1672 // profile current for as long as this node runs.
1673 //
1674 // ⚠ GATED TO THE NODE'S OWN OPERATOR. `viewer.did === the DID this node signs for` is the same test
1675 // /api/follow uses. Without it, any anonymous visitor to a public instance could make it join an
1676 // arbitrary topic per URL — a page view must not be a remote "make this node subscribe" button.
1677 // On a handset the operator is the only viewer there is, which is the case this fixes.
1678 //
1679 // Detached and non-fatal: the page must render at snapshot speed. An unreachable DID (every
1680 // bsky-hosted account) simply resolves no peer, and the render is unaffected.
1681 if (viewer && !(HOSTED_DIDS || []).includes(did) && !ROSTER.includes(did)) {
1682 try {
1683 const st = await writer.state().catch(() => null)
1684 if (st && st.did === viewer.did) {
1685 addPeer(did).then(
1686 (p) => { if (p) console.log(JSON.stringify({ msg: 'profile view: subscribed, ingest running', did })) },
1687 (e) => console.error(JSON.stringify({ msg: 'profile view: subscribe failed (page still rendered)', did, error: e.message }))
1688 )
1689 }
1690 } catch { /* the page is the product; a refresh attempt must never break it */ }
1691 }
16631692 const result = await withLensBylines(await withMod(await av.getAuthorFeed(did, { limit: 50, cursor: url.searchParams.get('cursor') || null, viewerDid: viewer ? viewer.did : null, filter }), viewer ? viewer.did : null))
16641693 // The profile header needs the SUBJECT's own profile — the same indexed record the per-post
16651694 // hydration uses, just looked up by the DID being viewed rather than by each post's author.
……@@ -2275,6 +2304,24 @@ const server = http.createServer(async (req, res) => {
22752304 const record = { $type: 'app.bsky.graph.follow', subject, createdAt: new Date().toISOString() }
22762305 await writer.putRecord('app.bsky.graph.follow', rkey, record, { action: 'create' })
22772306 await av.putFollow({ did: deviceDid, rkey, record })
2307 // ⛔ SUBSCRIBE NOW, NOT AT THE NEXT BOOT. A follow is the durable, replicating statement of
2308 // interest in an identity, and it used to change nothing until the node restarted: FAVELA_ROSTER
2309 // is captured at import, and favela-android's syncFollows runs only inside boot(). So following
2310 // someone left their posts absent — or worse, left an earlier one-shot view on screen, ageing
2311 // silently, which is how a handset came to show a two-week-old profile and its previous avatar
2312 // as though they were current (hyper-social #53).
2313 //
2314 // addPeer is the same runtime path /api/track uses: it joins the DID's topic, resolves a verify
2315 // key and starts the ingest loop. Idempotent, so a re-follow costs nothing.
2316 //
2317 // Detached and non-fatal ON PURPOSE. The follow RECORD is the durable half and it is already
2318 // written; making the response wait on a DHT rendezvous would turn a local write into a
2319 // network-latency operation, and an unreachable peer (every bsky-hosted account, which no peer
2320 // serves) would read as "follow failed" when the follow succeeded perfectly.
2321 addPeer(subject).then(
2322 (p) => { if (!p) console.log(JSON.stringify({ msg: 'follow: nobody serves this DID yet — the follow stands, ingest starts when someone does', did: subject })) },
2323 (e) => console.error(JSON.stringify({ msg: 'follow: subscribe failed (the follow itself is written)', did: subject, error: e.message }))
2324 )
22782325 }
22792326 if (isForm) return redirect(res, home)
22802327 return json(res, 200, JSON.stringify({ ok: true, following: !existing }))
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.81",
3 "version": "0.3.82",
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",