identity-repository.impl.ts raw

   1  import {
   2    IdentityRepositoryError,
   3    IdentityErrorCode,
   4  } from '../../domain/repositories/identity-repository';
   5  import type {
   6    IdentityRepository,
   7    IdentitySnapshot,
   8  } from '../../domain/repositories/identity-repository';
   9  import { IdentityId } from '../../domain/value-objects';
  10  import { EncryptionService } from '../encryption';
  11  import { NostrHelper } from '../../helpers/nostr-helper';
  12  
  13  /**
  14   * Encrypted identity as stored in browser sync storage.
  15   */
  16  interface EncryptedIdentity {
  17    id: string;
  18    nick: string;
  19    privkey: string;
  20    createdAt: string;
  21  }
  22  
  23  /**
  24   * Storage adapter interface - abstracts browser storage operations.
  25   * Implementations provided by Chrome/Firefox specific code.
  26   */
  27  export interface IdentityStorageAdapter {
  28    // Session (in-memory, decrypted) operations
  29    getSessionIdentities(): IdentitySnapshot[];
  30    setSessionIdentities(identities: IdentitySnapshot[]): void;
  31    saveSessionData(): Promise<void>;
  32  
  33    getSessionSelectedId(): string | null;
  34    setSessionSelectedId(id: string | null): void;
  35  
  36    // Sync (persistent, encrypted) operations
  37    getSyncIdentities(): EncryptedIdentity[];
  38    saveSyncIdentities(identities: EncryptedIdentity[]): Promise<void>;
  39  
  40    getSyncSelectedId(): string | null;
  41    saveSyncSelectedId(id: string | null): Promise<void>;
  42  }
  43  
  44  /**
  45   * Implementation of IdentityRepository using browser storage.
  46   * Handles encryption/decryption transparently.
  47   */
  48  export class BrowserIdentityRepository implements IdentityRepository {
  49    constructor(
  50      private readonly storage: IdentityStorageAdapter,
  51      private readonly encryption: EncryptionService
  52    ) {}
  53  
  54    async findById(id: IdentityId): Promise<IdentitySnapshot | undefined> {
  55      const identities = this.storage.getSessionIdentities();
  56      return identities.find((i) => i.id === id.value);
  57    }
  58  
  59    async findByPublicKey(publicKey: string): Promise<IdentitySnapshot | undefined> {
  60      const identities = this.storage.getSessionIdentities();
  61      return identities.find((i) => {
  62        try {
  63          const derivedPubkey = NostrHelper.pubkeyFromPrivkey(i.privkey);
  64          return derivedPubkey === publicKey;
  65        } catch {
  66          return false;
  67        }
  68      });
  69    }
  70  
  71    async findByPrivateKey(privateKey: string): Promise<IdentitySnapshot | undefined> {
  72      // Normalize the private key to hex format
  73      let privkeyHex: string;
  74      try {
  75        privkeyHex = NostrHelper.getNostrPrivkeyObject(privateKey.toLowerCase()).hex;
  76      } catch {
  77        return undefined;
  78      }
  79  
  80      const identities = this.storage.getSessionIdentities();
  81      return identities.find((i) => i.privkey === privkeyHex);
  82    }
  83  
  84    async findAll(): Promise<IdentitySnapshot[]> {
  85      return this.storage.getSessionIdentities();
  86    }
  87  
  88    async save(identity: IdentitySnapshot): Promise<void> {
  89      // Check for duplicate private key (excluding self)
  90      const existing = await this.findByPrivateKey(identity.privkey);
  91      if (existing && existing.id !== identity.id) {
  92        throw new IdentityRepositoryError(
  93          `An identity with the same private key already exists: ${existing.nick}`,
  94          IdentityErrorCode.DUPLICATE_PRIVATE_KEY
  95        );
  96      }
  97  
  98      // Update session storage
  99      const sessionIdentities = this.storage.getSessionIdentities();
 100      const existingIndex = sessionIdentities.findIndex((i) => i.id === identity.id);
 101  
 102      if (existingIndex >= 0) {
 103        // Update existing
 104        sessionIdentities[existingIndex] = identity;
 105      } else {
 106        // Add new
 107        sessionIdentities.push(identity);
 108  
 109        // Auto-select if first identity
 110        if (sessionIdentities.length === 1) {
 111          this.storage.setSessionSelectedId(identity.id);
 112        }
 113      }
 114  
 115      this.storage.setSessionIdentities(sessionIdentities);
 116      await this.storage.saveSessionData();
 117  
 118      // Encrypt and save to sync storage
 119      const encryptedIdentity = await this.encryptIdentity(identity);
 120      const syncIdentities = this.storage.getSyncIdentities();
 121      const syncIndex = syncIdentities.findIndex(
 122        async (i) => (await this.encryption.decryptString(i.id)) === identity.id
 123      );
 124  
 125      if (syncIndex >= 0) {
 126        syncIdentities[syncIndex] = encryptedIdentity;
 127      } else {
 128        syncIdentities.push(encryptedIdentity);
 129      }
 130  
 131      await this.storage.saveSyncIdentities(syncIdentities);
 132  
 133      // Update selected ID in sync if this was the first identity
 134      if (sessionIdentities.length === 1) {
 135        const encryptedId = await this.encryption.encryptString(identity.id);
 136        await this.storage.saveSyncSelectedId(encryptedId);
 137      }
 138    }
 139  
 140    async delete(id: IdentityId): Promise<boolean> {
 141      const sessionIdentities = this.storage.getSessionIdentities();
 142      const initialLength = sessionIdentities.length;
 143      const filtered = sessionIdentities.filter((i) => i.id !== id.value);
 144  
 145      if (filtered.length === initialLength) {
 146        return false; // Nothing was deleted
 147      }
 148  
 149      // Update selected identity if needed
 150      const currentSelectedId = this.storage.getSessionSelectedId();
 151      if (currentSelectedId === id.value) {
 152        const newSelectedId = filtered.length > 0 ? filtered[0].id : null;
 153        this.storage.setSessionSelectedId(newSelectedId);
 154      }
 155  
 156      this.storage.setSessionIdentities(filtered);
 157      await this.storage.saveSessionData();
 158  
 159      // Remove from sync storage
 160      const encryptedId = await this.encryption.encryptString(id.value);
 161      const syncIdentities = this.storage.getSyncIdentities();
 162      const filteredSync = syncIdentities.filter((i) => i.id !== encryptedId);
 163      await this.storage.saveSyncIdentities(filteredSync);
 164  
 165      // Update selected ID in sync
 166      const newSelectedId = this.storage.getSessionSelectedId();
 167      const encryptedSelectedId = newSelectedId
 168        ? await this.encryption.encryptString(newSelectedId)
 169        : null;
 170      await this.storage.saveSyncSelectedId(encryptedSelectedId);
 171  
 172      return true;
 173    }
 174  
 175    async getSelectedId(): Promise<IdentityId | null> {
 176      const selectedId = this.storage.getSessionSelectedId();
 177      return selectedId ? IdentityId.from(selectedId) : null;
 178    }
 179  
 180    async setSelectedId(id: IdentityId | null): Promise<void> {
 181      if (id) {
 182        // Verify the identity exists
 183        const exists = await this.findById(id);
 184        if (!exists) {
 185          throw new IdentityRepositoryError(
 186            `Identity not found: ${id.value}`,
 187            IdentityErrorCode.NOT_FOUND
 188          );
 189        }
 190      }
 191  
 192      this.storage.setSessionSelectedId(id?.value ?? null);
 193      await this.storage.saveSessionData();
 194  
 195      // Update sync storage
 196      const encryptedId = id
 197        ? await this.encryption.encryptString(id.value)
 198        : null;
 199      await this.storage.saveSyncSelectedId(encryptedId);
 200    }
 201  
 202    async count(): Promise<number> {
 203      return this.storage.getSessionIdentities().length;
 204    }
 205  
 206    // ─────────────────────────────────────────────────────────────────────────
 207    // Private helpers
 208    // ─────────────────────────────────────────────────────────────────────────
 209  
 210    private async encryptIdentity(identity: IdentitySnapshot): Promise<EncryptedIdentity> {
 211      return {
 212        id: await this.encryption.encryptString(identity.id),
 213        nick: await this.encryption.encryptString(identity.nick),
 214        privkey: await this.encryption.encryptString(identity.privkey),
 215        createdAt: await this.encryption.encryptString(identity.createdAt),
 216      };
 217    }
 218  }
 219  
 220  /**
 221   * Factory function to create a BrowserIdentityRepository.
 222   */
 223  export function createIdentityRepository(
 224    storage: IdentityStorageAdapter,
 225    encryption: EncryptionService
 226  ): IdentityRepository {
 227    return new BrowserIdentityRepository(storage, encryption);
 228  }
 229