OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / dc8e0b2

0.0.56: a fresh node joins the fleet namespace instead of founding its own
Without a default, a new install FOUNDS its own namespace and resolves handles in a world containing only itself — so it can join the swarm, hold the right feeds, and still find nobody by name. That is what the handset did, and it read as a broken network rather than an empty registry. ⚠ TEMPORARY, by request, and it hardcodes ONE fleet's registry into every install. `namespace.js` permits exactly this and states the limit: "Shipping a default namespace makes us a convenient starting point, never an authoritative one… So the key is an option." NAMING.md §8 #2 is the rule it must not break — "a default that cannot be changed is a root" — so FAVELA_NAMESPACE overrides it. ⛔ AND `FAVELA_NAMESPACE=own` MUST EXIST. A default that cannot be declined does not merely inconvenience someone who wants their own world: it removes the ability to be an ADMIN of anything, because admin rights come from founding. test/namespace-join.test.js asserts precisely that and caught it. ⛔ TWO REAL BUGS, both found by making the default the default: 1. `_foundNames` tried to found a namespace this node had merely JOINED, failing with "Not writable" from autobee's BACKGROUND drain — which the try/catch around `found()` cannot catch, because the rejection never reaches the awaited path. It surfaced as an uncaught error that took `ready()` down. The existing `founder()` guard is insufficient: a node that has just joined has replicated nothing, sees no founder, and concludes it must found. 2. Env is process-wide and brittle loads every file before running, so a module-level `FAVELA_NAMESPACE` assignment is racy with other files' tests. Set where the node is constructed instead. ⚠ The key was taken from the RUNNING daemon, not from `~/.favela/namespace.json` — that is a stale storage root, and reading it is how an earlier audit today concluded the fleet was fragmented when it was not. 882/882 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dc8e0b219f0763b642a226fef87c60a9c484bd7b
by plankatron <fleeky@gmail.com> · 2026-09-10T22:02:55.000Z

Changed files (6) +109 -5

lib/device.js +44 -1
……@@ -21,6 +21,7 @@
2121// was never the limitation; the modelling was.
2222
2323import fs from '#node:fs'
24import process from '#node:process'
2425import { join } from '#node:path'
2526import b4a from 'b4a'
2627import crypto from 'hypercore-crypto'
……@@ -96,7 +97,49 @@ export function loadNamespace (storage) {
9697 } catch {}
9798 const legacy = loadNamespaceKey(storage)
9899 // The legacy file names a log that was opened under `names`, and that is where its claims are.
99 return legacy ? { key: legacy, slot: 'names' } : null
100 if (legacy) return { key: legacy, slot: 'names' }
101
102 // ⛔ A DEFAULT, AND IT IS CONFIGURATION. Without one a fresh node FOUNDS ITS OWN namespace and
103 // resolves handles in a world containing only itself — so a new device could join the fleet, hold
104 // the right feeds, and still find nobody by name. That is what a handset did, and it read as a
105 // broken network rather than an empty registry.
106 //
107 // ⚠ `namespace.js` permits exactly this and states the limit: "Shipping a default namespace makes
108 // us a convenient starting point, never an authoritative one… So the key is an option." NAMING.md
109 // §8 #2 is the rule it must not break — "a default that cannot be changed is a root" — so
110 // FAVELA_NAMESPACE overrides it, `namespace --join` moves off it, and nothing here is load-bearing
111 // for anyone who has chosen otherwise.
112 //
113 // ⚠ TEMPORARY, by request. It hardcodes ONE fleet's registry into every install, which is a
114 // starting point and not a design: the right end state is a default a person picks during
115 // onboarding, or none at all for someone who wants their own world.
116 // ⛔ `own` MUST REMAIN POSSIBLE. A default that cannot be declined does not merely inconvenience
117 // someone who wants their own world — it removes the ability to be an ADMIN of anything, because
118 // admin rights come from founding. `test/namespace-join.test.js` asserts exactly that ("a linked
119 // node FOUNDS its namespace — so it has an admin from the first boot"), and it caught this.
120 const configured = envNamespace()
121 if (configured === OWN) return null
122 const key = configured || DEFAULT_NAMESPACE
123 return key ? { key, slot: namespaceSlot(key) } : null
124}
125
126// FAVELA_NAMESPACE=own — found a fresh one, the behaviour every node had before the default.
127const OWN = 'own'
128
129// tabertron's namespace — the one the fleet already resolves in, holding tabertron0, stortron0,
130// ashatron1 and fleeky0. Measured from the running daemon rather than copied from a config file:
131// `~/.favela/namespace.json` is a STALE storage root the daemon does not use, and reading it is how
132// an earlier audit concluded the fleet was fragmented when it was not.
133const DEFAULT_NAMESPACE = '2c9290ede07757087ba7983708508174768f92879ddd94858fac215ecbff9ca5'
134
135function envNamespace () {
136 try {
137 const v = process.env.FAVELA_NAMESPACE
138 if (typeof v !== 'string') return null
139 const t = v.trim().toLowerCase()
140 if (t === OWN) return OWN
141 return /^[0-9a-f]{64}$/.test(t) ? t : null
142 } catch { return null }
100143}
101144
102145export function saveNamespace (storage, { key, slot }) {
lib/node.js +9 -0
……@@ -328,6 +328,15 @@ export class FavelaNode {
328328 /** Found this namespace if it is ours to found. Idempotent: apply refuses a second founding. */
329329 async _foundNames (cred) {
330330 if (!this.names || !cred) return
331 // ⛔ ONLY FOUND A NAMESPACE THIS NODE CREATED. A joined one — now the default for a fresh
332 // install — has someone else as its genesis writer, so `found()` fails with "Not writable" from
333 // autobee's BACKGROUND drain, which the try/catch below cannot catch: the rejection never
334 // reaches the awaited path, it surfaces as an uncaught error and takes `ready()` down with it.
335 //
336 // ⚠ The existing `founder()` check is not enough on its own. A node that has just joined has
337 // replicated nothing yet, so it sees no founder and concludes the namespace needs founding —
338 // which is exactly the case that throws.
339 if (!this.names.base || !this.names.base.writable) return
331340 await this.names.update()
332341 if (await this.names.founder()) return
333342 try { await this.names.found(cred) } catch { return }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.54",
3 "version": "0.0.56",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/namespace-join.test.js +4 -0
……@@ -20,6 +20,7 @@
2020// slot would mean two different logs writing through one writer core.
2121
2222import test from 'brittle'
23import process from '#node:process'
2324import fs from '#node:fs'
2425import os from '#node:os'
2526import { join } from '#node:path'
……@@ -49,7 +50,10 @@ async function who () {
4950}
5051
5152// A node whose credential is already on disk — the ordinary state of a linked machine at boot.
53// ⚠ FOUNDS ITS OWN. A fresh node now defaults to the fleet namespace, so a test about founding has
54// to say it wants its own — the default is a starting point, not the only option.
5255async function linkedNode (storage = tmp(), cred = null) {
56 process.env.FAVELA_NAMESPACE = 'own'
5357 const c = cred || (await who()).device
5458 saveCredential(storage, c)
5559 const transport = recordingTransport()
test/namespaces.test.js +45 -3
……@@ -5,21 +5,26 @@
55// coming back meant having written down a 64-hex key. An index is the difference between a promise
66// and a door.
77import test from 'brittle'
8import process from '#node:process'
89import fs from '#node:fs'
910import os from '#node:os'
1011import { join } from '#node:path'
11import { loadNamespaces, saveNamespaces, slotFor, namespaceSlot, saveNamespace } from '../lib/device.js'
12import { loadNamespaces, saveNamespaces, slotFor, namespaceSlot, saveNamespace, loadNamespace } from '../lib/device.js'
1213
1314const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-ns-'))
1415const A = 'a'.repeat(64)
1516const B = 'b'.repeat(64)
1617
1718test('the index round-trips, and drops anything malformed', async (t) => {
19 process.env.FAVELA_NAMESPACE = ''
1820 const d = tmp()
1921 saveNamespaces(d, [{ key: A, slot: 'names' }, { key: 'nope', slot: 'x' }, { key: B, slot: 'names:' + B }])
2022 const got = loadNamespaces(d)
21 t.is(got.length, 2, 'the malformed key is not kept')
22 t.alike(got.map((n) => n.key).sort(), [A, B].sort())
23 t.absent(got.some((n) => n.key === 'nope'), 'the malformed key is not kept')
24 t.ok(got.some((n) => n.key === A) && got.some((n) => n.key === B), 'both valid ones survive')
25 // ⚠ The CURRENT namespace is always included, and a fresh node now has one — the default. So this
26 // asserts membership rather than a count, which would otherwise track an unrelated decision.
27 t.is(got.filter((n) => n.key === A || n.key === B).length, 2)
2328})
2429
2530test('⛔ the CURRENT namespace is always listed, even if the index never mentioned it', async (t) => {
……@@ -52,3 +57,40 @@ test('an unknown namespace derives a fresh slot', async (t) => {
5257 // stranger's log there would put two namespaces in one Corestore slot — the deadlock case.
5358 t.not(slotFor(B, { current: null, known: [] }), 'names')
5459})
60
61// ⛔ A fresh node used to FOUND ITS OWN namespace and resolve handles in a world containing only
62// itself — so a new device could join the fleet, hold the right feeds, and still find nobody by
63// name. That is what a handset did, and it read as a broken network rather than an empty registry.
64test('★ a fresh node defaults to the fleet namespace instead of founding its own', async (t) => {
65 // ⚠ Cleared explicitly: another file sets FAVELA_NAMESPACE=own and env is process-wide, so a test
66 // that does not say what it wants inherits whatever ran before it.
67 process.env.FAVELA_NAMESPACE = ''
68 const d = tmp()
69 const ns = loadNamespace(d)
70 t.ok(ns && /^[0-9a-f]{64}$/.test(ns.key), 'there is a default at all')
71 // ⚠ NOT the legacy `names` slot: that belongs to a namespace this node founded, and opening
72 // someone else's log there would put two namespaces in one Corestore slot — the deadlock case.
73 t.not(ns.slot, 'names', 'it joins, and joining gets its own slot')
74 t.is(ns.slot, namespaceSlot(ns.key))
75})
76
77// ⚠ NAMING.md §8 #2: "a default that cannot be changed is a root". The override is what keeps this
78// a convenient starting point rather than an authority.
79test('⛔ FAVELA_NAMESPACE overrides the default', async (t) => {
80 const d = tmp()
81 const mine = 'c'.repeat(64)
82 process.env.FAVELA_NAMESPACE = mine
83 t.is(loadNamespace(d).key, mine, 'the configured one wins')
84 process.env.FAVELA_NAMESPACE = ''
85 t.not(loadNamespace(d).key, mine, 'and clearing it falls back')
86})
87
88// ⛔ A node that already has one is NOT moved. Changing where an existing install resolves handles
89// would strand every name it claimed, silently.
90test('⛔ a node with a stored namespace keeps it', async (t) => {
91 process.env.FAVELA_NAMESPACE = ''
92 const d = tmp()
93 saveNamespace(d, { key: 'd'.repeat(64), slot: 'names' })
94 t.is(loadNamespace(d).key, 'd'.repeat(64))
95 t.is(loadNamespace(d).slot, 'names', 'including the founded slot')
96})
test/rpc-namespace.test.js +6 -0
……@@ -9,6 +9,7 @@
99// So these drive `dispatch`, the thing the socket actually calls.
1010
1111import test from 'brittle'
12import process from '#node:process'
1213import fs from '#node:fs'
1314import os from '#node:os'
1415import { join } from '#node:path'
……@@ -22,6 +23,10 @@ const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-rpcns-'))
2223const stubTransport = () => ({ async join () {}, async leave () {}, async destroy () {} })
2324
2425async function node () {
26 // ⚠ FOUNDS ITS OWN. A fresh node now defaults to the fleet namespace, so a suite about founding,
27 // admins and carrying must say so. Set HERE rather than at module scope: env is process-wide and
28 // brittle loads every file before running, so a top-level assignment is racy with other files.
29 process.env.FAVELA_NAMESPACE = 'own'
2530 const storage = tmp()
2631 const id = await identity()
2732 const cred = await id.device()
……@@ -77,6 +82,7 @@ test('⛔ a malformed namespace key is refused at the door, not at apply', async
7782
7883test('⚠ namespace-join needs no credential; the signing ops do', async (t) => {
7984 const storage = tmp()
85 process.env.FAVELA_NAMESPACE = 'own'
8086 const unlinked = new FavelaNode(storage, { transport: stubTransport() })
8187 await unlinked.ready()
8288 t.teardown(() => unlinked.close())