| … | … | @@ -10,9 +10,33 @@ |
| 10 | 10 | * label-device — rename a device |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import b4a from 'b4a' | |
| 13 | 14 | import { DeviceLinker } from '../device-linker.js' |
| 14 | 15 | import { requireAdmin, requireWriter, requireIdentity } from '../auth/check.js' |
| 15 | 16 | |
| 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 | */ | |
| 29 | function 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 | ||
| 16 | 40 | export function deviceHandlers (svc) { |
| 17 | 41 | // Active invite sessions — keyed by inviteHex. Cleaned up on completion |
| 18 | 42 | // or timeout. At most one active invite at a time is typical, but we |
| … | … | @@ -63,7 +87,16 @@ export function deviceHandlers (svc) { |
| 63 | 87 | : { repos: {}, orgs: {} }) |
| 64 | 88 | deploymentBundle = { |
| 65 | 89 | 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 | }, | |
| 67 | 100 | issueRegistry: issueKeys, |
| 68 | 101 | repoEventLog: { coreKey: svc.bootstrapKeys.repoEventLog }, |
| 69 | 102 | orgEventLog: { coreKey: svc.bootstrapKeys.orgEventLog }, |
| … | … | @@ -326,7 +326,11 @@ export async function startPearGit (opts = {}) { |
| 326 | 326 | bootstrapKey: mirrorBootstrapKey, |
| 327 | 327 | keyPair: mirrorBootstrapKey |
| 328 | 328 | ? { 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 | |
| 330 | 334 | } |
| 331 | 335 | ) |
| 332 | 336 | |
| … | … | @@ -907,7 +911,21 @@ export async function startPearGit (opts = {}) { |
| 907 | 911 | return { |
| 908 | 912 | orgs: { bootstrapKey: svc.bootstrapKeys.orgs }, |
| 909 | 913 | 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 | }, | |
| 911 | 929 | repoEventLog: { coreKey: svc.bootstrapKeys.repoEventLog }, |
| 912 | 930 | orgEventLog: { coreKey: svc.bootstrapKeys.orgEventLog }, |
| 913 | 931 | releaseEventLog: { coreKey: svc.bootstrapKeys.releaseEventLog } |
| … | … | @@ -2784,9 +2802,23 @@ export async function startPearGit (opts = {}) { |
| 2784 | 2802 | orgs: (keys.orgs && keys.orgs.bootstrapKey) |
| 2785 | 2803 | ? { bootstrapKey: keys.orgs.bootstrapKey } |
| 2786 | 2804 | : (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 | }, | |
| 2790 | 2822 | issueRegistry: keys.issues || existing.issueRegistry || { repos: {}, orgs: {} }, |
| 2791 | 2823 | repoEventLog: (keys.repoEventLog && keys.repoEventLog.coreKey) |
| 2792 | 2824 | ? { coreKey: keys.repoEventLog.coreKey } |
| … | … | @@ -3074,7 +3106,20 @@ function expandDeploymentBundle (opts) { |
| 3074 | 3106 | mounts.orgs = { bootstrapKey: b.orgs.bootstrapKey } |
| 3075 | 3107 | } |
| 3076 | 3108 | 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 | } | |
| 3078 | 3123 | } |
| 3079 | 3124 | if (b.issueRegistry) { |
| 3080 | 3125 | if (b.issueRegistry.repos && !mounts.issueRepos) { |
| … | … | @@ -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 | ||
| 44 | import { join } from '#node:path' | |
| 45 | import fs from '#node:fs' | |
| 46 | import Corestore from 'corestore' | |
| 47 | import crypto from 'hypercore-crypto' | |
| 48 | import b4a from 'b4a' | |
| 49 | import { IdentityRegistry } from 'hyper-identity/auth/identity-registry' | |
| 50 | ||
| 51 | const STATE_FILE = 'identity-resync.json' | |
| 52 | const 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 | */ | |
| 60 | export 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 | ||
| 71 | function 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. */ | |
| 78 | export 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. */ | |
| 86 | export 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 | */ | |
| 95 | export 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 | */ | |
| 118 | export 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 | } |
| … | … | @@ -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 | ||
| 15 | import test from 'brittle' | |
| 16 | import Corestore from 'corestore' | |
| 17 | import b4a from 'b4a' | |
| 18 | import fs from '#node:fs' | |
| 19 | import os from '#node:os' | |
| 20 | import path from '#node:path' | |
| 21 | import crypto from 'hypercore-crypto' | |
| 22 | import { IdentityRegistry } from 'hyper-identity/auth/identity-registry' | |
| 23 | import { | |
| 24 | forceResyncIdentity, | |
| 25 | loadIdentityResync, | |
| 26 | openResyncedIdentity | |
| 27 | } from '../lib/services/identity-resync.js' | |
| 28 | ||
| 29 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)) | |
| 30 | ||
| 31 | function 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 | ||
| 38 | async 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 | ||
| 44 | test('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 | ||
| 96 | test('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 | ||
| 124 | test('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 | }) |