// Two ways a peer ends up holding a name it cannot see — measured on a handset, both real.
//
// ⛔ THE SYMPTOM. `fleeky@ap.7rnx.net` was claimed from the phone on 2026-09-10 18:14:35, is in
// registrations.json against that device's own feed key, and answers webfinger and its actor URL
// with 200. The phone's settings page said "1. Your name — none yet", every boot, for a day.
//
// The surface records the name only when the bridge NAMES one:
//
// if (r.acct) await client.call('set-reach', { fediverse: r.acct }).catch(() => {})
//
// and `admitClaim`'s 202 body was `{ username, generation, pending }` — no `acct`. 202 is the path
// where the name IS claimed but holding the feed threw, so the one case that most needs recording is
// the one that recorded nothing. A later bridge boot rehydrated the held feed from registrations.json
// and the actor went live, with the device still knowing nothing about it.
//
// ⛔ AND IT COULD NOT BE UNDONE FROM THE DEVICE. Re-claiming returned `taken` — to the holder, whose
// proof had just been verified — and the release page returns to /settings unless `reach.fediverse`
// is set, which is the very thing that never got written. Name held, invisible, unreachable: the
// worst of the three, because everything involved reports success.
//
// A name is not the bridge's to withhold from the person who proved they hold it.
import test from 'brittle'
import b4a from 'b4a'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import { signAttested } from 'favela-identity/lib/attestation.js'
import { MemoryRegistrations } from '../lib/registrations.js'
import { Admission } from '../lib/admission.js'
import { admitClaim } from '../lib/admit.js'
const FEED = 'f'.repeat(64)
const OTHER_FEED = 'e'.repeat(64)
async function identity () {
const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
const kp = crypto.keyPair()
const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, kp.publicKey)
return { rootHex: b4a.toString(idk.identityKeyPair.publicKey, 'hex'), cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } }
}
const deps = (extra = {}) => ({
registrations: new MemoryRegistrations({ policy: 'open' }),
admission: new Admission(),
config: { host: 'ap.7rnx.net' },
...extra
})
async function claim (d, me, username, { feedKey = FEED } = {}) {
// ⛔ A FRESH NONCE PER ATTEMPT — it is single-use, so a reused one fails as "unknown or expired
// challenge", which reads like a hostile caller rather than a retry.
const { nonce } = d.admission.challenge()
const proof = signAttested(d.admission.claimBytes({ nonce, username, feedKey }), me.cred)
return admitClaim(d, { username, root: me.rootHex, feedKey, nonce, proof })
}
test('★★ the holder re-claiming their own name gets it back, not "taken"', async (t) => {
const d = deps()
const me = await identity()
const first = await claim(d, me, 'alice')
t.is(first.status, 200)
t.is(first.body.generation, 1)
// The device lost its local record — the 202 case below is exactly how — and asks again. Its proof
// is verified before this point, so the bridge already knows this is the holder.
const again = await claim(d, me, 'alice')
t.is(again.status, 200, 'the holder is admitted, not refused 409')
t.is(again.body.acct, 'alice@ap.7rnx.net', 'and told the name, which is the whole point of asking')
// ⛔ THE GENERATION MUST NOT MOVE. It is what makes a reused name a DIFFERENT actor: bumping it
// here would orphan every object id the live actor has already federated under generation 1.
t.is(again.body.generation, 1, 'the same actor, not a new one')
t.is((await d.registrations.get('alice')).generation, 1)
})
test('★★ a re-claim still refuses somebody else', async (t) => {
const d = deps()
const me = await identity()
const stranger = await identity()
t.is((await claim(d, me, 'alice')).status, 200)
const theirs = await claim(d, stranger, 'alice')
t.is(theirs.status, 409, 'a different root is still taken')
t.is(theirs.body.error, 'taken')
t.is((await d.registrations.get('alice')).root, me.rootHex, 'and the holder is unchanged')
})
test('★ re-claiming updates the feed key, because a device can be replaced', async (t) => {
const d = deps()
const me = await identity()
await claim(d, me, 'alice')
const moved = await claim(d, me, 'alice', { feedKey: OTHER_FEED })
t.is(moved.status, 200)
t.is((await d.registrations.get('alice')).feedKey, OTHER_FEED,
'the bridge holds the feed it was last told to hold')
})
test('★★★ a pending claim still names the acct — the case that recorded nothing', async (t) => {
// Holding the feed throws: the name IS claimed, the actor is not servable yet. This is the 202.
const d = deps({ onRegistered: async () => { throw new Error('swarm not ready') } })
const me = await identity()
const r = await claim(d, me, 'alice')
t.is(r.status, 202, 'claimed, and honest that it is not live yet')
t.ok(r.body.pending, 'the reason travels with it')
t.is(r.body.acct, 'alice@ap.7rnx.net',
'and so does the name — a peer records it on acct, so withholding it loses the name silently')
t.is(r.body.username, 'alice')
t.is(r.body.generation, 1)
})