OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 7e05a44

0.0.36: which networks this identity publishes to
A preference in the feed, like a profile, so it follows the identity to every device rather than living on whichever machine set it. Stores the designated credential alongside (identity.md §5.1) — with none, a projection that needs one is withheld rather than published under a guess. ⛔ OFF BY DEFAULT AND ABSENCE FAILS CLOSED. No record means publish nowhere. Reading a missing value as 'everything' is the one mistake here that cannot be taken back. ⛔ set-reach is a MUTATION, so it requires an attested device — the same gate as posting. set-profile is not: a self-label asserts nothing, while turning on a network puts words under this identity somewhere permanent. A browser is a controller, not a device. ⚠ The peer still knows no projection: it names networks (favela-markdown already defines them) and holds no schema, signer or wire format. boundary.test.js passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7e05a44228a5ae3c3e388cdf10594cdbc1b41f47
by plankatron <fleeky@gmail.com> · 2026-09-08T01:03:11.000Z

Changed files (4) +176 -2

lib/peer.js +54 -0
……@@ -23,6 +23,7 @@ import crypto from 'hypercore-crypto'
2323// that from the source text means re-implementing link parsing — including the cases where a `@`
2424// inside code fences or a link label is not a mention at all.
2525import { strip } from 'favela-markdown/lib/strip.js'
26import { NETWORKS } from 'favela-markdown/lib/reach.js'
2627
2728// Collections. Bare names, deliberately: reverse-DNS is atproto's convention and this stack is not
2829// atproto's. A third party's collection would be namespaced; ours are not.
……@@ -80,6 +81,9 @@ export const REPLY = 'reply'
8081// ⚠ Which is why the key is ALWAYS rendered next to it. A self-label is a claim, and two feeds may
8182// carry the same one.
8283export const PROFILE = 'profile'
84// Which networks this identity publishes to. A PREFERENCE, in the feed, so it follows the identity
85// to every device rather than living on whichever machine happened to set it.
86export const REACH = 'reach'
8387export const SELF = 'self'
8488
8589const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
……@@ -254,6 +258,56 @@ export class Peer {
254258 * Publish this feed's profile. Only the fields given are touched — a settings form showing two
255259 * fields must not erase a third it never rendered.
256260 */
261 /**
262 * Which networks this identity publishes to, and under which identifier.
263 *
264 * ⛔ OFF BY DEFAULT, AND ABSENCE FAILS CLOSED. No record means `[]` — publish nowhere. Reading a
265 * missing value as "everything" is the one mistake here that cannot be taken back, because the
266 * result is words under someone's name on a network that does not forget.
267 *
268 * ⚠ This is a preference, NOT a bridge. The peer names networks — `favela-markdown/lib/reach.js`
269 * defines them and this file already depends on it — and holds nothing that knows what any of
270 * them are: no schema, no signer, no wire format. Whatever reads this preference and acts on it
271 * is a separate component, which is what `test/boundary.test.js` exists to keep true.
272 */
273 async reach () {
274 await this.feed.update()
275 const rec = await this.feed.get(REACH, SELF)
276 const fm = (rec && rec.frontmatter) || {}
277 return {
278 networks: Array.isArray(fm.networks) ? fm.networks : [],
279 // identity.md §5.1 — the DESIGNATED credential. With none, a projection that needs one is
280 // withheld rather than published under a guess.
281 credential: fm.credential || null
282 }
283 }
284
285 /** Merges: a write naming only `networks` leaves the credential alone, and the reverse. */
286 async setReach ({ networks, credential } = {}) {
287 await this.feed.update()
288 const current = await this.reach()
289 const fm = { networks: current.networks, credential: current.credential }
290
291 if (networks !== undefined) {
292 if (!Array.isArray(networks)) throw new Error('setReach: networks must be an array')
293 for (const n of networks) {
294 // ⛔ Refused, not silently dropped. A name nothing can resolve to a bridge would sit in the
295 // feed looking enabled, and the reader would have no way to tell it apart from one that works.
296 if (n === 'favela') {
297 throw new Error('setReach: favela is the substrate, not a destination — it cannot be switched on or off')
298 }
299 if (!NETWORKS.includes(n)) throw new Error('setReach: unknown network "' + String(n) + '"')
300 }
301 fm.networks = [...new Set(networks)]
302 }
303 if (credential !== undefined) {
304 fm.credential = credential ? String(credential).slice(0, 256) : null
305 }
306 if (!fm.credential) delete fm.credential
307 await this.feed.put(REACH, SELF, { frontmatter: fm, body: '' })
308 return this.reach()
309 }
310
257311 async setProfile ({ name, avatar, bio } = {}) {
258312 await this.feed.update()
259313 const current = await this.feed.get(PROFILE, SELF)
lib/rpc.js +10 -1
……@@ -75,7 +75,13 @@ const MUTATIONS = new Set(['post', 'follow', 'unfollow', 'like', 'unlike', 'repl
7575 'namespace-invite', 'namespace-uninvite',
7676 // Room ops all sign with this device's credential.
7777 'room-create', 'room-post', 'room-config', 'room-add', 'room-role', 'room-remove', 'room-admit',
78 'room-request', 'room-list', 'room-delist', 'dm'])
78 'room-request', 'room-list', 'room-delist', 'dm',
79 // ⛔ Changing WHERE this identity publishes is an identity act, not a display preference.
80 // `set-profile` is not here because a self-label asserts nothing; turning on a network puts words
81 // under this identity somewhere that does not forget, so it requires an ATTESTED device — the
82 // same gate as posting. A browser cannot do it without one, which is the whole point of the
83 // browser being a controller rather than a device (see browsers.js).
84 'set-reach'])
7985
8086/**
8187 * The room, or a refusal that says which of the two possible things is wrong.
……@@ -307,6 +313,9 @@ export async function dispatch (node, cmd, a) {
307313 return profiles
308314 }
309315 case 'set-profile': { const r = await peer.setProfile(a); await peer.update(); return r }
316 // Which networks this identity publishes to. ⚠ A preference, not a bridge — see peer.reach().
317 case 'reach': return peer.reach()
318 case 'set-reach': { const r = await peer.setReach(a); await peer.update(); return r }
310319 // Bytes cross the socket as base64 — it is a local UNIX socket carrying a small image, and a
311320 // second transport for one field would be more moving parts than the field is worth.
312321 // ── files ───────────────────────────────────────────────────────────────────────────────
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.35",
3 "version": "0.0.36",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/reach.test.js +111 -0
……@@ -0,0 +1,111 @@
1// ⛔ WHICH NETWORKS THIS IDENTITY PUBLISHES TO — a preference, stored in its own feed.
2//
3// ⚠ NOT a bridge, and not knowledge of one. `favela-markdown/lib/reach.js` defines
4// NETWORKS = ['favela', 'bsky', 'fediverse'] and favela-peer already depends on it, so these are
5// favela's OWN network names. What the peer must never hold is the projection — the schemas, the
6// signing, the wire format — and it holds none of that. `boundary.test.js` is the arbiter and it
7// still passes.
8//
9// ⛔ IN THE FEED, not on disk. A person who turns on bsky publishing on their laptop has said
10// something about their identity, not about that laptop, so it has to replicate to their phone the
11// way a profile does. It is the same shape as setProfile for the same reason.
12//
13// ⛔ OFF BY DEFAULT, AND OFF MEANS OFF. An absent record must never read as "everything enabled":
14// publication is the one setting where a missing value has to fail closed.
15
16import test from 'brittle'
17import fs from '#node:fs'
18import os from '#node:os'
19import { join } from '#node:path'
20import Corestore from 'corestore'
21import { RecordStore } from '../lib/record-store.js'
22import { Feed } from '../lib/feed-base.js'
23import { Peer } from '../lib/peer.js'
24import { identity } from './helpers.js'
25
26const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-reach-'))
27
28async function mkPeer () {
29 const id = await identity()
30 const d = await id.device()
31 const f = new Feed(new Corestore(tmp()), null, { store: new RecordStore(tmp()) })
32 await f.ready()
33 await f.addDevice(d.bind(f.deviceKey))
34 await f.update()
35 return new Peer(f).ready()
36}
37
38test('⛔ a node publishes NOWHERE until it is told to', async (t) => {
39 const p = await mkPeer()
40 t.teardown(() => p.close())
41 const r = await p.reach()
42 t.alike(r.networks, [], 'no record means no publication, not all publication')
43})
44
45test('★ turning a network on, and off again', async (t) => {
46 const p = await mkPeer()
47 t.teardown(() => p.close())
48
49 await p.setReach({ networks: ['bsky'] })
50 t.alike((await p.reach()).networks, ['bsky'])
51
52 await p.setReach({ networks: ['bsky', 'fediverse'] })
53 t.alike((await p.reach()).networks.sort(), ['bsky', 'fediverse'])
54
55 // ⛔ An empty list is a real answer — "publish nowhere" — and must be storable. If it were treated
56 // as "unset" there would be no way to turn everything off once anything was on.
57 await p.setReach({ networks: [] })
58 t.alike((await p.reach()).networks, [])
59})
60
61test('⛔ an unknown network is REFUSED, not stored', async (t) => {
62 // Storing one would put a name in the feed that nothing can resolve to a bridge, and it would sit
63 // there looking enabled.
64 const p = await mkPeer()
65 t.teardown(() => p.close())
66 await t.exception(() => p.setReach({ networks: ['bsky', 'twitter'] }), /twitter|network/i)
67 t.alike((await p.reach()).networks, [], 'and nothing was written')
68})
69
70test('⛔ favela cannot be switched off — it is the substrate, not a destination', async (t) => {
71 // reach.js §5.4: "local is the substrate; there is no way to opt out of it". Accepting it here
72 // would imply a post could exist without existing locally.
73 const p = await mkPeer()
74 t.teardown(() => p.close())
75 await t.exception(() => p.setReach({ networks: ['favela'] }), /favela|substrate/i)
76})
77
78test('★★ the designated credential travels with it', async (t) => {
79 // identity.md §5.1: a user has at most ONE credential marked for atproto, and with none there is
80 // no projection. The peer stores WHICH identifier is designated; it does not know what the
81 // identifier means or hold anything that can sign with it.
82 const p = await mkPeer()
83 t.teardown(() => p.close())
84 await p.setReach({ networks: ['bsky'], credential: 'did:example:abc' })
85 const r = await p.reach()
86 t.is(r.credential, 'did:example:abc')
87
88 // Clearing it is how you stop projecting under an identifier without forgetting the preference.
89 await p.setReach({ credential: null })
90 t.is((await p.reach()).credential, null)
91 t.alike((await p.reach()).networks, ['bsky'], 'and the networks are untouched')
92})
93
94test('⚠ setReach merges — setting one field does not clear the other', async (t) => {
95 const p = await mkPeer()
96 t.teardown(() => p.close())
97 await p.setReach({ networks: ['bsky'], credential: 'did:example:abc' })
98 await p.setReach({ networks: ['fediverse'] })
99 t.is((await p.reach()).credential, 'did:example:abc', 'the credential survived a networks-only write')
100})
101
102test('⛔ set-reach requires an ATTESTED device, like posting does', async (t) => {
103 // Turning on a network publishes under this identity. That is an identity act, so it sits behind
104 // the same gate as a post — not behind the one that lets a browser change a display name.
105 const { dispatch } = await import('../lib/rpc.js')
106 const src = fs.readFileSync(new URL('../lib/rpc.js', import.meta.url).pathname, 'utf8')
107 const set = src.slice(src.indexOf('const MUTATIONS'), src.indexOf('async function requireLinked'))
108 t.ok(set.includes("'set-reach'"), 'set-reach is a mutation')
109 t.absent(set.includes("'set-profile'"), 'set-profile is not — a self-label asserts nothing')
110 t.ok(typeof dispatch === 'function')
111})