// A namespace — human names that resolve to feeds.
//
// NAMING.md: hashes are unique, names are not global, and there are global namespaces you can
// SUBSCRIBE to. A namespace is not an authority. Two people on different namespaces resolve the
// same string to different feeds, and that is correct rather than a conflict.
//
// ⛔ First-claim-wins is a property of the LINEARIZATION, not a check-then-write. Two peers racing
// one name both append; the log picks an order and every peer replays that order. There is no
// window in which both succeed.
//
// ── Claims are ATTESTED ───────────────────────────────────────────────────────────────────────
//
// Every claim carries a chain proof over the op's canonical bytes, so it announces WHICH identity
// made it and cannot be re-aimed at another feed, name or namespace without breaking. What that
// still cannot prove is that the identity owns the feed — the namespace is a separate log with no
// access to a feed's `__founder__` — so the anchor is recorded and checked where the feed is held.
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, normalize, parseHandle, formatHandle, canonicalOpBytes } from '../lib/namespace.js'
import { identity } from './helpers.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-ns-'))
const feed = (n) => String(n).repeat(2).padEnd(64, '0').slice(0, 64)
// A throwaway identity and its first device — the credential shape the daemon holds on disk.
// ⚠ Two of these are two DIFFERENT PEOPLE. A test that means "one person, two machines" must use
// `.child()` off the same device, which is the blind-pairing path.
async function who () {
const id = await identity()
const device = await id.device()
return { root: id.rootHex, device }
}
async function ns (key = null, store = null) {
const n = new Namespace((store || new Corestore(tmp())).namespace('names'), key)
await n.ready()
return n
}
test('names are normalised on the way in and out', (t) => {
t.is(normalize(' Alice '), 'alice', 'trimmed and lower-cased')
// ⛔ Two claims differing only by case would be a namespace built for impersonation.
t.is(normalize('ALICE'), normalize('alice'))
t.is(normalize('rust-p2p'), 'rust-p2p')
t.is(normalize('a'), null, 'too short')
t.is(normalize('x'.repeat(33)), null, 'too long')
t.is(normalize('has space'), null)
t.is(normalize('has/slash'), null, 'a separator would collide with the key layout')
t.is(normalize('-leading'), null, 'must start alphanumeric')
// ⚠ Not unicode, deliberately: confusable characters are a real problem and a half-done
// homoglyph defence is worse than an alphabet a human can verify by eye.
t.is(normalize('аlice'), null, 'cyrillic а is not latin a')
})
test('a handle is the name with its number on the end', (t) => {
t.alike(parseHandle('alice0'), { name: 'alice', number: 0 }, 'the first claimant is alice0')
t.alike(parseHandle('Alice12'), { name: 'alice', number: 12 }, 'normalised')
t.is(parseHandle('alice'), null, 'a bare name is not a handle — there is no bare name')
t.is(parseHandle('alice00'), null, 'no leading zeros: one handle, one spelling')
t.is(formatHandle('alice', 3), 'alice3')
// ⛔ A name may not END in a digit, or the split is ambiguous: `alice12` would be both
// "alice number 12" and "alice1 number 2". That rule is what buys a separator-free handle.
t.is(normalize('alice1'), null, 'a name cannot end in a digit')
t.is(normalize('web3'), null, 'which costs real names, deliberately')
t.is(normalize('rust-p2p'), 'rust-p2p', 'digits inside are fine')
})
test('★ the same NAME can be held by several people', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
const b = await who()
await n.claim('alice', feed('a'), a.device)
await n.update()
await n.claim('alice', feed('b'), b.device)
await n.update()
// ⛔ The point of the number: a taken name never blocks anyone, so there is nothing to squat and
// no race to win.
const all = await n.candidates('alice')
t.alike(all.map((c) => c.handle), ['alice0', 'alice1'])
t.is((await n.resolve('alice0')).feed, feed('a'))
t.is((await n.resolve('alice1')).feed, feed('b'), 'the second claimant is not turned away')
})
test('⛔ a bare name does not resolve to one feed', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
await n.claim('alice', feed('a'), a.device)
await n.update()
// NAMING.md §3.2: an unresolvable name fails VISIBLY rather than having a choice guessed for it.
// Picking "the first alice" would be that guess wearing a helpful face.
t.is(await n.resolve('alice'), null)
t.is((await n.candidates('alice')).length, 1, 'the caller is given the candidates instead')
})
test('a handle is looked up in both directions', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
await n.claim('stortron', feed('c'), a.device)
await n.update()
t.is(await n.handleFor(feed('c')), 'stortron0', 'feed -> handle, for rendering a byline')
t.is(await n.handleFor(feed('d')), null)
})
test('★ changing your name is release then claim — and numbers are NEVER reused', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
const b = await who()
await n.claim('alice', feed('a'), a.device)
await n.update()
t.is(await n.handleFor(feed('a')), 'alice0')
await n.rename('alice0', 'ada', feed('a'), a.device)
await n.update()
t.is(await n.resolve('alice0'), null, 'the old handle is retired')
t.is(await n.handleFor(feed('a')), 'ada0', 'and the new one is theirs')
// ⚠ A recycled handle is exactly the "familiar name now denotes someone else" failure NAMING.md
// warns about, so the counter never goes backwards.
await n.claim('alice', feed('b'), b.device)
await n.update()
t.is(await n.resolve('alice0'), null, 'alice0 stays retired forever')
t.is((await n.resolve('alice1')).feed, feed('b'), 'the next claimant gets a fresh number')
})
test('⛔ a stranger may not release someone else\'s handle', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const owner = await who()
const stranger = await who()
await n.claim('alice', feed('a'), owner.device)
await n.update()
// ⛔ Appended to the base UNDER TEST, so the hostile op provably reaches apply. An earlier version
// of this test built a second Namespace on the same key in a separate Corestore and never
// replicated them, so the release never arrived and the assertion could not fail — green whether
// the refusal worked or not. The re-aim test below uses this same pattern for the same reason.
await n.base.append(b4a.from(JSON.stringify(n.releaseOp('alice0', stranger.device))), { optimistic: true })
await n.update()
t.ok(await n.resolve('alice0'), 'a stranger cannot free someone else\'s handle and take it')
// And the owner still can — otherwise the assertion above would also pass against a release path
// that was simply broken for everyone.
await n.release('alice0', owner.device)
await n.update()
t.is(await n.resolve('alice0'), null, 'while its owner still can')
})
test('★ a SIBLING DEVICE may release what another device claimed', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const me = await who()
// The blind-pairing path: an already-linked device attests the next one from its own keypair,
// with no mnemonic. Two devices, one identity.
const laptop = me.device
const phone = await laptop.child()
await n.claim('alice', feed('a'), laptop)
await n.update()
t.ok(await n.resolve('alice0'), 'claimed on the laptop')
await n.release('alice0', phone)
await n.update()
// ★ The limitation the old writer comparison could not express: one identity's two devices are
// two different writers, so a writer-scoped check stranded a name on the machine that made it.
t.is(await n.resolve('alice0'), null, 'and released from the phone — the anchor is the identity')
})
test('⛔ one identity holds at most ONE live claim per name', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
await n.claim('alice', feed('a'), a.device)
await n.update()
await n.claim('alice', feed('b'), a.device)
await n.update()
// Otherwise one person quietly accumulates alice0, alice1, alice2 and re-creates exactly the
// scarcity the forced number exists to abolish.
t.alike((await n.candidates('alice')).map((c) => c.handle), ['alice0'], 'the second is refused')
// ⛔ And releasing frees them to claim it again — or a release would be a permanent ban on your
// own name, which would make renaming impossible.
await n.release('alice0', a.device)
await n.update()
await n.claim('alice', feed('c'), a.device)
await n.update()
t.is((await n.resolve('alice1')).feed, feed('c'), 'after releasing, the same person may reclaim')
})
test('search is prefix-and-substring, and an empty box matches nothing', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
for (const [name, f] of [['stortron', 'c'], ['tabertron', 'd'], ['ashatron', 'e']]) {
await n.claim(name, feed(f), a.device)
}
await n.update()
t.is((await n.search('tron')).length, 3, 'substring matches all three')
t.alike((await n.search('stor')).map((c) => c.handle), ['stortron0'])
// Fail closed: an empty box is not a request for the whole namespace.
t.alike(await n.search(''), [], 'an empty query is not "everyone"')
t.alike(await n.search('nobody'), [])
})
test('list is name-ordered and omits released names', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
await n.claim('zeta', feed('1'), a.device)
await n.claim('alpha', feed('2'), a.device)
await n.claim('mid', feed('3'), a.device)
await n.update()
await n.release('mid0', a.device)
await n.update()
t.alike((await n.list()).map((c) => c.handle), ['alpha0', 'zeta0'],
'ordered, and the released one is gone')
})
test('⛔ a namespace is a SUBSCRIPTION — two of them disagree, correctly', async (t) => {
const one = await ns()
const two = await ns()
t.teardown(() => Promise.all([one.close(), two.close()]))
const a = await who()
const b = await who()
await one.claim('alice', feed('a'), a.device)
await two.claim('alice', feed('b'), b.device)
await one.update(); await two.update()
t.is((await one.resolve('alice0')).feed, feed('a'))
t.is((await two.resolve('alice0')).feed, feed('b'))
t.not(one.key.toString('hex'), two.key.toString('hex'),
'different namespaces are different logs — "alice" meaning two people is the property we chose, not a bug')
})
test('a malformed claim is refused without wedging apply', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
await t.exception(n.claim('a', feed('a'), a.device), /2-32 characters/)
await t.exception(n.claim('has space', feed('a'), a.device), /2-32 characters/)
// A non-key feed is dropped by apply rather than stored.
await n.base.append(b4a.from(JSON.stringify({ t: 'claim', name: 'ok', feed: 'nope' })), { optimistic: true })
await n.update()
t.alike(await n.candidates('ok'), [], 'and apply survived it')
})
test('⛔ claiming without a credential fails LOUDLY, not silently', async (t) => {
const n = await ns()
t.teardown(() => n.close())
// A claim this device could not sign would be dropped by every peer's apply — a failure with no
// error anywhere, which is the worst kind. So it refuses at the call.
await t.exception(n.claim('alice', feed('a')), /credential/)
await t.exception(n.release('alice0'), /credential/)
})
test('⛔ a claim must PROVE the claimant owns the feed', async (t) => {
const n = await ns()
t.teardown(() => n.close())
// A raw claim op with no proof of anything — the shape a stranger appends. The namespace is
// open by construction (optimistic), so reaching apply is not the question; being STORED is.
await n.base.append(b4a.from(JSON.stringify({
t: 'claim', name: 'alice', feed: feed('a'), at: 0
})), { optimistic: true })
await n.update()
t.alike(await n.candidates('alice'), [],
'an unattested claim is refused — otherwise anyone can point any name at anyone else\'s feed')
})
test('⛔ a signed claim cannot be RE-AIMED at another feed', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
// ⛔ The GENUINE op, appended honestly — then the SAME op with one field changed and the SAME
// proof still attached. That is what a thief actually has: a valid signature and a wish.
const op = n.claimOp('alice', feed('a'), a.device)
await n.base.append(b4a.from(JSON.stringify(op)), { optimistic: true })
await n.update()
t.is((await n.resolve('alice0')).feed, feed('a'), 'the honest claim landed')
const tampered = { ...op, feed: feed('b') }
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.alike((await n.candidates('alice')).map((c) => c.feed), [feed('a')],
'so the re-aimed claim is refused and the honest one stands')
})
test('⛔ a claim signed for one namespace does not LIFT into another', async (t) => {
const one = await ns()
const two = await ns()
t.teardown(() => Promise.all([one.close(), two.close()]))
const a = await who()
// The exact signed op, replayed verbatim into a namespace this person never joined. Without `ns`
// inside the signed bytes the proof would verify perfectly and mint their name there.
const op = one.claimOp('alice', feed('a'), 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.ok(await one.resolve('alice0'), 'it is a valid claim where it was signed')
t.alike(await two.candidates('alice'), [],
'and refused everywhere else — a proof names the namespace it was signed for')
})
test('resolve carries the ANCHOR, so the feed can be checked against it', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const a = await who()
await n.claim('alice', feed('a'), a.device)
await n.update()
const r = await n.resolve('alice0')
// ⛔ NAMING.md §3: a name resolves to an identity ROOT and only then to a room key. Apply cannot
// check root-owns-feed — it has no access to the feed — so it reports the root and the caller
// that HOLDS the feed compares it against `__founder__.identityRoot`.
t.is(r.root, a.root, 'the anchor is the identity root, not the device and not the writer core')
t.is(r.device, a.device.hex, 'and the signing device, which is what revocation targets')
t.is(r.feed, feed('a'))
})
test('⚠ a stranger CAN aim a name at someone else\'s feed — under their OWN root', async (t) => {
const n = await ns()
t.teardown(() => n.close())
const owner = await who()
const stranger = await who()
await n.claim('alice', feed('a'), owner.device)
await n.update()
// ⛔ This SUCCEEDS, and it is meant to. apply cannot prove that an identity owns a feed — the
// namespace is a separate log with no access to a feed's `__founder__` — so refusing it here is
// not on offer. What apply CAN do is record whose claim it is, truthfully and unforgeably.
await n.claim('bob', feed('a'), stranger.device)
await n.update()
const theirs = await n.resolve('bob0')
t.is(theirs.feed, feed('a'), 'pointed at a feed they do not own')
t.is(theirs.root, stranger.root, 'but anchored to THEIR root, not the owner\'s — which is the tell')
t.not(theirs.root, owner.root)
// ⇒ `handleVerdict` compares that root against the feed's founder and returns MISMATCH, and
// `confirmedHandles` drops it so it can never reach a byline. See handle-verdict.test.js.
})