16 Commits

Author SHA1 Message Date
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
149 changed files with 17615 additions and 749 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

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": "v1.1.0",
"custom": {
"chrome": {
"version": "v1.0.1"
"version": "v1.1.0"
},
"firefox": {
"version": "v1.0.1"
"version": "v1.1.0"
}
},
"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",

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.0",
"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,83 @@
<!-- 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>
</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,6 +9,10 @@ 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';
@@ -66,6 +70,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,
},
],
},
{

View File

@@ -2,7 +2,9 @@
import {
BrowserSyncData,
BrowserSyncHandler,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
} from '@common';
@@ -57,6 +59,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): 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,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
BrowserSyncHandler,
Relay_ENCRYPTED,
@@ -49,6 +51,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
await chrome.storage.sync.clear();
}

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

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

@@ -41,6 +41,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()

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,14 +6,39 @@ 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';
// 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;
}
export const debug = function (message: any) {
const dateString = new Date().toISOString();
@@ -32,7 +57,7 @@ export interface PromptResponseMessage {
}
export interface BackgroundRequestMessage {
method: Nip07Method;
method: ExtensionMethod;
params: any;
host: string;
}
@@ -66,6 +91,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 +220,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 +273,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 +293,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 +390,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 +412,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,24 +1,108 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NostrHelper } from '@common';
import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
NwcClient,
NwcConnection_DECRYPTED,
WeblnMethod,
Nip07Method,
GetInfoResponse,
SendPaymentResponse,
RequestInvoiceResponse,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
checkWeblnPermissions,
debug,
getBrowserSessionData,
getPosition,
handleUnlockRequest,
isWeblnMethod,
nip04Decrypt,
nip04Encrypt,
nip44Decrypt,
nip44Encrypt,
openUnlockPopup,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
UnlockRequestMessage,
UnlockResponseMessage,
} from './background-common';
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
// Cache for NWC clients to avoid reconnecting for each request
const nwcClientCache = new Map<string, NwcClient>();
/**
* Get or create an NWC client for a connection
*/
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
const cached = nwcClientCache.get(connection.id);
if (cached && cached.isConnected()) {
return cached;
}
const client = new NwcClient({
walletPubkey: connection.walletPubkey,
relayUrl: connection.relayUrl,
secret: connection.secret,
});
await client.connect();
nwcClientCache.set(connection.id, client);
return client;
}
/**
* Parse invoice amount from a BOLT11 invoice string
* Returns amount in satoshis, or undefined if no amount specified
*/
function parseInvoiceAmount(invoice: string): number | undefined {
try {
// BOLT11 invoices start with 'ln' followed by network prefix and amount
// Format: ln[network][amount][multiplier]1[data]
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
if (!match) {
return undefined;
}
const amountStr = match[2];
const multiplier = match[3];
let amount = parseInt(amountStr, 10);
// Apply multiplier (amount is in BTC by default)
switch (multiplier) {
case 'm': // milli-bitcoin (0.001 BTC)
amount = amount * 100000;
break;
case 'u': // micro-bitcoin (0.000001 BTC)
amount = amount * 100;
break;
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
amount = Math.floor(amount / 10);
break;
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
amount = Math.floor(amount / 10000);
break;
default:
// No multiplier means BTC
amount = amount * 100000000;
}
return amount;
} catch {
return undefined;
}
}
type Relays = Record<string, { read: boolean; write: boolean }>;
const openPrompts = new Map<
@@ -29,8 +113,49 @@ const openPrompts = new Map<
}
>();
// 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;
}[] = [];
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);
@@ -51,6 +176,36 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
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 +218,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,9 +229,10 @@ 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');
@@ -107,17 +262,28 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
});
debug(response);
if (response === 'approve' || response === 'reject') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
policy,
req.params?.kind
);
await backgroundLogPermissionStored(
req.host,
req.method,
policy,
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
await backgroundLogNip07Action(req.method, req.host, false, false, {
kind: req.params?.kind,
peerPubkey: req.params?.peerPubkey,
});
throw new Error('Permission denied');
}
} else {
@@ -126,48 +292,216 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const relays: Relays = {};
let result: any;
switch (req.method) {
case 'getPublicKey':
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return result;
case 'signEvent':
return signEvent(req.params, currentIdentity.privkey);
result = signEvent(req.params, currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
kind: req.params?.kind,
});
return result;
case 'getRelays':
browserSessionData.relays.forEach((x) => {
relays[x.url] = { read: x.read, write: x.write };
});
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return relays;
case 'nip04.encrypt':
return await nip04Encrypt(
result = await nip04Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.encrypt':
return await nip44Encrypt(
result = await nip44Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip04.decrypt':
return await nip04Decrypt(
result = await nip04Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.decrypt':
return await nip44Decrypt(
result = await nip44Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
default:
throw new Error(`Not supported request method '${req.method}'.`);
}
});
}
/**
* Process a WebLN request after vault is unlocked
*/
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
const nwcConnections = browserSessionData.nwcConnections ?? [];
const method = req.method as WeblnMethod;
// webln.enable just checks if NWC is configured
if (method === 'webln.enable') {
if (nwcConnections.length === 0) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
debug('WebLN enabled');
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
}
// All other methods require an NWC connection
const defaultConnection = nwcConnections[0];
if (!defaultConnection) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
// Check reckless mode (but still prompt for payments)
const recklessApprove = await shouldRecklessModeApprove(req.host);
// Check WebLN permissions
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
? true
: checkWeblnPermissions(browserSessionData, req.host, method);
if (permissionState === false) {
throw new Error('Permission denied');
}
if (permissionState === undefined) {
// Ask user for permission
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
// 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');
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=${method}&host=${req.host}&id=${id}&nick=WebLN&event=${base64Event}`,
height,
width,
top,
left,
});
});
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
);
await backgroundLogPermissionStored(req.host, method, policy);
}
if (['reject', 'reject-once'].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,101 @@ 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');
});
});

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,450 @@
import { Injectable } from '@angular/core';
import {
Mint,
Wallet,
getDecodedToken,
getEncodedTokenV4,
Token,
Proof,
CheckStateEnum,
} from '@cashu/cashu-ts';
import { StorageService, CashuMint_DECRYPTED, CashuProof } from '@common';
import {
CashuReceiveResult,
CashuSendResult,
DecodedCashuToken,
CashuMintInfo,
CashuMintQuote,
CashuMintResult,
MintQuoteState,
} from './types';
interface CachedWallet {
wallet: Wallet;
mint: Mint;
mintId: string;
}
/**
* Angular service for managing Cashu ecash wallets
*/
@Injectable({
providedIn: 'root',
})
export class CashuService {
private wallets = new Map<string, CachedWallet>();
constructor(private storageService: StorageService) {}
/**
* Get all Cashu mints from storage
*/
getMints(): CashuMint_DECRYPTED[] {
const sessionData =
this.storageService.getBrowserSessionHandler().browserSessionData;
return sessionData?.cashuMints ?? [];
}
/**
* Get a single Cashu mint by ID
*/
getMint(mintId: string): CashuMint_DECRYPTED | undefined {
return this.getMints().find((m) => m.id === mintId);
}
/**
* Get a mint by URL
*/
getMintByUrl(mintUrl: string): CashuMint_DECRYPTED | undefined {
const normalizedUrl = mintUrl.replace(/\/$/, '');
return this.getMints().find((m) => m.mintUrl === normalizedUrl);
}
/**
* Add a new Cashu mint connection
*/
async addMint(name: string, mintUrl: string): Promise<CashuMint_DECRYPTED> {
// Test the mint connection first
await this.testMintConnection(mintUrl);
// Add to storage
return await this.storageService.addCashuMint({
name,
mintUrl,
unit: 'sat',
});
}
/**
* Delete a Cashu mint connection
*/
async deleteMint(mintId: string): Promise<void> {
// Remove from cache
this.wallets.delete(mintId);
await this.storageService.deleteCashuMint(mintId);
}
/**
* Get or create a wallet for a mint
*/
private async getWallet(mintId: string): Promise<CachedWallet> {
// Check cache
const cached = this.wallets.get(mintId);
if (cached) {
return cached;
}
// Get mint data from storage
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
// Create mint and wallet instances
const mint = new Mint(mintData.mintUrl);
const wallet = new Wallet(mint, { unit: mintData.unit || 'sat' });
// Load mint keys
await wallet.loadMint();
// Cache it
const cachedWallet: CachedWallet = {
wallet,
mint,
mintId,
};
this.wallets.set(mintId, cachedWallet);
return cachedWallet;
}
/**
* Test a mint connection by fetching its info
*/
async testMintConnection(mintUrl: string): Promise<CashuMintInfo> {
const normalizedUrl = mintUrl.replace(/\/$/, '');
const mint = new Mint(normalizedUrl);
const info = await mint.getInfo();
return {
name: info.name,
description: info.description,
version: info.version,
contact: info.contact?.map((c) => ({ method: c.method, info: c.info })),
nuts: info.nuts,
};
}
/**
* Decode a Cashu token without claiming it
*/
decodeToken(token: string): DecodedCashuToken | null {
try {
const decoded = getDecodedToken(token);
const proofs = decoded.proofs;
const amount = proofs.reduce((sum, p) => sum + p.amount, 0);
return {
mint: decoded.mint,
unit: decoded.unit || 'sat',
amount,
proofs,
};
} catch {
return null;
}
}
/**
* Receive a Cashu token
* This validates and claims the proofs, then stores them
*/
async receive(token: string): Promise<CashuReceiveResult> {
// Decode the token
const decoded = this.decodeToken(token);
if (!decoded) {
throw new Error('Invalid token format');
}
// Check if we have this mint
let mintData = this.getMintByUrl(decoded.mint);
// If we don't have this mint, add it automatically
if (!mintData) {
// Use the mint URL as the name initially
const urlObj = new URL(decoded.mint);
mintData = await this.storageService.addCashuMint({
name: urlObj.hostname,
mintUrl: decoded.mint,
unit: decoded.unit || 'sat',
});
}
// Get the wallet for this mint
const { wallet } = await this.getWallet(mintData.id);
// Receive the token (this swaps proofs with the mint)
const receivedProofs = await wallet.receive(token);
// Convert to our proof format with timestamp
const now = new Date().toISOString();
const newProofs: CashuProof[] = receivedProofs.map((p: Proof) => ({
id: p.id,
amount: p.amount,
secret: p.secret,
C: p.C,
receivedAt: now,
}));
// Merge with existing proofs
const existingProofs = mintData!.proofs || [];
const allProofs = [...existingProofs, ...newProofs];
// Update storage
await this.storageService.updateCashuMintProofs(mintData!.id, allProofs);
// Calculate received amount
const amount = newProofs.reduce((sum, p) => sum + p.amount, 0);
return {
amount,
mintUrl: decoded.mint,
mintId: mintData!.id,
};
}
/**
* Send Cashu tokens
* Creates an encoded token from existing proofs
*/
async send(mintId: string, amount: number): Promise<CashuSendResult> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
// Check we have enough balance
const balance = this.getBalance(mintId);
if (balance < amount) {
throw new Error(`Insufficient balance. Have ${balance} sats, need ${amount} sats`);
}
// Get the wallet
const { wallet } = await this.getWallet(mintId);
// Convert our proofs to the format cashu-ts expects
const proofs: Proof[] = mintData.proofs.map((p) => ({
id: p.id,
amount: p.amount,
secret: p.secret,
C: p.C,
}));
// Send - this returns send proofs and keep proofs (change)
const { send, keep } = await wallet.send(amount, proofs);
// Create the token to share
const token: Token = {
mint: mintData.mintUrl,
proofs: send,
unit: mintData.unit || 'sat',
};
const encodedToken = getEncodedTokenV4(token);
// Update our stored proofs to only keep the change (new proofs from mint)
const now = new Date().toISOString();
const keepProofs: CashuProof[] = keep.map((p: Proof) => ({
id: p.id,
amount: p.amount,
secret: p.secret,
C: p.C,
receivedAt: now,
}));
await this.storageService.updateCashuMintProofs(mintId, keepProofs);
return {
token: encodedToken,
amount,
};
}
/**
* Check if any proofs have been spent
* Removes spent proofs from storage
*/
async checkProofsSpent(mintId: string): Promise<number> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
if (mintData.proofs.length === 0) {
return 0;
}
const { wallet } = await this.getWallet(mintId);
// Only the secret field is needed for checking proof states
const proofsToCheck = mintData.proofs.map((p) => ({ secret: p.secret }));
// Check which proofs are spent using v3 API
const proofStates = await wallet.checkProofsStates(proofsToCheck);
// Filter out spent proofs
const unspentProofs: CashuProof[] = [];
let removedAmount = 0;
for (let i = 0; i < mintData.proofs.length; i++) {
if (proofStates[i].state !== CheckStateEnum.SPENT) {
unspentProofs.push(mintData.proofs[i]);
} else {
removedAmount += mintData.proofs[i].amount;
}
}
// Update storage if any were spent
if (removedAmount > 0) {
await this.storageService.updateCashuMintProofs(mintId, unspentProofs);
}
return removedAmount;
}
/**
* Create a mint quote (Lightning invoice) for depositing sats
* Returns a Lightning invoice that when paid will allow minting tokens
*/
async createMintQuote(mintId: string, amount: number): Promise<CashuMintQuote> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
if (amount <= 0) {
throw new Error('Amount must be greater than 0');
}
const { wallet } = await this.getWallet(mintId);
// Create a mint quote - this returns a Lightning invoice
const quote = await wallet.createMintQuote(amount);
return {
quoteId: quote.quote,
invoice: quote.request,
amount: amount,
state: quote.state as MintQuoteState,
expiry: quote.expiry,
};
}
/**
* Check the status of a mint quote
* Returns the current state (UNPAID, PAID, ISSUED)
*/
async checkMintQuote(mintId: string, quoteId: string): Promise<CashuMintQuote> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
const { wallet } = await this.getWallet(mintId);
// Check the quote status
const quote = await wallet.checkMintQuote(quoteId);
return {
quoteId: quote.quote,
invoice: quote.request,
amount: 0, // Amount not returned in check response
state: quote.state as MintQuoteState,
expiry: quote.expiry,
};
}
/**
* Mint tokens after paying the Lightning invoice
* This claims the tokens and stores them
*/
async mintTokens(mintId: string, amount: number, quoteId: string): Promise<CashuMintResult> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
const { wallet } = await this.getWallet(mintId);
// Mint the proofs
const mintedProofs = await wallet.mintProofs(amount, quoteId);
// Convert to our proof format with timestamp
const now = new Date().toISOString();
const newProofs: CashuProof[] = mintedProofs.map((p: Proof) => ({
id: p.id,
amount: p.amount,
secret: p.secret,
C: p.C,
receivedAt: now,
}));
// Merge with existing proofs
const existingProofs = mintData.proofs || [];
const allProofs = [...existingProofs, ...newProofs];
// Update storage
await this.storageService.updateCashuMintProofs(mintId, allProofs);
// Calculate minted amount
const mintedAmount = newProofs.reduce((sum, p) => sum + p.amount, 0);
return {
amount: mintedAmount,
mintId: mintId,
};
}
/**
* Get balance for a specific mint (in satoshis)
*/
getBalance(mintId: string): number {
const mintData = this.getMint(mintId);
if (!mintData) {
return 0;
}
return mintData.proofs.reduce((sum, p) => sum + p.amount, 0);
}
/**
* Get proofs for a specific mint
*/
getProofs(mintId: string): CashuProof[] {
const mintData = this.getMint(mintId);
if (!mintData) {
return [];
}
return mintData.proofs;
}
/**
* Get total balance across all mints (in satoshis)
*/
getTotalBalance(): number {
const mints = this.getMints();
return mints.reduce((sum, m) => sum + this.getBalance(m.id), 0);
}
/**
* Get cached total balance (same as getTotalBalance for Cashu since it's all local)
*/
getCachedTotalBalance(): number {
return this.getTotalBalance();
}
/**
* Format a balance for display (Cashu uses satoshis, not millisatoshis)
*/
formatBalance(sats: number | undefined): string {
if (sats === undefined) return '—';
return sats.toLocaleString('en-US');
}
}

View File

@@ -0,0 +1,71 @@
import type { Proof } from '@cashu/cashu-ts';
/**
* Result from receiving a Cashu token
*/
export interface CashuReceiveResult {
amount: number; // Amount received in satoshis
mintUrl: string; // Mint the tokens were from
mintId: string; // ID of the mint in our storage
}
/**
* Result from sending Cashu tokens
*/
export interface CashuSendResult {
token: string; // Encoded token to share (cashuB...)
amount: number; // Amount in satoshis
}
/**
* Information about a decoded Cashu token
*/
export interface DecodedCashuToken {
mint: string; // Mint URL
unit: string; // Unit (usually 'sat')
amount: number; // Total amount in the token
proofs: Proof[]; // The individual proofs
}
/**
* Mint contact info
*/
export interface MintContact {
method: string;
info: string;
}
/**
* Mint information returned when testing a connection
*/
export interface CashuMintInfo {
name?: string;
description?: string;
version?: string;
contact?: MintContact[];
nuts: Record<string, unknown>;
}
/**
* State of a mint quote
*/
export type MintQuoteState = 'UNPAID' | 'PAID' | 'ISSUED';
/**
* Result from creating a mint quote (Lightning invoice to deposit)
*/
export interface CashuMintQuote {
quoteId: string; // Quote ID for checking status and claiming
invoice: string; // Lightning invoice to pay
amount: number; // Amount in satoshis
state: MintQuoteState; // Current state of the quote
expiry?: number; // Expiry timestamp (unix seconds)
}
/**
* Result from minting tokens after paying the invoice
*/
export interface CashuMintResult {
amount: number; // Amount minted in satoshis
mintId: string; // ID of the mint
}

View File

@@ -1,22 +1,424 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Injectable } from '@angular/core';
declare const chrome: any;
export type LogCategory =
| 'nip07'
| 'permission'
| 'vault'
| 'profile'
| 'bookmark'
| 'system';
export interface LogEntry {
timestamp: Date;
level: 'log' | 'warn' | 'error' | 'debug';
category: LogCategory;
icon: string;
message: string;
data?: any;
}
// Serializable format for storage
interface StoredLogEntry {
timestamp: string;
level: 'log' | 'warn' | 'error' | 'debug';
category: LogCategory;
icon: string;
message: string;
data?: any;
}
const LOGS_STORAGE_KEY = 'extensionLogs';
@Injectable({
providedIn: 'root',
})
export class LoggerService {
#namespace: string | undefined;
#logs: LogEntry[] = [];
#maxLogs = 500;
initialize(namespace: string): void {
this.#namespace = namespace;
get logs(): LogEntry[] {
return this.#logs;
}
log(value: any) {
async initialize(namespace: string): Promise<void> {
this.#namespace = namespace;
await this.#loadLogsFromStorage();
}
async #loadLogsFromStorage(): Promise<void> {
try {
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
const result = await chrome.storage.session.get(LOGS_STORAGE_KEY);
if (result[LOGS_STORAGE_KEY]) {
// Convert stored format back to LogEntry with Date objects
this.#logs = (result[LOGS_STORAGE_KEY] as StoredLogEntry[]).map(
(entry) => ({
...entry,
timestamp: new Date(entry.timestamp),
})
);
}
}
} catch (error) {
console.error('Failed to load logs from storage:', error);
}
}
async #saveLogsToStorage(): Promise<void> {
try {
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
// Convert Date to ISO string for storage
const storedLogs: StoredLogEntry[] = this.#logs.map((entry) => ({
...entry,
timestamp: entry.timestamp.toISOString(),
}));
await chrome.storage.session.set({ [LOGS_STORAGE_KEY]: storedLogs });
}
} catch (error) {
console.error('Failed to save logs to storage:', error);
}
}
async refreshLogs(): Promise<void> {
await this.#loadLogsFromStorage();
}
// ============================================
// Generic logging methods
// ============================================
log(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('log', 'system', '📝', value, data);
this.#consoleLog('log', value);
}
warn(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('warn', 'system', '⚠️', value, data);
this.#consoleLog('warn', value);
}
error(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('error', 'system', '❌', value, data);
this.#consoleLog('error', value);
}
debug(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('debug', 'system', '🔍', value, data);
this.#consoleLog('debug', value);
}
// ============================================
// NIP-07 Action Logging
// ============================================
logNip07Action(
method: string,
host: string,
approved: boolean,
autoApproved: boolean,
details?: { kind?: number; peerPubkey?: string }
) {
this.#assureInitialized();
const approvalType = autoApproved ? 'auto-approved' : approved ? 'approved' : 'denied';
const icon = approved ? '✅' : '🚫';
let message = `${method} from ${host} - ${approvalType}`;
if (details?.kind !== undefined) {
message += ` (kind: ${details.kind})`;
}
this.#addLog('log', 'nip07', icon, message, {
method,
host,
approved,
autoApproved,
...details,
});
this.#consoleLog('log', message);
}
logNip07GetPublicKey(host: string, approved: boolean, autoApproved: boolean) {
this.logNip07Action('getPublicKey', host, approved, autoApproved);
}
logNip07SignEvent(
host: string,
kind: number,
approved: boolean,
autoApproved: boolean
) {
this.logNip07Action('signEvent', host, approved, autoApproved, { kind });
}
logNip07Encrypt(
method: 'nip04.encrypt' | 'nip44.encrypt',
host: string,
approved: boolean,
autoApproved: boolean,
peerPubkey?: string
) {
this.logNip07Action(method, host, approved, autoApproved, { peerPubkey });
}
logNip07Decrypt(
method: 'nip04.decrypt' | 'nip44.decrypt',
host: string,
approved: boolean,
autoApproved: boolean,
peerPubkey?: string
) {
this.logNip07Action(method, host, approved, autoApproved, { peerPubkey });
}
logNip07GetRelays(host: string, approved: boolean, autoApproved: boolean) {
this.logNip07Action('getRelays', host, approved, autoApproved);
}
// ============================================
// Permission Logging
// ============================================
logPermissionStored(
host: string,
method: string,
policy: string,
kind?: number
) {
this.#assureInitialized();
const icon = policy === 'allow' ? '🔓' : '🔒';
let message = `Permission stored: ${method} for ${host} - ${policy}`;
if (kind !== undefined) {
message += ` (kind: ${kind})`;
}
this.#addLog('log', 'permission', icon, message, { host, method, policy, kind });
this.#consoleLog('log', message);
}
logPermissionDeleted(host: string, method: string, kind?: number) {
this.#assureInitialized();
let message = `Permission deleted: ${method} for ${host}`;
if (kind !== undefined) {
message += ` (kind: ${kind})`;
}
this.#addLog('log', 'permission', '🗑️', message, { host, method, kind });
this.#consoleLog('log', message);
}
// ============================================
// Vault Operations Logging
// ============================================
logVaultUnlock() {
this.#assureInitialized();
this.#addLog('log', 'vault', '🔓', 'Vault unlocked', undefined);
this.#consoleLog('log', 'Vault unlocked');
}
logVaultLock() {
this.#assureInitialized();
this.#addLog('log', 'vault', '🔒', 'Vault locked', undefined);
this.#consoleLog('log', 'Vault locked');
}
logVaultCreated() {
this.#assureInitialized();
this.#addLog('log', 'vault', '🆕', 'Vault created', undefined);
this.#consoleLog('log', 'Vault created');
}
logVaultExport(fileName: string) {
this.#assureInitialized();
this.#addLog('log', 'vault', '📤', `Vault exported: ${fileName}`, { fileName });
this.#consoleLog('log', `Vault exported: ${fileName}`);
}
logVaultImport(fileName: string) {
this.#assureInitialized();
this.#addLog('log', 'vault', '📥', `Vault imported: ${fileName}`, { fileName });
this.#consoleLog('log', `Vault imported: ${fileName}`);
}
logVaultReset() {
this.#assureInitialized();
this.#addLog('warn', 'vault', '🗑️', 'Extension reset', undefined);
this.#consoleLog('warn', 'Extension reset');
}
// ============================================
// Profile Operations Logging
// ============================================
logProfileFetchError(pubkey: string, error: string) {
this.#assureInitialized();
const shortPubkey = pubkey.substring(0, 8) + '...';
this.#addLog('error', 'profile', '👤', `Failed to fetch profile for ${shortPubkey}: ${error}`, {
pubkey,
error,
});
this.#consoleLog('error', `Failed to fetch profile for ${shortPubkey}: ${error}`);
}
logProfileParseError(pubkey: string) {
this.#assureInitialized();
const shortPubkey = pubkey.substring(0, 8) + '...';
this.#addLog('error', 'profile', '👤', `Failed to parse profile content for ${shortPubkey}`, {
pubkey,
});
this.#consoleLog('error', `Failed to parse profile content for ${shortPubkey}`);
}
logNip05ValidationError(nip05: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'profile', '🔗', `NIP-05 validation failed for ${nip05}: ${error}`, {
nip05,
error,
});
this.#consoleLog('error', `NIP-05 validation failed for ${nip05}: ${error}`);
}
logNip05ValidationSuccess(nip05: string, pubkey: string) {
this.#assureInitialized();
const shortPubkey = pubkey.substring(0, 8) + '...';
this.#addLog('log', 'profile', '✓', `NIP-05 verified: ${nip05}${shortPubkey}`, {
nip05,
pubkey,
});
this.#consoleLog('log', `NIP-05 verified: ${nip05}${shortPubkey}`);
}
logProfileEdit(identityNick: string, field: string) {
this.#assureInitialized();
this.#addLog('log', 'profile', '✏️', `Profile edited: ${identityNick} - ${field}`, {
identityNick,
field,
});
this.#consoleLog('log', `Profile edited: ${identityNick} - ${field}`);
}
logIdentityCreated(nick: string) {
this.#assureInitialized();
this.#addLog('log', 'profile', '🆕', `Identity created: ${nick}`, { nick });
this.#consoleLog('log', `Identity created: ${nick}`);
}
logIdentityDeleted(nick: string) {
this.#assureInitialized();
this.#addLog('warn', 'profile', '🗑️', `Identity deleted: ${nick}`, { nick });
this.#consoleLog('warn', `Identity deleted: ${nick}`);
}
logIdentitySelected(nick: string) {
this.#assureInitialized();
this.#addLog('log', 'profile', '👆', `Identity selected: ${nick}`, { nick });
this.#consoleLog('log', `Identity selected: ${nick}`);
}
// ============================================
// Bookmark Operations Logging
// ============================================
logBookmarkAdded(url: string, title: string) {
this.#assureInitialized();
this.#addLog('log', 'bookmark', '🔖', `Bookmark added: ${title}`, { url, title });
this.#consoleLog('log', `Bookmark added: ${title}`);
}
logBookmarkRemoved(url: string, title: string) {
this.#assureInitialized();
this.#addLog('log', 'bookmark', '🗑️', `Bookmark removed: ${title}`, { url, title });
this.#consoleLog('log', `Bookmark removed: ${title}`);
}
// ============================================
// System/Error Logging
// ============================================
logRelayFetchError(identityNick: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'system', '📡', `Failed to fetch relays for ${identityNick}: ${error}`, {
identityNick,
error,
});
this.#consoleLog('error', `Failed to fetch relays for ${identityNick}: ${error}`);
}
logStorageError(operation: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'system', '💾', `Storage error (${operation}): ${error}`, {
operation,
error,
});
this.#consoleLog('error', `Storage error (${operation}): ${error}`);
}
logCryptoError(operation: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'system', '🔐', `Crypto error (${operation}): ${error}`, {
operation,
error,
});
this.#consoleLog('error', `Crypto error (${operation}): ${error}`);
}
// ============================================
// Internal methods
// ============================================
async clear(): Promise<void> {
this.#logs = [];
await this.#saveLogsToStorage();
}
#addLog(
level: LogEntry['level'],
category: LogCategory,
icon: string,
message: any,
data?: any
) {
const entry: LogEntry = {
timestamp: new Date(),
level,
category,
icon,
message: typeof message === 'string' ? message : JSON.stringify(message),
data,
};
this.#logs.unshift(entry);
// Limit stored logs
if (this.#logs.length > this.#maxLogs) {
this.#logs.pop();
}
// Save to storage asynchronously (don't block)
this.#saveLogsToStorage();
}
#consoleLog(level: 'log' | 'warn' | 'error' | 'debug', message: string) {
const nowString = new Date().toLocaleString();
console.log(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
const formattedMsg = `[${this.#namespace} - ${nowString}] ${message}`;
switch (level) {
case 'warn':
console.warn(formattedMsg);
break;
case 'error':
console.error(formattedMsg);
break;
case 'debug':
console.debug(formattedMsg);
break;
default:
console.log(formattedMsg);
}
}
#assureInitialized() {
@@ -27,3 +429,87 @@ export class LoggerService {
}
}
}
// ============================================
// Standalone functions for background script
// (Background script runs in different context without Angular DI)
// ============================================
export async function backgroundLog(
category: LogCategory,
icon: string,
level: LogEntry['level'],
message: string,
data?: any
): Promise<void> {
try {
if (typeof chrome === 'undefined' || !chrome.storage?.session) {
console.log(`[Background] ${message}`);
return;
}
const result = await chrome.storage.session.get(LOGS_STORAGE_KEY);
const existingLogs: StoredLogEntry[] = result[LOGS_STORAGE_KEY] || [];
const newEntry: StoredLogEntry = {
timestamp: new Date().toISOString(),
level,
category,
icon,
message,
data,
};
const updatedLogs = [newEntry, ...existingLogs].slice(0, 500);
await chrome.storage.session.set({ [LOGS_STORAGE_KEY]: updatedLogs });
} catch (error) {
console.error('Failed to add background log:', error);
}
}
export async function backgroundLogNip07Action(
method: string,
host: string,
approved: boolean,
autoApproved: boolean,
details?: { kind?: number; peerPubkey?: string }
): Promise<void> {
const approvalType = autoApproved
? 'auto-approved'
: approved
? 'approved'
: 'denied';
const icon = approved ? '✅' : '🚫';
let message = `${method} from ${host} - ${approvalType}`;
if (details?.kind !== undefined) {
message += ` (kind: ${details.kind})`;
}
await backgroundLog('nip07', icon, 'log', message, {
method,
host,
approved,
autoApproved,
...details,
});
}
export async function backgroundLogPermissionStored(
host: string,
method: string,
policy: string,
kind?: number
): Promise<void> {
const icon = policy === 'allow' ? '🔓' : '🔒';
let message = `Permission stored: ${method} for ${host} - ${policy}`;
if (kind !== undefined) {
message += ` (kind: ${kind})`;
}
await backgroundLog('permission', icon, 'log', message, {
host,
method,
policy,
kind,
});
}

View File

@@ -0,0 +1,541 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NostrHelper } from '@common';
import { finalizeEvent, nip04, nip44, getPublicKey } from 'nostr-tools';
import {
NwcRequest,
NwcResponse,
NwcGetBalanceResult,
NwcGetInfoResult,
NwcPayInvoiceParams,
NwcPayInvoiceResult,
NwcMakeInvoiceParams,
NwcMakeInvoiceResult,
NwcListTransactionsParams,
NwcListTransactionsResult,
NWC_METHODS,
} from './types';
export interface NwcConnectionData {
walletPubkey: string;
relayUrl: string;
secret: string;
}
export type NwcLogLevel = 'info' | 'warn' | 'error';
export type NwcLogCallback = (level: NwcLogLevel, message: string) => void;
interface PendingRequest {
resolve: (value: NwcResponse) => void;
reject: (reason: Error) => void;
timeout: ReturnType<typeof setTimeout>;
request: NwcRequest;
isRetry: boolean;
}
type EncryptionMode = 'nip44' | 'nip04';
/**
* NWC Client for communicating with NIP-47 wallet services
*/
export class NwcClient {
private ws: WebSocket | null = null;
private connected = false;
private pendingRequests = new Map<string, PendingRequest>();
private subscriptionId: string | null = null;
private conversationKey: Uint8Array;
private clientPubkey: string;
private encryptionMode: EncryptionMode = 'nip44';
private logCallback: NwcLogCallback | null = null;
constructor(
private connectionData: NwcConnectionData,
logCallback?: NwcLogCallback
) {
this.logCallback = logCallback ?? null;
// Derive the conversation key for NIP-44 encryption
this.conversationKey = nip44.v2.utils.getConversationKey(
NostrHelper.hex2bytes(connectionData.secret),
connectionData.walletPubkey
);
// Derive our public key from the secret
this.clientPubkey = getPublicKey(
NostrHelper.hex2bytes(connectionData.secret)
);
}
private log(level: NwcLogLevel, message: string): void {
if (this.logCallback) {
this.logCallback(level, message);
}
}
/**
* Connect to the NWC relay
*/
async connect(): Promise<void> {
if (this.connected) {
return;
}
return new Promise((resolve, reject) => {
try {
this.log('info', `Connecting to ${this.connectionData.relayUrl}...`);
this.ws = new WebSocket(this.connectionData.relayUrl);
const timeout = setTimeout(() => {
this.log('error', 'Connection timeout');
reject(new Error('Connection timeout'));
this.disconnect();
}, 10000);
this.ws.onopen = () => {
clearTimeout(timeout);
this.connected = true;
this.log('info', 'Connected to relay');
this.subscribe();
resolve();
};
this.ws.onerror = () => {
clearTimeout(timeout);
this.log('error', 'WebSocket error');
reject(new Error('WebSocket error'));
};
this.ws.onclose = () => {
this.connected = false;
this.subscriptionId = null;
// Reject all pending requests
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
} catch (error) {
reject(error);
}
});
}
/**
* Disconnect from the relay
*/
disconnect(): void {
if (this.ws) {
if (this.subscriptionId) {
this.ws.send(JSON.stringify(['CLOSE', this.subscriptionId]));
}
this.ws.close();
this.ws = null;
}
this.connected = false;
this.subscriptionId = null;
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.connected && this.ws?.readyState === WebSocket.OPEN;
}
/**
* Get wallet info
*/
async getInfo(): Promise<NwcGetInfoResult> {
const response = await this.sendRequest({
method: NWC_METHODS.GET_INFO,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcGetInfoResult;
}
/**
* Get wallet balance
*/
async getBalance(): Promise<NwcGetBalanceResult> {
const response = await this.sendRequest({
method: NWC_METHODS.GET_BALANCE,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcGetBalanceResult;
}
/**
* Pay a Lightning invoice
*/
async payInvoice(params: NwcPayInvoiceParams): Promise<NwcPayInvoiceResult> {
const response = await this.sendRequest({
method: NWC_METHODS.PAY_INVOICE,
params: params as unknown as Record<string, unknown>,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcPayInvoiceResult;
}
/**
* Create a Lightning invoice
*/
async makeInvoice(
params: NwcMakeInvoiceParams
): Promise<NwcMakeInvoiceResult> {
const response = await this.sendRequest({
method: NWC_METHODS.MAKE_INVOICE,
params: params as unknown as Record<string, unknown>,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcMakeInvoiceResult;
}
/**
* List transaction history
*/
async listTransactions(
params?: NwcListTransactionsParams
): Promise<NwcListTransactionsResult> {
const response = await this.sendRequest({
method: NWC_METHODS.LIST_TRANSACTIONS,
params: params as unknown as Record<string, unknown>,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcListTransactionsResult;
}
/**
* Encrypt content using current encryption mode
*/
private async encryptContent(plaintext: string): Promise<string> {
if (this.encryptionMode === 'nip04') {
return nip04.encrypt(
this.connectionData.secret,
this.connectionData.walletPubkey,
plaintext
);
} else {
return nip44.v2.encrypt(plaintext, this.conversationKey);
}
}
/**
* Send a request to the wallet
*/
private async sendRequest(
request: NwcRequest,
timeoutMs = 30000,
isRetry = false
): Promise<NwcResponse> {
if (!this.isConnected()) {
await this.connect();
}
// Encrypt the request content
const plaintext = JSON.stringify(request);
this.log(
'info',
`Sending ${request.method} request (using ${this.encryptionMode.toUpperCase()})`
);
const ciphertext = await this.encryptContent(plaintext);
// Create the NIP-47 request event (kind 23194)
const eventTemplate = {
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', this.connectionData.walletPubkey]],
content: ciphertext,
};
// Sign with the client secret
const signedEvent = finalizeEvent(
eventTemplate,
NostrHelper.hex2bytes(this.connectionData.secret)
);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(signedEvent.id);
this.log('error', `Request timeout for ${request.method}`);
reject(new Error('Request timeout'));
}, timeoutMs);
this.pendingRequests.set(signedEvent.id, {
resolve,
reject,
timeout,
request,
isRetry,
});
// Send the event
this.ws!.send(JSON.stringify(['EVENT', signedEvent]));
});
}
/**
* Retry a request with NIP-04 encryption
*/
private async retryWithNip04(request: NwcRequest): Promise<NwcResponse> {
this.log('warn', 'Retrying with NIP-04 encryption...');
this.encryptionMode = 'nip04';
return this.sendRequest(request, 30000, true);
}
/**
* Subscribe to response events from the wallet
*/
private subscribe(): void {
if (!this.ws || !this.connected) {
return;
}
// Generate a subscription ID
this.subscriptionId = Math.random().toString(36).substring(2, 15);
// Subscribe to kind 23195 (response) events addressed to us
const filter = {
kinds: [23195],
'#p': [this.clientPubkey],
since: Math.floor(Date.now() / 1000) - 10, // Last 10 seconds
};
this.ws.send(JSON.stringify(['REQ', this.subscriptionId, filter]));
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(data: string): void {
try {
const message = JSON.parse(data);
if (!Array.isArray(message)) {
return;
}
const [type, ...rest] = message;
switch (type) {
case 'EVENT':
this.handleEvent(rest[1]);
break;
case 'OK':
// Event was received by relay
break;
case 'EOSE':
// End of stored events
break;
case 'NOTICE':
this.log('warn', `Relay notice: ${rest[0]}`);
break;
}
} catch (error) {
this.log('error', `Error parsing message: ${(error as Error).message}`);
}
}
/**
* Check if an error indicates a decryption/encryption problem
*/
private isEncryptionError(errorMsg: string): boolean {
const lowerMsg = errorMsg.toLowerCase();
return (
lowerMsg.includes('decrypt') ||
lowerMsg.includes('initialization vector') ||
lowerMsg.includes('iv') ||
lowerMsg.includes('encrypt') ||
lowerMsg.includes('cipher') ||
lowerMsg.includes('parse')
);
}
/**
* Handle an incoming event (response from wallet)
*/
private async handleEvent(event: any): Promise<void> {
if (!event || event.kind !== 23195) {
return;
}
// Check if this event is from the wallet
if (event.pubkey !== this.connectionData.walletPubkey) {
return;
}
// Find the request ID from the 'e' tag
const eTag = event.tags?.find((t: string[]) => t[0] === 'e');
if (!eTag) {
return;
}
const requestId = eTag[1];
const pending = this.pendingRequests.get(requestId);
if (!pending) {
// Response for unknown request (might be old or from another session)
return;
}
// Clear the timeout and remove from pending
clearTimeout(pending.timeout);
this.pendingRequests.delete(requestId);
try {
// Try to decrypt the response
let decrypted: string;
// First, check if content looks like plain JSON (unencrypted error)
if (
event.content.startsWith('{') ||
event.content.startsWith('"')
) {
// Might be unencrypted error response
try {
const parsed = JSON.parse(event.content);
// If it has an error field, this is an unencrypted error response
if (parsed.error) {
this.log(
'error',
`Wallet error: ${parsed.error.message || JSON.stringify(parsed.error)}`
);
// Check if it's an encryption error and we haven't retried yet
const errorMsg =
parsed.error.message || JSON.stringify(parsed.error);
if (
!pending.isRetry &&
this.encryptionMode === 'nip44' &&
this.isEncryptionError(errorMsg)
) {
this.log(
'warn',
'Wallet returned encryption error, switching to NIP-04'
);
try {
const retryResponse = await this.retryWithNip04(pending.request);
pending.resolve(retryResponse);
return;
} catch (retryError) {
pending.reject(retryError as Error);
return;
}
}
pending.resolve(parsed as NwcResponse);
return;
}
} catch {
// Not valid JSON, continue with decryption
}
}
// Detect encryption format and decrypt
// NIP-04 format contains "?iv=" in the ciphertext
if (event.content.includes('?iv=')) {
this.log('info', 'Decrypting response (NIP-04 format)');
decrypted = await nip04.decrypt(
this.connectionData.secret,
this.connectionData.walletPubkey,
event.content
);
} else {
this.log('info', 'Decrypting response (NIP-44 format)');
try {
decrypted = nip44.v2.decrypt(event.content, this.conversationKey);
} catch (nip44Error) {
// NIP-44 decryption failed, maybe it's NIP-04 without standard format?
// Try NIP-04 as fallback
this.log(
'warn',
`NIP-44 decryption failed: ${(nip44Error as Error).message}, trying NIP-04...`
);
try {
decrypted = await nip04.decrypt(
this.connectionData.secret,
this.connectionData.walletPubkey,
event.content
);
} catch {
// Both failed, throw original error
throw nip44Error;
}
}
}
const response = JSON.parse(decrypted) as NwcResponse;
// Check if the decrypted response contains an encryption error
if (response.error) {
const errorMsg = response.error.message || '';
if (
!pending.isRetry &&
this.encryptionMode === 'nip44' &&
this.isEncryptionError(errorMsg)
) {
this.log(
'warn',
`Wallet returned encryption error: ${errorMsg}, retrying with NIP-04`
);
try {
const retryResponse = await this.retryWithNip04(pending.request);
pending.resolve(retryResponse);
return;
} catch (retryError) {
pending.reject(retryError as Error);
return;
}
}
this.log('error', `Wallet error: ${errorMsg}`);
} else {
this.log('info', 'Request successful');
}
pending.resolve(response);
} catch (error) {
const errorMsg = (error as Error).message;
this.log('error', `Failed to decrypt response: ${errorMsg}`);
// If this is an encryption error and we haven't retried, try NIP-04
if (
!pending.isRetry &&
this.encryptionMode === 'nip44' &&
this.isEncryptionError(errorMsg)
) {
this.log('warn', 'Decryption failed, retrying with NIP-04 encryption');
try {
const retryResponse = await this.retryWithNip04(pending.request);
pending.resolve(retryResponse);
return;
} catch (retryError) {
pending.reject(retryError as Error);
return;
}
}
pending.reject(new Error(`Failed to decrypt response: ${errorMsg}`));
}
}
}

View File

@@ -0,0 +1,416 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { StorageService, NwcConnection_DECRYPTED } from '@common';
import { NwcClient, NwcConnectionData, NwcLogLevel, NwcLogCallback } from './nwc-client';
import {
NwcGetInfoResult,
NwcPayInvoiceResult,
NwcMakeInvoiceResult,
NwcListTransactionsParams,
NwcLookupInvoiceResult,
} from './types';
import { parseNwcUrl } from '../storage/related/nwc';
export interface NwcLogEntry {
timestamp: Date;
level: NwcLogLevel;
message: string;
}
interface CachedClient {
client: NwcClient;
connectionId: string;
}
/**
* Angular service for managing NWC wallet connections
*/
@Injectable({
providedIn: 'root',
})
export class NwcService {
private clients = new Map<string, CachedClient>();
private _logs$ = new BehaviorSubject<NwcLogEntry[]>([]);
private maxLogs = 100;
/** Observable stream of NWC log entries */
readonly logs$ = this._logs$.asObservable();
constructor(private storageService: StorageService) {}
/** Get current logs */
get logs(): NwcLogEntry[] {
return this._logs$.value;
}
/** Clear all logs */
clearLogs(): void {
this._logs$.next([]);
}
/** Add a log entry */
private addLog(level: NwcLogLevel, message: string): void {
const entry: NwcLogEntry = {
timestamp: new Date(),
level,
message,
};
const logs = [entry, ...this._logs$.value].slice(0, this.maxLogs);
this._logs$.next(logs);
}
/** Create a log callback for the NWC client */
private createLogCallback(): NwcLogCallback {
return (level: NwcLogLevel, message: string) => {
this.addLog(level, message);
};
}
/**
* Parse and validate an NWC URL
*/
parseNwcUrl(url: string): {
walletPubkey: string;
relayUrl: string;
secret: string;
lud16?: string;
} | null {
return parseNwcUrl(url);
}
/**
* Get all NWC connections from storage
*/
getConnections(): NwcConnection_DECRYPTED[] {
const sessionData =
this.storageService.getBrowserSessionHandler().browserSessionData;
return sessionData?.nwcConnections ?? [];
}
/**
* Get a single NWC connection by ID
*/
getConnection(connectionId: string): NwcConnection_DECRYPTED | undefined {
return this.getConnections().find((c) => c.id === connectionId);
}
/**
* Add a new NWC connection
*/
async addConnection(name: string, connectionUrl: string): Promise<void> {
await this.storageService.addNwcConnection({ name, connectionUrl });
}
/**
* Delete an NWC connection
*/
async deleteConnection(connectionId: string): Promise<void> {
// Disconnect and remove the client if it exists
this.disconnectClient(connectionId);
await this.storageService.deleteNwcConnection(connectionId);
}
/**
* Get a connected client for a connection, creating it if necessary
*/
private async getClient(connectionId: string): Promise<NwcClient> {
// Check if we have a cached client
const cached = this.clients.get(connectionId);
if (cached && cached.client.isConnected()) {
return cached.client;
}
// Get the connection data
const connection = this.getConnection(connectionId);
if (!connection) {
throw new Error('Connection not found');
}
// Create a new client
const connectionData: NwcConnectionData = {
walletPubkey: connection.walletPubkey,
relayUrl: connection.relayUrl,
secret: connection.secret,
};
const client = new NwcClient(connectionData, this.createLogCallback());
await client.connect();
// Cache the client
this.clients.set(connectionId, {
client,
connectionId,
});
return client;
}
/**
* Disconnect a client
*/
private disconnectClient(connectionId: string): void {
const cached = this.clients.get(connectionId);
if (cached) {
cached.client.disconnect();
this.clients.delete(connectionId);
}
}
/**
* Disconnect all clients
*/
disconnectAll(): void {
for (const cached of this.clients.values()) {
cached.client.disconnect();
}
this.clients.clear();
}
/**
* Get wallet info for a connection
*/
async getInfo(connectionId: string): Promise<NwcGetInfoResult> {
const client = await this.getClient(connectionId);
return client.getInfo();
}
/**
* Get balance for a connection (in millisatoshis)
*/
async getBalance(connectionId: string): Promise<number> {
const client = await this.getClient(connectionId);
const result = await client.getBalance();
// Update the cached balance in storage
await this.storageService.updateNwcConnectionBalance(
connectionId,
result.balance
);
return result.balance;
}
/**
* Get balances for all connections
* Returns a map of connectionId -> balance in millisatoshis
*/
async getAllBalances(): Promise<Map<string, number>> {
const balances = new Map<string, number>();
const connections = this.getConnections();
const results = await Promise.allSettled(
connections.map(async (conn) => {
try {
const balance = await this.getBalance(conn.id);
return { id: conn.id, balance };
} catch (error) {
// Return cached balance if available
if (conn.cachedBalance !== undefined) {
return { id: conn.id, balance: conn.cachedBalance };
}
throw error;
}
})
);
for (const result of results) {
if (result.status === 'fulfilled') {
balances.set(result.value.id, result.value.balance);
}
}
return balances;
}
/**
* Get total balance across all connections (in millisatoshis)
*/
async getTotalBalance(): Promise<number> {
const balances = await this.getAllBalances();
let total = 0;
for (const balance of balances.values()) {
total += balance;
}
return total;
}
/**
* Get cached total balance (without making network requests)
*/
getCachedTotalBalance(): number {
const connections = this.getConnections();
let total = 0;
for (const conn of connections) {
if (conn.cachedBalance !== undefined) {
total += conn.cachedBalance;
}
}
return total;
}
/**
* Pay a Lightning invoice
*/
async payInvoice(
connectionId: string,
invoice: string,
amountMsat?: number
): Promise<NwcPayInvoiceResult> {
const client = await this.getClient(connectionId);
const result = await client.payInvoice({
invoice,
amount: amountMsat,
});
// Refresh balance after payment
try {
await this.getBalance(connectionId);
} catch {
// Ignore balance refresh errors
}
return result;
}
/**
* Create a Lightning invoice
*/
async makeInvoice(
connectionId: string,
amountMsat: number,
description?: string
): Promise<NwcMakeInvoiceResult> {
const client = await this.getClient(connectionId);
return client.makeInvoice({
amount: amountMsat,
description,
});
}
/**
* List transaction history
*/
async listTransactions(
connectionId: string,
params?: NwcListTransactionsParams
): Promise<NwcLookupInvoiceResult[]> {
const client = await this.getClient(connectionId);
const result = await client.listTransactions(params);
return result.transactions;
}
/**
* Resolve a Lightning Address (user@domain.com) to a bolt11 invoice
* Uses LNURL-pay protocol
*/
async resolveLightningAddress(
address: string,
amountMsat: number
): Promise<string> {
// Parse lightning address
const match = address.match(/^([^@]+)@([^@]+)$/);
if (!match) {
throw new Error('Invalid lightning address format');
}
const [, name, domain] = match;
// Fetch LNURL-pay endpoint
const lnurlpUrl = `https://${domain}/.well-known/lnurlp/${name}`;
this.addLog('info', `Fetching LNURL-pay from ${domain}...`);
const response = await fetch(lnurlpUrl);
if (!response.ok) {
throw new Error(`Failed to fetch LNURL-pay: ${response.status}`);
}
const lnurlpData = await response.json();
// Validate response
if (lnurlpData.status === 'ERROR') {
throw new Error(lnurlpData.reason || 'LNURL-pay error');
}
if (!lnurlpData.callback) {
throw new Error('Invalid LNURL-pay response: missing callback');
}
// Check amount bounds
const minSendable = lnurlpData.minSendable || 1000;
const maxSendable = lnurlpData.maxSendable || 100000000000;
if (amountMsat < minSendable) {
throw new Error(
`Amount too small. Minimum: ${Math.ceil(minSendable / 1000)} sats`
);
}
if (amountMsat > maxSendable) {
throw new Error(
`Amount too large. Maximum: ${Math.floor(maxSendable / 1000)} sats`
);
}
// Request invoice from callback
const callbackUrl = new URL(lnurlpData.callback);
callbackUrl.searchParams.set('amount', amountMsat.toString());
this.addLog('info', 'Requesting invoice...');
const invoiceResponse = await fetch(callbackUrl.toString());
if (!invoiceResponse.ok) {
throw new Error(`Failed to get invoice: ${invoiceResponse.status}`);
}
const invoiceData = await invoiceResponse.json();
if (invoiceData.status === 'ERROR') {
throw new Error(invoiceData.reason || 'Failed to get invoice');
}
if (!invoiceData.pr) {
throw new Error('Invalid invoice response: missing payment request');
}
this.addLog('info', 'Invoice received');
return invoiceData.pr;
}
/**
* Check if a string is a lightning address (user@domain)
*/
isLightningAddress(input: string): boolean {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input);
}
/**
* Check if a string is a bolt11 invoice
*/
isBolt11Invoice(input: string): boolean {
return /^ln(bc|tb|tbs)[0-9a-z]+$/i.test(input.toLowerCase());
}
/**
* Test a connection by getting wallet info
*/
async testConnection(connectionUrl: string): Promise<NwcGetInfoResult> {
this.addLog('info', 'Testing NWC connection...');
const parsed = this.parseNwcUrl(connectionUrl);
if (!parsed) {
this.addLog('error', 'Invalid NWC URL');
throw new Error('Invalid NWC URL');
}
const client = new NwcClient(parsed, this.createLogCallback());
try {
await client.connect();
const info = await client.getInfo();
this.addLog('info', `Connection test successful: ${info.alias || 'wallet'}`);
return info;
} catch (error) {
this.addLog('error', `Connection test failed: ${(error as Error).message}`);
throw error;
} finally {
client.disconnect();
}
}
}

View File

@@ -0,0 +1,130 @@
/**
* NIP-47 NWC Protocol Types
*/
export interface NwcRequest {
method: string;
params?: Record<string, unknown>;
}
export interface NwcResponse {
result_type: string;
error?: {
code: string;
message: string;
};
result?: Record<string, unknown>;
}
export interface NwcGetInfoResult {
alias?: string;
color?: string;
pubkey?: string;
network?: string;
block_height?: number;
block_hash?: string;
methods?: string[];
}
export interface NwcGetBalanceResult {
balance: number; // Balance in millisatoshis
}
export interface NwcPayInvoiceParams {
invoice: string;
amount?: number; // Optional amount in millisatoshis (for zero-amount invoices)
}
export interface NwcPayInvoiceResult {
preimage: string;
}
export interface NwcMakeInvoiceParams {
amount: number; // Amount in millisatoshis
description?: string;
description_hash?: string;
expiry?: number; // Expiry in seconds
}
export interface NwcMakeInvoiceResult {
type: 'incoming';
invoice: string;
description?: string;
description_hash?: string;
preimage?: string;
payment_hash: string;
amount: number;
fees_paid?: number;
created_at: number;
expires_at: number;
settled_at?: number;
metadata?: Record<string, unknown>;
}
export interface NwcLookupInvoiceParams {
payment_hash?: string;
invoice?: string;
}
export interface NwcLookupInvoiceResult {
type: 'incoming' | 'outgoing';
invoice?: string;
description?: string;
description_hash?: string;
preimage?: string;
payment_hash: string;
amount: number;
fees_paid?: number;
created_at: number;
expires_at?: number;
settled_at?: number;
metadata?: Record<string, unknown>;
}
export interface NwcListTransactionsParams {
from?: number;
until?: number;
limit?: number;
offset?: number;
unpaid?: boolean;
type?: 'incoming' | 'outgoing';
}
export interface NwcListTransactionsResult {
transactions: NwcLookupInvoiceResult[];
}
/**
* NWC Error Codes
*/
export const NWC_ERROR_CODES = {
RATE_LIMITED: 'RATE_LIMITED',
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
RESTRICTED: 'RESTRICTED',
UNAUTHORIZED: 'UNAUTHORIZED',
INTERNAL: 'INTERNAL',
OTHER: 'OTHER',
PAYMENT_FAILED: 'PAYMENT_FAILED',
NOT_FOUND: 'NOT_FOUND',
} as const;
export type NwcErrorCode = (typeof NWC_ERROR_CODES)[keyof typeof NWC_ERROR_CODES];
/**
* NWC Method names (from NIP-47)
*/
export const NWC_METHODS = {
GET_INFO: 'get_info',
GET_BALANCE: 'get_balance',
PAY_INVOICE: 'pay_invoice',
MAKE_INVOICE: 'make_invoice',
LOOKUP_INVOICE: 'lookup_invoice',
LIST_TRANSACTIONS: 'list_transactions',
PAY_KEYSEND: 'pay_keysend',
MULTI_PAY_INVOICE: 'multi_pay_invoice',
MULTI_PAY_KEYSEND: 'multi_pay_keysend',
} as const;
export type NwcMethod = (typeof NWC_METHODS)[keyof typeof NWC_METHODS];

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { SimplePool } from 'nostr-tools/pool';
import { FALLBACK_PROFILE_RELAYS } from '../../constants/fallback-relays';
import { ProfileMetadata, ProfileMetadataCache } from '../storage/types';
import { LoggerService } from '../logger/logger.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const chrome: any;
@@ -14,6 +15,7 @@ const STORAGE_KEY = 'profileMetadataCache';
providedIn: 'root',
})
export class ProfileMetadataService {
readonly #logger = inject(LoggerService);
#cache: ProfileMetadataCache = {};
#pool: SimplePool | null = null;
#fetchPromises = new Map<string, Promise<ProfileMetadata | null>>();
@@ -52,7 +54,8 @@ export class ProfileMetadataService {
}
}
} catch (error) {
console.error('Failed to load profile cache from storage:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logStorageError('load profile cache', errorMsg);
}
}
@@ -65,7 +68,8 @@ export class ProfileMetadataService {
await chrome.storage.session.set({ [STORAGE_KEY]: this.#cache });
}
} catch (error) {
console.error('Failed to save profile cache to storage:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logStorageError('save profile cache', errorMsg);
}
}
@@ -209,7 +213,7 @@ export class ProfileMetadataService {
this.#cache[pubkey] = profile;
results.set(pubkey, profile);
} catch {
console.error(`Failed to parse profile for ${pubkey}`);
this.#logger.logProfileParseError(pubkey);
results.set(pubkey, null);
}
}
@@ -225,7 +229,8 @@ export class ProfileMetadataService {
await this.#saveCacheToStorage();
} catch (error) {
console.error('Failed to fetch profiles:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logProfileFetchError('multiple', errorMsg);
// Set null for all unfetched pubkeys on error
for (const pubkey of uncachedPubkeys) {
if (!results.has(pubkey)) {
@@ -283,11 +288,12 @@ export class ProfileMetadataService {
return profile;
} catch {
console.error(`Failed to parse profile content for ${pubkey}`);
this.#logger.logProfileParseError(pubkey);
return null;
}
} catch (error) {
console.error(`Failed to fetch profile for ${pubkey}:`, error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logProfileFetchError(pubkey, errorMsg);
return null;
}
}

View File

@@ -20,6 +20,10 @@ export abstract class BrowserSessionHandler {
this.#browserSessionData = JSON.parse(JSON.stringify(data));
}
clearInMemoryData() {
this.#browserSessionData = undefined;
}
/**
* Persist the full data to the session data storage.
*

View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
} from './types';
@@ -104,6 +106,38 @@ export abstract class BrowserSyncHandler {
this.#browserSyncData.relays = Array.from(data.relays);
}
/**
* Persist the NWC connections to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void>;
setPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.nwcConnections = Array.from(data.nwcConnections);
}
/**
* Persist the Cashu mints to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void>;
setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.cashuMints = Array.from(data.cashuMints);
}
/**
* Clear all data from the sync data storage.
*/

View File

@@ -0,0 +1,361 @@
import {
CryptoHelper,
CashuMint_DECRYPTED,
CashuMint_ENCRYPTED,
CashuProof,
StorageService,
} from '@common';
import { LockedVaultContext } from './identity';
/**
* Validate a Cashu mint URL
*/
export function isValidMintUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
export const addCashuMint = async function (
this: StorageService,
data: {
name: string;
mintUrl: string;
unit?: string;
}
): Promise<CashuMint_DECRYPTED> {
this.assureIsInitialized();
// Validate the mint URL
if (!isValidMintUrl(data.mintUrl)) {
throw new Error('Invalid mint URL format');
}
// Normalize URL (remove trailing slash)
const normalizedUrl = data.mintUrl.replace(/\/$/, '');
// Check if a mint with the same URL already exists
const existingMint = (
this.getBrowserSessionHandler().browserSessionData?.cashuMints ?? []
).find((x) => x.mintUrl === normalizedUrl);
if (existingMint) {
throw new Error(
`A connection to this mint already exists: ${existingMint.name}`
);
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedMint: CashuMint_DECRYPTED = {
id: CryptoHelper.v4(),
name: data.name,
mintUrl: normalizedUrl,
unit: data.unit ?? 'sat',
createdAt: new Date().toISOString(),
proofs: [], // Start with no proofs
cachedBalance: 0,
cachedBalanceAt: new Date().toISOString(),
};
// Initialize array if needed
if (!browserSessionData.cashuMints) {
browserSessionData.cashuMints = [];
}
// Add the new mint to the session data
browserSessionData.cashuMints.push(decryptedMint);
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new mint and add it to the sync data
const encryptedMint = await encryptCashuMint.call(this, decryptedMint);
const encryptedMints = [
...(this.getBrowserSyncHandler().browserSyncData?.cashuMints ?? []),
encryptedMint,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
cashuMints: encryptedMints,
});
return decryptedMint;
};
export const deleteCashuMint = async function (
this: StorageService,
mintId: string
): Promise<void> {
this.assureIsInitialized();
if (!mintId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
// Remove from session data
browserSessionData.cashuMints = (browserSessionData.cashuMints ?? []).filter(
(x) => x.id !== mintId
);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data
const encryptedMintId = await this.encrypt(mintId);
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
cashuMints: (browserSyncData.cashuMints ?? []).filter(
(x) => x.id !== encryptedMintId
),
});
};
/**
* Update the proofs for a Cashu mint
* This is called after send/receive operations
*/
export const updateCashuMintProofs = async function (
this: StorageService,
mintId: string,
proofs: CashuProof[]
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
const sessionMint = (browserSessionData.cashuMints ?? []).find(
(x) => x.id === mintId
);
const encryptedMintId = await this.encrypt(mintId);
const syncMint = (browserSyncData.cashuMints ?? []).find(
(x) => x.id === encryptedMintId
);
if (!sessionMint || !syncMint) {
throw new Error('Cashu mint not found for proofs update.');
}
const now = new Date().toISOString();
// Calculate balance from proofs (sum of all proof amounts in satoshis)
const balance = proofs.reduce((sum, p) => sum + p.amount, 0);
// Update session data
sessionMint.proofs = proofs;
sessionMint.cachedBalance = balance;
sessionMint.cachedBalanceAt = now;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Update sync data
syncMint.proofs = await this.encrypt(JSON.stringify(proofs));
syncMint.cachedBalance = await this.encrypt(balance.toString());
syncMint.cachedBalanceAt = await this.encrypt(now);
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
cashuMints: browserSyncData.cashuMints ?? [],
});
};
export const encryptCashuMint = async function (
this: StorageService,
mint: CashuMint_DECRYPTED
): Promise<CashuMint_ENCRYPTED> {
const encrypted: CashuMint_ENCRYPTED = {
id: await this.encrypt(mint.id),
name: await this.encrypt(mint.name),
mintUrl: await this.encrypt(mint.mintUrl),
unit: await this.encrypt(mint.unit),
createdAt: await this.encrypt(mint.createdAt),
proofs: await this.encrypt(JSON.stringify(mint.proofs)),
};
if (mint.cachedBalance !== undefined) {
encrypted.cachedBalance = await this.encrypt(mint.cachedBalance.toString());
}
if (mint.cachedBalanceAt) {
encrypted.cachedBalanceAt = await this.encrypt(mint.cachedBalanceAt);
}
return encrypted;
};
export const decryptCashuMint = async function (
this: StorageService,
mint: CashuMint_ENCRYPTED,
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<CashuMint_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
// Normal decryption with unlocked vault
const proofsJson = await this.decrypt(mint.proofs, 'string');
const decrypted: CashuMint_DECRYPTED = {
id: await this.decrypt(mint.id, 'string'),
name: await this.decrypt(mint.name, 'string'),
mintUrl: await this.decrypt(mint.mintUrl, 'string'),
unit: await this.decrypt(mint.unit, 'string'),
createdAt: await this.decrypt(mint.createdAt, 'string'),
proofs: JSON.parse(proofsJson) as CashuProof[],
};
if (mint.cachedBalance) {
decrypted.cachedBalance = await this.decrypt(mint.cachedBalance, 'number');
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decrypt(
mint.cachedBalanceAt,
'string'
);
}
return decrypted;
}
// v2: Use pre-derived key
if (withLockedVault.keyBase64) {
const proofsJson = await this.decryptWithLockedVaultV2(
mint.proofs,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
const decrypted: CashuMint_DECRYPTED = {
id: await this.decryptWithLockedVaultV2(
mint.id,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
name: await this.decryptWithLockedVaultV2(
mint.name,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
mintUrl: await this.decryptWithLockedVaultV2(
mint.mintUrl,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
unit: await this.decryptWithLockedVaultV2(
mint.unit,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
createdAt: await this.decryptWithLockedVaultV2(
mint.createdAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
proofs: JSON.parse(proofsJson) as CashuProof[],
};
if (mint.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
mint.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
mint.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
return decrypted;
}
// v1: Use password (PBKDF2)
const proofsJson = await this.decryptWithLockedVault(
mint.proofs,
'string',
withLockedVault.iv,
withLockedVault.password!
);
const decrypted: CashuMint_DECRYPTED = {
id: await this.decryptWithLockedVault(
mint.id,
'string',
withLockedVault.iv,
withLockedVault.password!
),
name: await this.decryptWithLockedVault(
mint.name,
'string',
withLockedVault.iv,
withLockedVault.password!
),
mintUrl: await this.decryptWithLockedVault(
mint.mintUrl,
'string',
withLockedVault.iv,
withLockedVault.password!
),
unit: await this.decryptWithLockedVault(
mint.unit,
'string',
withLockedVault.iv,
withLockedVault.password!
),
createdAt: await this.decryptWithLockedVault(
mint.createdAt,
'string',
withLockedVault.iv,
withLockedVault.password!
),
proofs: JSON.parse(proofsJson) as CashuProof[],
};
if (mint.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVault(
mint.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.password!
);
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
mint.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.password!
);
}
return decrypted;
};
export const decryptCashuMints = async function (
this: StorageService,
mints: CashuMint_ENCRYPTED[],
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<CashuMint_DECRYPTED[]> {
const decryptedMints: CashuMint_DECRYPTED[] = [];
for (const mint of mints) {
const decryptedMint = await decryptCashuMint.call(
this,
mint,
withLockedVault
);
decryptedMints.push(decryptedMint);
}
return decryptedMints;
};

View File

@@ -0,0 +1,419 @@
import {
CryptoHelper,
NwcConnection_DECRYPTED,
NwcConnection_ENCRYPTED,
StorageService,
} from '@common';
import { LockedVaultContext } from './identity';
/**
* Parse a nostr+walletconnect:// URL into its components
*/
export function parseNwcUrl(url: string): {
walletPubkey: string;
relayUrl: string;
secret: string;
lud16?: string;
} | null {
try {
// Format: nostr+walletconnect://<pubkey>?relay=<url>&secret=<hex>&lud16=<optional>
const match = url.match(/^nostr\+walletconnect:\/\/([a-f0-9]{64})\?(.+)$/i);
if (!match) {
return null;
}
const walletPubkey = match[1].toLowerCase();
const params = new URLSearchParams(match[2]);
const relayUrl = params.get('relay');
const secret = params.get('secret');
const lud16 = params.get('lud16') || undefined;
if (!relayUrl || !secret) {
return null;
}
// Validate secret is 64-char hex
if (!/^[a-f0-9]{64}$/i.test(secret)) {
return null;
}
return {
walletPubkey,
relayUrl: decodeURIComponent(relayUrl),
secret: secret.toLowerCase(),
lud16,
};
} catch {
return null;
}
}
export const addNwcConnection = async function (
this: StorageService,
data: {
name: string;
connectionUrl: string;
}
): Promise<void> {
this.assureIsInitialized();
// Parse the NWC URL
const parsed = parseNwcUrl(data.connectionUrl);
if (!parsed) {
throw new Error('Invalid NWC URL format');
}
// Check if a connection with the same wallet pubkey already exists
const existingConnection = (
this.getBrowserSessionHandler().browserSessionData?.nwcConnections ?? []
).find((x) => x.walletPubkey === parsed.walletPubkey);
if (existingConnection) {
throw new Error(
`A connection to this wallet already exists: ${existingConnection.name}`
);
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedConnection: NwcConnection_DECRYPTED = {
id: CryptoHelper.v4(),
name: data.name,
connectionUrl: data.connectionUrl,
walletPubkey: parsed.walletPubkey,
relayUrl: parsed.relayUrl,
secret: parsed.secret,
lud16: parsed.lud16,
createdAt: new Date().toISOString(),
};
// Initialize array if needed
if (!browserSessionData.nwcConnections) {
browserSessionData.nwcConnections = [];
}
// Add the new connection to the session data
browserSessionData.nwcConnections.push(decryptedConnection);
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new connection and add it to the sync data
const encryptedConnection = await encryptNwcConnection.call(
this,
decryptedConnection
);
const encryptedConnections = [
...(this.getBrowserSyncHandler().browserSyncData?.nwcConnections ?? []),
encryptedConnection,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
nwcConnections: encryptedConnections,
});
};
export const deleteNwcConnection = async function (
this: StorageService,
connectionId: string
): Promise<void> {
this.assureIsInitialized();
if (!connectionId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
// Remove from session data
browserSessionData.nwcConnections = (
browserSessionData.nwcConnections ?? []
).filter((x) => x.id !== connectionId);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data
const encryptedConnectionId = await this.encrypt(connectionId);
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
nwcConnections: (browserSyncData.nwcConnections ?? []).filter(
(x) => x.id !== encryptedConnectionId
),
});
};
export const updateNwcConnectionBalance = async function (
this: StorageService,
connectionId: string,
balanceMillisats: number
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
const sessionConnection = (browserSessionData.nwcConnections ?? []).find(
(x) => x.id === connectionId
);
const encryptedConnectionId = await this.encrypt(connectionId);
const syncConnection = (browserSyncData.nwcConnections ?? []).find(
(x) => x.id === encryptedConnectionId
);
if (!sessionConnection || !syncConnection) {
throw new Error('NWC connection not found for balance update.');
}
const now = new Date().toISOString();
// Update session data
sessionConnection.cachedBalance = balanceMillisats;
sessionConnection.cachedBalanceAt = now;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Update sync data
syncConnection.cachedBalance = await this.encrypt(balanceMillisats.toString());
syncConnection.cachedBalanceAt = await this.encrypt(now);
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
nwcConnections: browserSyncData.nwcConnections ?? [],
});
};
export const encryptNwcConnection = async function (
this: StorageService,
connection: NwcConnection_DECRYPTED
): Promise<NwcConnection_ENCRYPTED> {
const encrypted: NwcConnection_ENCRYPTED = {
id: await this.encrypt(connection.id),
name: await this.encrypt(connection.name),
connectionUrl: await this.encrypt(connection.connectionUrl),
walletPubkey: await this.encrypt(connection.walletPubkey),
relayUrl: await this.encrypt(connection.relayUrl),
secret: await this.encrypt(connection.secret),
createdAt: await this.encrypt(connection.createdAt),
};
if (connection.lud16) {
encrypted.lud16 = await this.encrypt(connection.lud16);
}
if (connection.cachedBalance !== undefined) {
encrypted.cachedBalance = await this.encrypt(
connection.cachedBalance.toString()
);
}
if (connection.cachedBalanceAt) {
encrypted.cachedBalanceAt = await this.encrypt(connection.cachedBalanceAt);
}
return encrypted;
};
export const decryptNwcConnection = async function (
this: StorageService,
connection: NwcConnection_ENCRYPTED,
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<NwcConnection_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
// Normal decryption with unlocked vault
const decrypted: NwcConnection_DECRYPTED = {
id: await this.decrypt(connection.id, 'string'),
name: await this.decrypt(connection.name, 'string'),
connectionUrl: await this.decrypt(connection.connectionUrl, 'string'),
walletPubkey: await this.decrypt(connection.walletPubkey, 'string'),
relayUrl: await this.decrypt(connection.relayUrl, 'string'),
secret: await this.decrypt(connection.secret, 'string'),
createdAt: await this.decrypt(connection.createdAt, 'string'),
};
if (connection.lud16) {
decrypted.lud16 = await this.decrypt(connection.lud16, 'string');
}
if (connection.cachedBalance) {
decrypted.cachedBalance = await this.decrypt(
connection.cachedBalance,
'number'
);
}
if (connection.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decrypt(
connection.cachedBalanceAt,
'string'
);
}
return decrypted;
}
// v2: Use pre-derived key
if (withLockedVault.keyBase64) {
const decrypted: NwcConnection_DECRYPTED = {
id: await this.decryptWithLockedVaultV2(
connection.id,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
name: await this.decryptWithLockedVaultV2(
connection.name,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
connectionUrl: await this.decryptWithLockedVaultV2(
connection.connectionUrl,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
walletPubkey: await this.decryptWithLockedVaultV2(
connection.walletPubkey,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
relayUrl: await this.decryptWithLockedVaultV2(
connection.relayUrl,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
secret: await this.decryptWithLockedVaultV2(
connection.secret,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
createdAt: await this.decryptWithLockedVaultV2(
connection.createdAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
};
if (connection.lud16) {
decrypted.lud16 = await this.decryptWithLockedVaultV2(
connection.lud16,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
if (connection.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
connection.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
if (connection.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
connection.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
return decrypted;
}
// v1: Use password (PBKDF2)
const decrypted: NwcConnection_DECRYPTED = {
id: await this.decryptWithLockedVault(
connection.id,
'string',
withLockedVault.iv,
withLockedVault.password!
),
name: await this.decryptWithLockedVault(
connection.name,
'string',
withLockedVault.iv,
withLockedVault.password!
),
connectionUrl: await this.decryptWithLockedVault(
connection.connectionUrl,
'string',
withLockedVault.iv,
withLockedVault.password!
),
walletPubkey: await this.decryptWithLockedVault(
connection.walletPubkey,
'string',
withLockedVault.iv,
withLockedVault.password!
),
relayUrl: await this.decryptWithLockedVault(
connection.relayUrl,
'string',
withLockedVault.iv,
withLockedVault.password!
),
secret: await this.decryptWithLockedVault(
connection.secret,
'string',
withLockedVault.iv,
withLockedVault.password!
),
createdAt: await this.decryptWithLockedVault(
connection.createdAt,
'string',
withLockedVault.iv,
withLockedVault.password!
),
};
if (connection.lud16) {
decrypted.lud16 = await this.decryptWithLockedVault(
connection.lud16,
'string',
withLockedVault.iv,
withLockedVault.password!
);
}
if (connection.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVault(
connection.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.password!
);
}
if (connection.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
connection.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.password!
);
}
return decrypted;
};
export const decryptNwcConnections = async function (
this: StorageService,
connections: NwcConnection_ENCRYPTED[],
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<NwcConnection_DECRYPTED[]> {
const decryptedConnections: NwcConnection_DECRYPTED[] = [];
for (const connection of connections) {
const decryptedConnection = await decryptNwcConnection.call(
this,
connection,
withLockedVault
);
decryptedConnections.push(decryptedConnection);
}
return decryptedConnections;
};

View File

@@ -146,12 +146,17 @@ export const decryptPermissions = async function (
const decryptedPermissions: Permission_DECRYPTED[] = [];
for (const permission of permissions) {
const decryptedPermission = await decryptPermission.call(
this,
permission,
withLockedVault
);
decryptedPermissions.push(decryptedPermission);
try {
const decryptedPermission = await decryptPermission.call(
this,
permission,
withLockedVault
);
decryptedPermissions.push(decryptedPermission);
} catch (error) {
// Skip corrupted permissions (e.g., encrypted with wrong key)
console.warn('[vault] Skipping corrupted permission:', error);
}
}
return decryptedPermissions;

View File

@@ -8,7 +8,9 @@ import {
deriveKeyArgon2,
} from '@common';
import { Buffer } from 'buffer';
import { decryptCashuMints, encryptCashuMint } from './cashu';
import { decryptIdentities, encryptIdentity, LockedVaultContext } from './identity';
import { decryptNwcConnections, encryptNwcConnection } from './nwc';
import { decryptPermissions } from './permission';
import { decryptRelays, encryptRelay } from './relay';
@@ -34,6 +36,8 @@ export const createNewVault = async function (
identities: [],
permissions: [],
relays: [],
nwcConnections: [],
cashuMints: [],
selectedIdentityId: null,
};
await this.getBrowserSessionHandler().saveFullData(sessionData);
@@ -47,6 +51,8 @@ export const createNewVault = async function (
identities: [],
permissions: [],
relays: [],
nwcConnections: [],
cashuMints: [],
selectedIdentityId: null,
};
await this.getBrowserSyncHandler().saveAndSetFullData(syncData);
@@ -133,6 +139,22 @@ export const unlockVault = async function (
);
console.log('[vault] Decrypted', decryptedRelays.length, 'relays');
console.log('[vault] Decrypting NWC connections...');
const decryptedNwcConnections = await decryptNwcConnections.call(
this,
browserSyncData.nwcConnections ?? [],
withLockedVault
);
console.log('[vault] Decrypted', decryptedNwcConnections.length, 'NWC connections');
console.log('[vault] Decrypting Cashu mints...');
const decryptedCashuMints = await decryptCashuMints.call(
this,
browserSyncData.cashuMints ?? [],
withLockedVault
);
console.log('[vault] Decrypted', decryptedCashuMints.length, 'Cashu mints');
console.log('[vault] Decrypting selectedIdentityId...');
let decryptedSelectedIdentityId: string | null = null;
if (browserSyncData.selectedIdentityId !== null) {
@@ -163,6 +185,8 @@ export const unlockVault = async function (
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
nwcConnections: decryptedNwcConnections,
cashuMints: decryptedCashuMints,
};
console.log('[vault] Saving session data...');
@@ -234,6 +258,20 @@ async function migrateVaultV1ToV2(
encryptedPermissions.push(encryptedPermission);
}
// Re-encrypt NWC connections
const encryptedNwcConnections = [];
for (const nwcConnection of browserSessionData.nwcConnections ?? []) {
const encrypted = await encryptNwcConnection.call(this, nwcConnection);
encryptedNwcConnections.push(encrypted);
}
// Re-encrypt Cashu mints
const encryptedCashuMints = [];
for (const cashuMint of browserSessionData.cashuMints ?? []) {
const encrypted = await encryptCashuMint.call(this, cashuMint);
encryptedCashuMints.push(encrypted);
}
const encryptedSelectedIdentityId = browserSessionData.selectedIdentityId
? await this.encrypt(browserSessionData.selectedIdentityId)
: null;
@@ -247,6 +285,8 @@ async function migrateVaultV1ToV2(
identities: encryptedIdentities,
permissions: encryptedPermissions,
relays: encryptedRelays,
nwcConnections: encryptedNwcConnections,
cashuMints: encryptedCashuMints,
selectedIdentityId: encryptedSelectedIdentityId,
};

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSyncFlow, SignerMetaData } from './types';
import { Bookmark, BrowserSyncData, BrowserSyncFlow, SignerMetaData, SignerMetaData_VaultSnapshot } from './types';
import { v4 as uuidv4 } from 'uuid';
export abstract class SignerMetaHandler {
get signerMetaData(): SignerMetaData | undefined {
@@ -8,7 +9,8 @@ export abstract class SignerMetaHandler {
#signerMetaData?: SignerMetaData;
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts'];
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode'];
readonly DEFAULT_MAX_BACKUPS = 5;
/**
* Load the full data from the storage. If the storage is used for storing
* other data (e.g. browser sync data when the user decided to NOT sync),
@@ -56,6 +58,21 @@ export abstract class SignerMetaHandler {
await this.saveFullData(this.#signerMetaData);
}
/**
* Sets dev mode and immediately saves it.
*/
async setDevMode(enabled: boolean): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
devMode: enabled,
};
} else {
this.#signerMetaData.devMode = enabled;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Adds a host to the whitelist and immediately saves it.
*/
@@ -89,4 +106,142 @@ export abstract class SignerMetaHandler {
await this.saveFullData(this.#signerMetaData);
}
/**
* Sets the bookmarks array and immediately saves it.
*/
async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
bookmarks,
};
} else {
this.#signerMetaData.bookmarks = bookmarks;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Gets the current bookmarks.
*/
getBookmarks(): Bookmark[] {
return this.#signerMetaData?.bookmarks ?? [];
}
/**
* Gets the maximum number of backups to keep.
*/
getMaxBackups(): number {
return this.#signerMetaData?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
}
/**
* Sets the maximum number of backups to keep and immediately saves it.
*/
async setMaxBackups(count: number): Promise<void> {
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
if (!this.#signerMetaData) {
this.#signerMetaData = {
maxBackups: clampedCount,
};
} else {
this.#signerMetaData.maxBackups = clampedCount;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Gets all vault backups, sorted newest first.
*/
getBackups(): SignerMetaData_VaultSnapshot[] {
const backups = this.#signerMetaData?.vaultSnapshots ?? [];
return [...backups].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
/**
* Gets a specific backup by ID.
*/
getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined {
return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id);
}
/**
* Creates a new backup of the vault data.
* Automatically removes old backups if exceeding maxBackups.
*/
async createBackup(
browserSyncData: BrowserSyncData,
reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
): Promise<SignerMetaData_VaultSnapshot> {
const now = new Date();
const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const identityCount = browserSyncData.identities?.length ?? 0;
const snapshot: SignerMetaData_VaultSnapshot = {
id: uuidv4(),
fileName: `Vault Backup - ${dateTimeString}`,
createdAt: now.toISOString(),
data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone
identityCount,
reason,
};
if (!this.#signerMetaData) {
this.#signerMetaData = {
vaultSnapshots: [snapshot],
};
} else {
const existingBackups = this.#signerMetaData.vaultSnapshots ?? [];
existingBackups.push(snapshot);
// Enforce max backups limit (only for auto backups, keep manual and pre-restore)
const maxBackups = this.getMaxBackups();
const autoBackups = existingBackups.filter(b => b.reason === 'auto');
const otherBackups = existingBackups.filter(b => b.reason !== 'auto');
// Sort auto backups by date (newest first) and keep only maxBackups
autoBackups.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const trimmedAutoBackups = autoBackups.slice(0, maxBackups);
this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
}
await this.saveFullData(this.#signerMetaData);
return snapshot;
}
/**
* Deletes a backup by ID.
*/
async deleteBackup(backupId: string): Promise<boolean> {
if (!this.#signerMetaData?.vaultSnapshots) {
return false;
}
const initialLength = this.#signerMetaData.vaultSnapshots.length;
this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter(
b => b.id !== backupId
);
if (this.#signerMetaData.vaultSnapshots.length < initialLength) {
await this.saveFullData(this.#signerMetaData);
return true;
}
return false;
}
/**
* Gets the data from a backup for restoration.
* Note: The caller should create a pre-restore backup before calling this.
*/
getBackupData(backupId: string): BrowserSyncData | undefined {
const backup = this.getBackupById(backupId);
return backup?.data;
}
}

View File

@@ -20,6 +20,17 @@ import {
import { deletePermission } from './related/permission';
import { createNewVault, deleteVault, unlockVault } from './related/vault';
import { addRelay, deleteRelay, updateRelay } from './related/relay';
import {
addNwcConnection,
deleteNwcConnection,
updateNwcConnectionBalance,
} from './related/nwc';
import {
addCashuMint,
deleteCashuMint,
updateCashuMintProofs,
} from './related/cashu';
import { CashuMint_DECRYPTED, CashuProof } from './types';
export interface StorageServiceConfig {
browserSessionHandler: BrowserSessionHandler;
@@ -124,6 +135,14 @@ export class StorageService {
this.isInitialized = false;
}
async lockVault(): Promise<void> {
this.assureIsInitialized();
await this.getBrowserSessionHandler().clearData();
this.getBrowserSessionHandler().clearInMemoryData();
// Note: We don't set isInitialized = false here because the sync data
// (encrypted vault) is still loaded and we need it to unlock again
}
async unlockVault(password: string): Promise<void> {
await unlockVault.call(this, password);
}
@@ -168,6 +187,43 @@ export class StorageService {
await updateRelay.call(this, relayClone);
}
async addNwcConnection(data: {
name: string;
connectionUrl: string;
}): Promise<void> {
await addNwcConnection.call(this, data);
}
async deleteNwcConnection(connectionId: string): Promise<void> {
await deleteNwcConnection.call(this, connectionId);
}
async updateNwcConnectionBalance(
connectionId: string,
balanceMillisats: number
): Promise<void> {
await updateNwcConnectionBalance.call(this, connectionId, balanceMillisats);
}
async addCashuMint(data: {
name: string;
mintUrl: string;
unit?: string;
}): Promise<CashuMint_DECRYPTED> {
return await addCashuMint.call(this, data);
}
async deleteCashuMint(mintId: string): Promise<void> {
await deleteCashuMint.call(this, mintId);
}
async updateCashuMintProofs(
mintId: string,
proofs: CashuProof[]
): Promise<void> {
await updateCashuMintProofs.call(this, mintId, proofs);
}
exportVault(): string {
this.assureIsInitialized();
const vaultJson = JSON.stringify(
@@ -218,6 +274,17 @@ export class StorageService {
return this.#signerMetaHandler;
}
/**
* Get the current browser sync flow setting.
* Returns NO_SYNC if not initialized or no setting found.
*/
getSyncFlow(): BrowserSyncFlow {
if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) {
return BrowserSyncFlow.NO_SYNC;
}
return this.#signerMetaHandler.signerMetaData.syncFlow ?? BrowserSyncFlow.NO_SYNC;
}
/**
* Throws an exception if the service is not initialized.
*/

View File

@@ -1,10 +1,10 @@
import { Nip07Method, Nip07MethodPolicy } from '@common';
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
export interface Permission_DECRYPTED {
id: string;
identityId: string;
host: string;
method: Nip07Method;
method: ExtensionMethod;
methodPolicy: Nip07MethodPolicy;
kind?: number;
}
@@ -43,6 +43,80 @@ export interface Relay_ENCRYPTED {
write: string;
}
/**
* NWC (Nostr Wallet Connect) connection - Decrypted
* Stores NIP-47 wallet connection data
*/
export interface NwcConnection_DECRYPTED {
id: string;
name: string; // User-defined wallet name
connectionUrl: string; // Full nostr+walletconnect:// URL
walletPubkey: string; // Wallet service pubkey
relayUrl: string; // Relay URL for NWC communication
secret: string; // Client secret key (32-byte hex)
lud16?: string; // Optional lightning address
createdAt: string; // ISO timestamp
cachedBalance?: number; // Balance in millisatoshis
cachedBalanceAt?: string; // ISO timestamp when balance was fetched
}
/**
* NWC connection - Encrypted for storage
*/
export interface NwcConnection_ENCRYPTED {
id: string;
name: string;
connectionUrl: string;
walletPubkey: string;
relayUrl: string;
secret: string;
lud16?: string;
createdAt: string;
cachedBalance?: string; // Encrypted as string
cachedBalanceAt?: string;
}
/**
* Cashu Proof - represents a single ecash token
* This is the actual money stored locally
*/
export interface CashuProof {
id: string; // Keyset ID from mint
amount: number; // Satoshi amount
secret: string; // Blinded secret
C: string; // Unblinded signature (commitment)
receivedAt?: string; // ISO timestamp when token was received
}
/**
* Cashu Mint Connection - Decrypted
* Stores NIP-60 Cashu mint connection data with local proofs
*/
export interface CashuMint_DECRYPTED {
id: string;
name: string; // User-defined mint name
mintUrl: string; // Mint API URL
unit: string; // Unit (default: 'sat')
createdAt: string; // ISO timestamp
proofs: CashuProof[]; // Unspent proofs for this mint
cachedBalance?: number; // Sum of proof amounts (sats)
cachedBalanceAt?: string; // When balance was calculated
}
/**
* Cashu Mint Connection - Encrypted for storage
*/
export interface CashuMint_ENCRYPTED {
id: string;
name: string;
mintUrl: string;
unit: string;
createdAt: string;
proofs: string; // JSON stringified and encrypted
cachedBalance?: string;
cachedBalanceAt?: string;
}
export interface BrowserSyncData_PART_Unencrypted {
version: number;
iv: string;
@@ -57,6 +131,8 @@ export interface BrowserSyncData_PART_Encrypted {
permissions: Permission_ENCRYPTED[];
identities: Identity_ENCRYPTED[];
relays: Relay_ENCRYPTED[];
nwcConnections?: NwcConnection_ENCRYPTED[];
cashuMints?: CashuMint_ENCRYPTED[];
}
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted &
@@ -83,27 +159,52 @@ export interface BrowserSessionData {
identities: Identity_DECRYPTED[];
selectedIdentityId: string | null;
relays: Relay_DECRYPTED[];
nwcConnections?: NwcConnection_DECRYPTED[];
cashuMints?: CashuMint_DECRYPTED[];
}
export interface SignerMetaData_VaultSnapshot {
id: string;
fileName: string;
createdAt: string; // ISO timestamp
data: BrowserSyncData;
identityCount: number;
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
}
export const SIGNER_META_DATA_KEY = {
vaultSnapshots: 'vaultSnapshots',
};
/**
* Bookmark entry for storing user bookmarks
*/
export interface Bookmark {
id: string;
url: string;
title: string;
createdAt: number;
}
export interface SignerMetaData {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync))
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
// Maximum number of automatic backups to keep (default: 5)
maxBackups?: number;
// Reckless mode: auto-approve all actions without prompting
recklessMode?: boolean;
// Whitelisted hosts: auto-approve all actions from these hosts
whitelistedHosts?: string[];
// User bookmarks
bookmarks?: Bookmark[];
// Dev mode: show test permission prompt button in settings
devMode?: boolean;
}
/**

View File

@@ -1,12 +1,13 @@
.sam-text-header {
background: var(--background);
z-index: 20;
padding-top: var(--size);
padding-bottom: var(--size);
height: 48px;
min-height: 48px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
position: relative;
span {
font-family: var(--font-heading);
@@ -14,6 +15,41 @@
font-weight: 700;
letter-spacing: 0.1rem;
}
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.lock-btn,
.header-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;
}
}
// For backwards compatibility with single lock-btn
> .lock-btn {
position: absolute;
left: 0;
}
}
.sam-footer-grid-2 {

View File

@@ -84,3 +84,11 @@ h2.font-heading {
h3.font-heading {
font-size: 1.4rem;
}
// Emoji styling
.emoji {
font-family: var(--font-emoji);
font-style: normal;
font-weight: normal;
line-height: 1;
}

View File

@@ -16,6 +16,7 @@
--font-sans: 'IBM Plex Mono', monospace;
--font-heading: 'reglisse', sans-serif;
--font-theylive: 'theylive', sans-serif;
--font-emoji: 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Android Emoji', sans-serif;
// Border radius (from market)
--radius: 0.25rem;

View File

@@ -19,6 +19,7 @@ export * from './lib/helpers/nip05-validator';
// Models
export * from './lib/models/nostr';
export * from './lib/models/webln';
// Services (and related)
export * from './lib/services/storage/storage.service';
@@ -26,6 +27,13 @@ export * from './lib/services/storage/types';
export * from './lib/services/storage/browser-sync-handler';
export * from './lib/services/storage/browser-session-handler';
export * from './lib/services/storage/signer-meta-handler';
export * from './lib/services/storage/related/nwc';
export * from './lib/services/storage/related/cashu';
export * from './lib/services/nwc/nwc.service';
export * from './lib/services/nwc/nwc-client';
export * from './lib/services/nwc/types';
export * from './lib/services/cashu/cashu.service';
export * from './lib/services/cashu/types';
export * from './lib/services/logger/logger.service';
export * from './lib/services/startup/startup.service';
export * from './lib/services/profile-metadata/profile-metadata.service';

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",
"description": "Nostr Identity Manager & Signer",
"version": "1.0.1",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"version": "1.1.0",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [
"storage"

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,83 @@
<!-- 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>
</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

@@ -6,6 +6,10 @@ 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';
@@ -66,6 +70,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,
},
],
},
{

View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
BrowserSyncHandler,
Relay_ENCRYPTED,
@@ -56,6 +58,20 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void> {
await browser.storage.local.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void> {
await browser.storage.local.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
const props = Object.keys(await this.loadUnmigratedData());
await browser.storage.local.remove(props);

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