OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / names.cjs

118 lines · javascript ref:
// Petname store (naming Phase N1, Tier 0) — the user's own name→target aliases
// plus a "seen" log of nicknames observed in the wild.
//
// Mirrors backend/contacts.js (a Hyperbee in the user's Corestore) but needs no
// crypto, so it's a .cjs that loads under Node too — unit-testable with a temp
// Corestore. Names are stored NORMALIZED (name-normalize.cjs) so lookup is
// canonical and homograph-safe.
//
// Layout:
//   pet!<normalizedName>  { name, key?, link?, label, addedAt, updatedAt }
//   seen!<keyHex>         { lastNickname, seenAt }   ← self-asserted, never resolved
const Hyperbee = require('hyperbee')
const { normalize } = require('./name-normalize.cjs')
const { normalizeCatalogLink } = require('./catalog-safety.cjs')

const MAX_PETNAMES = 10_000
const HEX64_RE = /^[0-9a-f]{64}$/i
const MAX_LINK = 300

class Names {
  constructor (store, { now = Date.now } = {}) {
    if (!store) throw new Error('Names requires a Corestore')
    this.store = store
    this._now = now
    this._bee = null
    this._ready = false
  }

  async ready () {
    if (this._ready) return
    const core = this.store.get({ name: 'pearbrowser-names-v1' })
    this._bee = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
    await this._bee.ready()
    this._ready = true
  }

  _req () { if (!this._ready) throw new Error('Names not ready — call ready() first') }

  async setPetname ({ name, key, link, label } = {}) {
    this._req()
    const n = normalize(name)
    if (!n) throw new Error('name required')
    const keyHex = (typeof key === 'string' && HEX64_RE.test(key)) ? key.toLowerCase() : null
    const normalizedLink = normalizeCatalogLink(link)
    const lk = normalizedLink && normalizedLink.length <= MAX_LINK ? normalizedLink : null
    if (!keyHex && !lk) throw new Error('petname needs a key or link')
    const existing = await this._bee.get('pet!' + n)
    if (!existing) {
      const count = (await this.list({ limit: MAX_PETNAMES })).length
      if (count >= MAX_PETNAMES) throw new Error(`Petname cap reached (${MAX_PETNAMES})`)
    }
    const rec = {
      name: n,
      key: keyHex,
      link: lk,
      label: typeof label === 'string' ? label.slice(0, 128) : (existing ? existing.value.label : name),
      addedAt: existing ? existing.value.addedAt : this._now(),
      updatedAt: this._now()
    }
    await this._bee.put('pet!' + n, rec)
    return rec
  }

  async lookupPetname (name) {
    this._req()
    const n = normalize(name)
    if (!n) return null
    const e = await this._bee.get('pet!' + n)
    return e ? e.value : null
  }

  async list ({ limit = 1000 } = {}) {
    this._req()
    const out = []
    let c = 0
    const cap = Math.min(Math.max(1, limit), MAX_PETNAMES)
    for await (const e of this._bee.createReadStream({ gte: 'pet!', lt: 'pet!~' })) {
      out.push(e.value)
      if (++c >= cap) break
    }
    return out
  }

  // Resolver-ready map { normalizedName: { key, link, label } } for resolve-name.cjs.
  async petnameMap () {
    const map = {}
    for (const p of await this.list({ limit: MAX_PETNAMES })) {
      map[p.name] = { key: p.key, link: p.link, label: p.label }
    }
    return map
  }

  async removePetname (name) {
    this._req()
    const n = normalize(name)
    if (n) await this._bee.del('pet!' + n)
  }

  // A nickname a publisher self-asserts (seen on a loaded drive). Recorded for
  // an "unverified" hint, NEVER resolved (that would be the centralized,
  // squattable corner of Zooko's triangle).
  async recordSeen (keyHex, nickname) {
    this._req()
    if (typeof keyHex !== 'string' || !HEX64_RE.test(keyHex)) return
    await this._bee.put('seen!' + keyHex.toLowerCase(), {
      lastNickname: String(nickname || '').slice(0, 128),
      seenAt: this._now()
    })
  }

  async close () {
    if (this._bee) { try { await this._bee.close() } catch (_) {} this._bee = null }
    this._ready = false
  }
}

module.exports = { Names, MAX_PETNAMES }