| … | … | @@ -35,6 +35,7 @@ const FILE = 'device.json' |
| 35 | 35 | const FEED = 'feed.key' |
| 36 | 36 | const NAMESPACE = 'namespace.key' |
| 37 | 37 | const NAMESPACE_JSON = 'namespace.json' |
| 38 | const NAMESPACES_JSON = 'namespaces.json' | |
| 38 | 39 | const ROOMS = 'rooms.json' |
| 39 | 40 | |
| 40 | 41 | export function proofPath (storage) { return join(storage, FILE) } |
| … | … | @@ -102,6 +103,58 @@ export function saveNamespace (storage, { key, slot }) { |
| 102 | 103 | fs.writeFileSync(join(storage, NAMESPACE_JSON), JSON.stringify({ key, slot }, null, 2) + '\n') |
| 103 | 104 | } |
| 104 | 105 | |
| 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 | */ | |
| 117 | export 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 | */ | |
| 141 | export 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 | ||
| 147 | export 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 | ||
| 105 | 158 | /** |
| 106 | 159 | * The rooms this node is in — `[{ key, slot }]`. |
| 107 | 160 | * |
| … | … | @@ -247,4 +300,4 @@ export async function mintFromVault (vaultPath, { passphrase = null } = {}) { |
| 247 | 300 | return { keyPair, proof: b4a.toString(proof, 'base64') } |
| 248 | 301 | } |
| 249 | 302 | |
| 250 | export default { loadRooms, saveRooms, proofPath, feedKeyPath, loadFeedKey, saveFeedKey, loadNamespaceKey, saveNamespaceKey, loadNamespace, saveNamespace, namespaceSlot, loadCredential, saveCredential, bindWriter, mintForDevice, mintFromVault } | |
| 303 | export default { slotFor, loadNamespaces, saveNamespaces, loadRooms, saveRooms, proofPath, feedKeyPath, loadFeedKey, saveFeedKey, loadNamespaceKey, saveNamespaceKey, loadNamespace, saveNamespace, namespaceSlot, loadCredential, saveCredential, bindWriter, mintForDevice, mintFromVault } |
| … | … | @@ -26,7 +26,7 @@ import { Pairing } from './pairing.js' |
| 26 | 26 | import { Avatars } from './avatars.js' |
| 27 | 27 | import { Files } from './files.js' |
| 28 | 28 | import { Namespace } from './namespace.js' |
| 29 | import { loadNamespace, saveNamespace, namespaceSlot, loadRooms, saveRooms } from './device.js' | |
| 29 | import { loadNamespace, saveNamespace, namespaceSlot, slotFor, loadNamespaces, saveNamespaces, loadRooms, saveRooms } from './device.js' | |
| 30 | 30 | import { Room } from './room.js' |
| 31 | 31 | import { readAttested } from './attestation.js' |
| 32 | 32 | import { createMirror } from './mirror.js' |
| … | … | @@ -349,9 +349,16 @@ export class FavelaNode { |
| 349 | 349 | if (this.names && hex(this.names.key) === key) return { key, joined: false } |
| 350 | 350 | |
| 351 | 351 | const current = loadNamespace(this.storage) |
| 352 | const known = loadNamespaces(this.storage) | |
| 352 | 353 | // ⚠ Back to one we already hold: reuse ITS slot. Opening the same log in a second slot would |
| 353 | 354 | // 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 }) | |
| 355 | 362 | |
| 356 | 363 | if (this.names) { |
| 357 | 364 | if (this.transport) { try { await this.transport.leave(this.names) } catch {} } |
| … | … | @@ -360,6 +367,8 @@ export class FavelaNode { |
| 360 | 367 | } |
| 361 | 368 | await this._openNames({ key, slot }) |
| 362 | 369 | 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 }]) | |
| 363 | 372 | return { key, joined: true } |
| 364 | 373 | } |
| 365 | 374 |
| … | … | @@ -537,6 +537,35 @@ export async function dispatch (node, cmd, a) { |
| 537 | 537 | } |
| 538 | 538 | } |
| 539 | 539 | // 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 | } | |
| 540 | 569 | case 'namespace-join': return node.joinNamespace(a.key) |
| 541 | 570 | // Ask to carry this namespace's log. Grants nothing; an admin has to answer. |
| 542 | 571 | 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 } } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.53", | |
| 3 | "version": "0.0.54", | |
| 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,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. | |
| 7 | import test from 'brittle' | |
| 8 | import fs from '#node:fs' | |
| 9 | import os from '#node:os' | |
| 10 | import { join } from '#node:path' | |
| 11 | import { loadNamespaces, saveNamespaces, slotFor, namespaceSlot, saveNamespace } from '../lib/device.js' | |
| 12 | ||
| 13 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-ns-')) | |
| 14 | const A = 'a'.repeat(64) | |
| 15 | const B = 'b'.repeat(64) | |
| 16 | ||
| 17 | test('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 | ||
| 25 | test('⛔ 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 | ||
| 35 | test('★ 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 | ||
| 44 | test('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 | ||
| 49 | test('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 | }) |