// favela SSR gateway — a Bare backend that reads a roster of DIDs over P2P (atproto-did-peer), folds
// them into the appview index, and SERVES the views SERVER-RENDERED (no-JS HTML at real URLs) plus the
// JSON APIs as progressive enhancement. This is the "P2P-less window" (DESIGN §2.6/§4.1): a browser
// with a URL reads the community — no install, no JS, SEO-able, RSS/Atom for free. atcute is NOT in
// the backend (it serves HTML/JSON — atcute is UI-side per §4.1). Run: `bare bin/favela-web.js`.
//
// Currently single-viewer (the node operator's configured roster/labelers/mutes). The per-viewer DID
// session layer (browser sub-key attested to the DID + profile-entered StateBase community links) is
// the next increment — the render layer already takes a `viewer`.
import '../lib/bare-globals.js'
import http from 'bare-http1'
// Every outbound HTTP call in this daemon goes through the BOUNDED fetch, never bare-fetch directly.
// A raw bare-fetch to an HTTPS host that accepts TCP and then goes silent holds its socket for ever
// (bare-tls does not forward 'timeout' up to the wrapper bare-https puts around the socket, so the
// agent's own reaper cannot fire). That is what put 47,191 sockets and 24.3 GB into this process and
// OOM-killed stortron twice, on 2026-08-27 and 2026-08-29. See lib/bounded-fetch.js for the full
// mechanism and test/bounded-fetch.test.mjs for the drill that proves both the leak and the fix.
import { createBoundedFetch } from '../lib/bounded-fetch.js'
const bareFetch = createBoundedFetch({
onTimeout: ({ url, ms }) => console.error(JSON.stringify({ msg: 'fetch deadline exceeded — socket reaped', url, ms }))
})
import { readFile, writeFile, mkdir, rename } from 'bare-fs/promises'
import fs from 'bare-fs'
import Hyperswarm from 'hyperswarm'
import Corestore from 'corestore'
import { DidPeer, DidPeerRouter } from 'atproto-did-peer'
import { openCorestore } from 'atproto-store/lib/corestore.js'
import { OverlayRepoStore } from 'atproto-store/lib/overlay-repo-store.js'
import { roomKey16, blobPath, sniffMime, isSafeCid } from '../lib/blob-serve.js'
import { verifyBlobCid } from '../lib/blob-refs.js'
import { VERSION } from '../lib/version.js'
import { parseHostedDids, createIsForeign, isIgnoredCollection, IGNORED_COLLECTIONS } from '../lib/hosted-dids.js'
import { join } from 'bare-path'
import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
import { decodeFrame } from 'atproto-store/lib/firehose.js'
import { Appview, INGEST_VERSION, threadPostList } from '../lib/appview.js'
import { parseLenses, lensesEnabled, lensEnv, parseLensPref, LENS_PREF_FILE } from '../lib/lenses.js'
import { createLensReader, extractThreadReplies, extractThreadParents } from '../lib/lens-import.js'
import { frontsDiag } from '../lib/fronts-diag.js'
import { fromApItem, fromReply, mergeNotifications } from '../lib/notifications.js'
import { createApInbox } from '../lib/ap-inbox.js'
import { tidNow } from 'atproto-store/lib/tid.js' // rkey minting for like/follow records
import { strongRef, parseAtUri } from '../lib/strongref.js'
import { resolveVerifyKey } from '../lib/plc.js'
import net from 'bare-net'
import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js'
import { createHandleResolver } from '../lib/handles.js'
import { createTcpResolveTxt, chooseNameservers } from '../lib/dns-txt.js'
import { createFollowerLens } from '../lib/followers.js'
import { createApFollowers } from '../lib/ap-followers.js'
import { createConstellationClient } from '../lib/constellation.js'
import { Writer, writeTransportConfigured } from '../lib/writer.js'
import { SCOPE_COLLECTION, DEFAULT_SCOPE, POST_COLLECTIONS } from '../lib/scopes.js'
import { createMuteStore } from '../lib/mutes.js'
import { createBookmarkStore } from '../lib/bookmarks.js'
import { listRecord, listitemRecord, ownLists, membersOf, memberRkey, listUri as mkListUri, subscriptionRkey, missingMembers, LIST_COLLECTION, LISTITEM_COLLECTION, PURPOSES, CURATE } from '../lib/lists.js'
import { gateRecord, choiceOf, describe as describeGate, canReply, mentionsOf, listUriOf, GATE_CHOICES } from '../lib/threadgate.js'
import { postgateRecord, quoteChoiceOf, canQuote, describeQuoteGate, QUOTE_CHOICES } from '../lib/postgate.js'
import { parseListUri, resolveRoster } from '../lib/roster.js'
import { LabelStore, postModeration } from '../lib/labels.js'
import { readLabelFeed, Labeler } from '../lib/labeler.js'
import { LabelerExport } from '../lib/labeler-export.js'
import { LabelFirehose } from '../lib/label-firehose.js'
import { decodeLabel } from 'atproto-identity/lib/labels.js'
import crypto from 'hypercore-crypto'
import bareWs from 'bare-ws'
import { timelinePage, profilePage, tagPage, renderAtom, esc } from '../lib/render.js'
import { FavelaAuth, handoffDecision } from '../lib/auth.js'
import { extractToken, extractIdentity, requireIdentity, requireRepoOwner, requireMember } from '../lib/check.js'
import { makeIsMember, siteVisibility, isPublicPath, PUBLIC, MEMBERS } from '../lib/gate-policy.js'
// Membership on top of repo ownership is OPT-IN per node. Default OFF on purpose: turning it on
// without the operator's DID on a roster would refuse their OWN posts — a self-inflicted outage
// shipped as a security fix. Flip it once the roster is confirmed.
const REQUIRE_MEMBERSHIP = String(process.env.FAVELA_REQUIRE_MEMBERSHIP || '').trim() === '1'
import { joinPairInvite, createPairInvite } from '../lib/pairing.js'
// Live device-invite tunnels, did -> { inviteHex, state, done, src }. In memory because a blind-pairing
// tunnel is a live swarm connection that must outlive the HTTP request that minted it; a restart drops
// any pending invite, which is correct — an invite nobody is holding open is not an invite.
const deviceInvites = new Map()
import { serveStatic } from '../lib/static.js'
import { loginPage, settingsPage, postPage, statusPage, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, editPage } from '../lib/render.js'
import bareOs from 'bare-os'
// Which box am I looking at? /status is read over ssh or a tunnel as often as locally, and a status page
// that does not name its host is how you diagnose the wrong machine.
const HOSTNAME = (() => { try { return bareOs.hostname() } catch { return null } })()
import { Communities } from '../lib/communities.js'
import { FeedGenerator, slugRkey } from '../lib/feedgen.js'
import { CommunityRooms, CommunityDiscovery, FeedRegistries } from 'favela-community'
import { makeFeedReactor } from '../lib/feed-reactor.js'
import b4a from 'b4a'
// CLI subcommands: `favela-web pair | devices | revoke` — run + exit BEFORE booting the server daemon.
// Lets a user manage pairing/devices with a clean verb instead of `bare bin/favela-pair.js`. (No args →
// fall through to the server, unchanged.)
{
const sub = process.argv[2]
const { CLI_COMMANDS, runCli } = await import('../lib/cli.js')
if (sub && CLI_COMMANDS.includes(sub)) {
await runCli(process.argv.slice(2))
process.exit(0)
}
}
const PORT = Number(process.env.FAVELA_PORT || 4041)
// bare-http1's listen(port) defaults to 127.0.0.1 (unlike Node) — bind FAVELA_HOST so a remote membrane
// (the atproto-bridge proxy, possibly on another VPN host) can reach the lens-export XRPC. Default local.
const HOST = process.env.FAVELA_HOST || '127.0.0.1'
const EXTERNAL_URL = (process.env.FAVELA_EXTERNAL_URL || `http://localhost:${PORT}`).replace(/\/$/, '')
// Where a no-JS form POST returns to. ONLY a same-site absolute path is honoured — an attacker-supplied
// `return` would otherwise turn every like button into an open redirect.
function safeReturn (v) {
const s = String(v == null ? '' : v)
return /^\/[^\/\\]/.test(s) ? s : '/'
}
// Live browser-pairing invites, keyed by DID — see POST /api/devices/pair. Each entry owns a Hyperswarm,
// so it MUST be closed; `close()` drains protomux before destroy (pairing.js) so the delegation frame
// reaches the joining browser rather than being cut off mid-flight.
const PAIR_TTL_MS = 5 * 60 * 1000
const pendingPairs = new Map()
async function closePendingPair (did) {
const p = pendingPairs.get(did)
if (!p) return
pendingPairs.delete(did)
clearTimeout(p.timer)
try { await p.source.close() } catch {}
}
// Write target: the LOCAL hpp device favela relays posts to (favela stays keyless; hpp signs).
// ⛔ NO DEFAULT. This used to fall back to wj62's live control key, which meant ANY favela-web started
// without config — a test, a spike, a second instance, the copy now running inside favela-android's
// worklet — wrote into production wj62 over the swarm. That is the exact failure lib/writer.js records
// from 2026-08-09 (a post landed in wj62 from a test run), one layer up and fleet-wide.
//
// Unset now means writes are REFUSED, not redirected. An instance that cannot name the node it writes
// to has no business guessing. Deployments set FAVELA_CONTROL_KEY explicitly (see package.json
// pgh.defaults.env); favela-android does not set it at all, because its writes go through the worklet's
// in-process Writer({device}) and must never leave the handset.
const WRITE_CONTROL = process.env.FAVELA_CONTROL_KEY || null
const WRITE_SEED = process.env.FAVELA_WRITER_SEED || 'wj62-swarm-test-writer-2026-07-05'
// Roster source. FAVELA_LIST (an app.bsky.graph.list AT-URI) resolves the member DIDs over P2P from the
// list owner's repo; else FAVELA_ROSTER (comma-separated DIDs); else the fleet default. verifyKeys are
// resolved from PLC per DID (no hardcoding).
// Remote AppViews this node may READ (lens import). Default is none: an unconfigured favela reaches out
// to nobody and behaves exactly as before. Opt in with FAVELA_BRIDGE_URL (one lens named 'bsky', read
// THROUGH our own bridge so no bsky URL or credential lands in this config) or FAVELA_LENSES.
// Malformed entries are reported and skipped rather than fatal — a typo in one lens must not stop the
// daemon booting, but it must not vanish silently either.
// The stored preference (settings page) layered over env. `let`, not `const`, because saving in the UI
// rebuilds these in place — a setting that needs a daemon restart to take effect is a config file with
// extra steps, and on a server nobody is going to restart for it.
function readLensPref () {
try { return JSON.parse(fs.readFileSync(STORE(LENS_PREF_FILE), 'utf8')) } catch { return null }
}
let LENS_PREF = readLensPref()
let { lenses: LENSES, errors: LENS_ERRORS } = parseLenses(lensEnv(process.env, LENS_PREF))
for (const e of LENS_ERRORS) console.error(JSON.stringify({ msg: 'lens config problem — entry ignored', error: e }))
let LENSES_ON = lensesEnabled(LENSES)
// One reader for the process: it owns the cache, the single-flight coalescing, the per-lens circuit
// breaker and the concurrency cap, so those are properties of THIS NODE rather than of how many people
// happen to open a thread at once.
let lensReader = LENSES_ON ? createLensReader({ doFetch: bareFetch }) : null
console.log(JSON.stringify({ msg: 'lens import', enabled: LENSES_ON, lenses: LENSES.map((l) => l.name) }))
const LIST = (process.env.FAVELA_LIST || '').trim()
// ⛔ NO FLEET DEFAULT. This used to fall back to the author's own DIDs, so a stranger who installed
// favela-web silently indexed SOMEONE ELSE'S timeline and nothing errored to say so. Unconfigured
// means EMPTY — the node indexes nothing until told whose repos it serves. Same rule the write
// transport already follows (lib/writer.js: an instance that cannot name its hpp must not guess).
const ROSTER = (process.env.FAVELA_ROSTER || '').split(',').map((s) => s.trim()).filter(Boolean)
// Optional verifyKey overrides (FAVELA_VERIFYKEYS="did=hex,did=hex") for DIDs not resolvable via the
// PLC directory — local/offline/test peers, or a roster owner whose DID isn't PLC-published. Members
// still resolve via PLC unless overridden.
const VERIFY_OVERRIDES = new Map(
(process.env.FAVELA_VERIFYKEYS || '').split(',').map((s) => s.trim()).filter(Boolean)
.map((p) => { const i = p.indexOf('='); return i > 0 ? [p.slice(0, i), p.slice(i + 1)] : null }).filter(Boolean)
)
// Trusted labelers (moderation, OPT-IN): FAVELA_LABELERS = "feedKeyHex=didKey,feedKeyHex=didKey".
// favela replicates each labeler's signed feed, verifies + stores its labels, and applies them at render.
const LABELERS = (process.env.FAVELA_LABELERS || '').split(',').map((s) => s.trim()).filter(Boolean)
.map((p) => { const i = p.indexOf('='); return i > 0 ? { feedKey: p.slice(0, i), didKey: p.slice(i + 1) } : null }).filter(Boolean)
// Render policy: label val -> hide|warn|inform|ignore. FAVELA_LABEL_POLICY = "nsfw=warn,spam=hide".
// (System vals !hide/!warn are always honored regardless of policy.)
const LABEL_POLICY = Object.fromEntries((process.env.FAVELA_LABEL_POLICY || '').split(',').map((s) => s.trim()).filter(Boolean)
.map((p) => { const i = p.indexOf('='); return i > 0 ? [p.slice(0, i), p.slice(i + 1)] : null }).filter(Boolean))
// Moderation lists (viewer-side MUTE, OPT-IN): FAVELA_MODLISTS = "at://owner/app.bsky.graph.list/rkey,…".
// A modlist is an app.bsky.graph.list (purpose=modlist) — same records as a roster; favela reads its
// members and MUTES them (their posts collapse at render). Owners not on PLC use FAVELA_VERIFYKEYS.
const MODLISTS = (process.env.FAVELA_MODLISTS || '').split(',').map((s) => s.trim()).filter(Boolean)
// Co-owned communities (DESIGN §3.5): FAVELA_COMMUNITY_ROOMS = "id=roomKeyHex,id=roomKeyHex". Each is a
// plaintext hyper-rooms autobase room favela reads KEYLESSLY (by public bootstrapKey) for its member roster.
// A signed-in viewer's timeline scopes to the union of member DIDs of the community rooms they belong to.
const COMMUNITY_ROOMS = (process.env.FAVELA_COMMUNITY_ROOMS || '').split(',').map((s) => s.trim()).filter(Boolean)
.map((p) => { const i = p.indexOf('='); return i > 0 ? { id: p.slice(0, i), roomKey: p.slice(i + 1) } : null }).filter(Boolean)
.filter((c) => /^[0-9a-fA-F]{64}$/.test(c.roomKey))
// Discoverable communities by NAME (no hex): FAVELA_COMMUNITY_NAMES = "cat lovers,pets". Each is resolved
// to its roomKey over the DHT at boot (CommunityDiscovery) and then treated exactly like a configured room.
const COMMUNITY_NAMES = (process.env.FAVELA_COMMUNITY_NAMES || '').split(',').map((s) => s.trim()).filter(Boolean)
// Feed registries (community-ux.md §95): FAVELA_FEED_REGISTRY = "regKeyHex,regKeyHex". Each is a keyless,
// watched FeedRegistry — the durable P2P allow-set of feeds. favela-web REACTS to its entries (add-feed →
// serve getFeedSkeleton; remove-feed → stop) with NO restart and NO per-community env. This SUPERSEDES the
// static FAVELA_COMMUNITY_* config: a feed published into the registry is picked up live. Roster-first —
// entries with a roomKey (community feeds, curation from the room) are served; light feeds (no room) are
// logged + skipped for now.
const FEED_REGISTRIES = (process.env.FAVELA_FEED_REGISTRY || '').split(',').map((s) => s.trim()).filter(Boolean)
.filter((k) => /^[0-9a-fA-F]{64}$/.test(k))
// Lens export (DESIGN §3.6): project communities into bsky-subscribable custom feeds via getFeedSkeleton.
// FAVELA_FEEDGEN_HOSTNAME = the public HTTPS host serving the XRPC endpoints (e.g. favela.7rnx.net) →
// serviceDid = did:web:<host>. FAVELA_PUBLISHER_DID = the DID whose repo holds the feed-generator records
// (the account the publish ceremony writes to). Both set → the feed-generator surfaces come online.
const FEEDGEN_HOSTNAME = (process.env.FAVELA_FEEDGEN_HOSTNAME || '').trim().replace(/^https?:\/\//, '').replace(/\/$/, '')
const PUBLISHER_DID = (process.env.FAVELA_PUBLISHER_DID || '').trim()
// Labeler export (DESIGN §3.6, moderation): FAVELA_LABELER_SEED enables favela's own atproto labeler,
// sharing the did:web identity (FAVELA_FEEDGEN_HOSTNAME) — its signing key is published as #atproto_label
// and its labels are served over com.atproto.label.queryLabels. FAVELA_LABELER_VALUES declares the vals.
const LABELER_SEED = (process.env.FAVELA_LABELER_SEED || '').trim()
const LABELER_VALUES = (process.env.FAVELA_LABELER_VALUES || 'spam,!hide,!warn').split(',').map((s) => s.trim()).filter(Boolean)
const SERVICE_DID = FEEDGEN_HOSTNAME ? 'did:web:' + FEEDGEN_HOSTNAME : null
// STABLE store dirs. Previously each was `'data/web-…-' + Date.now()`, which minted a FRESH Corestore set
// every boot and ABANDONED the prior set (measured: multiple GB of orphaned stores across restarts) AND
// forced a full P2P re-replication + appview re-index on every restart/OTA/crash. Reuse one dir per store so
// a restart RESUMES from the persisted cache. The appview is a rebuildable cache — a corrupt dir can be wiped.
const STORE = (name) => (process.env.FAVELA_STORE_ROOT || 'data') + '/' + name
const swarm = new Hyperswarm()
// ONE Corestore, NAMESPACED — not one Corestore per concern. Each Corestore is a separate RocksDB
// instance, and in practice an ACTIVE instance here costs ~75 MB of ALLOCATED blocks for a few hundred
// KB of live content, so opening four multiplied that by four. Measured end-to-end on ashatron: three
// separate stores = 225 MB actual; this one namespaced store = 75 MB.
//
// ⚠ Do NOT read that as an intrinsic per-store floor — it is not, and an earlier version of this comment
// wrongly asserted a formula (write_buffer_size * 1.1). Measured on ashatron
// (corestore 7.11.0 / hypercore-storage 3.2.0 / rocksdb-native 3.17.2):
// fresh store, ready() only -> apparent 70K, actual 100K (WAL size=842 allocated=4096)
// ready() + one 5-byte append -> apparent 75K, actual 104K (WAL size=5178 allocated=8192)
// So a new store is ~100 KB. What is real but NOT yet explained: some active stores carry a WAL with
// size=65K yet allocated=71M (allocated >> live, which only happens via preallocation), and the same
// logical store measures 4.4 MB on tabertron vs 75 MB on ashatron. Filesystem-dependent, under
// investigation — do not encode a formula here until the trigger is actually identified.
// The justification for consolidating is the 225M -> 75M measurement above, not any formula.
//
// Namespacing is safe for these three because none owns a PUBLISHED derived key (all verified):
// did — replicated DID/firehose cores + consumed labeler feeds, fetched BY KEY, and a by-key get is
// namespace-independent, so nothing re-derives
// idx — appview index + label cache, both rebuildable: indexPeer() re-folds from the shared
// content-addressed overlay store on LOCAL disk (no network); the cache re-fills from feeds
// comm — CommunityRoom / FeedRegistry replicas opened by BOOTSTRAP key; favela is a keyless READER
// (never .append/addWriter) so there is no autobase writer identity to lose
// The labeler store stays SEPARATE on purpose — see the note where it is constructed.
//
// ⚠ One-time cost, deliberately accepted: these namespaces derive DIFFERENT keys than the old
// per-dir stores, so the index rebuilds once on first boot and web-did/web-idx/web-comm are left
// orphaned on disk (untouched, so reverting this line restores them). NOT the every-boot churn the
// note above fixed — that was a new dir per boot; this is one rebuild, from local disk.
//
// Root via openCorestore (atproto-store's Corestore class): its guard is `storage instanceof Corestore`,
// which fails across duplicated corestore module copies, and namespaces of this root share that class.
const store = openCorestore(STORE('web'))
const didStore = store.namespace('did')
const indexStore = store.namespace('idx')
// SHARED record byte store (content-addressed files, ONE copy per machine). ATPROTO_OVERLAY_ROOT points at
// the SAME location hpp uses (e.g. ~/.config/hyper-proto-peer/data/atproto-repo) so co-located hpp+favela-web
// share the EXACT files — identical layout (roomKey16 = hash('atproto-base/v1/room-key' ‖ did)), byte-exact
// DAG-CBOR (idempotent for records hpp already wrote). Falls back to a favela-local dir when run standalone.
// The appview index (web-idx) now holds only POINTERS; the record bytes are never duplicated into it.
// handle -> DID, for @mention facets at compose time. A mention carries a DID, so an unresolvable handle
// gets NO facet and stays plain text (facets.js refuses to guess) — mentioning the wrong account sends a
// stranger a notification. HTTPS .well-known only: measured, *.bsky.social resolves this way and NOT via
// the DNS TXT path, so the DNS mechanism would silently answer for almost nobody. Cached per process;
// short timeout, because composing must not block on someone else's webserver.
const _handleDids = new Map()
async function resolveHandle (handle) {
const h = String(handle || '').toLowerCase()
if (!/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/.test(h)) return null
if (_handleDids.has(h)) return _handleDids.get(h)
let did = null
try {
const res = await fetch(`https://${h}/.well-known/atproto-did`, { headers: { accept: 'text/plain' }, timeout: 4000 })
if (res && res.ok) {
const body = (await res.text()).trim()
if (/^did:[a-z]+:[a-zA-Z0-9._:%-]+$/.test(body)) did = body
}
} catch { did = null }
_handleDids.set(h, did)
return did
}
const OVERLAY_ROOT = process.env.ATPROTO_OVERLAY_ROOT || STORE('atproto-repo')
const _recordStores = new Map()
async function getRecordStore (did) {
if (!_recordStores.has(did)) _recordStores.set(did, await new OverlayRepoStore(join(OVERLAY_ROOT, roomKey16(did)), { did }).ready())
return _recordStores.get(did)
}
// Handle resolution for bylines. The handle is NOT in the profile record — atproto keeps it in the DID
// document (alsoKnownAs) — and alsoKnownAs is a CLAIM by the DID, not proof, so the resolver verifies it
// bidirectionally before anything renders it. Both mechanisms are wired: /.well-known/atproto-did
// carries both handles we serve (measured), DNS TXT is the spec's alternative. Unverified ->
// 'handle.invalid', so a DID claiming someone else's handle can never render as that person.
//
// ⛔ NOT bare-dns. `bare_dns_resolve_txt` hands c-ares a pointer into a JS arraybuffer that nothing
// references, so any GC during a query frees it and the callback faults — a SIGSEGV, uncatchable from
// JS, which took the public backend down 819 times in a week. The addon is statically linked into the
// bare runtime, so it cannot be fixed by patching a dependency. See lib/dns-txt.js.
//
// This is NOT an optional path. Measured 2026-08-24: every custom-domain handle sampled — pfrazee.com,
// dholms.xyz, jay.bsky.team, bsky.app — verifies ONLY over DNS TXT, because their /.well-known is a
// redirect, a 404 or nothing. Hosted subdomains (*.bsky.social, our own *.7rnx.net) are the reverse.
// Without TXT, most of the interesting identities on the network render handle.invalid.
//
// Same resolver as before: whatever /etc/resolv.conf points at, which is what c-ares defaulted to. No
// third-party DoH, because handle verification is the last place to hand a stranger the list of every
// identity we look up.
//
// FAVELA_NAMESERVERS is for the one environment that HAS a resolver and no file naming it: Android,
// where netd owns resolution, there is no /etc/resolv.conf, and `net.dns1` has been empty since Android
// 8 (confirmed on a handset 2026-08-24). The embedder passes the OS's own servers for the active network
// — favela-android reads them from ConnectivityManager — so this is still the network's resolver, it
// just arrives by a different route, and the objection above to third-party resolvers is untouched.
//
// Without it the phone logs "verification is OFF" and every custom-domain handle renders
// handle.invalid, which by the measurement above is most of the interesting identities on the network.
// An unusable env value falls through to the file rather than shadowing it: exporting the variable
// wrong must not silently turn verification off on a server.
const { servers: nameservers, source: nsSource } = chooseNameservers({
envValue: process.env.FAVELA_NAMESERVERS,
resolvConf: (() => { try { return fs.readFileSync('/etc/resolv.conf', 'utf8') } catch { return null } })()
})
if (nameservers.length === 0) {
console.log('[favela] no nameserver (FAVELA_NAMESERVERS unset, none in /etc/resolv.conf) — DNS TXT handle verification is OFF')
} else {
console.log('[favela] DNS TXT handle verification via ' + nameservers.length + ' nameserver(s) from ' + nsSource)
}
const handleResolver = createHandleResolver({
plc: new PlcDirectory({ fetchImpl: bareFetch }),
doFetch: bareFetch,
// null disables the DNS path rather than pointing it at nothing.
resolveTxt: nameservers.length
? createTcpResolveTxt({ connect: (opts) => net.createConnection(opts), servers: nameservers })
: null
})
// isForeign gates whether the appview may CREATE record bytes: for a DID THE LOCAL HPP HOSTS, hpp is the
// authority and the sole author of those bytes, so an absent file means "not materialised here yet", not
// "ours to write". Same predicate the retention path uses (below) — one definition of "ours", not two
// that can drift.
//
// ⛔ This was `!ROSTER.includes(did)` until 0.3.37, and roster membership is NOT hosting. On a node that
// rosters a DID it does not co-host (ashatron rosters wj62 but hpp there serves only e2jev) every record
// was refused as "hpp owns these" while no local hpp would ever author them — its appview froze on
// 2026-07-31 and stayed frozen through two restarts. See lib/hosted-dids.js.
const HPP_CONFIG = process.env.PEER_CONFIG || join(process.env.HOME || '', '.config/hyper-proto-peer/config.json')
let HOSTED_DIDS = null
try { HOSTED_DIDS = parseHostedDids(fs.readFileSync(HPP_CONFIG, 'utf8')) } catch { HOSTED_DIDS = null }
// Unknown (no config / unreadable / malformed) falls back to ROSTER — the pre-0.3.37 behaviour. Roster ⊇
// hosted, so the fallback can only refuse MORE writes, never permit one the old code refused.
const isForeignDid = createIsForeign({ hosted: HOSTED_DIDS, roster: ROSTER })
console.log(JSON.stringify({
msg: 'byte-authorship scope',
source: HOSTED_DIDS ? 'local hpp config' : 'ROSTER (hpp config unreadable — conservative fallback)',
hosted: HOSTED_DIDS || ROSTER,
roster: ROSTER,
weMayAuthorFor: ROSTER.filter(isForeignDid)
}))
const av = await new Appview(indexStore, { getRecordStore, handleResolver, isForeign: isForeignDid }).ready()
// spec/convergence.md §8 Q4, answered: BOTH a boot warning and a `diff` line, plus its own verb.
//
// The three surfaces answer three different questions and none substitutes for another. `diff` reports
// what the AUTHORITY holds right now, and only for the DIDs you ask about. This reports what has already
// ARRIVED here across every DID, from the persisted ledger — so a collection that stopped appearing
// upstream is still visible, and so an operator who never runs `diff` still learns. Boot is the moment
// they are guaranteed to read output at all.
//
// Warns only for UNKNOWN. Ignored-by-policy is a decision on file; announcing it every boot would train
// the reader to skip the block, which is how a real warning gets missed.
try {
const unknown = await av.unknownCollections()
if (unknown.length) {
console.warn(JSON.stringify({
msg: 'UNKNOWN collections have been ingested and no view indexes them',
collections: unknown.map((u) => ({ collection: u.collection, count: u.count, firstSeen: u.firstSeen, lastSeen: u.lastSeen, dids: u.dids })),
action: 'favela-web collections — then classify each in lib/hosted-dids.js (spec/convergence.md §2.1)'
}))
}
} catch { /* a diagnostic must never block boot */ }
// Network-wide followers (§7.6 Tier C). Independent of the inbound lens on purpose: this is a READ-ONLY
// reference query — no store, no retention, nothing to clobber — so it stays on even when FAVELA_SPACEDUST
// is off. Shares the handle resolver so follower names are bidirectionally verified, not claimed.
// The ActivityPub side of the same identity. ap-bridge already serves a standard AP followers
// collection over HTTP, and its actor document declares which DID it is (`alsoKnownAs`) — so no new
// storage or transport is needed to READ it, only the URL. OFF unless FAVELA_AP_BRIDGE is set, and
// fails soft: if it is unreachable the follower page still renders its atproto followers.
const apFollowers = createApFollowers({
base: (process.env.FAVELA_AP_BRIDGE || '').trim() || null,
doFetch: bareFetch
})
if (apFollowers.enabled) console.log(JSON.stringify({ msg: 'ap followers: reading', base: process.env.FAVELA_AP_BRIDGE.trim() }))
// The rkeys ap-bridge has actually projected — the OBSERVED half of the reach badges (lib/reach.js).
// One outbox fetch per request, cached 60s inside the reader, and null (not empty) when the AP side
// is unconfigured or unreachable so "not projected" and "could not ask" never render alike.
//
// Scoped to the PRIMARY roster DID: ap-bridge fronts one actor, and its outbox only ever describes
// that DID. Asking about anyone else's posts would be a category error, not a miss.
async function apProjected () {
if (!apFollowers.enabled || !ROSTER.length) return null
try { return await apFollowers.projectedRkeys(ROSTER[0]) } catch { return null }
}
// The fediverse half of notifications. Local socket only — ap-bridge is single-instance and holds
// other people's messages to us, so it is not a public endpoint. Returns null when this node cannot
// ask, which the page renders as "cannot see them" rather than "none".
const apInbox = createApInbox({
home: process.env.HOME || '',
exists: (p) => { try { return fs.existsSync(p) } catch { return false } },
connect: async (p) => { const { Client } = await import('hyper-svc'); return Client.connectUds(p) }
})
const followerLens = createFollowerLens({
constellation: createConstellationClient({
doFetch: bareFetch,
...(process.env.FAVELA_CONSTELLATION ? { endpoint: process.env.FAVELA_CONSTELLATION.trim() } : {})
}),
handles: handleResolver
})
// ── inbound lens (DESIGN §7.6) — OPT-IN, default OFF ────────────────────────────────────────────
// Subscribes to spacedust for links that touch OUR roster, fetches each linking record from its
// author's PDS, VERIFIES it against the DID document's key, and retains the content-bearing ones.
// Off unless FAVELA_SPACEDUST is set: an always-on subscription to a third-party service is not
// something a node should inherit by upgrading. `1`/`true` uses the public instance; any other value
// is treated as the endpoint URL.
//
// Reuses getRecordStore, so a retained foreign record lands in the SAME per-DID OverlayRepoStore layout
// hpp uses — files, not cores. The retention index gets its own namespace so it can never collide with
// the appview's; per §7.5 it is bookkeeping only (skip-test + eviction order), so losing it costs a
// re-fetch, never correctness. The retained records are Tier-B cache: the authoritative copy lives in
// the author's PDS and is re-fetchable, which is why eviction is always safe.
const SPACEDUST = (process.env.FAVELA_SPACEDUST || '').trim()
const RETENTION_BUDGET = Number(process.env.FAVELA_RETENTION_BUDGET || 0) || null
// How many of our OWN recent posts to ask Constellation about per roster DID. Bounds the sweep.
const BACKFILL_POSTS = Number(process.env.FAVELA_BACKFILL_POSTS || 50)
let inbound = null
if (SPACEDUST) {
const { createInboundLens } = await import('../lib/inbound.js')
const { createBeeIndex } = await import('../lib/retention.js')
const { createBareSocket } = await import('../lib/spacedust-socket.js')
const Hyperbee = (await import('hyperbee')).default
const retentionBee = new Hyperbee(store.namespace('retention').get({ name: 'retention' }), {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
await retentionBee.ready()
const isFlag = SPACEDUST === '1' || SPACEDUST.toLowerCase() === 'true'
inbound = createInboundLens({
roster: ROSTER,
openStore: getRecordStore,
appview: av,
index: createBeeIndex(retentionBee),
doFetch: bareFetch,
createSocket: createBareSocket,
// OUR OWN record at-uris — what Constellation is asked about. NOT our DIDs: its `subject` is an
// EXACT target, so a DID matches nothing for reply/quote sources and returns a silent zero that
// looks exactly like a quiet roster (measured live 2026-07-30). Spacedust is not symmetric here —
// its subject_did axis DOES extract DIDs from at-uris, so the live filter above is correctly
// keyed by roster DID.
//
// Bounded to the most recent BACKFILL_POSTS per roster DID: a full-history sweep is exactly the
// unbounded crawl §7.6 warns about, and recent posts are where inbound replies actually land.
// Known inefficiency: getAuthorFeed HYDRATES (a record read + profile + three counts per post)
// when all we want is the uri. Bounded and infrequent (cold start + roster change), so it is
// accepted for now rather than reaching into the private _author index.
listSubjectUris: async (dids) => {
const uris = []
for (const did of dids) {
try {
// getAuthorFeed returns { posts, cursor } — NOT a bare array. Iterating the object throws,
// and a swallowing catch turns that into a silent zero (it did, live, for a full retry cycle).
const { posts } = await av.getAuthorFeed(did, { limit: BACKFILL_POSTS })
for (const p of posts || []) if (p && p.uri) uris.push(p.uri)
} catch (err) {
// One unreadable author must not abort the sweep — but never swallow it. A silent skip here
// is indistinguishable from "nobody has replied to us", which is the whole failure mode.
console.log(JSON.stringify({ msg: 'inbound', event: 'subject-uris-failed', did, error: (err && err.message) || String(err) }))
}
}
return uris
},
// ⛔ NEVER retain into a repo hpp AUTHORS. Constellation reports ANY record linking to our subjects
// — including our OWN replies to our own posts — and writing those back over hpp's canonical files
// re-encoded them, dropped `langs`, and wedged the forward-only sign gate (2026-07-31: two wj62
// replies never federated). The set hpp signs for on this node is what its OWN config hosts — not
// the roster, which also names DIDs other nodes host (see lib/hosted-dids.js). Same function object
// as the appview gate above, so the two can never drift.
isForeign: isForeignDid,
budgetBytes: RETENTION_BUDGET,
...(isFlag ? {} : { endpoint: SPACEDUST }),
...(process.env.FAVELA_CONSTELLATION ? { constellationEndpoint: process.env.FAVELA_CONSTELLATION.trim() } : {}),
onEvent: (event, detail) => {
// Drops, refusals and truncations are invisible unless the daemon says so — §7.6's whole posture.
if (event === 'retained' || event === 'evicted') return // too chatty for steady state
console.log(JSON.stringify({ msg: 'inbound', event, ...detail }))
}
})
// ⛔ NOT AWAITED. This is the inbound lens: it subscribes to a remote index and backfills over HTTP.
// Awaiting it made a LOCAL, P2P-FIRST node refuse to serve until a REMOTE WEB SERVICE answered — and
// on 2026-08-24 that is exactly what happened. The bridge saturated, this fetch had no deadline
// (bare-fetch silently drops `timeout`: its Request destructures only body/method/headers/signal/
// agent, verified in node_modules/bare-fetch/lib/request.js), and the daemon hung in boot forever
// while every P2P capability it owns was already available. Both backends did it simultaneously,
// which left the proxy with no healthy node, which kept the fetch from ever completing — a deadlock
// that could only be broken by restarting Apache.
//
// A phone, tabertron and ashatron are P2P nodes. Nothing on the web side may gate them serving.
// The lens is an ENHANCEMENT: it fills in replies from outside this node's window. Without it the
// timeline still renders, so it starts in the background and reports failure instead of blocking.
//
// Deliberately not "bounded with a timeout" either. A deadline would have turned a permanent hang
// into a slow boot, when the correct answer is that a P2P node's readiness does not depend on this
// at all.
inbound.start().then(
() => console.log(JSON.stringify({ msg: 'inbound lens', enabled: true, subjects: ROSTER.length, budgetBytes: RETENTION_BUDGET })),
(e) => console.error(JSON.stringify({ msg: 'inbound lens FAILED to start — serving anyway, replies from outside this node will be missing', error: (e && e.message) || String(e) }))
)
} else {
console.log(JSON.stringify({ msg: 'inbound lens', enabled: false }))
}
// ONE announce-channel per connection, dispatched to DidPeers by DID (the multi-DID fix — a per-peer
// attach() would collide on the single ANNOUNCE_PROTOCOL channel and only the first-added DID would
// ever learn its cores). The router owns corestore replication + the shared channel.
// Give the router the SWARM (not just manual onconnection): this arms the canonical _refreshFronts loop —
// the pull-refresh staleness-ceiling that update({wait:true})s a replicated firehose core which saw no
// 'append' and re-picks the FRESHEST co-owner front. Without it a co-owned DID's tail silently follows a
// stalled front while another advances (the bridge stays current precisely because it wires this). The
// router owns the 'connection' handler when given the swarm, so we no longer add our own.
// rpc:true arms ONE protomux-rpc per connection, which is what lets a front RELAY a request to the peer
// that actually holds the data. Needed for getBlob: favela serves blobs from the SHARED overlay files,
// which only works for a DID whose bytes are co-located on this box. Every other DID 404'd — measured:
// wj62 had its 1 avatar blob locally and served, while e2jev (blobs on ashatron, in the LEGACY Hyperbee
// store that never replicates) returned BlobNotFound for every image. The bridge has always handled this
// by relaying get-blob to the holder; favela had no fallback at all. Read-only use — favela holds no key.
const router = new DidPeerRouter(didStore, { swarm, rpc: true })
const peers = []
const byDid = new Map()
const delay = (ms) => new Promise((r) => setTimeout(r, ms))
// Ask the peer that HOLDS a blob for its bytes, when they are not in our shared overlay dir. This is the
// same relay the keyless bridge performs (DidPeer.getBlob -> the holder's hpp `get-blob` RPC), and it is
// the only way to serve a DID whose blobs are not co-located — e.g. one still on the LEGACY Hyperbee blob
// store, which never replicates, so no amount of waiting would bring the bytes here.
//
// STRICTLY BOUNDED: this runs inside an HTTP request, so every step is capped and every failure returns
// null (the caller then 404s as before). A front with no RPC channel, an unreachable holder, or a holder
// that simply lacks the blob must degrade to "not found", never hang the page. Returns bytes or null.
// Every failure here USED to collapse into `return null`, so a 404 could not be told apart from "no
// front", "front with no RPC channel", "holder timed out" or "holder does not have it" — four faults
// with four different fixes. That cost a round trip on e2jev: the relay returned in 9ms (so it bailed on
// a guard rather than trying) and nothing said which. Same mistake as the byte-resolution message that
// asserted a content fault for a transport fault (hyper-social #14, lesson 2), reproduced immediately
// after quoting it. Each guard now names itself.
//
// The dedup key includes the DID's front/RPC topology, not just the reason — a line keyed on the reason
// alone is emitted once and then indistinguishable from a live failure, which is the trap #14 recorded
// for _logFetchMiss. Keyed this way it re-emits the moment connectivity changes.
// Keep a blob we had to fetch, so the next reader is served from disk and this node becomes a source.
//
// Writes into the SAME shared content-addressed dir hpp uses (blob-serve.js keeps that derivation in
// one place), so a co-located hpp sees the file too rather than a second copy appearing beside it.
//
// .part then rename: a reader hitting the path mid-write must never get a truncated image, and a
// truncated file under a content-addressed name would be served as fact forever. Same reason
// favela-android stages its OTA bundle that way.
//
// The CALLER verifies the bytes against the cid first. This function must never be handed unverified
// bytes — the filename is the hash, so writing the wrong body poisons the name permanently.
async function retainBlob (did, cid, bytes) {
const dest = blobPath(OVERLAY_ROOT, did, cid)
const dir = dest.slice(0, dest.lastIndexOf('/'))
await mkdir(dir, { recursive: true })
const part = dest + '.part'
await writeFile(part, bytes)
await rename(part, dest)
console.log(JSON.stringify({ msg: 'blob retained from relay — this node can now serve it', did, cid: String(cid).slice(0, 20), bytes: bytes.length }))
}
const _relayMiss = new Set()
function relayFronts (did) {
try {
const d = router.diagnose()
const mine = (d.fronts || []).filter((f) => f.did === did)
return { fronts: mine.length, withRpc: mine.filter((f) => f.rpcConnected).length, live: mine.filter((f) => f.live).length, keys: mine.map((f) => String(f.firehoseKey).slice(0, 12)) }
} catch { return { fronts: null, withRpc: null, live: null, keys: [] } }
}
function logRelayMiss (did, cid, reason, extra = {}) {
const t = relayFronts(did)
const k = did + ':' + reason + ':' + t.fronts + ':' + t.withRpc + ':' + t.live
if (_relayMiss.has(k)) return
_relayMiss.add(k)
try {
console.error(JSON.stringify({
msg: 'getBlob relay could not obtain the bytes — serving 404',
did, cid: String(cid).slice(0, 20), reason, ...extra, ...t
}))
} catch {}
}
const RELAY_BLOB_MS = 8000
async function relayBlob (did, cid) {
let front
try {
front = await Promise.race([
router.front(did, { timeout: RELAY_BLOB_MS }),
delay(RELAY_BLOB_MS).then(() => '__timeout__')
])
} catch (e) {
// router.front throws for a DID it does not front at all ("not a fronted DID") — a WIRING fault,
// distinct from a holder that simply lacks the blob.
logRelayMiss(did, cid, 'front-lookup-failed', { error: e.message })
return null
}
if (front === '__timeout__') { logRelayMiss(did, cid, 'front-lookup-timeout', { ms: RELAY_BLOB_MS }); return null }
if (!front) { logRelayMiss(did, cid, 'no-frontable-co-owner'); return null }
if (!front.rpc) {
// The suspected e2jev case: we replicate this DID's cores from a peer that SEEDS them rather than
// holds its key (a keyless bridge/relay announces the same cores), so there is no get-blob responder
// to ask. `withRpc` in the payload says whether ANY front for this DID has a channel.
logRelayMiss(did, cid, 'front-has-no-rpc-channel', { rev: front.head ? front.head.rev : null })
return null
}
let r
try {
r = await Promise.race([front.getBlob(cid), delay(RELAY_BLOB_MS).then(() => '__timeout__')])
} catch (e) {
logRelayMiss(did, cid, 'rpc-error', { error: e.message })
return null
}
if (r === '__timeout__') { logRelayMiss(did, cid, 'rpc-timeout', { ms: RELAY_BLOB_MS }); return null }
if (!r || !r.bytes) {
// Reached a holder and it answered: it genuinely does not have this blob. A CONTENT fault, not a
// transport one — the distinction that matters when deciding whether to chase wiring or bytes.
logRelayMiss(did, cid, 'holder-does-not-have-it')
return null
}
// The peer sends bytes hex-encoded in the c.json RPC value (serve-membrane's get-blob responder).
return b4a.from(r.bytes, 'hex')
}
// Bare has no global AbortController; the firehose sequencer only needs a signal with { aborted,
// add/removeEventListener('abort') }, so supply a minimal one (used to cut over a firehose tail on failover).
function makeAbort () {
const ls = new Set()
const signal = { aborted: false, addEventListener: (_t, fn) => ls.add(fn), removeEventListener: (_t, fn) => ls.delete(fn) }
return { signal, abort () { if (signal.aborted) return; signal.aborted = true; for (const fn of [...ls]) { try { fn() } catch {} } ls.clear() } }
}
// Open (once) a DidPeer for a DID: resolve its verifyKey, join its topic, and wire the hybrid ingest
// (snapshot baseline + incremental firehose tail). Deduped so the list owner and a member DID don't
// double-open the same cores. Returns the peer, or null if verifyKey resolution failed.
async function addPeer (did) {
if (byDid.has(did)) return byDid.get(did)
let verifyKey
const override = VERIFY_OVERRIDES.get(did)
if (override) { verifyKey = b4a.from(override, 'hex') } else {
try { verifyKey = await resolveVerifyKey(did, { fetch: bareFetch }) } catch (e) { console.error('[favela] verifyKey resolve failed for', did, '-', e.message); return null }
}
// EVERY DID is noRepo: follow the firehose ONLY (verified head + tail) and NEVER replicate the repo core —
// no duplicate copy. Records live once in the shared content-addressed OverlayRepoStore. coHosted decides
// the BASELINE source: a DID whose records hpp already holds on this box (wj62/loser-club on tabertron)
// reads them from the AUTHORITATIVE shared store (bypasses the membrane fork/desync); a non-co-hosted DID
// rebuilds its repo from the FIREHOSE BACKFILL (subscribeRepos(0) — each #commit frame carries the bytes).
// ⛔ CO-HOSTED MEANS hpp HOLDS IT, NOT "WE HAVE SOME RECORDS FOR IT". This was
// `(await store.records()).length > 0`, which is SELF-FULFILLING — the gate below says so in its own
// comment: our own putPost populates the shared store for ingested peers too. So the first time we
// glanced at a DID it acquired records, became "co-hosted" for ever, and from then on every boot
// tailed from the CURRENT END and could never fill a gap.
//
// Measured on a handset (hyper-social #53): ashatron viewed once in August, then frozen — posts 16
// days stale and the PREVIOUS avatar rendering as current, because the profile record was inside the
// hole. It reads as corruption rather than as a sync gap, which is the worst way for it to fail.
//
// hpp's config is the authority (the same source /api/track calls HOSTED-FIRST for the same reason).
// A DID we actually host is authoritative on disk and rightly tails from the end; anything else
// rebuilds from the firehose, which is the only copy that can close a gap.
//
// ⚠ Non-hosted DIDs therefore replay from seq 0 on each boot. That is idempotent (putPost/putProfile
// dedup by content signature, so a replay re-reads without re-writing) but it re-reads frames every
// launch. The real answer is a persisted per-DID fold cursor so a boot resumes instead of replaying;
// until that exists, correct-and-costly beats cheap-and-frozen.
const coHosted = (HOSTED_DIDS || []).includes(did)
console.log(JSON.stringify({ msg: 'firehose-only (noRepo) DID', did, baseline: coHosted ? 'shared-store' : 'firehose-backfill' }))
const peer = router.add(did, { verifyKey, noRepo: true, onError: (e) => console.error('[peer]', did, e.message) })
byDid.set(did, peer); peers.push(peer)
swarm.join(discoveryTopic(did), { client: true, server: true })
// FAILOVER-AWARE ingest (noRepo, event-driven, no poll). A CO-OWNED DID can move to a DIFFERENT co-owner's
// firehose core (a new firehoseKey) at any time; following one fixed core FREEZES the index at the failover
// point (the loser-club stall — wj62/ashatron both froze at their July co-owner failovers). So resolve the
// FRESHEST verified front (router.front → highest-rev co-owner), baseline + live-tail IT, and when the router
// opens a fresher front, abort the tail and re-resolve + re-baseline (the new co-owner's firehose is a
// distinct history). Baseline source per coHosted: the AUTHORITATIVE shared store (indexPeer, which for a
// noRepo peer reads OverlayRepoStore — needs ATPROTO_OVERLAY_ROOT → hpp's dir) vs firehose backfill from 0.
// #sync re-baselines within a front.
;(async () => {
let lastKey = null
while (byDid.has(did)) {
let front
try { front = await router.front(did, { timeout: 60000 }) } catch (e) { await delay(5000); continue }
const fhKey = front._firehoseKey ? b4a.toString(front._firehoseKey, 'hex') : 'primary'
if (fhKey !== lastKey) { console.log(JSON.stringify({ msg: 'ingest: following firehose front', did, front: fhKey.slice(0, 12) })); lastKey = fhKey }
const ac = makeAbort()
// Watch for a FRESHER front (a failover to another co-owner) → abort this tail so the loop re-resolves.
const watch = setInterval(() => { try { const f = router._freshestFront(did); if (f && f !== front) ac.abort() } catch {} }, 5000)
if (watch.unref) watch.unref()
try {
if (coHosted) { try { await av.indexPeer(front) } catch (e) { console.error('[favela] baseline index failed', did, '-', e.message) } }
// Sync the replicated firehose core's length/backlog BEFORE reading it: a freshly-opened replica
// reads length 0, and 'append' only fires for frames appended AFTER we connect — so without this the
// tail sits idle on the peer's existing history (this is exactly what FleetRelay.addSource does).
// Bounded so a slow/offline peer can't hang the loop; _refreshFronts keeps it fresh thereafter.
if (front.seq && front.seq.core) { try { await Promise.race([front.seq.core.update({ wait: true }), delay(8000)]) } catch {} }
// BACKFILL GATE. The coHosted fast path tails from the current end, which is right for a DID whose
// records the shared store already holds authoritatively — but it is self-fulfilling, because our
// own putPost populates that store for ingested peers too. So once this build indexes MORE than the
// build that last folded this DID (INGEST_VERSION), replay from 0 regardless of coHosted. Idempotent:
// putPost/putProfile are dedup-gated by content signature, so a replay re-reads without re-writing.
const backfill = await av.needsBackfill(did).catch(() => true)
const target = front.seq && front.seq.core ? front.seq.core.length : 0
const from = (coHosted && !backfill) ? target : 0
if (backfill) console.log(JSON.stringify({ msg: 'ingest: BACKFILL from seq 0 (this build indexes more than the last fold)', did, frames: target, ingestVersion: INGEST_VERSION }))
// An empty firehose has nothing to replay — mark it done now or every boot re-announces a backfill.
let marked = false
if (backfill && target === 0) { await av.markBackfilled(did); marked = true }
let seen = 0
for await (const { bytes } of front.subscribeRepos(from, { signal: ac.signal })) {
try {
const d = decodeFrame(bytes)
if (!d) continue
if (d.t === '#commit') await av.applyCommit(did, await DidPeer.commitOps(d.body))
else if (d.t === '#sync') await av.indexPeer(front) // re-baseline: chain discontinuity
} catch (e) { console.error('[favela] ingest frame failed', did, '-', e.message) }
// Mark ONLY once the replay has reached the length the front had when we started — not on
// starting one. A crash mid-replay must leave the marker unset so the next boot retries.
seen++
if (backfill && !marked && seen >= target) {
await av.markBackfilled(did)
marked = true
console.log(JSON.stringify({ msg: 'ingest: backfill complete', did, frames: seen, ingestVersion: INGEST_VERSION }))
}
}
} catch (e) {
if (!ac.signal.aborted) console.error('[favela] firehose loop', did, '-', e.message)
} finally { clearInterval(watch) }
await delay(1000) // brief backoff, then re-resolve the freshest front (failover cutover or reconnect)
}
})().catch((e) => console.error('[favela] ingest loop', did, '-', e.message))
// CO-HOSTED reconciliation — EVENT-DRIVEN (no polling timer). The co-located hpp writes each new record as
// a file into the SHARED store; that filesystem write IS the event. `fs.watch` the timeline collection dirs
// and re-index when a record lands — so favela stays current from the AUTHORITATIVE local store even when
// the firehose front's live tail is stalled on a lagging co-owner. Silent when idle (watch fires nothing);
// a short coalescing delay collapses a burst of writes into one indexPeer (event-triggered, not periodic).
// Non-co-hosted DIDs (no shared store) rely on the firehose backfill/tail above.
if (coHosted) {
const recDir = join(OVERLAY_ROOT, roomKey16(did), 'records')
let coalesce = null
const onChange = () => {
if (coalesce) return
coalesce = setTimeout(() => { coalesce = null; av.indexPeer(peer).catch((e) => console.error('[favela] re-index failed', did, '-', e.message)) }, 400)
if (coalesce.unref) coalesce.unref()
}
for (const col of [SCOPE_COLLECTION.public, SCOPE_COLLECTION.local]) {
try {
const dir = join(recDir, col)
fs.mkdirSync(dir, { recursive: true }) // ensure it exists before watching (fresh DID may lack it)
const w = fs.watch(dir, onChange)
if (w && w.on) w.on('error', () => {})
} catch (e) { console.error('[favela] record-dir watch setup failed', did, col, '-', e.message) }
}
}
return peer
}
// ── THE SUBSCRIPTION LIST, AND WHY IT IS NOT CONFIGURATION ───────────────────────────────────────────
// WHICH identities a node subscribes to was node-local: tabertron pinned FAVELA_ROSTER in apps.json
// while a handset derived its own from hosted + locally tracked DIDs. Same DID, two sources of truth,
// and they disagreed — ashatron was indexed on one and invisible on the other, which surfaced as a
// profile frozen two weeks in the past rather than as a missing subscription (hyper-social #53).
//
// A list is the fix that already exists: an app.bsky.graph.list in the owner's OWN repo, membership as
// listitems, resolved by lib/roster.js. It replicates with the identity, so every device hosting that
// DID converges on the same subscriptions with nothing configured anywhere.
//
// So look for one at a well-known rkey on the DID we host, when no FAVELA_LIST is set explicitly. No new
// substrate, no new env var to remember, and a second device inherits the roster by replication.
const SUBSCRIPTIONS_RKEY = 'subscriptions'
let listUri = LIST
if (!listUri) {
const selfDid = (HOSTED_DIDS || [])[0] || null
if (selfDid) {
const candidate = `at://${selfDid}/${LIST_COLLECTION}/${SUBSCRIPTIONS_RKEY}`
try {
const store = await getRecordStore(selfDid)
if (await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY)) {
listUri = candidate
console.log(JSON.stringify({ msg: 'roster: using the replicated subscriptions list', list: candidate }))
}
} catch (e) {
console.error(JSON.stringify({ msg: 'roster: could not read the subscriptions list (env roster still in force)', error: e.message }))
}
}
}
// Resolve the roster: from a list (open the owner first to READ it, then use its member DIDs) or the
// env fallback. A list-resolve failure falls back to FAVELA_ROSTER rather than coming up empty.
let rosterDids = ROSTER
if (listUri) {
try {
const { owner } = parseListUri(listUri)
const ownerPeer = await addPeer(owner)
if (!ownerPeer) throw new Error('could not open the list owner peer')
await ownerPeer.ready({ timeout: 60000 })
const r = await resolveRoster(ownerPeer, listUri)
// ⛔ HOSTED DIDs ARE NOT OPTIONAL. `rosterDids = r.members` alone would drop this node's OWN identity
// out of the roster the moment a list resolves — it would stop indexing itself, which is invisible
// until every one of your own posts is missing. Hosted FIRST, because addPeer runs in roster order
// and apProjected() asks about ROSTER[0] specifically.
rosterDids = [...(HOSTED_DIDS || []), ...r.members].filter((d, i, a) => a.indexOf(d) === i)
console.log(`[favela] list "${r.name || listUri}" -> ${r.members.length} member(s), ${rosterDids.length} in roster with hosted`)
} catch (e) { console.error('[favela] list resolve failed:', e.message, '- falling back to FAVELA_ROSTER'); rosterDids = ROSTER }
}
for (const did of rosterDids) await addPeer(did)
await swarm.flush()
console.log(`[favela] joined ${peers.length} DID topic(s); indexing over P2P...`)
// Moderation (opt-in): replicate each trusted labeler's signed feed and verify+store its labels. The
// labeler feed cores live on didStore, so the router's per-connection didStore.replicate already
// carries them once we join the feed's discoveryKey. Labels apply at render — posts are never deleted.
const labelStore = await new LabelStore(indexStore, { name: 'favela-labels' }).ready()
for (const { feedKey, didKey } of LABELERS) {
try {
const core = didStore.get({ key: b4a.from(feedKey, 'hex'), valueEncoding: 'binary' })
await core.ready()
swarm.join(core.discoveryKey, { client: true, server: true })
readLabelFeed(core, didKey, labelStore, { onError: (e) => console.error('[labeler]', e.message) })
console.log(`[favela] labeler subscribed: ${didKey.slice(0, 24)}… feed=${feedKey.slice(0, 12)}…`)
} catch (e) { console.error('[favela] bad labeler', feedKey, '-', e.message) }
}
if (LABELERS.length) await swarm.flush()
// Moderation lists (opt-in mute): resolve each subscribed modlist's members over P2P (same list
// machinery as the roster) into a muted-author set. Their posts collapse at render.
const MUTED = new Set()
for (const listUri of MODLISTS) {
try {
const { owner } = parseListUri(listUri)
const ownerPeer = await addPeer(owner)
if (!ownerPeer) continue
await ownerPeer.ready({ timeout: 60000 })
const r = await resolveRoster(ownerPeer, listUri)
for (const did of r.members) MUTED.add(did)
console.log(`[favela] modlist "${r.name || listUri}" -> ${r.members.length} muted author(s)`)
} catch (e) { console.error('[favela] modlist resolve failed:', listUri, '-', e.message) }
}
// Enrich a feed result with the two viewer-side moderation layers: labeler labels + modlist mute.
// Attaches { labels:[val], treatment, muted } per post; the render collapses hide/warn/muted.
const uriOf = (p) => `at://${p.did}/${(p.record && p.record.$type) || 'app.bsky.feed.post'}/${p.rkey}`
// Per-viewer mutes + block bookkeeping. NODE-LOCAL: a mute is private in atproto and must never become
// a record (see lib/mutes.js). Lives in the appview's own store, which is a rebuildable cache and is
// never replicated — the right home for something that must not leave this machine.
const muteStore = createMuteStore(av.bee.sub('mutes'))
// Private, node-local, per viewer — see lib/bookmarks.js for why this is deliberately NOT a record.
const bookmarkStore = createBookmarkStore(av.bee.sub('bookmarks'))
// `viewerDid` is now threaded in, because "muted" is a property of a READER, not of a post. The node-wide
// MODLIST set (an operator subscribing the whole node to a moderation list) is UNIONED with the viewer's
// own mutes rather than merged into storage — they are different facts with different owners.
//
// A BLOCKED account is hidden outright rather than collapsed: a mute says "not interested", a block says
// "not in my feed", and rendering a block as a revealable <details> would make the stronger action look
// weaker than the softer one.
async function withMod (result, viewerDid = null) {
const mutedByViewer = viewerDid ? await muteStore.mutedBy(viewerDid) : new Set()
// Blocks come from the INDEXED records now (any device, any peer), UNIONED with this node's own
// bookkeeping. The union matters during the changeover: a block made here before 0.3.27 has local
// bookkeeping and may not be re-indexed yet, and dropping it would silently unblock someone.
const blocked = new Set()
if (viewerDid) {
for (const d of await av.blocking(viewerDid)) blocked.add(d)
for (const d of await muteStore.blockedBy(viewerDid)) blocked.add(d)
}
if (blocked.size) result.posts = result.posts.filter((p) => !p || !blocked.has(p.did))
for (const p of result.posts) {
const mod = postModeration(await labelStore.getLabels(uriOf(p)), MUTED.has(p.did) || mutedByViewer.has(p.did), LABEL_POLICY)
if (mod.labels.length) p.labels = mod.labels
if (mod.treatment !== 'ignore') p.treatment = mod.treatment
if (mod.muted) p.muted = true
}
return result
}
// Write path: relay posts to the local hpp device (keyless — hpp signs). The authoring peer's
// firehose 'append' listener re-indexes automatically, so a new post appears without a manual poke.
// Guarded behind a Proxy rather than a check in each of the ~12 write routes: a per-route check is one
// forgotten route away from the hazard coming back, and the routes are added to regularly. Every method
// call throws the same named error, so a refusal reads as a refusal instead of a stack trace about a
// null controlKey.
const _writer = new Writer({ controlKey: WRITE_CONTROL, writerSeed: WRITE_SEED })
// WHICH TRANSPORT — swarm (FAVELA_CONTROL_KEY, a REMOTE hpp) or local UDS (FAVELA_HPP_SOCK, an hpp on
// this box). The gate used to test the control key ALONE, so a CO-LOCATED instance was refused its own
// local device: favela-android embeds this server beside an hpp whose control socket is right there, and
// it still logged "writes are DISABLED" and refused every post. Reads were unaffected, which is why the
// phone looked like it was working. See writeTransportConfigured for why this reads the ENV and not the
// Writer — DEFAULT_SOCK must never open the gate by itself.
const WRITE_TRANSPORT = writeTransportConfigured(process.env)
if (!WRITE_TRANSPORT) {
console.error(JSON.stringify({
msg: 'no write transport configured — writes are DISABLED for this instance (reads are unaffected)',
why: 'an instance that cannot name the hpp it writes to must not guess; there is no default',
fix: 'set FAVELA_HPP_SOCK to a co-located hpp control socket, or FAVELA_CONTROL_KEY for a remote one'
}))
} else {
console.log(JSON.stringify({ msg: 'write transport', transport: WRITE_TRANSPORT, uds: WRITE_TRANSPORT === 'uds' ? process.env.FAVELA_HPP_SOCK : null }))
}
const writer = WRITE_TRANSPORT
? _writer
: new Proxy({}, {
get (_t, prop) {
// Lifecycle, not writes — a caller must still be able to shut down cleanly. `then` must stay
// undefined or an `await` on this object would treat it as a thenable and hang.
if (prop === 'close' || prop === 'then') return undefined
// REJECTED PROMISE, not a synchronous throw. The login path does
// `await writer.isDeviceRevoked(k).catch(() => false)` — a sync throw escapes that .catch and
// would break sign-in on exactly the instances that have writes disabled (the phone). Rejecting
// preserves the documented fail-open: registry unreachable → treated as not-revoked.
return () => Promise.reject(new Error('writes disabled: FAVELA_CONTROL_KEY is not set on this instance'))
}
})
// ── CONVERGE THE SUBSCRIPTION LIST ACROSS EVERY DEVICE OF THIS IDENTITY ──────────────────────────────
// Devices that host the same DID ARE the same identity, so they should agree on what it subscribes to
// without anyone configuring anything. This merges each node's local roster into the identity's own
// app.bsky.graph.list, which then replicates like any other record.
//
// ⛔ GROW-ONLY UNION, NEVER A REPLACE. The first cut wrote the list wholesale, which made "who booted
// first" decide everyone's roster — a handset won that race against tabertron on 2026-08-25 and its
// derived set became authoritative for the identity (#53). A union is order-independent instead: each
// node contributes only what the list lacks, so any interleaving converges on the same set and there is
// no winner.
//
// ⛔ DETERMINISTIC rkeys. A fresh TID per add means two devices contributing the same DID concurrently
// write TWO records for one membership. resolveRoster dedups by subject so it READS correctly, but the
// repo accumulates duplicates forever. Deriving the rkey from the subject makes the add itself
// idempotent: same membership, same record, second write is a no-op.
//
// ⛔ CONTRIBUTE ONCE PER NODE, then treat the list as authoritative. Grow-only and "re-merge my local
// set every boot" together mean an unsubscribe can never stick: delete a member on one device and the
// next device to boot re-adds it from its own stale local set. The marker below makes contribution a
// one-time act, so removals survive.
//
// Steady state is ZERO writes: nothing new to contribute, nothing written.
const SUBSCRIPTIONS_MARKER = STORE('subscriptions-merged')
// The hash behind the deterministic rkey (lib/lists.js explains why it must be deterministic).
const rkeyHash = (subject) => b4a.toString(crypto.data(b4a.from(subject)), 'hex')
if (!LIST && (HOSTED_DIDS || []).length) {
const selfDid = HOSTED_DIDS[0]
const uri = `at://${selfDid}/${LIST_COLLECTION}/${SUBSCRIPTIONS_RKEY}`
;(async () => {
try {
const st = await writer.state().catch(() => null)
if (!st || st.did !== selfDid) return // this node does not sign for the identity — not ours to write
// What this node knows, minus itself: hosted DIDs are subscribed by virtue of being hosted, and a
// list entry for yourself says nothing.
const local = ROSTER
const contributed = fs.existsSync(SUBSCRIPTIONS_MARKER)
const store = await getRecordStore(selfDid)
const listRec = await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY)
if (!listRec) {
// Create-if-absent. A concurrent create from a sibling device is not an error: whoever loses the
// race still proceeds to merge, and both arrive at the same list.
try {
await writer.putRecord(LIST_COLLECTION, SUBSCRIPTIONS_RKEY, {
$type: LIST_COLLECTION,
purpose: CURATE,
name: 'subscriptions',
description: 'Identities this account subscribes to. Shared by every device of this identity.',
createdAt: new Date().toISOString()
}, { action: 'create' })
} catch (e) {
console.log(JSON.stringify({ msg: 'subscriptions: list already created by another device, merging into it', error: e.message }))
}
}
if (contributed) return // already gave this node's set to the list; the list is authoritative now
// Union: add only what the list does not already have.
// ⛔ store.list() is an ASYNC GENERATOR yielding { rkey, record } (overlay-repo-store.js:288) —
// not an array and not the { records } wrapper the P2P peer returns. Awaiting it and testing
// Array.isArray finds nothing, silently, and every node then re-contributes every boot.
const already = new Set()
for await (const { record } of store.list(LISTITEM_COLLECTION)) {
if (record && record.list === uri && record.subject) already.add(record.subject)
}
const missing = missingMembers({ local, listed: [...already], selfDid })
const createdAt = new Date().toISOString()
for (const did of missing) {
await writer.putRecord(LISTITEM_COLLECTION, subscriptionRkey(did, rkeyHash), {
$type: LISTITEM_COLLECTION, subject: did, list: uri, createdAt
}, { action: 'create' }).catch(() => {}) // deterministic rkey: a concurrent identical add is a no-op
addPeer(did).catch(() => {})
}
try { fs.writeFileSync(SUBSCRIPTIONS_MARKER, createdAt) } catch {}
if (missing.length) {
console.log(JSON.stringify({ msg: 'subscriptions: merged this node\'s roster into the shared list', list: uri, added: missing.length }))
}
} catch (e) {
console.error(JSON.stringify({ msg: 'subscriptions: merge failed (this node keeps its local roster)', error: e.message }))
}
})()
}
const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (d) => { b += d }); req.on('end', () => resolve(b)) })
// BINARY-safe body reader. readBody above concatenates onto a STRING, which mangles any byte that is
// not valid UTF-8 — fine for JSON/form posts, silently corrupting for an image. Blob uploads MUST use
// this one. Bounded: an unbounded upload buffered into a Bare daemon is a trivial DoS, and bsky rejects
// blobs over ~1 MB regardless, so refuse early rather than buffer-then-fail.
const MAX_BLOB_BYTES = 1024 * 1024
const readBodyBuffer = (req, max = MAX_BLOB_BYTES) => new Promise((resolve, reject) => {
const chunks = []
let n = 0
req.on('data', (d) => {
n += d.length
if (n > max) { reject(Object.assign(new Error('image too large (max ' + Math.floor(max / 1024) + ' KB)'), { code: 'ETOOLARGE' })); try { req.destroy() } catch {}; return }
chunks.push(b4a.from(d))
})
req.on('end', () => resolve(b4a.concat(chunks)))
req.on('error', reject)
})
// The mime type comes from the BYTES, never the client's content-type header. The header is
// attacker-controlled and it is what ends up inside the SIGNED record, while getBlob serves by sniffing
// magic bytes — trusting the header would let the record disagree with what is actually served.
// Anything not identifiable as an image is refused outright rather than stored as octet-stream.
function imageMimeOrNull (bytes) {
const m = sniffMime(bytes)
return m.startsWith('image/') ? m : null
}
// ── DID auth (the pgv3 method via hyper-identity; favela stays keyless) ────────
// FavelaAuth verifies a login proof: the browser's secp256k1 sub-key signs the challenge, and the
// DID's #atproto key delegated that sub-key (verified against the DID's PLC-published key via plc.js +
// bare-fetch). All secp256k1/ES256K, atproto-native. Sessions are cookie tokens; favela holds no key.
const auth = new FavelaAuth({
resolveVerifyKey: (did) => resolveVerifyKey(did, { fetch: bareFetch })
})
const NODE_CAN_WRITE = true // the node relay writer; per-viewer write authority lands later
// ── Co-owned communities (DESIGN §3.5): keyless read of hyper-rooms rooms → per-viewer scope ──
// favela-web opens each configured community room by its PUBLIC bootstrapKey (no key held) and reads the
// member roster. A signed-in viewer's timeline scopes to the union of member DIDs of the communities they
// belong to. The room's WRITE layer (mods' hpp) is separate (DESIGN step 3) — favela-web only reads.
const commStore = store.namespace('comm') // namespace of the ONE store — see the note at its creation
const communityRooms = new CommunityRooms(commStore, swarm)
const communities = new Communities({ communities: COMMUNITY_ROOMS, resolve: (c) => communityRooms.members(c.roomKey) })
// ── 5B policy: who participates, and who may LOOK ───────────────────────────────────────────────
// isMember reads the room roster favela already replicates; it fails closed (see gate-policy.js).
const isMember = makeIsMember(communities)
// A community closes its site with `set-meta visibility members` — an ordinary meta key, so no schema
// change. Cached: room meta replicates asynchronously and this is consulted per page render. Default
// (and every failure path) is PUBLIC, so a node that never sets it renders exactly as it does today.
let _visCache = { value: PUBLIC, exp: 0 }
const siteVisibilityOnce = async () => {
if (Date.now() < _visCache.exp) return _visCache.value
let value = PUBLIC
try {
const metas = await Promise.all(COMMUNITY_ROOMS.map((c) => communityRooms.meta(c.roomKey).catch(() => ({}))))
value = siteVisibility(metas)
} catch { value = PUBLIC }
_visCache = { value, exp: Date.now() + 60000 }
return value
}
// Lens export: one bsky custom feed per community, served from favela's own appview index. Online only when
// both feedgen envs are set; each community registers as a feed (rkey = slug of its id), and getFeedSkeleton
// reads the roster → the appview → bsky-only post URIs. registerFeed is idempotent (Map by rkey).
// Multi-tenant (phase 4): each community's OWN did:plc (bound in its room as meta:did) IS its feed-
// generator service; favela-web just serves getFeedSkeleton for all of them. A community is exported as a
// bsky feed IFF it has bound a DID. registerFeed reads that DID keylessly from the room (async).
const feedgen = new FeedGenerator({ hostname: FEEDGEN_HOSTNAME || null })
// Idempotent + quiet: meta:did replicates ASYNC (the room is opened keyless over the swarm), so it is
// usually NULL when this first runs at startup. Return false and retry on each room update (the watch below
// calls this too) — register exactly once, when meta:did actually arrives. No error spam on the not-yet case.
const registerFeed = async (c) => {
try {
const communityDid = await communityRooms.communityDid(c.roomKey)
if (!communityDid) return false // meta:did not replicated yet — the room watch retries
const rkey = slugRkey(c.id)
if (feedgen.has(communityDid, rkey)) return true // already exported (idempotent)
feedgen.addFeed({ communityDid, rkey, roomKey: c.roomKey, displayName: String(c.id), description: `favela community: ${c.id}` })
console.log(`[favela] feed export: at://${communityDid}/app.bsky.feed.generator/${rkey} (community "${c.id}")`)
return true
} catch (e) { console.error('[favela] registerFeed failed:', c.id, '-', e.message); return false }
}
// ── labeler export: favela's own atproto labeler (shares the did:web identity) ──
// The signing key is derived deterministically from FAVELA_LABELER_SEED; labels are stamped with the
// service DID as `src` and served over queryLabels. Bsky resolves did:web → #atproto_label VM (this key)
// → verifies our label sigs. Production of labels (a mod writing via a co-authored LabelRoom) is the
// full-node integration; this exposes whatever the local Labeler feed holds.
let labeler = null
let labelerExport = null
// Module-scope so shutdown() can close it: it is the one store that is NOT a namespace of `store`.
let labelerStore = null
if (SERVICE_DID && LABELER_SEED) {
// ⚠ DELIBERATELY ITS OWN Corestore — do NOT fold this into the namespaced `store` above.
// This is the one core favela AUTHORS rather than replicates: Labeler does
// `corestore.get({ name })`, a DERIVED key, and PUBLISHES it as `feedKey` — "consumers replicate the
// feed BY KEY and VERIFY each label" (lib/labeler.js:2). Namespacing changes that derived key, which
// would silently strand every existing subscriber and orphan the label history. Identity itself is
// safe either way (the signing key is seed-derived: crypto.hash(LABELER_SEED) -> did:key, which is
// what consumers verify against) — it is DISCOVERY that breaks, and only republishing the new key
// would recover it. Consumed labeler feeds are different: those live on didStore, fetched by key.
// The extra RocksDB floor (~75 MB) is the price of keeping "state we author" separable from "state we
// replicate or derive". Folding it in is only safe once Labeler accepts an EXPLICIT keypair.
labelerStore = new Corestore(STORE('web-labeler'))
labeler = await new Labeler(labelerStore, { privateKey: crypto.hash(b4a.from(LABELER_SEED)), src: SERVICE_DID, name: 'favela-labeler' }).ready()
const source = {
labels: async () => {
const core = labeler.core
await core.ready()
const out = []
for (let i = 0; i < core.length; i++) { try { out.push(decodeLabel(await core.get(i))) } catch {} }
return out
}
}
labelerExport = new LabelerExport({ labelerDid: SERVICE_DID, signingDidKey: labeler.didKey, hostname: FEEDGEN_HOSTNAME, source, labelValues: LABELER_VALUES })
console.log(`[favela] labeler export online: ${SERVICE_DID} (#atproto_labeler) values=[${LABELER_VALUES.join(',')}]`)
}
// The live-push firehose (com.atproto.label.subscribeLabels) over the labeler's feed core (phase 5).
const labelFirehose = labeler ? await new LabelFirehose(labeler.core).ready() : null
// The combined did:web document — one identity, whichever export surfaces are configured. The labeler
// adds a #atproto_label verificationMethod (its signing key) + a Multikey @context; the feedgen adds its
// service entry. Served at /.well-known/did.json whenever SERVICE_DID is set.
function buildDidDoc () {
// favela-web's own did:web doc carries ONLY the (favela-owned, 3B) labeler — communities are their own
// feed-generator + labeler services (did:plc), advertised via their OWN PLC docs, not here.
const doc = { '@context': ['https://www.w3.org/ns/did/v1'], id: SERVICE_DID, service: [] }
if (labelerExport) {
doc['@context'].push('https://w3id.org/security/multikey/v1')
doc.verificationMethod = [labelerExport.verificationMethod()]
doc.service.push(labelerExport.service())
}
return doc
}
// Watch each community room's roster so members are indexed NOW (baseline) AND when they change LATER — with
// NO restart (DESIGN §7 roster watcher). On any roster change: addPeer new member DIDs (so their posts flow
// into the index) and invalidate the per-viewer scope cache for that community (so scopeFor re-resolves at
// once instead of waiting out the TTL). watch() opens the room (joining its discoveryKey) and fires an
// initial baseline, so this subsumes the old one-shot boot index. Best-effort + background so it never
// blocks the HTTP server; rooms replicate async, so each replicated membership op re-fires the watcher and
// the roster is eventually-consistent.
if (COMMUNITY_ROOMS.length || COMMUNITY_NAMES.length) {
;(async () => {
try {
// Resolve any human community NAMES to roomKeys over the DHT, then treat them like configured rooms.
// A name not yet announced (owner offline) is skipped with a log — it resolves on a later boot.
if (COMMUNITY_NAMES.length) {
const disc = new CommunityDiscovery(swarm.dht)
for (const name of COMMUNITY_NAMES) {
try {
const roomKey = await disc.resolve(name)
if (!roomKey) { console.error('[favela] community name not found on the DHT (owner offline?):', name); continue }
if (COMMUNITY_ROOMS.some((c) => c.roomKey === roomKey)) continue // already configured by key
const c = { id: name, roomKey }
communities.add(c) // so scopeFor covers it
COMMUNITY_ROOMS.push(c) // so the watch loop below covers it (feed registration happens there)
console.log(`[favela] resolved community "${name}" → ${roomKey.slice(0, 12)}…`)
} catch (e) { console.error('[favela] community name resolve failed:', name, '-', e.message) }
}
}
for (const c of COMMUNITY_ROOMS) {
await registerFeed(c) // try now; if meta:did hasn't replicated yet the watch (below) registers it
await communityRooms.watch(c.roomKey, async (memberDids) => {
await registerFeed(c) // meta:did replicates async — register the feed the moment it arrives (idempotent)
communities.invalidate(c.id) // roster changed → drop this community's cached scope
for (const did of memberDids) {
if (byDid.has(did)) continue
try { await addPeer(did) } catch (e) { console.error('[favela] community member index failed', did, '-', e.message) }
}
})
}
await swarm.flush()
console.log(`[favela] watching ${COMMUNITY_ROOMS.length} community room(s) for roster changes`)
} catch (e) { console.error('[favela] community watch setup failed:', e.message) }
})()
}
// ── Feed registries (community-ux.md §120): REACT to the durable P2P feed list — no per-community env, no
// restart. favela-web watches each configured FeedRegistry (the keyless allow-set) and reconciles the feeds
// it serves to the registry's entries: add-feed → serve getFeedSkeleton + wire the roster into the index +
// viewer scope; remove-feed → stop serving. This SUPERSEDES the static FAVELA_COMMUNITY_* config — a feed
// published into the registry is picked up live. wireRoster mirrors the env path's roster watcher above.
if (FEED_REGISTRIES.length) {
const feedRegistries = new FeedRegistries(commStore, swarm)
const wireRoster = async (id, roomKey) => {
const c = { id, roomKey }
communities.add(c) // so scopeFor covers it
if (!COMMUNITY_ROOMS.some((x) => x.roomKey === roomKey)) COMMUNITY_ROOMS.push(c) // so scopeOf activates
await communityRooms.watch(roomKey, async (memberDids) => {
communities.invalidate(id) // roster changed → drop this community's cached scope
for (const did of memberDids) {
if (byDid.has(did)) continue
try { await addPeer(did) } catch (e) { console.error('[favela] registry member index failed', did, '-', e.message) }
}
})
}
const reconcile = makeFeedReactor({ feedgen, wireRoster, log: (m) => console.log(m) })
;(async () => {
try {
for (const regKey of FEED_REGISTRIES) {
await feedRegistries.watch(regKey, (feeds) => reconcile(regKey, feeds))
console.log(`[favela] watching feed registry ${regKey.slice(0, 12)}… for feed add/remove`)
}
await swarm.flush()
} catch (e) { console.error('[favela] feed registry watch setup failed:', e.message) }
})()
}
// A signed-in viewer's per-request author scope. null (anonymous / community-less / no rooms) → global.
const scopeOf = async (viewer) => (viewer && COMMUNITY_ROOMS.length ? await communities.scopeFor(viewer.did) : null)
const COOKIE = 'favela_session'
// ONE extraction path (lib/check.js, ported from pear-git-v3). This was a hand-rolled cookie regex
// here plus a second read in the logout branch — the shape that lets two callers disagree about who
// is signed in. extractToken also accepts `Authorization: Bearer`, so a non-browser client can
// authenticate without a cookie jar. The loopback /auth/handoff is unaffected: it takes its token
// from the query string, never from here.
const parseCookie = (req) => extractToken(req)
const viewerOf = (req) => extractIdentity(req, auth.sessions)
// WHICH repo does this node write to? Asked of the hpp, never assumed — and cached, because the write
// gate consults it on every mutating request and it is a property of the node, not of the request.
// Unresolved stays null so the gate DENIES: an instance with no write transport (the stortron lens)
// must refuse writes, not guess a target.
let _repoDid = null
const repoDidOnce = async () => {
if (_repoDid) return _repoDid
try { _repoDid = ((await writer.state()) || {}).did || null } catch { _repoDid = null }
return _repoDid
}
const SESSION_COOKIE = (token) => `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}`
const CLEAR_COOKIE = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
// Write-gate tiers (see the gate itself, at the top of the request handler). Listed here so the
// classification of a route is one grep away, not buried mid-file.
//
// OPEN: no session required. The auth routes ESTABLISH one, so gating them on having one would be
// circular. /api/theme is here for a different reason: it writes NOTHING server-side — its only
// effect is a cookie in the caller's own browser — and it must work on a read-only lens instance,
// which is the first thing the gate below refuses. A reader who is not signed in still gets to
// choose dark.
const OPEN_POST = new Set(['/api/auth/login', '/api/auth/pair', '/api/auth/logout', '/api/auth/challenge', '/api/theme'])
// IDENTITY: self-scoped or node-local. /api/devices/* act on the caller's OWN delegated sub-keys;
// track/bookmark/mute mutate this node's local index and stores only — nothing is signed into a repo,
// so repo ownership is not the relevant question, but anonymity still is.
const IDENTITY_POST = new Set([
'/api/devices/pair', '/api/devices/invite', '/api/devices/revoke',
// /api/lens changes where THIS NODE reads from — node-local, nothing signed into a repo, so repo
// ownership is not the question. It carries its own stricter check (viewer must be the identity this
// node signs for), because repointing a node's reads is an operator act, not a viewer preference.
'/api/lens',
'/api/track', '/api/bookmark', '/api/mute'
])
// Everything else POSTed authors a record (or a blob) into the repo and falls through to the
// repo-owner check. Deliberately NOT enumerated: an unlisted route must fail closed.
// Theme is applied HERE rather than threaded through a dozen page functions, because it affects
// exactly one attribute on <html> and every HTML response already funnels through this helper. The
// value is stamped on `res` by the request handler below — the response object is per-request, so
// there is no shared state to race.
//
// Server-side, so the first paint is correct. A script that flips a class after load would flash the
// wrong theme on every navigation and would never run at all with scripting off.
const html = (res, status, body, extra) => {
const t = res._theme
const themed = (t === 'dark' || t === 'light') && typeof body === 'string'
? body.replace('<html lang="en">', `<html lang="en" data-theme="${t}">`)
: body
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', ...(extra || {}) })
res.end(themed)
}
// system | light | dark. Absent or unrecognised means system, which is what everyone had before this
// existed, so an old cookie or a hand-edited one degrades to the previous behaviour rather than to a
// broken page.
const THEMES = new Set(['light', 'dark'])
function themeOf (req) {
const raw = (req.headers && req.headers.cookie) || ''
const m = raw.match(/(?:^|;\s*)favela_theme=([a-z]+)/)
return m && THEMES.has(m[1]) ? m[1] : null
}
const json = (res, status, body, extra) => { res.writeHead(status, { 'content-type': 'application/json', ...(extra || {}) }); res.end(body) }
const xml = (res, status, body, type) => { res.writeHead(status, { 'content-type': type }); res.end(body) }
const redirect = (res, to, extra) => { res.writeHead(303, { location: to, ...(extra || {}) }); res.end() }
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://localhost')
const path = url.pathname
const viewer = viewerOf(req) // { did, subKey } from the session cookie/Bearer, or null (anonymous)
res._theme = themeOf(req) // read once per request; html() applies it to whatever page is rendered
// ── WRITE GATE — one choke point, default-deny ──────────────────────────────────────────────
//
// Every mutating route used to carry (or forget) its own `if (!viewer)`. /api/post forgot, and on
// 2026-08-11 an anonymous request created a post in wj62; the images branch of that same handler
// had the check, which is why inspection missed it. pgv3 puts its read gate at ONE point above
// ~14 routes for exactly this reason: per-route checks are per-route chances to forget, and a
// route added later defaults to open. Here the default is DENY — a new POST route is gated as a
// repo write until someone deliberately lists it otherwise.
//
// Tiers, most open first:
// OPEN — establish the session; requiring one would be circular.
// IDENTITY — self-scoped: acts on the caller's own devices, or on state that never leaves
// this node. A live, non-revoked session is the whole requirement.
// (default) — AUTHORS A RECORD INTO THE REPO. Requires that the caller OWN that repo.
//
// ⛔ The repo owner check is the load-bearing one. `Writer` speaks to a single hpp control socket,
// so a write authors as THAT DID whoever is signed in — and sign-in admits any DID that can prove
// a delegation from its own #atproto key. Without this, any atproto account posts as wj62.
if (req.method === 'POST' && !OPEN_POST.has(path)) {
// ⛔ SAY "READ-ONLY" BEFORE ASKING AN HPP THAT ISN'T THERE. A lens instance (stortron) has no
// write transport, so every hpp call throws — including the device-revocation check. Fail-closed
// then surfaced as "this device has been revoked" to a signed-in user whose device was perfectly
// fine. Reported live on favela.7rnx.net. The refusal is right; the reason was a fabrication.
if (!writeTransportConfigured(process.env)) {
console.log(JSON.stringify({ msg: 'write refused', path, viewer: viewer ? viewer.did : null, status: 403, reason: 'read-only instance' }))
return json(res, 403, JSON.stringify({ error: 'this instance is read-only — it serves feeds and cannot author records' }))
}
const ctx = { identity: viewer }
const isDeviceRevoked = (k) => writer.isDeviceRevoked(k)
try {
if (IDENTITY_POST.has(path)) await requireIdentity(ctx, { isDeviceRevoked, failOpen: false })
else {
await requireRepoOwner(ctx, { repoDid: await repoDidOnce(), isDeviceRevoked })
if (REQUIRE_MEMBERSHIP) await requireMember(ctx, { isMember, isDeviceRevoked })
}
} catch (e) {
const status = e.status || 403
console.log(JSON.stringify({ msg: 'write refused', path, viewer: viewer ? viewer.did : null, status, reason: e.message }))
return json(res, status, JSON.stringify({ error: e.message }))
}
}
// ── READ GATE — a community may CLOSE its site (`set-meta visibility members`) ──────────────
// INERT unless every configured community asks to close (gate-policy.siteVisibility defaults to
// public), so a node that never sets the flag renders exactly as it does today. Exemptions live in
// isPublicPath and are load-bearing: gate /login or /api/auth/* and a visitor can never STOP being
// anonymous, turning "members only" into "nobody, ever". /xrpc stays open because bsky's AppView
// reads the lens anonymously. POSTs are already covered by the write gate above.
if (req.method === 'GET' && !viewer && !isPublicPath(path) && (await siteVisibilityOnce()) === MEMBERS) {
return redirect(res, '/login')
}
// ── auth (the browser client drives these; favela verifies keylessly) ──
if (path === '/api/auth/challenge') {
return json(res, 200, JSON.stringify({ challenge: auth.mintChallenge() }))
}
// SESSION HANDOFF — for a UI rendered in an embedded WebView (favela-android). The worklet signs in
// over this same loopback API and holds the token; the WebView has a separate cookie jar and no way
// to be given one, so without this every page it renders is anonymous. Swaps a token the caller
// already holds for the cookie form of the SAME session — see handoffDecision for why it refuses
// unknown tokens, off-box callers and non-local `next` values.
if (path === '/auth/handoff') {
const d = handoffDecision({
token: url.searchParams.get('token'),
next: url.searchParams.get('next'),
remoteAddress: req.socket && req.socket.remoteAddress,
isValid: (t) => !!auth.get(t)
})
if (!d.ok) return html(res, 403, '<h1>handoff refused</h1><p>' + esc(d.reason || 'refused') + '</p>')
return redirect(res, d.next, { 'set-cookie': SESSION_COOKIE(d.token) })
}
if (req.method === 'POST' && path === '/api/auth/login') {
let proof
try { proof = JSON.parse((await readBody(req)) || '{}') } catch { return json(res, 400, JSON.stringify({ error: 'bad json' })) }
try {
const s = await auth.login(proof) // verifies chain + PLC-anchors first bind; throws on any failure
// REVOCATION GATE (Option 3): the delegation is cryptographically valid, but the DID may have
// REVOKED this sub-key in the persistent device registry. Check via the local hpp. Fail-open if
// the registry is unreachable — the delegation still holds; revocation is a best-effort second gate.
if (proof.subKey && await writer.isDeviceRevoked(proof.subKey).catch(() => false)) {
auth.revoke(s.token)
return json(res, 403, JSON.stringify({ error: 'this device has been revoked' }))
}
return json(res, 200, JSON.stringify({ did: s.did, handle: s.handle }), { 'set-cookie': SESSION_COOKIE(s.token) })
} catch (e) { return json(res, 401, JSON.stringify({ error: e.message })) }
}
// hpp-INITIATED pairing (the fix for the backwards flow): the browser pastes ONE hash-URL invite the
// hpp minted; favela-web joins the blind-pairing tunnel, sends the browser's sub-key up, and gets the
// delegation back — no manual delegation shuttle. The browser still signs the challenge (proves it holds
// the sub-key secret); favela-web fetches the delegation via the invite, then runs the SAME auth.login.
if (req.method === 'POST' && path === '/api/auth/pair') {
let body
try { body = JSON.parse((await readBody(req)) || '{}') } catch { return json(res, 400, JSON.stringify({ error: 'bad json' })) }
const { invite, subKey, challenge, sig } = body
if (!invite || !subKey || !challenge || !sig) return json(res, 400, JSON.stringify({ error: 'invite, subKey, challenge, sig required' }))
try {
const del = await joinPairInvite(String(invite).trim(), { subKey }) // { did, subKey, delegation, exp } from the hpp
if (del.subKey && del.subKey.toLowerCase() !== String(subKey).toLowerCase()) return json(res, 401, JSON.stringify({ error: 'delegation is for a different sub-key' }))
const s = await auth.login({ did: del.did, subKey, delegation: del.delegation, challenge, sig })
// Revocation gate (Option 3) — a previously-revoked sub-key stays revoked until explicitly restored.
if (await writer.isDeviceRevoked(subKey).catch(() => false)) {
auth.revoke(s.token)
return json(res, 403, JSON.stringify({ error: 'this device has been revoked — restore it or use a new browser identity' }))
}
// Return the delegation so the browser PERSISTS it (localStorage) and can re-sign-in silently
// after a favela-web restart — no re-paste of the invite. (pgv3-parity persistence.)
return json(res, 200, JSON.stringify({ did: s.did, handle: s.handle, delegation: del.delegation }), { 'set-cookie': SESSION_COOKIE(s.token) })
} catch (e) { return json(res, 401, JSON.stringify({ error: e.message })) }
}
// Session identity for the browser client (cookie is HttpOnly → can't be read in JS). Always 200:
// { did } when signed in, { did: null } when anonymous.
if (path === '/api/auth/me') {
return json(res, 200, JSON.stringify({ did: viewer ? viewer.did : null }))
}
if (path === '/logout' || (req.method === 'POST' && path === '/api/auth/logout')) {
const t = parseCookie(req); if (t) auth.revoke(t)
// no-JS GET → redirect home; client POST → 200 (it re-renders itself).
if (req.method === 'POST') return json(res, 200, JSON.stringify({ ok: true }), { 'set-cookie': CLEAR_COOKIE })
return redirect(res, '/', { 'set-cookie': CLEAR_COOKIE })
}
// Device management (Option 3): the signed-in DID-holder lists + revokes/restores the DID's delegated
// browser sub-keys. Gated on a live session; the hpp additionally enforces its writer-allowlist. The
// registry is persistent + replicated, so a revoke propagates to every device and every login check.
// Mint a pairing invite from the WEB UI. `favela-web pair` has been able to do this since 0.2.15;
// the browser could only ever CONSUME an invite (POST /api/auth/pair), so adding a second device
// meant shell access to the box running favela-web. That is the wrong requirement for "sign in on my
// phone" — this closes the loop: generate here, paste at /login there.
//
// The invite object is LONG-LIVED (it holds a blind-pairing member on a Hyperswarm and resolves only
// when a browser joins), so it cannot live inside the request. It is parked per-DID and torn down on
// whichever comes first: a successful pair, the TTL, or the DID minting a replacement. ONE live invite
// per DID — minting again supersedes the previous one rather than accumulating swarms, which is both
// the safer resource story and the more obvious semantics (the newest code is the one that works).
if (req.method === 'POST' && path === '/api/devices/pair') {
if (!viewer) return json(res, 401, JSON.stringify({ error: 'sign in first' }))
let body; try { body = JSON.parse((await readBody(req)) || '{}') } catch { body = {} }
const rawLabel = typeof body.label === 'string' ? body.label.trim() : ''
const label = rawLabel ? rawLabel.slice(0, 64) : null
await closePendingPair(viewer.did)
try {
// onSubkey fires when the far browser presents its sub-key: the DID delegates it via the hpp.
// favela holds no key — the delegation is minted by the key-holder, exactly as the CLI does.
const source = await createPairInvite({ onSubkey: (sk) => writer.delegate(sk, { label }) })
const expiresAt = Date.now() + PAIR_TTL_MS
const timer = setTimeout(() => { closePendingPair(viewer.did).catch(() => {}) }, PAIR_TTL_MS)
if (typeof timer.unref === 'function') timer.unref()
pendingPairs.set(viewer.did, { source, timer })
// Resolve in the background and tear down either way — waitForPeer() rejects on timeout, and an
// un-awaited rejection here would be an unhandled promise, not a leak we notice later.
source.waitForPeer().then(
() => closePendingPair(viewer.did).catch(() => {}),
() => closePendingPair(viewer.did).catch(() => {})
)
return json(res, 200, JSON.stringify({
inviteHex: source.inviteHex,
loginUrl: EXTERNAL_URL + '/login#pair=' + source.inviteHex,
expiresAt
}))
} catch (e) { return json(res, 502, JSON.stringify({ error: 'could not mint an invite: ' + e.message })) }
}
// ── DEVICE INVITE (a NODE, not a browser) ────────────────────────────────────────────────────────
//
// ⛔ NOT /api/devices/pair. That mints a DELEGATION for a browser: a sub-key the DID vouches for,
// where every signature is still made by an hpp. This mints the CO-OWNER BUNDLE — the shared signing
// key plus a chained attestation — which is what a device needs to become another peer on the DID's
// swarm and sign its own commits offline (favela-android, or a second server).
//
// The two are trivially confusable and the confusion is SILENT: paste a browser invite into a node
// and its 64-hex keet device key is rejected by delegate() (which wants 66-hex secp256k1), the source
// never confirms, and the joiner waits out a five-minute timeout with no error. Hence a separate
// route with its own wording.
//
// The tunnel must OUTLIVE the request, so the source is held in memory here and the page polls it.
// One live invite per DID: minting a second would leave the first dangling with no way to close it.
if (req.method === 'POST' && path === '/api/devices/invite') {
if (!viewer) return json(res, 401, JSON.stringify({ error: 'sign in first' }))
let body; try { body = JSON.parse((await readBody(req)) || '{}') } catch { body = {} }
const did = body.did || viewer.did
if (!did) return json(res, 400, JSON.stringify({ error: 'did required' }))
const live = deviceInvites.get(did)
if (live && !live.done) return json(res, 200, JSON.stringify({ inviteHex: live.inviteHex, state: live.state, reused: true }))
try {
const { mintAdoptInvite } = await import('hyper-proto-peer/lib/adopt.js')
// Approval IS this request: it is owner-gated by the session, and coownerInvite mutates the
// roster + hands over the signing key, so it must never fire speculatively.
const src = await mintAdoptInvite({ did, approve: () => true })
const rec = { inviteHex: src.inviteHex, state: 'waiting', done: false, src }
deviceInvites.set(did, rec)
src.waitForPeer()
.then((r) => { rec.state = r ? ('joined: ' + String(r.devicePublicKey).slice(0, 16) + '…') : 'refused'; rec.done = true })
.catch((e) => { rec.state = 'failed: ' + (e && e.message); rec.done = true })
.then(() => { try { src.close() } catch {} })
return json(res, 200, JSON.stringify({ inviteHex: rec.inviteHex, state: rec.state }))
} catch (e) { return json(res, 502, JSON.stringify({ error: 'mint failed: ' + e.message })) }
}
if (path === '/api/devices/invite') {
if (!viewer) return json(res, 401, JSON.stringify({ error: 'sign in first' }))
const rec = deviceInvites.get(viewer.did)
return json(res, 200, JSON.stringify(rec ? { inviteHex: rec.inviteHex, state: rec.state, done: rec.done } : { state: 'none' }))
}
if (path === '/api/devices') {
if (!viewer) return json(res, 401, JSON.stringify({ error: 'sign in first' }))
try { return json(res, 200, JSON.stringify({ devices: await writer.listDevices() })) }
catch (e) { return json(res, 502, JSON.stringify({ error: 'device registry unreachable: ' + e.message })) }
}
if (req.method === 'POST' && path === '/api/devices/revoke') {
if (!viewer) return json(res, 401, JSON.stringify({ error: 'sign in first' }))
let body; try { body = JSON.parse((await readBody(req)) || '{}') } catch { return json(res, 400, JSON.stringify({ error: 'bad json' })) }
if (!body.subKey) return json(res, 400, JSON.stringify({ error: 'subKey required' }))
try { return json(res, 200, JSON.stringify(await writer.revokeDevice(body.subKey, { restore: !!body.restore }))) }
catch (e) { return json(res, 502, JSON.stringify({ error: e.message })) }
}
// Settings — currently just the avatar. Reads the CURRENT profile record from the shared overlay
// store (the same files hpp writes) so the page shows what is actually published, not a guess.
if (path === '/settings') {
let profile = null
let did = viewer ? viewer.did : null
if (viewer) {
try {
const st = await writer.state().catch(() => null)
if (st && st.did) did = st.did // the DID this node actually signs for
const store = await getRecordStore(did)
profile = await store.get('app.bsky.actor.profile', 'self')
} catch {}
}
// Devices are rendered SERVER-SIDE so the list is visible with JS off; only the actions
// (pair / revoke) are progressive enhancement, matching how the avatar section already works.
let devices = []
if (viewer) { try { devices = await writer.listDevices() } catch { devices = [] } }
// App updates, on a handset only. FAVELA_SHELL_STATE is set by favela-android's worklet and by
// nothing else, so a server never renders the section — `appUpdateSection(null)` is ''. Read per
// request rather than at boot: a check performed while this page is open must be visible on the
// next render, and the file is four fields.
let shell = null
if (process.env.FAVELA_SHELL_STATE) {
try { shell = JSON.parse(fs.readFileSync(process.env.FAVELA_SHELL_STATE, 'utf8')) } catch { shell = null }
}
// The lens control is shown only to the identity this node signs for — the same test /api/lens
// itself enforces, so the page never offers a control the route will refuse.
let isOperator = false
if (viewer) { try { const st = await writer.state(); isOperator = !!(st && st.did && st.did === viewer.did) } catch {} }
const lens = { enabled: LENSES_ON, lenses: LENSES.map((l) => ({ name: l.name, base: l.base })) }
return html(res, 200, settingsPage({ viewer, profile, did, devices, theme: res._theme, shell, lens, isOperator }))
}
if (path === '/login') {
// Sign-in UI: the client script (/static/login.js) generates the browser's secp256k1 sub-key,
// drives pairing (paste the hpp-minted delegation) + the challenge→sign→session flow. favela
// stays keyless — the sub-key secret never leaves the browser.
return html(res, 200, loginPage({ viewer }))
}
// Browser-side client + vendored crypto (served from web/static/).
if (path.startsWith('/static/')) {
if (await serveStatic(path.slice('/static/'.length), res)) return
return json(res, 404, JSON.stringify({ error: 'not found' }))
}
// com.atproto.sync.getBlob — serve a DID's blob (avatar/image) from the SHARED overlay files (slice 3b):
// ONE physical copy, the same files hpp writes. Content-addressed → immutable-cacheable. Content-type
// sniffed from the bytes (the mimeType lives in the record's blob ref, not the file).
if (path === '/xrpc/com.atproto.sync.getBlob') {
const bdid = url.searchParams.get('did'); const bcid = url.searchParams.get('cid')
if (!bdid || !isSafeCid(bcid)) return json(res, 400, JSON.stringify({ error: 'InvalidRequest', message: 'did + cid required' }))
let bytes = null
try { bytes = await readFile(blobPath(OVERLAY_ROOT, bdid, bcid)) } catch { bytes = null }
// A local miss used to be the END of it, which made this surface work only for a DID whose blob
// bytes happen to be co-located on THIS box. Measured: wj62 served its avatar (1 blob present)
// while every e2jev image 404'd — those bytes live on ashatron in the LEGACY Hyperbee blob store,
// which never replicates, so waiting could never help. Fall back to asking the peer that HOLDS the
// blob, exactly as the keyless bridge does (peer-front.getBlob -> hpp's get-blob RPC). Works for
// both backends: the holder answers from its overlay files or its legacy store, whichever it has.
if (!bytes) {
const relayed = await relayBlob(bdid, bcid)
// ⛔ VERIFY BEFORE SERVING, not just before storing. The CID *is* the hash, so a front that
// returns the wrong body cannot pass — and this used to be served straight through, which made
// any peer answering get-blob able to put arbitrary content under someone else's avatar.
if (relayed && await verifyBlobCid(relayed, bcid)) {
bytes = relayed
// ★ RETAIN. Content-addressed bytes we already hold have no reason to be fetched twice, and a
// node that keeps them becomes a SOURCE — which is the whole point of a content-addressed
// store and the thing this surface was missing. Measured 2026-08-24: tabertron served two of
// e2jev's blobs from disk while stortron 404'd the same CIDs, because a keyless reader had
// exactly two routes — local file, or ask the holder — and no way to keep what it was given.
// With this, the first request while a holder is up is the last one that needs it up.
//
// Best-effort: a failed write must never fail the response. We have the bytes; the reader
// gets them either way.
retainBlob(bdid, bcid, relayed).catch(() => {})
} else if (relayed) {
console.error(JSON.stringify({ msg: 'relayed blob failed content-address verification — refusing', did: bdid, cid: String(bcid).slice(0, 20) }))
}
}
if (!bytes) return json(res, 404, JSON.stringify({ error: 'BlobNotFound' }))
res.writeHead(200, { 'content-type': sniffMime(bytes), 'cache-control': 'public, max-age=31536000, immutable' })
return res.end(bytes)
}
// ── lens export (DESIGN §3.6): did:web identity + feed generator + labeler, when configured ──
// did:web resolution: bsky fetches this to find our feedgen/labeler service endpoints + signing key.
if (SERVICE_DID && path === '/.well-known/did.json') {
return json(res, 200, JSON.stringify(buildDidDoc()))
}
// com.atproto.label.queryLabels — the labeler pull surface (labels for uriPatterns/sources).
if (labelerExport && path === '/xrpc/com.atproto.label.queryLabels') {
const uriPatterns = url.searchParams.getAll('uriPatterns')
const sources = url.searchParams.getAll('sources')
const limit = Math.min(Number(url.searchParams.get('limit')) || 50, 250)
const cursor = url.searchParams.get('cursor') || null
const body = await labelerExport.queryLabels({ uriPatterns, sources: sources.length ? sources : null, limit, cursor })
return json(res, 200, JSON.stringify(body))
}
if (feedgen) {
if (path === '/xrpc/app.bsky.feed.describeFeedGenerator') {
return json(res, 200, JSON.stringify(feedgen.describe(url.searchParams.get('did') || null)))
}
if (path === '/xrpc/app.bsky.feed.getFeedSkeleton') {
let algo
try { algo = feedgen.resolve(url.searchParams.get('feed')) } catch (e) { return json(res, e.status || 400, JSON.stringify({ error: e.error || 'InvalidRequest', message: e.message })) }
const limit = Math.min(Number(url.searchParams.get('limit')) || 50, 100)
const cursor = url.searchParams.get('cursor') || null
const body = await feedgen.skeleton(algo, {
rosterOf: (roomKey) => communityRooms.members(roomKey),
curationOf: (roomKey) => communityRooms.curation(roomKey),
getTimeline: (opts) => av.getTimeline(opts),
getTagFeed: (tag, opts) => av.getTagFeed(tag, opts),
limit,
cursor
})
// A feed skeleton is DYNAMIC (new roster posts every minute). Tell bsky's AppView + any CDN/proxy
// (there is an Apache reverse proxy in front of us) NEVER to cache it, so every getFeed re-poll
// returns the current posts. Without this, an intermediary serves a stale skeleton and the feed
// appears "stuck" days behind while the generator itself is current (atproto feeds are pull-only —
// there is no push-new-posts signal, so this header is the freshness lever we control).
return json(res, 200, JSON.stringify(body), { 'cache-control': 'no-store, no-cache, max-age=0, must-revalidate' })
}
}
// Fill in bylines for authors this node holds NO profile record for — a bsky account whose posts
// arrived through the inbound lens has a name and picture nowhere in our index, so it rendered as a
// grey circle and a short DID while the SAME account showed correctly on its profile page (0.3.12
// fixed the page and not the byline).
//
// Done HERE and not in the appview on purpose: the appview is the index layer and must not make
// network calls during hydration. Bounded per page — distinct DIDs only, capped — and the lens reader
// caches, single-flights and circuit-breaks, so a timeline of 50 posts by 3 foreign authors costs at
// most 3 requests, and a repeat view costs none.
//
// Only displayName + avatar are taken. The HANDLE is deliberately NOT: a handle is an identity claim
// and this codebase only ever renders one it has verified bidirectionally (see byline). A display
// name is a label, not a claim of identity.
const LENS_BYLINE_CAP = 12
// Stamp the viewer's own bookmarks onto a result. One scan per render rather than a lookup per post.
async function withBookmarks (result, viewerDid) {
if (!viewerDid || !result || !Array.isArray(result.posts) || !result.posts.length) return result
const saved = await bookmarkStore.uris(viewerDid)
if (saved.size) for (const p of result.posts) { if (p && p.uri && saved.has(p.uri)) p.viewerBookmarked = true }
return result
}
async function withLensBylines (result) {
if (!lensReader || !LENSES.length || !result || !Array.isArray(result.posts) || !result.posts.length) return result
const need = []
const seen = new Set()
for (const p of result.posts) {
const a = p && p.author
if (!a || !a.did || a.displayName || a.avatar || a.avatarUrl) continue
if (seen.has(a.did)) continue
seen.add(a.did)
if (need.length < LENS_BYLINE_CAP) need.push(a.did)
}
// A QUOTED post carries its OWN author, so a quote of a bsky post shows a blank card author on every
// surface at once. Same fill, same budget.
for (const p of result.posts) {
const qa = p && p.quoted && p.quoted.author
if (!qa || !qa.did || qa.displayName || qa.avatar || qa.avatarUrl) continue
if (seen.has(qa.did)) continue
seen.add(qa.did)
if (need.length < LENS_BYLINE_CAP) need.push(qa.did)
}
if (!need.length) return result
const lens = LENSES[0]
const found = new Map()
await Promise.all(need.map(async (did) => {
try {
const body = await lensReader.get(lens, 'app.bsky.actor.getProfile', { actor: did })
if (body && (body.displayName || body.avatar)) found.set(did, { displayName: body.displayName || null, avatarUrl: typeof body.avatar === 'string' ? body.avatar : null })
} catch { /* a byline without a picture is not worth failing a page over */ }
}))
if (!found.size) return result
const fill = (a) => {
const hit = a && a.did ? found.get(a.did) : null
if (!hit) return
if (!a.displayName && hit.displayName) a.displayName = hit.displayName
if (!a.avatarUrl && hit.avatarUrl) a.avatarUrl = hit.avatarUrl
}
for (const p of result.posts) {
fill(p && p.author)
fill(p && p.quoted && p.quoted.author)
}
return result
}
// ── SSR pages (no JS required) ─────────────────────────────
if (path === '/') {
// `cursor` is read straight off the query string so paging works with NO JS — the "older posts"
// link is a plain <a>. Before this the cursor was computed on every request and discarded, so a
// reader could never see past the newest 50 posts.
const result = await withBookmarks(await withLensBylines(await withMod(await av.getTimeline({ limit: 50, cursor: url.searchParams.get('cursor') || null, authors: await scopeOf(viewer), viewerDid: viewer ? viewer.did : null }), viewer ? viewer.did : null)), viewer ? viewer.did : null)
// ?quote=<at-uri> loads the composer with that post attached. Hydrated here so the composer can
// PREVIEW what is being quoted — without it the form looks identical to a normal one and you cannot
// tell what, or whether, you are quoting. Unresolvable → no preview and no hidden field, so the
// post is never silently written as a plain one carrying a quote you thought you made.
let quoting = null
const qUri = url.searchParams.get('quote')
if (qUri) {
const qref = parseAtUri(qUri)
if (qref) {
try {
const qrec = await (await getRecordStore(qref.did)).get(qref.collection, qref.rkey)
if (qrec) {
const qprof = await av.getProfile(qref.did)
quoting = { uri: qUri, did: qref.did, who: (qprof && qprof.displayName) || null, text: qrec.text || '' }
}
} catch { quoting = null }
}
}
// The composer can only offer #listRule if it knows your lists.
let composerLists = []
if (viewer) {
try {
const st6 = await writer.state()
if (st6 && st6.did === viewer.did) {
composerLists = (await ownLists(await getRecordStore(st6.did), st6.did)).map((l) => ({ uri: l.uri, name: (l.record && l.record.name) || l.rkey }))
}
} catch { composerLists = [] }
}
// Freshness qualifies everything on this page, so it is computed for the page the reader is ON —
// not only for /status, which a reader never opens. In-memory router maps; no I/O.
return html(res, 200, timelinePage({ result, viewer, canWrite: NODE_CAN_WRITE, quoting, composerLists, apRkeys: await apProjected(), freshness: frontsDiag(router) }))
}
// Following feed — the DIDs the viewer follows, handed to getTimeline's existing `authors` filter.
// No new index: that filter already exists for community scoping.
if (path === '/following') {
if (!viewer) return html(res, 200, timelinePage({ result: { posts: [] }, viewer, active: 'following' }))
const authors = await av.following(viewer.did)
const result = authors.length
? await withBookmarks(await withLensBylines(await withMod(await av.getTimeline({ limit: 50, cursor: url.searchParams.get('cursor') || null, authors, viewerDid: viewer.did }), viewer.did)), viewer.did)
: { posts: [] }
return html(res, 200, timelinePage({ result, viewer, canWrite: NODE_CAN_WRITE, active: 'following', apRkeys: await apProjected(), freshness: frontsDiag(router), emptyMsg: authors.length ? 'nobody you follow has posted here yet' : 'you are not following anyone yet — open a profile to follow them' }))
}
// /profile/<did>/followers · /profile/<did>/following — the dedicated list.
//
// MUST precede the general /profile/ handler below, which slices the whole remainder as the DID and
// would otherwise treat "<did>/followers" as a DID and render an empty stranger.
//
// Handle resolution is DEADLINE-BOUNDED and parallel, deliberately: the profile page learned this the
// hard way — resolving serially hung it for >90s. A row whose handle does not resolve in time renders
// as a short DID, which is the honest fallback, not an error.
{
const m = path.match(/^\/profile\/([^/]+)\/(followers|following)$/)
if (m) {
const did = decodeURIComponent(m[1])
const kind = m[2]
if (!did.startsWith('did:')) return html(res, 404, timelinePage({ result: { posts: [] }, viewer }))
const all = kind === 'followers' ? await av.followers(did) : await av.following(did)
// Offset cursor. The underlying read is a full key-range scan either way, so an opaque cursor
// would buy nothing here beyond the appearance of one.
const PAGE = 50
const off = Math.max(0, parseInt(url.searchParams.get('cursor') || '0', 10) || 0)
const slice = all.slice(off, off + PAGE)
const deadline = delay(2500)
const accounts = await Promise.all(slice.map(async (d) => {
const prof = await av.getProfile(d).catch(() => null)
let h = null
try { h = await Promise.race([handleResolver.resolve(d), deadline.then(() => null)]) } catch {}
return {
did: d,
displayName: (prof && prof.displayName) || null,
handle: h && h.verified ? h.handle : null,
verified: !!(h && h.verified),
// No badge for our own indexed records — an unlabelled row means "we hold the follow", and
// that default has to stay silent or the label stops carrying information. AP followers get
// a source here once ap-bridge's follower state is readable (hyper-social #29).
source: null
}
}))
// The SAME identity's ActivityPub followers, appended and LABELLED. Only for the followers
// tab — "following" is a record we author, and we do not author AP follows yet (that is #29
// item 4's outbound half). Best-effort by construction: a failure here must not cost the page
// its atproto followers, so it degrades to an empty list rather than throwing.
let apRows = []
if (kind === 'followers' && apFollowers.enabled && off === 0) {
try { apRows = await apFollowers.followersOf(did) } catch { apRows = [] }
}
const subj = await av.getProfile(did).catch(() => null)
return html(res, 200, followListPage({
did,
kind,
accounts: accounts.concat(apRows),
viewer,
subjectName: (subj && subj.displayName) || null,
cursor: (off + PAGE) < all.length ? String(off + PAGE) : null
}))
}
}
if (path.startsWith('/profile/')) {
const did = decodeURIComponent(path.slice('/profile/'.length))
if (!did) return html(res, 400, timelinePage({ result: { posts: [] }, viewer }))
// Tab = an app.bsky.feed.getAuthorFeed filter value. The PAGE default is posts_no_replies (bsky's
// Posts tab); the appview keeps the lexicon default. An unknown value degrades inside getAuthorFeed
// rather than erroring, so a hand-edited query string cannot 500 a profile.
const filter = url.searchParams.get('filter') || 'posts_no_replies'
// ⛔ A PROFILE WE DO NOT SUBSCRIBE TO GOES STALE SILENTLY, AND LOOKS CURRENT. Nothing tails an
// identity outside the roster, so an earlier one-shot view renders as a complete, plausible profile
// that is simply months out of date — old posts, and the old avatar CID because the PROFILE RECORD
// is old too. Measured on a handset (hyper-social #53): ashatron's posts 16 days behind and their
// previous avatar shown as though it were current. That reads as corruption, not as "not
// subscribed", which is the worst way for this to fail.
//
// So looking at someone refreshes them. addPeer is the same idempotent runtime path /api/track
// uses — join the topic, resolve a verify key, start the ingest loop — and the loop then keeps the
// profile current for as long as this node runs.
//
// ⚠ GATED TO THE NODE'S OWN OPERATOR. `viewer.did === the DID this node signs for` is 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 "make this node subscribe" button.
// On a handset the operator is the only viewer there is, which is the case this fixes.
//
// Detached and non-fatal: the page must render at snapshot speed. An unreachable DID (every
// bsky-hosted account) simply resolves no peer, and the render is unaffected.
if (viewer && !(HOSTED_DIDS || []).includes(did) && !ROSTER.includes(did)) {
try {
const st = await writer.state().catch(() => null)
if (st && st.did === viewer.did) {
addPeer(did).then(
(p) => { if (p) console.log(JSON.stringify({ msg: 'profile view: subscribed, ingest running', did })) },
(e) => console.error(JSON.stringify({ msg: 'profile view: subscribe failed (page still rendered)', did, error: e.message }))
)
}
} catch { /* the page is the product; a refresh attempt must never break it */ }
}
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))
// The profile header needs the SUBJECT's own profile — the same indexed record the per-post
// hydration uses, just looked up by the DID being viewed rather than by each post's author.
const profile = await av.getProfile(did)
// A DID we hold no profile record for — typically a bsky account discovered through a backlink
// (a network follower) — rendered as a short DID, a placeholder avatar and an empty feed. Honest,
// and useless: it reads as a broken page rather than as "we don't hold this person".
//
// Ask a lens, and label it. Same rule the thread view's lens replies follow (DESIGN §7.6 Tier C —
// REFERENCED, never retained): it is not written to any store, it is attributed to a NAMED foreign
// source, and it carries no affordances, because acting on a profile we do not hold would be a lie.
// Nothing here changes what we federate; it only stops the page pretending the person is nameless.
let lensProfile = null
if (!profile && lensReader && LENSES.length) {
try {
const lens = LENSES[0]
const body = await lensReader.get(lens, 'app.bsky.actor.getProfile', { actor: did })
if (body && (body.displayName || body.handle || body.avatar)) {
lensProfile = {
lens: lens.name,
displayName: body.displayName || null,
handle: body.handle || null,
description: body.description || null,
avatarUrl: typeof body.avatar === 'string' ? body.avatar : null,
followersCount: Number.isFinite(body.followersCount) ? body.followersCount : null,
followsCount: Number.isFinite(body.followsCount) ? body.followsCount : null,
postsCount: Number.isFinite(body.postsCount) ? body.postsCount : null
}
}
} catch { lensProfile = null }
}
const graph = {
following: (await av.following(did)).length,
followers: (await av.followers(did)).length,
// Whether the VIEWER follows this profile, so the button renders the right verb.
viewerFollows: viewer ? !!(await av.followRkey(viewer.did, did)) : false,
isSelf: !!(viewer && viewer.did === did),
canFollow: !!(viewer && viewer.did),
// Network-wide followers (§7.6 Tier C — referenced, never retained). av.followers() above counts
// only follows authored by DIDs THIS NODE INDEXES, so a bsky account following you is invisible
// to it. Kept as a SEPARATE field, never merged into the local count: different denominators.
// Read-only by construction — this path holds no store and cannot write.
network: followerLens ? await followerLens.followersOf(did) : null,
// Viewer-relative moderation state, so the profile can offer the right verb.
viewerMuted: viewer ? await muteStore.isMuted(viewer.did, did) : false,
viewerBlocked: viewer ? (!!(await av.blockRkeyFor(viewer.did, did)) || await muteStore.isBlocked(viewer.did, did)) : false,
// The viewer's own lists, with whether THIS profile is already on each — so the control can show
// a ✓ rather than making someone guess whether they already added them.
viewerLists: await (async () => {
if (!viewer) return []
try {
const st = await writer.state()
if (!st || st.did !== viewer.did) return []
const store = await getRecordStore(st.did)
const ls = await ownLists(store, st.did)
const out = []
for (const l of ls) {
out.push({ uri: l.uri, name: (l.record && l.record.name) || l.rkey, member: !!(await memberRkey(store, l.uri, did)) })
}
return out
} catch { return [] }
})()
}
// Their POSTS via the lens. Clicking a bsky account gave a name and an avatar (0.3.12) and then an
// empty page — the profile of someone whose posts this node has never indexed is mostly a list of
// their posts, so without this the page is a shell.
//
// Fetched only when we hold NOTHING of our own for them: a DID we index should render OUR records,
// not a remote view of them. Shown as a NAMED foreign source with no affordances, same rule as the
// thread view's lens replies — acting on a post we do not hold would be a lie.
let lensPosts = null
// ⛔ NOT GATED ON lensProfile. It used to be, and lensProfile is itself only fetched when we hold NO
// profile record — so the moment a bsky account's profile arrived through a backlink, the header
// rendered from our own data, lensProfile stayed null, and their posts were never asked for. The
// page then looked legitimate and empty: a real name, a real avatar, "no posts from this author"
// for someone who posts daily. Reported 2026-08-25.
//
// The real condition is the one this is for: we have nothing of our OWN to show for this DID. A DID
// we index renders our records; anything else may be filled in from a named foreign source.
const holdNothing = !(result && result.posts && result.posts.length)
if (lensReader && LENSES.length && holdNothing) {
try {
const l = LENSES[0]
// The tab maps to the same getAuthorFeed filter the lexicon defines, so Media stays Media.
const body = await lensReader.get(l, 'app.bsky.feed.getAuthorFeed', { actor: did, limit: 30, filter })
const feed = (body && Array.isArray(body.feed)) ? body.feed : []
const posts = feed.map((f) => f && f.post).filter(Boolean).map((pp) => ({
did: (pp.author && pp.author.did) || did,
handle: (pp.author && pp.author.handle) || null,
displayName: (pp.author && pp.author.displayName) || null,
text: (pp.record && pp.record.text) || '',
createdAt: (pp.record && pp.record.createdAt) || pp.indexedAt || null,
replyCount: pp.replyCount || 0,
// Image thumbs come from the LENS's CDN — we hold no blob for these.
images: ((pp.embed && (pp.embed.images || (pp.embed.media && pp.embed.media.images))) || [])
.map((im) => ({ thumb: typeof im.thumb === 'string' ? im.thumb : null, alt: im.alt || '' }))
.filter((im) => im.thumb && /^https:\/\//i.test(im.thumb))
}))
if (posts.length) lensPosts = { name: l.name, posts }
} catch { lensPosts = null }
}
return html(res, 200, profilePage({ did, result, viewer, profile, graph, filter, lensProfile, lensPosts, apRkeys: await apProjected() }))
}
// A single post on its own page — the target of every permalink. Served from the author feed rather
// than a point lookup so the post arrives with the same moderation treatment (labels, mutes) the
// timeline would have given it; a permalink that bypasses moderation would be a hole in it.
if (path.startsWith('/post/')) {
const rest = path.slice('/post/'.length).split('/')
const did = decodeURIComponent(rest[0] || '')
const rkey = decodeURIComponent(rest[1] || '')
if (!did || !rkey) return html(res, 404, postPage({ post: null, viewer }))
const viewerDid = viewer ? viewer.did : null
// Point lookup + a thread in BOTH directions. This used to scan the author's last 100 posts and
// match by rkey, which 404'd every permalink to an older post; getPost reads the (did, rkey) index
// directly, so there is no horizon.
const thread = await av.getPostThread(`at://${did}/app.bsky.feed.post/${rkey}`, { viewerDid })
if (!thread) return html(res, 404, postPage({ post: null, viewer }))
// Moderation still covers everything shown. withMod mutates each post in place, so passing the
// FLAT list moderates the whole tree — the old code moderated only the post plus its DIRECT
// replies, so a nested reply bypassed labels and mutes.
// threadPostList flattens the TREE but yields the same objects, so filling a byline here propagates
// back through the nested replies — the same property withMod relies on. Without this the thread
// page kept the grey circles the timeline just lost (0.3.14): the identical bug, one surface over.
await withLensBylines({ posts: threadPostList(thread) })
await withMod({ posts: threadPostList(thread) }, viewer ? viewer.did : null)
// LENS IMPORT. favela indexes the firehoses of the peers it ingests, which is a strictly narrower
// window than a public AppView's: a bsky user replying to us was never in scope, so no amount of
// local indexing surfaces them. Ask each configured lens for the same thread and render what it has
// that we do not — ATTRIBUTED and separate, never merged into our tree.
//
// Fails soft by construction (the reader returns null on timeout, non-2xx, bad JSON or an open
// breaker), so a slow or down AppView costs this page nothing but the rows it would have added.
let lensReplies = []
let lensAncestors = []
if (lensReader) {
const localUris = new Set(threadPostList(thread).map((p) => p.uri).filter(Boolean))
const uri = thread.post.uri
// Ask for ancestors ONLY when our own walk actually stopped at one we never indexed. Otherwise
// parentHeight stays 0: a bigger response for a gap that does not exist is wasted on every thread
// whose chain we already hold in full, and it would fragment the reader's cache by params.
const gap = (thread.ancestors || []).some((a) => a && a.missing)
const results = await Promise.all(LENSES.map(async (lens) => {
const body = await lensReader.get(lens, 'app.bsky.feed.getPostThread', { uri, depth: 1, parentHeight: gap ? 10 : 0 })
return {
lens: lens.name,
replies: extractThreadReplies(body, { exclude: localUris }),
posts: gap ? extractThreadParents(body, { exclude: localUris }) : []
}
}))
lensReplies = results.filter((r) => r.replies.length).map((r) => ({ lens: r.lens, replies: r.replies }))
lensAncestors = results.filter((r) => r.posts.length).map((r) => ({ lens: r.lens, posts: r.posts }))
}
// The gate lives at the SAME rkey as the post, so it is directly addressable — no new index, no
// INGEST_VERSION bump. Absent record = no gate = anyone may reply.
let gateRec = null
try { gateRec = await (await getRecordStore(thread.post.did)).get('app.bsky.feed.threadgate', thread.post.rkey) } catch { gateRec = null }
// The postgate lives at the same rkey too — one more direct lookup, no index.
let pgRec = null
try { pgRec = await (await getRecordStore(thread.post.did)).get('app.bsky.feed.postgate', thread.post.rkey) } catch { pgRec = null }
const gateChoice = choiceOf(gateRec)
// #followingRule is "people the AUTHOR follows" — the direction matters, and reversing it would
// gate exactly the wrong set of people.
const authorFollowsViewer = (gateChoice === 'following' && viewer) ? !!(await av.followRkey(thread.post.did, viewer.did)) : false
// #listRule — resolvable ONLY when the list is one this node already tracks (a community roster).
// Otherwise membership stays null, which means "unknown", NOT "excluded": the reply box closes but
// the notice says the membership could not be checked, rather than telling someone they were left
// out of a list we never read.
let listMembers = null
let listUnknown = false
if (gateChoice === 'list') {
const lu = listUriOf(gateRec)
const parsed = lu ? parseListUri(lu) : null
try {
// Resolvable only if we can reach the list OWNER as a peer — the same path the modlist
// subscription uses. An arbitrary bsky list is not readable from here, and pretending
// otherwise would be worse than saying so.
const ownerPeer = parsed ? await addPeer(parsed.owner) : null
if (ownerPeer) {
await ownerPeer.ready({ timeout: 15000 })
const r = await resolveRoster(ownerPeer, lu)
if (r && Array.isArray(r.members)) listMembers = r.members
else listUnknown = true
} else listUnknown = true
} catch { listUnknown = true }
}
const viewerCanReply = canReply({
viewerDid: viewer ? viewer.did : null,
authorDid: thread.post.did,
record: gateRec,
authorFollowsViewer,
mentionedDids: mentionsOf(thread.post.record),
listMembers
})
return html(res, 200, postPage({ post: thread.post, viewer, ancestors: thread.ancestors, replies: thread.replies, canWrite: NODE_CAN_WRITE, lensReplies, lensAncestors, lensesOn: LENSES_ON, quoteNote: describeQuoteGate(quoteChoiceOf(pgRec)),
viewerCanQuote: canQuote({ viewerDid: viewer ? viewer.did : null, authorDid: thread.post.did, record: pgRec }),
gateNote: describeGate(gateChoice) + (listUnknown ? ' — and this node cannot read that list, so it cannot check whether you are a member' : ''), viewerCanReply }))
}
// Search — this node's index, plus bsky via the lens, kept as SEPARATE sections (see searchPage).
// A plain GET, so a search is a shareable URL and works with scripting off.
// Lists — yours, and the members of one. Read straight from the record store: we know our own DID,
// so both collections are directly addressable and no index is needed.
if (path === '/lists' || path.startsWith('/lists/')) {
if (!viewer) return html(res, 200, listsPage({ viewer: null }))
const st = await writer.state().catch(() => null)
const own = st && st.did === viewer.did
const store = own ? await getRecordStore(st.did) : null
const lists = store ? await ownLists(store, st.did) : []
const rkey = path.startsWith('/lists/') ? decodeURIComponent(path.slice('/lists/'.length)) : null
if (!rkey) return html(res, 200, listsPage({ viewer, lists, canWrite: own }))
const one = lists.find((l) => l.rkey === rkey)
if (!one) return html(res, 404, listsPage({ viewer, lists, canWrite: own, notFound: rkey }))
const members = await membersOf(store, one.uri)
// Names where we have them; a bare DID otherwise. A list of opaque DIDs is unusable.
const hydrated = []
for (const m of members) {
const prof = await av.getProfile(m.subject)
hydrated.push({ ...m, displayName: (prof && prof.displayName) || null })
}
return html(res, 200, listPage({ viewer, list: one, members: hydrated }))
}
// Notifications — the server renders the shell and nothing else. See web/static/notifications.js:
// hpp requires a real user bearer to mint service auth (deliberately), and holding one here would
// make favela-web a credential store, which is the thing it is built not to be.
if (path === '/notifications') {
// Answered from what this node already knows: the inbound lens folds replies touching our
// content into the appview, and ap-bridge keeps what the fediverse sent. No credential.
let items = []
let apAvailable = null
if (viewer) {
const collected = []
try {
// Our own recent posts are the subjects anyone could have replied to. The reply index is
// keyed by parent uri, so this is N cheap local range reads, not a network call.
const own = await av.getAuthorFeed(viewer.did, { limit: 20, filter: 'posts_no_replies' })
for (const post of (own && own.posts) || []) {
if (!post || !post.uri) continue
for (const r of await av.getReplies(post.uri, { limit: 20 })) {
if (!r || r.did === viewer.did) continue // your own reply is not a notification
collected.push({ ...fromReply(r, post.uri), authorDid: r.did })
}
}
} catch (e) {
console.error(JSON.stringify({ msg: 'notifications: atproto side failed', error: e.message }))
}
const apItems = await apInbox.list({ limit: 50 })
apAvailable = apItems !== null // null means COULD NOT ASK, not "none"
items = mergeNotifications({
atproto: collected,
activitypub: (apItems || []).map(fromApItem).filter(Boolean),
viewerDid: viewer.did,
limit: 50
})
}
return html(res, 200, notificationsPage({ viewer, items, apAvailable, pds: EXTERNAL_URL || 'https://pds.7rnx.net' }))
}
// Bookmarks — the viewer's own saved posts. Nothing shared, nothing federated.
if (path === '/bookmarks') {
if (!viewer) return html(res, 200, bookmarksPage({ viewer: null }))
const rows = await bookmarkStore.list(viewer.did, { limit: 50 })
const posts = []
let missing = 0
for (const row of rows) {
const ref = parseAtUri(row.uri)
if (!ref) { missing++; continue }
// A bookmark can outlive the post it points at — deleted by its author, or no longer held here.
const p = await av.getPost(ref.did, ref.rkey)
if (p) { p.viewerBookmarked = true; posts.push(p) } else missing++
}
const result = await withLensBylines(await withMod({ posts }, viewer.did))
return html(res, 200, bookmarksPage({ result, viewer, missing }))
}
if (path === '/search') {
const q = url.searchParams.get('q') || ''
if (!q.trim()) return html(res, 200, searchPage({ q: '', viewer, canWrite: NODE_CAN_WRITE }))
const local = await withLensBylines(await withMod(await av.searchPosts(q, { limit: 25, viewerDid: viewer ? viewer.did : null }), viewer ? viewer.did : null))
const actors = await av.searchActors(q, { limit: 10 })
// bsky's own search. Both endpoints are public — no auth, no session — so this needs none of the
// service-auth chain that notifications are blocked on. Failure is soft: a lens that is down or
// slow yields no remote section rather than an error page.
let lens = null
if (lensReader && LENSES.length) {
const l = LENSES[0]
try {
const [pRes, aRes] = await Promise.all([
lensReader.get(l, 'app.bsky.feed.searchPosts', { q, limit: 15 }).catch(() => null),
lensReader.get(l, 'app.bsky.actor.searchActors', { q, limit: 8 }).catch(() => null)
])
const posts = (pRes && Array.isArray(pRes.posts) ? pRes.posts : []).map((p) => ({
did: (p.author && p.author.did) || '',
handle: (p.author && p.author.handle) || null,
displayName: (p.author && p.author.displayName) || null,
text: (p.record && p.record.text) || '',
createdAt: (p.record && p.record.createdAt) || null,
replyCount: p.replyCount || 0
}))
const acts = (aRes && Array.isArray(aRes.actors) ? aRes.actors : []).map((a) => ({
did: a.did, handle: a.handle || null, displayName: a.displayName || null, description: a.description || null
}))
if (posts.length || acts.length) lens = { name: l.name, posts, actors: acts }
else lens = { name: l.name, posts: [], actors: [] }
} catch { lens = null }
}
return html(res, 200, searchPage({ q, local, actors, lens, viewer, canWrite: NODE_CAN_WRITE }))
}
if (path.startsWith('/tag/')) {
const tag = decodeURIComponent(path.slice('/tag/'.length))
const result = await withLensBylines(await withMod(await av.getTagFeed(tag, { limit: 50, cursor: url.searchParams.get('cursor') || null, viewerDid: viewer ? viewer.did : null }), viewer ? viewer.did : null))
return html(res, 200, tagPage({ tag, result, viewer }))
}
if (path === '/feed.xml') {
const { posts } = await av.getTimeline({ limit: 50 })
return xml(res, 200, renderAtom({ posts, selfUrl: EXTERNAL_URL + '/feed.xml' }), 'application/atom+xml; charset=utf-8')
}
// ── JSON APIs (programmatic / progressive enhancement) ─────
if (path === '/api/timeline') {
const limit = Math.min(Number(url.searchParams.get('limit')) || 30, 100)
return json(res, 200, JSON.stringify(await withMod(await av.getTimeline({ limit, authors: await scopeOf(viewer) }))))
}
// Which co-owner's firehose are we following, and why? Read-only, no auth (it exposes no content —
// only keys, revs and lengths that peers already announce to each other). Added because a frozen
// ingest is invisible from every other surface: see lib/fronts-diag.js.
// The operator page. Same data as /api/fronts + /api/lens, rendered for a human — a raw JSON blob is
// what we already had, and it is not what gets looked at when something is quietly frozen.
if (path === '/status') {
return html(res, 200, statusPage({
fronts: frontsDiag(router),
lens: { enabled: LENSES_ON, lenses: LENSES, errors: LENS_ERRORS, stats: lensReader ? lensReader.stats() : null },
host: HOSTNAME,
viewer
}))
}
if (path === '/api/fronts') {
const want = url.searchParams.get('did')
return json(res, 200, JSON.stringify(frontsDiag(router, want ? [want] : null), null, 2))
}
// Lens config + live reader counters (cache hits, failures, open breakers). The boot log said only
// whether lenses were enabled; this says whether they are WORKING.
//
// ⛔ EXCLUDE POST, or this swallows it. This route carries no method check and sits ~250 lines ahead
// of the POST handler that SETS the lens, so without the guard the setter is unreachable code that
// returns diagnostics and looks like a successful save.
if (path === '/api/lens' && req.method !== 'POST') {
return json(res, 200, JSON.stringify({
enabled: LENSES_ON,
lenses: LENSES.map((l) => ({ name: l.name, base: l.base, proxy: l.proxy || null })),
errors: LENS_ERRORS,
stats: lensReader ? lensReader.stats() : null
}, null, 2))
}
// Inbound-lens health. Everything §7.6 can silently drop — queue overflow, verify refusals,
// re-encode refusals, budget evictions, backfill truncation — is countable here or it is invisible.
if (path === '/api/inbound') {
return json(res, 200, JSON.stringify(
inbound ? { enabled: true, ...inbound.stats } : { enabled: false }, null, 2))
}
if (path === '/api/author') {
const did = url.searchParams.get('did')
if (!did) return json(res, 400, JSON.stringify({ error: 'did required' }))
return json(res, 200, JSON.stringify(await withMod(await av.getAuthorFeed(did, { limit: 30 }))))
}
if (path === '/api/feed') {
const tag = url.searchParams.get('tag')
if (!tag) return json(res, 400, JSON.stringify({ error: 'tag required' }))
const limit = Math.min(Number(url.searchParams.get('limit')) || 30, 100)
return json(res, 200, JSON.stringify(await withMod(await av.getTagFeed(tag, { limit }))))
}
// ── the read surface, as JSON ──────────────────────────────
// These answer the same questions the SSR pages already do, from the same appview methods. The pages
// rendered them straight to HTML, so a non-browser client had nowhere to ask — and favela-android
// hand-wrote each one as an IPC verb over its OWN second appview, which is how the phone ended up
// with a duplicate index and a second definition of every query.
//
// The engine is shared; only the RENDERING differs per surface. So the query belongs here and the
// markup does not. Web keeps using the SSR pages; mobile and desktop call these.
// `viewer` here is the SESSION object, not a DID — every call below takes viewer.did, matching the
// SSR routes. And withMod indexes `result.posts` unconditionally, so it must be handed a { posts }
// shape; it mutates each post IN PLACE, which is what lets a flattened tree moderate the whole tree.
if (path === '/api/thread') {
const uri = url.searchParams.get('uri')
if (!uri) return json(res, 400, JSON.stringify({ error: 'uri required' }))
const viewerDid = viewer ? viewer.did : null
const depth = Math.min(Number(url.searchParams.get('depth')) || 6, 40)
const parentHeight = Math.min(Number(url.searchParams.get('parentHeight')) || 80, 200)
const thread = await av.getPostThread(uri, { depth, parentHeight, viewerDid })
if (!thread) return json(res, 404, JSON.stringify({ error: 'not found' }))
// Flatten to moderate: threadPostList yields the SAME objects the tree holds, so labels/mutes
// applied here propagate back through the nested replies. Moderating only the root plus its
// direct replies let a nested reply bypass labels entirely (0.3.14, on the SSR page).
await withMod({ posts: threadPostList(thread) }, viewerDid)
return json(res, 200, JSON.stringify({ thread }))
}
if (path === '/api/replies') {
const uri = url.searchParams.get('uri')
if (!uri) return json(res, 400, JSON.stringify({ error: 'uri required' }))
const viewerDid = viewer ? viewer.did : null
const limit = Math.min(Number(url.searchParams.get('limit')) || 50, 200)
const replies = await av.getReplies(uri, { limit, viewerDid })
const moderated = await withMod({ posts: replies }, viewerDid)
return json(res, 200, JSON.stringify({ replies: moderated.posts }))
}
if (path === '/api/search') {
const q = url.searchParams.get('q')
if (!q) return json(res, 400, JSON.stringify({ error: 'q required' }))
const viewerDid = viewer ? viewer.did : null
const kind = url.searchParams.get('kind') === 'actors' ? 'actors' : 'posts'
const limit = Math.min(Number(url.searchParams.get('limit')) || 25, 100)
// The two shapes differ AT THE SOURCE and must not be "tidied" into one: searchPosts already
// returns { posts, cursor, truncated } — truncated matters, a scan-capped result is not an empty
// one — while searchActors returns a bare array. Wrapping the first again yields {posts:{posts}}.
if (kind === 'actors') {
return json(res, 200, JSON.stringify({ actors: await av.searchActors(q, { limit }) }))
}
return json(res, 200, JSON.stringify(
await withMod(await av.searchPosts(q, { limit, viewerDid }), viewerDid)
))
}
if (path === '/api/follows' || path === '/api/followers') {
const did = url.searchParams.get('did') || (viewer ? viewer.did : null)
if (!did) return json(res, 400, JSON.stringify({ error: 'did required' }))
const dids = path === '/api/follows' ? await av.following(did) : await av.followers(did)
if (url.searchParams.get('hydrate') !== '1') return json(res, 200, JSON.stringify({ dids }))
// hydrate=1 — profiles included, so a client is not forced into N+1 requests to render a list of
// people. Same shape as the /profile/<did>/(followers|following) page, and hydrated the same way:
// profiles in PARALLEL, handles raced against a deadline. The profile page learned that serially
// — it hung for >90s. A row whose handle misses the deadline renders as a short DID, which is the
// honest fallback rather than an error.
const limit = Math.min(Number(url.searchParams.get('limit')) || 200, 500)
const slice = dids.slice(0, limit)
const deadline = delay(2500)
const people = await Promise.all(slice.map(async (d) => {
const prof = await av.getProfile(d).catch(() => null)
let handle = null
try { handle = await Promise.race([handleResolver.resolve(d), deadline.then(() => null)]) } catch {}
return {
did: d,
handle: handle || (prof && prof.handle) || null,
displayName: (prof && prof.displayName) || null,
avatar: (prof && prof.avatar) || null,
description: (prof && prof.description) || null
}
}))
return json(res, 200, JSON.stringify({ dids, people, count: dids.length }))
}
// ── blobs: avatar + post images ────────────────────────────
// Raw-body upload (the browser POSTs the File directly; no multipart parser needed). Session-gated.
// Returns the atproto blob REF ready to embed in a record — the shape the fleet's existing records
// already use and that bsky demonstrably hydrates. favela stays keyless: the hpp stores the bytes.
if (req.method === 'POST' && path === '/api/upload') {
if (!viewer) return json(res, 401, JSON.stringify({ error: 'sign in first' }))
let bytes
try { bytes = await readBodyBuffer(req) } catch (e) { return json(res, e.code === 'ETOOLARGE' ? 413 : 400, JSON.stringify({ error: e.message })) }
if (!bytes || !bytes.length) return json(res, 400, JSON.stringify({ error: 'empty body' }))
const mime = imageMimeOrNull(bytes)
if (!mime) return json(res, 415, JSON.stringify({ error: 'not a recognised image (png/jpeg/gif/webp)' }))
try {
const up = await writer.uploadBlob(bytes, mime)
return json(res, 200, JSON.stringify({ blob: Writer.blobRef(up), cid: up.cid, mimeType: up.mimeType, size: up.size }))
} catch (e) { return json(res, 502, JSON.stringify({ error: 'upload failed: ' + e.message })) }
}
// Set the signed-in DID's avatar. Uploads the blob, then UPDATES app.bsky.actor.profile/self —
// reading the CURRENT record first so displayName/description (and any other field) survive. A
// blind put would silently blank the rest of the profile.
// Banner — the wide header image on a profile (app.bsky.actor.profile.banner). Identical shape to
// /api/avatar: same size cap, same magic-byte sniff, same signs-for-this-DID guard. Kept as its own
// route rather than a flag on the avatar one so a bug in either cannot overwrite the other's field.
if (req.method === 'POST' && path === '/api/banner') {
if (!viewer) return json(res, 401, JSON.stringify({ error: 'sign in first' }))
let bytes
try { bytes = await readBodyBuffer(req) } catch (e) { return json(res, e.code === 'ETOOLARGE' ? 413 : 400, JSON.stringify({ error: e.message })) }
if (!bytes || !bytes.length) return json(res, 400, JSON.stringify({ error: 'empty body' }))
const mime = imageMimeOrNull(bytes)
if (!mime) return json(res, 415, JSON.stringify({ error: 'not a recognised image (png/jpeg/gif/webp)' }))
try {
const st = await writer.state()
const deviceDid = st && st.did
if (!deviceDid) return json(res, 502, JSON.stringify({ error: 'device did not report a DID' }))
if (deviceDid !== viewer.did) return json(res, 403, JSON.stringify({ error: `this node signs for ${deviceDid}, not ${viewer.did} — banner not written` }))
const up = await writer.uploadBlob(bytes, mime)
const store = await getRecordStore(deviceDid)
const current = (await store.get('app.bsky.actor.profile', 'self')) || { $type: 'app.bsky.actor.profile' }
// Spread `current` FIRST so an existing avatar/displayName/description survives — a profile write
// that drops the other fields is the lossy-overwrite shape that cost two records on 2026-07-31.
const record = { ...current, $type: 'app.bsky.actor.profile', banner: Writer.blobRef(up) }
await writer.putRecord('app.bsky.actor.profile', 'self', record, { action: 'update' })
return json(res, 200, JSON.stringify({ ok: true, did: deviceDid, cid: up.cid, mimeType: up.mimeType, size: up.size }))
} catch (e) { return json(res, 502, JSON.stringify({ error: 'banner update failed: ' + e.message })) }
}
if (req.method === 'POST' && path === '/api/avatar') {
if (!viewer) return json(res, 401, JSON.stringify({ error: 'sign in first' }))
let bytes
try { bytes = await readBodyBuffer(req) } catch (e) { return json(res, e.code === 'ETOOLARGE' ? 413 : 400, JSON.stringify({ error: e.message })) }
if (!bytes || !bytes.length) return json(res, 400, JSON.stringify({ error: 'empty body' }))
const mime = imageMimeOrNull(bytes)
if (!mime) return json(res, 415, JSON.stringify({ error: 'not a recognised image (png/jpeg/gif/webp)' }))
try {
// favela's write path is single-DID: the UDS socket picks the device, so the record lands in
// whatever DID this hpp signs for. Refuse if that is not the viewer's own DID rather than write
// a profile into someone else's repo.
const st = await writer.state()
const deviceDid = st && st.did
if (!deviceDid) return json(res, 502, JSON.stringify({ error: 'device did not report a DID' }))
if (deviceDid !== viewer.did) {
return json(res, 403, JSON.stringify({ error: `this node signs for ${deviceDid}, not ${viewer.did} — avatar not written` }))
}
const up = await writer.uploadBlob(bytes, mime)
const store = await getRecordStore(deviceDid)
const current = (await store.get('app.bsky.actor.profile', 'self')) || { $type: 'app.bsky.actor.profile' }
const record = { ...current, $type: 'app.bsky.actor.profile', avatar: Writer.blobRef(up) }
await writer.putRecord('app.bsky.actor.profile', 'self', record, { action: 'update' })
return json(res, 200, JSON.stringify({ ok: true, did: deviceDid, cid: up.cid, mimeType: up.mimeType, size: up.size, displayName: record.displayName || null }))
} catch (e) { return json(res, 502, JSON.stringify({ error: 'avatar update failed: ' + e.message })) }
}
// Like / unlike a post. A TOGGLE, because that is what the UI affords — and unliking is a delete of
// the viewer's OWN like record, whose rkey only their repo knows, so the appview is asked for it.
// Plain-form friendly (redirects back) so it works with scripting off, like the composer.
if (req.method === 'POST' && path === '/api/like') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let uri, back
try {
if (isForm) { const q = new URLSearchParams(raw); uri = q.get('uri'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); uri = b.uri; back = b.return }
} catch { uri = null }
const home = safeReturn(back)
const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
if (typeof uri !== 'string' || !uri.startsWith('at://')) return fail(400, 'uri must be an at:// post uri')
try {
const st = await writer.state()
const deviceDid = st && st.did
if (!deviceDid) return fail(502, 'device did not report a DID')
if (deviceDid !== viewer.did) return fail(403, `this node signs for ${deviceDid}, not ${viewer.did}`)
const existing = await av.likeRkey(deviceDid, uri)
if (existing) {
await writer.putRecord('app.bsky.feed.like', existing, null, { action: 'delete' })
await av.delLike({ did: deviceDid, rkey: existing })
} else {
// subject is a com.atproto.repo.strongRef — uri AND cid, both required. A uri-only subject
// indexes fine here and is REJECTED by bsky's lexicon validation, so it federates as nothing
// while the UI shows a like (shipped exactly that in 0.2.41). Refuse rather than write one.
const ref = await strongRef(uri, getRecordStore)
if (!ref) return fail(409, 'cannot like a post whose record bytes are not held locally — a like needs its cid')
const rkey = tidNow()
const record = { $type: 'app.bsky.feed.like', subject: ref, createdAt: new Date().toISOString() }
await writer.putRecord('app.bsky.feed.like', rkey, record, { action: 'create' })
await av.putLike({ did: deviceDid, rkey, record })
}
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, liked: !existing, likeCount: await av.likeCount(uri) }))
} catch (e) { return fail(502, 'like failed: ' + e.message) }
}
// Repost / unrepost. Same toggle + strongRef shape as /api/like — subject is a strongRef, so it
// FAILS CLOSED rather than writing a uri-only ref that bsky would silently drop.
if (req.method === 'POST' && path === '/api/repost') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let uri, back
try {
if (isForm) { const q = new URLSearchParams(raw); uri = q.get('uri'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); uri = b.uri; back = b.return }
} catch { uri = null }
const home = safeReturn(back)
const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
if (typeof uri !== 'string' || !uri.startsWith('at://')) return fail(400, 'uri must be an at:// post uri')
try {
const st = await writer.state()
const deviceDid = st && st.did
if (!deviceDid) return fail(502, 'device did not report a DID')
if (deviceDid !== viewer.did) return fail(403, `this node signs for ${deviceDid}, not ${viewer.did}`)
const existing = await av.repostRkey(deviceDid, uri)
if (existing) {
await writer.putRecord('app.bsky.feed.repost', existing, null, { action: 'delete' })
await av.delRepost({ did: deviceDid, rkey: existing })
} else {
const ref = await strongRef(uri, getRecordStore)
if (!ref) return fail(409, 'cannot repost a post whose record bytes are not held locally — a repost needs its cid')
const rkey = tidNow()
const record = { $type: 'app.bsky.feed.repost', subject: ref, createdAt: new Date().toISOString() }
await writer.putRecord('app.bsky.feed.repost', rkey, record, { action: 'create' })
await av.putRepost({ did: deviceDid, rkey, record })
}
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, reposted: !existing, repostCount: await av.repostCount(uri) }))
} catch (e) { return fail(502, 'repost failed: ' + e.message) }
}
// Follow / unfollow a DID. Same toggle shape. A follow is what scopes the Following feed below.
// ── the lens setting, from the settings page ─────────────────────────────────────────────────
// Which remote AppView this node may read was reachable only by editing apps.json and restarting,
// which makes it a deployment detail rather than a setting — and on a handset there is no apps.json
// at all, so it could not be changed there by any means.
//
// ⛔ OPERATOR ONLY. This decides where this node sends the DIDs it looks up, so it is gated the same
// way /api/follow is: the viewer must be the identity this node signs for. A signed-in guest on
// somebody else's instance must not be able to repoint its reads.
//
// Applied IN PLACE. A setting that needs a daemon restart is a config file with extra steps.
if (req.method === 'POST' && path === '/api/lens') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let value, back
try {
if (isForm) { const q = new URLSearchParams(raw); value = q.get('bridgeUrl'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); value = b.bridgeUrl; back = b.return }
} catch { value = null }
const home = safeReturn(back) || '/settings'
const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
const st = await writer.state().catch(() => null)
if (!st || !st.did || st.did !== viewer.did) return fail(403, 'only this node\'s own identity can change where it reads')
const parsed = parseLensPref(value)
if (parsed.error) return fail(400, parsed.error)
try {
fs.writeFileSync(STORE(LENS_PREF_FILE), JSON.stringify({ bridgeUrl: parsed.bridgeUrl }, null, 2) + '\n')
LENS_PREF = { bridgeUrl: parsed.bridgeUrl }
const next = parseLenses(lensEnv(process.env, LENS_PREF))
for (const e of next.errors) console.error(JSON.stringify({ msg: 'lens config problem — entry ignored', error: e }))
LENSES = next.lenses
LENS_ERRORS = next.errors // or /api/lens keeps reporting the errors from boot
LENSES_ON = lensesEnabled(LENSES)
// Rebuilt rather than kept: the reader owns the cache, the circuit breaker and the concurrency
// cap, and carrying those across a change of source would answer the new lens from the old one.
lensReader = LENSES_ON ? createLensReader({ doFetch: bareFetch }) : null
console.log(JSON.stringify({ msg: 'lens import reconfigured', enabled: LENSES_ON, lenses: LENSES.map((l) => l.name) }))
} catch (e) { return fail(502, 'could not save: ' + e.message) }
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, enabled: LENSES_ON, lenses: LENSES.map((l) => l.name) }))
}
if (req.method === 'POST' && path === '/api/follow') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let subject, back
try {
if (isForm) { const q = new URLSearchParams(raw); subject = q.get('did'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); subject = b.did; back = b.return }
} catch { subject = null }
const home = safeReturn(back)
const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
if (typeof subject !== 'string' || !subject.startsWith('did:')) return fail(400, 'did required')
try {
const st = await writer.state()
const deviceDid = st && st.did
if (!deviceDid) return fail(502, 'device did not report a DID')
if (deviceDid !== viewer.did) return fail(403, `this node signs for ${deviceDid}, not ${viewer.did}`)
// Following yourself is meaningless and would pollute your own Following feed with your own posts
// twice over — refuse rather than write a record nothing can use.
if (subject === deviceDid) return fail(400, 'cannot follow yourself')
const existing = await av.followRkey(deviceDid, subject)
if (existing) {
await writer.putRecord('app.bsky.graph.follow', existing, null, { action: 'delete' })
await av.delFollow({ did: deviceDid, rkey: existing })
} else {
const rkey = tidNow()
const record = { $type: 'app.bsky.graph.follow', subject, createdAt: new Date().toISOString() }
await writer.putRecord('app.bsky.graph.follow', rkey, record, { action: 'create' })
await av.putFollow({ did: deviceDid, rkey, record })
// ⛔ SUBSCRIBE NOW, NOT AT THE NEXT BOOT. A follow is the durable, replicating statement of
// interest in an identity, and it used to change nothing until the node restarted: FAVELA_ROSTER
// is captured at import, and favela-android's syncFollows runs only inside boot(). So following
// someone left their posts absent — or worse, left an earlier one-shot view on screen, ageing
// silently, which is how a handset came to show a two-week-old profile and its previous avatar
// as though they were current (hyper-social #53).
//
// addPeer is the same runtime path /api/track uses: it joins the DID's topic, resolves a verify
// key and starts the ingest loop. Idempotent, so a re-follow costs nothing.
//
// Detached and non-fatal ON PURPOSE. The follow RECORD is the durable half and it is already
// written; making the response wait on a DHT rendezvous would turn a local write into a
// network-latency operation, and an unreachable peer (every bsky-hosted account, which no peer
// serves) would read as "follow failed" when the follow succeeded perfectly.
addPeer(subject).then(
(p) => { if (!p) console.log(JSON.stringify({ msg: 'follow: nobody serves this DID yet — the follow stands, ingest starts when someone does', did: subject })) },
(e) => console.error(JSON.stringify({ msg: 'follow: subscribe failed (the follow itself is written)', did: subject, error: e.message }))
)
}
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, following: !existing }))
} catch (e) { return fail(502, 'follow failed: ' + e.message) }
}
// Edit displayName / description. There was no way to set either: Settings did the avatar only, and
// no hpp subcommand writes records — so every peer's display name was whatever it happened to be at
// onboarding, and "change my name" meant editing a DAG-CBOR record by hand. Every atproto client has
// this; favela did not.
//
// Accepts JSON *or* form-urlencoded so it works with scripting off (same dual shape as /api/post):
// the no-JS path posts a plain <form> and gets a redirect back to /settings.
// One person, as JSON. `FavelaApi.profile({did})` has called GET /api/profile since it was written and
// this server only ever handled the POST — so the client has always 404'd here, silently, because
// every in-tree caller happened to read profiles through a LIST route that hydrates them inline
// (/api/follows?hydrate=1, /api/people). A phone asking about one tracked DID has no list to ride on.
//
// ⛔ `held` IS THE POINT. A DID we hold no profile record for is normal — a network follower
// discovered through a backlink, or an account indexed before it ever wrote app.bsky.actor.profile —
// and it must not render as "someone with no name". Absent and nameless are different facts, and the
// caller can only tell them apart if we say which one this is.
if (req.method === 'GET' && path === '/api/profile') {
const did = url.searchParams.get('did')
if (!did || !did.startsWith('did:')) return json(res, 400, JSON.stringify({ error: 'did required' }))
const prof = await av.getProfile(did).catch(() => null)
// Raced against a deadline, like every other handle hydration here: a handle that misses it renders
// as a short DID, which is the honest fallback. The profile page learned this the slow way — it
// resolved serially and hung for >90s.
let h = null
try { h = await Promise.race([handleResolver.resolve(did), delay(2500).then(() => null)]) } catch {}
return json(res, 200, JSON.stringify({
did,
held: !!prof,
displayName: (prof && prof.displayName) || null,
description: (prof && prof.description) || null,
avatar: (prof && prof.avatar) || null,
handle: h && h.verified ? h.handle : null,
verified: !!(h && h.verified)
}))
}
if (req.method === 'POST' && path === '/api/profile') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const fail = (code, msg) => isForm ? redirect(res, '/settings') : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
const raw = (await readBody(req)) || ''
let displayName, description
try {
if (isForm) { const q = new URLSearchParams(raw); displayName = q.get('displayName'); description = q.get('description') }
else { const b = JSON.parse(raw || '{}'); displayName = b.displayName; description = b.description }
} catch { return fail(400, 'bad body') }
// atproto caps these in GRAPHEMES (app.bsky.actor.profile: displayName 64, description 256), which
// is not string length — an emoji is one grapheme and several UTF-16 units. Rejecting on .length
// would refuse legal values.
const count = (v) => { try { return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(v)].length } catch { return [...String(v)].length } }
if (displayName != null && count(String(displayName)) > 64) return fail(400, 'display name is over 64 characters')
if (description != null && count(String(description)) > 256) return fail(400, 'bio is over 256 characters')
try {
// Same guard as /api/avatar: the UDS socket picks the device, so refuse rather than write a
// profile into a DID this node does not sign for.
const st = await writer.state()
const deviceDid = st && st.did
if (!deviceDid) return fail(502, 'device did not report a DID')
if (deviceDid !== viewer.did) return fail(403, `this node signs for ${deviceDid}, not ${viewer.did} — profile not written`)
const store = await getRecordStore(deviceDid)
const current = (await store.get('app.bsky.actor.profile', 'self')) || { $type: 'app.bsky.actor.profile' }
const record = { ...current, $type: 'app.bsky.actor.profile' }
// An EMPTY field clears it: atproto models "unset" as the key being absent, not as an empty
// string, and a stray "" would render as a blank name rather than falling back to the DID.
for (const [k, v] of [['displayName', displayName], ['description', description]]) {
if (v == null) continue
const t = String(v).trim()
if (t) record[k] = t; else delete record[k]
}
await writer.putRecord('app.bsky.actor.profile', 'self', record, { action: 'update' })
if (isForm) return redirect(res, '/settings')
return json(res, 200, JSON.stringify({ ok: true, did: deviceDid, displayName: record.displayName || null, description: record.description || null }))
} catch (e) { return fail(502, 'profile update failed: ' + e.message) }
}
// Create a list. Two records make a list work (see lib/lists.js): this writes the `list` itself;
// membership is separate `listitem` records.
if (req.method === 'POST' && path === '/api/list') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let name, purpose, description
try {
if (isForm) { const q = new URLSearchParams(raw); name = q.get('name'); purpose = q.get('purpose'); description = q.get('description') }
else { const b = JSON.parse(raw || '{}'); name = b.name; purpose = b.purpose; description = b.description }
} catch { name = null }
const fail = (code, msg) => isForm ? redirect(res, '/lists') : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
// PURPOSE is not a label: a modlist mutes or blocks everyone on it for anyone who subscribes, so it
// must be chosen explicitly and never defaulted into.
const rec = listRecord({ name, purpose: PURPOSES.includes(purpose) ? purpose : CURATE, description })
if (!rec) return fail(400, 'a list needs a name')
try {
const st = await writer.state()
if (!st || st.did !== viewer.did) return fail(403, 'this node does not sign for you')
const rkey = tidNow()
await writer.putRecord(LIST_COLLECTION, rkey, rec, { action: 'create' })
if (isForm) return redirect(res, '/lists')
return json(res, 200, JSON.stringify({ ok: true, uri: mkListUri(st.did, rkey), rkey }))
} catch (e) { return fail(502, 'list create failed: ' + e.message) }
}
// Add or remove a member. A TOGGLE, because that is what the UI affords — and removing means deleting
// THEIR listitem, so the rkey has to be found first.
if (req.method === 'POST' && path === '/api/list-member') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let list, subject, back
try {
if (isForm) { const q = new URLSearchParams(raw); list = q.get('list'); subject = q.get('subject'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); list = b.list; subject = b.subject; back = b.return }
} catch { list = null }
const home = safeReturn(back)
const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
if (typeof subject !== 'string' || !subject.startsWith('did:')) return fail(400, 'subject must be a did')
try {
const st = await writer.state()
if (!st || st.did !== viewer.did) return fail(403, 'this node does not sign for you')
// You may only edit YOUR OWN list: the uri must name your DID. Without this check the form could
// be pointed at anyone's list uri.
const ref = parseAtUri(list)
if (!ref || ref.did !== st.did || ref.collection !== LIST_COLLECTION) return fail(403, 'not your list')
const store = await getRecordStore(st.did)
const existing = await memberRkey(store, list, subject)
if (existing) {
await writer.putRecord(LISTITEM_COLLECTION, existing, null, { action: 'delete' })
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, member: false }))
}
const rec = listitemRecord({ subject, list })
if (!rec) return fail(400, 'bad subject or list')
await writer.putRecord(LISTITEM_COLLECTION, tidNow(), rec, { action: 'create' })
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, member: true }))
} catch (e) { return fail(502, 'list membership failed: ' + e.message) }
}
// TRACK — start reading a DID this node does not already read. See spec/did-tracking.md.
//
// Tracking is TWO things and only one of them is persistence:
// 1. the ACTION — resolve the DID over P2P, wait for a verified head, start ingesting. That is
// what tells a user "nobody is serving this right now" instead of showing an empty screen
// forever, and a list entry cannot say it.
// 2. the MEMORY — an app.bsky.graph.list entry, so the set survives a restart AND syncs to every
// other device, which a device-local file (favela-android's tracked-dids.json) cannot.
//
// The roster is resolved ONCE at boot, so adding a list entry alone would sit inert until restart —
// hence this route does its own addPeer rather than relying on the roster being re-read.
if (req.method === 'POST' && path === '/api/track') {
const raw = (await readBody(req)) || ''
let did, list, wait
try { const b = JSON.parse(raw || '{}'); did = b.did; list = b.list; wait = b.wait } catch { did = null }
if (!viewer) return json(res, 401, JSON.stringify({ error: 'sign in first' }))
if (typeof did !== 'string' || !did.startsWith('did:')) return json(res, 400, JSON.stringify({ error: 'did required' }))
const timeout = Math.min(Number(wait) || 45000, 120000)
// HOSTED-FIRST. hpp's config `dids[]` is the authority, NOT roster membership — conflating the two
// froze ashatron's appview for hours on 2026-08-06 (lib/hosted-dids.js). A DID we host is already
// on disk; addPeer's coHosted branch baselines it from the shared store rather than the network.
// HOSTED_DIDS is null when hpp's config is unreadable — a normal state for a favela-web with no
// local hpp, not an error. Null means "we host nothing", which is the correct answer there.
const hosted = (HOSTED_DIDS || []).includes(did)
try {
// Idempotent: addPeer returns the existing peer if we already read this DID.
const peer = await addPeer(did)
if (!peer) return json(res, 502, JSON.stringify({ error: 'could not resolve a verify key for ' + did }))
// addPeer returns BEFORE the ingest loop has a front — it starts the loop detached. So wait for a
// verified front here, which is the whole point of the route: an unreachable DID must say so.
let front = null
try { front = await router.front(did, { timeout }) } catch { front = null }
if (!front) {
return json(res, 503, JSON.stringify({
error: 'no verified head for ' + did + ' — nobody is serving it right now',
did,
hosted,
tracked: false
}))
}
// Index once inline so the response reflects reality; the ingest loop also folds it continuously.
let indexed = null
try { const r = await av.indexPeer(front); indexed = r && r.indexed != null ? r.indexed : null } catch {}
// MEMORY. Skipped with list:false — "look at this once" is a real thing to want, and it should
// not silently edit a record that syncs to every other device.
let tracked = false
if (list !== false) {
try {
const st = await writer.state()
if (st && st.did === viewer.did) {
const target = typeof list === 'string' && list ? list : null
if (target) {
const ref = parseAtUri(target)
if (!ref || ref.did !== st.did || ref.collection !== LIST_COLLECTION) {
return json(res, 403, JSON.stringify({ error: 'not your list' }))
}
const store = await getRecordStore(st.did)
if (!(await memberRkey(store, target, did))) {
const rec = listitemRecord({ subject: did, list: target })
if (rec) { await writer.putRecord(LISTITEM_COLLECTION, tidNow(), rec, { action: 'create' }); tracked = true }
} else tracked = true
}
}
} catch (e) {
// The DID is being read either way — membership is the durable half, not the working half.
console.error(JSON.stringify({ msg: 'track: membership write failed (still ingesting)', did, error: e.message }))
}
}
return json(res, 200, JSON.stringify({
did,
hosted,
indexed,
rev: (front.head && front.head.rev) || null,
tracked
}))
} catch (e) {
return json(res, 502, JSON.stringify({ error: 'track failed: ' + e.message }))
}
}
// BOOKMARK — private, node-local, per viewer. Writes nothing to the repo and federates nothing.
if (req.method === 'POST' && path === '/api/bookmark') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let uri, back
try {
if (isForm) { const q = new URLSearchParams(raw); uri = q.get('uri'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); uri = b.uri; back = b.return }
} catch { uri = null }
const home = safeReturn(back)
const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
if (typeof uri !== 'string' || !uri.startsWith('at://')) return fail(400, 'uri must be an at:// post uri')
const on = await bookmarkStore.has(viewer.did, uri)
if (on) await bookmarkStore.remove(viewer.did, uri)
else await bookmarkStore.add(viewer.did, uri)
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, bookmarked: !on }))
}
// MUTE — private and NODE-LOCAL. Never a record: in atproto a mute is server-side state, so writing
// one to the repo would publish "alice muted bob" to the network permanently, in a signed record.
// Nothing here touches hpp or the repo at all.
// Theme preference. Writes nothing but a cookie — no repo, no index, no session. A plain form POST
// with a redirect so it works with scripting off, which is the whole point: the no-JS path is the
// one that has to carry this, because a script-flipped class cannot fix the first paint.
//
// NOT HttpOnly, deliberately, unlike the session cookie: this is a display preference, and leaving
// it readable lets a future client-side control stay in sync without a round trip. It carries no
// authority, so there is nothing to steal.
if (req.method === 'POST' && path === '/api/theme') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let choice, back
try {
if (isForm) { const q = new URLSearchParams(raw); choice = q.get('theme'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); choice = b.theme; back = b.return }
} catch { choice = null }
const home = safeReturn(back) || '/settings'
// 'system' clears the cookie rather than storing a third value, so "follow the system" is the
// ABSENCE of a preference — the same state as a reader who never touched this.
const cookie = THEMES.has(choice)
? `favela_theme=${choice}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 3600}`
: 'favela_theme=; Path=/; SameSite=Lax; Max-Age=0'
return redirect(res, home, { 'set-cookie': cookie })
}
if (req.method === 'POST' && path === '/api/mute') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let did, back
try {
if (isForm) { const q = new URLSearchParams(raw); did = q.get('did'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); did = b.did; back = b.return }
} catch { did = null }
const home = safeReturn(back)
const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
if (typeof did !== 'string' || !did.startsWith('did:')) return fail(400, 'did required')
if (did === viewer.did) return fail(400, 'muting yourself is meaningless')
const on = await muteStore.isMuted(viewer.did, did)
if (on) await muteStore.unmute(viewer.did, did)
else await muteStore.mute(viewer.did, did)
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, muted: !on }))
}
// BLOCK — PUBLIC. app.bsky.graph.block is a real record that federates by design; bsky renders blocks
// as mutually visible. So this writes through hpp exactly like a follow, and remembers the rkey
// locally so unblock can delete it without scanning the repo for a matching subject.
//
// ⚠ KNOWN LIMIT: the appview does not yet ingest app.bsky.graph.block (adding a collection to the
// ingest filter means bumping INGEST_VERSION and re-indexing every DID). So a block made on ANOTHER
// device is not reflected here until that lands. Stated rather than hidden — the record itself is
// correct and federates; only this node's awareness of a remote block is missing.
if (req.method === 'POST' && path === '/api/block') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let did, back
try {
if (isForm) { const q = new URLSearchParams(raw); did = q.get('did'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); did = b.did; back = b.return }
} catch { did = null }
const home = safeReturn(back)
const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
if (typeof did !== 'string' || !did.startsWith('did:')) return fail(400, 'did required')
if (did === viewer.did) return fail(400, 'blocking yourself is meaningless')
try {
const st = await writer.state()
const deviceDid = st && st.did
if (!deviceDid) return fail(502, 'device did not report a DID')
if (deviceDid !== viewer.did) return fail(403, `this node signs for ${deviceDid}, not ${viewer.did}`)
// Prefer the INDEXED rkey: a block made on another device has no local bookkeeping, and without
// this an unblock would appear to work while the record stayed in the repo.
const existing = (await av.blockRkeyFor(viewer.did, did)) || (await muteStore.blockRkey(viewer.did, did))
if (existing) {
await writer.putRecord('app.bsky.graph.block', existing, null, { action: 'delete' })
await muteStore.unblock(viewer.did, did)
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, blocked: false }))
}
const rkey = tidNow()
await writer.putRecord('app.bsky.graph.block', rkey, { $type: 'app.bsky.graph.block', subject: did, createdAt: new Date().toISOString() }, { action: 'create' })
await muteStore.block(viewer.did, did, rkey)
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, blocked: true }))
} catch (e) { return fail(502, 'block failed: ' + e.message) }
}
// Delete one of the VIEWER'S OWN posts. Irreversible: the record leaves the repo and the delete
// federates, so anyone already holding it sees a tombstone rather than the post.
//
// OWNERSHIP IS ENFORCED HERE, not by hiding the button. The UI only renders the control on your own
// posts, but a hidden control is not a permission check — the uri arrives from the client and the
// only thing standing between it and someone else's repo is this comparison. Two gates: the uri's DID
// must be the viewer's, AND this node must actually sign for that DID (the same pair every other
// write path checks, because a node signing for someone else would make the first gate meaningless).
// GET /edit/<did>/<rkey> — the prefilled edit form. Own posts only; the check is repeated on
// POST because a GET guard is a UI affordance, not a permission.
{
const m = path.match(/^\/edit\/([^/]+)\/([^/]+)$/)
if (m && req.method === 'GET') {
const did = decodeURIComponent(m[1]); const rkey = decodeURIComponent(m[2])
const back = safeReturn(url.searchParams.get('return'))
if (!viewer) return redirect(res, '/login')
if (did !== viewer.did) return html(res, 403, editPage({ post: null, viewer, returnTo: back, error: 'you can only edit your own posts' }))
const post = await av.getPost(did, rkey).catch(() => null)
return html(res, post ? 200 : 404, editPage({ post, viewer, returnTo: back }))
}
}
// POST /api/edit — replace a post's text in place. Mirrors /api/delete's ownership checks
// exactly; see spec/record-vocabulary.md §10.5 for why this is honest on P2P and AP and only
// partial on bsky.
if (req.method === 'POST' && path === '/api/edit') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let uri, back, text
try {
if (isForm) { const q = new URLSearchParams(raw); uri = q.get('uri'); back = q.get('return'); text = q.get('text') }
else { const b = JSON.parse(raw || '{}'); uri = b.uri; back = b.return; text = b.text }
} catch { uri = null }
const home = safeReturn(back)
const ref = typeof uri === 'string' ? parseAtUri(uri) : null
const fail = async (code, msg) => {
if (!isForm) return json(res, code, JSON.stringify({ error: msg }))
const post = ref ? await av.getPost(ref.did, ref.rkey).catch(() => null) : null
return html(res, code, editPage({ post, viewer, returnTo: home, error: msg }))
}
if (!viewer) return fail(401, 'sign in first')
if (!ref) return fail(400, 'uri must be an at:// post uri')
if (ref.did !== viewer.did) return fail(403, 'you can only edit your own posts')
if (!POST_COLLECTIONS.includes(ref.collection)) return fail(400, 'not a post collection')
if (!text || !String(text).trim()) return fail(400, 'a post cannot be edited to nothing — delete it instead')
try {
const st = await writer.state()
const deviceDid = st && st.did
if (!deviceDid) return fail(502, 'device did not report a DID')
if (deviceDid !== viewer.did) return fail(403, `this node signs for ${deviceDid}, not ${viewer.did}`)
// The PRIOR record is what preserves createdAt, reply and embed — an edit that dropped them
// would silently detach a thread or delete images (writer.editPost documents why).
const prior = await av.getPost(ref.did, ref.rkey).catch(() => null)
await writer.editPost(ref.collection, ref.rkey, String(text), {
prior: prior && prior.record ? prior.record : null,
resolveHandle
})
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, edited: uri }))
} catch (e) { return fail(502, 'edit failed: ' + e.message) }
}
if (req.method === 'POST' && path === '/api/delete') {
const isForm = !(req.headers['content-type'] || '').includes('application/json')
const raw = (await readBody(req)) || ''
let uri, back
try {
if (isForm) { const q = new URLSearchParams(raw); uri = q.get('uri'); back = q.get('return') }
else { const b = JSON.parse(raw || '{}'); uri = b.uri; back = b.return }
} catch { uri = null }
const home = safeReturn(back)
const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
if (!viewer) return fail(401, 'sign in first')
const ref = typeof uri === 'string' ? parseAtUri(uri) : null
if (!ref) return fail(400, 'uri must be an at:// post uri')
if (ref.did !== viewer.did) return fail(403, 'you can only delete your own posts')
if (!POST_COLLECTIONS.includes(ref.collection)) return fail(400, 'not a post collection')
try {
const st = await writer.state()
const deviceDid = st && st.did
if (!deviceDid) return fail(502, 'device did not report a DID')
if (deviceDid !== viewer.did) return fail(403, `this node signs for ${deviceDid}, not ${viewer.did}`)
await writer.putRecord(ref.collection, ref.rkey, null, { action: 'delete' })
// Drop it from our own index immediately, so the page it redirects to does not still show it
// while the firehose round-trip completes.
try { await av.delPost({ did: deviceDid, rkey: ref.rkey }) } catch {}
if (isForm) return redirect(res, home)
return json(res, 200, JSON.stringify({ ok: true, deleted: uri }))
} catch (e) { return fail(502, 'delete failed: ' + e.message) }
}
// ── write: JSON (client) OR form-urlencoded (no-JS <form>) ──
if (req.method === 'POST' && path === '/api/post') {
const ctype0 = req.headers['content-type'] || ''
// ⛔ SIGN-IN GATE. This route had none: it authored as whatever DID the node signs for, so ANY
// unauthenticated request that could reach the port posted as that identity. FAVELA_HOST defaults
// to 0.0.0.0, so "could reach the port" is the whole network. Confirmed live on 2026-08-11 — an
// anonymous curl created a post in wj62.
//
// The near-miss is instructive: the images branch below already refuses without a viewer ("sign in
// to attach images"), so the gate was considered and applied to the ATTACHMENT rather than to the
// post. Every other write route here checks `!viewer` first; this one is now consistent with them.
//
// Checked BEFORE the body is read — an unauthenticated caller should not get to hand us bytes.
if (!viewer) {
return ctype0.includes('application/json')
? json(res, 401, JSON.stringify({ error: 'sign in first' }))
: redirect(res, '/')
}
const raw = (await readBody(req)) || ''
const ctype = ctype0
const isJson = ctype.includes('application/json')
let text, scope, images, replyTo
// `images` is JSON-only: it carries blob REFS returned by /api/upload, which the no-JS <form>
// path has no way to produce. The form keeps working exactly as before (text + scope).
// `replyTo` IS form-safe — the reply box is a plain form on the post page.
// `quoteOf` is form-safe like replyTo: the quote composer is a plain form, so quoting works with
// scripting off.
let quoteOf, gate, lang, quoteGate
if (isJson) { const b = JSON.parse(raw || '{}'); text = b.text; scope = b.scope; images = Array.isArray(b.images) ? b.images : null; replyTo = b.replyTo; quoteOf = b.quoteOf; gate = b.gate; lang = b.lang; quoteGate = b.quoteGate } else { const p = new URLSearchParams(raw); text = p.get('text'); scope = p.get('scope'); replyTo = p.get('replyTo'); quoteOf = p.get('quoteOf'); gate = p.get('gate'); lang = p.get('lang'); quoteGate = p.get('quoteGate') }
const backTo = replyTo ? (parseAtUri(replyTo) ? `/post/${encodeURIComponent(parseAtUri(replyTo).did)}/${encodeURIComponent(parseAtUri(replyTo).rkey)}` : '/') : '/'
if (!text) return isJson ? json(res, 400, JSON.stringify({ error: 'text required' })) : redirect(res, backTo)
if (images && images.length && !viewer) return json(res, 401, JSON.stringify({ error: 'sign in to attach images' }))
// A reply carries reply: { root, parent }, both strongRefs. `root` is the THREAD root: the parent's
// own root when the parent is itself a reply, else the parent. Getting this wrong is what makes a
// thread render as a flat list of orphans in every other client.
let reply = null
if (replyTo) {
const ref = parseAtUri(replyTo)
if (!ref) return isJson ? json(res, 400, JSON.stringify({ error: 'replyTo must be an at:// uri' })) : redirect(res, '/')
const parent = await strongRef(replyTo, getRecordStore)
if (!parent) return isJson ? json(res, 409, JSON.stringify({ error: 'cannot reply to a post whose record bytes are not held locally — a reply needs its cid' })) : redirect(res, backTo)
let root = parent
try {
const prec = await (await getRecordStore(ref.did)).get(ref.collection, ref.rkey)
const pr = prec && prec.reply && prec.reply.root
if (pr && typeof pr.uri === 'string' && typeof pr.cid === 'string') root = { uri: pr.uri, cid: pr.cid }
} catch {}
reply = { root, parent }
}
// A quote embeds a strongRef — uri AND cid — so it FAILS CLOSED exactly like a like or a reply:
// without the cid the record is lexicon-invalid and federates as nothing while looking fine here.
let quote = null
if (quoteOf) {
if (!parseAtUri(quoteOf)) return isJson ? json(res, 400, JSON.stringify({ error: 'quoteOf must be an at:// uri' })) : redirect(res, '/')
quote = await strongRef(quoteOf, getRecordStore)
if (!quote) return isJson ? json(res, 409, JSON.stringify({ error: 'cannot quote a post whose record bytes are not held locally — a quote needs its cid' })) : redirect(res, backTo)
}
// LANGUAGE. `langs` was hardcoded ['en'], which is wrong for anyone who does not write in English
// and is not cosmetic: bsky's custom-feed tuner runs a language detector on posts that omit langs
// and DROPS what it does not read as the viewer's language, so a mis-declared language makes a post
// vanish from feeds while looking fine everywhere else. A bad value is ignored rather than written.
const langs = /^[a-z]{2}(-[A-Za-z0-9]{2,8})?$/.test(String(lang || '')) ? [String(lang)] : ['en']
// SELF-THREAD. A line containing only --- splits the box into segments, each posted as a reply to
// the one before. Chosen over N textareas because it works with scripting off and survives a
// half-written draft being pasted around.
//
// The CHAIN is the whole correctness question: every segment after the first replies to its
// PREDECESSOR (parent) while carrying the FIRST post as root. Getting root wrong is what makes a
// thread render as a pile of orphans in every other client. Segments are posted in order and a
// failure mid-way STOPS rather than continuing — a thread with a hole in it is worse than a short
// one, and the segments already posted stay up.
const segments = String(text).split(/\r?\n\s*---\s*(?:\r?\n|$)/).map((t) => t.trim()).filter(Boolean)
if (!reply && segments.length > 1) {
const posted = []
let root = null
let parent = null
for (const seg of segments) {
const r = await writer.post(seg, {
scope: scope || DEFAULT_SCOPE,
// Images and the quote ride the FIRST segment only — they belong to the thread's opening
// post, and repeating them on every segment would post the same picture N times.
images: posted.length ? null : images,
quote: posted.length ? null : quote,
reply: parent ? { root, parent } : null,
resolveHandle,
langs
})
const st3 = await writer.state()
const uri = `at://${st3.did}/${r.collection}/${r.rkey}`
const ref = await strongRef(uri, getRecordStore)
if (!ref) break // cannot chain without a cid — stop cleanly rather than orphan the rest
if (!root) root = ref
parent = ref
posted.push(r)
}
const first = posted[0]
if (first && gate && GATE_CHOICES.includes(gate) && gate !== 'everyone') {
try {
const st4 = await writer.state()
const rec = gateRecord({ postUri: `at://${st4.did}/${first.collection}/${first.rkey}`, choice: gate })
if (rec) await writer.putRecord('app.bsky.feed.threadgate', first.rkey, rec, { action: 'create' })
} catch { /* the thread itself is fine */ }
}
return isJson ? json(res, 200, JSON.stringify({ ok: true, posts: posted.length, first: first || null })) : redirect(res, backTo)
}
const out = await writer.post(text, { scope: scope || DEFAULT_SCOPE, images, reply, resolveHandle, quote, langs })
// THREADGATE. A separate record whose rkey is IDENTICAL to the post's — that rkey is the entire
// linkage, so it can only be written once the post has one. 'everyone' writes NOTHING: an
// allow-everything gate would read as an empty allow, which means NOBODY (see lib/threadgate.js).
// A gate on a REPLY is meaningless (it gates a thread root), so it is only written for top-level
// posts. Failure here must never lose the post that already succeeded.
if (gate && (GATE_CHOICES.includes(gate) || gate.startsWith('at://')) && gate !== 'everyone' && !reply) {
try {
const st2 = await writer.state()
// A gate value that is an at:// uri means "members of THIS list" — #listRule, now authorable
// because lists are. Only your OWN list: the uri must name your DID, or the form could gate a
// thread on someone else's membership.
let rec
if (gate.startsWith('at://')) {
const lref = parseAtUri(gate)
rec = (lref && lref.did === st2.did && lref.collection === LIST_COLLECTION)
? { $type: 'app.bsky.feed.threadgate', post: `at://${st2.did}/${out.collection}/${out.rkey}`, allow: [{ $type: 'app.bsky.feed.threadgate#listRule', list: gate }], createdAt: new Date().toISOString() }
: null
} else {
rec = gateRecord({ postUri: `at://${st2.did}/${out.collection}/${out.rkey}`, choice: gate })
}
if (rec) await writer.putRecord('app.bsky.feed.threadgate', out.rkey, rec, { action: 'create' })
} catch (e) { console.error(JSON.stringify({ msg: 'threadgate write failed (post itself is fine)', error: e.message })) }
}
// POSTGATE — same rkey binding as the threadgate. 'anyone' writes nothing. Like a threadgate it
// only makes sense on a top-level post, and a failure must never lose the post that succeeded.
if (quoteGate === 'nobody' && QUOTE_CHOICES.includes(quoteGate) && !reply) {
try {
const st5 = await writer.state()
const pg = postgateRecord({ postUri: `at://${st5.did}/${out.collection}/${out.rkey}`, choice: quoteGate })
if (pg) await writer.putRecord('app.bsky.feed.postgate', out.rkey, pg, { action: 'create' })
} catch (e) { console.error(JSON.stringify({ msg: 'postgate write failed (post itself is fine)', error: e.message })) }
}
// Index our own write immediately so it shows without waiting for the firehose round-trip.
//
// ⛔ THIS USED TO BE `if (reply)`. A reply appeared in its thread instantly and a top-level post did
// not appear on the timeline at all until the firehose came back around — which reads as "my post
// vanished", and on 2026-08-18 sent us looking for a sync bug that was this asymmetry.
//
// READ THE RECORD BACK rather than rebuilding it. The hand-built shape carried text/createdAt/reply
// and dropped langs, embeds and facets, so an optimistic row rendered as a DEGRADED COPY of the post
// that then changed under the reader when the real one landed — an image appearing seconds later, a
// link becoming a link. The bytes hpp just signed are the truth; ask for them. The rebuild stays as
// a fallback for the window where the write is durable but not yet readable.
//
// Safe to run twice: putPost is dedup-gated by content signature, so the firehose fold that follows
// re-reads without re-writing.
try {
const wdid = (await writer.state()).did
let rec = null
try { rec = await (await getRecordStore(wdid)).get(out.collection, out.rkey) } catch {}
await av.putPost({
did: wdid,
collection: out.collection,
rkey: out.rkey,
record: rec || { $type: out.collection, text, createdAt: new Date().toISOString(), langs, ...(reply ? { reply } : {}) }
})
} catch {}
// ?posted=1 is the SUCCESS signal draft.js waits for. Clearing the draft on submit instead would
// throw the text away in exactly the case draft recovery exists for — the write that failed.
return isJson ? json(res, 200, JSON.stringify(out)) : redirect(res, backTo + (backTo.includes('?') ? '&' : '?') + 'posted=1')
}
json(res, 404, JSON.stringify({ error: 'not found' }))
} catch (e) {
// LOG IT, don't only answer it. This is the single catch-all for every route and it returned the
// reason to the CALLER and nowhere else — so a client that discards the body (an embedded WebView
// showing its own "HTTP 500" screen) leaves the operator with a 500 and no cause anywhere on the box.
// Cost a real debugging session on favela-android 2026-08-11. With the stack, because `e.message`
// alone cannot say WHICH of a route's several awaits threw.
//
// ⛔ `req.url`, NOT the parsed `url` — that is declared with const INSIDE the try above, so it is out
// of scope here and naming it would throw a ReferenceError from inside the error handler.
console.error(JSON.stringify({ msg: 'request failed', method: req.method, url: req.url, error: e.message, stack: e.stack }))
json(res, 500, JSON.stringify({ error: e.message }))
}
})
// ── com.atproto.label.subscribeLabels — WS firehose (phase 5), shares the HTTP server via 'upgrade' ──
// Only wired when a labeler is configured. bsky/Ozone (via the membrane proxy) subscribe with ?cursor=;
// LabelFirehose replays from there then live-tails. Any other upgrade path is rejected.
if (labelFirehose) {
const wss = new bareWs.Server({ server })
wss.on('connection', (ws, req) => {
try {
const u = new URL(req.url, 'http://localhost')
if (u.pathname !== '/xrpc/com.atproto.label.subscribeLabels') { try { ws.destroy() } catch {} ; return }
const cursor = Number(u.searchParams.get('cursor')) || 0
const stop = labelFirehose.serve((bytes) => { try { ws.write(Buffer.from(bytes)) } catch {} }, { cursor })
ws.on('close', stop)
ws.on('error', () => { try { stop() } catch {} })
} catch { try { ws.destroy() } catch {} }
})
console.log('[favela] subscribeLabels firehose online (WS)')
}
server.listen(PORT, HOST, () => console.log(`[favela] SSR gateway on ${EXTERNAL_URL} (bind ${HOST}:${PORT})`))
// ── Clean shutdown — CLOSE the corestores, don't just die ────────────────────────────────────────────
// favela-web had NO signal handler, so `pgh app stop` (SIGTERM, then SIGKILL after the grace period)
// killed it without ever calling close(). That STRANDS disk: RocksDB fallocate's each open WAL with
// FALLOC_FL_KEEP_SIZE at open — traced as a single `fallocate(fd, FALLOC_FL_KEEP_SIZE, 0, 73819750)`,
// i.e. write_buffer_size (64 MB) * 1.1 — and those blocks are released only by the `ftruncate(fd,
// <live size>)` that runs on a clean close. Measured: WHILE OPEN size=6192 allocated=73826304;
// AFTER CLOSE size=6192 allocated=8192. So an unclean exit leaves ~70 MB per store allocated
// indefinitely — exactly how the orphaned web-did dir ended up holding 70 MB with ZERO open fds.
//
// hpp already does this (index.js SIGINT/SIGTERM -> shutdown). Closing `store` covers the did/idx/comm
// namespaces (they are namespaces of it, not separate instances); labelerStore is separate by design
// and closed explicitly. Idempotent, and each close is time-boxed: a hung close must not block exit, or the
// supervisor SIGKILLs us and we are back to stranding blocks.
let shuttingDown = false
async function shutdown (signal, code = 0) {
if (shuttingDown) return
shuttingDown = true
console.log(`[favela] ${signal} — closing stores (releases preallocated WAL blocks)`)
const bounded = (p) => Promise.race([Promise.resolve(p).catch(() => {}), new Promise((r) => setTimeout(r, 4000))])
try { server.close() } catch {}
await bounded(swarm.destroy())
await bounded(store.close())
if (labelerStore) await bounded(labelerStore.close())
console.log('[favela] shutdown complete')
process.exit(code)
}
process.on('SIGTERM', () => { shutdown('SIGTERM') })
process.on('SIGINT', () => { shutdown('SIGINT') })
// ── OTA ──────────────────────────────────────────────────────────────────────────────────────────
// favela-web had NO control surface at all, so pgh-sidecar had no udsPath for it and could not push
// `release-ready`: autoupdate installed a new tree and the daemon kept serving the OLD code until
// someone ran stop/start by hand. That is the INSTALLED != RUNNING trap.
//
// A minimal hyper-svc Service on a UDS — status + release-ready, nothing else. This is a MANAGEMENT
// surface, not a second way into the app: it is a unix socket with no HTTP transport, so it is reachable
// only by something already on this machine as this user.
//
// ⚠ NOT pgh-runtime. It fetches ROOT trees without dep closures, which produces a broken install — the
// missing-dependency MODULE_NOT_FOUND that took the membrane down on 2026-07-31. The sidecar already
// does the full install correctly; our only job is to exit so the supervisor respawns onto it.
try {
const { Service } = await import('hyper-svc')
const { maintainSidecarRegistration } = await import('../lib/sidecar.js')
const udsPath = process.env.FAVELA_SOCK || STORE('svc.sock')
const svc = new Service({ name: 'favela-web', uds: udsPath })
svc.handle('status', async () => ({ ok: true, dids: ROSTER, port: PORT }))
// Operator HEALING, over the UDS rather than HTTP on purpose: this daemon binds 0.0.0.0, and a route
// that triggers outbound fetches and writes records has no business being reachable from the network.
// Same shape as hpp's `reindex-overlay` — repair the RUNNING daemon, no restart, owner-gated by the
// socket's file permissions. It must run IN here because the daemon holds the index store's lock.
//
// DRY RUN unless params.write. Driven by `favela-web repair-bytes` (lib/cli.js).
svc.handle('repair-bytes', async (ctx, params = {}) => {
const { repairBytes } = await import('../lib/repair-bytes.js')
const { createFetchRecord, createVerifyRecord, createIdentityResolver } = await import('../lib/record-fetch.js')
const want = params && params.did ? [params.did] : ROSTER
// Only ever repair DIDs this node does NOT host: where hpp is the authority those bytes are its job,
// and racing it is the whole class of bug this repair exists downstream of.
const dids = want.filter(isForeignDid)
const skipped = want.filter((d) => !isForeignDid(d))
const resolveIdentity = createIdentityResolver({ fetchImpl: bareFetch, plc: new PlcDirectory({ fetchImpl: bareFetch }) })
const report = await repairBytes({
dids,
listRefs: (did) => av.indexedRefs(did),
openStore: getRecordStore,
fetchRecord: createFetchRecord({ doFetch: bareFetch, resolveIdentity }),
verifyRecord: createVerifyRecord(),
write: !!(params && params.write),
limit: Number(params && params.limit) || 0,
onEvent: (event, detail) => {
if (event === 'written' || event === 'unverified' || event === 'reencode-differs') {
console.log(JSON.stringify({ msg: 'repair-bytes', event, ...detail }))
}
}
})
return { ok: true, write: !!(params && params.write), dids, skippedHostedLocally: skipped, ...report }
})
// The convergence loop: head (are we behind?), diff (what exactly differs?), backfill (pull it).
// Same UDS-only reasoning as repair-bytes above. All three share one resolver so the DID document is
// fetched once per call, and all three refuse DIDs this node HOSTS — hpp is the authority there.
const syncDeps = async () => {
const rs = await import('../lib/repo-sync.js')
const { createIdentityResolver } = await import('../lib/record-fetch.js')
const resolveIdentity = createIdentityResolver({ fetchImpl: bareFetch, plc: new PlcDirectory({ fetchImpl: bareFetch }) })
return { rs, resolveIdentity }
}
const targetDids = (params) => {
const want = params && params.did ? [params.did] : ROSTER
return { dids: want.filter(isForeignDid), skippedHostedLocally: want.filter((d) => !isForeignDid(d)) }
}
// The collection ledger (org #33). The THIRD of the three classes spec §2 defines, which no verb could
// previously show: `diff` reports what the authority holds, this reports what has arrived HERE and what
// we decided about it. Reads the daemon's own index, so it must run over the socket like the rest.
//
// The tracked + ignored halves are returned even when empty, because "we index these five, deliberately
// skip these two, and have never classified these" is the whole answer — a bare list of unknowns would
// leave the reader to work out what the other two classes even are.
svc.handle('collections', async (ctx, params = {}) => {
const ledger = await av.collectionLedger()
const seen = new Map(ledger.map((e) => [e.collection, e]))
return {
ok: true,
// The RAW ledger, alongside the curated classes below. The curation exists for a human reading a
// diagnostic; a programmatic caller (favela-android's `collections` IPC verb) wants the entries
// themselves, and synthesising them back out of `unknown` would be a subset masquerading as the
// whole. Additive — nothing that reads the curated fields changes.
ledger,
tracked: [...Appview.TRACKED.entries()].map(([collection, meta]) => ({ collection, view: meta.view, bytes: !!meta.bytes })),
ignored: [...IGNORED_COLLECTIONS.entries()].map(([collection, reason]) => ({
collection,
reason,
// Whether this node has actually SEEN it, and how often. An ignore entry nothing ever matches is
// worth knowing too — it may be describing a lexicon that no longer exists.
seen: seen.has(collection) ? seen.get(collection).count : 0,
lastSeen: seen.has(collection) ? seen.get(collection).lastSeen : null
})),
unknown: ledger.filter((e) => e.class === 'unknown')
}
})
svc.handle('repo-head', async (ctx, params = {}) => {
const { rs, resolveIdentity } = await syncDeps()
const fetchHead = rs.createFetchHead({ doFetch: bareFetch, resolveIdentity })
const { dids, skippedHostedLocally } = targetDids(params)
const out = []
for (const did of dids) {
const remote = await fetchHead(did)
// Our rev is the highest we have folded; the peer's head carries it (same field the relay-miss log
// reads). Absent peer/head = we have never folded this DID at all, which is itself the answer.
const front = byDid.get(did) || null
const localRev = front && front.head ? (front.head.rev || null) : null
out.push({
did,
remoteRev: remote ? remote.rev : null,
localRev,
active: remote ? remote.active : null,
status: remote ? remote.status : null,
behind: remote && remote.rev && localRev ? (localRev < remote.rev) : null,
unreachable: remote ? null : 'could not read the authoritative head'
})
}
return { ok: true, heads: out, skippedHostedLocally }
})
const runDiff = async (params, { rs, resolveIdentity }) => {
const fetchRepo = rs.createFetchRepo({ doFetch: bareFetch, resolveIdentity })
const { dids, skippedHostedLocally } = targetDids(params)
const results = []
for (const did of dids) {
// No `since`: convergence needs the COMPLETE record set. `extraLocal` ("we hold it, the repo does
// not") is only meaningful against the whole repo — against a diff, every record older than the
// rev looks extra and the report cries wolf on every run. See lib/repo-sync.js.
const got = await fetchRepo({ did })
if (!got) { results.push({ did, unreachable: 'could not fetch the repo' }); continue }
const remote = await rs.parseRepo({ did, carBytes: got.carBytes, pubkey: got.identity && got.identity.pubkey, partial: got.partial })
if (!remote) { results.push({ did, unreachable: 'malformed repo CAR' }); continue }
if (!remote.ok) { results.push({ did, refused: remote.reason }); continue }
const d = await rs.diffRepo({
did,
remote,
// Per-VIEW comparison: each tracked collection is checked against the view that owns it, not
// against the post index (which is why "in sync" used to mean "posts are in sync").
tracked: Appview.TRACKED,
listRefsFor: (x, collection) => av.indexedRefsFor(x, collection),
isIgnored: isIgnoredCollection,
hasBytes: async (x, collection, rkey) => {
try { const s = await getRecordStore(x); return (await s.getRaw(collection, rkey)) != null } catch { return false }
}
})
// Referenced-blob convergence (spec §6). Local-only: the records are already in hand, so this
// costs no extra fetch. Orphans are NOT covered — that needs listBlobs, which the membrane does
// not implement.
const { diffBlobs } = await import('../lib/blob-refs.js')
const blobs = await diffBlobs({
records: remote.records,
hasBlob: async (cid) => { try { return isSafeCid(cid) && fs.existsSync(blobPath(OVERLAY_ROOT, did, cid)) } catch { return false } },
// The authority's own inventory — the ONLY thing that can reveal an orphan. A membrane or peer
// too old to answer returns null, and the report degrades to "orphans unchecked" rather than
// claiming there are none.
listAuthorityBlobs: async () => {
const identity = got.identity
if (!identity || !identity.pds) return null
const out = []
let cursor = null
for (let page = 0; page < 20; page++) {
let u = identity.pds + '/xrpc/com.atproto.sync.listBlobs?did=' + encodeURIComponent(did) + '&limit=1000'
if (cursor) u += '&cursor=' + encodeURIComponent(cursor)
const res = await bareFetch(u, { method: 'GET', headers: { accept: 'application/json' }, timeout: 30000 })
if (!res || res.ok === false) return null
const body = await res.json()
if (!body || !Array.isArray(body.cids)) return null
out.push(...body.cids)
cursor = body.cursor || null
if (!cursor) break
}
return { cids: out }
}
})
results.push({ ...d, blobs, _remote: remote })
}
return { results, skippedHostedLocally }
}
svc.handle('repo-diff', async (ctx, params = {}) => {
const deps = await syncDeps()
const { results, skippedHostedLocally } = await runDiff(params, deps)
// Strip the parsed repo — it carries every record's bytes and has no business crossing the socket.
return { ok: true, skippedHostedLocally, dids: results.map(({ _remote, ...rest }) => rest) }
})
svc.handle('repo-backfill', async (ctx, params = {}) => {
const deps = await syncDeps()
const { results, skippedHostedLocally } = await runDiff(params, deps)
const write = !!(params && params.write)
const out = []
for (const r of results) {
if (!r._remote) { out.push(r); continue }
const { _remote, ...diff } = r
const applied = await deps.rs.applyBackfill({
did: r.did,
remote: _remote,
diff,
applyOps: (did, ops) => av.applyCommit(did, ops),
putBytes: async (did, collection, rkey, value) => { const s = await getRecordStore(did); await s.put(collection, rkey, value) },
write,
limit: Number(params && params.limit) || 0,
onEvent: (event, detail) => console.log(JSON.stringify({ msg: 'repo-backfill', event, did: r.did, ...detail }))
})
// Blobs are a separate axis from records: same "pull what is missing" intent, different transport
// (getBlob, by CID). Verified by CONTENT ADDRESS — the CID is the hash, so a wrong body cannot be
// written under the right name; we check it rather than trusting the server.
let blobsApplied = null
if (diff.blobs && diff.blobs.missing.length) {
blobsApplied = { fetched: 0, unfetchable: 0, hashMismatch: 0, errors: 0 }
if (write) {
const identity = await deps.resolveIdentity(r.did)
for (const b of diff.blobs.missing) {
if (!identity || !isSafeCid(b.cid)) { blobsApplied.unfetchable++; continue }
try {
const url = identity.pds + '/xrpc/com.atproto.sync.getBlob?did=' + encodeURIComponent(r.did) + '&cid=' + encodeURIComponent(b.cid)
const res = await bareFetch(url, { method: 'GET', timeout: 60000 })
if (!res || res.ok === false) { blobsApplied.unfetchable++; continue }
const bytes = new Uint8Array(await res.arrayBuffer())
if (!bytes.length) { blobsApplied.unfetchable++; continue }
const { verifyBlobCid } = await import('../lib/blob-refs.js')
if (!(await verifyBlobCid(bytes, b.cid))) { blobsApplied.hashMismatch++; continue }
const p = blobPath(OVERLAY_ROOT, r.did, b.cid)
fs.mkdirSync(p.slice(0, p.lastIndexOf('/')), { recursive: true })
fs.writeFileSync(p, bytes)
blobsApplied.fetched++
console.log(JSON.stringify({ msg: 'repo-backfill', event: 'blob-written', did: r.did, cid: b.cid, bytes: bytes.length }))
} catch (e) { blobsApplied.errors++ }
}
}
}
out.push({ ...diff, applied, blobsApplied })
}
return { ok: true, write, skippedHostedLocally, dids: out }
})
svc.handle('release-ready', async (ctx, params = {}) => {
const ver = (params && params.version) || '?'
console.log(`[ota] release-ready ${ver} — sidecar installed; exiting 75 for supervisor respawn`)
// Respond FIRST, then shut down cleanly; exit 75 is the supervisor's respawn signal.
setImmediate(() => { shutdown('ota', 75).catch(() => process.exit(75)) })
return { ok: true, willRespawn: true }
})
await svc.listen()
// Report the RUNNING version, not null. This was null, so `pgh app status` could only ever show the
// version on DISK — which is exactly the number that lies when an app installs an update and does not
// restart. Now the process states its own.
maintainSidecarRegistration({ udsPath, name: process.env.PGH_APP_NAME || 'favela-web', runtime: 'bare', version: VERSION })
console.log(`[favela] control surface on ${udsPath} — OTA respawn armed`)
} catch (e) {
// Non-fatal by design: serving the timeline matters more than being remotely manageable.
console.error('[favela] control surface unavailable (non-fatal):', (e && e.message) || e)
}