OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / 4e4e94b

0.0.1: favela-bridge — one reader, one reach decision, a projection per network
favela-peer must not know what atproto is (its boundary test, hyper-social #61 §10) and hyper-proto-peer signs a production DID. The projection belongs in neither, so it lives here: read the peer, decide, drive the sinks. ⛔ EACH NETWORK DERIVES FROM THE MARKDOWN, never from the other projection. Measured: the fediverse path today takes `record.text` — the STRIPPED atproto text — so a heading and a list reach Mastodon as literal • characters. Mastodon renders <h2> and <ul>. A projection of a projection loses what the second one never had. ⛔ CONSENT AND CAPABILITY ARE SEPARATE GATES. maxReach says what a room MAY reach; the identity's `reach` preference says what its owner ASKED for. Read fresh every cycle — unticking a network withdraws consent and a cached copy keeps publishing. A failure to read it is OFF: unknown consent is not consent. VERIFIED END TO END 2026-09-07 against did:plc:e2jevhuu… — markdown record → strip() → app.bsky.feed.post → hpp signs → pds.7rnx.net serves → relay crawls → visible on bsky.app/profile/ashatron.7rnx.net. Two live findings paid for it: · THE RELAY HAD MARKED US OFFLINE. getHostStatus returned status:offline at seq 40169 and never retries; requestCrawl took it to active/40194. · THE RKEY WAS SILENTLY DROPPING EVERY POST. Our PDS served 74 records, three with non-TID rkeys; bsky's AppView showed 71 and every one was TID-shaped. app.bsky.feed.post is keyed "tid" and the AppView enforces it by DISCARDING, with no error at any layer — our own PDS served all 74 happily, which is why the first check looked healthy. tidFor() is now a pure function of the record name (ms → µs << 10), so it is conformant, order-preserving AND deterministic, which is what keeps a second run idempotent without holding any state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4e4e94bf3040a3fc514659c1564c66ab8aba1c6e
by plankatron <noreply@anthropic.com> · 2026-09-08T01:00:29.000Z

Changed files (11) +661 -0

README.md +43 -0
……@@ -0,0 +1,43 @@
1# favela-bridge
2
3Projects 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
15favela-peer must not know what atproto is — `favela-peer/test/boundary.test.js` enforces it, from
16hyper-social #61 §10: *"the moment the daemon imports the projection the dependency has been
17inverted"*. And hyper-proto-peer signs a production DID. So the projection lives in neither: this
18reads the peer, decides, and drives the sinks.
19
20It is also where the reach decision belongs. `favela-markdown/lib/reach.js` exists because that
21check "would otherwise be written three times… and the third one would eventually be written
22wrong". One component asks once and fans out.
23
24## Consent, then capability
25
26Two 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
34A 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.
index.js +121 -0
……@@ -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
21import process from 'bare-process'
22import os from 'bare-os'
23import { join } from 'bare-path'
24import { planRecord } from './lib/plan.js'
25import { FavelaPeer } from './lib/peer.js'
26import { BskySink } from './lib/bsky.js'
27import { tidFor } from './lib/tid.js'
28
29const argv = process.argv.slice(2)
30const 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.
33const WATCH = argv.includes('--watch')
34const EVERY = Math.max(5, Number(process.env.FAVELA_BRIDGE_INTERVAL || 15)) * 1000
35const PEER_SOCK = process.env.FAVELA_PEER_SOCKET || join(os.homedir(), '.config', 'favela-peer', 'favela.sock')
36const 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.
41const ROOM_TYPE = process.env.FAVELA_ROOM_TYPE || 'public'
42
43async 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
57async 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
121main().catch((err) => { console.error('favela-bridge: ' + ((err && err.message) || err)); process.exit(1) })
lib/bsky.js +83 -0
……@@ -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
12import { Client } from 'hyper-svc'
13import { tidFor } from './tid.js'
14
15export 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
83export default { BskySink }
lib/peer.js +84 -0
……@@ -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
16import net from 'bare-net'
17
18export 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
84export default { FavelaPeer }
lib/plan.js +150 -0
……@@ -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
24import { maxReach } from 'favela-markdown/lib/reach.js'
25import { strip } from 'favela-markdown/lib/strip.js'
26import { 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.
30const MAX_GRAPHEMES = 300
31const MAX_BYTES = 3000
32
33const BLOB_RE = /!\[[^\]]*\]\(favela:\/\/blob\//
34
35const byteLength = (s) => (typeof Buffer !== 'undefined' && Buffer.byteLength)
36 ? Buffer.byteLength(s, 'utf8')
37 : new TextEncoder().encode(s).length
38
39const 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 */
47export 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 */
82function 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 */
132function 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
150export default { planRecord }
lib/tid.js +35 -0
……@@ -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
14const S32 = '234567abcdefghijklmnopqrstuvwxyz'
15
16/** @param {string} name a favela record name */
17export 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.
29function 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
35export default { tidFor }
node_modules/atproto-store +1 -0
……@@ -0,0 +1 @@
1../../atproto-store
node_modules/favela-markdown +1 -0
……@@ -0,0 +1 @@
1../../favela-markdown
package.json +16 -0
……@@ -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}
test/plan.test.js +81 -0
……@@ -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
15import test from 'brittle'
16import { planRecord } from '../lib/plan.js'
17
18const at = '2026-09-07T23:12:04.600Z'
19const row = (body, extra = {}) => ({ name: '1788822724600', body, createdAt: at, ...extra })
20
21test('★★ 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
28test('⛔ 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
38test('⛔ 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
47test('⛔ 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
53test('★★ 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
62test('⛔ 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
71test('⛔ losses are reported on a SUCCESSFUL projection too', async (t) => {
72 const p = planRecord(row('look ![p.webp](favela://blob/' + 'cd'.repeat(32) + ')'), { roomType: 'public', verb: 'post' })
73 t.ok(p.bsky.losses.length > 0, 'the picture is named: ' + p.bsky.losses.join('; '))
74})
75
76test('⛔ 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})
test/tid.test.js +46 -0
……@@ -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
13import test from 'brittle'
14import { isValidTid, tidMicros } from 'atproto-store/lib/tid.js'
15import { tidFor } from '../lib/tid.js'
16
17test('★★ 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
24test('★★ 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
31test('★ 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
39test('⛔ 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})