OverviewFilesCommitsIssuesDocsReleasesPeers

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

438 lines · javascript ref:
// Who CARRIES a namespace — admins, admitted writers, and revocation.
//
// ── The problem this exists for ───────────────────────────────────────────────────────────────
//
// A namespace is a shared autobee and always was. What it never had is anyone in it but its
// creator: `namespace.js` calls `host.ackWriter` and nothing else, and ack grants NOTHING.
// MEASURED against autobee 2.1.1 on 2026-09-07 (`probe-weight.mjs`):
//
//   genesis writer, before its first op   isIndexer false  writable true   system record null
//   genesis writer, after its first op    isIndexer TRUE   writable true   isGenesis, maxWeight 2
//   a peer, after ackWriter               isIndexer false  writable FALSE  system record STILL null
//   a peer, after addWriter(isIndexer)    isIndexer TRUE   writable true   maxWeight 2
//
// ⇒ Every node that joined someone else's namespace was a weightless optimistic appender for ever.
// The founder carried the whole log alone, because `isIndexer` is `weight >= 2` and `weight` is the
// `maxWeight` capability that only `addWriter` grants (`autobee/lib/writers.js:286`,
// `lib/system.js:51`).
//
// ── ⛔ Admin is not a gate on claiming ────────────────────────────────────────────────────────
//
// Two different privileges, and conflating them would quietly turn the registry into an authority:
//
//   ADMIT   who carries the log — sort weight, ordering, writability. Granted.
//   CLAIM   who may take a name — ANYONE, for ever, by the forced number. Ungranted, ungrantable.
//
// NAMING.md §4 says namespace governance is room governance, "who may claim = who may write". We
// take only half of that on purpose: this namespace stays open by construction, and the last test
// in this file pins it.
//
// ── ⛔ Admission CANNOT be self-service — weight is only conferred downward ────────────────────
//
// The first version of this file assumed a node could admit itself by signing an op naming its own
// writer core. MEASURED 2026-09-07 (`probe-optimistic.mjs`), that is silently inert:
//
//   self-admission (op rides the joiner's own weightless block)   maxWeight 0 on BOTH peers
//   the SAME admit carried by the founder                          maxWeight 2, isIndexer true
//
// `system.addWriter` computes `effective = coord && carrier < weight ? max(carrier, 0) : weight`
// (autobee/lib/system.js:64), where `carrier` is the weight of the block CARRYING the grant. Apply
// cannot see the cap: the op applies, every peer converges on the same view, and the writer gets
// nothing. A failure with no error anywhere.
//
// ⇒ Admission is TWO ACTS BY TWO PARTIES — the joiner `request`s with its own device, an admin
// answers with an `admit` carried by its own weighted block. The same rule `applyListFeed` already
// enforces with knocks, arrived at from the opposite direction: there it makes consent structural,
// here the substrate simply refuses to work any other way.

import test from 'brittle'
import Corestore from 'corestore'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import b4a from 'b4a'
import { Namespace, canonicalOpBytes } from '../lib/namespace.js'
import { identity } from './helpers.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-nsadmin-'))

// ⛔ HEX ONLY. A fixture like `'v4'.repeat(16)` is not a key, so the code under test rejects it as
// malformed and the assertion passes for the wrong reason — three tests died that way in #64 §7 #8.
// This throws instead of quietly producing one.
function key (ch) {
  const k = String(ch).repeat(64).slice(0, 64)
  if (!/^[0-9a-f]{64}$/.test(k)) throw new Error('test fixture is not a key: ' + k)
  return k
}
const feed = key

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

async function ns (key = null, store = null) {
  const n = new Namespace((store || new Corestore(tmp())).namespace('names'), key)
  await n.ready()
  return n
}

// A namespace that has been founded by `cred` — the normal state of every namespace that exists.
async function founded (cred, store = null) {
  const n = await ns(null, store)
  await n.found(cred)
  await n.update()
  return n
}

function pipe (a, b) {
  const s1 = a.replicate(true)
  const s2 = b.replicate(false)
  s1.pipe(s2).pipe(s1)
  return () => { s1.destroy(); s2.destroy() }
}

async function until (fn, { timeout = 10000 } = {}) {
  const deadline = Date.now() + timeout
  while (Date.now() < deadline) {
    const v = await fn()
    if (v) return v
    await new Promise((r) => setTimeout(r, 25))
  }
  return null
}

// The capability autobee actually granted, read from the substrate rather than from our own view —
// our view could say anything; this is what decides ordering and writability.
const maxWeight = async (n, writerHex) => {
  const info = await n.base.system.get(b4a.from(writerHex, 'hex'))
  return info ? info.maxWeight : null
}

test('★ founding a namespace makes its creator the first admin', async (t) => {
  const a = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  t.alike(await n.admins(), [a.root], 'the founder\'s identity ROOT, not its device and not its writer core')
  // ⛔ The founding op must be appended NON-optimistically: "an optimistic writer cannot be
  // genesis" (autobee/lib/writers.js:529), so an optimistic founding op is never processed at all
  // and the namespace would come up with no admin and no way to add one.
  t.is(await maxWeight(n, b4a.toString(n.base.local.key, 'hex')), 2,
    'and genesis carries weight 2 the moment it writes — measured, not granted')
})

test('⛔ founding is refused once a namespace has an admin', async (t) => {
  const a = await who()
  const b = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  // A second founding op reaching apply must change nothing, or whoever gets the key second becomes
  // an admin by asking. The window this cannot close is before the FIRST op — which is why
  // `found()` runs at creation, before the key has been shared with anyone.
  await n.base.append(b4a.from(JSON.stringify(n.foundOp(b.device))), { optimistic: true })
  await n.update()
  t.alike(await n.admins(), [a.root], 'the second claim to found is refused')
})

test('⛔ ackWriter grants NOTHING — the regression this whole file exists for', async (t) => {
  const a = await who()
  const stranger = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  // A stranger's claim is acked so its block sticks (that is what keeps the namespace open), and
  // that ack must never be mistaken for admission. If someone ever "simplifies" admit into ack,
  // every peer silently goes back to being an unwritable optimistic appender.
  await n.claim('alice', feed('a'), stranger.device)
  await n.update()
  t.ok(await n.resolve('alice0'), 'the claim landed')
  t.alike(await n.writers(), [], 'and granted the claimant no writer capability at all')
})

test('★ an admin answers a request, and the writer becomes an indexer', async (t) => {
  const a = await who()
  const b = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  const writer = key('b')
  await n.requestWriter(writer, b.device)
  await n.update()
  t.alike((await n.requests()).map((r) => r.writer), [writer], 'asking grants nothing on its own')
  t.alike(await n.writers(), [])

  await n.admitWriter(writer, a.device)
  await n.update()
  t.is(await maxWeight(n, writer), 2, 'addWriter(isIndexer: true) — weight 2, the capability that decides reorgs')
  t.alike((await n.writers()).map((w) => w.writer), [writer])
})

test('⛔ an admin cannot conscript a writer that never asked', async (t) => {
  const a = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  const writer = key('9')
  await n.admitWriter(writer, a.device)
  await n.update()
  // Two acts by two parties. Without the request, being made to carry someone's log would be
  // something an admin could do TO a machine rather than WITH it.
  t.is(await maxWeight(n, writer), null, 'refused: no request to answer')
})

test('⛔⛔ a node cannot admit ITSELF — and the refusal is LOUD', async (t) => {
  const a = await who()
  const b = await who()
  const one = await founded(a.device)
  const two = await ns(one.key, new Corestore(tmp()))
  t.teardown(() => Promise.all([one.close(), two.close()]))

  // The substrate caps the grant at the carrier block's weight and reports nothing. A silent zero
  // is exactly the failure this codebase refuses everywhere else, so the call refuses instead.
  await t.exception(two.admitWriter(key('a'), b.device), /confers? .*downward|weight/,
    'a weightless writer is told why, rather than appending something quietly inert')
})

test('⛔ a stranger cannot admit a writer', async (t) => {
  const a = await who()
  const stranger = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  const writer = key('c')
  await n.requestWriter(writer, stranger.device)
  await n.admitWriter(writer, stranger.device)
  await n.update()

  // The op is perfectly signed — by somebody with no standing. Admission is not "did you sign
  // this", it is "does the root you signed as appear on the admin list".
  t.is(await maxWeight(n, writer), null, 'refused: the signer\'s root is not an admin')
  t.alike(await n.writers(), [])
})

test('⛔ an admit cannot be RE-AIMED at a different writer core', async (t) => {
  const a = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  const mine = key('d')
  const theirs = key('e')
  await n.requestWriter(mine, a.device)
  await n.requestWriter(theirs, a.device)
  await n.update()
  const op = n.admitOp(mine, a.device)
  await n.base.append(b4a.from(JSON.stringify(op)), { optimistic: true })
  await n.update()
  t.is(await maxWeight(n, mine), 2, 'the honest admit landed')

  // What a thief actually holds: a valid signature and a wish. The writer key is inside the signed
  // bytes, so swapping it breaks the proof — one primitive, no second detached signature to keep
  // in step (#64 §1).
  const tampered = { ...op, writer: theirs }
  t.not(b4a.toString(canonicalOpBytes(tampered), 'utf8'), b4a.toString(canonicalOpBytes(op), 'utf8'),
    'the swap changes the bytes the proof covers — which is the whole mechanism')
  await n.base.append(b4a.from(JSON.stringify(tampered)), { optimistic: true })
  await n.update()
  t.is(await maxWeight(n, theirs), null, 'so the re-aimed admit grants nothing')
})

test('⛔ an admit signed for one namespace does not LIFT into another', async (t) => {
  const a = await who()
  const one = await founded(a.device)
  const two = await founded(a.device)
  t.teardown(() => Promise.all([one.close(), two.close()]))

  const writer = key('f')
  await one.requestWriter(writer, a.device); await two.requestWriter(writer, a.device)
  await one.update(); await two.update()
  const op = one.admitOp(writer, a.device)
  await one.base.append(b4a.from(JSON.stringify(op)), { optimistic: true })
  await two.base.append(b4a.from(JSON.stringify(op)), { optimistic: true })
  await one.update(); await two.update()

  t.is(await maxWeight(one, writer), 2, 'valid where it was signed')
  t.is(await maxWeight(two, writer), null,
    'and refused everywhere else — a proof names the namespace it was signed for')
})

test('★ a SIBLING DEVICE of an admin may admit — the anchor is the identity', async (t) => {
  const a = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  // The blind-pairing path: an already-linked device attests the next one from its own keypair.
  // One identity, two devices, two writers — so a device-scoped admin list could never express
  // "this person is an admin", which is the same limitation release had to shed (#64 §1.2).
  const phone = await a.device.child()
  const writer = key('0')
  await n.requestWriter(writer, phone)
  await n.update()
  await n.admitWriter(writer, phone)
  await n.update()
  t.is(await maxWeight(n, writer), 2, 'admitted from a device the founding op never named')
})

test('★ an admin may grant admin to another identity', async (t) => {
  const a = await who()
  const b = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  await n.grantAdmin(b.root, a.device)
  await n.update()
  t.alike((await n.admins()).sort(), [a.root, b.root].sort(), 'two identities carry the log')

  // And the grant is real: the new admin can now admit its own writer core, with no help.
  const writer = key('1')
  await n.requestWriter(writer, b.device)
  await n.update()
  // ⚠ Carried by the FOUNDER's block, because that is the one with weight. The new admin can
  // authorise, but cannot confer more weight than the block it rides.
  await n.admitWriter(writer, a.device)
  await n.update()
  t.is(await maxWeight(n, writer), 2)
})

test('⛔ a non-admin cannot grant admin — not even to itself', async (t) => {
  const a = await who()
  const stranger = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  await n.grantAdmin(stranger.root, stranger.device)
  await n.update()
  t.alike(await n.admins(), [a.root], 'or the admin list is a suggestion')
})

test('⛔ a revoked device cannot admit, and its writer is removed', async (t) => {
  const a = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  const laptop = await a.device.child()
  const writer = key('2')
  await n.requestWriter(writer, laptop)
  await n.update()
  await n.admitWriter(writer, laptop)
  await n.update()
  t.is(await maxWeight(n, writer), 2, 'admitted while trusted')

  await n.revokeDevice(laptop.hex, a.device)
  await n.update()

  // A tombstone, because absence is ambiguous in a replicated log. Checked BEFORE the grant, or
  // self-service admission makes revocation decorative — the ordering `feed.js:167` pays for.
  const again = key('3')
  await n.requestWriter(again, laptop)
  await n.update()
  await n.admitWriter(again, laptop)
  await n.update()
  t.is(await maxWeight(n, again), null, 'the retired device cannot admit anything further')
  t.is(await maxWeight(n, writer), 0, 'and what it already held is removed, not left standing')
})

test('⛔ a revoked ADMIN device cannot answer a request either', async (t) => {
  const a = await who()
  const b = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  // The founder's second machine, an admin by descent, later retired.
  const laptop = await a.device.child()
  await n.revokeDevice(laptop.hex, a.device)
  await n.update()

  // ⛔ Found by MUTATION: removing the revocation check in `adminSigner` broke no test, because
  // every other revocation case was caught earlier — at the REQUEST, by the requester's own device
  // being retired. This is the path that reaches the admin check: a perfectly good request from
  // somebody else, answered by a device that no longer speaks for the identity.
  const writer = key('5')
  await n.requestWriter(writer, b.device)
  await n.update()
  await n.admitWriter(writer, laptop)
  await n.update()

  t.is(await maxWeight(n, writer), null, 'a retired device carries no authority to admit anyone')
})

test('⛔ revocation is TRANSITIVE — a descendant of a revoked device is refused', async (t) => {
  const a = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  const laptop = await a.device.child()
  const phone = await laptop.child()      // minted BY the laptop, with no mnemonic
  await n.revokeDevice(laptop.hex, a.device)
  await n.update()

  const writer = key('4')
  await n.requestWriter(writer, phone)
  await n.update()
  await n.admitWriter(writer, phone)
  await n.update()
  // A compromised device can mint descendants, so revoking it has to reach them or it reaches
  // nothing. Every descendant carries a chain that names its revoked ancestor.
  t.is(await maxWeight(n, writer), null, 'refused: it descends from a revoked device')
})

test('★ CLAIMING STAYS OPEN — an admin list is not a gate on names', async (t) => {
  const a = await who()
  const stranger = await who()
  const n = await founded(a.device)
  t.teardown(() => n.close())

  // ⛔ The load-bearing test of this file. Weight decides who carries the log; it must never decide
  // who may have a name. The forced number is what makes a name unsquattable, and it only works if
  // nobody can be turned away.
  await n.claim('alice', feed('a'), stranger.device)
  await n.update()
  t.is((await n.resolve('alice0')).feed, feed('a'), 'a total stranger claims a handle as before')
  t.is(await maxWeight(n, b4a.toString(n.base.local.key, 'hex')), 2, 'while carrying no weight')
})

test('★★ a second peer joins, is admitted, and becomes writable — the fleet case', async (t) => {
  const a = await who()
  const b = await who()
  const storeA = new Corestore(tmp())
  const one = await founded(a.device, storeA)

  const two = await ns(one.key, new Corestore(tmp()))
  const stop = pipe(one.base, two.base)
  t.teardown(async () => { stop(); await one.close(); await two.close() })

  // Wait on a REPLICATION FACT, not a duration: the founder's admin entry arriving is what proves
  // the joiner has the log. A fixed sleep passes on a fast machine and flakes on a loaded one.
  t.alike(await until(async () => {
    await two.update()
    const a = await two.admins()
    return a.length ? a : null
  }), [a.root], 'the joiner replicated the founder\'s admin list')
  t.is(two.base.writable, false, 'a joined peer starts unwritable — this is stortron today')

  await one.grantAdmin(b.root, a.device)
  await one.update()

  // ⛔ B ASKS, and A answers. B cannot admit itself: the grant would ride B's own weightless block
  // and be capped to 0, silently. This is the ceremony an invite automates — the final act belongs
  // to the inviter, because only a writer that already has weight can confer any.
  await two.request(b.device)
  t.alike(await until(async () => {
    await one.update()
    const r = await one.requests()
    return r.length ? r.map((x) => x.writer) : null
  }), [b4a.toString(two.base.local.key, 'hex')], 'the ask reached the founder')

  await one.admitWriter(b4a.toString(two.base.local.key, 'hex'), a.device)
  await one.update()
  await until(async () => { await two.update(); return two.base.writable })
  t.is(two.base.writable, true, 'admitted, and now carrying the log rather than appealing to it')
  // ⚠ And the founder must INGEST that admit before it can see it. `two.base.writable` says the
  // joiner's own apply granted it; this says the grant converged, which is the fact that matters.
  t.is(await until(async () => {
    await one.update()
    return await maxWeight(one, b4a.toString(two.base.local.key, 'hex'))
  }), 2, 'seen as an indexer by the founder too')
})