// Running the bsky projection ON THE DEVICE that holds the key.
//
// ⛔ WHY THIS HAS TO EXIST AT ALL. The two networks are not symmetric, and the asymmetry is the whole
// bug. ap-bridge HOLDS a registered name's feed and projects the fediverse from it server-side, so a
// phone federates without anything running on it. atproto cannot work that way: the membrane is
// KEYLESS and re-serves only what the device SIGNED, so an atproto record exists solely because
// something next to the signing key authored it. On a handset that key is on the handset.
//
// Measured, which is why this is not a theory: a phone with a correct PLC record, a handle resolving
// to it, an active relay and nine fediverse posts had an atproto repo containing one bootstrap record
// — `app.bsky.feed.post/self.hello`, written by hpp's fresh-account path — and no
// `app.bsky.actor.profile` at all. The appview had never indexed it (`indexedAt: null`). Nothing was
// failing. Nothing was ever running.
//
// ⛔ NO SOCKET, AND THAT IS THE POINT. favela-bridge reaches both halves over unix sockets; SELinux
// refuses an unprivileged Android app a `sock_file`, so neither exists here. Both halves are already
// in this process, so they are passed as objects — the same move `inProcessClient` makes for the
// surface, and the reason lib/project.js in favela-bridge takes `peer` and `bsky` rather than paths.
import fs from 'bare-fs'
import { dispatch } from 'favela-peer/lib/rpc.js'
import { projectCycle } from 'favela-bridge/lib/project.js'
import { BskySink, POST_COLLECTION } from 'favela-bridge/lib/bsky.js'
import { tidFor } from 'favela-bridge/lib/tid.js'
/**
* hpp's UDS control surface, as a function call.
*
* ⛔ A FAITHFUL MIRROR OF `serve-device-uds.js`, not a reinterpretation. Each command below is the
* same one-line handler that file registers, against the same device methods — because the desktop
* projector talks to those handlers and a second implementation that drifts would project differently
* on a phone than on a laptop, silently.
*
* ⛔ NO `close`. BskySink closes its client after every call, which is correct for a socket it opened
* and fatal for the device this app is running on. bsky.js guards with `if (client && client.close)`,
* so omitting it is this adapter saying: this one is not yours to close.
*/
export function deviceClient (hppNode) {
if (!hppNode || !hppNode.device) throw new Error('deviceClient needs a booted hpp node')
const device = hppNode.device
const blobs = hppNode.blobs || null
return {
call: async (cmd, args = {}) => {
switch (cmd) {
case 'get-state': return device.state()
case 'apply-write': return device.applyWrite(args)
case 'get-record': {
const { collection, rkey } = args
if (!collection || !rkey) throw new Error('get-record: collection and rkey required')
// ⛔ ABSENT IS null, NOT AN ERROR. "Not written yet" is an ordinary state for a caller
// deciding whether to write, and the projector distinguishes it from "could not ask".
try { return (await device.repo.getRecord(collection, rkey)) || null } catch { return null }
}
case 'upload-blob': {
if (!blobs) throw new Error('upload-blob: this node has no blob store')
const { path, bytes, mimeType } = args
if (!bytes && !path) throw new Error('upload-blob: bytes or path required')
let buf
if (path) {
try { buf = fs.readFileSync(path) } catch { throw new Error('upload-blob: no file at ' + path) }
} else {
buf = bytes
}
if (!buf || !buf.length) throw new Error('upload-blob: that file is empty')
const type = mimeType || 'application/octet-stream'
const { cid, size } = await blobs.put(buf, type)
return { cid: cid.toString(), mimeType: type, size }
}
default: throw new Error('the in-process device surface does not serve ' + cmd)
}
}
}
}
/**
* favela-peer, as the projector expects to find it.
*
* ⛔ `reach()` FAILS CLOSED, matching favela-identity's socket client exactly. If consent cannot be
* read the answer is publish nothing — unknown consent is not consent — and a projector that inherited
* a "carry on with what we had" would keep publishing after someone switched a network off.
*/
export function peerClient (node) {
const call = (cmd, args = {}) => dispatch(node, cmd, args)
return {
call,
reach: async () => {
try {
const r = await call('reach')
return {
networks: Array.isArray(r && r.networks) ? r.networks : [],
credential: (r && r.credential) || null
}
} catch {
return { networks: [], credential: null, unknown: true }
}
},
ownPosts: async () => {
const me = await call('whoami')
const rows = await call('posts-by', { feed: me.feed })
return { feed: me.feed, rows: Array.isArray(rows) ? rows : [] }
},
// My positively-deleted posts and replies — what the projection retracts from bsky.
ownDeleted: async () => call('deleted-own')
}
}
/**
* Pick the hpp node to author into.
*
* ⚠ `reach.credential` is identity.md §5.1's DESIGNATED credential and wins when it names a booted
* DID. With one DID booted that is the answer anyway; with several and nothing designated there is no
* right guess, so this refuses rather than picking — publishing under the wrong identity is not an
* error anyone can take back.
*/
export function pickNode (hpp, credential = null) {
const nodes = (hpp && hpp.nodes) || []
if (nodes.length === 0) return { node: null, error: 'this device has no booted atproto identity' }
// ⛔ hpp returns `byDid` as a MAP (device-node.js:879). Indexing it like an object never matched, so a
// designated credential was ignored and a two-DID node refused every pass (tabertron, 2026-10-13).
const designated = credential && hpp.byDid
? (typeof hpp.byDid.get === 'function' ? hpp.byDid.get(credential) : hpp.byDid[credential])
: null
if (designated) return { node: designated, error: null }
if (nodes.length === 1) return { node: nodes[0], error: null }
return { node: null, error: 'this device holds several identities and none is designated — choose one in settings' }
}
/**
* One projection pass, on the device.
*
* ⛔ SAME FUNCTION AS THE DESKTOP RUNS. Everything network-shaped — consent, the reach lattice, TID
* rkeys, reconcile-don't-republish, refuse-when-the-repo-is-unreadable — lives in favela-bridge and is
* exercised by its tests. This file supplies two objects and nothing else.
*
* ⚠ `send` defaults to FALSE. Projection is publication onto a network that does not forget.
*/
export async function projectBsky (node, hpp, { send = false, limit = Infinity, reproject = false, roomType = 'public', log = () => {} } = {}) {
const peer = peerClient(node)
const reach = await peer.reach()
const { node: hppNode, error } = pickNode(hpp, reach.credential)
if (error) return { refused: error, sent: 0, skipped: 0, withheld: 0, held: 0 }
const bsky = new BskySink(null, { connect: async () => deviceClient(hppNode), log: (m, d) => log(m + (d ? ' ' + JSON.stringify(d) : '')) })
/**
* A favela target → the atproto subject that names it.
*
* ⛔ THE CID IS COMPUTED, NOT LOOKED UP. An atproto record's CID is a pure function of its bytes —
* `cidForValue(value)` — so naming our own record needs no extra index and no round trip to the PDS.
* Verified against the live repo: the computed CID for a published post matched the one the PDS
* serves, byte for byte.
*
* ⛔ AND IT RETURNS null RATHER THAN GUESSING. A subject that is not in the repo yet is the ordinary
* case when an edge is projected before the post it points at; planEdge reports that as a wait, and
* the next pass finds it. Inventing a CID would publish a like pointing at nothing.
*/
const resolveSubject = async (target) => {
if (!target || target.collection !== 'post') return null
const rkey = tidFor(target.name)
let value = null
try { value = await bsky.getRecord(POST_COLLECTION, rkey) } catch { value = null }
if (!value || value.unknown) return null
try {
const { cidForValue } = await import('atproto-repo/lib/cid.js')
const c = await cidForValue(value)
return { uri: 'at://' + hppNode.did + '/' + POST_COLLECTION + '/' + rkey, cid: String(c.cid) }
} catch (err) {
// ⛔ SAID OUT LOUD. This catch hid a real defect once already: `atproto-repo` was not resolvable
// from this package, every subject came back null, and NOTHING was projected — with the log
// reporting each edge as "the subject is not in the atproto repo yet", which is the message for
// an ordinary wait. A silent null here is indistinguishable from a subject that has not been
// published, and that is precisely the pair a person cannot tell apart from the outside.
log('subject could not be named — ' + ((err && err.message) || String(err)))
return null
}
}
return projectCycle({
peer,
bsky,
roomType,
send,
limit,
reproject,
log,
sinkLabel: 'this device (' + hppNode.did + ')',
resolveSubject
// ⚠ resolveRoot is deliberately absent for now: with no chain to walk, planEdge falls back to
// parent-as-root, which is correct for a reply to a top-level post and is every reply we can
// currently make (a reply to a reply needs the foreign-subject decision to be useful anyway).
})
}
/**
* Keep projecting for as long as the app runs — favela-bridge's `--watch`, inside the app.
*
* ⛔ WHY. Without this a desktop that ran favela-host instead of the four separate apps would silently
* stop posting to bsky: projection here was on demand and dry by default, and favela-bridge's watch
* daemon was the only thing that sent (hyper-social #77 §5, change 3).
*
* ⛔ EVENT-DRIVEN, NOT POLLED. The bridge polled every 15 s; this runs one pass at boot and one per
* append to any WRITER core of the node's own feed — ap-bridge FeedHolder's trigger for held favela
* feeds. ⛔ NOT autobee's 'update': in 2.12.1 it fires only on an interrupt (index.js:676), never when the
* view moves, and the 0.1.1 watch that listened for it projected once at boot and never again
* (tabertron, 2026-10-13). Writers are re-hooked on every fire, so a device admitted later is covered.
* Coalesced: never two passes at once, and any number of changes during a pass become exactly one more.
*
* ⚠ IT SENDS, as the deployed `--watch` did. Consent is not decided here: projectCycle reads the
* node's reach and withholds anything a network is not switched on for.
*
* `atproto` is boot()'s promise of the hpp handle; null (no identity) means nothing to project.
* `project` is injectable for tests. Returns { watching, stop }.
*/
export async function watchProjection (node, atproto, { log = () => {}, project = projectBsky } = {}) {
const hpp = await atproto
if (!hpp || !hpp.nodes || hpp.nodes.length === 0) return { watching: false, stop () {} }
let running = false
let again = false
let lastRefusal = null
let stopped = false
const pass = async () => {
if (stopped) return
if (running) { again = true; return }
running = true
try {
do {
again = false
// ⛔ One pass NEVER ends the watch — a sink respawning on OTA, a peer restarting, are ordinary.
try {
const r = await project(node, hpp, { send: true, log })
// ⛔ projectBsky refuses WITHOUT logging when it cannot pick an identity, and on a node with
// bsky on that silence means nothing reaches bsky. Said once per distinct reason.
if (r && r.refused && r.refused !== lastRefusal) log('projection refused: ' + r.refused)
lastRefusal = r && r.refused ? r.refused : null
} catch (err) { log('projection pass failed: ' + ((err && err.message) || err)) }
} while (again && !stopped)
} finally { running = false }
}
const base = node.peer && node.peer.feed && node.peer.feed.base
const hooked = new Map() // writer core -> listener
const rehook = () => {
let writers
try { writers = base && base.writers } catch { return }
if (!writers) return
try {
for (const w of writers) {
const core = w && w.core
if (!core || typeof core.on !== 'function' || hooked.has(core)) continue
const onAppend = () => { rehook(); pass() }
core.on('append', onAppend)
hooked.set(core, onAppend)
}
} catch { /* a writer set mid-change is not a failure; the next fire re-hooks */ }
}
rehook()
pass()
return {
watching: true,
hooked: () => hooked.size,
stop () {
stopped = true
for (const [core, fn] of hooked) { try { core.off('append', fn) } catch {} }
hooked.clear()
}
}
}
export default { deviceClient, peerClient, pickNode, projectBsky, watchProjection }