20 Commits
v1.0.6 ... main

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
woikos
5ca6eb177c Release v1.0.10 - Add unlock popup for locked vault
- Add unlock popup window that appears when vault is locked and a NIP-07
  request is made (similar to permission prompt popup)
- Implement standalone vault unlock logic in background script using
  Argon2id key derivation and AES-GCM decryption
- Queue pending NIP-07 requests while waiting for unlock, process after
  success
- Add unlock.html and unlock.ts for both Chrome and Firefox extensions

Files modified:
- package.json (version bump to v1.0.10)
- projects/chrome/public/unlock.html (new)
- projects/chrome/src/unlock.ts (new)
- projects/chrome/src/background.ts
- projects/chrome/src/background-common.ts
- projects/chrome/custom-webpack.config.ts
- projects/chrome/tsconfig.app.json
- projects/firefox/public/unlock.html (new)
- projects/firefox/src/unlock.ts (new)
- projects/firefox/src/background.ts
- projects/firefox/src/background-common.ts
- projects/firefox/custom-webpack.config.ts
- projects/firefox/tsconfig.app.json

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 06:23:36 +01:00
woikos
ebc96e7201 Release v1.0.9 - Add wallet tab with Cashu and Lightning support
- Add wallet tab with NWC (Nostr Wallet Connect) Lightning support
- Add Cashu ecash wallet with mint management, send/receive tokens
- Add Cashu deposit feature (mint via Lightning invoice)
- Add token viewer showing proof amounts and timestamps
- Add refresh button with auto-refresh for spent proof detection
- Add browser sync warning for Cashu users on welcome screen
- Add Cashu onboarding info panel with storage considerations
- Add settings page sync info note explaining how to change sync
- Add backups page for vault snapshot management
- Add About section to identity (You) page
- Fix lint accessibility issues in wallet component

Files modified:
- projects/common/src/lib/services/nwc/* (new)
- projects/common/src/lib/services/cashu/* (new)
- projects/common/src/lib/services/storage/* (extended)
- projects/chrome/src/app/components/home/wallet/*
- projects/firefox/src/app/components/home/wallet/*
- projects/chrome/src/app/components/welcome/*
- projects/firefox/src/app/components/welcome/*
- projects/chrome/src/app/components/home/settings/*
- projects/firefox/src/app/components/home/settings/*
- projects/chrome/src/app/components/home/identity/*
- projects/firefox/src/app/components/home/identity/*
- projects/chrome/src/app/components/home/backups/* (new)
- projects/firefox/src/app/components/home/backups/* (new)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 15:40:25 +01:00
woikos
1f8d478cd7 Update repository URLs to GitHub
Change all references from git.mleku.dev/mleku/plebeian-signer to
github.com/PlebeianApp/plebeian-signer for the public release.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 13:45:23 +01:00
woikos
3750e99e61 Release v1.0.8 - Add wallet tab and UI improvements
- Add new wallet tab with placeholder for future functionality
- Reorder bottom tabs: You, Identities, Wallet, Bookmarks, Settings
- Move Reset Extension button to bottom-right corner on login page
- Improve header layout consistency across all pages
- Add custom scrollbar styling for Chrome (thin, dark track, light thumb)
- Update edit button to use emoji icon on identity page
- Fix horizontal overflow issues in global styles

Files modified:
- package.json (version bump)
- projects/{chrome,firefox}/src/app/app.routes.ts (wallet route)
- projects/{chrome,firefox}/src/app/components/home/wallet/* (new)
- projects/{chrome,firefox}/src/app/components/home/home.component.* (tabs)
- projects/{chrome,firefox}/src/app/components/vault-login/* (reset btn)
- projects/{chrome,firefox}/src/styles.scss (scrollbar, overflow)
- Various component HTML/SCSS files (header consistency)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:30:55 +01:00
woikos
2c1f3265b7 Release v1.0.7 - Move lock button to page headers
- Move lock button from bottom navigation bar to each page header
- Add lock button styling to common SCSS with hover effect
- Remove logs and info tabs from bottom navigation
- Standardize header height to 48px across all pages
- Simplify home component by removing lock logic

Files modified:
- package.json, manifest.json (both browsers)
- home.component.html/ts (both browsers)
- identities, identity, bookmarks, logs, info, settings components
- projects/common/src/lib/styles/_common.scss

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 05:15:21 +01:00
woikos
7ff8e257dd Add prebuild script to fetch event kinds from nostr library
- Add scripts/fetch-kinds.js to fetch kinds.json from central source
- Add event-kinds.ts with TypeScript types and 184 event kinds
- Update package.json build scripts to fetch kinds before building
- Source: https://git.mleku.dev/mleku/nostr/raw/branch/main/encoders/kind/kinds.json

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 05:08:00 +01:00
216 changed files with 22597 additions and 1326 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

@@ -28,7 +28,7 @@ The repository is configured as monorepo to hold the extensions for Chrome and F
To build and run the Chrome extension from this code:
```
git clone https://git.mleku.dev/mleku/plebeian-signer
git clone https://github.com/PlebeianApp/plebeian-signer.git
cd plebeian-signer
npm ci
npm run build:chrome
@@ -46,7 +46,7 @@ then
To build and run the Firefox extension from this code:
```
git clone https://git.mleku.dev/mleku/plebeian-signer
git clone https://github.com/PlebeianApp/plebeian-signer.git
cd plebeian-signer
npm ci
npm run build:firefox

View File

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

View File

@@ -86,7 +86,7 @@ You have full control over your data:
## Open Source
Plebeian Signer is open source software. You can audit the code yourself:
- Repository: https://git.mleku.dev/mleku/plebeian-signer
- Repository: https://github.com/PlebeianApp/plebeian-signer
## Children's Privacy
@@ -99,7 +99,7 @@ If we make changes to this privacy policy, we will update the "Last Updated" dat
## Contact
For privacy-related questions or concerns, please open an issue on our repository:
https://git.mleku.dev/mleku/plebeian-signer/issues
https://github.com/PlebeianApp/plebeian-signer/issues
---

View File

@@ -69,7 +69,7 @@ You need to host the privacy policy at a public URL. Options:
2. **Simple webpage** - Create a basic HTML page
3. **Gist** - Create a public GitHub gist
Example URL format: `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md`
Example URL format: `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md`
---
@@ -102,8 +102,8 @@ Example URL format: `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main
- Upload promotional tiles if you have them
**Additional Fields:**
- **Official URL:** `https://git.mleku.dev/mleku/plebeian-signer`
- **Support URL:** `https://git.mleku.dev/mleku/plebeian-signer/issues`
- **Official URL:** `https://github.com/PlebeianApp/plebeian-signer`
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
### Step 4: Privacy Tab
@@ -181,8 +181,8 @@ Firefox may request source code because the extension uses bundled/minified Java
- **Categories:** Privacy & Security
**Additional Details:**
- **Homepage:** `https://git.mleku.dev/mleku/plebeian-signer`
- **Support URL:** `https://git.mleku.dev/mleku/plebeian-signer/issues`
- **Homepage:** `https://github.com/PlebeianApp/plebeian-signer`
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
- **License:** Select appropriate license
- **Privacy Policy:** Paste URL to hosted privacy policy

View File

@@ -66,8 +66,8 @@ Plebeian Signer is open source and respects your privacy:
### Links
- Source Code: https://git.mleku.dev/mleku/plebeian-signer
- Report Issues: https://git.mleku.dev/mleku/plebeian-signer/issues
- Source Code: https://github.com/PlebeianApp/plebeian-signer
- Report Issues: https://github.com/PlebeianApp/plebeian-signer/issues
---

View File

@@ -5,7 +5,7 @@ Developer accounts are set up. This document covers the remaining steps.
## Privacy Policy URL
```
https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md
https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md
```
## Screenshots Needed
@@ -48,7 +48,7 @@ Upload your screenshots.
| Field | Value |
|-------|-------|
| Single Purpose | Manage Nostr identities and sign cryptographic events for web applications |
| Privacy Policy URL | `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md` |
| Privacy Policy URL | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
**Permission Justifications:**
@@ -100,9 +100,9 @@ Build instructions to provide:
| Summary | Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility. |
| Description | Copy from `docs/store/STORE_DESCRIPTION.md` |
| Categories | Privacy & Security |
| Homepage | `https://git.mleku.dev/mleku/plebeian-signer` |
| Support URL | `https://git.mleku.dev/mleku/plebeian-signer/issues` |
| Privacy Policy | `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md` |
| Homepage | `https://github.com/PlebeianApp/plebeian-signer` |
| Support URL | `https://github.com/PlebeianApp/plebeian-signer/issues` |
| Privacy Policy | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
Upload your screenshots.

325
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v0.0.9",
"version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plebeian-signer",
"version": "v0.0.9",
"version": "1.2.2",
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",
@@ -16,13 +16,16 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@cashu/cashu-ts": "^3.2.0",
"@nostr-dev-kit/ndk": "^2.11.0",
"@popperjs/core": "^2.11.8",
"@types/qrcode": "^1.5.6",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"buffer": "^6.0.3",
"hash-wasm": "^4.11.0",
"nostr-tools": "^2.10.4",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"webextension-polyfill": "^0.12.0",
@@ -36,6 +39,7 @@
"@types/bootstrap": "^5.2.10",
"@types/chrome": "^0.0.293",
"@types/jasmine": "~5.1.0",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.1",
"angular-eslint": "19.0.2",
"eslint": "^9.16.0",
@@ -4712,6 +4716,70 @@
"node": ">=6.9.0"
}
},
"node_modules/@cashu/cashu-ts": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.2.0.tgz",
"integrity": "sha512-wOdqenmPs92+5feU2GIg92QcdNmCdg4AIau7Lq6G/uw1t+t/osjygupr2dmDzdQx7JBWHHNoVaUDSJm1G8phYg==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"@scure/bip32": "^2.0.1"
},
"engines": {
"node": ">=22.4.0"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -8157,7 +8225,6 @@
"version": "22.13.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz",
"integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
@@ -8173,6 +8240,15 @@
"@types/node": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.9.18",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
@@ -8237,6 +8313,13 @@
"@types/node": "*"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/webextension-polyfill": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.12.1.tgz",
@@ -9245,7 +9328,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -9925,6 +10007,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001696",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz",
@@ -10192,7 +10283,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -10205,7 +10295,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
@@ -10643,6 +10732,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -10772,6 +10870,12 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
@@ -12083,7 +12187,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -16124,7 +16227,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -16273,7 +16375,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -16495,6 +16596,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
@@ -16744,6 +16854,177 @@
"node": ">=0.9"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -16952,7 +17233,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -16968,6 +17248,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -17646,6 +17932,12 @@
"node": ">= 0.8"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -19024,7 +19316,6 @@
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
@@ -19856,6 +20147,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wildcard": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
@@ -19877,7 +20174,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -19966,7 +20262,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -19976,14 +20271,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -19993,7 +20286,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -20008,7 +20300,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"

View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.0.6",
"version": "1.2.2",
"custom": {
"chrome": {
"version": "v1.0.6"
"version": "v1.1.6"
},
"firefox": {
"version": "v1.0.6"
"version": "v1.1.6"
}
},
"scripts": {
@@ -15,10 +15,11 @@
"clean:firefox": "rimraf dist/firefox",
"start:chrome": "ng serve chrome",
"start:firefox": "ng serve firefox",
"fetch-kinds": "node scripts/fetch-kinds.js",
"prepare:chrome": "./chrome_prepare_manifest.sh",
"prepare:firefox": "./firefox_prepare_manifest.sh",
"build:chrome": "npm run prepare:chrome && ng build chrome",
"build:firefox": "npm run prepare:firefox && ng build firefox",
"build:chrome": "npm run fetch-kinds && npm run prepare:chrome && ng build chrome",
"build:firefox": "npm run fetch-kinds && npm run prepare:firefox && ng build firefox",
"watch:chrome": "npm run prepare:chrome && ng build chrome --watch --configuration development",
"watch:firefox": "npm run prepare:firefox && ng build firefox --watch --configuration development",
"test": "ng test",
@@ -35,13 +36,16 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@cashu/cashu-ts": "^3.2.0",
"@nostr-dev-kit/ndk": "^2.11.0",
"@popperjs/core": "^2.11.8",
"@types/qrcode": "^1.5.6",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"buffer": "^6.0.3",
"hash-wasm": "^4.11.0",
"nostr-tools": "^2.10.4",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"webextension-polyfill": "^0.12.0",
@@ -55,6 +59,7 @@
"@types/bootstrap": "^5.2.10",
"@types/chrome": "^0.0.293",
"@types/jasmine": "~5.1.0",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.1",
"angular-eslint": "19.0.2",
"eslint": "^9.16.0",
@@ -69,5 +74,6 @@
"rimraf": "^6.0.1",
"typescript": "~5.6.2",
"typescript-eslint": "8.18.0"
}
},
"license": "Unlicense"
}

Binary file not shown.

View File

@@ -22,5 +22,9 @@ module.exports = {
import: 'src/options.ts',
runtime: false,
},
unlock: {
import: 'src/unlock.ts',
runtime: false,
},
},
} as Configuration;

View File

@@ -2,8 +2,8 @@
"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.6",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"version": "1.1.6",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [
"windows",

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

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html>
<head>
<title>Plebeian Signer - Unlock</title>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="scripts.js"></script>
<style>
/* Prevent white flash on load */
html { background-color: #0a0a0a; }
@media (prefers-color-scheme: light) {
html { background-color: #ffffff; }
}
body {
background: var(--background);
height: 100vh;
width: 100vw;
color: var(--foreground);
font-size: 16px;
margin: 0;
display: flex;
flex-direction: column;
}
.color-primary {
color: var(--primary);
}
.page {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
box-sizing: border-box;
}
.header {
text-align: center;
font-size: 1.25rem;
font-weight: 500;
padding: var(--size) 0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
}
.logo-frame {
border: 2px solid var(--secondary);
border-radius: 100%;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.logo-frame img {
display: block;
}
.input-group {
width: 100%;
max-width: 280px;
display: flex;
}
.input-group input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border);
border-right: none;
border-radius: 6px 0 0 6px;
background: var(--background);
color: var(--foreground);
font-size: 14px;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
}
.input-group button {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: var(--background-light);
color: var(--muted-foreground);
cursor: pointer;
}
.input-group button:hover {
background: var(--muted);
}
.unlock-btn {
width: 100%;
max-width: 280px;
padding: 10px 16px;
border: none;
border-radius: 6px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.unlock-btn:hover:not(:disabled) {
opacity: 0.9;
}
.unlock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.alert {
position: fixed;
bottom: var(--size);
left: 50%;
transform: translateX(-50%);
padding: 10px 16px;
border-radius: 6px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.alert-danger {
background: var(--destructive);
color: var(--destructive-foreground);
}
.hidden {
display: none !important;
}
.deriving-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 1000;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--muted);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.deriving-text {
color: var(--foreground);
font-size: 14px;
}
.host-info {
text-align: center;
font-size: 13px;
color: var(--muted-foreground);
margin-top: 8px;
}
.host-name {
color: var(--primary);
font-weight: 500;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<span class="brand">Plebeian Signer</span>
</div>
<div class="content">
<div class="logo-frame">
<img src="logo.svg" height="100" width="100" alt="" />
</div>
<div id="hostInfo" class="host-info hidden">
<span class="host-name" id="hostSpan"></span><br>
is requesting access
</div>
<div class="input-group sam-mt">
<input
id="passwordInput"
type="password"
placeholder="vault password"
autocomplete="current-password"
/>
<button id="togglePassword" type="button">
<i class="bi bi-eye"></i>
</button>
</div>
<button id="unlockBtn" type="button" class="unlock-btn" disabled>
<i class="bi bi-box-arrow-in-right"></i>
<span>Unlock</span>
</button>
</div>
</div>
<!-- Deriving overlay -->
<div id="derivingOverlay" class="deriving-overlay hidden">
<div class="spinner"></div>
<div class="deriving-text">Unlocking vault...</div>
</div>
<!-- Error alert -->
<div id="errorAlert" class="alert alert-danger hidden">
<i class="bi bi-exclamation-triangle"></i>
<span id="errorMessage">Invalid password</span>
</div>
<script src="unlock.js"></script>
</body>
</html>

View File

@@ -4,17 +4,19 @@ 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';
import { SettingsComponent } from './components/home/settings/settings.component';
import { LogsComponent } from './components/home/logs/logs.component';
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
import { WalletComponent } from './components/home/wallet/wallet.component';
import { BackupsComponent } from './components/home/backups/backups.component';
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
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';
@@ -22,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,
@@ -76,6 +74,14 @@ export const routes: Routes = [
path: 'bookmarks',
component: BookmarksComponent,
},
{
path: 'wallet',
component: WalletComponent,
},
{
path: 'backups',
component: BackupsComponent,
},
],
},
{
@@ -102,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,10 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
EncryptedVault,
BrowserSyncHandler,
Identity_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
StoredCashuMint,
StoredIdentity,
StoredNwcConnection,
StoredPermission,
StoredRelay,
} from '@common';
/**
@@ -24,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);
@@ -51,12 +53,26 @@ 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: StoredNwcConnection[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: StoredCashuMint[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
const props = Object.keys(await this.loadUnmigratedData());
await chrome.storage.local.remove(props);

View File

@@ -1,10 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
Identity_ENCRYPTED,
Permission_ENCRYPTED,
EncryptedVault,
StoredCashuMint,
StoredIdentity,
StoredNwcConnection,
StoredPermission,
BrowserSyncHandler,
Relay_ENCRYPTED,
StoredRelay,
} from '@common';
/**
@@ -16,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);
@@ -43,12 +45,26 @@ 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: StoredNwcConnection[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: StoredCashuMint[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
await chrome.storage.sync.clear();
}

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

@@ -0,0 +1,86 @@
<div class="sam-text-header">
<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>
<span>Backups</span>
</div>
<div class="backup-settings">
<div class="setting-row">
<label for="maxBackups">Max Auto Backups:</label>
<input
id="maxBackups"
type="number"
[value]="maxBackups"
min="1"
max="20"
(change)="onMaxBackupsChange($event)"
/>
</div>
<p class="setting-note">
Automatic backups are created when significant changes are made.
Manual and pre-restore backups are not counted toward this limit.
</p>
</div>
<button class="btn btn-primary create-btn" (click)="createManualBackup()">
Create Backup Now
</button>
<div class="backups-list">
@if (backups.length === 0) {
<div class="empty-state">
<span>No backups yet</span>
</div>
}
@for (backup of backups; track backup.id) {
<div class="backup-item">
<div class="backup-info">
<span class="backup-date">{{ formatDate(backup.createdAt) }}</span>
<div class="backup-meta">
<span class="backup-reason" [class]="getReasonClass(backup.reason)">
{{ getReasonLabel(backup.reason) }}
</span>
<span class="backup-identities">{{ backup.identityCount }} identity(ies)</span>
</div>
</div>
<div class="backup-actions">
<button
class="btn btn-sm btn-secondary"
(click)="
confirm.show(
'Restore this backup? A backup of your current state will be created first.',
restoreBackup.bind(this, backup.id)
)
"
[disabled]="restoringBackupId !== null"
>
{{ restoringBackupId === backup.id ? 'Restoring...' : 'Restore' }}
</button>
<button
class="btn btn-sm btn-danger"
(click)="
confirm.show(
'Delete this backup? This cannot be undone.',
deleteBackup.bind(this, backup.id)
)
"
>
Delete
</button>
</div>
</div>
}
</div>
<lib-confirm #confirm></lib-confirm>

View File

@@ -0,0 +1,192 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
gap: 12px;
}
.sam-text-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: bold;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.lock-btn,
.back-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
&:hover {
background: var(--muted);
}
.emoji {
font-size: 16px;
}
}
.backup-settings {
background: var(--muted);
padding: 12px;
border-radius: 8px;
}
.setting-row {
display: flex;
align-items: center;
gap: 12px;
label {
font-weight: 500;
}
input[type="number"] {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--foreground);
}
}
.setting-note {
margin-top: 8px;
font-size: 12px;
color: var(--muted-foreground);
}
.create-btn {
align-self: flex-start;
}
.backups-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 100px;
color: var(--muted-foreground);
}
.backup-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
gap: 12px;
}
.backup-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.backup-date {
font-weight: 500;
font-size: 13px;
}
.backup-meta {
display: flex;
gap: 8px;
font-size: 11px;
}
.backup-reason {
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
&.reason-auto {
background: var(--muted);
color: var(--muted-foreground);
}
&.reason-manual {
background: rgba(34, 197, 94, 0.2);
color: rgb(34, 197, 94);
}
&.reason-prerestore {
background: rgba(234, 179, 8, 0.2);
color: rgb(234, 179, 8);
}
}
.backup-identities {
color: var(--muted-foreground);
}
.backup-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
&:hover:not(:disabled) {
opacity: 0.9;
}
}
.btn-secondary {
background: var(--secondary);
color: var(--secondary-foreground);
&:hover:not(:disabled) {
background: var(--muted);
}
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: rgb(239, 68, 68);
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.3);
}
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}

View File

@@ -0,0 +1,125 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
ConfirmComponent,
LoggerService,
NavComponent,
SignerMetaData_VaultSnapshot,
StartupService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
@Component({
selector: 'app-backups',
templateUrl: './backups.component.html',
styleUrl: './backups.component.scss',
imports: [ConfirmComponent],
})
export class BackupsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
backups: SignerMetaData_VaultSnapshot[] = [];
maxBackups = 5;
restoringBackupId: string | null = null;
ngOnInit(): void {
this.loadBackups();
this.maxBackups = this.storage.getSignerMetaHandler().getMaxBackups();
}
loadBackups(): void {
this.backups = this.storage.getSignerMetaHandler().getBackups();
}
async onMaxBackupsChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value, 10);
if (!isNaN(value) && value >= 1 && value <= 20) {
this.maxBackups = value;
await this.storage.getSignerMetaHandler().setMaxBackups(value);
}
}
async createManualBackup(): Promise<void> {
const browserSyncData = this.storage.getBrowserSyncHandler().browserSyncData;
if (browserSyncData) {
await this.storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
this.loadBackups();
}
}
async restoreBackup(backupId: string): Promise<void> {
this.restoringBackupId = backupId;
try {
// First, create a pre-restore backup of current state
const currentData = this.storage.getBrowserSyncHandler().browserSyncData;
if (currentData) {
await this.storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
}
// Get the backup data
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);
this.#logger.logVaultImport('Backup Restore');
this.storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to restore backup:', error);
this.restoringBackupId = null;
}
}
async deleteBackup(backupId: string): Promise<void> {
await this.storage.getSignerMetaHandler().deleteBackup(backupId);
this.loadBackups();
}
formatDate(isoDate: string): string {
const date = new Date(isoDate);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
getReasonLabel(reason?: string): string {
switch (reason) {
case 'auto':
return 'Auto';
case 'manual':
return 'Manual';
case 'pre-restore':
return 'Pre-Restore';
default:
return 'Unknown';
}
}
getReasonClass(reason?: string): string {
switch (reason) {
case 'auto':
return 'reason-auto';
case 'manual':
return 'reason-manual';
case 'pre-restore':
return 'reason-prerestore';
default:
return '';
}
}
goBack(): void {
this.#router.navigateByUrl('/home/settings');
}
async onClickLock(): Promise<void> {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,19 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="bookmarks-header">
<span class="bookmarks-title">Bookmarks</span>
<button class="btn btn-primary btn-sm" (click)="onBookmarkThisPage()">
<span class="emoji">🔖</span> Bookmark This Page
<div class="sam-text-header">
<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>
</button>
</div>

View File

@@ -2,21 +2,41 @@
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
}
.bookmarks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size);
flex-shrink: 0;
}
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.bookmarks-title {
font-weight: 600;
font-size: 1.1rem;
.sam-text-header {
margin-bottom: var(--size);
flex-shrink: 0;
.add-btn {
position: absolute;
right: 0;
background: transparent;
border: none;
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-light);
}
.emoji {
font-size: 20px;
}
}
}
}
.bookmarks-container {

View File

@@ -1,5 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Bookmark, LoggerService, SignerMetaData } from '@common';
import { Router } from '@angular/router';
import { Bookmark, LoggerService, NavComponent, SignerMetaData } from '@common';
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
@Component({
@@ -8,9 +9,10 @@ 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 #router = inject(Router);
bookmarks: Bookmark[] = [];
isLoading = true;
@@ -87,4 +89,10 @@ export class BookmarksComponent implements OnInit {
return url;
}
}
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -3,46 +3,23 @@
</div>
<div class="tabs">
<a
class="tab"
routerLink="/home/identity"
routerLinkActive="active"
title="Your selected identity"
>
<a class="tab" routerLink="/home/identity" routerLinkActive="active" title="You">
<span class="emoji">👤</span>
</a>
<a
class="tab"
routerLink="/home/identities"
routerLinkActive="active"
title="Identities"
>
<a class="tab" routerLink="/home/identities" routerLinkActive="active" title="Identities">
<span class="emoji">👥</span>
</a>
<a
class="tab"
routerLink="/home/settings"
routerLinkActive="active"
title="Settings"
>
<span class="emoji">⚙️</span>
<a class="tab" routerLink="/home/wallet" routerLinkActive="active" title="Wallet">
<span class="emoji">💰</span>
</a>
<a class="tab" routerLink="/home/bookmarks" routerLinkActive="active" title="Bookmarks">
<span class="emoji">🔖</span>
</a>
<a class="tab" routerLink="/home/logs" routerLinkActive="active" title="Logs">
<span class="emoji">🪵</span>
<a class="tab" routerLink="/home/settings" routerLinkActive="active" title="Settings">
<span class="emoji">⚙️</span>
</a>
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<span class="emoji">💡</span>
</a>
<button class="tab" (click)="onClickLock()" title="Lock">
<span class="emoji">🔒</span>
</button>
</div>

View File

@@ -4,12 +4,12 @@
flex-direction: column;
.tab-content {
height: calc(100% - 40px);
height: calc(100% - 48px);
}
.tabs {
height: 40px;
min-height: 40px;
height: 48px;
min-height: 48px;
background: var(--background-light);
display: flex;
flex-direction: row;
@@ -23,7 +23,6 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--muted-foreground);
border-top: 2px solid transparent;
@@ -31,6 +30,10 @@
cursor: pointer;
.emoji {
font-size: 20px;
}
&:hover {
background: var(--background-light-hover);
color: var(--foreground);

View File

@@ -1,6 +1,5 @@
import { Component, inject } from '@angular/core';
import { Router, RouterModule, RouterOutlet } from '@angular/router';
import { LoggerService, StorageService } from '@common';
import { Component } from '@angular/core';
import { RouterModule, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-home',
@@ -8,14 +7,4 @@ import { LoggerService, StorageService } from '@common';
styleUrl: './home.component.scss',
imports: [RouterOutlet, RouterModule],
})
export class HomeComponent {
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #logger = inject(LoggerService);
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}
export class HomeComponent {}

View File

@@ -1,13 +1,20 @@
<!-- 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">
<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="button btn btn-primary btn-sm" (click)="onClickNewIdentity()">
<div class="sam-flex-row gap-h">
<i class="bi bi-plus-lg"></i>
<span>New</span>
</div>
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">
<span class="emoji"></span>
</button>
</div>

View File

@@ -3,37 +3,62 @@
display: flex;
flex-direction: column;
overflow-y: auto;
padding-left: var(--size);
padding-right: var(--size);
> *:not(.custom-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.custom-header {
padding-top: var(--size);
padding-bottom: var(--size);
height: 48px;
min-height: 48px;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto;
align-items: center;
background: var(--background);
position: relative;
.button {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
justify-self: end;
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.header-btn,
.add-btn {
background: transparent;
border: none;
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-light);
}
.emoji {
font-size: 20px;
}
}
.add-btn {
position: absolute;
right: 0;
}
.text {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 700;
letter-spacing: 0.1rem;
justify-self: center;
height: 32px;
}
}

View File

@@ -3,6 +3,8 @@ import { Router } from '@angular/router';
import {
IconButtonComponent,
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -16,10 +18,11 @@ 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);
// Cache of pubkey -> profile for quick lookup
#profileCache = new Map<string, ProfileMetadata | null>();
@@ -73,4 +76,10 @@ export class IdentitiesComponent implements OnInit {
onClickWhitelistedApps() {
this.#router.navigateByUrl('/whitelisted-apps');
}
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,19 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<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()">
<img src="edit.svg" alt="Edit" class="edit-icon" />
<span class="emoji">📝</span>
</button>
</div>
@@ -70,4 +80,12 @@
</div>
</div>
<!-- About section -->
@if (aboutText) {
<div class="about-section">
<div class="about-header">About</div>
<div class="about-content">{{ aboutText }}</div>
</div>
}
<lib-toast #toast></lib-toast>

View File

@@ -4,14 +4,12 @@
flex-direction: column;
.sam-text-header {
position: relative;
.edit-btn {
position: absolute;
right: var(--size);
right: 0;
background: transparent;
border: none;
padding: 4px;
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
@@ -23,10 +21,8 @@
background-color: var(--background-light);
}
.edit-icon {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
.emoji {
font-size: 20px;
}
}
}
@@ -189,4 +185,33 @@
opacity: 1;
}
}
.about-section {
margin: var(--size);
margin-top: 0;
flex-shrink: 0;
max-height: 150px;
display: flex;
flex-direction: column;
.about-header {
font-size: 0.85rem;
font-weight: 600;
color: var(--muted-foreground);
margin-bottom: var(--size-h);
}
.about-content {
flex: 1;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.5;
color: var(--foreground);
background: var(--background-light);
border-radius: var(--radius-sm);
padding: var(--size);
white-space: pre-wrap;
word-break: break-word;
}
}
}

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);
@@ -52,6 +51,10 @@ export class IdentityComponent implements OnInit {
return this.profile?.banner;
}
get aboutText(): string | undefined {
return this.profile?.about;
}
copyToClipboard(pubkey: string | undefined) {
if (!pubkey) {
return;
@@ -76,13 +79,19 @@ export class IdentityComponent implements OnInit {
this.#router.navigateByUrl('/profile-edit');
}
async onClickLock() {
this.#logger.logVaultLock();
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,4 +1,14 @@
<div class="sam-text-header">
<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>
@@ -8,9 +18,9 @@
<span> Source code</span>
<a
href="https://git.mleku.dev/mleku/plebeian-signer"
href="https://github.com/PlebeianApp/plebeian-signer"
target="_blank"
>
git.mleku.dev/mleku/plebeian-signer
github.com/PlebeianApp/plebeian-signer
</a>

View File

@@ -4,6 +4,13 @@
flex-direction: column;
align-items: center;
overflow-y: auto;
padding-left: var(--size);
padding-right: var(--size);
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.sam-text-header {
width: 100%;
}
}

View File

@@ -1,4 +1,6 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, NavComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
@@ -6,6 +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 #router = inject(Router);
version = packageJson.custom.chrome.version;
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,8 +1,18 @@
<div class="logs-header">
<span class="logs-title">Logs</span>
<div class="sam-text-header">
<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" (click)="onRefresh()">Refresh</button>
<button class="btn btn-sm btn-secondary" (click)="onClear()">Clear</button>
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>
<button class="btn btn-sm btn-secondary" title="Clear logs" (click)="onClear()">Clear</button>
</div>
</div>

View File

@@ -2,26 +2,26 @@
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size);
flex-shrink: 0;
}
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.logs-actions {
display: flex;
gap: 8px;
}
.sam-text-header {
margin-bottom: var(--size);
flex-shrink: 0;
.logs-title {
font-weight: 600;
font-size: 1.1rem;
.logs-actions {
position: absolute;
right: 0;
display: flex;
gap: 8px;
}
}
}
.logs-container {

View File

@@ -1,5 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { LoggerService, LogEntry } from '@common';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, NavComponent } from '@common';
import { DatePipe } from '@angular/common';
@Component({
@@ -8,8 +9,9 @@ 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 #router = inject(Router);
get logs(): LogEntry[] {
return this.#logger.logs;
@@ -40,4 +42,10 @@ export class LogsComponent implements OnInit {
return 'log-info';
}
}
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,19 +1,47 @@
<div class="sam-text-header">
<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>
<span>SYNC: {{ syncFlow }}</span>
<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>
<button class="btn btn-primary" (click)="onClickExportVault()">
Export 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>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
<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">
To change sync mode, export your vault, reset the extension,
and re-import with the desired sync setting.
</p>
</div>
<button
class="btn btn-danger"
(click)="

View File

@@ -4,11 +4,57 @@
flex-direction: column;
row-gap: var(--size);
overflow-y: auto;
padding-left: var(--size);
padding-right: var(--size);
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.file-input {
position: absolute;
visibility: hidden;
}
}
.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;
font-weight: 500;
}
.sync-note {
margin: var(--size-h) 0 0 0;
font-size: 0.85rem;
color: var(--muted-foreground);
line-height: 1.4;
}
}

View File

@@ -1,4 +1,5 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
BrowserSyncData,
BrowserSyncFlow,
@@ -6,19 +7,23 @@ import {
DateHelper,
LoggerService,
NavComponent,
NavItemComponent,
StartupService,
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
import { Buffer } from 'buffer';
@Component({
selector: 'app-settings',
imports: [ConfirmComponent],
imports: [ConfirmComponent, NavItemComponent],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',
})
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);
@@ -42,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() {
@@ -101,4 +144,10 @@ export class SettingsComponent extends NavComponent implements OnInit {
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -0,0 +1,680 @@
<div class="sam-text-header">
<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>
</button>
}
<span>{{ title }}</span>
<div class="section-btns">
<button
class="section-btn"
[class.active]="activeSection.startsWith('cashu')"
title="Cashu"
(click)="setSection('cashu')"
>
<span class="emoji">🥜</span>
</button>
<button
class="section-btn"
[class.active]="activeSection.startsWith('lightning')"
title="Lightning"
(click)="setSection('lightning')"
>
<span class="emoji"></span>
</button>
</div>
</div>
<div class="wallet-container">
<!-- Main wallet menu -->
@if (activeSection === 'main') {
<div class="wallet-menu">
<button class="wallet-menu-item" (click)="setSection('cashu')">
<span class="emoji">🥜</span>
<span class="label">Cashu</span>
<span class="balance">{{ formatCashuBalance(totalCashuBalance) }} sats</span>
</button>
<button class="wallet-menu-item" (click)="setSection('lightning')">
<span class="emoji"></span>
<span class="label">Lightning</span>
<span class="balance">{{ formatBalance(totalLightningBalance) }} sats</span>
</button>
</div>
}
<!-- Cashu mint list -->
@else if (activeSection === 'cashu') {
<div class="lightning-section">
@if (mints.length === 0) {
<div class="cashu-onboarding">
<div class="empty-state">
<span class="sam-text-muted">No mints connected yet.</span>
<!-- 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="backup-reminder">
<span>Have you set up backups?</span>
<button class="link-btn" (click)="navigateToSettings()">
Configure Backups
</button>
</div>
</div>
</div>
} @else {
<div class="wallet-list">
@for (mint of mints; track mint.id) {
<button class="wallet-list-item" (click)="selectMint(mint.id)">
<span class="wallet-name">{{ mint.name }}</span>
<span class="wallet-balance">{{ formatCashuBalance(mint.cachedBalance) }} sats</span>
</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>
<span>Add Mint</span>
</button>
</div>
}
<!-- Cashu mint detail -->
@else if (activeSection === 'cashu-detail' && selectedMint) {
<div class="wallet-detail">
<div class="balance-row">
<div class="balance-display compact">
<span class="balance-value">{{ formatCashuBalance(selectedMintBalance) }}</span>
<span class="balance-unit">sats</span>
</div>
<button
class="refresh-icon-btn"
(click)="refreshMint()"
[disabled]="refreshingMint"
title="Refresh"
>
<span class="emoji" [class.spinning]="refreshingMint">🔄</span>
</button>
</div>
@if (refreshError) {
<div class="error-message small">{{ refreshError }}</div>
}
<div class="action-buttons">
<button class="action-btn deposit-btn" (click)="showDeposit()">
Deposit
</button>
<button class="action-btn receive-btn" (click)="showReceive()">
Receive
</button>
<button class="action-btn send-btn" (click)="showSend()" [disabled]="selectedMintBalance === 0">
Send
</button>
</div>
<!-- Token viewer section -->
<div class="token-section">
<div class="section-title">Tokens ({{ selectedMintProofs.length }})</div>
@if (selectedMintProofs.length === 0) {
<div class="empty-text">No tokens stored</div>
} @else {
<div class="token-list">
@for (proof of selectedMintProofs; track proof.secret) {
<div class="token-item">
<span class="token-amount">{{ proof.amount }}</span>
<span class="token-time">{{ formatProofTime(proof.receivedAt) }}</span>
</div>
}
</div>
}
</div>
<div class="wallet-info">
<div class="info-row">
<span class="info-label">Mint URL</span>
<span class="info-value">{{ selectedMint.mintUrl }}</span>
</div>
<div class="info-row">
<span class="info-label">Unit</span>
<span class="info-value">{{ selectedMint.unit }}</span>
</div>
</div>
<button class="delete-btn" (click)="deleteMint()">
Delete Mint
</button>
</div>
}
<!-- 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
id="mintName"
type="text"
[(ngModel)]="newMintName"
placeholder="My Mint"
[disabled]="addingMint"
/>
</div>
<div class="form-group">
<label for="mintUrl">Mint URL</label>
<input
id="mintUrl"
type="text"
[(ngModel)]="newMintUrl"
placeholder="https://mint.example.com"
[disabled]="addingMint"
/>
</div>
@if (mintError) {
<div class="error-message">{{ mintError }}</div>
}
@if (mintTestResult) {
<div class="success-message">{{ mintTestResult }}</div>
}
<div class="form-actions">
<button
class="test-btn"
(click)="testMint()"
[disabled]="testingMint || addingMint"
>
{{ testingMint ? 'Testing...' : 'Test Connection' }}
</button>
<button
class="add-btn"
(click)="addMint()"
[disabled]="addingMint"
>
{{ addingMint ? 'Adding...' : 'Add Mint' }}
</button>
</div>
</div>
}
<!-- Cashu receive token -->
@else if (activeSection === 'cashu-receive') {
<div class="add-wallet-form">
<div class="form-group">
<label for="receiveToken">Paste Cashu Token</label>
<textarea
id="receiveToken"
[(ngModel)]="receiveToken"
placeholder="cashuB..."
rows="5"
[disabled]="receivingToken"
></textarea>
</div>
@if (receiveError) {
<div class="error-message">{{ receiveError }}</div>
}
@if (receiveResult) {
<div class="success-message">{{ receiveResult }}</div>
}
<div class="form-actions">
<button
class="add-btn full-width"
(click)="receiveTokens()"
[disabled]="receivingToken"
>
{{ receivingToken ? 'Receiving...' : 'Receive Tokens' }}
</button>
</div>
</div>
}
<!-- Cashu send token -->
@else if (activeSection === 'cashu-send') {
<div class="add-wallet-form">
<div class="balance-info">
Available: {{ formatCashuBalance(selectedMintBalance) }} sats
</div>
<div class="form-group">
<label for="sendAmount">Amount (sats)</label>
<input
id="sendAmount"
type="number"
[(ngModel)]="sendAmount"
placeholder="0"
min="1"
[max]="selectedMintBalance"
[disabled]="sendingToken"
/>
</div>
@if (sendError) {
<div class="error-message">{{ sendError }}</div>
}
@if (sendResult) {
<div class="token-result">
<span class="token-label">Token to Share</span>
<textarea readonly rows="4">{{ sendResult }}</textarea>
<button class="copy-btn" (click)="copyToken()">
Copy Token
</button>
</div>
}
@if (!sendResult) {
<div class="form-actions">
<button
class="add-btn full-width"
(click)="sendTokens()"
[disabled]="sendingToken || sendAmount <= 0"
>
{{ sendingToken ? 'Creating...' : 'Create Token' }}
</button>
</div>
}
</div>
}
<!-- Cashu deposit (mint via Lightning) -->
@else if (activeSection === 'cashu-mint' && selectedMint) {
<div class="add-wallet-form">
@if (!depositInvoice) {
<div class="form-group">
<label for="depositAmount">Amount (sats)</label>
<input
id="depositAmount"
type="number"
[(ngModel)]="depositAmount"
placeholder="1000"
min="1"
[disabled]="creatingDepositQuote"
/>
</div>
@if (depositError) {
<div class="error-message">{{ depositError }}</div>
}
<div class="form-actions">
<button
class="add-btn full-width"
(click)="createDepositInvoice()"
[disabled]="creatingDepositQuote || depositAmount <= 0"
>
{{ creatingDepositQuote ? 'Creating...' : 'Create Invoice' }}
</button>
</div>
}
@if (depositInvoice) {
<div class="invoice-result">
@if (depositInvoiceQr) {
<img [src]="depositInvoiceQr" alt="Invoice QR Code" class="qr-code" />
}
<div class="deposit-status">
@if (depositQuoteState === 'UNPAID') {
<span class="status-waiting">Waiting for payment...</span>
@if (checkingDepositPayment) {
<span class="status-checking">checking</span>
}
} @else if (depositQuoteState === 'PAID') {
<span class="status-paid">Payment received! Claiming tokens...</span>
} @else if (depositQuoteState === 'ISSUED') {
<span class="status-issued">✓ Tokens received!</span>
}
</div>
@if (depositError) {
<div class="error-message">{{ depositError }}</div>
}
@if (depositSuccess) {
<div class="success-message">{{ depositSuccess }}</div>
}
@if (depositQuoteState === 'UNPAID') {
<div class="invoice-text">{{ depositInvoice }}</div>
<button class="copy-btn" (click)="copyDepositInvoice()">
Copy Invoice
</button>
}
</div>
}
</div>
}
<!-- Lightning wallet list -->
@else if (activeSection === 'lightning') {
<div class="lightning-section">
@if (connections.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No wallets connected yet.
</span>
</div>
} @else {
<div class="wallet-list">
@for (conn of connections; track conn.id) {
<button class="wallet-list-item" (click)="selectConnection(conn.id)">
<span class="wallet-name">{{ conn.name }}</span>
<span class="wallet-balance">{{ formatBalance(conn.cachedBalance) }} sats</span>
</button>
}
</div>
}
<button class="add-wallet-btn" (click)="showAddConnection()">
<span class="emoji">+</span>
<span>Add NWC Connection</span>
</button>
</div>
}
<!-- Lightning wallet detail -->
@else if (activeSection === 'lightning-detail' && selectedConnection) {
<div class="wallet-detail">
<div class="balance-row">
<div class="balance-display compact">
<span class="balance-value">{{ formatBalance(selectedConnection.cachedBalance) }}</span>
<span class="balance-unit">sats</span>
</div>
<button class="refresh-icon-btn" (click)="refreshWallet()" title="Refresh">
<span class="emoji">🔄</span>
</button>
</div>
<div class="action-buttons">
<button class="action-btn receive-btn" (click)="showLnReceive()">
Receive
</button>
<button class="action-btn send-btn" (click)="showLnPay()">
Pay
</button>
</div>
<div class="wallet-info">
<div class="info-row">
<span class="info-label">Relay</span>
<span class="info-value">{{ selectedConnection.relayUrl }}</span>
</div>
@if (selectedConnection.lud16) {
<button class="info-row-btn" (click)="copyLightningAddress()">
<span class="info-label">Lightning Address</span>
<span class="info-value">
{{ selectedConnection.lud16 }}
<span class="copy-hint">{{ addressCopied ? '✓ Copied' : '(tap to copy)' }}</span>
</span>
</button>
}
</div>
<!-- Transaction History -->
<div class="transaction-section">
<div class="section-title">Transactions</div>
@if (loadingTransactions) {
<div class="loading-text">Loading...</div>
} @else if (transactionsNotSupported) {
<div class="not-supported-text">Transaction history not supported by this wallet</div>
} @else if (transactionsError) {
<div class="error-text">{{ transactionsError }}</div>
} @else if (transactions.length === 0) {
<div class="empty-text">No transactions yet</div>
} @else {
<div class="transaction-list">
@for (tx of transactions; track tx.payment_hash) {
<div class="transaction-item" [class.incoming]="tx.type === 'incoming'" [class.outgoing]="tx.type === 'outgoing'">
<span class="tx-icon">{{ tx.type === 'incoming' ? '⬇' : '⬆' }}</span>
<span class="tx-type">{{ tx.type === 'incoming' ? 'Received' : 'Sent' }}</span>
<span class="tx-amount">{{ formatBalance(tx.amount) }}</span>
<span class="tx-time">{{ formatTransactionTime(tx.created_at) }}</span>
</div>
}
</div>
}
</div>
<button class="delete-btn-small" (click)="deleteConnection()">
Delete Wallet
</button>
</div>
}
<!-- Lightning receive invoice -->
@else if (activeSection === 'lightning-receive' && selectedConnection) {
<div class="add-wallet-form">
<div class="form-group">
<label for="lnReceiveAmount">Amount (sats)</label>
<input
id="lnReceiveAmount"
type="number"
[(ngModel)]="lnReceiveAmount"
placeholder="1000"
min="1"
[disabled]="generatingInvoice"
/>
</div>
<div class="form-group">
<label for="lnReceiveDescription">Description (optional)</label>
<input
id="lnReceiveDescription"
type="text"
[(ngModel)]="lnReceiveDescription"
placeholder="Payment for..."
[disabled]="generatingInvoice"
/>
</div>
@if (lnReceiveError) {
<div class="error-message">{{ lnReceiveError }}</div>
}
@if (!generatedInvoice) {
<div class="form-actions">
<button
class="add-btn full-width"
(click)="createReceiveInvoice()"
[disabled]="generatingInvoice || lnReceiveAmount <= 0"
>
{{ generatingInvoice ? 'Generating...' : 'Generate Invoice' }}
</button>
</div>
}
@if (generatedInvoice) {
<div class="invoice-result">
@if (generatedInvoiceQr) {
<img [src]="generatedInvoiceQr" alt="Invoice QR Code" class="qr-code" />
}
<div class="invoice-text">{{ generatedInvoice }}</div>
<button class="copy-btn" (click)="copyInvoice()">
{{ invoiceCopied ? 'Copied!' : 'Copy Invoice' }}
</button>
</div>
}
</div>
}
<!-- Pay Modal Overlay -->
@if (showPayModal && selectedConnection) {
<div class="modal-overlay" role="dialog" aria-modal="true" tabindex="-1" (click)="closePayModal()" (keydown.escape)="closePayModal()">
<div class="modal-content" role="document" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
<div class="modal-header">
<span>Pay Invoice</span>
<button class="modal-close" (click)="closePayModal()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="payInput">Lightning Address or Invoice</label>
<textarea
id="payInput"
[(ngModel)]="payInput"
placeholder="user@domain.com or lnbc1..."
rows="3"
[disabled]="paying"
></textarea>
</div>
<div class="form-group">
<label for="payAmount">Amount (sats) - required for addresses</label>
<input
id="payAmount"
type="number"
[(ngModel)]="payAmount"
placeholder="Optional for invoices"
min="1"
[disabled]="paying"
/>
</div>
@if (paymentError) {
<div class="error-message">{{ paymentError }}</div>
}
@if (paymentSuccess) {
<div class="success-message payment-success">Payment Successful!</div>
}
@if (!paymentSuccess) {
<div class="form-actions">
<button class="test-btn" (click)="closePayModal()" [disabled]="paying">
Cancel
</button>
<button
class="add-btn"
(click)="payInvoiceOrAddress()"
[disabled]="paying || !payInput.trim()"
>
{{ paying ? 'Paying...' : 'Pay' }}
</button>
</div>
}
</div>
</div>
</div>
}
<!-- Add wallet form -->
@else if (activeSection === 'lightning-add') {
<div class="add-wallet-form">
<div class="form-group">
<label for="walletName">Wallet Name</label>
<input
id="walletName"
type="text"
[(ngModel)]="newWalletName"
placeholder="My Lightning Wallet"
[disabled]="addingConnection"
/>
</div>
<div class="form-group">
<label for="walletUrl">NWC Connection URL</label>
<textarea
id="walletUrl"
[(ngModel)]="newWalletUrl"
placeholder="nostr+walletconnect://..."
rows="3"
[disabled]="addingConnection"
></textarea>
</div>
@if (connectionError) {
<div class="error-message">{{ connectionError }}</div>
}
@if (connectionTestResult) {
<div class="success-message">{{ connectionTestResult }}</div>
}
@if (nwcService.logs.length > 0) {
<div class="nwc-log">
<div class="log-header">
<span>Connection Log</span>
<button class="log-clear-btn" (click)="nwcService.clearLogs()">Clear</button>
</div>
<div class="log-entries">
@for (entry of nwcService.logs; track entry.timestamp) {
<div class="log-entry" [class.log-warn]="entry.level === 'warn'" [class.log-error]="entry.level === 'error'">
<span class="log-time">{{ entry.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-message">{{ entry.message }}</span>
</div>
}
</div>
</div>
}
<div class="form-actions">
<button
class="test-btn"
(click)="testConnection()"
[disabled]="testingConnection || addingConnection"
>
{{ testingConnection ? 'Testing...' : 'Test Connection' }}
</button>
<button
class="add-btn"
(click)="addConnection()"
[disabled]="addingConnection"
>
{{ addingConnection ? 'Adding...' : 'Add Wallet' }}
</button>
</div>
</div>
}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,989 @@
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import {
LoggerService,
NavComponent,
NwcService,
NwcConnection_DECRYPTED,
CashuService,
CashuMint_DECRYPTED,
CashuProof,
NwcLookupInvoiceResult,
BrowserSyncFlow,
} from '@common';
import * as QRCode from 'qrcode';
type WalletSection =
| 'main'
| 'cashu'
| 'cashu-detail'
| 'cashu-add'
| 'cashu-receive'
| 'cashu-send'
| 'cashu-mint'
| 'lightning'
| 'lightning-detail'
| 'lightning-add'
| 'lightning-receive'
| 'lightning-pay';
@Component({
selector: 'app-wallet',
templateUrl: './wallet.component.html',
styleUrl: './wallet.component.scss',
imports: [CommonModule, FormsModule],
})
export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
readonly #logger = inject(LoggerService);
readonly #router = inject(Router);
readonly nwcService = inject(NwcService);
readonly cashuService = inject(CashuService);
activeSection: WalletSection = 'main';
selectedConnectionId: string | null = null;
selectedMintId: string | null = null;
// Form fields for adding new NWC connection
newWalletName = '';
newWalletUrl = '';
addingConnection = false;
testingConnection = false;
connectionError = '';
connectionTestResult = '';
// Form fields for adding new Cashu mint
newMintName = '';
newMintUrl = '';
addingMint = false;
testingMint = false;
mintError = '';
mintTestResult = '';
// Cashu receive/send fields
receiveToken = '';
receivingToken = false;
receiveError = '';
receiveResult = '';
sendAmount = 0;
sendingToken = false;
sendError = '';
sendResult = '';
// Cashu mint (deposit) fields
depositAmount = 0;
creatingDepositQuote = false;
depositQuoteId = '';
depositInvoice = '';
depositInvoiceQr = '';
depositError = '';
depositSuccess = '';
checkingDepositPayment = false;
depositQuoteState: 'UNPAID' | 'PAID' | 'ISSUED' = 'UNPAID';
private depositPollingInterval: ReturnType<typeof setInterval> | null = null;
// Loading states
loadingBalances = false;
balanceError = '';
// Lightning transaction history
transactions: NwcLookupInvoiceResult[] = [];
loadingTransactions = false;
transactionsError = '';
transactionsNotSupported = false;
// Lightning receive
lnReceiveAmount = 0;
lnReceiveDescription = '';
generatingInvoice = false;
generatedInvoice = '';
generatedInvoiceQr = '';
lnReceiveError = '';
invoiceCopied = false;
// Lightning pay
showPayModal = false;
payInput = '';
payAmount = 0;
paying = false;
paymentSuccess = false;
paymentError = '';
// Clipboard feedback
addressCopied = false;
// Cashu onboarding info
showCashuInfo = true;
currentSyncFlow: BrowserSyncFlow = BrowserSyncFlow.NO_SYNC;
readonly BrowserSyncFlow = BrowserSyncFlow; // Expose enum to template
readonly browserDownloadSettingsUrl = 'chrome://settings/downloads';
// Cashu mint refresh
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':
return 'Cashu';
case 'cashu-detail':
return this.selectedMint?.name ?? 'Mint';
case 'cashu-add':
return 'Add Mint';
case 'cashu-receive':
return 'Receive';
case 'cashu-send':
return 'Send';
case 'cashu-mint':
return 'Deposit';
case 'lightning':
return 'Lightning';
case 'lightning-detail':
return this.selectedConnection?.name ?? 'Wallet';
case 'lightning-add':
return 'Add Wallet';
case 'lightning-receive':
return 'Receive';
case 'lightning-pay':
return 'Pay';
default:
return 'Wallet';
}
}
get showBackButton(): boolean {
return this.activeSection !== 'main';
}
get connections(): NwcConnection_DECRYPTED[] {
return this.nwcService.getConnections();
}
get selectedConnection(): NwcConnection_DECRYPTED | undefined {
if (!this.selectedConnectionId) return undefined;
return this.nwcService.getConnection(this.selectedConnectionId);
}
get totalLightningBalance(): number {
return this.nwcService.getCachedTotalBalance();
}
get mints(): CashuMint_DECRYPTED[] {
return this.cashuService.getMints();
}
get selectedMint(): CashuMint_DECRYPTED | undefined {
if (!this.selectedMintId) return undefined;
return this.cashuService.getMint(this.selectedMintId);
}
get totalCashuBalance(): number {
return this.cashuService.getCachedTotalBalance();
}
get selectedMintBalance(): number {
if (!this.selectedMintId) return 0;
return this.cashuService.getBalance(this.selectedMintId);
}
get selectedMintProofs(): CashuProof[] {
if (!this.selectedMintId) return [];
return this.cashuService.getProofs(this.selectedMintId);
}
ngOnInit(): void {
// Load current sync flow setting
this.currentSyncFlow = this.storage.getSyncFlow();
// Refresh balances on init if we have connections
if (this.connections.length > 0) {
this.refreshAllBalances();
}
}
ngOnDestroy(): void {
this.nwcService.disconnectAll();
this.stopDepositPolling();
}
setSection(section: WalletSection) {
this.activeSection = section;
this.connectionError = '';
this.connectionTestResult = '';
}
goBack() {
switch (this.activeSection) {
case 'lightning-detail':
case 'lightning-add':
this.activeSection = 'lightning';
this.selectedConnectionId = null;
this.resetAddForm();
this.resetLightningForms();
break;
case 'lightning-receive':
case 'lightning-pay':
this.activeSection = 'lightning-detail';
this.resetLightningForms();
break;
case 'cashu-detail':
case 'cashu-add':
this.activeSection = 'cashu';
this.selectedMintId = null;
this.resetAddMintForm();
break;
case 'cashu-receive':
case 'cashu-send':
case 'cashu-mint':
this.activeSection = 'cashu-detail';
this.resetReceiveSendForm();
this.resetDepositForm();
break;
case 'lightning':
case 'cashu':
this.activeSection = 'main';
break;
}
}
selectConnection(connectionId: string) {
this.selectedConnectionId = connectionId;
this.activeSection = 'lightning-detail';
this.loadTransactions(connectionId);
}
private resetLightningForms() {
this.lnReceiveAmount = 0;
this.lnReceiveDescription = '';
this.generatingInvoice = false;
this.generatedInvoice = '';
this.generatedInvoiceQr = '';
this.lnReceiveError = '';
this.invoiceCopied = false;
this.payInput = '';
this.payAmount = 0;
this.paying = false;
this.paymentSuccess = false;
this.paymentError = '';
this.showPayModal = false;
}
showAddConnection() {
this.resetAddForm();
this.activeSection = 'lightning-add';
}
private resetAddForm() {
this.newWalletName = '';
this.newWalletUrl = '';
this.connectionError = '';
this.connectionTestResult = '';
this.addingConnection = false;
this.testingConnection = false;
}
async testConnection() {
if (!this.newWalletUrl.trim()) {
this.connectionError = 'Please enter an NWC URL';
return;
}
this.testingConnection = true;
this.connectionError = '';
this.connectionTestResult = '';
this.nwcService.clearLogs();
try {
const info = await this.nwcService.testConnection(this.newWalletUrl);
this.connectionTestResult = `Connected! ${info.alias ? 'Wallet: ' + info.alias : ''}`;
// Hide logs on success
this.nwcService.clearLogs();
} catch (error) {
this.connectionError =
error instanceof Error ? error.message : 'Connection test failed';
// Keep logs visible on failure for debugging
} finally {
this.testingConnection = false;
}
}
async addConnection() {
if (!this.newWalletName.trim()) {
this.connectionError = 'Please enter a wallet name';
return;
}
if (!this.newWalletUrl.trim()) {
this.connectionError = 'Please enter an NWC URL';
return;
}
this.addingConnection = true;
this.connectionError = '';
try {
await this.nwcService.addConnection(
this.newWalletName.trim(),
this.newWalletUrl.trim()
);
// Refresh the balance for the new connection
const connections = this.nwcService.getConnections();
const newConnection = connections[connections.length - 1];
if (newConnection) {
try {
await this.nwcService.getBalance(newConnection.id);
} catch {
// Ignore balance fetch error
}
}
this.goBack();
} catch (error) {
this.connectionError =
error instanceof Error ? error.message : 'Failed to add connection';
} finally {
this.addingConnection = false;
}
}
async deleteConnection() {
if (!this.selectedConnectionId) return;
const connection = this.selectedConnection;
if (
!confirm(`Delete wallet "${connection?.name}"? This cannot be undone.`)
) {
return;
}
try {
await this.nwcService.deleteConnection(this.selectedConnectionId);
this.goBack();
} catch (error) {
console.error('Failed to delete connection:', error);
}
}
// Cashu methods
selectMint(mintId: string) {
this.selectedMintId = mintId;
this.activeSection = 'cashu-detail';
// Auto-refresh to check for spent proofs
this.refreshMint();
}
async refreshMint() {
if (!this.selectedMintId || this.refreshingMint) return;
this.refreshingMint = true;
this.refreshError = '';
try {
const removedAmount = await this.cashuService.checkProofsSpent(this.selectedMintId);
if (removedAmount > 0) {
// Balance was updated, proofs were spent
console.log(`Removed ${removedAmount} sats of spent proofs`);
}
} catch (error) {
this.refreshError = error instanceof Error ? error.message : 'Failed to refresh';
console.error('Failed to refresh mint:', error);
} finally {
this.refreshingMint = false;
}
}
showAddMint() {
this.resetAddMintForm();
this.activeSection = 'cashu-add';
}
showReceive() {
this.resetReceiveSendForm();
this.activeSection = 'cashu-receive';
}
showSend() {
this.resetReceiveSendForm();
this.activeSection = 'cashu-send';
}
private resetAddMintForm() {
this.newMintName = '';
this.newMintUrl = '';
this.mintError = '';
this.mintTestResult = '';
this.addingMint = false;
this.testingMint = false;
}
private resetReceiveSendForm() {
this.receiveToken = '';
this.receivingToken = false;
this.receiveError = '';
this.receiveResult = '';
this.sendAmount = 0;
this.sendingToken = false;
this.sendError = '';
this.sendResult = '';
}
private resetDepositForm() {
this.depositAmount = 0;
this.creatingDepositQuote = false;
this.depositQuoteId = '';
this.depositInvoice = '';
this.depositInvoiceQr = '';
this.depositError = '';
this.depositSuccess = '';
this.checkingDepositPayment = false;
this.depositQuoteState = 'UNPAID';
this.stopDepositPolling();
}
private stopDepositPolling() {
if (this.depositPollingInterval) {
clearInterval(this.depositPollingInterval);
this.depositPollingInterval = null;
}
}
async testMint() {
if (!this.newMintUrl.trim()) {
this.mintError = 'Please enter a mint URL';
return;
}
this.testingMint = true;
this.mintError = '';
this.mintTestResult = '';
try {
const info = await this.cashuService.testMintConnection(
this.newMintUrl.trim()
);
this.mintTestResult = `Connected! ${info.name ? 'Mint: ' + info.name : ''}`;
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Connection test failed';
} finally {
this.testingMint = false;
}
}
async addMint() {
if (!this.newMintName.trim()) {
this.mintError = 'Please enter a mint name';
return;
}
if (!this.newMintUrl.trim()) {
this.mintError = 'Please enter a mint URL';
return;
}
this.addingMint = true;
this.mintError = '';
try {
await this.cashuService.addMint(
this.newMintName.trim(),
this.newMintUrl.trim()
);
this.goBack();
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Failed to add mint';
} finally {
this.addingMint = false;
}
}
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;
const mint = this.selectedMint;
if (!confirm(`Delete mint "${mint?.name}"? Any tokens stored will be lost. This cannot be undone.`)) {
return;
}
try {
await this.cashuService.deleteMint(this.selectedMintId);
this.goBack();
} catch (error) {
console.error('Failed to delete mint:', error);
}
}
async receiveTokens() {
if (!this.receiveToken.trim()) {
this.receiveError = 'Please paste a Cashu token';
return;
}
this.receivingToken = true;
this.receiveError = '';
this.receiveResult = '';
try {
const result = await this.cashuService.receive(this.receiveToken.trim());
this.receiveResult = `Received ${result.amount} sats!`;
this.receiveToken = '';
} catch (error) {
this.receiveError =
error instanceof Error ? error.message : 'Failed to receive token';
} finally {
this.receivingToken = false;
}
}
async sendTokens() {
if (!this.selectedMintId) return;
if (this.sendAmount <= 0) {
this.sendError = 'Please enter a valid amount';
return;
}
const balance = this.selectedMintBalance;
if (this.sendAmount > balance) {
this.sendError = `Insufficient balance. You have ${balance} sats`;
return;
}
this.sendingToken = true;
this.sendError = '';
this.sendResult = '';
try {
const result = await this.cashuService.send(
this.selectedMintId,
this.sendAmount
);
this.sendResult = result.token;
this.sendAmount = 0;
} catch (error) {
this.sendError =
error instanceof Error ? error.message : 'Failed to create token';
} finally {
this.sendingToken = false;
}
}
copyToken() {
if (this.sendResult) {
navigator.clipboard.writeText(this.sendResult);
}
}
async checkProofs() {
if (!this.selectedMintId) return;
try {
const removedAmount = await this.cashuService.checkProofsSpent(
this.selectedMintId
);
if (removedAmount > 0) {
alert(`Removed ${removedAmount} sats of spent proofs.`);
} else {
alert('All proofs are valid.');
}
} catch (error) {
console.error('Failed to check proofs:', error);
}
}
// Cashu deposit (mint) methods
showDeposit() {
this.resetDepositForm();
this.activeSection = 'cashu-mint';
}
async createDepositInvoice() {
if (!this.selectedMintId) return;
if (this.depositAmount <= 0) {
this.depositError = 'Please enter an amount';
return;
}
this.creatingDepositQuote = true;
this.depositError = '';
this.depositInvoice = '';
this.depositInvoiceQr = '';
try {
const quote = await this.cashuService.createMintQuote(
this.selectedMintId,
this.depositAmount
);
this.depositQuoteId = quote.quoteId;
this.depositInvoice = quote.invoice;
this.depositQuoteState = quote.state;
// Generate QR code
this.depositInvoiceQr = await QRCode.toDataURL(quote.invoice, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
// Start polling for payment
this.startDepositPolling();
} catch (error) {
this.depositError =
error instanceof Error ? error.message : 'Failed to create invoice';
} finally {
this.creatingDepositQuote = false;
}
}
private startDepositPolling() {
// Poll every 3 seconds for payment confirmation
this.depositPollingInterval = setInterval(async () => {
await this.checkDepositPayment();
}, 3000);
}
async checkDepositPayment() {
if (!this.selectedMintId || !this.depositQuoteId) return;
this.checkingDepositPayment = true;
try {
const quote = await this.cashuService.checkMintQuote(
this.selectedMintId,
this.depositQuoteId
);
this.depositQuoteState = quote.state;
if (quote.state === 'PAID') {
// Invoice is paid, claim the tokens
this.stopDepositPolling();
await this.claimDepositTokens();
} else if (quote.state === 'ISSUED') {
// Already claimed
this.stopDepositPolling();
this.depositSuccess = 'Tokens already claimed!';
}
} catch (error) {
// Don't show error for polling failures, just log
console.error('Failed to check payment:', error);
} finally {
this.checkingDepositPayment = false;
}
}
async claimDepositTokens() {
if (!this.selectedMintId || !this.depositQuoteId) return;
try {
const result = await this.cashuService.mintTokens(
this.selectedMintId,
this.depositAmount,
this.depositQuoteId
);
this.depositSuccess = `Received ${result.amount} sats!`;
this.depositQuoteState = 'ISSUED';
} catch (error) {
this.depositError =
error instanceof Error ? error.message : 'Failed to claim tokens';
}
}
async copyDepositInvoice() {
if (this.depositInvoice) {
await navigator.clipboard.writeText(this.depositInvoice);
}
}
formatCashuBalance(sats: number | undefined): string {
return this.cashuService.formatBalance(sats);
}
async refreshBalance(connectionId: string) {
try {
await this.nwcService.getBalance(connectionId);
} catch (error) {
console.error('Failed to refresh balance:', error);
}
}
async refreshAllBalances() {
this.loadingBalances = true;
this.balanceError = '';
try {
await this.nwcService.getAllBalances();
} catch {
this.balanceError = 'Failed to refresh some balances';
} finally {
this.loadingBalances = false;
}
}
formatBalance(millisats: number | undefined): string {
if (millisats === undefined) return '—';
// Convert millisats to sats with 3 decimal places
const sats = millisats / 1000;
return sats.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
}
// Lightning transaction methods
async loadTransactions(connectionId: string) {
this.loadingTransactions = true;
this.transactionsError = '';
this.transactionsNotSupported = false;
try {
this.transactions = await this.nwcService.listTransactions(connectionId, {
limit: 20,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
if (errorMsg.includes('NOT_IMPLEMENTED') || errorMsg.includes('not supported')) {
this.transactionsNotSupported = true;
} else {
this.transactionsError = errorMsg;
}
this.transactions = [];
} finally {
this.loadingTransactions = false;
}
}
async refreshWallet() {
if (!this.selectedConnectionId) return;
// Refresh balance and transactions in parallel
await Promise.all([
this.refreshBalance(this.selectedConnectionId),
this.loadTransactions(this.selectedConnectionId),
]);
}
showLnReceive() {
this.resetLightningForms();
this.activeSection = 'lightning-receive';
}
showLnPay() {
this.resetLightningForms();
this.showPayModal = true;
}
closePayModal() {
this.showPayModal = false;
this.resetLightningForms();
}
async createReceiveInvoice() {
if (!this.selectedConnectionId) return;
if (this.lnReceiveAmount <= 0) {
this.lnReceiveError = 'Please enter an amount';
return;
}
this.generatingInvoice = true;
this.lnReceiveError = '';
this.generatedInvoice = '';
this.generatedInvoiceQr = '';
try {
const result = await this.nwcService.makeInvoice(
this.selectedConnectionId,
this.lnReceiveAmount * 1000, // Convert sats to millisats
this.lnReceiveDescription || undefined
);
this.generatedInvoice = result.invoice;
// Generate QR code
this.generatedInvoiceQr = await QRCode.toDataURL(result.invoice, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
} catch (error) {
this.lnReceiveError =
error instanceof Error ? error.message : 'Failed to create invoice';
} finally {
this.generatingInvoice = false;
}
}
async copyInvoice() {
if (this.generatedInvoice) {
await navigator.clipboard.writeText(this.generatedInvoice);
this.invoiceCopied = true;
setTimeout(() => (this.invoiceCopied = false), 2000);
}
}
async copyLightningAddress() {
const lud16 = this.selectedConnection?.lud16;
if (lud16) {
await navigator.clipboard.writeText(lud16);
this.addressCopied = true;
setTimeout(() => (this.addressCopied = false), 2000);
}
}
async payInvoiceOrAddress() {
if (!this.selectedConnectionId || !this.payInput.trim()) {
this.paymentError = 'Please enter a lightning address or invoice';
return;
}
this.paying = true;
this.paymentError = '';
this.paymentSuccess = false;
try {
let invoice = this.payInput.trim();
// Check if it's a lightning address
if (this.nwcService.isLightningAddress(invoice)) {
if (this.payAmount <= 0) {
this.paymentError = 'Please enter an amount for lightning address payments';
this.paying = false;
return;
}
// Resolve lightning address to invoice
invoice = await this.nwcService.resolveLightningAddress(
invoice,
this.payAmount * 1000 // Convert sats to millisats
);
}
// Pay the invoice
await this.nwcService.payInvoice(
this.selectedConnectionId,
invoice,
this.payAmount > 0 ? this.payAmount * 1000 : undefined
);
this.paymentSuccess = true;
// Refresh balance and transactions after payment
await this.refreshWallet();
// Close modal after a delay
setTimeout(() => {
this.closePayModal();
}, 2000);
} catch (error) {
this.paymentError =
error instanceof Error ? error.message : 'Payment failed';
} finally {
this.paying = false;
}
}
formatTransactionTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}
formatProofTime(isoTimestamp: string | undefined): string {
if (!isoTimestamp) return '—';
const date = new Date(isoTimestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
// Cashu onboarding methods
dismissCashuInfo() {
this.showCashuInfo = false;
}
navigateToSettings() {
this.#router.navigateByUrl('/home/settings');
}
}

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

@@ -43,23 +43,22 @@
<span>Sign in</span>
</div>
</button>
<button
class="sam-mt"
(click)="
confirm.show(
'Do you really want to reset the extension? All data will be lost.',
onClickResetExtension.bind(this)
)
"
type="button"
class="btn btn-link"
>
Reset Extension
</button>
</div>
</div>
<button
class="reset-btn"
(click)="
confirm.show(
'Do you really want to reset the extension? All data will be lost.',
onClickResetExtension.bind(this)
)
"
type="button"
>
Reset Extension
</button>
<!----------->
<!-- ALERT -->
<!----------->

View File

@@ -3,6 +3,7 @@
display: flex;
flex-direction: column;
justify-items: center;
position: relative;
.logo-frame {
border: 2px solid var(--secondary);
@@ -16,4 +17,21 @@
justify-content: center;
padding: 0 var(--size) var(--size) var(--size);
}
.reset-btn {
position: absolute;
bottom: var(--size);
right: var(--size);
background: transparent;
border: none;
color: var(--muted-foreground);
font-size: 0.75rem;
cursor: pointer;
padding: 4px 8px;
&:hover {
color: var(--foreground);
text-decoration: underline;
}
}
}

View File

@@ -1,44 +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="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,8 +0,0 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
}

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

@@ -6,26 +6,55 @@ import {
CryptoHelper,
SignerMetaData,
Identity_DECRYPTED,
Identity_ENCRYPTED,
Nip07Method,
Nip07MethodPolicy,
NostrHelper,
Permission_DECRYPTED,
Permission_ENCRYPTED,
Relay_DECRYPTED,
Relay_ENCRYPTED,
NwcConnection_DECRYPTED,
NwcConnection_ENCRYPTED,
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';
import { Buffer } from 'buffer';
export const debug = function (message: any) {
const dateString = new Date().toISOString();
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
};
// Unlock request/response message types
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
// 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;
@@ -33,7 +62,7 @@ export interface PromptResponseMessage {
}
export interface BackgroundRequestMessage {
method: Nip07Method;
method: ExtensionMethod;
params: any;
host: string;
}
@@ -196,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
) {
@@ -209,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,
};
@@ -372,3 +444,352 @@ const encrypt = async function (
// v1: Use password with PBKDF2
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
};
// ==========================================
// Unlock Vault Logic (for background script)
// ==========================================
/**
* Decrypt a value using AES-GCM with pre-derived key (v2)
*/
async function decryptV2(
encryptedBase64: string,
ivBase64: string,
keyBase64: string
): Promise<string> {
const keyBytes = Buffer.from(keyBase64, 'base64');
const iv = Buffer.from(ivBase64, '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);
}
/**
* Decrypt a value using PBKDF2 (v1)
*/
async function decryptV1(
encryptedBase64: string,
ivBase64: string,
password: string
): Promise<string> {
return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
}
/**
* Generic decrypt function that handles both v1 and v2
*/
async function decryptValue(
encrypted: string,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<string> {
if (isV2) {
return decryptV2(encrypted, iv, keyOrPassword);
}
return decryptV1(encrypted, iv, keyOrPassword);
}
/**
* Parse decrypted value to the desired type
*/
function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
switch (type) {
case 'number':
return parseInt(value);
case 'boolean':
return value === 'true';
default:
return value;
}
}
/**
* Decrypt an identity
*/
async function decryptIdentity(
identity: Identity_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Identity_DECRYPTED> {
return {
id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
};
}
/**
* Decrypt a permission
*/
async function decryptPermission(
permission: Permission_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Permission_DECRYPTED> {
const decrypted: Permission_DECRYPTED = {
id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
};
if (permission.kind) {
decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
}
return decrypted;
}
/**
* Decrypt a relay
*/
async function decryptRelay(
relay: Relay_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Relay_DECRYPTED> {
return {
id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
};
}
/**
* Decrypt an NWC connection
*/
async function decryptNwcConnection(
nwc: NwcConnection_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<NwcConnection_DECRYPTED> {
const decrypted: NwcConnection_DECRYPTED = {
id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
};
if (nwc.lud16) {
decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
}
if (nwc.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (nwc.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Decrypt a Cashu mint
*/
async function decryptCashuMint(
mint: CashuMint_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<CashuMint_DECRYPTED> {
const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
const decrypted: CashuMint_DECRYPTED = {
id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
proofs: JSON.parse(proofsJson),
};
if (mint.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Handle an unlock request from the unlock popup
*/
export async function handleUnlockRequest(
password: string
): Promise<{ success: boolean; error?: string }> {
try {
debug('handleUnlockRequest: Starting unlock...');
// Check if already unlocked
const existingSession = await getBrowserSessionData();
if (existingSession) {
debug('handleUnlockRequest: Already unlocked');
return { success: true };
}
// Get sync data
const browserSyncData = await getBrowserSyncData();
if (!browserSyncData) {
return { success: false, error: 'No vault data found' };
}
// Verify password
const passwordHash = await CryptoHelper.hash(password);
if (passwordHash !== browserSyncData.vaultHash) {
return { success: false, error: 'Invalid password' };
}
debug('handleUnlockRequest: Password verified');
// Detect vault version
const isV2 = !!browserSyncData.salt;
debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
let keyOrPassword: string;
let vaultKey: string | undefined;
let vaultPassword: string | undefined;
if (isV2) {
// v2: Derive key with Argon2id (~3 seconds)
debug('handleUnlockRequest: Deriving Argon2id key...');
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
const keyBytes = await deriveKeyArgon2(password, saltBytes);
vaultKey = Buffer.from(keyBytes).toString('base64');
keyOrPassword = vaultKey;
debug('handleUnlockRequest: Key derived');
} else {
// v1: Use password directly
vaultPassword = password;
keyOrPassword = password;
}
// Decrypt identities
debug('handleUnlockRequest: Decrypting identities...');
const decryptedIdentities: Identity_DECRYPTED[] = [];
for (const identity of browserSyncData.identities) {
const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
decryptedIdentities.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
// Decrypt permissions
debug('handleUnlockRequest: Decrypting permissions...');
const decryptedPermissions: Permission_DECRYPTED[] = [];
for (const permission of browserSyncData.permissions) {
try {
const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
decryptedPermissions.push(decrypted);
} catch (e) {
debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
}
}
debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
// Decrypt relays
debug('handleUnlockRequest: Decrypting relays...');
const decryptedRelays: Relay_DECRYPTED[] = [];
for (const relay of browserSyncData.relays) {
const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
decryptedRelays.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
// Decrypt NWC connections
debug('handleUnlockRequest: Decrypting NWC connections...');
const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
for (const nwc of browserSyncData.nwcConnections ?? []) {
const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
decryptedNwcConnections.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
// Decrypt Cashu mints
debug('handleUnlockRequest: Decrypting Cashu mints...');
const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
for (const mint of browserSyncData.cashuMints ?? []) {
const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
decryptedCashuMints.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
// Decrypt selectedIdentityId
let decryptedSelectedIdentityId: string | null = null;
if (browserSyncData.selectedIdentityId !== null) {
decryptedSelectedIdentityId = await decryptValue(
browserSyncData.selectedIdentityId,
browserSyncData.iv,
keyOrPassword,
isV2
);
}
debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
// Build session data
const browserSessionData: BrowserSessionData = {
vaultPassword: isV2 ? undefined : vaultPassword,
vaultKey: isV2 ? vaultKey : undefined,
iv: browserSyncData.iv,
salt: browserSyncData.salt,
permissions: decryptedPermissions,
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
nwcConnections: decryptedNwcConnections,
cashuMints: decryptedCashuMints,
};
// Save session data
debug('handleUnlockRequest: Saving session data...');
await chrome.storage.session.set(browserSessionData);
debug('handleUnlockRequest: Unlock complete!');
return { success: true };
} catch (error: any) {
debug(`handleUnlockRequest: Error: ${error.message}`);
return { success: false, error: error.message || 'Unlock failed' };
}
}
/**
* Open the unlock popup window
*/
export async function openUnlockPopup(host?: string): Promise<void> {
const width = 375;
const height = 500;
const { top, left } = await getPosition(width, height);
const id = crypto.randomUUID();
let url = `unlock.html?id=${id}`;
if (host) {
url += `&host=${encodeURIComponent(host)}`;
}
await chrome.windows.create({
type: 'popup',
url,
height,
width,
top,
left,
});
}

View File

@@ -1,40 +1,336 @@
/* 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,
nip44Encrypt,
openUnlockPopup,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
UnlockRequestMessage,
UnlockResponseMessage,
} from './background-common';
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>;
}
>();
// Track if unlock popup is already open
let unlockPopupOpen = false;
// Queue of pending NIP-07 requests waiting for unlock
const pendingRequests: {
request: BackgroundRequestMessage;
resolve: (result: any) => void;
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');
// Handle unlock request from unlock popup
if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
const unlockReq = message as UnlockRequestMessage;
debug('Processing unlock request');
const result = await handleUnlockRequest(unlockReq.password);
const response: UnlockResponseMessage = {
type: 'unlock-response',
id: unlockReq.id,
success: result.success,
error: result.error,
};
if (result.success) {
unlockPopupOpen = false;
// Process any pending NIP-07 requests
debug(`Processing ${pendingRequests.length} pending requests`);
while (pendingRequests.length > 0) {
const pending = pendingRequests.shift()!;
try {
const pendingResult = await processNip07Request(pending.request);
pending.resolve(pendingResult);
} catch (error) {
pending.reject(error);
}
}
}
return response;
}
const request = message as BackgroundRequestMessage | PromptResponseMessage;
debug(request);
@@ -43,18 +339,47 @@ 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;
}
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
// Vault is locked - open unlock popup and queue the request
const req = request as BackgroundRequestMessage;
debug('Vault locked, opening unlock popup');
if (!unlockPopupOpen) {
unlockPopupOpen = true;
await openUnlockPopup(req.host);
}
// Queue this request to be processed after unlock
return new Promise((resolve, reject) => {
pendingRequests.push({ request: req, resolve, reject });
});
}
// Process the request (NIP-07 or WebLN)
const req = request as BackgroundRequestMessage;
if (isWeblnMethod(req.method)) {
return processWeblnRequest(req);
}
return processNip07Request(req);
});
/**
* Process a NIP-07 request after vault is unlocked
*/
async function processNip07Request(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
@@ -67,8 +392,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
throw new Error('No Nostr identity available at endpoint.');
}
const req = request as BackgroundRequestMessage;
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
@@ -80,7 +403,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
browserSessionData,
currentIdentity,
req.host,
req.method,
req.method as Nip07Method,
req.params
);
debug(`permissionState result: ${permissionState}`);
@@ -90,29 +413,23 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
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,
@@ -122,19 +439,29 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
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 {
@@ -143,73 +470,191 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
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

@@ -5,6 +5,7 @@ import {
} from '@common';
import './app/common/extensions/array';
import browser from 'webextension-polyfill';
import { v4 as uuidv4 } from 'uuid';
//
// Functions
@@ -105,8 +106,12 @@ document.addEventListener('DOMContentLoaded', async () => {
}
newSnapshots.push({
id: uuidv4(),
fileName: file.name,
createdAt: new Date().toISOString(),
data: vault,
identityCount: vault.identities?.length ?? 0,
reason: 'manual',
});
}

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

@@ -18,6 +18,7 @@ body {
background: var(--background);
margin: 0;
overflow: hidden;
}
// Button styling to match market
@@ -128,3 +129,35 @@ button {
.modal-body {
color: #fafafa;
}
// Custom scrollbar styling for Chrome
* {
// Thin scrollbar
&::-webkit-scrollbar {
width: 6px;
}
// Track - black background, transparent by default
&::-webkit-scrollbar-track {
background: transparent;
}
// Thumb - white, transparent by default
&::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
}
// Show scrollbar on hover over scrollable area
&:hover::-webkit-scrollbar-track {
background: #1a1a1a;
}
&:hover::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.5);
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.7);
}
}

View File

@@ -0,0 +1,106 @@
import browser from 'webextension-polyfill';
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const host = params.get('host');
// Elements
const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
const togglePasswordBtn = document.getElementById('togglePassword');
const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
const derivingOverlay = document.getElementById('derivingOverlay');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const hostInfo = document.getElementById('hostInfo');
const hostSpan = document.getElementById('hostSpan');
// Show host info if available
if (host && hostInfo && hostSpan) {
hostSpan.innerText = host;
hostInfo.classList.remove('hidden');
}
// Toggle password visibility
togglePasswordBtn?.addEventListener('click', () => {
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye-slash"></i>';
} else {
passwordInput.type = 'password';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye"></i>';
}
});
// Enable/disable unlock button based on password input
passwordInput?.addEventListener('input', () => {
unlockBtn.disabled = !passwordInput.value;
});
// Handle enter key
passwordInput?.addEventListener('keyup', (e) => {
if (e.key === 'Enter' && passwordInput.value) {
attemptUnlock();
}
});
// Handle unlock button click
unlockBtn?.addEventListener('click', attemptUnlock);
async function attemptUnlock() {
if (!passwordInput?.value) return;
// Show deriving overlay
derivingOverlay?.classList.remove('hidden');
errorAlert?.classList.add('hidden');
const message: UnlockRequestMessage = {
type: 'unlock-request',
id,
password: passwordInput.value,
};
try {
const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
if (response.success) {
// Success - close the window
window.close();
} else {
// Failed - show error
derivingOverlay?.classList.add('hidden');
showError(response.error || 'Invalid password');
}
} catch (error) {
console.error('Failed to send unlock message:', error);
derivingOverlay?.classList.add('hidden');
showError('Failed to unlock vault');
}
}
function showError(message: string) {
if (errorAlert && errorMessage) {
errorMessage.innerText = message;
errorAlert.classList.remove('hidden');
setTimeout(() => {
errorAlert.classList.add('hidden');
}, 3000);
}
}
// Focus password input on load
document.addEventListener('DOMContentLoaded', () => {
passwordInput?.focus();
});

View File

@@ -12,7 +12,8 @@
"src/plebian-signer-extension.ts",
"src/plebian-signer-content-script.ts",
"src/prompt.ts",
"src/options.ts"
"src/options.ts",
"src/unlock.ts"
],
"include": ["src/**/*.d.ts"]
}

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

File diff suppressed because it is too large Load Diff

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

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