OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host commits / 4035908

the device authors its own atproto repo
⛔ THE TWO NETWORKS ARE NOT SYMMETRIC, and that asymmetry was the bug. ap-bridge HOLDS a registered name's feed and projects the fediverse from it server-side, so a phone federates with nothing 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 beside the signing key authored it. On a handset that key is on the handset, and nothing there had ever authored anything. Measured, not theorised. A phone holding did:plc:sija2uww… had: PLC record correct alsoKnownAs at://fleeky.7rnx.net, PDS pds.7rnx.net handle resolving /.well-known/atproto-did → the DID, 200 relay active getHostStatus pds.7rnx.net → active, our rev exactly appview indexedAt: null, postsCount 0 the repo ONE record — app.bsky.feed.post/self.hello collections ['app.bsky.feed.post'] — no app.bsky.actor.profile at all `self.hello` is hpp's FRESH-EMPTY-ACCOUNT bootstrap. Every identity step had succeeded and nothing had ever been published. The control account on the same PDS and relay has ten collections and 198 indexed posts, so the pipeline was never at fault. lib/project.js supplies the two things favela-bridge's extracted projectCycle needs and nothing else: deviceClient hpp's UDS control surface as a function call — get-state, apply-write, get-record, upload-blob against the same device methods serve-device-uds.js registers, so the phone and the laptop cannot project differently. It has NO `close`: BskySink closes its client after every call, right for a socket it opened and fatal for the device this app is running on. peerClient favela-peer's dispatch as {call, reach, ownPosts}. reach() FAILS CLOSED, matching the socket client — unknown consent is not consent. Neither has a socket, which is the point: SELinux refuses an unprivileged Android app a `sock_file`, so both halves are passed as objects — the move inProcessClient already makes. `pickNode` refuses when several identities are booted and none is designated. Publishing under the wrong identity is not an error anyone can take back. `bootAtproto` now hangs the booted handle on the node, because an op only ever receives `(node, args)` — so the desktop host and the Android worklet both gain it without either knowing. The new `bsky-project` verb is DRY BY DEFAULT; `send` must be asked for. The verb-set contract test caught the addition, which is what it is for; it is updated rather than loosened. 20/20 here, 40/40 in favela-bridge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
40359083fe3ab7f76079d9ae091d9cecad7fb96b
by plankatron <fleeky@gmail.com> · 2026-09-11T22:46:53.000Z

Changed files (5) +268 -1

lib/atproto.js +34 -0
……@@ -141,6 +141,34 @@ export const ATPROTO_OPS = {
141141 * be a network call on every settings load, and a node that is offline would report "not
142142 * published" about an identity that is. The distinction is the page's to explain.
143143 */
144 /**
145 * Author this identity's favela records into its atproto repo.
146 *
147 * ⛔ THE ONE THING THAT WAS NEVER RUNNING. ap-bridge HOLDS a registered name's feed and projects the
148 * fediverse from it server-side, so a phone federates with nothing running on it. atproto is the
149 * opposite: the membrane is keyless and re-serves only what the device SIGNED, so an atproto record
150 * exists solely because something beside the signing key authored it. On a handset that is the
151 * handset. Measured: a phone with a correct PLC record, a resolving handle, an active relay and nine
152 * fediverse posts had an atproto repo containing one bootstrap record and no profile at all.
153 *
154 * ⛔ DRY BY DEFAULT, like favela-bridge's CLI and for the same reason: projection is publication onto
155 * a network that does not forget. `send` must be asked for.
156 *
157 * ⚠ Consent and the reach lattice are enforced inside favela-bridge, not here — a room that may not
158 * leave the device is withheld even with bsky switched on.
159 */
160 'bsky-project': async (node, a = {}) => {
161 const { projectBsky } = await import('./project.js')
162 const lines = []
163 const r = await projectBsky(node, node.atproto, {
164 send: !!a.send,
165 limit: Number.isFinite(Number(a.limit)) ? Number(a.limit) : Infinity,
166 reproject: !!a.reproject,
167 log: (line) => lines.push(String(line))
168 })
169 return { ...r, log: lines }
170 },
171
144172 'did-status': async (node) => {
145173 const cfg = readJson(atprotoConfigPath(node))
146174 const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null
……@@ -386,5 +414,11 @@ export async function bootAtproto (node, { log = () => {} } = {}) {
386414 const up = (hpp && hpp.nodes && hpp.nodes.length) || 0
387415 if (up === 0) log('atproto booted NO DIDs — this device signs nothing', { configured: dids.length })
388416 else log('atproto up', { dids: up, configured: dids.length })
417 // ⛔ HUNG OFF THE NODE, because an op only ever receives `(node, args)`. The bsky projection has to
418 // run next to the signing key — the membrane is keyless and re-serves only what this device signed
419 // — so `bsky-project` needs the booted handle, and there is no other way for it to reach one.
420 // Attaching it here rather than in each caller's boot means the desktop host and the Android
421 // worklet both get it without either knowing it happened.
422 node.atproto = hpp
389423 return hpp
390424}
lib/project.js +140 -0
……@@ -0,0 +1,140 @@
1// Running the bsky projection ON THE DEVICE that holds the key.
2//
3// ⛔ WHY THIS HAS TO EXIST AT ALL. The two networks are not symmetric, and the asymmetry is the whole
4// bug. ap-bridge HOLDS a registered name's feed and projects the fediverse from it server-side, so a
5// phone federates without anything running on it. atproto cannot work that way: the membrane is
6// KEYLESS and re-serves only what the device SIGNED, so an atproto record exists solely because
7// something next to the signing key authored it. On a handset that key is on the handset.
8//
9// Measured, which is why this is not a theory: a phone with a correct PLC record, a handle resolving
10// to it, an active relay and nine fediverse posts had an atproto repo containing one bootstrap record
11// — `app.bsky.feed.post/self.hello`, written by hpp's fresh-account path — and no
12// `app.bsky.actor.profile` at all. The appview had never indexed it (`indexedAt: null`). Nothing was
13// failing. Nothing was ever running.
14//
15// ⛔ NO SOCKET, AND THAT IS THE POINT. favela-bridge reaches both halves over unix sockets; SELinux
16// refuses an unprivileged Android app a `sock_file`, so neither exists here. Both halves are already
17// in this process, so they are passed as objects — the same move `inProcessClient` makes for the
18// surface, and the reason lib/project.js in favela-bridge takes `peer` and `bsky` rather than paths.
19
20import fs from 'bare-fs'
21import { dispatch } from 'favela-peer/lib/rpc.js'
22import { projectCycle } from 'favela-bridge/lib/project.js'
23import { BskySink } from 'favela-bridge/lib/bsky.js'
24
25/**
26 * hpp's UDS control surface, as a function call.
27 *
28 * ⛔ A FAITHFUL MIRROR OF `serve-device-uds.js`, not a reinterpretation. Each command below is the
29 * same one-line handler that file registers, against the same device methods — because the desktop
30 * projector talks to those handlers and a second implementation that drifts would project differently
31 * on a phone than on a laptop, silently.
32 *
33 * ⛔ NO `close`. BskySink closes its client after every call, which is correct for a socket it opened
34 * and fatal for the device this app is running on. bsky.js guards with `if (client && client.close)`,
35 * so omitting it is this adapter saying: this one is not yours to close.
36 */
37export function deviceClient (hppNode) {
38 if (!hppNode || !hppNode.device) throw new Error('deviceClient needs a booted hpp node')
39 const device = hppNode.device
40 const blobs = hppNode.blobs || null
41
42 return {
43 call: async (cmd, args = {}) => {
44 switch (cmd) {
45 case 'get-state': return device.state()
46 case 'apply-write': return device.applyWrite(args)
47 case 'get-record': {
48 const { collection, rkey } = args
49 if (!collection || !rkey) throw new Error('get-record: collection and rkey required')
50 // ⛔ ABSENT IS null, NOT AN ERROR. "Not written yet" is an ordinary state for a caller
51 // deciding whether to write, and the projector distinguishes it from "could not ask".
52 try { return (await device.repo.getRecord(collection, rkey)) || null } catch { return null }
53 }
54 case 'upload-blob': {
55 if (!blobs) throw new Error('upload-blob: this node has no blob store')
56 const { path, bytes, mimeType } = args
57 if (!bytes && !path) throw new Error('upload-blob: bytes or path required')
58 let buf
59 if (path) {
60 try { buf = fs.readFileSync(path) } catch { throw new Error('upload-blob: no file at ' + path) }
61 } else {
62 buf = bytes
63 }
64 if (!buf || !buf.length) throw new Error('upload-blob: that file is empty')
65 const type = mimeType || 'application/octet-stream'
66 const { cid, size } = await blobs.put(buf, type)
67 return { cid: cid.toString(), mimeType: type, size }
68 }
69 default: throw new Error('the in-process device surface does not serve ' + cmd)
70 }
71 }
72 }
73}
74
75/**
76 * favela-peer, as the projector expects to find it.
77 *
78 * ⛔ `reach()` FAILS CLOSED, matching favela-identity's socket client exactly. If consent cannot be
79 * read the answer is publish nothing — unknown consent is not consent — and a projector that inherited
80 * a "carry on with what we had" would keep publishing after someone switched a network off.
81 */
82export function peerClient (node) {
83 const call = (cmd, args = {}) => dispatch(node, cmd, args)
84 return {
85 call,
86 reach: async () => {
87 try {
88 const r = await call('reach')
89 return {
90 networks: Array.isArray(r && r.networks) ? r.networks : [],
91 credential: (r && r.credential) || null
92 }
93 } catch {
94 return { networks: [], credential: null, unknown: true }
95 }
96 },
97 ownPosts: async () => {
98 const me = await call('whoami')
99 const rows = await call('posts-by', { feed: me.feed })
100 return { feed: me.feed, rows: Array.isArray(rows) ? rows : [] }
101 }
102 }
103}
104
105/**
106 * Pick the hpp node to author into.
107 *
108 * ⚠ `reach.credential` is identity.md §5.1's DESIGNATED credential and wins when it names a booted
109 * DID. With one DID booted that is the answer anyway; with several and nothing designated there is no
110 * right guess, so this refuses rather than picking — publishing under the wrong identity is not an
111 * error anyone can take back.
112 */
113export function pickNode (hpp, credential = null) {
114 const nodes = (hpp && hpp.nodes) || []
115 if (nodes.length === 0) return { node: null, error: 'this device has no booted atproto identity' }
116 if (credential && hpp.byDid && hpp.byDid[credential]) return { node: hpp.byDid[credential], error: null }
117 if (nodes.length === 1) return { node: nodes[0], error: null }
118 return { node: null, error: 'this device holds several identities and none is designated — choose one in settings' }
119}
120
121/**
122 * One projection pass, on the device.
123 *
124 * ⛔ SAME FUNCTION AS THE DESKTOP RUNS. Everything network-shaped — consent, the reach lattice, TID
125 * rkeys, reconcile-don't-republish, refuse-when-the-repo-is-unreadable — lives in favela-bridge and is
126 * exercised by its tests. This file supplies two objects and nothing else.
127 *
128 * ⚠ `send` defaults to FALSE. Projection is publication onto a network that does not forget.
129 */
130export async function projectBsky (node, hpp, { send = false, limit = Infinity, reproject = false, roomType = 'public', log = () => {} } = {}) {
131 const peer = peerClient(node)
132 const reach = await peer.reach()
133 const { node: hppNode, error } = pickNode(hpp, reach.credential)
134 if (error) return { refused: error, sent: 0, skipped: 0, withheld: 0, held: 0 }
135
136 const bsky = new BskySink(null, { connect: async () => deviceClient(hppNode), log: (m, d) => log(m + (d ? ' ' + JSON.stringify(d) : '')) })
137 return projectCycle({ peer, bsky, roomType, send, limit, reproject, log, sinkLabel: 'this device (' + hppNode.did + ')' })
138}
139
140export default { deviceClient, peerClient, pickNode, projectBsky }
package.json +1 -0
……@@ -19,6 +19,7 @@
1919 "bare-os": "^3.9.3",
2020 "bare-path": "^3.1.2",
2121 "bare-process": "^4.5.1",
22 "favela-bridge": "file:../favela-bridge",
2223 "favela-peer": "file:../favela-peer",
2324 "favela-surface": "file:../favela-surface",
2425 "hyper-proto-peer": "file:../hyper-proto-peer",
test/did-status.test.js +8 -1
……@@ -99,7 +99,14 @@ test('an already-submitted identity is not posted twice', async (t) => {
9999// ⛔ Pinned so a verb cannot appear without a page that can reach it, or vanish from under one.
100100// `did-carry` is the one a person uses; `did-adopt` is its irreversible half, exposed separately only
101101// because a bridge that is already carrying you should not have to be asked twice.
102//
103// `bsky-project` is the one that AUTHORS. The other five are about the identity — minting it,
104// publishing it, having a bridge carry it — and every one of them can succeed while the repo behind
105// the identity stays empty, which is exactly what happened: a correct PLC record, a resolving handle,
106// an active relay, and an atproto repo holding one bootstrap record. The fediverse needed no
107// equivalent because ap-bridge projects from the feed it HOLDS; the keyless membrane cannot, so the
108// device must author.
102109test('the verb set is exactly what the pages expect', (t) => {
103110 t.alike(Object.keys(ATPROTO_OPS).sort(),
104 ['did-adopt', 'did-carry', 'did-mint', 'did-status', 'did-submit'])
111 ['bsky-project', 'did-adopt', 'did-carry', 'did-mint', 'did-status', 'did-submit'])
105112})
test/project.test.js +85 -0
……@@ -0,0 +1,85 @@
1// The device-side half of the bsky projection: two adapters and a choice.
2//
3// ⛔ WHAT THIS IS FOR. atproto records exist only because something next to the SIGNING KEY authored
4// them — the membrane is keyless and re-serves what the device signed. The fediverse is the opposite:
5// ap-bridge holds the feed and projects it server-side. So a handset federates with nothing running on
6// it and publishes to bsky with nothing running on it either, which is how a phone ended up with a
7// correct PLC record, a resolving handle, an active relay, nine fediverse posts — and an atproto repo
8// holding one bootstrap record with no profile at all.
9//
10// The projection loop itself is favela-bridge's and is tested there. What is new here is talking to
11// hpp and favela-peer WITHOUT sockets, because SELinux gives an Android app no `sock_file`.
12
13import test from 'brittle'
14import { deviceClient, pickNode } from '../lib/project.js'
15
16function fakeHppNode (did = 'did:plc:example') {
17 const written = []
18 const records = new Map()
19 const blobsPut = []
20 return {
21 did,
22 written,
23 records,
24 blobsPut,
25 device: {
26 state: async () => ({ records: [...records.keys()].map((k) => { const i = k.indexOf('/'); return { collection: k.slice(0, i), rkey: k.slice(i + 1) } }) }),
27 applyWrite: async (op) => { written.push(op); records.set(op.collection + '/' + op.rkey, op.record); return { ok: true } },
28 repo: { getRecord: async (collection, rkey) => records.get(collection + '/' + rkey) || null }
29 },
30 blobs: { put: async (buf, mimeType) => { blobsPut.push({ size: buf.length, mimeType }); return { cid: 'bafyfake', size: buf.length } } }
31 }
32}
33
34test('★★★ the adapter serves exactly the commands the sink calls, against the device', async (t) => {
35 const n = fakeHppNode()
36 const c = deviceClient(n)
37
38 const rec = { $type: 'app.bsky.actor.profile', displayName: 'Fleeky' }
39 await c.call('apply-write', { action: 'put', collection: 'app.bsky.actor.profile', rkey: 'self', record: rec })
40 t.is(n.written.length, 1, 'apply-write reaches device.applyWrite')
41 t.alike(await c.call('get-record', { collection: 'app.bsky.actor.profile', rkey: 'self' }), rec, 'and is readable back')
42
43 const st = await c.call('get-state', {})
44 t.is(st.records.length, 1, 'get-state lists it')
45})
46
47test('★★ an absent record is null, never a throw', async (t) => {
48 const c = deviceClient(fakeHppNode())
49 t.is(await c.call('get-record', { collection: 'app.bsky.feed.post', rkey: 'nope' }), null,
50 '"not written yet" is an ordinary answer for a caller deciding whether to write')
51})
52
53test('★★★ the adapter has no close — the device must outlive the projection', (t) => {
54 const c = deviceClient(fakeHppNode())
55 t.absent(c.close, 'BskySink closes its client after every call; this device is not its to close')
56})
57
58test('★★ a refused command says so rather than silently doing nothing', async (t) => {
59 const c = deviceClient(fakeHppNode())
60 await t.exception(() => c.call('get-repo-car', {}), /does not serve/)
61})
62
63test('★★ blobs travel by path, and reach the device blob store', async (t) => {
64 const n = fakeHppNode()
65 const c = deviceClient(n)
66 const r = await c.call('upload-blob', { path: new URL('../package.json', import.meta.url).pathname, mimeType: 'image/png' })
67 t.is(r.cid, 'bafyfake')
68 t.is(r.mimeType, 'image/png')
69 t.is(n.blobsPut.length, 1, 'the bytes reached blobs.put')
70})
71
72test('★★★ with several identities and none designated it refuses rather than guessing', (t) => {
73 const a = fakeHppNode('did:plc:aaa')
74 const b = fakeHppNode('did:plc:bbb')
75 const hpp = { nodes: [a, b], byDid: { 'did:plc:aaa': a, 'did:plc:bbb': b } }
76
77 const guess = pickNode(hpp, null)
78 t.absent(guess.node, 'no guess is made')
79 t.ok(/designated/.test(guess.error), guess.error)
80
81 // ⛔ Publishing under the wrong identity is not an error anyone can take back.
82 t.is(pickNode(hpp, 'did:plc:bbb').node, b, 'the designated credential decides')
83 t.is(pickNode({ nodes: [a], byDid: { 'did:plc:aaa': a } }, null).node, a, 'one identity needs no designation')
84 t.ok(/no booted atproto identity/.test(pickNode({ nodes: [] }, null).error), 'and none is said plainly')
85})