| … | … | @@ -20,6 +20,7 @@ import fs from 'bare-fs' |
| 20 | 20 | import path from 'bare-path' |
| 21 | 21 | import b4a from 'b4a' |
| 22 | 22 | import { createUtf8Decoder } from './utf8.js' |
| 23 | import { rosterEnv } from './roster.js' | |
| 23 | 24 | |
| 24 | 25 | const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null |
| 25 | 26 | |
| … | … | @@ -238,6 +239,21 @@ async function boot () { |
| 238 | 239 | const ctlSock = state.hpp && state.hpp.nodes && state.hpp.nodes[0] && state.hpp.nodes[0].uds |
| 239 | 240 | if (ctlSock) process.env.FAVELA_HPP_SOCK = ctlSock |
| 240 | 241 | else log('hpp exposed no control socket — favela-web will refuse writes (reads unaffected)') |
| 242 | // THE READ PATH, and it was never told either. favela-web SUBSCRIBES to its roster: per DID it | |
| 243 | // resolves a verifyKey, joins the topic, and tails the freshest verified co-owner's firehose, | |
| 244 | // applying each #commit incrementally (bin/favela-web.js:565-687). It reads FAVELA_ROSTER at module | |
| 245 | // top level, so this must be set BEFORE the import below — and it never was. The phone therefore | |
| 246 | // booted with `joined 0 DID topic(s)` and an index that could only contain what was already in it: | |
| 247 | // records authored on another co-owner of our OWN DID reached the overlay store (pointers converged, | |
| 248 | // bytes fetched) and never reached the index every screen reads. Measured 2026-08-18 — three posts, | |
| 249 | // 404 on the handset two and a half hours and two boots later, with nothing in any log to say so. | |
| 250 | // | |
| 251 | // Hosted DIDs are in the roster because we INDEX them, not because we sign for them — the signing set | |
| 252 | // is the one control socket above. addPeer's coHosted branch baselines a hosted DID from the shared | |
| 253 | // store and tails from the current end, so this costs no refetch of what is already on disk. | |
| 254 | const roster = rosterEnv({ hosted: hostedDids(), tracked: readTracked() }) | |
| 255 | if (roster) process.env.FAVELA_ROSTER = roster | |
| 256 | log('favela-web roster', { dids: roster ? roster.split(',').length : 0 }) | |
| 241 | 257 | fs.mkdirSync(process.env.FAVELA_STORE_ROOT, { recursive: true }) |
| 242 | 258 | await import('favela-web/bin/favela-web.js') |
| 243 | 259 | state.favelaUrl = 'http://127.0.0.1:' + FAVELA_PORT |
| … | … | @@ -326,6 +342,11 @@ async function p2pSource () { |
| 326 | 342 | |
| 327 | 343 | const TRACKED = () => path.join(dirs.root, 'tracked-dids.json') |
| 328 | 344 | function readTracked () { try { return JSON.parse(fs.readFileSync(TRACKED(), 'utf8')) } catch { return [] } } |
| 345 | // EVERY hosted DID, not nodes[0]. A handset can house two accounts — reads are already multi-DID (one | |
| 346 | // index, DID-keyed, `viewerDid` per query), so half-subscribing the second one would show up as a | |
| 347 | // timeline quietly missing a person rather than as an error. (Writes are still single-DID: one control | |
| 348 | // socket, see favela-web/lib/writer.js:283. Roster membership carries no authority.) | |
| 349 | function hostedDids () { return ((state.hpp && state.hpp.nodes) || []).map((n) => n && n.did).filter(Boolean) } | |
| 329 | 350 | function writeTracked (list) { try { fs.writeFileSync(TRACKED(), JSON.stringify(list, null, 2) + '\n') } catch {} } |
| 330 | 351 | |
| 331 | 352 | const handlers = { |
| … | … | @@ -0,0 +1,72 @@ |
| 1 | // Which DIDs this node SUBSCRIBES to — the value favela-web reads as FAVELA_ROSTER. | |
| 2 | // | |
| 3 | // ⛔ ROSTER ≠ SIGNING SET. The roster is what this node INDEXES: it joins each DID's topic, resolves its | |
| 4 | // verifyKey, and follows the freshest verified co-owner's firehose (favela-web/bin/favela-web.js:565-687). | |
| 5 | // Every entry is read-only and independently verified. What this node can SIGN for is a different, much | |
| 6 | // smaller thing — one hpp control socket (favela-web/lib/writer.js:283, "favela's write path is | |
| 7 | // single-DID (the UDS socket picks the device)"). Our own hosted DIDs appear here because we index them, | |
| 8 | // NOT because we hold their keys, and nothing downstream may read authority out of roster membership. | |
| 9 | // | |
| 10 | // ⛔ WHY THIS EXISTS AT ALL. favela-web captures the roster at module top level, so it is frozen at | |
| 11 | // import. The phone never set it, so the in-process favela-web joined zero topics and its index — the one | |
| 12 | // every screen reads — could only ever contain what was already in it. See test/roster.test.mjs, whose | |
| 13 | // first test replicates favela-web's parser verbatim against the value we used to hand it. | |
| 14 | // | |
| 15 | // Pure and IO-free on purpose: the phone's boot is expensive to stand up, and the part worth testing is | |
| 16 | // this decision, not the boot. | |
| 17 | ||
| 18 | // A DID we are willing to turn into a topic join and a verifyKey resolution. | |
| 19 | // | |
| 20 | // The comma exclusion is load-bearing rather than cosmetic: the env format is comma-separated, so a DID | |
| 21 | // carrying a comma would arrive at favela-web as TWO entries, each garbage. Whitespace is excluded for | |
| 22 | // the same reason the parser trims — an entry that only survives trimming is an entry we should not have | |
| 23 | // emitted. `did:` alone, or any method with no identifier, is not a DID and must not reach | |
| 24 | // resolveVerifyKey, where it would cost a network round-trip to learn what a regex already knows. | |
| 25 | const DID = /^did:[a-z][a-z0-9]*:[A-Za-z0-9._%:-]+$/ | |
| 26 | ||
| 27 | function usable (v) { | |
| 28 | return typeof v === 'string' && DID.test(v) | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * The subscription set, in the order favela-web will open it. | |
| 33 | * | |
| 34 | * Hosted first, deliberately: `addPeer()` runs in roster order, and `apProjected()` asks about | |
| 35 | * `ROSTER[0]` specifically (bin/favela-web.js:351-352) — so a tracked stranger sorting ahead of our own | |
| 36 | * DID would quietly change which actor the ActivityPub side reports on. | |
| 37 | * | |
| 38 | * ALL hosted DIDs, not the first: reads are already multi-DID (one appview, DID-keyed, `viewerDid` per | |
| 39 | * query), so a phone housing two accounts is a read-path reality today. Taking `nodes[0]` here would | |
| 40 | * half-subscribe the second account — visible as a timeline that is simply missing one person's posts, | |
| 41 | * with nothing in the log to say so. | |
| 42 | * | |
| 43 | * @param {object} opts | |
| 44 | * @param {string[]} [opts.hosted] DIDs whose repos this node co-owns (hpp nodes) | |
| 45 | * @param {string[]} [opts.tracked] DIDs explicitly tracked by the user (tracked-dids.json) | |
| 46 | * @param {string[]} [opts.following] DIDs the viewer follows, from the index | |
| 47 | * @returns {string[]} deduped, ordered, every entry safe to hand to favela-web | |
| 48 | */ | |
| 49 | export function rosterFor ({ hosted = [], tracked = [], following = [] } = {}) { | |
| 50 | const out = [] | |
| 51 | const seen = new Set() | |
| 52 | for (const list of [hosted, tracked, following]) { | |
| 53 | if (!Array.isArray(list)) continue | |
| 54 | for (const did of list) { | |
| 55 | if (!usable(did) || seen.has(did)) continue | |
| 56 | seen.add(did) | |
| 57 | out.push(did) | |
| 58 | } | |
| 59 | } | |
| 60 | return out | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * The same set as the env string favela-web parses. | |
| 65 | * | |
| 66 | * Empty means empty: `String(undefined)` is the literal 'undefined', which favela-web would happily take | |
| 67 | * for a DID and then fail to resolve, so a node with nothing to subscribe to must produce '' — the same | |
| 68 | * silence as before, rather than one fabricated peer. | |
| 69 | */ | |
| 70 | export function rosterEnv (opts) { | |
| 71 | return rosterFor(opts).join(',') | |
| 72 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-android", |
| 3 | "version": "0.0.2", | |
| 3 | "version": "0.0.3", | |
| 4 | 4 | "description": "favela-android — a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.", |
| 5 | 5 | "private": true, |
| 6 | 6 | "type": "module", |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons", |
| 12 | "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/describe.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs", | |
| 12 | "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs", | |
| 13 | 13 | "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\"", |
| 14 | 14 | "gen:tree": "bare scripts/gen-tree-manifest.mjs" |
| 15 | 15 | }, |
| … | … | @@ -0,0 +1,81 @@ |
| 1 | // Which DIDs the phone's in-process favela-web subscribes to. | |
| 2 | // | |
| 3 | // ⛔ THE CONTROL BELOW IS THE BUG. favela-web reads its roster at MODULE TOP LEVEL: | |
| 4 | // | |
| 5 | // const ROSTER = (process.env.FAVELA_ROSTER || '').split(',').map((s) => s.trim()).filter(Boolean) | |
| 6 | // | |
| 7 | // (bin/favela-web.js:152). `prepareEnv()` never set FAVELA_ROSTER, so on the phone that expression | |
| 8 | // evaluated to `[]`, `rosterDids` was empty, `addPeer()` was never called, and the boot logged | |
| 9 | // | |
| 10 | // [favela] joined 0 DID topic(s); indexing over P2P... | |
| 11 | // | |
| 12 | // Nothing was wired wrong and nothing threw. The phone simply subscribed to no one, so every record | |
| 13 | // authored anywhere else — including on another co-owner of ITS OWN DID — reached its overlay store and | |
| 14 | // never reached the index every screen reads. Measured 2026-08-18: three posts, pointers converged | |
| 15 | // (checked 183→184→185, bytes fetched), still 404 on the phone two and a half hours and two boots later. | |
| 16 | // | |
| 17 | // So the first test replicates favela-web's parser VERBATIM against the value the phone used to hand it, | |
| 18 | // pinning the defect; the rest assert the roster that replaces it. | |
| 19 | // | |
| 20 | // ⛔ AND THE ROSTER IS NOT THE SIGNING SET. A roster is what this node INDEXES (many DIDs, read-only, | |
| 21 | // verified over the firehose). What it SIGNS for is one control socket (lib/writer.js:283). Conflating | |
| 22 | // them is how a write lands in someone else's repo, so `hosted` DIDs are in the roster because we index | |
| 23 | // them, not because we sign for them. | |
| 24 | import test from 'brittle' | |
| 25 | import { rosterFor, rosterEnv } from '../lib/roster.js' | |
| 26 | ||
| 27 | const WJ62 = 'did:plc:wj62ebbusln6gxo5azo6ogzo' | |
| 28 | const E2JEV = 'did:plc:e2jevhuurrhlm36ufk7fklpg' | |
| 29 | const WQNW = 'did:plc:wqnwulql7ndcexample00000' | |
| 30 | ||
| 31 | // favela-web/bin/favela-web.js:152, copied byte-for-byte. If favela-web ever changes how it parses the | |
| 32 | // env, this test fails and tells us — which is the point of pinning the CONSUMER's parser rather than | |
| 33 | // asserting our own idea of the format. | |
| 34 | const parseAsFavelaWebDoes = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean) | |
| 35 | ||
| 36 | test('control: what the phone used to hand favela-web parsed to nobody', (t) => { | |
| 37 | t.alike(parseAsFavelaWebDoes(undefined), [], 'unset FAVELA_ROSTER -> no DIDs -> joined 0 DID topic(s)') | |
| 38 | t.alike(parseAsFavelaWebDoes(''), [], 'empty string is the same silence') | |
| 39 | }) | |
| 40 | ||
| 41 | test('the roster round-trips through favela-web own parser', (t) => { | |
| 42 | const env = rosterEnv({ hosted: [WJ62], tracked: [E2JEV] }) | |
| 43 | t.alike(parseAsFavelaWebDoes(env), [WJ62, E2JEV], 'what we set is exactly what favela-web reads back') | |
| 44 | }) | |
| 45 | ||
| 46 | test('hosted DIDs come first', (t) => { | |
| 47 | // addPeer() runs in order and the first roster entry is also what apProjected() asks about | |
| 48 | // (bin/favela-web.js:351-352), so a tracked stranger must not displace our own DID. | |
| 49 | t.alike(rosterFor({ hosted: [WJ62], tracked: [E2JEV, WQNW] }), [WJ62, E2JEV, WQNW]) | |
| 50 | }) | |
| 51 | ||
| 52 | test('every hosted DID is carried, not just the first', (t) => { | |
| 53 | // Two accounts on one handset. Reads are already multi-DID; taking nodes[0] here would silently | |
| 54 | // half-subscribe the second user. | |
| 55 | t.alike(rosterFor({ hosted: [WJ62, E2JEV] }), [WJ62, E2JEV]) | |
| 56 | }) | |
| 57 | ||
| 58 | test('deduped across sources', (t) => { | |
| 59 | // A hosted DID you also follow, or a tracked DID you later co-own. addPeer() dedups by `byDid`, but a | |
| 60 | // duplicate here would still be a lie in the logged topic count. | |
| 61 | t.alike(rosterFor({ hosted: [WJ62], tracked: [WJ62], following: [WJ62, E2JEV] }), [WJ62, E2JEV]) | |
| 62 | }) | |
| 63 | ||
| 64 | test('junk is dropped rather than passed through', (t) => { | |
| 65 | // tracked-dids.json is a file on disk that a crash can truncate; `following` comes off the index. | |
| 66 | // A malformed entry must not become a topic join or a verifyKey resolution attempt. | |
| 67 | t.alike(rosterFor({ hosted: [WJ62], tracked: ['', ' ', null, undefined, 42, 'not-a-did', 'did:'] }), [WJ62]) | |
| 68 | }) | |
| 69 | ||
| 70 | test('a comma inside a DID can never split one entry into two', (t) => { | |
| 71 | // The env format is comma-separated, so a DID containing a comma would silently become two bogus | |
| 72 | // entries in favela-web. Nothing should be able to smuggle one through. | |
| 73 | t.alike(rosterFor({ tracked: ['did:plc:aaa,did:plc:bbb'] }), [], 'rejected, not split') | |
| 74 | }) | |
| 75 | ||
| 76 | test('an empty roster yields an empty string, not the word undefined', (t) => { | |
| 77 | // `String(undefined)` is 'undefined', which would parse to one garbage DID. A node with nothing to | |
| 78 | // subscribe to must produce the same silence as before, not a fake peer. | |
| 79 | t.is(rosterEnv({}), '') | |
| 80 | t.alike(parseAsFavelaWebDoes(rosterEnv({})), []) | |
| 81 | }) |