25 Commits
v1.0.1 ... main

Author SHA1 Message Date
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
woikos
8b6ead1f81 Release v1.0.6 - Add store publishing documentation
- Store description for Chrome Web Store and Firefox Add-ons
- Privacy policy (no data collection, local-only storage)
- Comprehensive publishing guide with step-by-step checklists
- Concise publishing checklist for quick reference

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:32:55 +01:00
woikos
38d9a9ef9f Add concise publishing checklist
Streamlined guide for completing Chrome/Firefox submissions once screenshots are ready.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:24:40 +01:00
woikos
b55a3f01b6 Add extension store publishing documentation
- Store description for Chrome Web Store and Firefox Add-ons
- Privacy policy (no data collection, local-only storage)
- Comprehensive publishing guide with checklists

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:17:36 +01:00
b7bedf085a Release v1.0.5 - Update release command to clean old zips
- Updated /release command to delete old zip files before creating new ones
- Ensures releases/ folder only contains the latest version

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:50:17 +01:00
ff82e41012 Update release command to delete old zips before creating new ones
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:44:56 +01:00
45b1fb58e9 Release v1.0.4 - Add logging system, lock button, and emoji navigation
- Comprehensive logging system with chrome.storage.session persistence
- NIP-07 action logging in background scripts with standalone functions
- Vault operation logging (unlock, lock, create, reset, import, export)
- Profile and bookmark operation logging
- Logs page with refresh functionality and category icons
- Lock button (🔒) in navigation bar to quickly lock vault
- Reduced nav bar size (40px height, 16px font) with emoji icons
- Reordered navigation: You, Permissions, Bookmarks, Logs, About, Lock
- Bookmarks functionality for saving frequently used Nostr apps
- Fixed lock/unlock flow by properly clearing in-memory session data

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:42:19 +01:00
b535a7b967 Release v1.0.3 - Add zip file creation to release process
- Update /release command to create zip files in releases/ folder
- Add v1.0.3 zip files for both Chrome and Firefox extensions

Files modified:
- .claude/commands/release.md
- releases/plebeian-signer-chrome-v1.0.3.zip (new)
- releases/plebeian-signer-firefox-v1.0.3.zip (new)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 09:08:34 +01:00
abd4a21f8f Release v1.0.2 - Fix Buffer polyfill race condition in prompt
- Fix race condition where permission prompts failed on first request
  due to Buffer polyfill not being initialized during module evaluation
- Replace Buffer.from() with native browser APIs (atob + TextDecoder)
  in prompt.ts for reliable base64 decoding
- Add debug logging to reckless mode approval checks
- Update permission encryption to support v2 vault key format
- Enhance LoggerService with warn/error/debug methods and log storage
- Add logs component for viewing extension activity
- Simplify deriving modal component
- Rename icon files from gooti to plebian-signer
- Update permissions component with improved styling

Files modified:
- projects/chrome/src/prompt.ts
- projects/firefox/src/prompt.ts
- projects/*/src/background-common.ts
- projects/common/src/lib/services/logger/logger.service.ts
- projects/*/src/app/components/home/logs/ (new)
- projects/*/public/*.svg, *.png (renamed)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 08:52:44 +01:00
222 changed files with 23618 additions and 983 deletions

View File

@@ -42,25 +42,34 @@ This project uses **standard semver with `v` prefix** (e.g., `v0.0.8`, `v1.2.3`)
```
If any step fails, fix issues before proceeding.
6. **Compose a commit message** following this format:
6. **Create release zip files** in the `releases/` folder:
```
mkdir -p releases
rm -f releases/plebeian-signer-chrome-v*.zip releases/plebeian-signer-firefox-v*.zip
cd dist/chrome && zip -r ../../releases/plebeian-signer-chrome-vX.Y.Z.zip . && cd ../..
cd dist/firefox && zip -r ../../releases/plebeian-signer-firefox-vX.Y.Z.zip . && cd ../..
```
Replace `vX.Y.Z` with the actual version number. Old zip files are deleted to keep only the latest release.
7. **Compose a commit message** following this format:
- First line: 72 chars max, imperative mood summary (e.g., "Release v0.0.8")
- Blank line
- Bullet points describing each significant change
- "Files modified:" section listing affected files
- Footer with Claude Code attribution
7. **Stage all changes** with `git add -A`
8. **Stage all changes** with `git add -A`
8. **Create the commit** with the composed message
9. **Create the commit** with the composed message
9. **Create a git tag** matching the version (e.g., `v0.0.8`)
10. **Create a git tag** matching the version (e.g., `v0.0.8`)
10. **Push to origin** with tags:
11. **Push to origin** with tags:
```
git push origin main --tags
```
11. **Report completion** with the new version and commit hash
12. **Report completion** with the new version and commit hash
## Important:
- This is a browser extension with separate Chrome and Firefox builds

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": "20kB",
"maximumError": "25kB"
}
],
"optimization": {
@@ -154,8 +154,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "20kB",
"maximumError": "25kB"
}
],
"optimization": {

View File

@@ -0,0 +1,112 @@
# Plebeian Signer Privacy Policy
**Last Updated:** December 20, 2025
## Overview
Plebeian Signer is a browser extension for managing Nostr identities and signing cryptographic events. This privacy policy explains how we handle your data.
## Data Collection
**We do not collect any personal data.**
Plebeian Signer operates entirely locally within your browser. We do not:
- Collect analytics or telemetry
- Track your usage or behavior
- Send your data to any external servers
- Use cookies or tracking technologies
- Share any information with third parties
## Data Storage
All data is stored locally in your browser using the browser's built-in storage APIs:
### What We Store Locally
1. **Encrypted Vault Data**
- Your Nostr private keys (encrypted with Argon2id + AES-256-GCM)
- Identity nicknames and metadata
- Relay configurations
- Site permissions
2. **Session Data**
- Temporary decryption keys (cleared when browser closes or vault locks)
- Cached profile metadata
3. **Extension Settings**
- Sync preferences
- Reckless mode settings
- Whitelisted hosts
### Encryption
Your private keys are never stored in plaintext. The vault uses:
- **Argon2id** for password-based key derivation (256MB memory, 4 threads, 8 iterations)
- **AES-256-GCM** for authenticated encryption
- **Random salt and IV** generated for each vault
## Network Communications
Plebeian Signer makes the following network requests:
1. **Nostr Relay Connections**
- To fetch your profile metadata (kind 0 events)
- To fetch relay lists (kind 10002 events)
- Only connects to relays you have configured
2. **NIP-05 Verification**
- Fetches `.well-known/nostr.json` from domains in NIP-05 identifiers
- Used only to verify identity claims
**We do not operate any servers.** All relay connections are made directly to the Nostr network.
## Permissions Explained
The extension requests these browser permissions:
- **`storage`**: To save your encrypted vault and settings
- **`activeTab`**: To inject the NIP-07 interface into web pages
- **`scripting`**: To enable communication between pages and the extension
## Data Sharing
We do not share any data with third parties. The extension:
- Has no backend servers
- Does not use analytics services
- Does not include advertising
- Does not sell or monetize your data in any way
## Your Control
You have full control over your data:
- **Export**: You can export your encrypted vault at any time
- **Delete**: Use the "Reset Extension" feature to delete all local data
- **Lock**: Lock your vault to clear session data immediately
## Open Source
Plebeian Signer is open source software. You can audit the code yourself:
- Repository: https://github.com/PlebeianApp/plebeian-signer
## Children's Privacy
This extension is not intended for children under 13 years of age. We do not knowingly collect any information from children.
## Changes to This Policy
If we make changes to this privacy policy, we will update the "Last Updated" date at the top of this document. Significant changes will be noted in the extension's release notes.
## Contact
For privacy-related questions or concerns, please open an issue on our repository:
https://github.com/PlebeianApp/plebeian-signer/issues
---
## Summary
- All data stays in your browser
- Private keys are encrypted with strong cryptography
- No analytics, tracking, or data collection
- No external servers (except Nostr relays you configure)
- Fully open source and auditable

View File

@@ -0,0 +1,293 @@
# Extension Store Publishing Guide
This guide walks you through publishing Plebeian Signer to the Chrome Web Store and Firefox Add-ons.
---
## Table of Contents
1. [Assets You Need to Create](#assets-you-need-to-create)
2. [Chrome Web Store](#chrome-web-store)
3. [Firefox Add-ons](#firefox-add-ons)
4. [Ongoing Maintenance](#ongoing-maintenance)
---
## Assets You Need to Create
Before submitting to either store, prepare these assets:
### Screenshots (Required for both stores)
Create 3-5 screenshots showing the extension in action:
1. **Main popup view** - Show the identity card with profile info
2. **Permission prompt** - Show a signing request popup
3. **Identity management** - Show the identity list/switching
4. **Permissions page** - Show the permissions management
5. **Settings page** - Show vault settings and options
**Specifications:**
- Chrome: 1280x800 or 640x400 pixels (PNG or JPEG)
- Firefox: 1280x800 recommended (PNG or JPEG)
**Tips:**
- Use a clean browser profile
- Show realistic data (not "test" or placeholder text)
- Capture the full popup or relevant UI area
- Consider adding captions/annotations
### Promotional Images (Chrome only)
Chrome Web Store uses promotional tiles:
| Size | Name | Required |
|------|------|----------|
| 440x280 | Small promo tile | Optional but recommended |
| 920x680 | Large promo tile | Optional |
| 1400x560 | Marquee promo tile | Optional |
**Design tips:**
- Include the extension icon/logo
- Add a tagline like "Secure Nostr Identity Manager"
- Use brand colors
- Keep text minimal and readable
### Icon (Already exists)
You already have icons in the extension:
- `icon-48.png` - 48x48
- `icon-128.png` - 128x128
Chrome also wants a 128x128 icon for the store listing (can use the same one).
### Privacy Policy URL
You need to host the privacy policy at a public URL. Options:
1. **GitHub/Gitea Pages** - Host `PRIVACY_POLICY.md` as a webpage
2. **Simple webpage** - Create a basic HTML page
3. **Gist** - Create a public GitHub gist
Example URL format: `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md`
---
## Chrome Web Store
### Step 1: Create Developer Account
1. Go to https://chrome.google.com/webstore/devconsole
2. Sign in with a Google account
3. Pay the one-time $5 USD registration fee
4. Accept the developer agreement
### Step 2: Create New Item
1. Click **"New Item"** button
2. Upload `releases/plebeian-signer-chrome-v1.0.5.zip`
3. Wait for the upload to process
### Step 3: Fill Store Listing
**Product Details:**
- **Name:** Plebeian Signer
- **Summary:** Copy from `STORE_DESCRIPTION.md` (short description, 132 chars max)
- **Description:** Copy from `STORE_DESCRIPTION.md` (full description)
- **Category:** Productivity
- **Language:** English
**Graphic Assets:**
- Upload your screenshots (at least 1 required, up to 5)
- Upload promotional tiles if you have them
**Additional Fields:**
- **Official URL:** `https://github.com/PlebeianApp/plebeian-signer`
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
### Step 4: Privacy Tab
- **Single Purpose:** "Manage Nostr identities and sign cryptographic events for web applications"
- **Permission Justifications:**
- `storage`: "Store encrypted vault containing user's Nostr identities and extension settings"
- `activeTab`: "Inject NIP-07 interface into the active tab when user visits Nostr applications"
- `scripting`: "Enable communication between web pages and the extension for signing requests"
- **Data Usage:** Check "I do not sell or transfer user data to third parties"
- **Privacy Policy URL:** Your hosted privacy policy URL
### Step 5: Distribution
- **Visibility:** Public
- **Distribution:** All regions (or select specific ones)
### Step 6: Submit for Review
1. Review all sections show green checkmarks
2. Click **"Submit for Review"**
3. Wait 1-3 business days (can take longer for first submission)
### Chrome Review Notes
Google may ask about:
- Why you need each permission
- How you handle user data
- Your identity/organization
Be prepared to respond to reviewer questions via the dashboard.
---
## Firefox Add-ons
### Step 1: Create Developer Account
1. Go to https://addons.mozilla.org/developers/
2. Sign in with a Firefox account (create one if needed)
3. No fee required
### Step 2: Submit New Add-on
1. Click **"Submit a New Add-on"**
2. Select **"On this site"** for hosting
3. Upload `releases/plebeian-signer-firefox-v1.0.5.zip`
4. Wait for automated validation
### Step 3: Source Code Submission
Firefox may request source code because the extension uses bundled/minified JavaScript.
**If prompted:**
1. Create a source code zip (exclude `node_modules`):
```bash
cd /home/mleku/src/git.mleku.dev/mleku/plebeian-signer
zip -r plebeian-signer-source.zip . -x "node_modules/*" -x "dist/*" -x ".git/*"
```
2. Upload this zip when asked
3. Include build instructions (point to CLAUDE.md or add a note):
```
Build Instructions:
1. npm ci
2. npm run build:firefox
3. Output is in dist/firefox/
```
### Step 4: Fill Listing Details
**Basic Information:**
- **Name:** Plebeian Signer
- **Add-on URL:** `plebeian-signer` (creates addons.mozilla.org/addon/plebeian-signer)
- **Summary:** Copy short description from `STORE_DESCRIPTION.md`
- **Description:** Copy full description (supports some HTML/Markdown)
- **Categories:** Privacy & Security
**Additional Details:**
- **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
**Media:**
- **Icon:** Already in the extension manifest
- **Screenshots:** Upload your screenshots
### Step 5: Submit for Review
1. Ensure all required fields are complete
2. Click **"Submit Version"**
3. Wait for review (usually hours to a few days)
### Firefox Review Notes
Firefox reviewers are generally faster but thorough. They may:
- Ask for source code (see Step 3)
- Question specific code patterns
- Request changes for policy compliance
---
## Ongoing Maintenance
### Updating the Extension
**For new releases:**
1. Build new version: `/release patch` (or `minor`/`major`)
2. Upload the new zip to each store
3. Add release notes describing changes
4. Submit for review
**Chrome:**
- Go to Developer Dashboard → Your extension → Package → Upload new package
**Firefox:**
- Go to Developer Hub → Your extension → Upload a New Version
### Responding to Reviews
Both stores may contact you with:
- Policy violation notices
- User reports
- Review questions
Monitor your developer email and respond promptly.
### Version Numbering
Both stores extract the version from `manifest.json`. Your current setup with `v1.0.5` in `package.json` feeds into the manifests correctly.
---
## Checklist
### Before First Submission
- [ ] Create 3-5 screenshots
- [ ] Create promotional images (Chrome, optional but recommended)
- [ ] Host privacy policy at a public URL
- [ ] Test the extension zip by loading it unpacked
- [ ] Prepare source code zip for Firefox
### Chrome Web Store
- [ ] Register developer account ($5)
- [ ] Upload extension zip
- [ ] Fill all required listing fields
- [ ] Add screenshots
- [ ] Add privacy policy URL
- [ ] Justify all permissions
- [ ] Submit for review
### Firefox Add-ons
- [ ] Register developer account (free)
- [ ] Upload extension zip
- [ ] Upload source code if requested
- [ ] Fill all required listing fields
- [ ] Add screenshots
- [ ] Add privacy policy URL
- [ ] Submit for review
---
## Helpful Links
- Chrome Developer Dashboard: https://chrome.google.com/webstore/devconsole
- Chrome Publishing Docs: https://developer.chrome.com/docs/webstore/publish/
- Firefox Developer Hub: https://addons.mozilla.org/developers/
- Firefox Extension Workshop: https://extensionworkshop.com/documentation/publish/
---
## Estimated Timeline
| Task | Time |
|------|------|
| Create screenshots | 30 min - 1 hour |
| Create promotional images | 1-2 hours (optional) |
| Host privacy policy | 15 min |
| Chrome submission | 30 min |
| Chrome review | 1-3 business days |
| Firefox submission | 30 min |
| Firefox review | Hours to 2 days |
**Total:** You can have both submissions done in an afternoon, with approvals coming within a week.

View File

@@ -0,0 +1,88 @@
# Plebeian Signer - Store Description
Use this content for Chrome Web Store and Firefox Add-ons listings.
---
## Short Description (132 characters max for Chrome)
Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility.
---
## Full Description
**Plebeian Signer** is a secure browser extension for managing your Nostr identities and signing events without exposing your private keys to web applications.
### Key Features
**Multi-Identity Management**
- Create and manage multiple Nostr identities from a single extension
- Easily switch between identities with one click
- Import existing keys or generate new ones
**Bank-Grade Security**
- Private keys never leave the extension
- Vault encrypted with Argon2id + AES-256-GCM (the same algorithms used by password managers)
- Automatic vault locking for protection
**NIP-07 Compatible**
- Works with all Nostr web applications that support NIP-07
- Supports NIP-04 and NIP-44 encryption/decryption
- Relay configuration per identity
**Permission Control**
- Fine-grained permission management per application
- Approve or deny signing requests on a per-site basis
- Optional "Reckless Mode" for trusted applications
- Whitelist trusted hosts for automatic approval
**User-Friendly Interface**
- Clean, intuitive design
- Profile metadata display with avatar and banner
- NIP-05 verification support
- Bookmark your favorite Nostr apps
### How It Works
1. Create a password-protected vault
2. Add your Nostr identities (import existing or generate new)
3. Visit any NIP-07 compatible Nostr application
4. Approve signing requests through the extension popup
### Privacy First
Plebeian Signer is open source and respects your privacy:
- No telemetry or analytics
- No external servers (except for profile metadata from Nostr relays)
- All cryptographic operations happen locally in your browser
- Your private keys are encrypted and never transmitted
### Supported NIPs
- NIP-07: Browser Extension for Nostr
- NIP-04: Encrypted Direct Messages
- NIP-44: Versioned Encryption
### Links
- Source Code: https://github.com/PlebeianApp/plebeian-signer
- Report Issues: https://github.com/PlebeianApp/plebeian-signer/issues
---
## Category Suggestions
**Chrome Web Store:**
- Primary: Productivity
- Secondary: Developer Tools
**Firefox Add-ons:**
- Primary: Privacy & Security
- Secondary: Other
---
## Tags/Keywords
nostr, nip-07, signing, identity, privacy, encryption, decentralized, keys, wallet, security

129
docs/store/publishing.md Normal file
View File

@@ -0,0 +1,129 @@
# Publishing Checklist
Developer accounts are set up. This document covers the remaining steps.
## Privacy Policy URL
```
https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md
```
## Screenshots Needed
Take 3-5 screenshots (1280x800 or 640x400 PNG/JPEG):
1. **Identity view** - Main popup showing profile card with avatar/banner
2. **Permission prompt** - A signing request popup from a Nostr app
3. **Identity list** - Multiple identities with switching UI
4. **Permissions page** - Managing site permissions
5. **Settings** - Vault/reckless mode settings
**Tips:**
- Load the extension in a clean browser profile
- Use real-looking test data, not "test123"
- Crop to show just the popup/relevant UI
---
## Chrome Web Store Submission
1. Go to https://chrome.google.com/webstore/devconsole
2. Click **"New Item"**
3. Upload: `releases/plebeian-signer-chrome-v1.0.5.zip`
### Store Listing Tab
| Field | Value |
|-------|-------|
| Name | Plebeian Signer |
| 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` (full description section) |
| Category | Productivity |
| Language | English |
Upload your screenshots.
### Privacy Tab
| Field | Value |
|-------|-------|
| Single Purpose | Manage Nostr identities and sign cryptographic events for web applications |
| Privacy Policy URL | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
**Permission Justifications:**
| Permission | Justification |
|------------|---------------|
| storage | Store encrypted vault containing user's Nostr identities and extension settings |
| activeTab | Inject NIP-07 interface into the active tab when user visits Nostr applications |
| scripting | Enable communication between web pages and the extension for signing requests |
Check: "I do not sell or transfer user data to third parties"
### Distribution Tab
- Visibility: Public
- Regions: All
Click **"Submit for Review"**
---
## Firefox Add-ons Submission
1. Go to https://addons.mozilla.org/developers/
2. Click **"Submit a New Add-on"**
3. Select **"On this site"**
4. Upload: `releases/plebeian-signer-firefox-v1.0.5.zip`
### If Asked for Source Code
Run this to create source zip:
```bash
cd /home/mleku/src/git.mleku.dev/mleku/plebeian-signer
zip -r plebeian-signer-source.zip . -x "node_modules/*" -x "dist/*" -x ".git/*" -x "releases/*"
```
Build instructions to provide:
```
1. npm ci
2. npm run build:firefox
3. Output is in dist/firefox/
```
### Listing Details
| Field | Value |
|-------|-------|
| Name | Plebeian Signer |
| Add-on URL | plebeian-signer |
| 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://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.
Click **"Submit Version"**
---
## After Submission
- **Chrome:** 1-3 business days review
- **Firefox:** Hours to 2 days review
Check your email for reviewer questions. Both dashboards show review status.
---
## Updating Later
When you release a new version:
1. Run `/release patch` (or minor/major)
2. Chrome: Dashboard → Your extension → Package → Upload new package
3. Firefox: Developer Hub → Your extension → Upload a New Version
4. Add release notes, submit for review

325
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v0.0.9",
"version": "v1.0.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plebeian-signer",
"version": "v0.0.9",
"version": "v1.0.8",
"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.1",
"version": "1.1.6",
"custom": {
"chrome": {
"version": "v1.0.1"
"version": "v1.1.6"
},
"firefox": {
"version": "v1.0.1"
"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"
}

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.1",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"version": "1.1.5",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [
"windows",

View File

Before

Width:  |  Height:  |  Size: 983 B

After

Width:  |  Height:  |  Size: 983 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

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="rejectButton" 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="rejectJustOnceButton" class="dropdown-item">
just once
</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="approveButton" type="button" class="btn btn-primary">
Approve
</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="approveJustOnceButton" class="dropdown-item" href="#">
just 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

@@ -9,10 +9,15 @@ import { IdentitiesComponent } from './components/home/identities/identities.com
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';
@@ -66,6 +71,22 @@ export const routes: Routes = [
path: 'settings',
component: SettingsComponent,
},
{
path: 'logs',
component: LogsComponent,
},
{
path: 'bookmarks',
component: BookmarksComponent,
},
{
path: 'wallet',
component: WalletComponent,
},
{
path: 'backups',
component: BackupsComponent,
},
],
},
{
@@ -92,6 +113,10 @@ export const routes: Routes = [
path: 'keys',
component: EditIdentityKeysComponent,
},
{
path: 'ncryptsec',
component: EditIdentityNcryptsecComponent,
},
{
path: 'permissions',
component: EditIdentityPermissionsComponent,

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SignerMetaData, SignerMetaHandler } from '@common';
import { ExtensionSettings, SignerMetaHandler } from '@common';
export class ChromeMetaHandler extends SignerMetaHandler {
async loadFullData(): Promise<Partial<Record<string, any>>> {
@@ -19,7 +19,7 @@ export class ChromeMetaHandler extends SignerMetaHandler {
return data;
}
async saveFullData(data: SignerMetaData): Promise<void> {
async saveFullData(data: ExtensionSettings): Promise<void> {
await chrome.storage.local.set(data);
}

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData, BrowserSessionHandler } from '@common';
import { VaultSession, BrowserSessionHandler } from '@common';
export class ChromeSessionHandler extends BrowserSessionHandler {
async loadFullData(): Promise<Partial<Record<string, any>>> {
return chrome.storage.session.get(null);
}
async saveFullData(data: BrowserSessionData): Promise<void> {
async saveFullData(data: VaultSession): Promise<void> {
await chrome.storage.session.set(data);
}

View File

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

@@ -7,7 +7,12 @@
<span class="text-muted" style="font-size: 12px">
Nothing configured so far.
</span>
} @for(hostPermissions of hostsPermissions; track hostPermissions) {
} @else {
<button class="btn btn-danger btn-sm remove-all-btn" (click)="onClickRemoveAllPermissions()">
Remove All Permissions
</button>
}
@for(hostPermissions of hostsPermissions; track hostPermissions) {
<div class="permissions-card">
<span style="margin-bottom: 4px; font-weight: 500">
{{ hostPermissions.host }}
@@ -22,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

@@ -17,6 +17,10 @@
top: 0;
}
.remove-all-btn {
margin-bottom: var(--size);
}
.permissions-card {
background: var(--background-light);
border-radius: 8px;

View File

@@ -5,6 +5,7 @@ import {
NavComponent,
Permission_DECRYPTED,
StorageService,
getKindName,
} from '@common';
import { ActivatedRoute } from '@angular/router';
@@ -41,6 +42,14 @@ export class PermissionsComponent extends NavComponent implements OnInit {
this.#buildHostsPermissions(this.identity?.id);
}
async onClickRemoveAllPermissions() {
const allPermissions = this.hostsPermissions.flatMap(hp => hp.permissions);
for (const permission of allPermissions) {
await this.#storage.deletePermission(permission.id);
}
this.#buildHostsPermissions(this.identity?.id);
}
#initialize(identityId: string) {
this.identity = this.#storage
.getBrowserSessionHandler()
@@ -78,4 +87,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
});
});
}
getKindTooltip(kind: number): string {
return getKindName(kind);
}
}

View File

@@ -28,7 +28,7 @@
</div>
<div class="info-banner">
<i class="bi bi-info-circle"></i>
<span class="emoji">💡</span>
<span>These relays are fetched from your NIP-65 relay list (kind 10002). To update your relay list, use a Nostr client that supports NIP-65.</span>
</div>

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

@@ -0,0 +1,46 @@
<!-- 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>Bookmarks</span>
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
<span class="emoji"></span>
</button>
</div>
<div class="bookmarks-container">
@if (isLoading) {
<div class="loading-state">Loading...</div>
} @else if (bookmarks.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No bookmarks yet. Click "Bookmark This Page" to add the current page.
</span>
</div>
} @else {
@for (bookmark of bookmarks; track bookmark.id) {
<div class="bookmark-item" (click)="openBookmark(bookmark)">
<div class="bookmark-info">
<span class="bookmark-title">{{ bookmark.title }}</span>
<span class="bookmark-url">{{ getDomain(bookmark.url) }}</span>
</div>
<button
class="remove-btn"
title="Remove bookmark"
(click)="onRemoveBookmark(bookmark); $event.stopPropagation()"
>
<span class="emoji"></span>
</button>
</div>
}
}
</div>

View File

@@ -0,0 +1,112 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.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 {
flex: 1;
overflow-y: auto;
}
.empty-state,
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted-foreground);
}
.bookmark-item {
display: flex;
align-items: center;
gap: var(--size-h);
padding: var(--size-h) var(--size);
margin-bottom: var(--size-hh);
background: var(--background-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: var(--background-light-hover);
}
}
.bookmark-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.bookmark-title {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-url {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-btn {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
color: var(--muted-foreground);
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--destructive);
color: var(--destructive-foreground);
}
}

View File

@@ -0,0 +1,98 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Bookmark, LoggerService, NavComponent, SignerMetaData } from '@common';
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
@Component({
selector: 'app-bookmarks',
templateUrl: './bookmarks.component.html',
styleUrl: './bookmarks.component.scss',
imports: [],
})
export class BookmarksComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #metaHandler = new ChromeMetaHandler();
readonly #router = inject(Router);
bookmarks: Bookmark[] = [];
isLoading = true;
async ngOnInit() {
await this.loadBookmarks();
}
async loadBookmarks() {
this.isLoading = true;
try {
const metaData = await this.#metaHandler.loadFullData() as SignerMetaData;
this.#metaHandler.setFullData(metaData);
this.bookmarks = this.#metaHandler.getBookmarks();
} catch (error) {
console.error('Failed to load bookmarks:', error);
} finally {
this.isLoading = false;
}
}
async onBookmarkThisPage() {
try {
// Get the current tab URL and title
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.url || !tab?.title) {
console.error('Could not get current tab info');
return;
}
// Check if already bookmarked
if (this.bookmarks.some(b => b.url === tab.url)) {
console.log('Page already bookmarked');
return;
}
const newBookmark: Bookmark = {
id: crypto.randomUUID(),
url: tab.url,
title: tab.title,
createdAt: Date.now(),
};
this.bookmarks = [newBookmark, ...this.bookmarks];
await this.saveBookmarks();
this.#logger.logBookmarkAdded(newBookmark.url, newBookmark.title);
} catch (error) {
console.error('Failed to bookmark page:', error);
}
}
async onRemoveBookmark(bookmark: Bookmark) {
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmark.id);
await this.saveBookmarks();
this.#logger.logBookmarkRemoved(bookmark.url, bookmark.title);
}
async saveBookmarks() {
try {
await this.#metaHandler.setBookmarks(this.bookmarks);
} catch (error) {
console.error('Failed to save bookmarks:', error);
}
}
openBookmark(bookmark: Bookmark) {
chrome.tabs.create({ url: bookmark.url });
}
getDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -3,34 +3,23 @@
</div>
<div class="tabs">
<a
class="tab"
routerLink="/home/identity"
routerLinkActive="active"
title="Your selected identity"
>
<i class="bi bi-person-circle"></i>
<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"
>
<i class="bi bi-people-fill"></i>
<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"
>
<i class="bi bi-gear"></i>
<a class="tab" routerLink="/home/wallet" routerLinkActive="active" title="Wallet">
<span class="emoji">💰</span>
</a>
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<i class="bi bi-info-circle"></i>
<a class="tab" routerLink="/home/bookmarks" routerLinkActive="active" title="Bookmarks">
<span class="emoji">🔖</span>
</a>
<a class="tab" routerLink="/home/settings" routerLinkActive="active" title="Settings">
<span class="emoji">⚙️</span>
</a>
</div>

View File

@@ -4,17 +4,17 @@
flex-direction: column;
.tab-content {
height: calc(100% - 60px);
height: calc(100% - 48px);
}
.tabs {
height: 60px;
min-height: 60px;
height: 48px;
min-height: 48px;
background: var(--background-light);
display: flex;
flex-direction: row;
a {
a, button {
all: unset;
}
@@ -23,14 +23,17 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--muted-foreground);
border-top: 3px solid transparent;
border-top: 2px solid transparent;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
cursor: pointer;
.emoji {
font-size: 20px;
}
&:hover {
background: var(--background-light-hover);
color: var(--foreground);
@@ -38,7 +41,7 @@
&.active {
color: var(--foreground);
border-top: 3px solid var(--primary);
border-top: 2px solid var(--primary);
}
}
}

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>
@@ -31,7 +38,7 @@
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
<span class="emoji">⚙️</span>
</button>
</div>
@@ -62,7 +69,7 @@
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button
icon="gear"
icon="⚙️"
title="Identity settings"
(click)="onClickEditIdentity(identity.id, $event)"
></lib-icon-button>

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

@@ -2,11 +2,12 @@ import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
PubkeyComponent,
StorageService,
ToastComponent,
VisualNip05Pipe,
validateNip05,
@@ -18,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;
@@ -26,9 +27,9 @@ 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);
ngOnInit(): void {
this.#loadData();
@@ -50,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;
@@ -74,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
@@ -136,13 +147,16 @@ export class IdentityComponent implements OnInit {
const result = await validateNip05(nip05, pubkey);
this.nip05isValidated = result.valid;
if (!result.valid) {
console.log('NIP-05 validation failed:', result.error);
if (result.valid) {
this.#logger.logNip05ValidationSuccess(nip05, pubkey);
} else {
this.#logger.logNip05ValidationError(nip05, result.error ?? 'Unknown error');
}
this.validating = false;
} catch (error) {
console.error('NIP-05 validation failed:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logNip05ValidationError(nip05, errorMsg);
this.nip05isValidated = false;
this.validating = false;
}

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

@@ -0,0 +1,30 @@
<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" title="Refresh logs" (click)="onRefresh()">Refresh</button>
<button class="btn btn-sm btn-secondary" title="Clear logs" (click)="onClear()">Clear</button>
</div>
</div>
<div class="logs-container">
@if (logs.length === 0) {
<div class="logs-empty">No activity logged yet</div>
}
@for (log of logs; track log.timestamp) {
<div class="log-entry" [class]="getLevelClass(log.level)">
<span class="log-icon emoji">{{ log.icon }}</span>
<span class="log-time">{{ log.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
}
</div>

View File

@@ -0,0 +1,88 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.sam-text-header {
margin-bottom: var(--size);
flex-shrink: 0;
.logs-actions {
position: absolute;
right: 0;
display: flex;
gap: 8px;
}
}
}
.logs-container {
flex: 1;
overflow-y: auto;
background: var(--background-light);
border-radius: 8px;
padding: var(--size-h);
}
.logs-empty {
color: var(--muted-foreground);
text-align: center;
padding: var(--size);
}
.log-entry {
font-family: var(--font-sans);
font-size: 11px;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 2px;
display: flex;
gap: 8px;
align-items: center;
&.log-error {
background: rgba(220, 53, 69, 0.15);
color: #ff6b6b;
}
&.log-warn {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
}
&.log-debug {
background: rgba(108, 117, 125, 0.15);
color: #adb5bd;
}
&.log-info {
background: rgba(13, 110, 253, 0.1);
color: var(--foreground);
}
}
.log-icon {
font-size: 14px;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.log-time {
color: var(--muted-foreground);
flex-shrink: 0;
font-size: 10px;
}
.log-message {
flex: 1;
word-break: break-word;
}

View File

@@ -0,0 +1,51 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, NavComponent } from '@common';
import { DatePipe } from '@angular/common';
@Component({
selector: 'app-logs',
templateUrl: './logs.component.html',
styleUrl: './logs.component.scss',
imports: [DatePipe],
})
export class LogsComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #router = inject(Router);
get logs(): LogEntry[] {
return this.#logger.logs;
}
ngOnInit() {
// Refresh logs from storage to get background script logs
this.#logger.refreshLogs();
}
async onRefresh() {
await this.#logger.refreshLogs();
}
async onClear() {
await this.#logger.clear();
}
getLevelClass(level: LogEntry['level']): string {
switch (level) {
case 'error':
return 'log-error';
case 'warn':
return 'log-warn';
case 'debug':
return 'log-debug';
default:
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,26 +1,33 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
BrowserSyncData,
BrowserSyncFlow,
ConfirmComponent,
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);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
const vault = JSON.stringify(
@@ -40,10 +47,49 @@ 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() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
@@ -69,6 +115,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
await this.#storage.deleteVault(true);
await this.#storage.importVault(vault);
this.#logger.logVaultImport(file.name);
this.#storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
@@ -84,6 +131,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
const fileName = `Plebeian Signer Chrome - Vault Export - ${dateTimeString}.json`;
this.#downloadJson(jsonVault, fileName);
this.#logger.logVaultExport(fileName);
}
#downloadJson(jsonString: string, fileName: string) {
@@ -96,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,660 @@
<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">
@if (showCashuInfo) {
<div class="info-panel">
<h3>Welcome to Cashu Wallet</h3>
<div class="info-section">
<h4>Storage Considerations</h4>
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
<div class="warning-box">
<p><strong>Browser Sync is enabled</strong></p>
<p>
Sync storage is limited to ~100KB shared across all your vault data
(identities, permissions, relays, and Cashu tokens). This limits
your Cashu wallet to approximately 300-400 tokens.
</p>
<p>
For larger Cashu holdings, consider disabling browser sync which
provides ~5MB of local storage (~18,000+ tokens).
</p>
<button class="link-btn" (click)="navigateToSettings()">
Change Sync Settings
</button>
</div>
} @else {
<div class="success-box">
<p><strong>Local Storage Mode</strong></p>
<p>
You have ~5MB of local storage available, which can hold
thousands of Cashu tokens. Your data stays on this device only.
</p>
</div>
}
</div>
<div class="info-section">
<h4>Backup Your Wallet</h4>
<p>
<strong>Important:</strong> Cashu tokens are bearer assets.
If you lose your vault backup, you lose your tokens permanently.
</p>
<p>
Vault exports are saved to your browser's downloads folder.
Configure this to point to either:
</p>
<ul>
<li>Your backup storage device (external drive, NAS)</li>
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
</ul>
<p class="browser-url">
<code>{{ browserDownloadSettingsUrl }}</code>
</p>
<button class="link-btn" (click)="navigateToSettings()">
Go to Backup Settings
</button>
</div>
<button class="dismiss-btn" (click)="dismissCashuInfo()">
Got it, let me add a mint
</button>
</div>
} @else {
<div class="empty-state">
<span class="sam-text-muted">No mints connected yet.</span>
<button class="show-info-btn" (click)="showCashuInfo = true">
Show storage info
</button>
</div>
}
</div>
} @else {
<div class="wallet-list">
@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>
}
<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">
<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,951 @@
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 = '';
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;
}
}
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,7 +1,7 @@
import { Component, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent, StorageService, DerivingModalComponent } from '@common';
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
@Component({
selector: 'app-new',
@@ -16,6 +16,7 @@ export class NewComponent extends NavComponent {
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
readonly #logger = inject(LoggerService);
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
@@ -35,6 +36,7 @@ export class NewComponent extends NavComponent {
try {
await this.#storage.createNewVault(this.password);
this.derivingModal.hide();
this.#logger.logVaultCreated();
this.#router.navigateByUrl('/home/identities');
} catch (error) {
this.derivingModal.hide();

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

@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import {
ConfirmComponent,
DerivingModalComponent,
LoggerService,
NostrHelper,
ProfileMetadataService,
StartupService,
@@ -28,6 +29,7 @@ export class VaultLoginComponent implements AfterViewInit {
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngAfterViewInit() {
this.passwordInput.nativeElement.focus();
@@ -69,6 +71,7 @@ export class VaultLoginComponent implements AfterViewInit {
// Unlock succeeded - hide modal and navigate
console.log('[login] Hiding modal and navigating');
this.derivingModal.hide();
this.#logger.logVaultUnlock();
// Fetch profile metadata for all identities in the background
this.#fetchAllProfiles();
@@ -102,6 +105,7 @@ export class VaultLoginComponent implements AfterViewInit {
async onClickResetExtension() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {

View File

@@ -37,6 +37,26 @@
<span> Sync OFF</span>
</button>
<div class="storage-info">
<details>
<summary>Important for Cashu wallet users</summary>
<p>
Browser sync storage is limited to ~100KB shared across all data
(identities, permissions, relays, and Cashu tokens).
</p>
<p>
If you plan to use the Cashu ecash wallet with significant balances,
choose <strong>"Sync OFF"</strong> which provides ~5MB of local storage
(enough for ~18,000+ tokens vs ~300-400 with sync).
</p>
<p>
<strong>Note:</strong> Cashu tokens are bearer assets. If you lose your
vault backup, you lose your tokens permanently. Make sure to configure
regular backups.
</p>
</details>
</div>
<div class="sam-flex-grow"></div>
<span class="sam-text-muted sam-text-md sam-mb">

View File

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

View File

@@ -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,25 +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;
@@ -32,7 +62,7 @@ export interface PromptResponseMessage {
}
export interface BackgroundRequestMessage {
method: Nip07Method;
method: ExtensionMethod;
params: any;
host: string;
}
@@ -66,6 +96,8 @@ export const shouldRecklessModeApprove = async function (
host: string
): Promise<boolean> {
const signerMetaData = await getSignerMetaData();
debug(`shouldRecklessModeApprove: recklessMode=${signerMetaData.recklessMode}, host=${host}`);
debug(`Full signerMetaData: ${JSON.stringify(signerMetaData)}`);
if (!signerMetaData.recklessMode) {
return false;
@@ -193,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
) {
@@ -206,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,
};
@@ -223,8 +298,7 @@ export const storePermission = async function (
// Encrypt permission to store in sync storage (depending on sync flow).
const encryptedPermission = await encryptPermission(
permission,
browserSessionData.iv,
browserSessionData.vaultPassword as string
browserSessionData
);
await savePermissionsToBrowserSyncStorage([
@@ -321,22 +395,20 @@ export const nip44Decrypt = async function (
const encryptPermission = async function (
permission: Permission_DECRYPTED,
iv: string,
password: string
sessionData: BrowserSessionData
): Promise<Permission_ENCRYPTED> {
const encryptedPermission: Permission_ENCRYPTED = {
id: await encrypt(permission.id, iv, password),
identityId: await encrypt(permission.identityId, iv, password),
host: await encrypt(permission.host, iv, password),
method: await encrypt(permission.method, iv, password),
methodPolicy: await encrypt(permission.methodPolicy, iv, password),
id: await encrypt(permission.id, sessionData),
identityId: await encrypt(permission.identityId, sessionData),
host: await encrypt(permission.host, sessionData),
method: await encrypt(permission.method, sessionData),
methodPolicy: await encrypt(permission.methodPolicy, sessionData),
};
if (typeof permission.kind !== 'undefined') {
encryptedPermission.kind = await encrypt(
permission.kind.toString(),
iv,
password
sessionData
);
}
@@ -345,8 +417,379 @@ const encryptPermission = async function (
const encrypt = async function (
value: string,
iv: string,
sessionData: BrowserSessionData
): Promise<string> {
// v2: Use pre-derived key with AES-GCM directly
if (sessionData.vaultKey) {
const keyBytes = Buffer.from(sessionData.vaultKey, 'base64');
const iv = Buffer.from(sessionData.iv, 'base64');
const key = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['encrypt']
);
const cipherText = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(value)
);
return Buffer.from(cipherText).toString('base64');
}
// 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 await CryptoHelper.encrypt(value, iv, password);
};
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,36 +1,336 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NostrHelper } from '@common';
import {
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);
@@ -39,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.');
}
@@ -63,10 +392,9 @@ 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}`);
if (recklessApprove) {
debug('Request auto-approved via reckless mode.');
} else {
@@ -75,49 +403,65 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
browserSessionData,
currentIdentity,
req.host,
req.method,
req.method as Nip07Method,
req.params
);
debug(`permissionState result: ${permissionState}`);
if (permissionState === false) {
throw new Error('Permission denied');
}
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,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
policy,
req.params?.kind
);
} 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,
'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)) {
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
throw new Error('Permission denied');
}
} else {
@@ -126,6 +470,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const relays: Relays = {};
switch (req.method) {
case 'getPublicKey':
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
@@ -170,4 +515,146 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
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,14 +1,32 @@
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
import { Nip07Method } from '@common';
import { ExtensionMethod } from '@common';
import { PromptResponse, PromptResponseMessage } from './background-common';
/**
* Decode base64 string to UTF-8 using native browser APIs.
* This avoids race conditions with the Buffer polyfill initialization.
*/
function base64ToUtf8(base64: string): string {
const binaryString = atob(base64);
const bytes = Uint8Array.from(binaryString, char => char.charCodeAt(0));
return new TextDecoder('utf-8').decode(bytes);
}
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;
const event = Buffer.from(params.get('event') as string, 'base64').toString();
let event = '{}';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let eventParsed: any = {};
try {
event = base64ToUtf8(params.get('event') as string);
eventParsed = JSON.parse(event);
} catch (e) {
console.error('Failed to parse event:', e);
}
let title = '';
switch (method) {
@@ -40,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;
}
@@ -62,8 +100,8 @@ Array.from(document.getElementsByClassName('host-INSERT')).forEach(
);
const kindSpanElement = document.getElementById('kindSpan');
if (kindSpanElement) {
kindSpanElement.innerText = JSON.parse(event).kind;
if (kindSpanElement && eventParsed.kind !== undefined) {
kindSpanElement.innerText = eventParsed.kind;
}
const cardGetPublicKeyElement = document.getElementById('cardGetPublicKey');
@@ -108,9 +146,8 @@ if (cardNip04EncryptElement && card2Nip04EncryptElement) {
'card2Nip04Encrypt_text'
);
if (card2Nip04Encrypt_textElement) {
const eventObject: { peerPubkey: string; plaintext: string } =
JSON.parse(event);
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext;
const eventObject = eventParsed as { peerPubkey: string; plaintext: string };
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext || '';
}
} else {
cardNip04EncryptElement.style.display = 'none';
@@ -126,9 +163,8 @@ if (cardNip44EncryptElement && card2Nip44EncryptElement) {
'card2Nip44Encrypt_text'
);
if (card2Nip44Encrypt_textElement) {
const eventObject: { peerPubkey: string; plaintext: string } =
JSON.parse(event);
card2Nip44Encrypt_textElement.innerText = eventObject.plaintext;
const eventObject = eventParsed as { peerPubkey: string; plaintext: string };
card2Nip44Encrypt_textElement.innerText = eventObject.plaintext || '';
}
} else {
cardNip44EncryptElement.style.display = 'none';
@@ -143,9 +179,8 @@ if (cardNip04DecryptElement && card2Nip04DecryptElement) {
'card2Nip04Decrypt_text'
);
if (card2Nip04Decrypt_textElement) {
const eventObject: { peerPubkey: string; ciphertext: string } =
JSON.parse(event);
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext;
const eventObject = eventParsed as { peerPubkey: string; ciphertext: string };
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext || '';
}
} else {
cardNip04DecryptElement.style.display = 'none';
@@ -161,9 +196,8 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
'card2Nip44Decrypt_text'
);
if (card2Nip44Decrypt_textElement) {
const eventObject: { peerPubkey: string; ciphertext: string } =
JSON.parse(event);
card2Nip44Decrypt_textElement.innerText = eventObject.ciphertext;
const eventObject = eventParsed as { peerPubkey: string; ciphertext: string };
card2Nip44Decrypt_textElement.innerText = eventObject.ciphertext || '';
}
} else {
cardNip44DecryptElement.style.display = 'none';
@@ -171,40 +205,118 @@ 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
//
function deliver(response: PromptResponse) {
async function deliver(response: PromptResponse) {
const message: PromptResponseMessage = {
id,
response,
};
browser.runtime.sendMessage(message);
try {
await browser.runtime.sendMessage(message);
} catch (error) {
console.error('Failed to send message:', error);
}
window.close();
}
document.addEventListener('DOMContentLoaded', function () {
const rejectJustOnceButton = document.getElementById('rejectJustOnceButton');
rejectJustOnceButton?.addEventListener('click', () => {
const rejectOnceButton = document.getElementById('rejectOnceButton');
rejectOnceButton?.addEventListener('click', () => {
deliver('reject-once');
});
const rejectButton = document.getElementById('rejectButton');
rejectButton?.addEventListener('click', () => {
const rejectAlwaysButton = document.getElementById('rejectAlwaysButton');
rejectAlwaysButton?.addEventListener('click', () => {
deliver('reject');
});
const approveJustOnceButton = document.getElementById(
'approveJustOnceButton'
);
approveJustOnceButton?.addEventListener('click', () => {
const approveOnceButton = document.getElementById('approveOnceButton');
approveOnceButton?.addEventListener('click', () => {
deliver('approve-once');
});
const approveButton = document.getElementById('approveButton');
approveButton?.addEventListener('click', () => {
const approveAlwaysButton = document.getElementById('approveAlwaysButton');
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

@@ -3,8 +3,7 @@
<div class="deriving-modal">
<div class="deriving-spinner"></div>
<h3>{{ message }}</h3>
<div class="deriving-timer">{{ elapsed.toFixed(1) }}s</div>
<p class="deriving-note">This may take 3-6 seconds for security</p>
<p class="deriving-note">This may take a few seconds</p>
</div>
</div>
}

View File

@@ -30,14 +30,6 @@
}
}
.deriving-timer {
font-size: 2.5rem;
font-weight: bold;
color: #ff3eb5;
font-family: monospace;
margin: 0.5rem 0;
}
.deriving-note {
margin: 0.5rem 0 0;
color: #a1a1a1;

View File

@@ -1,23 +1,16 @@
import {
Component,
OnDestroy,
} from '@angular/core';
import { Component } from '@angular/core';
@Component({
selector: 'app-deriving-modal',
templateUrl: './deriving-modal.component.html',
styleUrl: './deriving-modal.component.scss',
})
export class DerivingModalComponent implements OnDestroy {
export class DerivingModalComponent {
visible = false;
elapsed = 0;
message = 'Deriving encryption key';
#startTime: number | null = null;
#animationFrame: number | null = null;
/**
* Show the deriving modal and start the timer
* Show the deriving modal
* @param message Optional custom message
*/
show(message?: string): void {
@@ -25,35 +18,12 @@ export class DerivingModalComponent implements OnDestroy {
this.message = message;
}
this.visible = true;
this.elapsed = 0;
this.#startTime = performance.now();
this.#updateTimer();
}
/**
* Hide the modal and stop the timer
* Hide the modal
*/
hide(): void {
this.visible = false;
this.#stopTimer();
}
ngOnDestroy(): void {
this.#stopTimer();
}
#updateTimer(): void {
if (this.#startTime !== null) {
this.elapsed = (performance.now() - this.#startTime) / 1000;
this.#animationFrame = requestAnimationFrame(() => this.#updateTimer());
}
}
#stopTimer(): void {
this.#startTime = null;
if (this.#animationFrame !== null) {
cancelAnimationFrame(this.#animationFrame);
this.#animationFrame = null;
}
}
}

View File

@@ -1,3 +1,7 @@
<div class="icon-button">
<i [class]="'bi bi-' + icon"></i>
@if (isEmoji) {
<span class="emoji">{{ icon }}</span>
} @else {
<i [class]="'bi bi-' + icon"></i>
}
</div>

View File

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

View File

@@ -9,4 +9,9 @@ import { Component, Input } from '@angular/core';
})
export class IconButtonComponent {
@Input({ required: true }) icon!: string;
get isEmoji(): boolean {
// Check if the icon is an emoji (starts with a non-ASCII character)
return this.icon.length > 0 && this.icon.charCodeAt(0) > 255;
}
}

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

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