OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / identity-resync.js

150 lines · javascript ref:
/**
 * Identity-autobase force-resync (clean re-join).
 *
 * The identity autobase is the ONE base in this stack with no recovery path.
 * Per-repo trackers have `IssueRegistry.forceResync` + `repo-add-writer`
 * (0.1.167, issue #3); orgs/issues ride hyper-rooms' doorbell. Identity has
 * neither, so a peer whose linearizer ends up with an EMPTY indexer set is
 * stuck permanently:
 *
 *   boot record pins a system core derived from indexers:[]  (index.js:629-644)
 *     -> getIndexedSystem() -> system.checkout(0)             (apply-state.js:803)
 *     -> SystemView({ empty: length === 0 }) -> info null     (system.js:87-108)
 *     -> sys.indexers = []                                    (system.js:243-247)
 *     -> linearizer built with 0 indexers                     (index.js:1320-1336)
 *     -> shouldMigrate() returns false at BOTH gates:         (apply-state.js:330-332)
 *          :331 !fastForwarding && !indexersUpdated
 *          :332 indexers.length === 0   // read as "genesis"
 *     -> nothing confirms -> indexedLength stays 0 -> boot record never advances
 *
 * Autobase cannot tell "no indexers YET" from "no indexers ANY MORE", so its
 * repair path is disabled for exactly the peer that needs it.
 *
 * ── Why a new KEYPAIR, not just a fresh corestore ──
 * For per-repo trackers the local writer key is primaryKey-derived, so a fresh
 * corestore is sufficient (and a fresh NAMESPACE is not — it re-derives the same
 * key). The identity base PINS `keyPair`, so its local key is a pure function of
 * that keypair, independent of store and namespace. The keypair is the only
 * lever. Proven in proof-identity-resync.mjs step 2.
 *
 * ── Why a SEPARATE writer keypair ──
 * pear-git.js currently pins the DEVICE identity as the writer keypair. Rotating
 * it would mean minting a new device identity, which is forbidden. So the resync
 * mints a dedicated writer keypair, persisted alongside the daemon's state and
 * used ONLY as the identity autobase's local writer. This is the separation
 * `memory/identity-vs-writer-keypair` prescribes and `pear-git.js:199-208`
 * already argues for.
 *
 * The re-joined registry is READ-ONLY until an indexer admits the new key
 * (`IdentityRegistry.addWriter(localKeyHex, { role: 'writer' })` on the origin).
 * That is deliberate — see the autobase golden rule: never write content before
 * admission.
 */

import { join } from '#node:path'
import fs from '#node:fs'
import Corestore from 'corestore'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { IdentityRegistry } from 'hyper-identity/auth/identity-registry'

const STATE_FILE = 'identity-resync.json'
const SIDE_DIR = 'identity-resync'

/**
 * Read the persisted resync record, or null.
 * Boot uses this to reopen from the side store — the heal MUST survive a
 * restart, otherwise the next boot reopens the stuck main-corestore base and
 * silently reverts the fix (the lesson from IssueRegistry's sideStore index).
 */
export function loadIdentityResync (storage) {
  try {
    const raw = fs.readFileSync(join(storage, STATE_FILE), 'utf8')
    const rec = JSON.parse(raw)
    if (!rec || typeof rec.gen !== 'number' || !rec.writerPublicKey || !rec.writerSecretKey) return null
    return rec
  } catch {
    return null
  }
}

function saveIdentityResync (storage, rec) {
  const tmp = join(storage, STATE_FILE + '.tmp')
  fs.writeFileSync(tmp, JSON.stringify(rec, null, 2), { mode: 0o600 })
  fs.renameSync(tmp, join(storage, STATE_FILE))
}

/** Materialize the keypair from a persisted record. */
export function keyPairFromRecord (rec) {
  return {
    publicKey: b4a.from(rec.writerPublicKey, 'hex'),
    secretKey: b4a.from(rec.writerSecretKey, 'hex')
  }
}

/** Corestore path for a given resync generation. */
export function sideStorePath (storage, gen) {
  return join(storage, SIDE_DIR, 'gen-' + gen, 'store')
}

/**
 * Open an IdentityRegistry from a resync side store.
 * Used by BOTH forceResyncIdentity (create) and boot (reopen), so the two paths
 * can never drift.
 */
export async function openResyncedIdentity ({ storage, bootstrapKey, rec, db = null }) {
  const store = new Corestore(sideStorePath(storage, rec.gen))
  await store.ready()
  const registry = new IdentityRegistry(store.namespace('identity'), {
    db,
    bootstrapKey,
    keyPair: keyPairFromRecord(rec)
  })
  await registry.ready()
  return { store, registry }
}

/**
 * Force a clean re-join of the identity autobase.
 *
 * @param {object} opts
 * @param {string} opts.storage       daemon storage dir
 * @param {string} opts.bootstrapKey  the identity autobase root (hex) — MUST be
 *                                    the same base, or this is a fork, not a heal
 * @param {object} [opts.db]          the direct-write hyperbee, passed through
 * @param {object} [opts.keyPair]     override (tests); otherwise minted
 * @returns {Promise<{ registry, store, rec, localKeyHex }>} read-only until admitted
 */
export async function forceResyncIdentity ({ storage, bootstrapKey, db = null, keyPair = null }) {
  if (!storage) throw new Error('forceResyncIdentity requires storage')
  if (!bootstrapKey) throw new Error('forceResyncIdentity requires bootstrapKey (same base, or this forks)')

  const prev = loadIdentityResync(storage)
  const gen = (prev ? prev.gen : 0) + 1
  const kp = keyPair || crypto.keyPair()

  const rec = {
    gen,
    writerPublicKey: b4a.toString(kp.publicKey, 'hex'),
    writerSecretKey: b4a.toString(kp.secretKey, 'hex'),
    bootstrapKey,
    // Informational: the key an operator must admit on the origin.
    createdAt: Date.now()
  }

  fs.mkdirSync(join(storage, SIDE_DIR, 'gen-' + gen), { recursive: true })
  // Persist BEFORE opening, so a crash mid-open still leaves the next boot
  // pointing at the new generation rather than the stuck one.
  saveIdentityResync(storage, rec)

  const { store, registry } = await openResyncedIdentity({ storage, bootstrapKey, rec, db })
  const localKeyHex = registry.base.local ? b4a.toString(registry.base.local.key, 'hex') : null

  // eslint-disable-next-line no-console
  console.error('[identity-resync] re-joined identity autobase in FRESH corestore (gen ' + gen +
    '); local writer ' + (localKeyHex ? localKeyHex.slice(0, 16) : '?') +
    ' — READ-ONLY until an indexer admits it')

  return { registry, store, rec, localKeyHex }
}