Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5183a4fc0a | ||
|
|
a2d0a9bd32 | ||
|
|
5cf0fed4ed | ||
|
|
4a2bc4fe72 | ||
| a2e47d8612 | |||
| 2074c409f0 | |||
|
|
c11887dfa8 | ||
|
|
d98a0ef76e |
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.
|
||||
24
LICENSE
Normal file
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <http://unlicense.org/>
|
||||
68
PRIVACY_POLICY.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Plebeian Signer** is a browser extension for managing Nostr identities and signing events. This privacy policy explains how the extension handles your data.
|
||||
|
||||
## Data Collection
|
||||
|
||||
**Plebeian Signer does not collect, store, or transmit any user data to external servers.**
|
||||
|
||||
All data remains on your device under your control.
|
||||
|
||||
## Data Storage
|
||||
|
||||
The extension stores the following data locally in your browser:
|
||||
|
||||
- **Encrypted vault**: Your Nostr private keys, encrypted with your password using Argon2id + AES-256-GCM
|
||||
- **Identity metadata**: Display names, profile information you configure
|
||||
- **Permissions**: Your allow/deny decisions for websites
|
||||
- **Cashu wallet data**: Mint connections and ecash tokens you store
|
||||
- **Preferences**: Extension settings (sync mode, reckless mode, etc.)
|
||||
|
||||
This data is stored using your browser's built-in storage APIs and never leaves your device unless you enable browser sync (in which case it syncs through your browser's own sync service, not ours).
|
||||
|
||||
## External Connections
|
||||
|
||||
The extension only makes external network requests in the following cases:
|
||||
|
||||
1. **Cashu mints**: When you explicitly add a Cashu mint and perform wallet operations (deposit, send, receive), the extension connects to that mint's URL. You choose which mints to connect to.
|
||||
|
||||
2. **No other external connections**: The extension does not connect to any analytics services, tracking pixels, telemetry endpoints, or any servers operated by the developers.
|
||||
|
||||
## Third-Party Services
|
||||
|
||||
Plebeian Signer does not integrate with any third-party services. The only external services involved are:
|
||||
|
||||
- **Cashu mints**: User-configured ecash mints for wallet functionality
|
||||
- **Browser sync** (optional): Your browser's native sync service if you enable vault syncing
|
||||
|
||||
## Data Sharing
|
||||
|
||||
We do not share any data because we do not have access to any data. Your private keys and all extension data remain encrypted on your device.
|
||||
|
||||
## Security
|
||||
|
||||
- Private keys are encrypted at rest using Argon2id key derivation and AES-256-GCM encryption
|
||||
- Keys are never exposed to websites — only signatures are provided
|
||||
- The vault locks automatically and requires your password to unlock
|
||||
|
||||
## Your Rights
|
||||
|
||||
Since all data is stored locally on your device:
|
||||
|
||||
- **Access**: View your data anytime in the extension
|
||||
- **Delete**: Uninstall the extension or clear browser data to remove all stored data
|
||||
- **Export**: Use the extension's export features to backup your data
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
Any changes to this privacy policy will be reflected in the extension's repository and release notes.
|
||||
|
||||
## Contact
|
||||
|
||||
For questions about this privacy policy, please open an issue at the project repository.
|
||||
|
||||
---
|
||||
|
||||
**Last updated**: January 2026
|
||||
|
||||
**Extension**: Plebeian Signer v1.1.5
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v1.1.1",
|
||||
"version": "1.1.6",
|
||||
"custom": {
|
||||
"chrome": {
|
||||
"version": "v1.1.1"
|
||||
"version": "v1.1.6"
|
||||
},
|
||||
"firefox": {
|
||||
"version": "v1.1.1"
|
||||
"version": "v1.1.6"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -74,5 +74,6 @@
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "8.18.0"
|
||||
}
|
||||
},
|
||||
"license": "Unlicense"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.5",
|
||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||
"options_page": "options.html",
|
||||
"permissions": [
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NewIdentityComponent } from './components/new-identity/new-identity.com
|
||||
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
|
||||
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
|
||||
import { KeysComponent as EditIdentityKeysComponent } from './components/edit-identity/keys/keys.component';
|
||||
import { NcryptsecComponent as EditIdentityNcryptsecComponent } from './components/edit-identity/ncryptsec/ncryptsec.component';
|
||||
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
|
||||
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
|
||||
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
||||
@@ -112,6 +113,10 @@ export const routes: Routes = [
|
||||
path: 'keys',
|
||||
component: EditIdentityKeysComponent,
|
||||
},
|
||||
{
|
||||
path: 'ncryptsec',
|
||||
component: EditIdentityNcryptsecComponent,
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
component: EditIdentityPermissionsComponent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { SignerMetaData, SignerMetaHandler } from '@common';
|
||||
import { ExtensionSettings, SignerMetaHandler } from '@common';
|
||||
|
||||
export class ChromeMetaHandler extends SignerMetaHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
@@ -19,7 +19,7 @@ export class ChromeMetaHandler extends SignerMetaHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveFullData(data: SignerMetaData): Promise<void> {
|
||||
async saveFullData(data: ExtensionSettings): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSessionData, BrowserSessionHandler } from '@common';
|
||||
import { VaultSession, BrowserSessionHandler } from '@common';
|
||||
|
||||
export class ChromeSessionHandler extends BrowserSessionHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
return chrome.storage.session.get(null);
|
||||
}
|
||||
|
||||
async saveFullData(data: BrowserSessionData): Promise<void> {
|
||||
async saveFullData(data: VaultSession): Promise<void> {
|
||||
await chrome.storage.session.set(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
EncryptedVault,
|
||||
BrowserSyncHandler,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
@@ -26,20 +26,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -53,21 +53,21 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
@@ -18,20 +18,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
return await chrome.storage.sync.get(null);
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -45,21 +45,21 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
|
||||
@@ -136,6 +136,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="sam-mt-2">Encrypted Key (NIP-49)</span>
|
||||
|
||||
<button class="btn btn-primary sam-mt-h" (click)="navigateToNcryptsec()">
|
||||
Get ncryptsec
|
||||
</button>
|
||||
}
|
||||
|
||||
<lib-toast #toast [bottom]="16"></lib-toast>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
NavComponent,
|
||||
@@ -29,6 +29,7 @@ export class KeysComponent extends NavComponent implements OnInit {
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
@@ -51,6 +52,11 @@ export class KeysComponent extends NavComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
navigateToNcryptsec() {
|
||||
if (!this.identity) return;
|
||||
this.#router.navigateByUrl(`/edit-identity/${this.identity.id}/ncryptsec`);
|
||||
}
|
||||
|
||||
async #initialize(identityId: string) {
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<div class="header-pane">
|
||||
<lib-icon-button
|
||||
icon="chevron-left"
|
||||
(click)="navigateBack()"
|
||||
></lib-icon-button>
|
||||
<span>Get ncryptsec</span>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (shown after generation) -->
|
||||
@if (ncryptsec) {
|
||||
<div class="qr-container">
|
||||
<button
|
||||
type="button"
|
||||
class="qr-button"
|
||||
title="Copy to clipboard"
|
||||
(click)="copyToClipboard(ncryptsec); toast.show('Copied to clipboard')"
|
||||
>
|
||||
<img [src]="ncryptsecQr" alt="ncryptsec QR code" class="qr-code" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PASSWORD INPUT -->
|
||||
<div class="password-section">
|
||||
<label for="ncryptsecPasswordInput">Password</label>
|
||||
<div class="input-group sam-mt-h">
|
||||
<input
|
||||
#passwordInput
|
||||
id="ncryptsecPasswordInput"
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="Enter encryption password"
|
||||
[(ngModel)]="ncryptsecPassword"
|
||||
[disabled]="isGenerating"
|
||||
(keyup.enter)="generateNcryptsec()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary generate-btn"
|
||||
type="button"
|
||||
(click)="generateNcryptsec()"
|
||||
[disabled]="!ncryptsecPassword || isGenerating"
|
||||
>
|
||||
@if (isGenerating) {
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
Generating...
|
||||
} @else {
|
||||
Generate ncryptsec
|
||||
}
|
||||
</button>
|
||||
|
||||
|
||||
<p class="description">
|
||||
Enter a password to encrypt your private key. The resulting ncryptsec can be
|
||||
used to securely backup or transfer your key.
|
||||
</p>
|
||||
|
||||
<lib-toast #toast [bottom]="16"></lib-toast>
|
||||
@@ -0,0 +1,70 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
.header-pane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: var(--size-h);
|
||||
align-items: center;
|
||||
padding-bottom: var(--size);
|
||||
background-color: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.password-section {
|
||||
margin-bottom: var(--size);
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--size-q);
|
||||
}
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.qr-button {
|
||||
background: white;
|
||||
padding: var(--size);
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
} from '@common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ncryptsec',
|
||||
imports: [IconButtonComponent, FormsModule, ToastComponent],
|
||||
templateUrl: './ncryptsec.component.html',
|
||||
styleUrl: './ncryptsec.component.scss',
|
||||
})
|
||||
export class NcryptsecComponent
|
||||
extends NavComponent
|
||||
implements OnInit, AfterViewInit
|
||||
{
|
||||
@ViewChild('passwordInput') passwordInput!: ElementRef<HTMLInputElement>;
|
||||
|
||||
privkeyHex = '';
|
||||
ncryptsecPassword = '';
|
||||
ncryptsec = '';
|
||||
ncryptsecQr = '';
|
||||
isGenerating = false;
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
if (!identityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initialize(identityId);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.passwordInput.nativeElement.focus();
|
||||
}
|
||||
|
||||
async generateNcryptsec() {
|
||||
if (!this.privkeyHex || !this.ncryptsecPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGenerating = true;
|
||||
this.ncryptsec = '';
|
||||
this.ncryptsecQr = '';
|
||||
|
||||
try {
|
||||
this.ncryptsec = await NostrHelper.privkeyToNcryptsec(
|
||||
this.privkeyHex,
|
||||
this.ncryptsecPassword
|
||||
);
|
||||
|
||||
// Generate QR code
|
||||
this.ncryptsecQr = await QRCode.toDataURL(this.ncryptsec, {
|
||||
width: 250,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate ncryptsec:', error);
|
||||
} finally {
|
||||
this.isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
#initialize(identityId: string) {
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === identityId);
|
||||
|
||||
if (!identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.privkeyHex = identity.privkey;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
>
|
||||
<span class="text-muted">{{ permission.method }}</span>
|
||||
@if(typeof permission.kind !== 'undefined') {
|
||||
<span>(kind {{ permission.kind }})</span>
|
||||
<span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
|
||||
}
|
||||
<div class="sam-flex-grow"></div>
|
||||
<lib-icon-button
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NavComponent,
|
||||
Permission_DECRYPTED,
|
||||
StorageService,
|
||||
getKindName,
|
||||
} from '@common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@@ -86,4 +87,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getKindTooltip(kind: number): string {
|
||||
return getKindName(kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +40,13 @@ export interface UnlockResponseMessage {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const debug = function (message: any) {
|
||||
const dateString = new Date().toISOString();
|
||||
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
||||
};
|
||||
// Debug logging disabled - uncomment for development
|
||||
// export const debug = function (message: any) {
|
||||
// const dateString = new Date().toISOString();
|
||||
// console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
||||
// };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
|
||||
export const debug = function (_message: any) {};
|
||||
|
||||
export type PromptResponse =
|
||||
| 'reject'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
backgroundLogNip07Action,
|
||||
backgroundLogPermissionStored,
|
||||
NostrHelper,
|
||||
NwcClient,
|
||||
NwcConnection_DECRYPTED,
|
||||
@@ -441,7 +439,6 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
policy,
|
||||
req.params?.kind
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, req.method, policy, req.params?.kind);
|
||||
} else if (response === 'approve-all') {
|
||||
// P2: Store permission for ALL kinds/uses of this method from this host
|
||||
await storePermission(
|
||||
@@ -452,8 +449,6 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
'allow',
|
||||
undefined // undefined kind = allow all kinds for signEvent
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, req.method, 'allow', undefined);
|
||||
debug(`Stored approve-all permission for ${req.method} from ${req.host}`);
|
||||
} else if (response === 'reject-all') {
|
||||
// P2: Store deny permission for ALL uses of this method from this host
|
||||
await storePermission(
|
||||
@@ -464,15 +459,9 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
'deny',
|
||||
undefined
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, req.method, 'deny', undefined);
|
||||
debug(`Stored reject-all permission for ${req.method} from ${req.host}`);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||
await backgroundLogNip07Action(req.method, req.host, false, false, {
|
||||
kind: req.params?.kind,
|
||||
peerPubkey: req.params?.peerPubkey,
|
||||
});
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
} else {
|
||||
@@ -481,71 +470,47 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
}
|
||||
|
||||
const relays: Relays = {};
|
||||
let result: any;
|
||||
|
||||
switch (req.method) {
|
||||
case 'getPublicKey':
|
||||
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
|
||||
return result;
|
||||
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
|
||||
case 'signEvent':
|
||||
result = signEvent(req.params, currentIdentity.privkey);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
kind: req.params?.kind,
|
||||
});
|
||||
return result;
|
||||
return signEvent(req.params, currentIdentity.privkey);
|
||||
|
||||
case 'getRelays':
|
||||
browserSessionData.relays.forEach((x) => {
|
||||
relays[x.url] = { read: x.read, write: x.write };
|
||||
});
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
|
||||
return relays;
|
||||
|
||||
case 'nip04.encrypt':
|
||||
result = await nip04Encrypt(
|
||||
return await nip04Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip44.encrypt':
|
||||
result = await nip44Encrypt(
|
||||
return await nip44Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip04.decrypt':
|
||||
result = await nip04Decrypt(
|
||||
return await nip04Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip44.decrypt':
|
||||
result = await nip44Decrypt(
|
||||
return await nip44Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
default:
|
||||
throw new Error(`Not supported request method '${req.method}'.`);
|
||||
@@ -625,7 +590,6 @@ async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any>
|
||||
method,
|
||||
policy
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, method, policy);
|
||||
} else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||
// P2: Store permission for all uses of this WebLN method
|
||||
await storePermission(
|
||||
@@ -635,8 +599,6 @@ async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any>
|
||||
method,
|
||||
'allow'
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, method, 'allow');
|
||||
debug(`Stored approve-all permission for ${method} from ${req.host}`);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('IconButtonComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(IconButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.icon = 'settings'; // Required input
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('PubkeyComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(PubkeyComponent);
|
||||
component = fixture.componentInstance;
|
||||
// Valid test pubkey (64 hex chars)
|
||||
component.value = 'a'.repeat(64);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||
200
projects/common/src/lib/domain/entities/identity.spec.ts
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { bech32 } from '@scure/base';
|
||||
import * as utils from '@noble/curves/abstract/utils';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
import { encrypt as nip49Encrypt } from 'nostr-tools/nip49';
|
||||
|
||||
export interface NostrHexObject {
|
||||
represents: string;
|
||||
@@ -125,4 +126,21 @@ export class NostrHelper {
|
||||
hex: utils.bytesToHex(data),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a private key (hex) with a password using NIP-49.
|
||||
* Returns an ncryptsec bech32 string.
|
||||
* @param privkeyHex - The private key in hex format
|
||||
* @param password - The password to encrypt with
|
||||
* @param logN - Optional log2(N) parameter for scrypt (default: 16)
|
||||
* @returns Promise<string> - The ncryptsec bech32 encoded encrypted key
|
||||
*/
|
||||
static async privkeyToNcryptsec(
|
||||
privkeyHex: string,
|
||||
password: string,
|
||||
logN = 16
|
||||
): Promise<string> {
|
||||
const privkeyBytes = utils.hexToBytes(privkeyHex);
|
||||
return nip49Encrypt(privkeyBytes, password, logN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
BrowserIdentityRepository,
|
||||
createIdentityRepository,
|
||||
} from './identity-repository.impl';
|
||||
export type { IdentityStorageAdapter } from './identity-repository.impl';
|
||||
|
||||
export {
|
||||
BrowserPermissionRepository,
|
||||
createPermissionRepository,
|
||||
} from './permission-repository.impl';
|
||||
export type { PermissionStorageAdapter } from './permission-repository.impl';
|
||||
|
||||
export {
|
||||
BrowserRelayRepository,
|
||||
createRelayRepository,
|
||||
} from './relay-repository.impl';
|
||||
export type { RelayStorageAdapter } from './relay-repository.impl';
|
||||
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
PermissionRepository,
|
||||
PermissionSnapshot,
|
||||
PermissionQuery,
|
||||
ExtensionMethod,
|
||||
} from '../../domain/repositories/permission-repository';
|
||||
import { IdentityId, PermissionId } from '../../domain/value-objects';
|
||||
import { EncryptionService } from '../encryption';
|
||||
|
||||
/**
|
||||
* Encrypted permission as stored in browser sync storage.
|
||||
*/
|
||||
interface EncryptedPermission {
|
||||
id: string;
|
||||
identityId: string;
|
||||
host: string;
|
||||
method: string;
|
||||
methodPolicy: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage adapter interface for permissions.
|
||||
*/
|
||||
export interface PermissionStorageAdapter {
|
||||
// Session (in-memory, decrypted) operations
|
||||
getSessionPermissions(): PermissionSnapshot[];
|
||||
setSessionPermissions(permissions: PermissionSnapshot[]): void;
|
||||
saveSessionData(): Promise<void>;
|
||||
|
||||
// Sync (persistent, encrypted) operations
|
||||
getSyncPermissions(): EncryptedPermission[];
|
||||
saveSyncPermissions(permissions: EncryptedPermission[]): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of PermissionRepository using browser storage.
|
||||
*/
|
||||
export class BrowserPermissionRepository implements PermissionRepository {
|
||||
constructor(
|
||||
private readonly storage: PermissionStorageAdapter,
|
||||
private readonly encryption: EncryptionService
|
||||
) {}
|
||||
|
||||
async findById(id: PermissionId): Promise<PermissionSnapshot | undefined> {
|
||||
const permissions = this.storage.getSessionPermissions();
|
||||
return permissions.find((p) => p.id === id.value);
|
||||
}
|
||||
|
||||
async find(query: PermissionQuery): Promise<PermissionSnapshot[]> {
|
||||
let permissions = this.storage.getSessionPermissions();
|
||||
|
||||
if (query.identityId) {
|
||||
const identityIdValue = query.identityId.value;
|
||||
permissions = permissions.filter((p) => p.identityId === identityIdValue);
|
||||
}
|
||||
if (query.host) {
|
||||
const host = query.host;
|
||||
permissions = permissions.filter((p) => p.host === host);
|
||||
}
|
||||
if (query.method) {
|
||||
const method = query.method;
|
||||
permissions = permissions.filter((p) => p.method === method);
|
||||
}
|
||||
if (query.kind !== undefined) {
|
||||
const kind = query.kind;
|
||||
permissions = permissions.filter((p) => p.kind === kind);
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
async findExact(
|
||||
identityId: IdentityId,
|
||||
host: string,
|
||||
method: ExtensionMethod,
|
||||
kind?: number
|
||||
): Promise<PermissionSnapshot | undefined> {
|
||||
const permissions = this.storage.getSessionPermissions();
|
||||
return permissions.find(
|
||||
(p) =>
|
||||
p.identityId === identityId.value &&
|
||||
p.host === host &&
|
||||
p.method === method &&
|
||||
(kind === undefined ? p.kind === undefined : p.kind === kind)
|
||||
);
|
||||
}
|
||||
|
||||
async findByIdentity(identityId: IdentityId): Promise<PermissionSnapshot[]> {
|
||||
const permissions = this.storage.getSessionPermissions();
|
||||
return permissions.filter((p) => p.identityId === identityId.value);
|
||||
}
|
||||
|
||||
async findAll(): Promise<PermissionSnapshot[]> {
|
||||
return this.storage.getSessionPermissions();
|
||||
}
|
||||
|
||||
async save(permission: PermissionSnapshot): Promise<void> {
|
||||
const sessionPermissions = this.storage.getSessionPermissions();
|
||||
const existingIndex = sessionPermissions.findIndex((p) => p.id === permission.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
sessionPermissions[existingIndex] = permission;
|
||||
} else {
|
||||
sessionPermissions.push(permission);
|
||||
}
|
||||
|
||||
this.storage.setSessionPermissions(sessionPermissions);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Encrypt and save to sync storage
|
||||
const encryptedPermission = await this.encryptPermission(permission);
|
||||
const syncPermissions = this.storage.getSyncPermissions();
|
||||
|
||||
// Find by decrypting IDs (expensive but necessary for updates)
|
||||
let syncIndex = -1;
|
||||
for (let i = 0; i < syncPermissions.length; i++) {
|
||||
try {
|
||||
const decryptedId = await this.encryption.decryptString(syncPermissions[i].id);
|
||||
if (decryptedId === permission.id) {
|
||||
syncIndex = i;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip corrupted entries
|
||||
}
|
||||
}
|
||||
|
||||
if (syncIndex >= 0) {
|
||||
syncPermissions[syncIndex] = encryptedPermission;
|
||||
} else {
|
||||
syncPermissions.push(encryptedPermission);
|
||||
}
|
||||
|
||||
await this.storage.saveSyncPermissions(syncPermissions);
|
||||
}
|
||||
|
||||
async delete(id: PermissionId): Promise<boolean> {
|
||||
const sessionPermissions = this.storage.getSessionPermissions();
|
||||
const initialLength = sessionPermissions.length;
|
||||
const filtered = sessionPermissions.filter((p) => p.id !== id.value);
|
||||
|
||||
if (filtered.length === initialLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.storage.setSessionPermissions(filtered);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Remove from sync storage
|
||||
const encryptedId = await this.encryption.encryptString(id.value);
|
||||
const syncPermissions = this.storage.getSyncPermissions();
|
||||
const filteredSync = syncPermissions.filter((p) => p.id !== encryptedId);
|
||||
await this.storage.saveSyncPermissions(filteredSync);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByIdentity(identityId: IdentityId): Promise<number> {
|
||||
const sessionPermissions = this.storage.getSessionPermissions();
|
||||
const initialLength = sessionPermissions.length;
|
||||
const filtered = sessionPermissions.filter((p) => p.identityId !== identityId.value);
|
||||
const deletedCount = initialLength - filtered.length;
|
||||
|
||||
if (deletedCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.storage.setSessionPermissions(filtered);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Remove from sync storage
|
||||
const encryptedIdentityId = await this.encryption.encryptString(identityId.value);
|
||||
const syncPermissions = this.storage.getSyncPermissions();
|
||||
const filteredSync = syncPermissions.filter((p) => p.identityId !== encryptedIdentityId);
|
||||
await this.storage.saveSyncPermissions(filteredSync);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
async count(query?: PermissionQuery): Promise<number> {
|
||||
if (query) {
|
||||
const results = await this.find(query);
|
||||
return results.length;
|
||||
}
|
||||
return this.storage.getSessionPermissions().length;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async encryptPermission(permission: PermissionSnapshot): Promise<EncryptedPermission> {
|
||||
const encrypted: EncryptedPermission = {
|
||||
id: await this.encryption.encryptString(permission.id),
|
||||
identityId: await this.encryption.encryptString(permission.identityId),
|
||||
host: await this.encryption.encryptString(permission.host),
|
||||
method: await this.encryption.encryptString(permission.method),
|
||||
methodPolicy: await this.encryption.encryptString(permission.methodPolicy),
|
||||
};
|
||||
|
||||
if (permission.kind !== undefined) {
|
||||
encrypted.kind = await this.encryption.encryptNumber(permission.kind);
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a BrowserPermissionRepository.
|
||||
*/
|
||||
export function createPermissionRepository(
|
||||
storage: PermissionStorageAdapter,
|
||||
encryption: EncryptionService
|
||||
): PermissionRepository {
|
||||
return new BrowserPermissionRepository(storage, encryption);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
RelayRepositoryError,
|
||||
RelayErrorCode,
|
||||
} from '../../domain/repositories/relay-repository';
|
||||
import type {
|
||||
RelayRepository,
|
||||
RelaySnapshot,
|
||||
RelayQuery,
|
||||
} from '../../domain/repositories/relay-repository';
|
||||
import { IdentityId, RelayId } from '../../domain/value-objects';
|
||||
import { EncryptionService } from '../encryption';
|
||||
|
||||
/**
|
||||
* Encrypted relay as stored in browser sync storage.
|
||||
*/
|
||||
interface EncryptedRelay {
|
||||
id: string;
|
||||
identityId: string;
|
||||
url: string;
|
||||
read: string;
|
||||
write: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage adapter interface for relays.
|
||||
*/
|
||||
export interface RelayStorageAdapter {
|
||||
// Session (in-memory, decrypted) operations
|
||||
getSessionRelays(): RelaySnapshot[];
|
||||
setSessionRelays(relays: RelaySnapshot[]): void;
|
||||
saveSessionData(): Promise<void>;
|
||||
|
||||
// Sync (persistent, encrypted) operations
|
||||
getSyncRelays(): EncryptedRelay[];
|
||||
saveSyncRelays(relays: EncryptedRelay[]): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of RelayRepository using browser storage.
|
||||
*/
|
||||
export class BrowserRelayRepository implements RelayRepository {
|
||||
constructor(
|
||||
private readonly storage: RelayStorageAdapter,
|
||||
private readonly encryption: EncryptionService
|
||||
) {}
|
||||
|
||||
async findById(id: RelayId): Promise<RelaySnapshot | undefined> {
|
||||
const relays = this.storage.getSessionRelays();
|
||||
return relays.find((r) => r.id === id.value);
|
||||
}
|
||||
|
||||
async find(query: RelayQuery): Promise<RelaySnapshot[]> {
|
||||
let relays = this.storage.getSessionRelays();
|
||||
|
||||
if (query.identityId) {
|
||||
const identityIdValue = query.identityId.value;
|
||||
relays = relays.filter((r) => r.identityId === identityIdValue);
|
||||
}
|
||||
if (query.url) {
|
||||
const urlLower = query.url.toLowerCase();
|
||||
relays = relays.filter((r) => r.url.toLowerCase() === urlLower);
|
||||
}
|
||||
if (query.read !== undefined) {
|
||||
const read = query.read;
|
||||
relays = relays.filter((r) => r.read === read);
|
||||
}
|
||||
if (query.write !== undefined) {
|
||||
const write = query.write;
|
||||
relays = relays.filter((r) => r.write === write);
|
||||
}
|
||||
|
||||
return relays;
|
||||
}
|
||||
|
||||
async findByUrl(identityId: IdentityId, url: string): Promise<RelaySnapshot | undefined> {
|
||||
const relays = this.storage.getSessionRelays();
|
||||
return relays.find(
|
||||
(r) =>
|
||||
r.identityId === identityId.value &&
|
||||
r.url.toLowerCase() === url.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
async findByIdentity(identityId: IdentityId): Promise<RelaySnapshot[]> {
|
||||
const relays = this.storage.getSessionRelays();
|
||||
return relays.filter((r) => r.identityId === identityId.value);
|
||||
}
|
||||
|
||||
async findAll(): Promise<RelaySnapshot[]> {
|
||||
return this.storage.getSessionRelays();
|
||||
}
|
||||
|
||||
async save(relay: RelaySnapshot): Promise<void> {
|
||||
// Check for duplicate URL for the same identity (excluding self)
|
||||
const existing = await this.findByUrl(
|
||||
IdentityId.from(relay.identityId),
|
||||
relay.url
|
||||
);
|
||||
if (existing && existing.id !== relay.id) {
|
||||
throw new RelayRepositoryError(
|
||||
'A relay with the same URL already exists for this identity',
|
||||
RelayErrorCode.DUPLICATE_URL
|
||||
);
|
||||
}
|
||||
|
||||
const sessionRelays = this.storage.getSessionRelays();
|
||||
const existingIndex = sessionRelays.findIndex((r) => r.id === relay.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
sessionRelays[existingIndex] = relay;
|
||||
} else {
|
||||
sessionRelays.push(relay);
|
||||
}
|
||||
|
||||
this.storage.setSessionRelays(sessionRelays);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Encrypt and save to sync storage
|
||||
const encryptedRelay = await this.encryptRelay(relay);
|
||||
const syncRelays = this.storage.getSyncRelays();
|
||||
|
||||
// Find by decrypting IDs
|
||||
let syncIndex = -1;
|
||||
for (let i = 0; i < syncRelays.length; i++) {
|
||||
try {
|
||||
const decryptedId = await this.encryption.decryptString(syncRelays[i].id);
|
||||
if (decryptedId === relay.id) {
|
||||
syncIndex = i;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip corrupted entries
|
||||
}
|
||||
}
|
||||
|
||||
if (syncIndex >= 0) {
|
||||
syncRelays[syncIndex] = encryptedRelay;
|
||||
} else {
|
||||
syncRelays.push(encryptedRelay);
|
||||
}
|
||||
|
||||
await this.storage.saveSyncRelays(syncRelays);
|
||||
}
|
||||
|
||||
async delete(id: RelayId): Promise<boolean> {
|
||||
const sessionRelays = this.storage.getSessionRelays();
|
||||
const initialLength = sessionRelays.length;
|
||||
const filtered = sessionRelays.filter((r) => r.id !== id.value);
|
||||
|
||||
if (filtered.length === initialLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.storage.setSessionRelays(filtered);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Remove from sync storage
|
||||
const encryptedId = await this.encryption.encryptString(id.value);
|
||||
const syncRelays = this.storage.getSyncRelays();
|
||||
const filteredSync = syncRelays.filter((r) => r.id !== encryptedId);
|
||||
await this.storage.saveSyncRelays(filteredSync);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByIdentity(identityId: IdentityId): Promise<number> {
|
||||
const sessionRelays = this.storage.getSessionRelays();
|
||||
const initialLength = sessionRelays.length;
|
||||
const filtered = sessionRelays.filter((r) => r.identityId !== identityId.value);
|
||||
const deletedCount = initialLength - filtered.length;
|
||||
|
||||
if (deletedCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.storage.setSessionRelays(filtered);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Remove from sync storage
|
||||
const encryptedIdentityId = await this.encryption.encryptString(identityId.value);
|
||||
const syncRelays = this.storage.getSyncRelays();
|
||||
const filteredSync = syncRelays.filter((r) => r.identityId !== encryptedIdentityId);
|
||||
await this.storage.saveSyncRelays(filteredSync);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
async count(query?: RelayQuery): Promise<number> {
|
||||
if (query) {
|
||||
const results = await this.find(query);
|
||||
return results.length;
|
||||
}
|
||||
return this.storage.getSessionRelays().length;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async encryptRelay(relay: RelaySnapshot): Promise<EncryptedRelay> {
|
||||
return {
|
||||
id: await this.encryption.encryptString(relay.id),
|
||||
identityId: await this.encryption.encryptString(relay.identityId),
|
||||
url: await this.encryption.encryptString(relay.url),
|
||||
read: await this.encryption.encryptBoolean(relay.read),
|
||||
write: await this.encryption.encryptBoolean(relay.write),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a BrowserRelayRepository.
|
||||
*/
|
||||
export function createRelayRepository(
|
||||
storage: RelayStorageAdapter,
|
||||
encryption: EncryptionService
|
||||
): RelayRepository {
|
||||
return new BrowserRelayRepository(storage, encryption);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSessionData } from './types';
|
||||
import { VaultSession } from './types';
|
||||
|
||||
export abstract class BrowserSessionHandler {
|
||||
get browserSessionData(): BrowserSessionData | undefined {
|
||||
return this.#browserSessionData;
|
||||
get vaultSession(): VaultSession | undefined {
|
||||
return this.#vaultSession;
|
||||
}
|
||||
|
||||
#browserSessionData?: BrowserSessionData;
|
||||
/** @deprecated Use vaultSession instead */
|
||||
get browserSessionData(): VaultSession | undefined {
|
||||
return this.#vaultSession;
|
||||
}
|
||||
|
||||
#vaultSession?: VaultSession;
|
||||
|
||||
/**
|
||||
* Load the data from the browser session storage. It should be an empty object,
|
||||
@@ -16,12 +21,12 @@ export abstract class BrowserSessionHandler {
|
||||
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
|
||||
*/
|
||||
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
|
||||
setFullData(data: BrowserSessionData) {
|
||||
this.#browserSessionData = JSON.parse(JSON.stringify(data));
|
||||
setFullData(data: VaultSession) {
|
||||
this.#vaultSession = JSON.parse(JSON.stringify(data));
|
||||
}
|
||||
|
||||
clearInMemoryData() {
|
||||
this.#browserSessionData = undefined;
|
||||
this.#vaultSession = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +34,7 @@ export abstract class BrowserSessionHandler {
|
||||
*
|
||||
* ATTENTION: Make sure to call "setFullData(..)" afterwards of before to update the in-memory data.
|
||||
*/
|
||||
abstract saveFullData(data: BrowserSessionData): Promise<void>;
|
||||
abstract saveFullData(data: VaultSession): Promise<void>;
|
||||
|
||||
abstract clearData(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
StoredRelay,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -14,15 +14,20 @@ import {
|
||||
* some unencrypted properties (like, version and the vault hash).
|
||||
*/
|
||||
export abstract class BrowserSyncHandler {
|
||||
get browserSyncData(): BrowserSyncData | undefined {
|
||||
return this.#browserSyncData;
|
||||
get encryptedVault(): EncryptedVault | undefined {
|
||||
return this.#encryptedVault;
|
||||
}
|
||||
|
||||
/** @deprecated Use encryptedVault instead */
|
||||
get browserSyncData(): EncryptedVault | undefined {
|
||||
return this.#encryptedVault;
|
||||
}
|
||||
|
||||
get ignoreProperties(): string[] {
|
||||
return this.#ignoreProperties;
|
||||
}
|
||||
|
||||
#browserSyncData?: BrowserSyncData;
|
||||
#encryptedVault?: EncryptedVault;
|
||||
#ignoreProperties: string[] = [];
|
||||
|
||||
setIgnoreProperties(properties: string[]) {
|
||||
@@ -41,10 +46,10 @@ export abstract class BrowserSyncHandler {
|
||||
*
|
||||
* ATTENTION: In your implementation, make sure to call "setFullData(..)" at the end to update the in-memory data.
|
||||
*/
|
||||
abstract saveAndSetFullData(data: BrowserSyncData): Promise<void>;
|
||||
abstract saveAndSetFullData(data: EncryptedVault): Promise<void>;
|
||||
|
||||
setFullData(data: BrowserSyncData) {
|
||||
this.#browserSyncData = JSON.parse(JSON.stringify(data));
|
||||
setFullData(data: EncryptedVault) {
|
||||
this.#encryptedVault = JSON.parse(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,13 +58,13 @@ export abstract class BrowserSyncHandler {
|
||||
* ATTENTION: In your implementation, make sure to call "setPartialData_Permissions(..)" at the end to update the in-memory data.
|
||||
*/
|
||||
abstract saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void>;
|
||||
setPartialData_Permissions(data: { permissions: Permission_ENCRYPTED[] }) {
|
||||
if (!this.#browserSyncData) {
|
||||
setPartialData_Permissions(data: { permissions: StoredPermission[] }) {
|
||||
if (!this.#encryptedVault) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.permissions = Array.from(data.permissions);
|
||||
this.#encryptedVault.permissions = Array.from(data.permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,14 +73,14 @@ export abstract class BrowserSyncHandler {
|
||||
* ATTENTION: In your implementation, make sure to call "setPartialData_Identities(..)" at the end to update the in-memory data.
|
||||
*/
|
||||
abstract saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void>;
|
||||
|
||||
setPartialData_Identities(data: { identities: Identity_ENCRYPTED[] }) {
|
||||
if (!this.#browserSyncData) {
|
||||
setPartialData_Identities(data: { identities: StoredIdentity[] }) {
|
||||
if (!this.#encryptedVault) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.identities = Array.from(data.identities);
|
||||
this.#encryptedVault.identities = Array.from(data.identities);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,20 +95,20 @@ export abstract class BrowserSyncHandler {
|
||||
setPartialData_SelectedIdentityId(data: {
|
||||
selectedIdentityId: string | null;
|
||||
}) {
|
||||
if (!this.#browserSyncData) {
|
||||
if (!this.#encryptedVault) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.selectedIdentityId = data.selectedIdentityId;
|
||||
this.#encryptedVault.selectedIdentityId = data.selectedIdentityId;
|
||||
}
|
||||
|
||||
abstract saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void>;
|
||||
setPartialData_Relays(data: { relays: Relay_ENCRYPTED[] }) {
|
||||
if (!this.#browserSyncData) {
|
||||
setPartialData_Relays(data: { relays: StoredRelay[] }) {
|
||||
if (!this.#encryptedVault) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.relays = Array.from(data.relays);
|
||||
this.#encryptedVault.relays = Array.from(data.relays);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,15 +117,15 @@ export abstract class BrowserSyncHandler {
|
||||
* ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data.
|
||||
*/
|
||||
abstract saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void>;
|
||||
setPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}) {
|
||||
if (!this.#browserSyncData) {
|
||||
if (!this.#encryptedVault) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.nwcConnections = Array.from(data.nwcConnections);
|
||||
this.#encryptedVault.nwcConnections = Array.from(data.nwcConnections);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,13 +134,13 @@ export abstract class BrowserSyncHandler {
|
||||
* ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data.
|
||||
*/
|
||||
abstract saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void>;
|
||||
setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) {
|
||||
if (!this.#browserSyncData) {
|
||||
setPartialData_CashuMints(data: { cashuMints: StoredCashuMint[] }) {
|
||||
if (!this.#encryptedVault) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.cashuMints = Array.from(data.cashuMints);
|
||||
this.#encryptedVault.cashuMints = Array.from(data.cashuMints);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Bookmark, BrowserSyncData, BrowserSyncFlow, SignerMetaData, SignerMetaData_VaultSnapshot } from './types';
|
||||
import { Bookmark, EncryptedVault, SyncFlow, ExtensionSettings, VaultSnapshot } from './types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Handler for extension settings stored outside the encrypted vault.
|
||||
* This includes sync preferences, backups, reckless mode, whitelisted hosts, etc.
|
||||
*/
|
||||
export abstract class SignerMetaHandler {
|
||||
get signerMetaData(): SignerMetaData | undefined {
|
||||
return this.#signerMetaData;
|
||||
get extensionSettings(): ExtensionSettings | undefined {
|
||||
return this.#extensionSettings;
|
||||
}
|
||||
|
||||
#signerMetaData?: SignerMetaData;
|
||||
/** @deprecated Use extensionSettings instead */
|
||||
get signerMetaData(): ExtensionSettings | undefined {
|
||||
return this.#extensionSettings;
|
||||
}
|
||||
|
||||
#extensionSettings?: ExtensionSettings;
|
||||
|
||||
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode'];
|
||||
readonly DEFAULT_MAX_BACKUPS = 5;
|
||||
@@ -20,25 +29,30 @@ export abstract class SignerMetaHandler {
|
||||
*/
|
||||
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
|
||||
|
||||
setFullData(data: SignerMetaData) {
|
||||
this.#signerMetaData = data;
|
||||
setFullData(data: ExtensionSettings) {
|
||||
this.#extensionSettings = data;
|
||||
}
|
||||
|
||||
abstract saveFullData(data: SignerMetaData): Promise<void>;
|
||||
abstract saveFullData(data: ExtensionSettings): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the browser sync flow for the user and immediately saves it.
|
||||
* Sets the sync flow preference for the user and immediately saves it.
|
||||
*/
|
||||
async setBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
async setSyncFlow(flow: SyncFlow): Promise<void> {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
syncFlow: flow,
|
||||
};
|
||||
} else {
|
||||
this.#signerMetaData.syncFlow = flow;
|
||||
this.#extensionSettings.syncFlow = flow;
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
}
|
||||
|
||||
/** @deprecated Use setSyncFlow instead */
|
||||
async setBrowserSyncFlow(flow: SyncFlow): Promise<void> {
|
||||
return this.setSyncFlow(flow);
|
||||
}
|
||||
|
||||
abstract clearData(keep: string[]): Promise<void>;
|
||||
@@ -47,93 +61,93 @@ export abstract class SignerMetaHandler {
|
||||
* Sets the reckless mode and immediately saves it.
|
||||
*/
|
||||
async setRecklessMode(enabled: boolean): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
recklessMode: enabled,
|
||||
};
|
||||
} else {
|
||||
this.#signerMetaData.recklessMode = enabled;
|
||||
this.#extensionSettings.recklessMode = enabled;
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets dev mode and immediately saves it.
|
||||
*/
|
||||
async setDevMode(enabled: boolean): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
devMode: enabled,
|
||||
};
|
||||
} else {
|
||||
this.#signerMetaData.devMode = enabled;
|
||||
this.#extensionSettings.devMode = enabled;
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a host to the whitelist and immediately saves it.
|
||||
*/
|
||||
async addWhitelistedHost(host: string): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
whitelistedHosts: [host],
|
||||
};
|
||||
} else {
|
||||
const hosts = this.#signerMetaData.whitelistedHosts ?? [];
|
||||
const hosts = this.#extensionSettings.whitelistedHosts ?? [];
|
||||
if (!hosts.includes(host)) {
|
||||
hosts.push(host);
|
||||
this.#signerMetaData.whitelistedHosts = hosts;
|
||||
this.#extensionSettings.whitelistedHosts = hosts;
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a host from the whitelist and immediately saves it.
|
||||
*/
|
||||
async removeWhitelistedHost(host: string): Promise<void> {
|
||||
if (!this.#signerMetaData?.whitelistedHosts) {
|
||||
if (!this.#extensionSettings?.whitelistedHosts) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter(
|
||||
this.#extensionSettings.whitelistedHosts = this.#extensionSettings.whitelistedHosts.filter(
|
||||
(h) => h !== host
|
||||
);
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the bookmarks array and immediately saves it.
|
||||
*/
|
||||
async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
bookmarks,
|
||||
};
|
||||
} else {
|
||||
this.#signerMetaData.bookmarks = bookmarks;
|
||||
this.#extensionSettings.bookmarks = bookmarks;
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current bookmarks.
|
||||
*/
|
||||
getBookmarks(): Bookmark[] {
|
||||
return this.#signerMetaData?.bookmarks ?? [];
|
||||
return this.#extensionSettings?.bookmarks ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum number of backups to keep.
|
||||
*/
|
||||
getMaxBackups(): number {
|
||||
return this.#signerMetaData?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
|
||||
return this.#extensionSettings?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,22 +155,22 @@ export abstract class SignerMetaHandler {
|
||||
*/
|
||||
async setMaxBackups(count: number): Promise<void> {
|
||||
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
maxBackups: clampedCount,
|
||||
};
|
||||
} else {
|
||||
this.#signerMetaData.maxBackups = clampedCount;
|
||||
this.#extensionSettings.maxBackups = clampedCount;
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all vault backups, sorted newest first.
|
||||
*/
|
||||
getBackups(): SignerMetaData_VaultSnapshot[] {
|
||||
const backups = this.#signerMetaData?.vaultSnapshots ?? [];
|
||||
getBackups(): VaultSnapshot[] {
|
||||
const backups = this.#extensionSettings?.vaultSnapshots ?? [];
|
||||
return [...backups].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
@@ -165,8 +179,8 @@ export abstract class SignerMetaHandler {
|
||||
/**
|
||||
* Gets a specific backup by ID.
|
||||
*/
|
||||
getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined {
|
||||
return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id);
|
||||
getBackupById(id: string): VaultSnapshot | undefined {
|
||||
return this.#extensionSettings?.vaultSnapshots?.find(b => b.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,28 +188,28 @@ export abstract class SignerMetaHandler {
|
||||
* Automatically removes old backups if exceeding maxBackups.
|
||||
*/
|
||||
async createBackup(
|
||||
browserSyncData: BrowserSyncData,
|
||||
encryptedVault: EncryptedVault,
|
||||
reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
|
||||
): Promise<SignerMetaData_VaultSnapshot> {
|
||||
): Promise<VaultSnapshot> {
|
||||
const now = new Date();
|
||||
const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const identityCount = browserSyncData.identities?.length ?? 0;
|
||||
const identityCount = encryptedVault.identities?.length ?? 0;
|
||||
|
||||
const snapshot: SignerMetaData_VaultSnapshot = {
|
||||
const snapshot: VaultSnapshot = {
|
||||
id: uuidv4(),
|
||||
fileName: `Vault Backup - ${dateTimeString}`,
|
||||
createdAt: now.toISOString(),
|
||||
data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone
|
||||
data: JSON.parse(JSON.stringify(encryptedVault)), // Deep clone
|
||||
identityCount,
|
||||
reason,
|
||||
};
|
||||
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
if (!this.#extensionSettings) {
|
||||
this.#extensionSettings = {
|
||||
vaultSnapshots: [snapshot],
|
||||
};
|
||||
} else {
|
||||
const existingBackups = this.#signerMetaData.vaultSnapshots ?? [];
|
||||
const existingBackups = this.#extensionSettings.vaultSnapshots ?? [];
|
||||
existingBackups.push(snapshot);
|
||||
|
||||
// Enforce max backups limit (only for auto backups, keep manual and pre-restore)
|
||||
@@ -209,10 +223,10 @@ export abstract class SignerMetaHandler {
|
||||
);
|
||||
const trimmedAutoBackups = autoBackups.slice(0, maxBackups);
|
||||
|
||||
this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
|
||||
this.#extensionSettings.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -220,17 +234,17 @@ export abstract class SignerMetaHandler {
|
||||
* Deletes a backup by ID.
|
||||
*/
|
||||
async deleteBackup(backupId: string): Promise<boolean> {
|
||||
if (!this.#signerMetaData?.vaultSnapshots) {
|
||||
if (!this.#extensionSettings?.vaultSnapshots) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLength = this.#signerMetaData.vaultSnapshots.length;
|
||||
this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter(
|
||||
const initialLength = this.#extensionSettings.vaultSnapshots.length;
|
||||
this.#extensionSettings.vaultSnapshots = this.#extensionSettings.vaultSnapshots.filter(
|
||||
b => b.id !== backupId
|
||||
);
|
||||
|
||||
if (this.#signerMetaData.vaultSnapshots.length < initialLength) {
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
if (this.#extensionSettings.vaultSnapshots.length < initialLength) {
|
||||
await this.saveFullData(this.#extensionSettings);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -240,7 +254,7 @@ export abstract class SignerMetaHandler {
|
||||
* Gets the data from a backup for restoration.
|
||||
* Note: The caller should create a pre-restore backup before calling this.
|
||||
*/
|
||||
getBackupData(backupId: string): BrowserSyncData | undefined {
|
||||
getBackupData(backupId: string): EncryptedVault | undefined {
|
||||
const backup = this.getBackupById(backupId);
|
||||
return backup?.data;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Injectable } from '@angular/core';
|
||||
import { BrowserSyncHandler } from './browser-sync-handler';
|
||||
import { BrowserSessionHandler } from './browser-session-handler';
|
||||
import {
|
||||
BrowserSessionData,
|
||||
BrowserSyncData,
|
||||
BrowserSyncFlow,
|
||||
SignerMetaData,
|
||||
Relay_DECRYPTED,
|
||||
VaultSession,
|
||||
EncryptedVault,
|
||||
SyncFlow,
|
||||
ExtensionSettings,
|
||||
RelayData,
|
||||
CashuMintRecord,
|
||||
CashuProof,
|
||||
} from './types';
|
||||
import { SignerMetaHandler } from './signer-meta-handler';
|
||||
import { CryptoHelper } from '@common';
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
deleteCashuMint,
|
||||
updateCashuMintProofs,
|
||||
} from './related/cashu';
|
||||
import { CashuMint_DECRYPTED, CashuProof } from './types';
|
||||
|
||||
export interface StorageServiceConfig {
|
||||
browserSessionHandler: BrowserSessionHandler;
|
||||
@@ -62,13 +63,13 @@ export class StorageService {
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
async enableBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
|
||||
async enableBrowserSyncFlow(flow: SyncFlow): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
this.#signerMetaHandler.setBrowserSyncFlow(flow);
|
||||
this.#signerMetaHandler.setSyncFlow(flow);
|
||||
}
|
||||
|
||||
async loadSignerMetaData(): Promise<SignerMetaData | undefined> {
|
||||
async loadExtensionSettings(): Promise<ExtensionSettings | undefined> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
const data = await this.#signerMetaHandler.loadFullData();
|
||||
@@ -77,11 +78,16 @@ export class StorageService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.#signerMetaHandler.setFullData(data as SignerMetaData);
|
||||
return data as SignerMetaData;
|
||||
this.#signerMetaHandler.setFullData(data as ExtensionSettings);
|
||||
return data as ExtensionSettings;
|
||||
}
|
||||
|
||||
async loadBrowserSessionData(): Promise<BrowserSessionData | undefined> {
|
||||
/** @deprecated Use loadExtensionSettings instead */
|
||||
async loadSignerMetaData(): Promise<ExtensionSettings | undefined> {
|
||||
return this.loadExtensionSettings();
|
||||
}
|
||||
|
||||
async loadVaultSession(): Promise<VaultSession | undefined> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
const data = await this.#browserSessionHandler.loadFullData();
|
||||
@@ -91,22 +97,27 @@ export class StorageService {
|
||||
}
|
||||
|
||||
// Set the existing data for in-memory usage.
|
||||
this.#browserSessionHandler.setFullData(data as BrowserSessionData);
|
||||
return data as BrowserSessionData;
|
||||
this.#browserSessionHandler.setFullData(data as VaultSession);
|
||||
return data as VaultSession;
|
||||
}
|
||||
|
||||
/** @deprecated Use loadVaultSession instead */
|
||||
async loadBrowserSessionData(): Promise<VaultSession | undefined> {
|
||||
return this.loadVaultSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and migrate the browser sync data. If no data is available yet,
|
||||
* Load and migrate the encrypted vault data. If no data is available yet,
|
||||
* the returned object is undefined.
|
||||
*/
|
||||
async loadAndMigrateBrowserSyncData(): Promise<BrowserSyncData | undefined> {
|
||||
async loadAndMigrateEncryptedVault(): Promise<EncryptedVault | undefined> {
|
||||
this.assureIsInitialized();
|
||||
const unmigratedBrowserSyncData =
|
||||
const unmigratedEncryptedVault =
|
||||
await this.getBrowserSyncHandler().loadUnmigratedData();
|
||||
const { browserSyncData, migrationWasPerformed } =
|
||||
this.#migrateBrowserSyncData(unmigratedBrowserSyncData);
|
||||
const { encryptedVault, migrationWasPerformed } =
|
||||
this.#migrateEncryptedVault(unmigratedEncryptedVault);
|
||||
|
||||
if (!browserSyncData) {
|
||||
if (!encryptedVault) {
|
||||
// Nothing to do at this point.
|
||||
return undefined;
|
||||
}
|
||||
@@ -114,13 +125,18 @@ export class StorageService {
|
||||
// There is data. Check, if it was migrated.
|
||||
if (migrationWasPerformed) {
|
||||
// Persist the migrated data back to the browser sync storage.
|
||||
this.getBrowserSyncHandler().saveAndSetFullData(browserSyncData);
|
||||
this.getBrowserSyncHandler().saveAndSetFullData(encryptedVault);
|
||||
} else {
|
||||
// Set the data for in-memory usage.
|
||||
this.getBrowserSyncHandler().setFullData(browserSyncData);
|
||||
this.getBrowserSyncHandler().setFullData(encryptedVault);
|
||||
}
|
||||
|
||||
return browserSyncData;
|
||||
return encryptedVault;
|
||||
}
|
||||
|
||||
/** @deprecated Use loadAndMigrateEncryptedVault instead */
|
||||
async loadAndMigrateBrowserSyncData(): Promise<EncryptedVault | undefined> {
|
||||
return this.loadAndMigrateEncryptedVault();
|
||||
}
|
||||
|
||||
async deleteVault(doNotSetIsInitializedToFalse = false) {
|
||||
@@ -183,7 +199,7 @@ export class StorageService {
|
||||
await deleteRelay.call(this, relayId);
|
||||
}
|
||||
|
||||
async updateRelay(relayClone: Relay_DECRYPTED): Promise<void> {
|
||||
async updateRelay(relayClone: RelayData): Promise<void> {
|
||||
await updateRelay.call(this, relayClone);
|
||||
}
|
||||
|
||||
@@ -209,7 +225,7 @@ export class StorageService {
|
||||
name: string;
|
||||
mintUrl: string;
|
||||
unit?: string;
|
||||
}): Promise<CashuMint_DECRYPTED> {
|
||||
}): Promise<CashuMintRecord> {
|
||||
return await addCashuMint.call(this, data);
|
||||
}
|
||||
|
||||
@@ -227,36 +243,36 @@ export class StorageService {
|
||||
exportVault(): string {
|
||||
this.assureIsInitialized();
|
||||
const vaultJson = JSON.stringify(
|
||||
this.getBrowserSyncHandler().browserSyncData,
|
||||
this.getBrowserSyncHandler().encryptedVault,
|
||||
undefined,
|
||||
4
|
||||
);
|
||||
return vaultJson;
|
||||
}
|
||||
|
||||
async importVault(allegedBrowserSyncData: BrowserSyncData) {
|
||||
async importVault(allegedEncryptedVault: EncryptedVault) {
|
||||
this.assureIsInitialized();
|
||||
|
||||
const isValidData = this.#allegedBrowserSyncDataIsValid(
|
||||
allegedBrowserSyncData
|
||||
const isValidData = this.#allegedEncryptedVaultIsValid(
|
||||
allegedEncryptedVault
|
||||
);
|
||||
if (!isValidData) {
|
||||
throw new Error('The imported data is not valid.');
|
||||
}
|
||||
|
||||
await this.getBrowserSyncHandler().saveAndSetFullData(
|
||||
allegedBrowserSyncData
|
||||
allegedEncryptedVault
|
||||
);
|
||||
}
|
||||
|
||||
getBrowserSyncHandler(): BrowserSyncHandler {
|
||||
this.assureIsInitialized();
|
||||
|
||||
switch (this.#signerMetaHandler.signerMetaData?.syncFlow) {
|
||||
case BrowserSyncFlow.NO_SYNC:
|
||||
switch (this.#signerMetaHandler.extensionSettings?.syncFlow) {
|
||||
case SyncFlow.NO_SYNC:
|
||||
return this.#browserSyncNoHandler;
|
||||
|
||||
case BrowserSyncFlow.BROWSER_SYNC:
|
||||
case SyncFlow.BROWSER_SYNC:
|
||||
default:
|
||||
return this.#browserSyncYesHandler;
|
||||
}
|
||||
@@ -275,14 +291,14 @@ export class StorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current browser sync flow setting.
|
||||
* Get the current sync flow setting.
|
||||
* Returns NO_SYNC if not initialized or no setting found.
|
||||
*/
|
||||
getSyncFlow(): BrowserSyncFlow {
|
||||
if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) {
|
||||
return BrowserSyncFlow.NO_SYNC;
|
||||
getSyncFlow(): SyncFlow {
|
||||
if (!this.isInitialized || !this.#signerMetaHandler?.extensionSettings) {
|
||||
return SyncFlow.NO_SYNC;
|
||||
}
|
||||
return this.#signerMetaHandler.signerMetaData.syncFlow ?? BrowserSyncFlow.NO_SYNC;
|
||||
return this.#signerMetaHandler.extensionSettings.syncFlow ?? SyncFlow.NO_SYNC;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,25 +313,24 @@ export class StorageService {
|
||||
}
|
||||
|
||||
async encrypt(value: string): Promise<string> {
|
||||
const browserSessionData =
|
||||
this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
const vaultSession = this.getBrowserSessionHandler().vaultSession;
|
||||
if (!vaultSession) {
|
||||
throw new Error('Vault session is undefined.');
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key directly with AES-GCM
|
||||
if (browserSessionData.vaultKey) {
|
||||
return this.encryptV2(value, browserSessionData.iv, browserSessionData.vaultKey);
|
||||
if (vaultSession.vaultKey) {
|
||||
return this.encryptV2(value, vaultSession.iv, vaultSession.vaultKey);
|
||||
}
|
||||
|
||||
// v1: Use PBKDF2 with password
|
||||
if (!browserSessionData.vaultPassword) {
|
||||
if (!vaultSession.vaultPassword) {
|
||||
throw new Error('No vault password or key available.');
|
||||
}
|
||||
return CryptoHelper.encrypt(
|
||||
value,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultPassword
|
||||
vaultSession.iv,
|
||||
vaultSession.vaultPassword
|
||||
);
|
||||
}
|
||||
|
||||
@@ -347,31 +362,30 @@ export class StorageService {
|
||||
value: string,
|
||||
returnType: 'string' | 'number' | 'boolean'
|
||||
): Promise<any> {
|
||||
const browserSessionData =
|
||||
this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
const vaultSession = this.getBrowserSessionHandler().vaultSession;
|
||||
if (!vaultSession) {
|
||||
throw new Error('Vault session is undefined.');
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key directly with AES-GCM
|
||||
if (browserSessionData.vaultKey) {
|
||||
if (vaultSession.vaultKey) {
|
||||
const decryptedValue = await this.decryptV2(
|
||||
value,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultKey
|
||||
vaultSession.iv,
|
||||
vaultSession.vaultKey
|
||||
);
|
||||
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||
}
|
||||
|
||||
// v1: Use PBKDF2 with password
|
||||
if (!browserSessionData.vaultPassword) {
|
||||
if (!vaultSession.vaultPassword) {
|
||||
throw new Error('No vault password or key available.');
|
||||
}
|
||||
return this.decryptWithLockedVault(
|
||||
value,
|
||||
returnType,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultPassword
|
||||
vaultSession.iv,
|
||||
vaultSession.vaultPassword
|
||||
);
|
||||
}
|
||||
|
||||
@@ -445,28 +459,28 @@ export class StorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the browser sync data to the latest version.
|
||||
* Migrate the encrypted vault to the latest version.
|
||||
*/
|
||||
#migrateBrowserSyncData(browserSyncData: Partial<Record<string, any>>): {
|
||||
browserSyncData?: BrowserSyncData;
|
||||
#migrateEncryptedVault(encryptedVault: Partial<Record<string, any>>): {
|
||||
encryptedVault?: EncryptedVault;
|
||||
migrationWasPerformed: boolean;
|
||||
} {
|
||||
if (Object.keys(browserSyncData).length === 0) {
|
||||
// First run. There is no browser sync data yet.
|
||||
if (Object.keys(encryptedVault).length === 0) {
|
||||
// First run. There is no encrypted vault yet.
|
||||
return {
|
||||
browserSyncData: undefined,
|
||||
encryptedVault: undefined,
|
||||
migrationWasPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Will be implemented if migration is required.
|
||||
return {
|
||||
browserSyncData: browserSyncData as BrowserSyncData,
|
||||
encryptedVault: encryptedVault as EncryptedVault,
|
||||
migrationWasPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
#allegedBrowserSyncDataIsValid(data: BrowserSyncData): boolean {
|
||||
#allegedEncryptedVaultIsValid(data: EncryptedVault): boolean {
|
||||
if (typeof data.iv === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
|
||||
|
||||
export interface Permission_DECRYPTED {
|
||||
id: string;
|
||||
identityId: string;
|
||||
host: string;
|
||||
method: ExtensionMethod;
|
||||
methodPolicy: Nip07MethodPolicy;
|
||||
kind?: number;
|
||||
}
|
||||
// =============================================================================
|
||||
// STORAGE DATA TRANSFER OBJECTS (DTOs)
|
||||
// These types represent data as stored in browser storage
|
||||
// =============================================================================
|
||||
|
||||
export interface Permission_ENCRYPTED {
|
||||
/**
|
||||
* Permission as stored in encrypted vault (encrypted string fields)
|
||||
*/
|
||||
export interface StoredPermission {
|
||||
id: string;
|
||||
identityId: string;
|
||||
host: string;
|
||||
@@ -18,24 +17,37 @@ export interface Permission_ENCRYPTED {
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface Identity_DECRYPTED {
|
||||
/**
|
||||
* Permission in session memory (typed fields)
|
||||
*/
|
||||
export interface PermissionData {
|
||||
id: string;
|
||||
identityId: string;
|
||||
host: string;
|
||||
method: ExtensionMethod;
|
||||
methodPolicy: Nip07MethodPolicy;
|
||||
kind?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity as stored in encrypted vault
|
||||
*/
|
||||
export interface StoredIdentity {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
nick: string;
|
||||
privkey: string;
|
||||
}
|
||||
|
||||
export type Identity_ENCRYPTED = Identity_DECRYPTED;
|
||||
/**
|
||||
* Identity in session memory (same structure, just semantic clarity)
|
||||
*/
|
||||
export type IdentityData = StoredIdentity;
|
||||
|
||||
export interface Relay_DECRYPTED {
|
||||
id: string;
|
||||
identityId: string;
|
||||
url: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
||||
|
||||
export interface Relay_ENCRYPTED {
|
||||
/**
|
||||
* Relay as stored in encrypted vault (encrypted boolean fields)
|
||||
*/
|
||||
export interface StoredRelay {
|
||||
id: string;
|
||||
identityId: string;
|
||||
url: string;
|
||||
@@ -44,10 +56,21 @@ export interface Relay_ENCRYPTED {
|
||||
}
|
||||
|
||||
/**
|
||||
* NWC (Nostr Wallet Connect) connection - Decrypted
|
||||
* Relay in session memory (typed boolean fields)
|
||||
*/
|
||||
export interface RelayData {
|
||||
id: string;
|
||||
identityId: string;
|
||||
url: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* NWC (Nostr Wallet Connect) connection in session memory
|
||||
* Stores NIP-47 wallet connection data
|
||||
*/
|
||||
export interface NwcConnection_DECRYPTED {
|
||||
export interface NwcConnectionRecord {
|
||||
id: string;
|
||||
name: string; // User-defined wallet name
|
||||
connectionUrl: string; // Full nostr+walletconnect:// URL
|
||||
@@ -61,9 +84,9 @@ export interface NwcConnection_DECRYPTED {
|
||||
}
|
||||
|
||||
/**
|
||||
* NWC connection - Encrypted for storage
|
||||
* NWC connection as stored in encrypted vault
|
||||
*/
|
||||
export interface NwcConnection_ENCRYPTED {
|
||||
export interface StoredNwcConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
connectionUrl: string;
|
||||
@@ -89,10 +112,10 @@ export interface CashuProof {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashu Mint Connection - Decrypted
|
||||
* Cashu Mint Connection in session memory
|
||||
* Stores NIP-60 Cashu mint connection data with local proofs
|
||||
*/
|
||||
export interface CashuMint_DECRYPTED {
|
||||
export interface CashuMintRecord {
|
||||
id: string;
|
||||
name: string; // User-defined mint name
|
||||
mintUrl: string; // Mint API URL
|
||||
@@ -104,9 +127,9 @@ export interface CashuMint_DECRYPTED {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashu Mint Connection - Encrypted for storage
|
||||
* Cashu Mint Connection as stored in encrypted vault
|
||||
*/
|
||||
export interface CashuMint_ENCRYPTED {
|
||||
export interface StoredCashuMint {
|
||||
id: string;
|
||||
name: string;
|
||||
mintUrl: string;
|
||||
@@ -117,7 +140,15 @@ export interface CashuMint_ENCRYPTED {
|
||||
cachedBalanceAt?: string;
|
||||
}
|
||||
|
||||
export interface BrowserSyncData_PART_Unencrypted {
|
||||
// =============================================================================
|
||||
// ENCRYPTED VAULT
|
||||
// The vault is the encrypted container holding all sensitive data
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Vault header - unencrypted metadata needed to decrypt the vault
|
||||
*/
|
||||
export interface EncryptedVaultHeader {
|
||||
version: number;
|
||||
iv: string;
|
||||
vaultHash: string;
|
||||
@@ -126,26 +157,42 @@ export interface BrowserSyncData_PART_Unencrypted {
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
export interface BrowserSyncData_PART_Encrypted {
|
||||
/**
|
||||
* Vault content - encrypted payload containing all sensitive data
|
||||
*/
|
||||
export interface EncryptedVaultContent {
|
||||
selectedIdentityId: string | null;
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
identities: Identity_ENCRYPTED[];
|
||||
relays: Relay_ENCRYPTED[];
|
||||
nwcConnections?: NwcConnection_ENCRYPTED[];
|
||||
cashuMints?: CashuMint_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
identities: StoredIdentity[];
|
||||
relays: StoredRelay[];
|
||||
nwcConnections?: StoredNwcConnection[];
|
||||
cashuMints?: StoredCashuMint[];
|
||||
}
|
||||
|
||||
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted &
|
||||
BrowserSyncData_PART_Encrypted;
|
||||
/**
|
||||
* Complete encrypted vault as stored in browser sync storage
|
||||
*/
|
||||
export type EncryptedVault = EncryptedVaultHeader & EncryptedVaultContent;
|
||||
|
||||
export enum BrowserSyncFlow {
|
||||
/**
|
||||
* Sync flow preference for vault data
|
||||
*/
|
||||
export enum SyncFlow {
|
||||
NO_SYNC = 0,
|
||||
BROWSER_SYNC = 1,
|
||||
SIGNER_SYNC = 2,
|
||||
CUSTOM_SYNC = 3,
|
||||
}
|
||||
|
||||
export interface BrowserSessionData {
|
||||
// =============================================================================
|
||||
// VAULT SESSION
|
||||
// Runtime state when vault is unlocked
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Vault session - decrypted vault data in session memory
|
||||
*/
|
||||
export interface VaultSession {
|
||||
// The following properties purely come from the browser session storage
|
||||
// and will never be going into the browser sync storage.
|
||||
vaultPassword?: string; // v1 only: raw password for PBKDF2
|
||||
@@ -155,24 +202,32 @@ export interface BrowserSessionData {
|
||||
iv: string;
|
||||
// Version 2+: Random salt for Argon2id (base64)
|
||||
salt?: string;
|
||||
permissions: Permission_DECRYPTED[];
|
||||
identities: Identity_DECRYPTED[];
|
||||
permissions: PermissionData[];
|
||||
identities: IdentityData[];
|
||||
selectedIdentityId: string | null;
|
||||
relays: Relay_DECRYPTED[];
|
||||
nwcConnections?: NwcConnection_DECRYPTED[];
|
||||
cashuMints?: CashuMint_DECRYPTED[];
|
||||
relays: RelayData[];
|
||||
nwcConnections?: NwcConnectionRecord[];
|
||||
cashuMints?: CashuMintRecord[];
|
||||
}
|
||||
|
||||
export interface SignerMetaData_VaultSnapshot {
|
||||
// =============================================================================
|
||||
// EXTENSION SETTINGS
|
||||
// Non-vault configuration stored separately
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Vault snapshot for backup/restore
|
||||
*/
|
||||
export interface VaultSnapshot {
|
||||
id: string;
|
||||
fileName: string;
|
||||
createdAt: string; // ISO timestamp
|
||||
data: BrowserSyncData;
|
||||
data: EncryptedVault;
|
||||
identityCount: number;
|
||||
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
|
||||
}
|
||||
|
||||
export const SIGNER_META_DATA_KEY = {
|
||||
export const EXTENSION_SETTINGS_KEYS = {
|
||||
vaultSnapshots: 'vaultSnapshots',
|
||||
};
|
||||
|
||||
@@ -186,10 +241,13 @@ export interface Bookmark {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SignerMetaData {
|
||||
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync))
|
||||
/**
|
||||
* Extension settings - non-vault configuration
|
||||
*/
|
||||
export interface ExtensionSettings {
|
||||
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync)
|
||||
|
||||
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
|
||||
vaultSnapshots?: VaultSnapshot[];
|
||||
|
||||
// Maximum number of automatic backups to keep (default: 5)
|
||||
maxBackups?: number;
|
||||
@@ -229,3 +287,47 @@ export interface ProfileMetadata {
|
||||
* Cache for profile metadata, stored in session storage
|
||||
*/
|
||||
export type ProfileMetadataCache = Record<string, ProfileMetadata>;
|
||||
|
||||
// =============================================================================
|
||||
// BACKWARDS COMPATIBILITY ALIASES
|
||||
// These will be removed in a future version
|
||||
// =============================================================================
|
||||
|
||||
/** @deprecated Use StoredPermission instead */
|
||||
export type Permission_ENCRYPTED = StoredPermission;
|
||||
/** @deprecated Use PermissionData instead */
|
||||
export type Permission_DECRYPTED = PermissionData;
|
||||
/** @deprecated Use StoredIdentity instead */
|
||||
export type Identity_ENCRYPTED = StoredIdentity;
|
||||
/** @deprecated Use IdentityData instead */
|
||||
export type Identity_DECRYPTED = IdentityData;
|
||||
/** @deprecated Use StoredRelay instead */
|
||||
export type Relay_ENCRYPTED = StoredRelay;
|
||||
/** @deprecated Use RelayData instead */
|
||||
export type Relay_DECRYPTED = RelayData;
|
||||
/** @deprecated Use StoredNwcConnection instead */
|
||||
export type NwcConnection_ENCRYPTED = StoredNwcConnection;
|
||||
/** @deprecated Use NwcConnectionRecord instead */
|
||||
export type NwcConnection_DECRYPTED = NwcConnectionRecord;
|
||||
/** @deprecated Use StoredCashuMint instead */
|
||||
export type CashuMint_ENCRYPTED = StoredCashuMint;
|
||||
/** @deprecated Use CashuMintRecord instead */
|
||||
export type CashuMint_DECRYPTED = CashuMintRecord;
|
||||
/** @deprecated Use EncryptedVaultHeader instead */
|
||||
export type BrowserSyncData_PART_Unencrypted = EncryptedVaultHeader;
|
||||
/** @deprecated Use EncryptedVaultContent instead */
|
||||
export type BrowserSyncData_PART_Encrypted = EncryptedVaultContent;
|
||||
/** @deprecated Use EncryptedVault instead */
|
||||
export type BrowserSyncData = EncryptedVault;
|
||||
/** @deprecated Use SyncFlow instead */
|
||||
export const BrowserSyncFlow = SyncFlow;
|
||||
/** @deprecated Use SyncFlow instead */
|
||||
export type BrowserSyncFlow = SyncFlow;
|
||||
/** @deprecated Use VaultSession instead */
|
||||
export type BrowserSessionData = VaultSession;
|
||||
/** @deprecated Use VaultSnapshot instead */
|
||||
export type SignerMetaData_VaultSnapshot = VaultSnapshot;
|
||||
/** @deprecated Use EXTENSION_SETTINGS_KEYS instead */
|
||||
export const SIGNER_META_DATA_KEY = EXTENSION_SETTINGS_KEYS;
|
||||
/** @deprecated Use ExtensionSettings instead */
|
||||
export type SignerMetaData = ExtensionSettings;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.sam-text-muted {
|
||||
color: var(--muted-foreground);
|
||||
.sam-text-muted,
|
||||
.text-muted {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
.sam-text-lg {
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
* Public API Surface of common
|
||||
*/
|
||||
|
||||
// Domain (DDD Value Objects & Repository Interfaces)
|
||||
export * from './lib/domain';
|
||||
|
||||
// Infrastructure (Encryption & Repository Implementations)
|
||||
export * from './lib/infrastructure';
|
||||
|
||||
// Common
|
||||
export * from './lib/common/nav-component';
|
||||
|
||||
// Constants
|
||||
export * from './lib/constants/fallback-relays';
|
||||
export * from './lib/constants/event-kinds';
|
||||
|
||||
// Helpers
|
||||
export * from './lib/helpers/crypto-helper';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer",
|
||||
"description": "Nostr Identity Manager & Signer",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.5",
|
||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||
"options_page": "options.html",
|
||||
"permissions": [
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NewIdentityComponent } from './components/new-identity/new-identity.com
|
||||
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
|
||||
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
|
||||
import { KeysComponent as EditIdentityKeysComponent } from './components/edit-identity/keys/keys.component';
|
||||
import { NcryptsecComponent as EditIdentityNcryptsecComponent } from './components/edit-identity/ncryptsec/ncryptsec.component';
|
||||
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
|
||||
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
|
||||
import { WelcomeComponent } from './components/welcome/welcome.component';
|
||||
@@ -112,6 +113,10 @@ export const routes: Routes = [
|
||||
path: 'keys',
|
||||
component: EditIdentityKeysComponent,
|
||||
},
|
||||
{
|
||||
path: 'ncryptsec',
|
||||
component: EditIdentityNcryptsecComponent,
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
component: EditIdentityPermissionsComponent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { SignerMetaData, SignerMetaHandler } from '@common';
|
||||
import { ExtensionSettings, SignerMetaHandler } from '@common';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
export class FirefoxMetaHandler extends SignerMetaHandler {
|
||||
@@ -20,7 +20,7 @@ export class FirefoxMetaHandler extends SignerMetaHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveFullData(data: SignerMetaData): Promise<void> {
|
||||
async saveFullData(data: ExtensionSettings): Promise<void> {
|
||||
await browser.storage.local.set(data as Record<string, any>);
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSessionData, BrowserSessionHandler } from '@common';
|
||||
import { VaultSession, BrowserSessionHandler } from '@common';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
export class FirefoxSessionHandler extends BrowserSessionHandler {
|
||||
@@ -7,7 +7,7 @@ export class FirefoxSessionHandler extends BrowserSessionHandler {
|
||||
return browser.storage.session.get(null);
|
||||
}
|
||||
|
||||
async saveFullData(data: BrowserSessionData): Promise<void> {
|
||||
async saveFullData(data: VaultSession): Promise<void> {
|
||||
await browser.storage.session.set(data as Record<string, any>);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
@@ -25,20 +25,20 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await browser.storage.local.set(data as Record<string, any>);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -52,21 +52,21 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
@@ -19,20 +19,20 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
|
||||
return await browser.storage.sync.get(null);
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await browser.storage.sync.set(data as Record<string, any>);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -46,21 +46,21 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
|
||||
@@ -136,6 +136,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="sam-mt-2">Encrypted Key (NIP-49)</span>
|
||||
|
||||
<button class="btn btn-primary sam-mt-h" (click)="navigateToNcryptsec()">
|
||||
Get ncryptsec
|
||||
</button>
|
||||
}
|
||||
|
||||
<lib-toast #toast [bottom]="16"></lib-toast>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
NavComponent,
|
||||
@@ -29,6 +29,7 @@ export class KeysComponent extends NavComponent implements OnInit {
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
@@ -51,6 +52,11 @@ export class KeysComponent extends NavComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
navigateToNcryptsec() {
|
||||
if (!this.identity) return;
|
||||
this.#router.navigateByUrl(`/edit-identity/${this.identity.id}/ncryptsec`);
|
||||
}
|
||||
|
||||
async #initialize(identityId: string) {
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<div class="header-pane">
|
||||
<lib-icon-button
|
||||
icon="chevron-left"
|
||||
(click)="navigateBack()"
|
||||
></lib-icon-button>
|
||||
<span>Get ncryptsec</span>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (shown after generation) -->
|
||||
@if (ncryptsec) {
|
||||
<div class="qr-container">
|
||||
<button
|
||||
type="button"
|
||||
class="qr-button"
|
||||
title="Copy to clipboard"
|
||||
(click)="copyToClipboard(ncryptsec); toast.show('Copied to clipboard')"
|
||||
>
|
||||
<img [src]="ncryptsecQr" alt="ncryptsec QR code" class="qr-code" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PASSWORD INPUT -->
|
||||
<div class="password-section">
|
||||
<label for="ncryptsecPasswordInput">Password</label>
|
||||
<div class="input-group sam-mt-h">
|
||||
<input
|
||||
#passwordInput
|
||||
id="ncryptsecPasswordInput"
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="Enter encryption password"
|
||||
[(ngModel)]="ncryptsecPassword"
|
||||
[disabled]="isGenerating"
|
||||
(keyup.enter)="generateNcryptsec()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary generate-btn"
|
||||
type="button"
|
||||
(click)="generateNcryptsec()"
|
||||
[disabled]="!ncryptsecPassword || isGenerating"
|
||||
>
|
||||
@if (isGenerating) {
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
Generating...
|
||||
} @else {
|
||||
Generate ncryptsec
|
||||
}
|
||||
</button>
|
||||
|
||||
|
||||
<p class="description">
|
||||
Enter a password to encrypt your private key. The resulting ncryptsec can be
|
||||
used to securely backup or transfer your key.
|
||||
</p>
|
||||
|
||||
<lib-toast #toast [bottom]="16"></lib-toast>
|
||||
@@ -0,0 +1,70 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
.header-pane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: var(--size-h);
|
||||
align-items: center;
|
||||
padding-bottom: var(--size);
|
||||
background-color: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.password-section {
|
||||
margin-bottom: var(--size);
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--size-q);
|
||||
}
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.qr-button {
|
||||
background: white;
|
||||
padding: var(--size);
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
} from '@common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ncryptsec',
|
||||
imports: [IconButtonComponent, FormsModule, ToastComponent],
|
||||
templateUrl: './ncryptsec.component.html',
|
||||
styleUrl: './ncryptsec.component.scss',
|
||||
})
|
||||
export class NcryptsecComponent
|
||||
extends NavComponent
|
||||
implements OnInit, AfterViewInit
|
||||
{
|
||||
@ViewChild('passwordInput') passwordInput!: ElementRef<HTMLInputElement>;
|
||||
|
||||
privkeyHex = '';
|
||||
ncryptsecPassword = '';
|
||||
ncryptsec = '';
|
||||
ncryptsecQr = '';
|
||||
isGenerating = false;
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
if (!identityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initialize(identityId);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.passwordInput.nativeElement.focus();
|
||||
}
|
||||
|
||||
async generateNcryptsec() {
|
||||
if (!this.privkeyHex || !this.ncryptsecPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGenerating = true;
|
||||
this.ncryptsec = '';
|
||||
this.ncryptsecQr = '';
|
||||
|
||||
try {
|
||||
this.ncryptsec = await NostrHelper.privkeyToNcryptsec(
|
||||
this.privkeyHex,
|
||||
this.ncryptsecPassword
|
||||
);
|
||||
|
||||
// Generate QR code
|
||||
this.ncryptsecQr = await QRCode.toDataURL(this.ncryptsec, {
|
||||
width: 250,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate ncryptsec:', error);
|
||||
} finally {
|
||||
this.isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
#initialize(identityId: string) {
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === identityId);
|
||||
|
||||
if (!identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.privkeyHex = identity.privkey;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
>
|
||||
<span class="text-muted">{{ permission.method }}</span>
|
||||
@if(typeof permission.kind !== 'undefined') {
|
||||
<span>(kind {{ permission.kind }})</span>
|
||||
<span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
|
||||
}
|
||||
<div class="sam-flex-grow"></div>
|
||||
<lib-icon-button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { IconButtonComponent, Identity_DECRYPTED, NavComponent, Permission_DECRYPTED, StorageService } from '@common';
|
||||
import { IconButtonComponent, Identity_DECRYPTED, NavComponent, Permission_DECRYPTED, StorageService, getKindName } from '@common';
|
||||
|
||||
interface HostPermissions {
|
||||
host: string;
|
||||
@@ -80,4 +80,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getKindTooltip(kind: number): string {
|
||||
return getKindName(kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,13 @@ export interface UnlockResponseMessage {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const debug = function (message: any) {
|
||||
const dateString = new Date().toISOString();
|
||||
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
||||
};
|
||||
// Debug logging disabled - uncomment for development
|
||||
// export const debug = function (message: any) {
|
||||
// const dateString = new Date().toISOString();
|
||||
// console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
||||
// };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
|
||||
export const debug = function (_message: any) {};
|
||||
|
||||
export type PromptResponse =
|
||||
| 'reject'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
backgroundLogNip07Action,
|
||||
backgroundLogPermissionStored,
|
||||
NostrHelper,
|
||||
NwcClient,
|
||||
NwcConnection_DECRYPTED,
|
||||
@@ -441,7 +439,6 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
policy,
|
||||
req.params?.kind
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, req.method, policy, req.params?.kind);
|
||||
} else if (response === 'approve-all') {
|
||||
// P2: Store permission for ALL kinds/uses of this method from this host
|
||||
await storePermission(
|
||||
@@ -452,8 +449,6 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
'allow',
|
||||
undefined // undefined kind = allow all kinds for signEvent
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, req.method, 'allow', undefined);
|
||||
debug(`Stored approve-all permission for ${req.method} from ${req.host}`);
|
||||
} else if (response === 'reject-all') {
|
||||
// P2: Store deny permission for ALL uses of this method from this host
|
||||
await storePermission(
|
||||
@@ -464,15 +459,9 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
'deny',
|
||||
undefined
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, req.method, 'deny', undefined);
|
||||
debug(`Stored reject-all permission for ${req.method} from ${req.host}`);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||
await backgroundLogNip07Action(req.method, req.host, false, false, {
|
||||
kind: req.params?.kind,
|
||||
peerPubkey: req.params?.peerPubkey,
|
||||
});
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
} else {
|
||||
@@ -481,71 +470,47 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
||||
}
|
||||
|
||||
const relays: Relays = {};
|
||||
let result: any;
|
||||
|
||||
switch (req.method) {
|
||||
case 'getPublicKey':
|
||||
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
|
||||
return result;
|
||||
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
|
||||
case 'signEvent':
|
||||
result = signEvent(req.params, currentIdentity.privkey);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
kind: req.params?.kind,
|
||||
});
|
||||
return result;
|
||||
return signEvent(req.params, currentIdentity.privkey);
|
||||
|
||||
case 'getRelays':
|
||||
browserSessionData.relays.forEach((x) => {
|
||||
relays[x.url] = { read: x.read, write: x.write };
|
||||
});
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
|
||||
return relays;
|
||||
|
||||
case 'nip04.encrypt':
|
||||
result = await nip04Encrypt(
|
||||
return await nip04Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip44.encrypt':
|
||||
result = await nip44Encrypt(
|
||||
return await nip44Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip04.decrypt':
|
||||
result = await nip04Decrypt(
|
||||
return await nip04Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip44.decrypt':
|
||||
result = await nip44Decrypt(
|
||||
return await nip44Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
default:
|
||||
throw new Error(`Not supported request method '${req.method}'.`);
|
||||
@@ -625,7 +590,6 @@ async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any>
|
||||
method,
|
||||
policy
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, method, policy);
|
||||
} else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||
// P2: Store permission for all uses of this WebLN method
|
||||
await storePermission(
|
||||
@@ -635,8 +599,6 @@ async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any>
|
||||
method,
|
||||
'allow'
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, method, 'allow');
|
||||
debug(`Stored approve-all permission for ${method} from ${req.host}`);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||
|
||||
BIN
releases/plebeian-signer-chrome-v1.1.4.tar.gz
Normal file
BIN
releases/plebeian-signer-firefox-v1.1.4.tar.gz
Normal file
BIN
screenshots/chrome-cashu.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
screenshots/chrome-profile.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
screenshots/chrome-sign.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
screenshots/firefox-cashu.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
screenshots/firefox-profile.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
screenshots/firefox-sign.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
screenshots/store-firefox/01-identity-management.png
Normal file
|
After Width: | Height: | Size: 771 KiB |
BIN
screenshots/store-firefox/02-cashu-wallet.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
screenshots/store-firefox/03-signing-permissions.png
Normal file
|
After Width: | Height: | Size: 335 KiB |
77
screenshots/store-firefox/AMO_DESCRIPTION.txt
Normal file
@@ -0,0 +1,77 @@
|
||||
MOZILLA ADD-ONS (AMO) LISTING
|
||||
=============================
|
||||
|
||||
NAME: Plebeian Signer
|
||||
|
||||
SUMMARY (250 chars max):
|
||||
Manage multiple Nostr identities and sign events securely. Built-in Cashu ecash wallet for sending and receiving sats. Your private keys never leave the extension.
|
||||
|
||||
DESCRIPTION:
|
||||
------------
|
||||
|
||||
Plebeian Signer is a secure browser extension for managing your Nostr identities without exposing your private keys to web applications.
|
||||
|
||||
<b>Key Features</b>
|
||||
|
||||
<b>Multiple Identity Management</b>
|
||||
• Create and manage multiple Nostr identities in one place
|
||||
• Switch between profiles instantly when using Nostr apps
|
||||
• Import existing keys or generate new ones
|
||||
• Customize profiles with display names and metadata
|
||||
|
||||
<b>Secure Key Storage</b>
|
||||
• Private keys encrypted with Argon2id + AES-256-GCM
|
||||
• Keys never leave the extension - apps only receive signatures
|
||||
• Password-protected vault with automatic locking
|
||||
• Optional sync across browser instances
|
||||
|
||||
<b>NIP-07 Signing</b>
|
||||
• Full NIP-07 implementation (window.nostr interface)
|
||||
• Review event details before signing
|
||||
• Granular permission controls per site and event kind
|
||||
• One-click approve or reject with "always" options
|
||||
• Supports NIP-04 and NIP-44 encryption/decryption
|
||||
|
||||
<b>Built-in Cashu Wallet</b>
|
||||
• Store and manage ecash (Cashu tokens)
|
||||
• Send and receive tokens instantly
|
||||
• Deposit sats via Lightning invoices
|
||||
• Connect to multiple Cashu mints
|
||||
• View token history and check for spent proofs
|
||||
|
||||
<b>Privacy Focused</b>
|
||||
• No data collection or analytics
|
||||
• No external connections except to mints you configure
|
||||
• Fully open source
|
||||
• Works offline for signing operations
|
||||
|
||||
<b>Supported Nostr Apps</b>
|
||||
Works with any NIP-07 compatible application including Snort, Iris, Coracle, Nostrudel, Habla, and many more.
|
||||
|
||||
---
|
||||
|
||||
CATEGORIES:
|
||||
- Primary: Social & Communication
|
||||
- Secondary: Privacy & Security
|
||||
|
||||
TAGS: nostr, signing, identity, wallet, cashu, ecash, lightning, bitcoin, privacy, encryption
|
||||
|
||||
LICENSE: MIT
|
||||
|
||||
HOMEPAGE: https://git.mleku.dev/mleku/plebeian-signer
|
||||
|
||||
SUPPORT EMAIL: (your email)
|
||||
|
||||
PRIVACY POLICY: This extension does not collect, store, or transmit any user data to external servers. All data is stored locally in your browser using encrypted storage. The only external connections made are to Cashu mints that you explicitly configure.
|
||||
|
||||
---
|
||||
|
||||
SCREENSHOTS (1280x800):
|
||||
1. 01-identity-management.png - Multiple identity management
|
||||
2. 02-cashu-wallet.png - Built-in Cashu ecash wallet
|
||||
3. 03-signing-permissions.png - Secure event signing permissions
|
||||
|
||||
---
|
||||
|
||||
EXTENSION ID: plebian-signer@mleku.dev
|
||||
VERSION: 1.1.5
|
||||
BIN
screenshots/store/01-identity-management.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
screenshots/store/02-cashu-wallet.png
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
screenshots/store/03-signing-permissions.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
73
screenshots/store/STORE_DESCRIPTION.txt
Normal file
@@ -0,0 +1,73 @@
|
||||
CHROME WEB STORE LISTING
|
||||
========================
|
||||
|
||||
NAME: Plebeian Signer - Nostr Identity Manager & Signer
|
||||
|
||||
SHORT DESCRIPTION (132 chars max):
|
||||
Manage multiple Nostr identities, sign events securely, and store ecash with the built-in Cashu wallet. Your keys, your control.
|
||||
|
||||
DETAILED DESCRIPTION:
|
||||
---------------------
|
||||
|
||||
Plebeian Signer is a secure browser extension for managing your Nostr identities without exposing your private keys to web applications.
|
||||
|
||||
KEY FEATURES
|
||||
|
||||
Multiple Identity Management
|
||||
• Create and manage multiple Nostr identities in one place
|
||||
• Switch between profiles instantly when using Nostr apps
|
||||
• Import existing keys or generate new ones
|
||||
• Customize profiles with display names and metadata
|
||||
|
||||
Secure Key Storage
|
||||
• Private keys are encrypted with Argon2id + AES-256-GCM
|
||||
• Keys never leave the extension - apps only receive signatures
|
||||
• Password-protected vault with automatic locking
|
||||
• Optional sync across browser instances
|
||||
|
||||
NIP-07 Signing
|
||||
• Full NIP-07 implementation (window.nostr interface)
|
||||
• Review event details before signing
|
||||
• Granular permission controls per site and event kind
|
||||
• One-click approve or reject with "always" options
|
||||
• Supports NIP-04 and NIP-44 encryption/decryption
|
||||
|
||||
Built-in Cashu Wallet
|
||||
• Store and manage ecash (Cashu tokens)
|
||||
• Send and receive tokens instantly
|
||||
• Deposit sats via Lightning invoices
|
||||
• Connect to multiple Cashu mints
|
||||
• View token history and check for spent proofs
|
||||
|
||||
Privacy Focused
|
||||
• No data collection or analytics
|
||||
• No external connections except to mints you configure
|
||||
• Fully open source
|
||||
• Works offline for signing operations
|
||||
|
||||
PERFECT FOR
|
||||
• Nostr users managing multiple personas
|
||||
• Privacy-conscious users who want key isolation
|
||||
• Anyone tired of copy-pasting private keys
|
||||
• Users who want ecash functionality integrated with their identity
|
||||
|
||||
SUPPORTED NOSTR APPS
|
||||
Works with any NIP-07 compatible application including Snort, Iris, Coracle, Nostrudel, Habla, and many more.
|
||||
|
||||
OPEN SOURCE
|
||||
Plebeian Signer is free and open source software. Review the code, report issues, or contribute at the project repository.
|
||||
|
||||
---
|
||||
|
||||
CATEGORY: Social & Communication
|
||||
|
||||
LANGUAGE: English
|
||||
|
||||
PRIVACY POLICY: Not required (no user data collected)
|
||||
|
||||
---
|
||||
|
||||
SCREENSHOTS (1280x800):
|
||||
1. 01-identity-management.png - Multiple identity management
|
||||
2. 02-cashu-wallet.png - Built-in Cashu ecash wallet
|
||||
3. 03-signing-permissions.png - Secure event signing permissions
|
||||