OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / namespace-join.test.js

181 lines · javascript ref:
// A node's namespace: founded, ANNOUNCED, and joinable at runtime.
//
// ⛔ The bug this file exists for, and it is not the one we set out to fix. `node.js` joins the
// namespace to the transport inside `ready()` — where `this.transport` is still null, because
// `index.js` constructs the transport AFTER ready() so the overlay exists first. index.js then
// joins `peer.feed` and every held feed by hand, and NOT `names`.
//
// ⇒ The namespace autobee has never been announced on the swarm in the deployed daemon. Even two
// nodes holding the same namespace key would never have replicated it. A shared registry that
// cannot reach its peers is three private registries wearing one name.
//
// ── The key is CONFIGURATION ──────────────────────────────────────────────────────────────────
//
// NAMING.md §8 #2: a default that cannot be changed IS a root. So the key is a file, joining
// another is a runtime act rather than an edit-and-restart, and the namespace this node created
// stays on disk when it joins someone else's — you can go back, and nothing is destroyed.
//
// ⚠ Each namespace gets its own Corestore SLOT. Two Autobees deriving one local writer DEADLOCK
// rather than erroring (node.js:5, measured 2026-09-06) — and even opened one at a time, sharing a
// slot would mean two different logs writing through one writer core.

import test from 'brittle'
import process from '#node:process'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import b4a from 'b4a'
import { FavelaNode } from '../lib/node.js'
import { loadNamespace, saveNamespace, saveCredential } from '../lib/device.js'
import { identity } from './helpers.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-nsjoin-'))
const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))

// A transport that records what it was asked to announce. The real one needs a live DHT; what is
// under test is whether anything ASKS, which is exactly where the bug was.
function recordingTransport () {
  return {
    joined: [],
    left: [],
    async join (base) { this.joined.push(hex(base.discoveryKey)) },
    async leave (base) { this.left.push(hex(base.discoveryKey)) },
    async destroy () {}
  }
}

async function who () {
  const id = await identity()
  return { root: id.rootHex, device: await id.device() }
}

// A node whose credential is already on disk — the ordinary state of a linked machine at boot.
// ⚠ FOUNDS ITS OWN. A fresh node now defaults to the fleet namespace, so a test about founding has
// to say it wants its own — the default is a starting point, not the only option.
async function linkedNode (storage = tmp(), cred = null) {
  process.env.FAVELA_NAMESPACE = 'own'
  const c = cred || (await who()).device
  saveCredential(storage, c)
  const transport = recordingTransport()
  const node = new FavelaNode(storage, { transport })
  await node.ready()
  return { node, transport, cred: c, storage }
}

test('⛔ the namespace is ANNOUNCED — the regression that made it three private registries', async (t) => {
  const { node, transport } = await linkedNode()
  t.teardown(() => node.close())

  t.ok(transport.joined.includes(hex(node.names.discoveryKey)),
    'a namespace nobody announces cannot replicate, however correct its apply is')
  t.ok(transport.joined.includes(hex(node.peer.feed.discoveryKey)), 'and the feed still is')
})

test('★ a linked node FOUNDS its namespace — so it has an admin from the first boot', async (t) => {
  const { node, cred } = await linkedNode()
  t.teardown(() => node.close())

  const founder = await node.names.founder()
  t.ok(founder, 'founded at creation, before the key could have been shared with anyone')
  t.alike(await node.names.admins(), [founder.root])
  // The identity, not the device and not the writer core: one person's machines are one admin.
  t.is(founder.root, (await import('../lib/attestation.js')).readAttested(cred.proof, undefined).root)
})

test('⚠ an UNLINKED node opens a namespace and does not found it', async (t) => {
  const storage = tmp()
  const node = new FavelaNode(storage, { transport: recordingTransport() })
  await node.ready()
  t.teardown(() => node.close())

  // A daemon with no credential still starts — `favela link` needs something to talk to. It cannot
  // found anything, because founding is a signed act, and it must not pretend otherwise.
  t.is(await node.names.founder(), null, 'no credential, no founding — and no error either')
  t.alike(await node.names.admins(), [])
})

test('★ linking a node that booted unlinked founds its namespace then', async (t) => {
  const storage = tmp()
  const node = new FavelaNode(storage, { transport: recordingTransport() })
  await node.ready()
  t.teardown(() => node.close())
  t.is(await node.names.founder(), null)

  const me = await who()
  await node.link(me.device)
  // ⚠ The window is real and it is small: between an unlinked first boot and `favela link`, this
  // namespace has no admin. Nobody else can found it because nobody else has the key — it has never
  // been shared and it is announced under a discovery key derived from itself.
  t.is((await node.names.founder()).root, me.root, 'founded on link, not left admin-less for ever')
})

test('the stored key is honoured — a restart does not mint a new namespace', async (t) => {
  const { node, storage } = await linkedNode()
  const first = hex(node.names.key)
  await node.close()

  const again = new FavelaNode(storage, { transport: recordingTransport() })
  await again.ready()
  t.teardown(() => again.close())
  t.is(hex(again.names.key), first, 'or every restart would resolve handles in a new world')
})

test('legacy `namespace.key` is seeded, never discarded', async (t) => {
  const storage = tmp()
  fs.mkdirSync(storage, { recursive: true })
  const legacy = 'ab'.repeat(32)
  fs.writeFileSync(join(storage, 'namespace.key'), legacy + '\n')

  const loaded = loadNamespace(storage)
  t.is(loaded.key, legacy, 'the deployed fleet holds one of these on every host')
  t.is(loaded.slot, 'names', 'in the Corestore slot it was written under, or its log is unreachable')
  t.ok(fs.existsSync(join(storage, 'namespace.key')), 'and the old file is left alone')
})

test('★★ joining another namespace is a RUNTIME act, and loses nothing', async (t) => {
  const a = await linkedNode()
  const b = await linkedNode()
  t.teardown(async () => { await a.node.close(); await b.node.close() })

  const mine = hex(a.node.names.key)
  const theirs = hex(b.node.names.key)
  t.not(mine, theirs)

  // Claim a handle in my own namespace, so there is something that could be lost.
  await a.node.names.claim('alice', hex(a.node.key), a.cred)
  await a.node.names.update()
  t.ok(await a.node.names.resolve('alice0'))

  await a.node.joinNamespace(theirs)
  t.is(hex(a.node.names.key), theirs, 'no restart, no file edit')
  t.is(await a.node.names.resolve('alice0'), null, 'and their namespace does not know my handle')
  t.is(loadNamespace(a.storage).key, theirs, 'persisted, so the next boot agrees')
  t.ok(a.transport.left.length > 0, 'the old namespace stops being announced')
  t.ok(a.transport.joined.includes(hex(b.node.names.discoveryKey)), 'and the new one starts')

  // ⛔ Going back finds it exactly as it was. Joining someone else's namespace must never be a
  // one-way door that quietly destroys the one you made — NEVER delete persistent state.
  await a.node.joinNamespace(mine)
  t.is(hex(a.node.names.key), mine)
  t.ok(await a.node.names.resolve('alice0'), 'the handle claimed before the detour is still there')
})

test('⛔ joining a namespace you already hold is a no-op, not a second writer', async (t) => {
  const { node } = await linkedNode()
  t.teardown(() => node.close())
  const mine = hex(node.names.key)
  const writer = hex(node.names.base.local.key)

  await node.joinNamespace(mine)
  t.is(hex(node.names.base.local.key), writer,
    'reopening in a different slot would give one node two writer cores in one log')
})

test('⛔ a malformed namespace key is refused at the door', async (t) => {
  const { node } = await linkedNode()
  t.teardown(() => node.close())
  const mine = hex(node.names.key)
  await t.exception(node.joinNamespace('not-a-key'), /32 bytes of hex|namespace key/)
  t.is(hex(node.names.key), mine, 'and the node keeps the one it had')
})