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>
This commit is contained in:
woikos
2025-12-25 05:21:44 +01:00
parent 87d76bb4a8
commit d98a0ef76e
58 changed files with 4927 additions and 277 deletions

View File

@@ -0,0 +1,228 @@
import {
IdentityRepositoryError,
IdentityErrorCode,
} from '../../domain/repositories/identity-repository';
import type {
IdentityRepository,
IdentitySnapshot,
} from '../../domain/repositories/identity-repository';
import { IdentityId } from '../../domain/value-objects';
import { EncryptionService } from '../encryption';
import { NostrHelper } from '../../helpers/nostr-helper';
/**
* Encrypted identity as stored in browser sync storage.
*/
interface EncryptedIdentity {
id: string;
nick: string;
privkey: string;
createdAt: string;
}
/**
* Storage adapter interface - abstracts browser storage operations.
* Implementations provided by Chrome/Firefox specific code.
*/
export interface IdentityStorageAdapter {
// Session (in-memory, decrypted) operations
getSessionIdentities(): IdentitySnapshot[];
setSessionIdentities(identities: IdentitySnapshot[]): void;
saveSessionData(): Promise<void>;
getSessionSelectedId(): string | null;
setSessionSelectedId(id: string | null): void;
// Sync (persistent, encrypted) operations
getSyncIdentities(): EncryptedIdentity[];
saveSyncIdentities(identities: EncryptedIdentity[]): Promise<void>;
getSyncSelectedId(): string | null;
saveSyncSelectedId(id: string | null): Promise<void>;
}
/**
* Implementation of IdentityRepository using browser storage.
* Handles encryption/decryption transparently.
*/
export class BrowserIdentityRepository implements IdentityRepository {
constructor(
private readonly storage: IdentityStorageAdapter,
private readonly encryption: EncryptionService
) {}
async findById(id: IdentityId): Promise<IdentitySnapshot | undefined> {
const identities = this.storage.getSessionIdentities();
return identities.find((i) => i.id === id.value);
}
async findByPublicKey(publicKey: string): Promise<IdentitySnapshot | undefined> {
const identities = this.storage.getSessionIdentities();
return identities.find((i) => {
try {
const derivedPubkey = NostrHelper.pubkeyFromPrivkey(i.privkey);
return derivedPubkey === publicKey;
} catch {
return false;
}
});
}
async findByPrivateKey(privateKey: string): Promise<IdentitySnapshot | undefined> {
// Normalize the private key to hex format
let privkeyHex: string;
try {
privkeyHex = NostrHelper.getNostrPrivkeyObject(privateKey.toLowerCase()).hex;
} catch {
return undefined;
}
const identities = this.storage.getSessionIdentities();
return identities.find((i) => i.privkey === privkeyHex);
}
async findAll(): Promise<IdentitySnapshot[]> {
return this.storage.getSessionIdentities();
}
async save(identity: IdentitySnapshot): Promise<void> {
// Check for duplicate private key (excluding self)
const existing = await this.findByPrivateKey(identity.privkey);
if (existing && existing.id !== identity.id) {
throw new IdentityRepositoryError(
`An identity with the same private key already exists: ${existing.nick}`,
IdentityErrorCode.DUPLICATE_PRIVATE_KEY
);
}
// Update session storage
const sessionIdentities = this.storage.getSessionIdentities();
const existingIndex = sessionIdentities.findIndex((i) => i.id === identity.id);
if (existingIndex >= 0) {
// Update existing
sessionIdentities[existingIndex] = identity;
} else {
// Add new
sessionIdentities.push(identity);
// Auto-select if first identity
if (sessionIdentities.length === 1) {
this.storage.setSessionSelectedId(identity.id);
}
}
this.storage.setSessionIdentities(sessionIdentities);
await this.storage.saveSessionData();
// Encrypt and save to sync storage
const encryptedIdentity = await this.encryptIdentity(identity);
const syncIdentities = this.storage.getSyncIdentities();
const syncIndex = syncIdentities.findIndex(
async (i) => (await this.encryption.decryptString(i.id)) === identity.id
);
if (syncIndex >= 0) {
syncIdentities[syncIndex] = encryptedIdentity;
} else {
syncIdentities.push(encryptedIdentity);
}
await this.storage.saveSyncIdentities(syncIdentities);
// Update selected ID in sync if this was the first identity
if (sessionIdentities.length === 1) {
const encryptedId = await this.encryption.encryptString(identity.id);
await this.storage.saveSyncSelectedId(encryptedId);
}
}
async delete(id: IdentityId): Promise<boolean> {
const sessionIdentities = this.storage.getSessionIdentities();
const initialLength = sessionIdentities.length;
const filtered = sessionIdentities.filter((i) => i.id !== id.value);
if (filtered.length === initialLength) {
return false; // Nothing was deleted
}
// Update selected identity if needed
const currentSelectedId = this.storage.getSessionSelectedId();
if (currentSelectedId === id.value) {
const newSelectedId = filtered.length > 0 ? filtered[0].id : null;
this.storage.setSessionSelectedId(newSelectedId);
}
this.storage.setSessionIdentities(filtered);
await this.storage.saveSessionData();
// Remove from sync storage
const encryptedId = await this.encryption.encryptString(id.value);
const syncIdentities = this.storage.getSyncIdentities();
const filteredSync = syncIdentities.filter((i) => i.id !== encryptedId);
await this.storage.saveSyncIdentities(filteredSync);
// Update selected ID in sync
const newSelectedId = this.storage.getSessionSelectedId();
const encryptedSelectedId = newSelectedId
? await this.encryption.encryptString(newSelectedId)
: null;
await this.storage.saveSyncSelectedId(encryptedSelectedId);
return true;
}
async getSelectedId(): Promise<IdentityId | null> {
const selectedId = this.storage.getSessionSelectedId();
return selectedId ? IdentityId.from(selectedId) : null;
}
async setSelectedId(id: IdentityId | null): Promise<void> {
if (id) {
// Verify the identity exists
const exists = await this.findById(id);
if (!exists) {
throw new IdentityRepositoryError(
`Identity not found: ${id.value}`,
IdentityErrorCode.NOT_FOUND
);
}
}
this.storage.setSessionSelectedId(id?.value ?? null);
await this.storage.saveSessionData();
// Update sync storage
const encryptedId = id
? await this.encryption.encryptString(id.value)
: null;
await this.storage.saveSyncSelectedId(encryptedId);
}
async count(): Promise<number> {
return this.storage.getSessionIdentities().length;
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
private async encryptIdentity(identity: IdentitySnapshot): Promise<EncryptedIdentity> {
return {
id: await this.encryption.encryptString(identity.id),
nick: await this.encryption.encryptString(identity.nick),
privkey: await this.encryption.encryptString(identity.privkey),
createdAt: await this.encryption.encryptString(identity.createdAt),
};
}
}
/**
* Factory function to create a BrowserIdentityRepository.
*/
export function createIdentityRepository(
storage: IdentityStorageAdapter,
encryption: EncryptionService
): IdentityRepository {
return new BrowserIdentityRepository(storage, encryption);
}

View File

@@ -0,0 +1,17 @@
export {
BrowserIdentityRepository,
createIdentityRepository,
} from './identity-repository.impl';
export type { IdentityStorageAdapter } from './identity-repository.impl';
export {
BrowserPermissionRepository,
createPermissionRepository,
} from './permission-repository.impl';
export type { PermissionStorageAdapter } from './permission-repository.impl';
export {
BrowserRelayRepository,
createRelayRepository,
} from './relay-repository.impl';
export type { RelayStorageAdapter } from './relay-repository.impl';

View File

@@ -0,0 +1,218 @@
import type {
PermissionRepository,
PermissionSnapshot,
PermissionQuery,
ExtensionMethod,
} from '../../domain/repositories/permission-repository';
import { IdentityId, PermissionId } from '../../domain/value-objects';
import { EncryptionService } from '../encryption';
/**
* Encrypted permission as stored in browser sync storage.
*/
interface EncryptedPermission {
id: string;
identityId: string;
host: string;
method: string;
methodPolicy: string;
kind?: string;
}
/**
* Storage adapter interface for permissions.
*/
export interface PermissionStorageAdapter {
// Session (in-memory, decrypted) operations
getSessionPermissions(): PermissionSnapshot[];
setSessionPermissions(permissions: PermissionSnapshot[]): void;
saveSessionData(): Promise<void>;
// Sync (persistent, encrypted) operations
getSyncPermissions(): EncryptedPermission[];
saveSyncPermissions(permissions: EncryptedPermission[]): Promise<void>;
}
/**
* Implementation of PermissionRepository using browser storage.
*/
export class BrowserPermissionRepository implements PermissionRepository {
constructor(
private readonly storage: PermissionStorageAdapter,
private readonly encryption: EncryptionService
) {}
async findById(id: PermissionId): Promise<PermissionSnapshot | undefined> {
const permissions = this.storage.getSessionPermissions();
return permissions.find((p) => p.id === id.value);
}
async find(query: PermissionQuery): Promise<PermissionSnapshot[]> {
let permissions = this.storage.getSessionPermissions();
if (query.identityId) {
const identityIdValue = query.identityId.value;
permissions = permissions.filter((p) => p.identityId === identityIdValue);
}
if (query.host) {
const host = query.host;
permissions = permissions.filter((p) => p.host === host);
}
if (query.method) {
const method = query.method;
permissions = permissions.filter((p) => p.method === method);
}
if (query.kind !== undefined) {
const kind = query.kind;
permissions = permissions.filter((p) => p.kind === kind);
}
return permissions;
}
async findExact(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): Promise<PermissionSnapshot | undefined> {
const permissions = this.storage.getSessionPermissions();
return permissions.find(
(p) =>
p.identityId === identityId.value &&
p.host === host &&
p.method === method &&
(kind === undefined ? p.kind === undefined : p.kind === kind)
);
}
async findByIdentity(identityId: IdentityId): Promise<PermissionSnapshot[]> {
const permissions = this.storage.getSessionPermissions();
return permissions.filter((p) => p.identityId === identityId.value);
}
async findAll(): Promise<PermissionSnapshot[]> {
return this.storage.getSessionPermissions();
}
async save(permission: PermissionSnapshot): Promise<void> {
const sessionPermissions = this.storage.getSessionPermissions();
const existingIndex = sessionPermissions.findIndex((p) => p.id === permission.id);
if (existingIndex >= 0) {
sessionPermissions[existingIndex] = permission;
} else {
sessionPermissions.push(permission);
}
this.storage.setSessionPermissions(sessionPermissions);
await this.storage.saveSessionData();
// Encrypt and save to sync storage
const encryptedPermission = await this.encryptPermission(permission);
const syncPermissions = this.storage.getSyncPermissions();
// Find by decrypting IDs (expensive but necessary for updates)
let syncIndex = -1;
for (let i = 0; i < syncPermissions.length; i++) {
try {
const decryptedId = await this.encryption.decryptString(syncPermissions[i].id);
if (decryptedId === permission.id) {
syncIndex = i;
break;
}
} catch {
// Skip corrupted entries
}
}
if (syncIndex >= 0) {
syncPermissions[syncIndex] = encryptedPermission;
} else {
syncPermissions.push(encryptedPermission);
}
await this.storage.saveSyncPermissions(syncPermissions);
}
async delete(id: PermissionId): Promise<boolean> {
const sessionPermissions = this.storage.getSessionPermissions();
const initialLength = sessionPermissions.length;
const filtered = sessionPermissions.filter((p) => p.id !== id.value);
if (filtered.length === initialLength) {
return false;
}
this.storage.setSessionPermissions(filtered);
await this.storage.saveSessionData();
// Remove from sync storage
const encryptedId = await this.encryption.encryptString(id.value);
const syncPermissions = this.storage.getSyncPermissions();
const filteredSync = syncPermissions.filter((p) => p.id !== encryptedId);
await this.storage.saveSyncPermissions(filteredSync);
return true;
}
async deleteByIdentity(identityId: IdentityId): Promise<number> {
const sessionPermissions = this.storage.getSessionPermissions();
const initialLength = sessionPermissions.length;
const filtered = sessionPermissions.filter((p) => p.identityId !== identityId.value);
const deletedCount = initialLength - filtered.length;
if (deletedCount === 0) {
return 0;
}
this.storage.setSessionPermissions(filtered);
await this.storage.saveSessionData();
// Remove from sync storage
const encryptedIdentityId = await this.encryption.encryptString(identityId.value);
const syncPermissions = this.storage.getSyncPermissions();
const filteredSync = syncPermissions.filter((p) => p.identityId !== encryptedIdentityId);
await this.storage.saveSyncPermissions(filteredSync);
return deletedCount;
}
async count(query?: PermissionQuery): Promise<number> {
if (query) {
const results = await this.find(query);
return results.length;
}
return this.storage.getSessionPermissions().length;
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
private async encryptPermission(permission: PermissionSnapshot): Promise<EncryptedPermission> {
const encrypted: EncryptedPermission = {
id: await this.encryption.encryptString(permission.id),
identityId: await this.encryption.encryptString(permission.identityId),
host: await this.encryption.encryptString(permission.host),
method: await this.encryption.encryptString(permission.method),
methodPolicy: await this.encryption.encryptString(permission.methodPolicy),
};
if (permission.kind !== undefined) {
encrypted.kind = await this.encryption.encryptNumber(permission.kind);
}
return encrypted;
}
}
/**
* Factory function to create a BrowserPermissionRepository.
*/
export function createPermissionRepository(
storage: PermissionStorageAdapter,
encryption: EncryptionService
): PermissionRepository {
return new BrowserPermissionRepository(storage, encryption);
}

View File

@@ -0,0 +1,219 @@
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);
}