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