8 Commits

Author SHA1 Message Date
woikos
4a2bc4fe72 Release v1.1.5 - Remove debug and signing logs
- Disable debug() logging function in background scripts
- Remove backgroundLogNip07Action calls for NIP-07 operations
- Remove backgroundLogPermissionStored calls for permission events
- Clean up unused imports and result variables
- Simplify switch statement returns in processNip07Request

Files modified:
- package.json (version bump)
- projects/chrome/src/background-common.ts
- projects/chrome/src/background.ts
- projects/firefox/src/background-common.ts
- projects/firefox/src/background.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:07:56 +01:00
a2e47d8612 Release v1.1.4 - Improve ncryptsec export page UX
- Auto-focus password input when page loads
- Move QR code above password input form (displays after generation)
- Move explanation text below the form
- Replace ncryptsec text output with clickable QR code button
- Add hover/active effects and "Copy to clipboard" tooltip to QR code
- Remove redundant copy button and text display

Files modified:
- package.json (version bump)
- projects/chrome/public/manifest.json
- projects/chrome/src/app/components/edit-identity/ncryptsec/*
- projects/firefox/public/manifest.json
- projects/firefox/src/app/components/edit-identity/ncryptsec/*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 10:04:07 +02:00
2074c409f0 Release v1.1.3 - Add NIP-49 ncryptsec export feature
- Add ncryptsec page for exporting encrypted private keys (NIP-49)
- Implement password-based encryption using scrypt + XChaCha20-Poly1305
- Display QR code for easy mobile scanning of encrypted key
- Add click-to-copy functionality for ncryptsec string
- Add privkeyToNcryptsec() method to NostrHelper using nostr-tools nip49

Files modified:
- projects/common/src/lib/helpers/nostr-helper.ts
- projects/chrome/src/app/app.routes.ts
- projects/chrome/src/app/components/edit-identity/keys/keys.component.*
- projects/chrome/src/app/components/edit-identity/ncryptsec/ (new)
- projects/firefox/src/app/app.routes.ts
- projects/firefox/src/app/components/edit-identity/keys/keys.component.*
- projects/firefox/src/app/components/edit-identity/ncryptsec/ (new)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:39:47 +02:00
woikos
c11887dfa8 Release v1.1.2 - DDD refactoring with domain layer and ubiquitous language
- Add domain layer with value objects (IdentityId, Nickname, NostrKeyPair, etc.)
- Add rich domain entities (Identity, Permission, Relay) with behavior
- Add domain events for identity lifecycle (Created, Renamed, Selected, etc.)
- Add repository interfaces and infrastructure implementations
- Rename storage types to ubiquitous language (EncryptedVault, VaultSession, etc.)
- Fix PermissionChecker to prioritize kind-specific rules over blanket rules
- Add comprehensive test coverage for domain layer (113 tests passing)
- Maintain backwards compatibility with @deprecated aliases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 05:29:42 +01:00
woikos
d98a0ef76e Implement DDD refactoring phases 1-4 with domain layer and ubiquitous language
Phase 1-3: Domain Layer Foundation
- Add value objects: IdentityId, PermissionId, RelayId, WalletId, Nickname, NostrKeyPair
- Add rich domain entities: Identity, Permission, Relay with behavior
- Add domain events: IdentityCreated, IdentityRenamed, IdentitySelected, etc.
- Add repository interfaces for Identity, Permission, Relay
- Add infrastructure layer with repository implementations
- Add EncryptionService abstraction

Phase 4: Ubiquitous Language Cleanup
- Rename BrowserSyncData → EncryptedVault (encrypted vault storage)
- Rename BrowserSessionData → VaultSession (decrypted session state)
- Rename SignerMetaData → ExtensionSettings (extension configuration)
- Rename Identity_ENCRYPTED → StoredIdentity (storage DTO)
- Rename Identity_DECRYPTED → IdentityData (session DTO)
- Similar renames for Permission, Relay, NwcConnection, CashuMint
- Add backwards compatibility aliases with @deprecated markers

Test Coverage
- Add comprehensive tests for all value objects
- Add tests for domain entities and their behavior
- Add tests for domain events
- Fix PermissionChecker to prioritize kind-specific rules over blanket rules
- Fix pre-existing component test issues (IconButton, Pubkey)

All 113 tests pass. Both Chrome and Firefox builds succeed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 05:21:44 +01:00
woikos
87d76bb4a8 Release v1.1.1 - Add permission prompt queue system and batch actions
- Add single-active-prompt queue to prevent permission window spam
- Implement request deduplication using hash-based matching
- Add 30-second timeout for unanswered prompts with cleanup
- Add window close event handling for orphaned prompts
- Add queue size limit (100 requests max)
- Add "All Queued" row with Reject All/Approve All buttons
- Hide batch buttons when queue size is 1 or less
- Add 'reject-all' and 'approve-all' response types to PromptResponse

Files modified:
- package.json
- projects/chrome/public/prompt.html
- projects/chrome/src/background-common.ts
- projects/chrome/src/background.ts
- projects/chrome/src/prompt.ts
- projects/firefox/public/prompt.html
- projects/firefox/src/background-common.ts
- projects/firefox/src/background.ts
- projects/firefox/src/prompt.ts
- releases/plebeian-signer-chrome-v1.1.1.zip
- releases/plebeian-signer-firefox-v1.1.1.zip

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:44:14 +01:00
woikos
57434681f9 Release v1.1.0 - Add WebLN API support for Lightning wallet integration
- Add window.webln API for web app Lightning wallet integration
- Implement webln.enable(), getInfo(), sendPayment(), makeInvoice() methods
- Add WebLN permission prompts with proper amount display for payments
- Dispatch webln:ready and webln:enabled events per WebLN standard
- Add NWC client caching for persistent wallet connections
- Implement inline BOLT11 invoice amount parsing
- Always prompt for sendPayment (security-critical, irreversible)
- Add signMessage/verifyMessage stubs that return "not supported"
- Fix response handling for undefined returns in content script

Files modified:
- projects/common/src/lib/models/nostr.ts (WeblnMethod, ExtensionMethod types)
- projects/common/src/lib/models/webln.ts (new - WebLN response types)
- projects/common/src/public-api.ts (export webln types)
- projects/{chrome,firefox}/src/plebian-signer-extension.ts (window.webln)
- projects/{chrome,firefox}/src/background.ts (WebLN handlers)
- projects/{chrome,firefox}/src/background-common.ts (WebLN permissions)
- projects/{chrome,firefox}/public/prompt.html (WebLN cards)
- projects/{chrome,firefox}/src/prompt.ts (WebLN method handling)
- projects/common/src/lib/services/storage/types.ts (ExtensionMethod type)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:02:05 +01:00
woikos
586e2ab23f Release v1.0.11 - Add dev mode with test prompt button on all headers
- Add Dev Mode toggle to settings that persists in vault metadata
- Add test permission prompt button () to all page headers when dev mode enabled
- Move devMode and onTestPrompt to NavComponent base class for inheritance
- Refactor all home components to extend NavComponent
- Simplify permission prompt layout: remove duplicate domain from header
- Convert permission descriptions to flowing single paragraphs
- Update header-buttons styling for consistent lock/magic button layout

Files modified:
- projects/common/src/lib/common/nav-component.ts (devMode, onTestPrompt)
- projects/common/src/lib/services/storage/types.ts (devMode property)
- projects/common/src/lib/services/storage/signer-meta-handler.ts (setDevMode)
- projects/common/src/lib/styles/_common.scss (header-buttons styling)
- projects/*/src/app/components/home/*/settings.component.* (dev mode UI)
- projects/*/src/app/components/home/*/*.component.* (extend NavComponent)
- projects/*/public/prompt.html (simplified layout)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 07:41:51 +01:00
130 changed files with 7716 additions and 902 deletions

690
DDD_ANALYSIS.md Normal file
View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.0.10",
"version": "v1.1.5",
"custom": {
"chrome": {
"version": "v1.0.10"
"version": "v1.1.5"
},
"firefox": {
"version": "v1.0.10"
"version": "v1.1.5"
}
},
"scripts": {

View File

@@ -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.0.10",
"version": "1.1.5",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

@@ -27,11 +27,66 @@
.page {
height: 100%;
display: grid;
grid-template-rows: 1fr 60px;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr;
overflow-y: hidden;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--size);
background: var(--background);
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
}
.action-label {
width: 60px;
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground);
}
.action-buttons {
display: flex;
gap: 8px;
flex: 1;
}
.action-buttons button {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-reject {
background: var(--muted);
color: var(--foreground);
}
.btn-reject:hover {
background: var(--border);
}
.btn-accept {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-accept:hover {
opacity: 0.9;
}
.card {
padding: var(--size);
background: var(--background-light);
@@ -54,6 +109,12 @@
font-size: 12px;
color: gray;
}
.description {
margin: 0;
text-align: center;
line-height: 1.5;
}
</style>
</head>
<body>
@@ -63,64 +124,31 @@
<span id="titleSpan" style="font-weight: 400 !important"></span>
</div>
<span
class="host-INSERT sam-align-self-center sam-text-muted"
style="font-weight: 500"
></span>
<!-- Card for getPublicKey -->
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your public key</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your public key</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for getRelays -->
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your relays</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your relays</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for signEvent -->
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">sign an event</b> (kind
<span id="kindSpan"></span>) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">sign an event</b> (kind <span id="kindSpan"></span>)
for the selected identity <b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for signEvent -->
@@ -130,20 +158,11 @@
<!-- Card for nip04.encrypt -->
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.encrypt -->
@@ -153,20 +172,11 @@
<!-- Card for nip44.encrypt -->
<div id="cardNip44Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.encrypt -->
@@ -176,20 +186,11 @@
<!-- Card for nip04.decrypt -->
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.decrypt -->
@@ -199,72 +200,90 @@
<!-- Card for nip44.decrypt -->
<div id="cardNip44Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.decrypt -->
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
<div id="card2Nip44Decrypt_text" class="text"></div>
</div>
<!-- Card for webln.enable -->
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">connect to your Lightning wallet</b>.
</p>
</div>
<!-- Card for webln.getInfo -->
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your wallet info</b>.
</p>
</div>
<!-- Card for webln.sendPayment -->
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a Lightning payment</b> of
<b id="paymentAmountSpan" class="color-primary"></b>.
</p>
</div>
<!-- Card2 for webln.sendPayment (shows invoice) -->
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<div id="card2WeblnSendPayment_json" class="json"></div>
</div>
<!-- Card for webln.makeInvoice -->
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">create a Lightning invoice</b>
<span id="invoiceAmountSpan"></span>.
</p>
</div>
<!-- Card for webln.keysend -->
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a keysend payment</b>.
</p>
</div>
</div>
<!------------->
<!-- ACTIONS -->
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectAlwaysButton" class="dropdown-item">
Reject Always
</button>
</li>
</ul>
<div class="actions">
<div class="action-row">
<span class="action-label">Reject</span>
<div class="action-buttons">
<button id="rejectOnceButton" type="button" class="btn-reject">Once</button>
<button id="rejectAlwaysButton" type="button" class="btn-reject">Always</button>
</div>
</div>
<div class="btn-group">
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
Approve Always
</button>
<button
type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="approveOnceButton" class="dropdown-item">
Approve Once
</button>
</li>
</ul>
<div class="action-row">
<span class="action-label">Accept</span>
<div class="action-buttons">
<button id="approveOnceButton" type="button" class="btn-accept">Once</button>
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
</div>
</div>
<div class="action-row" id="allQueuedRow">
<span class="action-label">All Queued</span>
<div class="action-buttons">
<button id="rejectAllButton" type="button" class="btn-reject">Reject All</button>
<button id="approveAllButton" type="button" class="btn-accept">Approve All</button>
</div>
</div>
</div>
</div>

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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()

View File

@@ -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>

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>

View File

@@ -3,9 +3,9 @@ import { Router } from '@angular/router';
import {
ConfirmComponent,
LoggerService,
NavComponent,
SignerMetaData_VaultSnapshot,
StartupService,
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
@@ -15,9 +15,8 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
styleUrl: './backups.component.scss',
imports: [ConfirmComponent],
})
export class BackupsComponent implements OnInit {
export class BackupsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
@@ -27,11 +26,11 @@ export class BackupsComponent implements OnInit {
ngOnInit(): void {
this.loadBackups();
this.maxBackups = this.#storage.getSignerMetaHandler().getMaxBackups();
this.maxBackups = this.storage.getSignerMetaHandler().getMaxBackups();
}
loadBackups(): void {
this.backups = this.#storage.getSignerMetaHandler().getBackups();
this.backups = this.storage.getSignerMetaHandler().getBackups();
}
async onMaxBackupsChange(event: Event): Promise<void> {
@@ -39,14 +38,14 @@ export class BackupsComponent implements OnInit {
const value = parseInt(input.value, 10);
if (!isNaN(value) && value >= 1 && value <= 20) {
this.maxBackups = value;
await this.#storage.getSignerMetaHandler().setMaxBackups(value);
await this.storage.getSignerMetaHandler().setMaxBackups(value);
}
}
async createManualBackup(): Promise<void> {
const browserSyncData = this.#storage.getBrowserSyncHandler().browserSyncData;
const browserSyncData = this.storage.getBrowserSyncHandler().browserSyncData;
if (browserSyncData) {
await this.#storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
await this.storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
this.loadBackups();
}
}
@@ -55,22 +54,22 @@ export class BackupsComponent implements OnInit {
this.restoringBackupId = backupId;
try {
// First, create a pre-restore backup of current state
const currentData = this.#storage.getBrowserSyncHandler().browserSyncData;
const currentData = this.storage.getBrowserSyncHandler().browserSyncData;
if (currentData) {
await this.#storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
await this.storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
}
// Get the backup data
const backupData = this.#storage.getSignerMetaHandler().getBackupData(backupId);
const backupData = this.storage.getSignerMetaHandler().getBackupData(backupId);
if (!backupData) {
throw new Error('Backup not found');
}
// Import the backup
await this.#storage.deleteVault(true);
await this.#storage.importVault(backupData);
await this.storage.deleteVault(true);
await this.storage.importVault(backupData);
this.#logger.logVaultImport('Backup Restore');
this.#storage.isInitialized = false;
this.storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to restore backup:', error);
@@ -79,7 +78,7 @@ export class BackupsComponent implements OnInit {
}
async deleteBackup(backupId: string): Promise<void> {
await this.#storage.getSignerMetaHandler().deleteBackup(backupId);
await this.storage.getSignerMetaHandler().deleteBackup(backupId);
this.loadBackups();
}
@@ -120,7 +119,7 @@ export class BackupsComponent implements OnInit {
async onClickLock(): Promise<void> {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Bookmarks</span>
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
<span class="emoji"></span>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
import { Bookmark, LoggerService, NavComponent, SignerMetaData } from '@common';
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
@Component({
@@ -9,10 +9,9 @@ import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
styleUrl: './bookmarks.component.scss',
imports: [],
})
export class BookmarksComponent implements OnInit {
export class BookmarksComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #metaHandler = new ChromeMetaHandler();
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
bookmarks: Bookmark[] = [];
@@ -93,7 +92,7 @@ export class BookmarksComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="custom-header" style="position: sticky; top: 0">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span class="text">Identities</span>
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">

View File

@@ -19,9 +19,16 @@
background: var(--background);
position: relative;
.lock-btn,
.add-btn {
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.header-btn,
.add-btn {
background: transparent;
border: none;
padding: 8px;
@@ -41,11 +48,8 @@
}
}
.lock-btn {
left: 0;
}
.add-btn {
position: absolute;
right: 0;
}

View File

@@ -4,6 +4,7 @@ import {
IconButtonComponent,
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -17,8 +18,8 @@ import {
styleUrl: './identities.component.scss',
imports: [IconButtonComponent, ToastComponent],
})
export class IdentitiesComponent implements OnInit {
readonly storage = inject(StorageService);
export class IdentitiesComponent extends NavComponent implements OnInit {
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>You</span>
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
<span class="emoji">📝</span>

View File

@@ -3,11 +3,11 @@ import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
PubkeyComponent,
StorageService,
ToastComponent,
VisualNip05Pipe,
validateNip05,
@@ -19,7 +19,7 @@ import {
templateUrl: './identity.component.html',
styleUrl: './identity.component.scss',
})
export class IdentityComponent implements OnInit {
export class IdentityComponent extends NavComponent implements OnInit {
selectedIdentity: Identity_DECRYPTED | undefined;
selectedIdentityNpub: string | undefined;
profile: ProfileMetadata | null = null;
@@ -27,7 +27,6 @@ export class IdentityComponent implements OnInit {
validating = false;
loading = true;
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
@@ -82,17 +81,17 @@ export class IdentityComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
async #loadData() {
try {
const selectedIdentityId =
this.#storage.getBrowserSessionHandler().browserSessionData
this.storage.getBrowserSessionHandler().browserSessionData
?.selectedIdentityId ?? null;
const identity = this.#storage
const identity = this.storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find(
(x) => x.id === selectedIdentityId

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Plebeian Signer </span>
</div>

View File

@@ -1,6 +1,6 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
import { LoggerService, NavComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
@@ -8,16 +8,15 @@ import packageJson from '../../../../../../../package.json';
templateUrl: './info.component.html',
styleUrl: './info.component.scss',
})
export class InfoComponent {
export class InfoComponent extends NavComponent {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
version = packageJson.custom.chrome.version;
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Logs</span>
<div class="logs-actions">
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, StorageService } from '@common';
import { LoggerService, LogEntry, NavComponent } from '@common';
import { DatePipe } from '@angular/common';
@Component({
@@ -9,9 +9,8 @@ import { DatePipe } from '@angular/common';
styleUrl: './logs.component.scss',
imports: [DatePipe],
})
export class LogsComponent implements OnInit {
export class LogsComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
get logs(): LogEntry[] {
@@ -46,7 +45,7 @@ export class LogsComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,10 +1,39 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Settings </span>
</div>
<div class="vault-buttons">
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
</div>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
<div class="dev-mode-row">
<label class="toggle-label">
<input type="checkbox" [checked]="devMode" (change)="onToggleDevMode($event)" />
<span>Dev Mode</span>
</label>
</div>
<div class="sam-flex-grow"></div>
<div class="sync-info">
<span class="sync-label">SYNC: {{ syncFlow }}</span>
<p class="sync-note">
@@ -13,20 +42,6 @@
</p>
</div>
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
<div class="sam-flex-grow"></div>
<button
class="btn btn-danger"
(click)="

View File

@@ -16,6 +16,35 @@
}
}
.vault-buttons {
display: flex;
gap: var(--size);
button {
flex: 1;
}
}
.dev-mode-row {
display: flex;
align-items: center;
gap: var(--size);
.toggle-label {
display: flex;
align-items: center;
gap: var(--size-h);
cursor: pointer;
font-size: 0.9rem;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
}
.sync-info {
.sync-label {
display: block;

View File

@@ -12,6 +12,7 @@ import {
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
import { Buffer } from 'buffer';
@Component({
selector: 'app-settings',
@@ -22,6 +23,7 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
export class SettingsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
syncFlow: string | undefined;
override devMode = false;
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
@@ -45,6 +47,44 @@ export class SettingsComponent extends NavComponent implements OnInit {
default:
break;
}
// Load dev mode setting
this.devMode = this.#storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
}
async onToggleDevMode(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.devMode = checked;
await this.#storage.getSignerMetaHandler().setDevMode(checked);
}
override async onTestPrompt() {
// Open a test permission prompt window
const testEvent = {
kind: 1,
content: 'This is a test note for permission prompt preview.',
tags: [],
created_at: Math.floor(Date.now() / 1000),
};
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
const currentIdentity = this.#storage.getBrowserSessionHandler().browserSessionData?.identities.find(
i => i.id === this.#storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
);
const nick = currentIdentity?.nick ?? 'Test Identity';
const width = 375;
const height = 600;
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
chrome.windows.create({
type: 'popup',
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
width,
height,
left,
top,
});
}
async onResetExtension() {

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
@if (showBackButton) {
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>

View File

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import {
LoggerService,
StorageService,
NavComponent,
NwcService,
NwcConnection_DECRYPTED,
CashuService,
@@ -35,9 +35,8 @@ type WalletSection =
styleUrl: './wallet.component.scss',
imports: [CommonModule, FormsModule],
})
export class WalletComponent implements OnInit, OnDestroy {
export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly nwcService = inject(NwcService);
readonly cashuService = inject(CashuService);
@@ -195,7 +194,7 @@ export class WalletComponent implements OnInit, OnDestroy {
ngOnInit(): void {
// Load current sync flow setting
this.currentSyncFlow = this.#storage.getSyncFlow();
this.currentSyncFlow = this.storage.getSyncFlow();
// Refresh balances on init if we have connections
if (this.connections.length > 0) {
@@ -937,7 +936,7 @@ export class WalletComponent implements OnInit, OnDestroy {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}

View File

@@ -17,7 +17,7 @@ export class WhitelistedAppsComponent extends NavComponent {
@ViewChild('toast') toast!: ToastComponent;
@ViewChild('confirm') confirm!: ConfirmComponent;
readonly storage = inject(StorageService);
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
get whitelistedHosts(): string[] {

View File

@@ -19,6 +19,8 @@ import {
CashuMint_DECRYPTED,
CashuMint_ENCRYPTED,
deriveKeyArgon2,
ExtensionMethod,
WeblnMethod,
} from '@common';
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
@@ -38,16 +40,21 @@ 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'
| 'reject-once'
| 'reject-all' // P2: Reject all requests of this type from this host
| 'approve'
| 'approve-once';
| 'approve-once'
| 'approve-all'; // P2: Approve all requests of this type from this host
export interface PromptResponseMessage {
id: string;
@@ -55,7 +62,7 @@ export interface PromptResponseMessage {
}
export interface BackgroundRequestMessage {
method: Nip07Method;
method: ExtensionMethod;
params: any;
host: string;
}
@@ -218,11 +225,51 @@ export const checkPermissions = function (
return undefined;
};
/**
* Check if a method is a WebLN method
*/
export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
return method.startsWith('webln.');
};
/**
* Check WebLN permissions for a host.
* Note: WebLN permissions are NOT tied to identities since the wallet is global.
* For sendPayment, always returns undefined (require user prompt for security).
*/
export const checkWeblnPermissions = function (
browserSessionData: BrowserSessionData,
host: string,
method: WeblnMethod
): boolean | undefined {
// sendPayment ALWAYS requires user approval (security-critical, irreversible)
if (method === 'webln.sendPayment') {
return undefined;
}
// keysend also requires approval
if (method === 'webln.keysend') {
return undefined;
}
// For other WebLN methods, check stored permissions
// WebLN permissions use 'webln' as the identityId
const permissions = browserSessionData.permissions.filter(
(x) => x.identityId === 'webln' && x.host === host && x.method === method
);
if (permissions.length === 0) {
return undefined;
}
return permissions.every((x) => x.methodPolicy === 'allow');
};
export const storePermission = async function (
browserSessionData: BrowserSessionData,
identity: Identity_DECRYPTED,
identity: Identity_DECRYPTED | null,
host: string,
method: Nip07Method,
method: ExtensionMethod,
methodPolicy: Nip07MethodPolicy,
kind?: number
) {
@@ -231,11 +278,14 @@ export const storePermission = async function (
throw new Error(`Could not retrieve sync data`);
}
// For WebLN methods, use 'webln' as identityId since wallet is global
const identityId = identity?.id ?? 'webln';
const permission: Permission_DECRYPTED = {
id: crypto.randomUUID(),
identityId: identity.id,
identityId,
host,
method,
method: method as Nip07Method, // Cast for storage compatibility
methodPolicy,
kind,
};

View File

@@ -1,16 +1,23 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
NwcClient,
NwcConnection_DECRYPTED,
WeblnMethod,
Nip07Method,
GetInfoResponse,
SendPaymentResponse,
RequestInvoiceResponse,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
checkWeblnPermissions,
debug,
getBrowserSessionData,
getPosition,
handleUnlockRequest,
isWeblnMethod,
nip04Decrypt,
nip04Encrypt,
nip44Decrypt,
@@ -27,13 +34,93 @@ import {
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
// Cache for NWC clients to avoid reconnecting for each request
const nwcClientCache = new Map<string, NwcClient>();
/**
* Get or create an NWC client for a connection
*/
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
const cached = nwcClientCache.get(connection.id);
if (cached && cached.isConnected()) {
return cached;
}
const client = new NwcClient({
walletPubkey: connection.walletPubkey,
relayUrl: connection.relayUrl,
secret: connection.secret,
});
await client.connect();
nwcClientCache.set(connection.id, client);
return client;
}
/**
* Parse invoice amount from a BOLT11 invoice string
* Returns amount in satoshis, or undefined if no amount specified
*/
function parseInvoiceAmount(invoice: string): number | undefined {
try {
// BOLT11 invoices start with 'ln' followed by network prefix and amount
// Format: ln[network][amount][multiplier]1[data]
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
if (!match) {
return undefined;
}
const amountStr = match[2];
const multiplier = match[3];
let amount = parseInt(amountStr, 10);
// Apply multiplier (amount is in BTC by default)
switch (multiplier) {
case 'm': // milli-bitcoin (0.001 BTC)
amount = amount * 100000;
break;
case 'u': // micro-bitcoin (0.000001 BTC)
amount = amount * 100;
break;
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
amount = Math.floor(amount / 10);
break;
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
amount = Math.floor(amount / 10000);
break;
default:
// No multiplier means BTC
amount = amount * 100000000;
}
return amount;
} catch {
return undefined;
}
}
type Relays = Record<string, { read: boolean; write: boolean }>;
// ==========================================
// Permission Prompt Queue System (P0)
// ==========================================
// Timeout for permission prompts (30 seconds)
const PROMPT_TIMEOUT_MS = 30000;
// Maximum number of queued permission requests (prevent DoS)
const MAX_PERMISSION_QUEUE_SIZE = 100;
// Track open prompts with metadata for cleanup
const openPrompts = new Map<
string,
{
resolve: (response: PromptResponse) => void;
reject: (reason?: any) => void;
windowId?: number;
timeoutId?: ReturnType<typeof setTimeout>;
}
>();
@@ -47,6 +134,170 @@ const pendingRequests: {
reject: (error: any) => void;
}[] = [];
// Queue for permission requests (only one prompt shown at a time)
interface PermissionQueueItem {
id: string;
url: string;
width: number;
height: number;
resolve: (response: PromptResponse) => void;
reject: (reason?: any) => void;
}
const permissionQueue: PermissionQueueItem[] = [];
let activePromptId: string | null = null;
/**
* Show the next permission prompt from the queue
*/
async function showNextPermissionPrompt(): Promise<void> {
if (activePromptId || permissionQueue.length === 0) {
return;
}
const next = permissionQueue[0];
activePromptId = next.id;
const { top, left } = await getPosition(next.width, next.height);
try {
const window = await browser.windows.create({
type: 'popup',
url: next.url,
height: next.height,
width: next.width,
top,
left,
});
const promptData = openPrompts.get(next.id);
if (promptData && window.id) {
promptData.windowId = window.id;
promptData.timeoutId = setTimeout(() => {
debug(`Prompt ${next.id} timed out after ${PROMPT_TIMEOUT_MS}ms`);
cleanupPrompt(next.id, 'timeout');
}, PROMPT_TIMEOUT_MS);
}
} catch (error) {
debug(`Failed to create prompt window: ${error}`);
cleanupPrompt(next.id, 'error');
}
}
/**
* Clean up a prompt and process the next one in queue
*/
function cleanupPrompt(promptId: string, reason: 'response' | 'timeout' | 'closed' | 'error'): void {
const promptData = openPrompts.get(promptId);
if (promptData) {
if (promptData.timeoutId) {
clearTimeout(promptData.timeoutId);
}
if (reason !== 'response') {
promptData.reject(new Error(`Permission prompt ${reason}`));
}
openPrompts.delete(promptId);
}
const queueIndex = permissionQueue.findIndex(item => item.id === promptId);
if (queueIndex !== -1) {
permissionQueue.splice(queueIndex, 1);
}
if (activePromptId === promptId) {
activePromptId = null;
}
showNextPermissionPrompt();
}
/**
* Queue a permission prompt request
*/
function queuePermissionPrompt(
urlWithoutId: string,
width: number,
height: number
): Promise<PromptResponse> {
return new Promise((resolve, reject) => {
if (permissionQueue.length >= MAX_PERMISSION_QUEUE_SIZE) {
reject(new Error('Too many pending permission requests. Please try again later.'));
return;
}
const id = crypto.randomUUID();
const separator = urlWithoutId.includes('?') ? '&' : '?';
const url = `${urlWithoutId}${separator}id=${id}`;
openPrompts.set(id, { resolve, reject });
permissionQueue.push({ id, url, width, height, resolve, reject });
debug(`Queued permission prompt ${id}. Queue size: ${permissionQueue.length}`);
showNextPermissionPrompt();
});
}
// Listen for window close events to clean up orphaned prompts
browser.windows.onRemoved.addListener((windowId: number) => {
for (const [promptId, promptData] of openPrompts.entries()) {
if (promptData.windowId === windowId) {
debug(`Prompt window ${windowId} closed without response`);
cleanupPrompt(promptId, 'closed');
break;
}
}
});
// ==========================================
// Request Deduplication (P1)
// ==========================================
const pendingRequestPromises = new Map<string, Promise<PromptResponse>>();
/**
* Generate a hash key for request deduplication
*/
function getRequestHash(host: string, method: string, params: any): string {
if (method === 'signEvent' && params?.kind !== undefined) {
return `${host}:${method}:kind${params.kind}`;
}
if ((method.includes('encrypt') || method.includes('decrypt')) && params?.peerPubkey) {
return `${host}:${method}:${params.peerPubkey}`;
}
return `${host}:${method}`;
}
/**
* Queue a permission prompt with deduplication
*/
function queuePermissionPromptDeduped(
host: string,
method: string,
params: any,
urlWithoutId: string,
width: number,
height: number
): Promise<PromptResponse> {
const hash = getRequestHash(host, method, params);
const existingPromise = pendingRequestPromises.get(hash);
if (existingPromise) {
debug(`Deduplicating request: ${hash}`);
return existingPromise;
}
const promise = queuePermissionPrompt(urlWithoutId, width, height)
.finally(() => {
pendingRequestPromises.delete(hash);
});
pendingRequestPromises.set(hash, promise);
debug(`New permission request: ${hash}`);
return promise;
}
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
debug('Message received');
@@ -88,13 +339,12 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
const promptResponse = request as PromptResponseMessage;
const openPrompt = openPrompts.get(promptResponse.id);
if (!openPrompt) {
throw new Error(
'Prompt response could not be matched to any previous request.'
);
debug('Prompt response could not be matched (may have timed out)');
return;
}
openPrompt.resolve(promptResponse.response);
openPrompts.delete(promptResponse.id);
cleanupPrompt(promptResponse.id, 'response');
return;
}
@@ -116,8 +366,12 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
});
}
// Process the NIP-07 request
return processNip07Request(request as BackgroundRequestMessage);
// Process the request (NIP-07 or WebLN)
const req = request as BackgroundRequestMessage;
if (isWeblnMethod(req.method)) {
return processWeblnRequest(req);
}
return processNip07Request(req);
});
/**
@@ -149,7 +403,7 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
browserSessionData,
currentIdentity,
req.host,
req.method,
req.method as Nip07Method,
req.params
);
debug(`permissionState result: ${permissionState}`);
@@ -159,29 +413,23 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
}
if (permissionState === undefined) {
// Ask user for permission.
// Ask user for permission (queued + deduplicated)
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
// Include queue info for user awareness
const queueSize = permissionQueue.length;
const promptUrl = `prompt.html?method=${req.method}&host=${req.host}&nick=${encodeURIComponent(currentIdentity.nick)}&event=${base64Event}&queue=${queueSize}`;
const response = await queuePermissionPromptDeduped(req.host, req.method, req.params, promptUrl, width, height);
debug(response);
// Handle permission storage based on response type
if (response === 'approve' || response === 'reject') {
// Store permission for this specific kind (if signEvent) or method
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
@@ -191,19 +439,29 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
policy,
req.params?.kind
);
await backgroundLogPermissionStored(
} else if (response === 'approve-all') {
// P2: Store permission for ALL kinds/uses of this method from this host
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
policy,
req.params?.kind
'allow',
undefined // undefined kind = allow all kinds for signEvent
);
} else if (response === 'reject-all') {
// P2: Store deny permission for ALL uses of this method from this host
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
'deny',
undefined
);
}
if (['reject', 'reject-once'].includes(response)) {
await backgroundLogNip07Action(req.method, req.host, false, false, {
kind: req.params?.kind,
peerPubkey: req.params?.peerPubkey,
});
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
throw new Error('Permission denied');
}
} else {
@@ -212,73 +470,191 @@ 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}'.`);
}
}
/**
* Process a WebLN request after vault is unlocked
*/
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
const nwcConnections = browserSessionData.nwcConnections ?? [];
const method = req.method as WeblnMethod;
// webln.enable just checks if NWC is configured
if (method === 'webln.enable') {
if (nwcConnections.length === 0) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
debug('WebLN enabled');
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
}
// All other methods require an NWC connection
const defaultConnection = nwcConnections[0];
if (!defaultConnection) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
// Check reckless mode (but still prompt for payments)
const recklessApprove = await shouldRecklessModeApprove(req.host);
// Check WebLN permissions
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
? true
: checkWeblnPermissions(browserSessionData, req.host, method);
if (permissionState === false) {
throw new Error('Permission denied');
}
if (permissionState === undefined) {
// Ask user for permission (queued + deduplicated)
const width = 375;
const height = 600;
// For sendPayment, include the invoice amount in the prompt data
let promptParams = req.params ?? {};
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
promptParams = { ...promptParams, amountSats };
}
const base64Event = Buffer.from(
JSON.stringify(promptParams, undefined, 2)
).toString('base64');
// Include queue info for user awareness
const queueSize = permissionQueue.length;
const promptUrl = `prompt.html?method=${method}&host=${req.host}&nick=WebLN&event=${base64Event}&queue=${queueSize}`;
const response = await queuePermissionPromptDeduped(req.host, method, req.params, promptUrl, width, height);
debug(response);
// Store permission for non-payment methods
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
null, // WebLN has no identity
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(
browserSessionData,
null,
req.host,
method,
'allow'
);
}
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
throw new Error('Permission denied');
}
}
// Execute the WebLN method
let result: any;
const client = await getNwcClient(defaultConnection);
switch (method) {
case 'webln.getInfo': {
const info = await client.getInfo();
result = {
node: {
alias: info.alias,
pubkey: info.pubkey,
color: info.color,
},
} as GetInfoResponse;
debug('webln.getInfo result:');
debug(result);
return result;
}
case 'webln.sendPayment': {
const invoice = req.params.paymentRequest;
const payResult = await client.payInvoice({ invoice });
result = { preimage: payResult.preimage } as SendPaymentResponse;
debug('webln.sendPayment result:');
debug(result);
return result;
}
case 'webln.makeInvoice': {
// Convert sats to millisats (NWC uses millisats)
const amountSats = typeof req.params.amount === 'string'
? parseInt(req.params.amount, 10)
: req.params.amount ?? req.params.defaultAmount ?? 0;
const amountMsat = amountSats * 1000;
const invoiceResult = await client.makeInvoice({
amount: amountMsat,
description: req.params.defaultMemo,
});
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
debug('webln.makeInvoice result:');
debug(result);
return result;
}
case 'webln.keysend':
throw new Error('keysend is not yet supported');
default:
throw new Error(`Not supported WebLN method '${method}'.`);
}
}

View File

@@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Event, EventTemplate } from 'nostr-tools';
import { Nip07Method } from '@common';
import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
import { ExtensionMethod } from '@common';
// Extend Window interface for NIP-07
// Extend Window interface for NIP-07 and WebLN
declare global {
interface Window {
nostr?: any;
webln?: any;
}
}
@@ -38,7 +39,7 @@ class Messenger {
window.addEventListener('message', this.#handleCallResponse.bind(this));
}
async request(method: Nip07Method, params: any): Promise<any> {
async request(method: ExtensionMethod, params: any): Promise<any> {
const id = generateUUID();
return new Promise((resolve, reject) => {
@@ -89,7 +90,7 @@ const nostr = {
return pubkey;
},
async signEvent(event: EventTemplate): Promise<Event> {
async signEvent(event: EventTemplate): Promise<NostrEvent> {
debug('signEvent received');
const signedEvent = await this.messenger.request('signEvent', event);
debug('signEvent response:');
@@ -158,6 +159,92 @@ const nostr = {
window.nostr = nostr as any;
// WebLN types (inline to avoid build issues with @common types in injected script)
interface RequestInvoiceArgs {
amount?: string | number;
defaultAmount?: string | number;
minimumAmount?: string | number;
maximumAmount?: string | number;
defaultMemo?: string;
}
interface KeysendArgs {
destination: string;
amount: string | number;
customRecords?: Record<string, string>;
}
// Create a shared messenger instance for WebLN
const weblnMessenger = nostr.messenger;
const webln = {
enabled: false,
async enable(): Promise<void> {
debug('webln.enable received');
await weblnMessenger.request('webln.enable', {});
this.enabled = true;
debug('webln.enable completed');
// Dispatch webln:enabled event as per WebLN spec
window.dispatchEvent(new Event('webln:enabled'));
},
async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
debug('webln.getInfo received');
const info = await weblnMessenger.request('webln.getInfo', {});
debug('webln.getInfo response:');
debug(info);
return info;
},
async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
debug('webln.sendPayment received');
const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
debug('webln.sendPayment response:');
debug(result);
return result;
},
async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
debug('webln.keysend received');
const result = await weblnMessenger.request('webln.keysend', args);
debug('webln.keysend response:');
debug(result);
return result;
},
async makeInvoice(
args: string | number | RequestInvoiceArgs
): Promise<{ paymentRequest: string }> {
debug('webln.makeInvoice received');
// Normalize args to RequestInvoiceArgs
let normalizedArgs: RequestInvoiceArgs;
if (typeof args === 'string' || typeof args === 'number') {
normalizedArgs = { amount: args };
} else {
normalizedArgs = args;
}
const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
debug('webln.makeInvoice response:');
debug(result);
return result;
},
signMessage(): Promise<{ message: string; signature: string }> {
throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
},
verifyMessage(): Promise<void> {
throw new Error('verifyMessage is not supported - NWC does not provide message verification');
},
};
window.webln = webln as any;
// Dispatch webln:ready event to signal that webln is available
// This is dispatched on document as per the WebLN standard
document.dispatchEvent(new Event('webln:ready'));
const debug = function (value: any) {
console.log(JSON.stringify(value));
};

View File

@@ -1,5 +1,5 @@
import browser from 'webextension-polyfill';
import { Nip07Method } from '@common';
import { ExtensionMethod } from '@common';
import { PromptResponse, PromptResponseMessage } from './background-common';
/**
@@ -14,7 +14,7 @@ function base64ToUtf8(base64: string): string {
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const method = params.get('method') as Nip07Method;
const method = params.get('method') as ExtensionMethod;
const host = params.get('host') as string;
const nick = params.get('nick') as string;
@@ -58,6 +58,26 @@ switch (method) {
title = 'Get Relays';
break;
case 'webln.enable':
title = 'Enable WebLN';
break;
case 'webln.getInfo':
title = 'Wallet Info';
break;
case 'webln.sendPayment':
title = 'Send Payment';
break;
case 'webln.makeInvoice':
title = 'Create Invoice';
break;
case 'webln.keysend':
title = 'Keysend Payment';
break;
default:
break;
}
@@ -185,6 +205,65 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
}
}
// WebLN card visibility
const cardWeblnEnableElement = document.getElementById('cardWeblnEnable');
if (cardWeblnEnableElement) {
if (method !== 'webln.enable') {
cardWeblnEnableElement.style.display = 'none';
}
}
const cardWeblnGetInfoElement = document.getElementById('cardWeblnGetInfo');
if (cardWeblnGetInfoElement) {
if (method !== 'webln.getInfo') {
cardWeblnGetInfoElement.style.display = 'none';
}
}
const cardWeblnSendPaymentElement = document.getElementById('cardWeblnSendPayment');
const card2WeblnSendPaymentElement = document.getElementById('card2WeblnSendPayment');
if (cardWeblnSendPaymentElement && card2WeblnSendPaymentElement) {
if (method === 'webln.sendPayment') {
// Display amount in sats
const paymentAmountSpan = document.getElementById('paymentAmountSpan');
if (paymentAmountSpan && eventParsed.amountSats !== undefined) {
paymentAmountSpan.innerText = `${eventParsed.amountSats.toLocaleString()} sats`;
} else if (paymentAmountSpan) {
paymentAmountSpan.innerText = 'unknown amount';
}
// Show invoice in json card
const card2WeblnSendPayment_jsonElement = document.getElementById('card2WeblnSendPayment_json');
if (card2WeblnSendPayment_jsonElement && eventParsed.paymentRequest) {
card2WeblnSendPayment_jsonElement.innerText = eventParsed.paymentRequest;
}
} else {
cardWeblnSendPaymentElement.style.display = 'none';
card2WeblnSendPaymentElement.style.display = 'none';
}
}
const cardWeblnMakeInvoiceElement = document.getElementById('cardWeblnMakeInvoice');
if (cardWeblnMakeInvoiceElement) {
if (method === 'webln.makeInvoice') {
const invoiceAmountSpan = document.getElementById('invoiceAmountSpan');
if (invoiceAmountSpan) {
const amount = eventParsed.amount ?? eventParsed.defaultAmount;
if (amount) {
invoiceAmountSpan.innerText = ` for ${Number(amount).toLocaleString()} sats`;
}
}
} else {
cardWeblnMakeInvoiceElement.style.display = 'none';
}
}
const cardWeblnKeysendElement = document.getElementById('cardWeblnKeysend');
if (cardWeblnKeysendElement) {
if (method !== 'webln.keysend') {
cardWeblnKeysendElement.style.display = 'none';
}
}
//
// Functions
//
@@ -223,4 +302,21 @@ document.addEventListener('DOMContentLoaded', function () {
approveAlwaysButton?.addEventListener('click', () => {
deliver('approve');
});
const rejectAllButton = document.getElementById('rejectAllButton');
rejectAllButton?.addEventListener('click', () => {
deliver('reject-all');
});
const approveAllButton = document.getElementById('approveAllButton');
approveAllButton?.addEventListener('click', () => {
deliver('approve-all');
});
// Show/hide "All Queued" row based on queue size
const queueSize = parseInt(params.get('queueSize') || '0', 10);
const allQueuedRow = document.getElementById('allQueuedRow');
if (allQueuedRow && queueSize <= 1) {
allQueuedRow.style.display = 'none';
}
});

View File

@@ -1,8 +1,29 @@
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { StorageService } from '../services/storage/storage.service';
import { Buffer } from 'buffer';
declare const chrome: {
windows: {
create: (options: {
type: string;
url: string;
width: number;
height: number;
left: number;
top: number;
}) => void;
};
};
export class NavComponent {
readonly #router = inject(Router);
protected readonly storage = inject(StorageService);
devMode = false;
constructor() {
this.devMode = this.storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
}
navigateBack() {
window.history.back();
@@ -11,4 +32,32 @@ export class NavComponent {
navigate(path: string) {
this.#router.navigate([path]);
}
onTestPrompt() {
const testEvent = {
kind: 1,
content: 'This is a test note for permission prompt preview.',
tags: [],
created_at: Math.floor(Date.now() / 1000),
};
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
const currentIdentity = this.storage.getBrowserSessionHandler().browserSessionData?.identities.find(
i => i.id === this.storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
);
const nick = currentIdentity?.nick ?? 'Test Identity';
const width = 375;
const height = 600;
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
chrome.windows.create({
type: 'popup',
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
width,
height,
left,
top,
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,219 @@
import {
RelayRepositoryError,
RelayErrorCode,
} from '../../domain/repositories/relay-repository';
import type {
RelayRepository,
RelaySnapshot,
RelayQuery,
} from '../../domain/repositories/relay-repository';
import { IdentityId, RelayId } from '../../domain/value-objects';
import { EncryptionService } from '../encryption';
/**
* Encrypted relay as stored in browser sync storage.
*/
interface EncryptedRelay {
id: string;
identityId: string;
url: string;
read: string;
write: string;
}
/**
* Storage adapter interface for relays.
*/
export interface RelayStorageAdapter {
// Session (in-memory, decrypted) operations
getSessionRelays(): RelaySnapshot[];
setSessionRelays(relays: RelaySnapshot[]): void;
saveSessionData(): Promise<void>;
// Sync (persistent, encrypted) operations
getSyncRelays(): EncryptedRelay[];
saveSyncRelays(relays: EncryptedRelay[]): Promise<void>;
}
/**
* Implementation of RelayRepository using browser storage.
*/
export class BrowserRelayRepository implements RelayRepository {
constructor(
private readonly storage: RelayStorageAdapter,
private readonly encryption: EncryptionService
) {}
async findById(id: RelayId): Promise<RelaySnapshot | undefined> {
const relays = this.storage.getSessionRelays();
return relays.find((r) => r.id === id.value);
}
async find(query: RelayQuery): Promise<RelaySnapshot[]> {
let relays = this.storage.getSessionRelays();
if (query.identityId) {
const identityIdValue = query.identityId.value;
relays = relays.filter((r) => r.identityId === identityIdValue);
}
if (query.url) {
const urlLower = query.url.toLowerCase();
relays = relays.filter((r) => r.url.toLowerCase() === urlLower);
}
if (query.read !== undefined) {
const read = query.read;
relays = relays.filter((r) => r.read === read);
}
if (query.write !== undefined) {
const write = query.write;
relays = relays.filter((r) => r.write === write);
}
return relays;
}
async findByUrl(identityId: IdentityId, url: string): Promise<RelaySnapshot | undefined> {
const relays = this.storage.getSessionRelays();
return relays.find(
(r) =>
r.identityId === identityId.value &&
r.url.toLowerCase() === url.toLowerCase()
);
}
async findByIdentity(identityId: IdentityId): Promise<RelaySnapshot[]> {
const relays = this.storage.getSessionRelays();
return relays.filter((r) => r.identityId === identityId.value);
}
async findAll(): Promise<RelaySnapshot[]> {
return this.storage.getSessionRelays();
}
async save(relay: RelaySnapshot): Promise<void> {
// Check for duplicate URL for the same identity (excluding self)
const existing = await this.findByUrl(
IdentityId.from(relay.identityId),
relay.url
);
if (existing && existing.id !== relay.id) {
throw new RelayRepositoryError(
'A relay with the same URL already exists for this identity',
RelayErrorCode.DUPLICATE_URL
);
}
const sessionRelays = this.storage.getSessionRelays();
const existingIndex = sessionRelays.findIndex((r) => r.id === relay.id);
if (existingIndex >= 0) {
sessionRelays[existingIndex] = relay;
} else {
sessionRelays.push(relay);
}
this.storage.setSessionRelays(sessionRelays);
await this.storage.saveSessionData();
// Encrypt and save to sync storage
const encryptedRelay = await this.encryptRelay(relay);
const syncRelays = this.storage.getSyncRelays();
// Find by decrypting IDs
let syncIndex = -1;
for (let i = 0; i < syncRelays.length; i++) {
try {
const decryptedId = await this.encryption.decryptString(syncRelays[i].id);
if (decryptedId === relay.id) {
syncIndex = i;
break;
}
} catch {
// Skip corrupted entries
}
}
if (syncIndex >= 0) {
syncRelays[syncIndex] = encryptedRelay;
} else {
syncRelays.push(encryptedRelay);
}
await this.storage.saveSyncRelays(syncRelays);
}
async delete(id: RelayId): Promise<boolean> {
const sessionRelays = this.storage.getSessionRelays();
const initialLength = sessionRelays.length;
const filtered = sessionRelays.filter((r) => r.id !== id.value);
if (filtered.length === initialLength) {
return false;
}
this.storage.setSessionRelays(filtered);
await this.storage.saveSessionData();
// Remove from sync storage
const encryptedId = await this.encryption.encryptString(id.value);
const syncRelays = this.storage.getSyncRelays();
const filteredSync = syncRelays.filter((r) => r.id !== encryptedId);
await this.storage.saveSyncRelays(filteredSync);
return true;
}
async deleteByIdentity(identityId: IdentityId): Promise<number> {
const sessionRelays = this.storage.getSessionRelays();
const initialLength = sessionRelays.length;
const filtered = sessionRelays.filter((r) => r.identityId !== identityId.value);
const deletedCount = initialLength - filtered.length;
if (deletedCount === 0) {
return 0;
}
this.storage.setSessionRelays(filtered);
await this.storage.saveSessionData();
// Remove from sync storage
const encryptedIdentityId = await this.encryption.encryptString(identityId.value);
const syncRelays = this.storage.getSyncRelays();
const filteredSync = syncRelays.filter((r) => r.identityId !== encryptedIdentityId);
await this.storage.saveSyncRelays(filteredSync);
return deletedCount;
}
async count(query?: RelayQuery): Promise<number> {
if (query) {
const results = await this.find(query);
return results.length;
}
return this.storage.getSessionRelays().length;
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
private async encryptRelay(relay: RelaySnapshot): Promise<EncryptedRelay> {
return {
id: await this.encryption.encryptString(relay.id),
identityId: await this.encryption.encryptString(relay.identityId),
url: await this.encryption.encryptString(relay.url),
read: await this.encryption.encryptBoolean(relay.read),
write: await this.encryption.encryptBoolean(relay.write),
};
}
}
/**
* Factory function to create a BrowserRelayRepository.
*/
export function createRelayRepository(
storage: RelayStorageAdapter,
encryption: EncryptionService
): RelayRepository {
return new BrowserRelayRepository(storage, encryption);
}

View File

@@ -8,3 +8,12 @@ export type Nip07Method =
| 'nip44.decrypt';
export type Nip07MethodPolicy = 'allow' | 'deny';
export type WeblnMethod =
| 'webln.enable'
| 'webln.getInfo'
| 'webln.sendPayment'
| 'webln.makeInvoice'
| 'webln.keysend';
export type ExtensionMethod = Nip07Method | WeblnMethod;

View File

@@ -0,0 +1,41 @@
/**
* WebLN API Types
* Based on the WebLN specification: https://webln.dev/
*/
export interface WebLNNode {
alias?: string;
pubkey?: string;
color?: string;
}
export interface GetInfoResponse {
node: WebLNNode;
}
export interface SendPaymentResponse {
preimage: string;
}
export interface RequestInvoiceArgs {
amount?: string | number;
defaultAmount?: string | number;
minimumAmount?: string | number;
maximumAmount?: string | number;
defaultMemo?: string;
}
export interface RequestInvoiceResponse {
paymentRequest: string;
}
export interface KeysendArgs {
destination: string;
amount: string | number;
customRecords?: Record<string, string>;
}
export interface SignMessageResponse {
message: string;
signature: string;
}

View File

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

View File

@@ -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);
}
/**

View File

@@ -1,15 +1,24 @@
/* 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;
}
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
#extensionSettings?: ExtensionSettings;
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode'];
readonly DEFAULT_MAX_BACKUPS = 5;
/**
* Load the full data from the storage. If the storage is used for storing
@@ -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,78 +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.#extensionSettings) {
this.#extensionSettings = {
devMode: enabled,
};
} else {
this.#extensionSettings.devMode = enabled;
}
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;
}
/**
@@ -126,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()
);
@@ -150,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);
}
/**
@@ -159,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)
@@ -194,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;
}
@@ -205,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;
@@ -225,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;
}

View File

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

View File

@@ -1,15 +1,14 @@
import { Nip07Method, Nip07MethodPolicy } from '@common';
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
export interface Permission_DECRYPTED {
id: string;
identityId: string;
host: string;
method: Nip07Method;
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;
@@ -202,6 +260,9 @@ export interface SignerMetaData {
// User bookmarks
bookmarks?: Bookmark[];
// Dev mode: show test permission prompt button in settings
devMode?: boolean;
}
/**
@@ -226,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;

View File

@@ -16,9 +16,16 @@
letter-spacing: 0.1rem;
}
.lock-btn {
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.lock-btn,
.header-btn {
background: transparent;
border: none;
padding: 8px;
@@ -37,6 +44,12 @@
font-size: 20px;
}
}
// For backwards compatibility with single lock-btn
> .lock-btn {
position: absolute;
left: 0;
}
}
.sam-footer-grid-2 {

View File

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

View File

@@ -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';
@@ -19,6 +26,7 @@ export * from './lib/helpers/nip05-validator';
// Models
export * from './lib/models/nostr';
export * from './lib/models/webln';
// Services (and related)
export * from './lib/services/storage/storage.service';

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
"version": "1.0.10",
"version": "1.1.5",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

@@ -27,11 +27,66 @@
.page {
height: 100%;
display: grid;
grid-template-rows: 1fr 60px;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr;
overflow-y: hidden;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--size);
background: var(--background);
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
}
.action-label {
width: 60px;
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground);
}
.action-buttons {
display: flex;
gap: 8px;
flex: 1;
}
.action-buttons button {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-reject {
background: var(--muted);
color: var(--foreground);
}
.btn-reject:hover {
background: var(--border);
}
.btn-accept {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-accept:hover {
opacity: 0.9;
}
.card {
padding: var(--size);
background: var(--background-light);
@@ -54,6 +109,12 @@
font-size: 12px;
color: gray;
}
.description {
margin: 0;
text-align: center;
line-height: 1.5;
}
</style>
</head>
<body>
@@ -63,64 +124,31 @@
<span id="titleSpan" style="font-weight: 400 !important"></span>
</div>
<span
class="host-INSERT sam-align-self-center sam-text-muted"
style="font-weight: 500"
></span>
<!-- Card for getPublicKey -->
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your public key</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your public key</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for getRelays -->
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your relays</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your relays</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for signEvent -->
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">sign an event</b> (kind
<span id="kindSpan"></span>) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">sign an event</b> (kind <span id="kindSpan"></span>)
for the selected identity <b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for signEvent -->
@@ -130,20 +158,11 @@
<!-- Card for nip04.encrypt -->
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.encrypt -->
@@ -153,20 +172,11 @@
<!-- Card for nip44.encrypt -->
<div id="cardNip44Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.encrypt -->
@@ -176,20 +186,11 @@
<!-- Card for nip04.decrypt -->
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.decrypt -->
@@ -199,72 +200,90 @@
<!-- Card for nip44.decrypt -->
<div id="cardNip44Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.decrypt -->
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
<div id="card2Nip44Decrypt_text" class="text"></div>
</div>
<!-- Card for webln.enable -->
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">connect to your Lightning wallet</b>.
</p>
</div>
<!-- Card for webln.getInfo -->
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your wallet info</b>.
</p>
</div>
<!-- Card for webln.sendPayment -->
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a Lightning payment</b> of
<b id="paymentAmountSpan" class="color-primary"></b>.
</p>
</div>
<!-- Card2 for webln.sendPayment (shows invoice) -->
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<div id="card2WeblnSendPayment_json" class="json"></div>
</div>
<!-- Card for webln.makeInvoice -->
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">create a Lightning invoice</b>
<span id="invoiceAmountSpan"></span>.
</p>
</div>
<!-- Card for webln.keysend -->
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a keysend payment</b>.
</p>
</div>
</div>
<!------------->
<!-- ACTIONS -->
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectAlwaysButton" class="dropdown-item">
Reject Always
</button>
</li>
</ul>
<div class="actions">
<div class="action-row">
<span class="action-label">Reject</span>
<div class="action-buttons">
<button id="rejectOnceButton" type="button" class="btn-reject">Once</button>
<button id="rejectAlwaysButton" type="button" class="btn-reject">Always</button>
</div>
</div>
<div class="btn-group">
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
Approve Always
</button>
<button
type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="approveOnceButton" class="dropdown-item">
Approve Once
</button>
</li>
</ul>
<div class="action-row">
<span class="action-label">Accept</span>
<div class="action-buttons">
<button id="approveOnceButton" type="button" class="btn-accept">Once</button>
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
</div>
</div>
<div class="action-row" id="allQueuedRow">
<span class="action-label">All Queued</span>
<div class="action-buttons">
<button id="rejectAllButton" type="button" class="btn-reject">Reject All</button>
<button id="approveAllButton" type="button" class="btn-accept">Approve All</button>
</div>
</div>
</div>
</div>

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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()

View File

@@ -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>

View File

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

Some files were not shown because too many files have changed in this diff Show More