14 Commits

Author SHA1 Message Date
woikos
482356a9e4 Release v1.2.2 - Simplify Cashu onboarding
- Remove storage info page, replace with simple backup reminder
- Add "Have you set up backups?" link to Configure Backups in settings
- Increase component style budget to 30kB

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:17:30 +01:00
woikos
a90eafbf18 Release v1.2.1 - Add quick-add mint list to Cashu wallet
- Add suggested mints list (Minibits, Coinos, 21Mint, Macadamia, Stablenut)
- Show quick-add menu on empty Cashu page with + icon and descriptions
- Add collapsible "Quick Add" disclosure when mints exist
- Hide already-added mints from the list
- Closes #6

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:00:48 +01:00
woikos
58e9053867 Release v1.2.0 - Streamlined vault creation flow
- Remove sync preference welcome page, default to no-sync
- Redesign vault-create home with nickname + nsec input
- Add generate key button, visibility toggle, clipboard copy
- Add vault file import with persistent snapshot list
- Navigate to profile view after identity creation
- Fix router state access for identity data passing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 17:30:42 +01:00
woikos
5183a4fc0a Add Unlicense (public domain)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 13:05:26 +01:00
woikos
a2d0a9bd32 Add privacy policy for extension store submissions
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:45:39 +01:00
woikos
5cf0fed4ed Add store screenshots and descriptions for Chrome/Firefox
- Chrome Web Store screenshots (1280x800)
- Firefox AMO screenshots (1280x800)
- Store listing descriptions for both platforms
- Source screenshots from extension UI

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:31:24 +01:00
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
169 changed files with 9323 additions and 1446 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.

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

68
PRIVACY_POLICY.md Normal file
View File

@@ -0,0 +1,68 @@
# Privacy Policy
**Plebeian Signer** is a browser extension for managing Nostr identities and signing events. This privacy policy explains how the extension handles your data.
## Data Collection
**Plebeian Signer does not collect, store, or transmit any user data to external servers.**
All data remains on your device under your control.
## Data Storage
The extension stores the following data locally in your browser:
- **Encrypted vault**: Your Nostr private keys, encrypted with your password using Argon2id + AES-256-GCM
- **Identity metadata**: Display names, profile information you configure
- **Permissions**: Your allow/deny decisions for websites
- **Cashu wallet data**: Mint connections and ecash tokens you store
- **Preferences**: Extension settings (sync mode, reckless mode, etc.)
This data is stored using your browser's built-in storage APIs and never leaves your device unless you enable browser sync (in which case it syncs through your browser's own sync service, not ours).
## External Connections
The extension only makes external network requests in the following cases:
1. **Cashu mints**: When you explicitly add a Cashu mint and perform wallet operations (deposit, send, receive), the extension connects to that mint's URL. You choose which mints to connect to.
2. **No other external connections**: The extension does not connect to any analytics services, tracking pixels, telemetry endpoints, or any servers operated by the developers.
## Third-Party Services
Plebeian Signer does not integrate with any third-party services. The only external services involved are:
- **Cashu mints**: User-configured ecash mints for wallet functionality
- **Browser sync** (optional): Your browser's native sync service if you enable vault syncing
## Data Sharing
We do not share any data because we do not have access to any data. Your private keys and all extension data remain encrypted on your device.
## Security
- Private keys are encrypted at rest using Argon2id key derivation and AES-256-GCM encryption
- Keys are never exposed to websites — only signatures are provided
- The vault locks automatically and requires your password to unlock
## Your Rights
Since all data is stored locally on your device:
- **Access**: View your data anytime in the extension
- **Delete**: Uninstall the extension or clear browser data to remove all stored data
- **Export**: Use the extension's export features to backup your data
## Changes to This Policy
Any changes to this privacy policy will be reflected in the extension's repository and release notes.
## Contact
For questions about this privacy policy, please open an issue at the project repository.
---
**Last updated**: January 2026
**Extension**: Plebeian Signer v1.1.5

View File

@@ -51,8 +51,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "20kB",
"maximumError": "25kB"
"maximumWarning": "25kB",
"maximumError": "30kB"
}
],
"optimization": {
@@ -154,8 +154,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "20kB",
"maximumError": "25kB"
"maximumWarning": "25kB",
"maximumError": "30kB"
}
],
"optimization": {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.0.8",
"version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plebeian-signer",
"version": "v1.0.8",
"version": "1.2.2",
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",

View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.0.10",
"version": "1.2.2",
"custom": {
"chrome": {
"version": "v1.0.10"
"version": "v1.1.6"
},
"firefox": {
"version": "v1.0.10"
"version": "v1.1.6"
}
},
"scripts": {
@@ -74,5 +74,6 @@
"rimraf": "^6.0.1",
"typescript": "~5.6.2",
"typescript-eslint": "8.18.0"
}
},
"license": "Unlicense"
}

Binary file not shown.

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.6",
"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

@@ -4,7 +4,6 @@ import { VaultLoginComponent } from './components/vault-login/vault-login.compon
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
import { HomeComponent as VaultCreateHomeComponent } from './components/vault-create/home/home.component';
import { NewComponent as VaultCreateNewComponent } from './components/vault-create/new/new.component';
import { WelcomeComponent } from './components/welcome/welcome.component';
import { IdentitiesComponent } from './components/home/identities/identities.component';
import { IdentityComponent } from './components/home/identity/identity.component';
import { InfoComponent } from './components/home/info/info.component';
@@ -17,6 +16,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';
@@ -24,10 +24,6 @@ import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelis
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
export const routes: Routes = [
{
path: 'welcome',
component: WelcomeComponent,
},
{
path: 'vault-login',
component: VaultLoginComponent,
@@ -112,6 +108,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>
@@ -50,73 +57,41 @@
<div class="lightning-section">
@if (mints.length === 0) {
<div class="cashu-onboarding">
@if (showCashuInfo) {
<div class="info-panel">
<h3>Welcome to Cashu Wallet</h3>
<div class="empty-state">
<span class="sam-text-muted">No mints connected yet.</span>
<div class="info-section">
<h4>Storage Considerations</h4>
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
<div class="warning-box">
<p><strong>Browser Sync is enabled</strong></p>
<p>
Sync storage is limited to ~100KB shared across all your vault data
(identities, permissions, relays, and Cashu tokens). This limits
your Cashu wallet to approximately 300-400 tokens.
</p>
<p>
For larger Cashu holdings, consider disabling browser sync which
provides ~5MB of local storage (~18,000+ tokens).
</p>
<button class="link-btn" (click)="navigateToSettings()">
Change Sync Settings
</button>
</div>
} @else {
<div class="success-box">
<p><strong>Local Storage Mode</strong></p>
<p>
You have ~5MB of local storage available, which can hold
thousands of Cashu tokens. Your data stays on this device only.
</p>
</div>
<!-- Suggested mints for quick-add -->
<div class="quick-add-section">
<div class="quick-add-label">Quick Add a Mint</div>
<div class="quick-add-menu">
@for (mint of suggestedMints; track mint.url) {
@if (!isMintAlreadyAdded(mint.url)) {
<button
class="quick-add-item"
[disabled]="addingMint"
(click)="quickAddMint(mint)"
>
<span class="mint-row">
<span class="add-icon">+</span>
<span class="mint-name">{{ mint.name }}</span>
</span>
<span class="mint-desc">{{ mint.description }}</span>
</button>
}
}
</div>
@if (mintError) {
<div class="error-message small">{{ mintError }}</div>
}
</div>
<div class="info-section">
<h4>Backup Your Wallet</h4>
<p>
<strong>Important:</strong> Cashu tokens are bearer assets.
If you lose your vault backup, you lose your tokens permanently.
</p>
<p>
Vault exports are saved to your browser's downloads folder.
Configure this to point to either:
</p>
<ul>
<li>Your backup storage device (external drive, NAS)</li>
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
</ul>
<p class="browser-url">
<code>{{ browserDownloadSettingsUrl }}</code>
</p>
<div class="backup-reminder">
<span>Have you set up backups?</span>
<button class="link-btn" (click)="navigateToSettings()">
Go to Backup Settings
Configure Backups
</button>
</div>
<button class="dismiss-btn" (click)="dismissCashuInfo()">
Got it, let me add a mint
</button>
</div>
} @else {
<div class="empty-state">
<span class="sam-text-muted">No mints connected yet.</span>
<button class="show-info-btn" (click)="showCashuInfo = true">
Show storage info
</button>
</div>
}
</div>
} @else {
<div class="wallet-list">
@@ -127,6 +102,33 @@
</button>
}
</div>
<!-- Quick add disclosure when mints exist -->
@if (hasUnavailableMints()) {
<details class="quick-add-disclosure">
<summary>Quick Add</summary>
<div class="quick-add-menu">
@for (mint of suggestedMints; track mint.url) {
@if (!isMintAlreadyAdded(mint.url)) {
<button
class="quick-add-item"
[disabled]="addingMint"
(click)="quickAddMint(mint)"
>
<span class="mint-row">
<span class="add-icon">+</span>
<span class="mint-name">{{ mint.name }}</span>
</span>
<span class="mint-desc">{{ mint.description }}</span>
</button>
}
}
</div>
@if (mintError) {
<div class="error-message small">{{ mintError }}</div>
}
</details>
}
}
<button class="add-wallet-btn" (click)="showAddMint()">
<span class="emoji">+</span>
@@ -203,6 +205,31 @@
<!-- Cashu add mint form -->
@else if (activeSection === 'cashu-add') {
<div class="add-wallet-form">
<!-- Suggested mints -->
<div class="suggested-mints">
<div class="suggested-label">Quick Add</div>
<div class="suggested-list">
@for (mint of suggestedMints; track mint.url) {
<button
class="suggested-mint-btn"
[class.already-added]="isMintAlreadyAdded(mint.url)"
[disabled]="isMintAlreadyAdded(mint.url) || addingMint"
(click)="selectSuggestedMint(mint)"
[title]="mint.description"
>
<span class="mint-name">{{ mint.name }}</span>
@if (isMintAlreadyAdded(mint.url)) {
<span class="added-badge"></span>
}
</button>
}
</div>
</div>
<div class="form-divider">
<span>or enter manually</span>
</div>
<div class="form-group">
<label for="mintName">Mint Name</label>
<input

View File

@@ -1134,3 +1134,198 @@
padding: var(--size);
}
}
// Suggested mints quick-add
.suggested-mints {
display: flex;
flex-direction: column;
gap: var(--size-h);
&.centered {
align-items: center;
margin-top: var(--size);
.suggested-list {
justify-content: center;
}
}
.suggested-label {
font-size: 0.75rem;
color: var(--muted-foreground);
text-transform: uppercase;
font-weight: 500;
}
.suggested-list {
display: flex;
flex-wrap: wrap;
gap: var(--size-h);
}
.suggested-mint-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: var(--background-light);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.8rem;
color: var(--foreground);
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--background-light-hover);
border-color: var(--primary);
}
&:disabled {
cursor: not-allowed;
}
&.already-added {
opacity: 0.5;
background: transparent;
.added-badge {
color: var(--success, #22c55e);
font-size: 0.7rem;
}
}
.mint-name {
font-weight: 500;
}
}
}
.form-divider {
display: flex;
align-items: center;
gap: var(--size);
color: var(--muted-foreground);
font-size: 0.75rem;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
}
// Quick add section (empty state)
.quick-add-section {
width: 100%;
margin-top: var(--size);
.quick-add-label {
font-size: 0.75rem;
color: var(--muted-foreground);
text-transform: uppercase;
font-weight: 500;
text-align: center;
margin-bottom: var(--size-h);
}
}
// Backup reminder
.backup-reminder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-h);
margin-top: var(--size);
padding-top: var(--size);
border-top: 1px solid var(--border);
span {
font-size: 0.8rem;
color: var(--muted-foreground);
}
}
// Quick add disclosure (when mints exist)
.quick-add-disclosure {
margin-top: var(--size-h);
summary {
font-size: 0.8rem;
color: var(--muted-foreground);
cursor: pointer;
padding: var(--size-h);
text-align: center;
user-select: none;
&:hover {
color: var(--foreground);
}
&::marker {
color: var(--muted-foreground);
}
}
&[open] summary {
margin-bottom: var(--size-h);
}
}
// Quick add menu (shared by both)
.quick-add-menu {
display: flex;
flex-direction: column;
gap: 2px;
background: var(--background-light);
border-radius: var(--radius-md);
overflow: hidden;
}
.quick-add-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: var(--size-h) var(--size);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background: var(--background-light-hover);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.mint-row {
display: flex;
align-items: center;
gap: var(--size-h);
}
.add-icon {
font-size: 1rem;
font-weight: 600;
color: var(--success, #22c55e);
}
.mint-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
}
.mint-desc {
font-size: 0.75rem;
color: var(--muted-foreground);
padding-left: calc(1rem + var(--size-h));
}
}

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);
@@ -124,6 +123,15 @@ export class WalletComponent implements OnInit, OnDestroy {
refreshingMint = false;
refreshError = '';
// Suggested mints for quick-add
readonly suggestedMints = [
{ name: 'Minibits', url: 'https://mint.minibits.cash', description: 'Well-established mobile wallet mint' },
{ name: 'Coinos', url: 'https://mint.coinos.io', description: 'Lightning wallet with Cashu integration' },
{ name: '21Mint', url: 'https://21mint.me', description: 'Community mint' },
{ name: 'Macadamia', url: 'https://mint.macadamia.cash', description: 'Reliable community mint' },
{ name: 'Stablenut (USD)', url: 'https://stablenut.umint.cash', unit: 'usd', description: 'USD-denominated mint' },
];
get title(): string {
switch (this.activeSection) {
case 'cashu':
@@ -195,7 +203,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) {
@@ -500,6 +508,35 @@ export class WalletComponent implements OnInit, OnDestroy {
}
}
selectSuggestedMint(mint: { name: string; url: string }) {
this.newMintName = mint.name;
this.newMintUrl = mint.url;
this.mintError = '';
this.mintTestResult = '';
}
isMintAlreadyAdded(mintUrl: string): boolean {
return this.mints.some(m => m.mintUrl === mintUrl);
}
hasUnavailableMints(): boolean {
return this.suggestedMints.some(m => !this.isMintAlreadyAdded(m.url));
}
async quickAddMint(mint: { name: string; url: string }) {
this.addingMint = true;
this.mintError = '';
try {
await this.cashuService.addMint(mint.name, mint.url);
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Failed to add mint';
} finally {
this.addingMint = false;
}
}
async deleteMint() {
if (!this.selectedMintId) return;
@@ -937,7 +974,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

@@ -1,32 +1,120 @@
<div class="vertically-centered">
<div class="sam-flex-column center">
<div class="sam-flex-column gap" style="align-items: center">
<span class="title">Plebeian Signer</span>
<div class="container">
<div class="logo-section">
<div class="logo-frame">
<img src="logo.svg" height="80" width="80" alt="" />
</div>
<span class="title">Plebeian Signer</span>
</div>
<div class="logo-frame">
<img src="logo.svg" height="120" width="120" alt="" />
</div>
<!-- New Identity Section -->
<div class="section">
<h2 class="section-heading">Restore or Create New Identity</h2>
<span class="section-note">Create a new nostr identity or paste in your current nsec.</span>
<input
type="text"
class="form-control"
placeholder="nickname"
[(ngModel)]="nickname"
/>
<div class="input-group">
<input
#nsecInputElement
type="password"
class="form-control"
placeholder="nsec or hex private key"
[(ngModel)]="nsecInput"
(ngModelChange)="validateNsec()"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleVisibility(nsecInputElement)"
title="toggle visibility"
>
<i
class="bi"
[class.bi-eye]="nsecInputElement.type === 'password'"
[class.bi-eye-slash]="nsecInputElement.type === 'text'"
></i>
</button>
<button
class="btn btn-outline-secondary"
type="button"
(click)="copyToClipboard()"
title="copy to clipboard"
>
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="button-row">
<button
type="button"
class="sam-mt-2 btn btn-primary"
(click)="router.navigateByUrl('/vault-create/new')"
class="btn btn-outline-secondary generate-btn"
(click)="generateKey()"
title="generate new key"
>
<div class="sam-flex-row gap-h">
<i class="bi bi-plus-circle" style="height: 22px"></i>
<span>Create a new vault</span>
</div>
<span>generate</span>
<span></span>
</button>
<span class="sam-text-muted">or</span>
<button
type="button"
class="btn btn-secondary"
(click)="router.navigateByUrl('/vault-import')"
class="btn btn-primary continue-btn"
[disabled]="!isNsecValid || !nickname"
(click)="onContinueWithNsec()"
>
<span>Import a vault</span>
<span>Continue</span>
<i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
<!-- Import Section -->
<div class="section">
<h2 class="section-heading">Import a Vault</h2>
<input
#fileInput
type="file"
class="file-input"
accept=".json"
(change)="onFileSelected($event)"
/>
<div class="import-controls">
<button
type="button"
class="btn btn-outline-secondary file-btn"
(click)="fileInput.click()"
>
<i class="bi bi-folder2-open"></i>
<span>Add vault file</span>
</button>
@if (snapshots.length > 0) {
<div class="import-row">
<select class="form-select" [(ngModel)]="selectedSnapshot">
@for (snapshot of snapshots; track snapshot.id) {
<option [ngValue]="snapshot">
{{ snapshot.fileName }} ({{ snapshot.identityCount }} identities)
</option>
}
</select>
<button
type="button"
class="btn btn-primary icon-btn"
[disabled]="!selectedSnapshot"
(click)="onImport()"
title="import vault"
>
<i class="bi bi-arrow-right"></i>
</button>
</div>
}
</div>
</div>
</div>

View File

@@ -2,18 +2,26 @@
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
.vertically-centered {
height: 100%;
.container {
display: flex;
justify-content: center;
flex-direction: column;
padding: var(--size);
gap: var(--size);
}
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-half);
padding-bottom: var(--size-half);
}
.title {
font-size: 20px;
font-weight: 500;
margin-bottom: var(--size);
}
.logo-frame {
@@ -21,8 +29,73 @@
border-radius: 100%;
}
.section {
display: flex;
flex-direction: column;
gap: var(--size);
margin-top: var(--size);
}
.section-heading {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.section-note {
font-size: 14px;
color: var(--muted-foreground);
}
.button-row {
display: flex;
gap: var(--size);
justify-content: flex-end;
}
.generate-btn {
display: flex;
align-items: center;
gap: var(--size-half);
}
.continue-btn {
display: flex;
align-items: center;
gap: var(--size-half);
}
.file-input {
position: absolute;
visibility: hidden;
}
.file-btn {
display: flex;
align-items: center;
gap: var(--size-half);
}
.import-controls {
display: flex;
flex-direction: column;
gap: var(--size);
}
.import-row {
display: flex;
gap: var(--size-half);
select {
flex: 1;
}
}
.icon-btn {
width: 42px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@@ -1,13 +1,161 @@
import { Component, inject } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent } from '@common';
import {
NavComponent,
NostrHelper,
StorageService,
StartupService,
SignerMetaData_VaultSnapshot,
BrowserSyncData,
} from '@common';
import { generateSecretKey } from 'nostr-tools';
import { bytesToHex } from '@noble/hashes/utils';
import { v4 as uuidv4 } from 'uuid';
import browser from 'webextension-polyfill';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
const VAULT_SNAPSHOTS_KEY = 'vaultSnapshots';
@Component({
selector: 'app-home',
imports: [],
imports: [FormsModule],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent extends NavComponent {
export class HomeComponent extends NavComponent implements OnInit {
readonly router = inject(Router);
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
nickname = '';
nsecInput = '';
isNsecValid = false;
snapshots: SignerMetaData_VaultSnapshot[] = [];
selectedSnapshot: SignerMetaData_VaultSnapshot | undefined;
ngOnInit(): void {
this.#loadSnapshots();
}
generateKey() {
const sk = generateSecretKey();
const privkey = bytesToHex(sk);
this.nsecInput = NostrHelper.privkey2nsec(privkey);
this.validateNsec();
}
toggleVisibility(element: HTMLInputElement) {
element.type = element.type === 'password' ? 'text' : 'password';
}
async copyToClipboard() {
if (this.nsecInput) {
await navigator.clipboard.writeText(this.nsecInput);
}
}
validateNsec() {
if (!this.nsecInput) {
this.isNsecValid = false;
return;
}
try {
NostrHelper.getNostrPrivkeyObject(this.nsecInput.toLowerCase());
this.isNsecValid = true;
} catch {
this.isNsecValid = false;
}
}
onContinueWithNsec() {
if (!this.isNsecValid || !this.nickname) {
return;
}
// Navigate to password step, passing nsec and nickname in state
this.router.navigateByUrl('/vault-create/new', {
state: { nsec: this.nsecInput, nickname: this.nickname },
});
}
async onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) {
return;
}
try {
const file = files[0];
const text = await file.text();
const vault = JSON.parse(text) as BrowserSyncData;
// Check if file already exists
if (this.snapshots.some((s) => s.fileName === file.name)) {
input.value = '';
return;
}
const newSnapshot: SignerMetaData_VaultSnapshot = {
id: uuidv4(),
fileName: file.name,
createdAt: new Date().toISOString(),
data: vault,
identityCount: vault.identities?.length ?? 0,
reason: 'manual',
};
this.snapshots = [...this.snapshots, newSnapshot].sort((a, b) =>
b.fileName.localeCompare(a.fileName)
);
this.selectedSnapshot = newSnapshot;
await this.#saveSnapshots();
} catch (error) {
console.error('Failed to load vault file:', error);
}
// Reset input so same file can be selected again
input.value = '';
}
async onImport() {
if (!this.selectedSnapshot) {
return;
}
try {
await this.#storage.deleteVault(true);
await this.#storage.importVault(this.selectedSnapshot.data);
// Restart the app to properly reinitialize and route to vault-login
this.#storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to import vault:', error);
}
}
async #loadSnapshots() {
const data = (await browser.storage.local.get(VAULT_SNAPSHOTS_KEY)) as {
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
};
this.snapshots = data.vaultSnapshots
? [...data.vaultSnapshots].sort((a, b) =>
b.fileName.localeCompare(a.fileName)
)
: [];
if (this.snapshots.length > 0) {
this.selectedSnapshot = this.snapshots[0];
}
}
async #saveSnapshots() {
await browser.storage.local.set({
[VAULT_SNAPSHOTS_KEY]: this.snapshots,
});
}
}

View File

@@ -1,7 +1,12 @@
import { Component, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
import {
LoggerService,
NavComponent,
StorageService,
DerivingModalComponent,
} from '@common';
@Component({
selector: 'app-new',
@@ -18,6 +23,15 @@ export class NewComponent extends NavComponent {
readonly #storage = inject(StorageService);
readonly #logger = inject(LoggerService);
// Access router state via history.state (persists after navigation completes)
get #nsec(): string | undefined {
return history.state?.nsec;
}
get #nickname(): string | undefined {
return history.state?.nickname;
}
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
element.type = 'text';
@@ -35,9 +49,22 @@ export class NewComponent extends NavComponent {
this.derivingModal.show('Creating secure vault');
try {
await this.#storage.createNewVault(this.password);
this.derivingModal.hide();
this.#logger.logVaultCreated();
this.#router.navigateByUrl('/home/identities');
// If nsec and nickname were passed, add the identity
if (this.#nsec && this.#nickname) {
try {
await this.#storage.addIdentity({
nick: this.#nickname,
privkeyString: this.#nsec,
});
} catch (error) {
console.error('Failed to add identity:', error);
}
}
this.derivingModal.hide();
this.#router.navigateByUrl('/home/identity');
} catch (error) {
this.derivingModal.hide();
console.error('Failed to create vault:', error);

View File

@@ -1,64 +0,0 @@
<div class="sam-text-header sam-mb-h">
<span>Plebeian Signer Setup - Sync Preference</span>
</div>
<span class="sam-text-muted sam-text-md sam-text-align-center2">
Plebeian Signer always encrypts sensitive data like private keys and site permissions
independent of the chosen sync mode.
</span>
<span class="sam-mt sam-text-lg">Sync : Google Chrome</span>
<span class="sam-text-muted sam-text-md sam-text-align-center2">
Your encrypted data is synced between browser instances. You need to be signed
in with your account.
</span>
<button
type="button"
class="sam-mt btn btn-primary"
(click)="onClickSync(true)"
>
<span> Sync ON</span>
</button>
<span class="sam-mt sam-text-lg">Offline</span>
<span class="sam-text-muted sam-text-md">
Your encrypted data is never uploaded to any servers. It remains in your local
browser instance.
</span>
<button
type="button"
class="sam-mt sam-mb-2 btn btn-secondary"
(click)="onClickSync(false)"
>
<span> Sync OFF</span>
</button>
<div class="storage-info">
<details>
<summary>Important for Cashu wallet users</summary>
<p>
Browser sync storage is limited to ~100KB shared across all data
(identities, permissions, relays, and Cashu tokens).
</p>
<p>
If you plan to use the Cashu ecash wallet with significant balances,
choose <strong>"Sync OFF"</strong> which provides ~5MB of local storage
(enough for ~18,000+ tokens vs ~300-400 with sync).
</p>
<p>
<strong>Note:</strong> Cashu tokens are bearer assets. If you lose your
vault backup, you lose your tokens permanently. Make sure to configure
regular backups.
</p>
</details>
</div>
<div class="sam-flex-grow"></div>
<span class="sam-text-muted sam-text-md sam-mb">
Your preference can later be changed at any time.
</span>

View File

@@ -1,46 +0,0 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
}
.storage-info {
margin-top: 1rem;
width: 100%;
details {
background: rgba(255, 193, 7, 0.1);
border: 1px solid var(--warning, #ffc107);
border-radius: 6px;
padding: 0.5rem;
summary {
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
color: var(--warning, #ffc107);
&:hover {
text-decoration: underline;
}
}
p {
margin: 0.75rem 0 0 0;
font-size: 0.85rem;
line-height: 1.4;
color: var(--text-muted, #6c757d);
&:last-child {
margin-bottom: 0.5rem;
}
strong {
color: var(--text, #212529);
}
}
}
}

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { WelcomeComponent } from './welcome.component';
describe('WelcomeComponent', () => {
let component: WelcomeComponent;
let fixture: ComponentFixture<WelcomeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WelcomeComponent]
})
.compileComponents();
fixture = TestBed.createComponent(WelcomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,41 +0,0 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { BrowserSyncFlow, StorageService } from '@common';
@Component({
selector: 'app-welcome',
imports: [],
templateUrl: './welcome.component.html',
styleUrl: './welcome.component.scss',
})
export class WelcomeComponent {
readonly router = inject(Router);
readonly #storage = inject(StorageService);
async onClickSync(enabled: boolean) {
const flow: BrowserSyncFlow = enabled
? BrowserSyncFlow.BROWSER_SYNC
: BrowserSyncFlow.NO_SYNC;
await this.#storage.enableBrowserSyncFlow(flow);
// In case the user has selected the BROWSER_SYNC flow,
// we have to check if there is sync data available (e.g. from
// another browser instance).
// If so, navigate to /vault-login, otherwise to /vault-create/home.
if (flow === BrowserSyncFlow.BROWSER_SYNC) {
const browserSyncData =
await this.#storage.loadAndMigrateBrowserSyncData();
if (
typeof browserSyncData !== 'undefined' &&
Object.keys(browserSyncData).length > 0
) {
await this.router.navigateByUrl('/vault-login');
return;
}
}
await this.router.navigateByUrl('/vault-create/home');
}
}

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

@@ -5,6 +5,7 @@ import {
StorageService,
StorageServiceConfig,
} from '../storage/storage.service';
import { SyncFlow } from '../storage/types';
@Injectable({
providedIn: 'root',
@@ -25,8 +26,9 @@ export class StartupService {
// Step 1: Load the user settings
const signerMetaData = await this.#storage.loadSignerMetaData();
if (typeof signerMetaData?.syncFlow === 'undefined') {
// Very first run. The user has not set up Plebeian Signer yet.
this.#router.navigateByUrl('/welcome');
// Very first run - default to NO_SYNC (sync can be enabled later via export/import)
await this.#storage.enableBrowserSyncFlow(SyncFlow.NO_SYNC);
this.#router.navigateByUrl('/vault-create/home');
return;
}
this.#storage.enableBrowserSyncFlow(signerMetaData.syncFlow);

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

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