OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / d2efa9e

0.0.49: a profile can carry a banner
AS2 gives an actor `icon` and `image` — the avatar and the header — and Mastodon renders both. favela's profile record held {name, avatar, bio}, so `image` could never be emitted and every actor advertised no header. hyper-social #67 §7.6 recorded this as "the banner cannot work at all — it needs a new field end to end". This is that field at the source. It reuses the avatar's machinery exactly, because none of it was avatar-specific: `avatars.put` types from magic bytes, refuses SVG, caps at 512 KiB and registers in the overlay. A banner is an oid on the profile record like the avatar is, so the bytes travel with the feed and no second mechanism exists to keep in step. `banner-put` is its own verb rather than `avatar-put {field}`: the two are different acts, and a mistyped field name would silently replace the wrong picture. Blank removes, matching name/avatar/bio — absence asserts nothing, "" asserts empty. Setting one never touches the other, which the test pins in both directions. Tests written first and red for the right reason. One existing test pinned the empty profile's exact shape and legitimately had to widen; that is the contract changing, not a failure being papered over. 344/344. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d2efa9eedd3c8f30eab04a075b80add882a97cd9
by plankatron <fleeky@gmail.com> · 2026-09-09T22:49:09.000Z

Changed files (5) +70 -10

bin/favela.js +11 -3
……@@ -38,7 +38,7 @@ const USAGE = `favela — a p2p feed of markdown records
3838 favela handles [query] every handle in the namespace, or those matching
3939 favela resolve <handle> a handle -> feed, and whether the feed AGREES
4040 favela profile your display name and picture
41 favela set-profile [--name X] [--bio "..."] [--avatar <file>]
41 favela set-profile [--name X] [--bio "..."] [--avatar <file>] [--banner <file>]
4242 favela invite [--ttl <ms>] one-time invite for a browser
4343 favela browsers browsers you have admitted
4444 favela revoke <publicKey> retire a browser
……@@ -266,14 +266,17 @@ async function main () {
266266 case 'set-profile': {
267267 const name = str(argv, 'name')
268268 const avatar = str(argv, 'avatar')
269 // A banner is the wide header image; --avatar is the round one. Both are optional and
270 // independent, and passing neither with --name/--bio leaves both alone.
271 const banner = str(argv, 'banner')
269272 // ⛔ THE BIO HAD NO WAY IN. `peer.setProfile` has taken `{name, avatar, bio}` since it was
270273 // written and this caller only ever passed the first two, so the supported surface could not
271274 // set a bio at all — the profile record's body stayed empty on every node, and every
272275 // projection of it downstream (the AP actor's `summary`, the surface's byline) was empty for
273276 // a reason that looked like the projection was broken. It was never reached.
274277 const bio = str(argv, 'bio')
275 if (name === undefined && avatar === undefined && bio === undefined) {
276 throw new Error('set-profile needs --name, --bio and/or --avatar <file>')
278 if (name === undefined && avatar === undefined && bio === undefined && banner === undefined) {
279 throw new Error('set-profile needs --name, --bio, --avatar <file> and/or --banner <file>')
277280 }
278281 // One call, so a name-and-bio change is ONE record write and not two — the record is
279282 // overwritten in place, and two writes would briefly publish a profile with only half of it.
……@@ -294,6 +297,11 @@ async function main () {
294297 const r = await call('avatar-put', { path: avatar })
295298 console.error('avatar ' + r.mimeType + ' ' + Math.round(r.size / 1024) + ' KiB -> ' + r.oid.slice(0, 16) + '…')
296299 }
300 if (banner !== undefined) {
301 if (!banner) throw new Error('--banner needs a file path')
302 const r = await call('banner-put', { path: banner })
303 console.error('banner ' + r.mimeType + ' ' + Math.round(r.size / 1024) + ' KiB -> ' + r.oid.slice(0, 16) + '…')
304 }
297305 return console.log(JSON.stringify(await call('profile'), null, 2))
298306 }
299307 case 'invite': {
lib/peer.js +13 -4
……@@ -308,7 +308,7 @@ export class Peer {
308308 return this.reach()
309309 }
310310
311 async setProfile ({ name, avatar, bio } = {}) {
311 async setProfile ({ name, avatar, bio, banner } = {}) {
312312 await this.feed.update()
313313 const current = await this.feed.get(PROFILE, SELF)
314314 const fm = { ...((current && current.frontmatter) || {}) }
……@@ -326,6 +326,14 @@ export class Peer {
326326 if (avatar) fm.avatar = String(avatar)
327327 else delete fm.avatar
328328 }
329 // ⛔ A SECOND PICTURE, not a variant of the first. AS2 has carried both `icon` and `image`
330 // since forever and Mastodon renders both, so an identity that can only hold one is missing a
331 // field the destination already had (hyper-social #67 §7.6). Same shape as the avatar — an oid,
332 // bytes in the overlay — because nothing about a banner needs a second mechanism.
333 if (banner !== undefined) {
334 if (banner) fm.banner = String(banner)
335 else delete fm.banner
336 }
329337 // The record is mutable and overwritten in place, so createdAt keeps meaning creation.
330338 if (typeof fm.createdAt !== 'string') fm.createdAt = new Date().toISOString()
331339
……@@ -349,12 +357,12 @@ export class Peer {
349357 }
350358
351359 await this.feed.put(PROFILE, SELF, { frontmatter: fm, body })
352 return { name: fm.name || null, avatar: fm.avatar || null, bio: body.trim() || null }
360 return { name: fm.name || null, avatar: fm.avatar || null, banner: fm.banner || null, bio: body.trim() || null }
353361 }
354362
355363 /** This feed's own profile. */
356364 async profile () {
357 return (await this.profileOf(this.feed.key)) || { name: null, avatar: null, bio: null }
365 return (await this.profileOf(this.feed.key)) || { name: null, avatar: null, banner: null, bio: null }
358366 }
359367
360368 /**
……@@ -371,7 +379,7 @@ export class Peer {
371379 const rec = await feed.get(PROFILE, SELF)
372380 if (!rec) return null
373381 const fm = rec.frontmatter || {}
374 return { name: fm.name || null, avatar: fm.avatar || null, bio: (rec.body || '').trim() || null }
382 return { name: fm.name || null, avatar: fm.avatar || null, banner: fm.banner || null, bio: (rec.body || '').trim() || null }
375383 }
376384 return null
377385 }
……@@ -401,6 +409,7 @@ export class Peer {
401409 out[author] = {
402410 name: fm.name || null,
403411 avatar: fm.avatar || null,
412 banner: fm.banner || null,
404413 bio: (rec.body || '').trim() || null,
405414 root: (founder && founder.identityRoot) || null
406415 }
lib/rpc.js +10 -0
……@@ -336,6 +336,16 @@ export async function dispatch (node, cmd, a) {
336336 await peer.update()
337337 return r
338338 }
339 // ⛔ A SECOND PICTURE, stored by the SAME machinery. `avatars.put` types from magic bytes and
340 // registers in the overlay — none of which is avatar-specific — so a banner needs a verb, not a
341 // mechanism. It is a separate verb rather than `avatar-put {field}` because the two are
342 // different acts and a mistyped field name would silently replace the wrong picture.
343 case 'banner-put': {
344 const r = await node.avatars.put(a.path)
345 await peer.setProfile({ banner: r.oid })
346 await peer.update()
347 return r
348 }
339349 case 'avatar-get': {
340350 const r = await node.avatars.get(a.oid)
341351 return r || { found: false }
package.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.48",
4 "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
3 "version": "0.0.49",
4 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
77 "test": "brittle-bare test/*.test.js"
test/profile.test.js +34 -1
……@@ -43,7 +43,7 @@ async function mkPeer () {
4343test('a fresh feed publishes no profile at all', async (t) => {
4444 const b = await mkPeer()
4545 t.teardown(() => b.close())
46 t.alike(await b.profile(), { name: null, avatar: null, bio: null }, 'nothing published yet')
46 t.alike(await b.profile(), { name: null, avatar: null, banner: null, bio: null }, 'nothing published yet')
4747 t.alike(await b.profiles(), {},
4848 'and ABSENT from the map — "has not published one" and "published an empty one" differ')
4949})
……@@ -286,3 +286,36 @@ test('★ profiles carry the identity ROOT — what a mention has to name', asyn
286286 t.is(row.root, founder.identityRoot, 'the root the feed was founded under')
287287 t.is(row.root.length, 64)
288288})
289
290// ── the banner ────────────────────────────────────────────────────────────────────────────────
291//
292// ⛔ A BANNER IS A SECOND PICTURE, not a variant of the first. AS2 gives an actor `icon` (the
293// avatar) and `image` (the header), and Mastodon renders both — so a profile that can only carry
294// one of them is missing a field the destination has always had. `getProfile` could not emit
295// `image` because nothing upstream ever stored one: hyper-social #67 §7.6, "the banner cannot work
296// at all — it needs a new field end to end".
297//
298// It reuses the avatar's machinery exactly: an oid on the profile record, bytes in the overlay,
299// typed from magic bytes. Nothing about a banner needs a second mechanism.
300test('a banner is stored alongside the avatar and neither displaces the other', async (t) => {
301 const p = await mkPeer()
302
303 await p.setProfile({ name: 'ash', avatar: 'a'.repeat(64) })
304 const withBanner = await p.setProfile({ banner: 'b'.repeat(64) })
305
306 t.is(withBanner.avatar, 'a'.repeat(64), 'setting a banner leaves the avatar alone')
307 t.is(withBanner.banner, 'b'.repeat(64), 'and the banner is returned')
308 t.is(withBanner.name, 'ash', 'and the name survives both')
309
310 // ⚠ Blank REMOVES, matching how name/avatar/bio already behave. Absence asserts nothing.
311 const cleared = await p.setProfile({ banner: '' })
312 t.absent(cleared.banner, 'a blank banner removes it')
313 t.is(cleared.avatar, 'a'.repeat(64), 'and still does not touch the avatar')
314})
315
316test('the banner travels in the published record, not a local registry', async (t) => {
317 const p = await mkPeer()
318 await p.setProfile({ banner: 'c'.repeat(64) })
319 const rec = await p.feed.get('profile', 'self')
320 t.is(rec.frontmatter.banner, 'c'.repeat(64), '⛔ in the FEED — a registry does not replicate')
321})