OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 41564d4

register identity records via autobase view (cross-daemon visibility)
Pre-fix: registerIdentity wrote the full identity record + reverse indexes (pubkey, sshkey fingerprint, noise key) directly to a per-daemon Hyperbee `db`. That db is corestore-auto-derived per-machine and never replicates. Mirror peers saw user.identities=[<id>] in the replicated autobase view but had no full identity record locally — getIdentity returned null on the mirror, "Linked identities (0)" / "No SSH keys linked" symptom on stortron's UI even though tabertron's showed everything correct. Fix: 1. Add REGISTER_IDENTITY op type (lib/auth/identity-operations.js). 2. Apply handler writes the full record + all reverse indexes (pubkey:<hex>, sshkey:<fp>, noisekey:<hex>) into the autobase view (lib/auth/identity-apply.js). Replicates to mirrors. 3. registerIdentity() now appends a REGISTER_IDENTITY op via the autobase. Keeps a parallel write to local db for backward-compat during the migration window (legacy daemons still readable). 4. getIdentity / getIdentityByPublicKey / getIdentityBySSHKey / getIdentityByNoiseKey now try the view first (replicated, source of truth post-fix), fall back to local db (legacy/cache). Backward compat: existing identity records in any daemon's db remain readable. New writes flow through the view. No data deletion. Migration of existing tabertron-only records to the view is a separate one-shot op (read each db record, replay as REGISTER_IDENTITY op). Mechanism proof: pear-git-v3/test/identity-record-replication.test.js — 11/11 asserts pass. Test 1: register-identity op on primary → mirror's view has full record + all reverse indexes via standard autobase replication. Control: today's local-only db pattern → mirror.db is empty, lookup returns null (reproduces prod symptom). Downstream consumers (hypernote, pear-git, hyper-social-web) are not affected — adding an op type is additive; the registerIdentity API signature is unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
41564d41ae64532ede92f02a947c0024081bf65e
by plankatron <fleeky@gmail.com> · 2026-05-02T18:50:06.000Z

Changed files (3) +119 -27

lib/auth/identity-apply.js +22 -0
……@@ -439,6 +439,28 @@ export function createApplyFunction (opts = {}) {
439439 })
440440 break
441441 }
442
443 case OpTypes.REGISTER_IDENTITY: {
444 // Full identity record into the view (replicated to mirrors).
445 // Replaces the previous direct-write to a per-daemon
446 // Hyperbee (`db`), which was local-only. Mirror peers now
447 // see the full record + reverse indexes via standard
448 // autobase replication. See pear-git-v3/test/
449 // identity-record-replication.test.js for the proof.
450 const identity = op.identity
451 if (!identity || !identity.identityId) break
452 await b.put(`${prefix}identity:${identity.identityId}`, identity)
453 if (identity.publicKey) {
454 await b.put(`${prefix}pubkey:${identity.publicKey}`, { identityId: identity.identityId })
455 }
456 if (identity.method === 'ssh-key' && identity.credentials && identity.credentials.fingerprint) {
457 await b.put(`${prefix}sshkey:${identity.credentials.fingerprint}`, { identityId: identity.identityId })
458 }
459 if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
460 await b.put(`${prefix}noisekey:${identity.credentials.publicKey}`, { identityId: identity.identityId })
461 }
462 break
463 }
442464 }
443465 } catch (err) {
444466 console.error('[IdentityApply] Skipping malformed op:', err.message)
lib/auth/identity-operations.js +23 -1
……@@ -37,7 +37,17 @@ export const OpTypes = {
3737 // issues a canonical in-band `addWriter` op per namespace. This op
3838 // is a control-plane message for the enclosing app — the apply
3939 // here only records it, no side effects. See pear-git-v3 docs.
40 REGISTER_MIRROR_WRITERS: 'register-mirror-writers'
40 REGISTER_MIRROR_WRITERS: 'register-mirror-writers',
41 // Full identity record register. Writes the identity record (method,
42 // publicKey, credentials, fingerprint reverse-lookups) into the
43 // Autobase view so mirror peers replicate it. Replaces the previous
44 // direct-write to a per-daemon Hyperbee, which was local-only and
45 // broke cross-daemon visibility (mirrors saw user.identities=[<id>]
46 // but had no full record → "Linked identities (0)" / "No SSH keys
47 // linked" symptom).
48 // See pear-git-v3/test/identity-record-replication.test.js for the
49 // mechanical proof + control case.
50 REGISTER_IDENTITY: 'register-identity'
4151}
4252
4353/**
……@@ -152,6 +162,18 @@ export function validateOp (op) {
152162 }
153163 return { valid: true }
154164
165 case OpTypes.REGISTER_IDENTITY:
166 if (!op.identity || typeof op.identity !== 'object') {
167 return { valid: false, error: 'identity object required' }
168 }
169 if (!op.identity.identityId || typeof op.identity.identityId !== 'string') {
170 return { valid: false, error: 'identity.identityId required' }
171 }
172 if (!op.identity.method || typeof op.identity.method !== 'string') {
173 return { valid: false, error: 'identity.method required' }
174 }
175 return { valid: true }
176
155177 default:
156178 return { valid: false, error: `Unknown op type: ${op.type}` }
157179 }
lib/auth/identity-registry.js +74 -26
……@@ -194,65 +194,113 @@ export class IdentityRegistry {
194194 // ==================== IDENTITY OPERATIONS ====================
195195
196196 /**
197 * Register identity in direct-write store
198 * @param {Object} identity - { identityId, method, publicKey, ... }
197 * Register an identity record.
198 *
199 * Writes through the Autobase apply (`REGISTER_IDENTITY` op) so the
200 * full record + reverse indexes (pubkey, sshkey, noisekey) replicate
201 * to mirror peers via the autobase view. Keeps a parallel write to
202 * the local Hyperbee `db` for backward-compat with daemons that
203 * still read from there during the migration window.
204 *
205 * Pre-fix this method only wrote to `db` (local-only) — see
206 * pear-git-v3/test/identity-record-replication.test.js for the
207 * proof that putting it in the view fixes cross-daemon visibility.
208 *
209 * @param {Object} identity - { identityId, method, publicKey, credentials, ... }
199210 */
200211 async registerIdentity (identity) {
201 if (!this.db) throw new Error('No direct-write db configured')
202 await this.db.put(`identity:${identity.identityId}`, identity)
203
204 // Create reverse lookup indexes based on identity method
205 if (identity.method === 'ssh-key') {
206 const fingerprint = identity.credentials && identity.credentials.fingerprint
207 if (fingerprint) {
208 await this.db.put(`sshkey:${fingerprint}`, { identityId: identity.identityId })
209 }
210 } else if (identity.method === 'noise-key') {
211 const noiseKey = identity.credentials && identity.credentials.publicKey
212 if (noiseKey) {
213 await this.db.put(`noisekey:${noiseKey}`, { identityId: identity.identityId })
214 }
212 if (!identity || !identity.identityId || !identity.method) {
213 throw new Error('registerIdentity requires { identityId, method, ... }')
215214 }
216 if (identity.publicKey) {
217 await this.db.put(`pubkey:${identity.publicKey}`, { identityId: identity.identityId })
215 // 1. Append op to autobase — view picks up record + reverse indexes
216 // via apply, replicates to all peers.
217 await this._base.append(encodeOp({
218 type: OpTypes.REGISTER_IDENTITY,
219 identity
220 }))
221
222 // 2. Mirror to local db too, so legacy code paths reading directly
223 // from db keep working until everything reads from the view.
224 // (This write is local-only; no harm if it diverges — view is
225 // the source of truth.)
226 if (this.db) {
227 try {
228 await this.db.put(`identity:${identity.identityId}`, identity)
229 if (identity.method === 'ssh-key' && identity.credentials && identity.credentials.fingerprint) {
230 await this.db.put(`sshkey:${identity.credentials.fingerprint}`, { identityId: identity.identityId })
231 } else if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
232 await this.db.put(`noisekey:${identity.credentials.publicKey}`, { identityId: identity.identityId })
233 }
234 if (identity.publicKey) {
235 await this.db.put(`pubkey:${identity.publicKey}`, { identityId: identity.identityId })
236 }
237 } catch {
238 // db is best-effort cache; failure here doesn't undo the autobase append
239 }
218240 }
219241 }
220242
221243 /**
222 * Get identity by ID
244 * Get identity by ID. Try the autobase view first (replicated, source
245 * of truth post-fix); fall back to the local db (legacy/cache).
223246 */
224247 async getIdentity (identityId) {
248 if (this._view) {
249 const entry = await this._view.get(`${this._prefix}identity:${identityId}`).catch(() => null)
250 if (entry && entry.value) return entry.value
251 }
225252 if (!this.db) return null
226 const entry = await this.db.get(`identity:${identityId}`)
253 const entry = await this.db.get(`identity:${identityId}`).catch(() => null)
227254 return entry ? entry.value : null
228255 }
229256
230257 /**
231 * Get identity by public key
258 * Get identity by public key. View first, then db fallback.
232259 */
233260 async getIdentityByPublicKey (publicKey) {
261 if (this._view) {
262 const idx = await this._view.get(`${this._prefix}pubkey:${publicKey}`).catch(() => null)
263 if (idx && idx.value && idx.value.identityId) {
264 const id = await this.getIdentity(idx.value.identityId)
265 if (id) return id
266 }
267 }
234268 if (!this.db) return null
235 const entry = await this.db.get(`pubkey:${publicKey}`)
269 const entry = await this.db.get(`pubkey:${publicKey}`).catch(() => null)
236270 if (!entry) return null
237271 return this.getIdentity(entry.value.identityId)
238272 }
239273
240274 /**
241 * Get identity by SSH key fingerprint
275 * Get identity by SSH key fingerprint. View first, then db fallback.
242276 */
243277 async getIdentityBySSHKey (fingerprint) {
278 if (this._view) {
279 const idx = await this._view.get(`${this._prefix}sshkey:${fingerprint}`).catch(() => null)
280 if (idx && idx.value && idx.value.identityId) {
281 const id = await this.getIdentity(idx.value.identityId)
282 if (id) return id
283 }
284 }
244285 if (!this.db) return null
245 const entry = await this.db.get(`sshkey:${fingerprint}`)
286 const entry = await this.db.get(`sshkey:${fingerprint}`).catch(() => null)
246287 if (!entry) return null
247288 return this.getIdentity(entry.value.identityId)
248289 }
249290
250291 /**
251 * Get identity by Noise protocol public key
292 * Get identity by Noise protocol public key. View first, then db fallback.
252293 */
253294 async getIdentityByNoiseKey (noiseKey) {
295 if (this._view) {
296 const idx = await this._view.get(`${this._prefix}noisekey:${noiseKey}`).catch(() => null)
297 if (idx && idx.value && idx.value.identityId) {
298 const id = await this.getIdentity(idx.value.identityId)
299 if (id) return id
300 }
301 }
254302 if (!this.db) return null
255 const entry = await this.db.get(`noisekey:${noiseKey}`)
303 const entry = await this.db.get(`noisekey:${noiseKey}`).catch(() => null)
256304 if (!entry) return null
257305 return this.getIdentity(entry.value.identityId)
258306 }