OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / bin / favela-web.js

3447 lines · javascript ref:
// 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)
}