Files
plebeian-signer/projects/common/src/lib/infrastructure/repositories/relay-repository.impl.ts
woikos d98a0ef76e Implement DDD refactoring phases 1-4 with domain layer and ubiquitous language
Phase 1-3: Domain Layer Foundation
- Add value objects: IdentityId, PermissionId, RelayId, WalletId, Nickname, NostrKeyPair
- Add rich domain entities: Identity, Permission, Relay with behavior
- Add domain events: IdentityCreated, IdentityRenamed, IdentitySelected, etc.
- Add repository interfaces for Identity, Permission, Relay
- Add infrastructure layer with repository implementations
- Add EncryptionService abstraction

Phase 4: Ubiquitous Language Cleanup
- Rename BrowserSyncData → EncryptedVault (encrypted vault storage)
- Rename BrowserSessionData → VaultSession (decrypted session state)
- Rename SignerMetaData → ExtensionSettings (extension configuration)
- Rename Identity_ENCRYPTED → StoredIdentity (storage DTO)
- Rename Identity_DECRYPTED → IdentityData (session DTO)
- Similar renames for Permission, Relay, NwcConnection, CashuMint
- Add backwards compatibility aliases with @deprecated markers

Test Coverage
- Add comprehensive tests for all value objects
- Add tests for domain entities and their behavior
- Add tests for domain events
- Fix PermissionChecker to prioritize kind-specific rules over blanket rules
- Fix pre-existing component test issues (IconButton, Pubkey)

All 113 tests pass. Both Chrome and Firefox builds succeed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 05:21:44 +01:00

220 lines
6.8 KiB
TypeScript

import {
RelayRepositoryError,
RelayErrorCode,
} from '../../domain/repositories/relay-repository';
import type {
RelayRepository,
RelaySnapshot,
RelayQuery,
} from '../../domain/repositories/relay-repository';
import { IdentityId, RelayId } from '../../domain/value-objects';
import { EncryptionService } from '../encryption';
/**
* Encrypted relay as stored in browser sync storage.
*/
interface EncryptedRelay {
id: string;
identityId: string;
url: string;
read: string;
write: string;
}
/**
* Storage adapter interface for relays.
*/
export interface RelayStorageAdapter {
// Session (in-memory, decrypted) operations
getSessionRelays(): RelaySnapshot[];
setSessionRelays(relays: RelaySnapshot[]): void;
saveSessionData(): Promise<void>;
// Sync (persistent, encrypted) operations
getSyncRelays(): EncryptedRelay[];
saveSyncRelays(relays: EncryptedRelay[]): Promise<void>;
}
/**
* Implementation of RelayRepository using browser storage.
*/
export class BrowserRelayRepository implements RelayRepository {
constructor(
private readonly storage: RelayStorageAdapter,
private readonly encryption: EncryptionService
) {}
async findById(id: RelayId): Promise<RelaySnapshot | undefined> {
const relays = this.storage.getSessionRelays();
return relays.find((r) => r.id === id.value);
}
async find(query: RelayQuery): Promise<RelaySnapshot[]> {
let relays = this.storage.getSessionRelays();
if (query.identityId) {
const identityIdValue = query.identityId.value;
relays = relays.filter((r) => r.identityId === identityIdValue);
}
if (query.url) {
const urlLower = query.url.toLowerCase();
relays = relays.filter((r) => r.url.toLowerCase() === urlLower);
}
if (query.read !== undefined) {
const read = query.read;
relays = relays.filter((r) => r.read === read);
}
if (query.write !== undefined) {
const write = query.write;
relays = relays.filter((r) => r.write === write);
}
return relays;
}
async findByUrl(identityId: IdentityId, url: string): Promise<RelaySnapshot | undefined> {
const relays = this.storage.getSessionRelays();
return relays.find(
(r) =>
r.identityId === identityId.value &&
r.url.toLowerCase() === url.toLowerCase()
);
}
async findByIdentity(identityId: IdentityId): Promise<RelaySnapshot[]> {
const relays = this.storage.getSessionRelays();
return relays.filter((r) => r.identityId === identityId.value);
}
async findAll(): Promise<RelaySnapshot[]> {
return this.storage.getSessionRelays();
}
async save(relay: RelaySnapshot): Promise<void> {
// Check for duplicate URL for the same identity (excluding self)
const existing = await this.findByUrl(
IdentityId.from(relay.identityId),
relay.url
);
if (existing && existing.id !== relay.id) {
throw new RelayRepositoryError(
'A relay with the same URL already exists for this identity',
RelayErrorCode.DUPLICATE_URL
);
}
const sessionRelays = this.storage.getSessionRelays();
const existingIndex = sessionRelays.findIndex((r) => r.id === relay.id);
if (existingIndex >= 0) {
sessionRelays[existingIndex] = relay;
} else {
sessionRelays.push(relay);
}
this.storage.setSessionRelays(sessionRelays);
await this.storage.saveSessionData();
// Encrypt and save to sync storage
const encryptedRelay = await this.encryptRelay(relay);
const syncRelays = this.storage.getSyncRelays();
// Find by decrypting IDs
let syncIndex = -1;
for (let i = 0; i < syncRelays.length; i++) {
try {
const decryptedId = await this.encryption.decryptString(syncRelays[i].id);
if (decryptedId === relay.id) {
syncIndex = i;
break;
}
} catch {
// Skip corrupted entries
}
}
if (syncIndex >= 0) {
syncRelays[syncIndex] = encryptedRelay;
} else {
syncRelays.push(encryptedRelay);
}
await this.storage.saveSyncRelays(syncRelays);
}
async delete(id: RelayId): Promise<boolean> {
const sessionRelays = this.storage.getSessionRelays();
const initialLength = sessionRelays.length;
const filtered = sessionRelays.filter((r) => r.id !== id.value);
if (filtered.length === initialLength) {
return false;
}
this.storage.setSessionRelays(filtered);
await this.storage.saveSessionData();
// Remove from sync storage
const encryptedId = await this.encryption.encryptString(id.value);
const syncRelays = this.storage.getSyncRelays();
const filteredSync = syncRelays.filter((r) => r.id !== encryptedId);
await this.storage.saveSyncRelays(filteredSync);
return true;
}
async deleteByIdentity(identityId: IdentityId): Promise<number> {
const sessionRelays = this.storage.getSessionRelays();
const initialLength = sessionRelays.length;
const filtered = sessionRelays.filter((r) => r.identityId !== identityId.value);
const deletedCount = initialLength - filtered.length;
if (deletedCount === 0) {
return 0;
}
this.storage.setSessionRelays(filtered);
await this.storage.saveSessionData();
// Remove from sync storage
const encryptedIdentityId = await this.encryption.encryptString(identityId.value);
const syncRelays = this.storage.getSyncRelays();
const filteredSync = syncRelays.filter((r) => r.identityId !== encryptedIdentityId);
await this.storage.saveSyncRelays(filteredSync);
return deletedCount;
}
async count(query?: RelayQuery): Promise<number> {
if (query) {
const results = await this.find(query);
return results.length;
}
return this.storage.getSessionRelays().length;
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
private async encryptRelay(relay: RelaySnapshot): Promise<EncryptedRelay> {
return {
id: await this.encryption.encryptString(relay.id),
identityId: await this.encryption.encryptString(relay.identityId),
url: await this.encryption.encryptString(relay.url),
read: await this.encryption.encryptBoolean(relay.read),
write: await this.encryption.encryptBoolean(relay.write),
};
}
}
/**
* Factory function to create a BrowserRelayRepository.
*/
export function createRelayRepository(
storage: RelayStorageAdapter,
encryption: EncryptionService
): RelayRepository {
return new BrowserRelayRepository(storage, encryption);
}