OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / release-registry.js

350 lines · javascript ref:
/**
 * 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
}