OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / reclaim-and-pending.test.js

116 lines · javascript ref:
// 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)
})