/**
* ReleaseRegistry — Autobase-backed index of published releases.
*
* Multi-writer. Each publisher runs a registry; linked devices (or
* explicitly added writers) all append to the same logical registry.
* Clients replicate the registry by its bootstrap key over Hyperswarm
* to resolve app names → tree hashes + deploy locators.
*
* Autobase shape (follows hyperident's IdentityRegistry pattern):
* - Base: autobase keyed by the first writer's hypercore
* - Writers: append op records (JSON-encoded)
* - Apply: single apply function translates ops → Hyperbee view
* - View: Hyperbee with the same keyspace as the earlier single-writer
* implementation so downstream readers (getLatest, list, etc.) don't
* change
*
* Ops:
* { type: 'add-release', entry } — append a release record
* { type: 'remove-release', name, version }
* { type: 'add-writer', publicKey } — grant another device write
*
* Entry schema (unchanged):
* name, version, treeHash, serverKey, upgrade, deps, publishedAt,
* publisherDevicePubkey, sig?, acceptedConflicts?
*
* Hyperbee view keys (unchanged):
* release:<name>:<version> → entry
* latest:<name> → { version, publishedAt }
* by-tree:<treeHash> → { name, version }
*/
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import sodium from 'sodium-native'
export const OP_ADD_RELEASE = 'add-release'
export const OP_REMOVE_RELEASE = 'remove-release'
export const OP_ADD_WRITER = 'add-writer'
export class ReleaseRegistry {
/**
* @param {Corestore} corestore
* @param {object} [opts]
* @param {string} [opts.namespace='release-registry'] — corestore sub-namespace
* @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 || 'release-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_ADD_RELEASE) {
const e = op.entry
if (!e || !e.name || !e.version || !e.treeHash) continue
await view.put('release:' + e.name + ':' + e.version, e)
// Update latest pointer if this entry is newer
const latestKey = 'latest:' + e.name
const existing = await view.get(latestKey)
if (!existing || compareTimestamps(e.publishedAt, existing.value.publishedAt) > 0) {
await view.put(latestKey, { version: e.version, publishedAt: e.publishedAt })
}
await view.put('by-tree:' + e.treeHash, { name: e.name, version: e.version })
}
if (op.type === OP_REMOVE_RELEASE) {
const key = 'release:' + op.name + ':' + op.version
await view.del(key)
const latestKey = 'latest:' + op.name
const latest = await view.get(latestKey)
if (latest && latest.value.version === op.version) {
// Roll back to the next most recent release for this name
let fallback = null
const prefix = 'release:' + op.name + ':'
for await (const e of view.createReadStream({ gt: prefix, lt: prefix + '\xff' })) {
if (!fallback || compareTimestamps(e.value.publishedAt, fallback.publishedAt) > 0) {
fallback = e.value
}
}
if (fallback) {
await view.put(latestKey, { version: fallback.version, publishedAt: fallback.publishedAt })
} else {
await view.del(latestKey)
}
}
}
if (op.type === OP_ADD_WRITER && op.publicKey) {
try {
await base.addWriter(b4a.from(op.publicKey, 'hex'))
} catch {
// autobase throws if already a writer — safe to ignore
}
}
}
}
this._base = new Autobase(this._store, this._bootstrap, {
apply,
open: (store) => new Hyperbee(store.get({ name: 'releases', valueEncoding: 'json' }), {
keyEncoding: 'utf-8',
valueEncoding: 'json'
}),
valueEncoding: 'binary',
// Canonical keet multi-writer opts. Single-writer (the common
// publisher case) ignores these — they only matter once a publisher
// adds a mirror/linked-device writer, at which point optimistic
// append + periodic ack keeps the writers converging (per the keet
// pattern in multi-writer-autobase memory). Harmless before then.
optimistic: true,
ackInterval: 1000,
ackThreshold: 4
})
await this._base.ready()
this._view = this._base.view
this._ready = true
}
async close () {
if (this._base) await this._base.close()
this._ready = false
}
// ── Keys / identity ─────────────────────────────────────────
get key () { return this._base && this._base.key }
get keyHex () {
const k = this.key
return k ? b4a.toString(k, 'hex') : null
}
get localWriterKey () {
return this._base && this._base.local && this._base.local.key
}
get isWriter () {
return !!(this._base && this._base.writable)
}
// ── Ops (writer-only) ────────────────────────────────────────
async add (entry) {
if (!this._ready) await this.ready()
if (!this.isWriter) throw new Error('not a writer on this registry')
if (!entry || !entry.name || !entry.version || !entry.treeHash) {
throw new Error('release entry requires name, version, treeHash')
}
const normalized = {
name: entry.name,
version: entry.version,
// runtime drives the materializer (node vs godot vs bare→node alias)
// and the launcher shebang at install time. Drop-on-store would
// make installers fall back to 'node' and produce wrong launchers
// (e.g. `exec node` for a bare app → require.addon throws).
runtime: entry.runtime || 'node',
treeHash: entry.treeHash,
serverKey: entry.serverKey || null,
upgrade: entry.upgrade || null,
deps: entry.deps || [],
publishedAt: entry.publishedAt || new Date().toISOString(),
publisherDevicePubkey: entry.publisherDevicePubkey || null,
// MUST be carried: `sig` covers every field except itself, so a field
// dropped here is a field the stored entry no longer hashes to and the
// signature stops verifying. Anything added to the signed entry has to
// be added to this list too (org #144).
publisherChain: entry.publisherChain || null,
sig: entry.sig || null,
acceptedConflicts: entry.acceptedConflicts || null
}
await this._base.append(JSON.stringify({ type: OP_ADD_RELEASE, entry: normalized }))
await this._base.update()
return normalized
}
async remove (name, version) {
if (!this._ready) await this.ready()
if (!this.isWriter) throw new Error('not a writer on this registry')
await this._base.append(JSON.stringify({ type: OP_REMOVE_RELEASE, name, version }))
await this._base.update()
}
async addWriter (publicKey) {
if (!this._ready) await this.ready()
if (!this.isWriter) throw new Error('not a writer on this registry')
const hex = Buffer.isBuffer(publicKey) ? publicKey.toString('hex') : publicKey
await this._base.append(JSON.stringify({ type: OP_ADD_WRITER, publicKey: hex }))
await this._base.update()
}
// ── Queries (readable by anyone with the registry key) ───────
async get (name, version) {
if (!this._ready) await this.ready()
const entry = await this._view.get('release:' + name + ':' + version)
if (!entry) return null
assertEntryIntegrity(entry.value)
return entry.value
}
async latest (name) {
if (!this._ready) await this.ready()
const latest = await this._view.get('latest:' + name)
if (!latest) return null
return this.get(name, latest.value.version)
}
async lookupByTree (treeHash) {
if (!this._ready) await this.ready()
const ref = await this._view.get('by-tree:' + treeHash)
if (!ref) return null
return this.get(ref.value.name, ref.value.version)
}
async list (nameFilter) {
if (!this._ready) await this.ready()
const prefix = nameFilter ? 'release:' + nameFilter + ':' : 'release:'
const out = []
for await (const entry of this._view.createReadStream({ gt: prefix, lt: prefix + '\xff' })) {
out.push(entry.value)
}
return out
}
// ── Replication handle ───────────────────────────────────────
replicate (stream) {
return this._base.replicate(stream)
}
/**
* Pull updates from the network (call after the underlying cores
* have replicated new blocks).
*/
async update () {
if (!this._ready) await this.ready()
return this._base.update()
}
}
// ── Canonical signing (unchanged) ──────────────────────────────
export function canonicalBytes (entry) {
const { sig, ...rest } = entry // eslint-disable-line no-unused-vars
const keys = Object.keys(rest).sort()
const ordered = {}
for (const k of keys) ordered[k] = rest[k]
return Buffer.from(JSON.stringify(ordered))
}
export function signEntry (entry, secretKey) {
const sig = Buffer.alloc(64)
sodium.crypto_sign_detached(sig, canonicalBytes(entry), secretKey)
return { ...entry, sig: sig.toString('hex') }
}
/**
* Verify a release entry's signature against the device key it names.
*
* Transitional by design (org #144): an entry with NO `sig` passes. Every
* release published before signing was wired carries `sig: null`, and
* refusing those would make every installed app un-updatable and every
* existing release uninstallable. What this closes is the case that actually
* matters now — an entry that carries a signature which does not verify.
*
* Enforcement (refuse unsigned) is a later flip, once publishers have
* re-published. Until then this is a tamper check, not an authenticity
* guarantee: an attacker who can write to the registry can still simply omit
* the signature.
*
* @returns {{ ok: boolean, signed: boolean, reason?: string }}
*/
export function verifyReleaseEntry (entry) {
if (!entry) return { ok: false, signed: false, reason: 'no entry' }
if (!entry.sig) return { ok: true, signed: false }
const pubkey = entry.publisherDevicePubkey
if (!pubkey) {
return { ok: false, signed: true, reason: 'entry is signed but names no publisherDevicePubkey' }
}
let ok = false
try {
ok = verifyEntry(entry, pubkey)
} catch (err) {
return { ok: false, signed: true, reason: 'signature check threw: ' + err.message }
}
if (!ok) {
return {
ok: false,
signed: true,
reason: 'signature does not verify against publisherDevicePubkey ' + String(pubkey).slice(0, 12) + '…'
}
}
return { ok: true, signed: true }
}
/**
* Throwing form, used on every read path so no call site can forget it. A
* signed entry that fails verification is not a release, it is a tamper — and
* a loud failure degrades to "not found" in callers that catch, which is the
* safe direction.
*/
export function assertEntryIntegrity (entry) {
const v = verifyReleaseEntry(entry)
if (!v.ok) {
throw Object.assign(
new Error(
'release ' + (entry && entry.name) + '@' + (entry && entry.version) +
' failed verification: ' + v.reason
),
{ code: 'EBADRELEASESIG', entry }
)
}
return entry
}
export function verifyEntry (entry, publisherPublicKey) {
if (!entry.sig) return false
const sig = b4a.from(entry.sig, 'hex')
const pubkey = Buffer.isBuffer(publisherPublicKey)
? publisherPublicKey
: b4a.from(publisherPublicKey, 'hex')
return sodium.crypto_sign_verify_detached(sig, canonicalBytes(entry), pubkey)
}
function compareTimestamps (a, b) {
if (a === b) return 0
return a > b ? 1 : -1
}