Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a2bc4fe72 | ||
| a2e47d8612 | |||
| 2074c409f0 | |||
|
|
c11887dfa8 | ||
|
|
d98a0ef76e | ||
|
|
87d76bb4a8 | ||
|
|
57434681f9 | ||
|
|
586e2ab23f | ||
|
|
5ca6eb177c | ||
|
|
ebc96e7201 | ||
|
|
1f8d478cd7 |
690
DDD_ANALYSIS.md
Normal file
690
DDD_ANALYSIS.md
Normal file
@@ -0,0 +1,690 @@
|
||||
# Domain-Driven Design Analysis: Plebeian Signer
|
||||
|
||||
This document analyzes the Plebeian Signer codebase through the lens of Domain-Driven Design (DDD) principles, identifying bounded contexts, current patterns, anti-patterns, and providing actionable recommendations for improvement.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Plebeian Signer is a browser extension for Nostr identity management implementing NIP-07. The codebase has **good structural foundations** (monorepo with shared library, handler abstraction pattern) but suffers from several DDD anti-patterns:
|
||||
|
||||
- **God Service**: `StorageService` handles too many responsibilities
|
||||
- **Anemic Domain Models**: Types are data containers without behavior
|
||||
- **Mixed Concerns**: Encryption logic interleaved with domain operations
|
||||
- **Weak Ubiquitous Language**: Generic naming (`BrowserSyncData`) obscures domain concepts
|
||||
|
||||
**Priority Recommendations:**
|
||||
1. Extract domain aggregates with behavior (Identity, Vault, Wallet)
|
||||
2. Separate encryption into an infrastructure layer
|
||||
3. Introduce repository pattern for each aggregate
|
||||
4. Rename types to reflect ubiquitous language
|
||||
|
||||
---
|
||||
|
||||
## Domain Overview
|
||||
|
||||
### Core Domain Problem
|
||||
|
||||
> Enable users to manage multiple Nostr identities securely, sign events without exposing private keys to web applications, and interact with Lightning/Cashu wallets.
|
||||
|
||||
### Subdomain Classification
|
||||
|
||||
| Subdomain | Type | Rationale |
|
||||
|-----------|------|-----------|
|
||||
| **Identity & Signing** | Core | The differentiator - secure key management and NIP-07 implementation |
|
||||
| **Permission Management** | Core | Critical security layer - controls what apps can do |
|
||||
| **Vault Encryption** | Supporting | Necessary security but standard cryptographic patterns |
|
||||
| **Wallet Integration** | Supporting | Extends functionality but not the core value proposition |
|
||||
| **Profile Caching** | Generic | Standard caching pattern, could use any solution |
|
||||
| **Relay Management** | Supporting | Per-identity configuration, fairly standard |
|
||||
|
||||
---
|
||||
|
||||
## Bounded Contexts
|
||||
|
||||
### Identified Contexts
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CONTEXT MAP │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ Shared Kernel ┌──────────────────┐ │
|
||||
│ │ Vault Context │◄─────────(crypto)──────────►│ Identity Context │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - VaultState │ │ - Identity │ │
|
||||
│ │ - Encryption │ │ - KeyPair │ │
|
||||
│ │ - Migration │ │ - Signing │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ │ Customer/Supplier │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Permission Ctx │ │ Wallet Context │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - Policy │ │ - NWC │ │
|
||||
│ │ - Host Rules │ │ - Cashu │ │
|
||||
│ │ - Method Auth │ │ - Lightning │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Relay Context │◄──── Conformist ────────────►│ Profile Context │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - Per-identity │ │ - Kind 0 cache │ │
|
||||
│ │ - Read/Write │ │ - Metadata │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ Legend: ◄──► Bidirectional, ──► Supplier direction │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Context Definitions
|
||||
|
||||
#### 1. Vault Context
|
||||
**Responsibility:** Secure storage lifecycle - creation, locking, unlocking, encryption, migration.
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/storage/related/vault.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- VaultState (locked/unlocked)
|
||||
- EncryptionKey (Argon2id-derived)
|
||||
- VaultVersion (migration support)
|
||||
- Salt, IV (cryptographic parameters)
|
||||
|
||||
**Language:**
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Vault | The encrypted container holding all sensitive data |
|
||||
| Unlock | Derive key from password and decrypt vault contents |
|
||||
| Lock | Clear session data, requiring password to access again |
|
||||
| Migration | Upgrade vault encryption scheme (v1→v2) |
|
||||
|
||||
#### 2. Identity Context
|
||||
**Responsibility:** Nostr identity lifecycle and cryptographic operations.
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/storage/related/identity.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- Identity (aggregates pubkey, privkey, nick)
|
||||
- KeyPair (hex or nsec/npub representations)
|
||||
- SelectedIdentity (current active identity)
|
||||
- EventSigning (NIP-07 signEvent)
|
||||
|
||||
**Language:**
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Identity | A Nostr keypair with a user-defined nickname |
|
||||
| Selected Identity | The currently active identity for signing |
|
||||
| Sign | Create schnorr signature for a Nostr event |
|
||||
| Switch | Change the active identity |
|
||||
|
||||
#### 3. Permission Context
|
||||
**Responsibility:** Authorization decisions for NIP-07 method calls.
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/storage/related/permission.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- PermissionPolicy (allow/deny)
|
||||
- MethodPermission (per NIP-07 method)
|
||||
- KindPermission (signEvent kind filtering)
|
||||
- HostWhitelist (trusted domains)
|
||||
- RecklessMode (auto-approve all)
|
||||
|
||||
**Language:**
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Permission | A stored allow/deny decision for identity+host+method |
|
||||
| Reckless Mode | Global setting to auto-approve all requests |
|
||||
| Whitelist | Hosts that auto-approve without prompting |
|
||||
| Prompt | UI asking user to authorize a request |
|
||||
|
||||
#### 4. Wallet Context
|
||||
**Responsibility:** Lightning and Cashu wallet operations.
|
||||
|
||||
**Current Location:**
|
||||
- `projects/common/src/lib/services/nwc/`
|
||||
- `projects/common/src/lib/services/cashu/`
|
||||
- `projects/common/src/lib/services/storage/related/nwc.ts`
|
||||
- `projects/common/src/lib/services/storage/related/cashu.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- NwcConnection (NIP-47 wallet connect)
|
||||
- CashuMint (ecash mint connection)
|
||||
- CashuProof (unspent tokens)
|
||||
- LightningInvoice, Keysend
|
||||
|
||||
#### 5. Relay Context
|
||||
**Responsibility:** Per-identity relay configuration.
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/storage/related/relay.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- RelayConfiguration (URL + read/write permissions)
|
||||
- IdentityRelays (relays scoped to an identity)
|
||||
|
||||
#### 6. Profile Context
|
||||
**Responsibility:** Caching Nostr profile metadata (kind 0 events).
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/profile-metadata/`
|
||||
|
||||
**Key Concepts:**
|
||||
- ProfileMetadata (name, picture, nip05, etc.)
|
||||
- MetadataCache (fetchedAt timestamp)
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### What's Working Well
|
||||
|
||||
1. **Monorepo Structure**
|
||||
- Clean separation: `projects/common`, `projects/chrome`, `projects/firefox`
|
||||
- Shared library via `@common` alias
|
||||
- Browser-specific implementations isolated
|
||||
|
||||
2. **Handler Abstraction (Adapter Pattern)**
|
||||
```
|
||||
StorageService
|
||||
├→ BrowserSessionHandler (abstract → ChromeSessionHandler, FirefoxSessionHandler)
|
||||
├→ BrowserSyncHandler (abstract → ChromeSyncYesHandler, ChromeSyncNoHandler, ...)
|
||||
└→ SignerMetaHandler (abstract → ChromeMetaHandler, FirefoxMetaHandler)
|
||||
```
|
||||
This enables pluggable browser implementations - good DDD practice.
|
||||
|
||||
3. **Encrypted/Decrypted Type Pairs**
|
||||
- `Identity_DECRYPTED` / `Identity_ENCRYPTED`
|
||||
- Clear distinction between storage states
|
||||
|
||||
4. **Vault Versioning**
|
||||
- Migration path from v1 (PBKDF2) to v2 (Argon2id)
|
||||
- Automatic upgrade on unlock
|
||||
|
||||
5. **Cascade Deletes**
|
||||
- Deleting an identity removes associated permissions and relays
|
||||
- Maintains referential integrity
|
||||
|
||||
### Anti-Patterns Identified
|
||||
|
||||
#### 1. God Service (`StorageService`)
|
||||
|
||||
**Location:** `projects/common/src/lib/services/storage/storage.service.ts`
|
||||
|
||||
**Problem:** Single service handles:
|
||||
- Vault lifecycle (create, unlock, delete, migrate)
|
||||
- Identity CRUD (add, delete, switch)
|
||||
- Permission management
|
||||
- Relay configuration
|
||||
- NWC wallet connections
|
||||
- Cashu mint management
|
||||
- Encryption/decryption orchestration
|
||||
|
||||
**Symptoms:**
|
||||
- 500+ lines when including bound methods
|
||||
- Methods dynamically attached via functional composition
|
||||
- Implicit dependencies between operations
|
||||
- Difficult to test in isolation
|
||||
|
||||
**DDD Violation:** Violates single responsibility; should be split into aggregate-specific repositories.
|
||||
|
||||
#### 2. Anemic Domain Models
|
||||
|
||||
**Location:** `projects/common/src/lib/services/storage/types.ts`
|
||||
|
||||
**Problem:** All domain types are pure data containers:
|
||||
|
||||
```typescript
|
||||
// Current: Anemic model
|
||||
interface Identity_DECRYPTED {
|
||||
id: string;
|
||||
nick: string;
|
||||
privkey: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// All behavior lives in external functions:
|
||||
// - addIdentity() in identity.ts
|
||||
// - switchIdentity() in identity.ts
|
||||
// - encryptIdentity() in identity.ts
|
||||
```
|
||||
|
||||
**Should Be:**
|
||||
```typescript
|
||||
// Rich domain model
|
||||
class Identity {
|
||||
private constructor(
|
||||
private readonly _id: IdentityId,
|
||||
private _nick: Nickname,
|
||||
private readonly _keyPair: NostrKeyPair,
|
||||
private readonly _createdAt: Date
|
||||
) {}
|
||||
|
||||
static create(nick: string, privateKey?: string): Identity { /* ... */ }
|
||||
|
||||
get publicKey(): string { return this._keyPair.publicKey; }
|
||||
|
||||
sign(event: UnsignedEvent): SignedEvent {
|
||||
return this._keyPair.sign(event);
|
||||
}
|
||||
|
||||
rename(newNick: string): void {
|
||||
this._nick = Nickname.create(newNick);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Mixed Encryption Concerns
|
||||
|
||||
**Problem:** Domain operations and encryption logic are interleaved:
|
||||
|
||||
```typescript
|
||||
// In identity.ts
|
||||
export async function addIdentity(this: StorageService, data: {...}) {
|
||||
// Domain logic
|
||||
const identity_decrypted: Identity_DECRYPTED = {
|
||||
id: uuid(),
|
||||
nick: data.nick,
|
||||
privkey: data.privkeyString,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Encryption concern mixed in
|
||||
const identity_encrypted = await encryptIdentity.call(this, identity_decrypted);
|
||||
|
||||
// Storage concern
|
||||
await this.#browserSyncHandler.addIdentity(identity_encrypted);
|
||||
this.#browserSessionHandler.addIdentity(identity_decrypted);
|
||||
}
|
||||
```
|
||||
|
||||
**Should Be:** Encryption as infrastructure layer, repositories handle persistence:
|
||||
|
||||
```typescript
|
||||
class IdentityRepository {
|
||||
async save(identity: Identity): Promise<void> {
|
||||
const encrypted = this.encryptionService.encrypt(identity.toSnapshot());
|
||||
await this.syncHandler.save(encrypted);
|
||||
this.sessionHandler.cache(identity);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Weak Ubiquitous Language
|
||||
|
||||
**Problem:** Type names reflect technical storage, not domain concepts:
|
||||
|
||||
| Current Name | Domain Concept |
|
||||
|--------------|----------------|
|
||||
| `BrowserSyncData` | `EncryptedVault` |
|
||||
| `BrowserSessionData` | `UnlockedVaultState` |
|
||||
| `SignerMetaData` | `ExtensionSettings` |
|
||||
| `Identity_DECRYPTED` | `Identity` |
|
||||
| `Identity_ENCRYPTED` | `EncryptedIdentity` |
|
||||
|
||||
#### 5. Implicit Aggregate Boundaries
|
||||
|
||||
**Problem:** No clear aggregate roots. External code can manipulate any data:
|
||||
|
||||
```typescript
|
||||
// Anyone can reach into session data
|
||||
const identity = this.#browserSessionHandler.getIdentity(id);
|
||||
identity.nick = "changed"; // No invariant protection!
|
||||
```
|
||||
|
||||
**Should Have:** Aggregate roots as single entry points with invariant protection.
|
||||
|
||||
#### 6. TypeScript Union Type Issues
|
||||
|
||||
**Problem:** `LockedVaultContext` uses optional fields instead of discriminated unions:
|
||||
|
||||
```typescript
|
||||
// Current: Confusing optional fields
|
||||
type LockedVaultContext =
|
||||
| { iv: string; password: string; keyBase64?: undefined }
|
||||
| { iv: string; keyBase64: string; password?: undefined };
|
||||
|
||||
// Better: Discriminated union
|
||||
type LockedVaultContext =
|
||||
| { version: 1; iv: string; password: string }
|
||||
| { version: 2; iv: string; keyBase64: string };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Domain Model
|
||||
|
||||
### Aggregate Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AGGREGATE MAP │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Vault Aggregate (Root: Vault) │ │
|
||||
│ │ │ │
|
||||
│ │ Vault ──────┬──► Identity[] (child entities) │ │
|
||||
│ │ ├──► Permission[] (child entities) │ │
|
||||
│ │ ├──► Relay[] (child entities) │ │
|
||||
│ │ ├──► NwcConnection[] (child entities) │ │
|
||||
│ │ └──► CashuMint[] (child entities) │ │
|
||||
│ │ │ │
|
||||
│ │ Invariants: │ │
|
||||
│ │ - At most one identity can be selected │ │
|
||||
│ │ - Permissions must reference existing identities │ │
|
||||
│ │ - Relays must reference existing identities │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ExtensionSettings Aggregate (Root: ExtensionSettings) │ │
|
||||
│ │ │ │
|
||||
│ │ ExtensionSettings ──┬──► SyncPreference │ │
|
||||
│ │ ├──► SecurityPolicy (reckless, whitelist)│ │
|
||||
│ │ ├──► Bookmark[] │ │
|
||||
│ │ └──► VaultSnapshot[] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ProfileCache Aggregate (Root: ProfileCache) │ │
|
||||
│ │ │ │
|
||||
│ │ ProfileCache ──► ProfileMetadata[] │ │
|
||||
│ │ │ │
|
||||
│ │ Invariants: │ │
|
||||
│ │ - Entries expire after TTL │ │
|
||||
│ │ - One entry per pubkey │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Value Objects
|
||||
|
||||
```typescript
|
||||
// Strongly-typed identity
|
||||
class IdentityId {
|
||||
private constructor(private readonly value: string) {}
|
||||
static generate(): IdentityId { return new IdentityId(uuid()); }
|
||||
static from(value: string): IdentityId { return new IdentityId(value); }
|
||||
equals(other: IdentityId): boolean { return this.value === other.value; }
|
||||
toString(): string { return this.value; }
|
||||
}
|
||||
|
||||
// Self-validating nickname
|
||||
class Nickname {
|
||||
private constructor(private readonly value: string) {}
|
||||
static create(value: string): Nickname {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new InvalidNicknameError(value);
|
||||
}
|
||||
return new Nickname(value.trim());
|
||||
}
|
||||
toString(): string { return this.value; }
|
||||
}
|
||||
|
||||
// Nostr key pair encapsulation
|
||||
class NostrKeyPair {
|
||||
private constructor(
|
||||
private readonly privateKeyHex: string,
|
||||
private readonly publicKeyHex: string
|
||||
) {}
|
||||
|
||||
static fromPrivateKey(privkey: string): NostrKeyPair {
|
||||
const hex = privkey.startsWith('nsec')
|
||||
? NostrHelper.nsecToHex(privkey)
|
||||
: privkey;
|
||||
const pubkey = NostrHelper.pubkeyFromPrivkey(hex);
|
||||
return new NostrKeyPair(hex, pubkey);
|
||||
}
|
||||
|
||||
get publicKey(): string { return this.publicKeyHex; }
|
||||
get npub(): string { return NostrHelper.pubkey2npub(this.publicKeyHex); }
|
||||
|
||||
sign(event: UnsignedEvent): SignedEvent {
|
||||
return NostrHelper.signEvent(event, this.privateKeyHex);
|
||||
}
|
||||
|
||||
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
|
||||
return version === 4
|
||||
? NostrHelper.nip04Encrypt(plaintext, this.privateKeyHex, recipientPubkey)
|
||||
: NostrHelper.nip44Encrypt(plaintext, this.privateKeyHex, recipientPubkey);
|
||||
}
|
||||
}
|
||||
|
||||
// Permission policy
|
||||
class PermissionPolicy {
|
||||
private constructor(
|
||||
private readonly identityId: IdentityId,
|
||||
private readonly host: string,
|
||||
private readonly method: Nip07Method,
|
||||
private readonly decision: 'allow' | 'deny',
|
||||
private readonly kind?: number
|
||||
) {}
|
||||
|
||||
static allow(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
|
||||
return new PermissionPolicy(identityId, host, method, 'allow', kind);
|
||||
}
|
||||
|
||||
static deny(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
|
||||
return new PermissionPolicy(identityId, host, method, 'deny', kind);
|
||||
}
|
||||
|
||||
matches(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): boolean {
|
||||
return this.identityId.equals(identityId)
|
||||
&& this.host === host
|
||||
&& this.method === method
|
||||
&& (this.kind === undefined || this.kind === kind);
|
||||
}
|
||||
|
||||
isAllowed(): boolean { return this.decision === 'allow'; }
|
||||
}
|
||||
```
|
||||
|
||||
### Rich Domain Entities
|
||||
|
||||
```typescript
|
||||
class Identity {
|
||||
private readonly _id: IdentityId;
|
||||
private _nickname: Nickname;
|
||||
private readonly _keyPair: NostrKeyPair;
|
||||
private readonly _createdAt: Date;
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
private constructor(
|
||||
id: IdentityId,
|
||||
nickname: Nickname,
|
||||
keyPair: NostrKeyPair,
|
||||
createdAt: Date
|
||||
) {
|
||||
this._id = id;
|
||||
this._nickname = nickname;
|
||||
this._keyPair = keyPair;
|
||||
this._createdAt = createdAt;
|
||||
}
|
||||
|
||||
static create(nickname: string, privateKey?: string): Identity {
|
||||
const keyPair = privateKey
|
||||
? NostrKeyPair.fromPrivateKey(privateKey)
|
||||
: NostrKeyPair.generate();
|
||||
|
||||
const identity = new Identity(
|
||||
IdentityId.generate(),
|
||||
Nickname.create(nickname),
|
||||
keyPair,
|
||||
new Date()
|
||||
);
|
||||
|
||||
identity._domainEvents.push(new IdentityCreated(identity._id, identity.publicKey));
|
||||
return identity;
|
||||
}
|
||||
|
||||
get id(): IdentityId { return this._id; }
|
||||
get publicKey(): string { return this._keyPair.publicKey; }
|
||||
get npub(): string { return this._keyPair.npub; }
|
||||
get nickname(): string { return this._nickname.toString(); }
|
||||
|
||||
rename(newNickname: string): void {
|
||||
const oldNickname = this._nickname.toString();
|
||||
this._nickname = Nickname.create(newNickname);
|
||||
this._domainEvents.push(new IdentityRenamed(this._id, oldNickname, newNickname));
|
||||
}
|
||||
|
||||
sign(event: UnsignedEvent): SignedEvent {
|
||||
return this._keyPair.sign(event);
|
||||
}
|
||||
|
||||
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
|
||||
return this._keyPair.encrypt(plaintext, recipientPubkey, version);
|
||||
}
|
||||
|
||||
pullDomainEvents(): DomainEvent[] {
|
||||
const events = [...this._domainEvents];
|
||||
this._domainEvents = [];
|
||||
return events;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Roadmap
|
||||
|
||||
### Phase 1: Extract Value Objects (Low Risk)
|
||||
|
||||
**Goal:** Introduce type safety without changing behavior.
|
||||
|
||||
1. Create `IdentityId`, `Nickname`, `NostrKeyPair` value objects
|
||||
2. Use them in existing interfaces initially
|
||||
3. Add validation in factory methods
|
||||
4. Update helpers to use value objects
|
||||
|
||||
**Files to Modify:**
|
||||
- Create `projects/common/src/lib/domain/value-objects/`
|
||||
- Update `projects/common/src/lib/helpers/nostr-helper.ts`
|
||||
|
||||
### Phase 2: Introduce Repository Pattern (Medium Risk)
|
||||
|
||||
**Goal:** Separate storage concerns from domain logic.
|
||||
|
||||
1. Define repository interfaces in domain layer
|
||||
2. Create `IdentityRepository`, `PermissionRepository`, etc.
|
||||
3. Move encryption to `EncryptionService` infrastructure
|
||||
4. Refactor `StorageService` to delegate to repositories
|
||||
|
||||
**New Structure:**
|
||||
```
|
||||
projects/common/src/lib/
|
||||
├── domain/
|
||||
│ ├── identity/
|
||||
│ │ ├── Identity.ts
|
||||
│ │ ├── IdentityRepository.ts (interface)
|
||||
│ │ └── events/
|
||||
│ ├── permission/
|
||||
│ │ ├── PermissionPolicy.ts
|
||||
│ │ └── PermissionRepository.ts (interface)
|
||||
│ └── vault/
|
||||
│ ├── Vault.ts
|
||||
│ └── VaultRepository.ts (interface)
|
||||
├── infrastructure/
|
||||
│ ├── encryption/
|
||||
│ │ └── EncryptionService.ts
|
||||
│ └── persistence/
|
||||
│ ├── ChromeIdentityRepository.ts
|
||||
│ └── FirefoxIdentityRepository.ts
|
||||
└── application/
|
||||
├── IdentityApplicationService.ts
|
||||
└── VaultApplicationService.ts
|
||||
```
|
||||
|
||||
### Phase 3: Rich Domain Model (Higher Risk)
|
||||
|
||||
**Goal:** Move behavior into domain entities.
|
||||
|
||||
1. Convert `Identity_DECRYPTED` interface to `Identity` class
|
||||
2. Move signing logic into `Identity.sign()`
|
||||
3. Move encryption decision logic into domain
|
||||
4. Add domain events for state changes
|
||||
|
||||
### Phase 4: Ubiquitous Language Cleanup
|
||||
|
||||
**Goal:** Align code with domain language.
|
||||
|
||||
| Old Name | New Name |
|
||||
|----------|----------|
|
||||
| `BrowserSyncData` | `EncryptedVault` |
|
||||
| `BrowserSessionData` | `VaultSession` |
|
||||
| `SignerMetaData` | `ExtensionSettings` |
|
||||
| `StorageService` | `VaultService` (or split into multiple) |
|
||||
| `addIdentity()` | `Identity.create()` + `IdentityRepository.save()` |
|
||||
| `switchIdentity()` | `Vault.selectIdentity()` |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priorities
|
||||
|
||||
### High Priority (Security/Correctness)
|
||||
|
||||
1. **Encapsulate KeyPair operations** - Private keys should never be accessed directly
|
||||
2. **Enforce invariants** - Selected identity must exist, permissions must reference valid identities
|
||||
3. **Clear transaction boundaries** - What gets saved together?
|
||||
|
||||
### Medium Priority (Maintainability)
|
||||
|
||||
1. **Split StorageService** - Into VaultService, IdentityRepository, PermissionRepository
|
||||
2. **Extract EncryptionService** - Pure infrastructure concern
|
||||
3. **Type-safe IDs** - Prevent mixing up identity IDs with permission IDs
|
||||
|
||||
### Lower Priority (Polish)
|
||||
|
||||
1. **Domain events** - For audit trail and extensibility
|
||||
2. **Full ubiquitous language** - Rename all types
|
||||
3. **Discriminated unions** - For vault context types
|
||||
|
||||
---
|
||||
|
||||
## Testing Implications
|
||||
|
||||
Current state makes testing difficult because:
|
||||
- `StorageService` requires mocking 4 handlers
|
||||
- Encryption is interleaved with logic
|
||||
- No clear boundaries to test in isolation
|
||||
|
||||
With proposed changes:
|
||||
- Domain entities testable in isolation (no storage mocks)
|
||||
- Repositories testable with in-memory implementations
|
||||
- Clear separation enables focused unit tests
|
||||
|
||||
```typescript
|
||||
// Example: Testing Identity domain logic
|
||||
describe('Identity', () => {
|
||||
it('signs events with internal keypair', () => {
|
||||
const identity = Identity.create('Test', 'nsec1...');
|
||||
const event = { kind: 1, content: 'test', /* ... */ };
|
||||
|
||||
const signed = identity.sign(event);
|
||||
|
||||
expect(signed.sig).toBeDefined();
|
||||
expect(signed.pubkey).toBe(identity.publicKey);
|
||||
});
|
||||
|
||||
it('prevents duplicate private keys via repository', async () => {
|
||||
const repository = new InMemoryIdentityRepository();
|
||||
const existing = Identity.create('First', 'nsec1abc...');
|
||||
await repository.save(existing);
|
||||
|
||||
const duplicate = Identity.create('Second', 'nsec1abc...');
|
||||
|
||||
await expect(repository.save(duplicate))
|
||||
.rejects.toThrow(DuplicateIdentityError);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Plebeian Signer codebase has solid foundations but would benefit significantly from DDD tactical patterns. The recommended approach:
|
||||
|
||||
1. **Start with value objects** - Low risk, immediate type safety benefits
|
||||
2. **Introduce repositories gradually** - Extract one at a time, starting with Identity
|
||||
3. **Defer full rich domain model** - Until repositories stabilize the architecture
|
||||
4. **Update language as you go** - Rename types when touching files anyway
|
||||
|
||||
The goal is not architectural purity but **maintainability, testability, and security**. DDD patterns are a means to those ends in a domain (cryptographic identity management) where correctness matters.
|
||||
@@ -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": {
|
||||
|
||||
@@ -86,7 +86,7 @@ You have full control over your data:
|
||||
## Open Source
|
||||
|
||||
Plebeian Signer is open source software. You can audit the code yourself:
|
||||
- Repository: https://git.mleku.dev/mleku/plebeian-signer
|
||||
- Repository: https://github.com/PlebeianApp/plebeian-signer
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
@@ -99,7 +99,7 @@ If we make changes to this privacy policy, we will update the "Last Updated" dat
|
||||
## Contact
|
||||
|
||||
For privacy-related questions or concerns, please open an issue on our repository:
|
||||
https://git.mleku.dev/mleku/plebeian-signer/issues
|
||||
https://github.com/PlebeianApp/plebeian-signer/issues
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ You need to host the privacy policy at a public URL. Options:
|
||||
2. **Simple webpage** - Create a basic HTML page
|
||||
3. **Gist** - Create a public GitHub gist
|
||||
|
||||
Example URL format: `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md`
|
||||
Example URL format: `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -102,8 +102,8 @@ Example URL format: `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main
|
||||
- Upload promotional tiles if you have them
|
||||
|
||||
**Additional Fields:**
|
||||
- **Official URL:** `https://git.mleku.dev/mleku/plebeian-signer`
|
||||
- **Support URL:** `https://git.mleku.dev/mleku/plebeian-signer/issues`
|
||||
- **Official URL:** `https://github.com/PlebeianApp/plebeian-signer`
|
||||
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
|
||||
|
||||
### Step 4: Privacy Tab
|
||||
|
||||
@@ -181,8 +181,8 @@ Firefox may request source code because the extension uses bundled/minified Java
|
||||
- **Categories:** Privacy & Security
|
||||
|
||||
**Additional Details:**
|
||||
- **Homepage:** `https://git.mleku.dev/mleku/plebeian-signer`
|
||||
- **Support URL:** `https://git.mleku.dev/mleku/plebeian-signer/issues`
|
||||
- **Homepage:** `https://github.com/PlebeianApp/plebeian-signer`
|
||||
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
|
||||
- **License:** Select appropriate license
|
||||
- **Privacy Policy:** Paste URL to hosted privacy policy
|
||||
|
||||
|
||||
@@ -66,8 +66,8 @@ Plebeian Signer is open source and respects your privacy:
|
||||
|
||||
### Links
|
||||
|
||||
- Source Code: https://git.mleku.dev/mleku/plebeian-signer
|
||||
- Report Issues: https://git.mleku.dev/mleku/plebeian-signer/issues
|
||||
- Source Code: https://github.com/PlebeianApp/plebeian-signer
|
||||
- Report Issues: https://github.com/PlebeianApp/plebeian-signer/issues
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Developer accounts are set up. This document covers the remaining steps.
|
||||
## Privacy Policy URL
|
||||
|
||||
```
|
||||
https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md
|
||||
https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md
|
||||
```
|
||||
|
||||
## Screenshots Needed
|
||||
@@ -48,7 +48,7 @@ Upload your screenshots.
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Single Purpose | Manage Nostr identities and sign cryptographic events for web applications |
|
||||
| Privacy Policy URL | `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md` |
|
||||
| Privacy Policy URL | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
|
||||
|
||||
**Permission Justifications:**
|
||||
|
||||
@@ -100,9 +100,9 @@ Build instructions to provide:
|
||||
| Summary | Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility. |
|
||||
| Description | Copy from `docs/store/STORE_DESCRIPTION.md` |
|
||||
| Categories | Privacy & Security |
|
||||
| Homepage | `https://git.mleku.dev/mleku/plebeian-signer` |
|
||||
| Support URL | `https://git.mleku.dev/mleku/plebeian-signer/issues` |
|
||||
| Privacy Policy | `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md` |
|
||||
| Homepage | `https://github.com/PlebeianApp/plebeian-signer` |
|
||||
| Support URL | `https://github.com/PlebeianApp/plebeian-signer/issues` |
|
||||
| Privacy Policy | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
|
||||
|
||||
Upload your screenshots.
|
||||
|
||||
|
||||
325
package-lock.json
generated
325
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v0.0.9",
|
||||
"version": "v1.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plebeian-signer",
|
||||
"version": "v0.0.9",
|
||||
"version": "v1.0.8",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
@@ -16,13 +16,16 @@
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"@cashu/cashu-ts": "^3.2.0",
|
||||
"@nostr-dev-kit/ndk": "^2.11.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"buffer": "^6.0.3",
|
||||
"hash-wasm": "^4.11.0",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"webextension-polyfill": "^0.12.0",
|
||||
@@ -36,6 +39,7 @@
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/chrome": "^0.0.293",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/webextension-polyfill": "^0.12.1",
|
||||
"angular-eslint": "19.0.2",
|
||||
"eslint": "^9.16.0",
|
||||
@@ -4712,6 +4716,70 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.2.0.tgz",
|
||||
"integrity": "sha512-wOdqenmPs92+5feU2GIg92QcdNmCdg4AIau7Lq6G/uw1t+t/osjygupr2dmDzdQx7JBWHHNoVaUDSJm1G8phYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@scure/bip32": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
@@ -8157,7 +8225,6 @@
|
||||
"version": "22.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz",
|
||||
"integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
@@ -8173,6 +8240,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.9.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
|
||||
@@ -8237,6 +8313,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webextension-polyfill": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.12.1.tgz",
|
||||
@@ -9245,7 +9328,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -9925,6 +10007,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001696",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz",
|
||||
@@ -10192,7 +10283,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -10205,7 +10295,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
@@ -10643,6 +10732,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -10772,6 +10870,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dns-packet": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
@@ -12083,7 +12187,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -16124,7 +16227,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -16273,7 +16375,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -16495,6 +16596,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
@@ -16744,6 +16854,177 @@
|
||||
"node": ">=0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
@@ -16952,7 +17233,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -16968,6 +17248,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -17646,6 +17932,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -19024,7 +19316,6 @@
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
@@ -19856,6 +20147,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wildcard": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
||||
@@ -19877,7 +20174,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -19966,7 +20262,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -19976,14 +20271,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -19993,7 +20286,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -20008,7 +20300,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
|
||||
10
package.json
10
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v1.0.8",
|
||||
"version": "v1.1.5",
|
||||
"custom": {
|
||||
"chrome": {
|
||||
"version": "v1.0.8"
|
||||
"version": "v1.1.5"
|
||||
},
|
||||
"firefox": {
|
||||
"version": "v1.0.8"
|
||||
"version": "v1.1.5"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -36,13 +36,16 @@
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"@cashu/cashu-ts": "^3.2.0",
|
||||
"@nostr-dev-kit/ndk": "^2.11.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"buffer": "^6.0.3",
|
||||
"hash-wasm": "^4.11.0",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"webextension-polyfill": "^0.12.0",
|
||||
@@ -56,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;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||
"version": "1.0.8",
|
||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||
"version": "1.1.5",
|
||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||
"options_page": "options.html",
|
||||
"permissions": [
|
||||
"windows",
|
||||
|
||||
@@ -27,11 +27,66 @@
|
||||
.page {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 60px;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: var(--size);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
width: 60px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.btn-reject:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.btn-accept:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--size);
|
||||
background: var(--background-light);
|
||||
@@ -54,6 +109,12 @@
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -63,64 +124,31 @@
|
||||
<span id="titleSpan" style="font-weight: 400 !important"></span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="host-INSERT sam-align-self-center sam-text-muted"
|
||||
style="font-weight: 500"
|
||||
></span>
|
||||
|
||||
<!-- Card for getPublicKey -->
|
||||
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">read your public key</b> <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">read your public key</b> for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for getRelays -->
|
||||
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">read your relays</b> <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">read your relays</b> for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for signEvent -->
|
||||
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">sign an event</b> (kind
|
||||
<span id="kindSpan"></span>) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">sign an event</b> (kind <span id="kindSpan"></span>)
|
||||
for the selected identity <b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for signEvent -->
|
||||
@@ -130,20 +158,11 @@
|
||||
|
||||
<!-- Card for nip04.encrypt -->
|
||||
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">encrypt a text</b> (NIP04) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">encrypt a text</b> (NIP04) for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for nip04.encrypt -->
|
||||
@@ -153,20 +172,11 @@
|
||||
|
||||
<!-- Card for nip44.encrypt -->
|
||||
<div id="cardNip44Encrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">encrypt a text</b> (NIP44) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">encrypt a text</b> (NIP44) for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for nip44.encrypt -->
|
||||
@@ -176,20 +186,11 @@
|
||||
|
||||
<!-- Card for nip04.decrypt -->
|
||||
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">decrypt a text</b> (NIP04) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">decrypt a text</b> (NIP04) for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for nip04.decrypt -->
|
||||
@@ -199,72 +200,90 @@
|
||||
|
||||
<!-- Card for nip44.decrypt -->
|
||||
<div id="cardNip44Decrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">decrypt a text</b> (NIP44) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">decrypt a text</b> (NIP44) for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for nip44.decrypt -->
|
||||
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<div id="card2Nip44Decrypt_text" class="text"></div>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.enable -->
|
||||
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">connect to your Lightning wallet</b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.getInfo -->
|
||||
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">read your wallet info</b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.sendPayment -->
|
||||
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">send a Lightning payment</b> of
|
||||
<b id="paymentAmountSpan" class="color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for webln.sendPayment (shows invoice) -->
|
||||
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
|
||||
<div id="card2WeblnSendPayment_json" class="json"></div>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.makeInvoice -->
|
||||
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">create a Lightning invoice</b>
|
||||
<span id="invoiceAmountSpan"></span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.keysend -->
|
||||
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">send a keysend payment</b>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!------------->
|
||||
<!-- ACTIONS -->
|
||||
<!------------->
|
||||
<div class="sam-footer-grid-2">
|
||||
<div class="btn-group">
|
||||
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button id="rejectAlwaysButton" class="dropdown-item">
|
||||
Reject Always
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<div class="action-row">
|
||||
<span class="action-label">Reject</span>
|
||||
<div class="action-buttons">
|
||||
<button id="rejectOnceButton" type="button" class="btn-reject">Once</button>
|
||||
<button id="rejectAlwaysButton" type="button" class="btn-reject">Always</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
|
||||
Approve Always
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<button id="approveOnceButton" class="dropdown-item">
|
||||
Approve Once
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="action-row">
|
||||
<span class="action-label">Accept</span>
|
||||
<div class="action-buttons">
|
||||
<button id="approveOnceButton" type="button" class="btn-accept">Once</button>
|
||||
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row" id="allQueuedRow">
|
||||
<span class="action-label">All Queued</span>
|
||||
<div class="action-buttons">
|
||||
<button id="rejectAllButton" type="button" class="btn-reject">Reject All</button>
|
||||
<button id="approveAllButton" type="button" class="btn-accept">Approve All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
245
projects/chrome/public/unlock.html
Normal file
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>
|
||||
@@ -12,10 +12,12 @@ import { SettingsComponent } from './components/home/settings/settings.component
|
||||
import { LogsComponent } from './components/home/logs/logs.component';
|
||||
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
|
||||
import { WalletComponent } from './components/home/wallet/wallet.component';
|
||||
import { BackupsComponent } from './components/home/backups/backups.component';
|
||||
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
|
||||
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
|
||||
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
|
||||
import { KeysComponent as EditIdentityKeysComponent } from './components/edit-identity/keys/keys.component';
|
||||
import { NcryptsecComponent as EditIdentityNcryptsecComponent } from './components/edit-identity/ncryptsec/ncryptsec.component';
|
||||
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
|
||||
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
|
||||
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
||||
@@ -81,6 +83,10 @@ export const routes: Routes = [
|
||||
path: 'wallet',
|
||||
component: WalletComponent,
|
||||
},
|
||||
{
|
||||
path: 'backups',
|
||||
component: BackupsComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -107,6 +113,10 @@ export const routes: Routes = [
|
||||
path: 'keys',
|
||||
component: EditIdentityKeysComponent,
|
||||
},
|
||||
{
|
||||
path: 'ncryptsec',
|
||||
component: EditIdentityNcryptsecComponent,
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
component: EditIdentityPermissionsComponent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { SignerMetaData, SignerMetaHandler } from '@common';
|
||||
import { ExtensionSettings, SignerMetaHandler } from '@common';
|
||||
|
||||
export class ChromeMetaHandler extends SignerMetaHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
@@ -19,7 +19,7 @@ export class ChromeMetaHandler extends SignerMetaHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveFullData(data: SignerMetaData): Promise<void> {
|
||||
async saveFullData(data: ExtensionSettings): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSessionData, BrowserSessionHandler } from '@common';
|
||||
import { VaultSession, BrowserSessionHandler } from '@common';
|
||||
|
||||
export class ChromeSessionHandler extends BrowserSessionHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
return chrome.storage.session.get(null);
|
||||
}
|
||||
|
||||
async saveFullData(data: BrowserSessionData): Promise<void> {
|
||||
async saveFullData(data: VaultSession): Promise<void> {
|
||||
await chrome.storage.session.set(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
EncryptedVault,
|
||||
BrowserSyncHandler,
|
||||
Identity_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
@@ -24,20 +26,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -51,12 +53,26 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
const props = Object.keys(await this.loadUnmigratedData());
|
||||
await chrome.storage.local.remove(props);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
Identity_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
@@ -16,20 +18,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
return await chrome.storage.sync.get(null);
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -43,12 +45,26 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
await chrome.storage.sync.clear();
|
||||
}
|
||||
|
||||
@@ -136,6 +136,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="sam-mt-2">Encrypted Key (NIP-49)</span>
|
||||
|
||||
<button class="btn btn-primary sam-mt-h" (click)="navigateToNcryptsec()">
|
||||
Get ncryptsec
|
||||
</button>
|
||||
}
|
||||
|
||||
<lib-toast #toast [bottom]="16"></lib-toast>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
NavComponent,
|
||||
@@ -29,6 +29,7 @@ export class KeysComponent extends NavComponent implements OnInit {
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
@@ -51,6 +52,11 @@ export class KeysComponent extends NavComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
navigateToNcryptsec() {
|
||||
if (!this.identity) return;
|
||||
this.#router.navigateByUrl(`/edit-identity/${this.identity.id}/ncryptsec`);
|
||||
}
|
||||
|
||||
async #initialize(identityId: string) {
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<div class="header-pane">
|
||||
<lib-icon-button
|
||||
icon="chevron-left"
|
||||
(click)="navigateBack()"
|
||||
></lib-icon-button>
|
||||
<span>Get ncryptsec</span>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (shown after generation) -->
|
||||
@if (ncryptsec) {
|
||||
<div class="qr-container">
|
||||
<button
|
||||
type="button"
|
||||
class="qr-button"
|
||||
title="Copy to clipboard"
|
||||
(click)="copyToClipboard(ncryptsec); toast.show('Copied to clipboard')"
|
||||
>
|
||||
<img [src]="ncryptsecQr" alt="ncryptsec QR code" class="qr-code" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PASSWORD INPUT -->
|
||||
<div class="password-section">
|
||||
<label for="ncryptsecPasswordInput">Password</label>
|
||||
<div class="input-group sam-mt-h">
|
||||
<input
|
||||
#passwordInput
|
||||
id="ncryptsecPasswordInput"
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="Enter encryption password"
|
||||
[(ngModel)]="ncryptsecPassword"
|
||||
[disabled]="isGenerating"
|
||||
(keyup.enter)="generateNcryptsec()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary generate-btn"
|
||||
type="button"
|
||||
(click)="generateNcryptsec()"
|
||||
[disabled]="!ncryptsecPassword || isGenerating"
|
||||
>
|
||||
@if (isGenerating) {
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
Generating...
|
||||
} @else {
|
||||
Generate ncryptsec
|
||||
}
|
||||
</button>
|
||||
|
||||
|
||||
<p class="description">
|
||||
Enter a password to encrypt your private key. The resulting ncryptsec can be
|
||||
used to securely backup or transfer your key.
|
||||
</p>
|
||||
|
||||
<lib-toast #toast [bottom]="16"></lib-toast>
|
||||
@@ -0,0 +1,70 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
.header-pane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: var(--size-h);
|
||||
align-items: center;
|
||||
padding-bottom: var(--size);
|
||||
background-color: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.password-section {
|
||||
margin-bottom: var(--size);
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--size-q);
|
||||
}
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.qr-button {
|
||||
background: white;
|
||||
padding: var(--size);
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
} from '@common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ncryptsec',
|
||||
imports: [IconButtonComponent, FormsModule, ToastComponent],
|
||||
templateUrl: './ncryptsec.component.html',
|
||||
styleUrl: './ncryptsec.component.scss',
|
||||
})
|
||||
export class NcryptsecComponent
|
||||
extends NavComponent
|
||||
implements OnInit, AfterViewInit
|
||||
{
|
||||
@ViewChild('passwordInput') passwordInput!: ElementRef<HTMLInputElement>;
|
||||
|
||||
privkeyHex = '';
|
||||
ncryptsecPassword = '';
|
||||
ncryptsec = '';
|
||||
ncryptsecQr = '';
|
||||
isGenerating = false;
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
if (!identityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initialize(identityId);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.passwordInput.nativeElement.focus();
|
||||
}
|
||||
|
||||
async generateNcryptsec() {
|
||||
if (!this.privkeyHex || !this.ncryptsecPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGenerating = true;
|
||||
this.ncryptsec = '';
|
||||
this.ncryptsecQr = '';
|
||||
|
||||
try {
|
||||
this.ncryptsec = await NostrHelper.privkeyToNcryptsec(
|
||||
this.privkeyHex,
|
||||
this.ncryptsecPassword
|
||||
);
|
||||
|
||||
// Generate QR code
|
||||
this.ncryptsecQr = await QRCode.toDataURL(this.ncryptsec, {
|
||||
width: 250,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate ncryptsec:', error);
|
||||
} finally {
|
||||
this.isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
#initialize(identityId: string) {
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === identityId);
|
||||
|
||||
if (!identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.privkeyHex = identity.privkey;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
>
|
||||
<span class="text-muted">{{ permission.method }}</span>
|
||||
@if(typeof permission.kind !== 'undefined') {
|
||||
<span>(kind {{ permission.kind }})</span>
|
||||
<span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
|
||||
}
|
||||
<div class="sam-flex-grow"></div>
|
||||
<lib-icon-button
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NavComponent,
|
||||
Permission_DECRYPTED,
|
||||
StorageService,
|
||||
getKindName,
|
||||
} from '@common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@@ -86,4 +87,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getKindTooltip(kind: number): string {
|
||||
return getKindName(kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span>Bookmarks</span>
|
||||
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
|
||||
<span class="emoji">➕</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
|
||||
import { Bookmark, LoggerService, NavComponent, SignerMetaData } from '@common';
|
||||
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
|
||||
|
||||
@Component({
|
||||
@@ -9,10 +9,9 @@ import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
|
||||
styleUrl: './bookmarks.component.scss',
|
||||
imports: [],
|
||||
})
|
||||
export class BookmarksComponent implements OnInit {
|
||||
export class BookmarksComponent extends NavComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #metaHandler = new ChromeMetaHandler();
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
bookmarks: Bookmark[] = [];
|
||||
@@ -93,7 +92,7 @@ export class BookmarksComponent implements OnInit {
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="custom-header" style="position: sticky; top: 0">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="text">Identities</span>
|
||||
|
||||
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">
|
||||
|
||||
@@ -19,9 +19,16 @@
|
||||
background: var(--background);
|
||||
position: relative;
|
||||
|
||||
.lock-btn,
|
||||
.add-btn {
|
||||
.header-buttons {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-btn,
|
||||
.add-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
@@ -41,11 +48,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.lock-btn {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -17,8 +18,8 @@ import {
|
||||
styleUrl: './identities.component.scss',
|
||||
imports: [IconButtonComponent, ToastComponent],
|
||||
})
|
||||
export class IdentitiesComponent implements OnInit {
|
||||
readonly storage = inject(StorageService);
|
||||
export class IdentitiesComponent extends NavComponent implements OnInit {
|
||||
override readonly storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span>You</span>
|
||||
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
|
||||
<span class="emoji">📝</span>
|
||||
@@ -73,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>
|
||||
|
||||
@@ -185,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Router } from '@angular/router';
|
||||
import {
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
PubkeyComponent,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
VisualNip05Pipe,
|
||||
validateNip05,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
templateUrl: './identity.component.html',
|
||||
styleUrl: './identity.component.scss',
|
||||
})
|
||||
export class IdentityComponent implements OnInit {
|
||||
export class IdentityComponent extends NavComponent implements OnInit {
|
||||
selectedIdentity: Identity_DECRYPTED | undefined;
|
||||
selectedIdentityNpub: string | undefined;
|
||||
profile: ProfileMetadata | null = null;
|
||||
@@ -27,7 +27,6 @@ export class IdentityComponent implements OnInit {
|
||||
validating = false;
|
||||
loading = true;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
@@ -52,6 +51,10 @@ export class IdentityComponent implements OnInit {
|
||||
return this.profile?.banner;
|
||||
}
|
||||
|
||||
get aboutText(): string | undefined {
|
||||
return this.profile?.about;
|
||||
}
|
||||
|
||||
copyToClipboard(pubkey: string | undefined) {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
@@ -78,17 +81,17 @@ export class IdentityComponent implements OnInit {
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
|
||||
async #loadData() {
|
||||
try {
|
||||
const selectedIdentityId =
|
||||
this.#storage.getBrowserSessionHandler().browserSessionData
|
||||
this.storage.getBrowserSessionHandler().browserSessionData
|
||||
?.selectedIdentityId ?? null;
|
||||
|
||||
const identity = this.#storage
|
||||
const identity = this.storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find(
|
||||
(x) => x.id === selectedIdentityId
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span> Plebeian Signer </span>
|
||||
</div>
|
||||
|
||||
@@ -11,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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, StorageService } from '@common';
|
||||
import { LoggerService, NavComponent } from '@common';
|
||||
import packageJson from '../../../../../../../package.json';
|
||||
|
||||
@Component({
|
||||
@@ -8,16 +8,15 @@ import packageJson from '../../../../../../../package.json';
|
||||
templateUrl: './info.component.html',
|
||||
styleUrl: './info.component.scss',
|
||||
})
|
||||
export class InfoComponent {
|
||||
export class InfoComponent extends NavComponent {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
version = packageJson.custom.chrome.version;
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span>Logs</span>
|
||||
<div class="logs-actions">
|
||||
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, LogEntry, StorageService } from '@common';
|
||||
import { LoggerService, LogEntry, NavComponent } from '@common';
|
||||
import { DatePipe } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
@@ -9,9 +9,8 @@ import { DatePipe } from '@angular/common';
|
||||
styleUrl: './logs.component.scss',
|
||||
imports: [DatePipe],
|
||||
})
|
||||
export class LogsComponent implements OnInit {
|
||||
export class LogsComponent extends NavComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
get logs(): LogEntry[] {
|
||||
@@ -46,7 +45,7 @@ export class LogsComponent implements OnInit {
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span> Settings </span>
|
||||
</div>
|
||||
|
||||
<span>SYNC: {{ syncFlow }}</span>
|
||||
|
||||
<button class="btn btn-primary" (click)="onClickExportVault()">
|
||||
Export Vault
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary" (click)="navigate('/vault-import')">
|
||||
Import Vault
|
||||
</button>
|
||||
<div class="vault-buttons">
|
||||
<button class="btn btn-primary" (click)="onClickExportVault()">
|
||||
Export Vault
|
||||
</button>
|
||||
<button class="btn btn-primary" (click)="navigate('/vault-import')">
|
||||
Import Vault
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
|
||||
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
|
||||
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
|
||||
|
||||
<div class="dev-mode-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" [checked]="devMode" (change)="onToggleDevMode($event)" />
|
||||
<span>Dev Mode</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<div class="sync-info">
|
||||
<span class="sync-label">SYNC: {{ syncFlow }}</span>
|
||||
<p class="sync-note">
|
||||
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)="
|
||||
|
||||
@@ -15,3 +15,46 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -22,6 +23,7 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
|
||||
export class SettingsComponent extends NavComponent implements OnInit {
|
||||
readonly #router = inject(Router);
|
||||
syncFlow: string | undefined;
|
||||
override devMode = false;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #startup = inject(StartupService);
|
||||
@@ -45,6 +47,44 @@ export class SettingsComponent extends NavComponent implements OnInit {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Load dev mode setting
|
||||
this.devMode = this.#storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
|
||||
}
|
||||
|
||||
async onToggleDevMode(event: Event) {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
this.devMode = checked;
|
||||
await this.#storage.getSignerMetaHandler().setDevMode(checked);
|
||||
}
|
||||
|
||||
override async onTestPrompt() {
|
||||
// Open a test permission prompt window
|
||||
const testEvent = {
|
||||
kind: 1,
|
||||
content: 'This is a test note for permission prompt preview.',
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
|
||||
const currentIdentity = this.#storage.getBrowserSessionHandler().browserSessionData?.identities.find(
|
||||
i => i.id === this.#storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
|
||||
);
|
||||
const nick = currentIdentity?.nick ?? 'Test Identity';
|
||||
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
const left = Math.round((screen.width - width) / 2);
|
||||
const top = Math.round((screen.height - height) / 2);
|
||||
|
||||
chrome.windows.create({
|
||||
type: 'popup',
|
||||
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
});
|
||||
}
|
||||
|
||||
async onResetExtension() {
|
||||
|
||||
@@ -1,14 +1,660 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>Wallet</span>
|
||||
<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">
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">
|
||||
Wallet functionality coming soon.
|
||||
</span>
|
||||
</div>
|
||||
<!-- Main wallet menu -->
|
||||
@if (activeSection === 'main') {
|
||||
<div class="wallet-menu">
|
||||
<button class="wallet-menu-item" (click)="setSection('cashu')">
|
||||
<span class="emoji">🥜</span>
|
||||
<span class="label">Cashu</span>
|
||||
<span class="balance">{{ formatCashuBalance(totalCashuBalance) }} sats</span>
|
||||
</button>
|
||||
<button class="wallet-menu-item" (click)="setSection('lightning')">
|
||||
<span class="emoji">⚡</span>
|
||||
<span class="label">Lightning</span>
|
||||
<span class="balance">{{ formatBalance(totalLightningBalance) }} sats</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu mint list -->
|
||||
@else if (activeSection === 'cashu') {
|
||||
<div class="lightning-section">
|
||||
@if (mints.length === 0) {
|
||||
<div class="cashu-onboarding">
|
||||
@if (showCashuInfo) {
|
||||
<div class="info-panel">
|
||||
<h3>Welcome to Cashu Wallet</h3>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Storage Considerations</h4>
|
||||
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
|
||||
<div class="warning-box">
|
||||
<p><strong>Browser Sync is enabled</strong></p>
|
||||
<p>
|
||||
Sync storage is limited to ~100KB shared across all your vault data
|
||||
(identities, permissions, relays, and Cashu tokens). This limits
|
||||
your Cashu wallet to approximately 300-400 tokens.
|
||||
</p>
|
||||
<p>
|
||||
For larger Cashu holdings, consider disabling browser sync which
|
||||
provides ~5MB of local storage (~18,000+ tokens).
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Change Sync Settings
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="success-box">
|
||||
<p><strong>Local Storage Mode</strong></p>
|
||||
<p>
|
||||
You have ~5MB of local storage available, which can hold
|
||||
thousands of Cashu tokens. Your data stays on this device only.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Backup Your Wallet</h4>
|
||||
<p>
|
||||
<strong>Important:</strong> Cashu tokens are bearer assets.
|
||||
If you lose your vault backup, you lose your tokens permanently.
|
||||
</p>
|
||||
<p>
|
||||
Vault exports are saved to your browser's downloads folder.
|
||||
Configure this to point to either:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Your backup storage device (external drive, NAS)</li>
|
||||
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
|
||||
</ul>
|
||||
<p class="browser-url">
|
||||
<code>{{ browserDownloadSettingsUrl }}</code>
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Go to Backup Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="dismiss-btn" (click)="dismissCashuInfo()">
|
||||
Got it, let me add a mint
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">No mints connected yet.</span>
|
||||
<button class="show-info-btn" (click)="showCashuInfo = true">
|
||||
Show storage info
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wallet-list">
|
||||
@for (mint of mints; track mint.id) {
|
||||
<button class="wallet-list-item" (click)="selectMint(mint.id)">
|
||||
<span class="wallet-name">{{ mint.name }}</span>
|
||||
<span class="wallet-balance">{{ formatCashuBalance(mint.cachedBalance) }} sats</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button class="add-wallet-btn" (click)="showAddMint()">
|
||||
<span class="emoji">+</span>
|
||||
<span>Add Mint</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu mint detail -->
|
||||
@else if (activeSection === 'cashu-detail' && selectedMint) {
|
||||
<div class="wallet-detail">
|
||||
<div class="balance-row">
|
||||
<div class="balance-display compact">
|
||||
<span class="balance-value">{{ formatCashuBalance(selectedMintBalance) }}</span>
|
||||
<span class="balance-unit">sats</span>
|
||||
</div>
|
||||
<button
|
||||
class="refresh-icon-btn"
|
||||
(click)="refreshMint()"
|
||||
[disabled]="refreshingMint"
|
||||
title="Refresh"
|
||||
>
|
||||
<span class="emoji" [class.spinning]="refreshingMint">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (refreshError) {
|
||||
<div class="error-message small">{{ refreshError }}</div>
|
||||
}
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn deposit-btn" (click)="showDeposit()">
|
||||
Deposit
|
||||
</button>
|
||||
<button class="action-btn receive-btn" (click)="showReceive()">
|
||||
Receive
|
||||
</button>
|
||||
<button class="action-btn send-btn" (click)="showSend()" [disabled]="selectedMintBalance === 0">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Token viewer section -->
|
||||
<div class="token-section">
|
||||
<div class="section-title">Tokens ({{ selectedMintProofs.length }})</div>
|
||||
@if (selectedMintProofs.length === 0) {
|
||||
<div class="empty-text">No tokens stored</div>
|
||||
} @else {
|
||||
<div class="token-list">
|
||||
@for (proof of selectedMintProofs; track proof.secret) {
|
||||
<div class="token-item">
|
||||
<span class="token-amount">{{ proof.amount }}</span>
|
||||
<span class="token-time">{{ formatProofTime(proof.receivedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="wallet-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Mint URL</span>
|
||||
<span class="info-value">{{ selectedMint.mintUrl }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Unit</span>
|
||||
<span class="info-value">{{ selectedMint.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="delete-btn" (click)="deleteMint()">
|
||||
Delete Mint
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu add mint form -->
|
||||
@else if (activeSection === 'cashu-add') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="mintName">Mint Name</label>
|
||||
<input
|
||||
id="mintName"
|
||||
type="text"
|
||||
[(ngModel)]="newMintName"
|
||||
placeholder="My Mint"
|
||||
[disabled]="addingMint"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mintUrl">Mint URL</label>
|
||||
<input
|
||||
id="mintUrl"
|
||||
type="text"
|
||||
[(ngModel)]="newMintUrl"
|
||||
placeholder="https://mint.example.com"
|
||||
[disabled]="addingMint"
|
||||
/>
|
||||
</div>
|
||||
@if (mintError) {
|
||||
<div class="error-message">{{ mintError }}</div>
|
||||
}
|
||||
@if (mintTestResult) {
|
||||
<div class="success-message">{{ mintTestResult }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="test-btn"
|
||||
(click)="testMint()"
|
||||
[disabled]="testingMint || addingMint"
|
||||
>
|
||||
{{ testingMint ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="addMint()"
|
||||
[disabled]="addingMint"
|
||||
>
|
||||
{{ addingMint ? 'Adding...' : 'Add Mint' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu receive token -->
|
||||
@else if (activeSection === 'cashu-receive') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="receiveToken">Paste Cashu Token</label>
|
||||
<textarea
|
||||
id="receiveToken"
|
||||
[(ngModel)]="receiveToken"
|
||||
placeholder="cashuB..."
|
||||
rows="5"
|
||||
[disabled]="receivingToken"
|
||||
></textarea>
|
||||
</div>
|
||||
@if (receiveError) {
|
||||
<div class="error-message">{{ receiveError }}</div>
|
||||
}
|
||||
@if (receiveResult) {
|
||||
<div class="success-message">{{ receiveResult }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="receiveTokens()"
|
||||
[disabled]="receivingToken"
|
||||
>
|
||||
{{ receivingToken ? 'Receiving...' : 'Receive Tokens' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu send token -->
|
||||
@else if (activeSection === 'cashu-send') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="balance-info">
|
||||
Available: {{ formatCashuBalance(selectedMintBalance) }} sats
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sendAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="sendAmount"
|
||||
type="number"
|
||||
[(ngModel)]="sendAmount"
|
||||
placeholder="0"
|
||||
min="1"
|
||||
[max]="selectedMintBalance"
|
||||
[disabled]="sendingToken"
|
||||
/>
|
||||
</div>
|
||||
@if (sendError) {
|
||||
<div class="error-message">{{ sendError }}</div>
|
||||
}
|
||||
@if (sendResult) {
|
||||
<div class="token-result">
|
||||
<span class="token-label">Token to Share</span>
|
||||
<textarea readonly rows="4">{{ sendResult }}</textarea>
|
||||
<button class="copy-btn" (click)="copyToken()">
|
||||
Copy Token
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (!sendResult) {
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="sendTokens()"
|
||||
[disabled]="sendingToken || sendAmount <= 0"
|
||||
>
|
||||
{{ sendingToken ? 'Creating...' : 'Create Token' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu deposit (mint via Lightning) -->
|
||||
@else if (activeSection === 'cashu-mint' && selectedMint) {
|
||||
<div class="add-wallet-form">
|
||||
@if (!depositInvoice) {
|
||||
<div class="form-group">
|
||||
<label for="depositAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="depositAmount"
|
||||
type="number"
|
||||
[(ngModel)]="depositAmount"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
[disabled]="creatingDepositQuote"
|
||||
/>
|
||||
</div>
|
||||
@if (depositError) {
|
||||
<div class="error-message">{{ depositError }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="createDepositInvoice()"
|
||||
[disabled]="creatingDepositQuote || depositAmount <= 0"
|
||||
>
|
||||
{{ creatingDepositQuote ? 'Creating...' : 'Create Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (depositInvoice) {
|
||||
<div class="invoice-result">
|
||||
@if (depositInvoiceQr) {
|
||||
<img [src]="depositInvoiceQr" alt="Invoice QR Code" class="qr-code" />
|
||||
}
|
||||
<div class="deposit-status">
|
||||
@if (depositQuoteState === 'UNPAID') {
|
||||
<span class="status-waiting">Waiting for payment...</span>
|
||||
@if (checkingDepositPayment) {
|
||||
<span class="status-checking">checking</span>
|
||||
}
|
||||
} @else if (depositQuoteState === 'PAID') {
|
||||
<span class="status-paid">Payment received! Claiming tokens...</span>
|
||||
} @else if (depositQuoteState === 'ISSUED') {
|
||||
<span class="status-issued">✓ Tokens received!</span>
|
||||
}
|
||||
</div>
|
||||
@if (depositError) {
|
||||
<div class="error-message">{{ depositError }}</div>
|
||||
}
|
||||
@if (depositSuccess) {
|
||||
<div class="success-message">{{ depositSuccess }}</div>
|
||||
}
|
||||
@if (depositQuoteState === 'UNPAID') {
|
||||
<div class="invoice-text">{{ depositInvoice }}</div>
|
||||
<button class="copy-btn" (click)="copyDepositInvoice()">
|
||||
Copy Invoice
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning wallet list -->
|
||||
@else if (activeSection === 'lightning') {
|
||||
<div class="lightning-section">
|
||||
@if (connections.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">
|
||||
No wallets connected yet.
|
||||
</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wallet-list">
|
||||
@for (conn of connections; track conn.id) {
|
||||
<button class="wallet-list-item" (click)="selectConnection(conn.id)">
|
||||
<span class="wallet-name">{{ conn.name }}</span>
|
||||
<span class="wallet-balance">{{ formatBalance(conn.cachedBalance) }} sats</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button class="add-wallet-btn" (click)="showAddConnection()">
|
||||
<span class="emoji">+</span>
|
||||
<span>Add NWC Connection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning wallet detail -->
|
||||
@else if (activeSection === 'lightning-detail' && selectedConnection) {
|
||||
<div class="wallet-detail">
|
||||
<div class="balance-row">
|
||||
<div class="balance-display compact">
|
||||
<span class="balance-value">{{ formatBalance(selectedConnection.cachedBalance) }}</span>
|
||||
<span class="balance-unit">sats</span>
|
||||
</div>
|
||||
<button class="refresh-icon-btn" (click)="refreshWallet()" title="Refresh">
|
||||
<span class="emoji">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn receive-btn" (click)="showLnReceive()">
|
||||
Receive
|
||||
</button>
|
||||
<button class="action-btn send-btn" (click)="showLnPay()">
|
||||
Pay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wallet-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Relay</span>
|
||||
<span class="info-value">{{ selectedConnection.relayUrl }}</span>
|
||||
</div>
|
||||
@if (selectedConnection.lud16) {
|
||||
<button class="info-row-btn" (click)="copyLightningAddress()">
|
||||
<span class="info-label">Lightning Address</span>
|
||||
<span class="info-value">
|
||||
{{ selectedConnection.lud16 }}
|
||||
<span class="copy-hint">{{ addressCopied ? '✓ Copied' : '(tap to copy)' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Transaction History -->
|
||||
<div class="transaction-section">
|
||||
<div class="section-title">Transactions</div>
|
||||
@if (loadingTransactions) {
|
||||
<div class="loading-text">Loading...</div>
|
||||
} @else if (transactionsNotSupported) {
|
||||
<div class="not-supported-text">Transaction history not supported by this wallet</div>
|
||||
} @else if (transactionsError) {
|
||||
<div class="error-text">{{ transactionsError }}</div>
|
||||
} @else if (transactions.length === 0) {
|
||||
<div class="empty-text">No transactions yet</div>
|
||||
} @else {
|
||||
<div class="transaction-list">
|
||||
@for (tx of transactions; track tx.payment_hash) {
|
||||
<div class="transaction-item" [class.incoming]="tx.type === 'incoming'" [class.outgoing]="tx.type === 'outgoing'">
|
||||
<span class="tx-icon">{{ tx.type === 'incoming' ? '⬇' : '⬆' }}</span>
|
||||
<span class="tx-type">{{ tx.type === 'incoming' ? 'Received' : 'Sent' }}</span>
|
||||
<span class="tx-amount">{{ formatBalance(tx.amount) }}</span>
|
||||
<span class="tx-time">{{ formatTransactionTime(tx.created_at) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="delete-btn-small" (click)="deleteConnection()">
|
||||
Delete Wallet
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning receive invoice -->
|
||||
@else if (activeSection === 'lightning-receive' && selectedConnection) {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="lnReceiveAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="lnReceiveAmount"
|
||||
type="number"
|
||||
[(ngModel)]="lnReceiveAmount"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
[disabled]="generatingInvoice"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lnReceiveDescription">Description (optional)</label>
|
||||
<input
|
||||
id="lnReceiveDescription"
|
||||
type="text"
|
||||
[(ngModel)]="lnReceiveDescription"
|
||||
placeholder="Payment for..."
|
||||
[disabled]="generatingInvoice"
|
||||
/>
|
||||
</div>
|
||||
@if (lnReceiveError) {
|
||||
<div class="error-message">{{ lnReceiveError }}</div>
|
||||
}
|
||||
@if (!generatedInvoice) {
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="createReceiveInvoice()"
|
||||
[disabled]="generatingInvoice || lnReceiveAmount <= 0"
|
||||
>
|
||||
{{ generatingInvoice ? 'Generating...' : 'Generate Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (generatedInvoice) {
|
||||
<div class="invoice-result">
|
||||
@if (generatedInvoiceQr) {
|
||||
<img [src]="generatedInvoiceQr" alt="Invoice QR Code" class="qr-code" />
|
||||
}
|
||||
<div class="invoice-text">{{ generatedInvoice }}</div>
|
||||
<button class="copy-btn" (click)="copyInvoice()">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Pay Modal Overlay -->
|
||||
@if (showPayModal && selectedConnection) {
|
||||
<div class="modal-overlay" role="dialog" aria-modal="true" tabindex="-1" (click)="closePayModal()" (keydown.escape)="closePayModal()">
|
||||
<div class="modal-content" role="document" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<span>Pay Invoice</span>
|
||||
<button class="modal-close" (click)="closePayModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="payInput">Lightning Address or Invoice</label>
|
||||
<textarea
|
||||
id="payInput"
|
||||
[(ngModel)]="payInput"
|
||||
placeholder="user@domain.com or lnbc1..."
|
||||
rows="3"
|
||||
[disabled]="paying"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="payAmount">Amount (sats) - required for addresses</label>
|
||||
<input
|
||||
id="payAmount"
|
||||
type="number"
|
||||
[(ngModel)]="payAmount"
|
||||
placeholder="Optional for invoices"
|
||||
min="1"
|
||||
[disabled]="paying"
|
||||
/>
|
||||
</div>
|
||||
@if (paymentError) {
|
||||
<div class="error-message">{{ paymentError }}</div>
|
||||
}
|
||||
@if (paymentSuccess) {
|
||||
<div class="success-message payment-success">Payment Successful!</div>
|
||||
}
|
||||
@if (!paymentSuccess) {
|
||||
<div class="form-actions">
|
||||
<button class="test-btn" (click)="closePayModal()" [disabled]="paying">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="payInvoiceOrAddress()"
|
||||
[disabled]="paying || !payInput.trim()"
|
||||
>
|
||||
{{ paying ? 'Paying...' : 'Pay' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add wallet form -->
|
||||
@else if (activeSection === 'lightning-add') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="walletName">Wallet Name</label>
|
||||
<input
|
||||
id="walletName"
|
||||
type="text"
|
||||
[(ngModel)]="newWalletName"
|
||||
placeholder="My Lightning Wallet"
|
||||
[disabled]="addingConnection"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="walletUrl">NWC Connection URL</label>
|
||||
<textarea
|
||||
id="walletUrl"
|
||||
[(ngModel)]="newWalletUrl"
|
||||
placeholder="nostr+walletconnect://..."
|
||||
rows="3"
|
||||
[disabled]="addingConnection"
|
||||
></textarea>
|
||||
</div>
|
||||
@if (connectionError) {
|
||||
<div class="error-message">{{ connectionError }}</div>
|
||||
}
|
||||
@if (connectionTestResult) {
|
||||
<div class="success-message">{{ connectionTestResult }}</div>
|
||||
}
|
||||
@if (nwcService.logs.length > 0) {
|
||||
<div class="nwc-log">
|
||||
<div class="log-header">
|
||||
<span>Connection Log</span>
|
||||
<button class="log-clear-btn" (click)="nwcService.clearLogs()">Clear</button>
|
||||
</div>
|
||||
<div class="log-entries">
|
||||
@for (entry of nwcService.logs; track entry.timestamp) {
|
||||
<div class="log-entry" [class.log-warn]="entry.level === 'warn'" [class.log-error]="entry.level === 'error'">
|
||||
<span class="log-time">{{ entry.timestamp | date:'HH:mm:ss' }}</span>
|
||||
<span class="log-message">{{ entry.message }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="test-btn"
|
||||
(click)="testConnection()"
|
||||
[disabled]="testingConnection || addingConnection"
|
||||
>
|
||||
{{ testingConnection ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="addConnection()"
|
||||
[disabled]="addingConnection"
|
||||
>
|
||||
{{ addingConnection ? 'Adding...' : 'Add Wallet' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,951 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, StorageService } from '@common';
|
||||
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: [],
|
||||
imports: [CommonModule, FormsModule],
|
||||
})
|
||||
export class WalletComponent {
|
||||
export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly nwcService = inject(NwcService);
|
||||
readonly cashuService = inject(CashuService);
|
||||
|
||||
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();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
|
||||
// Cashu onboarding methods
|
||||
dismissCashuInfo() {
|
||||
this.showCashuInfo = false;
|
||||
}
|
||||
|
||||
navigateToSettings() {
|
||||
this.#router.navigateByUrl('/home/settings');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export class WhitelistedAppsComponent extends NavComponent {
|
||||
@ViewChild('toast') toast!: ToastComponent;
|
||||
@ViewChild('confirm') confirm!: ConfirmComponent;
|
||||
|
||||
readonly storage = inject(StorageService);
|
||||
override readonly storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
get whitelistedHosts(): string[] {
|
||||
|
||||
@@ -6,26 +6,55 @@ import {
|
||||
CryptoHelper,
|
||||
SignerMetaData,
|
||||
Identity_DECRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
Nip07Method,
|
||||
Nip07MethodPolicy,
|
||||
NostrHelper,
|
||||
Permission_DECRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_DECRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
NwcConnection_DECRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
CashuMint_DECRYPTED,
|
||||
CashuMint_ENCRYPTED,
|
||||
deriveKeyArgon2,
|
||||
ExtensionMethod,
|
||||
WeblnMethod,
|
||||
} from '@common';
|
||||
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
|
||||
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
export const debug = function (message: any) {
|
||||
const dateString = new Date().toISOString();
|
||||
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
||||
};
|
||||
// Unlock request/response message types
|
||||
export interface UnlockRequestMessage {
|
||||
type: 'unlock-request';
|
||||
id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UnlockResponseMessage {
|
||||
type: 'unlock-response';
|
||||
id: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Debug logging disabled - uncomment for development
|
||||
// export const debug = function (message: any) {
|
||||
// const dateString = new Date().toISOString();
|
||||
// console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
||||
// };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
|
||||
export const debug = function (_message: any) {};
|
||||
|
||||
export type PromptResponse =
|
||||
| 'reject'
|
||||
| 'reject-once'
|
||||
| 'reject-all' // P2: Reject all requests of this type from this host
|
||||
| 'approve'
|
||||
| 'approve-once';
|
||||
| 'approve-once'
|
||||
| 'approve-all'; // P2: Approve all requests of this type from this host
|
||||
|
||||
export interface PromptResponseMessage {
|
||||
id: string;
|
||||
@@ -33,7 +62,7 @@ export interface PromptResponseMessage {
|
||||
}
|
||||
|
||||
export interface BackgroundRequestMessage {
|
||||
method: Nip07Method;
|
||||
method: ExtensionMethod;
|
||||
params: any;
|
||||
host: string;
|
||||
}
|
||||
@@ -196,11 +225,51 @@ export const checkPermissions = function (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a method is a WebLN method
|
||||
*/
|
||||
export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
|
||||
return method.startsWith('webln.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check WebLN permissions for a host.
|
||||
* Note: WebLN permissions are NOT tied to identities since the wallet is global.
|
||||
* For sendPayment, always returns undefined (require user prompt for security).
|
||||
*/
|
||||
export const checkWeblnPermissions = function (
|
||||
browserSessionData: BrowserSessionData,
|
||||
host: string,
|
||||
method: WeblnMethod
|
||||
): boolean | undefined {
|
||||
// sendPayment ALWAYS requires user approval (security-critical, irreversible)
|
||||
if (method === 'webln.sendPayment') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// keysend also requires approval
|
||||
if (method === 'webln.keysend') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For other WebLN methods, check stored permissions
|
||||
// WebLN permissions use 'webln' as the identityId
|
||||
const permissions = browserSessionData.permissions.filter(
|
||||
(x) => x.identityId === 'webln' && x.host === host && x.method === method
|
||||
);
|
||||
|
||||
if (permissions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return permissions.every((x) => x.methodPolicy === 'allow');
|
||||
};
|
||||
|
||||
export const storePermission = async function (
|
||||
browserSessionData: BrowserSessionData,
|
||||
identity: Identity_DECRYPTED,
|
||||
identity: Identity_DECRYPTED | null,
|
||||
host: string,
|
||||
method: Nip07Method,
|
||||
method: ExtensionMethod,
|
||||
methodPolicy: Nip07MethodPolicy,
|
||||
kind?: number
|
||||
) {
|
||||
@@ -209,11 +278,14 @@ export const storePermission = async function (
|
||||
throw new Error(`Could not retrieve sync data`);
|
||||
}
|
||||
|
||||
// For WebLN methods, use 'webln' as identityId since wallet is global
|
||||
const identityId = identity?.id ?? 'webln';
|
||||
|
||||
const permission: Permission_DECRYPTED = {
|
||||
id: crypto.randomUUID(),
|
||||
identityId: identity.id,
|
||||
identityId,
|
||||
host,
|
||||
method,
|
||||
method: method as Nip07Method, // Cast for storage compatibility
|
||||
methodPolicy,
|
||||
kind,
|
||||
};
|
||||
@@ -372,3 +444,352 @@ const encrypt = async function (
|
||||
// v1: Use password with PBKDF2
|
||||
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Unlock Vault Logic (for background script)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Decrypt a value using AES-GCM with pre-derived key (v2)
|
||||
*/
|
||||
async function decryptV2(
|
||||
encryptedBase64: string,
|
||||
ivBase64: string,
|
||||
keyBase64: string
|
||||
): Promise<string> {
|
||||
const keyBytes = Buffer.from(keyBase64, 'base64');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const cipherText = Buffer.from(encryptedBase64, 'base64');
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
cipherText
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value using PBKDF2 (v1)
|
||||
*/
|
||||
async function decryptV1(
|
||||
encryptedBase64: string,
|
||||
ivBase64: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic decrypt function that handles both v1 and v2
|
||||
*/
|
||||
async function decryptValue(
|
||||
encrypted: string,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<string> {
|
||||
if (isV2) {
|
||||
return decryptV2(encrypted, iv, keyOrPassword);
|
||||
}
|
||||
return decryptV1(encrypted, iv, keyOrPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse decrypted value to the desired type
|
||||
*/
|
||||
function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return parseInt(value);
|
||||
case 'boolean':
|
||||
return value === 'true';
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an identity
|
||||
*/
|
||||
async function decryptIdentity(
|
||||
identity: Identity_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<Identity_DECRYPTED> {
|
||||
return {
|
||||
id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
|
||||
nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
|
||||
createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
|
||||
privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a permission
|
||||
*/
|
||||
async function decryptPermission(
|
||||
permission: Permission_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<Permission_DECRYPTED> {
|
||||
const decrypted: Permission_DECRYPTED = {
|
||||
id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
|
||||
identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
|
||||
host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
|
||||
method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
|
||||
methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
|
||||
};
|
||||
if (permission.kind) {
|
||||
decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a relay
|
||||
*/
|
||||
async function decryptRelay(
|
||||
relay: Relay_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<Relay_DECRYPTED> {
|
||||
return {
|
||||
id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
|
||||
identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
|
||||
url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
|
||||
read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
|
||||
write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an NWC connection
|
||||
*/
|
||||
async function decryptNwcConnection(
|
||||
nwc: NwcConnection_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<NwcConnection_DECRYPTED> {
|
||||
const decrypted: NwcConnection_DECRYPTED = {
|
||||
id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
|
||||
name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
|
||||
connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
|
||||
walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
|
||||
relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
|
||||
secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
|
||||
createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
|
||||
};
|
||||
if (nwc.lud16) {
|
||||
decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
|
||||
}
|
||||
if (nwc.cachedBalance) {
|
||||
decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
|
||||
}
|
||||
if (nwc.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a Cashu mint
|
||||
*/
|
||||
async function decryptCashuMint(
|
||||
mint: CashuMint_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<CashuMint_DECRYPTED> {
|
||||
const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
|
||||
const decrypted: CashuMint_DECRYPTED = {
|
||||
id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
|
||||
name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
|
||||
mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
|
||||
unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
|
||||
createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
|
||||
proofs: JSON.parse(proofsJson),
|
||||
};
|
||||
if (mint.cachedBalance) {
|
||||
decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
|
||||
}
|
||||
if (mint.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an unlock request from the unlock popup
|
||||
*/
|
||||
export async function handleUnlockRequest(
|
||||
password: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
debug('handleUnlockRequest: Starting unlock...');
|
||||
|
||||
// Check if already unlocked
|
||||
const existingSession = await getBrowserSessionData();
|
||||
if (existingSession) {
|
||||
debug('handleUnlockRequest: Already unlocked');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Get sync data
|
||||
const browserSyncData = await getBrowserSyncData();
|
||||
if (!browserSyncData) {
|
||||
return { success: false, error: 'No vault data found' };
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordHash = await CryptoHelper.hash(password);
|
||||
if (passwordHash !== browserSyncData.vaultHash) {
|
||||
return { success: false, error: 'Invalid password' };
|
||||
}
|
||||
debug('handleUnlockRequest: Password verified');
|
||||
|
||||
// Detect vault version
|
||||
const isV2 = !!browserSyncData.salt;
|
||||
debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
|
||||
|
||||
let keyOrPassword: string;
|
||||
let vaultKey: string | undefined;
|
||||
let vaultPassword: string | undefined;
|
||||
|
||||
if (isV2) {
|
||||
// v2: Derive key with Argon2id (~3 seconds)
|
||||
debug('handleUnlockRequest: Deriving Argon2id key...');
|
||||
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
|
||||
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||
vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||
keyOrPassword = vaultKey;
|
||||
debug('handleUnlockRequest: Key derived');
|
||||
} else {
|
||||
// v1: Use password directly
|
||||
vaultPassword = password;
|
||||
keyOrPassword = password;
|
||||
}
|
||||
|
||||
// Decrypt identities
|
||||
debug('handleUnlockRequest: Decrypting identities...');
|
||||
const decryptedIdentities: Identity_DECRYPTED[] = [];
|
||||
for (const identity of browserSyncData.identities) {
|
||||
const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedIdentities.push(decrypted);
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
|
||||
|
||||
// Decrypt permissions
|
||||
debug('handleUnlockRequest: Decrypting permissions...');
|
||||
const decryptedPermissions: Permission_DECRYPTED[] = [];
|
||||
for (const permission of browserSyncData.permissions) {
|
||||
try {
|
||||
const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedPermissions.push(decrypted);
|
||||
} catch (e) {
|
||||
debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
|
||||
}
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
|
||||
|
||||
// Decrypt relays
|
||||
debug('handleUnlockRequest: Decrypting relays...');
|
||||
const decryptedRelays: Relay_DECRYPTED[] = [];
|
||||
for (const relay of browserSyncData.relays) {
|
||||
const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedRelays.push(decrypted);
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
|
||||
|
||||
// Decrypt NWC connections
|
||||
debug('handleUnlockRequest: Decrypting NWC connections...');
|
||||
const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
|
||||
for (const nwc of browserSyncData.nwcConnections ?? []) {
|
||||
const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedNwcConnections.push(decrypted);
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
|
||||
|
||||
// Decrypt Cashu mints
|
||||
debug('handleUnlockRequest: Decrypting Cashu mints...');
|
||||
const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
|
||||
for (const mint of browserSyncData.cashuMints ?? []) {
|
||||
const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedCashuMints.push(decrypted);
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
|
||||
|
||||
// Decrypt selectedIdentityId
|
||||
let decryptedSelectedIdentityId: string | null = null;
|
||||
if (browserSyncData.selectedIdentityId !== null) {
|
||||
decryptedSelectedIdentityId = await decryptValue(
|
||||
browserSyncData.selectedIdentityId,
|
||||
browserSyncData.iv,
|
||||
keyOrPassword,
|
||||
isV2
|
||||
);
|
||||
}
|
||||
debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
|
||||
|
||||
// Build session data
|
||||
const browserSessionData: BrowserSessionData = {
|
||||
vaultPassword: isV2 ? undefined : vaultPassword,
|
||||
vaultKey: isV2 ? vaultKey : undefined,
|
||||
iv: browserSyncData.iv,
|
||||
salt: browserSyncData.salt,
|
||||
permissions: decryptedPermissions,
|
||||
identities: decryptedIdentities,
|
||||
selectedIdentityId: decryptedSelectedIdentityId,
|
||||
relays: decryptedRelays,
|
||||
nwcConnections: decryptedNwcConnections,
|
||||
cashuMints: decryptedCashuMints,
|
||||
};
|
||||
|
||||
// Save session data
|
||||
debug('handleUnlockRequest: Saving session data...');
|
||||
await chrome.storage.session.set(browserSessionData);
|
||||
debug('handleUnlockRequest: Unlock complete!');
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
debug(`handleUnlockRequest: Error: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Unlock failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the unlock popup window
|
||||
*/
|
||||
export async function openUnlockPopup(host?: string): Promise<void> {
|
||||
const width = 375;
|
||||
const height = 500;
|
||||
const { top, left } = await getPosition(width, height);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
let url = `unlock.html?id=${id}`;
|
||||
if (host) {
|
||||
url += `&host=${encodeURIComponent(host)}`;
|
||||
}
|
||||
|
||||
await chrome.windows.create({
|
||||
type: 'popup',
|
||||
url,
|
||||
height,
|
||||
width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,40 +1,336 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
backgroundLogNip07Action,
|
||||
backgroundLogPermissionStored,
|
||||
NostrHelper,
|
||||
NwcClient,
|
||||
NwcConnection_DECRYPTED,
|
||||
WeblnMethod,
|
||||
Nip07Method,
|
||||
GetInfoResponse,
|
||||
SendPaymentResponse,
|
||||
RequestInvoiceResponse,
|
||||
} from '@common';
|
||||
import {
|
||||
BackgroundRequestMessage,
|
||||
checkPermissions,
|
||||
checkWeblnPermissions,
|
||||
debug,
|
||||
getBrowserSessionData,
|
||||
getPosition,
|
||||
handleUnlockRequest,
|
||||
isWeblnMethod,
|
||||
nip04Decrypt,
|
||||
nip04Encrypt,
|
||||
nip44Decrypt,
|
||||
nip44Encrypt,
|
||||
openUnlockPopup,
|
||||
PromptResponse,
|
||||
PromptResponseMessage,
|
||||
shouldRecklessModeApprove,
|
||||
signEvent,
|
||||
storePermission,
|
||||
UnlockRequestMessage,
|
||||
UnlockResponseMessage,
|
||||
} from './background-common';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
// Cache for NWC clients to avoid reconnecting for each request
|
||||
const nwcClientCache = new Map<string, NwcClient>();
|
||||
|
||||
/**
|
||||
* Get or create an NWC client for a connection
|
||||
*/
|
||||
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
|
||||
const cached = nwcClientCache.get(connection.id);
|
||||
if (cached && cached.isConnected()) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const client = new NwcClient({
|
||||
walletPubkey: connection.walletPubkey,
|
||||
relayUrl: connection.relayUrl,
|
||||
secret: connection.secret,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
nwcClientCache.set(connection.id, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse invoice amount from a BOLT11 invoice string
|
||||
* Returns amount in satoshis, or undefined if no amount specified
|
||||
*/
|
||||
function parseInvoiceAmount(invoice: string): number | undefined {
|
||||
try {
|
||||
// BOLT11 invoices start with 'ln' followed by network prefix and amount
|
||||
// Format: ln[network][amount][multiplier]1[data]
|
||||
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
|
||||
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const amountStr = match[2];
|
||||
const multiplier = match[3];
|
||||
|
||||
let amount = parseInt(amountStr, 10);
|
||||
|
||||
// Apply multiplier (amount is in BTC by default)
|
||||
switch (multiplier) {
|
||||
case 'm': // milli-bitcoin (0.001 BTC)
|
||||
amount = amount * 100000;
|
||||
break;
|
||||
case 'u': // micro-bitcoin (0.000001 BTC)
|
||||
amount = amount * 100;
|
||||
break;
|
||||
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
|
||||
amount = Math.floor(amount / 10);
|
||||
break;
|
||||
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
|
||||
amount = Math.floor(amount / 10000);
|
||||
break;
|
||||
default:
|
||||
// No multiplier means BTC
|
||||
amount = amount * 100000000;
|
||||
}
|
||||
|
||||
return amount;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
// ==========================================
|
||||
// Permission Prompt Queue System (P0)
|
||||
// ==========================================
|
||||
|
||||
// Timeout for permission prompts (30 seconds)
|
||||
const PROMPT_TIMEOUT_MS = 30000;
|
||||
|
||||
// Maximum number of queued permission requests (prevent DoS)
|
||||
const MAX_PERMISSION_QUEUE_SIZE = 100;
|
||||
|
||||
// Track open prompts with metadata for cleanup
|
||||
const openPrompts = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (reason?: any) => void;
|
||||
windowId?: number;
|
||||
timeoutId?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Track if unlock popup is already open
|
||||
let unlockPopupOpen = false;
|
||||
|
||||
// Queue of pending NIP-07 requests waiting for unlock
|
||||
const pendingRequests: {
|
||||
request: BackgroundRequestMessage;
|
||||
resolve: (result: any) => void;
|
||||
reject: (error: any) => void;
|
||||
}[] = [];
|
||||
|
||||
// Queue for permission requests (only one prompt shown at a time)
|
||||
interface PermissionQueueItem {
|
||||
id: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
|
||||
const permissionQueue: PermissionQueueItem[] = [];
|
||||
let activePromptId: string | null = null;
|
||||
|
||||
/**
|
||||
* Show the next permission prompt from the queue
|
||||
*/
|
||||
async function showNextPermissionPrompt(): Promise<void> {
|
||||
if (activePromptId || permissionQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = permissionQueue[0];
|
||||
activePromptId = next.id;
|
||||
|
||||
const { top, left } = await getPosition(next.width, next.height);
|
||||
|
||||
try {
|
||||
const window = await browser.windows.create({
|
||||
type: 'popup',
|
||||
url: next.url,
|
||||
height: next.height,
|
||||
width: next.width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
|
||||
const promptData = openPrompts.get(next.id);
|
||||
if (promptData && window.id) {
|
||||
promptData.windowId = window.id;
|
||||
promptData.timeoutId = setTimeout(() => {
|
||||
debug(`Prompt ${next.id} timed out after ${PROMPT_TIMEOUT_MS}ms`);
|
||||
cleanupPrompt(next.id, 'timeout');
|
||||
}, PROMPT_TIMEOUT_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
debug(`Failed to create prompt window: ${error}`);
|
||||
cleanupPrompt(next.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a prompt and process the next one in queue
|
||||
*/
|
||||
function cleanupPrompt(promptId: string, reason: 'response' | 'timeout' | 'closed' | 'error'): void {
|
||||
const promptData = openPrompts.get(promptId);
|
||||
|
||||
if (promptData) {
|
||||
if (promptData.timeoutId) {
|
||||
clearTimeout(promptData.timeoutId);
|
||||
}
|
||||
if (reason !== 'response') {
|
||||
promptData.reject(new Error(`Permission prompt ${reason}`));
|
||||
}
|
||||
openPrompts.delete(promptId);
|
||||
}
|
||||
|
||||
const queueIndex = permissionQueue.findIndex(item => item.id === promptId);
|
||||
if (queueIndex !== -1) {
|
||||
permissionQueue.splice(queueIndex, 1);
|
||||
}
|
||||
|
||||
if (activePromptId === promptId) {
|
||||
activePromptId = null;
|
||||
}
|
||||
|
||||
showNextPermissionPrompt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a permission prompt request
|
||||
*/
|
||||
function queuePermissionPrompt(
|
||||
urlWithoutId: string,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<PromptResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (permissionQueue.length >= MAX_PERMISSION_QUEUE_SIZE) {
|
||||
reject(new Error('Too many pending permission requests. Please try again later.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const separator = urlWithoutId.includes('?') ? '&' : '?';
|
||||
const url = `${urlWithoutId}${separator}id=${id}`;
|
||||
|
||||
openPrompts.set(id, { resolve, reject });
|
||||
permissionQueue.push({ id, url, width, height, resolve, reject });
|
||||
|
||||
debug(`Queued permission prompt ${id}. Queue size: ${permissionQueue.length}`);
|
||||
showNextPermissionPrompt();
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for window close events to clean up orphaned prompts
|
||||
browser.windows.onRemoved.addListener((windowId: number) => {
|
||||
for (const [promptId, promptData] of openPrompts.entries()) {
|
||||
if (promptData.windowId === windowId) {
|
||||
debug(`Prompt window ${windowId} closed without response`);
|
||||
cleanupPrompt(promptId, 'closed');
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Request Deduplication (P1)
|
||||
// ==========================================
|
||||
|
||||
const pendingRequestPromises = new Map<string, Promise<PromptResponse>>();
|
||||
|
||||
/**
|
||||
* Generate a hash key for request deduplication
|
||||
*/
|
||||
function getRequestHash(host: string, method: string, params: any): string {
|
||||
if (method === 'signEvent' && params?.kind !== undefined) {
|
||||
return `${host}:${method}:kind${params.kind}`;
|
||||
}
|
||||
if ((method.includes('encrypt') || method.includes('decrypt')) && params?.peerPubkey) {
|
||||
return `${host}:${method}:${params.peerPubkey}`;
|
||||
}
|
||||
return `${host}:${method}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a permission prompt with deduplication
|
||||
*/
|
||||
function queuePermissionPromptDeduped(
|
||||
host: string,
|
||||
method: string,
|
||||
params: any,
|
||||
urlWithoutId: string,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<PromptResponse> {
|
||||
const hash = getRequestHash(host, method, params);
|
||||
|
||||
const existingPromise = pendingRequestPromises.get(hash);
|
||||
if (existingPromise) {
|
||||
debug(`Deduplicating request: ${hash}`);
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const promise = queuePermissionPrompt(urlWithoutId, width, height)
|
||||
.finally(() => {
|
||||
pendingRequestPromises.delete(hash);
|
||||
});
|
||||
|
||||
pendingRequestPromises.set(hash, promise);
|
||||
debug(`New permission request: ${hash}`);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
debug('Message received');
|
||||
|
||||
// Handle unlock request from unlock popup
|
||||
if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
|
||||
const unlockReq = message as UnlockRequestMessage;
|
||||
debug('Processing unlock request');
|
||||
const result = await handleUnlockRequest(unlockReq.password);
|
||||
const response: UnlockResponseMessage = {
|
||||
type: 'unlock-response',
|
||||
id: unlockReq.id,
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
unlockPopupOpen = false;
|
||||
// Process any pending NIP-07 requests
|
||||
debug(`Processing ${pendingRequests.length} pending requests`);
|
||||
while (pendingRequests.length > 0) {
|
||||
const pending = pendingRequests.shift()!;
|
||||
try {
|
||||
const pendingResult = await processNip07Request(pending.request);
|
||||
pending.resolve(pendingResult);
|
||||
} catch (error) {
|
||||
pending.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const request = message as BackgroundRequestMessage | PromptResponseMessage;
|
||||
debug(request);
|
||||
|
||||
@@ -43,18 +339,47 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
const promptResponse = request as PromptResponseMessage;
|
||||
const openPrompt = openPrompts.get(promptResponse.id);
|
||||
if (!openPrompt) {
|
||||
throw new Error(
|
||||
'Prompt response could not be matched to any previous request.'
|
||||
);
|
||||
debug('Prompt response could not be matched (may have timed out)');
|
||||
return;
|
||||
}
|
||||
|
||||
openPrompt.resolve(promptResponse.response);
|
||||
openPrompts.delete(promptResponse.id);
|
||||
cleanupPrompt(promptResponse.id, 'response');
|
||||
return;
|
||||
}
|
||||
|
||||
const browserSessionData = await getBrowserSessionData();
|
||||
|
||||
if (!browserSessionData) {
|
||||
// Vault is locked - open unlock popup and queue the request
|
||||
const req = request as BackgroundRequestMessage;
|
||||
debug('Vault locked, opening unlock popup');
|
||||
|
||||
if (!unlockPopupOpen) {
|
||||
unlockPopupOpen = true;
|
||||
await openUnlockPopup(req.host);
|
||||
}
|
||||
|
||||
// Queue this request to be processed after unlock
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.push({ request: req, resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
// Process the request (NIP-07 or WebLN)
|
||||
const req = request as BackgroundRequestMessage;
|
||||
if (isWeblnMethod(req.method)) {
|
||||
return processWeblnRequest(req);
|
||||
}
|
||||
return processNip07Request(req);
|
||||
});
|
||||
|
||||
/**
|
||||
* Process a NIP-07 request after vault is unlocked
|
||||
*/
|
||||
async function processNip07Request(req: BackgroundRequestMessage): Promise<any> {
|
||||
const browserSessionData = await getBrowserSessionData();
|
||||
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
||||
}
|
||||
@@ -67,8 +392,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
throw new Error('No Nostr identity available at endpoint.');
|
||||
}
|
||||
|
||||
const req = request as BackgroundRequestMessage;
|
||||
|
||||
// Check reckless mode first
|
||||
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||
debug(`recklessApprove result: ${recklessApprove}`);
|
||||
@@ -80,7 +403,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
req.method as Nip07Method,
|
||||
req.params
|
||||
);
|
||||
debug(`permissionState result: ${permissionState}`);
|
||||
@@ -90,29 +413,23 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
}
|
||||
|
||||
if (permissionState === undefined) {
|
||||
// Ask user for permission.
|
||||
// Ask user for permission (queued + deduplicated)
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
const { top, left } = await getPosition(width, height);
|
||||
|
||||
const base64Event = Buffer.from(
|
||||
JSON.stringify(req.params ?? {}, undefined, 2)
|
||||
).toString('base64');
|
||||
|
||||
const response = await new Promise<PromptResponse>((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
openPrompts.set(id, { resolve, reject });
|
||||
browser.windows.create({
|
||||
type: 'popup',
|
||||
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
|
||||
height,
|
||||
width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
});
|
||||
// Include queue info for user awareness
|
||||
const queueSize = permissionQueue.length;
|
||||
const promptUrl = `prompt.html?method=${req.method}&host=${req.host}&nick=${encodeURIComponent(currentIdentity.nick)}&event=${base64Event}&queue=${queueSize}`;
|
||||
const response = await queuePermissionPromptDeduped(req.host, req.method, req.params, promptUrl, width, height);
|
||||
debug(response);
|
||||
|
||||
// Handle permission storage based on response type
|
||||
if (response === 'approve' || response === 'reject') {
|
||||
// Store permission for this specific kind (if signEvent) or method
|
||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
@@ -122,19 +439,29 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
policy,
|
||||
req.params?.kind
|
||||
);
|
||||
await backgroundLogPermissionStored(
|
||||
} else if (response === 'approve-all') {
|
||||
// P2: Store permission for ALL kinds/uses of this method from this host
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
policy,
|
||||
req.params?.kind
|
||||
'allow',
|
||||
undefined // undefined kind = allow all kinds for signEvent
|
||||
);
|
||||
} else if (response === 'reject-all') {
|
||||
// P2: Store deny permission for ALL uses of this method from this host
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
'deny',
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once'].includes(response)) {
|
||||
await backgroundLogNip07Action(req.method, req.host, false, false, {
|
||||
kind: req.params?.kind,
|
||||
peerPubkey: req.params?.peerPubkey,
|
||||
});
|
||||
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
} else {
|
||||
@@ -143,73 +470,191 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
}
|
||||
|
||||
const relays: Relays = {};
|
||||
let result: any;
|
||||
|
||||
switch (req.method) {
|
||||
case 'getPublicKey':
|
||||
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
|
||||
return result;
|
||||
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
|
||||
case 'signEvent':
|
||||
result = signEvent(req.params, currentIdentity.privkey);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
kind: req.params?.kind,
|
||||
});
|
||||
return result;
|
||||
return signEvent(req.params, currentIdentity.privkey);
|
||||
|
||||
case 'getRelays':
|
||||
browserSessionData.relays.forEach((x) => {
|
||||
relays[x.url] = { read: x.read, write: x.write };
|
||||
});
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
|
||||
return relays;
|
||||
|
||||
case 'nip04.encrypt':
|
||||
result = await nip04Encrypt(
|
||||
return await nip04Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip44.encrypt':
|
||||
result = await nip44Encrypt(
|
||||
return await nip44Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip04.decrypt':
|
||||
result = await nip04Decrypt(
|
||||
return await nip04Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip44.decrypt':
|
||||
result = await nip44Decrypt(
|
||||
return await nip44Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
default:
|
||||
throw new Error(`Not supported request method '${req.method}'.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a WebLN request after vault is unlocked
|
||||
*/
|
||||
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
|
||||
const browserSessionData = await getBrowserSessionData();
|
||||
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
||||
}
|
||||
|
||||
const nwcConnections = browserSessionData.nwcConnections ?? [];
|
||||
const method = req.method as WeblnMethod;
|
||||
|
||||
// webln.enable just checks if NWC is configured
|
||||
if (method === 'webln.enable') {
|
||||
if (nwcConnections.length === 0) {
|
||||
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||
}
|
||||
debug('WebLN enabled');
|
||||
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
|
||||
}
|
||||
|
||||
// All other methods require an NWC connection
|
||||
const defaultConnection = nwcConnections[0];
|
||||
if (!defaultConnection) {
|
||||
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||
}
|
||||
|
||||
// Check reckless mode (but still prompt for payments)
|
||||
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||
|
||||
// Check WebLN permissions
|
||||
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
|
||||
? true
|
||||
: checkWeblnPermissions(browserSessionData, req.host, method);
|
||||
|
||||
if (permissionState === false) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
if (permissionState === undefined) {
|
||||
// Ask user for permission (queued + deduplicated)
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
|
||||
// For sendPayment, include the invoice amount in the prompt data
|
||||
let promptParams = req.params ?? {};
|
||||
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
|
||||
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
|
||||
promptParams = { ...promptParams, amountSats };
|
||||
}
|
||||
|
||||
const base64Event = Buffer.from(
|
||||
JSON.stringify(promptParams, undefined, 2)
|
||||
).toString('base64');
|
||||
|
||||
// Include queue info for user awareness
|
||||
const queueSize = permissionQueue.length;
|
||||
const promptUrl = `prompt.html?method=${method}&host=${req.host}&nick=WebLN&event=${base64Event}&queue=${queueSize}`;
|
||||
const response = await queuePermissionPromptDeduped(req.host, method, req.params, promptUrl, width, height);
|
||||
|
||||
debug(response);
|
||||
|
||||
// Store permission for non-payment methods
|
||||
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
null, // WebLN has no identity
|
||||
req.host,
|
||||
method,
|
||||
policy
|
||||
);
|
||||
} else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||
// P2: Store permission for all uses of this WebLN method
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
null,
|
||||
req.host,
|
||||
method,
|
||||
'allow'
|
||||
);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the WebLN method
|
||||
let result: any;
|
||||
const client = await getNwcClient(defaultConnection);
|
||||
|
||||
switch (method) {
|
||||
case 'webln.getInfo': {
|
||||
const info = await client.getInfo();
|
||||
result = {
|
||||
node: {
|
||||
alias: info.alias,
|
||||
pubkey: info.pubkey,
|
||||
color: info.color,
|
||||
},
|
||||
} as GetInfoResponse;
|
||||
debug('webln.getInfo result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'webln.sendPayment': {
|
||||
const invoice = req.params.paymentRequest;
|
||||
const payResult = await client.payInvoice({ invoice });
|
||||
result = { preimage: payResult.preimage } as SendPaymentResponse;
|
||||
debug('webln.sendPayment result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'webln.makeInvoice': {
|
||||
// Convert sats to millisats (NWC uses millisats)
|
||||
const amountSats = typeof req.params.amount === 'string'
|
||||
? parseInt(req.params.amount, 10)
|
||||
: req.params.amount ?? req.params.defaultAmount ?? 0;
|
||||
const amountMsat = amountSats * 1000;
|
||||
|
||||
const invoiceResult = await client.makeInvoice({
|
||||
amount: amountMsat,
|
||||
description: req.params.defaultMemo,
|
||||
});
|
||||
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
|
||||
debug('webln.makeInvoice result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'webln.keysend':
|
||||
throw new Error('keysend is not yet supported');
|
||||
|
||||
default:
|
||||
throw new Error(`Not supported WebLN method '${method}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Event, EventTemplate } from 'nostr-tools';
|
||||
import { Nip07Method } from '@common';
|
||||
import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
|
||||
import { ExtensionMethod } from '@common';
|
||||
|
||||
// Extend Window interface for NIP-07
|
||||
// Extend Window interface for NIP-07 and WebLN
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: any;
|
||||
webln?: any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +39,7 @@ class Messenger {
|
||||
window.addEventListener('message', this.#handleCallResponse.bind(this));
|
||||
}
|
||||
|
||||
async request(method: Nip07Method, params: any): Promise<any> {
|
||||
async request(method: ExtensionMethod, params: any): Promise<any> {
|
||||
const id = generateUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -89,7 +90,7 @@ const nostr = {
|
||||
return pubkey;
|
||||
},
|
||||
|
||||
async signEvent(event: EventTemplate): Promise<Event> {
|
||||
async signEvent(event: EventTemplate): Promise<NostrEvent> {
|
||||
debug('signEvent received');
|
||||
const signedEvent = await this.messenger.request('signEvent', event);
|
||||
debug('signEvent response:');
|
||||
@@ -158,6 +159,92 @@ const nostr = {
|
||||
|
||||
window.nostr = nostr as any;
|
||||
|
||||
// WebLN types (inline to avoid build issues with @common types in injected script)
|
||||
interface RequestInvoiceArgs {
|
||||
amount?: string | number;
|
||||
defaultAmount?: string | number;
|
||||
minimumAmount?: string | number;
|
||||
maximumAmount?: string | number;
|
||||
defaultMemo?: string;
|
||||
}
|
||||
|
||||
interface KeysendArgs {
|
||||
destination: string;
|
||||
amount: string | number;
|
||||
customRecords?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Create a shared messenger instance for WebLN
|
||||
const weblnMessenger = nostr.messenger;
|
||||
|
||||
const webln = {
|
||||
enabled: false,
|
||||
|
||||
async enable(): Promise<void> {
|
||||
debug('webln.enable received');
|
||||
await weblnMessenger.request('webln.enable', {});
|
||||
this.enabled = true;
|
||||
debug('webln.enable completed');
|
||||
// Dispatch webln:enabled event as per WebLN spec
|
||||
window.dispatchEvent(new Event('webln:enabled'));
|
||||
},
|
||||
|
||||
async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
|
||||
debug('webln.getInfo received');
|
||||
const info = await weblnMessenger.request('webln.getInfo', {});
|
||||
debug('webln.getInfo response:');
|
||||
debug(info);
|
||||
return info;
|
||||
},
|
||||
|
||||
async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
|
||||
debug('webln.sendPayment received');
|
||||
const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
|
||||
debug('webln.sendPayment response:');
|
||||
debug(result);
|
||||
return result;
|
||||
},
|
||||
|
||||
async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
|
||||
debug('webln.keysend received');
|
||||
const result = await weblnMessenger.request('webln.keysend', args);
|
||||
debug('webln.keysend response:');
|
||||
debug(result);
|
||||
return result;
|
||||
},
|
||||
|
||||
async makeInvoice(
|
||||
args: string | number | RequestInvoiceArgs
|
||||
): Promise<{ paymentRequest: string }> {
|
||||
debug('webln.makeInvoice received');
|
||||
// Normalize args to RequestInvoiceArgs
|
||||
let normalizedArgs: RequestInvoiceArgs;
|
||||
if (typeof args === 'string' || typeof args === 'number') {
|
||||
normalizedArgs = { amount: args };
|
||||
} else {
|
||||
normalizedArgs = args;
|
||||
}
|
||||
const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
|
||||
debug('webln.makeInvoice response:');
|
||||
debug(result);
|
||||
return result;
|
||||
},
|
||||
|
||||
signMessage(): Promise<{ message: string; signature: string }> {
|
||||
throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
|
||||
},
|
||||
|
||||
verifyMessage(): Promise<void> {
|
||||
throw new Error('verifyMessage is not supported - NWC does not provide message verification');
|
||||
},
|
||||
};
|
||||
|
||||
window.webln = webln as any;
|
||||
|
||||
// Dispatch webln:ready event to signal that webln is available
|
||||
// This is dispatched on document as per the WebLN standard
|
||||
document.dispatchEvent(new Event('webln:ready'));
|
||||
|
||||
const debug = function (value: any) {
|
||||
console.log(JSON.stringify(value));
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
import { Nip07Method } from '@common';
|
||||
import { ExtensionMethod } from '@common';
|
||||
import { PromptResponse, PromptResponseMessage } from './background-common';
|
||||
|
||||
/**
|
||||
@@ -14,7 +14,7 @@ function base64ToUtf8(base64: string): string {
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = params.get('id') as string;
|
||||
const method = params.get('method') as Nip07Method;
|
||||
const method = params.get('method') as ExtensionMethod;
|
||||
const host = params.get('host') as string;
|
||||
const nick = params.get('nick') as string;
|
||||
|
||||
@@ -58,6 +58,26 @@ switch (method) {
|
||||
title = 'Get Relays';
|
||||
break;
|
||||
|
||||
case 'webln.enable':
|
||||
title = 'Enable WebLN';
|
||||
break;
|
||||
|
||||
case 'webln.getInfo':
|
||||
title = 'Wallet Info';
|
||||
break;
|
||||
|
||||
case 'webln.sendPayment':
|
||||
title = 'Send Payment';
|
||||
break;
|
||||
|
||||
case 'webln.makeInvoice':
|
||||
title = 'Create Invoice';
|
||||
break;
|
||||
|
||||
case 'webln.keysend':
|
||||
title = 'Keysend Payment';
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -185,6 +205,65 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
|
||||
}
|
||||
}
|
||||
|
||||
// WebLN card visibility
|
||||
const cardWeblnEnableElement = document.getElementById('cardWeblnEnable');
|
||||
if (cardWeblnEnableElement) {
|
||||
if (method !== 'webln.enable') {
|
||||
cardWeblnEnableElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardWeblnGetInfoElement = document.getElementById('cardWeblnGetInfo');
|
||||
if (cardWeblnGetInfoElement) {
|
||||
if (method !== 'webln.getInfo') {
|
||||
cardWeblnGetInfoElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardWeblnSendPaymentElement = document.getElementById('cardWeblnSendPayment');
|
||||
const card2WeblnSendPaymentElement = document.getElementById('card2WeblnSendPayment');
|
||||
if (cardWeblnSendPaymentElement && card2WeblnSendPaymentElement) {
|
||||
if (method === 'webln.sendPayment') {
|
||||
// Display amount in sats
|
||||
const paymentAmountSpan = document.getElementById('paymentAmountSpan');
|
||||
if (paymentAmountSpan && eventParsed.amountSats !== undefined) {
|
||||
paymentAmountSpan.innerText = `${eventParsed.amountSats.toLocaleString()} sats`;
|
||||
} else if (paymentAmountSpan) {
|
||||
paymentAmountSpan.innerText = 'unknown amount';
|
||||
}
|
||||
// Show invoice in json card
|
||||
const card2WeblnSendPayment_jsonElement = document.getElementById('card2WeblnSendPayment_json');
|
||||
if (card2WeblnSendPayment_jsonElement && eventParsed.paymentRequest) {
|
||||
card2WeblnSendPayment_jsonElement.innerText = eventParsed.paymentRequest;
|
||||
}
|
||||
} else {
|
||||
cardWeblnSendPaymentElement.style.display = 'none';
|
||||
card2WeblnSendPaymentElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardWeblnMakeInvoiceElement = document.getElementById('cardWeblnMakeInvoice');
|
||||
if (cardWeblnMakeInvoiceElement) {
|
||||
if (method === 'webln.makeInvoice') {
|
||||
const invoiceAmountSpan = document.getElementById('invoiceAmountSpan');
|
||||
if (invoiceAmountSpan) {
|
||||
const amount = eventParsed.amount ?? eventParsed.defaultAmount;
|
||||
if (amount) {
|
||||
invoiceAmountSpan.innerText = ` for ${Number(amount).toLocaleString()} sats`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cardWeblnMakeInvoiceElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardWeblnKeysendElement = document.getElementById('cardWeblnKeysend');
|
||||
if (cardWeblnKeysendElement) {
|
||||
if (method !== 'webln.keysend') {
|
||||
cardWeblnKeysendElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Functions
|
||||
//
|
||||
@@ -223,4 +302,21 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
approveAlwaysButton?.addEventListener('click', () => {
|
||||
deliver('approve');
|
||||
});
|
||||
|
||||
const rejectAllButton = document.getElementById('rejectAllButton');
|
||||
rejectAllButton?.addEventListener('click', () => {
|
||||
deliver('reject-all');
|
||||
});
|
||||
|
||||
const approveAllButton = document.getElementById('approveAllButton');
|
||||
approveAllButton?.addEventListener('click', () => {
|
||||
deliver('approve-all');
|
||||
});
|
||||
|
||||
// Show/hide "All Queued" row based on queue size
|
||||
const queueSize = parseInt(params.get('queueSize') || '0', 10);
|
||||
const allQueuedRow = document.getElementById('allQueuedRow');
|
||||
if (allQueuedRow && queueSize <= 1) {
|
||||
allQueuedRow.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
106
projects/chrome/src/unlock.ts
Normal file
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('IconButtonComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(IconButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.icon = 'settings'; // Required input
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('PubkeyComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(PubkeyComponent);
|
||||
component = fixture.componentInstance;
|
||||
// Valid test pubkey (64 hex chars)
|
||||
component.value = 'a'.repeat(64);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||
200
projects/common/src/lib/domain/entities/identity.spec.ts
Normal file
200
projects/common/src/lib/domain/entities/identity.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Identity, UnsignedEvent, SignedEvent, SigningFunction } from './identity';
|
||||
import { IdentityCreated, IdentityRenamed, IdentitySigned } from '../events';
|
||||
|
||||
describe('Identity Entity', () => {
|
||||
const TEST_PRIVATE_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
|
||||
describe('create', () => {
|
||||
it('should create identity with generated keypair when no private key provided', () => {
|
||||
const identity = Identity.create('Alice');
|
||||
|
||||
expect(identity.nickname).toEqual('Alice');
|
||||
expect(identity.publicKey).toBeTruthy();
|
||||
expect(identity.publicKey.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should create identity with provided private key', () => {
|
||||
const identity = Identity.create('Bob', TEST_PRIVATE_KEY);
|
||||
|
||||
expect(identity.nickname).toEqual('Bob');
|
||||
expect(identity.publicKey).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should raise IdentityCreated event', () => {
|
||||
const identity = Identity.create('Charlie');
|
||||
const events = identity.pullDomainEvents();
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toBeInstanceOf(IdentityCreated);
|
||||
|
||||
const createdEvent = events[0] as IdentityCreated;
|
||||
expect(createdEvent.identityId).toEqual(identity.id.toString());
|
||||
expect(createdEvent.publicKey).toEqual(identity.publicKey);
|
||||
expect(createdEvent.nickname).toEqual('Charlie');
|
||||
});
|
||||
|
||||
it('should set createdAt timestamp', () => {
|
||||
const before = new Date();
|
||||
const identity = Identity.create('Dana');
|
||||
const after = new Date();
|
||||
|
||||
expect(identity.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(identity.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromSnapshot', () => {
|
||||
it('should reconstruct identity from snapshot', () => {
|
||||
const original = Identity.create('Eve', TEST_PRIVATE_KEY);
|
||||
original.pullDomainEvents(); // Clear creation event
|
||||
|
||||
const snapshot = original.toSnapshot();
|
||||
const restored = Identity.fromSnapshot(snapshot);
|
||||
|
||||
expect(restored.id.toString()).toEqual(original.id.toString());
|
||||
expect(restored.nickname).toEqual('Eve');
|
||||
expect(restored.publicKey).toEqual(original.publicKey);
|
||||
});
|
||||
|
||||
it('should not raise events when loading from snapshot', () => {
|
||||
const original = Identity.create('Frank');
|
||||
const snapshot = original.toSnapshot();
|
||||
|
||||
const restored = Identity.fromSnapshot(snapshot);
|
||||
const events = restored.pullDomainEvents();
|
||||
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rename', () => {
|
||||
it('should update nickname', () => {
|
||||
const identity = Identity.create('OldName');
|
||||
identity.pullDomainEvents(); // Clear creation event
|
||||
|
||||
identity.rename('NewName');
|
||||
|
||||
expect(identity.nickname).toEqual('NewName');
|
||||
});
|
||||
|
||||
it('should raise IdentityRenamed event', () => {
|
||||
const identity = Identity.create('OldName');
|
||||
identity.pullDomainEvents(); // Clear creation event
|
||||
|
||||
identity.rename('NewName');
|
||||
const events = identity.pullDomainEvents();
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toBeInstanceOf(IdentityRenamed);
|
||||
|
||||
const renamedEvent = events[0] as IdentityRenamed;
|
||||
expect(renamedEvent.identityId).toEqual(identity.id.toString());
|
||||
expect(renamedEvent.oldNickname).toEqual('OldName');
|
||||
expect(renamedEvent.newNickname).toEqual('NewName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign', () => {
|
||||
it('should call signing function with event and return signed event', () => {
|
||||
const identity = Identity.create('Signer', TEST_PRIVATE_KEY);
|
||||
identity.pullDomainEvents();
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Hello, Nostr!',
|
||||
};
|
||||
|
||||
const mockSignFn: SigningFunction = (event, privateKeyBytes) => {
|
||||
expect(privateKeyBytes).toBeInstanceOf(Uint8Array);
|
||||
expect(privateKeyBytes.length).toBe(32);
|
||||
|
||||
return {
|
||||
...event,
|
||||
id: 'mock-event-id',
|
||||
pubkey: identity.publicKey,
|
||||
sig: 'mock-signature',
|
||||
} as SignedEvent;
|
||||
};
|
||||
|
||||
const signedEvent = identity.sign(unsignedEvent, mockSignFn);
|
||||
|
||||
expect(signedEvent.id).toEqual('mock-event-id');
|
||||
expect(signedEvent.pubkey).toEqual(identity.publicKey);
|
||||
expect(signedEvent.sig).toEqual('mock-signature');
|
||||
});
|
||||
|
||||
it('should raise IdentitySigned event', () => {
|
||||
const identity = Identity.create('Signer', TEST_PRIVATE_KEY);
|
||||
identity.pullDomainEvents();
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
};
|
||||
|
||||
const mockSignFn: SigningFunction = (event) => ({
|
||||
...event,
|
||||
id: 'signed-event-id',
|
||||
pubkey: identity.publicKey,
|
||||
sig: 'sig',
|
||||
} as SignedEvent);
|
||||
|
||||
identity.sign(unsignedEvent, mockSignFn);
|
||||
const events = identity.pullDomainEvents();
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toBeInstanceOf(IdentitySigned);
|
||||
|
||||
const signedEvt = events[0] as IdentitySigned;
|
||||
expect(signedEvt.identityId).toEqual(identity.id.toString());
|
||||
expect(signedEvt.eventKind).toBe(1);
|
||||
expect(signedEvt.signedEventId).toEqual('signed-event-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSnapshot', () => {
|
||||
it('should create complete snapshot for storage', () => {
|
||||
const identity = Identity.create('Snapshot Test', TEST_PRIVATE_KEY);
|
||||
const snapshot = identity.toSnapshot();
|
||||
|
||||
expect(snapshot.id).toEqual(identity.id.toString());
|
||||
expect(snapshot.nick).toEqual('Snapshot Test');
|
||||
expect(snapshot.privkey).toBeTruthy();
|
||||
expect(snapshot.createdAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('npub', () => {
|
||||
it('should return bech32 encoded public key', () => {
|
||||
const identity = Identity.create('NpubTest');
|
||||
|
||||
expect(identity.npub).toMatch(/^npub1[a-z0-9]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pullDomainEvents', () => {
|
||||
it('should clear events after pulling', () => {
|
||||
const identity = Identity.create('Test');
|
||||
|
||||
const firstPull = identity.pullDomainEvents();
|
||||
const secondPull = identity.pullDomainEvents();
|
||||
|
||||
expect(firstPull.length).toBe(1);
|
||||
expect(secondPull.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should accumulate multiple events', () => {
|
||||
const identity = Identity.create('Multi');
|
||||
identity.rename('Name1');
|
||||
identity.rename('Name2');
|
||||
|
||||
const events = identity.pullDomainEvents();
|
||||
|
||||
expect(events.length).toBe(3); // Created + 2 renames
|
||||
});
|
||||
});
|
||||
});
|
||||
305
projects/common/src/lib/domain/entities/identity.ts
Normal file
305
projects/common/src/lib/domain/entities/identity.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { AggregateRoot } from '../events/domain-event';
|
||||
import { IdentityCreated, IdentityRenamed, IdentitySigned } from '../events/identity-events';
|
||||
import {
|
||||
IdentityId,
|
||||
Nickname,
|
||||
NostrKeyPair,
|
||||
} from '../value-objects';
|
||||
import type { IdentitySnapshot } from '../repositories/identity-repository';
|
||||
|
||||
/**
|
||||
* Represents an unsigned Nostr event template.
|
||||
* This is what gets passed to the sign method.
|
||||
*/
|
||||
export interface UnsignedEvent {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a signed Nostr event.
|
||||
*/
|
||||
export interface SignedEvent extends UnsignedEvent {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing function type - injected to avoid coupling to nostr-tools.
|
||||
*/
|
||||
export type SigningFunction = (event: UnsignedEvent, privateKeyBytes: Uint8Array) => SignedEvent;
|
||||
|
||||
/**
|
||||
* Encryption function types for NIP-04 and NIP-44.
|
||||
*/
|
||||
export type EncryptFunction = (
|
||||
privateKeyBytes: Uint8Array,
|
||||
peerPubkey: string,
|
||||
plaintext: string
|
||||
) => Promise<string>;
|
||||
|
||||
export type DecryptFunction = (
|
||||
privateKeyBytes: Uint8Array,
|
||||
peerPubkey: string,
|
||||
ciphertext: string
|
||||
) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Identity entity - represents a Nostr identity with its keypair.
|
||||
*
|
||||
* This is an aggregate root that encapsulates all operations
|
||||
* related to a single Nostr identity.
|
||||
*/
|
||||
export class Identity extends AggregateRoot {
|
||||
private readonly _id: IdentityId;
|
||||
private _nickname: Nickname;
|
||||
private readonly _keyPair: NostrKeyPair;
|
||||
private readonly _createdAt: Date;
|
||||
|
||||
private constructor(
|
||||
id: IdentityId,
|
||||
nickname: Nickname,
|
||||
keyPair: NostrKeyPair,
|
||||
createdAt: Date
|
||||
) {
|
||||
super();
|
||||
this._id = id;
|
||||
this._nickname = nickname;
|
||||
this._keyPair = keyPair;
|
||||
this._createdAt = createdAt;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Factory Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new identity with an optional private key.
|
||||
* If no private key is provided, a new one will be generated.
|
||||
*
|
||||
* @param nickname - User-friendly name for this identity
|
||||
* @param privateKey - Optional private key (hex or nsec format)
|
||||
* @throws InvalidNicknameError if nickname is invalid
|
||||
* @throws InvalidNostrKeyError if private key is invalid
|
||||
*/
|
||||
static create(nickname: string, privateKey?: string): Identity {
|
||||
const keyPair = privateKey
|
||||
? NostrKeyPair.fromPrivateKey(privateKey)
|
||||
: NostrKeyPair.generate();
|
||||
|
||||
const identity = new Identity(
|
||||
IdentityId.generate(),
|
||||
Nickname.create(nickname),
|
||||
keyPair,
|
||||
new Date()
|
||||
);
|
||||
|
||||
identity.addDomainEvent(
|
||||
new IdentityCreated(
|
||||
identity._id.value,
|
||||
identity.publicKey,
|
||||
identity.nickname
|
||||
)
|
||||
);
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute an identity from storage.
|
||||
* This bypasses validation since data comes from trusted storage.
|
||||
*/
|
||||
static fromSnapshot(snapshot: IdentitySnapshot): Identity {
|
||||
return new Identity(
|
||||
IdentityId.from(snapshot.id),
|
||||
Nickname.fromStorage(snapshot.nick),
|
||||
NostrKeyPair.fromStorage(snapshot.privkey),
|
||||
new Date(snapshot.createdAt)
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Getters (Read-only access to state)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
get id(): IdentityId {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get nickname(): string {
|
||||
return this._nickname.value;
|
||||
}
|
||||
|
||||
get publicKey(): string {
|
||||
return this._keyPair.publicKeyHex;
|
||||
}
|
||||
|
||||
get npub(): string {
|
||||
return this._keyPair.npub;
|
||||
}
|
||||
|
||||
get nsec(): string {
|
||||
return this._keyPair.nsec;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this._createdAt;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Behavior Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Rename this identity.
|
||||
*
|
||||
* @param newNickname - The new nickname
|
||||
* @throws InvalidNicknameError if nickname is invalid
|
||||
*/
|
||||
rename(newNickname: string): void {
|
||||
const oldNickname = this._nickname.value;
|
||||
this._nickname = Nickname.create(newNickname);
|
||||
|
||||
this.addDomainEvent(
|
||||
new IdentityRenamed(this._id.value, oldNickname, newNickname)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a Nostr event with this identity's private key.
|
||||
*
|
||||
* @param event - The unsigned event template
|
||||
* @param signFn - The signing function (injected to avoid coupling)
|
||||
* @returns The signed event with id, pubkey, and sig
|
||||
*/
|
||||
sign(event: UnsignedEvent, signFn: SigningFunction): SignedEvent {
|
||||
const signedEvent = signFn(event, this._keyPair.getPrivateKeyBytes());
|
||||
|
||||
this.addDomainEvent(
|
||||
new IdentitySigned(this._id.value, event.kind, signedEvent.id)
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message using NIP-04 encryption.
|
||||
*
|
||||
* @param plaintext - The message to encrypt
|
||||
* @param recipientPubkey - The recipient's public key (hex)
|
||||
* @param encryptFn - The NIP-04 encryption function
|
||||
*/
|
||||
async encryptNip04(
|
||||
plaintext: string,
|
||||
recipientPubkey: string,
|
||||
encryptFn: EncryptFunction
|
||||
): Promise<string> {
|
||||
return encryptFn(
|
||||
this._keyPair.getPrivateKeyBytes(),
|
||||
recipientPubkey,
|
||||
plaintext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message using NIP-04 decryption.
|
||||
*
|
||||
* @param ciphertext - The encrypted message
|
||||
* @param senderPubkey - The sender's public key (hex)
|
||||
* @param decryptFn - The NIP-04 decryption function
|
||||
*/
|
||||
async decryptNip04(
|
||||
ciphertext: string,
|
||||
senderPubkey: string,
|
||||
decryptFn: DecryptFunction
|
||||
): Promise<string> {
|
||||
return decryptFn(
|
||||
this._keyPair.getPrivateKeyBytes(),
|
||||
senderPubkey,
|
||||
ciphertext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message using NIP-44 encryption.
|
||||
*
|
||||
* @param plaintext - The message to encrypt
|
||||
* @param recipientPubkey - The recipient's public key (hex)
|
||||
* @param encryptFn - The NIP-44 encryption function
|
||||
*/
|
||||
async encryptNip44(
|
||||
plaintext: string,
|
||||
recipientPubkey: string,
|
||||
encryptFn: EncryptFunction
|
||||
): Promise<string> {
|
||||
return encryptFn(
|
||||
this._keyPair.getPrivateKeyBytes(),
|
||||
recipientPubkey,
|
||||
plaintext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message using NIP-44 decryption.
|
||||
*
|
||||
* @param ciphertext - The encrypted message
|
||||
* @param senderPubkey - The sender's public key (hex)
|
||||
* @param decryptFn - The NIP-44 decryption function
|
||||
*/
|
||||
async decryptNip44(
|
||||
ciphertext: string,
|
||||
senderPubkey: string,
|
||||
decryptFn: DecryptFunction
|
||||
): Promise<string> {
|
||||
return decryptFn(
|
||||
this._keyPair.getPrivateKeyBytes(),
|
||||
senderPubkey,
|
||||
ciphertext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this identity has the same private key as another.
|
||||
* Used for duplicate detection.
|
||||
*/
|
||||
hasSameKeyAs(other: Identity): boolean {
|
||||
return this._keyPair.hasSamePublicKey(other._keyPair);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this identity matches a given public key.
|
||||
*/
|
||||
matchesPublicKey(publicKey: string): boolean {
|
||||
return this._keyPair.matchesPublicKey(publicKey);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Persistence
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert to a snapshot for persistence.
|
||||
*/
|
||||
toSnapshot(): IdentitySnapshot {
|
||||
return {
|
||||
id: this._id.value,
|
||||
nick: this._nickname.value,
|
||||
privkey: this._keyPair.toStorageHex(),
|
||||
createdAt: this._createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Equality
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check equality based on identity ID.
|
||||
*/
|
||||
equals(other: Identity): boolean {
|
||||
return this._id.equals(other._id);
|
||||
}
|
||||
}
|
||||
21
projects/common/src/lib/domain/entities/index.ts
Normal file
21
projects/common/src/lib/domain/entities/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
Identity,
|
||||
} from './identity';
|
||||
export type {
|
||||
UnsignedEvent,
|
||||
SignedEvent,
|
||||
SigningFunction,
|
||||
EncryptFunction,
|
||||
DecryptFunction,
|
||||
} from './identity';
|
||||
|
||||
export {
|
||||
Permission,
|
||||
PermissionChecker,
|
||||
} from './permission';
|
||||
|
||||
export {
|
||||
Relay,
|
||||
InvalidRelayUrlError,
|
||||
toNip65RelayList,
|
||||
} from './relay';
|
||||
175
projects/common/src/lib/domain/entities/permission.spec.ts
Normal file
175
projects/common/src/lib/domain/entities/permission.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Permission, PermissionChecker } from './permission';
|
||||
import { IdentityId } from '../value-objects';
|
||||
|
||||
describe('Permission Entity', () => {
|
||||
const testIdentityId = IdentityId.from('identity-1');
|
||||
const testHost = 'example.com';
|
||||
const testMethod = 'signEvent';
|
||||
|
||||
describe('allow', () => {
|
||||
it('should create an allow permission', () => {
|
||||
const permission = Permission.allow(testIdentityId, testHost, testMethod);
|
||||
|
||||
expect(permission.isAllowed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should create permission with kind for signEvent', () => {
|
||||
const permission = Permission.allow(testIdentityId, testHost, testMethod, 1);
|
||||
|
||||
expect(permission.isAllowed()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deny', () => {
|
||||
it('should create a deny permission', () => {
|
||||
const permission = Permission.deny(testIdentityId, testHost, testMethod);
|
||||
|
||||
expect(permission.isAllowed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matches', () => {
|
||||
it('should match when all parameters are the same', () => {
|
||||
const permission = Permission.allow(testIdentityId, testHost, testMethod);
|
||||
|
||||
expect(permission.matches(testIdentityId, testHost, testMethod)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when identity differs', () => {
|
||||
const permission = Permission.allow(testIdentityId, testHost, testMethod);
|
||||
const differentIdentity = IdentityId.from('identity-2');
|
||||
|
||||
expect(permission.matches(differentIdentity, testHost, testMethod)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match when host differs', () => {
|
||||
const permission = Permission.allow(testIdentityId, testHost, testMethod);
|
||||
|
||||
expect(permission.matches(testIdentityId, 'other.com', testMethod)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match when method differs', () => {
|
||||
const permission = Permission.allow(testIdentityId, testHost, testMethod);
|
||||
|
||||
expect(permission.matches(testIdentityId, testHost, 'getPublicKey')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match any kind when permission has no kind specified', () => {
|
||||
const permission = Permission.allow(testIdentityId, testHost, testMethod);
|
||||
|
||||
expect(permission.matches(testIdentityId, testHost, testMethod, 1)).toBe(true);
|
||||
expect(permission.matches(testIdentityId, testHost, testMethod, 30023)).toBe(true);
|
||||
});
|
||||
|
||||
it('should only match specific kind when permission has kind', () => {
|
||||
const permission = Permission.allow(testIdentityId, testHost, testMethod, 1);
|
||||
|
||||
expect(permission.matches(testIdentityId, testHost, testMethod, 1)).toBe(true);
|
||||
expect(permission.matches(testIdentityId, testHost, testMethod, 30023)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromSnapshot', () => {
|
||||
it('should reconstruct permission from snapshot', () => {
|
||||
const original = Permission.allow(testIdentityId, testHost, testMethod, 1);
|
||||
const snapshot = original.toSnapshot();
|
||||
|
||||
const restored = Permission.fromSnapshot(snapshot);
|
||||
|
||||
expect(restored.isAllowed()).toBe(true);
|
||||
expect(restored.matches(testIdentityId, testHost, testMethod, 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSnapshot', () => {
|
||||
it('should create valid snapshot', () => {
|
||||
const permission = Permission.allow(testIdentityId, testHost, testMethod, 1);
|
||||
const snapshot = permission.toSnapshot();
|
||||
|
||||
expect(snapshot.identityId).toEqual(testIdentityId.toString());
|
||||
expect(snapshot.host).toEqual(testHost);
|
||||
expect(snapshot.method).toEqual(testMethod);
|
||||
expect(snapshot.methodPolicy).toEqual('allow');
|
||||
expect(snapshot.kind).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionChecker', () => {
|
||||
const identity1 = IdentityId.from('identity-1');
|
||||
const identity2 = IdentityId.from('identity-2');
|
||||
|
||||
describe('check', () => {
|
||||
it('should return true for allowed permission', () => {
|
||||
const permissions = [
|
||||
Permission.allow(identity1, 'example.com', 'signEvent'),
|
||||
];
|
||||
const checker = new PermissionChecker(permissions);
|
||||
|
||||
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for denied permission', () => {
|
||||
const permissions = [
|
||||
Permission.deny(identity1, 'example.com', 'signEvent'),
|
||||
];
|
||||
const checker = new PermissionChecker(permissions);
|
||||
|
||||
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined when no matching permission exists', () => {
|
||||
const permissions = [
|
||||
Permission.allow(identity1, 'example.com', 'signEvent'),
|
||||
];
|
||||
const checker = new PermissionChecker(permissions);
|
||||
|
||||
expect(checker.check(identity2, 'example.com', 'signEvent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should check kind-specific permissions first', () => {
|
||||
const permissions = [
|
||||
Permission.deny(identity1, 'example.com', 'signEvent', 1), // Deny kind 1
|
||||
Permission.allow(identity1, 'example.com', 'signEvent'), // Allow all others
|
||||
];
|
||||
const checker = new PermissionChecker(permissions);
|
||||
|
||||
expect(checker.check(identity1, 'example.com', 'signEvent', 1)).toBe(false);
|
||||
expect(checker.check(identity1, 'example.com', 'signEvent', 30023)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple identities', () => {
|
||||
const permissions = [
|
||||
Permission.allow(identity1, 'example.com', 'signEvent'),
|
||||
Permission.deny(identity2, 'example.com', 'signEvent'),
|
||||
];
|
||||
const checker = new PermissionChecker(permissions);
|
||||
|
||||
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(true);
|
||||
expect(checker.check(identity2, 'example.com', 'signEvent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple hosts', () => {
|
||||
const permissions = [
|
||||
Permission.allow(identity1, 'allowed.com', 'signEvent'),
|
||||
Permission.deny(identity1, 'denied.com', 'signEvent'),
|
||||
];
|
||||
const checker = new PermissionChecker(permissions);
|
||||
|
||||
expect(checker.check(identity1, 'allowed.com', 'signEvent')).toBe(true);
|
||||
expect(checker.check(identity1, 'denied.com', 'signEvent')).toBe(false);
|
||||
expect(checker.check(identity1, 'unknown.com', 'signEvent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple methods', () => {
|
||||
const permissions = [
|
||||
Permission.allow(identity1, 'example.com', 'getPublicKey'),
|
||||
Permission.deny(identity1, 'example.com', 'signEvent'),
|
||||
];
|
||||
const checker = new PermissionChecker(permissions);
|
||||
|
||||
expect(checker.check(identity1, 'example.com', 'getPublicKey')).toBe(true);
|
||||
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
332
projects/common/src/lib/domain/entities/permission.ts
Normal file
332
projects/common/src/lib/domain/entities/permission.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { IdentityId, PermissionId } from '../value-objects';
|
||||
import type {
|
||||
PermissionSnapshot,
|
||||
ExtensionMethod,
|
||||
PermissionPolicy,
|
||||
} from '../repositories/permission-repository';
|
||||
|
||||
/**
|
||||
* Permission entity - represents an authorization decision for
|
||||
* a specific identity, host, and method combination.
|
||||
*
|
||||
* Permissions are immutable once created - to change a permission,
|
||||
* delete it and create a new one.
|
||||
*/
|
||||
export class Permission {
|
||||
private readonly _id: PermissionId;
|
||||
private readonly _identityId: IdentityId;
|
||||
private readonly _host: string;
|
||||
private readonly _method: ExtensionMethod;
|
||||
private readonly _policy: PermissionPolicy;
|
||||
private readonly _kind?: number;
|
||||
|
||||
private constructor(
|
||||
id: PermissionId,
|
||||
identityId: IdentityId,
|
||||
host: string,
|
||||
method: ExtensionMethod,
|
||||
policy: PermissionPolicy,
|
||||
kind?: number
|
||||
) {
|
||||
this._id = id;
|
||||
this._identityId = identityId;
|
||||
this._host = host;
|
||||
this._method = method;
|
||||
this._policy = policy;
|
||||
this._kind = kind;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Factory Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create an "allow" permission.
|
||||
*/
|
||||
static allow(
|
||||
identityId: IdentityId,
|
||||
host: string,
|
||||
method: ExtensionMethod,
|
||||
kind?: number
|
||||
): Permission {
|
||||
return new Permission(
|
||||
PermissionId.generate(),
|
||||
identityId,
|
||||
Permission.normalizeHost(host),
|
||||
method,
|
||||
'allow',
|
||||
kind
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "deny" permission.
|
||||
*/
|
||||
static deny(
|
||||
identityId: IdentityId,
|
||||
host: string,
|
||||
method: ExtensionMethod,
|
||||
kind?: number
|
||||
): Permission {
|
||||
return new Permission(
|
||||
PermissionId.generate(),
|
||||
identityId,
|
||||
Permission.normalizeHost(host),
|
||||
method,
|
||||
'deny',
|
||||
kind
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a permission with explicit policy.
|
||||
*/
|
||||
static create(
|
||||
identityId: IdentityId,
|
||||
host: string,
|
||||
method: ExtensionMethod,
|
||||
policy: PermissionPolicy,
|
||||
kind?: number
|
||||
): Permission {
|
||||
return new Permission(
|
||||
PermissionId.generate(),
|
||||
identityId,
|
||||
Permission.normalizeHost(host),
|
||||
method,
|
||||
policy,
|
||||
kind
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute a permission from storage.
|
||||
*/
|
||||
static fromSnapshot(snapshot: PermissionSnapshot): Permission {
|
||||
return new Permission(
|
||||
PermissionId.from(snapshot.id),
|
||||
IdentityId.from(snapshot.identityId),
|
||||
snapshot.host,
|
||||
snapshot.method,
|
||||
snapshot.methodPolicy,
|
||||
snapshot.kind
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Getters
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
get id(): PermissionId {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get identityId(): IdentityId {
|
||||
return this._identityId;
|
||||
}
|
||||
|
||||
get host(): string {
|
||||
return this._host;
|
||||
}
|
||||
|
||||
get method(): ExtensionMethod {
|
||||
return this._method;
|
||||
}
|
||||
|
||||
get policy(): PermissionPolicy {
|
||||
return this._policy;
|
||||
}
|
||||
|
||||
get kind(): number | undefined {
|
||||
return this._kind;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Behavior
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if this permission allows the action.
|
||||
*/
|
||||
isAllowed(): boolean {
|
||||
return this._policy === 'allow';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this permission denies the action.
|
||||
*/
|
||||
isDenied(): boolean {
|
||||
return this._policy === 'deny';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this permission matches the given criteria.
|
||||
* For signEvent with kind specified, also checks the kind.
|
||||
*/
|
||||
matches(
|
||||
identityId: IdentityId,
|
||||
host: string,
|
||||
method: ExtensionMethod,
|
||||
kind?: number
|
||||
): boolean {
|
||||
if (!this._identityId.equals(identityId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._host !== Permission.normalizeHost(host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._method !== method) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For signEvent, handle kind matching
|
||||
if (method === 'signEvent') {
|
||||
// If this permission has no kind, it matches all kinds
|
||||
if (this._kind === undefined) {
|
||||
return true;
|
||||
}
|
||||
// If checking a specific kind, must match exactly
|
||||
return this._kind === kind;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this permission applies to a specific event kind.
|
||||
* Only relevant for signEvent method.
|
||||
*/
|
||||
appliesToKind(kind: number): boolean {
|
||||
if (this._method !== 'signEvent') {
|
||||
return false;
|
||||
}
|
||||
// No kind restriction means applies to all
|
||||
if (this._kind === undefined) {
|
||||
return true;
|
||||
}
|
||||
return this._kind === kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a blanket permission (no kind restriction).
|
||||
*/
|
||||
isBlanketPermission(): boolean {
|
||||
return this._method === 'signEvent' && this._kind === undefined;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Persistence
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert to a snapshot for persistence.
|
||||
*/
|
||||
toSnapshot(): PermissionSnapshot {
|
||||
const snapshot: PermissionSnapshot = {
|
||||
id: this._id.value,
|
||||
identityId: this._identityId.value,
|
||||
host: this._host,
|
||||
method: this._method,
|
||||
methodPolicy: this._policy,
|
||||
};
|
||||
|
||||
if (this._kind !== undefined) {
|
||||
snapshot.kind = this._kind;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Equality
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check equality based on permission ID.
|
||||
*/
|
||||
equals(other: Permission): boolean {
|
||||
return this._id.equals(other._id);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static normalizeHost(host: string): string {
|
||||
return host.toLowerCase().trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission checker - evaluates permissions for a request.
|
||||
* This encapsulates the permission checking logic.
|
||||
*/
|
||||
export class PermissionChecker {
|
||||
constructor(private readonly permissions: Permission[]) {}
|
||||
|
||||
/**
|
||||
* Check if an action is allowed.
|
||||
*
|
||||
* @returns true if allowed, false if denied, undefined if no matching permission
|
||||
*/
|
||||
check(
|
||||
identityId: IdentityId,
|
||||
host: string,
|
||||
method: ExtensionMethod,
|
||||
kind?: number
|
||||
): boolean | undefined {
|
||||
const matching = this.permissions.filter((p) =>
|
||||
p.matches(identityId, host, method, kind)
|
||||
);
|
||||
|
||||
if (matching.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For signEvent with kind, check specific rules
|
||||
// Kind-specific rules take priority over blanket rules
|
||||
if (method === 'signEvent' && kind !== undefined) {
|
||||
// Check for specific kind deny first (takes priority)
|
||||
if (matching.some((p) => p.kind === kind && p.isDenied())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for specific kind allow
|
||||
if (matching.some((p) => p.kind === kind && p.isAllowed())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to blanket allow (no kind restriction)
|
||||
if (matching.some((p) => p.isBlanketPermission() && p.isAllowed())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to blanket deny
|
||||
if (matching.some((p) => p.isBlanketPermission() && p.isDenied())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No specific rule found
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For other methods, all matching permissions must allow
|
||||
return matching.every((p) => p.isAllowed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a specific identity.
|
||||
*/
|
||||
forIdentity(identityId: IdentityId): Permission[] {
|
||||
return this.permissions.filter((p) => p.identityId.equals(identityId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a specific host.
|
||||
*/
|
||||
forHost(host: string): Permission[] {
|
||||
const normalizedHost = host.toLowerCase().trim();
|
||||
return this.permissions.filter((p) => p.host === normalizedHost);
|
||||
}
|
||||
}
|
||||
155
projects/common/src/lib/domain/entities/relay.spec.ts
Normal file
155
projects/common/src/lib/domain/entities/relay.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Relay, InvalidRelayUrlError, toNip65RelayList } from './relay';
|
||||
import { IdentityId } from '../value-objects';
|
||||
|
||||
describe('Relay Entity', () => {
|
||||
const testIdentityId = IdentityId.from('identity-1');
|
||||
const validUrl = 'wss://relay.example.com';
|
||||
|
||||
describe('create', () => {
|
||||
it('should create relay with default read/write permissions', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl);
|
||||
|
||||
expect(relay.url).toEqual(validUrl);
|
||||
expect(relay.read).toBe(true);
|
||||
expect(relay.write).toBe(true);
|
||||
});
|
||||
|
||||
it('should create relay with specified permissions', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl, true, false);
|
||||
|
||||
expect(relay.read).toBe(true);
|
||||
expect(relay.write).toBe(false);
|
||||
});
|
||||
|
||||
it('should create relay with read-only permissions', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl, true, false);
|
||||
|
||||
expect(relay.read).toBe(true);
|
||||
expect(relay.write).toBe(false);
|
||||
});
|
||||
|
||||
it('should create relay with write-only permissions', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl, false, true);
|
||||
|
||||
expect(relay.read).toBe(false);
|
||||
expect(relay.write).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw InvalidRelayUrlError for invalid URL', () => {
|
||||
expect(() => Relay.create(testIdentityId, 'not-a-url')).toThrowError(InvalidRelayUrlError);
|
||||
});
|
||||
|
||||
it('should throw InvalidRelayUrlError for http URL', () => {
|
||||
expect(() => Relay.create(testIdentityId, 'http://relay.example.com')).toThrowError(InvalidRelayUrlError);
|
||||
});
|
||||
|
||||
it('should accept wss:// URL', () => {
|
||||
expect(() => Relay.create(testIdentityId, 'wss://relay.example.com')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept ws:// URL (for local development)', () => {
|
||||
expect(() => Relay.create(testIdentityId, 'ws://localhost:8080')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUrl', () => {
|
||||
it('should update URL to valid new URL', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl);
|
||||
|
||||
relay.updateUrl('wss://new-relay.example.com');
|
||||
|
||||
expect(relay.url).toEqual('wss://new-relay.example.com');
|
||||
});
|
||||
|
||||
it('should throw InvalidRelayUrlError for invalid new URL', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl);
|
||||
|
||||
expect(() => relay.updateUrl('not-a-url')).toThrowError(InvalidRelayUrlError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read permission toggling', () => {
|
||||
it('should enable read', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl, false, false);
|
||||
|
||||
relay.enableRead();
|
||||
|
||||
expect(relay.read).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable read', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl, true, true);
|
||||
|
||||
relay.disableRead();
|
||||
|
||||
expect(relay.read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('write permission toggling', () => {
|
||||
it('should enable write', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl, false, false);
|
||||
|
||||
relay.enableWrite();
|
||||
|
||||
expect(relay.write).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable write', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl, true, true);
|
||||
|
||||
relay.disableWrite();
|
||||
|
||||
expect(relay.write).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromSnapshot', () => {
|
||||
it('should reconstruct relay from snapshot', () => {
|
||||
const original = Relay.create(testIdentityId, validUrl, true, false);
|
||||
const snapshot = original.toSnapshot();
|
||||
|
||||
const restored = Relay.fromSnapshot(snapshot);
|
||||
|
||||
expect(restored.url).toEqual(validUrl);
|
||||
expect(restored.read).toBe(true);
|
||||
expect(restored.write).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSnapshot', () => {
|
||||
it('should create valid snapshot', () => {
|
||||
const relay = Relay.create(testIdentityId, validUrl, true, false);
|
||||
const snapshot = relay.toSnapshot();
|
||||
|
||||
expect(snapshot.identityId).toEqual(testIdentityId.toString());
|
||||
expect(snapshot.url).toEqual(validUrl);
|
||||
expect(snapshot.read).toBe(true);
|
||||
expect(snapshot.write).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNip65RelayList', () => {
|
||||
const identityId = IdentityId.from('identity-1');
|
||||
|
||||
it('should convert relays to NIP-65 format', () => {
|
||||
const relays = [
|
||||
Relay.create(identityId, 'wss://relay1.com', true, true),
|
||||
Relay.create(identityId, 'wss://relay2.com', true, false),
|
||||
Relay.create(identityId, 'wss://relay3.com', false, true),
|
||||
];
|
||||
|
||||
const nip65List = toNip65RelayList(relays);
|
||||
|
||||
expect(nip65List['wss://relay1.com']).toEqual({ read: true, write: true });
|
||||
expect(nip65List['wss://relay2.com']).toEqual({ read: true, write: false });
|
||||
expect(nip65List['wss://relay3.com']).toEqual({ read: false, write: true });
|
||||
});
|
||||
|
||||
it('should return empty object for empty relay list', () => {
|
||||
const nip65List = toNip65RelayList([]);
|
||||
|
||||
expect(nip65List).toEqual({});
|
||||
});
|
||||
});
|
||||
268
projects/common/src/lib/domain/entities/relay.ts
Normal file
268
projects/common/src/lib/domain/entities/relay.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { IdentityId, RelayId } from '../value-objects';
|
||||
import type { RelaySnapshot } from '../repositories/relay-repository';
|
||||
|
||||
/**
|
||||
* Relay entity - represents a Nostr relay configuration for an identity.
|
||||
*/
|
||||
export class Relay {
|
||||
private readonly _id: RelayId;
|
||||
private readonly _identityId: IdentityId;
|
||||
private _url: string;
|
||||
private _read: boolean;
|
||||
private _write: boolean;
|
||||
|
||||
private constructor(
|
||||
id: RelayId,
|
||||
identityId: IdentityId,
|
||||
url: string,
|
||||
read: boolean,
|
||||
write: boolean
|
||||
) {
|
||||
this._id = id;
|
||||
this._identityId = identityId;
|
||||
this._url = Relay.normalizeUrl(url);
|
||||
this._read = read;
|
||||
this._write = write;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Factory Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new relay configuration.
|
||||
*
|
||||
* @param identityId - The identity this relay belongs to
|
||||
* @param url - The relay WebSocket URL
|
||||
* @param read - Whether to read events from this relay
|
||||
* @param write - Whether to write events to this relay
|
||||
*/
|
||||
static create(
|
||||
identityId: IdentityId,
|
||||
url: string,
|
||||
read = true,
|
||||
write = true
|
||||
): Relay {
|
||||
Relay.validateUrl(url);
|
||||
|
||||
return new Relay(
|
||||
RelayId.generate(),
|
||||
identityId,
|
||||
url,
|
||||
read,
|
||||
write
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute a relay from storage.
|
||||
*/
|
||||
static fromSnapshot(snapshot: RelaySnapshot): Relay {
|
||||
return new Relay(
|
||||
RelayId.from(snapshot.id),
|
||||
IdentityId.from(snapshot.identityId),
|
||||
snapshot.url,
|
||||
snapshot.read,
|
||||
snapshot.write
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Getters
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
get id(): RelayId {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get identityId(): IdentityId {
|
||||
return this._identityId;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
get read(): boolean {
|
||||
return this._read;
|
||||
}
|
||||
|
||||
get write(): boolean {
|
||||
return this._write;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Behavior
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update the relay URL.
|
||||
*/
|
||||
updateUrl(newUrl: string): void {
|
||||
Relay.validateUrl(newUrl);
|
||||
this._url = Relay.normalizeUrl(newUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable reading from this relay.
|
||||
*/
|
||||
enableRead(): void {
|
||||
this._read = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable reading from this relay.
|
||||
*/
|
||||
disableRead(): void {
|
||||
this._read = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable writing to this relay.
|
||||
*/
|
||||
enableWrite(): void {
|
||||
this._write = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable writing to this relay.
|
||||
*/
|
||||
disableWrite(): void {
|
||||
this._write = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set both read and write permissions.
|
||||
*/
|
||||
setPermissions(read: boolean, write: boolean): void {
|
||||
this._read = read;
|
||||
this._write = write;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this relay is enabled for either read or write.
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this._read || this._write;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this relay has the same URL as another (case-insensitive).
|
||||
*/
|
||||
hasSameUrl(url: string): boolean {
|
||||
return this._url.toLowerCase() === Relay.normalizeUrl(url).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this relay belongs to a specific identity.
|
||||
*/
|
||||
belongsTo(identityId: IdentityId): boolean {
|
||||
return this._identityId.equals(identityId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Persistence
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert to a snapshot for persistence.
|
||||
*/
|
||||
toSnapshot(): RelaySnapshot {
|
||||
return {
|
||||
id: this._id.value,
|
||||
identityId: this._identityId.value,
|
||||
url: this._url,
|
||||
read: this._read,
|
||||
write: this._write,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a clone for modification without affecting the original.
|
||||
*/
|
||||
clone(): Relay {
|
||||
return new Relay(
|
||||
this._id,
|
||||
this._identityId,
|
||||
this._url,
|
||||
this._read,
|
||||
this._write
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Equality
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check equality based on relay ID.
|
||||
*/
|
||||
equals(other: Relay): boolean {
|
||||
return this._id.equals(other._id);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static normalizeUrl(url: string): string {
|
||||
let normalized = url.trim();
|
||||
// Remove trailing slash
|
||||
if (normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static validateUrl(url: string): void {
|
||||
const normalized = Relay.normalizeUrl(url);
|
||||
|
||||
if (!normalized) {
|
||||
throw new InvalidRelayUrlError('Relay URL cannot be empty');
|
||||
}
|
||||
|
||||
// Must start with wss:// or ws://
|
||||
if (!normalized.startsWith('wss://') && !normalized.startsWith('ws://')) {
|
||||
throw new InvalidRelayUrlError(
|
||||
'Relay URL must start with wss:// or ws://'
|
||||
);
|
||||
}
|
||||
|
||||
// Try to parse as URL
|
||||
try {
|
||||
new URL(normalized);
|
||||
} catch {
|
||||
throw new InvalidRelayUrlError(`Invalid relay URL: ${url}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a relay URL is invalid.
|
||||
*/
|
||||
export class InvalidRelayUrlError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidRelayUrlError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert relay list to NIP-65 format.
|
||||
*/
|
||||
export function toNip65RelayList(
|
||||
relays: Relay[]
|
||||
): Record<string, { read: boolean; write: boolean }> {
|
||||
const result: Record<string, { read: boolean; write: boolean }> = {};
|
||||
|
||||
for (const relay of relays) {
|
||||
if (relay.isEnabled()) {
|
||||
result[relay.url] = {
|
||||
read: relay.read,
|
||||
write: relay.write,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
81
projects/common/src/lib/domain/events/domain-event.spec.ts
Normal file
81
projects/common/src/lib/domain/events/domain-event.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { DomainEvent, AggregateRoot } from './domain-event';
|
||||
|
||||
// Concrete implementation for testing
|
||||
class TestEvent extends DomainEvent {
|
||||
readonly eventType = 'test.event';
|
||||
|
||||
constructor(readonly testData: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
class TestAggregate extends AggregateRoot {
|
||||
doSomething(data: string): void {
|
||||
this.addDomainEvent(new TestEvent(data));
|
||||
}
|
||||
}
|
||||
|
||||
describe('DomainEvent', () => {
|
||||
describe('base properties', () => {
|
||||
it('should have occurredAt timestamp', () => {
|
||||
const before = new Date();
|
||||
const event = new TestEvent('test');
|
||||
const after = new Date();
|
||||
|
||||
expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it('should have unique eventId', () => {
|
||||
const event1 = new TestEvent('test1');
|
||||
const event2 = new TestEvent('test2');
|
||||
|
||||
expect(event1.eventId).not.toEqual(event2.eventId);
|
||||
});
|
||||
|
||||
it('should have eventType from subclass', () => {
|
||||
const event = new TestEvent('test');
|
||||
|
||||
expect(event.eventType).toEqual('test.event');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AggregateRoot', () => {
|
||||
describe('domain events', () => {
|
||||
it('should collect domain events', () => {
|
||||
const aggregate = new TestAggregate();
|
||||
|
||||
aggregate.doSomething('first');
|
||||
aggregate.doSomething('second');
|
||||
|
||||
const events = aggregate.pullDomainEvents();
|
||||
|
||||
expect(events.length).toBe(2);
|
||||
expect((events[0] as TestEvent).testData).toEqual('first');
|
||||
expect((events[1] as TestEvent).testData).toEqual('second');
|
||||
});
|
||||
|
||||
it('should clear events after pulling', () => {
|
||||
const aggregate = new TestAggregate();
|
||||
aggregate.doSomething('test');
|
||||
|
||||
aggregate.pullDomainEvents();
|
||||
const secondPull = aggregate.pullDomainEvents();
|
||||
|
||||
expect(secondPull.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve event order', () => {
|
||||
const aggregate = new TestAggregate();
|
||||
|
||||
aggregate.doSomething('1');
|
||||
aggregate.doSomething('2');
|
||||
aggregate.doSomething('3');
|
||||
|
||||
const events = aggregate.pullDomainEvents();
|
||||
|
||||
expect(events.map(e => (e as TestEvent).testData)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
projects/common/src/lib/domain/events/domain-event.ts
Normal file
55
projects/common/src/lib/domain/events/domain-event.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Base class for all domain events.
|
||||
* Domain events capture significant occurrences in the domain that
|
||||
* domain experts care about.
|
||||
*/
|
||||
export abstract class DomainEvent {
|
||||
readonly occurredAt: Date;
|
||||
readonly eventId: string;
|
||||
|
||||
constructor() {
|
||||
this.occurredAt = new Date();
|
||||
this.eventId = crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event type identifier.
|
||||
* Used for event routing and serialization.
|
||||
*/
|
||||
abstract get eventType(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for entities that can raise domain events.
|
||||
*/
|
||||
export interface EventRaiser {
|
||||
/**
|
||||
* Pull all pending domain events from the entity.
|
||||
* This clears the internal event list.
|
||||
*/
|
||||
pullDomainEvents(): DomainEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for aggregate roots that can raise domain events.
|
||||
*/
|
||||
export abstract class AggregateRoot implements EventRaiser {
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
protected addDomainEvent(event: DomainEvent): void {
|
||||
this._domainEvents.push(event);
|
||||
}
|
||||
|
||||
pullDomainEvents(): DomainEvent[] {
|
||||
const events = [...this._domainEvents];
|
||||
this._domainEvents = [];
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any pending domain events.
|
||||
*/
|
||||
hasPendingEvents(): boolean {
|
||||
return this._domainEvents.length > 0;
|
||||
}
|
||||
}
|
||||
110
projects/common/src/lib/domain/events/identity-events.spec.ts
Normal file
110
projects/common/src/lib/domain/events/identity-events.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
IdentityCreated,
|
||||
IdentityRenamed,
|
||||
IdentitySelected,
|
||||
IdentitySigned,
|
||||
IdentityDeleted,
|
||||
} from './identity-events';
|
||||
|
||||
describe('Identity Domain Events', () => {
|
||||
describe('IdentityCreated', () => {
|
||||
it('should store identity creation data', () => {
|
||||
const event = new IdentityCreated('id-123', 'pubkey-abc', 'Alice');
|
||||
|
||||
expect(event.identityId).toEqual('id-123');
|
||||
expect(event.publicKey).toEqual('pubkey-abc');
|
||||
expect(event.nickname).toEqual('Alice');
|
||||
});
|
||||
|
||||
it('should have correct event type', () => {
|
||||
const event = new IdentityCreated('id', 'pubkey', 'name');
|
||||
|
||||
expect(event.eventType).toEqual('identity.created');
|
||||
});
|
||||
|
||||
it('should have inherited base properties', () => {
|
||||
const event = new IdentityCreated('id', 'pubkey', 'name');
|
||||
|
||||
expect(event.eventId).toBeTruthy();
|
||||
expect(event.occurredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IdentityRenamed', () => {
|
||||
it('should store rename data', () => {
|
||||
const event = new IdentityRenamed('id-123', 'OldName', 'NewName');
|
||||
|
||||
expect(event.identityId).toEqual('id-123');
|
||||
expect(event.oldNickname).toEqual('OldName');
|
||||
expect(event.newNickname).toEqual('NewName');
|
||||
});
|
||||
|
||||
it('should have correct event type', () => {
|
||||
const event = new IdentityRenamed('id', 'old', 'new');
|
||||
|
||||
expect(event.eventType).toEqual('identity.renamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IdentitySelected', () => {
|
||||
it('should store selection data with previous identity', () => {
|
||||
const event = new IdentitySelected('id-new', 'id-old');
|
||||
|
||||
expect(event.identityId).toEqual('id-new');
|
||||
expect(event.previousIdentityId).toEqual('id-old');
|
||||
});
|
||||
|
||||
it('should handle null previous identity', () => {
|
||||
const event = new IdentitySelected('id-new', null);
|
||||
|
||||
expect(event.identityId).toEqual('id-new');
|
||||
expect(event.previousIdentityId).toBeNull();
|
||||
});
|
||||
|
||||
it('should have correct event type', () => {
|
||||
const event = new IdentitySelected('id', null);
|
||||
|
||||
expect(event.eventType).toEqual('identity.selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IdentitySigned', () => {
|
||||
it('should store signing data', () => {
|
||||
const event = new IdentitySigned('id-123', 1, 'event-id-abc');
|
||||
|
||||
expect(event.identityId).toEqual('id-123');
|
||||
expect(event.eventKind).toBe(1);
|
||||
expect(event.signedEventId).toEqual('event-id-abc');
|
||||
});
|
||||
|
||||
it('should have correct event type', () => {
|
||||
const event = new IdentitySigned('id', 1, 'event-id');
|
||||
|
||||
expect(event.eventType).toEqual('identity.signed');
|
||||
});
|
||||
|
||||
it('should handle various event kinds', () => {
|
||||
const kindExamples = [0, 1, 3, 4, 7, 30023, 10002];
|
||||
|
||||
kindExamples.forEach(kind => {
|
||||
const event = new IdentitySigned('id', kind, 'event');
|
||||
expect(event.eventKind).toBe(kind);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('IdentityDeleted', () => {
|
||||
it('should store deletion data', () => {
|
||||
const event = new IdentityDeleted('id-123', 'pubkey-abc');
|
||||
|
||||
expect(event.identityId).toEqual('id-123');
|
||||
expect(event.publicKey).toEqual('pubkey-abc');
|
||||
});
|
||||
|
||||
it('should have correct event type', () => {
|
||||
const event = new IdentityDeleted('id', 'pubkey');
|
||||
|
||||
expect(event.eventType).toEqual('identity.deleted');
|
||||
});
|
||||
});
|
||||
});
|
||||
74
projects/common/src/lib/domain/events/identity-events.ts
Normal file
74
projects/common/src/lib/domain/events/identity-events.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { DomainEvent } from './domain-event';
|
||||
|
||||
/**
|
||||
* Event raised when a new identity is created.
|
||||
*/
|
||||
export class IdentityCreated extends DomainEvent {
|
||||
readonly eventType = 'identity.created';
|
||||
|
||||
constructor(
|
||||
readonly identityId: string,
|
||||
readonly publicKey: string,
|
||||
readonly nickname: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event raised when an identity is renamed.
|
||||
*/
|
||||
export class IdentityRenamed extends DomainEvent {
|
||||
readonly eventType = 'identity.renamed';
|
||||
|
||||
constructor(
|
||||
readonly identityId: string,
|
||||
readonly oldNickname: string,
|
||||
readonly newNickname: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event raised when an identity is selected (made active).
|
||||
*/
|
||||
export class IdentitySelected extends DomainEvent {
|
||||
readonly eventType = 'identity.selected';
|
||||
|
||||
constructor(
|
||||
readonly identityId: string,
|
||||
readonly previousIdentityId: string | null
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event raised when an identity signs an event.
|
||||
*/
|
||||
export class IdentitySigned extends DomainEvent {
|
||||
readonly eventType = 'identity.signed';
|
||||
|
||||
constructor(
|
||||
readonly identityId: string,
|
||||
readonly eventKind: number,
|
||||
readonly signedEventId: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event raised when an identity is deleted.
|
||||
*/
|
||||
export class IdentityDeleted extends DomainEvent {
|
||||
readonly eventType = 'identity.deleted';
|
||||
|
||||
constructor(
|
||||
readonly identityId: string,
|
||||
readonly publicKey: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
9
projects/common/src/lib/domain/events/index.ts
Normal file
9
projects/common/src/lib/domain/events/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { DomainEvent, AggregateRoot } from './domain-event';
|
||||
export type { EventRaiser } from './domain-event';
|
||||
export {
|
||||
IdentityCreated,
|
||||
IdentityRenamed,
|
||||
IdentitySelected,
|
||||
IdentitySigned,
|
||||
IdentityDeleted,
|
||||
} from './identity-events';
|
||||
11
projects/common/src/lib/domain/index.ts
Normal file
11
projects/common/src/lib/domain/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Value Objects
|
||||
export * from './value-objects';
|
||||
|
||||
// Repository Interfaces
|
||||
export * from './repositories';
|
||||
|
||||
// Domain Events
|
||||
export * from './events';
|
||||
|
||||
// Domain Entities
|
||||
export * from './entities';
|
||||
@@ -0,0 +1,89 @@
|
||||
import { IdentityId } from '../value-objects';
|
||||
|
||||
/**
|
||||
* Snapshot of an identity for persistence.
|
||||
* This is the data structure that gets persisted, separate from the domain entity.
|
||||
*/
|
||||
export interface IdentitySnapshot {
|
||||
id: string;
|
||||
nick: string;
|
||||
privkey: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository interface for Identity aggregate.
|
||||
* Implementations handle encryption and storage specifics.
|
||||
*/
|
||||
export interface IdentityRepository {
|
||||
/**
|
||||
* Find an identity by its ID.
|
||||
* Returns undefined if not found.
|
||||
*/
|
||||
findById(id: IdentityId): Promise<IdentitySnapshot | undefined>;
|
||||
|
||||
/**
|
||||
* Find an identity by its public key.
|
||||
* Returns undefined if not found.
|
||||
*/
|
||||
findByPublicKey(publicKey: string): Promise<IdentitySnapshot | undefined>;
|
||||
|
||||
/**
|
||||
* Find an identity by its private key.
|
||||
* Used for duplicate detection.
|
||||
* Returns undefined if not found.
|
||||
*/
|
||||
findByPrivateKey(privateKey: string): Promise<IdentitySnapshot | undefined>;
|
||||
|
||||
/**
|
||||
* Get all identities.
|
||||
*/
|
||||
findAll(): Promise<IdentitySnapshot[]>;
|
||||
|
||||
/**
|
||||
* Save a new or updated identity.
|
||||
* If an identity with the same ID exists, it will be updated.
|
||||
*/
|
||||
save(identity: IdentitySnapshot): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete an identity by its ID.
|
||||
* Returns true if the identity was deleted, false if it didn't exist.
|
||||
*/
|
||||
delete(id: IdentityId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get the currently selected identity ID.
|
||||
*/
|
||||
getSelectedId(): Promise<IdentityId | null>;
|
||||
|
||||
/**
|
||||
* Set the currently selected identity ID.
|
||||
*/
|
||||
setSelectedId(id: IdentityId | null): Promise<void>;
|
||||
|
||||
/**
|
||||
* Count the total number of identities.
|
||||
*/
|
||||
count(): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when an identity operation fails.
|
||||
*/
|
||||
export class IdentityRepositoryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: IdentityErrorCode
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'IdentityRepositoryError';
|
||||
}
|
||||
}
|
||||
|
||||
export enum IdentityErrorCode {
|
||||
DUPLICATE_PRIVATE_KEY = 'DUPLICATE_PRIVATE_KEY',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
|
||||
STORAGE_FAILED = 'STORAGE_FAILED',
|
||||
}
|
||||
30
projects/common/src/lib/domain/repositories/index.ts
Normal file
30
projects/common/src/lib/domain/repositories/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export {
|
||||
IdentityRepositoryError,
|
||||
IdentityErrorCode,
|
||||
} from './identity-repository';
|
||||
export type {
|
||||
IdentityRepository,
|
||||
IdentitySnapshot,
|
||||
} from './identity-repository';
|
||||
|
||||
export {
|
||||
PermissionRepositoryError,
|
||||
PermissionErrorCode,
|
||||
} from './permission-repository';
|
||||
export type {
|
||||
PermissionRepository,
|
||||
PermissionSnapshot,
|
||||
PermissionQuery,
|
||||
ExtensionMethod,
|
||||
PermissionPolicy,
|
||||
} from './permission-repository';
|
||||
|
||||
export {
|
||||
RelayRepositoryError,
|
||||
RelayErrorCode,
|
||||
} from './relay-repository';
|
||||
export type {
|
||||
RelayRepository,
|
||||
RelaySnapshot,
|
||||
RelayQuery,
|
||||
} from './relay-repository';
|
||||
@@ -0,0 +1,108 @@
|
||||
import { IdentityId, PermissionId } from '../value-objects';
|
||||
import type { ExtensionMethod, Nip07MethodPolicy } from '../../models/nostr';
|
||||
|
||||
// Re-export types from models for convenience
|
||||
// These are the canonical definitions used throughout the app
|
||||
export type { ExtensionMethod, Nip07MethodPolicy as PermissionPolicy } from '../../models/nostr';
|
||||
|
||||
// Local type alias for cleaner code
|
||||
type PermissionPolicy = Nip07MethodPolicy;
|
||||
|
||||
/**
|
||||
* Snapshot of a permission for persistence.
|
||||
*/
|
||||
export interface PermissionSnapshot {
|
||||
id: string;
|
||||
identityId: string;
|
||||
host: string;
|
||||
method: ExtensionMethod;
|
||||
methodPolicy: PermissionPolicy;
|
||||
kind?: number; // For signEvent, filter by event kind
|
||||
}
|
||||
|
||||
/**
|
||||
* Query criteria for finding permissions.
|
||||
*/
|
||||
export interface PermissionQuery {
|
||||
identityId?: IdentityId;
|
||||
host?: string;
|
||||
method?: ExtensionMethod;
|
||||
kind?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository interface for Permission aggregate.
|
||||
*/
|
||||
export interface PermissionRepository {
|
||||
/**
|
||||
* Find a permission by its ID.
|
||||
*/
|
||||
findById(id: PermissionId): Promise<PermissionSnapshot | undefined>;
|
||||
|
||||
/**
|
||||
* Find permissions matching the query criteria.
|
||||
*/
|
||||
find(query: PermissionQuery): Promise<PermissionSnapshot[]>;
|
||||
|
||||
/**
|
||||
* Find a specific permission for an identity, host, method, and optionally kind.
|
||||
* This is the most common lookup for checking if an action is allowed.
|
||||
*/
|
||||
findExact(
|
||||
identityId: IdentityId,
|
||||
host: string,
|
||||
method: ExtensionMethod,
|
||||
kind?: number
|
||||
): Promise<PermissionSnapshot | undefined>;
|
||||
|
||||
/**
|
||||
* Get all permissions for an identity.
|
||||
*/
|
||||
findByIdentity(identityId: IdentityId): Promise<PermissionSnapshot[]>;
|
||||
|
||||
/**
|
||||
* Get all permissions.
|
||||
*/
|
||||
findAll(): Promise<PermissionSnapshot[]>;
|
||||
|
||||
/**
|
||||
* Save a new or updated permission.
|
||||
*/
|
||||
save(permission: PermissionSnapshot): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a permission by its ID.
|
||||
*/
|
||||
delete(id: PermissionId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Delete all permissions for an identity.
|
||||
* Used when deleting an identity (cascade delete).
|
||||
*/
|
||||
deleteByIdentity(identityId: IdentityId): Promise<number>;
|
||||
|
||||
/**
|
||||
* Count permissions matching the query.
|
||||
*/
|
||||
count(query?: PermissionQuery): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a permission operation fails.
|
||||
*/
|
||||
export class PermissionRepositoryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: PermissionErrorCode
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PermissionRepositoryError';
|
||||
}
|
||||
}
|
||||
|
||||
export enum PermissionErrorCode {
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
|
||||
DECRYPTION_FAILED = 'DECRYPTION_FAILED',
|
||||
STORAGE_FAILED = 'STORAGE_FAILED',
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { IdentityId, RelayId } from '../value-objects';
|
||||
|
||||
/**
|
||||
* Snapshot of a relay for persistence.
|
||||
*/
|
||||
export interface RelaySnapshot {
|
||||
id: string;
|
||||
identityId: string;
|
||||
url: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query criteria for finding relays.
|
||||
*/
|
||||
export interface RelayQuery {
|
||||
identityId?: IdentityId;
|
||||
url?: string;
|
||||
read?: boolean;
|
||||
write?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository interface for Relay aggregate.
|
||||
*/
|
||||
export interface RelayRepository {
|
||||
/**
|
||||
* Find a relay by its ID.
|
||||
*/
|
||||
findById(id: RelayId): Promise<RelaySnapshot | undefined>;
|
||||
|
||||
/**
|
||||
* Find relays matching the query criteria.
|
||||
*/
|
||||
find(query: RelayQuery): Promise<RelaySnapshot[]>;
|
||||
|
||||
/**
|
||||
* Find a relay by URL for a specific identity.
|
||||
* Used for duplicate detection.
|
||||
*/
|
||||
findByUrl(identityId: IdentityId, url: string): Promise<RelaySnapshot | undefined>;
|
||||
|
||||
/**
|
||||
* Get all relays for an identity.
|
||||
*/
|
||||
findByIdentity(identityId: IdentityId): Promise<RelaySnapshot[]>;
|
||||
|
||||
/**
|
||||
* Get all relays.
|
||||
*/
|
||||
findAll(): Promise<RelaySnapshot[]>;
|
||||
|
||||
/**
|
||||
* Save a new or updated relay.
|
||||
*/
|
||||
save(relay: RelaySnapshot): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a relay by its ID.
|
||||
*/
|
||||
delete(id: RelayId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Delete all relays for an identity.
|
||||
* Used when deleting an identity (cascade delete).
|
||||
*/
|
||||
deleteByIdentity(identityId: IdentityId): Promise<number>;
|
||||
|
||||
/**
|
||||
* Count relays matching the query.
|
||||
*/
|
||||
count(query?: RelayQuery): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a relay operation fails.
|
||||
*/
|
||||
export class RelayRepositoryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: RelayErrorCode
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RelayRepositoryError';
|
||||
}
|
||||
}
|
||||
|
||||
export enum RelayErrorCode {
|
||||
DUPLICATE_URL = 'DUPLICATE_URL',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
|
||||
STORAGE_FAILED = 'STORAGE_FAILED',
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { IdentityId, PermissionId, RelayId, NwcConnectionId, CashuMintId } from './index';
|
||||
|
||||
describe('EntityId Value Objects', () => {
|
||||
describe('IdentityId', () => {
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = IdentityId.generate();
|
||||
const id2 = IdentityId.generate();
|
||||
|
||||
expect(id1.toString()).not.toEqual(id2.toString());
|
||||
});
|
||||
|
||||
it('should create from existing string value', () => {
|
||||
const value = 'test-identity-id-123';
|
||||
const id = IdentityId.from(value);
|
||||
|
||||
expect(id.toString()).toEqual(value);
|
||||
});
|
||||
|
||||
it('should be equal when values match', () => {
|
||||
const value = 'same-id';
|
||||
const id1 = IdentityId.from(value);
|
||||
const id2 = IdentityId.from(value);
|
||||
|
||||
expect(id1.equals(id2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be equal when values differ', () => {
|
||||
const id1 = IdentityId.from('id-1');
|
||||
const id2 = IdentityId.from('id-2');
|
||||
|
||||
expect(id1.equals(id2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionId', () => {
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = PermissionId.generate();
|
||||
const id2 = PermissionId.generate();
|
||||
|
||||
expect(id1.toString()).not.toEqual(id2.toString());
|
||||
});
|
||||
|
||||
it('should create from existing string value', () => {
|
||||
const value = 'test-permission-id-456';
|
||||
const id = PermissionId.from(value);
|
||||
|
||||
expect(id.toString()).toEqual(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RelayId', () => {
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = RelayId.generate();
|
||||
const id2 = RelayId.generate();
|
||||
|
||||
expect(id1.toString()).not.toEqual(id2.toString());
|
||||
});
|
||||
|
||||
it('should create from existing string value', () => {
|
||||
const value = 'test-relay-id-789';
|
||||
const id = RelayId.from(value);
|
||||
|
||||
expect(id.toString()).toEqual(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NwcConnectionId', () => {
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = NwcConnectionId.generate();
|
||||
const id2 = NwcConnectionId.generate();
|
||||
|
||||
expect(id1.toString()).not.toEqual(id2.toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('CashuMintId', () => {
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = CashuMintId.generate();
|
||||
const id2 = CashuMintId.generate();
|
||||
|
||||
expect(id1.toString()).not.toEqual(id2.toString());
|
||||
});
|
||||
});
|
||||
});
|
||||
30
projects/common/src/lib/domain/value-objects/entity-id.ts
Normal file
30
projects/common/src/lib/domain/value-objects/entity-id.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Base class for strongly-typed entity IDs.
|
||||
* Prevents mixing up different ID types (e.g., IdentityId vs PermissionId).
|
||||
*/
|
||||
export abstract class EntityId<T extends string = string> {
|
||||
protected constructor(protected readonly _value: string) {
|
||||
if (!_value || _value.trim() === '') {
|
||||
throw new Error(`${this.constructor.name} cannot be empty`);
|
||||
}
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: EntityId<T>): boolean {
|
||||
if (!(other instanceof this.constructor)) {
|
||||
return false;
|
||||
}
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
36
projects/common/src/lib/domain/value-objects/identity-id.ts
Normal file
36
projects/common/src/lib/domain/value-objects/identity-id.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { EntityId } from './entity-id';
|
||||
|
||||
/**
|
||||
* Strongly-typed identifier for Identity entities.
|
||||
* Prevents accidental mixing with other ID types.
|
||||
*/
|
||||
export class IdentityId extends EntityId<'IdentityId'> {
|
||||
private readonly _brand = 'IdentityId' as const;
|
||||
|
||||
private constructor(value: string) {
|
||||
super(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new unique IdentityId.
|
||||
*/
|
||||
static generate(): IdentityId {
|
||||
return new IdentityId(uuidv4());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IdentityId from an existing string value.
|
||||
* Use this when reconstituting from storage.
|
||||
*/
|
||||
static from(value: string): IdentityId {
|
||||
return new IdentityId(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if two IDs are equal.
|
||||
*/
|
||||
override equals(other: IdentityId): boolean {
|
||||
return other instanceof IdentityId && this._value === other._value;
|
||||
}
|
||||
}
|
||||
16
projects/common/src/lib/domain/value-objects/index.ts
Normal file
16
projects/common/src/lib/domain/value-objects/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Base
|
||||
export { EntityId } from './entity-id';
|
||||
|
||||
// Entity IDs
|
||||
export { IdentityId } from './identity-id';
|
||||
export { PermissionId } from './permission-id';
|
||||
export { RelayId } from './relay-id';
|
||||
export { NwcConnectionId, CashuMintId } from './wallet-id';
|
||||
|
||||
// Domain Value Objects
|
||||
export { Nickname, InvalidNicknameError } from './nickname';
|
||||
export {
|
||||
NostrKeyPair,
|
||||
NostrPublicKey,
|
||||
InvalidNostrKeyError,
|
||||
} from './nostr-keypair';
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Nickname, InvalidNicknameError } from './nickname';
|
||||
|
||||
describe('Nickname Value Object', () => {
|
||||
describe('create', () => {
|
||||
it('should create a valid nickname', () => {
|
||||
const nickname = Nickname.create('Alice');
|
||||
|
||||
expect(nickname.toString()).toEqual('Alice');
|
||||
});
|
||||
|
||||
it('should trim whitespace from nickname', () => {
|
||||
const nickname = Nickname.create(' Bob ');
|
||||
|
||||
expect(nickname.toString()).toEqual('Bob');
|
||||
});
|
||||
|
||||
it('should throw InvalidNicknameError for empty string', () => {
|
||||
expect(() => Nickname.create('')).toThrowError(InvalidNicknameError);
|
||||
});
|
||||
|
||||
it('should throw InvalidNicknameError for whitespace-only string', () => {
|
||||
expect(() => Nickname.create(' ')).toThrowError(InvalidNicknameError);
|
||||
});
|
||||
|
||||
it('should throw InvalidNicknameError for nickname exceeding 50 characters', () => {
|
||||
const longNickname = 'a'.repeat(51);
|
||||
|
||||
expect(() => Nickname.create(longNickname)).toThrowError(InvalidNicknameError);
|
||||
});
|
||||
|
||||
it('should allow nickname with exactly 50 characters', () => {
|
||||
const maxNickname = 'a'.repeat(50);
|
||||
|
||||
expect(() => Nickname.create(maxNickname)).not.toThrow();
|
||||
expect(Nickname.create(maxNickname).toString()).toEqual(maxNickname);
|
||||
});
|
||||
|
||||
it('should allow single character nickname', () => {
|
||||
const nickname = Nickname.create('X');
|
||||
|
||||
expect(nickname.toString()).toEqual('X');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromStorage', () => {
|
||||
it('should create nickname from storage without validation', () => {
|
||||
// This allows loading potentially invalid data from storage
|
||||
// without throwing during deserialization
|
||||
const nickname = Nickname.fromStorage('stored-nickname');
|
||||
|
||||
expect(nickname.toString()).toEqual('stored-nickname');
|
||||
});
|
||||
|
||||
it('should handle long nicknames from legacy storage', () => {
|
||||
const longLegacyNickname = 'a'.repeat(100);
|
||||
const nickname = Nickname.fromStorage(longLegacyNickname);
|
||||
|
||||
expect(nickname.toString()).toEqual(longLegacyNickname);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal nicknames', () => {
|
||||
const nick1 = Nickname.create('Alice');
|
||||
const nick2 = Nickname.create('Alice');
|
||||
|
||||
expect(nick1.equals(nick2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different nicknames', () => {
|
||||
const nick1 = Nickname.create('Alice');
|
||||
const nick2 = Nickname.create('Bob');
|
||||
|
||||
expect(nick1.equals(nick2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case-sensitive', () => {
|
||||
const nick1 = Nickname.create('alice');
|
||||
const nick2 = Nickname.create('Alice');
|
||||
|
||||
expect(nick1.equals(nick2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InvalidNicknameError', () => {
|
||||
it('should be an instance of InvalidNicknameError', () => {
|
||||
try {
|
||||
Nickname.create('');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(InvalidNicknameError);
|
||||
expect((e as InvalidNicknameError).message).toContain('cannot be empty');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
66
projects/common/src/lib/domain/value-objects/nickname.ts
Normal file
66
projects/common/src/lib/domain/value-objects/nickname.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Value object representing a user-defined nickname for an identity.
|
||||
* Self-validating and immutable.
|
||||
*/
|
||||
export class Nickname {
|
||||
private static readonly MAX_LENGTH = 50;
|
||||
private static readonly MIN_LENGTH = 1;
|
||||
|
||||
private constructor(private readonly _value: string) {}
|
||||
|
||||
/**
|
||||
* Create a new Nickname from a string value.
|
||||
* Trims whitespace and validates length.
|
||||
*
|
||||
* @throws Error if nickname is empty or too long
|
||||
*/
|
||||
static create(value: string): Nickname {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
|
||||
if (trimmed.length < Nickname.MIN_LENGTH) {
|
||||
throw new InvalidNicknameError('Nickname cannot be empty');
|
||||
}
|
||||
|
||||
if (trimmed.length > Nickname.MAX_LENGTH) {
|
||||
throw new InvalidNicknameError(
|
||||
`Nickname cannot exceed ${Nickname.MAX_LENGTH} characters`
|
||||
);
|
||||
}
|
||||
|
||||
return new Nickname(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute a Nickname from storage without validation.
|
||||
* Use only when loading from trusted storage.
|
||||
*/
|
||||
static fromStorage(value: string): Nickname {
|
||||
return new Nickname(value);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: Nickname): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when nickname validation fails.
|
||||
*/
|
||||
export class InvalidNicknameError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidNicknameError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NostrKeyPair, InvalidNostrKeyError } from './nostr-keypair';
|
||||
|
||||
describe('NostrKeyPair Value Object', () => {
|
||||
// Known test vectors
|
||||
const TEST_PRIVATE_KEY_HEX = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
|
||||
describe('generate', () => {
|
||||
it('should generate a valid keypair', () => {
|
||||
const keyPair = NostrKeyPair.generate();
|
||||
|
||||
expect(keyPair.publicKeyHex).toBeTruthy();
|
||||
expect(keyPair.publicKeyHex.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should generate unique keypairs each time', () => {
|
||||
const keyPair1 = NostrKeyPair.generate();
|
||||
const keyPair2 = NostrKeyPair.generate();
|
||||
|
||||
expect(keyPair1.publicKeyHex).not.toEqual(keyPair2.publicKeyHex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromPrivateKey', () => {
|
||||
it('should create keypair from valid hex private key', () => {
|
||||
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
|
||||
|
||||
expect(keyPair.publicKeyHex).toBeTruthy();
|
||||
expect(keyPair.publicKeyHex.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should throw InvalidNostrKeyError for empty string', () => {
|
||||
expect(() => NostrKeyPair.fromPrivateKey('')).toThrowError(InvalidNostrKeyError);
|
||||
});
|
||||
|
||||
it('should throw InvalidNostrKeyError for invalid hex', () => {
|
||||
expect(() => NostrKeyPair.fromPrivateKey('not-valid-hex')).toThrowError(InvalidNostrKeyError);
|
||||
});
|
||||
|
||||
it('should throw InvalidNostrKeyError for hex that is too short', () => {
|
||||
const shortHex = '0123456789abcdef';
|
||||
expect(() => NostrKeyPair.fromPrivateKey(shortHex)).toThrowError(InvalidNostrKeyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public key formats', () => {
|
||||
it('should return hex public key', () => {
|
||||
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
|
||||
|
||||
expect(keyPair.publicKeyHex).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('should return npub format', () => {
|
||||
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
|
||||
|
||||
expect(keyPair.npub).toMatch(/^npub1[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
it('should return nsec format', () => {
|
||||
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
|
||||
|
||||
expect(keyPair.nsec).toMatch(/^nsec1[a-z0-9]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrivateKeyBytes', () => {
|
||||
it('should return 32-byte Uint8Array', () => {
|
||||
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
|
||||
const bytes = keyPair.getPrivateKeyBytes();
|
||||
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(bytes.length).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toStorageHex', () => {
|
||||
it('should return the hex private key for storage', () => {
|
||||
const keyPair = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
|
||||
|
||||
expect(keyPair.toStorageHex()).toEqual(TEST_PRIVATE_KEY_HEX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deterministic derivation', () => {
|
||||
it('should derive the same public key from the same private key', () => {
|
||||
const keyPair1 = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
|
||||
const keyPair2 = NostrKeyPair.fromPrivateKey(TEST_PRIVATE_KEY_HEX);
|
||||
|
||||
expect(keyPair1.publicKeyHex).toEqual(keyPair2.publicKeyHex);
|
||||
});
|
||||
});
|
||||
});
|
||||
223
projects/common/src/lib/domain/value-objects/nostr-keypair.ts
Normal file
223
projects/common/src/lib/domain/value-objects/nostr-keypair.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { bech32 } from '@scure/base';
|
||||
import * as utils from '@noble/curves/abstract/utils';
|
||||
import { getPublicKey, generateSecretKey } from 'nostr-tools';
|
||||
|
||||
/**
|
||||
* Value object encapsulating a Nostr keypair.
|
||||
* Provides type-safe access to public key operations while protecting the private key.
|
||||
*
|
||||
* The private key is never exposed directly - all operations that need it
|
||||
* are performed through methods on this class.
|
||||
*/
|
||||
export class NostrKeyPair {
|
||||
private readonly _privateKeyHex: string;
|
||||
private readonly _publicKeyHex: string;
|
||||
|
||||
private constructor(privateKeyHex: string, publicKeyHex: string) {
|
||||
this._privateKeyHex = privateKeyHex;
|
||||
this._publicKeyHex = publicKeyHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new random keypair.
|
||||
*/
|
||||
static generate(): NostrKeyPair {
|
||||
const privateKeyBytes = generateSecretKey();
|
||||
const privateKeyHex = utils.bytesToHex(privateKeyBytes);
|
||||
const publicKeyHex = getPublicKey(privateKeyBytes);
|
||||
return new NostrKeyPair(privateKeyHex, publicKeyHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keypair from an existing private key.
|
||||
* Accepts either hex or nsec format.
|
||||
*
|
||||
* @throws InvalidNostrKeyError if the key is invalid
|
||||
*/
|
||||
static fromPrivateKey(privateKey: string): NostrKeyPair {
|
||||
try {
|
||||
const hex = NostrKeyPair.normalizeToHex(privateKey);
|
||||
NostrKeyPair.validateHexKey(hex);
|
||||
const publicKeyHex = NostrKeyPair.derivePublicKey(hex);
|
||||
return new NostrKeyPair(hex, publicKeyHex);
|
||||
} catch (error) {
|
||||
throw new InvalidNostrKeyError(
|
||||
`Invalid private key: ${error instanceof Error ? error.message : 'unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute a keypair from storage.
|
||||
* Assumes the stored hex is valid (from trusted source).
|
||||
*/
|
||||
static fromStorage(privateKeyHex: string): NostrKeyPair {
|
||||
const publicKeyHex = NostrKeyPair.derivePublicKey(privateKeyHex);
|
||||
return new NostrKeyPair(privateKeyHex, publicKeyHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key in hex format.
|
||||
*/
|
||||
get publicKeyHex(): string {
|
||||
return this._publicKeyHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key in npub (bech32) format.
|
||||
*/
|
||||
get npub(): string {
|
||||
const data = utils.hexToBytes(this._publicKeyHex);
|
||||
const words = bech32.toWords(data);
|
||||
return bech32.encode('npub', words, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the private key in nsec (bech32) format.
|
||||
* Use with caution - only for display/export purposes.
|
||||
*/
|
||||
get nsec(): string {
|
||||
const data = utils.hexToBytes(this._privateKeyHex);
|
||||
const words = bech32.toWords(data);
|
||||
return bech32.encode('nsec', words, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the private key bytes for cryptographic operations.
|
||||
* Internal use only - required for signing and encryption.
|
||||
*/
|
||||
getPrivateKeyBytes(): Uint8Array {
|
||||
return utils.hexToBytes(this._privateKeyHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the private key hex for storage.
|
||||
* This should only be used when persisting to encrypted storage.
|
||||
*/
|
||||
toStorageHex(): string {
|
||||
return this._privateKeyHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this keypair has the same public key as another.
|
||||
*/
|
||||
hasSamePublicKey(other: NostrKeyPair): boolean {
|
||||
return this._publicKeyHex === other._publicKeyHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this keypair matches a given public key.
|
||||
*/
|
||||
matchesPublicKey(publicKeyHex: string): boolean {
|
||||
return this._publicKeyHex === publicKeyHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value equality based on public key.
|
||||
* Two keypairs are equal if they represent the same identity.
|
||||
*/
|
||||
equals(other: NostrKeyPair): boolean {
|
||||
return this._publicKeyHex === other._publicKeyHex;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static normalizeToHex(privateKey: string): string {
|
||||
if (privateKey.startsWith('nsec')) {
|
||||
return NostrKeyPair.nsecToHex(privateKey);
|
||||
}
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
private static nsecToHex(nsec: string): string {
|
||||
const { prefix, words } = bech32.decode(nsec as `${string}1${string}`, 5000);
|
||||
if (prefix !== 'nsec') {
|
||||
throw new Error('Invalid nsec prefix');
|
||||
}
|
||||
const data = new Uint8Array(bech32.fromWords(words));
|
||||
return utils.bytesToHex(data);
|
||||
}
|
||||
|
||||
private static validateHexKey(hex: string): void {
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
|
||||
throw new Error('Private key must be 64 hex characters');
|
||||
}
|
||||
}
|
||||
|
||||
private static derivePublicKey(privateKeyHex: string): string {
|
||||
const privateKeyBytes = utils.hexToBytes(privateKeyHex);
|
||||
return getPublicKey(privateKeyBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a Nostr key is invalid.
|
||||
*/
|
||||
export class InvalidNostrKeyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidNostrKeyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility functions for public key operations (no private key needed).
|
||||
*/
|
||||
export class NostrPublicKey {
|
||||
private constructor(private readonly _hex: string) {}
|
||||
|
||||
/**
|
||||
* Create from hex or npub format.
|
||||
*/
|
||||
static from(publicKey: string): NostrPublicKey {
|
||||
if (publicKey.startsWith('npub')) {
|
||||
const hex = NostrPublicKey.npubToHex(publicKey);
|
||||
return new NostrPublicKey(hex);
|
||||
}
|
||||
NostrPublicKey.validateHex(publicKey);
|
||||
return new NostrPublicKey(publicKey);
|
||||
}
|
||||
|
||||
get hex(): string {
|
||||
return this._hex;
|
||||
}
|
||||
|
||||
get npub(): string {
|
||||
const data = utils.hexToBytes(this._hex);
|
||||
const words = bech32.toWords(data);
|
||||
return bech32.encode('npub', words, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a shortened display version of the public key.
|
||||
*/
|
||||
shortened(prefixLength = 8, suffixLength = 4): string {
|
||||
const npub = this.npub;
|
||||
return `${npub.slice(0, prefixLength)}...${npub.slice(-suffixLength)}`;
|
||||
}
|
||||
|
||||
equals(other: NostrPublicKey): boolean {
|
||||
return this._hex === other._hex;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._hex;
|
||||
}
|
||||
|
||||
private static npubToHex(npub: string): string {
|
||||
const { prefix, words } = bech32.decode(npub as `${string}1${string}`, 5000);
|
||||
if (prefix !== 'npub') {
|
||||
throw new InvalidNostrKeyError('Invalid npub prefix');
|
||||
}
|
||||
const data = new Uint8Array(bech32.fromWords(words));
|
||||
return utils.bytesToHex(data);
|
||||
}
|
||||
|
||||
private static validateHex(hex: string): void {
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
|
||||
throw new InvalidNostrKeyError('Public key must be 64 hex characters');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { EntityId } from './entity-id';
|
||||
|
||||
/**
|
||||
* Strongly-typed identifier for Permission entities.
|
||||
* Prevents accidental mixing with other ID types.
|
||||
*/
|
||||
export class PermissionId extends EntityId<'PermissionId'> {
|
||||
private readonly _brand = 'PermissionId' as const;
|
||||
|
||||
private constructor(value: string) {
|
||||
super(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new unique PermissionId.
|
||||
*/
|
||||
static generate(): PermissionId {
|
||||
return new PermissionId(uuidv4());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PermissionId from an existing string value.
|
||||
* Use this when reconstituting from storage.
|
||||
*/
|
||||
static from(value: string): PermissionId {
|
||||
return new PermissionId(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if two IDs are equal.
|
||||
*/
|
||||
override equals(other: PermissionId): boolean {
|
||||
return other instanceof PermissionId && this._value === other._value;
|
||||
}
|
||||
}
|
||||
36
projects/common/src/lib/domain/value-objects/relay-id.ts
Normal file
36
projects/common/src/lib/domain/value-objects/relay-id.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { EntityId } from './entity-id';
|
||||
|
||||
/**
|
||||
* Strongly-typed identifier for Relay entities.
|
||||
* Prevents accidental mixing with other ID types.
|
||||
*/
|
||||
export class RelayId extends EntityId<'RelayId'> {
|
||||
private readonly _brand = 'RelayId' as const;
|
||||
|
||||
private constructor(value: string) {
|
||||
super(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new unique RelayId.
|
||||
*/
|
||||
static generate(): RelayId {
|
||||
return new RelayId(uuidv4());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a RelayId from an existing string value.
|
||||
* Use this when reconstituting from storage.
|
||||
*/
|
||||
static from(value: string): RelayId {
|
||||
return new RelayId(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if two IDs are equal.
|
||||
*/
|
||||
override equals(other: RelayId): boolean {
|
||||
return other instanceof RelayId && this._value === other._value;
|
||||
}
|
||||
}
|
||||
48
projects/common/src/lib/domain/value-objects/wallet-id.ts
Normal file
48
projects/common/src/lib/domain/value-objects/wallet-id.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { EntityId } from './entity-id';
|
||||
|
||||
/**
|
||||
* Strongly-typed identifier for NWC wallet connection entities.
|
||||
*/
|
||||
export class NwcConnectionId extends EntityId<'NwcConnectionId'> {
|
||||
private readonly _brand = 'NwcConnectionId' as const;
|
||||
|
||||
private constructor(value: string) {
|
||||
super(value);
|
||||
}
|
||||
|
||||
static generate(): NwcConnectionId {
|
||||
return new NwcConnectionId(uuidv4());
|
||||
}
|
||||
|
||||
static from(value: string): NwcConnectionId {
|
||||
return new NwcConnectionId(value);
|
||||
}
|
||||
|
||||
override equals(other: NwcConnectionId): boolean {
|
||||
return other instanceof NwcConnectionId && this._value === other._value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly-typed identifier for Cashu mint entities.
|
||||
*/
|
||||
export class CashuMintId extends EntityId<'CashuMintId'> {
|
||||
private readonly _brand = 'CashuMintId' as const;
|
||||
|
||||
private constructor(value: string) {
|
||||
super(value);
|
||||
}
|
||||
|
||||
static generate(): CashuMintId {
|
||||
return new CashuMintId(uuidv4());
|
||||
}
|
||||
|
||||
static from(value: string): CashuMintId {
|
||||
return new CashuMintId(value);
|
||||
}
|
||||
|
||||
override equals(other: CashuMintId): boolean {
|
||||
return other instanceof CashuMintId && this._value === other._value;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { bech32 } from '@scure/base';
|
||||
import * as utils from '@noble/curves/abstract/utils';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
import { encrypt as nip49Encrypt } from 'nostr-tools/nip49';
|
||||
|
||||
export interface NostrHexObject {
|
||||
represents: string;
|
||||
@@ -125,4 +126,21 @@ export class NostrHelper {
|
||||
hex: utils.bytesToHex(data),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a private key (hex) with a password using NIP-49.
|
||||
* Returns an ncryptsec bech32 string.
|
||||
* @param privkeyHex - The private key in hex format
|
||||
* @param password - The password to encrypt with
|
||||
* @param logN - Optional log2(N) parameter for scrypt (default: 16)
|
||||
* @returns Promise<string> - The ncryptsec bech32 encoded encrypted key
|
||||
*/
|
||||
static async privkeyToNcryptsec(
|
||||
privkeyHex: string,
|
||||
password: string,
|
||||
logN = 16
|
||||
): Promise<string> {
|
||||
const privkeyBytes = utils.hexToBytes(privkeyHex);
|
||||
return nip49Encrypt(privkeyBytes, password, logN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Context containing the cryptographic parameters needed for encryption/decryption.
|
||||
* This abstracts away the vault version differences (v1 PBKDF2 vs v2 Argon2id).
|
||||
*/
|
||||
export type EncryptionContext =
|
||||
| EncryptionContextV1
|
||||
| EncryptionContextV2;
|
||||
|
||||
/**
|
||||
* v1: PBKDF2-derived key from password
|
||||
*/
|
||||
export interface EncryptionContextV1 {
|
||||
version: 1;
|
||||
iv: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v2: Pre-derived Argon2id key
|
||||
*/
|
||||
export interface EncryptionContextV2 {
|
||||
version: 2;
|
||||
iv: string;
|
||||
keyBase64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for v1 context
|
||||
*/
|
||||
export function isV1Context(ctx: EncryptionContext): ctx is EncryptionContextV1 {
|
||||
return ctx.version === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for v2 context
|
||||
*/
|
||||
export function isV2Context(ctx: EncryptionContext): ctx is EncryptionContextV2 {
|
||||
return ctx.version === 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an encryption context from session data.
|
||||
* Returns undefined if no valid context can be created.
|
||||
*/
|
||||
export function createEncryptionContext(params: {
|
||||
iv: string;
|
||||
vaultPassword?: string;
|
||||
vaultKey?: string;
|
||||
}): EncryptionContext | undefined {
|
||||
if (params.vaultKey) {
|
||||
return {
|
||||
version: 2,
|
||||
iv: params.iv,
|
||||
keyBase64: params.vaultKey,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.vaultPassword) {
|
||||
return {
|
||||
version: 1,
|
||||
iv: params.iv,
|
||||
password: params.vaultPassword,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Buffer } from 'buffer';
|
||||
import { CryptoHelper } from '../../helpers/crypto-helper';
|
||||
import {
|
||||
EncryptionContext,
|
||||
isV2Context,
|
||||
} from './encryption-context';
|
||||
|
||||
/**
|
||||
* Service responsible for encrypting and decrypting data.
|
||||
* Abstracts away vault version differences (v1 PBKDF2 vs v2 Argon2id).
|
||||
*
|
||||
* This is an infrastructure service - it knows nothing about domain concepts,
|
||||
* only about cryptographic operations.
|
||||
*/
|
||||
export class EncryptionService {
|
||||
constructor(private readonly context: EncryptionContext) {}
|
||||
|
||||
/**
|
||||
* Encrypt a string value.
|
||||
*/
|
||||
async encryptString(value: string): Promise<string> {
|
||||
if (isV2Context(this.context)) {
|
||||
return this.encryptWithKeyV2(value);
|
||||
}
|
||||
return CryptoHelper.encrypt(value, this.context.iv, this.context.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a number value (converts to string first).
|
||||
*/
|
||||
async encryptNumber(value: number): Promise<string> {
|
||||
return this.encryptString(value.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a boolean value (converts to string first).
|
||||
*/
|
||||
async encryptBoolean(value: boolean): Promise<string> {
|
||||
return this.encryptString(value.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value to string.
|
||||
*/
|
||||
async decryptString(encrypted: string): Promise<string> {
|
||||
if (isV2Context(this.context)) {
|
||||
return this.decryptWithKeyV2(encrypted);
|
||||
}
|
||||
return CryptoHelper.decrypt(encrypted, this.context.iv, this.context.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value to number.
|
||||
*/
|
||||
async decryptNumber(encrypted: string): Promise<number> {
|
||||
const decrypted = await this.decryptString(encrypted);
|
||||
return parseInt(decrypted, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value to boolean.
|
||||
*/
|
||||
async decryptBoolean(encrypted: string): Promise<boolean> {
|
||||
const decrypted = await this.decryptString(encrypted);
|
||||
return decrypted === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the encryption context (for serialization or passing to other services).
|
||||
*/
|
||||
getContext(): EncryptionContext {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// V2 encryption/decryption using pre-derived Argon2id key
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async encryptWithKeyV2(text: string): Promise<string> {
|
||||
if (!isV2Context(this.context)) {
|
||||
throw new Error('V2 encryption requires keyBase64');
|
||||
}
|
||||
|
||||
const keyBytes = Buffer.from(this.context.keyBase64, 'base64');
|
||||
const iv = Buffer.from(this.context.iv, 'base64');
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
const cipherText = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
new TextEncoder().encode(text)
|
||||
);
|
||||
|
||||
return Buffer.from(cipherText).toString('base64');
|
||||
}
|
||||
|
||||
private async decryptWithKeyV2(encryptedBase64: string): Promise<string> {
|
||||
if (!isV2Context(this.context)) {
|
||||
throw new Error('V2 decryption requires keyBase64');
|
||||
}
|
||||
|
||||
const keyBytes = Buffer.from(this.context.keyBase64, 'base64');
|
||||
const iv = Buffer.from(this.context.iv, 'base64');
|
||||
const cipherText = Buffer.from(encryptedBase64, 'base64');
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
cipherText
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create an EncryptionService from session data.
|
||||
*/
|
||||
export function createEncryptionService(params: {
|
||||
iv: string;
|
||||
vaultPassword?: string;
|
||||
vaultKey?: string;
|
||||
}): EncryptionService {
|
||||
if (params.vaultKey) {
|
||||
return new EncryptionService({
|
||||
version: 2,
|
||||
iv: params.iv,
|
||||
keyBase64: params.vaultKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (params.vaultPassword) {
|
||||
return new EncryptionService({
|
||||
version: 1,
|
||||
iv: params.iv,
|
||||
password: params.vaultPassword,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('Either vaultPassword or vaultKey must be provided');
|
||||
}
|
||||
15
projects/common/src/lib/infrastructure/encryption/index.ts
Normal file
15
projects/common/src/lib/infrastructure/encryption/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
isV1Context,
|
||||
isV2Context,
|
||||
createEncryptionContext,
|
||||
} from './encryption-context';
|
||||
export type {
|
||||
EncryptionContext,
|
||||
EncryptionContextV1,
|
||||
EncryptionContextV2,
|
||||
} from './encryption-context';
|
||||
|
||||
export {
|
||||
EncryptionService,
|
||||
createEncryptionService,
|
||||
} from './encryption.service';
|
||||
2
projects/common/src/lib/infrastructure/index.ts
Normal file
2
projects/common/src/lib/infrastructure/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './encryption';
|
||||
export * from './repositories';
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
IdentityRepositoryError,
|
||||
IdentityErrorCode,
|
||||
} from '../../domain/repositories/identity-repository';
|
||||
import type {
|
||||
IdentityRepository,
|
||||
IdentitySnapshot,
|
||||
} from '../../domain/repositories/identity-repository';
|
||||
import { IdentityId } from '../../domain/value-objects';
|
||||
import { EncryptionService } from '../encryption';
|
||||
import { NostrHelper } from '../../helpers/nostr-helper';
|
||||
|
||||
/**
|
||||
* Encrypted identity as stored in browser sync storage.
|
||||
*/
|
||||
interface EncryptedIdentity {
|
||||
id: string;
|
||||
nick: string;
|
||||
privkey: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage adapter interface - abstracts browser storage operations.
|
||||
* Implementations provided by Chrome/Firefox specific code.
|
||||
*/
|
||||
export interface IdentityStorageAdapter {
|
||||
// Session (in-memory, decrypted) operations
|
||||
getSessionIdentities(): IdentitySnapshot[];
|
||||
setSessionIdentities(identities: IdentitySnapshot[]): void;
|
||||
saveSessionData(): Promise<void>;
|
||||
|
||||
getSessionSelectedId(): string | null;
|
||||
setSessionSelectedId(id: string | null): void;
|
||||
|
||||
// Sync (persistent, encrypted) operations
|
||||
getSyncIdentities(): EncryptedIdentity[];
|
||||
saveSyncIdentities(identities: EncryptedIdentity[]): Promise<void>;
|
||||
|
||||
getSyncSelectedId(): string | null;
|
||||
saveSyncSelectedId(id: string | null): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of IdentityRepository using browser storage.
|
||||
* Handles encryption/decryption transparently.
|
||||
*/
|
||||
export class BrowserIdentityRepository implements IdentityRepository {
|
||||
constructor(
|
||||
private readonly storage: IdentityStorageAdapter,
|
||||
private readonly encryption: EncryptionService
|
||||
) {}
|
||||
|
||||
async findById(id: IdentityId): Promise<IdentitySnapshot | undefined> {
|
||||
const identities = this.storage.getSessionIdentities();
|
||||
return identities.find((i) => i.id === id.value);
|
||||
}
|
||||
|
||||
async findByPublicKey(publicKey: string): Promise<IdentitySnapshot | undefined> {
|
||||
const identities = this.storage.getSessionIdentities();
|
||||
return identities.find((i) => {
|
||||
try {
|
||||
const derivedPubkey = NostrHelper.pubkeyFromPrivkey(i.privkey);
|
||||
return derivedPubkey === publicKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findByPrivateKey(privateKey: string): Promise<IdentitySnapshot | undefined> {
|
||||
// Normalize the private key to hex format
|
||||
let privkeyHex: string;
|
||||
try {
|
||||
privkeyHex = NostrHelper.getNostrPrivkeyObject(privateKey.toLowerCase()).hex;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const identities = this.storage.getSessionIdentities();
|
||||
return identities.find((i) => i.privkey === privkeyHex);
|
||||
}
|
||||
|
||||
async findAll(): Promise<IdentitySnapshot[]> {
|
||||
return this.storage.getSessionIdentities();
|
||||
}
|
||||
|
||||
async save(identity: IdentitySnapshot): Promise<void> {
|
||||
// Check for duplicate private key (excluding self)
|
||||
const existing = await this.findByPrivateKey(identity.privkey);
|
||||
if (existing && existing.id !== identity.id) {
|
||||
throw new IdentityRepositoryError(
|
||||
`An identity with the same private key already exists: ${existing.nick}`,
|
||||
IdentityErrorCode.DUPLICATE_PRIVATE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
// Update session storage
|
||||
const sessionIdentities = this.storage.getSessionIdentities();
|
||||
const existingIndex = sessionIdentities.findIndex((i) => i.id === identity.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing
|
||||
sessionIdentities[existingIndex] = identity;
|
||||
} else {
|
||||
// Add new
|
||||
sessionIdentities.push(identity);
|
||||
|
||||
// Auto-select if first identity
|
||||
if (sessionIdentities.length === 1) {
|
||||
this.storage.setSessionSelectedId(identity.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.storage.setSessionIdentities(sessionIdentities);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Encrypt and save to sync storage
|
||||
const encryptedIdentity = await this.encryptIdentity(identity);
|
||||
const syncIdentities = this.storage.getSyncIdentities();
|
||||
const syncIndex = syncIdentities.findIndex(
|
||||
async (i) => (await this.encryption.decryptString(i.id)) === identity.id
|
||||
);
|
||||
|
||||
if (syncIndex >= 0) {
|
||||
syncIdentities[syncIndex] = encryptedIdentity;
|
||||
} else {
|
||||
syncIdentities.push(encryptedIdentity);
|
||||
}
|
||||
|
||||
await this.storage.saveSyncIdentities(syncIdentities);
|
||||
|
||||
// Update selected ID in sync if this was the first identity
|
||||
if (sessionIdentities.length === 1) {
|
||||
const encryptedId = await this.encryption.encryptString(identity.id);
|
||||
await this.storage.saveSyncSelectedId(encryptedId);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: IdentityId): Promise<boolean> {
|
||||
const sessionIdentities = this.storage.getSessionIdentities();
|
||||
const initialLength = sessionIdentities.length;
|
||||
const filtered = sessionIdentities.filter((i) => i.id !== id.value);
|
||||
|
||||
if (filtered.length === initialLength) {
|
||||
return false; // Nothing was deleted
|
||||
}
|
||||
|
||||
// Update selected identity if needed
|
||||
const currentSelectedId = this.storage.getSessionSelectedId();
|
||||
if (currentSelectedId === id.value) {
|
||||
const newSelectedId = filtered.length > 0 ? filtered[0].id : null;
|
||||
this.storage.setSessionSelectedId(newSelectedId);
|
||||
}
|
||||
|
||||
this.storage.setSessionIdentities(filtered);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Remove from sync storage
|
||||
const encryptedId = await this.encryption.encryptString(id.value);
|
||||
const syncIdentities = this.storage.getSyncIdentities();
|
||||
const filteredSync = syncIdentities.filter((i) => i.id !== encryptedId);
|
||||
await this.storage.saveSyncIdentities(filteredSync);
|
||||
|
||||
// Update selected ID in sync
|
||||
const newSelectedId = this.storage.getSessionSelectedId();
|
||||
const encryptedSelectedId = newSelectedId
|
||||
? await this.encryption.encryptString(newSelectedId)
|
||||
: null;
|
||||
await this.storage.saveSyncSelectedId(encryptedSelectedId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async getSelectedId(): Promise<IdentityId | null> {
|
||||
const selectedId = this.storage.getSessionSelectedId();
|
||||
return selectedId ? IdentityId.from(selectedId) : null;
|
||||
}
|
||||
|
||||
async setSelectedId(id: IdentityId | null): Promise<void> {
|
||||
if (id) {
|
||||
// Verify the identity exists
|
||||
const exists = await this.findById(id);
|
||||
if (!exists) {
|
||||
throw new IdentityRepositoryError(
|
||||
`Identity not found: ${id.value}`,
|
||||
IdentityErrorCode.NOT_FOUND
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.storage.setSessionSelectedId(id?.value ?? null);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Update sync storage
|
||||
const encryptedId = id
|
||||
? await this.encryption.encryptString(id.value)
|
||||
: null;
|
||||
await this.storage.saveSyncSelectedId(encryptedId);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.storage.getSessionIdentities().length;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async encryptIdentity(identity: IdentitySnapshot): Promise<EncryptedIdentity> {
|
||||
return {
|
||||
id: await this.encryption.encryptString(identity.id),
|
||||
nick: await this.encryption.encryptString(identity.nick),
|
||||
privkey: await this.encryption.encryptString(identity.privkey),
|
||||
createdAt: await this.encryption.encryptString(identity.createdAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a BrowserIdentityRepository.
|
||||
*/
|
||||
export function createIdentityRepository(
|
||||
storage: IdentityStorageAdapter,
|
||||
encryption: EncryptionService
|
||||
): IdentityRepository {
|
||||
return new BrowserIdentityRepository(storage, encryption);
|
||||
}
|
||||
17
projects/common/src/lib/infrastructure/repositories/index.ts
Normal file
17
projects/common/src/lib/infrastructure/repositories/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
BrowserIdentityRepository,
|
||||
createIdentityRepository,
|
||||
} from './identity-repository.impl';
|
||||
export type { IdentityStorageAdapter } from './identity-repository.impl';
|
||||
|
||||
export {
|
||||
BrowserPermissionRepository,
|
||||
createPermissionRepository,
|
||||
} from './permission-repository.impl';
|
||||
export type { PermissionStorageAdapter } from './permission-repository.impl';
|
||||
|
||||
export {
|
||||
BrowserRelayRepository,
|
||||
createRelayRepository,
|
||||
} from './relay-repository.impl';
|
||||
export type { RelayStorageAdapter } from './relay-repository.impl';
|
||||
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
PermissionRepository,
|
||||
PermissionSnapshot,
|
||||
PermissionQuery,
|
||||
ExtensionMethod,
|
||||
} from '../../domain/repositories/permission-repository';
|
||||
import { IdentityId, PermissionId } from '../../domain/value-objects';
|
||||
import { EncryptionService } from '../encryption';
|
||||
|
||||
/**
|
||||
* Encrypted permission as stored in browser sync storage.
|
||||
*/
|
||||
interface EncryptedPermission {
|
||||
id: string;
|
||||
identityId: string;
|
||||
host: string;
|
||||
method: string;
|
||||
methodPolicy: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage adapter interface for permissions.
|
||||
*/
|
||||
export interface PermissionStorageAdapter {
|
||||
// Session (in-memory, decrypted) operations
|
||||
getSessionPermissions(): PermissionSnapshot[];
|
||||
setSessionPermissions(permissions: PermissionSnapshot[]): void;
|
||||
saveSessionData(): Promise<void>;
|
||||
|
||||
// Sync (persistent, encrypted) operations
|
||||
getSyncPermissions(): EncryptedPermission[];
|
||||
saveSyncPermissions(permissions: EncryptedPermission[]): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of PermissionRepository using browser storage.
|
||||
*/
|
||||
export class BrowserPermissionRepository implements PermissionRepository {
|
||||
constructor(
|
||||
private readonly storage: PermissionStorageAdapter,
|
||||
private readonly encryption: EncryptionService
|
||||
) {}
|
||||
|
||||
async findById(id: PermissionId): Promise<PermissionSnapshot | undefined> {
|
||||
const permissions = this.storage.getSessionPermissions();
|
||||
return permissions.find((p) => p.id === id.value);
|
||||
}
|
||||
|
||||
async find(query: PermissionQuery): Promise<PermissionSnapshot[]> {
|
||||
let permissions = this.storage.getSessionPermissions();
|
||||
|
||||
if (query.identityId) {
|
||||
const identityIdValue = query.identityId.value;
|
||||
permissions = permissions.filter((p) => p.identityId === identityIdValue);
|
||||
}
|
||||
if (query.host) {
|
||||
const host = query.host;
|
||||
permissions = permissions.filter((p) => p.host === host);
|
||||
}
|
||||
if (query.method) {
|
||||
const method = query.method;
|
||||
permissions = permissions.filter((p) => p.method === method);
|
||||
}
|
||||
if (query.kind !== undefined) {
|
||||
const kind = query.kind;
|
||||
permissions = permissions.filter((p) => p.kind === kind);
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
async findExact(
|
||||
identityId: IdentityId,
|
||||
host: string,
|
||||
method: ExtensionMethod,
|
||||
kind?: number
|
||||
): Promise<PermissionSnapshot | undefined> {
|
||||
const permissions = this.storage.getSessionPermissions();
|
||||
return permissions.find(
|
||||
(p) =>
|
||||
p.identityId === identityId.value &&
|
||||
p.host === host &&
|
||||
p.method === method &&
|
||||
(kind === undefined ? p.kind === undefined : p.kind === kind)
|
||||
);
|
||||
}
|
||||
|
||||
async findByIdentity(identityId: IdentityId): Promise<PermissionSnapshot[]> {
|
||||
const permissions = this.storage.getSessionPermissions();
|
||||
return permissions.filter((p) => p.identityId === identityId.value);
|
||||
}
|
||||
|
||||
async findAll(): Promise<PermissionSnapshot[]> {
|
||||
return this.storage.getSessionPermissions();
|
||||
}
|
||||
|
||||
async save(permission: PermissionSnapshot): Promise<void> {
|
||||
const sessionPermissions = this.storage.getSessionPermissions();
|
||||
const existingIndex = sessionPermissions.findIndex((p) => p.id === permission.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
sessionPermissions[existingIndex] = permission;
|
||||
} else {
|
||||
sessionPermissions.push(permission);
|
||||
}
|
||||
|
||||
this.storage.setSessionPermissions(sessionPermissions);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Encrypt and save to sync storage
|
||||
const encryptedPermission = await this.encryptPermission(permission);
|
||||
const syncPermissions = this.storage.getSyncPermissions();
|
||||
|
||||
// Find by decrypting IDs (expensive but necessary for updates)
|
||||
let syncIndex = -1;
|
||||
for (let i = 0; i < syncPermissions.length; i++) {
|
||||
try {
|
||||
const decryptedId = await this.encryption.decryptString(syncPermissions[i].id);
|
||||
if (decryptedId === permission.id) {
|
||||
syncIndex = i;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip corrupted entries
|
||||
}
|
||||
}
|
||||
|
||||
if (syncIndex >= 0) {
|
||||
syncPermissions[syncIndex] = encryptedPermission;
|
||||
} else {
|
||||
syncPermissions.push(encryptedPermission);
|
||||
}
|
||||
|
||||
await this.storage.saveSyncPermissions(syncPermissions);
|
||||
}
|
||||
|
||||
async delete(id: PermissionId): Promise<boolean> {
|
||||
const sessionPermissions = this.storage.getSessionPermissions();
|
||||
const initialLength = sessionPermissions.length;
|
||||
const filtered = sessionPermissions.filter((p) => p.id !== id.value);
|
||||
|
||||
if (filtered.length === initialLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.storage.setSessionPermissions(filtered);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Remove from sync storage
|
||||
const encryptedId = await this.encryption.encryptString(id.value);
|
||||
const syncPermissions = this.storage.getSyncPermissions();
|
||||
const filteredSync = syncPermissions.filter((p) => p.id !== encryptedId);
|
||||
await this.storage.saveSyncPermissions(filteredSync);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByIdentity(identityId: IdentityId): Promise<number> {
|
||||
const sessionPermissions = this.storage.getSessionPermissions();
|
||||
const initialLength = sessionPermissions.length;
|
||||
const filtered = sessionPermissions.filter((p) => p.identityId !== identityId.value);
|
||||
const deletedCount = initialLength - filtered.length;
|
||||
|
||||
if (deletedCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.storage.setSessionPermissions(filtered);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Remove from sync storage
|
||||
const encryptedIdentityId = await this.encryption.encryptString(identityId.value);
|
||||
const syncPermissions = this.storage.getSyncPermissions();
|
||||
const filteredSync = syncPermissions.filter((p) => p.identityId !== encryptedIdentityId);
|
||||
await this.storage.saveSyncPermissions(filteredSync);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
async count(query?: PermissionQuery): Promise<number> {
|
||||
if (query) {
|
||||
const results = await this.find(query);
|
||||
return results.length;
|
||||
}
|
||||
return this.storage.getSessionPermissions().length;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async encryptPermission(permission: PermissionSnapshot): Promise<EncryptedPermission> {
|
||||
const encrypted: EncryptedPermission = {
|
||||
id: await this.encryption.encryptString(permission.id),
|
||||
identityId: await this.encryption.encryptString(permission.identityId),
|
||||
host: await this.encryption.encryptString(permission.host),
|
||||
method: await this.encryption.encryptString(permission.method),
|
||||
methodPolicy: await this.encryption.encryptString(permission.methodPolicy),
|
||||
};
|
||||
|
||||
if (permission.kind !== undefined) {
|
||||
encrypted.kind = await this.encryption.encryptNumber(permission.kind);
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a BrowserPermissionRepository.
|
||||
*/
|
||||
export function createPermissionRepository(
|
||||
storage: PermissionStorageAdapter,
|
||||
encryption: EncryptionService
|
||||
): PermissionRepository {
|
||||
return new BrowserPermissionRepository(storage, encryption);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
RelayRepositoryError,
|
||||
RelayErrorCode,
|
||||
} from '../../domain/repositories/relay-repository';
|
||||
import type {
|
||||
RelayRepository,
|
||||
RelaySnapshot,
|
||||
RelayQuery,
|
||||
} from '../../domain/repositories/relay-repository';
|
||||
import { IdentityId, RelayId } from '../../domain/value-objects';
|
||||
import { EncryptionService } from '../encryption';
|
||||
|
||||
/**
|
||||
* Encrypted relay as stored in browser sync storage.
|
||||
*/
|
||||
interface EncryptedRelay {
|
||||
id: string;
|
||||
identityId: string;
|
||||
url: string;
|
||||
read: string;
|
||||
write: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage adapter interface for relays.
|
||||
*/
|
||||
export interface RelayStorageAdapter {
|
||||
// Session (in-memory, decrypted) operations
|
||||
getSessionRelays(): RelaySnapshot[];
|
||||
setSessionRelays(relays: RelaySnapshot[]): void;
|
||||
saveSessionData(): Promise<void>;
|
||||
|
||||
// Sync (persistent, encrypted) operations
|
||||
getSyncRelays(): EncryptedRelay[];
|
||||
saveSyncRelays(relays: EncryptedRelay[]): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of RelayRepository using browser storage.
|
||||
*/
|
||||
export class BrowserRelayRepository implements RelayRepository {
|
||||
constructor(
|
||||
private readonly storage: RelayStorageAdapter,
|
||||
private readonly encryption: EncryptionService
|
||||
) {}
|
||||
|
||||
async findById(id: RelayId): Promise<RelaySnapshot | undefined> {
|
||||
const relays = this.storage.getSessionRelays();
|
||||
return relays.find((r) => r.id === id.value);
|
||||
}
|
||||
|
||||
async find(query: RelayQuery): Promise<RelaySnapshot[]> {
|
||||
let relays = this.storage.getSessionRelays();
|
||||
|
||||
if (query.identityId) {
|
||||
const identityIdValue = query.identityId.value;
|
||||
relays = relays.filter((r) => r.identityId === identityIdValue);
|
||||
}
|
||||
if (query.url) {
|
||||
const urlLower = query.url.toLowerCase();
|
||||
relays = relays.filter((r) => r.url.toLowerCase() === urlLower);
|
||||
}
|
||||
if (query.read !== undefined) {
|
||||
const read = query.read;
|
||||
relays = relays.filter((r) => r.read === read);
|
||||
}
|
||||
if (query.write !== undefined) {
|
||||
const write = query.write;
|
||||
relays = relays.filter((r) => r.write === write);
|
||||
}
|
||||
|
||||
return relays;
|
||||
}
|
||||
|
||||
async findByUrl(identityId: IdentityId, url: string): Promise<RelaySnapshot | undefined> {
|
||||
const relays = this.storage.getSessionRelays();
|
||||
return relays.find(
|
||||
(r) =>
|
||||
r.identityId === identityId.value &&
|
||||
r.url.toLowerCase() === url.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
async findByIdentity(identityId: IdentityId): Promise<RelaySnapshot[]> {
|
||||
const relays = this.storage.getSessionRelays();
|
||||
return relays.filter((r) => r.identityId === identityId.value);
|
||||
}
|
||||
|
||||
async findAll(): Promise<RelaySnapshot[]> {
|
||||
return this.storage.getSessionRelays();
|
||||
}
|
||||
|
||||
async save(relay: RelaySnapshot): Promise<void> {
|
||||
// Check for duplicate URL for the same identity (excluding self)
|
||||
const existing = await this.findByUrl(
|
||||
IdentityId.from(relay.identityId),
|
||||
relay.url
|
||||
);
|
||||
if (existing && existing.id !== relay.id) {
|
||||
throw new RelayRepositoryError(
|
||||
'A relay with the same URL already exists for this identity',
|
||||
RelayErrorCode.DUPLICATE_URL
|
||||
);
|
||||
}
|
||||
|
||||
const sessionRelays = this.storage.getSessionRelays();
|
||||
const existingIndex = sessionRelays.findIndex((r) => r.id === relay.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
sessionRelays[existingIndex] = relay;
|
||||
} else {
|
||||
sessionRelays.push(relay);
|
||||
}
|
||||
|
||||
this.storage.setSessionRelays(sessionRelays);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Encrypt and save to sync storage
|
||||
const encryptedRelay = await this.encryptRelay(relay);
|
||||
const syncRelays = this.storage.getSyncRelays();
|
||||
|
||||
// Find by decrypting IDs
|
||||
let syncIndex = -1;
|
||||
for (let i = 0; i < syncRelays.length; i++) {
|
||||
try {
|
||||
const decryptedId = await this.encryption.decryptString(syncRelays[i].id);
|
||||
if (decryptedId === relay.id) {
|
||||
syncIndex = i;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip corrupted entries
|
||||
}
|
||||
}
|
||||
|
||||
if (syncIndex >= 0) {
|
||||
syncRelays[syncIndex] = encryptedRelay;
|
||||
} else {
|
||||
syncRelays.push(encryptedRelay);
|
||||
}
|
||||
|
||||
await this.storage.saveSyncRelays(syncRelays);
|
||||
}
|
||||
|
||||
async delete(id: RelayId): Promise<boolean> {
|
||||
const sessionRelays = this.storage.getSessionRelays();
|
||||
const initialLength = sessionRelays.length;
|
||||
const filtered = sessionRelays.filter((r) => r.id !== id.value);
|
||||
|
||||
if (filtered.length === initialLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.storage.setSessionRelays(filtered);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Remove from sync storage
|
||||
const encryptedId = await this.encryption.encryptString(id.value);
|
||||
const syncRelays = this.storage.getSyncRelays();
|
||||
const filteredSync = syncRelays.filter((r) => r.id !== encryptedId);
|
||||
await this.storage.saveSyncRelays(filteredSync);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByIdentity(identityId: IdentityId): Promise<number> {
|
||||
const sessionRelays = this.storage.getSessionRelays();
|
||||
const initialLength = sessionRelays.length;
|
||||
const filtered = sessionRelays.filter((r) => r.identityId !== identityId.value);
|
||||
const deletedCount = initialLength - filtered.length;
|
||||
|
||||
if (deletedCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.storage.setSessionRelays(filtered);
|
||||
await this.storage.saveSessionData();
|
||||
|
||||
// Remove from sync storage
|
||||
const encryptedIdentityId = await this.encryption.encryptString(identityId.value);
|
||||
const syncRelays = this.storage.getSyncRelays();
|
||||
const filteredSync = syncRelays.filter((r) => r.identityId !== encryptedIdentityId);
|
||||
await this.storage.saveSyncRelays(filteredSync);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
async count(query?: RelayQuery): Promise<number> {
|
||||
if (query) {
|
||||
const results = await this.find(query);
|
||||
return results.length;
|
||||
}
|
||||
return this.storage.getSessionRelays().length;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async encryptRelay(relay: RelaySnapshot): Promise<EncryptedRelay> {
|
||||
return {
|
||||
id: await this.encryption.encryptString(relay.id),
|
||||
identityId: await this.encryption.encryptString(relay.identityId),
|
||||
url: await this.encryption.encryptString(relay.url),
|
||||
read: await this.encryption.encryptBoolean(relay.read),
|
||||
write: await this.encryption.encryptBoolean(relay.write),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a BrowserRelayRepository.
|
||||
*/
|
||||
export function createRelayRepository(
|
||||
storage: RelayStorageAdapter,
|
||||
encryption: EncryptionService
|
||||
): RelayRepository {
|
||||
return new BrowserRelayRepository(storage, encryption);
|
||||
}
|
||||
@@ -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
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
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
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user