OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / writer.js

298 lines · javascript ref:
// 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 {}
  }
}