/**
* NameRegistry — Autobase-backed P2P app-name registry.
*
* A global shared registry mapping flat app names (e.g., "pear-git-v3")
* to publisher identity + release-registry location. Names are claimed
* TOFU: first writer to append `claim-name` for a given name owns it.
* Apply rejects duplicate claims — Autobase linearization gives this
* for free.
*
* Moderation: because Autobase only replays ops from registered writers,
* a toxic writer can be REMOVED via `remove-writer` and their claims
* vanish from the linearized view on next reindex. Invites (ADD_WRITER)
* are the anti-spam gate — you choose who earns write access.
*
* Autobase shape (same pattern as ReleaseRegistry / IdentityRegistry):
* - Base keyed by the first writer's hypercore
* - Writers append JSON-encoded ops
* - Apply translates ops → Hyperbee view
*
* Ops:
* { type: 'claim-name', entry } — claim a name for a publisher
* { type: 'update-name', name, updates } — publisher only: rotate registryKey/metadata
* { type: 'release-name', name } — publisher only: tombstone, frees for reclaim
* { type: 'add-writer', publicKey } — grant another device claim authority
* { type: 'remove-writer', publicKey } — revoke write access
*
* Entry schema (for claim-name):
* name — flat string; unique across the registry
* publisher: {
* devicePubkey — hyperident device pubkey that signed the claim
* registryKey — the publisher's ReleaseRegistry autobase key
* serverKey — hyperident deploy topic (same as publisher's device pubkey today)
* identityRoot — the publisher's identity root pubkey (survives device rotation)
* }
* description? — human-readable summary
* claimedAt — ms epoch
*
* Hyperbee view keys:
* claim:<name> → entry
* by-publisher:<idRoot>:<name> → { name } (reverse index: "names this identity owns")
* by-device:<devicePubkey>:<name> → { name } (reverse index: "names this device signed")
* tombstone:<name> → { releasedAt, releasedBy } (soft delete; reclaimable)
*/
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import b4a from 'b4a'
import sodium from 'sodium-native'
// Imported UNCONDITIONALLY, and never injected. apply() must reach the same
// verdict on every peer replaying the same log — a registry that could not
// verify a chain would reject ops its peers accept, and the two views would
// diverge on identical history. Optional verification is not an option here.
import IdentityKey from 'keet-identity-key'
export const OP_CLAIM_NAME = 'claim-name'
export const OP_UPDATE_NAME = 'update-name'
export const OP_RELEASE_NAME = 'release-name'
export const OP_REANCHOR_NAME = 'reanchor-name'
export const OP_ADD_WRITER = 'add-writer'
export const OP_REMOVE_WRITER = 'remove-writer'
// Op version. Absent/1 = the legacy unsigned form, authorised by a bare string
// compare on `signedBy`. 2 = device-signed with an attestation chain back to
// the claim's identityRoot.
//
// Legacy ops are still accepted so that replaying EXISTING history reaches the
// same view it always did — `release-name` has been callable, so tombstones may
// already be in the log, and rejecting them on reindex would resurrect released
// names. Writers only ever emit v2. Closing the legacy path is a follow-up once
// the fleet is known to be on v2 (same in-band pattern as pgv3's schema guard).
export const OP_VERSION_SIGNED = 2
export class NameRegistry {
/**
* @param {Corestore} corestore
* @param {object} [opts]
* @param {string} [opts.namespace='name-registry']
* @param {Buffer|string} [opts.bootstrap] — hex or Buffer autobase key. If
* omitted, creates a NEW registry and this node becomes the first writer.
*/
constructor (corestore, opts = {}) {
this._store = corestore.namespace(opts.namespace || 'name-registry')
this._bootstrap = opts.bootstrap
? (Buffer.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(node.value.toString()) } catch { continue }
if (op.type === OP_CLAIM_NAME) {
const e = op.entry
if (!e || !e.name || !e.publisher) continue
if (!e.publisher.devicePubkey || !e.publisher.registryKey) continue
const key = 'claim:' + e.name
const existing = await view.get(key)
// First-claim-wins. If a live claim exists, reject.
// A tombstoned name (released by publisher) is reclaimable.
if (existing) {
const tomb = await view.get('tombstone:' + e.name)
if (!tomb) continue // name actively owned — reject
// tombstoned → allow reclaim, clear tombstone
await view.del('tombstone:' + e.name)
}
const entry = {
name: e.name,
publisher: {
devicePubkey: e.publisher.devicePubkey,
registryKey: e.publisher.registryKey,
serverKey: e.publisher.serverKey || e.publisher.devicePubkey,
identityRoot: e.publisher.identityRoot || e.publisher.devicePubkey
},
description: e.description || '',
claimedAt: e.claimedAt || Date.now()
}
await view.put(key, entry)
await view.put('by-publisher:' + entry.publisher.identityRoot + ':' + e.name, { name: e.name })
await view.put('by-device:' + entry.publisher.devicePubkey + ':' + e.name, { name: e.name })
}
if (op.type === OP_UPDATE_NAME) {
if (!op.name || !op.signedBy) continue
const existing = await view.get('claim:' + op.name)
if (!existing) continue
// v2: device signature + chain back to the claim's root.
// Legacy: the original string compare. See OP_VERSION_SIGNED.
if (!opAuthorised(op, existing.value)) continue
// Deep-copy `publisher`: `{ ...existing.value }` is shallow, so
// assigning through updated.publisher mutated the object read out of
// the bee rather than a copy of it.
const updated = {
...existing.value,
publisher: { ...existing.value.publisher }
}
if (op.updates && typeof op.updates === 'object') {
if (op.updates.registryKey) updated.publisher.registryKey = op.updates.registryKey
if (op.updates.serverKey) updated.publisher.serverKey = op.updates.serverKey
if (op.updates.description !== undefined) updated.description = op.updates.description
updated.updatedAt = Date.now()
}
await view.put('claim:' + op.name, updated)
}
// Re-anchor a claim whose identityRoot still holds a DEVICE key
// (written before org #142). Authorised by the key being replaced:
// the stale anchor IS this device, so signing proves control of it,
// and the chain proves which root that device belongs to. A claim
// already anchored to a root fails the anchor===device test and is
// left alone. See verifyReanchorOp.
if (op.type === OP_REANCHOR_NAME) {
if (!op.name) continue
const existing = await view.get('claim:' + op.name)
if (!existing) continue
const claim = existing.value
const newRoot = verifyReanchorOp(op, claim)
if (!newRoot) continue
const oldRoot = claim.publisher.identityRoot
await view.put('claim:' + op.name, {
...claim,
publisher: { ...claim.publisher, identityRoot: newRoot },
updatedAt: Date.now()
})
// The reverse index is keyed by the anchor, so it has to move with
// it — otherwise `listByPublisher(root)` misses the name and
// `listByPublisher(oldDeviceKey)` keeps reporting it.
await view.del('by-publisher:' + oldRoot + ':' + op.name)
await view.put('by-publisher:' + newRoot + ':' + op.name, { name: op.name })
}
if (op.type === OP_RELEASE_NAME) {
if (!op.name || !op.signedBy) continue
const existing = await view.get('claim:' + op.name)
if (!existing) continue
if (!opAuthorised(op, existing.value)) continue
// Don't delete the claim; tombstone allows audit + reclaim semantics
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) {
// eslint-disable-next-line no-console
console.error('[name-registry] addWriter failed:', err.message)
}
}
if (op.type === OP_REMOVE_WRITER && op.publicKey) {
try {
await base.removeWriter(b4a.from(op.publicKey, 'hex'))
} catch (err) {
// eslint-disable-next-line no-console
console.error('[name-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 () {
if (!this._base) return null
return b4a.toString(this._base.key, 'hex')
}
get writable () {
return !!(this._base && this._base.writable)
}
// ── Writes ───────────────────────────────────────────────────
async claim (entry) {
if (!this._ready) await this.ready()
if (!entry || !entry.name || !entry.publisher) {
throw new Error('claim requires { name, publisher: { devicePubkey, registryKey } }')
}
const op = {
type: OP_CLAIM_NAME,
entry: {
...entry,
claimedAt: entry.claimedAt || Date.now()
}
}
await this._base.append(b4a.from(JSON.stringify(op)))
await this.update()
}
async updateName (name, updates, signer) {
if (!this._ready) await this.ready()
if (!name) throw new Error('updateName requires { name }')
const op = buildAuthOp(OP_UPDATE_NAME, { name, updates: updates || {} }, signer)
await this._base.append(b4a.from(JSON.stringify(op)))
await this.update()
}
/**
* Move a claim's anchor from the device key it was mistakenly written with
* (org #142) to the identity root that attests that device.
*
* Only the device currently named as the anchor can do this, and only to
* its own chain's root — see verifyReanchorOp. No-op on a claim that is
* already root-anchored.
*/
async reanchor (name, signer) {
if (!this._ready) await this.ready()
if (!name) throw new Error('reanchor requires { name }')
if (!signer || typeof signer === 'string' || !signer.identityRoot ||
!signer.devicePubkey || !signer.deviceSecretKey || !signer.chain) {
throw new Error('reanchor requires a signer with { identityRoot, devicePubkey, deviceSecretKey, chain } — there is no unsigned form')
}
const op = signOp({
type: OP_REANCHOR_NAME,
v: OP_VERSION_SIGNED,
name,
newRoot: signer.identityRoot,
devicePubkey: signer.devicePubkey,
chain: signer.chain
}, signer.deviceSecretKey)
await this._base.append(b4a.from(JSON.stringify(op)))
await this.update()
}
async release (name, signer) {
if (!this._ready) await this.ready()
if (!name) throw new Error('release requires { name }')
const op = buildAuthOp(OP_RELEASE_NAME, { name }, signer)
await this._base.append(b4a.from(JSON.stringify(op)))
await this.update()
}
async addWriter (publicKey) {
if (!this._ready) await this.ready()
const hex = typeof publicKey === 'string' ? publicKey : b4a.toString(publicKey, 'hex')
const op = { type: OP_ADD_WRITER, publicKey: hex }
await this._base.append(b4a.from(JSON.stringify(op)))
await this.update()
}
async removeWriter (publicKey) {
if (!this._ready) await this.ready()
const hex = typeof publicKey === 'string' ? publicKey : b4a.toString(publicKey, 'hex')
const op = { type: OP_REMOVE_WRITER, publicKey: hex }
await this._base.append(b4a.from(JSON.stringify(op)))
await this.update()
}
// ── Reads ────────────────────────────────────────────────────
async resolve (name) {
if (!this._ready) await this.ready()
const entry = await this._view.get('claim:' + name)
if (!entry) return null
// If tombstoned, treat as unclaimed
const tomb = await this._view.get('tombstone:' + name)
if (tomb) 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: 'claim:', lt: 'claim:\xff' })) {
const tomb = await this._view.get('tombstone:' + entry.value.name)
if (tomb && !opts.includeReleased) continue
if (opts.publisher && entry.value.publisher.identityRoot !== opts.publisher) continue
out.push(entry.value)
}
return out
}
async listByPublisher (identityRoot) {
return this.list({ publisher: identityRoot })
}
async update () {
if (!this._ready) await this.ready()
return this._base.update()
}
replicate (stream) {
return this._base.replicate(stream)
}
get discoveryKey () {
return this._base ? this._base.discoveryKey : null
}
}
// ── Signed ops (v2) ────────────────────────────────────────────
//
// A name is owned by an identity ROOT, but the root key is not available to
// sign with: a linked device holds only its own device key plus a keet proof
// that the root attested it. So the op is signed by the DEVICE and carries the
// chain, exactly as pear-git-v3 authenticates envelopes
// (lib/auth/keet-verifier.js). Verification resolves device -> root and then
// checks that root against the claim.
/**
* Build an authorised update/release op.
*
* `signer` is the identity acting: `{ identityRoot, devicePubkey,
* deviceSecretKey, chain }` — exactly the fields loadOrCreateIdentity already
* returns. Produces a v2 signed op.
*
* A bare string is accepted as the legacy `signedBy` and produces an UNSIGNED
* op. That path exists only so callers not yet migrated keep working; it will
* stop being authorised when the legacy gate closes.
*/
export function buildAuthOp (type, fields, signer) {
if (typeof signer === 'string') {
if (!signer) throw new Error(type + ' requires a signer')
return { type, ...fields, signedBy: signer }
}
if (!signer || !signer.identityRoot) {
throw new Error(type + ' requires a signer with { identityRoot, devicePubkey, deviceSecretKey, chain }')
}
const { identityRoot, devicePubkey, deviceSecretKey, chain } = signer
if (!devicePubkey || !deviceSecretKey || !chain) {
// Refuse rather than silently emitting an op that apply will drop — a
// dropped op is indistinguishable from replication lag at the call site.
throw new Error(
type + ': signer is missing ' +
[!devicePubkey && 'devicePubkey', !deviceSecretKey && 'deviceSecretKey', !chain && 'chain']
.filter(Boolean).join(', ') +
' (an identity with no attestation chain cannot prove it owns a name)'
)
}
return signOp({
type,
v: OP_VERSION_SIGNED,
...fields,
signedBy: identityRoot,
devicePubkey,
chain
}, deviceSecretKey)
}
/**
* Canonical bytes an op's signature covers: every field except `sig`, keys
* sorted, JSON. Same shape as the release registry's canonicalBytes.
*/
export function canonicalOpBytes (op) {
const { sig, ...rest } = op // eslint-disable-line no-unused-vars
const ordered = {}
for (const k of Object.keys(rest).sort()) ordered[k] = rest[k]
return b4a.from(JSON.stringify(ordered))
}
/**
* Sign an op with a device secret key, returning a copy carrying `sig`.
*/
export function signOp (op, deviceSecretKey) {
const sig = b4a.alloc(sodium.crypto_sign_BYTES)
const secret = b4a.isBuffer(deviceSecretKey)
? deviceSecretKey
: b4a.from(deviceSecretKey, 'hex')
sodium.crypto_sign_detached(sig, canonicalOpBytes(op), secret)
return { ...op, sig: b4a.toString(sig, 'hex') }
}
/**
* Is this op authorised to act on `claim`?
*
* Returns true only when the op is signed by a device that the claim's
* identityRoot has attested. Fail-closed: any malformed field, any failed
* check, any thrown verifier is a refusal.
*
* Deterministic and side-effect free — it is called from apply(), so every
* peer must reach the same verdict from the same bytes.
*/
export function verifySignedOp (op, claim) {
if (!op || !claim || !claim.publisher) return false
const root = claim.publisher.identityRoot
if (!root || typeof op.signedBy !== 'string') return false
// The op must name the root that actually owns the claim.
if (op.signedBy !== root) return false
if (typeof op.devicePubkey !== 'string' ||
typeof op.chain !== 'string' ||
typeof op.sig !== 'string') {
return false
}
// 1. The chain proves "root R attested device D".
let verified
try {
const proof = b4a.from(op.chain, 'base64')
if (!proof || proof.length === 0) return false
verified = IdentityKey.verify(proof)
} catch {
return false
}
if (!verified || !verified.identityPublicKey || !verified.devicePublicKey) return false
// 2. That R is the claim's root, and that D is who the op says signed it.
// Without the second check the chain proves nothing about THIS op.
if (b4a.toString(verified.identityPublicKey, 'hex') !== root) return false
if (b4a.toString(verified.devicePublicKey, 'hex') !== op.devicePubkey) return false
// 3. D actually signed these exact bytes.
try {
const sig = b4a.from(op.sig, 'hex')
if (sig.length !== sodium.crypto_sign_BYTES) return false
const devicePub = b4a.from(op.devicePubkey, 'hex')
if (devicePub.length !== sodium.crypto_sign_PUBLICKEYBYTES) return false
return sodium.crypto_sign_verify_detached(sig, canonicalOpBytes(op), devicePub)
} catch {
return false
}
}
/**
* Verify a re-anchor op against the claim it targets.
*
* Distinct from verifySignedOp because that one requires the chain's root to
* equal the claim's anchor — which is false by construction here: the whole
* point is that the anchor is a DEVICE key, not a root.
*
* Fail-closed; returns the new root on success, null otherwise.
*
* @returns {string|null} the verified root (hex), or null
*/
export function verifyReanchorOp (op, claim) {
if (!op || !claim || !claim.publisher) return null
if (!(op.v >= OP_VERSION_SIGNED)) return null
if (typeof op.devicePubkey !== 'string' || typeof op.chain !== 'string' ||
typeof op.sig !== 'string' || typeof op.newRoot !== 'string') {
return null
}
// The stale anchor must BE this device. This is the authorisation: the
// signature below proves control of the very key being replaced. It also
// makes the op a no-op on an already-root-anchored claim, since a root is
// not a device key anyone can sign with here.
if (claim.publisher.identityRoot !== op.devicePubkey) return null
let verified
try {
const proof = b4a.from(op.chain, 'base64')
if (!proof || proof.length === 0) return null
verified = IdentityKey.verify(proof)
} catch {
return null
}
if (!verified || !verified.identityPublicKey || !verified.devicePublicKey) return null
// The chain must describe THIS device, and the op may only name the root
// that chain actually attests — otherwise a name could be re-pointed at an
// arbitrary identity.
if (b4a.toString(verified.devicePublicKey, 'hex') !== op.devicePubkey) return null
const root = b4a.toString(verified.identityPublicKey, 'hex')
if (root !== op.newRoot) return null
try {
const sig = b4a.from(op.sig, 'hex')
if (sig.length !== sodium.crypto_sign_BYTES) return null
const devicePub = b4a.from(op.devicePubkey, 'hex')
if (devicePub.length !== sodium.crypto_sign_PUBLICKEYBYTES) return null
if (!sodium.crypto_sign_verify_detached(sig, canonicalOpBytes(op), devicePub)) return null
} catch {
return null
}
return root
}
/**
* Authorisation gate shared by update-name and release-name.
*
* v2 ops must carry a valid device signature + chain. Legacy ops (no `v`)
* keep the original string compare so replaying existing history is unchanged
* — see OP_VERSION_SIGNED for why that path still exists.
*/
export function opAuthorised (op, claim) {
if (op && op.v >= OP_VERSION_SIGNED) return verifySignedOp(op, claim)
return !!(claim && claim.publisher && claim.publisher.identityRoot === op.signedBy)
}