/**
* 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) }
}