OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / abdbb5a

identity: ship the origin's system key in the deployment bundle (+ resync no longer strips it)
A joining mirror had to DERIVE its own identity `_system` core. That core's manifest is a function of the indexer set (autobase/lib/store.js:541-578), so a mirror that ends up with an empty indexer set derives a DIFFERENT `_system` core — after which the origin's signed checkpoints are for a core it does not hold, and it can never converge. Observed on stortron: system core c50971db against tabertron's 2d5e40e6, same base key, indexedLength stuck at 0. Keet does not have this problem because the inviter TELLS the joiner which system core to adopt: every room invitation carries `fastForwardTo: base.getSystemKey()` (keet-core/index.js:1991,1999) and the joiner hands it to Autobase as `{ key }` (tasks/pairing/keys.js:46 → index.js:1643). Adopt the same shape: - dump-keys advertises identity.fastForwardTo via base.getSystemKey() - device-invite --mirror puts it in the deployment bundle - expandDeploymentBundle carries it into mounts.identity - startPearGit passes it to IdentityRegistry (null on the origin) Also fixes a strip bug in resync-from-origin, which rebuilt `identity` as `{ bootstrapKey }` and so would have silently DELETED fastForwardTo on the next resync, sending the mirror back to deriving its own system core with nothing logged. It now spreads the existing object and overlays only what the origin reports. Adds lib/services/identity-resync.js — the identity analogue of IssueRegistry.forceResync (0.1.167, #3): a clean re-join under a fresh corestore plus a freshly minted writer keypair, persisted so the heal survives a restart. Unlike per-repo trackers the identity base pins `keyPair`, so its local key is a pure function of that keypair and a fresh corestore alone changes nothing — the keypair is the lever. Read-only until an indexer admits the new key. Not wired to any RPC yet; break-glass only. All additive: bundles without the field behave exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
abdbb5a89e7a4abad645c63d47626d0e49452c74
by plankatron <fleeky@gmail.com> · 2026-08-09T21:29:27.000Z

Changed files (4) +377 -7

lib/handlers/devices.js +34 -1
……@@ -10,9 +10,33 @@
1010 * label-device — rename a device
1111 */
1212
13import b4a from 'b4a'
1314import { DeviceLinker } from '../device-linker.js'
1415import { requireAdmin, requireWriter, requireIdentity } from '../auth/check.js'
1516
17/**
18 * This daemon's identity-autobase `_system` core key, hex, or null.
19 *
20 * Shipped in the deployment bundle so a joining mirror can fast-forward onto
21 * OUR system state rather than deriving its own — the same thing keet puts in
22 * a room invitation (keet-core/index.js:1991). Deriving independently is how a
23 * mirror can end up on a divergent `_system` core (its manifest is a function
24 * of the indexer set) from which it can never converge.
25 *
26 * Fail-soft: `getSystemKey()` is autobase 7.x (index.js:271). If it is absent
27 * or throws, the field is null and the joiner behaves exactly as before.
28 */
29function identitySystemKeyHex (svc) {
30 try {
31 const base = svc.identityRegistry && svc.identityRegistry.base
32 if (!base || typeof base.getSystemKey !== 'function') return null
33 const key = base.getSystemKey()
34 return key ? b4a.toString(key, 'hex') : null
35 } catch {
36 return null
37 }
38}
39
1640export function deviceHandlers (svc) {
1741 // Active invite sessions — keyed by inviteHex. Cleaned up on completion
1842 // or timeout. At most one active invite at a time is typical, but we
……@@ -63,7 +87,16 @@ export function deviceHandlers (svc) {
6387 : { repos: {}, orgs: {} })
6488 deploymentBundle = {
6589 orgs: { bootstrapKey: svc.bootstrapKeys.orgs },
66 identity: { bootstrapKey: svc.bootstrapKeys.identity },
90 // fastForwardTo = THIS daemon's `_system` core key. The joiner
91 // fast-forwards onto our system state instead of deriving its own —
92 // exactly what keet puts in a room invitation
93 // (keet-core/index.js:1991 `fastForwardTo: e.base.getSystemKey()`).
94 // Best-effort: getSystemKey() is autobase 7.x (index.js:271); if it
95 // is missing the field is null and the joiner behaves as before.
96 identity: {
97 bootstrapKey: svc.bootstrapKeys.identity,
98 fastForwardTo: identitySystemKeyHex(svc)
99 },
67100 issueRegistry: issueKeys,
68101 repoEventLog: { coreKey: svc.bootstrapKeys.repoEventLog },
69102 orgEventLog: { coreKey: svc.bootstrapKeys.orgEventLog },
lib/pear-git.js +51 -6
……@@ -326,7 +326,11 @@ export async function startPearGit (opts = {}) {
326326 bootstrapKey: mirrorBootstrapKey,
327327 keyPair: mirrorBootstrapKey
328328 ? { publicKey: identity.publicKey, secretKey: identity.secretKey }
329 : undefined
329 : undefined,
330 // Mirror only: adopt the origin's `_system` core instead of deriving one.
331 // Null on the origin (it IS the system) and on any bundle predating the
332 // field, both of which behave as before.
333 fastForwardTo: (mountOpts.identity && mountOpts.identity.fastForwardTo) || null
330334 }
331335 )
332336
……@@ -907,7 +911,21 @@ export async function startPearGit (opts = {}) {
907911 return {
908912 orgs: { bootstrapKey: svc.bootstrapKeys.orgs },
909913 issues: issueKeys, // { repos: { name: keyHex }, orgs: { name: keyHex } }
910 identity: { bootstrapKey: svc.bootstrapKeys.identity },
914 // fastForwardTo = OUR `_system` core key, so a resyncing mirror can adopt
915 // our system state instead of deriving its own (keet ships the same value
916 // in every room invitation — keet-core/index.js:1991). Fail-soft: null on
917 // older autobase without getSystemKey(), and consumers treat it as absent.
918 identity: {
919 bootstrapKey: svc.bootstrapKeys.identity,
920 fastForwardTo: (() => {
921 try {
922 const b = identityRegistry.base
923 if (!b || typeof b.getSystemKey !== 'function') return null
924 const k = b.getSystemKey()
925 return k ? b4a.toString(k, 'hex') : null
926 } catch { return null }
927 })()
928 },
911929 repoEventLog: { coreKey: svc.bootstrapKeys.repoEventLog },
912930 orgEventLog: { coreKey: svc.bootstrapKeys.orgEventLog },
913931 releaseEventLog: { coreKey: svc.bootstrapKeys.releaseEventLog }
……@@ -2784,9 +2802,23 @@ export async function startPearGit (opts = {}) {
27842802 orgs: (keys.orgs && keys.orgs.bootstrapKey)
27852803 ? { bootstrapKey: keys.orgs.bootstrapKey }
27862804 : (existing.orgs || {}),
2787 identity: (keys.identity && keys.identity.bootstrapKey)
2788 ? { bootstrapKey: keys.identity.bootstrapKey }
2789 : (existing.identity || {}),
2805 // Spread `existing.identity` FIRST so sibling fields survive. Writing
2806 // `{ bootstrapKey }` alone silently DROPPED every other key on this
2807 // object — which would have deleted `fastForwardTo` (the origin's
2808 // `_system` core key) on the next resync, sending the mirror back to
2809 // deriving its own system state with nothing logged.
2810 //
2811 // Prefer a freshly-reported fastForwardTo when the origin sends one,
2812 // otherwise keep what we already have.
2813 identity: {
2814 ...(existing.identity || {}),
2815 ...((keys.identity && keys.identity.bootstrapKey)
2816 ? { bootstrapKey: keys.identity.bootstrapKey }
2817 : {}),
2818 ...((keys.identity && keys.identity.fastForwardTo)
2819 ? { fastForwardTo: keys.identity.fastForwardTo }
2820 : {})
2821 },
27902822 issueRegistry: keys.issues || existing.issueRegistry || { repos: {}, orgs: {} },
27912823 repoEventLog: (keys.repoEventLog && keys.repoEventLog.coreKey)
27922824 ? { coreKey: keys.repoEventLog.coreKey }
……@@ -3074,7 +3106,20 @@ function expandDeploymentBundle (opts) {
30743106 mounts.orgs = { bootstrapKey: b.orgs.bootstrapKey }
30753107 }
30763108 if (b.identity && b.identity.bootstrapKey && !(mounts.identity && mounts.identity.bootstrapKey)) {
3077 mounts.identity = { bootstrapKey: b.identity.bootstrapKey }
3109 // fastForwardTo = the ORIGIN's `_system` core key. Keet ships exactly this
3110 // in every room invitation (keet-core/index.js:1991 `fastForwardTo:
3111 // e.base.getSystemKey()`) and the joiner hands it to Autobase as `{ key }`
3112 // (tasks/pairing/keys.js:46 → index.js:1643). Without it a joining mirror
3113 // must DERIVE its own system state; a mirror that ends up with an empty
3114 // indexer set derives a DIFFERENT `_system` core (the manifest is a
3115 // function of the indexer set, autobase/lib/store.js:541-578), after which
3116 // the origin's signed checkpoints are for a core it does not hold and it
3117 // can never converge. Optional — older bundles lack the field and behave
3118 // exactly as before.
3119 mounts.identity = {
3120 bootstrapKey: b.identity.bootstrapKey,
3121 fastForwardTo: b.identity.fastForwardTo || null
3122 }
30783123 }
30793124 if (b.issueRegistry) {
30803125 if (b.issueRegistry.repos && !mounts.issueRepos) {
lib/services/identity-resync.js +149 -0
……@@ -0,0 +1,149 @@
1/**
2 * Identity-autobase force-resync (clean re-join).
3 *
4 * The identity autobase is the ONE base in this stack with no recovery path.
5 * Per-repo trackers have `IssueRegistry.forceResync` + `repo-add-writer`
6 * (0.1.167, issue #3); orgs/issues ride hyper-rooms' doorbell. Identity has
7 * neither, so a peer whose linearizer ends up with an EMPTY indexer set is
8 * stuck permanently:
9 *
10 * boot record pins a system core derived from indexers:[] (index.js:629-644)
11 * -> getIndexedSystem() -> system.checkout(0) (apply-state.js:803)
12 * -> SystemView({ empty: length === 0 }) -> info null (system.js:87-108)
13 * -> sys.indexers = [] (system.js:243-247)
14 * -> linearizer built with 0 indexers (index.js:1320-1336)
15 * -> shouldMigrate() returns false at BOTH gates: (apply-state.js:330-332)
16 * :331 !fastForwarding && !indexersUpdated
17 * :332 indexers.length === 0 // read as "genesis"
18 * -> nothing confirms -> indexedLength stays 0 -> boot record never advances
19 *
20 * Autobase cannot tell "no indexers YET" from "no indexers ANY MORE", so its
21 * repair path is disabled for exactly the peer that needs it.
22 *
23 * ── Why a new KEYPAIR, not just a fresh corestore ──
24 * For per-repo trackers the local writer key is primaryKey-derived, so a fresh
25 * corestore is sufficient (and a fresh NAMESPACE is not — it re-derives the same
26 * key). The identity base PINS `keyPair`, so its local key is a pure function of
27 * that keypair, independent of store and namespace. The keypair is the only
28 * lever. Proven in proof-identity-resync.mjs step 2.
29 *
30 * ── Why a SEPARATE writer keypair ──
31 * pear-git.js currently pins the DEVICE identity as the writer keypair. Rotating
32 * it would mean minting a new device identity, which is forbidden. So the resync
33 * mints a dedicated writer keypair, persisted alongside the daemon's state and
34 * used ONLY as the identity autobase's local writer. This is the separation
35 * `memory/identity-vs-writer-keypair` prescribes and `pear-git.js:199-208`
36 * already argues for.
37 *
38 * The re-joined registry is READ-ONLY until an indexer admits the new key
39 * (`IdentityRegistry.addWriter(localKeyHex, { role: 'writer' })` on the origin).
40 * That is deliberate — see the autobase golden rule: never write content before
41 * admission.
42 */
43
44import { join } from '#node:path'
45import fs from '#node:fs'
46import Corestore from 'corestore'
47import crypto from 'hypercore-crypto'
48import b4a from 'b4a'
49import { IdentityRegistry } from 'hyper-identity/auth/identity-registry'
50
51const STATE_FILE = 'identity-resync.json'
52const SIDE_DIR = 'identity-resync'
53
54/**
55 * Read the persisted resync record, or null.
56 * Boot uses this to reopen from the side store — the heal MUST survive a
57 * restart, otherwise the next boot reopens the stuck main-corestore base and
58 * silently reverts the fix (the lesson from IssueRegistry's sideStore index).
59 */
60export function loadIdentityResync (storage) {
61 try {
62 const raw = fs.readFileSync(join(storage, STATE_FILE), 'utf8')
63 const rec = JSON.parse(raw)
64 if (!rec || typeof rec.gen !== 'number' || !rec.writerPublicKey || !rec.writerSecretKey) return null
65 return rec
66 } catch {
67 return null
68 }
69}
70
71function saveIdentityResync (storage, rec) {
72 const tmp = join(storage, STATE_FILE + '.tmp')
73 fs.writeFileSync(tmp, JSON.stringify(rec, null, 2), { mode: 0o600 })
74 fs.renameSync(tmp, join(storage, STATE_FILE))
75}
76
77/** Materialize the keypair from a persisted record. */
78export function keyPairFromRecord (rec) {
79 return {
80 publicKey: b4a.from(rec.writerPublicKey, 'hex'),
81 secretKey: b4a.from(rec.writerSecretKey, 'hex')
82 }
83}
84
85/** Corestore path for a given resync generation. */
86export function sideStorePath (storage, gen) {
87 return join(storage, SIDE_DIR, 'gen-' + gen, 'store')
88}
89
90/**
91 * Open an IdentityRegistry from a resync side store.
92 * Used by BOTH forceResyncIdentity (create) and boot (reopen), so the two paths
93 * can never drift.
94 */
95export async function openResyncedIdentity ({ storage, bootstrapKey, rec, db = null }) {
96 const store = new Corestore(sideStorePath(storage, rec.gen))
97 await store.ready()
98 const registry = new IdentityRegistry(store.namespace('identity'), {
99 db,
100 bootstrapKey,
101 keyPair: keyPairFromRecord(rec)
102 })
103 await registry.ready()
104 return { store, registry }
105}
106
107/**
108 * Force a clean re-join of the identity autobase.
109 *
110 * @param {object} opts
111 * @param {string} opts.storage daemon storage dir
112 * @param {string} opts.bootstrapKey the identity autobase root (hex) — MUST be
113 * the same base, or this is a fork, not a heal
114 * @param {object} [opts.db] the direct-write hyperbee, passed through
115 * @param {object} [opts.keyPair] override (tests); otherwise minted
116 * @returns {Promise<{ registry, store, rec, localKeyHex }>} read-only until admitted
117 */
118export async function forceResyncIdentity ({ storage, bootstrapKey, db = null, keyPair = null }) {
119 if (!storage) throw new Error('forceResyncIdentity requires storage')
120 if (!bootstrapKey) throw new Error('forceResyncIdentity requires bootstrapKey (same base, or this forks)')
121
122 const prev = loadIdentityResync(storage)
123 const gen = (prev ? prev.gen : 0) + 1
124 const kp = keyPair || crypto.keyPair()
125
126 const rec = {
127 gen,
128 writerPublicKey: b4a.toString(kp.publicKey, 'hex'),
129 writerSecretKey: b4a.toString(kp.secretKey, 'hex'),
130 bootstrapKey,
131 // Informational: the key an operator must admit on the origin.
132 createdAt: Date.now()
133 }
134
135 fs.mkdirSync(join(storage, SIDE_DIR, 'gen-' + gen), { recursive: true })
136 // Persist BEFORE opening, so a crash mid-open still leaves the next boot
137 // pointing at the new generation rather than the stuck one.
138 saveIdentityResync(storage, rec)
139
140 const { store, registry } = await openResyncedIdentity({ storage, bootstrapKey, rec, db })
141 const localKeyHex = registry.base.local ? b4a.toString(registry.base.local.key, 'hex') : null
142
143 // eslint-disable-next-line no-console
144 console.error('[identity-resync] re-joined identity autobase in FRESH corestore (gen ' + gen +
145 '); local writer ' + (localKeyHex ? localKeyHex.slice(0, 16) : '?') +
146 ' — READ-ONLY until an indexer admits it')
147
148 return { registry, store, rec, localKeyHex }
149}
test/identity-resync.test.js +143 -0
……@@ -0,0 +1,143 @@
1// Identity-autobase force-resync + re-admission.
2//
3// Asserts the recovery contract:
4// 1. the re-joined peer uses a DIFFERENT local writer key (the keypair is the
5// lever for this base — it pins keyPair, so a fresh corestore alone is not
6// enough, unlike per-repo trackers)
7// 2. it can READ existing history while still read-only
8// 3. it is READ-ONLY until an indexer admits it (autobase golden rule:
9// never write content before admission)
10// 4. after admission it is writable, has a non-empty indexer set, and its
11// writes actually linearize — visible on the ORIGIN, not just locally
12// 5. the resync record round-trips, so a restart reopens the healed store
13// rather than reverting to the stuck one
14
15import test from 'brittle'
16import Corestore from 'corestore'
17import b4a from 'b4a'
18import fs from '#node:fs'
19import os from '#node:os'
20import path from '#node:path'
21import crypto from 'hypercore-crypto'
22import { IdentityRegistry } from 'hyper-identity/auth/identity-registry'
23import {
24 forceResyncIdentity,
25 loadIdentityResync,
26 openResyncedIdentity
27} from '../lib/services/identity-resync.js'
28
29const sleep = (ms) => new Promise(r => setTimeout(r, ms))
30
31function wire (a, b) {
32 const s1 = a.base.replicate(true)
33 const s2 = b.base.replicate(false)
34 s1.pipe(s2).pipe(s1)
35 return () => { try { s1.destroy(); s2.destroy() } catch {} }
36}
37
38async function tmpdir (name) {
39 const dir = path.join(os.tmpdir(), 'pgv3-identity-resync-' + name + '-' + crypto.randomBytes(6).toString('hex'))
40 fs.mkdirSync(dir, { recursive: true })
41 return dir
42}
43
44test('forceResyncIdentity: clean re-join + re-admission restores a working writer', async (t) => {
45 const originDir = await tmpdir('origin')
46 const peerDir = await tmpdir('peer')
47
48 // ── origin with real history ──
49 const originStore = new Corestore(path.join(originDir, 'store'))
50 await originStore.ready()
51 const origin = new IdentityRegistry(originStore.namespace('identity'), {})
52 await origin.ready()
53 const bootstrapKey = b4a.toString(origin.base.key, 'hex')
54 const seeded = await origin.createUser({ name: 'pre-existing-user' })
55
56 // ── the peer force-resyncs: fresh corestore + freshly minted writer keypair ──
57 const { registry: peer, store: peerStore, rec, localKeyHex } =
58 await forceResyncIdentity({ storage: peerDir, bootstrapKey })
59
60 t.ok(localKeyHex, 'resynced peer has a local writer key')
61 t.is(rec.gen, 1, 'first resync is generation 1')
62
63 const un = wire(origin, peer)
64 await sleep(1500)
65 try { await peer.base.update() } catch {}
66
67 // 2 + 3 — reads history, but must NOT be writable yet
68 t.absent(peer.base.writable, 'resynced peer is READ-ONLY before admission')
69 const readBeforeAdmit = await peer.getUser(seeded.userId)
70 t.ok(readBeforeAdmit, 'read-only peer can READ existing history')
71
72 // 4 — re-admission by the indexer
73 await origin.addWriter(localKeyHex, { role: 'writer', addedBy: 'test-origin' })
74 await origin.base.update()
75 await sleep(3000)
76 await peer.base.update()
77
78 t.ok(peer.base.writable, 'peer is WRITABLE after the indexer admits it')
79 const lz = peer.base.linearizer || (peer.base._applyState && peer.base._applyState.linearizer)
80 t.ok(lz && lz.indexers.length > 0, 'peer has a NON-EMPTY indexer set after admission')
81
82 // the payload: does a write actually persist and linearize?
83 const written = await peer.createUser({ name: 'written-through-healed-peer' })
84 await sleep(2500)
85 await peer.base.update()
86 await origin.base.update()
87
88 t.ok(await peer.getUser(written.userId), 'healed peer reads back its own write')
89 t.ok(await origin.getUser(written.userId), 'the ORIGIN sees the write — it really linearized')
90
91 un()
92 await peer.close(); await peerStore.close()
93 await origin.close(); await originStore.close()
94})
95
96test('the resync record survives a restart (the heal is not reverted on boot)', async (t) => {
97 const originDir = await tmpdir('origin2')
98 const peerDir = await tmpdir('peer2')
99
100 const originStore = new Corestore(path.join(originDir, 'store'))
101 await originStore.ready()
102 const origin = new IdentityRegistry(originStore.namespace('identity'), {})
103 await origin.ready()
104 const bootstrapKey = b4a.toString(origin.base.key, 'hex')
105
106 const first = await forceResyncIdentity({ storage: peerDir, bootstrapKey })
107 const firstLocal = first.localKeyHex
108 await first.registry.close(); await first.store.close()
109
110 // simulate a daemon restart: read the persisted record and reopen from it
111 const rec = loadIdentityResync(peerDir)
112 t.ok(rec, 'resync record persisted')
113 t.is(rec.gen, 1, 'record carries the generation')
114
115 const reopened = await openResyncedIdentity({ storage: peerDir, bootstrapKey, rec })
116 const reopenedLocal = b4a.toString(reopened.registry.base.local.key, 'hex')
117 t.is(reopenedLocal, firstLocal,
118 'reopening from the record yields the SAME writer key — the heal survives restart')
119
120 await reopened.registry.close(); await reopened.store.close()
121 await origin.close(); await originStore.close()
122})
123
124test('a second resync mints a NEW generation and a NEW writer key', async (t) => {
125 const originDir = await tmpdir('origin3')
126 const peerDir = await tmpdir('peer3')
127
128 const originStore = new Corestore(path.join(originDir, 'store'))
129 await originStore.ready()
130 const origin = new IdentityRegistry(originStore.namespace('identity'), {})
131 await origin.ready()
132 const bootstrapKey = b4a.toString(origin.base.key, 'hex')
133
134 const one = await forceResyncIdentity({ storage: peerDir, bootstrapKey })
135 await one.registry.close(); await one.store.close()
136
137 const two = await forceResyncIdentity({ storage: peerDir, bootstrapKey })
138 t.is(two.rec.gen, 2, 'generation increments')
139 t.not(two.localKeyHex, one.localKeyHex, 'second resync uses a DIFFERENT writer key')
140 await two.registry.close(); await two.store.close()
141
142 await origin.close(); await originStore.close()
143})