| … | … | @@ -23,6 +23,7 @@ import crypto from 'hypercore-crypto' |
| 23 | 23 | // that from the source text means re-implementing link parsing — including the cases where a `@` |
| 24 | 24 | // inside code fences or a link label is not a mention at all. |
| 25 | 25 | import { strip } from 'favela-markdown/lib/strip.js' |
| 26 | import { NETWORKS } from 'favela-markdown/lib/reach.js' | |
| 26 | 27 | |
| 27 | 28 | // Collections. Bare names, deliberately: reverse-DNS is atproto's convention and this stack is not |
| 28 | 29 | // atproto's. A third party's collection would be namespaced; ours are not. |
| … | … | @@ -80,6 +81,9 @@ export const REPLY = 'reply' |
| 80 | 81 | // ⚠ Which is why the key is ALWAYS rendered next to it. A self-label is a claim, and two feeds may |
| 81 | 82 | // carry the same one. |
| 82 | 83 | export 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. | |
| 86 | export const REACH = 'reach' | |
| 83 | 87 | export const SELF = 'self' |
| 84 | 88 | |
| 85 | 89 | const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex')) |
| … | … | @@ -254,6 +258,56 @@ export class Peer { |
| 254 | 258 | * Publish this feed's profile. Only the fields given are touched — a settings form showing two |
| 255 | 259 | * fields must not erase a third it never rendered. |
| 256 | 260 | */ |
| 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 | ||
| 257 | 311 | async setProfile ({ name, avatar, bio } = {}) { |
| 258 | 312 | await this.feed.update() |
| 259 | 313 | const current = await this.feed.get(PROFILE, SELF) |
| … | … | @@ -75,7 +75,13 @@ const MUTATIONS = new Set(['post', 'follow', 'unfollow', 'like', 'unlike', 'repl |
| 75 | 75 | 'namespace-invite', 'namespace-uninvite', |
| 76 | 76 | // Room ops all sign with this device's credential. |
| 77 | 77 | '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']) | |
| 79 | 85 | |
| 80 | 86 | /** |
| 81 | 87 | * 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) { |
| 307 | 313 | return profiles |
| 308 | 314 | } |
| 309 | 315 | 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 } | |
| 310 | 319 | // Bytes cross the socket as base64 — it is a local UNIX socket carrying a small image, and a |
| 311 | 320 | // second transport for one field would be more moving parts than the field is worth. |
| 312 | 321 | // ── files ─────────────────────────────────────────────────────────────────────────────── |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.35", | |
| 3 | "version": "0.0.36", | |
| 4 | 4 | "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -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 | ||
| 16 | import test from 'brittle' | |
| 17 | import fs from '#node:fs' | |
| 18 | import os from '#node:os' | |
| 19 | import { join } from '#node:path' | |
| 20 | import Corestore from 'corestore' | |
| 21 | import { RecordStore } from '../lib/record-store.js' | |
| 22 | import { Feed } from '../lib/feed-base.js' | |
| 23 | import { Peer } from '../lib/peer.js' | |
| 24 | import { identity } from './helpers.js' | |
| 25 | ||
| 26 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-reach-')) | |
| 27 | ||
| 28 | async 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 | ||
| 38 | test('⛔ 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 | ||
| 45 | test('★ 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 | ||
| 61 | test('⛔ 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 | ||
| 70 | test('⛔ 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 | ||
| 78 | test('★★ 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 | ||
| 94 | test('⚠ 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 | ||
| 102 | test('⛔ 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 | }) |