| … | … | @@ -0,0 +1,43 @@ |
| 1 | # favela-bridge | |
| 2 | ||
| 3 | Projects a node's own favela records onto the networks its identity has turned on. | |
| 4 | ||
| 5 | favela-bridge plan and report; publish nothing | |
| 6 | favela-bridge --send project once | |
| 7 | favela-bridge --watch project continuously (implies --send) | |
| 8 | ||
| 9 | FAVELA_PEER_SOCKET favela-peer's socket | |
| 10 | FAVELA_BSKY_SOCKET hyper-proto-peer's control socket — required to publish | |
| 11 | FAVELA_BRIDGE_INTERVAL seconds between cycles when watching (default 15) | |
| 12 | ||
| 13 | ## Why it is its own component | |
| 14 | ||
| 15 | favela-peer must not know what atproto is — `favela-peer/test/boundary.test.js` enforces it, from | |
| 16 | hyper-social #61 §10: *"the moment the daemon imports the projection the dependency has been | |
| 17 | inverted"*. And hyper-proto-peer signs a production DID. So the projection lives in neither: this | |
| 18 | reads the peer, decides, and drives the sinks. | |
| 19 | ||
| 20 | It is also where the reach decision belongs. `favela-markdown/lib/reach.js` exists because that | |
| 21 | check "would otherwise be written three times… and the third one would eventually be written | |
| 22 | wrong". One component asks once and fans out. | |
| 23 | ||
| 24 | ## Consent, then capability | |
| 25 | ||
| 26 | Two independent gates, and both must pass: | |
| 27 | ||
| 28 | - **`maxReach(roomType, verb)`** — what a room MAY reach. A followers room cannot reach bsky at all, | |
| 29 | because atproto has no addressing and a projection it cannot restrict is a leak. | |
| 30 | - **the identity's `reach` preference** — what its owner has ASKED for, set in favela-surface's | |
| 31 | settings and stored in the peer's own feed. Read every cycle: unticking a network withdraws | |
| 32 | consent, and a cached copy would keep publishing. | |
| 33 | ||
| 34 | A failure to read either is OFF. Unknown consent is not consent. | |
| 35 | ||
| 36 | ## What it does not do yet | |
| 37 | ||
| 38 | - **fediverse delivery.** The plan is computed and reported; ap-bridge holds the RSA key and signs | |
| 39 | at delivery, and that wiring is the next step. Reported rather than omitted so the gap is visible. | |
| 40 | - **facets.** Ours carry `favela://<root>`; atproto wants a `did:plc:`. Mentions travel as plain | |
| 41 | text and say so. | |
| 42 | - **blobs.** hpp exposes `upload-blob`; nothing calls it. A post with a picture sends its text and | |
| 43 | reports the picture lost. |
| … | … | @@ -0,0 +1,121 @@ |
| 1 | #!/usr/bin/env bare | |
| 2 | // favela-bridge — projects a node's own favela records onto the networks its rooms may reach. | |
| 3 | // | |
| 4 | // favela-bridge plan every record and report; sends nothing | |
| 5 | // favela-bridge --send actually project | |
| 6 | // | |
| 7 | // FAVELA_PEER_SOCKET favela-peer's socket (default ~/.config/favela-peer/favela.sock) | |
| 8 | // FAVELA_BSKY_SOCKET hyper-proto-peer's control socket — REQUIRED for --send | |
| 9 | // | |
| 10 | // ⛔ DRY BY DEFAULT. Projection is publication: it puts words under an identity on a network that | |
| 11 | // does not forget. The default run says what would happen and does nothing, because the cost of | |
| 12 | // discovering the plan was wrong after publishing is not symmetric with reading it first. | |
| 13 | // | |
| 14 | // ⛔ ONE REACH DECISION, TWO PROJECTIONS. See lib/plan.js — each network derives from the markdown, | |
| 15 | // never from the other's output. | |
| 16 | // | |
| 17 | // ⚠ v1 SENDS TO BSKY ONLY. The fediverse plan is computed, reported, and not delivered: ap-bridge | |
| 18 | // holds the RSA key and signs at delivery, and wiring that is the next step, not this one. It is | |
| 19 | // reported rather than omitted so the gap is visible (library.md §4). | |
| 20 | ||
| 21 | import process from 'bare-process' | |
| 22 | import os from 'bare-os' | |
| 23 | import { join } from 'bare-path' | |
| 24 | import { planRecord } from './lib/plan.js' | |
| 25 | import { FavelaPeer } from './lib/peer.js' | |
| 26 | import { BskySink } from './lib/bsky.js' | |
| 27 | import { tidFor } from './lib/tid.js' | |
| 28 | ||
| 29 | const argv = process.argv.slice(2) | |
| 30 | const SEND = argv.includes('--send') | |
| 31 | // ⛔ --watch implies --send. A watch that only planned would be a process that costs power and | |
| 32 | // changes nothing, and the flag would read as if it were working. | |
| 33 | const WATCH = argv.includes('--watch') | |
| 34 | const EVERY = Math.max(5, Number(process.env.FAVELA_BRIDGE_INTERVAL || 15)) * 1000 | |
| 35 | const PEER_SOCK = process.env.FAVELA_PEER_SOCKET || join(os.homedir(), '.config', 'favela-peer', 'favela.sock') | |
| 36 | const BSKY_SOCK = process.env.FAVELA_BSKY_SOCKET || null | |
| 37 | ||
| 38 | // ⚠ v1 assumes the node's own feed is a PUBLIC one-writer room. identity.md §7.6: "a personal feed | |
| 39 | // is a one-writer room". That assumption is stated here rather than buried, because it is the input | |
| 40 | // the reach lattice cannot infer — and if it is ever wrong, it is wrong in the direction of a leak. | |
| 41 | const ROOM_TYPE = process.env.FAVELA_ROOM_TYPE || 'public' | |
| 42 | ||
| 43 | async function main () { | |
| 44 | if (WATCH) { | |
| 45 | console.log('favela-bridge · watching every ' + (EVERY / 1000) + 's · ctrl-c to stop') | |
| 46 | for (;;) { | |
| 47 | // ⛔ One cycle NEVER throws the loop. A peer restarting, a sink respawning on OTA, a socket | |
| 48 | // that vanished — all ordinary. A bridge that exited on the first of them would stop | |
| 49 | // publishing silently, which is the failure this whole component is meant to make visible. | |
| 50 | try { await cycle(true) } catch (err) { console.error(' cycle failed: ' + ((err && err.message) || err)) } | |
| 51 | await new Promise((r) => setTimeout(r, EVERY)) | |
| 52 | } | |
| 53 | } | |
| 54 | await cycle(SEND) | |
| 55 | } | |
| 56 | ||
| 57 | async function cycle (send) { | |
| 58 | const peer = new FavelaPeer(PEER_SOCK) | |
| 59 | const bsky = new BskySink(BSKY_SOCK) | |
| 60 | ||
| 61 | // ⛔ The identity's own preference decides, not this process's configuration. Read fresh so a | |
| 62 | // toggle in settings takes effect on the next cycle rather than on the next restart. | |
| 63 | const reach = await peer.reach() | |
| 64 | const { feed, rows } = await peer.ownPosts() | |
| 65 | console.log('favela-bridge · feed ' + feed.slice(0, 12) + ' · ' + rows.length + ' own post(s)') | |
| 66 | console.log(' room type ' + ROOM_TYPE + (process.env.FAVELA_ROOM_TYPE ? '' : ' (assumed)')) | |
| 67 | console.log(' bsky sink ' + (bsky.configured ? BSKY_SOCK : 'not configured')) | |
| 68 | console.log(' enabled ' + (reach.unknown | |
| 69 | ? 'UNKNOWN — the peer could not be asked, so nothing will be published' | |
| 70 | : (reach.networks.length ? reach.networks.join(', ') : 'nothing (turn a network on in settings)'))) | |
| 71 | console.log(' mode ' + (send ? 'SEND' : 'dry run — nothing will be published')) | |
| 72 | ||
| 73 | // ⛔ Ask what is already there ALWAYS, not only when sending. A dry run whose whole job is to say | |
| 74 | // what would happen must not overstate it — the first version skipped this read and reported | |
| 75 | // "would send 6" for a repo that already held two of them. | |
| 76 | // | |
| 77 | // A null answer means we could not find out, which is different from an empty repo, and the | |
| 78 | // difference is a duplicate of every post. | |
| 79 | const found = bsky.configured ? await bsky.have() : new Set() | |
| 80 | const have = found === null ? new Set() : found | |
| 81 | if (send && found === null) { | |
| 82 | console.error(' refusing to send: could not read what the bsky sink already holds') | |
| 83 | process.exit(1) | |
| 84 | } | |
| 85 | if (found === null) console.log(' ⚠ could not read the sink — "already there" is unknown below') | |
| 86 | ||
| 87 | let sent = 0; let skipped = 0; let withheld = 0 | |
| 88 | for (const row of rows) { | |
| 89 | const plan = planRecord(row, { roomType: ROOM_TYPE, verb: 'post' }) | |
| 90 | const line = ' ' + row.name + ' [' + plan.networks.join(',') + ']' | |
| 91 | ||
| 92 | // ⛔ CONSENT FIRST, then capability. The reach lattice says what a room MAY reach; this says | |
| 93 | // what its owner has ASKED for. A post may be publishable and still must not be published. | |
| 94 | if (!reach.networks.includes('bsky')) { | |
| 95 | withheld++ | |
| 96 | console.log(line + ' bsky: not enabled for this identity') | |
| 97 | } else if (!plan.bsky.project) { | |
| 98 | withheld++ | |
| 99 | console.log(line + ' bsky: withheld — ' + plan.bsky.losses.join('; ')) | |
| 100 | } else if (have.has(tidFor(row.name))) { | |
| 101 | skipped++ | |
| 102 | console.log(line + ' bsky: already there') | |
| 103 | } else if (!send) { | |
| 104 | console.log(line + ' bsky: would send ' + JSON.stringify(plan.bsky.record.text.slice(0, 48)) + | |
| 105 | (plan.bsky.losses.length ? ' — losing: ' + plan.bsky.losses.join('; ') : '')) | |
| 106 | } else { | |
| 107 | const r = await bsky.send(row.name, plan.bsky.record) | |
| 108 | if (r.sent) { sent++; console.log(line + ' bsky: SENT ' + r.rkey + (plan.bsky.losses.length ? ' — lost: ' + plan.bsky.losses.join('; ') : '')) } | |
| 109 | else console.log(line + ' bsky: FAILED — ' + r.error) | |
| 110 | } | |
| 111 | ||
| 112 | // Reported, never delivered, in v1. Silence here would read as "the fediverse got it". | |
| 113 | if (plan.fediverse.project) console.log(' fediverse: planned (' + plan.fediverse.content.length + ' bytes of html) — delivery unwired') | |
| 114 | else console.log(' fediverse: withheld — ' + plan.fediverse.losses.join('; ')) | |
| 115 | } | |
| 116 | ||
| 117 | console.log(' ' + (send ? 'sent ' + sent : 'would send ' + (rows.length - withheld - skipped)) + | |
| 118 | ' · already there ' + skipped + ' · withheld ' + withheld) | |
| 119 | } | |
| 120 | ||
| 121 | main().catch((err) => { console.error('favela-bridge: ' + ((err && err.message) || err)); process.exit(1) }) |
| … | … | @@ -0,0 +1,83 @@ |
| 1 | // The bsky sink: a co-located hyper-proto-peer, reached over its unix control socket. | |
| 2 | // | |
| 3 | // ⛔ WE DO NOT SIGN AND WE DO NOT SPEAK TO BSKY. hpp holds the DID signing key and forms the commit; | |
| 4 | // the keyless membrane (atproto-bridge) re-serves it over HTTP; bsky's relay crawls that. This hands | |
| 5 | // hpp a record and stops. Verified 2026-09-07 on ashatron: apply-write returned ok, records 79→80, | |
| 6 | // and get-repo-car rebuilt and verified the resulting commit. | |
| 7 | // | |
| 8 | // ⚠ hpp's UDS surface is filesystem-gated and trusted WITHOUT the writer allowlist — its own header | |
| 9 | // says so, and favela-web was its first co-located client. So the socket path IS the capability: | |
| 10 | // whoever can open it can write that DID. That is why it is configuration and never a default. | |
| 11 | ||
| 12 | import { Client } from 'hyper-svc' | |
| 13 | import { tidFor } from './tid.js' | |
| 14 | ||
| 15 | export class BskySink { | |
| 16 | constructor (sock, opts = {}) { | |
| 17 | this.sock = sock || null | |
| 18 | this._connect = opts.connect || null | |
| 19 | this._log = opts.log || (() => {}) | |
| 20 | } | |
| 21 | ||
| 22 | get configured () { return !!this.sock } | |
| 23 | ||
| 24 | async _open () { | |
| 25 | if (this._connect) return this._connect(this.sock) | |
| 26 | return Client.discover({ uds: this.sock, prefer: 'uds' }) | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * Send one planned record. Resolves `{ sent, rkey?, error? }` and NEVER rejects — a bridge that is | |
| 31 | * down is an ordinary condition and must not become an exception in the caller's loop. | |
| 32 | */ | |
| 33 | async send (name, record) { | |
| 34 | if (!this.configured) return { sent: false, error: 'no bsky sink configured' } | |
| 35 | let client = null | |
| 36 | try { | |
| 37 | client = await this._open() | |
| 38 | // ⛔ A REAL TID. An earlier version used 'favela-' + name because hpp accepted it and the | |
| 39 | // rkey then traced back to its source record. MEASURED on the live network: our PDS served | |
| 40 | // all 74 posts, and bsky's AppView showed the 71 with TID rkeys and dropped the three without, | |
| 41 | // silently. `app.bsky.feed.post` is keyed "tid" and the AppView enforces it. | |
| 42 | const rkey = tidFor(name) | |
| 43 | const res = await client.call('apply-write', { | |
| 44 | action: 'create', collection: 'app.bsky.feed.post', rkey, record | |
| 45 | }) | |
| 46 | this._log('sent', { rkey, ok: !!(res && res.ok) }) | |
| 47 | // ⚠ NOT verified here. apply-write acknowledges the APPEND; the commit is re-signed a moment | |
| 48 | // later. Measured: calling get-repo-car immediately after returned "signed commit data CID != | |
| 49 | // rebuilt MST root", and the same call seconds later verified. Treating that window as failure | |
| 50 | // would raise a false alarm on every post. | |
| 51 | return { sent: true, rkey } | |
| 52 | } catch (err) { | |
| 53 | const msg = (err && err.message) || String(err) | |
| 54 | this._log('send-failed', { err: msg }) | |
| 55 | return { sent: false, error: msg } | |
| 56 | } finally { | |
| 57 | // hpp respawns on OTA; a cached connection across one fails every later post with an error | |
| 58 | // about a socket rather than about a bridge. | |
| 59 | if (client && client.close) { try { await client.close() } catch {} } | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | /** What this DID already holds, so a run does not re-send what it sent last time. */ | |
| 64 | async have () { | |
| 65 | if (!this.configured) return new Set() | |
| 66 | let client = null | |
| 67 | try { | |
| 68 | client = await this._open() | |
| 69 | const s = await client.call('get-state', {}) | |
| 70 | return new Set((s.records || []) | |
| 71 | .filter((r) => r.collection === 'app.bsky.feed.post') | |
| 72 | .map((r) => r.rkey)) | |
| 73 | } catch { | |
| 74 | // ⛔ An EMPTY set would read as "nothing is there" and re-send everything. Unknown is not | |
| 75 | // empty, so the caller is told to do nothing rather than told the repo is bare. | |
| 76 | return null | |
| 77 | } finally { | |
| 78 | if (client && client.close) { try { await client.close() } catch {} } | |
| 79 | } | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | export default { BskySink } |
| … | … | @@ -0,0 +1,84 @@ |
| 1 | // The source: favela-peer, read over its local unix socket. | |
| 2 | // | |
| 3 | // ⛔ READ ONLY, AND PULL. The peer is never asked to push and never told a bridge exists — that is | |
| 4 | // the boundary its own test enforces (`favela-peer/test/boundary.test.js`, hyper-social #61 §10): | |
| 5 | // "the moment the daemon imports the projection the dependency has been inverted". So this reaches | |
| 6 | // in, takes records in favela's own vocabulary, and the peer stays ignorant of what happens next. | |
| 7 | // | |
| 8 | // It is also the shape every other bridge here already uses — ap-bridge's records.js: "replicate the | |
| 9 | // peer's cores over Hyperswarm, read records, project. No key involved." This does the same over a | |
| 10 | // socket instead of the swarm, because for a co-located bridge a DHT rendezvous buys nothing. | |
| 11 | // | |
| 12 | // ⚠ Its wire format is newline-delimited JSON, `{id, cmd, args}` → `{id, ok, value|error}`, NOT | |
| 13 | // hyper-svc. The two daemons in this path speak different protocols and that is not worth papering | |
| 14 | // over: favela-peer's is deliberately the smallest thing that works (its rpc.js says so). | |
| 15 | ||
| 16 | import net from 'bare-net' | |
| 17 | ||
| 18 | export class FavelaPeer { | |
| 19 | constructor (sock) { | |
| 20 | if (!sock) throw new Error('FavelaPeer: a socket path is required') | |
| 21 | this.sock = sock | |
| 22 | } | |
| 23 | ||
| 24 | /** One request, one connection. There is no session to keep and no benefit to holding one open. */ | |
| 25 | call (cmd, args = {}) { | |
| 26 | return new Promise((resolve, reject) => { | |
| 27 | const c = net.connect(this.sock) | |
| 28 | let buf = '' | |
| 29 | let done = false | |
| 30 | const finish = (fn, v) => { if (!done) { done = true; try { c.destroy() } catch {} ; fn(v) } } | |
| 31 | c.on('error', (err) => finish(reject, err)) | |
| 32 | c.on('connect', () => c.write(JSON.stringify({ id: 1, cmd, args }) + '\n')) | |
| 33 | c.on('data', (d) => { | |
| 34 | buf += d | |
| 35 | const i = buf.indexOf('\n') | |
| 36 | if (i < 0) return | |
| 37 | let r | |
| 38 | try { r = JSON.parse(buf.slice(0, i)) } catch (err) { return finish(reject, err) } | |
| 39 | if (r.ok) finish(resolve, r.value) | |
| 40 | else finish(reject, new Error(r.error || 'favela-peer refused ' + cmd)) | |
| 41 | }) | |
| 42 | // ⚠ A peer that accepts the connection and never answers would hang the bridge for ever. | |
| 43 | setTimeout(() => finish(reject, new Error('favela-peer timed out on ' + cmd)), 30000) | |
| 44 | }) | |
| 45 | } | |
| 46 | ||
| 47 | async whoami () { return this.call('whoami') } | |
| 48 | ||
| 49 | /** | |
| 50 | * Which networks this identity has turned on, from the peer's own feed. | |
| 51 | * | |
| 52 | * ⛔ READ EVERY CYCLE, never cached. A person who unticks bsky in settings has withdrawn consent | |
| 53 | * to publish, and a bridge holding a stale copy would keep publishing until someone restarted it. | |
| 54 | * | |
| 55 | * ⛔ AND A FAILURE READS AS OFF. If the peer cannot be asked, the answer is not "carry on with | |
| 56 | * what we had" — it is publish nothing. Unknown consent is not consent. | |
| 57 | */ | |
| 58 | async reach () { | |
| 59 | try { | |
| 60 | const r = await this.call('reach') | |
| 61 | return { | |
| 62 | networks: Array.isArray(r && r.networks) ? r.networks : [], | |
| 63 | credential: (r && r.credential) || null | |
| 64 | } | |
| 65 | } catch { | |
| 66 | return { networks: [], credential: null, unknown: true } | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * This node's own posts, newest last. | |
| 72 | * | |
| 73 | * ⚠ OUR OWN ONLY. A bridge projects what its identity wrote; forwarding a followed feed's posts | |
| 74 | * under our DID would be republishing someone else's words as ours, which is a different act with | |
| 75 | * a different name and is not this. | |
| 76 | */ | |
| 77 | async ownPosts () { | |
| 78 | const me = await this.whoami() | |
| 79 | const rows = await this.call('posts-by', { feed: me.feed }) | |
| 80 | return { feed: me.feed, rows: Array.isArray(rows) ? rows : [] } | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | export default { FavelaPeer } |
| … | … | @@ -0,0 +1,150 @@ |
| 1 | // plan.js — what one favela record becomes on each network. Pure: no I/O, no clock, no throw. | |
| 2 | // | |
| 3 | // ── Why this is one function and not two ────────────────────────────────────────────────────── | |
| 4 | // | |
| 5 | // `favela-markdown/lib/reach.js` exists because the reach check "would otherwise be written three | |
| 6 | // times — favela-web, hyper-proto-peer, ap-bridge — and the third one would eventually be written | |
| 7 | // wrong". The same argument applies to the projection itself: if hpp decides what reaches bsky and | |
| 8 | // ap-bridge decides what reaches the fediverse, two components read one record and each answers the | |
| 9 | // audience question alone. Here it is answered once, and the answer fans out. | |
| 10 | // | |
| 11 | // ⛔ EACH NETWORK DERIVES FROM THE MARKDOWN. Not from the other projection. | |
| 12 | // | |
| 13 | // Measured 2026-09-07: today's fediverse path takes `record.text` — the STRIPPED atproto text — and | |
| 14 | // ap-bridge's `textToHtml` splits it on blank lines and wraps each in <p>. So `# Heading` and a | |
| 15 | // bulleted list arrive at Mastodon as paragraphs containing literal • characters, because the AP | |
| 16 | // projection is derived from the bsky one and bsky's `text` is plain by protocol. Mastodon renders | |
| 17 | // <h2>, <ul>, <a>, <strong>. Deriving each network from the canonical markdown is the difference | |
| 18 | // between sending structure and sending the residue of somebody else's format. | |
| 19 | // | |
| 20 | // ⛔ EVERYTHING FAILS CLOSED. An unknown room type, a missing verb, a body that will not parse: the | |
| 21 | // answer is {favela} and nothing leaves. `reach.js` §6.1 is the one place a defect here is a privacy | |
| 22 | // bug rather than a rendering bug. | |
| 23 | ||
| 24 | import { maxReach } from 'favela-markdown/lib/reach.js' | |
| 25 | import { strip } from 'favela-markdown/lib/strip.js' | |
| 26 | import { render } from 'favela-markdown/lib/render.js' | |
| 27 | ||
| 28 | // app.bsky.feed.post caps `text` at 300 GRAPHEMES and 3000 bytes. Graphemes, not characters — an | |
| 29 | // emoji is one grapheme and four bytes, and counting the wrong one rejects valid posts. | |
| 30 | const MAX_GRAPHEMES = 300 | |
| 31 | const MAX_BYTES = 3000 | |
| 32 | ||
| 33 | const BLOB_RE = /!\[[^\]]*\]\(favela:\/\/blob\// | |
| 34 | ||
| 35 | const byteLength = (s) => (typeof Buffer !== 'undefined' && Buffer.byteLength) | |
| 36 | ? Buffer.byteLength(s, 'utf8') | |
| 37 | : new TextEncoder().encode(s).length | |
| 38 | ||
| 39 | const WITHHELD = (reason) => ({ project: false, losses: [reason] }) | |
| 40 | ||
| 41 | /** | |
| 42 | * @param {object} row a favela record: { name, body, createdAt } | |
| 43 | * @param {object} ctx { roomType, verb } — the CALLER supplies the room's kind, because reach.js | |
| 44 | * has no storage and cannot inspect a room. Missing or unknown fails closed. | |
| 45 | * @returns {{ networks: string[], bsky: object, fediverse: object }} | |
| 46 | */ | |
| 47 | export function planRecord (row, ctx) { | |
| 48 | const roomType = (ctx && typeof ctx.roomType === 'string') ? ctx.roomType : null | |
| 49 | const verb = (ctx && typeof ctx.verb === 'string') ? ctx.verb : 'post' | |
| 50 | ||
| 51 | // ⛔ The lattice decides the audience before anything is rendered. Rendering first and filtering | |
| 52 | // after is how a projection gets built for a network it was never allowed to reach. | |
| 53 | // ⚠ maxReach returns a SET. Treating it as an array silently produced {favela} for every room — | |
| 54 | // fail-closed, so nothing leaked, and nothing projected either. A deny-by-default bug is quiet by | |
| 55 | // construction, which is exactly why it needs a test that asserts the PERMITTED case. | |
| 56 | let networks = ['favela'] | |
| 57 | try { | |
| 58 | const r = maxReach({ roomType, verb }) | |
| 59 | if (r && typeof r[Symbol.iterator] === 'function') networks = [...r] | |
| 60 | } catch { | |
| 61 | networks = ['favela'] | |
| 62 | } | |
| 63 | if (!networks.includes('favela')) networks.push('favela') | |
| 64 | ||
| 65 | const body = (row && typeof row.body === 'string') ? row.body : '' | |
| 66 | const createdAt = (row && row.createdAt) || null | |
| 67 | ||
| 68 | return { | |
| 69 | networks, | |
| 70 | bsky: networks.includes('bsky') ? planBsky(body, createdAt) : WITHHELD('room does not reach bsky'), | |
| 71 | fediverse: networks.includes('fediverse') ? planFediverse(body, createdAt) : WITHHELD('room does not reach the fediverse') | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * bsky — plain text plus facets, capped. | |
| 77 | * | |
| 78 | * ⛔ WITHHELD, NEVER TRUNCATED. Cutting a post at 300 publishes something the author did not write, | |
| 79 | * under their name, with nothing to say it was cut. The canonical record stays whole on favela and | |
| 80 | * the loss is reported. | |
| 81 | */ | |
| 82 | function planBsky (body, createdAt) { | |
| 83 | const losses = [] | |
| 84 | let text = '' | |
| 85 | let facets = [] | |
| 86 | try { | |
| 87 | const s = strip(body) | |
| 88 | text = (s && typeof s.text === 'string') ? s.text : '' | |
| 89 | facets = (s && Array.isArray(s.facets)) ? s.facets : [] | |
| 90 | } catch (err) { | |
| 91 | return WITHHELD('could not strip the record: ' + ((err && err.message) || String(err))) | |
| 92 | } | |
| 93 | ||
| 94 | // ⚠ Counted from the SOURCE. strip() removes an image entirely, so by the time we hold `text` | |
| 95 | // there is no evidence a picture was ever in the post. | |
| 96 | if (BLOB_RE.test(body)) { | |
| 97 | losses.push('attachment(s) not projected — bsky needs an uploaded blob ref, which is unbuilt') | |
| 98 | } | |
| 99 | ||
| 100 | if (!text.trim()) return { project: false, losses: losses.concat(['nothing to post — no text survives the strip']) } | |
| 101 | ||
| 102 | const graphemes = [...text].length | |
| 103 | if (graphemes > MAX_GRAPHEMES || byteLength(text) > MAX_BYTES) { | |
| 104 | return { | |
| 105 | project: false, | |
| 106 | losses: losses.concat(['too long for bsky — ' + graphemes + ' graphemes against a limit of ' + MAX_GRAPHEMES]) | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | if (facets.length) { | |
| 111 | // ⚠ Ours are {byteStart, byteEnd, type, value} where value is a favela:// URI. atproto wants | |
| 112 | // {index, features:[{$type, did}]}, so projecting one means resolving a favela identity root to | |
| 113 | // a did:plc: — the credential link in identity.md §5.1, which does not exist yet. | |
| 114 | losses.push(facets.length + ' facet(s) sent as plain text — resolving favela:// to a did:plc: is unbuilt') | |
| 115 | } | |
| 116 | ||
| 117 | return { | |
| 118 | project: true, | |
| 119 | // ⚠ The AUTHOR's time, unchanged. Stamping projection time would make every backfilled record | |
| 120 | // claim to be new; the field is the author's statement about when they wrote it. | |
| 121 | record: { $type: 'app.bsky.feed.post', text, createdAt }, | |
| 122 | losses | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | /** | |
| 127 | * fediverse — HTML, because AS2 `content` is HTML and Mastodon renders a useful subset of it. | |
| 128 | * | |
| 129 | * ⛔ Rendered from the MARKDOWN, which is the entire point of planning both here. Headings, lists, | |
| 130 | * emphasis and links survive; they cannot survive a trip through bsky's plain `text`. | |
| 131 | */ | |
| 132 | function planFediverse (body, createdAt) { | |
| 133 | const losses = [] | |
| 134 | let content = '' | |
| 135 | try { | |
| 136 | content = String(render(body) || '') | |
| 137 | } catch (err) { | |
| 138 | return WITHHELD('could not render the record: ' + ((err && err.message) || String(err))) | |
| 139 | } | |
| 140 | if (!content.trim()) return { project: false, losses: ['nothing to post — the record renders empty'] } | |
| 141 | if (BLOB_RE.test(body)) { | |
| 142 | // ap-bridge HAS projectAttachments; wiring it needs the blob to be reachable from there. | |
| 143 | losses.push('attachment(s) not projected — the blob is not yet offered to ap-bridge') | |
| 144 | } | |
| 145 | // ⚠ No length limit. Mastodon instances impose their own and reject at delivery; withholding here | |
| 146 | // on a guess would silence posts a given instance would have accepted. | |
| 147 | return { project: true, content, createdAt, losses } | |
| 148 | } | |
| 149 | ||
| 150 | export default { planRecord } |
| … | … | @@ -0,0 +1,35 @@ |
| 1 | // The rkey. ⛔ A TID, because bsky requires one and says nothing when it does not get one. | |
| 2 | // | |
| 3 | // MEASURED 2026-09-07: our PDS served 74 posts, three with non-TID rkeys; bsky's AppView showed the | |
| 4 | // other 71 and dropped those three silently. `app.bsky.feed.post` is keyed "tid" in the lexicon. | |
| 5 | // | |
| 6 | // ⛔ PURE, unlike atproto-store's `tidNow`, which keeps module-level state to guarantee monotonicity | |
| 7 | // and therefore returns a different rkey each time it is called for the same record. This bridge | |
| 8 | // holds no record→rkey map — it asks the sink which rkeys it already has — so the mapping has to be | |
| 9 | // a function of the record alone or every run posts everything again. | |
| 10 | // | |
| 11 | // A favela record name is milliseconds since the epoch, and a TID is microseconds shifted left by a | |
| 12 | // 10-bit clock id, so the conversion is exact and the ordering survives. | |
| 13 | ||
| 14 | const S32 = '234567abcdefghijklmnopqrstuvwxyz' | |
| 15 | ||
| 16 | /** @param {string} name a favela record name */ | |
| 17 | export function tidFor (name) { | |
| 18 | const digits = String(name == null ? '' : name).replace(/\D/g, '') | |
| 19 | // ⚠ A name that is not a timestamp still has to produce a conformant key, or the record is | |
| 20 | // dropped by bsky with no error. Falling back to a hash of the name keeps it deterministic. | |
| 21 | const ms = digits ? BigInt(digits) : hash53(String(name)) | |
| 22 | let n = (ms * 1000n) << 10n | |
| 23 | let s = '' | |
| 24 | for (let i = 0; i < 13; i++) { s = S32[Number(n % 32n)] + s; n /= 32n } | |
| 25 | return s | |
| 26 | } | |
| 27 | ||
| 28 | // 53 bits, deterministic, no crypto — this only has to be stable and in range, never unguessable. | |
| 29 | function hash53 (s) { | |
| 30 | let h = 0n | |
| 31 | for (let i = 0; i < s.length; i++) h = (h * 131n + BigInt(s.charCodeAt(i))) % 9007199254740993n | |
| 32 | return h | |
| 33 | } | |
| 34 | ||
| 35 | export default { tidFor } |
| … | … | @@ -0,0 +1 @@ |
| 1 | ../../atproto-store |
| … | … | @@ -0,0 +1 @@ |
| 1 | ../../favela-markdown |
| … | … | @@ -0,0 +1,16 @@ |
| 1 | { | |
| 2 | "name": "favela-bridge", | |
| 3 | "version": "0.0.1", | |
| 4 | "description": "Projects favela records onto the networks a room may reach. One reader, one reach decision, one projection per network.", | |
| 5 | "type": "module", | |
| 6 | "main": "index.js", | |
| 7 | "bin": { "favela-bridge": "index.js" }, | |
| 8 | "scripts": { "test": "brittle-bare test/*.test.js" }, | |
| 9 | "dependencies": { | |
| 10 | "favela-markdown": "file:../favela-markdown", | |
| 11 | "hyper-svc": "file:../hyper-svc", | |
| 12 | "bare-net": "^1.3.0", | |
| 13 | "bare-process": "^4.0.0" | |
| 14 | }, | |
| 15 | "devDependencies": { "brittle": "^3.5.0" } | |
| 16 | } |
| … | … | @@ -0,0 +1,81 @@ |
| 1 | // ⛔ ONE REACH DECISION, ONE PROJECTION PER NETWORK. | |
| 2 | // | |
| 3 | // `favela-markdown/lib/reach.js` says why this component exists at all: "Written here once, this | |
| 4 | // check would otherwise be written three times — favela-web, hyper-proto-peer, ap-bridge — and the | |
| 5 | // third one would eventually be written wrong." The same applies one level up. If hpp projects to | |
| 6 | // bsky and ap-bridge projects to the fediverse, two things read the same record and each decides | |
| 7 | // independently what it may reach. This plans once and fans out. | |
| 8 | // | |
| 9 | // ⛔ AND EACH NETWORK DERIVES FROM THE MARKDOWN, never from another projection. Measured: today the | |
| 10 | // AP path takes `record.text` — the STRIPPED atproto text — and ap-bridge's textToHtml wraps it in | |
| 11 | // <p>. So a heading and a list reach Mastodon as literal bullet characters, because the fediverse | |
| 12 | // projection is derived from the bsky one and bsky's text is plain by protocol. A projection of a | |
| 13 | // projection loses what the second one never had. | |
| 14 | ||
| 15 | import test from 'brittle' | |
| 16 | import { planRecord } from '../lib/plan.js' | |
| 17 | ||
| 18 | const at = '2026-09-07T23:12:04.600Z' | |
| 19 | const row = (body, extra = {}) => ({ name: '1788822724600', body, createdAt: at, ...extra }) | |
| 20 | ||
| 21 | test('★★ a public post reaches bsky and the fediverse', async (t) => { | |
| 22 | const p = planRecord(row('hello'), { roomType: 'public', verb: 'post' }) | |
| 23 | t.alike(p.networks.sort(), ['bsky', 'favela', 'fediverse']) | |
| 24 | t.ok(p.bsky.project, 'bsky gets a post') | |
| 25 | t.ok(p.fediverse.project, 'the fediverse gets one too') | |
| 26 | }) | |
| 27 | ||
| 28 | test('⛔ a followers room reaches the fediverse ONLY — atproto has no addressing', async (t) => { | |
| 29 | // reach.js: "atproto has NO addressing at all, so a projection it cannot restrict is a leak, and | |
| 30 | // the only safe answer is to withhold." | |
| 31 | const p = planRecord(row('hello'), { roomType: 'followers', verb: 'post' }) | |
| 32 | t.absent(p.networks.includes('bsky')) | |
| 33 | t.ok(p.networks.includes('fediverse')) | |
| 34 | t.absent(p.bsky.project, 'and the bsky plan refuses') | |
| 35 | t.ok(p.bsky.losses.some((l) => /reach|address|withheld/i.test(l)), p.bsky.losses.join('; ')) | |
| 36 | }) | |
| 37 | ||
| 38 | test('⛔ a members room and a dm reach NOTHING but favela', async (t) => { | |
| 39 | for (const roomType of ['members', 'dm']) { | |
| 40 | const p = planRecord(row('secret'), { roomType, verb: 'post' }) | |
| 41 | t.alike(p.networks, ['favela'], roomType + ' stays local') | |
| 42 | t.absent(p.bsky.project, roomType + ' does not project to bsky') | |
| 43 | t.absent(p.fediverse.project, roomType + ' does not project to the fediverse') | |
| 44 | } | |
| 45 | }) | |
| 46 | ||
| 47 | test('⛔ an UNKNOWN room type fails closed', async (t) => { | |
| 48 | // Deny by default. An unrecognised container must never widen an audience. | |
| 49 | const p = planRecord(row('hello'), { roomType: 'wat', verb: 'post' }) | |
| 50 | t.alike(p.networks, ['favela']) | |
| 51 | }) | |
| 52 | ||
| 53 | test('★★ the fediverse gets STRUCTURE, bsky gets plain text', async (t) => { | |
| 54 | // The whole reason each network derives from the markdown separately. | |
| 55 | const md = '# Heading\n\n* one\n* two' | |
| 56 | const p = planRecord(row(md), { roomType: 'public', verb: 'post' }) | |
| 57 | t.is(p.bsky.record.text.indexOf('#'), -1, 'bsky text carries no markdown syntax') | |
| 58 | t.ok(/<h1|<h2/.test(p.fediverse.content), 'the fediverse gets a real heading: ' + p.fediverse.content.slice(0, 60)) | |
| 59 | t.ok(/<ul|<li/.test(p.fediverse.content), 'and a real list') | |
| 60 | }) | |
| 61 | ||
| 62 | test('⛔ too long for bsky is WITHHELD there and still sent to the fediverse', async (t) => { | |
| 63 | // 300 graphemes is bsky's limit, not the fediverse's. One network's ceiling must not silence the | |
| 64 | // other — which is exactly what a single shared projection would have done. | |
| 65 | const p = planRecord(row('x'.repeat(400)), { roomType: 'public', verb: 'post' }) | |
| 66 | t.absent(p.bsky.project) | |
| 67 | t.ok(p.bsky.losses.some((l) => /300/.test(l))) | |
| 68 | t.ok(p.fediverse.project, 'the fediverse has no such limit') | |
| 69 | }) | |
| 70 | ||
| 71 | test('⛔ losses are reported on a SUCCESSFUL projection too', async (t) => { | |
| 72 | const p = planRecord(row('look  + ')'), { roomType: 'public', verb: 'post' }) | |
| 73 | t.ok(p.bsky.losses.length > 0, 'the picture is named: ' + p.bsky.losses.join('; ')) | |
| 74 | }) | |
| 75 | ||
| 76 | test('⛔ planRecord NEVER throws — it runs on the projection path', async (t) => { | |
| 77 | for (const bad of [undefined, null, {}, { body: null }, { body: 123 }]) { | |
| 78 | t.execution(() => planRecord(bad, { roomType: 'public', verb: 'post' }), JSON.stringify(bad)) | |
| 79 | } | |
| 80 | t.execution(() => planRecord(row('hi'), null), 'and with no context at all') | |
| 81 | }) |
| … | … | @@ -0,0 +1,46 @@ |
| 1 | // ⛔ THE RKEY IS PART OF THE CONTRACT, NOT A LABEL. | |
| 2 | // | |
| 3 | // MEASURED against the live network 2026-09-07: our PDS held 74 posts for did:plc:e2jevhuu…, three | |
| 4 | // of them with non-TID rkeys (favela-…, favela-…, self.hello). bsky's AppView showed 71 — every one | |
| 5 | // TID-shaped, none of the three. `app.bsky.feed.post` is keyed "tid" in the lexicon and the AppView | |
| 6 | // enforces it by DROPPING what does not conform, silently. Our own PDS served all 74 happily, which | |
| 7 | // is why the first check looked healthy. | |
| 8 | // | |
| 9 | // So the rkey must be a real TID. It must ALSO stay a pure function of the favela record, because | |
| 10 | // that is what makes a second run idempotent: the bridge holds no map, and asks the sink what rkeys | |
| 11 | // it already has. | |
| 12 | ||
| 13 | import test from 'brittle' | |
| 14 | import { isValidTid, tidMicros } from 'atproto-store/lib/tid.js' | |
| 15 | import { tidFor } from '../lib/tid.js' | |
| 16 | ||
| 17 | test('★★ a favela record name becomes a VALID tid', async (t) => { | |
| 18 | // Validated by atproto-store's own checker, not by our idea of the format. | |
| 19 | for (const name of ['1788822724600', '1788737926306', '1']) { | |
| 20 | t.ok(isValidTid(tidFor(name)), name + ' -> ' + tidFor(name)) | |
| 21 | } | |
| 22 | }) | |
| 23 | ||
| 24 | test('★★ the mapping is PURE — the same record always gets the same rkey', async (t) => { | |
| 25 | // Idempotency depends on this. tidNow() carries module-level monotonic state, so calling it twice | |
| 26 | // for one record yields two rkeys and a duplicate post. | |
| 27 | t.is(tidFor('1788822724600'), tidFor('1788822724600')) | |
| 28 | t.not(tidFor('1788822724600'), tidFor('1788822724601')) | |
| 29 | }) | |
| 30 | ||
| 31 | test('★ it preserves ORDER, because a TID sorts by time', async (t) => { | |
| 32 | const a = tidFor('1788737926306') | |
| 33 | const b = tidFor('1788822724600') | |
| 34 | t.ok(a < b, 'the earlier post sorts first: ' + a + ' < ' + b) | |
| 35 | // And it decodes back to the moment the record claims. | |
| 36 | t.is(Number(tidMicros(b) / 1000n), 1788822724600) | |
| 37 | }) | |
| 38 | ||
| 39 | test('⛔ a name that is not a timestamp still yields a valid tid', async (t) => { | |
| 40 | // Record names are ms-since-epoch today, but nothing enforces it and an invalid rkey is dropped | |
| 41 | // by bsky without a word. Anything unparseable must still produce something conformant. | |
| 42 | for (const odd of ['self', 'hello-world', '', null, undefined]) { | |
| 43 | const k = tidFor(odd) | |
| 44 | t.ok(isValidTid(k), JSON.stringify(odd) + ' -> ' + k) | |
| 45 | } | |
| 46 | }) |