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:
690
DDD_ANALYSIS.md
Normal file
690
DDD_ANALYSIS.md
Normal 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.
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { SignerMetaData, SignerMetaHandler } from '@common';
|
||||
import { ExtensionSettings, SignerMetaHandler } from '@common';
|
||||
|
||||
export class ChromeMetaHandler extends SignerMetaHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
@@ -19,7 +19,7 @@ export class ChromeMetaHandler extends SignerMetaHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveFullData(data: SignerMetaData): Promise<void> {
|
||||
async saveFullData(data: ExtensionSettings): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSessionData, BrowserSessionHandler } from '@common';
|
||||
import { VaultSession, BrowserSessionHandler } from '@common';
|
||||
|
||||
export class ChromeSessionHandler extends BrowserSessionHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
return chrome.storage.session.get(null);
|
||||
}
|
||||
|
||||
async saveFullData(data: BrowserSessionData): Promise<void> {
|
||||
async saveFullData(data: VaultSession): Promise<void> {
|
||||
await chrome.storage.session.set(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
EncryptedVault,
|
||||
BrowserSyncHandler,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
@@ -26,20 +26,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -53,21 +53,21 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
@@ -18,20 +18,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
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);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -45,21 +45,21 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
>
|
||||
<span class="text-muted">{{ permission.method }}</span>
|
||||
@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>
|
||||
<lib-icon-button
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NavComponent,
|
||||
Permission_DECRYPTED,
|
||||
StorageService,
|
||||
getKindName,
|
||||
} from '@common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@@ -86,4 +87,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getKindTooltip(kind: number): string {
|
||||
return getKindName(kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('IconButtonComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(IconButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.icon = 'settings'; // Required input
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('PubkeyComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(PubkeyComponent);
|
||||
component = fixture.componentInstance;
|
||||
// Valid test pubkey (64 hex chars)
|
||||
component.value = 'a'.repeat(64);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||
200
projects/common/src/lib/domain/entities/identity.spec.ts
Normal file
200
projects/common/src/lib/domain/entities/identity.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
305
projects/common/src/lib/domain/entities/identity.ts
Normal file
305
projects/common/src/lib/domain/entities/identity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
projects/common/src/lib/domain/entities/index.ts
Normal file
21
projects/common/src/lib/domain/entities/index.ts
Normal 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';
|
||||
175
projects/common/src/lib/domain/entities/permission.spec.ts
Normal file
175
projects/common/src/lib/domain/entities/permission.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
332
projects/common/src/lib/domain/entities/permission.ts
Normal file
332
projects/common/src/lib/domain/entities/permission.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
155
projects/common/src/lib/domain/entities/relay.spec.ts
Normal file
155
projects/common/src/lib/domain/entities/relay.spec.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
268
projects/common/src/lib/domain/entities/relay.ts
Normal file
268
projects/common/src/lib/domain/entities/relay.ts
Normal 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;
|
||||
}
|
||||
81
projects/common/src/lib/domain/events/domain-event.spec.ts
Normal file
81
projects/common/src/lib/domain/events/domain-event.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
projects/common/src/lib/domain/events/domain-event.ts
Normal file
55
projects/common/src/lib/domain/events/domain-event.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
110
projects/common/src/lib/domain/events/identity-events.spec.ts
Normal file
110
projects/common/src/lib/domain/events/identity-events.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
74
projects/common/src/lib/domain/events/identity-events.ts
Normal file
74
projects/common/src/lib/domain/events/identity-events.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
projects/common/src/lib/domain/events/index.ts
Normal file
9
projects/common/src/lib/domain/events/index.ts
Normal 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';
|
||||
11
projects/common/src/lib/domain/index.ts
Normal file
11
projects/common/src/lib/domain/index.ts
Normal 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';
|
||||
@@ -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',
|
||||
}
|
||||
30
projects/common/src/lib/domain/repositories/index.ts
Normal file
30
projects/common/src/lib/domain/repositories/index.ts
Normal 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';
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
30
projects/common/src/lib/domain/value-objects/entity-id.ts
Normal file
30
projects/common/src/lib/domain/value-objects/entity-id.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
projects/common/src/lib/domain/value-objects/identity-id.ts
Normal file
36
projects/common/src/lib/domain/value-objects/identity-id.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
projects/common/src/lib/domain/value-objects/index.ts
Normal file
16
projects/common/src/lib/domain/value-objects/index.ts
Normal 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';
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
66
projects/common/src/lib/domain/value-objects/nickname.ts
Normal file
66
projects/common/src/lib/domain/value-objects/nickname.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
223
projects/common/src/lib/domain/value-objects/nostr-keypair.ts
Normal file
223
projects/common/src/lib/domain/value-objects/nostr-keypair.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
36
projects/common/src/lib/domain/value-objects/relay-id.ts
Normal file
36
projects/common/src/lib/domain/value-objects/relay-id.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
48
projects/common/src/lib/domain/value-objects/wallet-id.ts
Normal file
48
projects/common/src/lib/domain/value-objects/wallet-id.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
15
projects/common/src/lib/infrastructure/encryption/index.ts
Normal file
15
projects/common/src/lib/infrastructure/encryption/index.ts
Normal 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';
|
||||
2
projects/common/src/lib/infrastructure/index.ts
Normal file
2
projects/common/src/lib/infrastructure/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './encryption';
|
||||
export * from './repositories';
|
||||
@@ -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);
|
||||
}
|
||||
17
projects/common/src/lib/infrastructure/repositories/index.ts
Normal file
17
projects/common/src/lib/infrastructure/repositories/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSessionData } from './types';
|
||||
import { VaultSession } from './types';
|
||||
|
||||
export abstract class BrowserSessionHandler {
|
||||
get browserSessionData(): BrowserSessionData | undefined {
|
||||
return this.#browserSessionData;
|
||||
get vaultSession(): VaultSession | undefined {
|
||||
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,
|
||||
@@ -16,12 +21,12 @@ export abstract class BrowserSessionHandler {
|
||||
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
|
||||
*/
|
||||
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
|
||||
setFullData(data: BrowserSessionData) {
|
||||
this.#browserSessionData = JSON.parse(JSON.stringify(data));
|
||||
setFullData(data: VaultSession) {
|
||||
this.#vaultSession = JSON.parse(JSON.stringify(data));
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
abstract saveFullData(data: BrowserSessionData): Promise<void>;
|
||||
abstract saveFullData(data: VaultSession): Promise<void>;
|
||||
|
||||
abstract clearData(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
StoredRelay,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -14,15 +14,20 @@ import {
|
||||
* some unencrypted properties (like, version and the vault hash).
|
||||
*/
|
||||
export abstract class BrowserSyncHandler {
|
||||
get browserSyncData(): BrowserSyncData | undefined {
|
||||
return this.#browserSyncData;
|
||||
get encryptedVault(): EncryptedVault | undefined {
|
||||
return this.#encryptedVault;
|
||||
}
|
||||
|
||||
/** @deprecated Use encryptedVault instead */
|
||||
get browserSyncData(): EncryptedVault | undefined {
|
||||
return this.#encryptedVault;
|
||||
}
|
||||
|
||||
get ignoreProperties(): string[] {
|
||||
return this.#ignoreProperties;
|
||||
}
|
||||
|
||||
#browserSyncData?: BrowserSyncData;
|
||||
#encryptedVault?: EncryptedVault;
|
||||
#ignoreProperties: 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.
|
||||
*/
|
||||
abstract saveAndSetFullData(data: BrowserSyncData): Promise<void>;
|
||||
abstract saveAndSetFullData(data: EncryptedVault): Promise<void>;
|
||||
|
||||
setFullData(data: BrowserSyncData) {
|
||||
this.#browserSyncData = JSON.parse(JSON.stringify(data));
|
||||
setFullData(data: EncryptedVault) {
|
||||
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.
|
||||
*/
|
||||
abstract saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void>;
|
||||
setPartialData_Permissions(data: { permissions: Permission_ENCRYPTED[] }) {
|
||||
if (!this.#browserSyncData) {
|
||||
setPartialData_Permissions(data: { permissions: StoredPermission[] }) {
|
||||
if (!this.#encryptedVault) {
|
||||
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.
|
||||
*/
|
||||
abstract saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void>;
|
||||
|
||||
setPartialData_Identities(data: { identities: Identity_ENCRYPTED[] }) {
|
||||
if (!this.#browserSyncData) {
|
||||
setPartialData_Identities(data: { identities: StoredIdentity[] }) {
|
||||
if (!this.#encryptedVault) {
|
||||
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: {
|
||||
selectedIdentityId: string | null;
|
||||
}) {
|
||||
if (!this.#browserSyncData) {
|
||||
if (!this.#encryptedVault) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.selectedIdentityId = data.selectedIdentityId;
|
||||
this.#encryptedVault.selectedIdentityId = data.selectedIdentityId;
|
||||
}
|
||||
|
||||
abstract saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void>;
|
||||
setPartialData_Relays(data: { relays: Relay_ENCRYPTED[] }) {
|
||||
if (!this.#browserSyncData) {
|
||||
setPartialData_Relays(data: { relays: StoredRelay[] }) {
|
||||
if (!this.#encryptedVault) {
|
||||
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.
|
||||
*/
|
||||
abstract saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void>;
|
||||
setPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}) {
|
||||
if (!this.#browserSyncData) {
|
||||
if (!this.#encryptedVault) {
|
||||
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.
|
||||
*/
|
||||
abstract saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void>;
|
||||
setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) {
|
||||
if (!this.#browserSyncData) {
|
||||
setPartialData_CashuMints(data: { cashuMints: StoredCashuMint[] }) {
|
||||
if (!this.#encryptedVault) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.cashuMints = Array.from(data.cashuMints);
|
||||
this.#encryptedVault.cashuMints = Array.from(data.cashuMints);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
/* 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';
|
||||
|
||||
/**
|
||||
* Handler for extension settings stored outside the encrypted vault.
|
||||
* This includes sync preferences, backups, reckless mode, whitelisted hosts, etc.
|
||||
*/
|
||||
export abstract class SignerMetaHandler {
|
||||
get signerMetaData(): SignerMetaData | undefined {
|
||||
return this.#signerMetaData;
|
||||
get extensionSettings(): ExtensionSettings | undefined {
|
||||
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 DEFAULT_MAX_BACKUPS = 5;
|
||||
@@ -20,25 +29,30 @@ export abstract class SignerMetaHandler {
|
||||
*/
|
||||
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
|
||||
|
||||
setFullData(data: SignerMetaData) {
|
||||
this.#signerMetaData = data;
|
||||
setFullData(data: ExtensionSettings) {
|
||||
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> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
async setSyncFlow(flow: SyncFlow): Promise<void> {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
syncFlow: flow,
|
||||
};
|
||||
} 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>;
|
||||
@@ -47,93 +61,93 @@ export abstract class SignerMetaHandler {
|
||||
* Sets the reckless mode and immediately saves it.
|
||||
*/
|
||||
async setRecklessMode(enabled: boolean): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
recklessMode: enabled,
|
||||
};
|
||||
} 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.
|
||||
*/
|
||||
async setDevMode(enabled: boolean): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
devMode: enabled,
|
||||
};
|
||||
} 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.
|
||||
*/
|
||||
async addWhitelistedHost(host: string): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
whitelistedHosts: [host],
|
||||
};
|
||||
} else {
|
||||
const hosts = this.#signerMetaData.whitelistedHosts ?? [];
|
||||
const hosts = this.#extensionSettings.whitelistedHosts ?? [];
|
||||
if (!hosts.includes(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.
|
||||
*/
|
||||
async removeWhitelistedHost(host: string): Promise<void> {
|
||||
if (!this.#signerMetaData?.whitelistedHosts) {
|
||||
if (!this.#extensionSettings?.whitelistedHosts) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter(
|
||||
this.#extensionSettings.whitelistedHosts = this.#extensionSettings.whitelistedHosts.filter(
|
||||
(h) => h !== host
|
||||
);
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the bookmarks array and immediately saves it.
|
||||
*/
|
||||
async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
bookmarks,
|
||||
};
|
||||
} else {
|
||||
this.#signerMetaData.bookmarks = bookmarks;
|
||||
this.#extensionSettings.bookmarks = bookmarks;
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current bookmarks.
|
||||
*/
|
||||
getBookmarks(): Bookmark[] {
|
||||
return this.#signerMetaData?.bookmarks ?? [];
|
||||
return this.#extensionSettings?.bookmarks ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum number of backups to keep.
|
||||
*/
|
||||
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> {
|
||||
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
maxBackups: clampedCount,
|
||||
};
|
||||
} 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.
|
||||
*/
|
||||
getBackups(): SignerMetaData_VaultSnapshot[] {
|
||||
const backups = this.#signerMetaData?.vaultSnapshots ?? [];
|
||||
getBackups(): VaultSnapshot[] {
|
||||
const backups = this.#extensionSettings?.vaultSnapshots ?? [];
|
||||
return [...backups].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
@@ -165,8 +179,8 @@ export abstract class SignerMetaHandler {
|
||||
/**
|
||||
* Gets a specific backup by ID.
|
||||
*/
|
||||
getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined {
|
||||
return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id);
|
||||
getBackupById(id: string): VaultSnapshot | undefined {
|
||||
return this.#extensionSettings?.vaultSnapshots?.find(b => b.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,28 +188,28 @@ export abstract class SignerMetaHandler {
|
||||
* Automatically removes old backups if exceeding maxBackups.
|
||||
*/
|
||||
async createBackup(
|
||||
browserSyncData: BrowserSyncData,
|
||||
encryptedVault: EncryptedVault,
|
||||
reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
|
||||
): Promise<SignerMetaData_VaultSnapshot> {
|
||||
): Promise<VaultSnapshot> {
|
||||
const now = new Date();
|
||||
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(),
|
||||
fileName: `Vault Backup - ${dateTimeString}`,
|
||||
createdAt: now.toISOString(),
|
||||
data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone
|
||||
data: JSON.parse(JSON.stringify(encryptedVault)), // Deep clone
|
||||
identityCount,
|
||||
reason,
|
||||
};
|
||||
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
vaultSnapshots: [snapshot],
|
||||
};
|
||||
} else {
|
||||
const existingBackups = this.#signerMetaData.vaultSnapshots ?? [];
|
||||
const existingBackups = this.#extensionSettings.vaultSnapshots ?? [];
|
||||
existingBackups.push(snapshot);
|
||||
|
||||
// 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);
|
||||
|
||||
this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
|
||||
this.#extensionSettings.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -220,17 +234,17 @@ export abstract class SignerMetaHandler {
|
||||
* Deletes a backup by ID.
|
||||
*/
|
||||
async deleteBackup(backupId: string): Promise<boolean> {
|
||||
if (!this.#signerMetaData?.vaultSnapshots) {
|
||||
if (!this.#extensionSettings?.vaultSnapshots) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLength = this.#signerMetaData.vaultSnapshots.length;
|
||||
this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter(
|
||||
const initialLength = this.#extensionSettings.vaultSnapshots.length;
|
||||
this.#extensionSettings.vaultSnapshots = this.#extensionSettings.vaultSnapshots.filter(
|
||||
b => b.id !== backupId
|
||||
);
|
||||
|
||||
if (this.#signerMetaData.vaultSnapshots.length < initialLength) {
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
if (this.#extensionSettings.vaultSnapshots.length < initialLength) {
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -240,7 +254,7 @@ export abstract class SignerMetaHandler {
|
||||
* Gets the data from a backup for restoration.
|
||||
* 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);
|
||||
return backup?.data;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Injectable } from '@angular/core';
|
||||
import { BrowserSyncHandler } from './browser-sync-handler';
|
||||
import { BrowserSessionHandler } from './browser-session-handler';
|
||||
import {
|
||||
BrowserSessionData,
|
||||
BrowserSyncData,
|
||||
BrowserSyncFlow,
|
||||
SignerMetaData,
|
||||
Relay_DECRYPTED,
|
||||
VaultSession,
|
||||
EncryptedVault,
|
||||
SyncFlow,
|
||||
ExtensionSettings,
|
||||
RelayData,
|
||||
CashuMintRecord,
|
||||
CashuProof,
|
||||
} from './types';
|
||||
import { SignerMetaHandler } from './signer-meta-handler';
|
||||
import { CryptoHelper } from '@common';
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
deleteCashuMint,
|
||||
updateCashuMintProofs,
|
||||
} from './related/cashu';
|
||||
import { CashuMint_DECRYPTED, CashuProof } from './types';
|
||||
|
||||
export interface StorageServiceConfig {
|
||||
browserSessionHandler: BrowserSessionHandler;
|
||||
@@ -62,13 +63,13 @@ export class StorageService {
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
async enableBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
|
||||
async enableBrowserSyncFlow(flow: SyncFlow): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
this.#signerMetaHandler.setBrowserSyncFlow(flow);
|
||||
this.#signerMetaHandler.setSyncFlow(flow);
|
||||
}
|
||||
|
||||
async loadSignerMetaData(): Promise<SignerMetaData | undefined> {
|
||||
async loadExtensionSettings(): Promise<ExtensionSettings | undefined> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
const data = await this.#signerMetaHandler.loadFullData();
|
||||
@@ -77,11 +78,16 @@ export class StorageService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.#signerMetaHandler.setFullData(data as SignerMetaData);
|
||||
return data as SignerMetaData;
|
||||
this.#signerMetaHandler.setFullData(data as ExtensionSettings);
|
||||
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();
|
||||
|
||||
const data = await this.#browserSessionHandler.loadFullData();
|
||||
@@ -91,22 +97,27 @@ export class StorageService {
|
||||
}
|
||||
|
||||
// Set the existing data for in-memory usage.
|
||||
this.#browserSessionHandler.setFullData(data as BrowserSessionData);
|
||||
return data as BrowserSessionData;
|
||||
this.#browserSessionHandler.setFullData(data as VaultSession);
|
||||
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.
|
||||
*/
|
||||
async loadAndMigrateBrowserSyncData(): Promise<BrowserSyncData | undefined> {
|
||||
async loadAndMigrateEncryptedVault(): Promise<EncryptedVault | undefined> {
|
||||
this.assureIsInitialized();
|
||||
const unmigratedBrowserSyncData =
|
||||
const unmigratedEncryptedVault =
|
||||
await this.getBrowserSyncHandler().loadUnmigratedData();
|
||||
const { browserSyncData, migrationWasPerformed } =
|
||||
this.#migrateBrowserSyncData(unmigratedBrowserSyncData);
|
||||
const { encryptedVault, migrationWasPerformed } =
|
||||
this.#migrateEncryptedVault(unmigratedEncryptedVault);
|
||||
|
||||
if (!browserSyncData) {
|
||||
if (!encryptedVault) {
|
||||
// Nothing to do at this point.
|
||||
return undefined;
|
||||
}
|
||||
@@ -114,13 +125,18 @@ export class StorageService {
|
||||
// There is data. Check, if it was migrated.
|
||||
if (migrationWasPerformed) {
|
||||
// Persist the migrated data back to the browser sync storage.
|
||||
this.getBrowserSyncHandler().saveAndSetFullData(browserSyncData);
|
||||
this.getBrowserSyncHandler().saveAndSetFullData(encryptedVault);
|
||||
} else {
|
||||
// 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) {
|
||||
@@ -183,7 +199,7 @@ export class StorageService {
|
||||
await deleteRelay.call(this, relayId);
|
||||
}
|
||||
|
||||
async updateRelay(relayClone: Relay_DECRYPTED): Promise<void> {
|
||||
async updateRelay(relayClone: RelayData): Promise<void> {
|
||||
await updateRelay.call(this, relayClone);
|
||||
}
|
||||
|
||||
@@ -209,7 +225,7 @@ export class StorageService {
|
||||
name: string;
|
||||
mintUrl: string;
|
||||
unit?: string;
|
||||
}): Promise<CashuMint_DECRYPTED> {
|
||||
}): Promise<CashuMintRecord> {
|
||||
return await addCashuMint.call(this, data);
|
||||
}
|
||||
|
||||
@@ -227,36 +243,36 @@ export class StorageService {
|
||||
exportVault(): string {
|
||||
this.assureIsInitialized();
|
||||
const vaultJson = JSON.stringify(
|
||||
this.getBrowserSyncHandler().browserSyncData,
|
||||
this.getBrowserSyncHandler().encryptedVault,
|
||||
undefined,
|
||||
4
|
||||
);
|
||||
return vaultJson;
|
||||
}
|
||||
|
||||
async importVault(allegedBrowserSyncData: BrowserSyncData) {
|
||||
async importVault(allegedEncryptedVault: EncryptedVault) {
|
||||
this.assureIsInitialized();
|
||||
|
||||
const isValidData = this.#allegedBrowserSyncDataIsValid(
|
||||
allegedBrowserSyncData
|
||||
const isValidData = this.#allegedEncryptedVaultIsValid(
|
||||
allegedEncryptedVault
|
||||
);
|
||||
if (!isValidData) {
|
||||
throw new Error('The imported data is not valid.');
|
||||
}
|
||||
|
||||
await this.getBrowserSyncHandler().saveAndSetFullData(
|
||||
allegedBrowserSyncData
|
||||
allegedEncryptedVault
|
||||
);
|
||||
}
|
||||
|
||||
getBrowserSyncHandler(): BrowserSyncHandler {
|
||||
this.assureIsInitialized();
|
||||
|
||||
switch (this.#signerMetaHandler.signerMetaData?.syncFlow) {
|
||||
case BrowserSyncFlow.NO_SYNC:
|
||||
switch (this.#signerMetaHandler.extensionSettings?.syncFlow) {
|
||||
case SyncFlow.NO_SYNC:
|
||||
return this.#browserSyncNoHandler;
|
||||
|
||||
case BrowserSyncFlow.BROWSER_SYNC:
|
||||
case SyncFlow.BROWSER_SYNC:
|
||||
default:
|
||||
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.
|
||||
*/
|
||||
getSyncFlow(): BrowserSyncFlow {
|
||||
if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) {
|
||||
return BrowserSyncFlow.NO_SYNC;
|
||||
getSyncFlow(): SyncFlow {
|
||||
if (!this.isInitialized || !this.#signerMetaHandler?.extensionSettings) {
|
||||
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> {
|
||||
const browserSessionData =
|
||||
this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
const vaultSession = this.getBrowserSessionHandler().vaultSession;
|
||||
if (!vaultSession) {
|
||||
throw new Error('Vault session is undefined.');
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key directly with AES-GCM
|
||||
if (browserSessionData.vaultKey) {
|
||||
return this.encryptV2(value, browserSessionData.iv, browserSessionData.vaultKey);
|
||||
if (vaultSession.vaultKey) {
|
||||
return this.encryptV2(value, vaultSession.iv, vaultSession.vaultKey);
|
||||
}
|
||||
|
||||
// v1: Use PBKDF2 with password
|
||||
if (!browserSessionData.vaultPassword) {
|
||||
if (!vaultSession.vaultPassword) {
|
||||
throw new Error('No vault password or key available.');
|
||||
}
|
||||
return CryptoHelper.encrypt(
|
||||
value,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultPassword
|
||||
vaultSession.iv,
|
||||
vaultSession.vaultPassword
|
||||
);
|
||||
}
|
||||
|
||||
@@ -347,31 +362,30 @@ export class StorageService {
|
||||
value: string,
|
||||
returnType: 'string' | 'number' | 'boolean'
|
||||
): Promise<any> {
|
||||
const browserSessionData =
|
||||
this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
const vaultSession = this.getBrowserSessionHandler().vaultSession;
|
||||
if (!vaultSession) {
|
||||
throw new Error('Vault session is undefined.');
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key directly with AES-GCM
|
||||
if (browserSessionData.vaultKey) {
|
||||
if (vaultSession.vaultKey) {
|
||||
const decryptedValue = await this.decryptV2(
|
||||
value,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultKey
|
||||
vaultSession.iv,
|
||||
vaultSession.vaultKey
|
||||
);
|
||||
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||
}
|
||||
|
||||
// v1: Use PBKDF2 with password
|
||||
if (!browserSessionData.vaultPassword) {
|
||||
if (!vaultSession.vaultPassword) {
|
||||
throw new Error('No vault password or key available.');
|
||||
}
|
||||
return this.decryptWithLockedVault(
|
||||
value,
|
||||
returnType,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultPassword
|
||||
vaultSession.iv,
|
||||
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>>): {
|
||||
browserSyncData?: BrowserSyncData;
|
||||
#migrateEncryptedVault(encryptedVault: Partial<Record<string, any>>): {
|
||||
encryptedVault?: EncryptedVault;
|
||||
migrationWasPerformed: boolean;
|
||||
} {
|
||||
if (Object.keys(browserSyncData).length === 0) {
|
||||
// First run. There is no browser sync data yet.
|
||||
if (Object.keys(encryptedVault).length === 0) {
|
||||
// First run. There is no encrypted vault yet.
|
||||
return {
|
||||
browserSyncData: undefined,
|
||||
encryptedVault: undefined,
|
||||
migrationWasPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Will be implemented if migration is required.
|
||||
return {
|
||||
browserSyncData: browserSyncData as BrowserSyncData,
|
||||
encryptedVault: encryptedVault as EncryptedVault,
|
||||
migrationWasPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
#allegedBrowserSyncDataIsValid(data: BrowserSyncData): boolean {
|
||||
#allegedEncryptedVaultIsValid(data: EncryptedVault): boolean {
|
||||
if (typeof data.iv === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
|
||||
|
||||
export interface Permission_DECRYPTED {
|
||||
id: string;
|
||||
identityId: string;
|
||||
host: string;
|
||||
method: ExtensionMethod;
|
||||
methodPolicy: Nip07MethodPolicy;
|
||||
kind?: number;
|
||||
}
|
||||
// =============================================================================
|
||||
// STORAGE DATA TRANSFER OBJECTS (DTOs)
|
||||
// These types represent data as stored in browser storage
|
||||
// =============================================================================
|
||||
|
||||
export interface Permission_ENCRYPTED {
|
||||
/**
|
||||
* Permission as stored in encrypted vault (encrypted string fields)
|
||||
*/
|
||||
export interface StoredPermission {
|
||||
id: string;
|
||||
identityId: string;
|
||||
host: string;
|
||||
@@ -18,24 +17,37 @@ export interface Permission_ENCRYPTED {
|
||||
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;
|
||||
createdAt: string;
|
||||
nick: 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;
|
||||
identityId: string;
|
||||
url: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
||||
|
||||
export interface Relay_ENCRYPTED {
|
||||
/**
|
||||
* Relay as stored in encrypted vault (encrypted boolean fields)
|
||||
*/
|
||||
export interface StoredRelay {
|
||||
id: string;
|
||||
identityId: 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
|
||||
*/
|
||||
export interface NwcConnection_DECRYPTED {
|
||||
export interface NwcConnectionRecord {
|
||||
id: string;
|
||||
name: string; // User-defined wallet name
|
||||
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;
|
||||
name: 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
|
||||
*/
|
||||
export interface CashuMint_DECRYPTED {
|
||||
export interface CashuMintRecord {
|
||||
id: string;
|
||||
name: string; // User-defined mint name
|
||||
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;
|
||||
name: string;
|
||||
mintUrl: string;
|
||||
@@ -117,7 +140,15 @@ export interface CashuMint_ENCRYPTED {
|
||||
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;
|
||||
iv: string;
|
||||
vaultHash: string;
|
||||
@@ -126,26 +157,42 @@ export interface BrowserSyncData_PART_Unencrypted {
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
export interface BrowserSyncData_PART_Encrypted {
|
||||
/**
|
||||
* Vault content - encrypted payload containing all sensitive data
|
||||
*/
|
||||
export interface EncryptedVaultContent {
|
||||
selectedIdentityId: string | null;
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
identities: Identity_ENCRYPTED[];
|
||||
relays: Relay_ENCRYPTED[];
|
||||
nwcConnections?: NwcConnection_ENCRYPTED[];
|
||||
cashuMints?: CashuMint_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
identities: StoredIdentity[];
|
||||
relays: StoredRelay[];
|
||||
nwcConnections?: StoredNwcConnection[];
|
||||
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,
|
||||
BROWSER_SYNC = 1,
|
||||
SIGNER_SYNC = 2,
|
||||
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
|
||||
// and will never be going into the browser sync storage.
|
||||
vaultPassword?: string; // v1 only: raw password for PBKDF2
|
||||
@@ -155,24 +202,32 @@ export interface BrowserSessionData {
|
||||
iv: string;
|
||||
// Version 2+: Random salt for Argon2id (base64)
|
||||
salt?: string;
|
||||
permissions: Permission_DECRYPTED[];
|
||||
identities: Identity_DECRYPTED[];
|
||||
permissions: PermissionData[];
|
||||
identities: IdentityData[];
|
||||
selectedIdentityId: string | null;
|
||||
relays: Relay_DECRYPTED[];
|
||||
nwcConnections?: NwcConnection_DECRYPTED[];
|
||||
cashuMints?: CashuMint_DECRYPTED[];
|
||||
relays: RelayData[];
|
||||
nwcConnections?: NwcConnectionRecord[];
|
||||
cashuMints?: CashuMintRecord[];
|
||||
}
|
||||
|
||||
export interface SignerMetaData_VaultSnapshot {
|
||||
// =============================================================================
|
||||
// EXTENSION SETTINGS
|
||||
// Non-vault configuration stored separately
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Vault snapshot for backup/restore
|
||||
*/
|
||||
export interface VaultSnapshot {
|
||||
id: string;
|
||||
fileName: string;
|
||||
createdAt: string; // ISO timestamp
|
||||
data: BrowserSyncData;
|
||||
data: EncryptedVault;
|
||||
identityCount: number;
|
||||
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
|
||||
}
|
||||
|
||||
export const SIGNER_META_DATA_KEY = {
|
||||
export const EXTENSION_SETTINGS_KEYS = {
|
||||
vaultSnapshots: 'vaultSnapshots',
|
||||
};
|
||||
|
||||
@@ -186,10 +241,13 @@ export interface Bookmark {
|
||||
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)
|
||||
maxBackups?: number;
|
||||
@@ -229,3 +287,47 @@ export interface ProfileMetadata {
|
||||
* Cache for profile metadata, stored in session storage
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.sam-text-muted {
|
||||
color: var(--muted-foreground);
|
||||
.sam-text-muted,
|
||||
.text-muted {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
.sam-text-lg {
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
* Public API Surface of common
|
||||
*/
|
||||
|
||||
// Domain (DDD Value Objects & Repository Interfaces)
|
||||
export * from './lib/domain';
|
||||
|
||||
// Infrastructure (Encryption & Repository Implementations)
|
||||
export * from './lib/infrastructure';
|
||||
|
||||
// Common
|
||||
export * from './lib/common/nav-component';
|
||||
|
||||
// Constants
|
||||
export * from './lib/constants/fallback-relays';
|
||||
export * from './lib/constants/event-kinds';
|
||||
|
||||
// Helpers
|
||||
export * from './lib/helpers/crypto-helper';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { SignerMetaData, SignerMetaHandler } from '@common';
|
||||
import { ExtensionSettings, SignerMetaHandler } from '@common';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
export class FirefoxMetaHandler extends SignerMetaHandler {
|
||||
@@ -20,7 +20,7 @@ export class FirefoxMetaHandler extends SignerMetaHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveFullData(data: SignerMetaData): Promise<void> {
|
||||
async saveFullData(data: ExtensionSettings): Promise<void> {
|
||||
await browser.storage.local.set(data as Record<string, any>);
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSessionData, BrowserSessionHandler } from '@common';
|
||||
import { VaultSession, BrowserSessionHandler } from '@common';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
export class FirefoxSessionHandler extends BrowserSessionHandler {
|
||||
@@ -7,7 +7,7 @@ export class FirefoxSessionHandler extends BrowserSessionHandler {
|
||||
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>);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
@@ -25,20 +25,20 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await browser.storage.local.set(data as Record<string, any>);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -52,21 +52,21 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
@@ -19,20 +19,20 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
|
||||
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>);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -46,21 +46,21 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
>
|
||||
<span class="text-muted">{{ permission.method }}</span>
|
||||
@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>
|
||||
<lib-icon-button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
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 {
|
||||
host: string;
|
||||
@@ -80,4 +80,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getKindTooltip(kind: number): string {
|
||||
return getKindName(kind);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user