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