Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57434681f9 | ||
|
|
586e2ab23f | ||
|
|
5ca6eb177c | ||
|
|
ebc96e7201 | ||
|
|
1f8d478cd7 | ||
|
|
3750e99e61 | ||
|
|
2c1f3265b7 | ||
|
|
7ff8e257dd | ||
|
|
8b6ead1f81 | ||
|
|
38d9a9ef9f | ||
|
|
b55a3f01b6 | ||
|
b7bedf085a
|
|||
|
ff82e41012
|
|||
|
45b1fb58e9
|
|||
|
b535a7b967
|
|||
|
abd4a21f8f
|
|||
|
4b2d23e942
|
|||
|
ebe2b695cc
|
|||
|
ddb74c61b2
|
@@ -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
|
||||
|
||||
25
CLAUDE.md
@@ -27,7 +27,7 @@ npm run build:chrome && npm run build:firefox
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
This is an Angular CLI monorepo with three projects:
|
||||
This is an Angular 19 CLI monorepo with three projects:
|
||||
|
||||
- **projects/chrome**: Chrome extension (MV3)
|
||||
- **projects/firefox**: Firefox extension
|
||||
@@ -49,10 +49,20 @@ Message flow: Web App → `window.nostr` → Content Script → Background → C
|
||||
|
||||
- **BrowserSyncHandler**: Encrypted vault data synced across browser instances (or local-only based on user preference)
|
||||
- **BrowserSessionHandler**: Session-scoped decrypted data (unlocked vault state)
|
||||
- **SignerMetaHandler**: Extension metadata (sync flow preference)
|
||||
- **SignerMetaHandler**: Extension metadata (sync flow preference, reckless mode, whitelisted hosts)
|
||||
|
||||
Each browser (Chrome/Firefox) has its own handler implementations in `projects/{browser}/src/app/common/data/`.
|
||||
|
||||
### Vault Encryption (v2)
|
||||
|
||||
The vault uses Argon2id + AES-256-GCM for password-based encryption:
|
||||
- **Key derivation**: Argon2id with 256MB memory, 4 threads, 8 iterations (~3 second derivation)
|
||||
- **Encryption**: AES-256-GCM with random 12-byte IV per encryption
|
||||
- **Salt**: Random 32-byte salt per vault (stored in `BrowserSyncData.salt`)
|
||||
- The derived key is cached in session storage (`BrowserSessionData.vaultKey`) to avoid re-derivation on each operation
|
||||
|
||||
Note: Argon2id runs on main thread via WebAssembly (hash-wasm) because Web Workers cannot load external scripts in browser extensions due to CSP restrictions. A deriving modal provides user feedback during the ~3 second operation.
|
||||
|
||||
### Custom Webpack Build
|
||||
|
||||
Both extensions use `@angular-builders/custom-webpack` to bundle additional entry points beyond the main Angular app:
|
||||
@@ -66,9 +76,18 @@ Both extensions use `@angular-builders/custom-webpack` to bundle additional entr
|
||||
|
||||
The `@common` import alias resolves to `projects/common/src/public-api.ts`. Key exports:
|
||||
- `StorageService`: Central data management with encryption/decryption
|
||||
- `CryptoHelper`, `NostrHelper`: Cryptographic utilities
|
||||
- `CryptoHelper`, `NostrHelper`: Cryptographic utilities (nostr-tools based)
|
||||
- `Argon2Crypto`: Vault encryption with Argon2id key derivation
|
||||
- Shared Angular components and pipes
|
||||
|
||||
### Permission System
|
||||
|
||||
Permissions are stored per identity+host+method combination. The background script checks permissions before executing NIP-07 methods:
|
||||
- `allow`/`deny` policies can be stored for each method
|
||||
- Kind-specific permissions supported for `signEvent`
|
||||
- **Reckless mode**: Auto-approves all actions without prompting (global setting)
|
||||
- **Whitelisted hosts**: Auto-approves all actions from specific hosts
|
||||
|
||||
## Testing Extensions Locally
|
||||
|
||||
**Chrome:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
version=$( cat package.json | jq '.custom.chrome.version' | tr -d '"')
|
||||
# Extract version and strip 'v' prefix if present (manifest requires bare semver)
|
||||
version=$( cat package.json | jq -r '.custom.chrome.version' | sed 's/^v//')
|
||||
|
||||
jq '.version = $newVersion' --arg newVersion $version ./projects/chrome/public/manifest.json > ./projects/chrome/public/tmp.manifest.json && mv ./projects/chrome/public/tmp.manifest.json ./projects/chrome/public/manifest.json
|
||||
|
||||
|
||||
112
docs/store/PRIVACY_POLICY.md
Normal 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
|
||||
293
docs/store/PUBLISHING_GUIDE.md
Normal 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.
|
||||
88
docs/store/STORE_DESCRIPTION.md
Normal 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
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
version=$( cat package.json | jq '.custom.firefox.version' | tr -d '"')
|
||||
# Extract version and strip 'v' prefix if present (manifest requires bare semver)
|
||||
version=$( cat package.json | jq -r '.custom.firefox.version' | sed 's/^v//')
|
||||
|
||||
jq '.version = $newVersion' --arg newVersion $version ./projects/firefox/public/manifest.json > ./projects/firefox/public/tmp.manifest.json && mv ./projects/firefox/public/tmp.manifest.json ./projects/firefox/public/manifest.json
|
||||
|
||||
|
||||
336
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebian-signer",
|
||||
"version": "0.0.4",
|
||||
"name": "plebeian-signer",
|
||||
"version": "v1.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plebian-signer",
|
||||
"version": "0.0.4",
|
||||
"name": "plebeian-signer",
|
||||
"version": "v1.0.8",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
@@ -16,12 +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",
|
||||
@@ -35,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",
|
||||
@@ -4711,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",
|
||||
@@ -8156,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"
|
||||
@@ -8172,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",
|
||||
@@ -8236,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",
|
||||
@@ -9244,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"
|
||||
@@ -9924,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",
|
||||
@@ -10191,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"
|
||||
@@ -10204,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": {
|
||||
@@ -10642,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",
|
||||
@@ -10771,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",
|
||||
@@ -12082,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.*"
|
||||
@@ -12320,6 +12424,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash-wasm": {
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.11.0.tgz",
|
||||
"integrity": "sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -16117,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"
|
||||
@@ -16266,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"
|
||||
@@ -16488,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",
|
||||
@@ -16737,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",
|
||||
@@ -16945,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"
|
||||
@@ -16961,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",
|
||||
@@ -17639,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",
|
||||
@@ -19017,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": {
|
||||
@@ -19849,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",
|
||||
@@ -19870,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",
|
||||
@@ -19959,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"
|
||||
@@ -19969,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"
|
||||
@@ -19986,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",
|
||||
@@ -20001,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"
|
||||
|
||||
16
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v0.0.8",
|
||||
"version": "v1.1.0",
|
||||
"custom": {
|
||||
"chrome": {
|
||||
"version": "v0.0.8"
|
||||
"version": "v1.1.0"
|
||||
},
|
||||
"firefox": {
|
||||
"version": "v0.0.8"
|
||||
"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,12 +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",
|
||||
@@ -54,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",
|
||||
|
||||
@@ -22,5 +22,9 @@ module.exports = {
|
||||
import: 'src/options.ts',
|
||||
runtime: false,
|
||||
},
|
||||
unlock: {
|
||||
import: 'src/unlock.ts',
|
||||
runtime: false,
|
||||
},
|
||||
},
|
||||
} as Configuration;
|
||||
|
||||
3
projects/chrome/public/edit.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.06 9L15 9.94L5.92 19H5V18.08L14.06 9ZM17.66 3C17.41 3 17.15 3.1 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18.17 3.09 17.92 3 17.66 3ZM14.06 6.19L3 17.25V21H6.75L17.81 9.94L14.06 6.19Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
@@ -2,13 +2,16 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||
"version": "v0.0.8",
|
||||
"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",
|
||||
"storage"
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "index.html",
|
||||
"default_icon": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#ffffff" class="bi bi-person-fill" viewBox="0 0 16 16">
|
||||
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
|
||||
</svg>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4C11.606 4 11.2159 4.0776 10.8519 4.22836C10.488 4.37913 10.1573 4.6001 9.87868 4.87868C9.6001 5.15726 9.37913 5.48797 9.22836 5.85195C9.0776 6.21593 9 6.60603 9 7C9 7.39397 9.0776 7.78407 9.22836 8.14805C9.37913 8.51203 9.6001 8.84274 9.87868 9.12132C10.1573 9.3999 10.488 9.62087 10.8519 9.77164C11.2159 9.9224 11.606 10 12 10C12.7956 10 13.5587 9.68393 14.1213 9.12132C14.6839 8.55871 15 7.79565 15 7C15 6.20435 14.6839 5.44129 14.1213 4.87868C13.5587 4.31607 12.7956 4 12 4ZM7 7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7C17 8.32608 16.4732 9.59785 15.5355 10.5355C14.5979 11.4732 13.3261 12 12 12C10.6739 12 9.40215 11.4732 8.46447 10.5355C7.52678 9.59785 7 8.32608 7 7ZM3.5 19C3.5 17.6739 4.02678 16.4021 4.96447 15.4645C5.90215 14.5268 7.17392 14 8.5 14H15.5C16.1566 14 16.8068 14.1293 17.4134 14.3806C18.02 14.6319 18.5712 15.0002 19.0355 15.4645C19.4998 15.9288 19.8681 16.48 20.1194 17.0866C20.3707 17.6932 20.5 18.3434 20.5 19V21H18.5V19C18.5 18.2044 18.1839 17.4413 17.6213 16.8787C17.0587 16.3161 16.2956 16 15.5 16H8.5C7.70435 16 6.94129 16.3161 6.37868 16.8787C5.81607 17.4413 5.5 18.2044 5.5 19V21H3.5V19Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 219 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 983 B After Width: | Height: | Size: 983 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -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>
|
||||
|
||||
245
projects/chrome/public/unlock.html
Normal 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>
|
||||
@@ -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';
|
||||
@@ -16,6 +20,8 @@ import { KeysComponent as EditIdentityKeysComponent } from './components/edit-id
|
||||
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
|
||||
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
|
||||
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
||||
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
|
||||
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -64,12 +70,36 @@ export const routes: Routes = [
|
||||
path: 'settings',
|
||||
component: SettingsComponent,
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
component: LogsComponent,
|
||||
},
|
||||
{
|
||||
path: 'bookmarks',
|
||||
component: BookmarksComponent,
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
component: WalletComponent,
|
||||
},
|
||||
{
|
||||
path: 'backups',
|
||||
component: BackupsComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'new-identity',
|
||||
component: NewIdentityComponent,
|
||||
},
|
||||
{
|
||||
path: 'whitelisted-apps',
|
||||
component: WhitelistedAppsComponent,
|
||||
},
|
||||
{
|
||||
path: 'profile-edit',
|
||||
component: ProfileEditComponent,
|
||||
},
|
||||
{
|
||||
path: 'edit-identity/:id',
|
||||
component: EditIdentityComponent,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.remove-all-btn {
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.permissions-card {
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
<!-- 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>
|
||||
|
||||
<div class="reckless-mode-row">
|
||||
<label class="reckless-label" (click)="onToggleRecklessMode()">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isRecklessMode"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="onToggleRecklessMode()"
|
||||
/>
|
||||
<span
|
||||
class="reckless-text"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="bottom"
|
||||
title="Auto-approve all actions. If whitelist has entries, only those apps are auto-approved."
|
||||
>Reckless mode</span>
|
||||
</label>
|
||||
<button
|
||||
class="gear-btn"
|
||||
title="Manage whitelisted apps"
|
||||
(click)="onClickWhitelistedApps()"
|
||||
>
|
||||
<span class="emoji">⚙️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,11 +65,11 @@
|
||||
class="avatar"
|
||||
[src]="getAvatarUrl(identity)"
|
||||
alt=""
|
||||
(error)="$any($event.target).src = 'assets/person-fill.svg'"
|
||||
(error)="$any($event.target).src = 'person-fill.svg'"
|
||||
/>
|
||||
<span class="name">{{ getDisplayName(identity) }}</span>
|
||||
<lib-icon-button
|
||||
icon="gear"
|
||||
icon="⚙️"
|
||||
title="Identity settings"
|
||||
(click)="onClickEditIdentity(identity.id, $event)"
|
||||
></lib-icon-button>
|
||||
|
||||
@@ -3,35 +3,113 @@
|
||||
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-size: 20px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1rem;
|
||||
justify-self: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.reckless-mode-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
|
||||
.reckless-label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reckless-text {
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.gear-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground);
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -16,14 +18,19 @@ 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>();
|
||||
|
||||
get isRecklessMode(): boolean {
|
||||
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.#profileMetadata.initialize();
|
||||
this.#loadProfiles();
|
||||
@@ -40,7 +47,7 @@ export class IdentitiesComponent implements OnInit {
|
||||
|
||||
getAvatarUrl(identity: Identity_DECRYPTED): string {
|
||||
const profile = this.#profileCache.get(identity.id);
|
||||
return profile?.picture || 'assets/person-fill.svg';
|
||||
return profile?.picture || 'person-fill.svg';
|
||||
}
|
||||
|
||||
getDisplayName(identity: Identity_DECRYPTED): string {
|
||||
@@ -60,4 +67,19 @@ export class IdentitiesComponent implements OnInit {
|
||||
async onClickSelectIdentity(identityId: string) {
|
||||
await this.storage.switchIdentity(identityId);
|
||||
}
|
||||
|
||||
async onToggleRecklessMode() {
|
||||
const newValue = !this.isRecklessMode;
|
||||
await this.storage.getSignerMetaHandler().setRecklessMode(newValue);
|
||||
}
|
||||
|
||||
onClickWhitelistedApps() {
|
||||
this.#router.navigateByUrl('/whitelisted-apps');
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
<!-- 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()">
|
||||
<span class="emoji">📝</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="identity-container">
|
||||
@@ -22,7 +36,6 @@
|
||||
</div>
|
||||
|
||||
<!-- Display name (primary, large) -->
|
||||
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="name-badge-container" (click)="onClickShowDetails()">
|
||||
<span class="display-name">
|
||||
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
|
||||
@@ -67,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>
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.sam-text-header {
|
||||
.edit-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.identity-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -123,6 +147,7 @@
|
||||
}
|
||||
|
||||
.nip05-row {
|
||||
@extend %text-badge;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -134,7 +159,6 @@
|
||||
}
|
||||
|
||||
.nip05-badge {
|
||||
@extend %text-badge;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
}
|
||||
@@ -161,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,16 @@ 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,
|
||||
} from '@common';
|
||||
import NDK from '@nostr-dev-kit/ndk';
|
||||
|
||||
@Component({
|
||||
selector: 'app-identity',
|
||||
@@ -18,7 +19,7 @@ import NDK from '@nostr-dev-kit/ndk';
|
||||
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;
|
||||
@@ -67,13 +72,26 @@ export class IdentityComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
onClickEditProfile() {
|
||||
if (!this.selectedIdentity) {
|
||||
return;
|
||||
}
|
||||
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
|
||||
@@ -125,28 +143,21 @@ export class IdentityComponent implements OnInit {
|
||||
try {
|
||||
this.validating = true;
|
||||
|
||||
// Get relays for validation
|
||||
const relays =
|
||||
this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.relays.filter(
|
||||
(x) => x.identityId === this.selectedIdentity?.id
|
||||
) ?? [];
|
||||
// Direct NIP-05 validation - fetches .well-known/nostr.json directly
|
||||
const result = await validateNip05(nip05, pubkey);
|
||||
this.nip05isValidated = result.valid;
|
||||
|
||||
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
|
||||
|
||||
if (relevantRelays.length > 0) {
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: relevantRelays,
|
||||
});
|
||||
await ndk.connect();
|
||||
const user = ndk.getUser({ pubkey });
|
||||
this.nip05isValidated = (await user.validateNip05(nip05)) ?? undefined;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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)="
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
1136
projects/chrome/src/app/components/home/wallet/wallet.component.scss
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<div class="sam-text-header">
|
||||
<span>Edit Profile</span>
|
||||
</div>
|
||||
|
||||
@if(loading) {
|
||||
<div class="loading-container">
|
||||
<span class="sam-text-muted">Loading profile...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="content">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="display_name">Display Name</label>
|
||||
<input
|
||||
id="display_name"
|
||||
type="text"
|
||||
placeholder="Display name"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.display_name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="picture">Avatar URL</label>
|
||||
<input
|
||||
id="picture"
|
||||
type="url"
|
||||
placeholder="https://example.com/avatar.jpg"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.picture"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="banner">Banner URL</label>
|
||||
<input
|
||||
id="banner"
|
||||
type="url"
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.banner"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="url"
|
||||
placeholder="https://yourwebsite.com"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.website"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="about">About</label>
|
||||
<textarea
|
||||
id="about"
|
||||
placeholder="Tell us about yourself..."
|
||||
class="form-control"
|
||||
rows="4"
|
||||
[(ngModel)]="profile.about"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nip05">NIP-05 Identifier</label>
|
||||
<input
|
||||
id="nip05"
|
||||
type="text"
|
||||
placeholder="you@example.com"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.nip05"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="lud16">Lightning Address (LUD-16)</label>
|
||||
<input
|
||||
id="lud16"
|
||||
type="text"
|
||||
placeholder="you@getalby.com"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.lud16"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="lnurl">LNURL</label>
|
||||
<input
|
||||
id="lnurl"
|
||||
type="text"
|
||||
placeholder="lnurl1..."
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.lnurl"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sam-footer-grid-2">
|
||||
<button type="button" class="btn btn-secondary" (click)="onClickCancel()">
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
[disabled]="saving"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="onClickSave()"
|
||||
>
|
||||
@if(saving) {
|
||||
Saving...
|
||||
} @else {
|
||||
Save
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if(alertMessage) {
|
||||
<div class="alert-container">
|
||||
<div class="alert alert-danger sam-flex-row gap" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span>{{ alertMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<lib-toast #toast></lib-toast>
|
||||
@@ -0,0 +1,69 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: var(--size);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
font-size: 14px;
|
||||
background: var(--background-light);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-container {
|
||||
position: absolute;
|
||||
bottom: 70px;
|
||||
left: var(--size);
|
||||
right: var(--size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
FALLBACK_PROFILE_RELAYS,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
ProfileMetadataService,
|
||||
RelayListService,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
publishToRelaysWithAuth,
|
||||
} from '@common';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import { finalizeEvent } from 'nostr-tools';
|
||||
import { hexToBytes } from '@noble/hashes/utils';
|
||||
|
||||
interface ProfileFormData {
|
||||
name: string;
|
||||
display_name: string;
|
||||
picture: string;
|
||||
banner: string;
|
||||
website: string;
|
||||
about: string;
|
||||
nip05: string;
|
||||
lud16: string;
|
||||
lnurl: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-edit',
|
||||
templateUrl: './profile-edit.component.html',
|
||||
styleUrl: './profile-edit.component.scss',
|
||||
imports: [FormsModule, ToastComponent],
|
||||
})
|
||||
export class ProfileEditComponent extends NavComponent implements OnInit {
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #relayList = inject(RelayListService);
|
||||
|
||||
profile: ProfileFormData = {
|
||||
name: '',
|
||||
display_name: '',
|
||||
picture: '',
|
||||
banner: '',
|
||||
website: '',
|
||||
about: '',
|
||||
nip05: '',
|
||||
lud16: '',
|
||||
lnurl: '',
|
||||
};
|
||||
|
||||
// Store original event content to preserve extra fields
|
||||
#originalContent: Record<string, unknown> = {};
|
||||
#originalTags: string[][] = [];
|
||||
|
||||
loading = true;
|
||||
saving = false;
|
||||
alertMessage: string | undefined;
|
||||
#privkey: string | undefined;
|
||||
#pubkey: string | undefined;
|
||||
|
||||
async ngOnInit() {
|
||||
await this.#loadProfile();
|
||||
}
|
||||
|
||||
async #loadProfile() {
|
||||
try {
|
||||
const selectedIdentityId =
|
||||
this.#storage.getBrowserSessionHandler().browserSessionData
|
||||
?.selectedIdentityId ?? null;
|
||||
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find(
|
||||
(x) => x.id === selectedIdentityId
|
||||
);
|
||||
|
||||
if (!identity) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.#privkey = identity.privkey;
|
||||
this.#pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
||||
|
||||
// Initialize services
|
||||
await this.#profileMetadata.initialize();
|
||||
|
||||
// Try to get cached profile first
|
||||
const cachedProfile = this.#profileMetadata.getCachedProfile(this.#pubkey);
|
||||
if (cachedProfile) {
|
||||
this.profile = {
|
||||
name: cachedProfile.name || '',
|
||||
display_name: cachedProfile.display_name || cachedProfile.displayName || '',
|
||||
picture: cachedProfile.picture || '',
|
||||
banner: cachedProfile.banner || '',
|
||||
website: cachedProfile.website || '',
|
||||
about: cachedProfile.about || '',
|
||||
nip05: cachedProfile.nip05 || '',
|
||||
lud16: cachedProfile.lud16 || '',
|
||||
lnurl: cachedProfile.lud06 || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch the actual kind 0 event to get original content and tags
|
||||
await this.#fetchOriginalEvent();
|
||||
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to load profile:', error);
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchOriginalEvent() {
|
||||
if (!this.#pubkey) return;
|
||||
|
||||
const pool = new SimplePool();
|
||||
try {
|
||||
const events = await this.#queryWithTimeout(
|
||||
pool,
|
||||
FALLBACK_PROFILE_RELAYS,
|
||||
[{ kinds: [0], authors: [this.#pubkey] }],
|
||||
10000
|
||||
);
|
||||
|
||||
if (events.length > 0) {
|
||||
// Get the most recent event
|
||||
const latestEvent = events.reduce((latest, event) =>
|
||||
event.created_at > latest.created_at ? event : latest
|
||||
);
|
||||
|
||||
// Store original tags (excluding the ones we'll update)
|
||||
this.#originalTags = latestEvent.tags.filter(
|
||||
(tag: string[]) =>
|
||||
tag[0] !== 'name' &&
|
||||
tag[0] !== 'display_name' &&
|
||||
tag[0] !== 'picture' &&
|
||||
tag[0] !== 'banner' &&
|
||||
tag[0] !== 'website' &&
|
||||
tag[0] !== 'about' &&
|
||||
tag[0] !== 'nip05' &&
|
||||
tag[0] !== 'lud16' &&
|
||||
tag[0] !== 'client'
|
||||
);
|
||||
|
||||
// Parse and store original content
|
||||
try {
|
||||
this.#originalContent = JSON.parse(latestEvent.content);
|
||||
|
||||
// Update form with values from event content
|
||||
this.profile = {
|
||||
name: (this.#originalContent['name'] as string) || '',
|
||||
display_name:
|
||||
(this.#originalContent['display_name'] as string) ||
|
||||
(this.#originalContent['displayName'] as string) ||
|
||||
'',
|
||||
picture: (this.#originalContent['picture'] as string) || '',
|
||||
banner: (this.#originalContent['banner'] as string) || '',
|
||||
website: (this.#originalContent['website'] as string) || '',
|
||||
about: (this.#originalContent['about'] as string) || '',
|
||||
nip05: (this.#originalContent['nip05'] as string) || '',
|
||||
lud16: (this.#originalContent['lud16'] as string) || '',
|
||||
lnurl: (this.#originalContent['lnurl'] as string) || '',
|
||||
};
|
||||
} catch {
|
||||
console.error('Failed to parse profile content');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pool.close(FALLBACK_PROFILE_RELAYS);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async #queryWithTimeout(pool: SimplePool, relays: string[], filters: any[], timeoutMs: number): Promise<any[]> {
|
||||
return new Promise((resolve) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const events: any[] = [];
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(events);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
const sub = pool.subscribeMany(relays, filters, {
|
||||
onevent(event) {
|
||||
events.push(event);
|
||||
},
|
||||
oneose() {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
sub.close();
|
||||
resolve(events);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async onClickSave() {
|
||||
if (this.saving || !this.#privkey || !this.#pubkey) return;
|
||||
|
||||
this.saving = true;
|
||||
this.alertMessage = undefined;
|
||||
|
||||
try {
|
||||
// Build the content JSON, preserving extra fields
|
||||
const content: Record<string, unknown> = { ...this.#originalContent };
|
||||
|
||||
// Update with form values
|
||||
content['name'] = this.profile.name;
|
||||
content['display_name'] = this.profile.display_name;
|
||||
content['displayName'] = this.profile.display_name; // Some clients use this
|
||||
content['picture'] = this.profile.picture;
|
||||
content['banner'] = this.profile.banner;
|
||||
content['website'] = this.profile.website;
|
||||
content['about'] = this.profile.about;
|
||||
content['nip05'] = this.profile.nip05;
|
||||
content['lud16'] = this.profile.lud16;
|
||||
if (this.profile.lnurl) {
|
||||
content['lnurl'] = this.profile.lnurl;
|
||||
}
|
||||
content['pubkey'] = this.#pubkey;
|
||||
|
||||
// Build tags array, preserving extra tags
|
||||
const tags: string[][] = [...this.#originalTags];
|
||||
|
||||
// Add standard tags
|
||||
if (this.profile.name) tags.push(['name', this.profile.name]);
|
||||
if (this.profile.display_name) tags.push(['display_name', this.profile.display_name]);
|
||||
if (this.profile.picture) tags.push(['picture', this.profile.picture]);
|
||||
if (this.profile.banner) tags.push(['banner', this.profile.banner]);
|
||||
if (this.profile.website) tags.push(['website', this.profile.website]);
|
||||
if (this.profile.about) tags.push(['about', this.profile.about]);
|
||||
if (this.profile.nip05) tags.push(['nip05', this.profile.nip05]);
|
||||
if (this.profile.lud16) tags.push(['lud16', this.profile.lud16]);
|
||||
|
||||
// Add alt tag if not present
|
||||
if (!tags.some(t => t[0] === 'alt')) {
|
||||
tags.push(['alt', `User profile for ${this.profile.name || this.profile.display_name || 'user'}`]);
|
||||
}
|
||||
|
||||
// Always add client tag
|
||||
tags.push(['client', 'plebeian-signer']);
|
||||
|
||||
// Create the unsigned event
|
||||
const unsignedEvent = {
|
||||
kind: 0,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
// Sign the event
|
||||
const privkeyBytes = hexToBytes(this.#privkey);
|
||||
const signedEvent = finalizeEvent(unsignedEvent, privkeyBytes);
|
||||
|
||||
// Get write relays from NIP-65 or use fallback
|
||||
await this.#relayList.initialize();
|
||||
const writeRelays = await this.#relayList.fetchRelayList(this.#pubkey);
|
||||
let relayUrls: string[];
|
||||
|
||||
if (writeRelays.length > 0) {
|
||||
// Filter to write relays only
|
||||
relayUrls = writeRelays
|
||||
.filter(r => r.write)
|
||||
.map(r => r.url);
|
||||
|
||||
// If no write relays found, use all relays
|
||||
if (relayUrls.length === 0) {
|
||||
relayUrls = writeRelays.map(r => r.url);
|
||||
}
|
||||
} else {
|
||||
// Use fallback relays
|
||||
relayUrls = FALLBACK_PROFILE_RELAYS;
|
||||
}
|
||||
|
||||
// Publish to relays with NIP-42 authentication support
|
||||
const results = await publishToRelaysWithAuth(
|
||||
relayUrls,
|
||||
signedEvent,
|
||||
this.#privkey
|
||||
);
|
||||
|
||||
// Count successes
|
||||
const successes = results.filter(r => r.success);
|
||||
const failures = results.filter(r => !r.success);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('Some relays failed:', failures.map(f => `${f.relay}: ${f.message}`));
|
||||
}
|
||||
|
||||
if (successes.length === 0) {
|
||||
throw new Error('Failed to publish to any relay');
|
||||
}
|
||||
|
||||
console.log(`Profile published to ${successes.length}/${results.length} relays`);
|
||||
|
||||
// Clear cached profile and refetch
|
||||
await this.#profileMetadata.clearCacheForPubkey(this.#pubkey);
|
||||
await this.#profileMetadata.fetchProfile(this.#pubkey);
|
||||
|
||||
// Navigate back to identity page
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
} catch (error) {
|
||||
console.error('Failed to save profile:', error);
|
||||
this.alertMessage = error instanceof Error ? error.message : 'Failed to save profile';
|
||||
setTimeout(() => {
|
||||
this.alertMessage = undefined;
|
||||
}, 4500);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onClickCancel() {
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
<app-deriving-modal #derivingModal></app-deriving-modal>
|
||||
|
||||
<div class="sam-text-header">
|
||||
<span>Plebeian Signer</span>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, ViewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavComponent, StorageService } from '@common';
|
||||
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-new',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, DerivingModalComponent],
|
||||
templateUrl: './new.component.html',
|
||||
styleUrl: './new.component.scss',
|
||||
})
|
||||
export class NewComponent extends NavComponent {
|
||||
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
|
||||
|
||||
password = '';
|
||||
|
||||
readonly #router = inject(Router);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
toggleType(element: HTMLInputElement) {
|
||||
if (element.type === 'password') {
|
||||
@@ -28,7 +31,16 @@ export class NewComponent extends NavComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#storage.createNewVault(this.password);
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
// Show deriving modal during key derivation (~3-6 seconds)
|
||||
this.derivingModal.show('Creating secure vault');
|
||||
try {
|
||||
await this.#storage.createNewVault(this.password);
|
||||
this.derivingModal.hide();
|
||||
this.#logger.logVaultCreated();
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
} catch (error) {
|
||||
this.derivingModal.hide();
|
||||
console.error('Failed to create vault:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<app-deriving-modal #derivingModal></app-deriving-modal>
|
||||
|
||||
<div class="sam-text-header">
|
||||
<span class="brand">Plebeian Signer</span>
|
||||
</div>
|
||||
@@ -41,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 -->
|
||||
<!----------->
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
DerivingModalComponent,
|
||||
LoggerService,
|
||||
NostrHelper,
|
||||
ProfileMetadataService,
|
||||
StartupService,
|
||||
@@ -14,10 +16,11 @@ import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-se
|
||||
selector: 'app-vault-login',
|
||||
templateUrl: './vault-login.component.html',
|
||||
styleUrl: './vault-login.component.scss',
|
||||
imports: [FormsModule, ConfirmComponent],
|
||||
imports: [FormsModule, ConfirmComponent, DerivingModalComponent],
|
||||
})
|
||||
export class VaultLoginComponent implements AfterViewInit {
|
||||
@ViewChild('passwordInputElement') passwordInput!: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
|
||||
|
||||
loginPassword = '';
|
||||
showInvalidPasswordAlert = false;
|
||||
@@ -26,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();
|
||||
@@ -40,24 +44,39 @@ export class VaultLoginComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
async loginVault() {
|
||||
console.log('[login] loginVault called');
|
||||
if (!this.loginPassword) {
|
||||
console.log('[login] No password, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[login] Showing deriving modal');
|
||||
// Show deriving modal during key derivation (~3-6 seconds)
|
||||
this.derivingModal.show('Unlocking vault');
|
||||
|
||||
try {
|
||||
console.log('[login] Calling unlockVault...');
|
||||
await this.#storage.unlockVault(this.loginPassword);
|
||||
|
||||
// Fetch profile metadata for all identities in the background
|
||||
this.#fetchAllProfiles();
|
||||
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
console.log('[login] unlockVault succeeded!');
|
||||
} catch (error) {
|
||||
console.error('[login] unlockVault FAILED:', error);
|
||||
this.derivingModal.hide();
|
||||
this.showInvalidPasswordAlert = true;
|
||||
console.log(error);
|
||||
window.setTimeout(() => {
|
||||
this.showInvalidPasswordAlert = false;
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +105,7 @@ export class VaultLoginComponent implements AfterViewInit {
|
||||
|
||||
async onClickResetExtension() {
|
||||
try {
|
||||
this.#logger.logVaultReset();
|
||||
await this.#storage.resetExtension();
|
||||
this.#startup.startOver(getNewStorageServiceConfig());
|
||||
} catch (error) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<div class="custom-header">
|
||||
<button class="back-btn" (click)="onClickBack()">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<span class="text">Whitelisted Apps</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<button
|
||||
class="btn btn-primary whitelist-btn"
|
||||
(click)="onClickWhitelistCurrentTab()"
|
||||
>
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span>Whitelist current tab</span>
|
||||
</button>
|
||||
|
||||
<div class="hosts-list">
|
||||
@if (whitelistedHosts.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">No whitelisted apps yet</span>
|
||||
</div>
|
||||
@if (isRecklessMode) {
|
||||
<div class="warning-note">
|
||||
<span>⚠ All sites will be auto-approved without prompting</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@for (host of whitelistedHosts; track host) {
|
||||
<div class="host-item">
|
||||
<span class="host-name">{{ host }}</span>
|
||||
<button
|
||||
class="remove-btn"
|
||||
title="Remove from whitelist"
|
||||
(click)="onClickRemoveHost(host)"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<lib-toast #toast></lib-toast>
|
||||
<lib-confirm #confirm></lib-confirm>
|
||||
@@ -0,0 +1,134 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
.custom-header {
|
||||
padding: var(--size);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
background: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
.back-btn {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
justify-self: start;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05rem;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 var(--size) var(--size) var(--size);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.whitelist-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.hosts-list {
|
||||
flex-grow: 1;
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.warning-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border: 1px solid rgba(255, 193, 7, 0.4);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #ffc107;
|
||||
}
|
||||
}
|
||||
|
||||
.host-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.host-name {
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--destructive);
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject, ViewChild } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
NavComponent,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
} from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-whitelisted-apps',
|
||||
templateUrl: './whitelisted-apps.component.html',
|
||||
styleUrl: './whitelisted-apps.component.scss',
|
||||
imports: [ToastComponent, ConfirmComponent],
|
||||
})
|
||||
export class WhitelistedAppsComponent extends NavComponent {
|
||||
@ViewChild('toast') toast!: ToastComponent;
|
||||
@ViewChild('confirm') confirm!: ConfirmComponent;
|
||||
|
||||
override readonly storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
get whitelistedHosts(): string[] {
|
||||
return this.storage.getSignerMetaHandler().signerMetaData?.whitelistedHosts ?? [];
|
||||
}
|
||||
|
||||
get isRecklessMode(): boolean {
|
||||
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
|
||||
}
|
||||
|
||||
async onClickWhitelistCurrentTab() {
|
||||
try {
|
||||
// Get current active tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (tabs.length === 0 || !tabs[0].url) {
|
||||
this.toast.show('No active tab found');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(tabs[0].url);
|
||||
const host = url.host;
|
||||
|
||||
if (!host) {
|
||||
this.toast.show('Cannot get host from current tab');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already whitelisted
|
||||
if (this.whitelistedHosts.includes(host)) {
|
||||
this.toast.show(`${host} is already whitelisted`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.storage.getSignerMetaHandler().addWhitelistedHost(host);
|
||||
this.toast.show(`Added ${host} to whitelist`);
|
||||
} catch (error) {
|
||||
console.error('Error getting current tab:', error);
|
||||
this.toast.show('Error getting current tab');
|
||||
}
|
||||
}
|
||||
|
||||
onClickRemoveHost(host: string) {
|
||||
this.confirm.show(`Remove ${host} from whitelist?`, async () => {
|
||||
await this.storage.getSignerMetaHandler().removeWhitelistedHost(host);
|
||||
this.toast.show(`Removed ${host} from whitelist`);
|
||||
});
|
||||
}
|
||||
|
||||
onClickBack() {
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -48,6 +73,42 @@ export const getBrowserSessionData = async function (): Promise<
|
||||
return browserSessionData as BrowserSessionData;
|
||||
};
|
||||
|
||||
export const getSignerMetaData = async function (): Promise<SignerMetaData> {
|
||||
const signerMetaHandler = new ChromeMetaHandler();
|
||||
return (await signerMetaHandler.loadFullData()) as SignerMetaData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if reckless mode should auto-approve the request.
|
||||
* Returns true if should auto-approve, false if should use normal permission flow.
|
||||
*
|
||||
* Logic:
|
||||
* - If reckless mode is OFF → return false (use normal flow)
|
||||
* - If reckless mode is ON and whitelist is empty → return true (approve all)
|
||||
* - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const whitelistedHosts = signerMetaData.whitelistedHosts ?? [];
|
||||
|
||||
if (whitelistedHosts.length === 0) {
|
||||
// Reckless mode ON, no whitelist → approve all
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reckless mode ON, whitelist has entries → only approve if host is whitelisted
|
||||
return whitelistedHosts.includes(host);
|
||||
};
|
||||
|
||||
export const getBrowserSyncData = async function (): Promise<
|
||||
BrowserSyncData | undefined
|
||||
> {
|
||||
@@ -159,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
|
||||
) {
|
||||
@@ -172,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,
|
||||
};
|
||||
@@ -189,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([
|
||||
@@ -287,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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +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<
|
||||
@@ -28,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);
|
||||
|
||||
@@ -50,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.');
|
||||
}
|
||||
@@ -62,27 +218,206 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
throw new Error('No Nostr identity available at endpoint.');
|
||||
}
|
||||
|
||||
const req = request as BackgroundRequestMessage;
|
||||
const permissionState = checkPermissions(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
req.params
|
||||
);
|
||||
// Check reckless mode first
|
||||
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||
debug(`recklessApprove result: ${recklessApprove}`);
|
||||
if (recklessApprove) {
|
||||
debug('Request auto-approved via reckless mode.');
|
||||
} else {
|
||||
// Normal permission flow
|
||||
const permissionState = checkPermissions(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method as Nip07Method,
|
||||
req.params
|
||||
);
|
||||
debug(`permissionState result: ${permissionState}`);
|
||||
|
||||
if (permissionState === false) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
if (permissionState === undefined) {
|
||||
// Ask user for permission.
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
const { top, left } = await getPosition(width, height);
|
||||
|
||||
const base64Event = Buffer.from(
|
||||
JSON.stringify(req.params ?? {}, undefined, 2)
|
||||
).toString('base64');
|
||||
|
||||
const response = await new Promise<PromptResponse>((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
openPrompts.set(id, { resolve, reject });
|
||||
browser.windows.create({
|
||||
type: 'popup',
|
||||
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
|
||||
height,
|
||||
width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
});
|
||||
debug(response);
|
||||
if (response === 'approve' || response === 'reject') {
|
||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
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 {
|
||||
debug('Request allowed (via saved permission).');
|
||||
}
|
||||
}
|
||||
|
||||
const relays: Relays = {};
|
||||
let result: any;
|
||||
|
||||
switch (req.method) {
|
||||
case 'getPublicKey':
|
||||
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
|
||||
return result;
|
||||
|
||||
case 'signEvent':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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.
|
||||
// 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(req.params ?? {}, undefined, 2)
|
||||
JSON.stringify(promptParams, undefined, 2)
|
||||
).toString('base64');
|
||||
|
||||
const response = await new Promise<PromptResponse>((resolve, reject) => {
|
||||
@@ -90,75 +425,83 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
openPrompts.set(id, { resolve, reject });
|
||||
browser.windows.create({
|
||||
type: 'popup',
|
||||
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
|
||||
url: `prompt.html?method=${method}&host=${req.host}&id=${id}&nick=WebLN&event=${base64Event}`,
|
||||
height,
|
||||
width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
});
|
||||
|
||||
debug(response);
|
||||
if (response === 'approve' || response === 'reject') {
|
||||
|
||||
// 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,
|
||||
currentIdentity,
|
||||
null, // WebLN has no identity
|
||||
req.host,
|
||||
req.method,
|
||||
response === 'approve' ? 'allow' : 'deny',
|
||||
req.params?.kind
|
||||
method,
|
||||
policy
|
||||
);
|
||||
await backgroundLogPermissionStored(req.host, method, policy);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once'].includes(response)) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
} else {
|
||||
debug('Request allowed (via saved permission).');
|
||||
}
|
||||
|
||||
const relays: Relays = {};
|
||||
switch (req.method) {
|
||||
case 'getPublicKey':
|
||||
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
// Execute the WebLN method
|
||||
let result: any;
|
||||
const client = await getNwcClient(defaultConnection);
|
||||
|
||||
case 'signEvent':
|
||||
return signEvent(req.params, currentIdentity.privkey);
|
||||
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 'getRelays':
|
||||
browserSessionData.relays.forEach((x) => {
|
||||
relays[x.url] = { read: x.read, write: x.write };
|
||||
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,
|
||||
});
|
||||
return relays;
|
||||
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
|
||||
debug('webln.makeInvoice result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'nip04.encrypt':
|
||||
return await nip04Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
|
||||
case 'nip44.encrypt':
|
||||
return await nip44Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
|
||||
case 'nip04.decrypt':
|
||||
return await nip04Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
|
||||
case 'nip44.decrypt':
|
||||
return await nip44Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
case 'webln.keysend':
|
||||
throw new Error('keysend is not yet supported');
|
||||
|
||||
default:
|
||||
throw new Error(`Not supported request method '${req.method}'.`);
|
||||
throw new Error(`Not supported WebLN method '${method}'.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/* 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 and WebLN
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: any;
|
||||
webln?: any;
|
||||
}
|
||||
}
|
||||
|
||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
@@ -31,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) => {
|
||||
@@ -82,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:');
|
||||
@@ -151,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));
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ body {
|
||||
background: var(--background);
|
||||
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Button styling to match market
|
||||
@@ -101,3 +102,62 @@ button {
|
||||
border-color: var(--border);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
// Bootstrap modal overrides - always use dark theme for modals
|
||||
.modal-content {
|
||||
background-color: #1a1a1a;
|
||||
border-color: #3d3d3d;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom-color: #3d3d3d;
|
||||
|
||||
.modal-title {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top-color: #3d3d3d;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
106
projects/chrome/src/unlock.ts
Normal 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();
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
@if (visible) {
|
||||
<div class="deriving-overlay">
|
||||
<div class="deriving-modal">
|
||||
<div class="deriving-spinner"></div>
|
||||
<h3>{{ message }}</h3>
|
||||
<p class="deriving-note">This may take a few seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Modal always uses dark theme for visibility over any content
|
||||
.deriving-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.deriving-modal {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid #3d3d3d;
|
||||
|
||||
h3 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
color: #fafafa;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.deriving-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: #a1a1a1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.deriving-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #3d3d3d;
|
||||
border-top-color: #ff3eb5;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-deriving-modal',
|
||||
templateUrl: './deriving-modal.component.html',
|
||||
styleUrl: './deriving-modal.component.scss',
|
||||
})
|
||||
export class DerivingModalComponent {
|
||||
visible = false;
|
||||
message = 'Deriving encryption key';
|
||||
|
||||
/**
|
||||
* Show the deriving modal
|
||||
* @param message Optional custom message
|
||||
*/
|
||||
show(message?: string): void {
|
||||
if (message) {
|
||||
this.message = message;
|
||||
}
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the modal
|
||||
*/
|
||||
hide(): void {
|
||||
this.visible = false;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
1756
projects/common/src/lib/constants/event-kinds.ts
Normal file
150
projects/common/src/lib/helpers/argon2-crypto.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Secure vault encryption/decryption using Argon2id + AES-GCM
|
||||
*
|
||||
* - Argon2id key derivation with ~3 second computation time
|
||||
* - AES-256-GCM authenticated encryption
|
||||
* - Random 32-byte salt per vault
|
||||
* - Random 12-byte IV per encryption
|
||||
*
|
||||
* Note: Uses main thread for Argon2id (via WebAssembly) because Web Workers
|
||||
* in browser extensions cannot load external scripts due to CSP restrictions.
|
||||
* The deriving modal provides user feedback during the ~3 second derivation.
|
||||
*/
|
||||
|
||||
import { argon2id } from 'hash-wasm';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
// Argon2id parameters tuned for ~3 second derivation on typical hardware
|
||||
const ARGON2_CONFIG = {
|
||||
parallelism: 4, // 4 threads
|
||||
iterations: 8, // Time cost
|
||||
memorySize: 262144, // 256 MB memory
|
||||
hashLength: 32, // 256-bit key for AES-256
|
||||
outputType: 'binary' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive an encryption key from password using Argon2id
|
||||
* @param password - User's password
|
||||
* @param salt - Random 32-byte salt
|
||||
* @returns 32-byte derived key
|
||||
*/
|
||||
export async function deriveKeyArgon2(
|
||||
password: string,
|
||||
salt: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
// Use hash-wasm's argon2id (WebAssembly-based, runs on main thread)
|
||||
// This blocks the UI for ~3 seconds, which is why we show a modal
|
||||
const result = await argon2id({
|
||||
password: password,
|
||||
salt: salt,
|
||||
...ARGON2_CONFIG,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random salt for Argon2id
|
||||
* @returns Base64 encoded 32-byte salt
|
||||
*/
|
||||
export function generateSalt(): string {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||||
return Buffer.from(salt).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random IV for AES-GCM
|
||||
* @returns Base64 encoded 12-byte IV
|
||||
*/
|
||||
export function generateIV(): string {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
return Buffer.from(iv).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data using Argon2id-derived key + AES-256-GCM
|
||||
* @param plaintext - Data to encrypt
|
||||
* @param password - User's password
|
||||
* @param saltBase64 - Base64 encoded 32-byte salt
|
||||
* @param ivBase64 - Base64 encoded 12-byte IV
|
||||
* @returns Base64 encoded ciphertext
|
||||
*/
|
||||
export async function encryptWithArgon2(
|
||||
plaintext: string,
|
||||
password: string,
|
||||
saltBase64: string,
|
||||
ivBase64: string
|
||||
): Promise<string> {
|
||||
const salt = Buffer.from(saltBase64, 'base64');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
|
||||
// Derive key using Argon2id (~3 seconds, in worker)
|
||||
const keyBytes = await deriveKeyArgon2(password, salt);
|
||||
|
||||
// Import key for AES-GCM
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
// Encrypt the data
|
||||
const encoder = new TextEncoder();
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv },
|
||||
key,
|
||||
encoder.encode(plaintext)
|
||||
);
|
||||
|
||||
return Buffer.from(encrypted).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data using Argon2id-derived key + AES-256-GCM
|
||||
* @param ciphertextBase64 - Base64 encoded ciphertext
|
||||
* @param password - User's password
|
||||
* @param saltBase64 - Base64 encoded 32-byte salt
|
||||
* @param ivBase64 - Base64 encoded 12-byte IV
|
||||
* @returns Decrypted plaintext
|
||||
* @throws Error if password is wrong or data is corrupted
|
||||
*/
|
||||
export async function decryptWithArgon2(
|
||||
ciphertextBase64: string,
|
||||
password: string,
|
||||
saltBase64: string,
|
||||
ivBase64: string
|
||||
): Promise<string> {
|
||||
const salt = Buffer.from(saltBase64, 'base64');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const ciphertext = Buffer.from(ciphertextBase64, 'base64');
|
||||
|
||||
// Derive key using Argon2id (~3 seconds, in worker)
|
||||
const keyBytes = await deriveKeyArgon2(password, salt);
|
||||
|
||||
// Import key for AES-GCM
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decrypt
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Decryption failed - invalid password or corrupted data');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
}
|
||||
|
||||
127
projects/common/src/lib/helpers/nip05-validator.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* NIP-05 Verification Helper
|
||||
*
|
||||
* Directly validates NIP-05 identifiers by fetching the .well-known/nostr.json
|
||||
* file and comparing the pubkey.
|
||||
*/
|
||||
|
||||
export interface Nip05ValidationResult {
|
||||
valid: boolean;
|
||||
pubkey?: string;
|
||||
relays?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a NIP-05 identifier into its components
|
||||
* @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev" or "_@mleku.dev")
|
||||
* @returns Object with name and domain, or null if invalid
|
||||
*/
|
||||
export function parseNip05(nip05: string): { name: string; domain: string } | null {
|
||||
if (!nip05 || typeof nip05 !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = nip05.toLowerCase().trim().split('@');
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [name, domain] = parts;
|
||||
if (!name || !domain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Basic domain validation
|
||||
if (!domain.includes('.') || domain.includes('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, domain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a NIP-05 identifier against a pubkey
|
||||
*
|
||||
* @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev")
|
||||
* @param expectedPubkey - The expected pubkey in hex format
|
||||
* @param timeoutMs - Fetch timeout in milliseconds
|
||||
* @returns Validation result with status and any discovered relays
|
||||
*/
|
||||
export async function validateNip05(
|
||||
nip05: string,
|
||||
expectedPubkey: string,
|
||||
timeoutMs = 10000
|
||||
): Promise<Nip05ValidationResult> {
|
||||
const parsed = parseNip05(nip05);
|
||||
if (!parsed) {
|
||||
return { valid: false, error: 'Invalid NIP-05 format' };
|
||||
}
|
||||
|
||||
const { name, domain } = parsed;
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if the names object exists and contains the requested name
|
||||
if (!data.names || typeof data.names !== 'object') {
|
||||
return { valid: false, error: 'Invalid nostr.json structure: missing names' };
|
||||
}
|
||||
|
||||
// NIP-05 names are case-insensitive
|
||||
const pubkeyFromJson = data.names[name] || data.names[name.toLowerCase()];
|
||||
|
||||
if (!pubkeyFromJson) {
|
||||
return { valid: false, error: `Name "${name}" not found in nostr.json` };
|
||||
}
|
||||
|
||||
// Compare pubkeys (case-insensitive hex comparison)
|
||||
const normalizedExpected = expectedPubkey.toLowerCase();
|
||||
const normalizedFound = pubkeyFromJson.toLowerCase();
|
||||
const valid = normalizedExpected === normalizedFound;
|
||||
|
||||
// Extract relays if present
|
||||
let relays: string[] | undefined;
|
||||
if (data.relays && typeof data.relays === 'object') {
|
||||
const relayList = data.relays[pubkeyFromJson] || data.relays[normalizedFound];
|
||||
if (Array.isArray(relayList)) {
|
||||
relays = relayList;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid,
|
||||
pubkey: pubkeyFromJson,
|
||||
relays,
|
||||
error: valid ? undefined : 'Pubkey mismatch',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return { valid: false, error: 'Request timeout' };
|
||||
}
|
||||
return { valid: false, error: error.message };
|
||||
}
|
||||
return { valid: false, error: 'Unknown error' };
|
||||
}
|
||||
}
|
||||
324
projects/common/src/lib/helpers/websocket-auth.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* NIP-42 Relay Authentication
|
||||
*
|
||||
* Handles WebSocket connections to relays that require authentication.
|
||||
* When a relay sends an AUTH challenge, this module signs the challenge
|
||||
* and authenticates before proceeding with event publishing.
|
||||
*/
|
||||
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools';
|
||||
|
||||
export interface AuthenticatedRelayConnection {
|
||||
ws: WebSocket;
|
||||
url: string;
|
||||
authenticated: boolean;
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
relay: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-42 authentication event (kind 22242)
|
||||
*/
|
||||
function createAuthEvent(
|
||||
relayUrl: string,
|
||||
challenge: string,
|
||||
privateKeyHex: string
|
||||
): ReturnType<typeof finalizeEvent> {
|
||||
const unsignedEvent = {
|
||||
kind: 22242,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['relay', relayUrl],
|
||||
['challenge', challenge],
|
||||
],
|
||||
content: '',
|
||||
};
|
||||
|
||||
// Convert hex private key to Uint8Array
|
||||
const privkeyBytes = hexToBytes(privateKeyHex);
|
||||
return finalizeEvent(unsignedEvent, privkeyBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array
|
||||
*/
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a relay with NIP-42 authentication support
|
||||
*
|
||||
* @param relayUrl - The relay WebSocket URL (e.g., wss://relay.example.com)
|
||||
* @param privateKeyHex - The private key in hex format for signing
|
||||
* @param timeoutMs - Connection and authentication timeout in milliseconds
|
||||
* @returns Promise resolving to authenticated connection or null if failed
|
||||
*/
|
||||
export async function connectWithAuth(
|
||||
relayUrl: string,
|
||||
privateKeyHex: string,
|
||||
timeoutMs = 10000
|
||||
): Promise<AuthenticatedRelayConnection | null> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
|
||||
const ws = new WebSocket(relayUrl);
|
||||
const pubkey = getPublicKey(hexToBytes(privateKeyHex));
|
||||
|
||||
ws.onopen = () => {
|
||||
// Connection open, wait for AUTH challenge or proceed directly
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
const messageType = message[0];
|
||||
|
||||
if (messageType === 'AUTH') {
|
||||
// Relay sent an auth challenge
|
||||
const challenge = message[1];
|
||||
const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
|
||||
|
||||
// Send AUTH response
|
||||
ws.send(JSON.stringify(['AUTH', authEvent]));
|
||||
} else if (messageType === 'OK') {
|
||||
// Check if this is the AUTH response
|
||||
const success = message[2];
|
||||
const msg = message[3] || '';
|
||||
|
||||
if (success) {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ws,
|
||||
url: relayUrl,
|
||||
authenticated: true,
|
||||
pubkey,
|
||||
});
|
||||
} else {
|
||||
console.error(`Auth failed for ${relayUrl}: ${msg}`);
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
resolve(null);
|
||||
}
|
||||
} else if (messageType === 'NOTICE') {
|
||||
// Some relays don't require auth - connection is ready
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ws,
|
||||
url: relayUrl,
|
||||
authenticated: false,
|
||||
pubkey,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
// For relays that don't send AUTH challenge, resolve after short delay
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ws,
|
||||
url: relayUrl,
|
||||
authenticated: false, // No auth was required
|
||||
pubkey,
|
||||
});
|
||||
}
|
||||
}, 2000); // Wait 2 seconds for potential AUTH challenge
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event to a relay with NIP-42 authentication support
|
||||
*
|
||||
* This function handles the complete flow:
|
||||
* 1. Connect to relay
|
||||
* 2. Handle AUTH challenge if sent
|
||||
* 3. Publish the event
|
||||
* 4. Wait for OK response
|
||||
* 5. Close connection
|
||||
*
|
||||
* @param relayUrl - The relay WebSocket URL
|
||||
* @param signedEvent - The already-signed Nostr event to publish
|
||||
* @param privateKeyHex - Private key for AUTH (if required)
|
||||
* @param timeoutMs - Timeout for the entire operation
|
||||
* @returns Promise resolving to publish result
|
||||
*/
|
||||
export async function publishEventWithAuth(
|
||||
relayUrl: string,
|
||||
signedEvent: ReturnType<typeof finalizeEvent>,
|
||||
privateKeyHex: string,
|
||||
timeoutMs = 15000
|
||||
): Promise<PublishResult> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: 'Timeout',
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
let ws: WebSocket;
|
||||
let authenticated = false;
|
||||
let eventSent = false;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(relayUrl);
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: `Connection failed: ${e}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sendEvent = () => {
|
||||
if (!eventSent && ws.readyState === WebSocket.OPEN) {
|
||||
eventSent = true;
|
||||
ws.send(JSON.stringify(['EVENT', signedEvent]));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
// Wait a moment for potential AUTH challenge before sending event
|
||||
setTimeout(() => {
|
||||
if (!authenticated) {
|
||||
// No auth challenge received, try sending event directly
|
||||
sendEvent();
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
const messageType = message[0];
|
||||
|
||||
if (messageType === 'AUTH') {
|
||||
// Relay requires authentication
|
||||
const challenge = message[1];
|
||||
const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
|
||||
ws.send(JSON.stringify(['AUTH', authEvent]));
|
||||
authenticated = true;
|
||||
} else if (messageType === 'OK') {
|
||||
const eventId = message[1];
|
||||
const success = message[2];
|
||||
const msg = message[3] || '';
|
||||
|
||||
// Check if this is our event or AUTH response
|
||||
if (eventId === signedEvent.id) {
|
||||
// This is the response to our published event
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
|
||||
if (success) {
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: true,
|
||||
message: 'Published successfully',
|
||||
});
|
||||
} else {
|
||||
// Check if we need to retry after auth
|
||||
if (msg.includes('auth-required') && !authenticated) {
|
||||
// Relay requires auth but didn't send challenge
|
||||
// This shouldn't normally happen
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: 'Auth required but no challenge received',
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: msg || 'Publish rejected',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (authenticated && !eventSent) {
|
||||
// This is the OK response to our AUTH
|
||||
if (success) {
|
||||
// Auth succeeded, now send the event
|
||||
sendEvent();
|
||||
} else {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: `Authentication failed: ${msg}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (messageType === 'NOTICE') {
|
||||
// Log notices but don't fail
|
||||
console.log(`Relay ${relayUrl} notice: ${message[1]}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: 'Connection error',
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// If we haven't resolved yet, treat as failure
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event to multiple relays with NIP-42 support
|
||||
*
|
||||
* @param relayUrls - Array of relay WebSocket URLs
|
||||
* @param signedEvent - The already-signed Nostr event to publish
|
||||
* @param privateKeyHex - Private key for AUTH (if required)
|
||||
* @returns Promise resolving to array of publish results
|
||||
*/
|
||||
export async function publishToRelaysWithAuth(
|
||||
relayUrls: string[],
|
||||
signedEvent: ReturnType<typeof finalizeEvent>,
|
||||
privateKeyHex: string
|
||||
): Promise<PublishResult[]> {
|
||||
const results = await Promise.all(
|
||||
relayUrls.map((url) => publishEventWithAuth(url, signedEvent, privateKeyHex))
|
||||
);
|
||||
return results;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
41
projects/common/src/lib/models/webln.ts
Normal 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;
|
||||
}
|
||||
450
projects/common/src/lib/services/cashu/cashu.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
71
projects/common/src/lib/services/cashu/types.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
541
projects/common/src/lib/services/nwc/nwc-client.ts
Normal 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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
416
projects/common/src/lib/services/nwc/nwc.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
130
projects/common/src/lib/services/nwc/types.ts
Normal 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];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
361
projects/common/src/lib/services/storage/related/cashu.ts
Normal 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;
|
||||
};
|
||||
@@ -166,10 +166,19 @@ export const encryptIdentity = async function (
|
||||
return encryptedIdentity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Locked vault context for decryption during unlock
|
||||
* - v1 vaults use password (PBKDF2)
|
||||
* - v2 vaults use keyBase64 (pre-derived Argon2id key)
|
||||
*/
|
||||
export type LockedVaultContext =
|
||||
| { iv: string; password: string; keyBase64?: undefined }
|
||||
| { iv: string; keyBase64: string; password?: undefined };
|
||||
|
||||
export const decryptIdentities = async function (
|
||||
this: StorageService,
|
||||
identities: Identity_ENCRYPTED[],
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Identity_DECRYPTED[]> {
|
||||
const decryptedIdentities: Identity_DECRYPTED[] = [];
|
||||
|
||||
@@ -188,7 +197,7 @@ export const decryptIdentities = async function (
|
||||
export const decryptIdentity = async function (
|
||||
this: StorageService,
|
||||
identity: Identity_ENCRYPTED,
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Identity_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
const decryptedIdentity: Identity_DECRYPTED = {
|
||||
@@ -201,30 +210,62 @@ export const decryptIdentity = async function (
|
||||
return decryptedIdentity;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decryptedIdentity: Identity_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
identity.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
nick: await this.decryptWithLockedVaultV2(
|
||||
identity.nick,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVaultV2(
|
||||
identity.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
privkey: await this.decryptWithLockedVaultV2(
|
||||
identity.privkey,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
return decryptedIdentity;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decryptedIdentity: Identity_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
identity.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
nick: await this.decryptWithLockedVault(
|
||||
identity.nick,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVault(
|
||||
identity.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
privkey: await this.decryptWithLockedVault(
|
||||
identity.privkey,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
419
projects/common/src/lib/services/storage/related/nwc.ts
Normal 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;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Permission_ENCRYPTED,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { LockedVaultContext } from './identity';
|
||||
|
||||
export const deletePermission = async function (
|
||||
this: StorageService,
|
||||
@@ -32,7 +33,7 @@ export const deletePermission = async function (
|
||||
export const decryptPermission = async function (
|
||||
this: StorageService,
|
||||
permission: Permission_ENCRYPTED,
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Permission_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
const decryptedPermission: Permission_DECRYPTED = {
|
||||
@@ -48,36 +49,82 @@ export const decryptPermission = async function (
|
||||
return decryptedPermission;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decryptedPermission: Permission_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
permission.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
identityId: await this.decryptWithLockedVaultV2(
|
||||
permission.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
method: await this.decryptWithLockedVaultV2(
|
||||
permission.method,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
methodPolicy: await this.decryptWithLockedVaultV2(
|
||||
permission.methodPolicy,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
host: await this.decryptWithLockedVaultV2(
|
||||
permission.host,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
if (permission.kind) {
|
||||
decryptedPermission.kind = await this.decryptWithLockedVaultV2(
|
||||
permission.kind,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
);
|
||||
}
|
||||
return decryptedPermission;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decryptedPermission: Permission_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
permission.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
identityId: await this.decryptWithLockedVault(
|
||||
permission.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
method: await this.decryptWithLockedVault(
|
||||
permission.method,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
methodPolicy: await this.decryptWithLockedVault(
|
||||
permission.methodPolicy,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
host: await this.decryptWithLockedVault(
|
||||
permission.host,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
if (permission.kind) {
|
||||
@@ -85,7 +132,7 @@ export const decryptPermission = async function (
|
||||
permission.kind,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
);
|
||||
}
|
||||
return decryptedPermission;
|
||||
@@ -94,17 +141,22 @@ export const decryptPermission = async function (
|
||||
export const decryptPermissions = async function (
|
||||
this: StorageService,
|
||||
permissions: Permission_ENCRYPTED[],
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Permission_DECRYPTED[]> {
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Relay_ENCRYPTED,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { LockedVaultContext } from './identity';
|
||||
|
||||
export const addRelay = async function (
|
||||
this: StorageService,
|
||||
@@ -126,7 +127,7 @@ export const updateRelay = async function (
|
||||
export const decryptRelay = async function (
|
||||
this: StorageService,
|
||||
relay: Relay_ENCRYPTED,
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Relay_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
const decryptedRelay: Relay_DECRYPTED = {
|
||||
@@ -139,36 +140,74 @@ export const decryptRelay = async function (
|
||||
return decryptedRelay;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decryptedRelay: Relay_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
relay.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
identityId: await this.decryptWithLockedVaultV2(
|
||||
relay.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
url: await this.decryptWithLockedVaultV2(
|
||||
relay.url,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
read: await this.decryptWithLockedVaultV2(
|
||||
relay.read,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
write: await this.decryptWithLockedVaultV2(
|
||||
relay.write,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
return decryptedRelay;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decryptedRelay: Relay_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
relay.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
identityId: await this.decryptWithLockedVault(
|
||||
relay.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
url: await this.decryptWithLockedVault(
|
||||
relay.url,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
read: await this.decryptWithLockedVault(
|
||||
relay.read,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
write: await this.decryptWithLockedVault(
|
||||
relay.write,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
return decryptedRelay;
|
||||
@@ -177,7 +216,7 @@ export const decryptRelay = async function (
|
||||
export const decryptRelays = async function (
|
||||
this: StorageService,
|
||||
relays: Relay_ENCRYPTED[],
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Relay_DECRYPTED[]> {
|
||||
const decryptedRelays: Relay_DECRYPTED[] = [];
|
||||
|
||||
|
||||