OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / handle-registry.js

228 lines · javascript ref:
/**
 * HandleRegistry — Autobase-backed P2P registry mapping human handles to
 * hyperident identities. `name -> { identityRoot, devicePubkey, metadata }`.
 *
 * This is the identity-layer generalization of pgh's app-name NameRegistry:
 * same proven mechanism (Autobase linearization gives TOFU first-claim-wins for
 * free; the writer set is the anti-spam gate; releases tombstone for reclaim),
 * but the value is pluggable identity data instead of a pgh publishing payload.
 * Standalone (its own autobase / swarm topic) so it can be shared across apps
 * without depending on pgh.
 *
 * Ops (JSON-encoded appends):
 *   { type: 'claim-handle', entry }              — claim a handle
 *   { type: 'update-handle', name, updates, signedBy }  — owner: rotate device/metadata
 *   { type: 'release-handle', name, signedBy }   — owner: tombstone (reclaimable)
 *   { type: 'add-writer', publicKey }            — grant claim authority
 *   { type: 'remove-writer', publicKey }         — revoke
 *
 * Claim entry:
 *   name          — flat handle string, unique
 *   identity: {
 *     identityRoot  — hyperident root pubkey (survives device rotation; the owner key)
 *     devicePubkey  — the device pubkey to reach the identity at (e.g. a shell address)
 *   }
 *   metadata?     — free-form (e.g. { kind: 'shell' })
 *   claimedAt     — ms epoch
 *
 * Hyperbee view keys:
 *   handle:<name>                    → entry
 *   by-identity:<identityRoot>:<name> → { name }
 *   by-device:<devicePubkey>:<name>   → { name }
 *   tombstone:<name>                 → { releasedAt, releasedBy }
 *
 * AuthZ note: update/release check op.signedBy === claim.identity.identityRoot,
 * an UNVERIFIED caller-supplied field (same TOFU model as pgh NameRegistry). The
 * real gate is Autobase write access. Cryptographic writer->identity binding is a
 * follow-up (see plan Phase 2, decision ii).
 */

import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import b4a from 'b4a'

export const OP_CLAIM_HANDLE = 'claim-handle'
export const OP_UPDATE_HANDLE = 'update-handle'
export const OP_RELEASE_HANDLE = 'release-handle'
export const OP_ADD_WRITER = 'add-writer'
export const OP_REMOVE_WRITER = 'remove-writer'

export class HandleRegistry {
  /**
   * @param {Corestore} corestore
   * @param {object} [opts]
   * @param {string} [opts.namespace='handle-registry']
   * @param {Buffer|string} [opts.bootstrap] — autobase key (hex/Buffer). Omit to
   *   create a NEW registry (this node becomes the first writer).
   */
  constructor (corestore, opts = {}) {
    this._store = corestore.namespace(opts.namespace || 'handle-registry')
    this._bootstrap = opts.bootstrap
      ? (b4a.isBuffer(opts.bootstrap) ? opts.bootstrap : b4a.from(opts.bootstrap, 'hex'))
      : null
    this._base = null
    this._view = null
    this._ready = false
  }

  async ready () {
    if (this._ready) return

    const apply = async (nodes, view, base) => {
      for (const node of nodes) {
        let op
        try { op = JSON.parse(b4a.toString(node.value)) } catch { continue }

        if (op.type === OP_CLAIM_HANDLE) {
          const e = op.entry
          if (!e || !e.name || !e.identity) continue
          if (!e.identity.identityRoot && !e.identity.devicePubkey) continue

          const key = 'handle:' + e.name
          const existing = await view.get(key)
          if (existing) {
            const tomb = await view.get('tombstone:' + e.name)
            if (!tomb) continue // actively owned — reject (first-claim-wins)
            await view.del('tombstone:' + e.name) // tombstoned → allow reclaim
          }

          const identityRoot = e.identity.identityRoot || e.identity.devicePubkey
          const devicePubkey = e.identity.devicePubkey || e.identity.identityRoot
          const entry = {
            name: e.name,
            identity: { identityRoot, devicePubkey },
            metadata: e.metadata || {},
            claimedAt: e.claimedAt || Date.now()
          }
          await view.put(key, entry)
          await view.put('by-identity:' + identityRoot + ':' + e.name, { name: e.name })
          await view.put('by-device:' + devicePubkey + ':' + e.name, { name: e.name })
        }

        if (op.type === OP_UPDATE_HANDLE) {
          if (!op.name || !op.signedBy) continue
          const existing = await view.get('handle:' + op.name)
          if (!existing) continue
          if (existing.value.identity.identityRoot !== op.signedBy) continue
          const updated = { ...existing.value, identity: { ...existing.value.identity } }
          if (op.updates && typeof op.updates === 'object') {
            if (op.updates.devicePubkey) updated.identity.devicePubkey = op.updates.devicePubkey
            if (op.updates.metadata) updated.metadata = { ...updated.metadata, ...op.updates.metadata }
            updated.updatedAt = Date.now()
          }
          await view.put('handle:' + op.name, updated)
        }

        if (op.type === OP_RELEASE_HANDLE) {
          if (!op.name || !op.signedBy) continue
          const existing = await view.get('handle:' + op.name)
          if (!existing) continue
          if (existing.value.identity.identityRoot !== op.signedBy) continue
          await view.put('tombstone:' + op.name, { releasedAt: Date.now(), releasedBy: op.signedBy })
        }

        if (op.type === OP_ADD_WRITER && op.publicKey) {
          try { await base.addWriter(b4a.from(op.publicKey, 'hex')) } catch (err) {
            console.error('[handle-registry] addWriter failed:', err.message)
          }
        }

        if (op.type === OP_REMOVE_WRITER && op.publicKey) {
          try { await base.removeWriter(b4a.from(op.publicKey, 'hex')) } catch (err) {
            console.error('[handle-registry] removeWriter failed:', err.message)
          }
        }
      }
    }

    this._base = new Autobase(this._store, this._bootstrap, {
      open: (store) => new Hyperbee(store.get('view'), { keyEncoding: 'utf-8', valueEncoding: 'json' }),
      apply,
      valueEncoding: 'binary'
    })

    await this._base.ready()
    this._view = this._base.view
    this._ready = true
  }

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

  get keyHex () { return this._base ? b4a.toString(this._base.key, 'hex') : null }
  get localKeyHex () { return this._base ? b4a.toString(this._base.local.key, 'hex') : null }
  get writable () { return !!(this._base && this._base.writable) }
  get discoveryKey () { return this._base ? this._base.discoveryKey : null }

  // ── Writes ───────────────────────────────────────────────────

  async claim (entry) {
    if (!this._ready) await this.ready()
    if (!entry || !entry.name || !entry.identity) {
      throw new Error('claim requires { name, identity: { identityRoot?, devicePubkey? } }')
    }
    const op = { type: OP_CLAIM_HANDLE, entry: { ...entry, claimedAt: entry.claimedAt || Date.now() } }
    await this._base.append(b4a.from(JSON.stringify(op)))
    await this.update()
  }

  async updateHandle (name, updates, signedBy) {
    if (!this._ready) await this.ready()
    if (!name || !signedBy) throw new Error('updateHandle requires { name, signedBy }')
    await this._base.append(b4a.from(JSON.stringify({ type: OP_UPDATE_HANDLE, name, updates: updates || {}, signedBy })))
    await this.update()
  }

  async release (name, signedBy) {
    if (!this._ready) await this.ready()
    if (!name || !signedBy) throw new Error('release requires { name, signedBy }')
    await this._base.append(b4a.from(JSON.stringify({ type: OP_RELEASE_HANDLE, name, signedBy })))
    await this.update()
  }

  async addWriter (publicKey) {
    if (!this._ready) await this.ready()
    const hex = typeof publicKey === 'string' ? publicKey : b4a.toString(publicKey, 'hex')
    await this._base.append(b4a.from(JSON.stringify({ type: OP_ADD_WRITER, publicKey: hex })))
    await this.update()
  }

  async removeWriter (publicKey) {
    if (!this._ready) await this.ready()
    const hex = typeof publicKey === 'string' ? publicKey : b4a.toString(publicKey, 'hex')
    await this._base.append(b4a.from(JSON.stringify({ type: OP_REMOVE_WRITER, publicKey: hex })))
    await this.update()
  }

  // ── Reads ────────────────────────────────────────────────────

  async resolve (name) {
    if (!this._ready) await this.ready()
    const entry = await this._view.get('handle:' + name)
    if (!entry) return null
    if (await this._view.get('tombstone:' + name)) return null
    return entry.value
  }

  async list (opts = {}) {
    if (!this._ready) await this.ready()
    const out = []
    for await (const entry of this._view.createReadStream({ gt: 'handle:', lt: 'handle:\xff' })) {
      if (!opts.includeReleased && await this._view.get('tombstone:' + entry.value.name)) continue
      if (opts.identity && entry.value.identity.identityRoot !== opts.identity) continue
      out.push(entry.value)
    }
    return out
  }

  async update () {
    if (!this._ready) await this.ready()
    return this._base.update()
  }

  replicate (stream) { return this._base.replicate(stream) }
}