| … | … | @@ -309,6 +309,117 @@ export class AuthManager { |
| 309 | 309 | } |
| 310 | 310 | } |
| 311 | 311 | |
| 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 | ||
| 312 | 423 | // ==================== IDENTITY METHODS ==================== |
| 313 | 424 | |
| 314 | 425 | async getUserWithIdentities (identityId) { |
| … | … | @@ -322,10 +433,6 @@ export class AuthManager { |
| 322 | 433 | return this.identityRegistry.upgradePermissions(userId, resourceName, permissions) |
| 323 | 434 | } |
| 324 | 435 | |
| 325 | async generateChallenge (options = {}) { | |
| 326 | return await this.challengeManager.generate(options) | |
| 327 | } | |
| 328 | ||
| 329 | 436 | async verifyChallenge (challengeId, challenge) { |
| 330 | 437 | return await this.challengeManager.verify(challengeId, challenge) |
| 331 | 438 | } |
| … | … | @@ -542,6 +542,112 @@ export class IdentityRegistry { |
| 542 | 542 | return false |
| 543 | 543 | } |
| 544 | 544 | |
| 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 | ||
| 545 | 651 | // ==================== FEDERATION & LIFECYCLE ==================== |
| 546 | 652 | |
| 547 | 653 | /** |
| … | … | @@ -269,3 +269,109 @@ test('deleteUser', async (t) => { |
| 269 | 269 | |
| 270 | 270 | await cleanup() |
| 271 | 271 | }) |
| 272 | ||
| 273 | // --- New methods --- | |
| 274 | ||
| 275 | test('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 | ||
| 293 | test('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 | ||
| 309 | test('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 | ||
| 332 | test('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 | ||
| 354 | test('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 | }) |