OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / 8a35278

0.0.8: project the profile onto bsky, not just the posts
favela-bridge carried posts and nothing else. A display name, bio, avatar or banner reached the fediverse — ap-bridge has projected all four from the start — and stopped there, leaving the bsky profile empty with nothing anywhere reporting a failure, because there was no failure. The projection had never been written. lib/profile.js is pure and decides only what SHOULD be there; the sink uploads and writes. It never truncates, matching plan.js: an over-long display name or description is OMITTED and reported rather than cut, because publishing half a sentence under someone's name is worse than publishing none of it, and the rest of the profile still goes. Graphemes, not bytes — one taco emoji is one grapheme and four bytes, and counting the wrong one rejects a valid name. ⛔ png and jpeg only. favela types from magic bytes and accepts gif and webp too, which bsky's lexicon does not, so a picture this peer holds happily can be one bsky refuses. Reported rather than re-encoded: converting someone's picture to fit a destination is a decision about their content. ⛔ An empty profile projects NOTHING. Writing {$type} alone would replace whatever is there with nothing — a projection that erases. ⛔ And it reconciles against the REPO, via hpp's new get-record, rather than a local note of what it last sent. That distinction is the whole lesson of today: a follower relationship existed in our own file and on no server, and every layer reported success. "could not ask" is kept distinct from "absent" for the same reason — writing because a read failed would republish on every transient error, and each write is a repo version. Consent is checked first, exactly as for a post: a profile is published content too. The blob upload hands hpp a PATH rather than hex bytes. Both ends are on one machine and favela-peer settled this question the same way; hex-encoding also needs b4a, which resolves here only from a PARENT node_modules — declaring it would have published a tree that could not find it and crashed the installed app at boot. 24/24 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8a35278f929d3f12954d5b88e8abc35b331f5cae
by plankatron <fleeky@gmail.com> · 2026-09-09T23:02:19.000Z

Changed files (5) +324 -4

index.js +71 -0
……@@ -22,6 +22,7 @@ import process from 'bare-process'
2222import os from 'bare-os'
2323import { join } from 'bare-path'
2424import { planRecord } from './lib/plan.js'
25import { planProfile, sameProfile } from './lib/profile.js'
2526import { FavelaPeer } from './lib/peer.js'
2627import { BskySink } from './lib/bsky.js'
2728import { tidFor } from './lib/tid.js'
……@@ -101,6 +102,12 @@ async function cycle (send) {
101102 }
102103 if (found === null) console.log(' ⚠ could not read the sink — "already there" is unknown below')
103104
105 // ⛔ THE PROFILE IS PROJECTED TOO, and it never was. favela-bridge carried posts and nothing else,
106 // so a display name, bio, avatar or banner reached the fediverse (ap-bridge has projected all four
107 // from the start) and stopped — the bsky profile stayed empty with nothing reporting a failure,
108 // because there was no failure. The projection had simply never been written.
109 await reconcileProfile(peer, bsky, reach, send)
110
104111 let sent = 0; let skipped = 0; let withheld = 0; let held = 0
105112 for (const row of rows) {
106113 const plan = planRecord(row, { roomType: ROOM_TYPE, verb: 'post' })
……@@ -141,4 +148,68 @@ async function cycle (send) {
141148 (held ? ' · held by --limit ' + held : ''))
142149}
143150
151/**
152 * Bring the bsky profile into line with favela's, and say what it did.
153 *
154 * ⛔ RECONCILIATION, not an event. There is no "the profile changed" signal to subscribe to here —
155 * the cycle already reads the peer every pass, so the honest shape is to compare what SHOULD be
156 * there against what IS and act on the difference. Comparing against the repo rather than a local
157 * note of what we last sent is the part that matters: a projector that trusts its own memory drifts
158 * from the thing it claims to describe, which is how a follower relationship existed in our file and
159 * on no server.
160 */
161const blobCache = new Map() // oid -> blob ref. Content-addressed, so this can never go stale.
162
163async function reconcileProfile (peer, bsky, reach, send) {
164 if (!bsky.configured) return
165 // ⛔ CONSENT FIRST, exactly as for a post. A profile is published content too.
166 if (!reach.networks.includes('bsky')) { console.log(' profile bsky: not enabled for this identity'); return }
167
168 let prof = null
169 try { prof = await peer.call('profile') } catch { prof = null }
170 if (!prof) { console.log(' profile could not be read from the peer — leaving bsky alone'); return }
171
172 // An oid names a picture the peer holds; this is where it becomes a file on disk.
173 const describe = async (oid) => {
174 if (!oid) return null
175 try {
176 const r = await peer.call('avatar-get', { oid })
177 return (r && r.path) ? { oid, path: r.path, mimeType: r.mimeType, size: r.size } : null
178 } catch { return null }
179 }
180
181 const plan = planProfile({
182 name: prof.name,
183 bio: prof.bio,
184 avatar: await describe(prof.avatar),
185 banner: await describe(prof.banner)
186 })
187 for (const l of plan.losses) console.log(' profile ' + l)
188 if (!plan.project) { console.log(' profile nothing published on favela — leaving bsky alone'); return }
189
190 const actual = await bsky.getProfile()
191 // ⛔ "COULD NOT ASK" IS NOT "ABSENT". Writing because a read failed would republish the profile on
192 // every transient socket error, and each write is a repo version.
193 if (actual && actual.unknown) { console.log(' profile could not read what bsky holds — not writing'); return }
194
195 const record = { ...plan.record }
196 for (const [field, img] of Object.entries(plan.images)) {
197 let ref = blobCache.get(img.oid)
198 if (!ref) {
199 // ⚠ Only uploaded when we do not already hold its ref: the bytes are content-addressed, so a
200 // re-upload is idempotent, but it is still a 512 KiB round trip per cycle if done blindly.
201 ref = await bsky.uploadBlob(img.path, img.mimeType)
202 if (ref) blobCache.set(img.oid, ref)
203 }
204 if (ref) record[field] = ref
205 else console.log(' profile ' + field + ' could not be uploaded — omitted from this write')
206 }
207
208 if (sameProfile(record, actual)) { console.log(' profile bsky: already matches'); return }
209 if (!send) { console.log(' profile bsky: would update (' + Object.keys(record).filter((k) => k !== '$type').join(', ') + ')'); return }
210
211 const r = await bsky.putProfile(record)
212 console.log(' profile bsky: ' + (r.sent ? 'UPDATED (' + Object.keys(record).filter((k) => k !== '$type').join(', ') + ')' : 'FAILED — ' + r.error))
213}
214
144215main().catch((err) => { console.error('favela-bridge: ' + ((err && err.message) || err)); process.exit(1) })
lib/bsky.js +75 -0
……@@ -12,6 +12,9 @@
1212import { Client } from 'hyper-svc'
1313import { tidFor } from './tid.js'
1414
15export const PROFILE_COLLECTION = 'app.bsky.actor.profile'
16export const PROFILE_RKEY = 'self'
17
1518export class BskySink {
1619 constructor (sock, opts = {}) {
1720 this.sock = sock || null
……@@ -78,6 +81,78 @@ export class BskySink {
7881 if (client && client.close) { try { await client.close() } catch {} }
7982 }
8083 }
84 // ── the profile ─────────────────────────────────────────────────────────────────────────────
85 //
86 // ⛔ READ BEFORE WRITE. The bsky profile is a singleton at rkey `self` and applyWrite upserts it,
87 // so writing unconditionally on every cycle would append a repo version every fifteen seconds for
88 // a profile nobody changed. Reconciling against the record actually in the repo — rather than a
89 // local note of what we last sent — is also the only version that cannot drift from it.
90
91 /** The `app.bsky.actor.profile` currently in the repo, or null. Never throws. */
92 async getProfile () {
93 if (!this.configured) return null
94 let client = null
95 try {
96 client = await this._open()
97 return await client.call('get-record', { collection: PROFILE_COLLECTION, rkey: PROFILE_RKEY })
98 } catch (err) {
99 // ⛔ null means "absent"; this means "could not ask", and the caller must not treat the two
100 // alike — writing because a read failed would republish on every transient error.
101 this._log('profile-read-failed', { err: (err && err.message) || String(err) })
102 return { unknown: true }
103 } finally {
104 if (client && client.close) { try { await client.close() } catch {} }
105 }
106 }
107
108 /**
109 * Hand hpp a picture BY PATH and return an atproto blob ref, or null.
110 *
111 * ⛔ A PATH, never bytes. Both ends are on one machine — it is a unix socket — and favela-peer
112 * settled this same question the same way. Hex-encoding a picture to hand it to a process that
113 * writes it straight back to disk doubles it in transit, and needs a buffer library this package
114 * would otherwise not depend on: b4a resolves here only from a PARENT node_modules, so declaring
115 * it would have published a tree that could not find it and crashed the installed app at boot.
116 */
117 async uploadBlob (path, mimeType) {
118 if (!this.configured || !path) return null
119 let client = null
120 try {
121 client = await this._open()
122 const r = await client.call('upload-blob', { path, mimeType })
123 if (!r || !r.cid) return null
124 // The atproto blob shape. `size` is the byte count hpp actually stored, not what we sent.
125 return { $type: 'blob', ref: { $link: r.cid }, mimeType: r.mimeType || mimeType, size: r.size }
126 } catch (err) {
127 this._log('blob-upload-failed', { err: (err && err.message) || String(err) })
128 return null
129 } finally {
130 if (client && client.close) { try { await client.close() } catch {} }
131 }
132 }
133
134 /** Write the profile record. `{ sent, error? }`, never rejects. */
135 async putProfile (record) {
136 if (!this.configured) return { sent: false, error: 'no bsky sink configured' }
137 let client = null
138 try {
139 client = await this._open()
140 await client.call('apply-write', {
141 // ⛔ `put`, not `create`: the profile is a singleton and every write after the first is a
142 // replacement. applyWrite routes both to putRecord, so this is naming the act correctly
143 // rather than relying on that.
144 action: 'put', collection: PROFILE_COLLECTION, rkey: PROFILE_RKEY, record
145 })
146 this._log('profile-written', {})
147 return { sent: true }
148 } catch (err) {
149 const msg = (err && err.message) || String(err)
150 this._log('profile-write-failed', { err: msg })
151 return { sent: false, error: msg }
152 } finally {
153 if (client && client.close) { try { await client.close() } catch {} }
154 }
155 }
81156}
82157
83158export default { BskySink }
lib/profile.js +107 -0
……@@ -0,0 +1,107 @@
1// profile.js — what a favela profile becomes on bsky. Pure: no I/O, no clock, no throw.
2//
3// The fediverse got a profile projection from the start (ap-bridge's buildActor reads name, summary,
4// icon and image). bsky never did: favela-bridge projected POSTS and nothing else, so a person could
5// set a display name, a bio, an avatar and a banner, watch all four reach Mastodon, and find their
6// bsky profile still empty with nothing anywhere reporting a failure. There was no bug to find —
7// the projection had simply never been written.
8//
9// ⛔ IT DECIDES, IT DOES NOT UPLOAD. Images are named by oid here and resolved to blob refs by the
10// caller, because uploading is I/O and this stays testable without a repo, a socket or a network.
11//
12// ⛔ AND IT NEVER TRUNCATES. `plan.js` withholds a post rather than cutting it at 300 — publishing
13// something the author did not write, under their name, with nothing to say it was cut. A field is
14// the same: an over-long display name is OMITTED and reported, and the rest of the profile still
15// goes. Dropping one field is recoverable; publishing half a sentence as though it were the whole
16// one is not.
17
18// app.bsky.actor.profile: displayName maxGraphemes 64, description maxGraphemes 256.
19export const MAX_NAME_GRAPHEMES = 64
20export const MAX_DESCRIPTION_GRAPHEMES = 256
21
22// ⛔ png and jpeg ONLY. favela accepts gif and webp too (avatars.js types from magic bytes and
23// refuses only SVG), and bsky's lexicon does not — so an avatar this peer holds happily can be one
24// bsky will not take. Reported rather than converted: re-encoding somebody's picture to fit a
25// destination is a decision about their content, not a projection of it.
26export const BSKY_IMAGE_TYPES = ['image/png', 'image/jpeg']
27
28const graphemesOf = (s) => [...String(s || '')].length
29
30/**
31 * planProfile({ name, bio, avatar, banner }) -> { project, record, images, losses }
32 *
33 * `avatar` and `banner` are `{ oid, mimeType, size }` or null — what the peer holds.
34 * `images` names which oids the caller must resolve to blob refs, keyed by the field they fill.
35 *
36 * TOTAL: never throws. An unreadable profile projects nothing rather than a wrong something.
37 */
38export function planProfile ({ name = null, bio = null, avatar = null, banner = null } = {}) {
39 const losses = []
40 const record = { $type: 'app.bsky.actor.profile' }
41 const images = {}
42
43 const n = typeof name === 'string' ? name.trim() : ''
44 if (n) {
45 if (graphemesOf(n) > MAX_NAME_GRAPHEMES) {
46 losses.push('display name omitted — ' + graphemesOf(n) + ' graphemes against a limit of ' + MAX_NAME_GRAPHEMES)
47 } else {
48 record.displayName = n
49 }
50 }
51
52 const d = typeof bio === 'string' ? bio.trim() : ''
53 if (d) {
54 if (graphemesOf(d) > MAX_DESCRIPTION_GRAPHEMES) {
55 losses.push('description omitted — ' + graphemesOf(d) + ' graphemes against a limit of ' + MAX_DESCRIPTION_GRAPHEMES)
56 } else {
57 record.description = d
58 }
59 }
60
61 for (const [field, img] of [['avatar', avatar], ['banner', banner]]) {
62 if (!img || !img.oid) continue
63 if (!BSKY_IMAGE_TYPES.includes(img.mimeType)) {
64 losses.push(field + ' omitted — bsky takes ' + BSKY_IMAGE_TYPES.join(' or ') + ', this is ' + (img.mimeType || 'untyped'))
65 continue
66 }
67 images[field] = img
68 }
69
70 // ⛔ AN EMPTY RECORD IS NOT A PROFILE. Writing `{$type}` alone would replace whatever is there
71 // with nothing — a projection that erases. If a peer has published no profile, there is nothing
72 // to project and the right act is to leave the destination alone.
73 const project = !!(record.displayName || record.description || Object.keys(images).length)
74 return { project, record, images, losses }
75}
76
77/**
78 * Is the profile we would write already the one that is there?
79 *
80 * ⛔ COMPARED AGAINST THE REPO, never against a local note of what we last sent. A projector that
81 * trusts its own memory writes when it should not and stays silent when it should not — the shape
82 * that let ap-bridge deliver into a follower relationship that existed only in our own file.
83 *
84 * Blob refs compare by their CID: the bytes are content-addressed, so an identical picture yields an
85 * identical ref and a changed one cannot collide with it.
86 */
87export function sameProfile (desired, actual) {
88 if (!desired || !actual) return false
89 const norm = (r) => JSON.stringify({
90 displayName: r.displayName || null,
91 description: r.description || null,
92 avatar: cidOf(r.avatar),
93 banner: cidOf(r.banner)
94 })
95 return norm(desired) === norm(actual)
96}
97
98function cidOf (blob) {
99 if (!blob) return null
100 if (typeof blob === 'string') return blob
101 if (blob.ref && typeof blob.ref === 'object' && blob.ref.$link) return blob.ref.$link
102 if (typeof blob.ref === 'string') return blob.ref
103 if (blob.cid) return blob.cid
104 return null
105}
106
107export default { planProfile, sameProfile }
package.json +4 -4
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-bridge",
3 "version": "0.0.7",
3 "version": "0.0.8",
44 "description": "Projects favela records onto the networks a room may reach. One reader, one reach decision, one projection per network.",
55 "type": "module",
66 "main": "index.js",
……@@ -11,11 +11,11 @@
1111 "test": "brittle-bare test/*.test.js"
1212 },
1313 "dependencies": {
14 "favela-markdown": "file:../favela-markdown",
15 "hyper-svc": "file:../hyper-svc",
1614 "bare-net": "^1.3.0",
1715 "bare-process": "^4.0.0",
18 "favela-identity": "file:../favela-identity"
16 "favela-identity": "file:../favela-identity",
17 "favela-markdown": "file:../favela-markdown",
18 "hyper-svc": "file:../hyper-svc"
1919 },
2020 "devDependencies": {
2121 "brittle": "^3.5.0"
test/profile.test.js +67 -0
……@@ -0,0 +1,67 @@
1// The bsky profile projection. Pure, so all of this runs with no repo, no socket and no network.
2import test from 'brittle'
3import { planProfile, sameProfile, MAX_NAME_GRAPHEMES, MAX_DESCRIPTION_GRAPHEMES } from '../lib/profile.js'
4
5const png = (oid) => ({ oid, mimeType: 'image/png', size: 100 })
6
7test('a full profile projects every field', (t) => {
8 const p = planProfile({ name: 'ash', bio: 'downstairs device', avatar: png('a'), banner: png('b') })
9 t.ok(p.project)
10 t.is(p.record.$type, 'app.bsky.actor.profile')
11 t.is(p.record.displayName, 'ash')
12 t.is(p.record.description, 'downstairs device')
13 t.alike(Object.keys(p.images).sort(), ['avatar', 'banner'], 'both pictures named for upload')
14 t.is(p.losses.length, 0)
15})
16
17// ⛔ The same rule plan.js applies to a post: never publish something the author did not write.
18test('an over-long field is OMITTED and reported, and the rest still goes', (t) => {
19 const p = planProfile({ name: 'x'.repeat(MAX_NAME_GRAPHEMES + 1), bio: 'fine' })
20 t.absent(p.record.displayName, 'the name is not truncated to fit')
21 t.is(p.record.description, 'fine', 'and the rest of the profile is unaffected')
22 t.ok(p.losses.some((l) => l.includes('display name omitted')), 'and the loss is named')
23 t.ok(p.project, 'a profile with one usable field is still worth writing')
24
25 const q = planProfile({ bio: 'y'.repeat(MAX_DESCRIPTION_GRAPHEMES + 1) })
26 t.absent(q.record.description)
27 t.ok(q.losses.some((l) => l.includes('description omitted')))
28})
29
30test('★ an emoji is ONE grapheme, not four bytes', (t) => {
31 const p = planProfile({ name: '🌮'.repeat(MAX_NAME_GRAPHEMES) })
32 t.ok(p.record.displayName, 'counted as graphemes — a byte count would reject this')
33})
34
35// ⛔ favela accepts gif and webp; bsky's lexicon does not.
36test('a picture bsky will not take is omitted rather than converted', (t) => {
37 const p = planProfile({ name: 'ash', avatar: { oid: 'a', mimeType: 'image/webp', size: 10 } })
38 t.absent(p.images.avatar, 'not offered for upload')
39 t.ok(p.losses.some((l) => l.includes('avatar omitted')), 'and said so')
40 t.is(p.record.displayName, 'ash', 'the rest of the profile still projects')
41})
42
43// ⛔ A projection must never erase.
44test('⛔ an empty profile projects NOTHING rather than an empty record', (t) => {
45 const p = planProfile({})
46 t.absent(p.project, 'writing {$type} alone would replace what is there with nothing')
47 const q = planProfile({ name: ' ', bio: '' })
48 t.absent(q.project, 'and whitespace is not content')
49})
50
51// ⛔ Reconciliation compares against the REPO, not a local note of what we last sent.
52test('sameProfile compares blob refs by CID', (t) => {
53 const desired = { displayName: 'ash', avatar: { $type: 'blob', ref: { $link: 'bafk1' }, mimeType: 'image/png', size: 1 } }
54 const actual = { displayName: 'ash', avatar: { $type: 'blob', ref: { $link: 'bafk1' }, mimeType: 'image/png', size: 1 } }
55 t.ok(sameProfile(desired, actual), 'identical content-addressed picture is the same picture')
56
57 const changed = { displayName: 'ash', avatar: { $type: 'blob', ref: { $link: 'bafk2' }, mimeType: 'image/png', size: 1 } }
58 t.absent(sameProfile(desired, changed), 'a different CID is a different picture')
59 t.absent(sameProfile(desired, { displayName: 'ashatron' }), 'a changed name is a change')
60 t.absent(sameProfile(desired, null), 'nothing published yet is not "the same"')
61})
62
63test('adding a banner to a profile that had none is a change', (t) => {
64 const before = { displayName: 'ash' }
65 const after = { displayName: 'ash', banner: { ref: { $link: 'bafk9' } } }
66 t.absent(sameProfile(after, before), '⛔ or the first banner would never be written')
67})