// favela Writer — relays a post (and device/pairing ops) to the hpp device, which signs it. favela is
// keyless: it never holds the DID signing key; the hpp does. Two transports, tried in order:
//
// 1. LOCAL UDS (preferred) — when favela-web is CO-LOCATED with the hpp (the normal case), it reaches
// the device over the hpp's unix socket (hyper-svc): instant local IPC, NO DHT rendezvous, and
// owner-gated by the filesystem so it needs NO swarm identity / writer-allowlist (no seed collision).
// 2. SWARM (fallback) — a REMOTE hpp: connect to the device control key over Hyperswarm with an
// allowlisted writer keypair + ProtomuxRPC. (The original transport; kept for the remote case.)
//
// Both surfaces expose the SAME ops (apply-write / delegate / list-devices / revoke-device /
// is-device-revoked / get-state), so a single `_call(method, params)` routes over whichever is live.
import Hyperswarm from 'hyperswarm'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import c from 'compact-encoding'
import ProtomuxRPC from 'protomux-rpc'
import { Client } from 'hyper-svc'
import { tidNow } from 'atproto-store/lib/tid.js'
import { collectionForScope, DEFAULT_SCOPE, SCOPE_COLLECTION } from './scopes.js'
import { detectFacets } from './facets.js'
const DEVICE_PROTOCOL = 'hyper-proto-peer/device/v1' // must match hpp serve-device (swarm surface)
const DEFAULT_SOCK = (process.env.HOME || '') + '/.config/hyper-proto-peer/data/control.sock'
/**
* Which write transport, if any, this process has been EXPLICITLY configured with.
*
* The caller (bin/favela-web.js) refuses writes wholesale when this returns null, because an instance
* that cannot name the node it writes to must not guess — the 2026-08-09 accident was a test run
* reaching live wj62 through a default.
*
* ⛔ READ THE ENV, NOT THE Writer. The constructor below falls back to DEFAULT_SOCK when nothing is set,
* so asking "does the writer have a uds" is always true and would silently hand writes to whatever hpp
* owns the default socket — the very hazard the gate exists to prevent. Only an explicitly-set value counts.
*
* UDS outranks the swarm key: if a device is reachable on this box, taking a DHT round-trip to it is
* strictly worse, and the co-located case (favela-android's embedded favela-web, a desktop hpp+web pair)
* is the normal one.
*
* @returns {'uds'|'swarm'|null}
*/
export function writeTransportConfigured (env = {}) {
if (String(env.FAVELA_HPP_SOCK || '').trim()) return 'uds'
if (String(env.FAVELA_CONTROL_KEY || '').trim()) return 'swarm'
return null
}
export class Writer {
// controlKey/writerSeed: only needed for the SWARM fallback (remote hpp). uds: the local hpp socket
// (defaults to the co-located hpp; FAVELA_HPP_SOCK overrides). Local UDS is tried first.
constructor ({ controlKey, writerSeed, uds, device = null, blobs = null } = {}) {
// IN-PROCESS TRANSPORT (favela-android). The phone runs the node in the SAME process — there is no
// socket and no DHT to reach, the SwarmDevice object is right here. Every method on this class is
// record-BUILDING (scope->collection, facets, embeds, langs, the grapheme cap) plus one `_call`, so
// injecting the device lets the React Native port reuse this write path verbatim instead of
// reimplementing the record shapes — which is how a uri-only strongRef shipped once already.
//
// ⛔ IT MUST ALSO FORECLOSE THE SOCKET. With a device injected there is deliberately NO uds and NO
// swarm fallback: a `Writer({device})` that could still reach DEFAULT_SOCK is one forgotten stub away
// from a test writing to production — which is exactly what happened on 2026-08-09 (a post landed in
// wj62 from test/writer.test.mjs because the unknown option was ignored and ready() connected to the
// live hpp). Being unable to reach a socket is the property, not a detail.
this.device = device
this.blobs = blobs
this.uds = device ? null : (uds || process.env.FAVELA_HPP_SOCK || DEFAULT_SOCK)
this.controlKey = controlKey ? String(controlKey).toLowerCase() : null
this.writerSeed = writerSeed || null
this._uds = null // hyper-svc Client (local)
this._rpc = null // ProtomuxRPC (swarm fallback)
this.swarm = null
}
_startSwarm () {
if (this.swarm || !this.controlKey || !this.writerSeed) return
this.swarm = new Hyperswarm({ keyPair: crypto.keyPair(crypto.hash(b4a.from(this.writerSeed))) })
this.swarm.on('connection', (conn) => {
if (b4a.toString(conn.remotePublicKey, 'hex') !== this.controlKey) { conn.destroy(); return }
this._rpc = new ProtomuxRPC(conn, { protocol: DEVICE_PROTOCOL, valueEncoding: c.json })
this._rpc.on('close', () => { if (this._rpc && this._rpc.closed) this._rpc = null })
})
this.swarm.join(b4a.from(this.controlKey, 'hex'), { client: true, server: false })
}
async ready ({ timeout = 20000 } = {}) {
if (this.device) return true // in-process: nothing to connect to
// 1. LOCAL UDS first — instant, no DHT. Client.connectUds throws fast if the socket is absent.
if (this._uds && !this._uds.closed) return true
if (this.uds) {
try { this._uds = await Client.connectUds(this.uds); if (this._uds) return true } catch { this._uds = null }
}
// 2. SWARM fallback — remote hpp (or no local socket). Poll for the ProtomuxRPC channel.
this._startSwarm()
if (!this.swarm) return false // no swarm creds and no socket → unreachable
const end = Date.now() + timeout
while (Date.now() < end) { if (this._rpc && !this._rpc.closed) return true; await new Promise((r) => setTimeout(r, 100)) }
return false
}
async _call (method, params) {
if (this.device) return this._deviceCall(method, params || {})
if (!(await this.ready())) throw new Error('Writer: no device connection (hpp up? local socket present? control key current?)')
if (this._uds && !this._uds.closed) {
// The cached UDS client goes STALE when the co-located hpp RESTARTS: bare-net half-open means the
// socket dies but the Client's `.closed` never flips, so ready() keeps returning it and the call
// HANGS forever (this wedged /api/post after every hpp restart). Time-box the call; on timeout/
// error drop the client, reconnect ONCE, and retry — so an hpp restart self-heals per-request.
try {
return await this._udsCall(method, params)
} catch (err) {
try { await this._uds.close() } catch {}
this._uds = null
if (await this.ready()) { if (this._uds && !this._uds.closed) return await this._udsCall(method, params) }
if (!this._rpc || this._rpc.closed) throw err // no swarm fallback → surface the original error
}
}
return this._rpc.request(method, params || {}) // swarm: ProtomuxRPC.request
}
// The in-process dispatch: the SAME seven ops the UDS/swarm surfaces expose (serve-device-uds.js),
// mapped straight onto the SwarmDevice. Bytes are NOT hex-encoded here — hex exists because the UDS
// RPC value encoding is JSON; in-process the Buffer is handed over as-is.
async _deviceCall (method, params) {
const d = this.device
switch (method) {
case 'apply-write': return d.applyWrite(params)
case 'get-state': return d.state()
case 'delegate': return d.delegate(params)
case 'list-devices': return { devices: await d.listDevices() }
case 'revoke-device': return params.restore ? d.restoreDevice(params.subKey) : d.revokeDevice(params.subKey, { reason: params.reason || null })
case 'is-device-revoked': return { revoked: await d.isDeviceRevoked(params.subKey) }
case 'upload-blob': {
if (!this.blobs) throw new Error('Writer: no blob store injected — cannot upload')
const bytes = params.bytes && typeof params.bytes === 'string' ? b4a.from(params.bytes, 'hex') : params.bytes
const r = await this.blobs.put(bytes, params.mimeType || 'application/octet-stream')
return { cid: String(r.cid), mimeType: r.mimeType || params.mimeType, size: r.size }
}
default: throw new Error('Writer: unsupported in-process method ' + method)
}
}
// A UDS call bounded by a timeout so a stale (post-hpp-restart) socket can't hang the caller
// indefinitely. On timeout the underlying call promise is abandoned; the caller reconnects + retries.
async _udsCall (method, params, ms = 10000) {
let t = null
try {
return await Promise.race([
this._uds.call(method, params || {}),
new Promise((_, reject) => { t = setTimeout(() => reject(new Error('UDS call timed out (stale hpp socket?)')), ms) })
])
} finally { if (t) clearTimeout(t) }
}
// Post a text record. `scope` selects the collection (POSSE): 'public' -> app.bsky.feed.post (fronted to
// bsky) or 'local' -> net.favela.local.post (favela-only). Returns { rkey, scope, collection, ...sign }.
// `images` = [{ blob, alt?, aspectRatio? }] where blob came from uploadBlob → Writer.blobRef.
// Edit an existing post IN PLACE: same collection, same rkey, new CID.
//
// ★ atproto ALLOWS this — putRecord is lexicon-agnostic and the PDS accepts it — but bsky's AppView
// deliberately ignores post updates, so getRecord reflects an edit and getPostThread does not
// (atproto discussion #3038). The edit is real on the P2P side and on ActivityPub; on bsky it is
// partial. See spec/record-vocabulary.md §10.5. Callers MUST NOT imply otherwise in the UI.
//
// What is PRESERVED from the original, deliberately:
// createdAt — an edit is not a new post. Rewriting it would reorder the timeline and lie about
// when the thing was said.
// reply / embed — editing text must not silently detach a thread or drop images.
// collection — an edit cannot change a post's REACH. Moving app.bsky.feed.post to
// net.favela.local.post would "unpublish" from bsky by writing a new record while
// the old one keeps federating: two records, one rkey, no way back.
//
// Facets are RE-DERIVED, never carried over: they are byte offsets into the OLD text and would
// land on the wrong characters (or outside the string) once the text changes.
async editPost (collection, rkey, text, { langs = null, resolveHandle = null, prior = null } = {}) {
if (!collection || !rkey) throw new Error('editPost: collection and rkey required')
const graphemes = [...String(text)].length
if (graphemes > 300) throw new Error(`too long: ${graphemes} graphemes (bsky limit 300)`)
const base = prior && typeof prior === 'object' ? prior : {}
const record = {
$type: collection,
text: String(text),
createdAt: base.createdAt || new Date().toISOString()
}
if (Array.isArray(langs) && langs.length) record.langs = langs
else if (Array.isArray(base.langs) && base.langs.length) record.langs = base.langs
if (base.reply) record.reply = base.reply
if (base.embed) record.embed = base.embed
try {
const facets = await detectFacets(record.text, { resolveHandle })
if (facets.length) record.facets = facets
} catch { /* an edit without facets beats a failed edit */ }
await this.putRecord(collection, rkey, record, { action: 'update' })
return { collection, rkey, record }
}
async post (text, { scope = DEFAULT_SCOPE, langs = ['en'], images = null, reply = null, resolveHandle = null, quote = null } = {}) {
const collection = collectionForScope(scope) // throws on an unknown scope (no silent public fallback)
const graphemes = [...String(text)].length
if (graphemes > 300) throw new Error(`too long: ${graphemes} graphemes (bsky limit 300)`)
const rkey = tidNow()
const record = { $type: collection, text, createdAt: new Date().toISOString() }
// reply: { root, parent } — both com.atproto.repo.strongRef. Built by the caller (it needs the
// appview to resolve cids); passed through verbatim so this stays a thin signer.
if (reply && reply.root && reply.parent) record.reply = reply
// Image embed — the shape decoded from wj62's existing working image post. Capped at 4, matching
// app.bsky.embed.images' maxLength: bsky REJECTS a longer array outright, and a record the relay
// refuses would federate as nothing while looking fine locally.
const imageEmbed = (Array.isArray(images) && images.length)
? {
$type: 'app.bsky.embed.images',
images: images.slice(0, 4).map((im) => ({
alt: String((im && im.alt) || ''),
image: im.blob,
...(im && im.aspectRatio ? { aspectRatio: im.aspectRatio } : {})
}))
}
: null
// A QUOTE is a strongRef (uri AND cid) exactly like a like's subject, and the caller resolves it —
// this stays a thin signer. Quote + images is `recordWithMedia`, which is the ONLY lexicon-valid way
// to carry both: a record can hold one `embed`, so emitting two would silently drop one of them.
if (quote && quote.uri && quote.cid) {
record.embed = imageEmbed
? { $type: 'app.bsky.embed.recordWithMedia', record: { $type: 'app.bsky.embed.record', record: { uri: quote.uri, cid: quote.cid } }, media: imageEmbed }
: { $type: 'app.bsky.embed.record', record: { uri: quote.uri, cid: quote.cid } }
} else if (imageEmbed) {
record.embed = imageEmbed
}
// Declare the post language. bsky.app's CUSTOM-FEED view applies FeedTuner.preferredLangOnly — a tuner it
// does NOT apply to author feeds or the raw getFeed XRPC — which runs the `lande` language detector on any
// post that omits `langs` and drops whatever it doesn't read as the viewer's content language. Short/
// technical posts get misdetected ("test 2" -> French, "hello" -> Finnish, "stortron/origin" -> Dutch) and
// silently vanish from the feed on bsky.app while still appearing everywhere else. A declared langs makes
// getPostLanguage take the fast path (return the tag, never run lande), so the post always renders. Only
// the bsky-fronted collection carries langs; net.favela.local.post has no such field.
if (collection === SCOPE_COLLECTION.public && Array.isArray(langs) && langs.length) record.langs = langs
// Rich-text facets. Until 0.3.11 we READ facets and wrote none, so every link, @mention and #tag
// posted from favela shipped as inert plain text to bsky and every other client — the exact inverse
// of what renderRichText does on the read side. Byte offsets, not string indices (see facets.js).
// A mention resolves to a DID or is omitted: an unlinked handle is a papercut, a mention pointing at
// the wrong account is someone else's notification. Failure here must never block the post itself.
if (collection === SCOPE_COLLECTION.public) {
try {
const facets = await detectFacets(text, { resolveHandle })
if (facets.length) record.facets = facets
} catch { /* a post without facets beats no post */ }
}
const r = await this._call('apply-write', { action: 'create', collection, rkey, record })
return { rkey, scope, collection, ...r }
}
// ── blobs (avatars + post images) ───────────────────────────────────────────────────────────────
// Upload bytes to the hpp, which stores them content-addressed and returns the atproto blob CID.
// favela stays keyless: it never signs — it hands the hpp bytes and the hpp owns storage. The UDS
// surface is JSON, so the bytes travel as hex (matching serve-device-uds's b4a.from(bytes,'hex')).
async uploadBlob (bytes, mimeType = 'application/octet-stream') {
const buf = b4a.isBuffer(bytes) ? bytes : b4a.from(bytes)
const r = await this._call('upload-blob', { bytes: b4a.toString(buf, 'hex'), mimeType })
if (!r || !r.cid) throw new Error('upload-blob returned no cid')
return { cid: r.cid, mimeType: r.mimeType || mimeType, size: r.size != null ? r.size : buf.length }
}
// The atproto blob REFERENCE embedded in a record. This shape is not a guess — it is exactly what
// the fleet's existing records already hold on disk (verified by decoding wj62's live
// app.bsky.actor.profile/self and its working image post): `ref` is a PLAIN object carrying $link,
// not a CID instance. Records written in this shape federate to bsky today, so match it byte-for-byte
// rather than "correcting" it — changing the encoding is a separate, riskier decision.
static blobRef ({ cid, mimeType, size }) {
return { $type: 'blob', ref: { $link: String(cid) }, mimeType, size }
}
// Write any record through the hpp (it signs). action 'create'|'update' are equivalent at the repo
// layer (last-writer-wins on the key); 'delete' removes it.
async putRecord (collection, rkey, record, { action = 'update' } = {}) {
return this._call('apply-write', { action, collection, rkey, record })
}
// Delegate a browser sub-key to act as the DID (the hpp signs the delegation). Returns
// { did, subKey, delegation, exp }. Used by the pairing mint (favela-pair / lib/cli.js).
async delegate (subKey, { ttlSec, label } = {}) {
return this._call('delegate', { subKey, ...(ttlSec ? { ttlSec } : {}), ...(label ? { label } : {}) })
}
// The device's own view of itself — used to confirm WHICH DID this hpp signs for before writing a
// profile record. favela's write path is single-DID (the UDS socket picks the device), so a signed-in
// viewer whose DID differs from the device's must not have their write silently land in someone
// else's repo.
async state () { return this._call('get-state', {}) }
// Device registry (Option 3): list / revoke / query the DID's delegated browser sub-keys.
async listDevices () { const r = await this._call('list-devices', {}); return (r && r.devices) || [] }
async revokeDevice (subKey, { restore = false, reason = null } = {}) { return this._call('revoke-device', { subKey, restore, reason }) }
async isDeviceRevoked (subKey) { const r = await this._call('is-device-revoked', { subKey }); return !!(r && r.revoked) }
async close () {
try { if (this._uds) await this._uds.close() } catch {}
try { if (this.swarm) await this.swarm.destroy() } catch {}
}
}