| … | … | @@ -21,6 +21,7 @@ |
| 21 | 21 | // was never the limitation; the modelling was. |
| 22 | 22 | |
| 23 | 23 | import fs from '#node:fs' |
| 24 | import process from '#node:process' | |
| 24 | 25 | import { join } from '#node:path' |
| 25 | 26 | import b4a from 'b4a' |
| 26 | 27 | import crypto from 'hypercore-crypto' |
| … | … | @@ -96,7 +97,49 @@ export function loadNamespace (storage) { |
| 96 | 97 | } catch {} |
| 97 | 98 | const legacy = loadNamespaceKey(storage) |
| 98 | 99 | // 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. | |
| 127 | const 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. | |
| 133 | const DEFAULT_NAMESPACE = '2c9290ede07757087ba7983708508174768f92879ddd94858fac215ecbff9ca5' | |
| 134 | ||
| 135 | function 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 } | |
| 100 | 143 | } |
| 101 | 144 | |
| 102 | 145 | export function saveNamespace (storage, { key, slot }) { |
| … | … | @@ -328,6 +328,15 @@ export class FavelaNode { |
| 328 | 328 | /** Found this namespace if it is ours to found. Idempotent: apply refuses a second founding. */ |
| 329 | 329 | async _foundNames (cred) { |
| 330 | 330 | 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 | |
| 331 | 340 | await this.names.update() |
| 332 | 341 | if (await this.names.founder()) return |
| 333 | 342 | try { await this.names.found(cred) } catch { return } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.54", | |
| 3 | "version": "0.0.56", | |
| 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": { |
| … | … | @@ -20,6 +20,7 @@ |
| 20 | 20 | // slot would mean two different logs writing through one writer core. |
| 21 | 21 | |
| 22 | 22 | import test from 'brittle' |
| 23 | import process from '#node:process' | |
| 23 | 24 | import fs from '#node:fs' |
| 24 | 25 | import os from '#node:os' |
| 25 | 26 | import { join } from '#node:path' |
| … | … | @@ -49,7 +50,10 @@ async function who () { |
| 49 | 50 | } |
| 50 | 51 | |
| 51 | 52 | // 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. | |
| 52 | 55 | async function linkedNode (storage = tmp(), cred = null) { |
| 56 | process.env.FAVELA_NAMESPACE = 'own' | |
| 53 | 57 | const c = cred || (await who()).device |
| 54 | 58 | saveCredential(storage, c) |
| 55 | 59 | const transport = recordingTransport() |
| … | … | @@ -5,21 +5,26 @@ |
| 5 | 5 | // coming back meant having written down a 64-hex key. An index is the difference between a promise |
| 6 | 6 | // and a door. |
| 7 | 7 | import test from 'brittle' |
| 8 | import process from '#node:process' | |
| 8 | 9 | import fs from '#node:fs' |
| 9 | 10 | import os from '#node:os' |
| 10 | 11 | import { join } from '#node:path' |
| 11 | import { loadNamespaces, saveNamespaces, slotFor, namespaceSlot, saveNamespace } from '../lib/device.js' | |
| 12 | import { loadNamespaces, saveNamespaces, slotFor, namespaceSlot, saveNamespace, loadNamespace } from '../lib/device.js' | |
| 12 | 13 | |
| 13 | 14 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-ns-')) |
| 14 | 15 | const A = 'a'.repeat(64) |
| 15 | 16 | const B = 'b'.repeat(64) |
| 16 | 17 | |
| 17 | 18 | test('the index round-trips, and drops anything malformed', async (t) => { |
| 19 | process.env.FAVELA_NAMESPACE = '' | |
| 18 | 20 | const d = tmp() |
| 19 | 21 | saveNamespaces(d, [{ key: A, slot: 'names' }, { key: 'nope', slot: 'x' }, { key: B, slot: 'names:' + B }]) |
| 20 | 22 | 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) | |
| 23 | 28 | }) |
| 24 | 29 | |
| 25 | 30 | test('⛔ 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) => { |
| 52 | 57 | // stranger's log there would put two namespaces in one Corestore slot — the deadlock case. |
| 53 | 58 | t.not(slotFor(B, { current: null, known: [] }), 'names') |
| 54 | 59 | }) |
| 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. | |
| 64 | test('★ 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. | |
| 79 | test('⛔ 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. | |
| 90 | test('⛔ 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 | }) |
| … | … | @@ -9,6 +9,7 @@ |
| 9 | 9 | // So these drive `dispatch`, the thing the socket actually calls. |
| 10 | 10 | |
| 11 | 11 | import test from 'brittle' |
| 12 | import process from '#node:process' | |
| 12 | 13 | import fs from '#node:fs' |
| 13 | 14 | import os from '#node:os' |
| 14 | 15 | import { join } from '#node:path' |
| … | … | @@ -22,6 +23,10 @@ const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-rpcns-')) |
| 22 | 23 | const stubTransport = () => ({ async join () {}, async leave () {}, async destroy () {} }) |
| 23 | 24 | |
| 24 | 25 | async 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' | |
| 25 | 30 | const storage = tmp() |
| 26 | 31 | const id = await identity() |
| 27 | 32 | const cred = await id.device() |
| … | … | @@ -77,6 +82,7 @@ test('⛔ a malformed namespace key is refused at the door, not at apply', async |
| 77 | 82 | |
| 78 | 83 | test('⚠ namespace-join needs no credential; the signing ops do', async (t) => { |
| 79 | 84 | const storage = tmp() |
| 85 | process.env.FAVELA_NAMESPACE = 'own' | |
| 80 | 86 | const unlinked = new FavelaNode(storage, { transport: stubTransport() }) |
| 81 | 87 | await unlinked.ready() |
| 82 | 88 | t.teardown(() => unlinked.close()) |