OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 0a93140

Add merge protocol, import identity, user queries to hyper-identity
IdentityRegistry additions: - getUserWithIdentities() — resolve user + all linked identity objects - listPublicUsers() — users with usernames for public directory - deleteUserCascade() — delete user + cleanup identities, indexes - importIdentity() — link new keypair to existing user (device linking) AuthManager additions: - initiateMerge() — create merge request with challenge for target - confirmMerge() — verify target signature, execute merge - getMerge() — query merge request status Also included (from prior fix): - ChallengeManager: add get() and consume() methods (separate lookup from consumption) - WebIdentityProvider: use autoRegister() instead of manual 3-step creation - Challenge encoding: hex → base64 to match pear-git/hypernote - generateChallenge() exposed on AuthManager 5 new tests (51 total, 134 assertions) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
0a93140544513db1badc40577620f0b68055bf7c
by plankatron <fleeky@gmail.com> · 2026-03-25T00:52:38.000Z

Changed files (3) +323 -4

lib/auth.js +111 -4
……@@ -309,6 +309,117 @@ export class AuthManager {
309309 }
310310 }
311311
312 // ==================== ACCOUNT MERGE PROTOCOL ====================
313
314 /**
315 * Initiate an account merge. The primary user (caller) wants to merge
316 * the target user (identified by publicKey) into their account.
317 *
318 * Returns a merge request with a challenge that the target must sign.
319 *
320 * @param {string} primaryUserId - The user initiating the merge
321 * @param {string} targetPublicKey - Public key of the account to merge in
322 * @returns {Promise<Object>} { mergeId, challenge, expiresAt, primaryUserId, targetPublicKey }
323 */
324 async initiateMerge (primaryUserId, targetPublicKey) {
325 // Verify primary user exists
326 const primaryUser = await this.identityRegistry.getUser(primaryUserId)
327 if (!primaryUser) throw new Error('Primary user not found')
328
329 // Verify target identity exists and has a user
330 const targetIdentity = await this.identityRegistry.getIdentityByPublicKey(targetPublicKey)
331 if (!targetIdentity || !targetIdentity.userId) {
332 throw new Error('Target identity not found or not linked to a user')
333 }
334
335 if (targetIdentity.userId === primaryUserId) {
336 throw new Error('Cannot merge account with itself')
337 }
338
339 // Generate a merge challenge
340 const mergeChallenge = await this.challengeManager.generate({
341 metadata: {
342 type: 'account-merge',
343 primaryUserId,
344 targetPublicKey,
345 targetUserId: targetIdentity.userId
346 }
347 })
348
349 // Store merge request in db
350 if (this.db) {
351 await this.db.put(`merge:${mergeChallenge.challengeId}`, {
352 mergeId: mergeChallenge.challengeId,
353 primaryUserId,
354 targetPublicKey,
355 targetUserId: targetIdentity.userId,
356 challenge: mergeChallenge.challenge,
357 expiresAt: mergeChallenge.expiresAt,
358 status: 'pending'
359 })
360 }
361
362 return {
363 mergeId: mergeChallenge.challengeId,
364 challenge: mergeChallenge.challenge,
365 expiresAt: mergeChallenge.expiresAt,
366 primaryUserId,
367 targetPublicKey
368 }
369 }
370
371 /**
372 * Confirm a merge by providing the target user's signature.
373 * The target must sign the merge challenge with their private key.
374 *
375 * @param {string} mergeId - Merge request ID
376 * @param {string} signature - Target's signature of the challenge
377 * @returns {Promise<Object>} Merge result
378 */
379 async confirmMerge (mergeId, signature) {
380 if (!this.db) throw new Error('No db configured for merge')
381
382 const entry = await this.db.get(`merge:${mergeId}`)
383 if (!entry) throw new Error('Merge request not found')
384
385 const merge = entry.value
386 if (merge.status !== 'pending') throw new Error('Merge already completed or expired')
387 if (merge.expiresAt < Date.now()) throw new Error('Merge request expired')
388
389 // Verify the signature is from the target
390 const { ANProto } = await import('./anproto.js')
391 const valid = ANProto.verify(merge.challenge, signature, merge.targetPublicKey)
392 if (!valid) throw new Error('Invalid signature — must be signed by the target account')
393
394 // Execute the merge
395 await this.identityRegistry.mergeAccounts(merge.primaryUserId, merge.targetUserId)
396
397 // Mark merge as complete
398 merge.status = 'completed'
399 merge.completedAt = Date.now()
400 await this.db.put(`merge:${mergeId}`, merge)
401
402 return {
403 merged: true,
404 primaryUserId: merge.primaryUserId,
405 secondaryUserId: merge.targetUserId
406 }
407 }
408
409 /**
410 * Get merge request status
411 * @param {string} mergeId
412 * @returns {Promise<Object|null>}
413 */
414 async getMerge (mergeId) {
415 if (!this.db) return null
416 const entry = await this.db.get(`merge:${mergeId}`)
417 if (!entry) return null
418 // Don't expose the challenge value
419 const { challenge, ...safe } = entry.value
420 return safe
421 }
422
312423 // ==================== IDENTITY METHODS ====================
313424
314425 async getUserWithIdentities (identityId) {
……@@ -322,10 +433,6 @@ export class AuthManager {
322433 return this.identityRegistry.upgradePermissions(userId, resourceName, permissions)
323434 }
324435
325 async generateChallenge (options = {}) {
326 return await this.challengeManager.generate(options)
327 }
328
329436 async verifyChallenge (challengeId, challenge) {
330437 return await this.challengeManager.verify(challengeId, challenge)
331438 }
lib/auth/identity-registry.js +106 -0
……@@ -542,6 +542,112 @@ export class IdentityRegistry {
542542 return false
543543 }
544544
545 // ==================== USER QUERIES ====================
546
547 /**
548 * Get user with all linked identity objects resolved
549 * @param {string} userId
550 * @returns {Promise<{ user: Object, identities: Object[] } | null>}
551 */
552 async getUserWithIdentities (userId) {
553 const user = await this.getUser(userId)
554 if (!user) return null
555
556 const identities = []
557 for (const id of (user.identities || [])) {
558 const identity = await this.getIdentity(id)
559 if (identity) identities.push(identity)
560 }
561
562 return { user, identities }
563 }
564
565 /**
566 * List users who have set a username (public directory)
567 * @returns {Promise<Object[]>} Users with username, name, createdAt
568 */
569 async listPublicUsers () {
570 const users = []
571 for await (const entry of this._view.createReadStream({
572 gte: `${this._prefix}user:`,
573 lt: `${this._prefix}user:\xff`
574 })) {
575 const user = entry.value
576 if (user.username && !user.mergedInto) {
577 users.push({
578 userId: user.userId,
579 username: user.username,
580 name: user.name,
581 createdAt: user.createdAt
582 })
583 }
584 }
585 return users
586 }
587
588 /**
589 * Delete user and cascade cleanup (identities, indexes)
590 * @param {string} userId
591 */
592 async deleteUserCascade (userId) {
593 const user = await this.getUser(userId)
594 if (!user) return
595
596 // Remove identity records and pubkey indexes from direct-write db
597 if (this.db) {
598 for (const identityId of (user.identities || [])) {
599 const identity = await this.getIdentity(identityId)
600 if (identity && identity.publicKey) {
601 await this.db.del(`pubkey:${identity.publicKey}`)
602 }
603 await this.db.del(`identity:${identityId}`)
604 }
605
606 // Remove username index
607 if (user.username) {
608 await this.db.del(`username:${user.username}`)
609 }
610 }
611
612 // Delete user via Autobase
613 await this.deleteUser(userId)
614 }
615
616 /**
617 * Import a keypair into an existing user account.
618 * Registers the new identity and links it to the user.
619 * Used for device linking — same person, new browser/device.
620 *
621 * @param {string} userId - Existing user to link to
622 * @param {string} publicKey - New public key (44-char base64)
623 * @param {string} method - Auth method (default: 'web-identity')
624 * @returns {Promise<Object>} The new identity record
625 */
626 async importIdentity (userId, publicKey, method = 'web-identity') {
627 // Check user exists
628 const user = await this.getUser(userId)
629 if (!user) throw new Error('User not found')
630
631 // Check identity doesn't already exist
632 const existing = await this.getIdentityByPublicKey(publicKey)
633 if (existing) {
634 if (existing.userId === userId) return existing // Already linked
635 throw new Error('This key is already linked to a different user')
636 }
637
638 const identity = {
639 identityId: publicKey,
640 method,
641 publicKey,
642 userId,
643 createdAt: Date.now()
644 }
645 await this.registerIdentity(identity)
646 await this.linkIdentityToUser(publicKey, userId)
647
648 return identity
649 }
650
545651 // ==================== FEDERATION & LIFECYCLE ====================
546652
547653 /**
test/identity-registry.test.js +106 -0
……@@ -269,3 +269,109 @@ test('deleteUser', async (t) => {
269269
270270 await cleanup()
271271})
272
273// --- New methods ---
274
275test('getUserWithIdentities', async (t) => {
276 const { registry, cleanup } = await createRegistry()
277
278 const result = await registry.autoRegister({
279 identityId: 'key-a',
280 method: 'web-identity',
281 credentials: { publicKey: 'key-a' }
282 })
283
284 const full = await registry.getUserWithIdentities(result.user.userId)
285 t.ok(full, 'returns result')
286 t.is(full.user.userId, result.user.userId)
287 t.ok(full.identities.length >= 1, 'has identities')
288 t.is(full.identities[0].identityId, 'key-a')
289
290 await cleanup()
291})
292
293test('listPublicUsers', async (t) => {
294 const { registry, cleanup } = await createRegistry()
295
296 const u1 = await registry.createUser({ name: 'Alice' })
297 const u2 = await registry.createUser({ name: 'Bob' })
298
299 await registry.setUsername(u1.userId, 'alice')
300 // u2 has no username
301
302 const publicUsers = await registry.listPublicUsers()
303 t.is(publicUsers.length, 1, 'only users with username')
304 t.is(publicUsers[0].username, 'alice')
305
306 await cleanup()
307})
308
309test('deleteUserCascade', async (t) => {
310 const { registry, cleanup } = await createRegistry()
311
312 const result = await registry.autoRegister({
313 identityId: 'del-key',
314 method: 'web-identity',
315 credentials: { publicKey: 'del-key' }
316 })
317 await registry.setUsername(result.user.userId, 'deleteme')
318
319 // Verify everything exists
320 t.ok(await registry.getUser(result.user.userId), 'user exists')
321 t.ok(await registry.getIdentityByPublicKey('del-key'), 'pubkey index exists')
322 t.ok(await registry.getUserByUsername('deleteme'), 'username index exists')
323
324 await registry.deleteUserCascade(result.user.userId)
325
326 t.absent(await registry.getUser(result.user.userId), 'user gone')
327 t.absent(await registry.getIdentityByPublicKey('del-key'), 'pubkey index gone')
328
329 await cleanup()
330})
331
332test('importIdentity links new key to existing user', async (t) => {
333 const { registry, cleanup } = await createRegistry()
334
335 const result = await registry.autoRegister({
336 identityId: 'device1',
337 method: 'web-identity',
338 credentials: { publicKey: 'device1' }
339 })
340
341 // Import a second keypair for the same user
342 const imported = await registry.importIdentity(result.user.userId, 'device2')
343 t.is(imported.publicKey, 'device2')
344 t.is(imported.userId, result.user.userId)
345
346 // Both keys resolve to the same user
347 const user1 = await registry.getUserByIdentity('device1')
348 const user2 = await registry.getUserByIdentity('device2')
349 t.is(user1.userId, user2.userId, 'both devices resolve to same user')
350
351 await cleanup()
352})
353
354test('importIdentity rejects key already linked to different user', async (t) => {
355 const { registry, cleanup } = await createRegistry()
356
357 const u1 = await registry.autoRegister({
358 identityId: 'user1key',
359 method: 'web-identity',
360 credentials: { publicKey: 'user1key' }
361 })
362
363 await registry.autoRegister({
364 identityId: 'user2key',
365 method: 'web-identity',
366 credentials: { publicKey: 'user2key' }
367 })
368
369 try {
370 await registry.importIdentity(u1.user.userId, 'user2key')
371 t.fail('should have thrown')
372 } catch (err) {
373 t.ok(err.message.includes('already linked'), 'throws on conflict')
374 }
375
376 await cleanup()
377})