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

690
DDD_ANALYSIS.md Normal file
View File

@@ -0,0 +1,690 @@
# Domain-Driven Design Analysis: Plebeian Signer
This document analyzes the Plebeian Signer codebase through the lens of Domain-Driven Design (DDD) principles, identifying bounded contexts, current patterns, anti-patterns, and providing actionable recommendations for improvement.
## Executive Summary
Plebeian Signer is a browser extension for Nostr identity management implementing NIP-07. The codebase has **good structural foundations** (monorepo with shared library, handler abstraction pattern) but suffers from several DDD anti-patterns:
- **God Service**: `StorageService` handles too many responsibilities
- **Anemic Domain Models**: Types are data containers without behavior
- **Mixed Concerns**: Encryption logic interleaved with domain operations
- **Weak Ubiquitous Language**: Generic naming (`BrowserSyncData`) obscures domain concepts
**Priority Recommendations:**
1. Extract domain aggregates with behavior (Identity, Vault, Wallet)
2. Separate encryption into an infrastructure layer
3. Introduce repository pattern for each aggregate
4. Rename types to reflect ubiquitous language
---
## Domain Overview
### Core Domain Problem
> Enable users to manage multiple Nostr identities securely, sign events without exposing private keys to web applications, and interact with Lightning/Cashu wallets.
### Subdomain Classification
| Subdomain | Type | Rationale |
|-----------|------|-----------|
| **Identity & Signing** | Core | The differentiator - secure key management and NIP-07 implementation |
| **Permission Management** | Core | Critical security layer - controls what apps can do |
| **Vault Encryption** | Supporting | Necessary security but standard cryptographic patterns |
| **Wallet Integration** | Supporting | Extends functionality but not the core value proposition |
| **Profile Caching** | Generic | Standard caching pattern, could use any solution |
| **Relay Management** | Supporting | Per-identity configuration, fairly standard |
---
## Bounded Contexts
### Identified Contexts
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONTEXT MAP │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ Shared Kernel ┌──────────────────┐ │
│ │ Vault Context │◄─────────(crypto)──────────►│ Identity Context │ │
│ │ │ │ │ │
│ │ - VaultState │ │ - Identity │ │
│ │ - Encryption │ │ - KeyPair │ │
│ │ - Migration │ │ - Signing │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ │ Customer/Supplier │ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Permission Ctx │ │ Wallet Context │ │
│ │ │ │ │ │
│ │ - Policy │ │ - NWC │ │
│ │ - Host Rules │ │ - Cashu │ │
│ │ - Method Auth │ │ - Lightning │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Relay Context │◄──── Conformist ────────────►│ Profile Context │ │
│ │ │ │ │ │
│ │ - Per-identity │ │ - Kind 0 cache │ │
│ │ - Read/Write │ │ - Metadata │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Legend: ◄──► Bidirectional, ──► Supplier direction │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Context Definitions
#### 1. Vault Context
**Responsibility:** Secure storage lifecycle - creation, locking, unlocking, encryption, migration.
**Current Location:** `projects/common/src/lib/services/storage/related/vault.ts`
**Key Concepts:**
- VaultState (locked/unlocked)
- EncryptionKey (Argon2id-derived)
- VaultVersion (migration support)
- Salt, IV (cryptographic parameters)
**Language:**
| Term | Definition |
|------|------------|
| Vault | The encrypted container holding all sensitive data |
| Unlock | Derive key from password and decrypt vault contents |
| Lock | Clear session data, requiring password to access again |
| Migration | Upgrade vault encryption scheme (v1→v2) |
#### 2. Identity Context
**Responsibility:** Nostr identity lifecycle and cryptographic operations.
**Current Location:** `projects/common/src/lib/services/storage/related/identity.ts`
**Key Concepts:**
- Identity (aggregates pubkey, privkey, nick)
- KeyPair (hex or nsec/npub representations)
- SelectedIdentity (current active identity)
- EventSigning (NIP-07 signEvent)
**Language:**
| Term | Definition |
|------|------------|
| Identity | A Nostr keypair with a user-defined nickname |
| Selected Identity | The currently active identity for signing |
| Sign | Create schnorr signature for a Nostr event |
| Switch | Change the active identity |
#### 3. Permission Context
**Responsibility:** Authorization decisions for NIP-07 method calls.
**Current Location:** `projects/common/src/lib/services/storage/related/permission.ts`
**Key Concepts:**
- PermissionPolicy (allow/deny)
- MethodPermission (per NIP-07 method)
- KindPermission (signEvent kind filtering)
- HostWhitelist (trusted domains)
- RecklessMode (auto-approve all)
**Language:**
| Term | Definition |
|------|------------|
| Permission | A stored allow/deny decision for identity+host+method |
| Reckless Mode | Global setting to auto-approve all requests |
| Whitelist | Hosts that auto-approve without prompting |
| Prompt | UI asking user to authorize a request |
#### 4. Wallet Context
**Responsibility:** Lightning and Cashu wallet operations.
**Current Location:**
- `projects/common/src/lib/services/nwc/`
- `projects/common/src/lib/services/cashu/`
- `projects/common/src/lib/services/storage/related/nwc.ts`
- `projects/common/src/lib/services/storage/related/cashu.ts`
**Key Concepts:**
- NwcConnection (NIP-47 wallet connect)
- CashuMint (ecash mint connection)
- CashuProof (unspent tokens)
- LightningInvoice, Keysend
#### 5. Relay Context
**Responsibility:** Per-identity relay configuration.
**Current Location:** `projects/common/src/lib/services/storage/related/relay.ts`
**Key Concepts:**
- RelayConfiguration (URL + read/write permissions)
- IdentityRelays (relays scoped to an identity)
#### 6. Profile Context
**Responsibility:** Caching Nostr profile metadata (kind 0 events).
**Current Location:** `projects/common/src/lib/services/profile-metadata/`
**Key Concepts:**
- ProfileMetadata (name, picture, nip05, etc.)
- MetadataCache (fetchedAt timestamp)
---
## Current Architecture Analysis
### What's Working Well
1. **Monorepo Structure**
- Clean separation: `projects/common`, `projects/chrome`, `projects/firefox`
- Shared library via `@common` alias
- Browser-specific implementations isolated
2. **Handler Abstraction (Adapter Pattern)**
```
StorageService
├→ BrowserSessionHandler (abstract → ChromeSessionHandler, FirefoxSessionHandler)
├→ BrowserSyncHandler (abstract → ChromeSyncYesHandler, ChromeSyncNoHandler, ...)
└→ SignerMetaHandler (abstract → ChromeMetaHandler, FirefoxMetaHandler)
```
This enables pluggable browser implementations - good DDD practice.
3. **Encrypted/Decrypted Type Pairs**
- `Identity_DECRYPTED` / `Identity_ENCRYPTED`
- Clear distinction between storage states
4. **Vault Versioning**
- Migration path from v1 (PBKDF2) to v2 (Argon2id)
- Automatic upgrade on unlock
5. **Cascade Deletes**
- Deleting an identity removes associated permissions and relays
- Maintains referential integrity
### Anti-Patterns Identified
#### 1. God Service (`StorageService`)
**Location:** `projects/common/src/lib/services/storage/storage.service.ts`
**Problem:** Single service handles:
- Vault lifecycle (create, unlock, delete, migrate)
- Identity CRUD (add, delete, switch)
- Permission management
- Relay configuration
- NWC wallet connections
- Cashu mint management
- Encryption/decryption orchestration
**Symptoms:**
- 500+ lines when including bound methods
- Methods dynamically attached via functional composition
- Implicit dependencies between operations
- Difficult to test in isolation
**DDD Violation:** Violates single responsibility; should be split into aggregate-specific repositories.
#### 2. Anemic Domain Models
**Location:** `projects/common/src/lib/services/storage/types.ts`
**Problem:** All domain types are pure data containers:
```typescript
// Current: Anemic model
interface Identity_DECRYPTED {
id: string;
nick: string;
privkey: string;
createdAt: string;
}
// All behavior lives in external functions:
// - addIdentity() in identity.ts
// - switchIdentity() in identity.ts
// - encryptIdentity() in identity.ts
```
**Should Be:**
```typescript
// Rich domain model
class Identity {
private constructor(
private readonly _id: IdentityId,
private _nick: Nickname,
private readonly _keyPair: NostrKeyPair,
private readonly _createdAt: Date
) {}
static create(nick: string, privateKey?: string): Identity { /* ... */ }
get publicKey(): string { return this._keyPair.publicKey; }
sign(event: UnsignedEvent): SignedEvent {
return this._keyPair.sign(event);
}
rename(newNick: string): void {
this._nick = Nickname.create(newNick);
}
}
```
#### 3. Mixed Encryption Concerns
**Problem:** Domain operations and encryption logic are interleaved:
```typescript
// In identity.ts
export async function addIdentity(this: StorageService, data: {...}) {
// Domain logic
const identity_decrypted: Identity_DECRYPTED = {
id: uuid(),
nick: data.nick,
privkey: data.privkeyString,
createdAt: new Date().toISOString(),
};
// Encryption concern mixed in
const identity_encrypted = await encryptIdentity.call(this, identity_decrypted);
// Storage concern
await this.#browserSyncHandler.addIdentity(identity_encrypted);
this.#browserSessionHandler.addIdentity(identity_decrypted);
}
```
**Should Be:** Encryption as infrastructure layer, repositories handle persistence:
```typescript
class IdentityRepository {
async save(identity: Identity): Promise<void> {
const encrypted = this.encryptionService.encrypt(identity.toSnapshot());
await this.syncHandler.save(encrypted);
this.sessionHandler.cache(identity);
}
}
```
#### 4. Weak Ubiquitous Language
**Problem:** Type names reflect technical storage, not domain concepts:
| Current Name | Domain Concept |
|--------------|----------------|
| `BrowserSyncData` | `EncryptedVault` |
| `BrowserSessionData` | `UnlockedVaultState` |
| `SignerMetaData` | `ExtensionSettings` |
| `Identity_DECRYPTED` | `Identity` |
| `Identity_ENCRYPTED` | `EncryptedIdentity` |
#### 5. Implicit Aggregate Boundaries
**Problem:** No clear aggregate roots. External code can manipulate any data:
```typescript
// Anyone can reach into session data
const identity = this.#browserSessionHandler.getIdentity(id);
identity.nick = "changed"; // No invariant protection!
```
**Should Have:** Aggregate roots as single entry points with invariant protection.
#### 6. TypeScript Union Type Issues
**Problem:** `LockedVaultContext` uses optional fields instead of discriminated unions:
```typescript
// Current: Confusing optional fields
type LockedVaultContext =
| { iv: string; password: string; keyBase64?: undefined }
| { iv: string; keyBase64: string; password?: undefined };
// Better: Discriminated union
type LockedVaultContext =
| { version: 1; iv: string; password: string }
| { version: 2; iv: string; keyBase64: string };
```
---
## Recommended Domain Model
### Aggregate Design
```
┌─────────────────────────────────────────────────────────────────────┐
│ AGGREGATE MAP │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Vault Aggregate (Root: Vault) │ │
│ │ │ │
│ │ Vault ──────┬──► Identity[] (child entities) │ │
│ │ ├──► Permission[] (child entities) │ │
│ │ ├──► Relay[] (child entities) │ │
│ │ ├──► NwcConnection[] (child entities) │ │
│ │ └──► CashuMint[] (child entities) │ │
│ │ │ │
│ │ Invariants: │ │
│ │ - At most one identity can be selected │ │
│ │ - Permissions must reference existing identities │ │
│ │ - Relays must reference existing identities │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ExtensionSettings Aggregate (Root: ExtensionSettings) │ │
│ │ │ │
│ │ ExtensionSettings ──┬──► SyncPreference │ │
│ │ ├──► SecurityPolicy (reckless, whitelist)│ │
│ │ ├──► Bookmark[] │ │
│ │ └──► VaultSnapshot[] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ProfileCache Aggregate (Root: ProfileCache) │ │
│ │ │ │
│ │ ProfileCache ──► ProfileMetadata[] │ │
│ │ │ │
│ │ Invariants: │ │
│ │ - Entries expire after TTL │ │
│ │ - One entry per pubkey │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Value Objects
```typescript
// Strongly-typed identity
class IdentityId {
private constructor(private readonly value: string) {}
static generate(): IdentityId { return new IdentityId(uuid()); }
static from(value: string): IdentityId { return new IdentityId(value); }
equals(other: IdentityId): boolean { return this.value === other.value; }
toString(): string { return this.value; }
}
// Self-validating nickname
class Nickname {
private constructor(private readonly value: string) {}
static create(value: string): Nickname {
if (!value || value.trim().length === 0) {
throw new InvalidNicknameError(value);
}
return new Nickname(value.trim());
}
toString(): string { return this.value; }
}
// Nostr key pair encapsulation
class NostrKeyPair {
private constructor(
private readonly privateKeyHex: string,
private readonly publicKeyHex: string
) {}
static fromPrivateKey(privkey: string): NostrKeyPair {
const hex = privkey.startsWith('nsec')
? NostrHelper.nsecToHex(privkey)
: privkey;
const pubkey = NostrHelper.pubkeyFromPrivkey(hex);
return new NostrKeyPair(hex, pubkey);
}
get publicKey(): string { return this.publicKeyHex; }
get npub(): string { return NostrHelper.pubkey2npub(this.publicKeyHex); }
sign(event: UnsignedEvent): SignedEvent {
return NostrHelper.signEvent(event, this.privateKeyHex);
}
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
return version === 4
? NostrHelper.nip04Encrypt(plaintext, this.privateKeyHex, recipientPubkey)
: NostrHelper.nip44Encrypt(plaintext, this.privateKeyHex, recipientPubkey);
}
}
// Permission policy
class PermissionPolicy {
private constructor(
private readonly identityId: IdentityId,
private readonly host: string,
private readonly method: Nip07Method,
private readonly decision: 'allow' | 'deny',
private readonly kind?: number
) {}
static allow(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
return new PermissionPolicy(identityId, host, method, 'allow', kind);
}
static deny(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
return new PermissionPolicy(identityId, host, method, 'deny', kind);
}
matches(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): boolean {
return this.identityId.equals(identityId)
&& this.host === host
&& this.method === method
&& (this.kind === undefined || this.kind === kind);
}
isAllowed(): boolean { return this.decision === 'allow'; }
}
```
### Rich Domain Entities
```typescript
class Identity {
private readonly _id: IdentityId;
private _nickname: Nickname;
private readonly _keyPair: NostrKeyPair;
private readonly _createdAt: Date;
private _domainEvents: DomainEvent[] = [];
private constructor(
id: IdentityId,
nickname: Nickname,
keyPair: NostrKeyPair,
createdAt: Date
) {
this._id = id;
this._nickname = nickname;
this._keyPair = keyPair;
this._createdAt = createdAt;
}
static create(nickname: string, privateKey?: string): Identity {
const keyPair = privateKey
? NostrKeyPair.fromPrivateKey(privateKey)
: NostrKeyPair.generate();
const identity = new Identity(
IdentityId.generate(),
Nickname.create(nickname),
keyPair,
new Date()
);
identity._domainEvents.push(new IdentityCreated(identity._id, identity.publicKey));
return identity;
}
get id(): IdentityId { return this._id; }
get publicKey(): string { return this._keyPair.publicKey; }
get npub(): string { return this._keyPair.npub; }
get nickname(): string { return this._nickname.toString(); }
rename(newNickname: string): void {
const oldNickname = this._nickname.toString();
this._nickname = Nickname.create(newNickname);
this._domainEvents.push(new IdentityRenamed(this._id, oldNickname, newNickname));
}
sign(event: UnsignedEvent): SignedEvent {
return this._keyPair.sign(event);
}
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
return this._keyPair.encrypt(plaintext, recipientPubkey, version);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
```
---
## Refactoring Roadmap
### Phase 1: Extract Value Objects (Low Risk)
**Goal:** Introduce type safety without changing behavior.
1. Create `IdentityId`, `Nickname`, `NostrKeyPair` value objects
2. Use them in existing interfaces initially
3. Add validation in factory methods
4. Update helpers to use value objects
**Files to Modify:**
- Create `projects/common/src/lib/domain/value-objects/`
- Update `projects/common/src/lib/helpers/nostr-helper.ts`
### Phase 2: Introduce Repository Pattern (Medium Risk)
**Goal:** Separate storage concerns from domain logic.
1. Define repository interfaces in domain layer
2. Create `IdentityRepository`, `PermissionRepository`, etc.
3. Move encryption to `EncryptionService` infrastructure
4. Refactor `StorageService` to delegate to repositories
**New Structure:**
```
projects/common/src/lib/
├── domain/
│ ├── identity/
│ │ ├── Identity.ts
│ │ ├── IdentityRepository.ts (interface)
│ │ └── events/
│ ├── permission/
│ │ ├── PermissionPolicy.ts
│ │ └── PermissionRepository.ts (interface)
│ └── vault/
│ ├── Vault.ts
│ └── VaultRepository.ts (interface)
├── infrastructure/
│ ├── encryption/
│ │ └── EncryptionService.ts
│ └── persistence/
│ ├── ChromeIdentityRepository.ts
│ └── FirefoxIdentityRepository.ts
└── application/
├── IdentityApplicationService.ts
└── VaultApplicationService.ts
```
### Phase 3: Rich Domain Model (Higher Risk)
**Goal:** Move behavior into domain entities.
1. Convert `Identity_DECRYPTED` interface to `Identity` class
2. Move signing logic into `Identity.sign()`
3. Move encryption decision logic into domain
4. Add domain events for state changes
### Phase 4: Ubiquitous Language Cleanup
**Goal:** Align code with domain language.
| Old Name | New Name |
|----------|----------|
| `BrowserSyncData` | `EncryptedVault` |
| `BrowserSessionData` | `VaultSession` |
| `SignerMetaData` | `ExtensionSettings` |
| `StorageService` | `VaultService` (or split into multiple) |
| `addIdentity()` | `Identity.create()` + `IdentityRepository.save()` |
| `switchIdentity()` | `Vault.selectIdentity()` |
---
## Implementation Priorities
### High Priority (Security/Correctness)
1. **Encapsulate KeyPair operations** - Private keys should never be accessed directly
2. **Enforce invariants** - Selected identity must exist, permissions must reference valid identities
3. **Clear transaction boundaries** - What gets saved together?
### Medium Priority (Maintainability)
1. **Split StorageService** - Into VaultService, IdentityRepository, PermissionRepository
2. **Extract EncryptionService** - Pure infrastructure concern
3. **Type-safe IDs** - Prevent mixing up identity IDs with permission IDs
### Lower Priority (Polish)
1. **Domain events** - For audit trail and extensibility
2. **Full ubiquitous language** - Rename all types
3. **Discriminated unions** - For vault context types
---
## Testing Implications
Current state makes testing difficult because:
- `StorageService` requires mocking 4 handlers
- Encryption is interleaved with logic
- No clear boundaries to test in isolation
With proposed changes:
- Domain entities testable in isolation (no storage mocks)
- Repositories testable with in-memory implementations
- Clear separation enables focused unit tests
```typescript
// Example: Testing Identity domain logic
describe('Identity', () => {
it('signs events with internal keypair', () => {
const identity = Identity.create('Test', 'nsec1...');
const event = { kind: 1, content: 'test', /* ... */ };
const signed = identity.sign(event);
expect(signed.sig).toBeDefined();
expect(signed.pubkey).toBe(identity.publicKey);
});
it('prevents duplicate private keys via repository', async () => {
const repository = new InMemoryIdentityRepository();
const existing = Identity.create('First', 'nsec1abc...');
await repository.save(existing);
const duplicate = Identity.create('Second', 'nsec1abc...');
await expect(repository.save(duplicate))
.rejects.toThrow(DuplicateIdentityError);
});
});
```
---
## Conclusion
The Plebeian Signer codebase has solid foundations but would benefit significantly from DDD tactical patterns. The recommended approach:
1. **Start with value objects** - Low risk, immediate type safety benefits
2. **Introduce repositories gradually** - Extract one at a time, starting with Identity
3. **Defer full rich domain model** - Until repositories stabilize the architecture
4. **Update language as you go** - Rename types when touching files anyway
The goal is not architectural purity but **maintainability, testability, and security**. DDD patterns are a means to those ends in a domain (cryptographic identity management) where correctness matters.

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { SignerMetaData, SignerMetaHandler } from '@common'; import { ExtensionSettings, SignerMetaHandler } from '@common';
export class ChromeMetaHandler extends SignerMetaHandler { export class ChromeMetaHandler extends SignerMetaHandler {
async loadFullData(): Promise<Partial<Record<string, any>>> { async loadFullData(): Promise<Partial<Record<string, any>>> {
@@ -19,7 +19,7 @@ export class ChromeMetaHandler extends SignerMetaHandler {
return data; return data;
} }
async saveFullData(data: SignerMetaData): Promise<void> { async saveFullData(data: ExtensionSettings): Promise<void> {
await chrome.storage.local.set(data); await chrome.storage.local.set(data);
} }

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData, BrowserSessionHandler } from '@common'; import { VaultSession, BrowserSessionHandler } from '@common';
export class ChromeSessionHandler extends BrowserSessionHandler { export class ChromeSessionHandler extends BrowserSessionHandler {
async loadFullData(): Promise<Partial<Record<string, any>>> { async loadFullData(): Promise<Partial<Record<string, any>>> {
return chrome.storage.session.get(null); return chrome.storage.session.get(null);
} }
async saveFullData(data: BrowserSessionData): Promise<void> { async saveFullData(data: VaultSession): Promise<void> {
await chrome.storage.session.set(data); await chrome.storage.session.set(data);
} }

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
BrowserSyncData, EncryptedVault,
BrowserSyncHandler, BrowserSyncHandler,
CashuMint_ENCRYPTED, StoredCashuMint,
Identity_ENCRYPTED, StoredIdentity,
NwcConnection_ENCRYPTED, StoredNwcConnection,
Permission_ENCRYPTED, StoredPermission,
Relay_ENCRYPTED, StoredRelay,
} from '@common'; } from '@common';
/** /**
@@ -26,20 +26,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
return data; return data;
} }
async saveAndSetFullData(data: BrowserSyncData): Promise<void> { async saveAndSetFullData(data: EncryptedVault): Promise<void> {
await chrome.storage.local.set(data); await chrome.storage.local.set(data);
this.setFullData(data); this.setFullData(data);
} }
async saveAndSetPartialData_Permissions(data: { async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[]; permissions: StoredPermission[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.local.set(data); await chrome.storage.local.set(data);
this.setPartialData_Permissions(data); this.setPartialData_Permissions(data);
} }
async saveAndSetPartialData_Identities(data: { async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[]; identities: StoredIdentity[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.local.set(data); await chrome.storage.local.set(data);
this.setPartialData_Identities(data); this.setPartialData_Identities(data);
@@ -53,21 +53,21 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
} }
async saveAndSetPartialData_Relays(data: { async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[]; relays: StoredRelay[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.local.set(data); await chrome.storage.local.set(data);
this.setPartialData_Relays(data); this.setPartialData_Relays(data);
} }
async saveAndSetPartialData_NwcConnections(data: { async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[]; nwcConnections: StoredNwcConnection[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.local.set(data); await chrome.storage.local.set(data);
this.setPartialData_NwcConnections(data); this.setPartialData_NwcConnections(data);
} }
async saveAndSetPartialData_CashuMints(data: { async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[]; cashuMints: StoredCashuMint[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.local.set(data); await chrome.storage.local.set(data);
this.setPartialData_CashuMints(data); this.setPartialData_CashuMints(data);

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
BrowserSyncData, EncryptedVault,
CashuMint_ENCRYPTED, StoredCashuMint,
Identity_ENCRYPTED, StoredIdentity,
NwcConnection_ENCRYPTED, StoredNwcConnection,
Permission_ENCRYPTED, StoredPermission,
BrowserSyncHandler, BrowserSyncHandler,
Relay_ENCRYPTED, StoredRelay,
} from '@common'; } from '@common';
/** /**
@@ -18,20 +18,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
return await chrome.storage.sync.get(null); return await chrome.storage.sync.get(null);
} }
async saveAndSetFullData(data: BrowserSyncData): Promise<void> { async saveAndSetFullData(data: EncryptedVault): Promise<void> {
await chrome.storage.sync.set(data); await chrome.storage.sync.set(data);
this.setFullData(data); this.setFullData(data);
} }
async saveAndSetPartialData_Permissions(data: { async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[]; permissions: StoredPermission[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.sync.set(data); await chrome.storage.sync.set(data);
this.setPartialData_Permissions(data); this.setPartialData_Permissions(data);
} }
async saveAndSetPartialData_Identities(data: { async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[]; identities: StoredIdentity[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.sync.set(data); await chrome.storage.sync.set(data);
this.setPartialData_Identities(data); this.setPartialData_Identities(data);
@@ -45,21 +45,21 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
} }
async saveAndSetPartialData_Relays(data: { async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[]; relays: StoredRelay[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.sync.set(data); await chrome.storage.sync.set(data);
this.setPartialData_Relays(data); this.setPartialData_Relays(data);
} }
async saveAndSetPartialData_NwcConnections(data: { async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[]; nwcConnections: StoredNwcConnection[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.sync.set(data); await chrome.storage.sync.set(data);
this.setPartialData_NwcConnections(data); this.setPartialData_NwcConnections(data);
} }
async saveAndSetPartialData_CashuMints(data: { async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[]; cashuMints: StoredCashuMint[];
}): Promise<void> { }): Promise<void> {
await chrome.storage.sync.set(data); await chrome.storage.sync.set(data);
this.setPartialData_CashuMints(data); this.setPartialData_CashuMints(data);

View File

@@ -27,7 +27,7 @@
> >
<span class="text-muted">{{ permission.method }}</span> <span class="text-muted">{{ permission.method }}</span>
@if(typeof permission.kind !== 'undefined') { @if(typeof permission.kind !== 'undefined') {
<span>(kind {{ permission.kind }})</span> <span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
} }
<div class="sam-flex-grow"></div> <div class="sam-flex-grow"></div>
<lib-icon-button <lib-icon-button

View File

@@ -5,6 +5,7 @@ import {
NavComponent, NavComponent,
Permission_DECRYPTED, Permission_DECRYPTED,
StorageService, StorageService,
getKindName,
} from '@common'; } from '@common';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
@@ -86,4 +87,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
}); });
}); });
} }
getKindTooltip(kind: number): string {
return getKindName(kind);
}
} }

View File

@@ -14,6 +14,7 @@ describe('IconButtonComponent', () => {
fixture = TestBed.createComponent(IconButtonComponent); fixture = TestBed.createComponent(IconButtonComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
component.icon = 'settings'; // Required input
fixture.detectChanges(); fixture.detectChanges();
}); });

View File

@@ -14,6 +14,8 @@ describe('PubkeyComponent', () => {
fixture = TestBed.createComponent(PubkeyComponent); fixture = TestBed.createComponent(PubkeyComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
// Valid test pubkey (64 hex chars)
component.value = 'a'.repeat(64);
fixture.detectChanges(); fixture.detectChanges();
}); });

View File

@@ -0,0 +1,200 @@
import { Identity, UnsignedEvent, SignedEvent, SigningFunction } from './identity';
import { IdentityCreated, IdentityRenamed, IdentitySigned } from '../events';
describe('Identity Entity', () => {
const TEST_PRIVATE_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
describe('create', () => {
it('should create identity with generated keypair when no private key provided', () => {
const identity = Identity.create('Alice');
expect(identity.nickname).toEqual('Alice');
expect(identity.publicKey).toBeTruthy();
expect(identity.publicKey.length).toBe(64);
});
it('should create identity with provided private key', () => {
const identity = Identity.create('Bob', TEST_PRIVATE_KEY);
expect(identity.nickname).toEqual('Bob');
expect(identity.publicKey).toBeTruthy();
});
it('should raise IdentityCreated event', () => {
const identity = Identity.create('Charlie');
const events = identity.pullDomainEvents();
expect(events.length).toBe(1);
expect(events[0]).toBeInstanceOf(IdentityCreated);
const createdEvent = events[0] as IdentityCreated;
expect(createdEvent.identityId).toEqual(identity.id.toString());
expect(createdEvent.publicKey).toEqual(identity.publicKey);
expect(createdEvent.nickname).toEqual('Charlie');
});
it('should set createdAt timestamp', () => {
const before = new Date();
const identity = Identity.create('Dana');
const after = new Date();
expect(identity.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(identity.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
});
describe('fromSnapshot', () => {
it('should reconstruct identity from snapshot', () => {
const original = Identity.create('Eve', TEST_PRIVATE_KEY);
original.pullDomainEvents(); // Clear creation event
const snapshot = original.toSnapshot();
const restored = Identity.fromSnapshot(snapshot);
expect(restored.id.toString()).toEqual(original.id.toString());
expect(restored.nickname).toEqual('Eve');
expect(restored.publicKey).toEqual(original.publicKey);
});
it('should not raise events when loading from snapshot', () => {
const original = Identity.create('Frank');
const snapshot = original.toSnapshot();
const restored = Identity.fromSnapshot(snapshot);
const events = restored.pullDomainEvents();
expect(events.length).toBe(0);
});
});
describe('rename', () => {
it('should update nickname', () => {
const identity = Identity.create('OldName');
identity.pullDomainEvents(); // Clear creation event
identity.rename('NewName');
expect(identity.nickname).toEqual('NewName');
});
it('should raise IdentityRenamed event', () => {
const identity = Identity.create('OldName');
identity.pullDomainEvents(); // Clear creation event
identity.rename('NewName');
const events = identity.pullDomainEvents();
expect(events.length).toBe(1);
expect(events[0]).toBeInstanceOf(IdentityRenamed);
const renamedEvent = events[0] as IdentityRenamed;
expect(renamedEvent.identityId).toEqual(identity.id.toString());
expect(renamedEvent.oldNickname).toEqual('OldName');
expect(renamedEvent.newNickname).toEqual('NewName');
});
});
describe('sign', () => {
it('should call signing function with event and return signed event', () => {
const identity = Identity.create('Signer', TEST_PRIVATE_KEY);
identity.pullDomainEvents();
const unsignedEvent: UnsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello, Nostr!',
};
const mockSignFn: SigningFunction = (event, privateKeyBytes) => {
expect(privateKeyBytes).toBeInstanceOf(Uint8Array);
expect(privateKeyBytes.length).toBe(32);
return {
...event,
id: 'mock-event-id',
pubkey: identity.publicKey,
sig: 'mock-signature',
} as SignedEvent;
};
const signedEvent = identity.sign(unsignedEvent, mockSignFn);
expect(signedEvent.id).toEqual('mock-event-id');
expect(signedEvent.pubkey).toEqual(identity.publicKey);
expect(signedEvent.sig).toEqual('mock-signature');
});
it('should raise IdentitySigned event', () => {
const identity = Identity.create('Signer', TEST_PRIVATE_KEY);
identity.pullDomainEvents();
const unsignedEvent: UnsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Test',
};
const mockSignFn: SigningFunction = (event) => ({
...event,
id: 'signed-event-id',
pubkey: identity.publicKey,
sig: 'sig',
} as SignedEvent);
identity.sign(unsignedEvent, mockSignFn);
const events = identity.pullDomainEvents();
expect(events.length).toBe(1);
expect(events[0]).toBeInstanceOf(IdentitySigned);
const signedEvt = events[0] as IdentitySigned;
expect(signedEvt.identityId).toEqual(identity.id.toString());
expect(signedEvt.eventKind).toBe(1);
expect(signedEvt.signedEventId).toEqual('signed-event-id');
});
});
describe('toSnapshot', () => {
it('should create complete snapshot for storage', () => {
const identity = Identity.create('Snapshot Test', TEST_PRIVATE_KEY);
const snapshot = identity.toSnapshot();
expect(snapshot.id).toEqual(identity.id.toString());
expect(snapshot.nick).toEqual('Snapshot Test');
expect(snapshot.privkey).toBeTruthy();
expect(snapshot.createdAt).toBeTruthy();
});
});
describe('npub', () => {
it('should return bech32 encoded public key', () => {
const identity = Identity.create('NpubTest');
expect(identity.npub).toMatch(/^npub1[a-z0-9]+$/);
});
});
describe('pullDomainEvents', () => {
it('should clear events after pulling', () => {
const identity = Identity.create('Test');
const firstPull = identity.pullDomainEvents();
const secondPull = identity.pullDomainEvents();
expect(firstPull.length).toBe(1);
expect(secondPull.length).toBe(0);
});
it('should accumulate multiple events', () => {
const identity = Identity.create('Multi');
identity.rename('Name1');
identity.rename('Name2');
const events = identity.pullDomainEvents();
expect(events.length).toBe(3); // Created + 2 renames
});
});
});

View File

@@ -0,0 +1,305 @@
import { AggregateRoot } from '../events/domain-event';
import { IdentityCreated, IdentityRenamed, IdentitySigned } from '../events/identity-events';
import {
IdentityId,
Nickname,
NostrKeyPair,
} from '../value-objects';
import type { IdentitySnapshot } from '../repositories/identity-repository';
/**
* Represents an unsigned Nostr event template.
* This is what gets passed to the sign method.
*/
export interface UnsignedEvent {
kind: number;
created_at: number;
tags: string[][];
content: string;
}
/**
* Represents a signed Nostr event.
*/
export interface SignedEvent extends UnsignedEvent {
id: string;
pubkey: string;
sig: string;
}
/**
* Signing function type - injected to avoid coupling to nostr-tools.
*/
export type SigningFunction = (event: UnsignedEvent, privateKeyBytes: Uint8Array) => SignedEvent;
/**
* Encryption function types for NIP-04 and NIP-44.
*/
export type EncryptFunction = (
privateKeyBytes: Uint8Array,
peerPubkey: string,
plaintext: string
) => Promise<string>;
export type DecryptFunction = (
privateKeyBytes: Uint8Array,
peerPubkey: string,
ciphertext: string
) => Promise<string>;
/**
* Identity entity - represents a Nostr identity with its keypair.
*
* This is an aggregate root that encapsulates all operations
* related to a single Nostr identity.
*/
export class Identity extends AggregateRoot {
private readonly _id: IdentityId;
private _nickname: Nickname;
private readonly _keyPair: NostrKeyPair;
private readonly _createdAt: Date;
private constructor(
id: IdentityId,
nickname: Nickname,
keyPair: NostrKeyPair,
createdAt: Date
) {
super();
this._id = id;
this._nickname = nickname;
this._keyPair = keyPair;
this._createdAt = createdAt;
}
// ─────────────────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Create a new identity with an optional private key.
* If no private key is provided, a new one will be generated.
*
* @param nickname - User-friendly name for this identity
* @param privateKey - Optional private key (hex or nsec format)
* @throws InvalidNicknameError if nickname is invalid
* @throws InvalidNostrKeyError if private key is invalid
*/
static create(nickname: string, privateKey?: string): Identity {
const keyPair = privateKey
? NostrKeyPair.fromPrivateKey(privateKey)
: NostrKeyPair.generate();
const identity = new Identity(
IdentityId.generate(),
Nickname.create(nickname),
keyPair,
new Date()
);
identity.addDomainEvent(
new IdentityCreated(
identity._id.value,
identity.publicKey,
identity.nickname
)
);
return identity;
}
/**
* Reconstitute an identity from storage.
* This bypasses validation since data comes from trusted storage.
*/
static fromSnapshot(snapshot: IdentitySnapshot): Identity {
return new Identity(
IdentityId.from(snapshot.id),
Nickname.fromStorage(snapshot.nick),
NostrKeyPair.fromStorage(snapshot.privkey),
new Date(snapshot.createdAt)
);
}
// ─────────────────────────────────────────────────────────────────────────
// Getters (Read-only access to state)
// ─────────────────────────────────────────────────────────────────────────
get id(): IdentityId {
return this._id;
}
get nickname(): string {
return this._nickname.value;
}
get publicKey(): string {
return this._keyPair.publicKeyHex;
}
get npub(): string {
return this._keyPair.npub;
}
get nsec(): string {
return this._keyPair.nsec;
}
get createdAt(): Date {
return this._createdAt;
}
// ─────────────────────────────────────────────────────────────────────────
// Behavior Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Rename this identity.
*
* @param newNickname - The new nickname
* @throws InvalidNicknameError if nickname is invalid
*/
rename(newNickname: string): void {
const oldNickname = this._nickname.value;
this._nickname = Nickname.create(newNickname);
this.addDomainEvent(
new IdentityRenamed(this._id.value, oldNickname, newNickname)
);
}
/**
* Sign a Nostr event with this identity's private key.
*
* @param event - The unsigned event template
* @param signFn - The signing function (injected to avoid coupling)
* @returns The signed event with id, pubkey, and sig
*/
sign(event: UnsignedEvent, signFn: SigningFunction): SignedEvent {
const signedEvent = signFn(event, this._keyPair.getPrivateKeyBytes());
this.addDomainEvent(
new IdentitySigned(this._id.value, event.kind, signedEvent.id)
);
return signedEvent;
}
/**
* Encrypt a message using NIP-04 encryption.
*
* @param plaintext - The message to encrypt
* @param recipientPubkey - The recipient's public key (hex)
* @param encryptFn - The NIP-04 encryption function
*/
async encryptNip04(
plaintext: string,
recipientPubkey: string,
encryptFn: EncryptFunction
): Promise<string> {
return encryptFn(
this._keyPair.getPrivateKeyBytes(),
recipientPubkey,
plaintext
);
}
/**
* Decrypt a message using NIP-04 decryption.
*
* @param ciphertext - The encrypted message
* @param senderPubkey - The sender's public key (hex)
* @param decryptFn - The NIP-04 decryption function
*/
async decryptNip04(
ciphertext: string,
senderPubkey: string,
decryptFn: DecryptFunction
): Promise<string> {
return decryptFn(
this._keyPair.getPrivateKeyBytes(),
senderPubkey,
ciphertext
);
}
/**
* Encrypt a message using NIP-44 encryption.
*
* @param plaintext - The message to encrypt
* @param recipientPubkey - The recipient's public key (hex)
* @param encryptFn - The NIP-44 encryption function
*/
async encryptNip44(
plaintext: string,
recipientPubkey: string,
encryptFn: EncryptFunction
): Promise<string> {
return encryptFn(
this._keyPair.getPrivateKeyBytes(),
recipientPubkey,
plaintext
);
}
/**
* Decrypt a message using NIP-44 decryption.
*
* @param ciphertext - The encrypted message
* @param senderPubkey - The sender's public key (hex)
* @param decryptFn - The NIP-44 decryption function
*/
async decryptNip44(
ciphertext: string,
senderPubkey: string,
decryptFn: DecryptFunction
): Promise<string> {
return decryptFn(
this._keyPair.getPrivateKeyBytes(),
senderPubkey,
ciphertext
);
}
/**
* Check if this identity has the same private key as another.
* Used for duplicate detection.
*/
hasSameKeyAs(other: Identity): boolean {
return this._keyPair.hasSamePublicKey(other._keyPair);
}
/**
* Check if this identity matches a given public key.
*/
matchesPublicKey(publicKey: string): boolean {
return this._keyPair.matchesPublicKey(publicKey);
}
// ─────────────────────────────────────────────────────────────────────────
// Persistence
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert to a snapshot for persistence.
*/
toSnapshot(): IdentitySnapshot {
return {
id: this._id.value,
nick: this._nickname.value,
privkey: this._keyPair.toStorageHex(),
createdAt: this._createdAt.toISOString(),
};
}
// ─────────────────────────────────────────────────────────────────────────
// Equality
// ─────────────────────────────────────────────────────────────────────────
/**
* Check equality based on identity ID.
*/
equals(other: Identity): boolean {
return this._id.equals(other._id);
}
}

View File

@@ -0,0 +1,21 @@
export {
Identity,
} from './identity';
export type {
UnsignedEvent,
SignedEvent,
SigningFunction,
EncryptFunction,
DecryptFunction,
} from './identity';
export {
Permission,
PermissionChecker,
} from './permission';
export {
Relay,
InvalidRelayUrlError,
toNip65RelayList,
} from './relay';

View File

@@ -0,0 +1,175 @@
import { Permission, PermissionChecker } from './permission';
import { IdentityId } from '../value-objects';
describe('Permission Entity', () => {
const testIdentityId = IdentityId.from('identity-1');
const testHost = 'example.com';
const testMethod = 'signEvent';
describe('allow', () => {
it('should create an allow permission', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.isAllowed()).toBe(true);
});
it('should create permission with kind for signEvent', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod, 1);
expect(permission.isAllowed()).toBe(true);
});
});
describe('deny', () => {
it('should create a deny permission', () => {
const permission = Permission.deny(testIdentityId, testHost, testMethod);
expect(permission.isAllowed()).toBe(false);
});
});
describe('matches', () => {
it('should match when all parameters are the same', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.matches(testIdentityId, testHost, testMethod)).toBe(true);
});
it('should not match when identity differs', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
const differentIdentity = IdentityId.from('identity-2');
expect(permission.matches(differentIdentity, testHost, testMethod)).toBe(false);
});
it('should not match when host differs', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.matches(testIdentityId, 'other.com', testMethod)).toBe(false);
});
it('should not match when method differs', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.matches(testIdentityId, testHost, 'getPublicKey')).toBe(false);
});
it('should match any kind when permission has no kind specified', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.matches(testIdentityId, testHost, testMethod, 1)).toBe(true);
expect(permission.matches(testIdentityId, testHost, testMethod, 30023)).toBe(true);
});
it('should only match specific kind when permission has kind', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod, 1);
expect(permission.matches(testIdentityId, testHost, testMethod, 1)).toBe(true);
expect(permission.matches(testIdentityId, testHost, testMethod, 30023)).toBe(false);
});
});
describe('fromSnapshot', () => {
it('should reconstruct permission from snapshot', () => {
const original = Permission.allow(testIdentityId, testHost, testMethod, 1);
const snapshot = original.toSnapshot();
const restored = Permission.fromSnapshot(snapshot);
expect(restored.isAllowed()).toBe(true);
expect(restored.matches(testIdentityId, testHost, testMethod, 1)).toBe(true);
});
});
describe('toSnapshot', () => {
it('should create valid snapshot', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod, 1);
const snapshot = permission.toSnapshot();
expect(snapshot.identityId).toEqual(testIdentityId.toString());
expect(snapshot.host).toEqual(testHost);
expect(snapshot.method).toEqual(testMethod);
expect(snapshot.methodPolicy).toEqual('allow');
expect(snapshot.kind).toBe(1);
});
});
});
describe('PermissionChecker', () => {
const identity1 = IdentityId.from('identity-1');
const identity2 = IdentityId.from('identity-2');
describe('check', () => {
it('should return true for allowed permission', () => {
const permissions = [
Permission.allow(identity1, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(true);
});
it('should return false for denied permission', () => {
const permissions = [
Permission.deny(identity1, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(false);
});
it('should return undefined when no matching permission exists', () => {
const permissions = [
Permission.allow(identity1, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity2, 'example.com', 'signEvent')).toBeUndefined();
});
it('should check kind-specific permissions first', () => {
const permissions = [
Permission.deny(identity1, 'example.com', 'signEvent', 1), // Deny kind 1
Permission.allow(identity1, 'example.com', 'signEvent'), // Allow all others
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'signEvent', 1)).toBe(false);
expect(checker.check(identity1, 'example.com', 'signEvent', 30023)).toBe(true);
});
it('should handle multiple identities', () => {
const permissions = [
Permission.allow(identity1, 'example.com', 'signEvent'),
Permission.deny(identity2, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(true);
expect(checker.check(identity2, 'example.com', 'signEvent')).toBe(false);
});
it('should handle multiple hosts', () => {
const permissions = [
Permission.allow(identity1, 'allowed.com', 'signEvent'),
Permission.deny(identity1, 'denied.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'allowed.com', 'signEvent')).toBe(true);
expect(checker.check(identity1, 'denied.com', 'signEvent')).toBe(false);
expect(checker.check(identity1, 'unknown.com', 'signEvent')).toBeUndefined();
});
it('should handle multiple methods', () => {
const permissions = [
Permission.allow(identity1, 'example.com', 'getPublicKey'),
Permission.deny(identity1, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'getPublicKey')).toBe(true);
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(false);
});
});
});

View File

@@ -0,0 +1,332 @@
import { IdentityId, PermissionId } from '../value-objects';
import type {
PermissionSnapshot,
ExtensionMethod,
PermissionPolicy,
} from '../repositories/permission-repository';
/**
* Permission entity - represents an authorization decision for
* a specific identity, host, and method combination.
*
* Permissions are immutable once created - to change a permission,
* delete it and create a new one.
*/
export class Permission {
private readonly _id: PermissionId;
private readonly _identityId: IdentityId;
private readonly _host: string;
private readonly _method: ExtensionMethod;
private readonly _policy: PermissionPolicy;
private readonly _kind?: number;
private constructor(
id: PermissionId,
identityId: IdentityId,
host: string,
method: ExtensionMethod,
policy: PermissionPolicy,
kind?: number
) {
this._id = id;
this._identityId = identityId;
this._host = host;
this._method = method;
this._policy = policy;
this._kind = kind;
}
// ─────────────────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Create an "allow" permission.
*/
static allow(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): Permission {
return new Permission(
PermissionId.generate(),
identityId,
Permission.normalizeHost(host),
method,
'allow',
kind
);
}
/**
* Create a "deny" permission.
*/
static deny(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): Permission {
return new Permission(
PermissionId.generate(),
identityId,
Permission.normalizeHost(host),
method,
'deny',
kind
);
}
/**
* Create a permission with explicit policy.
*/
static create(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
policy: PermissionPolicy,
kind?: number
): Permission {
return new Permission(
PermissionId.generate(),
identityId,
Permission.normalizeHost(host),
method,
policy,
kind
);
}
/**
* Reconstitute a permission from storage.
*/
static fromSnapshot(snapshot: PermissionSnapshot): Permission {
return new Permission(
PermissionId.from(snapshot.id),
IdentityId.from(snapshot.identityId),
snapshot.host,
snapshot.method,
snapshot.methodPolicy,
snapshot.kind
);
}
// ─────────────────────────────────────────────────────────────────────────
// Getters
// ─────────────────────────────────────────────────────────────────────────
get id(): PermissionId {
return this._id;
}
get identityId(): IdentityId {
return this._identityId;
}
get host(): string {
return this._host;
}
get method(): ExtensionMethod {
return this._method;
}
get policy(): PermissionPolicy {
return this._policy;
}
get kind(): number | undefined {
return this._kind;
}
// ─────────────────────────────────────────────────────────────────────────
// Behavior
// ─────────────────────────────────────────────────────────────────────────
/**
* Check if this permission allows the action.
*/
isAllowed(): boolean {
return this._policy === 'allow';
}
/**
* Check if this permission denies the action.
*/
isDenied(): boolean {
return this._policy === 'deny';
}
/**
* Check if this permission matches the given criteria.
* For signEvent with kind specified, also checks the kind.
*/
matches(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): boolean {
if (!this._identityId.equals(identityId)) {
return false;
}
if (this._host !== Permission.normalizeHost(host)) {
return false;
}
if (this._method !== method) {
return false;
}
// For signEvent, handle kind matching
if (method === 'signEvent') {
// If this permission has no kind, it matches all kinds
if (this._kind === undefined) {
return true;
}
// If checking a specific kind, must match exactly
return this._kind === kind;
}
return true;
}
/**
* Check if this permission applies to a specific event kind.
* Only relevant for signEvent method.
*/
appliesToKind(kind: number): boolean {
if (this._method !== 'signEvent') {
return false;
}
// No kind restriction means applies to all
if (this._kind === undefined) {
return true;
}
return this._kind === kind;
}
/**
* Check if this is a blanket permission (no kind restriction).
*/
isBlanketPermission(): boolean {
return this._method === 'signEvent' && this._kind === undefined;
}
// ─────────────────────────────────────────────────────────────────────────
// Persistence
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert to a snapshot for persistence.
*/
toSnapshot(): PermissionSnapshot {
const snapshot: PermissionSnapshot = {
id: this._id.value,
identityId: this._identityId.value,
host: this._host,
method: this._method,
methodPolicy: this._policy,
};
if (this._kind !== undefined) {
snapshot.kind = this._kind;
}
return snapshot;
}
// ─────────────────────────────────────────────────────────────────────────
// Equality
// ─────────────────────────────────────────────────────────────────────────
/**
* Check equality based on permission ID.
*/
equals(other: Permission): boolean {
return this._id.equals(other._id);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private static normalizeHost(host: string): string {
return host.toLowerCase().trim();
}
}
/**
* Permission checker - evaluates permissions for a request.
* This encapsulates the permission checking logic.
*/
export class PermissionChecker {
constructor(private readonly permissions: Permission[]) {}
/**
* Check if an action is allowed.
*
* @returns true if allowed, false if denied, undefined if no matching permission
*/
check(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): boolean | undefined {
const matching = this.permissions.filter((p) =>
p.matches(identityId, host, method, kind)
);
if (matching.length === 0) {
return undefined;
}
// For signEvent with kind, check specific rules
// Kind-specific rules take priority over blanket rules
if (method === 'signEvent' && kind !== undefined) {
// Check for specific kind deny first (takes priority)
if (matching.some((p) => p.kind === kind && p.isDenied())) {
return false;
}
// Check for specific kind allow
if (matching.some((p) => p.kind === kind && p.isAllowed())) {
return true;
}
// Fall back to blanket allow (no kind restriction)
if (matching.some((p) => p.isBlanketPermission() && p.isAllowed())) {
return true;
}
// Fall back to blanket deny
if (matching.some((p) => p.isBlanketPermission() && p.isDenied())) {
return false;
}
// No specific rule found
return undefined;
}
// For other methods, all matching permissions must allow
return matching.every((p) => p.isAllowed());
}
/**
* Get all permissions for a specific identity.
*/
forIdentity(identityId: IdentityId): Permission[] {
return this.permissions.filter((p) => p.identityId.equals(identityId));
}
/**
* Get all permissions for a specific host.
*/
forHost(host: string): Permission[] {
const normalizedHost = host.toLowerCase().trim();
return this.permissions.filter((p) => p.host === normalizedHost);
}
}

View File

@@ -0,0 +1,155 @@
import { Relay, InvalidRelayUrlError, toNip65RelayList } from './relay';
import { IdentityId } from '../value-objects';
describe('Relay Entity', () => {
const testIdentityId = IdentityId.from('identity-1');
const validUrl = 'wss://relay.example.com';
describe('create', () => {
it('should create relay with default read/write permissions', () => {
const relay = Relay.create(testIdentityId, validUrl);
expect(relay.url).toEqual(validUrl);
expect(relay.read).toBe(true);
expect(relay.write).toBe(true);
});
it('should create relay with specified permissions', () => {
const relay = Relay.create(testIdentityId, validUrl, true, false);
expect(relay.read).toBe(true);
expect(relay.write).toBe(false);
});
it('should create relay with read-only permissions', () => {
const relay = Relay.create(testIdentityId, validUrl, true, false);
expect(relay.read).toBe(true);
expect(relay.write).toBe(false);
});
it('should create relay with write-only permissions', () => {
const relay = Relay.create(testIdentityId, validUrl, false, true);
expect(relay.read).toBe(false);
expect(relay.write).toBe(true);
});
it('should throw InvalidRelayUrlError for invalid URL', () => {
expect(() => Relay.create(testIdentityId, 'not-a-url')).toThrowError(InvalidRelayUrlError);
});
it('should throw InvalidRelayUrlError for http URL', () => {
expect(() => Relay.create(testIdentityId, 'http://relay.example.com')).toThrowError(InvalidRelayUrlError);
});
it('should accept wss:// URL', () => {
expect(() => Relay.create(testIdentityId, 'wss://relay.example.com')).not.toThrow();
});
it('should accept ws:// URL (for local development)', () => {
expect(() => Relay.create(testIdentityId, 'ws://localhost:8080')).not.toThrow();
});
});
describe('updateUrl', () => {
it('should update URL to valid new URL', () => {
const relay = Relay.create(testIdentityId, validUrl);
relay.updateUrl('wss://new-relay.example.com');
expect(relay.url).toEqual('wss://new-relay.example.com');
});
it('should throw InvalidRelayUrlError for invalid new URL', () => {
const relay = Relay.create(testIdentityId, validUrl);
expect(() => relay.updateUrl('not-a-url')).toThrowError(InvalidRelayUrlError);
});
});
describe('read permission toggling', () => {
it('should enable read', () => {
const relay = Relay.create(testIdentityId, validUrl, false, false);
relay.enableRead();
expect(relay.read).toBe(true);
});
it('should disable read', () => {
const relay = Relay.create(testIdentityId, validUrl, true, true);
relay.disableRead();
expect(relay.read).toBe(false);
});
});
describe('write permission toggling', () => {
it('should enable write', () => {
const relay = Relay.create(testIdentityId, validUrl, false, false);
relay.enableWrite();
expect(relay.write).toBe(true);
});
it('should disable write', () => {
const relay = Relay.create(testIdentityId, validUrl, true, true);
relay.disableWrite();
expect(relay.write).toBe(false);
});
});
describe('fromSnapshot', () => {
it('should reconstruct relay from snapshot', () => {
const original = Relay.create(testIdentityId, validUrl, true, false);
const snapshot = original.toSnapshot();
const restored = Relay.fromSnapshot(snapshot);
expect(restored.url).toEqual(validUrl);
expect(restored.read).toBe(true);
expect(restored.write).toBe(false);
});
});
describe('toSnapshot', () => {
it('should create valid snapshot', () => {
const relay = Relay.create(testIdentityId, validUrl, true, false);
const snapshot = relay.toSnapshot();
expect(snapshot.identityId).toEqual(testIdentityId.toString());
expect(snapshot.url).toEqual(validUrl);
expect(snapshot.read).toBe(true);
expect(snapshot.write).toBe(false);
});
});
});
describe('toNip65RelayList', () => {
const identityId = IdentityId.from('identity-1');
it('should convert relays to NIP-65 format', () => {
const relays = [
Relay.create(identityId, 'wss://relay1.com', true, true),
Relay.create(identityId, 'wss://relay2.com', true, false),
Relay.create(identityId, 'wss://relay3.com', false, true),
];
const nip65List = toNip65RelayList(relays);
expect(nip65List['wss://relay1.com']).toEqual({ read: true, write: true });
expect(nip65List['wss://relay2.com']).toEqual({ read: true, write: false });
expect(nip65List['wss://relay3.com']).toEqual({ read: false, write: true });
});
it('should return empty object for empty relay list', () => {
const nip65List = toNip65RelayList([]);
expect(nip65List).toEqual({});
});
});

View File

@@ -0,0 +1,268 @@
import { IdentityId, RelayId } from '../value-objects';
import type { RelaySnapshot } from '../repositories/relay-repository';
/**
* Relay entity - represents a Nostr relay configuration for an identity.
*/
export class Relay {
private readonly _id: RelayId;
private readonly _identityId: IdentityId;
private _url: string;
private _read: boolean;
private _write: boolean;
private constructor(
id: RelayId,
identityId: IdentityId,
url: string,
read: boolean,
write: boolean
) {
this._id = id;
this._identityId = identityId;
this._url = Relay.normalizeUrl(url);
this._read = read;
this._write = write;
}
// ─────────────────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Create a new relay configuration.
*
* @param identityId - The identity this relay belongs to
* @param url - The relay WebSocket URL
* @param read - Whether to read events from this relay
* @param write - Whether to write events to this relay
*/
static create(
identityId: IdentityId,
url: string,
read = true,
write = true
): Relay {
Relay.validateUrl(url);
return new Relay(
RelayId.generate(),
identityId,
url,
read,
write
);
}
/**
* Reconstitute a relay from storage.
*/
static fromSnapshot(snapshot: RelaySnapshot): Relay {
return new Relay(
RelayId.from(snapshot.id),
IdentityId.from(snapshot.identityId),
snapshot.url,
snapshot.read,
snapshot.write
);
}
// ─────────────────────────────────────────────────────────────────────────
// Getters
// ─────────────────────────────────────────────────────────────────────────
get id(): RelayId {
return this._id;
}
get identityId(): IdentityId {
return this._identityId;
}
get url(): string {
return this._url;
}
get read(): boolean {
return this._read;
}
get write(): boolean {
return this._write;
}
// ─────────────────────────────────────────────────────────────────────────
// Behavior
// ─────────────────────────────────────────────────────────────────────────
/**
* Update the relay URL.
*/
updateUrl(newUrl: string): void {
Relay.validateUrl(newUrl);
this._url = Relay.normalizeUrl(newUrl);
}
/**
* Enable reading from this relay.
*/
enableRead(): void {
this._read = true;
}
/**
* Disable reading from this relay.
*/
disableRead(): void {
this._read = false;
}
/**
* Enable writing to this relay.
*/
enableWrite(): void {
this._write = true;
}
/**
* Disable writing to this relay.
*/
disableWrite(): void {
this._write = false;
}
/**
* Set both read and write permissions.
*/
setPermissions(read: boolean, write: boolean): void {
this._read = read;
this._write = write;
}
/**
* Check if this relay is enabled for either read or write.
*/
isEnabled(): boolean {
return this._read || this._write;
}
/**
* Check if this relay has the same URL as another (case-insensitive).
*/
hasSameUrl(url: string): boolean {
return this._url.toLowerCase() === Relay.normalizeUrl(url).toLowerCase();
}
/**
* Check if this relay belongs to a specific identity.
*/
belongsTo(identityId: IdentityId): boolean {
return this._identityId.equals(identityId);
}
// ─────────────────────────────────────────────────────────────────────────
// Persistence
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert to a snapshot for persistence.
*/
toSnapshot(): RelaySnapshot {
return {
id: this._id.value,
identityId: this._identityId.value,
url: this._url,
read: this._read,
write: this._write,
};
}
/**
* Create a clone for modification without affecting the original.
*/
clone(): Relay {
return new Relay(
this._id,
this._identityId,
this._url,
this._read,
this._write
);
}
// ─────────────────────────────────────────────────────────────────────────
// Equality
// ─────────────────────────────────────────────────────────────────────────
/**
* Check equality based on relay ID.
*/
equals(other: Relay): boolean {
return this._id.equals(other._id);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private static normalizeUrl(url: string): string {
let normalized = url.trim();
// Remove trailing slash
if (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
private static validateUrl(url: string): void {
const normalized = Relay.normalizeUrl(url);
if (!normalized) {
throw new InvalidRelayUrlError('Relay URL cannot be empty');
}
// Must start with wss:// or ws://
if (!normalized.startsWith('wss://') && !normalized.startsWith('ws://')) {
throw new InvalidRelayUrlError(
'Relay URL must start with wss:// or ws://'
);
}
// Try to parse as URL
try {
new URL(normalized);
} catch {
throw new InvalidRelayUrlError(`Invalid relay URL: ${url}`);
}
}
}
/**
* Error thrown when a relay URL is invalid.
*/
export class InvalidRelayUrlError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidRelayUrlError';
}
}
/**
* Helper to convert relay list to NIP-65 format.
*/
export function toNip65RelayList(
relays: Relay[]
): Record<string, { read: boolean; write: boolean }> {
const result: Record<string, { read: boolean; write: boolean }> = {};
for (const relay of relays) {
if (relay.isEnabled()) {
result[relay.url] = {
read: relay.read,
write: relay.write,
};
}
}
return result;
}

View File

@@ -0,0 +1,81 @@
import { DomainEvent, AggregateRoot } from './domain-event';
// Concrete implementation for testing
class TestEvent extends DomainEvent {
readonly eventType = 'test.event';
constructor(readonly testData: string) {
super();
}
}
class TestAggregate extends AggregateRoot {
doSomething(data: string): void {
this.addDomainEvent(new TestEvent(data));
}
}
describe('DomainEvent', () => {
describe('base properties', () => {
it('should have occurredAt timestamp', () => {
const before = new Date();
const event = new TestEvent('test');
const after = new Date();
expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('should have unique eventId', () => {
const event1 = new TestEvent('test1');
const event2 = new TestEvent('test2');
expect(event1.eventId).not.toEqual(event2.eventId);
});
it('should have eventType from subclass', () => {
const event = new TestEvent('test');
expect(event.eventType).toEqual('test.event');
});
});
});
describe('AggregateRoot', () => {
describe('domain events', () => {
it('should collect domain events', () => {
const aggregate = new TestAggregate();
aggregate.doSomething('first');
aggregate.doSomething('second');
const events = aggregate.pullDomainEvents();
expect(events.length).toBe(2);
expect((events[0] as TestEvent).testData).toEqual('first');
expect((events[1] as TestEvent).testData).toEqual('second');
});
it('should clear events after pulling', () => {
const aggregate = new TestAggregate();
aggregate.doSomething('test');
aggregate.pullDomainEvents();
const secondPull = aggregate.pullDomainEvents();
expect(secondPull.length).toBe(0);
});
it('should preserve event order', () => {
const aggregate = new TestAggregate();
aggregate.doSomething('1');
aggregate.doSomething('2');
aggregate.doSomething('3');
const events = aggregate.pullDomainEvents();
expect(events.map(e => (e as TestEvent).testData)).toEqual(['1', '2', '3']);
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* Base class for all domain events.
* Domain events capture significant occurrences in the domain that
* domain experts care about.
*/
export abstract class DomainEvent {
readonly occurredAt: Date;
readonly eventId: string;
constructor() {
this.occurredAt = new Date();
this.eventId = crypto.randomUUID();
}
/**
* Get the event type identifier.
* Used for event routing and serialization.
*/
abstract get eventType(): string;
}
/**
* Interface for entities that can raise domain events.
*/
export interface EventRaiser {
/**
* Pull all pending domain events from the entity.
* This clears the internal event list.
*/
pullDomainEvents(): DomainEvent[];
}
/**
* Base class for aggregate roots that can raise domain events.
*/
export abstract class AggregateRoot implements EventRaiser {
private _domainEvents: DomainEvent[] = [];
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
/**
* Check if there are any pending domain events.
*/
hasPendingEvents(): boolean {
return this._domainEvents.length > 0;
}
}

View File

@@ -0,0 +1,110 @@
import {
IdentityCreated,
IdentityRenamed,
IdentitySelected,
IdentitySigned,
IdentityDeleted,
} from './identity-events';
describe('Identity Domain Events', () => {
describe('IdentityCreated', () => {
it('should store identity creation data', () => {
const event = new IdentityCreated('id-123', 'pubkey-abc', 'Alice');
expect(event.identityId).toEqual('id-123');
expect(event.publicKey).toEqual('pubkey-abc');
expect(event.nickname).toEqual('Alice');
});
it('should have correct event type', () => {
const event = new IdentityCreated('id', 'pubkey', 'name');
expect(event.eventType).toEqual('identity.created');
});
it('should have inherited base properties', () => {
const event = new IdentityCreated('id', 'pubkey', 'name');
expect(event.eventId).toBeTruthy();
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('IdentityRenamed', () => {
it('should store rename data', () => {
const event = new IdentityRenamed('id-123', 'OldName', 'NewName');
expect(event.identityId).toEqual('id-123');
expect(event.oldNickname).toEqual('OldName');
expect(event.newNickname).toEqual('NewName');
});
it('should have correct event type', () => {
const event = new IdentityRenamed('id', 'old', 'new');
expect(event.eventType).toEqual('identity.renamed');
});
});
describe('IdentitySelected', () => {
it('should store selection data with previous identity', () => {
const event = new IdentitySelected('id-new', 'id-old');
expect(event.identityId).toEqual('id-new');
expect(event.previousIdentityId).toEqual('id-old');
});
it('should handle null previous identity', () => {
const event = new IdentitySelected('id-new', null);
expect(event.identityId).toEqual('id-new');
expect(event.previousIdentityId).toBeNull();
});
it('should have correct event type', () => {
const event = new IdentitySelected('id', null);
expect(event.eventType).toEqual('identity.selected');
});
});
describe('IdentitySigned', () => {
it('should store signing data', () => {
const event = new IdentitySigned('id-123', 1, 'event-id-abc');
expect(event.identityId).toEqual('id-123');
expect(event.eventKind).toBe(1);
expect(event.signedEventId).toEqual('event-id-abc');
});
it('should have correct event type', () => {
const event = new IdentitySigned('id', 1, 'event-id');
expect(event.eventType).toEqual('identity.signed');
});
it('should handle various event kinds', () => {
const kindExamples = [0, 1, 3, 4, 7, 30023, 10002];
kindExamples.forEach(kind => {
const event = new IdentitySigned('id', kind, 'event');
expect(event.eventKind).toBe(kind);
});
});
});
describe('IdentityDeleted', () => {
it('should store deletion data', () => {
const event = new IdentityDeleted('id-123', 'pubkey-abc');
expect(event.identityId).toEqual('id-123');
expect(event.publicKey).toEqual('pubkey-abc');
});
it('should have correct event type', () => {
const event = new IdentityDeleted('id', 'pubkey');
expect(event.eventType).toEqual('identity.deleted');
});
});
});

View File

@@ -0,0 +1,74 @@
import { DomainEvent } from './domain-event';
/**
* Event raised when a new identity is created.
*/
export class IdentityCreated extends DomainEvent {
readonly eventType = 'identity.created';
constructor(
readonly identityId: string,
readonly publicKey: string,
readonly nickname: string
) {
super();
}
}
/**
* Event raised when an identity is renamed.
*/
export class IdentityRenamed extends DomainEvent {
readonly eventType = 'identity.renamed';
constructor(
readonly identityId: string,
readonly oldNickname: string,
readonly newNickname: string
) {
super();
}
}
/**
* Event raised when an identity is selected (made active).
*/
export class IdentitySelected extends DomainEvent {
readonly eventType = 'identity.selected';
constructor(
readonly identityId: string,
readonly previousIdentityId: string | null
) {
super();
}
}
/**
* Event raised when an identity signs an event.
*/
export class IdentitySigned extends DomainEvent {
readonly eventType = 'identity.signed';
constructor(
readonly identityId: string,
readonly eventKind: number,
readonly signedEventId: string
) {
super();
}
}
/**
* Event raised when an identity is deleted.
*/
export class IdentityDeleted extends DomainEvent {
readonly eventType = 'identity.deleted';
constructor(
readonly identityId: string,
readonly publicKey: string
) {
super();
}
}

View File

@@ -0,0 +1,9 @@
export { DomainEvent, AggregateRoot } from './domain-event';
export type { EventRaiser } from './domain-event';
export {
IdentityCreated,
IdentityRenamed,
IdentitySelected,
IdentitySigned,
IdentityDeleted,
} from './identity-events';

View File

@@ -0,0 +1,11 @@
// Value Objects
export * from './value-objects';
// Repository Interfaces
export * from './repositories';
// Domain Events
export * from './events';
// Domain Entities
export * from './entities';

View File

@@ -0,0 +1,89 @@
import { IdentityId } from '../value-objects';
/**
* Snapshot of an identity for persistence.
* This is the data structure that gets persisted, separate from the domain entity.
*/
export interface IdentitySnapshot {
id: string;
nick: string;
privkey: string;
createdAt: string;
}
/**
* Repository interface for Identity aggregate.
* Implementations handle encryption and storage specifics.
*/
export interface IdentityRepository {
/**
* Find an identity by its ID.
* Returns undefined if not found.
*/
findById(id: IdentityId): Promise<IdentitySnapshot | undefined>;
/**
* Find an identity by its public key.
* Returns undefined if not found.
*/
findByPublicKey(publicKey: string): Promise<IdentitySnapshot | undefined>;
/**
* Find an identity by its private key.
* Used for duplicate detection.
* Returns undefined if not found.
*/
findByPrivateKey(privateKey: string): Promise<IdentitySnapshot | undefined>;
/**
* Get all identities.
*/
findAll(): Promise<IdentitySnapshot[]>;
/**
* Save a new or updated identity.
* If an identity with the same ID exists, it will be updated.
*/
save(identity: IdentitySnapshot): Promise<void>;
/**
* Delete an identity by its ID.
* Returns true if the identity was deleted, false if it didn't exist.
*/
delete(id: IdentityId): Promise<boolean>;
/**
* Get the currently selected identity ID.
*/
getSelectedId(): Promise<IdentityId | null>;
/**
* Set the currently selected identity ID.
*/
setSelectedId(id: IdentityId | null): Promise<void>;
/**
* Count the total number of identities.
*/
count(): Promise<number>;
}
/**
* Error thrown when an identity operation fails.
*/
export class IdentityRepositoryError extends Error {
constructor(
message: string,
public readonly code: IdentityErrorCode
) {
super(message);
this.name = 'IdentityRepositoryError';
}
}
export enum IdentityErrorCode {
DUPLICATE_PRIVATE_KEY = 'DUPLICATE_PRIVATE_KEY',
NOT_FOUND = 'NOT_FOUND',
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
STORAGE_FAILED = 'STORAGE_FAILED',
}

View File

@@ -0,0 +1,30 @@
export {
IdentityRepositoryError,
IdentityErrorCode,
} from './identity-repository';
export type {
IdentityRepository,
IdentitySnapshot,
} from './identity-repository';
export {
PermissionRepositoryError,
PermissionErrorCode,
} from './permission-repository';
export type {
PermissionRepository,
PermissionSnapshot,
PermissionQuery,
ExtensionMethod,
PermissionPolicy,
} from './permission-repository';
export {
RelayRepositoryError,
RelayErrorCode,
} from './relay-repository';
export type {
RelayRepository,
RelaySnapshot,
RelayQuery,
} from './relay-repository';

View File

@@ -0,0 +1,108 @@
import { IdentityId, PermissionId } from '../value-objects';
import type { ExtensionMethod, Nip07MethodPolicy } from '../../models/nostr';
// Re-export types from models for convenience
// These are the canonical definitions used throughout the app
export type { ExtensionMethod, Nip07MethodPolicy as PermissionPolicy } from '../../models/nostr';
// Local type alias for cleaner code
type PermissionPolicy = Nip07MethodPolicy;
/**
* Snapshot of a permission for persistence.
*/
export interface PermissionSnapshot {
id: string;
identityId: string;
host: string;
method: ExtensionMethod;
methodPolicy: PermissionPolicy;
kind?: number; // For signEvent, filter by event kind
}
/**
* Query criteria for finding permissions.
*/
export interface PermissionQuery {
identityId?: IdentityId;
host?: string;
method?: ExtensionMethod;
kind?: number;
}
/**
* Repository interface for Permission aggregate.
*/
export interface PermissionRepository {
/**
* Find a permission by its ID.
*/
findById(id: PermissionId): Promise<PermissionSnapshot | undefined>;
/**
* Find permissions matching the query criteria.
*/
find(query: PermissionQuery): Promise<PermissionSnapshot[]>;
/**
* Find a specific permission for an identity, host, method, and optionally kind.
* This is the most common lookup for checking if an action is allowed.
*/
findExact(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): Promise<PermissionSnapshot | undefined>;
/**
* Get all permissions for an identity.
*/
findByIdentity(identityId: IdentityId): Promise<PermissionSnapshot[]>;
/**
* Get all permissions.
*/
findAll(): Promise<PermissionSnapshot[]>;
/**
* Save a new or updated permission.
*/
save(permission: PermissionSnapshot): Promise<void>;
/**
* Delete a permission by its ID.
*/
delete(id: PermissionId): Promise<boolean>;
/**
* Delete all permissions for an identity.
* Used when deleting an identity (cascade delete).
*/
deleteByIdentity(identityId: IdentityId): Promise<number>;
/**
* Count permissions matching the query.
*/
count(query?: PermissionQuery): Promise<number>;
}
/**
* Error thrown when a permission operation fails.
*/
export class PermissionRepositoryError extends Error {
constructor(
message: string,
public readonly code: PermissionErrorCode
) {
super(message);
this.name = 'PermissionRepositoryError';
}
}
export enum PermissionErrorCode {
NOT_FOUND = 'NOT_FOUND',
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
DECRYPTION_FAILED = 'DECRYPTION_FAILED',
STORAGE_FAILED = 'STORAGE_FAILED',
}

View File

@@ -0,0 +1,94 @@
import { IdentityId, RelayId } from '../value-objects';
/**
* Snapshot of a relay for persistence.
*/
export interface RelaySnapshot {
id: string;
identityId: string;
url: string;
read: boolean;
write: boolean;
}
/**
* Query criteria for finding relays.
*/
export interface RelayQuery {
identityId?: IdentityId;
url?: string;
read?: boolean;
write?: boolean;
}
/**
* Repository interface for Relay aggregate.
*/
export interface RelayRepository {
/**
* Find a relay by its ID.
*/
findById(id: RelayId): Promise<RelaySnapshot | undefined>;
/**
* Find relays matching the query criteria.
*/
find(query: RelayQuery): Promise<RelaySnapshot[]>;
/**
* Find a relay by URL for a specific identity.
* Used for duplicate detection.
*/
findByUrl(identityId: IdentityId, url: string): Promise<RelaySnapshot | undefined>;
/**
* Get all relays for an identity.
*/
findByIdentity(identityId: IdentityId): Promise<RelaySnapshot[]>;
/**
* Get all relays.
*/
findAll(): Promise<RelaySnapshot[]>;
/**
* Save a new or updated relay.
*/
save(relay: RelaySnapshot): Promise<void>;
/**
* Delete a relay by its ID.
*/
delete(id: RelayId): Promise<boolean>;
/**
* Delete all relays for an identity.
* Used when deleting an identity (cascade delete).
*/
deleteByIdentity(identityId: IdentityId): Promise<number>;
/**
* Count relays matching the query.
*/
count(query?: RelayQuery): Promise<number>;
}
/**
* Error thrown when a relay operation fails.
*/
export class RelayRepositoryError extends Error {
constructor(
message: string,
public readonly code: RelayErrorCode
) {
super(message);
this.name = 'RelayRepositoryError';
}
}
export enum RelayErrorCode {
DUPLICATE_URL = 'DUPLICATE_URL',
NOT_FOUND = 'NOT_FOUND',
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
STORAGE_FAILED = 'STORAGE_FAILED',
}

View File

@@ -0,0 +1,84 @@
import { IdentityId, PermissionId, RelayId, NwcConnectionId, CashuMintId } from './index';
describe('EntityId Value Objects', () => {
describe('IdentityId', () => {
it('should generate unique IDs', () => {
const id1 = IdentityId.generate();
const id2 = IdentityId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
it('should create from existing string value', () => {
const value = 'test-identity-id-123';
const id = IdentityId.from(value);
expect(id.toString()).toEqual(value);
});
it('should be equal when values match', () => {
const value = 'same-id';
const id1 = IdentityId.from(value);
const id2 = IdentityId.from(value);
expect(id1.equals(id2)).toBe(true);
});
it('should not be equal when values differ', () => {
const id1 = IdentityId.from('id-1');
const id2 = IdentityId.from('id-2');
expect(id1.equals(id2)).toBe(false);
});
});
describe('PermissionId', () => {
it('should generate unique IDs', () => {
const id1 = PermissionId.generate();
const id2 = PermissionId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
it('should create from existing string value', () => {
const value = 'test-permission-id-456';
const id = PermissionId.from(value);
expect(id.toString()).toEqual(value);
});
});
describe('RelayId', () => {
it('should generate unique IDs', () => {
const id1 = RelayId.generate();
const id2 = RelayId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
it('should create from existing string value', () => {
const value = 'test-relay-id-789';
const id = RelayId.from(value);
expect(id.toString()).toEqual(value);
});
});
describe('NwcConnectionId', () => {
it('should generate unique IDs', () => {
const id1 = NwcConnectionId.generate();
const id2 = NwcConnectionId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
});
describe('CashuMintId', () => {
it('should generate unique IDs', () => {
const id1 = CashuMintId.generate();
const id2 = CashuMintId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
});
});

View File

@@ -0,0 +1,30 @@
/**
* Base class for strongly-typed entity IDs.
* Prevents mixing up different ID types (e.g., IdentityId vs PermissionId).
*/
export abstract class EntityId<T extends string = string> {
protected constructor(protected readonly _value: string) {
if (!_value || _value.trim() === '') {
throw new Error(`${this.constructor.name} cannot be empty`);
}
}
get value(): string {
return this._value;
}
equals(other: EntityId<T>): boolean {
if (!(other instanceof this.constructor)) {
return false;
}
return this._value === other._value;
}
toString(): string {
return this._value;
}
toJSON(): string {
return this._value;
}
}

View File

@@ -0,0 +1,36 @@
import { v4 as uuidv4 } from 'uuid';
import { EntityId } from './entity-id';
/**
* Strongly-typed identifier for Identity entities.
* Prevents accidental mixing with other ID types.
*/
export class IdentityId extends EntityId<'IdentityId'> {
private readonly _brand = 'IdentityId' as const;
private constructor(value: string) {
super(value);
}
/**
* Generate a new unique IdentityId.
*/
static generate(): IdentityId {
return new IdentityId(uuidv4());
}
/**
* Create an IdentityId from an existing string value.
* Use this when reconstituting from storage.
*/
static from(value: string): IdentityId {
return new IdentityId(value);
}
/**
* Type guard to check if two IDs are equal.
*/
override equals(other: IdentityId): boolean {
return other instanceof IdentityId && this._value === other._value;
}
}

View File

@@ -0,0 +1,16 @@
// Base
export { EntityId } from './entity-id';
// Entity IDs
export { IdentityId } from './identity-id';
export { PermissionId } from './permission-id';
export { RelayId } from './relay-id';
export { NwcConnectionId, CashuMintId } from './wallet-id';
// Domain Value Objects
export { Nickname, InvalidNicknameError } from './nickname';
export {
NostrKeyPair,
NostrPublicKey,
InvalidNostrKeyError,
} from './nostr-keypair';

View File

@@ -0,0 +1,95 @@
import { Nickname, InvalidNicknameError } from './nickname';
describe('Nickname Value Object', () => {
describe('create', () => {
it('should create a valid nickname', () => {
const nickname = Nickname.create('Alice');
expect(nickname.toString()).toEqual('Alice');
});
it('should trim whitespace from nickname', () => {
const nickname = Nickname.create(' Bob ');
expect(nickname.toString()).toEqual('Bob');
});
it('should throw InvalidNicknameError for empty string', () => {
expect(() => Nickname.create('')).toThrowError(InvalidNicknameError);
});
it('should throw InvalidNicknameError for whitespace-only string', () => {
expect(() => Nickname.create(' ')).toThrowError(InvalidNicknameError);
});
it('should throw InvalidNicknameError for nickname exceeding 50 characters', () => {
const longNickname = 'a'.repeat(51);
expect(() => Nickname.create(longNickname)).toThrowError(InvalidNicknameError);
});
it('should allow nickname with exactly 50 characters', () => {
const maxNickname = 'a'.repeat(50);
expect(() => Nickname.create(maxNickname)).not.toThrow();
expect(Nickname.create(maxNickname).toString()).toEqual(maxNickname);
});
it('should allow single character nickname', () => {
const nickname = Nickname.create('X');
expect(nickname.toString()).toEqual('X');
});
});
describe('fromStorage', () => {
it('should create nickname from storage without validation', () => {
// This allows loading potentially invalid data from storage
// without throwing during deserialization
const nickname = Nickname.fromStorage('stored-nickname');
expect(nickname.toString()).toEqual('stored-nickname');
});
it('should handle long nicknames from legacy storage', () => {
const longLegacyNickname = 'a'.repeat(100);
const nickname = Nickname.fromStorage(longLegacyNickname);
expect(nickname.toString()).toEqual(longLegacyNickname);
});
});
describe('equals', () => {
it('should return true for equal nicknames', () => {
const nick1 = Nickname.create('Alice');
const nick2 = Nickname.create('Alice');
expect(nick1.equals(nick2)).toBe(true);
});
it('should return false for different nicknames', () => {
const nick1 = Nickname.create('Alice');
const nick2 = Nickname.create('Bob');
expect(nick1.equals(nick2)).toBe(false);
});
it('should be case-sensitive', () => {
const nick1 = Nickname.create('alice');
const nick2 = Nickname.create('Alice');
expect(nick1.equals(nick2)).toBe(false);
});
});
describe('InvalidNicknameError', () => {
it('should be an instance of InvalidNicknameError', () => {
try {
Nickname.create('');
} catch (e) {
expect(e).toBeInstanceOf(InvalidNicknameError);
expect((e as InvalidNicknameError).message).toContain('cannot be empty');
}
});
});
});

View File

@@ -0,0 +1,66 @@
/**
* Value object representing a user-defined nickname for an identity.
* Self-validating and immutable.
*/
export class Nickname {
private static readonly MAX_LENGTH = 50;
private static readonly MIN_LENGTH = 1;
private constructor(private readonly _value: string) {}
/**
* Create a new Nickname from a string value.
* Trims whitespace and validates length.
*
* @throws Error if nickname is empty or too long
*/
static create(value: string): Nickname {
const trimmed = value?.trim() ?? '';
if (trimmed.length < Nickname.MIN_LENGTH) {
throw new InvalidNicknameError('Nickname cannot be empty');
}
if (trimmed.length > Nickname.MAX_LENGTH) {
throw new InvalidNicknameError(
`Nickname cannot exceed ${Nickname.MAX_LENGTH} characters`
);
}
return new Nickname(trimmed);
}
/**
* Reconstitute a Nickname from storage without validation.
* Use only when loading from trusted storage.
*/
static fromStorage(value: string): Nickname {
return new Nickname(value);
}
get value(): string {
return this._value;
}
equals(other: Nickname): boolean {
return this._value === other._value;
}
toString(): string {
return this._value;
}
toJSON(): string {
return this._value;
}
}
/**
* Error thrown when nickname validation fails.
*/
export class InvalidNicknameError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidNicknameError';
}
}

View File

@@ -0,0 +1,91 @@
import { NostrKeyPair, InvalidNostrKeyError } from './nostr-keypair';
describe('NostrKeyPair Value Object', () => {
// Known test vectors
const TEST_PRIVATE_KEY_HEX = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
describe('generate', () => {
it('should generate a valid keypair', () => {
const keyPair = NostrKeyPair.generate();
expect(keyPair.publicKeyHex).toBeTruthy();
expect(keyPair.publicKeyHex.length).toBe(64);
});
it('should generate unique keypairs each time', () => {
const keyPair1 = NostrKeyPair.generate();
const keyPair2 = NostrKeyPair.generate();
expect(keyPair1.publicKeyHex).not.toEqual(keyPair2.publicKeyHex);
});
});
describe('fromPrivateKey', () => {
it('should create keypair from valid hex private key', () => {
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
expect(keyPair.publicKeyHex).toBeTruthy();
expect(keyPair.publicKeyHex.length).toBe(64);
});
it('should throw InvalidNostrKeyError for empty string', () => {
expect(() => NostrKeyPair.fromPrivateKey('')).toThrowError(InvalidNostrKeyError);
});
it('should throw InvalidNostrKeyError for invalid hex', () => {
expect(() => NostrKeyPair.fromPrivateKey('not-valid-hex')).toThrowError(InvalidNostrKeyError);
});
it('should throw InvalidNostrKeyError for hex that is too short', () => {
const shortHex = '0123456789abcdef';
expect(() => NostrKeyPair.fromPrivateKey(shortHex)).toThrowError(InvalidNostrKeyError);
});
});
describe('public key formats', () => {
it('should return hex public key', () => {
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
expect(keyPair.publicKeyHex).toMatch(/^[0-9a-f]{64}$/);
});
it('should return npub format', () => {
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
expect(keyPair.npub).toMatch(/^npub1[a-z0-9]+$/);
});
it('should return nsec format', () => {
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
expect(keyPair.nsec).toMatch(/^nsec1[a-z0-9]+$/);
});
});
describe('getPrivateKeyBytes', () => {
it('should return 32-byte Uint8Array', () => {
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
const bytes = keyPair.getPrivateKeyBytes();
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(32);
});
});
describe('toStorageHex', () => {
it('should return the hex private key for storage', () => {
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
expect(keyPair.toStorageHex()).toEqual(TEST_PRIVATE_KEY_HEX);
});
});
describe('deterministic derivation', () => {
it('should derive the same public key from the same private key', () => {
const keyPair1 = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
const keyPair2 = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
expect(keyPair1.publicKeyHex).toEqual(keyPair2.publicKeyHex);
});
});
});

View File

@@ -0,0 +1,223 @@
import { bech32 } from '@scure/base';
import * as utils from '@noble/curves/abstract/utils';
import { getPublicKey, generateSecretKey } from 'nostr-tools';
/**
* Value object encapsulating a Nostr keypair.
* Provides type-safe access to public key operations while protecting the private key.
*
* The private key is never exposed directly - all operations that need it
* are performed through methods on this class.
*/
export class NostrKeyPair {
private readonly _privateKeyHex: string;
private readonly _publicKeyHex: string;
private constructor(privateKeyHex: string, publicKeyHex: string) {
this._privateKeyHex = privateKeyHex;
this._publicKeyHex = publicKeyHex;
}
/**
* Generate a new random keypair.
*/
static generate(): NostrKeyPair {
const privateKeyBytes = generateSecretKey();
const privateKeyHex = utils.bytesToHex(privateKeyBytes);
const publicKeyHex = getPublicKey(privateKeyBytes);
return new NostrKeyPair(privateKeyHex, publicKeyHex);
}
/**
* Create a keypair from an existing private key.
* Accepts either hex or nsec format.
*
* @throws InvalidNostrKeyError if the key is invalid
*/
static fromPrivateKey(privateKey: string): NostrKeyPair {
try {
const hex = NostrKeyPair.normalizeToHex(privateKey);
NostrKeyPair.validateHexKey(hex);
const publicKeyHex = NostrKeyPair.derivePublicKey(hex);
return new NostrKeyPair(hex, publicKeyHex);
} catch (error) {
throw new InvalidNostrKeyError(
`Invalid private key: ${error instanceof Error ? error.message : 'unknown error'}`
);
}
}
/**
* Reconstitute a keypair from storage.
* Assumes the stored hex is valid (from trusted source).
*/
static fromStorage(privateKeyHex: string): NostrKeyPair {
const publicKeyHex = NostrKeyPair.derivePublicKey(privateKeyHex);
return new NostrKeyPair(privateKeyHex, publicKeyHex);
}
/**
* Get the public key in hex format.
*/
get publicKeyHex(): string {
return this._publicKeyHex;
}
/**
* Get the public key in npub (bech32) format.
*/
get npub(): string {
const data = utils.hexToBytes(this._publicKeyHex);
const words = bech32.toWords(data);
return bech32.encode('npub', words, 5000);
}
/**
* Get the private key in nsec (bech32) format.
* Use with caution - only for display/export purposes.
*/
get nsec(): string {
const data = utils.hexToBytes(this._privateKeyHex);
const words = bech32.toWords(data);
return bech32.encode('nsec', words, 5000);
}
/**
* Get the private key bytes for cryptographic operations.
* Internal use only - required for signing and encryption.
*/
getPrivateKeyBytes(): Uint8Array {
return utils.hexToBytes(this._privateKeyHex);
}
/**
* Get the private key hex for storage.
* This should only be used when persisting to encrypted storage.
*/
toStorageHex(): string {
return this._privateKeyHex;
}
/**
* Check if this keypair has the same public key as another.
*/
hasSamePublicKey(other: NostrKeyPair): boolean {
return this._publicKeyHex === other._publicKeyHex;
}
/**
* Check if this keypair matches a given public key.
*/
matchesPublicKey(publicKeyHex: string): boolean {
return this._publicKeyHex === publicKeyHex;
}
/**
* Value equality based on public key.
* Two keypairs are equal if they represent the same identity.
*/
equals(other: NostrKeyPair): boolean {
return this._publicKeyHex === other._publicKeyHex;
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
private static normalizeToHex(privateKey: string): string {
if (privateKey.startsWith('nsec')) {
return NostrKeyPair.nsecToHex(privateKey);
}
return privateKey;
}
private static nsecToHex(nsec: string): string {
const { prefix, words } = bech32.decode(nsec as `${string}1${string}`, 5000);
if (prefix !== 'nsec') {
throw new Error('Invalid nsec prefix');
}
const data = new Uint8Array(bech32.fromWords(words));
return utils.bytesToHex(data);
}
private static validateHexKey(hex: string): void {
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
throw new Error('Private key must be 64 hex characters');
}
}
private static derivePublicKey(privateKeyHex: string): string {
const privateKeyBytes = utils.hexToBytes(privateKeyHex);
return getPublicKey(privateKeyBytes);
}
}
/**
* Error thrown when a Nostr key is invalid.
*/
export class InvalidNostrKeyError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidNostrKeyError';
}
}
/**
* Utility functions for public key operations (no private key needed).
*/
export class NostrPublicKey {
private constructor(private readonly _hex: string) {}
/**
* Create from hex or npub format.
*/
static from(publicKey: string): NostrPublicKey {
if (publicKey.startsWith('npub')) {
const hex = NostrPublicKey.npubToHex(publicKey);
return new NostrPublicKey(hex);
}
NostrPublicKey.validateHex(publicKey);
return new NostrPublicKey(publicKey);
}
get hex(): string {
return this._hex;
}
get npub(): string {
const data = utils.hexToBytes(this._hex);
const words = bech32.toWords(data);
return bech32.encode('npub', words, 5000);
}
/**
* Get a shortened display version of the public key.
*/
shortened(prefixLength = 8, suffixLength = 4): string {
const npub = this.npub;
return `${npub.slice(0, prefixLength)}...${npub.slice(-suffixLength)}`;
}
equals(other: NostrPublicKey): boolean {
return this._hex === other._hex;
}
toString(): string {
return this._hex;
}
private static npubToHex(npub: string): string {
const { prefix, words } = bech32.decode(npub as `${string}1${string}`, 5000);
if (prefix !== 'npub') {
throw new InvalidNostrKeyError('Invalid npub prefix');
}
const data = new Uint8Array(bech32.fromWords(words));
return utils.bytesToHex(data);
}
private static validateHex(hex: string): void {
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
throw new InvalidNostrKeyError('Public key must be 64 hex characters');
}
}
}

View File

@@ -0,0 +1,36 @@
import { v4 as uuidv4 } from 'uuid';
import { EntityId } from './entity-id';
/**
* Strongly-typed identifier for Permission entities.
* Prevents accidental mixing with other ID types.
*/
export class PermissionId extends EntityId<'PermissionId'> {
private readonly _brand = 'PermissionId' as const;
private constructor(value: string) {
super(value);
}
/**
* Generate a new unique PermissionId.
*/
static generate(): PermissionId {
return new PermissionId(uuidv4());
}
/**
* Create a PermissionId from an existing string value.
* Use this when reconstituting from storage.
*/
static from(value: string): PermissionId {
return new PermissionId(value);
}
/**
* Type guard to check if two IDs are equal.
*/
override equals(other: PermissionId): boolean {
return other instanceof PermissionId && this._value === other._value;
}
}

View File

@@ -0,0 +1,36 @@
import { v4 as uuidv4 } from 'uuid';
import { EntityId } from './entity-id';
/**
* Strongly-typed identifier for Relay entities.
* Prevents accidental mixing with other ID types.
*/
export class RelayId extends EntityId<'RelayId'> {
private readonly _brand = 'RelayId' as const;
private constructor(value: string) {
super(value);
}
/**
* Generate a new unique RelayId.
*/
static generate(): RelayId {
return new RelayId(uuidv4());
}
/**
* Create a RelayId from an existing string value.
* Use this when reconstituting from storage.
*/
static from(value: string): RelayId {
return new RelayId(value);
}
/**
* Type guard to check if two IDs are equal.
*/
override equals(other: RelayId): boolean {
return other instanceof RelayId && this._value === other._value;
}
}

View File

@@ -0,0 +1,48 @@
import { v4 as uuidv4 } from 'uuid';
import { EntityId } from './entity-id';
/**
* Strongly-typed identifier for NWC wallet connection entities.
*/
export class NwcConnectionId extends EntityId<'NwcConnectionId'> {
private readonly _brand = 'NwcConnectionId' as const;
private constructor(value: string) {
super(value);
}
static generate(): NwcConnectionId {
return new NwcConnectionId(uuidv4());
}
static from(value: string): NwcConnectionId {
return new NwcConnectionId(value);
}
override equals(other: NwcConnectionId): boolean {
return other instanceof NwcConnectionId && this._value === other._value;
}
}
/**
* Strongly-typed identifier for Cashu mint entities.
*/
export class CashuMintId extends EntityId<'CashuMintId'> {
private readonly _brand = 'CashuMintId' as const;
private constructor(value: string) {
super(value);
}
static generate(): CashuMintId {
return new CashuMintId(uuidv4());
}
static from(value: string): CashuMintId {
return new CashuMintId(value);
}
override equals(other: CashuMintId): boolean {
return other instanceof CashuMintId && this._value === other._value;
}
}

View File

@@ -0,0 +1,67 @@
/**
* Context containing the cryptographic parameters needed for encryption/decryption.
* This abstracts away the vault version differences (v1 PBKDF2 vs v2 Argon2id).
*/
export type EncryptionContext =
| EncryptionContextV1
| EncryptionContextV2;
/**
* v1: PBKDF2-derived key from password
*/
export interface EncryptionContextV1 {
version: 1;
iv: string;
password: string;
}
/**
* v2: Pre-derived Argon2id key
*/
export interface EncryptionContextV2 {
version: 2;
iv: string;
keyBase64: string;
}
/**
* Type guard for v1 context
*/
export function isV1Context(ctx: EncryptionContext): ctx is EncryptionContextV1 {
return ctx.version === 1;
}
/**
* Type guard for v2 context
*/
export function isV2Context(ctx: EncryptionContext): ctx is EncryptionContextV2 {
return ctx.version === 2;
}
/**
* Create an encryption context from session data.
* Returns undefined if no valid context can be created.
*/
export function createEncryptionContext(params: {
iv: string;
vaultPassword?: string;
vaultKey?: string;
}): EncryptionContext | undefined {
if (params.vaultKey) {
return {
version: 2,
iv: params.iv,
keyBase64: params.vaultKey,
};
}
if (params.vaultPassword) {
return {
version: 1,
iv: params.iv,
password: params.vaultPassword,
};
}
return undefined;
}

View File

@@ -0,0 +1,156 @@
import { Buffer } from 'buffer';
import { CryptoHelper } from '../../helpers/crypto-helper';
import {
EncryptionContext,
isV2Context,
} from './encryption-context';
/**
* Service responsible for encrypting and decrypting data.
* Abstracts away vault version differences (v1 PBKDF2 vs v2 Argon2id).
*
* This is an infrastructure service - it knows nothing about domain concepts,
* only about cryptographic operations.
*/
export class EncryptionService {
constructor(private readonly context: EncryptionContext) {}
/**
* Encrypt a string value.
*/
async encryptString(value: string): Promise<string> {
if (isV2Context(this.context)) {
return this.encryptWithKeyV2(value);
}
return CryptoHelper.encrypt(value, this.context.iv, this.context.password);
}
/**
* Encrypt a number value (converts to string first).
*/
async encryptNumber(value: number): Promise<string> {
return this.encryptString(value.toString());
}
/**
* Encrypt a boolean value (converts to string first).
*/
async encryptBoolean(value: boolean): Promise<string> {
return this.encryptString(value.toString());
}
/**
* Decrypt a value to string.
*/
async decryptString(encrypted: string): Promise<string> {
if (isV2Context(this.context)) {
return this.decryptWithKeyV2(encrypted);
}
return CryptoHelper.decrypt(encrypted, this.context.iv, this.context.password);
}
/**
* Decrypt a value to number.
*/
async decryptNumber(encrypted: string): Promise<number> {
const decrypted = await this.decryptString(encrypted);
return parseInt(decrypted, 10);
}
/**
* Decrypt a value to boolean.
*/
async decryptBoolean(encrypted: string): Promise<boolean> {
const decrypted = await this.decryptString(encrypted);
return decrypted === 'true';
}
/**
* Get the encryption context (for serialization or passing to other services).
*/
getContext(): EncryptionContext {
return this.context;
}
// ─────────────────────────────────────────────────────────────────────────
// V2 encryption/decryption using pre-derived Argon2id key
// ─────────────────────────────────────────────────────────────────────────
private async encryptWithKeyV2(text: string): Promise<string> {
if (!isV2Context(this.context)) {
throw new Error('V2 encryption requires keyBase64');
}
const keyBytes = Buffer.from(this.context.keyBase64, 'base64');
const iv = Buffer.from(this.context.iv, 'base64');
const key = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['encrypt']
);
const cipherText = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(text)
);
return Buffer.from(cipherText).toString('base64');
}
private async decryptWithKeyV2(encryptedBase64: string): Promise<string> {
if (!isV2Context(this.context)) {
throw new Error('V2 decryption requires keyBase64');
}
const keyBytes = Buffer.from(this.context.keyBase64, 'base64');
const iv = Buffer.from(this.context.iv, 'base64');
const cipherText = Buffer.from(encryptedBase64, 'base64');
const key = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
cipherText
);
return new TextDecoder().decode(decrypted);
}
}
/**
* Factory function to create an EncryptionService from session data.
*/
export function createEncryptionService(params: {
iv: string;
vaultPassword?: string;
vaultKey?: string;
}): EncryptionService {
if (params.vaultKey) {
return new EncryptionService({
version: 2,
iv: params.iv,
keyBase64: params.vaultKey,
});
}
if (params.vaultPassword) {
return new EncryptionService({
version: 1,
iv: params.iv,
password: params.vaultPassword,
});
}
throw new Error('Either vaultPassword or vaultKey must be provided');
}

View File

@@ -0,0 +1,15 @@
export {
isV1Context,
isV2Context,
createEncryptionContext,
} from './encryption-context';
export type {
EncryptionContext,
EncryptionContextV1,
EncryptionContextV2,
} from './encryption-context';
export {
EncryptionService,
createEncryptionService,
} from './encryption.service';

View File

@@ -0,0 +1,2 @@
export * from './encryption';
export * from './repositories';

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);
}

View File

@@ -1,12 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData } from './types'; import { VaultSession } from './types';
export abstract class BrowserSessionHandler { export abstract class BrowserSessionHandler {
get browserSessionData(): BrowserSessionData | undefined { get vaultSession(): VaultSession | undefined {
return this.#browserSessionData; return this.#vaultSession;
} }
#browserSessionData?: BrowserSessionData; /** @deprecated Use vaultSession instead */
get browserSessionData(): VaultSession | undefined {
return this.#vaultSession;
}
#vaultSession?: VaultSession;
/** /**
* Load the data from the browser session storage. It should be an empty object, * Load the data from the browser session storage. It should be an empty object,
@@ -16,12 +21,12 @@ export abstract class BrowserSessionHandler {
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data. * ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
*/ */
abstract loadFullData(): Promise<Partial<Record<string, any>>>; abstract loadFullData(): Promise<Partial<Record<string, any>>>;
setFullData(data: BrowserSessionData) { setFullData(data: VaultSession) {
this.#browserSessionData = JSON.parse(JSON.stringify(data)); this.#vaultSession = JSON.parse(JSON.stringify(data));
} }
clearInMemoryData() { clearInMemoryData() {
this.#browserSessionData = undefined; this.#vaultSession = undefined;
} }
/** /**
@@ -29,7 +34,7 @@ export abstract class BrowserSessionHandler {
* *
* ATTENTION: Make sure to call "setFullData(..)" afterwards of before to update the in-memory data. * ATTENTION: Make sure to call "setFullData(..)" afterwards of before to update the in-memory data.
*/ */
abstract saveFullData(data: BrowserSessionData): Promise<void>; abstract saveFullData(data: VaultSession): Promise<void>;
abstract clearData(): Promise<void>; abstract clearData(): Promise<void>;
} }

View File

@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
BrowserSyncData, EncryptedVault,
CashuMint_ENCRYPTED, StoredCashuMint,
Identity_ENCRYPTED, StoredIdentity,
NwcConnection_ENCRYPTED, StoredNwcConnection,
Permission_ENCRYPTED, StoredPermission,
Relay_ENCRYPTED, StoredRelay,
} from './types'; } from './types';
/** /**
@@ -14,15 +14,20 @@ import {
* some unencrypted properties (like, version and the vault hash). * some unencrypted properties (like, version and the vault hash).
*/ */
export abstract class BrowserSyncHandler { export abstract class BrowserSyncHandler {
get browserSyncData(): BrowserSyncData | undefined { get encryptedVault(): EncryptedVault | undefined {
return this.#browserSyncData; return this.#encryptedVault;
}
/** @deprecated Use encryptedVault instead */
get browserSyncData(): EncryptedVault | undefined {
return this.#encryptedVault;
} }
get ignoreProperties(): string[] { get ignoreProperties(): string[] {
return this.#ignoreProperties; return this.#ignoreProperties;
} }
#browserSyncData?: BrowserSyncData; #encryptedVault?: EncryptedVault;
#ignoreProperties: string[] = []; #ignoreProperties: string[] = [];
setIgnoreProperties(properties: string[]) { setIgnoreProperties(properties: string[]) {
@@ -41,10 +46,10 @@ export abstract class BrowserSyncHandler {
* *
* ATTENTION: In your implementation, make sure to call "setFullData(..)" at the end to update the in-memory data. * ATTENTION: In your implementation, make sure to call "setFullData(..)" at the end to update the in-memory data.
*/ */
abstract saveAndSetFullData(data: BrowserSyncData): Promise<void>; abstract saveAndSetFullData(data: EncryptedVault): Promise<void>;
setFullData(data: BrowserSyncData) { setFullData(data: EncryptedVault) {
this.#browserSyncData = JSON.parse(JSON.stringify(data)); this.#encryptedVault = JSON.parse(JSON.stringify(data));
} }
/** /**
@@ -53,13 +58,13 @@ export abstract class BrowserSyncHandler {
* ATTENTION: In your implementation, make sure to call "setPartialData_Permissions(..)" at the end to update the in-memory data. * ATTENTION: In your implementation, make sure to call "setPartialData_Permissions(..)" at the end to update the in-memory data.
*/ */
abstract saveAndSetPartialData_Permissions(data: { abstract saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[]; permissions: StoredPermission[];
}): Promise<void>; }): Promise<void>;
setPartialData_Permissions(data: { permissions: Permission_ENCRYPTED[] }) { setPartialData_Permissions(data: { permissions: StoredPermission[] }) {
if (!this.#browserSyncData) { if (!this.#encryptedVault) {
return; return;
} }
this.#browserSyncData.permissions = Array.from(data.permissions); this.#encryptedVault.permissions = Array.from(data.permissions);
} }
/** /**
@@ -68,14 +73,14 @@ export abstract class BrowserSyncHandler {
* ATTENTION: In your implementation, make sure to call "setPartialData_Identities(..)" at the end to update the in-memory data. * ATTENTION: In your implementation, make sure to call "setPartialData_Identities(..)" at the end to update the in-memory data.
*/ */
abstract saveAndSetPartialData_Identities(data: { abstract saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[]; identities: StoredIdentity[];
}): Promise<void>; }): Promise<void>;
setPartialData_Identities(data: { identities: Identity_ENCRYPTED[] }) { setPartialData_Identities(data: { identities: StoredIdentity[] }) {
if (!this.#browserSyncData) { if (!this.#encryptedVault) {
return; return;
} }
this.#browserSyncData.identities = Array.from(data.identities); this.#encryptedVault.identities = Array.from(data.identities);
} }
/** /**
@@ -90,20 +95,20 @@ export abstract class BrowserSyncHandler {
setPartialData_SelectedIdentityId(data: { setPartialData_SelectedIdentityId(data: {
selectedIdentityId: string | null; selectedIdentityId: string | null;
}) { }) {
if (!this.#browserSyncData) { if (!this.#encryptedVault) {
return; return;
} }
this.#browserSyncData.selectedIdentityId = data.selectedIdentityId; this.#encryptedVault.selectedIdentityId = data.selectedIdentityId;
} }
abstract saveAndSetPartialData_Relays(data: { abstract saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[]; relays: StoredRelay[];
}): Promise<void>; }): Promise<void>;
setPartialData_Relays(data: { relays: Relay_ENCRYPTED[] }) { setPartialData_Relays(data: { relays: StoredRelay[] }) {
if (!this.#browserSyncData) { if (!this.#encryptedVault) {
return; return;
} }
this.#browserSyncData.relays = Array.from(data.relays); this.#encryptedVault.relays = Array.from(data.relays);
} }
/** /**
@@ -112,15 +117,15 @@ export abstract class BrowserSyncHandler {
* ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data. * ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data.
*/ */
abstract saveAndSetPartialData_NwcConnections(data: { abstract saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[]; nwcConnections: StoredNwcConnection[];
}): Promise<void>; }): Promise<void>;
setPartialData_NwcConnections(data: { setPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[]; nwcConnections: StoredNwcConnection[];
}) { }) {
if (!this.#browserSyncData) { if (!this.#encryptedVault) {
return; return;
} }
this.#browserSyncData.nwcConnections = Array.from(data.nwcConnections); this.#encryptedVault.nwcConnections = Array.from(data.nwcConnections);
} }
/** /**
@@ -129,13 +134,13 @@ export abstract class BrowserSyncHandler {
* ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data. * ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data.
*/ */
abstract saveAndSetPartialData_CashuMints(data: { abstract saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[]; cashuMints: StoredCashuMint[];
}): Promise<void>; }): Promise<void>;
setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) { setPartialData_CashuMints(data: { cashuMints: StoredCashuMint[] }) {
if (!this.#browserSyncData) { if (!this.#encryptedVault) {
return; return;
} }
this.#browserSyncData.cashuMints = Array.from(data.cashuMints); this.#encryptedVault.cashuMints = Array.from(data.cashuMints);
} }
/** /**

View File

@@ -1,13 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Bookmark, BrowserSyncData, BrowserSyncFlow, SignerMetaData, SignerMetaData_VaultSnapshot } from './types'; import { Bookmark, EncryptedVault, SyncFlow, ExtensionSettings, VaultSnapshot } from './types';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
/**
* Handler for extension settings stored outside the encrypted vault.
* This includes sync preferences, backups, reckless mode, whitelisted hosts, etc.
*/
export abstract class SignerMetaHandler { export abstract class SignerMetaHandler {
get signerMetaData(): SignerMetaData | undefined { get extensionSettings(): ExtensionSettings | undefined {
return this.#signerMetaData; return this.#extensionSettings;
} }
#signerMetaData?: SignerMetaData; /** @deprecated Use extensionSettings instead */
get signerMetaData(): ExtensionSettings | undefined {
return this.#extensionSettings;
}
#extensionSettings?: ExtensionSettings;
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode']; readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode'];
readonly DEFAULT_MAX_BACKUPS = 5; readonly DEFAULT_MAX_BACKUPS = 5;
@@ -20,25 +29,30 @@ export abstract class SignerMetaHandler {
*/ */
abstract loadFullData(): Promise<Partial<Record<string, any>>>; abstract loadFullData(): Promise<Partial<Record<string, any>>>;
setFullData(data: SignerMetaData) { setFullData(data: ExtensionSettings) {
this.#signerMetaData = data; this.#extensionSettings = data;
} }
abstract saveFullData(data: SignerMetaData): Promise<void>; abstract saveFullData(data: ExtensionSettings): Promise<void>;
/** /**
* Sets the browser sync flow for the user and immediately saves it. * Sets the sync flow preference for the user and immediately saves it.
*/ */
async setBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> { async setSyncFlow(flow: SyncFlow): Promise<void> {
if (!this.#signerMetaData) { if (!this.#extensionSettings) {
this.#signerMetaData = { this.#extensionSettings = {
syncFlow: flow, syncFlow: flow,
}; };
} else { } else {
this.#signerMetaData.syncFlow = flow; this.#extensionSettings.syncFlow = flow;
} }
await this.saveFullData(this.#signerMetaData); await this.saveFullData(this.#extensionSettings);
}
/** @deprecated Use setSyncFlow instead */
async setBrowserSyncFlow(flow: SyncFlow): Promise<void> {
return this.setSyncFlow(flow);
} }
abstract clearData(keep: string[]): Promise<void>; abstract clearData(keep: string[]): Promise<void>;
@@ -47,93 +61,93 @@ export abstract class SignerMetaHandler {
* Sets the reckless mode and immediately saves it. * Sets the reckless mode and immediately saves it.
*/ */
async setRecklessMode(enabled: boolean): Promise<void> { async setRecklessMode(enabled: boolean): Promise<void> {
if (!this.#signerMetaData) { if (!this.#extensionSettings) {
this.#signerMetaData = { this.#extensionSettings = {
recklessMode: enabled, recklessMode: enabled,
}; };
} else { } else {
this.#signerMetaData.recklessMode = enabled; this.#extensionSettings.recklessMode = enabled;
} }
await this.saveFullData(this.#signerMetaData); await this.saveFullData(this.#extensionSettings);
} }
/** /**
* Sets dev mode and immediately saves it. * Sets dev mode and immediately saves it.
*/ */
async setDevMode(enabled: boolean): Promise<void> { async setDevMode(enabled: boolean): Promise<void> {
if (!this.#signerMetaData) { if (!this.#extensionSettings) {
this.#signerMetaData = { this.#extensionSettings = {
devMode: enabled, devMode: enabled,
}; };
} else { } else {
this.#signerMetaData.devMode = enabled; this.#extensionSettings.devMode = enabled;
} }
await this.saveFullData(this.#signerMetaData); await this.saveFullData(this.#extensionSettings);
} }
/** /**
* Adds a host to the whitelist and immediately saves it. * Adds a host to the whitelist and immediately saves it.
*/ */
async addWhitelistedHost(host: string): Promise<void> { async addWhitelistedHost(host: string): Promise<void> {
if (!this.#signerMetaData) { if (!this.#extensionSettings) {
this.#signerMetaData = { this.#extensionSettings = {
whitelistedHosts: [host], whitelistedHosts: [host],
}; };
} else { } else {
const hosts = this.#signerMetaData.whitelistedHosts ?? []; const hosts = this.#extensionSettings.whitelistedHosts ?? [];
if (!hosts.includes(host)) { if (!hosts.includes(host)) {
hosts.push(host); hosts.push(host);
this.#signerMetaData.whitelistedHosts = hosts; this.#extensionSettings.whitelistedHosts = hosts;
} }
} }
await this.saveFullData(this.#signerMetaData); await this.saveFullData(this.#extensionSettings);
} }
/** /**
* Removes a host from the whitelist and immediately saves it. * Removes a host from the whitelist and immediately saves it.
*/ */
async removeWhitelistedHost(host: string): Promise<void> { async removeWhitelistedHost(host: string): Promise<void> {
if (!this.#signerMetaData?.whitelistedHosts) { if (!this.#extensionSettings?.whitelistedHosts) {
return; return;
} }
this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter( this.#extensionSettings.whitelistedHosts = this.#extensionSettings.whitelistedHosts.filter(
(h) => h !== host (h) => h !== host
); );
await this.saveFullData(this.#signerMetaData); await this.saveFullData(this.#extensionSettings);
} }
/** /**
* Sets the bookmarks array and immediately saves it. * Sets the bookmarks array and immediately saves it.
*/ */
async setBookmarks(bookmarks: Bookmark[]): Promise<void> { async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
if (!this.#signerMetaData) { if (!this.#extensionSettings) {
this.#signerMetaData = { this.#extensionSettings = {
bookmarks, bookmarks,
}; };
} else { } else {
this.#signerMetaData.bookmarks = bookmarks; this.#extensionSettings.bookmarks = bookmarks;
} }
await this.saveFullData(this.#signerMetaData); await this.saveFullData(this.#extensionSettings);
} }
/** /**
* Gets the current bookmarks. * Gets the current bookmarks.
*/ */
getBookmarks(): Bookmark[] { getBookmarks(): Bookmark[] {
return this.#signerMetaData?.bookmarks ?? []; return this.#extensionSettings?.bookmarks ?? [];
} }
/** /**
* Gets the maximum number of backups to keep. * Gets the maximum number of backups to keep.
*/ */
getMaxBackups(): number { getMaxBackups(): number {
return this.#signerMetaData?.maxBackups ?? this.DEFAULT_MAX_BACKUPS; return this.#extensionSettings?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
} }
/** /**
@@ -141,22 +155,22 @@ export abstract class SignerMetaHandler {
*/ */
async setMaxBackups(count: number): Promise<void> { async setMaxBackups(count: number): Promise<void> {
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20 const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
if (!this.#signerMetaData) { if (!this.#extensionSettings) {
this.#signerMetaData = { this.#extensionSettings = {
maxBackups: clampedCount, maxBackups: clampedCount,
}; };
} else { } else {
this.#signerMetaData.maxBackups = clampedCount; this.#extensionSettings.maxBackups = clampedCount;
} }
await this.saveFullData(this.#signerMetaData); await this.saveFullData(this.#extensionSettings);
} }
/** /**
* Gets all vault backups, sorted newest first. * Gets all vault backups, sorted newest first.
*/ */
getBackups(): SignerMetaData_VaultSnapshot[] { getBackups(): VaultSnapshot[] {
const backups = this.#signerMetaData?.vaultSnapshots ?? []; const backups = this.#extensionSettings?.vaultSnapshots ?? [];
return [...backups].sort((a, b) => return [...backups].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
); );
@@ -165,8 +179,8 @@ export abstract class SignerMetaHandler {
/** /**
* Gets a specific backup by ID. * Gets a specific backup by ID.
*/ */
getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined { getBackupById(id: string): VaultSnapshot | undefined {
return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id); return this.#extensionSettings?.vaultSnapshots?.find(b => b.id === id);
} }
/** /**
@@ -174,28 +188,28 @@ export abstract class SignerMetaHandler {
* Automatically removes old backups if exceeding maxBackups. * Automatically removes old backups if exceeding maxBackups.
*/ */
async createBackup( async createBackup(
browserSyncData: BrowserSyncData, encryptedVault: EncryptedVault,
reason: 'manual' | 'auto' | 'pre-restore' = 'manual' reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
): Promise<SignerMetaData_VaultSnapshot> { ): Promise<VaultSnapshot> {
const now = new Date(); const now = new Date();
const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const identityCount = browserSyncData.identities?.length ?? 0; const identityCount = encryptedVault.identities?.length ?? 0;
const snapshot: SignerMetaData_VaultSnapshot = { const snapshot: VaultSnapshot = {
id: uuidv4(), id: uuidv4(),
fileName: `Vault Backup - ${dateTimeString}`, fileName: `Vault Backup - ${dateTimeString}`,
createdAt: now.toISOString(), createdAt: now.toISOString(),
data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone data: JSON.parse(JSON.stringify(encryptedVault)), // Deep clone
identityCount, identityCount,
reason, reason,
}; };
if (!this.#signerMetaData) { if (!this.#extensionSettings) {
this.#signerMetaData = { this.#extensionSettings = {
vaultSnapshots: [snapshot], vaultSnapshots: [snapshot],
}; };
} else { } else {
const existingBackups = this.#signerMetaData.vaultSnapshots ?? []; const existingBackups = this.#extensionSettings.vaultSnapshots ?? [];
existingBackups.push(snapshot); existingBackups.push(snapshot);
// Enforce max backups limit (only for auto backups, keep manual and pre-restore) // Enforce max backups limit (only for auto backups, keep manual and pre-restore)
@@ -209,10 +223,10 @@ export abstract class SignerMetaHandler {
); );
const trimmedAutoBackups = autoBackups.slice(0, maxBackups); const trimmedAutoBackups = autoBackups.slice(0, maxBackups);
this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups]; this.#extensionSettings.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
} }
await this.saveFullData(this.#signerMetaData); await this.saveFullData(this.#extensionSettings);
return snapshot; return snapshot;
} }
@@ -220,17 +234,17 @@ export abstract class SignerMetaHandler {
* Deletes a backup by ID. * Deletes a backup by ID.
*/ */
async deleteBackup(backupId: string): Promise<boolean> { async deleteBackup(backupId: string): Promise<boolean> {
if (!this.#signerMetaData?.vaultSnapshots) { if (!this.#extensionSettings?.vaultSnapshots) {
return false; return false;
} }
const initialLength = this.#signerMetaData.vaultSnapshots.length; const initialLength = this.#extensionSettings.vaultSnapshots.length;
this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter( this.#extensionSettings.vaultSnapshots = this.#extensionSettings.vaultSnapshots.filter(
b => b.id !== backupId b => b.id !== backupId
); );
if (this.#signerMetaData.vaultSnapshots.length < initialLength) { if (this.#extensionSettings.vaultSnapshots.length < initialLength) {
await this.saveFullData(this.#signerMetaData); await this.saveFullData(this.#extensionSettings);
return true; return true;
} }
return false; return false;
@@ -240,7 +254,7 @@ export abstract class SignerMetaHandler {
* Gets the data from a backup for restoration. * Gets the data from a backup for restoration.
* Note: The caller should create a pre-restore backup before calling this. * Note: The caller should create a pre-restore backup before calling this.
*/ */
getBackupData(backupId: string): BrowserSyncData | undefined { getBackupData(backupId: string): EncryptedVault | undefined {
const backup = this.getBackupById(backupId); const backup = this.getBackupById(backupId);
return backup?.data; return backup?.data;
} }

View File

@@ -3,11 +3,13 @@ import { Injectable } from '@angular/core';
import { BrowserSyncHandler } from './browser-sync-handler'; import { BrowserSyncHandler } from './browser-sync-handler';
import { BrowserSessionHandler } from './browser-session-handler'; import { BrowserSessionHandler } from './browser-session-handler';
import { import {
BrowserSessionData, VaultSession,
BrowserSyncData, EncryptedVault,
BrowserSyncFlow, SyncFlow,
SignerMetaData, ExtensionSettings,
Relay_DECRYPTED, RelayData,
CashuMintRecord,
CashuProof,
} from './types'; } from './types';
import { SignerMetaHandler } from './signer-meta-handler'; import { SignerMetaHandler } from './signer-meta-handler';
import { CryptoHelper } from '@common'; import { CryptoHelper } from '@common';
@@ -30,7 +32,6 @@ import {
deleteCashuMint, deleteCashuMint,
updateCashuMintProofs, updateCashuMintProofs,
} from './related/cashu'; } from './related/cashu';
import { CashuMint_DECRYPTED, CashuProof } from './types';
export interface StorageServiceConfig { export interface StorageServiceConfig {
browserSessionHandler: BrowserSessionHandler; browserSessionHandler: BrowserSessionHandler;
@@ -62,13 +63,13 @@ export class StorageService {
this.isInitialized = true; this.isInitialized = true;
} }
async enableBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> { async enableBrowserSyncFlow(flow: SyncFlow): Promise<void> {
this.assureIsInitialized(); this.assureIsInitialized();
this.#signerMetaHandler.setBrowserSyncFlow(flow); this.#signerMetaHandler.setSyncFlow(flow);
} }
async loadSignerMetaData(): Promise<SignerMetaData | undefined> { async loadExtensionSettings(): Promise<ExtensionSettings | undefined> {
this.assureIsInitialized(); this.assureIsInitialized();
const data = await this.#signerMetaHandler.loadFullData(); const data = await this.#signerMetaHandler.loadFullData();
@@ -77,11 +78,16 @@ export class StorageService {
return undefined; return undefined;
} }
this.#signerMetaHandler.setFullData(data as SignerMetaData); this.#signerMetaHandler.setFullData(data as ExtensionSettings);
return data as SignerMetaData; return data as ExtensionSettings;
} }
async loadBrowserSessionData(): Promise<BrowserSessionData | undefined> { /** @deprecated Use loadExtensionSettings instead */
async loadSignerMetaData(): Promise<ExtensionSettings | undefined> {
return this.loadExtensionSettings();
}
async loadVaultSession(): Promise<VaultSession | undefined> {
this.assureIsInitialized(); this.assureIsInitialized();
const data = await this.#browserSessionHandler.loadFullData(); const data = await this.#browserSessionHandler.loadFullData();
@@ -91,22 +97,27 @@ export class StorageService {
} }
// Set the existing data for in-memory usage. // Set the existing data for in-memory usage.
this.#browserSessionHandler.setFullData(data as BrowserSessionData); this.#browserSessionHandler.setFullData(data as VaultSession);
return data as BrowserSessionData; return data as VaultSession;
}
/** @deprecated Use loadVaultSession instead */
async loadBrowserSessionData(): Promise<VaultSession | undefined> {
return this.loadVaultSession();
} }
/** /**
* Load and migrate the browser sync data. If no data is available yet, * Load and migrate the encrypted vault data. If no data is available yet,
* the returned object is undefined. * the returned object is undefined.
*/ */
async loadAndMigrateBrowserSyncData(): Promise<BrowserSyncData | undefined> { async loadAndMigrateEncryptedVault(): Promise<EncryptedVault | undefined> {
this.assureIsInitialized(); this.assureIsInitialized();
const unmigratedBrowserSyncData = const unmigratedEncryptedVault =
await this.getBrowserSyncHandler().loadUnmigratedData(); await this.getBrowserSyncHandler().loadUnmigratedData();
const { browserSyncData, migrationWasPerformed } = const { encryptedVault, migrationWasPerformed } =
this.#migrateBrowserSyncData(unmigratedBrowserSyncData); this.#migrateEncryptedVault(unmigratedEncryptedVault);
if (!browserSyncData) { if (!encryptedVault) {
// Nothing to do at this point. // Nothing to do at this point.
return undefined; return undefined;
} }
@@ -114,13 +125,18 @@ export class StorageService {
// There is data. Check, if it was migrated. // There is data. Check, if it was migrated.
if (migrationWasPerformed) { if (migrationWasPerformed) {
// Persist the migrated data back to the browser sync storage. // Persist the migrated data back to the browser sync storage.
this.getBrowserSyncHandler().saveAndSetFullData(browserSyncData); this.getBrowserSyncHandler().saveAndSetFullData(encryptedVault);
} else { } else {
// Set the data for in-memory usage. // Set the data for in-memory usage.
this.getBrowserSyncHandler().setFullData(browserSyncData); this.getBrowserSyncHandler().setFullData(encryptedVault);
} }
return browserSyncData; return encryptedVault;
}
/** @deprecated Use loadAndMigrateEncryptedVault instead */
async loadAndMigrateBrowserSyncData(): Promise<EncryptedVault | undefined> {
return this.loadAndMigrateEncryptedVault();
} }
async deleteVault(doNotSetIsInitializedToFalse = false) { async deleteVault(doNotSetIsInitializedToFalse = false) {
@@ -183,7 +199,7 @@ export class StorageService {
await deleteRelay.call(this, relayId); await deleteRelay.call(this, relayId);
} }
async updateRelay(relayClone: Relay_DECRYPTED): Promise<void> { async updateRelay(relayClone: RelayData): Promise<void> {
await updateRelay.call(this, relayClone); await updateRelay.call(this, relayClone);
} }
@@ -209,7 +225,7 @@ export class StorageService {
name: string; name: string;
mintUrl: string; mintUrl: string;
unit?: string; unit?: string;
}): Promise<CashuMint_DECRYPTED> { }): Promise<CashuMintRecord> {
return await addCashuMint.call(this, data); return await addCashuMint.call(this, data);
} }
@@ -227,36 +243,36 @@ export class StorageService {
exportVault(): string { exportVault(): string {
this.assureIsInitialized(); this.assureIsInitialized();
const vaultJson = JSON.stringify( const vaultJson = JSON.stringify(
this.getBrowserSyncHandler().browserSyncData, this.getBrowserSyncHandler().encryptedVault,
undefined, undefined,
4 4
); );
return vaultJson; return vaultJson;
} }
async importVault(allegedBrowserSyncData: BrowserSyncData) { async importVault(allegedEncryptedVault: EncryptedVault) {
this.assureIsInitialized(); this.assureIsInitialized();
const isValidData = this.#allegedBrowserSyncDataIsValid( const isValidData = this.#allegedEncryptedVaultIsValid(
allegedBrowserSyncData allegedEncryptedVault
); );
if (!isValidData) { if (!isValidData) {
throw new Error('The imported data is not valid.'); throw new Error('The imported data is not valid.');
} }
await this.getBrowserSyncHandler().saveAndSetFullData( await this.getBrowserSyncHandler().saveAndSetFullData(
allegedBrowserSyncData allegedEncryptedVault
); );
} }
getBrowserSyncHandler(): BrowserSyncHandler { getBrowserSyncHandler(): BrowserSyncHandler {
this.assureIsInitialized(); this.assureIsInitialized();
switch (this.#signerMetaHandler.signerMetaData?.syncFlow) { switch (this.#signerMetaHandler.extensionSettings?.syncFlow) {
case BrowserSyncFlow.NO_SYNC: case SyncFlow.NO_SYNC:
return this.#browserSyncNoHandler; return this.#browserSyncNoHandler;
case BrowserSyncFlow.BROWSER_SYNC: case SyncFlow.BROWSER_SYNC:
default: default:
return this.#browserSyncYesHandler; return this.#browserSyncYesHandler;
} }
@@ -275,14 +291,14 @@ export class StorageService {
} }
/** /**
* Get the current browser sync flow setting. * Get the current sync flow setting.
* Returns NO_SYNC if not initialized or no setting found. * Returns NO_SYNC if not initialized or no setting found.
*/ */
getSyncFlow(): BrowserSyncFlow { getSyncFlow(): SyncFlow {
if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) { if (!this.isInitialized || !this.#signerMetaHandler?.extensionSettings) {
return BrowserSyncFlow.NO_SYNC; return SyncFlow.NO_SYNC;
} }
return this.#signerMetaHandler.signerMetaData.syncFlow ?? BrowserSyncFlow.NO_SYNC; return this.#signerMetaHandler.extensionSettings.syncFlow ?? SyncFlow.NO_SYNC;
} }
/** /**
@@ -297,25 +313,24 @@ export class StorageService {
} }
async encrypt(value: string): Promise<string> { async encrypt(value: string): Promise<string> {
const browserSessionData = const vaultSession = this.getBrowserSessionHandler().vaultSession;
this.getBrowserSessionHandler().browserSessionData; if (!vaultSession) {
if (!browserSessionData) { throw new Error('Vault session is undefined.');
throw new Error('Browser session data is undefined.');
} }
// v2: Use pre-derived key directly with AES-GCM // v2: Use pre-derived key directly with AES-GCM
if (browserSessionData.vaultKey) { if (vaultSession.vaultKey) {
return this.encryptV2(value, browserSessionData.iv, browserSessionData.vaultKey); return this.encryptV2(value, vaultSession.iv, vaultSession.vaultKey);
} }
// v1: Use PBKDF2 with password // v1: Use PBKDF2 with password
if (!browserSessionData.vaultPassword) { if (!vaultSession.vaultPassword) {
throw new Error('No vault password or key available.'); throw new Error('No vault password or key available.');
} }
return CryptoHelper.encrypt( return CryptoHelper.encrypt(
value, value,
browserSessionData.iv, vaultSession.iv,
browserSessionData.vaultPassword vaultSession.vaultPassword
); );
} }
@@ -347,31 +362,30 @@ export class StorageService {
value: string, value: string,
returnType: 'string' | 'number' | 'boolean' returnType: 'string' | 'number' | 'boolean'
): Promise<any> { ): Promise<any> {
const browserSessionData = const vaultSession = this.getBrowserSessionHandler().vaultSession;
this.getBrowserSessionHandler().browserSessionData; if (!vaultSession) {
if (!browserSessionData) { throw new Error('Vault session is undefined.');
throw new Error('Browser session data is undefined.');
} }
// v2: Use pre-derived key directly with AES-GCM // v2: Use pre-derived key directly with AES-GCM
if (browserSessionData.vaultKey) { if (vaultSession.vaultKey) {
const decryptedValue = await this.decryptV2( const decryptedValue = await this.decryptV2(
value, value,
browserSessionData.iv, vaultSession.iv,
browserSessionData.vaultKey vaultSession.vaultKey
); );
return this.parseDecryptedValue(decryptedValue, returnType); return this.parseDecryptedValue(decryptedValue, returnType);
} }
// v1: Use PBKDF2 with password // v1: Use PBKDF2 with password
if (!browserSessionData.vaultPassword) { if (!vaultSession.vaultPassword) {
throw new Error('No vault password or key available.'); throw new Error('No vault password or key available.');
} }
return this.decryptWithLockedVault( return this.decryptWithLockedVault(
value, value,
returnType, returnType,
browserSessionData.iv, vaultSession.iv,
browserSessionData.vaultPassword vaultSession.vaultPassword
); );
} }
@@ -445,28 +459,28 @@ export class StorageService {
} }
/** /**
* Migrate the browser sync data to the latest version. * Migrate the encrypted vault to the latest version.
*/ */
#migrateBrowserSyncData(browserSyncData: Partial<Record<string, any>>): { #migrateEncryptedVault(encryptedVault: Partial<Record<string, any>>): {
browserSyncData?: BrowserSyncData; encryptedVault?: EncryptedVault;
migrationWasPerformed: boolean; migrationWasPerformed: boolean;
} { } {
if (Object.keys(browserSyncData).length === 0) { if (Object.keys(encryptedVault).length === 0) {
// First run. There is no browser sync data yet. // First run. There is no encrypted vault yet.
return { return {
browserSyncData: undefined, encryptedVault: undefined,
migrationWasPerformed: false, migrationWasPerformed: false,
}; };
} }
// Will be implemented if migration is required. // Will be implemented if migration is required.
return { return {
browserSyncData: browserSyncData as BrowserSyncData, encryptedVault: encryptedVault as EncryptedVault,
migrationWasPerformed: false, migrationWasPerformed: false,
}; };
} }
#allegedBrowserSyncDataIsValid(data: BrowserSyncData): boolean { #allegedEncryptedVaultIsValid(data: EncryptedVault): boolean {
if (typeof data.iv === 'undefined') { if (typeof data.iv === 'undefined') {
return false; return false;
} }

View File

@@ -1,15 +1,14 @@
import { ExtensionMethod, Nip07MethodPolicy } from '@common'; import { ExtensionMethod, Nip07MethodPolicy } from '@common';
export interface Permission_DECRYPTED { // =============================================================================
id: string; // STORAGE DATA TRANSFER OBJECTS (DTOs)
identityId: string; // These types represent data as stored in browser storage
host: string; // =============================================================================
method: ExtensionMethod;
methodPolicy: Nip07MethodPolicy;
kind?: number;
}
export interface Permission_ENCRYPTED { /**
* Permission as stored in encrypted vault (encrypted string fields)
*/
export interface StoredPermission {
id: string; id: string;
identityId: string; identityId: string;
host: string; host: string;
@@ -18,24 +17,37 @@ export interface Permission_ENCRYPTED {
kind?: string; kind?: string;
} }
export interface Identity_DECRYPTED { /**
* Permission in session memory (typed fields)
*/
export interface PermissionData {
id: string;
identityId: string;
host: string;
method: ExtensionMethod;
methodPolicy: Nip07MethodPolicy;
kind?: number;
}
/**
* Identity as stored in encrypted vault
*/
export interface StoredIdentity {
id: string; id: string;
createdAt: string; createdAt: string;
nick: string; nick: string;
privkey: string; privkey: string;
} }
export type Identity_ENCRYPTED = Identity_DECRYPTED; /**
* Identity in session memory (same structure, just semantic clarity)
*/
export type IdentityData = StoredIdentity;
export interface Relay_DECRYPTED { /**
id: string; * Relay as stored in encrypted vault (encrypted boolean fields)
identityId: string; */
url: string; export interface StoredRelay {
read: boolean;
write: boolean;
}
export interface Relay_ENCRYPTED {
id: string; id: string;
identityId: string; identityId: string;
url: string; url: string;
@@ -44,10 +56,21 @@ export interface Relay_ENCRYPTED {
} }
/** /**
* NWC (Nostr Wallet Connect) connection - Decrypted * Relay in session memory (typed boolean fields)
*/
export interface RelayData {
id: string;
identityId: string;
url: string;
read: boolean;
write: boolean;
}
/**
* NWC (Nostr Wallet Connect) connection in session memory
* Stores NIP-47 wallet connection data * Stores NIP-47 wallet connection data
*/ */
export interface NwcConnection_DECRYPTED { export interface NwcConnectionRecord {
id: string; id: string;
name: string; // User-defined wallet name name: string; // User-defined wallet name
connectionUrl: string; // Full nostr+walletconnect:// URL connectionUrl: string; // Full nostr+walletconnect:// URL
@@ -61,9 +84,9 @@ export interface NwcConnection_DECRYPTED {
} }
/** /**
* NWC connection - Encrypted for storage * NWC connection as stored in encrypted vault
*/ */
export interface NwcConnection_ENCRYPTED { export interface StoredNwcConnection {
id: string; id: string;
name: string; name: string;
connectionUrl: string; connectionUrl: string;
@@ -89,10 +112,10 @@ export interface CashuProof {
} }
/** /**
* Cashu Mint Connection - Decrypted * Cashu Mint Connection in session memory
* Stores NIP-60 Cashu mint connection data with local proofs * Stores NIP-60 Cashu mint connection data with local proofs
*/ */
export interface CashuMint_DECRYPTED { export interface CashuMintRecord {
id: string; id: string;
name: string; // User-defined mint name name: string; // User-defined mint name
mintUrl: string; // Mint API URL mintUrl: string; // Mint API URL
@@ -104,9 +127,9 @@ export interface CashuMint_DECRYPTED {
} }
/** /**
* Cashu Mint Connection - Encrypted for storage * Cashu Mint Connection as stored in encrypted vault
*/ */
export interface CashuMint_ENCRYPTED { export interface StoredCashuMint {
id: string; id: string;
name: string; name: string;
mintUrl: string; mintUrl: string;
@@ -117,7 +140,15 @@ export interface CashuMint_ENCRYPTED {
cachedBalanceAt?: string; cachedBalanceAt?: string;
} }
export interface BrowserSyncData_PART_Unencrypted { // =============================================================================
// ENCRYPTED VAULT
// The vault is the encrypted container holding all sensitive data
// =============================================================================
/**
* Vault header - unencrypted metadata needed to decrypt the vault
*/
export interface EncryptedVaultHeader {
version: number; version: number;
iv: string; iv: string;
vaultHash: string; vaultHash: string;
@@ -126,26 +157,42 @@ export interface BrowserSyncData_PART_Unencrypted {
salt?: string; salt?: string;
} }
export interface BrowserSyncData_PART_Encrypted { /**
* Vault content - encrypted payload containing all sensitive data
*/
export interface EncryptedVaultContent {
selectedIdentityId: string | null; selectedIdentityId: string | null;
permissions: Permission_ENCRYPTED[]; permissions: StoredPermission[];
identities: Identity_ENCRYPTED[]; identities: StoredIdentity[];
relays: Relay_ENCRYPTED[]; relays: StoredRelay[];
nwcConnections?: NwcConnection_ENCRYPTED[]; nwcConnections?: StoredNwcConnection[];
cashuMints?: CashuMint_ENCRYPTED[]; cashuMints?: StoredCashuMint[];
} }
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted & /**
BrowserSyncData_PART_Encrypted; * Complete encrypted vault as stored in browser sync storage
*/
export type EncryptedVault = EncryptedVaultHeader & EncryptedVaultContent;
export enum BrowserSyncFlow { /**
* Sync flow preference for vault data
*/
export enum SyncFlow {
NO_SYNC = 0, NO_SYNC = 0,
BROWSER_SYNC = 1, BROWSER_SYNC = 1,
SIGNER_SYNC = 2, SIGNER_SYNC = 2,
CUSTOM_SYNC = 3, CUSTOM_SYNC = 3,
} }
export interface BrowserSessionData { // =============================================================================
// VAULT SESSION
// Runtime state when vault is unlocked
// =============================================================================
/**
* Vault session - decrypted vault data in session memory
*/
export interface VaultSession {
// The following properties purely come from the browser session storage // The following properties purely come from the browser session storage
// and will never be going into the browser sync storage. // and will never be going into the browser sync storage.
vaultPassword?: string; // v1 only: raw password for PBKDF2 vaultPassword?: string; // v1 only: raw password for PBKDF2
@@ -155,24 +202,32 @@ export interface BrowserSessionData {
iv: string; iv: string;
// Version 2+: Random salt for Argon2id (base64) // Version 2+: Random salt for Argon2id (base64)
salt?: string; salt?: string;
permissions: Permission_DECRYPTED[]; permissions: PermissionData[];
identities: Identity_DECRYPTED[]; identities: IdentityData[];
selectedIdentityId: string | null; selectedIdentityId: string | null;
relays: Relay_DECRYPTED[]; relays: RelayData[];
nwcConnections?: NwcConnection_DECRYPTED[]; nwcConnections?: NwcConnectionRecord[];
cashuMints?: CashuMint_DECRYPTED[]; cashuMints?: CashuMintRecord[];
} }
export interface SignerMetaData_VaultSnapshot { // =============================================================================
// EXTENSION SETTINGS
// Non-vault configuration stored separately
// =============================================================================
/**
* Vault snapshot for backup/restore
*/
export interface VaultSnapshot {
id: string; id: string;
fileName: string; fileName: string;
createdAt: string; // ISO timestamp createdAt: string; // ISO timestamp
data: BrowserSyncData; data: EncryptedVault;
identityCount: number; identityCount: number;
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
} }
export const SIGNER_META_DATA_KEY = { export const EXTENSION_SETTINGS_KEYS = {
vaultSnapshots: 'vaultSnapshots', vaultSnapshots: 'vaultSnapshots',
}; };
@@ -186,10 +241,13 @@ export interface Bookmark {
createdAt: number; createdAt: number;
} }
export interface SignerMetaData { /**
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync)) * Extension settings - non-vault configuration
*/
export interface ExtensionSettings {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync)
vaultSnapshots?: SignerMetaData_VaultSnapshot[]; vaultSnapshots?: VaultSnapshot[];
// Maximum number of automatic backups to keep (default: 5) // Maximum number of automatic backups to keep (default: 5)
maxBackups?: number; maxBackups?: number;
@@ -229,3 +287,47 @@ export interface ProfileMetadata {
* Cache for profile metadata, stored in session storage * Cache for profile metadata, stored in session storage
*/ */
export type ProfileMetadataCache = Record<string, ProfileMetadata>; export type ProfileMetadataCache = Record<string, ProfileMetadata>;
// =============================================================================
// BACKWARDS COMPATIBILITY ALIASES
// These will be removed in a future version
// =============================================================================
/** @deprecated Use StoredPermission instead */
export type Permission_ENCRYPTED = StoredPermission;
/** @deprecated Use PermissionData instead */
export type Permission_DECRYPTED = PermissionData;
/** @deprecated Use StoredIdentity instead */
export type Identity_ENCRYPTED = StoredIdentity;
/** @deprecated Use IdentityData instead */
export type Identity_DECRYPTED = IdentityData;
/** @deprecated Use StoredRelay instead */
export type Relay_ENCRYPTED = StoredRelay;
/** @deprecated Use RelayData instead */
export type Relay_DECRYPTED = RelayData;
/** @deprecated Use StoredNwcConnection instead */
export type NwcConnection_ENCRYPTED = StoredNwcConnection;
/** @deprecated Use NwcConnectionRecord instead */
export type NwcConnection_DECRYPTED = NwcConnectionRecord;
/** @deprecated Use StoredCashuMint instead */
export type CashuMint_ENCRYPTED = StoredCashuMint;
/** @deprecated Use CashuMintRecord instead */
export type CashuMint_DECRYPTED = CashuMintRecord;
/** @deprecated Use EncryptedVaultHeader instead */
export type BrowserSyncData_PART_Unencrypted = EncryptedVaultHeader;
/** @deprecated Use EncryptedVaultContent instead */
export type BrowserSyncData_PART_Encrypted = EncryptedVaultContent;
/** @deprecated Use EncryptedVault instead */
export type BrowserSyncData = EncryptedVault;
/** @deprecated Use SyncFlow instead */
export const BrowserSyncFlow = SyncFlow;
/** @deprecated Use SyncFlow instead */
export type BrowserSyncFlow = SyncFlow;
/** @deprecated Use VaultSession instead */
export type BrowserSessionData = VaultSession;
/** @deprecated Use VaultSnapshot instead */
export type SignerMetaData_VaultSnapshot = VaultSnapshot;
/** @deprecated Use EXTENSION_SETTINGS_KEYS instead */
export const SIGNER_META_DATA_KEY = EXTENSION_SETTINGS_KEYS;
/** @deprecated Use ExtensionSettings instead */
export type SignerMetaData = ExtensionSettings;

View File

@@ -1,5 +1,6 @@
.sam-text-muted { .sam-text-muted,
color: var(--muted-foreground); .text-muted {
color: var(--muted-foreground) !important;
} }
.sam-text-lg { .sam-text-lg {

View File

@@ -2,11 +2,18 @@
* Public API Surface of common * Public API Surface of common
*/ */
// Domain (DDD Value Objects & Repository Interfaces)
export * from './lib/domain';
// Infrastructure (Encryption & Repository Implementations)
export * from './lib/infrastructure';
// Common // Common
export * from './lib/common/nav-component'; export * from './lib/common/nav-component';
// Constants // Constants
export * from './lib/constants/fallback-relays'; export * from './lib/constants/fallback-relays';
export * from './lib/constants/event-kinds';
// Helpers // Helpers
export * from './lib/helpers/crypto-helper'; export * from './lib/helpers/crypto-helper';

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { SignerMetaData, SignerMetaHandler } from '@common'; import { ExtensionSettings, SignerMetaHandler } from '@common';
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
export class FirefoxMetaHandler extends SignerMetaHandler { export class FirefoxMetaHandler extends SignerMetaHandler {
@@ -20,7 +20,7 @@ export class FirefoxMetaHandler extends SignerMetaHandler {
return data; return data;
} }
async saveFullData(data: SignerMetaData): Promise<void> { async saveFullData(data: ExtensionSettings): Promise<void> {
await browser.storage.local.set(data as Record<string, any>); await browser.storage.local.set(data as Record<string, any>);
console.log(data); console.log(data);
} }

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData, BrowserSessionHandler } from '@common'; import { VaultSession, BrowserSessionHandler } from '@common';
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
export class FirefoxSessionHandler extends BrowserSessionHandler { export class FirefoxSessionHandler extends BrowserSessionHandler {
@@ -7,7 +7,7 @@ export class FirefoxSessionHandler extends BrowserSessionHandler {
return browser.storage.session.get(null); return browser.storage.session.get(null);
} }
async saveFullData(data: BrowserSessionData): Promise<void> { async saveFullData(data: VaultSession): Promise<void> {
await browser.storage.session.set(data as Record<string, any>); await browser.storage.session.set(data as Record<string, any>);
} }

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
BrowserSyncData, EncryptedVault,
CashuMint_ENCRYPTED, StoredCashuMint,
Identity_ENCRYPTED, StoredIdentity,
NwcConnection_ENCRYPTED, StoredNwcConnection,
Permission_ENCRYPTED, StoredPermission,
BrowserSyncHandler, BrowserSyncHandler,
Relay_ENCRYPTED, StoredRelay,
} from '@common'; } from '@common';
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
@@ -25,20 +25,20 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
return data; return data;
} }
async saveAndSetFullData(data: BrowserSyncData): Promise<void> { async saveAndSetFullData(data: EncryptedVault): Promise<void> {
await browser.storage.local.set(data as Record<string, any>); await browser.storage.local.set(data as Record<string, any>);
this.setFullData(data); this.setFullData(data);
} }
async saveAndSetPartialData_Permissions(data: { async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[]; permissions: StoredPermission[];
}): Promise<void> { }): Promise<void> {
await browser.storage.local.set(data); await browser.storage.local.set(data);
this.setPartialData_Permissions(data); this.setPartialData_Permissions(data);
} }
async saveAndSetPartialData_Identities(data: { async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[]; identities: StoredIdentity[];
}): Promise<void> { }): Promise<void> {
await browser.storage.local.set(data); await browser.storage.local.set(data);
this.setPartialData_Identities(data); this.setPartialData_Identities(data);
@@ -52,21 +52,21 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
} }
async saveAndSetPartialData_Relays(data: { async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[]; relays: StoredRelay[];
}): Promise<void> { }): Promise<void> {
await browser.storage.local.set(data); await browser.storage.local.set(data);
this.setPartialData_Relays(data); this.setPartialData_Relays(data);
} }
async saveAndSetPartialData_NwcConnections(data: { async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[]; nwcConnections: StoredNwcConnection[];
}): Promise<void> { }): Promise<void> {
await browser.storage.local.set(data); await browser.storage.local.set(data);
this.setPartialData_NwcConnections(data); this.setPartialData_NwcConnections(data);
} }
async saveAndSetPartialData_CashuMints(data: { async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[]; cashuMints: StoredCashuMint[];
}): Promise<void> { }): Promise<void> {
await browser.storage.local.set(data); await browser.storage.local.set(data);
this.setPartialData_CashuMints(data); this.setPartialData_CashuMints(data);

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
BrowserSyncData, EncryptedVault,
CashuMint_ENCRYPTED, StoredCashuMint,
Identity_ENCRYPTED, StoredIdentity,
NwcConnection_ENCRYPTED, StoredNwcConnection,
Permission_ENCRYPTED, StoredPermission,
BrowserSyncHandler, BrowserSyncHandler,
Relay_ENCRYPTED, StoredRelay,
} from '@common'; } from '@common';
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
@@ -19,20 +19,20 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
return await browser.storage.sync.get(null); return await browser.storage.sync.get(null);
} }
async saveAndSetFullData(data: BrowserSyncData): Promise<void> { async saveAndSetFullData(data: EncryptedVault): Promise<void> {
await browser.storage.sync.set(data as Record<string, any>); await browser.storage.sync.set(data as Record<string, any>);
this.setFullData(data); this.setFullData(data);
} }
async saveAndSetPartialData_Permissions(data: { async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[]; permissions: StoredPermission[];
}): Promise<void> { }): Promise<void> {
await browser.storage.sync.set(data); await browser.storage.sync.set(data);
this.setPartialData_Permissions(data); this.setPartialData_Permissions(data);
} }
async saveAndSetPartialData_Identities(data: { async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[]; identities: StoredIdentity[];
}): Promise<void> { }): Promise<void> {
await browser.storage.sync.set(data); await browser.storage.sync.set(data);
this.setPartialData_Identities(data); this.setPartialData_Identities(data);
@@ -46,21 +46,21 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
} }
async saveAndSetPartialData_Relays(data: { async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[]; relays: StoredRelay[];
}): Promise<void> { }): Promise<void> {
await browser.storage.sync.set(data); await browser.storage.sync.set(data);
this.setPartialData_Relays(data); this.setPartialData_Relays(data);
} }
async saveAndSetPartialData_NwcConnections(data: { async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[]; nwcConnections: StoredNwcConnection[];
}): Promise<void> { }): Promise<void> {
await browser.storage.sync.set(data); await browser.storage.sync.set(data);
this.setPartialData_NwcConnections(data); this.setPartialData_NwcConnections(data);
} }
async saveAndSetPartialData_CashuMints(data: { async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[]; cashuMints: StoredCashuMint[];
}): Promise<void> { }): Promise<void> {
await browser.storage.sync.set(data); await browser.storage.sync.set(data);
this.setPartialData_CashuMints(data); this.setPartialData_CashuMints(data);

View File

@@ -30,7 +30,7 @@
> >
<span class="text-muted">{{ permission.method }}</span> <span class="text-muted">{{ permission.method }}</span>
@if(typeof permission.kind !== 'undefined') { @if(typeof permission.kind !== 'undefined') {
<span>(kind {{ permission.kind }})</span> <span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
} }
<div class="sam-flex-grow"></div> <div class="sam-flex-grow"></div>
<lib-icon-button <lib-icon-button

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { IconButtonComponent, Identity_DECRYPTED, NavComponent, Permission_DECRYPTED, StorageService } from '@common'; import { IconButtonComponent, Identity_DECRYPTED, NavComponent, Permission_DECRYPTED, StorageService, getKindName } from '@common';
interface HostPermissions { interface HostPermissions {
host: string; host: string;
@@ -80,4 +80,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
}); });
}); });
} }
getKindTooltip(kind: number): string {
return getKindName(kind);
}
} }