| … | … | @@ -141,6 +141,34 @@ export const ATPROTO_OPS = { |
| 141 | 141 | * be a network call on every settings load, and a node that is offline would report "not |
| 142 | 142 | * published" about an identity that is. The distinction is the page's to explain. |
| 143 | 143 | */ |
| 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 | ||
| 144 | 172 | 'did-status': async (node) => { |
| 145 | 173 | const cfg = readJson(atprotoConfigPath(node)) |
| 146 | 174 | const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null |
| … | … | @@ -386,5 +414,11 @@ export async function bootAtproto (node, { log = () => {} } = {}) { |
| 386 | 414 | const up = (hpp && hpp.nodes && hpp.nodes.length) || 0 |
| 387 | 415 | if (up === 0) log('atproto booted NO DIDs — this device signs nothing', { configured: dids.length }) |
| 388 | 416 | 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 | |
| 389 | 423 | return hpp |
| 390 | 424 | } |
| … | … | @@ -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 | ||
| 20 | import fs from 'bare-fs' | |
| 21 | import { dispatch } from 'favela-peer/lib/rpc.js' | |
| 22 | import { projectCycle } from 'favela-bridge/lib/project.js' | |
| 23 | import { 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 | */ | |
| 37 | export 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 | */ | |
| 82 | export 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 | */ | |
| 113 | export 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 | */ | |
| 130 | export 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 | ||
| 140 | export default { deviceClient, peerClient, pickNode, projectBsky } |
| … | … | @@ -19,6 +19,7 @@ |
| 19 | 19 | "bare-os": "^3.9.3", |
| 20 | 20 | "bare-path": "^3.1.2", |
| 21 | 21 | "bare-process": "^4.5.1", |
| 22 | "favela-bridge": "file:../favela-bridge", | |
| 22 | 23 | "favela-peer": "file:../favela-peer", |
| 23 | 24 | "favela-surface": "file:../favela-surface", |
| 24 | 25 | "hyper-proto-peer": "file:../hyper-proto-peer", |
| … | … | @@ -99,7 +99,14 @@ test('an already-submitted identity is not posted twice', async (t) => { |
| 99 | 99 | // ⛔ Pinned so a verb cannot appear without a page that can reach it, or vanish from under one. |
| 100 | 100 | // `did-carry` is the one a person uses; `did-adopt` is its irreversible half, exposed separately only |
| 101 | 101 | // 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. | |
| 102 | 109 | test('the verb set is exactly what the pages expect', (t) => { |
| 103 | 110 | 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']) | |
| 105 | 112 | }) |
| … | … | @@ -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 | ||
| 13 | import test from 'brittle' | |
| 14 | import { deviceClient, pickNode } from '../lib/project.js' | |
| 15 | ||
| 16 | function 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 | ||
| 34 | test('★★★ 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 | ||
| 47 | test('★★ 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 | ||
| 53 | test('★★★ 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 | ||
| 58 | test('★★ 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 | ||
| 63 | test('★★ 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 | ||
| 72 | test('★★★ 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 | }) |