OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / register-rpc.test.js

194 lines · javascript ref:
// The swarm door onto registration — the same room as the HTTP one.
//
// ⛔ WHY IT EXISTS. favela-peer has no outbound HTTP (measured: no bare-fetch, no bare-https, nothing
// in lib/), so a peer asking a bridge for a name cannot use the HTTP route. Both already speak the
// swarm. What must NOT happen is a second copy of the rules, so these assert that this door reaches
// the same `admit.js` and refuses exactly what the other door refuses.

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 { registerHandlers } from '../lib/register-rpc.js'

const FEED = 'f'.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 = (policy = 'open', extra = {}) => ({
  registrations: new MemoryRegistrations({ policy }),
  admission: new Admission(),
  config: { host: 'ap.7rnx.net' },
  ...extra
})

test('★ a peer asks the policy before trying, rather than guessing from a refusal', async (t) => {
  const h = registerHandlers(deps('invite'))
  const p = await h.policy()
  t.is(p.policy, 'invite')
  t.is(p.host, 'ap.7rnx.net')
  t.ok(p.proof, 'and that this bridge requires a proof')
})

test('★★ the whole ceremony over the swarm door: challenge, sign, claim', async (t) => {
  const d = deps('open')
  const h = registerHandlers(d)
  const me = await identity()

  const { nonce } = await h.challenge()
  const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
  const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })

  t.is(r.status, 200)
  t.is(r.body.acct, 'alice@ap.7rnx.net', 'the name a person actually types')
  t.is(r.body.actor, 'https://ap.7rnx.net/ap/users/alice/actor')
  t.is((await d.registrations.get('alice')).root, me.rootHex)
})

test('⛔ it refuses exactly what the HTTP door refuses', async (t) => {
  const d = deps('open')
  const h = registerHandlers(d)
  const me = await identity()
  const other = await identity()

  t.is((await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED })).status, 403, 'no proof')

  const { nonce } = await h.challenge()
  const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), other.cred)
  const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })
  t.is(r.status, 403, 'a proof from somebody else')
  t.is(r.body.error, 'proof does not attest that identity')

  t.is(await d.registrations.get('alice'), null, 'and nothing was registered')
})

test('⛔ the feed is held at claim time, so a name is a served actor not a row in a file', async (t) => {
  const held = []
  const d = deps('open', { onRegistered: async (u) => { held.push(u) } })
  const h = registerHandlers(d)
  const me = await identity()

  const { nonce } = await h.challenge()
  const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
  t.is((await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })).status, 200)
  t.is(held.length, 1)
  t.is(held[0].feedKey, FEED, 'the bridge was told which feed to hold')
})

test('⚠ a name claimed but not servable says so, rather than promising an actor that is dead', async (t) => {
  const d = deps('open', { onRegistered: async () => { throw new Error('swarm unreachable') } })
  const h = registerHandlers(d)
  const me = await identity()

  const { nonce } = await h.challenge()
  const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
  const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })

  t.is(r.status, 202, 'claimed, and honest that it is not live')
  t.ok(/swarm unreachable/.test(r.body.pending), r.body.pending)
  t.ok(await d.registrations.get('alice'), 'the name IS held — releasing it would lose what the peer was told it had')
})

test('★ releasing over the same door needs the same proof', async (t) => {
  const d = deps('open')
  const h = registerHandlers(d)
  const me = await identity()

  let { nonce } = await h.challenge()
  let proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
  await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })

  t.is((await h.release({ username: 'alice', root: me.rootHex })).status, 403, 'unproven cannot burn it')
  t.ok(await d.registrations.get('alice'), 'still held')

  ;({ nonce } = await h.challenge())
  proof = signAttested(d.admission.releaseBytes({ nonce, username: 'alice' }), me.cred)
  t.is((await h.release({ username: 'alice', root: me.rootHex, nonce, proof })).status, 200, 'the holder can')
})

// ── the claim reply names the user's observation feed (hyper-social #82, I1 S3) ─────────────────────────────
//
// ⛔ SIGNED BY THE BRIDGE KEY (operator decision O-2 b). The peer pins the observer root it is handed only because
// it arrives signed by the swarm key it claimed against. And a RE-CLAIM returns it too — that is how a peer that
// registered before observation feeds existed (fleeky, ashatron) learns its feed.
test('★★ the claim reply carries a signed observer grant; a re-claim returns it again', async (t) => {
  const { verifyObserver } = await import('favela-identity/lib/observer-grant.js')
  const bridge = crypto.keyPair()
  const OBS = '0b'.repeat(32)
  const ROOT = 'e4'.repeat(32)
  const d = deps('open', {
    onRegistered: async () => {},
    observerFor: async ({ username, generation }) => ({ username, generation, observationFeed: OBS, observerRoot: ROOT }),
    bridgeKeyPair: bridge
  })
  const h = registerHandlers(d)
  const me = await identity()
  const claim = async () => {
    const { nonce } = await h.challenge()
    const proof = signAttested(d.admission.claimBytes({ nonce, username: 'fleeky', feedKey: FEED }), me.cred)
    return h.claim({ username: 'fleeky', root: me.rootHex, feedKey: FEED, nonce, proof })
  }
  const r = await claim()
  t.is(r.status, 200)
  const o = r.body.observer
  t.ok(o, 'the reply carries an observer grant')
  t.alike({ observationFeed: o.observationFeed, observerRoot: o.observerRoot }, { observationFeed: OBS, observerRoot: ROOT })
  t.is(verifyObserver({ username: 'fleeky', generation: r.body.generation, observationFeed: o.observationFeed, observerRoot: o.observerRoot }, o.sig, b4a.toString(bridge.publicKey, 'hex')), true, 'verifiable against the bridge key')

  const again = await claim()
  t.is(again.status, 200, 'a re-claim by the same holder is not "pending"')
  t.is(again.body.observer && again.body.observer.observationFeed, OBS, 'and returns the grant again')
})

test('no observer configured: the reply is unchanged (no grant field)', async (t) => {
  const d = deps('open', { onRegistered: async () => {} })
  const h = registerHandlers(d)
  const me = await identity()
  const { nonce } = await h.challenge()
  const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
  const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })
  t.is(r.status, 200)
  t.absent(r.body.observer)
})

// ⛔ A PEER THAT ASKS LATE ON A LONG-LIVED CONNECTION MUST STILL BE ANSWERED. 2026-10-14: a phone holding fleeky's
// feed stays connected to the bridge for hours (the bridge holds its feeds). The bridge opens its registration
// channel the moment the connection appears; the phone has none yet, so ITS protomux rejects that open
// (_requestSession → _rejectSession) and the bridge's channel closes. When the user later presses "claim", the
// phone's open meets no channel and no pair() handler and is rejected too: "channel closed", every time, until the
// connection drops. A fresh connection that asks at once (every earlier registration) never saw it.
test('★ a client that opens the registration channel LATE, on an already-muxed connection, is answered', async (t) => {
  const SecretStream = (await import('@hyperswarm/secret-stream')).default
  const Protomux = (await import('protomux')).default
  const { attachRegistration, PROTOCOL } = await import('../lib/register-rpc.js')
  const ProtomuxRPC = (await import('protomux-rpc')).default
  const c = (await import('compact-encoding')).default

  const bridge = new SecretStream(true)
  const peer = new SecretStream(false)
  bridge.rawStream.pipe(peer.rawStream).pipe(bridge.rawStream)
  t.teardown(() => { try { bridge.destroy() } catch {} try { peer.destroy() } catch {} })

  // As in production: the peer's stream already carries a mux (feed replication), so the bridge's early open is
  // processed — and rejected — on arrival.
  Protomux.from(peer)
  attachRegistration(bridge, registerHandlers(deps()))
  await new Promise((resolve) => setTimeout(resolve, 300))

  const rpc = new ProtomuxRPC(peer, { protocol: PROTOCOL, valueEncoding: c.json })
  const answer = await Promise.race([
    rpc.request('policy', {}).then((r) => ({ ok: r }), (e) => ({ err: e.message })),
    new Promise((resolve) => setTimeout(() => resolve({ err: 'timeout' }), 3000))
  ])
  t.ok(answer.ok, 'answered (was: ' + (answer.err || 'ok') + ')')
  t.is(answer.ok && answer.ok.policy, 'open')
})