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