OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 473ab6f

0.0.54: the namespaces this node holds, and the slot bug that hid claims
`namespaceSettings` has always told people "the one you are in now stays on disk — you can come back to it, handles and all". True, and unreachable: nothing listed what was on disk, so coming back meant having written down a 64-hex key. An index is the difference between a promise and a door. `namespaces` lists them, `namespace-forget` drops one from the list. ★ AND IT FIXES A REAL BUG FOUND WHILE READING joinNamespace. The namespace a node FOUNDED lives in the legacy slot `names`, which `namespaceSlot(key)` structurally cannot produce — the key does not exist until the log is opened. So leaving the founded namespace and returning later derived `names:<key>`, opened a SECOND log, and left every handle claimed there resolving from a slot the node no longer used. Nothing was lost; it was somewhere else, which presents as loss and is worse. The index is now consulted for the slot, and the rule is extracted as `slotFor()` so it can be tested without booting a node — which is how the case above is pinned. ⛔ `namespace-forget` DROPS THE INDEX ENTRY, NEVER THE LOG. The slot and every claim in it stay on disk. Forgetting the current namespace is refused, and so is forgetting a FOUNDED one — its legacy slot cannot be re-derived from the key, so the index entry is the only record of where its claims live. ⚠ Also recorded for the next person: the fleet's namespace is 2c9290ede07757087ba7983708508174768f92879ddd94858fac215ecbff9ca5 and all three hosts are already in it — tabertron founded it, ashatron and stortron joined, and it holds ashatron0/stortron0/tabertron0. I read `~/.favela/namespace.json` and concluded the fleet was fragmented; that is a STALE storage root the running daemon does not use. It lives in ~/.config/favela-peer/. 362/362 tests, 874/874 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
473ab6f49c2ceda03b6612d191f2fb60a5782bd9
by plankatron <fleeky@gmail.com> · 2026-09-10T15:18:13.000Z

Changed files (5) +149 -4

lib/device.js +54 -1
……@@ -35,6 +35,7 @@ const FILE = 'device.json'
3535const FEED = 'feed.key'
3636const NAMESPACE = 'namespace.key'
3737const NAMESPACE_JSON = 'namespace.json'
38const NAMESPACES_JSON = 'namespaces.json'
3839const ROOMS = 'rooms.json'
3940
4041export function proofPath (storage) { return join(storage, FILE) }
……@@ -102,6 +103,58 @@ export function saveNamespace (storage, { key, slot }) {
102103 fs.writeFileSync(join(storage, NAMESPACE_JSON), JSON.stringify({ key, slot }, null, 2) + '\n')
103104}
104105
106/**
107 * Every namespace this node has on disk — `[{ key, slot }]`, current one included.
108 *
109 * ⛔ AN INDEX, because the slots cannot be enumerated. A joined namespace lives under
110 * `names:<key>` and the founded one under the legacy `names`, and nothing on disk lists them — so
111 * `namespaceSettings` could truthfully promise "you can come back to it, handles and all" while
112 * giving nobody a way to find out what "it" was. A 64-hex key you have to remember is not a door.
113 *
114 * ⚠ LOCAL state, exactly like `loadRooms`: which namespaces this machine holds is a fact about this
115 * machine, not something derivable from anyone's log.
116 */
117export function loadNamespaces (storage) {
118 let list = []
119 try {
120 const raw = JSON.parse(fs.readFileSync(join(storage, NAMESPACES_JSON), 'utf8'))
121 if (Array.isArray(raw)) list = raw.filter((n) => n && /^[0-9a-f]{64}$/.test(n.key) && typeof n.slot === 'string' && n.slot)
122 } catch {}
123 // ⛔ The CURRENT one is always in the list, even if the index predates it or was never written.
124 // An index that could omit the namespace you are standing in would offer you a switch to nowhere.
125 const cur = loadNamespace(storage)
126 if (cur && !list.some((n) => n.key === cur.key)) list = [{ key: cur.key, slot: cur.slot }, ...list]
127 return list
128}
129
130/**
131 * Which Corestore slot a namespace's log belongs in — pure, so the rule can be tested.
132 *
133 * ⛔ THE FOUNDED NAMESPACE IS THE WHOLE REASON THIS EXISTS. Its log lives in the legacy slot
134 * `names`, which `namespaceSlot(key)` cannot produce because the key did not exist when the slot was
135 * named. So leaving it and returning computed `names:<key>`, opened a SECOND log, and left every
136 * handle claimed there resolving from a slot this node no longer used. Nothing was lost; it was
137 * somewhere else, which is worse, because it presents as loss.
138 *
139 * Order matters: the namespace we are standing in knows its own slot, then the index, then derive.
140 */
141export function slotFor (key, { current = null, known = [] } = {}) {
142 if (current && current.key === key && current.slot) return current.slot
143 const recorded = (known || []).find((n) => n && n.key === key && n.slot)
144 return recorded ? recorded.slot : namespaceSlot(key)
145}
146
147export function saveNamespaces (storage, list) {
148 const seen = new Set()
149 const clean = []
150 for (const n of list || []) {
151 if (!n || !/^[0-9a-f]{64}$/.test(n.key) || seen.has(n.key)) continue
152 seen.add(n.key)
153 clean.push({ key: n.key, slot: n.slot })
154 }
155 fs.writeFileSync(join(storage, NAMESPACES_JSON), JSON.stringify(clean, null, 2) + '\n')
156}
157
105158/**
106159 * The rooms this node is in — `[{ key, slot }]`.
107160 *
……@@ -247,4 +300,4 @@ export async function mintFromVault (vaultPath, { passphrase = null } = {}) {
247300 return { keyPair, proof: b4a.toString(proof, 'base64') }
248301}
249302
250export default { loadRooms, saveRooms, proofPath, feedKeyPath, loadFeedKey, saveFeedKey, loadNamespaceKey, saveNamespaceKey, loadNamespace, saveNamespace, namespaceSlot, loadCredential, saveCredential, bindWriter, mintForDevice, mintFromVault }
303export default { slotFor, loadNamespaces, saveNamespaces, loadRooms, saveRooms, proofPath, feedKeyPath, loadFeedKey, saveFeedKey, loadNamespaceKey, saveNamespaceKey, loadNamespace, saveNamespace, namespaceSlot, loadCredential, saveCredential, bindWriter, mintForDevice, mintFromVault }
lib/node.js +11 -2
……@@ -26,7 +26,7 @@ import { Pairing } from './pairing.js'
2626import { Avatars } from './avatars.js'
2727import { Files } from './files.js'
2828import { Namespace } from './namespace.js'
29import { loadNamespace, saveNamespace, namespaceSlot, loadRooms, saveRooms } from './device.js'
29import { loadNamespace, saveNamespace, namespaceSlot, slotFor, loadNamespaces, saveNamespaces, loadRooms, saveRooms } from './device.js'
3030import { Room } from './room.js'
3131import { readAttested } from './attestation.js'
3232import { createMirror } from './mirror.js'
……@@ -349,9 +349,16 @@ export class FavelaNode {
349349 if (this.names && hex(this.names.key) === key) return { key, joined: false }
350350
351351 const current = loadNamespace(this.storage)
352 const known = loadNamespaces(this.storage)
352353 // ⚠ Back to one we already hold: reuse ITS slot. Opening the same log in a second slot would
353354 // give this node two writer cores in one namespace — not fatal, and permanently confusing.
354 const slot = current && current.key === key ? current.slot : namespaceSlot(key)
355 //
356 // ⛔ AND THE INDEX IS CONSULTED, not just the current one. The namespace this node FOUNDED lives
357 // in the legacy slot `names`, which `namespaceSlot(key)` cannot produce — so leaving it and
358 // coming back later computed `names:<key>`, opened a SECOND log, and left every handle claimed
359 // there resolving from a slot this node was no longer using. The claims were never lost; they
360 // were simply somewhere else, which is worse because it looks like loss.
361 const slot = slotFor(key, { current, known })
355362
356363 if (this.names) {
357364 if (this.transport) { try { await this.transport.leave(this.names) } catch {} }
……@@ -360,6 +367,8 @@ export class FavelaNode {
360367 }
361368 await this._openNames({ key, slot })
362369 saveNamespace(this.storage, { key, slot })
370 // Remembered so it can be returned to by name rather than by a key someone has to have kept.
371 saveNamespaces(this.storage, [...known.filter((n) => n.key !== key), { key, slot }])
363372 return { key, joined: true }
364373 }
365374
lib/rpc.js +29 -0
……@@ -537,6 +537,35 @@ export async function dispatch (node, cmd, a) {
537537 }
538538 }
539539 // Resolve handles in a different namespace from now on. The one we leave stays on disk.
540 // ⛔ EVERY namespace this node holds, so "you can come back to it" is a door rather than a
541 // promise. `namespaceSettings` has always said the one you leave stays on disk; without this
542 // there was no way to learn what it was, and a 64-hex key someone has to have written down is
543 // not a way back.
544 case 'namespaces': {
545 const { loadNamespaces } = await import('./device.js')
546 const cur = node.names ? node.names.key.toString('hex') : null
547 return {
548 current: cur,
549 namespaces: loadNamespaces(node.storage).map((n) => ({ ...n, current: n.key === cur }))
550 }
551 }
552 // ⛔ FORGETS THE INDEX ENTRY, NEVER THE LOG. The slot stays on disk with every claim in it —
553 // this removes it from the list, and joining by key again finds the same slot through the index
554 // it was just dropped from... which it cannot. Stated plainly rather than discovered: forgetting
555 // the CURRENT namespace is refused, and forgetting a founded one loses the only record of its
556 // legacy slot, so that is refused too.
557 case 'namespace-forget': {
558 const { loadNamespaces, saveNamespaces } = await import('./device.js')
559 const key = String(a.key || '').toLowerCase()
560 const cur = node.names ? node.names.key.toString('hex') : null
561 if (key === cur) throw new Error('that is the namespace this node is in — join another first')
562 const known = loadNamespaces(node.storage)
563 const entry = known.find((n) => n.key === key)
564 if (!entry) throw new Error('this node does not hold that namespace')
565 if (entry.slot === 'names') throw new Error('that is the namespace this node founded — forgetting it would lose the only record of where its claims live')
566 saveNamespaces(node.storage, known.filter((n) => n.key !== key))
567 return { forgotten: key, note: 'the log stays on disk; join by key to return to it' }
568 }
540569 case 'namespace-join': return node.joinNamespace(a.key)
541570 // Ask to carry this namespace's log. Grants nothing; an admin has to answer.
542571 case 'namespace-request': { await node.names.request(node.cred, a.token); await node.names.update(); return { asked: node.names.base.local.key.toString('hex'), token: a.token ? 'redeemed' : null } }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.53",
3 "version": "0.0.54",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/namespaces.test.js +54 -0
……@@ -0,0 +1,54 @@
1// The namespaces this node holds, and which slot each one's log lives in.
2//
3// ⛔ `namespaceSettings` has always told people "the one you are in now stays on disk — you can come
4// back to it, handles and all". That was true and unreachable: nothing listed what was on disk, so
5// coming back meant having written down a 64-hex key. An index is the difference between a promise
6// and a door.
7import test from 'brittle'
8import fs from '#node:fs'
9import os from '#node:os'
10import { join } from '#node:path'
11import { loadNamespaces, saveNamespaces, slotFor, namespaceSlot, saveNamespace } from '../lib/device.js'
12
13const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-ns-'))
14const A = 'a'.repeat(64)
15const B = 'b'.repeat(64)
16
17test('the index round-trips, and drops anything malformed', async (t) => {
18 const d = tmp()
19 saveNamespaces(d, [{ key: A, slot: 'names' }, { key: 'nope', slot: 'x' }, { key: B, slot: 'names:' + B }])
20 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})
24
25test('⛔ the CURRENT namespace is always listed, even if the index never mentioned it', async (t) => {
26 const d = tmp()
27 saveNamespaces(d, [{ key: B, slot: 'names:' + B }])
28 saveNamespace(d, { key: A, slot: 'names' })
29 const got = loadNamespaces(d)
30 // An index that could omit the namespace you are standing in would offer a switch to nowhere.
31 t.ok(got.some((n) => n.key === A), 'the one this node is in appears')
32 t.is(got.length, 2)
33})
34
35test('★ returning to the FOUNDED namespace reuses its legacy slot, not a derived one', async (t) => {
36 // This is the bug. The founded log lives in `names`; namespaceSlot() cannot produce that, so
37 // leaving and returning used to open a SECOND log and leave the claims behind in the first.
38 const known = [{ key: A, slot: 'names' }]
39 const current = { key: B, slot: 'names:' + B }
40 t.is(slotFor(A, { current, known }), 'names', 'the recorded slot wins over the derived one')
41 t.not(slotFor(A, { current, known }), namespaceSlot(A), 'and it is NOT what would have been derived')
42})
43
44test('a namespace we are standing in knows its own slot', async (t) => {
45 const current = { key: A, slot: 'names' }
46 t.is(slotFor(A, { current, known: [] }), 'names')
47})
48
49test('an unknown namespace derives a fresh slot', async (t) => {
50 t.is(slotFor(B, { current: null, known: [] }), namespaceSlot(B))
51 // ⚠ Never the legacy `names`: that slot belongs to whatever this node founded, and opening a
52 // stranger's log there would put two namespaces in one Corestore slot — the deadlock case.
53 t.not(slotFor(B, { current: null, known: [] }), 'names')
54})