OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / test / name-registry-convergence.test.js

149 lines · javascript ref:
// N5 — the multi-writer registry over a real Autobase: two-writer convergence
// (incl. a conflicting claim that must resolve IDENTICALLY on both nodes) and the
// SPIKE-AUTOBEE-DURABILITY acceptance test (a claim survives ALL writers going
// offline, re-served from a fresh never-writer node). Offline, Node-only.
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Corestore from 'corestore'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import storeMod from '../backend/name-registry-store.cjs'
import resolveMod from '../backend/resolve-name.cjs'
const { NameRegistry } = storeMod
const { resolveName } = resolveMod

const ENC = b4a.alloc(32, 0x4e)
const hex = (x) => b4a.toString(x, 'hex')
const signer = (kp) => (msg) => hex(crypto.sign(b4a.from(msg, 'utf-8'), kp.secretKey))
const owner = (kp) => hex(kp.publicKey)
const TARGET_A = 'aa'.repeat(32); const TARGET_B = 'bb'.repeat(32); const TARGET_C = 'cc'.repeat(32)
const HYPER_LINK = `hyper://${TARGET_C}/link-only-app`

const tmps = []
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
function wire (a, b) {
  const s1 = a.replicate(true); const s2 = b.replicate(false)
  s1.on('error', () => {}); s2.on('error', () => {})
  s1.pipe(s2).pipe(s1)
  return () => { try { s1.destroy() } catch {} try { s2.destroy() } catch {} }
}
async function until (label, pred, ms = 12000) {
  const t0 = Date.now()
  while (Date.now() - t0 < ms) { if (await pred()) return true; await sleep(120) }
  throw new Error('timeout: ' + label)
}
async function newStore () {
  const dir = await mkdtemp(join(tmpdir(), 'namereg-')); tmps.push(dir)
  const store = new Corestore(dir); await store.ready()
  return { store, dir }
}

test('two-writer convergence: distinct claims replicate; a conflict resolves identically', async () => {
  const ka = crypto.keyPair(); const kb = crypto.keyPair() // op owners (separate from writer keys)
  const a = await newStore(); const b = await newStore()
  const A = await new NameRegistry(a.store, { encryptionKey: ENC, namespace: 'nr' }).ready()
  await A.claim({ name: 'alice', target: TARGET_A, owner: owner(ka) }, signer(ka))
  const B = await new NameRegistry(b.store, { bootstrap: A.key, encryptionKey: ENC, namespace: 'nr' }).ready()
  const unwire = wire(a.store, b.store)
  try {
    await until('B sees alice', async () => (await B.resolve('alice'))?.owner === owner(ka))
    assert.equal(B.writable, false)
    await A.addWriter(B.localKey)
    await until('B writable', async () => { await B.update(); return B.writable })
    await B.claim({ name: 'bob', target: TARGET_B, owner: owner(kb) }, signer(kb))
    // concurrent conflict: both claim 'carol' with different owners
    await A.claim({ name: 'carol', target: TARGET_A, owner: owner(ka) }, signer(ka))
    await B.claim({ name: 'carol', target: TARGET_B, owner: owner(kb) }, signer(kb))
    await until('converge', async () => {
      const aA = (await A.resolve('alice'))?.owner; const bA = (await B.resolve('alice'))?.owner
      const aB = (await A.resolve('bob'))?.owner; const bB = (await B.resolve('bob'))?.owner
      const aC = (await A.resolve('carol'))?.owner; const bC = (await B.resolve('carol'))?.owner
      return aA === owner(ka) && bA === owner(ka) && aB === owner(kb) && bB === owner(kb) && aC && aC === bC
    })
    // carol resolves to the SAME owner on both nodes — deterministic linear order
    assert.equal((await A.resolve('carol')).owner, (await B.resolve('carol')).owner)
  } finally { unwire(); await A.close(); await B.close(); await a.store.close(); await b.store.close() }
})

test('DURABILITY: a claim survives all writers going offline (fresh node re-serves)', async () => {
  const ka = crypto.keyPair()
  const a = await newStore(); const p = await newStore(); const c = await newStore()
  const A = await new NameRegistry(a.store, { encryptionKey: ENC, namespace: 'nr' }).ready()
  await A.claim({ name: 'durable', target: TARGET_C, owner: owner(ka) }, signer(ka))
  await until('A has durable', async () => (await A.resolve('durable'))?.target === TARGET_C)

  // replica P mirrors A
  const P = await new NameRegistry(p.store, { bootstrap: A.key, encryptionKey: ENC, namespace: 'nr' }).ready()
  const uw1 = wire(a.store, p.store)
  await until('P mirrors durable', async () => (await P.resolve('durable'))?.target === TARGET_C)

  // the ONLY writer goes offline
  uw1(); await A.close(); await a.store.close()

  // fresh never-writer C re-serves the claim from P
  const C = await new NameRegistry(c.store, { bootstrap: A.key, encryptionKey: ENC, namespace: 'nr' }).ready()
  const uw2 = wire(p.store, c.store)
  try {
    await until('C re-serves durable', async () => (await C.resolve('durable'))?.target === TARGET_C)
    const r = await C.resolve('durable')
    assert.equal(r.owner, owner(ka))
    assert.equal(r.target, TARGET_C)
  } finally { uw2(); await P.close(); await C.close(); await p.store.close(); await c.store.close() }
})

const PAYPAL_SQUAT = 'pаypаl' // latin p, Cyrillic а ×2 — skeleton folds to 'paypal'

test('store parity: releasing a sibling confusable keeps the skeleton blocked (homograph fix)', async () => {
  const ka = crypto.keyPair(); const kb = crypto.keyPair()
  const s = await newStore()
  const R = await new NameRegistry(s.store, { encryptionKey: ENC, namespace: 'nr' }).ready()
  try {
    await R.claim({ name: 'paypal', target: TARGET_A, owner: owner(ka) }, signer(ka))
    await R.claim({ name: PAYPAL_SQUAT, target: TARGET_A, owner: owner(ka) }, signer(ka)) // same-owner variant
    await R.release({ name: PAYPAL_SQUAT, owner: owner(ka) }, signer(ka))
    await R.claim({ name: PAYPAL_SQUAT, target: TARGET_B, owner: owner(kb) }, signer(kb)) // attacker squat
    assert.equal((await R.resolve('paypal')).owner, owner(ka)) // original still held
    assert.equal(await R.resolve(PAYPAL_SQUAT), null) // squat rejected in the LIVE store too
  } finally { await R.close(); await s.store.close() }
})

test('store list() includes non-ASCII (i18n) names — range-bound fix', async () => {
  const ka = crypto.keyPair()
  const s = await newStore()
  const R = await new NameRegistry(s.store, { encryptionKey: ENC, namespace: 'nr' }).ready()
  try {
    await R.claim({ name: 'ascii', target: TARGET_A, owner: owner(ka) }, signer(ka))
    await R.claim({ name: '日本語', target: TARGET_B, owner: owner(ka) }, signer(ka)) // sorts above '~'
    const list = await R.list()
    assert.equal(list.length, 2) // the non-ASCII name is NOT silently dropped
    assert.ok((await R.resolve('日本語')) != null)
  } finally { await R.close(); await s.store.close() }
})

test('end-to-end: claim → activeMap → resolveName (the CMD_NAME_RESOLVE path)', async () => {
  const ka = crypto.keyPair()
  const s = await newStore()
  const R = await new NameRegistry(s.store, { encryptionKey: ENC, namespace: 'nr' }).ready()
  try {
    await R.claim({ name: 'Alice', target: TARGET_A, owner: owner(ka) }, signer(ka))
    await R.claim({ name: 'LinkApp', target: HYPER_LINK, owner: owner(ka) }, signer(ka))
    const registry = await R.activeMap() // what the backend injects into the resolver
    // a typed bare name resolves through the registry tier to a navigable key
    const r = resolveName('alice', { petnames: {}, registry })
    assert.equal(r.provenance, 'registry')
    assert.equal(r.key, TARGET_A) // → go() builds hyper://<target>/
    const link = resolveName('linkapp', { petnames: {}, registry })
    assert.equal(link.provenance, 'registry')
    assert.equal(link.key, TARGET_C)
    assert.equal(link.link, HYPER_LINK)
    // a petname still wins over the registry (tier order preserved end-to-end)
    const pet = resolveName('alice', { petnames: { alice: { key: TARGET_B } }, registry })
    assert.equal(pet.provenance, 'petname')
  } finally { await R.close(); await s.store.close() }
})

test.after(async () => { for (const d of tmps) { try { await rm(d, { recursive: true, force: true }) } catch {} } })