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 */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { SignerMetaData, SignerMetaHandler } from '@common';
|
import { ExtensionSettings, SignerMetaHandler } from '@common';
|
||||||
|
|
||||||
export class ChromeMetaHandler extends SignerMetaHandler {
|
export class ChromeMetaHandler extends SignerMetaHandler {
|
||||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||||
@@ -19,7 +19,7 @@ export class ChromeMetaHandler extends SignerMetaHandler {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveFullData(data: SignerMetaData): Promise<void> {
|
async saveFullData(data: ExtensionSettings): Promise<void> {
|
||||||
await chrome.storage.local.set(data);
|
await chrome.storage.local.set(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { BrowserSessionData, BrowserSessionHandler } from '@common';
|
import { VaultSession, BrowserSessionHandler } from '@common';
|
||||||
|
|
||||||
export class ChromeSessionHandler extends BrowserSessionHandler {
|
export class ChromeSessionHandler extends BrowserSessionHandler {
|
||||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||||
return chrome.storage.session.get(null);
|
return chrome.storage.session.get(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveFullData(data: BrowserSessionData): Promise<void> {
|
async saveFullData(data: VaultSession): Promise<void> {
|
||||||
await chrome.storage.session.set(data);
|
await chrome.storage.session.set(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import {
|
import {
|
||||||
BrowserSyncData,
|
EncryptedVault,
|
||||||
BrowserSyncHandler,
|
BrowserSyncHandler,
|
||||||
CashuMint_ENCRYPTED,
|
StoredCashuMint,
|
||||||
Identity_ENCRYPTED,
|
StoredIdentity,
|
||||||
NwcConnection_ENCRYPTED,
|
StoredNwcConnection,
|
||||||
Permission_ENCRYPTED,
|
StoredPermission,
|
||||||
Relay_ENCRYPTED,
|
StoredRelay,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,20 +26,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||||
await chrome.storage.local.set(data);
|
await chrome.storage.local.set(data);
|
||||||
this.setFullData(data);
|
this.setFullData(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Permissions(data: {
|
async saveAndSetPartialData_Permissions(data: {
|
||||||
permissions: Permission_ENCRYPTED[];
|
permissions: StoredPermission[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.local.set(data);
|
await chrome.storage.local.set(data);
|
||||||
this.setPartialData_Permissions(data);
|
this.setPartialData_Permissions(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Identities(data: {
|
async saveAndSetPartialData_Identities(data: {
|
||||||
identities: Identity_ENCRYPTED[];
|
identities: StoredIdentity[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.local.set(data);
|
await chrome.storage.local.set(data);
|
||||||
this.setPartialData_Identities(data);
|
this.setPartialData_Identities(data);
|
||||||
@@ -53,21 +53,21 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Relays(data: {
|
async saveAndSetPartialData_Relays(data: {
|
||||||
relays: Relay_ENCRYPTED[];
|
relays: StoredRelay[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.local.set(data);
|
await chrome.storage.local.set(data);
|
||||||
this.setPartialData_Relays(data);
|
this.setPartialData_Relays(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_NwcConnections(data: {
|
async saveAndSetPartialData_NwcConnections(data: {
|
||||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
nwcConnections: StoredNwcConnection[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.local.set(data);
|
await chrome.storage.local.set(data);
|
||||||
this.setPartialData_NwcConnections(data);
|
this.setPartialData_NwcConnections(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_CashuMints(data: {
|
async saveAndSetPartialData_CashuMints(data: {
|
||||||
cashuMints: CashuMint_ENCRYPTED[];
|
cashuMints: StoredCashuMint[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.local.set(data);
|
await chrome.storage.local.set(data);
|
||||||
this.setPartialData_CashuMints(data);
|
this.setPartialData_CashuMints(data);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import {
|
import {
|
||||||
BrowserSyncData,
|
EncryptedVault,
|
||||||
CashuMint_ENCRYPTED,
|
StoredCashuMint,
|
||||||
Identity_ENCRYPTED,
|
StoredIdentity,
|
||||||
NwcConnection_ENCRYPTED,
|
StoredNwcConnection,
|
||||||
Permission_ENCRYPTED,
|
StoredPermission,
|
||||||
BrowserSyncHandler,
|
BrowserSyncHandler,
|
||||||
Relay_ENCRYPTED,
|
StoredRelay,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,20 +18,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
|||||||
return await chrome.storage.sync.get(null);
|
return await chrome.storage.sync.get(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||||
await chrome.storage.sync.set(data);
|
await chrome.storage.sync.set(data);
|
||||||
this.setFullData(data);
|
this.setFullData(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Permissions(data: {
|
async saveAndSetPartialData_Permissions(data: {
|
||||||
permissions: Permission_ENCRYPTED[];
|
permissions: StoredPermission[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.sync.set(data);
|
await chrome.storage.sync.set(data);
|
||||||
this.setPartialData_Permissions(data);
|
this.setPartialData_Permissions(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Identities(data: {
|
async saveAndSetPartialData_Identities(data: {
|
||||||
identities: Identity_ENCRYPTED[];
|
identities: StoredIdentity[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.sync.set(data);
|
await chrome.storage.sync.set(data);
|
||||||
this.setPartialData_Identities(data);
|
this.setPartialData_Identities(data);
|
||||||
@@ -45,21 +45,21 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Relays(data: {
|
async saveAndSetPartialData_Relays(data: {
|
||||||
relays: Relay_ENCRYPTED[];
|
relays: StoredRelay[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.sync.set(data);
|
await chrome.storage.sync.set(data);
|
||||||
this.setPartialData_Relays(data);
|
this.setPartialData_Relays(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_NwcConnections(data: {
|
async saveAndSetPartialData_NwcConnections(data: {
|
||||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
nwcConnections: StoredNwcConnection[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.sync.set(data);
|
await chrome.storage.sync.set(data);
|
||||||
this.setPartialData_NwcConnections(data);
|
this.setPartialData_NwcConnections(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_CashuMints(data: {
|
async saveAndSetPartialData_CashuMints(data: {
|
||||||
cashuMints: CashuMint_ENCRYPTED[];
|
cashuMints: StoredCashuMint[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await chrome.storage.sync.set(data);
|
await chrome.storage.sync.set(data);
|
||||||
this.setPartialData_CashuMints(data);
|
this.setPartialData_CashuMints(data);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
>
|
>
|
||||||
<span class="text-muted">{{ permission.method }}</span>
|
<span class="text-muted">{{ permission.method }}</span>
|
||||||
@if(typeof permission.kind !== 'undefined') {
|
@if(typeof permission.kind !== 'undefined') {
|
||||||
<span>(kind {{ permission.kind }})</span>
|
<span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
|
||||||
}
|
}
|
||||||
<div class="sam-flex-grow"></div>
|
<div class="sam-flex-grow"></div>
|
||||||
<lib-icon-button
|
<lib-icon-button
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
NavComponent,
|
NavComponent,
|
||||||
Permission_DECRYPTED,
|
Permission_DECRYPTED,
|
||||||
StorageService,
|
StorageService,
|
||||||
|
getKindName,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
@@ -86,4 +87,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getKindTooltip(kind: number): string {
|
||||||
|
return getKindName(kind);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe('IconButtonComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(IconButtonComponent);
|
fixture = TestBed.createComponent(IconButtonComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
|
component.icon = 'settings'; // Required input
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ describe('PubkeyComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(PubkeyComponent);
|
fixture = TestBed.createComponent(PubkeyComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
|
// Valid test pubkey (64 hex chars)
|
||||||
|
component.value = 'a'.repeat(64);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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 */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { BrowserSessionData } from './types';
|
import { VaultSession } from './types';
|
||||||
|
|
||||||
export abstract class BrowserSessionHandler {
|
export abstract class BrowserSessionHandler {
|
||||||
get browserSessionData(): BrowserSessionData | undefined {
|
get vaultSession(): VaultSession | undefined {
|
||||||
return this.#browserSessionData;
|
return this.#vaultSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
#browserSessionData?: BrowserSessionData;
|
/** @deprecated Use vaultSession instead */
|
||||||
|
get browserSessionData(): VaultSession | undefined {
|
||||||
|
return this.#vaultSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
#vaultSession?: VaultSession;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the data from the browser session storage. It should be an empty object,
|
* Load the data from the browser session storage. It should be an empty object,
|
||||||
@@ -16,12 +21,12 @@ export abstract class BrowserSessionHandler {
|
|||||||
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
|
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
|
||||||
*/
|
*/
|
||||||
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
|
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
|
||||||
setFullData(data: BrowserSessionData) {
|
setFullData(data: VaultSession) {
|
||||||
this.#browserSessionData = JSON.parse(JSON.stringify(data));
|
this.#vaultSession = JSON.parse(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
clearInMemoryData() {
|
clearInMemoryData() {
|
||||||
this.#browserSessionData = undefined;
|
this.#vaultSession = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +34,7 @@ export abstract class BrowserSessionHandler {
|
|||||||
*
|
*
|
||||||
* ATTENTION: Make sure to call "setFullData(..)" afterwards of before to update the in-memory data.
|
* ATTENTION: Make sure to call "setFullData(..)" afterwards of before to update the in-memory data.
|
||||||
*/
|
*/
|
||||||
abstract saveFullData(data: BrowserSessionData): Promise<void>;
|
abstract saveFullData(data: VaultSession): Promise<void>;
|
||||||
|
|
||||||
abstract clearData(): Promise<void>;
|
abstract clearData(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import {
|
import {
|
||||||
BrowserSyncData,
|
EncryptedVault,
|
||||||
CashuMint_ENCRYPTED,
|
StoredCashuMint,
|
||||||
Identity_ENCRYPTED,
|
StoredIdentity,
|
||||||
NwcConnection_ENCRYPTED,
|
StoredNwcConnection,
|
||||||
Permission_ENCRYPTED,
|
StoredPermission,
|
||||||
Relay_ENCRYPTED,
|
StoredRelay,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,15 +14,20 @@ import {
|
|||||||
* some unencrypted properties (like, version and the vault hash).
|
* some unencrypted properties (like, version and the vault hash).
|
||||||
*/
|
*/
|
||||||
export abstract class BrowserSyncHandler {
|
export abstract class BrowserSyncHandler {
|
||||||
get browserSyncData(): BrowserSyncData | undefined {
|
get encryptedVault(): EncryptedVault | undefined {
|
||||||
return this.#browserSyncData;
|
return this.#encryptedVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use encryptedVault instead */
|
||||||
|
get browserSyncData(): EncryptedVault | undefined {
|
||||||
|
return this.#encryptedVault;
|
||||||
}
|
}
|
||||||
|
|
||||||
get ignoreProperties(): string[] {
|
get ignoreProperties(): string[] {
|
||||||
return this.#ignoreProperties;
|
return this.#ignoreProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
#browserSyncData?: BrowserSyncData;
|
#encryptedVault?: EncryptedVault;
|
||||||
#ignoreProperties: string[] = [];
|
#ignoreProperties: string[] = [];
|
||||||
|
|
||||||
setIgnoreProperties(properties: string[]) {
|
setIgnoreProperties(properties: string[]) {
|
||||||
@@ -41,10 +46,10 @@ export abstract class BrowserSyncHandler {
|
|||||||
*
|
*
|
||||||
* ATTENTION: In your implementation, make sure to call "setFullData(..)" at the end to update the in-memory data.
|
* ATTENTION: In your implementation, make sure to call "setFullData(..)" at the end to update the in-memory data.
|
||||||
*/
|
*/
|
||||||
abstract saveAndSetFullData(data: BrowserSyncData): Promise<void>;
|
abstract saveAndSetFullData(data: EncryptedVault): Promise<void>;
|
||||||
|
|
||||||
setFullData(data: BrowserSyncData) {
|
setFullData(data: EncryptedVault) {
|
||||||
this.#browserSyncData = JSON.parse(JSON.stringify(data));
|
this.#encryptedVault = JSON.parse(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,13 +58,13 @@ export abstract class BrowserSyncHandler {
|
|||||||
* ATTENTION: In your implementation, make sure to call "setPartialData_Permissions(..)" at the end to update the in-memory data.
|
* ATTENTION: In your implementation, make sure to call "setPartialData_Permissions(..)" at the end to update the in-memory data.
|
||||||
*/
|
*/
|
||||||
abstract saveAndSetPartialData_Permissions(data: {
|
abstract saveAndSetPartialData_Permissions(data: {
|
||||||
permissions: Permission_ENCRYPTED[];
|
permissions: StoredPermission[];
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
setPartialData_Permissions(data: { permissions: Permission_ENCRYPTED[] }) {
|
setPartialData_Permissions(data: { permissions: StoredPermission[] }) {
|
||||||
if (!this.#browserSyncData) {
|
if (!this.#encryptedVault) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#browserSyncData.permissions = Array.from(data.permissions);
|
this.#encryptedVault.permissions = Array.from(data.permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,14 +73,14 @@ export abstract class BrowserSyncHandler {
|
|||||||
* ATTENTION: In your implementation, make sure to call "setPartialData_Identities(..)" at the end to update the in-memory data.
|
* ATTENTION: In your implementation, make sure to call "setPartialData_Identities(..)" at the end to update the in-memory data.
|
||||||
*/
|
*/
|
||||||
abstract saveAndSetPartialData_Identities(data: {
|
abstract saveAndSetPartialData_Identities(data: {
|
||||||
identities: Identity_ENCRYPTED[];
|
identities: StoredIdentity[];
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
|
||||||
setPartialData_Identities(data: { identities: Identity_ENCRYPTED[] }) {
|
setPartialData_Identities(data: { identities: StoredIdentity[] }) {
|
||||||
if (!this.#browserSyncData) {
|
if (!this.#encryptedVault) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#browserSyncData.identities = Array.from(data.identities);
|
this.#encryptedVault.identities = Array.from(data.identities);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,20 +95,20 @@ export abstract class BrowserSyncHandler {
|
|||||||
setPartialData_SelectedIdentityId(data: {
|
setPartialData_SelectedIdentityId(data: {
|
||||||
selectedIdentityId: string | null;
|
selectedIdentityId: string | null;
|
||||||
}) {
|
}) {
|
||||||
if (!this.#browserSyncData) {
|
if (!this.#encryptedVault) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#browserSyncData.selectedIdentityId = data.selectedIdentityId;
|
this.#encryptedVault.selectedIdentityId = data.selectedIdentityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract saveAndSetPartialData_Relays(data: {
|
abstract saveAndSetPartialData_Relays(data: {
|
||||||
relays: Relay_ENCRYPTED[];
|
relays: StoredRelay[];
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
setPartialData_Relays(data: { relays: Relay_ENCRYPTED[] }) {
|
setPartialData_Relays(data: { relays: StoredRelay[] }) {
|
||||||
if (!this.#browserSyncData) {
|
if (!this.#encryptedVault) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#browserSyncData.relays = Array.from(data.relays);
|
this.#encryptedVault.relays = Array.from(data.relays);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,15 +117,15 @@ export abstract class BrowserSyncHandler {
|
|||||||
* ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data.
|
* ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data.
|
||||||
*/
|
*/
|
||||||
abstract saveAndSetPartialData_NwcConnections(data: {
|
abstract saveAndSetPartialData_NwcConnections(data: {
|
||||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
nwcConnections: StoredNwcConnection[];
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
setPartialData_NwcConnections(data: {
|
setPartialData_NwcConnections(data: {
|
||||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
nwcConnections: StoredNwcConnection[];
|
||||||
}) {
|
}) {
|
||||||
if (!this.#browserSyncData) {
|
if (!this.#encryptedVault) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#browserSyncData.nwcConnections = Array.from(data.nwcConnections);
|
this.#encryptedVault.nwcConnections = Array.from(data.nwcConnections);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,13 +134,13 @@ export abstract class BrowserSyncHandler {
|
|||||||
* ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data.
|
* ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data.
|
||||||
*/
|
*/
|
||||||
abstract saveAndSetPartialData_CashuMints(data: {
|
abstract saveAndSetPartialData_CashuMints(data: {
|
||||||
cashuMints: CashuMint_ENCRYPTED[];
|
cashuMints: StoredCashuMint[];
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) {
|
setPartialData_CashuMints(data: { cashuMints: StoredCashuMint[] }) {
|
||||||
if (!this.#browserSyncData) {
|
if (!this.#encryptedVault) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#browserSyncData.cashuMints = Array.from(data.cashuMints);
|
this.#encryptedVault.cashuMints = Array.from(data.cashuMints);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Bookmark, BrowserSyncData, BrowserSyncFlow, SignerMetaData, SignerMetaData_VaultSnapshot } from './types';
|
import { Bookmark, EncryptedVault, SyncFlow, ExtensionSettings, VaultSnapshot } from './types';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for extension settings stored outside the encrypted vault.
|
||||||
|
* This includes sync preferences, backups, reckless mode, whitelisted hosts, etc.
|
||||||
|
*/
|
||||||
export abstract class SignerMetaHandler {
|
export abstract class SignerMetaHandler {
|
||||||
get signerMetaData(): SignerMetaData | undefined {
|
get extensionSettings(): ExtensionSettings | undefined {
|
||||||
return this.#signerMetaData;
|
return this.#extensionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
#signerMetaData?: SignerMetaData;
|
/** @deprecated Use extensionSettings instead */
|
||||||
|
get signerMetaData(): ExtensionSettings | undefined {
|
||||||
|
return this.#extensionSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extensionSettings?: ExtensionSettings;
|
||||||
|
|
||||||
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode'];
|
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode'];
|
||||||
readonly DEFAULT_MAX_BACKUPS = 5;
|
readonly DEFAULT_MAX_BACKUPS = 5;
|
||||||
@@ -20,25 +29,30 @@ export abstract class SignerMetaHandler {
|
|||||||
*/
|
*/
|
||||||
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
|
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
|
||||||
|
|
||||||
setFullData(data: SignerMetaData) {
|
setFullData(data: ExtensionSettings) {
|
||||||
this.#signerMetaData = data;
|
this.#extensionSettings = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract saveFullData(data: SignerMetaData): Promise<void>;
|
abstract saveFullData(data: ExtensionSettings): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the browser sync flow for the user and immediately saves it.
|
* Sets the sync flow preference for the user and immediately saves it.
|
||||||
*/
|
*/
|
||||||
async setBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
|
async setSyncFlow(flow: SyncFlow): Promise<void> {
|
||||||
if (!this.#signerMetaData) {
|
if (!this.#extensionSettings) {
|
||||||
this.#signerMetaData = {
|
this.#extensionSettings = {
|
||||||
syncFlow: flow,
|
syncFlow: flow,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.#signerMetaData.syncFlow = flow;
|
this.#extensionSettings.syncFlow = flow;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveFullData(this.#signerMetaData);
|
await this.saveFullData(this.#extensionSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use setSyncFlow instead */
|
||||||
|
async setBrowserSyncFlow(flow: SyncFlow): Promise<void> {
|
||||||
|
return this.setSyncFlow(flow);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract clearData(keep: string[]): Promise<void>;
|
abstract clearData(keep: string[]): Promise<void>;
|
||||||
@@ -47,93 +61,93 @@ export abstract class SignerMetaHandler {
|
|||||||
* Sets the reckless mode and immediately saves it.
|
* Sets the reckless mode and immediately saves it.
|
||||||
*/
|
*/
|
||||||
async setRecklessMode(enabled: boolean): Promise<void> {
|
async setRecklessMode(enabled: boolean): Promise<void> {
|
||||||
if (!this.#signerMetaData) {
|
if (!this.#extensionSettings) {
|
||||||
this.#signerMetaData = {
|
this.#extensionSettings = {
|
||||||
recklessMode: enabled,
|
recklessMode: enabled,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.#signerMetaData.recklessMode = enabled;
|
this.#extensionSettings.recklessMode = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveFullData(this.#signerMetaData);
|
await this.saveFullData(this.#extensionSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets dev mode and immediately saves it.
|
* Sets dev mode and immediately saves it.
|
||||||
*/
|
*/
|
||||||
async setDevMode(enabled: boolean): Promise<void> {
|
async setDevMode(enabled: boolean): Promise<void> {
|
||||||
if (!this.#signerMetaData) {
|
if (!this.#extensionSettings) {
|
||||||
this.#signerMetaData = {
|
this.#extensionSettings = {
|
||||||
devMode: enabled,
|
devMode: enabled,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.#signerMetaData.devMode = enabled;
|
this.#extensionSettings.devMode = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveFullData(this.#signerMetaData);
|
await this.saveFullData(this.#extensionSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a host to the whitelist and immediately saves it.
|
* Adds a host to the whitelist and immediately saves it.
|
||||||
*/
|
*/
|
||||||
async addWhitelistedHost(host: string): Promise<void> {
|
async addWhitelistedHost(host: string): Promise<void> {
|
||||||
if (!this.#signerMetaData) {
|
if (!this.#extensionSettings) {
|
||||||
this.#signerMetaData = {
|
this.#extensionSettings = {
|
||||||
whitelistedHosts: [host],
|
whitelistedHosts: [host],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const hosts = this.#signerMetaData.whitelistedHosts ?? [];
|
const hosts = this.#extensionSettings.whitelistedHosts ?? [];
|
||||||
if (!hosts.includes(host)) {
|
if (!hosts.includes(host)) {
|
||||||
hosts.push(host);
|
hosts.push(host);
|
||||||
this.#signerMetaData.whitelistedHosts = hosts;
|
this.#extensionSettings.whitelistedHosts = hosts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveFullData(this.#signerMetaData);
|
await this.saveFullData(this.#extensionSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a host from the whitelist and immediately saves it.
|
* Removes a host from the whitelist and immediately saves it.
|
||||||
*/
|
*/
|
||||||
async removeWhitelistedHost(host: string): Promise<void> {
|
async removeWhitelistedHost(host: string): Promise<void> {
|
||||||
if (!this.#signerMetaData?.whitelistedHosts) {
|
if (!this.#extensionSettings?.whitelistedHosts) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter(
|
this.#extensionSettings.whitelistedHosts = this.#extensionSettings.whitelistedHosts.filter(
|
||||||
(h) => h !== host
|
(h) => h !== host
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.saveFullData(this.#signerMetaData);
|
await this.saveFullData(this.#extensionSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the bookmarks array and immediately saves it.
|
* Sets the bookmarks array and immediately saves it.
|
||||||
*/
|
*/
|
||||||
async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
|
async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
|
||||||
if (!this.#signerMetaData) {
|
if (!this.#extensionSettings) {
|
||||||
this.#signerMetaData = {
|
this.#extensionSettings = {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.#signerMetaData.bookmarks = bookmarks;
|
this.#extensionSettings.bookmarks = bookmarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveFullData(this.#signerMetaData);
|
await this.saveFullData(this.#extensionSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current bookmarks.
|
* Gets the current bookmarks.
|
||||||
*/
|
*/
|
||||||
getBookmarks(): Bookmark[] {
|
getBookmarks(): Bookmark[] {
|
||||||
return this.#signerMetaData?.bookmarks ?? [];
|
return this.#extensionSettings?.bookmarks ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the maximum number of backups to keep.
|
* Gets the maximum number of backups to keep.
|
||||||
*/
|
*/
|
||||||
getMaxBackups(): number {
|
getMaxBackups(): number {
|
||||||
return this.#signerMetaData?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
|
return this.#extensionSettings?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,22 +155,22 @@ export abstract class SignerMetaHandler {
|
|||||||
*/
|
*/
|
||||||
async setMaxBackups(count: number): Promise<void> {
|
async setMaxBackups(count: number): Promise<void> {
|
||||||
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
|
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
|
||||||
if (!this.#signerMetaData) {
|
if (!this.#extensionSettings) {
|
||||||
this.#signerMetaData = {
|
this.#extensionSettings = {
|
||||||
maxBackups: clampedCount,
|
maxBackups: clampedCount,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.#signerMetaData.maxBackups = clampedCount;
|
this.#extensionSettings.maxBackups = clampedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveFullData(this.#signerMetaData);
|
await this.saveFullData(this.#extensionSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets all vault backups, sorted newest first.
|
* Gets all vault backups, sorted newest first.
|
||||||
*/
|
*/
|
||||||
getBackups(): SignerMetaData_VaultSnapshot[] {
|
getBackups(): VaultSnapshot[] {
|
||||||
const backups = this.#signerMetaData?.vaultSnapshots ?? [];
|
const backups = this.#extensionSettings?.vaultSnapshots ?? [];
|
||||||
return [...backups].sort((a, b) =>
|
return [...backups].sort((a, b) =>
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
);
|
);
|
||||||
@@ -165,8 +179,8 @@ export abstract class SignerMetaHandler {
|
|||||||
/**
|
/**
|
||||||
* Gets a specific backup by ID.
|
* Gets a specific backup by ID.
|
||||||
*/
|
*/
|
||||||
getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined {
|
getBackupById(id: string): VaultSnapshot | undefined {
|
||||||
return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id);
|
return this.#extensionSettings?.vaultSnapshots?.find(b => b.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,28 +188,28 @@ export abstract class SignerMetaHandler {
|
|||||||
* Automatically removes old backups if exceeding maxBackups.
|
* Automatically removes old backups if exceeding maxBackups.
|
||||||
*/
|
*/
|
||||||
async createBackup(
|
async createBackup(
|
||||||
browserSyncData: BrowserSyncData,
|
encryptedVault: EncryptedVault,
|
||||||
reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
|
reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
|
||||||
): Promise<SignerMetaData_VaultSnapshot> {
|
): Promise<VaultSnapshot> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
const identityCount = browserSyncData.identities?.length ?? 0;
|
const identityCount = encryptedVault.identities?.length ?? 0;
|
||||||
|
|
||||||
const snapshot: SignerMetaData_VaultSnapshot = {
|
const snapshot: VaultSnapshot = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
fileName: `Vault Backup - ${dateTimeString}`,
|
fileName: `Vault Backup - ${dateTimeString}`,
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone
|
data: JSON.parse(JSON.stringify(encryptedVault)), // Deep clone
|
||||||
identityCount,
|
identityCount,
|
||||||
reason,
|
reason,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.#signerMetaData) {
|
if (!this.#extensionSettings) {
|
||||||
this.#signerMetaData = {
|
this.#extensionSettings = {
|
||||||
vaultSnapshots: [snapshot],
|
vaultSnapshots: [snapshot],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const existingBackups = this.#signerMetaData.vaultSnapshots ?? [];
|
const existingBackups = this.#extensionSettings.vaultSnapshots ?? [];
|
||||||
existingBackups.push(snapshot);
|
existingBackups.push(snapshot);
|
||||||
|
|
||||||
// Enforce max backups limit (only for auto backups, keep manual and pre-restore)
|
// Enforce max backups limit (only for auto backups, keep manual and pre-restore)
|
||||||
@@ -209,10 +223,10 @@ export abstract class SignerMetaHandler {
|
|||||||
);
|
);
|
||||||
const trimmedAutoBackups = autoBackups.slice(0, maxBackups);
|
const trimmedAutoBackups = autoBackups.slice(0, maxBackups);
|
||||||
|
|
||||||
this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
|
this.#extensionSettings.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveFullData(this.#signerMetaData);
|
await this.saveFullData(this.#extensionSettings);
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,17 +234,17 @@ export abstract class SignerMetaHandler {
|
|||||||
* Deletes a backup by ID.
|
* Deletes a backup by ID.
|
||||||
*/
|
*/
|
||||||
async deleteBackup(backupId: string): Promise<boolean> {
|
async deleteBackup(backupId: string): Promise<boolean> {
|
||||||
if (!this.#signerMetaData?.vaultSnapshots) {
|
if (!this.#extensionSettings?.vaultSnapshots) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialLength = this.#signerMetaData.vaultSnapshots.length;
|
const initialLength = this.#extensionSettings.vaultSnapshots.length;
|
||||||
this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter(
|
this.#extensionSettings.vaultSnapshots = this.#extensionSettings.vaultSnapshots.filter(
|
||||||
b => b.id !== backupId
|
b => b.id !== backupId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.#signerMetaData.vaultSnapshots.length < initialLength) {
|
if (this.#extensionSettings.vaultSnapshots.length < initialLength) {
|
||||||
await this.saveFullData(this.#signerMetaData);
|
await this.saveFullData(this.#extensionSettings);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -240,7 +254,7 @@ export abstract class SignerMetaHandler {
|
|||||||
* Gets the data from a backup for restoration.
|
* Gets the data from a backup for restoration.
|
||||||
* Note: The caller should create a pre-restore backup before calling this.
|
* Note: The caller should create a pre-restore backup before calling this.
|
||||||
*/
|
*/
|
||||||
getBackupData(backupId: string): BrowserSyncData | undefined {
|
getBackupData(backupId: string): EncryptedVault | undefined {
|
||||||
const backup = this.getBackupById(backupId);
|
const backup = this.getBackupById(backupId);
|
||||||
return backup?.data;
|
return backup?.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { Injectable } from '@angular/core';
|
|||||||
import { BrowserSyncHandler } from './browser-sync-handler';
|
import { BrowserSyncHandler } from './browser-sync-handler';
|
||||||
import { BrowserSessionHandler } from './browser-session-handler';
|
import { BrowserSessionHandler } from './browser-session-handler';
|
||||||
import {
|
import {
|
||||||
BrowserSessionData,
|
VaultSession,
|
||||||
BrowserSyncData,
|
EncryptedVault,
|
||||||
BrowserSyncFlow,
|
SyncFlow,
|
||||||
SignerMetaData,
|
ExtensionSettings,
|
||||||
Relay_DECRYPTED,
|
RelayData,
|
||||||
|
CashuMintRecord,
|
||||||
|
CashuProof,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { SignerMetaHandler } from './signer-meta-handler';
|
import { SignerMetaHandler } from './signer-meta-handler';
|
||||||
import { CryptoHelper } from '@common';
|
import { CryptoHelper } from '@common';
|
||||||
@@ -30,7 +32,6 @@ import {
|
|||||||
deleteCashuMint,
|
deleteCashuMint,
|
||||||
updateCashuMintProofs,
|
updateCashuMintProofs,
|
||||||
} from './related/cashu';
|
} from './related/cashu';
|
||||||
import { CashuMint_DECRYPTED, CashuProof } from './types';
|
|
||||||
|
|
||||||
export interface StorageServiceConfig {
|
export interface StorageServiceConfig {
|
||||||
browserSessionHandler: BrowserSessionHandler;
|
browserSessionHandler: BrowserSessionHandler;
|
||||||
@@ -62,13 +63,13 @@ export class StorageService {
|
|||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
|
async enableBrowserSyncFlow(flow: SyncFlow): Promise<void> {
|
||||||
this.assureIsInitialized();
|
this.assureIsInitialized();
|
||||||
|
|
||||||
this.#signerMetaHandler.setBrowserSyncFlow(flow);
|
this.#signerMetaHandler.setSyncFlow(flow);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSignerMetaData(): Promise<SignerMetaData | undefined> {
|
async loadExtensionSettings(): Promise<ExtensionSettings | undefined> {
|
||||||
this.assureIsInitialized();
|
this.assureIsInitialized();
|
||||||
|
|
||||||
const data = await this.#signerMetaHandler.loadFullData();
|
const data = await this.#signerMetaHandler.loadFullData();
|
||||||
@@ -77,11 +78,16 @@ export class StorageService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#signerMetaHandler.setFullData(data as SignerMetaData);
|
this.#signerMetaHandler.setFullData(data as ExtensionSettings);
|
||||||
return data as SignerMetaData;
|
return data as ExtensionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadBrowserSessionData(): Promise<BrowserSessionData | undefined> {
|
/** @deprecated Use loadExtensionSettings instead */
|
||||||
|
async loadSignerMetaData(): Promise<ExtensionSettings | undefined> {
|
||||||
|
return this.loadExtensionSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadVaultSession(): Promise<VaultSession | undefined> {
|
||||||
this.assureIsInitialized();
|
this.assureIsInitialized();
|
||||||
|
|
||||||
const data = await this.#browserSessionHandler.loadFullData();
|
const data = await this.#browserSessionHandler.loadFullData();
|
||||||
@@ -91,22 +97,27 @@ export class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set the existing data for in-memory usage.
|
// Set the existing data for in-memory usage.
|
||||||
this.#browserSessionHandler.setFullData(data as BrowserSessionData);
|
this.#browserSessionHandler.setFullData(data as VaultSession);
|
||||||
return data as BrowserSessionData;
|
return data as VaultSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use loadVaultSession instead */
|
||||||
|
async loadBrowserSessionData(): Promise<VaultSession | undefined> {
|
||||||
|
return this.loadVaultSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and migrate the browser sync data. If no data is available yet,
|
* Load and migrate the encrypted vault data. If no data is available yet,
|
||||||
* the returned object is undefined.
|
* the returned object is undefined.
|
||||||
*/
|
*/
|
||||||
async loadAndMigrateBrowserSyncData(): Promise<BrowserSyncData | undefined> {
|
async loadAndMigrateEncryptedVault(): Promise<EncryptedVault | undefined> {
|
||||||
this.assureIsInitialized();
|
this.assureIsInitialized();
|
||||||
const unmigratedBrowserSyncData =
|
const unmigratedEncryptedVault =
|
||||||
await this.getBrowserSyncHandler().loadUnmigratedData();
|
await this.getBrowserSyncHandler().loadUnmigratedData();
|
||||||
const { browserSyncData, migrationWasPerformed } =
|
const { encryptedVault, migrationWasPerformed } =
|
||||||
this.#migrateBrowserSyncData(unmigratedBrowserSyncData);
|
this.#migrateEncryptedVault(unmigratedEncryptedVault);
|
||||||
|
|
||||||
if (!browserSyncData) {
|
if (!encryptedVault) {
|
||||||
// Nothing to do at this point.
|
// Nothing to do at this point.
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -114,13 +125,18 @@ export class StorageService {
|
|||||||
// There is data. Check, if it was migrated.
|
// There is data. Check, if it was migrated.
|
||||||
if (migrationWasPerformed) {
|
if (migrationWasPerformed) {
|
||||||
// Persist the migrated data back to the browser sync storage.
|
// Persist the migrated data back to the browser sync storage.
|
||||||
this.getBrowserSyncHandler().saveAndSetFullData(browserSyncData);
|
this.getBrowserSyncHandler().saveAndSetFullData(encryptedVault);
|
||||||
} else {
|
} else {
|
||||||
// Set the data for in-memory usage.
|
// Set the data for in-memory usage.
|
||||||
this.getBrowserSyncHandler().setFullData(browserSyncData);
|
this.getBrowserSyncHandler().setFullData(encryptedVault);
|
||||||
}
|
}
|
||||||
|
|
||||||
return browserSyncData;
|
return encryptedVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use loadAndMigrateEncryptedVault instead */
|
||||||
|
async loadAndMigrateBrowserSyncData(): Promise<EncryptedVault | undefined> {
|
||||||
|
return this.loadAndMigrateEncryptedVault();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteVault(doNotSetIsInitializedToFalse = false) {
|
async deleteVault(doNotSetIsInitializedToFalse = false) {
|
||||||
@@ -183,7 +199,7 @@ export class StorageService {
|
|||||||
await deleteRelay.call(this, relayId);
|
await deleteRelay.call(this, relayId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRelay(relayClone: Relay_DECRYPTED): Promise<void> {
|
async updateRelay(relayClone: RelayData): Promise<void> {
|
||||||
await updateRelay.call(this, relayClone);
|
await updateRelay.call(this, relayClone);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +225,7 @@ export class StorageService {
|
|||||||
name: string;
|
name: string;
|
||||||
mintUrl: string;
|
mintUrl: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
}): Promise<CashuMint_DECRYPTED> {
|
}): Promise<CashuMintRecord> {
|
||||||
return await addCashuMint.call(this, data);
|
return await addCashuMint.call(this, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,36 +243,36 @@ export class StorageService {
|
|||||||
exportVault(): string {
|
exportVault(): string {
|
||||||
this.assureIsInitialized();
|
this.assureIsInitialized();
|
||||||
const vaultJson = JSON.stringify(
|
const vaultJson = JSON.stringify(
|
||||||
this.getBrowserSyncHandler().browserSyncData,
|
this.getBrowserSyncHandler().encryptedVault,
|
||||||
undefined,
|
undefined,
|
||||||
4
|
4
|
||||||
);
|
);
|
||||||
return vaultJson;
|
return vaultJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
async importVault(allegedBrowserSyncData: BrowserSyncData) {
|
async importVault(allegedEncryptedVault: EncryptedVault) {
|
||||||
this.assureIsInitialized();
|
this.assureIsInitialized();
|
||||||
|
|
||||||
const isValidData = this.#allegedBrowserSyncDataIsValid(
|
const isValidData = this.#allegedEncryptedVaultIsValid(
|
||||||
allegedBrowserSyncData
|
allegedEncryptedVault
|
||||||
);
|
);
|
||||||
if (!isValidData) {
|
if (!isValidData) {
|
||||||
throw new Error('The imported data is not valid.');
|
throw new Error('The imported data is not valid.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.getBrowserSyncHandler().saveAndSetFullData(
|
await this.getBrowserSyncHandler().saveAndSetFullData(
|
||||||
allegedBrowserSyncData
|
allegedEncryptedVault
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBrowserSyncHandler(): BrowserSyncHandler {
|
getBrowserSyncHandler(): BrowserSyncHandler {
|
||||||
this.assureIsInitialized();
|
this.assureIsInitialized();
|
||||||
|
|
||||||
switch (this.#signerMetaHandler.signerMetaData?.syncFlow) {
|
switch (this.#signerMetaHandler.extensionSettings?.syncFlow) {
|
||||||
case BrowserSyncFlow.NO_SYNC:
|
case SyncFlow.NO_SYNC:
|
||||||
return this.#browserSyncNoHandler;
|
return this.#browserSyncNoHandler;
|
||||||
|
|
||||||
case BrowserSyncFlow.BROWSER_SYNC:
|
case SyncFlow.BROWSER_SYNC:
|
||||||
default:
|
default:
|
||||||
return this.#browserSyncYesHandler;
|
return this.#browserSyncYesHandler;
|
||||||
}
|
}
|
||||||
@@ -275,14 +291,14 @@ export class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current browser sync flow setting.
|
* Get the current sync flow setting.
|
||||||
* Returns NO_SYNC if not initialized or no setting found.
|
* Returns NO_SYNC if not initialized or no setting found.
|
||||||
*/
|
*/
|
||||||
getSyncFlow(): BrowserSyncFlow {
|
getSyncFlow(): SyncFlow {
|
||||||
if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) {
|
if (!this.isInitialized || !this.#signerMetaHandler?.extensionSettings) {
|
||||||
return BrowserSyncFlow.NO_SYNC;
|
return SyncFlow.NO_SYNC;
|
||||||
}
|
}
|
||||||
return this.#signerMetaHandler.signerMetaData.syncFlow ?? BrowserSyncFlow.NO_SYNC;
|
return this.#signerMetaHandler.extensionSettings.syncFlow ?? SyncFlow.NO_SYNC;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -297,25 +313,24 @@ export class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async encrypt(value: string): Promise<string> {
|
async encrypt(value: string): Promise<string> {
|
||||||
const browserSessionData =
|
const vaultSession = this.getBrowserSessionHandler().vaultSession;
|
||||||
this.getBrowserSessionHandler().browserSessionData;
|
if (!vaultSession) {
|
||||||
if (!browserSessionData) {
|
throw new Error('Vault session is undefined.');
|
||||||
throw new Error('Browser session data is undefined.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2: Use pre-derived key directly with AES-GCM
|
// v2: Use pre-derived key directly with AES-GCM
|
||||||
if (browserSessionData.vaultKey) {
|
if (vaultSession.vaultKey) {
|
||||||
return this.encryptV2(value, browserSessionData.iv, browserSessionData.vaultKey);
|
return this.encryptV2(value, vaultSession.iv, vaultSession.vaultKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1: Use PBKDF2 with password
|
// v1: Use PBKDF2 with password
|
||||||
if (!browserSessionData.vaultPassword) {
|
if (!vaultSession.vaultPassword) {
|
||||||
throw new Error('No vault password or key available.');
|
throw new Error('No vault password or key available.');
|
||||||
}
|
}
|
||||||
return CryptoHelper.encrypt(
|
return CryptoHelper.encrypt(
|
||||||
value,
|
value,
|
||||||
browserSessionData.iv,
|
vaultSession.iv,
|
||||||
browserSessionData.vaultPassword
|
vaultSession.vaultPassword
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,31 +362,30 @@ export class StorageService {
|
|||||||
value: string,
|
value: string,
|
||||||
returnType: 'string' | 'number' | 'boolean'
|
returnType: 'string' | 'number' | 'boolean'
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const browserSessionData =
|
const vaultSession = this.getBrowserSessionHandler().vaultSession;
|
||||||
this.getBrowserSessionHandler().browserSessionData;
|
if (!vaultSession) {
|
||||||
if (!browserSessionData) {
|
throw new Error('Vault session is undefined.');
|
||||||
throw new Error('Browser session data is undefined.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2: Use pre-derived key directly with AES-GCM
|
// v2: Use pre-derived key directly with AES-GCM
|
||||||
if (browserSessionData.vaultKey) {
|
if (vaultSession.vaultKey) {
|
||||||
const decryptedValue = await this.decryptV2(
|
const decryptedValue = await this.decryptV2(
|
||||||
value,
|
value,
|
||||||
browserSessionData.iv,
|
vaultSession.iv,
|
||||||
browserSessionData.vaultKey
|
vaultSession.vaultKey
|
||||||
);
|
);
|
||||||
return this.parseDecryptedValue(decryptedValue, returnType);
|
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1: Use PBKDF2 with password
|
// v1: Use PBKDF2 with password
|
||||||
if (!browserSessionData.vaultPassword) {
|
if (!vaultSession.vaultPassword) {
|
||||||
throw new Error('No vault password or key available.');
|
throw new Error('No vault password or key available.');
|
||||||
}
|
}
|
||||||
return this.decryptWithLockedVault(
|
return this.decryptWithLockedVault(
|
||||||
value,
|
value,
|
||||||
returnType,
|
returnType,
|
||||||
browserSessionData.iv,
|
vaultSession.iv,
|
||||||
browserSessionData.vaultPassword
|
vaultSession.vaultPassword
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,28 +459,28 @@ export class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate the browser sync data to the latest version.
|
* Migrate the encrypted vault to the latest version.
|
||||||
*/
|
*/
|
||||||
#migrateBrowserSyncData(browserSyncData: Partial<Record<string, any>>): {
|
#migrateEncryptedVault(encryptedVault: Partial<Record<string, any>>): {
|
||||||
browserSyncData?: BrowserSyncData;
|
encryptedVault?: EncryptedVault;
|
||||||
migrationWasPerformed: boolean;
|
migrationWasPerformed: boolean;
|
||||||
} {
|
} {
|
||||||
if (Object.keys(browserSyncData).length === 0) {
|
if (Object.keys(encryptedVault).length === 0) {
|
||||||
// First run. There is no browser sync data yet.
|
// First run. There is no encrypted vault yet.
|
||||||
return {
|
return {
|
||||||
browserSyncData: undefined,
|
encryptedVault: undefined,
|
||||||
migrationWasPerformed: false,
|
migrationWasPerformed: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Will be implemented if migration is required.
|
// Will be implemented if migration is required.
|
||||||
return {
|
return {
|
||||||
browserSyncData: browserSyncData as BrowserSyncData,
|
encryptedVault: encryptedVault as EncryptedVault,
|
||||||
migrationWasPerformed: false,
|
migrationWasPerformed: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#allegedBrowserSyncDataIsValid(data: BrowserSyncData): boolean {
|
#allegedEncryptedVaultIsValid(data: EncryptedVault): boolean {
|
||||||
if (typeof data.iv === 'undefined') {
|
if (typeof data.iv === 'undefined') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
|
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
|
||||||
|
|
||||||
export interface Permission_DECRYPTED {
|
// =============================================================================
|
||||||
id: string;
|
// STORAGE DATA TRANSFER OBJECTS (DTOs)
|
||||||
identityId: string;
|
// These types represent data as stored in browser storage
|
||||||
host: string;
|
// =============================================================================
|
||||||
method: ExtensionMethod;
|
|
||||||
methodPolicy: Nip07MethodPolicy;
|
|
||||||
kind?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Permission_ENCRYPTED {
|
/**
|
||||||
|
* Permission as stored in encrypted vault (encrypted string fields)
|
||||||
|
*/
|
||||||
|
export interface StoredPermission {
|
||||||
id: string;
|
id: string;
|
||||||
identityId: string;
|
identityId: string;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -18,24 +17,37 @@ export interface Permission_ENCRYPTED {
|
|||||||
kind?: string;
|
kind?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Identity_DECRYPTED {
|
/**
|
||||||
|
* Permission in session memory (typed fields)
|
||||||
|
*/
|
||||||
|
export interface PermissionData {
|
||||||
|
id: string;
|
||||||
|
identityId: string;
|
||||||
|
host: string;
|
||||||
|
method: ExtensionMethod;
|
||||||
|
methodPolicy: Nip07MethodPolicy;
|
||||||
|
kind?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity as stored in encrypted vault
|
||||||
|
*/
|
||||||
|
export interface StoredIdentity {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
nick: string;
|
nick: string;
|
||||||
privkey: string;
|
privkey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Identity_ENCRYPTED = Identity_DECRYPTED;
|
/**
|
||||||
|
* Identity in session memory (same structure, just semantic clarity)
|
||||||
|
*/
|
||||||
|
export type IdentityData = StoredIdentity;
|
||||||
|
|
||||||
export interface Relay_DECRYPTED {
|
/**
|
||||||
id: string;
|
* Relay as stored in encrypted vault (encrypted boolean fields)
|
||||||
identityId: string;
|
*/
|
||||||
url: string;
|
export interface StoredRelay {
|
||||||
read: boolean;
|
|
||||||
write: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Relay_ENCRYPTED {
|
|
||||||
id: string;
|
id: string;
|
||||||
identityId: string;
|
identityId: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -44,10 +56,21 @@ export interface Relay_ENCRYPTED {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NWC (Nostr Wallet Connect) connection - Decrypted
|
* Relay in session memory (typed boolean fields)
|
||||||
|
*/
|
||||||
|
export interface RelayData {
|
||||||
|
id: string;
|
||||||
|
identityId: string;
|
||||||
|
url: string;
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NWC (Nostr Wallet Connect) connection in session memory
|
||||||
* Stores NIP-47 wallet connection data
|
* Stores NIP-47 wallet connection data
|
||||||
*/
|
*/
|
||||||
export interface NwcConnection_DECRYPTED {
|
export interface NwcConnectionRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string; // User-defined wallet name
|
name: string; // User-defined wallet name
|
||||||
connectionUrl: string; // Full nostr+walletconnect:// URL
|
connectionUrl: string; // Full nostr+walletconnect:// URL
|
||||||
@@ -61,9 +84,9 @@ export interface NwcConnection_DECRYPTED {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NWC connection - Encrypted for storage
|
* NWC connection as stored in encrypted vault
|
||||||
*/
|
*/
|
||||||
export interface NwcConnection_ENCRYPTED {
|
export interface StoredNwcConnection {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
connectionUrl: string;
|
connectionUrl: string;
|
||||||
@@ -89,10 +112,10 @@ export interface CashuProof {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cashu Mint Connection - Decrypted
|
* Cashu Mint Connection in session memory
|
||||||
* Stores NIP-60 Cashu mint connection data with local proofs
|
* Stores NIP-60 Cashu mint connection data with local proofs
|
||||||
*/
|
*/
|
||||||
export interface CashuMint_DECRYPTED {
|
export interface CashuMintRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string; // User-defined mint name
|
name: string; // User-defined mint name
|
||||||
mintUrl: string; // Mint API URL
|
mintUrl: string; // Mint API URL
|
||||||
@@ -104,9 +127,9 @@ export interface CashuMint_DECRYPTED {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cashu Mint Connection - Encrypted for storage
|
* Cashu Mint Connection as stored in encrypted vault
|
||||||
*/
|
*/
|
||||||
export interface CashuMint_ENCRYPTED {
|
export interface StoredCashuMint {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
mintUrl: string;
|
mintUrl: string;
|
||||||
@@ -117,7 +140,15 @@ export interface CashuMint_ENCRYPTED {
|
|||||||
cachedBalanceAt?: string;
|
cachedBalanceAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserSyncData_PART_Unencrypted {
|
// =============================================================================
|
||||||
|
// ENCRYPTED VAULT
|
||||||
|
// The vault is the encrypted container holding all sensitive data
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vault header - unencrypted metadata needed to decrypt the vault
|
||||||
|
*/
|
||||||
|
export interface EncryptedVaultHeader {
|
||||||
version: number;
|
version: number;
|
||||||
iv: string;
|
iv: string;
|
||||||
vaultHash: string;
|
vaultHash: string;
|
||||||
@@ -126,26 +157,42 @@ export interface BrowserSyncData_PART_Unencrypted {
|
|||||||
salt?: string;
|
salt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserSyncData_PART_Encrypted {
|
/**
|
||||||
|
* Vault content - encrypted payload containing all sensitive data
|
||||||
|
*/
|
||||||
|
export interface EncryptedVaultContent {
|
||||||
selectedIdentityId: string | null;
|
selectedIdentityId: string | null;
|
||||||
permissions: Permission_ENCRYPTED[];
|
permissions: StoredPermission[];
|
||||||
identities: Identity_ENCRYPTED[];
|
identities: StoredIdentity[];
|
||||||
relays: Relay_ENCRYPTED[];
|
relays: StoredRelay[];
|
||||||
nwcConnections?: NwcConnection_ENCRYPTED[];
|
nwcConnections?: StoredNwcConnection[];
|
||||||
cashuMints?: CashuMint_ENCRYPTED[];
|
cashuMints?: StoredCashuMint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted &
|
/**
|
||||||
BrowserSyncData_PART_Encrypted;
|
* Complete encrypted vault as stored in browser sync storage
|
||||||
|
*/
|
||||||
|
export type EncryptedVault = EncryptedVaultHeader & EncryptedVaultContent;
|
||||||
|
|
||||||
export enum BrowserSyncFlow {
|
/**
|
||||||
|
* Sync flow preference for vault data
|
||||||
|
*/
|
||||||
|
export enum SyncFlow {
|
||||||
NO_SYNC = 0,
|
NO_SYNC = 0,
|
||||||
BROWSER_SYNC = 1,
|
BROWSER_SYNC = 1,
|
||||||
SIGNER_SYNC = 2,
|
SIGNER_SYNC = 2,
|
||||||
CUSTOM_SYNC = 3,
|
CUSTOM_SYNC = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserSessionData {
|
// =============================================================================
|
||||||
|
// VAULT SESSION
|
||||||
|
// Runtime state when vault is unlocked
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vault session - decrypted vault data in session memory
|
||||||
|
*/
|
||||||
|
export interface VaultSession {
|
||||||
// The following properties purely come from the browser session storage
|
// The following properties purely come from the browser session storage
|
||||||
// and will never be going into the browser sync storage.
|
// and will never be going into the browser sync storage.
|
||||||
vaultPassword?: string; // v1 only: raw password for PBKDF2
|
vaultPassword?: string; // v1 only: raw password for PBKDF2
|
||||||
@@ -155,24 +202,32 @@ export interface BrowserSessionData {
|
|||||||
iv: string;
|
iv: string;
|
||||||
// Version 2+: Random salt for Argon2id (base64)
|
// Version 2+: Random salt for Argon2id (base64)
|
||||||
salt?: string;
|
salt?: string;
|
||||||
permissions: Permission_DECRYPTED[];
|
permissions: PermissionData[];
|
||||||
identities: Identity_DECRYPTED[];
|
identities: IdentityData[];
|
||||||
selectedIdentityId: string | null;
|
selectedIdentityId: string | null;
|
||||||
relays: Relay_DECRYPTED[];
|
relays: RelayData[];
|
||||||
nwcConnections?: NwcConnection_DECRYPTED[];
|
nwcConnections?: NwcConnectionRecord[];
|
||||||
cashuMints?: CashuMint_DECRYPTED[];
|
cashuMints?: CashuMintRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignerMetaData_VaultSnapshot {
|
// =============================================================================
|
||||||
|
// EXTENSION SETTINGS
|
||||||
|
// Non-vault configuration stored separately
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vault snapshot for backup/restore
|
||||||
|
*/
|
||||||
|
export interface VaultSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
createdAt: string; // ISO timestamp
|
createdAt: string; // ISO timestamp
|
||||||
data: BrowserSyncData;
|
data: EncryptedVault;
|
||||||
identityCount: number;
|
identityCount: number;
|
||||||
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
|
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SIGNER_META_DATA_KEY = {
|
export const EXTENSION_SETTINGS_KEYS = {
|
||||||
vaultSnapshots: 'vaultSnapshots',
|
vaultSnapshots: 'vaultSnapshots',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,10 +241,13 @@ export interface Bookmark {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignerMetaData {
|
/**
|
||||||
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync))
|
* Extension settings - non-vault configuration
|
||||||
|
*/
|
||||||
|
export interface ExtensionSettings {
|
||||||
|
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync)
|
||||||
|
|
||||||
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
|
vaultSnapshots?: VaultSnapshot[];
|
||||||
|
|
||||||
// Maximum number of automatic backups to keep (default: 5)
|
// Maximum number of automatic backups to keep (default: 5)
|
||||||
maxBackups?: number;
|
maxBackups?: number;
|
||||||
@@ -229,3 +287,47 @@ export interface ProfileMetadata {
|
|||||||
* Cache for profile metadata, stored in session storage
|
* Cache for profile metadata, stored in session storage
|
||||||
*/
|
*/
|
||||||
export type ProfileMetadataCache = Record<string, ProfileMetadata>;
|
export type ProfileMetadataCache = Record<string, ProfileMetadata>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BACKWARDS COMPATIBILITY ALIASES
|
||||||
|
// These will be removed in a future version
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** @deprecated Use StoredPermission instead */
|
||||||
|
export type Permission_ENCRYPTED = StoredPermission;
|
||||||
|
/** @deprecated Use PermissionData instead */
|
||||||
|
export type Permission_DECRYPTED = PermissionData;
|
||||||
|
/** @deprecated Use StoredIdentity instead */
|
||||||
|
export type Identity_ENCRYPTED = StoredIdentity;
|
||||||
|
/** @deprecated Use IdentityData instead */
|
||||||
|
export type Identity_DECRYPTED = IdentityData;
|
||||||
|
/** @deprecated Use StoredRelay instead */
|
||||||
|
export type Relay_ENCRYPTED = StoredRelay;
|
||||||
|
/** @deprecated Use RelayData instead */
|
||||||
|
export type Relay_DECRYPTED = RelayData;
|
||||||
|
/** @deprecated Use StoredNwcConnection instead */
|
||||||
|
export type NwcConnection_ENCRYPTED = StoredNwcConnection;
|
||||||
|
/** @deprecated Use NwcConnectionRecord instead */
|
||||||
|
export type NwcConnection_DECRYPTED = NwcConnectionRecord;
|
||||||
|
/** @deprecated Use StoredCashuMint instead */
|
||||||
|
export type CashuMint_ENCRYPTED = StoredCashuMint;
|
||||||
|
/** @deprecated Use CashuMintRecord instead */
|
||||||
|
export type CashuMint_DECRYPTED = CashuMintRecord;
|
||||||
|
/** @deprecated Use EncryptedVaultHeader instead */
|
||||||
|
export type BrowserSyncData_PART_Unencrypted = EncryptedVaultHeader;
|
||||||
|
/** @deprecated Use EncryptedVaultContent instead */
|
||||||
|
export type BrowserSyncData_PART_Encrypted = EncryptedVaultContent;
|
||||||
|
/** @deprecated Use EncryptedVault instead */
|
||||||
|
export type BrowserSyncData = EncryptedVault;
|
||||||
|
/** @deprecated Use SyncFlow instead */
|
||||||
|
export const BrowserSyncFlow = SyncFlow;
|
||||||
|
/** @deprecated Use SyncFlow instead */
|
||||||
|
export type BrowserSyncFlow = SyncFlow;
|
||||||
|
/** @deprecated Use VaultSession instead */
|
||||||
|
export type BrowserSessionData = VaultSession;
|
||||||
|
/** @deprecated Use VaultSnapshot instead */
|
||||||
|
export type SignerMetaData_VaultSnapshot = VaultSnapshot;
|
||||||
|
/** @deprecated Use EXTENSION_SETTINGS_KEYS instead */
|
||||||
|
export const SIGNER_META_DATA_KEY = EXTENSION_SETTINGS_KEYS;
|
||||||
|
/** @deprecated Use ExtensionSettings instead */
|
||||||
|
export type SignerMetaData = ExtensionSettings;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.sam-text-muted {
|
.sam-text-muted,
|
||||||
color: var(--muted-foreground);
|
.text-muted {
|
||||||
|
color: var(--muted-foreground) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sam-text-lg {
|
.sam-text-lg {
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
* Public API Surface of common
|
* Public API Surface of common
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Domain (DDD Value Objects & Repository Interfaces)
|
||||||
|
export * from './lib/domain';
|
||||||
|
|
||||||
|
// Infrastructure (Encryption & Repository Implementations)
|
||||||
|
export * from './lib/infrastructure';
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
export * from './lib/common/nav-component';
|
export * from './lib/common/nav-component';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
export * from './lib/constants/fallback-relays';
|
export * from './lib/constants/fallback-relays';
|
||||||
|
export * from './lib/constants/event-kinds';
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
export * from './lib/helpers/crypto-helper';
|
export * from './lib/helpers/crypto-helper';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { SignerMetaData, SignerMetaHandler } from '@common';
|
import { ExtensionSettings, SignerMetaHandler } from '@common';
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
export class FirefoxMetaHandler extends SignerMetaHandler {
|
export class FirefoxMetaHandler extends SignerMetaHandler {
|
||||||
@@ -20,7 +20,7 @@ export class FirefoxMetaHandler extends SignerMetaHandler {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveFullData(data: SignerMetaData): Promise<void> {
|
async saveFullData(data: ExtensionSettings): Promise<void> {
|
||||||
await browser.storage.local.set(data as Record<string, any>);
|
await browser.storage.local.set(data as Record<string, any>);
|
||||||
console.log(data);
|
console.log(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { BrowserSessionData, BrowserSessionHandler } from '@common';
|
import { VaultSession, BrowserSessionHandler } from '@common';
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
export class FirefoxSessionHandler extends BrowserSessionHandler {
|
export class FirefoxSessionHandler extends BrowserSessionHandler {
|
||||||
@@ -7,7 +7,7 @@ export class FirefoxSessionHandler extends BrowserSessionHandler {
|
|||||||
return browser.storage.session.get(null);
|
return browser.storage.session.get(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveFullData(data: BrowserSessionData): Promise<void> {
|
async saveFullData(data: VaultSession): Promise<void> {
|
||||||
await browser.storage.session.set(data as Record<string, any>);
|
await browser.storage.session.set(data as Record<string, any>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import {
|
import {
|
||||||
BrowserSyncData,
|
EncryptedVault,
|
||||||
CashuMint_ENCRYPTED,
|
StoredCashuMint,
|
||||||
Identity_ENCRYPTED,
|
StoredIdentity,
|
||||||
NwcConnection_ENCRYPTED,
|
StoredNwcConnection,
|
||||||
Permission_ENCRYPTED,
|
StoredPermission,
|
||||||
BrowserSyncHandler,
|
BrowserSyncHandler,
|
||||||
Relay_ENCRYPTED,
|
StoredRelay,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
@@ -25,20 +25,20 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||||
await browser.storage.local.set(data as Record<string, any>);
|
await browser.storage.local.set(data as Record<string, any>);
|
||||||
this.setFullData(data);
|
this.setFullData(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Permissions(data: {
|
async saveAndSetPartialData_Permissions(data: {
|
||||||
permissions: Permission_ENCRYPTED[];
|
permissions: StoredPermission[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.local.set(data);
|
await browser.storage.local.set(data);
|
||||||
this.setPartialData_Permissions(data);
|
this.setPartialData_Permissions(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Identities(data: {
|
async saveAndSetPartialData_Identities(data: {
|
||||||
identities: Identity_ENCRYPTED[];
|
identities: StoredIdentity[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.local.set(data);
|
await browser.storage.local.set(data);
|
||||||
this.setPartialData_Identities(data);
|
this.setPartialData_Identities(data);
|
||||||
@@ -52,21 +52,21 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Relays(data: {
|
async saveAndSetPartialData_Relays(data: {
|
||||||
relays: Relay_ENCRYPTED[];
|
relays: StoredRelay[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.local.set(data);
|
await browser.storage.local.set(data);
|
||||||
this.setPartialData_Relays(data);
|
this.setPartialData_Relays(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_NwcConnections(data: {
|
async saveAndSetPartialData_NwcConnections(data: {
|
||||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
nwcConnections: StoredNwcConnection[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.local.set(data);
|
await browser.storage.local.set(data);
|
||||||
this.setPartialData_NwcConnections(data);
|
this.setPartialData_NwcConnections(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_CashuMints(data: {
|
async saveAndSetPartialData_CashuMints(data: {
|
||||||
cashuMints: CashuMint_ENCRYPTED[];
|
cashuMints: StoredCashuMint[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.local.set(data);
|
await browser.storage.local.set(data);
|
||||||
this.setPartialData_CashuMints(data);
|
this.setPartialData_CashuMints(data);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import {
|
import {
|
||||||
BrowserSyncData,
|
EncryptedVault,
|
||||||
CashuMint_ENCRYPTED,
|
StoredCashuMint,
|
||||||
Identity_ENCRYPTED,
|
StoredIdentity,
|
||||||
NwcConnection_ENCRYPTED,
|
StoredNwcConnection,
|
||||||
Permission_ENCRYPTED,
|
StoredPermission,
|
||||||
BrowserSyncHandler,
|
BrowserSyncHandler,
|
||||||
Relay_ENCRYPTED,
|
StoredRelay,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
@@ -19,20 +19,20 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
|
|||||||
return await browser.storage.sync.get(null);
|
return await browser.storage.sync.get(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||||
await browser.storage.sync.set(data as Record<string, any>);
|
await browser.storage.sync.set(data as Record<string, any>);
|
||||||
this.setFullData(data);
|
this.setFullData(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Permissions(data: {
|
async saveAndSetPartialData_Permissions(data: {
|
||||||
permissions: Permission_ENCRYPTED[];
|
permissions: StoredPermission[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.sync.set(data);
|
await browser.storage.sync.set(data);
|
||||||
this.setPartialData_Permissions(data);
|
this.setPartialData_Permissions(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Identities(data: {
|
async saveAndSetPartialData_Identities(data: {
|
||||||
identities: Identity_ENCRYPTED[];
|
identities: StoredIdentity[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.sync.set(data);
|
await browser.storage.sync.set(data);
|
||||||
this.setPartialData_Identities(data);
|
this.setPartialData_Identities(data);
|
||||||
@@ -46,21 +46,21 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_Relays(data: {
|
async saveAndSetPartialData_Relays(data: {
|
||||||
relays: Relay_ENCRYPTED[];
|
relays: StoredRelay[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.sync.set(data);
|
await browser.storage.sync.set(data);
|
||||||
this.setPartialData_Relays(data);
|
this.setPartialData_Relays(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_NwcConnections(data: {
|
async saveAndSetPartialData_NwcConnections(data: {
|
||||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
nwcConnections: StoredNwcConnection[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.sync.set(data);
|
await browser.storage.sync.set(data);
|
||||||
this.setPartialData_NwcConnections(data);
|
this.setPartialData_NwcConnections(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAndSetPartialData_CashuMints(data: {
|
async saveAndSetPartialData_CashuMints(data: {
|
||||||
cashuMints: CashuMint_ENCRYPTED[];
|
cashuMints: StoredCashuMint[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await browser.storage.sync.set(data);
|
await browser.storage.sync.set(data);
|
||||||
this.setPartialData_CashuMints(data);
|
this.setPartialData_CashuMints(data);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
>
|
>
|
||||||
<span class="text-muted">{{ permission.method }}</span>
|
<span class="text-muted">{{ permission.method }}</span>
|
||||||
@if(typeof permission.kind !== 'undefined') {
|
@if(typeof permission.kind !== 'undefined') {
|
||||||
<span>(kind {{ permission.kind }})</span>
|
<span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
|
||||||
}
|
}
|
||||||
<div class="sam-flex-grow"></div>
|
<div class="sam-flex-grow"></div>
|
||||||
<lib-icon-button
|
<lib-icon-button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, OnInit } from '@angular/core';
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { IconButtonComponent, Identity_DECRYPTED, NavComponent, Permission_DECRYPTED, StorageService } from '@common';
|
import { IconButtonComponent, Identity_DECRYPTED, NavComponent, Permission_DECRYPTED, StorageService, getKindName } from '@common';
|
||||||
|
|
||||||
interface HostPermissions {
|
interface HostPermissions {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -80,4 +80,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getKindTooltip(kind: number): string {
|
||||||
|
return getKindName(kind);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user