relay-repository.impl.ts raw

   1  import {
   2    RelayRepositoryError,
   3    RelayErrorCode,
   4  } from '../../domain/repositories/relay-repository';
   5  import type {
   6    RelayRepository,
   7    RelaySnapshot,
   8    RelayQuery,
   9  } from '../../domain/repositories/relay-repository';
  10  import { IdentityId, RelayId } from '../../domain/value-objects';
  11  import { EncryptionService } from '../encryption';
  12  
  13  /**
  14   * Encrypted relay as stored in browser sync storage.
  15   */
  16  interface EncryptedRelay {
  17    id: string;
  18    identityId: string;
  19    url: string;
  20    read: string;
  21    write: string;
  22  }
  23  
  24  /**
  25   * Storage adapter interface for relays.
  26   */
  27  export interface RelayStorageAdapter {
  28    // Session (in-memory, decrypted) operations
  29    getSessionRelays(): RelaySnapshot[];
  30    setSessionRelays(relays: RelaySnapshot[]): void;
  31    saveSessionData(): Promise<void>;
  32  
  33    // Sync (persistent, encrypted) operations
  34    getSyncRelays(): EncryptedRelay[];
  35    saveSyncRelays(relays: EncryptedRelay[]): Promise<void>;
  36  }
  37  
  38  /**
  39   * Implementation of RelayRepository using browser storage.
  40   */
  41  export class BrowserRelayRepository implements RelayRepository {
  42    constructor(
  43      private readonly storage: RelayStorageAdapter,
  44      private readonly encryption: EncryptionService
  45    ) {}
  46  
  47    async findById(id: RelayId): Promise<RelaySnapshot | undefined> {
  48      const relays = this.storage.getSessionRelays();
  49      return relays.find((r) => r.id === id.value);
  50    }
  51  
  52    async find(query: RelayQuery): Promise<RelaySnapshot[]> {
  53      let relays = this.storage.getSessionRelays();
  54  
  55      if (query.identityId) {
  56        const identityIdValue = query.identityId.value;
  57        relays = relays.filter((r) => r.identityId === identityIdValue);
  58      }
  59      if (query.url) {
  60        const urlLower = query.url.toLowerCase();
  61        relays = relays.filter((r) => r.url.toLowerCase() === urlLower);
  62      }
  63      if (query.read !== undefined) {
  64        const read = query.read;
  65        relays = relays.filter((r) => r.read === read);
  66      }
  67      if (query.write !== undefined) {
  68        const write = query.write;
  69        relays = relays.filter((r) => r.write === write);
  70      }
  71  
  72      return relays;
  73    }
  74  
  75    async findByUrl(identityId: IdentityId, url: string): Promise<RelaySnapshot | undefined> {
  76      const relays = this.storage.getSessionRelays();
  77      return relays.find(
  78        (r) =>
  79          r.identityId === identityId.value &&
  80          r.url.toLowerCase() === url.toLowerCase()
  81      );
  82    }
  83  
  84    async findByIdentity(identityId: IdentityId): Promise<RelaySnapshot[]> {
  85      const relays = this.storage.getSessionRelays();
  86      return relays.filter((r) => r.identityId === identityId.value);
  87    }
  88  
  89    async findAll(): Promise<RelaySnapshot[]> {
  90      return this.storage.getSessionRelays();
  91    }
  92  
  93    async save(relay: RelaySnapshot): Promise<void> {
  94      // Check for duplicate URL for the same identity (excluding self)
  95      const existing = await this.findByUrl(
  96        IdentityId.from(relay.identityId),
  97        relay.url
  98      );
  99      if (existing && existing.id !== relay.id) {
 100        throw new RelayRepositoryError(
 101          'A relay with the same URL already exists for this identity',
 102          RelayErrorCode.DUPLICATE_URL
 103        );
 104      }
 105  
 106      const sessionRelays = this.storage.getSessionRelays();
 107      const existingIndex = sessionRelays.findIndex((r) => r.id === relay.id);
 108  
 109      if (existingIndex >= 0) {
 110        sessionRelays[existingIndex] = relay;
 111      } else {
 112        sessionRelays.push(relay);
 113      }
 114  
 115      this.storage.setSessionRelays(sessionRelays);
 116      await this.storage.saveSessionData();
 117  
 118      // Encrypt and save to sync storage
 119      const encryptedRelay = await this.encryptRelay(relay);
 120      const syncRelays = this.storage.getSyncRelays();
 121  
 122      // Find by decrypting IDs
 123      let syncIndex = -1;
 124      for (let i = 0; i < syncRelays.length; i++) {
 125        try {
 126          const decryptedId = await this.encryption.decryptString(syncRelays[i].id);
 127          if (decryptedId === relay.id) {
 128            syncIndex = i;
 129            break;
 130          }
 131        } catch {
 132          // Skip corrupted entries
 133        }
 134      }
 135  
 136      if (syncIndex >= 0) {
 137        syncRelays[syncIndex] = encryptedRelay;
 138      } else {
 139        syncRelays.push(encryptedRelay);
 140      }
 141  
 142      await this.storage.saveSyncRelays(syncRelays);
 143    }
 144  
 145    async delete(id: RelayId): Promise<boolean> {
 146      const sessionRelays = this.storage.getSessionRelays();
 147      const initialLength = sessionRelays.length;
 148      const filtered = sessionRelays.filter((r) => r.id !== id.value);
 149  
 150      if (filtered.length === initialLength) {
 151        return false;
 152      }
 153  
 154      this.storage.setSessionRelays(filtered);
 155      await this.storage.saveSessionData();
 156  
 157      // Remove from sync storage
 158      const encryptedId = await this.encryption.encryptString(id.value);
 159      const syncRelays = this.storage.getSyncRelays();
 160      const filteredSync = syncRelays.filter((r) => r.id !== encryptedId);
 161      await this.storage.saveSyncRelays(filteredSync);
 162  
 163      return true;
 164    }
 165  
 166    async deleteByIdentity(identityId: IdentityId): Promise<number> {
 167      const sessionRelays = this.storage.getSessionRelays();
 168      const initialLength = sessionRelays.length;
 169      const filtered = sessionRelays.filter((r) => r.identityId !== identityId.value);
 170      const deletedCount = initialLength - filtered.length;
 171  
 172      if (deletedCount === 0) {
 173        return 0;
 174      }
 175  
 176      this.storage.setSessionRelays(filtered);
 177      await this.storage.saveSessionData();
 178  
 179      // Remove from sync storage
 180      const encryptedIdentityId = await this.encryption.encryptString(identityId.value);
 181      const syncRelays = this.storage.getSyncRelays();
 182      const filteredSync = syncRelays.filter((r) => r.identityId !== encryptedIdentityId);
 183      await this.storage.saveSyncRelays(filteredSync);
 184  
 185      return deletedCount;
 186    }
 187  
 188    async count(query?: RelayQuery): Promise<number> {
 189      if (query) {
 190        const results = await this.find(query);
 191        return results.length;
 192      }
 193      return this.storage.getSessionRelays().length;
 194    }
 195  
 196    // ─────────────────────────────────────────────────────────────────────────
 197    // Private helpers
 198    // ─────────────────────────────────────────────────────────────────────────
 199  
 200    private async encryptRelay(relay: RelaySnapshot): Promise<EncryptedRelay> {
 201      return {
 202        id: await this.encryption.encryptString(relay.id),
 203        identityId: await this.encryption.encryptString(relay.identityId),
 204        url: await this.encryption.encryptString(relay.url),
 205        read: await this.encryption.encryptBoolean(relay.read),
 206        write: await this.encryption.encryptBoolean(relay.write),
 207      };
 208    }
 209  }
 210  
 211  /**
 212   * Factory function to create a BrowserRelayRepository.
 213   */
 214  export function createRelayRepository(
 215    storage: RelayStorageAdapter,
 216    encryption: EncryptionService
 217  ): RelayRepository {
 218    return new BrowserRelayRepository(storage, encryption);
 219  }
 220