11 Commits
v1.1.1 ... main

Author SHA1 Message Date
woikos
482356a9e4 Release v1.2.2 - Simplify Cashu onboarding
- Remove storage info page, replace with simple backup reminder
- Add "Have you set up backups?" link to Configure Backups in settings
- Increase component style budget to 30kB

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:17:30 +01:00
woikos
a90eafbf18 Release v1.2.1 - Add quick-add mint list to Cashu wallet
- Add suggested mints list (Minibits, Coinos, 21Mint, Macadamia, Stablenut)
- Show quick-add menu on empty Cashu page with + icon and descriptions
- Add collapsible "Quick Add" disclosure when mints exist
- Hide already-added mints from the list
- Closes #6

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:00:48 +01:00
woikos
58e9053867 Release v1.2.0 - Streamlined vault creation flow
- Remove sync preference welcome page, default to no-sync
- Redesign vault-create home with nickname + nsec input
- Add generate key button, visibility toggle, clipboard copy
- Add vault file import with persistent snapshot list
- Navigate to profile view after identity creation
- Fix router state access for identity data passing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 17:30:42 +01:00
woikos
5183a4fc0a Add Unlicense (public domain)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 13:05:26 +01:00
woikos
a2d0a9bd32 Add privacy policy for extension store submissions
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:45:39 +01:00
woikos
5cf0fed4ed Add store screenshots and descriptions for Chrome/Firefox
- Chrome Web Store screenshots (1280x800)
- Firefox AMO screenshots (1280x800)
- Store listing descriptions for both platforms
- Source screenshots from extension UI

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:31:24 +01:00
woikos
4a2bc4fe72 Release v1.1.5 - Remove debug and signing logs
- Disable debug() logging function in background scripts
- Remove backgroundLogNip07Action calls for NIP-07 operations
- Remove backgroundLogPermissionStored calls for permission events
- Clean up unused imports and result variables
- Simplify switch statement returns in processNip07Request

Files modified:
- package.json (version bump)
- projects/chrome/src/background-common.ts
- projects/chrome/src/background.ts
- projects/firefox/src/background-common.ts
- projects/firefox/src/background.ts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:07:56 +01:00
a2e47d8612 Release v1.1.4 - Improve ncryptsec export page UX
- Auto-focus password input when page loads
- Move QR code above password input form (displays after generation)
- Move explanation text below the form
- Replace ncryptsec text output with clickable QR code button
- Add hover/active effects and "Copy to clipboard" tooltip to QR code
- Remove redundant copy button and text display

Files modified:
- package.json (version bump)
- projects/chrome/public/manifest.json
- projects/chrome/src/app/components/edit-identity/ncryptsec/*
- projects/firefox/public/manifest.json
- projects/firefox/src/app/components/edit-identity/ncryptsec/*

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 10:04:07 +02:00
2074c409f0 Release v1.1.3 - Add NIP-49 ncryptsec export feature
- Add ncryptsec page for exporting encrypted private keys (NIP-49)
- Implement password-based encryption using scrypt + XChaCha20-Poly1305
- Display QR code for easy mobile scanning of encrypted key
- Add click-to-copy functionality for ncryptsec string
- Add privkeyToNcryptsec() method to NostrHelper using nostr-tools nip49

Files modified:
- projects/common/src/lib/helpers/nostr-helper.ts
- projects/chrome/src/app/app.routes.ts
- projects/chrome/src/app/components/edit-identity/keys/keys.component.*
- projects/chrome/src/app/components/edit-identity/ncryptsec/ (new)
- projects/firefox/src/app/app.routes.ts
- projects/firefox/src/app/components/edit-identity/keys/keys.component.*
- projects/firefox/src/app/components/edit-identity/ncryptsec/ (new)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:39:47 +02:00
woikos
c11887dfa8 Release v1.1.2 - DDD refactoring with domain layer and ubiquitous language
- Add domain layer with value objects (IdentityId, Nickname, NostrKeyPair, etc.)
- Add rich domain entities (Identity, Permission, Relay) with behavior
- Add domain events for identity lifecycle (Created, Renamed, Selected, etc.)
- Add repository interfaces and infrastructure implementations
- Rename storage types to ubiquitous language (EncryptedVault, VaultSession, etc.)
- Fix PermissionChecker to prioritize kind-specific rules over blanket rules
- Add comprehensive test coverage for domain layer (113 tests passing)
- Maintain backwards compatibility with @deprecated aliases

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 05:29:42 +01:00
woikos
d98a0ef76e Implement DDD refactoring phases 1-4 with domain layer and ubiquitous language
Phase 1-3: Domain Layer Foundation
- Add value objects: IdentityId, PermissionId, RelayId, WalletId, Nickname, NostrKeyPair
- Add rich domain entities: Identity, Permission, Relay with behavior
- Add domain events: IdentityCreated, IdentityRenamed, IdentitySelected, etc.
- Add repository interfaces for Identity, Permission, Relay
- Add infrastructure layer with repository implementations
- Add EncryptionService abstraction

Phase 4: Ubiquitous Language Cleanup
- Rename BrowserSyncData → EncryptedVault (encrypted vault storage)
- Rename BrowserSessionData → VaultSession (decrypted session state)
- Rename SignerMetaData → ExtensionSettings (extension configuration)
- Rename Identity_ENCRYPTED → StoredIdentity (storage DTO)
- Rename Identity_DECRYPTED → IdentityData (session DTO)
- Similar renames for Permission, Relay, NwcConnection, CashuMint
- Add backwards compatibility aliases with @deprecated markers

Test Coverage
- Add comprehensive tests for all value objects
- Add tests for domain entities and their behavior
- Add tests for domain events
- Fix PermissionChecker to prioritize kind-specific rules over blanket rules
- Fix pre-existing component test issues (IconButton, Pubkey)

All 113 tests pass. Both Chrome and Firefox builds succeed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 05:21:44 +01:00
125 changed files with 7079 additions and 924 deletions

690
DDD_ANALYSIS.md Normal file
View File

@@ -0,0 +1,690 @@
# Domain-Driven Design Analysis: Plebeian Signer
This document analyzes the Plebeian Signer codebase through the lens of Domain-Driven Design (DDD) principles, identifying bounded contexts, current patterns, anti-patterns, and providing actionable recommendations for improvement.
## Executive Summary
Plebeian Signer is a browser extension for Nostr identity management implementing NIP-07. The codebase has **good structural foundations** (monorepo with shared library, handler abstraction pattern) but suffers from several DDD anti-patterns:
- **God Service**: `StorageService` handles too many responsibilities
- **Anemic Domain Models**: Types are data containers without behavior
- **Mixed Concerns**: Encryption logic interleaved with domain operations
- **Weak Ubiquitous Language**: Generic naming (`BrowserSyncData`) obscures domain concepts
**Priority Recommendations:**
1. Extract domain aggregates with behavior (Identity, Vault, Wallet)
2. Separate encryption into an infrastructure layer
3. Introduce repository pattern for each aggregate
4. Rename types to reflect ubiquitous language
---
## Domain Overview
### Core Domain Problem
> Enable users to manage multiple Nostr identities securely, sign events without exposing private keys to web applications, and interact with Lightning/Cashu wallets.
### Subdomain Classification
| Subdomain | Type | Rationale |
|-----------|------|-----------|
| **Identity & Signing** | Core | The differentiator - secure key management and NIP-07 implementation |
| **Permission Management** | Core | Critical security layer - controls what apps can do |
| **Vault Encryption** | Supporting | Necessary security but standard cryptographic patterns |
| **Wallet Integration** | Supporting | Extends functionality but not the core value proposition |
| **Profile Caching** | Generic | Standard caching pattern, could use any solution |
| **Relay Management** | Supporting | Per-identity configuration, fairly standard |
---
## Bounded Contexts
### Identified Contexts
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONTEXT MAP │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ Shared Kernel ┌──────────────────┐ │
│ │ Vault Context │◄─────────(crypto)──────────►│ Identity Context │ │
│ │ │ │ │ │
│ │ - VaultState │ │ - Identity │ │
│ │ - Encryption │ │ - KeyPair │ │
│ │ - Migration │ │ - Signing │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ │ Customer/Supplier │ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Permission Ctx │ │ Wallet Context │ │
│ │ │ │ │ │
│ │ - Policy │ │ - NWC │ │
│ │ - Host Rules │ │ - Cashu │ │
│ │ - Method Auth │ │ - Lightning │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Relay Context │◄──── Conformist ────────────►│ Profile Context │ │
│ │ │ │ │ │
│ │ - Per-identity │ │ - Kind 0 cache │ │
│ │ - Read/Write │ │ - Metadata │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Legend: ◄──► Bidirectional, ──► Supplier direction │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Context Definitions
#### 1. Vault Context
**Responsibility:** Secure storage lifecycle - creation, locking, unlocking, encryption, migration.
**Current Location:** `projects/common/src/lib/services/storage/related/vault.ts`
**Key Concepts:**
- VaultState (locked/unlocked)
- EncryptionKey (Argon2id-derived)
- VaultVersion (migration support)
- Salt, IV (cryptographic parameters)
**Language:**
| Term | Definition |
|------|------------|
| Vault | The encrypted container holding all sensitive data |
| Unlock | Derive key from password and decrypt vault contents |
| Lock | Clear session data, requiring password to access again |
| Migration | Upgrade vault encryption scheme (v1→v2) |
#### 2. Identity Context
**Responsibility:** Nostr identity lifecycle and cryptographic operations.
**Current Location:** `projects/common/src/lib/services/storage/related/identity.ts`
**Key Concepts:**
- Identity (aggregates pubkey, privkey, nick)
- KeyPair (hex or nsec/npub representations)
- SelectedIdentity (current active identity)
- EventSigning (NIP-07 signEvent)
**Language:**
| Term | Definition |
|------|------------|
| Identity | A Nostr keypair with a user-defined nickname |
| Selected Identity | The currently active identity for signing |
| Sign | Create schnorr signature for a Nostr event |
| Switch | Change the active identity |
#### 3. Permission Context
**Responsibility:** Authorization decisions for NIP-07 method calls.
**Current Location:** `projects/common/src/lib/services/storage/related/permission.ts`
**Key Concepts:**
- PermissionPolicy (allow/deny)
- MethodPermission (per NIP-07 method)
- KindPermission (signEvent kind filtering)
- HostWhitelist (trusted domains)
- RecklessMode (auto-approve all)
**Language:**
| Term | Definition |
|------|------------|
| Permission | A stored allow/deny decision for identity+host+method |
| Reckless Mode | Global setting to auto-approve all requests |
| Whitelist | Hosts that auto-approve without prompting |
| Prompt | UI asking user to authorize a request |
#### 4. Wallet Context
**Responsibility:** Lightning and Cashu wallet operations.
**Current Location:**
- `projects/common/src/lib/services/nwc/`
- `projects/common/src/lib/services/cashu/`
- `projects/common/src/lib/services/storage/related/nwc.ts`
- `projects/common/src/lib/services/storage/related/cashu.ts`
**Key Concepts:**
- NwcConnection (NIP-47 wallet connect)
- CashuMint (ecash mint connection)
- CashuProof (unspent tokens)
- LightningInvoice, Keysend
#### 5. Relay Context
**Responsibility:** Per-identity relay configuration.
**Current Location:** `projects/common/src/lib/services/storage/related/relay.ts`
**Key Concepts:**
- RelayConfiguration (URL + read/write permissions)
- IdentityRelays (relays scoped to an identity)
#### 6. Profile Context
**Responsibility:** Caching Nostr profile metadata (kind 0 events).
**Current Location:** `projects/common/src/lib/services/profile-metadata/`
**Key Concepts:**
- ProfileMetadata (name, picture, nip05, etc.)
- MetadataCache (fetchedAt timestamp)
---
## Current Architecture Analysis
### What's Working Well
1. **Monorepo Structure**
- Clean separation: `projects/common`, `projects/chrome`, `projects/firefox`
- Shared library via `@common` alias
- Browser-specific implementations isolated
2. **Handler Abstraction (Adapter Pattern)**
```
StorageService
├→ BrowserSessionHandler (abstract → ChromeSessionHandler, FirefoxSessionHandler)
├→ BrowserSyncHandler (abstract → ChromeSyncYesHandler, ChromeSyncNoHandler, ...)
└→ SignerMetaHandler (abstract → ChromeMetaHandler, FirefoxMetaHandler)
```
This enables pluggable browser implementations - good DDD practice.
3. **Encrypted/Decrypted Type Pairs**
- `Identity_DECRYPTED` / `Identity_ENCRYPTED`
- Clear distinction between storage states
4. **Vault Versioning**
- Migration path from v1 (PBKDF2) to v2 (Argon2id)
- Automatic upgrade on unlock
5. **Cascade Deletes**
- Deleting an identity removes associated permissions and relays
- Maintains referential integrity
### Anti-Patterns Identified
#### 1. God Service (`StorageService`)
**Location:** `projects/common/src/lib/services/storage/storage.service.ts`
**Problem:** Single service handles:
- Vault lifecycle (create, unlock, delete, migrate)
- Identity CRUD (add, delete, switch)
- Permission management
- Relay configuration
- NWC wallet connections
- Cashu mint management
- Encryption/decryption orchestration
**Symptoms:**
- 500+ lines when including bound methods
- Methods dynamically attached via functional composition
- Implicit dependencies between operations
- Difficult to test in isolation
**DDD Violation:** Violates single responsibility; should be split into aggregate-specific repositories.
#### 2. Anemic Domain Models
**Location:** `projects/common/src/lib/services/storage/types.ts`
**Problem:** All domain types are pure data containers:
```typescript
// Current: Anemic model
interface Identity_DECRYPTED {
id: string;
nick: string;
privkey: string;
createdAt: string;
}
// All behavior lives in external functions:
// - addIdentity() in identity.ts
// - switchIdentity() in identity.ts
// - encryptIdentity() in identity.ts
```
**Should Be:**
```typescript
// Rich domain model
class Identity {
private constructor(
private readonly _id: IdentityId,
private _nick: Nickname,
private readonly _keyPair: NostrKeyPair,
private readonly _createdAt: Date
) {}
static create(nick: string, privateKey?: string): Identity { /* ... */ }
get publicKey(): string { return this._keyPair.publicKey; }
sign(event: UnsignedEvent): SignedEvent {
return this._keyPair.sign(event);
}
rename(newNick: string): void {
this._nick = Nickname.create(newNick);
}
}
```
#### 3. Mixed Encryption Concerns
**Problem:** Domain operations and encryption logic are interleaved:
```typescript
// In identity.ts
export async function addIdentity(this: StorageService, data: {...}) {
// Domain logic
const identity_decrypted: Identity_DECRYPTED = {
id: uuid(),
nick: data.nick,
privkey: data.privkeyString,
createdAt: new Date().toISOString(),
};
// Encryption concern mixed in
const identity_encrypted = await encryptIdentity.call(this, identity_decrypted);
// Storage concern
await this.#browserSyncHandler.addIdentity(identity_encrypted);
this.#browserSessionHandler.addIdentity(identity_decrypted);
}
```
**Should Be:** Encryption as infrastructure layer, repositories handle persistence:
```typescript
class IdentityRepository {
async save(identity: Identity): Promise<void> {
const encrypted = this.encryptionService.encrypt(identity.toSnapshot());
await this.syncHandler.save(encrypted);
this.sessionHandler.cache(identity);
}
}
```
#### 4. Weak Ubiquitous Language
**Problem:** Type names reflect technical storage, not domain concepts:
| Current Name | Domain Concept |
|--------------|----------------|
| `BrowserSyncData` | `EncryptedVault` |
| `BrowserSessionData` | `UnlockedVaultState` |
| `SignerMetaData` | `ExtensionSettings` |
| `Identity_DECRYPTED` | `Identity` |
| `Identity_ENCRYPTED` | `EncryptedIdentity` |
#### 5. Implicit Aggregate Boundaries
**Problem:** No clear aggregate roots. External code can manipulate any data:
```typescript
// Anyone can reach into session data
const identity = this.#browserSessionHandler.getIdentity(id);
identity.nick = "changed"; // No invariant protection!
```
**Should Have:** Aggregate roots as single entry points with invariant protection.
#### 6. TypeScript Union Type Issues
**Problem:** `LockedVaultContext` uses optional fields instead of discriminated unions:
```typescript
// Current: Confusing optional fields
type LockedVaultContext =
| { iv: string; password: string; keyBase64?: undefined }
| { iv: string; keyBase64: string; password?: undefined };
// Better: Discriminated union
type LockedVaultContext =
| { version: 1; iv: string; password: string }
| { version: 2; iv: string; keyBase64: string };
```
---
## Recommended Domain Model
### Aggregate Design
```
┌─────────────────────────────────────────────────────────────────────┐
│ AGGREGATE MAP │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Vault Aggregate (Root: Vault) │ │
│ │ │ │
│ │ Vault ──────┬──► Identity[] (child entities) │ │
│ │ ├──► Permission[] (child entities) │ │
│ │ ├──► Relay[] (child entities) │ │
│ │ ├──► NwcConnection[] (child entities) │ │
│ │ └──► CashuMint[] (child entities) │ │
│ │ │ │
│ │ Invariants: │ │
│ │ - At most one identity can be selected │ │
│ │ - Permissions must reference existing identities │ │
│ │ - Relays must reference existing identities │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ExtensionSettings Aggregate (Root: ExtensionSettings) │ │
│ │ │ │
│ │ ExtensionSettings ──┬──► SyncPreference │ │
│ │ ├──► SecurityPolicy (reckless, whitelist)│ │
│ │ ├──► Bookmark[] │ │
│ │ └──► VaultSnapshot[] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ProfileCache Aggregate (Root: ProfileCache) │ │
│ │ │ │
│ │ ProfileCache ──► ProfileMetadata[] │ │
│ │ │ │
│ │ Invariants: │ │
│ │ - Entries expire after TTL │ │
│ │ - One entry per pubkey │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Value Objects
```typescript
// Strongly-typed identity
class IdentityId {
private constructor(private readonly value: string) {}
static generate(): IdentityId { return new IdentityId(uuid()); }
static from(value: string): IdentityId { return new IdentityId(value); }
equals(other: IdentityId): boolean { return this.value === other.value; }
toString(): string { return this.value; }
}
// Self-validating nickname
class Nickname {
private constructor(private readonly value: string) {}
static create(value: string): Nickname {
if (!value || value.trim().length === 0) {
throw new InvalidNicknameError(value);
}
return new Nickname(value.trim());
}
toString(): string { return this.value; }
}
// Nostr key pair encapsulation
class NostrKeyPair {
private constructor(
private readonly privateKeyHex: string,
private readonly publicKeyHex: string
) {}
static fromPrivateKey(privkey: string): NostrKeyPair {
const hex = privkey.startsWith('nsec')
? NostrHelper.nsecToHex(privkey)
: privkey;
const pubkey = NostrHelper.pubkeyFromPrivkey(hex);
return new NostrKeyPair(hex, pubkey);
}
get publicKey(): string { return this.publicKeyHex; }
get npub(): string { return NostrHelper.pubkey2npub(this.publicKeyHex); }
sign(event: UnsignedEvent): SignedEvent {
return NostrHelper.signEvent(event, this.privateKeyHex);
}
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
return version === 4
? NostrHelper.nip04Encrypt(plaintext, this.privateKeyHex, recipientPubkey)
: NostrHelper.nip44Encrypt(plaintext, this.privateKeyHex, recipientPubkey);
}
}
// Permission policy
class PermissionPolicy {
private constructor(
private readonly identityId: IdentityId,
private readonly host: string,
private readonly method: Nip07Method,
private readonly decision: 'allow' | 'deny',
private readonly kind?: number
) {}
static allow(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
return new PermissionPolicy(identityId, host, method, 'allow', kind);
}
static deny(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
return new PermissionPolicy(identityId, host, method, 'deny', kind);
}
matches(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): boolean {
return this.identityId.equals(identityId)
&& this.host === host
&& this.method === method
&& (this.kind === undefined || this.kind === kind);
}
isAllowed(): boolean { return this.decision === 'allow'; }
}
```
### Rich Domain Entities
```typescript
class Identity {
private readonly _id: IdentityId;
private _nickname: Nickname;
private readonly _keyPair: NostrKeyPair;
private readonly _createdAt: Date;
private _domainEvents: DomainEvent[] = [];
private constructor(
id: IdentityId,
nickname: Nickname,
keyPair: NostrKeyPair,
createdAt: Date
) {
this._id = id;
this._nickname = nickname;
this._keyPair = keyPair;
this._createdAt = createdAt;
}
static create(nickname: string, privateKey?: string): Identity {
const keyPair = privateKey
? NostrKeyPair.fromPrivateKey(privateKey)
: NostrKeyPair.generate();
const identity = new Identity(
IdentityId.generate(),
Nickname.create(nickname),
keyPair,
new Date()
);
identity._domainEvents.push(new IdentityCreated(identity._id, identity.publicKey));
return identity;
}
get id(): IdentityId { return this._id; }
get publicKey(): string { return this._keyPair.publicKey; }
get npub(): string { return this._keyPair.npub; }
get nickname(): string { return this._nickname.toString(); }
rename(newNickname: string): void {
const oldNickname = this._nickname.toString();
this._nickname = Nickname.create(newNickname);
this._domainEvents.push(new IdentityRenamed(this._id, oldNickname, newNickname));
}
sign(event: UnsignedEvent): SignedEvent {
return this._keyPair.sign(event);
}
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
return this._keyPair.encrypt(plaintext, recipientPubkey, version);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
```
---
## Refactoring Roadmap
### Phase 1: Extract Value Objects (Low Risk)
**Goal:** Introduce type safety without changing behavior.
1. Create `IdentityId`, `Nickname`, `NostrKeyPair` value objects
2. Use them in existing interfaces initially
3. Add validation in factory methods
4. Update helpers to use value objects
**Files to Modify:**
- Create `projects/common/src/lib/domain/value-objects/`
- Update `projects/common/src/lib/helpers/nostr-helper.ts`
### Phase 2: Introduce Repository Pattern (Medium Risk)
**Goal:** Separate storage concerns from domain logic.
1. Define repository interfaces in domain layer
2. Create `IdentityRepository`, `PermissionRepository`, etc.
3. Move encryption to `EncryptionService` infrastructure
4. Refactor `StorageService` to delegate to repositories
**New Structure:**
```
projects/common/src/lib/
├── domain/
│ ├── identity/
│ │ ├── Identity.ts
│ │ ├── IdentityRepository.ts (interface)
│ │ └── events/
│ ├── permission/
│ │ ├── PermissionPolicy.ts
│ │ └── PermissionRepository.ts (interface)
│ └── vault/
│ ├── Vault.ts
│ └── VaultRepository.ts (interface)
├── infrastructure/
│ ├── encryption/
│ │ └── EncryptionService.ts
│ └── persistence/
│ ├── ChromeIdentityRepository.ts
│ └── FirefoxIdentityRepository.ts
└── application/
├── IdentityApplicationService.ts
└── VaultApplicationService.ts
```
### Phase 3: Rich Domain Model (Higher Risk)
**Goal:** Move behavior into domain entities.
1. Convert `Identity_DECRYPTED` interface to `Identity` class
2. Move signing logic into `Identity.sign()`
3. Move encryption decision logic into domain
4. Add domain events for state changes
### Phase 4: Ubiquitous Language Cleanup
**Goal:** Align code with domain language.
| Old Name | New Name |
|----------|----------|
| `BrowserSyncData` | `EncryptedVault` |
| `BrowserSessionData` | `VaultSession` |
| `SignerMetaData` | `ExtensionSettings` |
| `StorageService` | `VaultService` (or split into multiple) |
| `addIdentity()` | `Identity.create()` + `IdentityRepository.save()` |
| `switchIdentity()` | `Vault.selectIdentity()` |
---
## Implementation Priorities
### High Priority (Security/Correctness)
1. **Encapsulate KeyPair operations** - Private keys should never be accessed directly
2. **Enforce invariants** - Selected identity must exist, permissions must reference valid identities
3. **Clear transaction boundaries** - What gets saved together?
### Medium Priority (Maintainability)
1. **Split StorageService** - Into VaultService, IdentityRepository, PermissionRepository
2. **Extract EncryptionService** - Pure infrastructure concern
3. **Type-safe IDs** - Prevent mixing up identity IDs with permission IDs
### Lower Priority (Polish)
1. **Domain events** - For audit trail and extensibility
2. **Full ubiquitous language** - Rename all types
3. **Discriminated unions** - For vault context types
---
## Testing Implications
Current state makes testing difficult because:
- `StorageService` requires mocking 4 handlers
- Encryption is interleaved with logic
- No clear boundaries to test in isolation
With proposed changes:
- Domain entities testable in isolation (no storage mocks)
- Repositories testable with in-memory implementations
- Clear separation enables focused unit tests
```typescript
// Example: Testing Identity domain logic
describe('Identity', () => {
it('signs events with internal keypair', () => {
const identity = Identity.create('Test', 'nsec1...');
const event = { kind: 1, content: 'test', /* ... */ };
const signed = identity.sign(event);
expect(signed.sig).toBeDefined();
expect(signed.pubkey).toBe(identity.publicKey);
});
it('prevents duplicate private keys via repository', async () => {
const repository = new InMemoryIdentityRepository();
const existing = Identity.create('First', 'nsec1abc...');
await repository.save(existing);
const duplicate = Identity.create('Second', 'nsec1abc...');
await expect(repository.save(duplicate))
.rejects.toThrow(DuplicateIdentityError);
});
});
```
---
## Conclusion
The Plebeian Signer codebase has solid foundations but would benefit significantly from DDD tactical patterns. The recommended approach:
1. **Start with value objects** - Low risk, immediate type safety benefits
2. **Introduce repositories gradually** - Extract one at a time, starting with Identity
3. **Defer full rich domain model** - Until repositories stabilize the architecture
4. **Update language as you go** - Rename types when touching files anyway
The goal is not architectural purity but **maintainability, testability, and security**. DDD patterns are a means to those ends in a domain (cryptographic identity management) where correctness matters.

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

68
PRIVACY_POLICY.md Normal file
View File

@@ -0,0 +1,68 @@
# Privacy Policy
**Plebeian Signer** is a browser extension for managing Nostr identities and signing events. This privacy policy explains how the extension handles your data.
## Data Collection
**Plebeian Signer does not collect, store, or transmit any user data to external servers.**
All data remains on your device under your control.
## Data Storage
The extension stores the following data locally in your browser:
- **Encrypted vault**: Your Nostr private keys, encrypted with your password using Argon2id + AES-256-GCM
- **Identity metadata**: Display names, profile information you configure
- **Permissions**: Your allow/deny decisions for websites
- **Cashu wallet data**: Mint connections and ecash tokens you store
- **Preferences**: Extension settings (sync mode, reckless mode, etc.)
This data is stored using your browser's built-in storage APIs and never leaves your device unless you enable browser sync (in which case it syncs through your browser's own sync service, not ours).
## External Connections
The extension only makes external network requests in the following cases:
1. **Cashu mints**: When you explicitly add a Cashu mint and perform wallet operations (deposit, send, receive), the extension connects to that mint's URL. You choose which mints to connect to.
2. **No other external connections**: The extension does not connect to any analytics services, tracking pixels, telemetry endpoints, or any servers operated by the developers.
## Third-Party Services
Plebeian Signer does not integrate with any third-party services. The only external services involved are:
- **Cashu mints**: User-configured ecash mints for wallet functionality
- **Browser sync** (optional): Your browser's native sync service if you enable vault syncing
## Data Sharing
We do not share any data because we do not have access to any data. Your private keys and all extension data remain encrypted on your device.
## Security
- Private keys are encrypted at rest using Argon2id key derivation and AES-256-GCM encryption
- Keys are never exposed to websites — only signatures are provided
- The vault locks automatically and requires your password to unlock
## Your Rights
Since all data is stored locally on your device:
- **Access**: View your data anytime in the extension
- **Delete**: Uninstall the extension or clear browser data to remove all stored data
- **Export**: Use the extension's export features to backup your data
## Changes to This Policy
Any changes to this privacy policy will be reflected in the extension's repository and release notes.
## Contact
For questions about this privacy policy, please open an issue at the project repository.
---
**Last updated**: January 2026
**Extension**: Plebeian Signer v1.1.5

View File

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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.0.8",
"version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plebeian-signer",
"version": "v1.0.8",
"version": "1.2.2",
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",

View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.1.1",
"version": "1.2.2",
"custom": {
"chrome": {
"version": "v1.1.1"
"version": "v1.1.6"
},
"firefox": {
"version": "v1.1.1"
"version": "v1.1.6"
}
},
"scripts": {
@@ -74,5 +74,6 @@
"rimraf": "^6.0.1",
"typescript": "~5.6.2",
"typescript-eslint": "8.18.0"
}
},
"license": "Unlicense"
}

Binary file not shown.

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
"version": "1.1.1",
"version": "1.1.6",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

@@ -4,7 +4,6 @@ import { VaultLoginComponent } from './components/vault-login/vault-login.compon
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
import { HomeComponent as VaultCreateHomeComponent } from './components/vault-create/home/home.component';
import { NewComponent as VaultCreateNewComponent } from './components/vault-create/new/new.component';
import { WelcomeComponent } from './components/welcome/welcome.component';
import { IdentitiesComponent } from './components/home/identities/identities.component';
import { IdentityComponent } from './components/home/identity/identity.component';
import { InfoComponent } from './components/home/info/info.component';
@@ -17,6 +16,7 @@ import { NewIdentityComponent } from './components/new-identity/new-identity.com
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';
@@ -24,10 +24,6 @@ import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelis
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
export const routes: Routes = [
{
path: 'welcome',
component: WelcomeComponent,
},
{
path: 'vault-login',
component: VaultLoginComponent,
@@ -112,6 +108,10 @@ export const routes: Routes = [
path: 'keys',
component: EditIdentityKeysComponent,
},
{
path: 'ncryptsec',
component: EditIdentityNcryptsecComponent,
},
{
path: 'permissions',
component: EditIdentityPermissionsComponent,

View File

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

View File

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

View File

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

View File

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

View File

@@ -136,6 +136,12 @@
</button>
</div>
</div>
<span class="sam-mt-2">Encrypted Key (NIP-49)</span>
<button class="btn btn-primary sam-mt-h" (click)="navigateToNcryptsec()">
Get ncryptsec
</button>
}
<lib-toast #toast [bottom]="16"></lib-toast>

View File

@@ -1,5 +1,5 @@
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import {
IconButtonComponent,
NavComponent,
@@ -29,6 +29,7 @@ export class KeysComponent extends NavComponent implements OnInit {
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
@@ -51,6 +52,11 @@ export class KeysComponent extends NavComponent implements OnInit {
}
}
navigateToNcryptsec() {
if (!this.identity) return;
this.#router.navigateByUrl(`/edit-identity/${this.identity.id}/ncryptsec`);
}
async #initialize(identityId: string) {
const identity = this.#storage
.getBrowserSessionHandler()

View File

@@ -0,0 +1,60 @@
<div class="header-pane">
<lib-icon-button
icon="chevron-left"
(click)="navigateBack()"
></lib-icon-button>
<span>Get ncryptsec</span>
</div>
<!-- QR Code (shown after generation) -->
@if (ncryptsec) {
<div class="qr-container">
<button
type="button"
class="qr-button"
title="Copy to clipboard"
(click)="copyToClipboard(ncryptsec); toast.show('Copied to clipboard')"
>
<img [src]="ncryptsecQr" alt="ncryptsec QR code" class="qr-code" />
</button>
</div>
}
<!-- PASSWORD INPUT -->
<div class="password-section">
<label for="ncryptsecPasswordInput">Password</label>
<div class="input-group sam-mt-h">
<input
#passwordInput
id="ncryptsecPasswordInput"
type="password"
class="form-control"
placeholder="Enter encryption password"
[(ngModel)]="ncryptsecPassword"
[disabled]="isGenerating"
(keyup.enter)="generateNcryptsec()"
/>
</div>
</div>
<button
class="btn btn-primary generate-btn"
type="button"
(click)="generateNcryptsec()"
[disabled]="!ncryptsecPassword || isGenerating"
>
@if (isGenerating) {
<span class="spinner-border spinner-border-sm" role="status"></span>
Generating...
} @else {
Generate ncryptsec
}
</button>
<p class="description">
Enter a password to encrypt your private key. The resulting ncryptsec can be
used to securely backup or transfer your key.
</p>
<lib-toast #toast [bottom]="16"></lib-toast>

View File

@@ -0,0 +1,70 @@
:host {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
.header-pane {
display: flex;
flex-direction: row;
column-gap: var(--size-h);
align-items: center;
padding-bottom: var(--size);
background-color: var(--background);
position: sticky;
top: 0;
}
}
.description {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: var(--size);
}
.password-section {
margin-bottom: var(--size);
label {
font-weight: 500;
margin-bottom: var(--size-q);
}
}
.generate-btn {
width: 100%;
margin-bottom: var(--size);
}
.qr-container {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: var(--size);
}
.qr-button {
background: white;
padding: var(--size);
border-radius: 8px;
border: none;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&:active {
transform: scale(0.98);
}
}
.qr-code {
width: 250px;
height: 250px;
display: block;
}

View File

@@ -0,0 +1,100 @@
import {
AfterViewInit,
Component,
ElementRef,
inject,
OnInit,
ViewChild,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
IconButtonComponent,
NavComponent,
NostrHelper,
StorageService,
ToastComponent,
} from '@common';
import { FormsModule } from '@angular/forms';
import * as QRCode from 'qrcode';
@Component({
selector: 'app-ncryptsec',
imports: [IconButtonComponent, FormsModule, ToastComponent],
templateUrl: './ncryptsec.component.html',
styleUrl: './ncryptsec.component.scss',
})
export class NcryptsecComponent
extends NavComponent
implements OnInit, AfterViewInit
{
@ViewChild('passwordInput') passwordInput!: ElementRef<HTMLInputElement>;
privkeyHex = '';
ncryptsecPassword = '';
ncryptsec = '';
ncryptsecQr = '';
isGenerating = false;
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
if (!identityId) {
return;
}
this.#initialize(identityId);
}
ngAfterViewInit(): void {
this.passwordInput.nativeElement.focus();
}
async generateNcryptsec() {
if (!this.privkeyHex || !this.ncryptsecPassword) {
return;
}
this.isGenerating = true;
this.ncryptsec = '';
this.ncryptsecQr = '';
try {
this.ncryptsec = await NostrHelper.privkeyToNcryptsec(
this.privkeyHex,
this.ncryptsecPassword
);
// Generate QR code
this.ncryptsecQr = await QRCode.toDataURL(this.ncryptsec, {
width: 250,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
} catch (error) {
console.error('Failed to generate ncryptsec:', error);
} finally {
this.isGenerating = false;
}
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
#initialize(identityId: string) {
const identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === identityId);
if (!identity) {
return;
}
this.privkeyHex = identity.privkey;
}
}

View File

@@ -27,7 +27,7 @@
>
<span class="text-muted">{{ permission.method }}</span>
@if(typeof permission.kind !== 'undefined') {
<span>(kind {{ permission.kind }})</span>
<span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
}
<div class="sam-flex-grow"></div>
<lib-icon-button

View File

@@ -5,6 +5,7 @@ import {
NavComponent,
Permission_DECRYPTED,
StorageService,
getKindName,
} from '@common';
import { ActivatedRoute } from '@angular/router';
@@ -86,4 +87,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
});
});
}
getKindTooltip(kind: number): string {
return getKindName(kind);
}
}

View File

@@ -57,73 +57,41 @@
<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
<!-- Suggested mints for quick-add -->
<div class="quick-add-section">
<div class="quick-add-label">Quick Add a Mint</div>
<div class="quick-add-menu">
@for (mint of suggestedMints; track mint.url) {
@if (!isMintAlreadyAdded(mint.url)) {
<button
class="quick-add-item"
[disabled]="addingMint"
(click)="quickAddMint(mint)"
>
<span class="mint-row">
<span class="add-icon">+</span>
<span class="mint-name">{{ mint.name }}</span>
</span>
<span class="mint-desc">{{ mint.description }}</span>
</button>
}
}
</div>
@if (mintError) {
<div class="error-message small">{{ mintError }}</div>
}
</div>
<div class="backup-reminder">
<span>Have you set up backups?</span>
<button class="link-btn" (click)="navigateToSettings()">
Configure Backups
</button>
</div>
}
</div>
</div>
} @else {
<div class="wallet-list">
@@ -134,6 +102,33 @@
</button>
}
</div>
<!-- Quick add disclosure when mints exist -->
@if (hasUnavailableMints()) {
<details class="quick-add-disclosure">
<summary>Quick Add</summary>
<div class="quick-add-menu">
@for (mint of suggestedMints; track mint.url) {
@if (!isMintAlreadyAdded(mint.url)) {
<button
class="quick-add-item"
[disabled]="addingMint"
(click)="quickAddMint(mint)"
>
<span class="mint-row">
<span class="add-icon">+</span>
<span class="mint-name">{{ mint.name }}</span>
</span>
<span class="mint-desc">{{ mint.description }}</span>
</button>
}
}
</div>
@if (mintError) {
<div class="error-message small">{{ mintError }}</div>
}
</details>
}
}
<button class="add-wallet-btn" (click)="showAddMint()">
<span class="emoji">+</span>
@@ -210,6 +205,31 @@
<!-- Cashu add mint form -->
@else if (activeSection === 'cashu-add') {
<div class="add-wallet-form">
<!-- Suggested mints -->
<div class="suggested-mints">
<div class="suggested-label">Quick Add</div>
<div class="suggested-list">
@for (mint of suggestedMints; track mint.url) {
<button
class="suggested-mint-btn"
[class.already-added]="isMintAlreadyAdded(mint.url)"
[disabled]="isMintAlreadyAdded(mint.url) || addingMint"
(click)="selectSuggestedMint(mint)"
[title]="mint.description"
>
<span class="mint-name">{{ mint.name }}</span>
@if (isMintAlreadyAdded(mint.url)) {
<span class="added-badge"></span>
}
</button>
}
</div>
</div>
<div class="form-divider">
<span>or enter manually</span>
</div>
<div class="form-group">
<label for="mintName">Mint Name</label>
<input

View File

@@ -1134,3 +1134,198 @@
padding: var(--size);
}
}
// Suggested mints quick-add
.suggested-mints {
display: flex;
flex-direction: column;
gap: var(--size-h);
&.centered {
align-items: center;
margin-top: var(--size);
.suggested-list {
justify-content: center;
}
}
.suggested-label {
font-size: 0.75rem;
color: var(--muted-foreground);
text-transform: uppercase;
font-weight: 500;
}
.suggested-list {
display: flex;
flex-wrap: wrap;
gap: var(--size-h);
}
.suggested-mint-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: var(--background-light);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.8rem;
color: var(--foreground);
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--background-light-hover);
border-color: var(--primary);
}
&:disabled {
cursor: not-allowed;
}
&.already-added {
opacity: 0.5;
background: transparent;
.added-badge {
color: var(--success, #22c55e);
font-size: 0.7rem;
}
}
.mint-name {
font-weight: 500;
}
}
}
.form-divider {
display: flex;
align-items: center;
gap: var(--size);
color: var(--muted-foreground);
font-size: 0.75rem;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
}
// Quick add section (empty state)
.quick-add-section {
width: 100%;
margin-top: var(--size);
.quick-add-label {
font-size: 0.75rem;
color: var(--muted-foreground);
text-transform: uppercase;
font-weight: 500;
text-align: center;
margin-bottom: var(--size-h);
}
}
// Backup reminder
.backup-reminder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-h);
margin-top: var(--size);
padding-top: var(--size);
border-top: 1px solid var(--border);
span {
font-size: 0.8rem;
color: var(--muted-foreground);
}
}
// Quick add disclosure (when mints exist)
.quick-add-disclosure {
margin-top: var(--size-h);
summary {
font-size: 0.8rem;
color: var(--muted-foreground);
cursor: pointer;
padding: var(--size-h);
text-align: center;
user-select: none;
&:hover {
color: var(--foreground);
}
&::marker {
color: var(--muted-foreground);
}
}
&[open] summary {
margin-bottom: var(--size-h);
}
}
// Quick add menu (shared by both)
.quick-add-menu {
display: flex;
flex-direction: column;
gap: 2px;
background: var(--background-light);
border-radius: var(--radius-md);
overflow: hidden;
}
.quick-add-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: var(--size-h) var(--size);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background: var(--background-light-hover);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.mint-row {
display: flex;
align-items: center;
gap: var(--size-h);
}
.add-icon {
font-size: 1rem;
font-weight: 600;
color: var(--success, #22c55e);
}
.mint-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
}
.mint-desc {
font-size: 0.75rem;
color: var(--muted-foreground);
padding-left: calc(1rem + var(--size-h));
}
}

View File

@@ -123,6 +123,15 @@ export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
refreshingMint = false;
refreshError = '';
// Suggested mints for quick-add
readonly suggestedMints = [
{ name: 'Minibits', url: 'https://mint.minibits.cash', description: 'Well-established mobile wallet mint' },
{ name: 'Coinos', url: 'https://mint.coinos.io', description: 'Lightning wallet with Cashu integration' },
{ name: '21Mint', url: 'https://21mint.me', description: 'Community mint' },
{ name: 'Macadamia', url: 'https://mint.macadamia.cash', description: 'Reliable community mint' },
{ name: 'Stablenut (USD)', url: 'https://stablenut.umint.cash', unit: 'usd', description: 'USD-denominated mint' },
];
get title(): string {
switch (this.activeSection) {
case 'cashu':
@@ -499,6 +508,35 @@ export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
}
}
selectSuggestedMint(mint: { name: string; url: string }) {
this.newMintName = mint.name;
this.newMintUrl = mint.url;
this.mintError = '';
this.mintTestResult = '';
}
isMintAlreadyAdded(mintUrl: string): boolean {
return this.mints.some(m => m.mintUrl === mintUrl);
}
hasUnavailableMints(): boolean {
return this.suggestedMints.some(m => !this.isMintAlreadyAdded(m.url));
}
async quickAddMint(mint: { name: string; url: string }) {
this.addingMint = true;
this.mintError = '';
try {
await this.cashuService.addMint(mint.name, mint.url);
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Failed to add mint';
} finally {
this.addingMint = false;
}
}
async deleteMint() {
if (!this.selectedMintId) return;

View File

@@ -1,32 +1,120 @@
<div class="vertically-centered">
<div class="sam-flex-column center">
<div class="sam-flex-column gap" style="align-items: center">
<span class="title">Plebeian Signer</span>
<div class="container">
<div class="logo-section">
<div class="logo-frame">
<img src="logo.svg" height="120" width="120" alt="" />
<img src="logo.svg" height="80" width="80" alt="" />
</div>
<span class="title">Plebeian Signer</span>
</div>
<!-- New Identity Section -->
<div class="section">
<h2 class="section-heading">Restore or Create New Identity</h2>
<span class="section-note">Create a new nostr identity or paste in your current nsec.</span>
<input
type="text"
class="form-control"
placeholder="nickname"
[(ngModel)]="nickname"
/>
<div class="input-group">
<input
#nsecInputElement
type="password"
class="form-control"
placeholder="nsec or hex private key"
[(ngModel)]="nsecInput"
(ngModelChange)="validateNsec()"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleVisibility(nsecInputElement)"
title="toggle visibility"
>
<i
class="bi"
[class.bi-eye]="nsecInputElement.type === 'password'"
[class.bi-eye-slash]="nsecInputElement.type === 'text'"
></i>
</button>
<button
class="btn btn-outline-secondary"
type="button"
(click)="copyToClipboard()"
title="copy to clipboard"
>
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="button-row">
<button
type="button"
class="btn btn-outline-secondary generate-btn"
(click)="generateKey()"
title="generate new key"
>
<span>generate</span>
<span></span>
</button>
<button
type="button"
class="sam-mt-2 btn btn-primary"
(click)="router.navigateByUrl('/vault-create/new')"
class="btn btn-primary continue-btn"
[disabled]="!isNsecValid || !nickname"
(click)="onContinueWithNsec()"
>
<div class="sam-flex-row gap-h">
<i class="bi bi-plus-circle" style="height: 22px"></i>
<span>Create a new vault</span>
</div>
<span>Continue</span>
<i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
<span class="sam-text-muted">or</span>
<!-- Import Section -->
<div class="section">
<h2 class="section-heading">Import a Vault</h2>
<input
#fileInput
type="file"
class="file-input"
accept=".json"
(change)="onFileSelected($event)"
/>
<div class="import-controls">
<button
type="button"
class="btn btn-secondary"
(click)="router.navigateByUrl('/vault-import')"
class="btn btn-outline-secondary file-btn"
(click)="fileInput.click()"
>
<span>Import a vault</span>
<i class="bi bi-folder2-open"></i>
<span>Add vault file</span>
</button>
@if (snapshots.length > 0) {
<div class="import-row">
<select class="form-select" [(ngModel)]="selectedSnapshot">
@for (snapshot of snapshots; track snapshot.id) {
<option [ngValue]="snapshot">
{{ snapshot.fileName }} ({{ snapshot.identityCount }} identities)
</option>
}
</select>
<button
type="button"
class="btn btn-primary icon-btn"
[disabled]="!selectedSnapshot"
(click)="onImport()"
title="import vault"
>
<i class="bi bi-arrow-right"></i>
</button>
</div>
}
</div>
</div>
</div>

View File

@@ -2,18 +2,26 @@
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
.vertically-centered {
height: 100%;
.container {
display: flex;
justify-content: center;
flex-direction: column;
padding: var(--size);
gap: var(--size);
}
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-half);
padding-bottom: var(--size-half);
}
.title {
font-size: 20px;
font-weight: 500;
margin-bottom: var(--size);
}
.logo-frame {
@@ -21,8 +29,73 @@
border-radius: 100%;
}
.section {
display: flex;
flex-direction: column;
gap: var(--size);
margin-top: var(--size);
}
.section-heading {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.section-note {
font-size: 14px;
color: var(--muted-foreground);
}
.button-row {
display: flex;
gap: var(--size);
justify-content: flex-end;
}
.generate-btn {
display: flex;
align-items: center;
gap: var(--size-half);
}
.continue-btn {
display: flex;
align-items: center;
gap: var(--size-half);
}
.file-input {
position: absolute;
visibility: hidden;
}
.file-btn {
display: flex;
align-items: center;
gap: var(--size-half);
}
.import-controls {
display: flex;
flex-direction: column;
gap: var(--size);
}
.import-row {
display: flex;
gap: var(--size-half);
select {
flex: 1;
}
}
.icon-btn {
width: 42px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@@ -1,13 +1,161 @@
import { Component, inject } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent } from '@common';
import {
NavComponent,
NostrHelper,
StorageService,
StartupService,
SignerMetaData_VaultSnapshot,
BrowserSyncData,
} from '@common';
import { generateSecretKey } from 'nostr-tools';
import { bytesToHex } from '@noble/hashes/utils';
import { v4 as uuidv4 } from 'uuid';
import browser from 'webextension-polyfill';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
const VAULT_SNAPSHOTS_KEY = 'vaultSnapshots';
@Component({
selector: 'app-home',
imports: [],
imports: [FormsModule],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent extends NavComponent {
export class HomeComponent extends NavComponent implements OnInit {
readonly router = inject(Router);
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
nickname = '';
nsecInput = '';
isNsecValid = false;
snapshots: SignerMetaData_VaultSnapshot[] = [];
selectedSnapshot: SignerMetaData_VaultSnapshot | undefined;
ngOnInit(): void {
this.#loadSnapshots();
}
generateKey() {
const sk = generateSecretKey();
const privkey = bytesToHex(sk);
this.nsecInput = NostrHelper.privkey2nsec(privkey);
this.validateNsec();
}
toggleVisibility(element: HTMLInputElement) {
element.type = element.type === 'password' ? 'text' : 'password';
}
async copyToClipboard() {
if (this.nsecInput) {
await navigator.clipboard.writeText(this.nsecInput);
}
}
validateNsec() {
if (!this.nsecInput) {
this.isNsecValid = false;
return;
}
try {
NostrHelper.getNostrPrivkeyObject(this.nsecInput.toLowerCase());
this.isNsecValid = true;
} catch {
this.isNsecValid = false;
}
}
onContinueWithNsec() {
if (!this.isNsecValid || !this.nickname) {
return;
}
// Navigate to password step, passing nsec and nickname in state
this.router.navigateByUrl('/vault-create/new', {
state: { nsec: this.nsecInput, nickname: this.nickname },
});
}
async onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) {
return;
}
try {
const file = files[0];
const text = await file.text();
const vault = JSON.parse(text) as BrowserSyncData;
// Check if file already exists
if (this.snapshots.some((s) => s.fileName === file.name)) {
input.value = '';
return;
}
const newSnapshot: SignerMetaData_VaultSnapshot = {
id: uuidv4(),
fileName: file.name,
createdAt: new Date().toISOString(),
data: vault,
identityCount: vault.identities?.length ?? 0,
reason: 'manual',
};
this.snapshots = [...this.snapshots, newSnapshot].sort((a, b) =>
b.fileName.localeCompare(a.fileName)
);
this.selectedSnapshot = newSnapshot;
await this.#saveSnapshots();
} catch (error) {
console.error('Failed to load vault file:', error);
}
// Reset input so same file can be selected again
input.value = '';
}
async onImport() {
if (!this.selectedSnapshot) {
return;
}
try {
await this.#storage.deleteVault(true);
await this.#storage.importVault(this.selectedSnapshot.data);
// Restart the app to properly reinitialize and route to vault-login
this.#storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to import vault:', error);
}
}
async #loadSnapshots() {
const data = (await browser.storage.local.get(VAULT_SNAPSHOTS_KEY)) as {
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
};
this.snapshots = data.vaultSnapshots
? [...data.vaultSnapshots].sort((a, b) =>
b.fileName.localeCompare(a.fileName)
)
: [];
if (this.snapshots.length > 0) {
this.selectedSnapshot = this.snapshots[0];
}
}
async #saveSnapshots() {
await browser.storage.local.set({
[VAULT_SNAPSHOTS_KEY]: this.snapshots,
});
}
}

View File

@@ -1,7 +1,12 @@
import { Component, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
import {
LoggerService,
NavComponent,
StorageService,
DerivingModalComponent,
} from '@common';
@Component({
selector: 'app-new',
@@ -18,6 +23,15 @@ export class NewComponent extends NavComponent {
readonly #storage = inject(StorageService);
readonly #logger = inject(LoggerService);
// Access router state via history.state (persists after navigation completes)
get #nsec(): string | undefined {
return history.state?.nsec;
}
get #nickname(): string | undefined {
return history.state?.nickname;
}
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
element.type = 'text';
@@ -35,9 +49,22 @@ export class NewComponent extends NavComponent {
this.derivingModal.show('Creating secure vault');
try {
await this.#storage.createNewVault(this.password);
this.derivingModal.hide();
this.#logger.logVaultCreated();
this.#router.navigateByUrl('/home/identities');
// If nsec and nickname were passed, add the identity
if (this.#nsec && this.#nickname) {
try {
await this.#storage.addIdentity({
nick: this.#nickname,
privkeyString: this.#nsec,
});
} catch (error) {
console.error('Failed to add identity:', error);
}
}
this.derivingModal.hide();
this.#router.navigateByUrl('/home/identity');
} catch (error) {
this.derivingModal.hide();
console.error('Failed to create vault:', error);

View File

@@ -1,64 +0,0 @@
<div class="sam-text-header sam-mb-h">
<span>Plebeian Signer Setup - Sync Preference</span>
</div>
<span class="sam-text-muted sam-text-md sam-text-align-center2">
Plebeian Signer always encrypts sensitive data like private keys and site permissions
independent of the chosen sync mode.
</span>
<span class="sam-mt sam-text-lg">Sync : Google Chrome</span>
<span class="sam-text-muted sam-text-md sam-text-align-center2">
Your encrypted data is synced between browser instances. You need to be signed
in with your account.
</span>
<button
type="button"
class="sam-mt btn btn-primary"
(click)="onClickSync(true)"
>
<span> Sync ON</span>
</button>
<span class="sam-mt sam-text-lg">Offline</span>
<span class="sam-text-muted sam-text-md">
Your encrypted data is never uploaded to any servers. It remains in your local
browser instance.
</span>
<button
type="button"
class="sam-mt sam-mb-2 btn btn-secondary"
(click)="onClickSync(false)"
>
<span> Sync OFF</span>
</button>
<div class="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">
Your preference can later be changed at any time.
</span>

View File

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

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { WelcomeComponent } from './welcome.component';
describe('WelcomeComponent', () => {
let component: WelcomeComponent;
let fixture: ComponentFixture<WelcomeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WelcomeComponent]
})
.compileComponents();
fixture = TestBed.createComponent(WelcomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,41 +0,0 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { BrowserSyncFlow, StorageService } from '@common';
@Component({
selector: 'app-welcome',
imports: [],
templateUrl: './welcome.component.html',
styleUrl: './welcome.component.scss',
})
export class WelcomeComponent {
readonly router = inject(Router);
readonly #storage = inject(StorageService);
async onClickSync(enabled: boolean) {
const flow: BrowserSyncFlow = enabled
? BrowserSyncFlow.BROWSER_SYNC
: BrowserSyncFlow.NO_SYNC;
await this.#storage.enableBrowserSyncFlow(flow);
// In case the user has selected the BROWSER_SYNC flow,
// we have to check if there is sync data available (e.g. from
// another browser instance).
// If so, navigate to /vault-login, otherwise to /vault-create/home.
if (flow === BrowserSyncFlow.BROWSER_SYNC) {
const browserSyncData =
await this.#storage.loadAndMigrateBrowserSyncData();
if (
typeof browserSyncData !== 'undefined' &&
Object.keys(browserSyncData).length > 0
) {
await this.router.navigateByUrl('/vault-login');
return;
}
}
await this.router.navigateByUrl('/vault-create/home');
}
}

View File

@@ -40,10 +40,13 @@ export interface UnlockResponseMessage {
error?: string;
}
export const debug = function (message: any) {
const dateString = new Date().toISOString();
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
};
// 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'

View File

@@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
NwcClient,
NwcConnection_DECRYPTED,
@@ -441,7 +439,6 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
policy,
req.params?.kind
);
await backgroundLogPermissionStored(req.host, req.method, policy, req.params?.kind);
} else if (response === 'approve-all') {
// P2: Store permission for ALL kinds/uses of this method from this host
await storePermission(
@@ -452,8 +449,6 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
'allow',
undefined // undefined kind = allow all kinds for signEvent
);
await backgroundLogPermissionStored(req.host, req.method, 'allow', undefined);
debug(`Stored approve-all permission for ${req.method} from ${req.host}`);
} else if (response === 'reject-all') {
// P2: Store deny permission for ALL uses of this method from this host
await storePermission(
@@ -464,15 +459,9 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
'deny',
undefined
);
await backgroundLogPermissionStored(req.host, req.method, 'deny', undefined);
debug(`Stored reject-all permission for ${req.method} from ${req.host}`);
}
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
await backgroundLogNip07Action(req.method, req.host, false, false, {
kind: req.params?.kind,
peerPubkey: req.params?.peerPubkey,
});
throw new Error('Permission denied');
}
} else {
@@ -481,71 +470,47 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
}
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}'.`);
@@ -625,7 +590,6 @@ async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any>
method,
policy
);
await backgroundLogPermissionStored(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(
@@ -635,8 +599,6 @@ async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any>
method,
'allow'
);
await backgroundLogPermissionStored(req.host, method, 'allow');
debug(`Stored approve-all permission for ${method} from ${req.host}`);
}
if (['reject', 'reject-once', 'reject-all'].includes(response)) {

View File

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

View File

@@ -14,6 +14,8 @@ describe('PubkeyComponent', () => {
fixture = TestBed.createComponent(PubkeyComponent);
component = fixture.componentInstance;
// Valid test pubkey (64 hex chars)
component.value = 'a'.repeat(64);
fixture.detectChanges();
});

View File

@@ -0,0 +1,200 @@
import { Identity, UnsignedEvent, SignedEvent, SigningFunction } from './identity';
import { IdentityCreated, IdentityRenamed, IdentitySigned } from '../events';
describe('Identity Entity', () => {
const TEST_PRIVATE_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
describe('create', () => {
it('should create identity with generated keypair when no private key provided', () => {
const identity = Identity.create('Alice');
expect(identity.nickname).toEqual('Alice');
expect(identity.publicKey).toBeTruthy();
expect(identity.publicKey.length).toBe(64);
});
it('should create identity with provided private key', () => {
const identity = Identity.create('Bob', TEST_PRIVATE_KEY);
expect(identity.nickname).toEqual('Bob');
expect(identity.publicKey).toBeTruthy();
});
it('should raise IdentityCreated event', () => {
const identity = Identity.create('Charlie');
const events = identity.pullDomainEvents();
expect(events.length).toBe(1);
expect(events[0]).toBeInstanceOf(IdentityCreated);
const createdEvent = events[0] as IdentityCreated;
expect(createdEvent.identityId).toEqual(identity.id.toString());
expect(createdEvent.publicKey).toEqual(identity.publicKey);
expect(createdEvent.nickname).toEqual('Charlie');
});
it('should set createdAt timestamp', () => {
const before = new Date();
const identity = Identity.create('Dana');
const after = new Date();
expect(identity.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(identity.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
});
describe('fromSnapshot', () => {
it('should reconstruct identity from snapshot', () => {
const original = Identity.create('Eve', TEST_PRIVATE_KEY);
original.pullDomainEvents(); // Clear creation event
const snapshot = original.toSnapshot();
const restored = Identity.fromSnapshot(snapshot);
expect(restored.id.toString()).toEqual(original.id.toString());
expect(restored.nickname).toEqual('Eve');
expect(restored.publicKey).toEqual(original.publicKey);
});
it('should not raise events when loading from snapshot', () => {
const original = Identity.create('Frank');
const snapshot = original.toSnapshot();
const restored = Identity.fromSnapshot(snapshot);
const events = restored.pullDomainEvents();
expect(events.length).toBe(0);
});
});
describe('rename', () => {
it('should update nickname', () => {
const identity = Identity.create('OldName');
identity.pullDomainEvents(); // Clear creation event
identity.rename('NewName');
expect(identity.nickname).toEqual('NewName');
});
it('should raise IdentityRenamed event', () => {
const identity = Identity.create('OldName');
identity.pullDomainEvents(); // Clear creation event
identity.rename('NewName');
const events = identity.pullDomainEvents();
expect(events.length).toBe(1);
expect(events[0]).toBeInstanceOf(IdentityRenamed);
const renamedEvent = events[0] as IdentityRenamed;
expect(renamedEvent.identityId).toEqual(identity.id.toString());
expect(renamedEvent.oldNickname).toEqual('OldName');
expect(renamedEvent.newNickname).toEqual('NewName');
});
});
describe('sign', () => {
it('should call signing function with event and return signed event', () => {
const identity = Identity.create('Signer', TEST_PRIVATE_KEY);
identity.pullDomainEvents();
const unsignedEvent: UnsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello, Nostr!',
};
const mockSignFn: SigningFunction = (event, privateKeyBytes) => {
expect(privateKeyBytes).toBeInstanceOf(Uint8Array);
expect(privateKeyBytes.length).toBe(32);
return {
...event,
id: 'mock-event-id',
pubkey: identity.publicKey,
sig: 'mock-signature',
} as SignedEvent;
};
const signedEvent = identity.sign(unsignedEvent, mockSignFn);
expect(signedEvent.id).toEqual('mock-event-id');
expect(signedEvent.pubkey).toEqual(identity.publicKey);
expect(signedEvent.sig).toEqual('mock-signature');
});
it('should raise IdentitySigned event', () => {
const identity = Identity.create('Signer', TEST_PRIVATE_KEY);
identity.pullDomainEvents();
const unsignedEvent: UnsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Test',
};
const mockSignFn: SigningFunction = (event) => ({
...event,
id: 'signed-event-id',
pubkey: identity.publicKey,
sig: 'sig',
} as SignedEvent);
identity.sign(unsignedEvent, mockSignFn);
const events = identity.pullDomainEvents();
expect(events.length).toBe(1);
expect(events[0]).toBeInstanceOf(IdentitySigned);
const signedEvt = events[0] as IdentitySigned;
expect(signedEvt.identityId).toEqual(identity.id.toString());
expect(signedEvt.eventKind).toBe(1);
expect(signedEvt.signedEventId).toEqual('signed-event-id');
});
});
describe('toSnapshot', () => {
it('should create complete snapshot for storage', () => {
const identity = Identity.create('Snapshot Test', TEST_PRIVATE_KEY);
const snapshot = identity.toSnapshot();
expect(snapshot.id).toEqual(identity.id.toString());
expect(snapshot.nick).toEqual('Snapshot Test');
expect(snapshot.privkey).toBeTruthy();
expect(snapshot.createdAt).toBeTruthy();
});
});
describe('npub', () => {
it('should return bech32 encoded public key', () => {
const identity = Identity.create('NpubTest');
expect(identity.npub).toMatch(/^npub1[a-z0-9]+$/);
});
});
describe('pullDomainEvents', () => {
it('should clear events after pulling', () => {
const identity = Identity.create('Test');
const firstPull = identity.pullDomainEvents();
const secondPull = identity.pullDomainEvents();
expect(firstPull.length).toBe(1);
expect(secondPull.length).toBe(0);
});
it('should accumulate multiple events', () => {
const identity = Identity.create('Multi');
identity.rename('Name1');
identity.rename('Name2');
const events = identity.pullDomainEvents();
expect(events.length).toBe(3); // Created + 2 renames
});
});
});

View File

@@ -0,0 +1,305 @@
import { AggregateRoot } from '../events/domain-event';
import { IdentityCreated, IdentityRenamed, IdentitySigned } from '../events/identity-events';
import {
IdentityId,
Nickname,
NostrKeyPair,
} from '../value-objects';
import type { IdentitySnapshot } from '../repositories/identity-repository';
/**
* Represents an unsigned Nostr event template.
* This is what gets passed to the sign method.
*/
export interface UnsignedEvent {
kind: number;
created_at: number;
tags: string[][];
content: string;
}
/**
* Represents a signed Nostr event.
*/
export interface SignedEvent extends UnsignedEvent {
id: string;
pubkey: string;
sig: string;
}
/**
* Signing function type - injected to avoid coupling to nostr-tools.
*/
export type SigningFunction = (event: UnsignedEvent, privateKeyBytes: Uint8Array) => SignedEvent;
/**
* Encryption function types for NIP-04 and NIP-44.
*/
export type EncryptFunction = (
privateKeyBytes: Uint8Array,
peerPubkey: string,
plaintext: string
) => Promise<string>;
export type DecryptFunction = (
privateKeyBytes: Uint8Array,
peerPubkey: string,
ciphertext: string
) => Promise<string>;
/**
* Identity entity - represents a Nostr identity with its keypair.
*
* This is an aggregate root that encapsulates all operations
* related to a single Nostr identity.
*/
export class Identity extends AggregateRoot {
private readonly _id: IdentityId;
private _nickname: Nickname;
private readonly _keyPair: NostrKeyPair;
private readonly _createdAt: Date;
private constructor(
id: IdentityId,
nickname: Nickname,
keyPair: NostrKeyPair,
createdAt: Date
) {
super();
this._id = id;
this._nickname = nickname;
this._keyPair = keyPair;
this._createdAt = createdAt;
}
// ─────────────────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Create a new identity with an optional private key.
* If no private key is provided, a new one will be generated.
*
* @param nickname - User-friendly name for this identity
* @param privateKey - Optional private key (hex or nsec format)
* @throws InvalidNicknameError if nickname is invalid
* @throws InvalidNostrKeyError if private key is invalid
*/
static create(nickname: string, privateKey?: string): Identity {
const keyPair = privateKey
? NostrKeyPair.fromPrivateKey(privateKey)
: NostrKeyPair.generate();
const identity = new Identity(
IdentityId.generate(),
Nickname.create(nickname),
keyPair,
new Date()
);
identity.addDomainEvent(
new IdentityCreated(
identity._id.value,
identity.publicKey,
identity.nickname
)
);
return identity;
}
/**
* Reconstitute an identity from storage.
* This bypasses validation since data comes from trusted storage.
*/
static fromSnapshot(snapshot: IdentitySnapshot): Identity {
return new Identity(
IdentityId.from(snapshot.id),
Nickname.fromStorage(snapshot.nick),
NostrKeyPair.fromStorage(snapshot.privkey),
new Date(snapshot.createdAt)
);
}
// ─────────────────────────────────────────────────────────────────────────
// Getters (Read-only access to state)
// ─────────────────────────────────────────────────────────────────────────
get id(): IdentityId {
return this._id;
}
get nickname(): string {
return this._nickname.value;
}
get publicKey(): string {
return this._keyPair.publicKeyHex;
}
get npub(): string {
return this._keyPair.npub;
}
get nsec(): string {
return this._keyPair.nsec;
}
get createdAt(): Date {
return this._createdAt;
}
// ─────────────────────────────────────────────────────────────────────────
// Behavior Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Rename this identity.
*
* @param newNickname - The new nickname
* @throws InvalidNicknameError if nickname is invalid
*/
rename(newNickname: string): void {
const oldNickname = this._nickname.value;
this._nickname = Nickname.create(newNickname);
this.addDomainEvent(
new IdentityRenamed(this._id.value, oldNickname, newNickname)
);
}
/**
* Sign a Nostr event with this identity's private key.
*
* @param event - The unsigned event template
* @param signFn - The signing function (injected to avoid coupling)
* @returns The signed event with id, pubkey, and sig
*/
sign(event: UnsignedEvent, signFn: SigningFunction): SignedEvent {
const signedEvent = signFn(event, this._keyPair.getPrivateKeyBytes());
this.addDomainEvent(
new IdentitySigned(this._id.value, event.kind, signedEvent.id)
);
return signedEvent;
}
/**
* Encrypt a message using NIP-04 encryption.
*
* @param plaintext - The message to encrypt
* @param recipientPubkey - The recipient's public key (hex)
* @param encryptFn - The NIP-04 encryption function
*/
async encryptNip04(
plaintext: string,
recipientPubkey: string,
encryptFn: EncryptFunction
): Promise<string> {
return encryptFn(
this._keyPair.getPrivateKeyBytes(),
recipientPubkey,
plaintext
);
}
/**
* Decrypt a message using NIP-04 decryption.
*
* @param ciphertext - The encrypted message
* @param senderPubkey - The sender's public key (hex)
* @param decryptFn - The NIP-04 decryption function
*/
async decryptNip04(
ciphertext: string,
senderPubkey: string,
decryptFn: DecryptFunction
): Promise<string> {
return decryptFn(
this._keyPair.getPrivateKeyBytes(),
senderPubkey,
ciphertext
);
}
/**
* Encrypt a message using NIP-44 encryption.
*
* @param plaintext - The message to encrypt
* @param recipientPubkey - The recipient's public key (hex)
* @param encryptFn - The NIP-44 encryption function
*/
async encryptNip44(
plaintext: string,
recipientPubkey: string,
encryptFn: EncryptFunction
): Promise<string> {
return encryptFn(
this._keyPair.getPrivateKeyBytes(),
recipientPubkey,
plaintext
);
}
/**
* Decrypt a message using NIP-44 decryption.
*
* @param ciphertext - The encrypted message
* @param senderPubkey - The sender's public key (hex)
* @param decryptFn - The NIP-44 decryption function
*/
async decryptNip44(
ciphertext: string,
senderPubkey: string,
decryptFn: DecryptFunction
): Promise<string> {
return decryptFn(
this._keyPair.getPrivateKeyBytes(),
senderPubkey,
ciphertext
);
}
/**
* Check if this identity has the same private key as another.
* Used for duplicate detection.
*/
hasSameKeyAs(other: Identity): boolean {
return this._keyPair.hasSamePublicKey(other._keyPair);
}
/**
* Check if this identity matches a given public key.
*/
matchesPublicKey(publicKey: string): boolean {
return this._keyPair.matchesPublicKey(publicKey);
}
// ─────────────────────────────────────────────────────────────────────────
// Persistence
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert to a snapshot for persistence.
*/
toSnapshot(): IdentitySnapshot {
return {
id: this._id.value,
nick: this._nickname.value,
privkey: this._keyPair.toStorageHex(),
createdAt: this._createdAt.toISOString(),
};
}
// ─────────────────────────────────────────────────────────────────────────
// Equality
// ─────────────────────────────────────────────────────────────────────────
/**
* Check equality based on identity ID.
*/
equals(other: Identity): boolean {
return this._id.equals(other._id);
}
}

View File

@@ -0,0 +1,21 @@
export {
Identity,
} from './identity';
export type {
UnsignedEvent,
SignedEvent,
SigningFunction,
EncryptFunction,
DecryptFunction,
} from './identity';
export {
Permission,
PermissionChecker,
} from './permission';
export {
Relay,
InvalidRelayUrlError,
toNip65RelayList,
} from './relay';

View File

@@ -0,0 +1,175 @@
import { Permission, PermissionChecker } from './permission';
import { IdentityId } from '../value-objects';
describe('Permission Entity', () => {
const testIdentityId = IdentityId.from('identity-1');
const testHost = 'example.com';
const testMethod = 'signEvent';
describe('allow', () => {
it('should create an allow permission', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.isAllowed()).toBe(true);
});
it('should create permission with kind for signEvent', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod, 1);
expect(permission.isAllowed()).toBe(true);
});
});
describe('deny', () => {
it('should create a deny permission', () => {
const permission = Permission.deny(testIdentityId, testHost, testMethod);
expect(permission.isAllowed()).toBe(false);
});
});
describe('matches', () => {
it('should match when all parameters are the same', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.matches(testIdentityId, testHost, testMethod)).toBe(true);
});
it('should not match when identity differs', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
const differentIdentity = IdentityId.from('identity-2');
expect(permission.matches(differentIdentity, testHost, testMethod)).toBe(false);
});
it('should not match when host differs', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.matches(testIdentityId, 'other.com', testMethod)).toBe(false);
});
it('should not match when method differs', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.matches(testIdentityId, testHost, 'getPublicKey')).toBe(false);
});
it('should match any kind when permission has no kind specified', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod);
expect(permission.matches(testIdentityId, testHost, testMethod, 1)).toBe(true);
expect(permission.matches(testIdentityId, testHost, testMethod, 30023)).toBe(true);
});
it('should only match specific kind when permission has kind', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod, 1);
expect(permission.matches(testIdentityId, testHost, testMethod, 1)).toBe(true);
expect(permission.matches(testIdentityId, testHost, testMethod, 30023)).toBe(false);
});
});
describe('fromSnapshot', () => {
it('should reconstruct permission from snapshot', () => {
const original = Permission.allow(testIdentityId, testHost, testMethod, 1);
const snapshot = original.toSnapshot();
const restored = Permission.fromSnapshot(snapshot);
expect(restored.isAllowed()).toBe(true);
expect(restored.matches(testIdentityId, testHost, testMethod, 1)).toBe(true);
});
});
describe('toSnapshot', () => {
it('should create valid snapshot', () => {
const permission = Permission.allow(testIdentityId, testHost, testMethod, 1);
const snapshot = permission.toSnapshot();
expect(snapshot.identityId).toEqual(testIdentityId.toString());
expect(snapshot.host).toEqual(testHost);
expect(snapshot.method).toEqual(testMethod);
expect(snapshot.methodPolicy).toEqual('allow');
expect(snapshot.kind).toBe(1);
});
});
});
describe('PermissionChecker', () => {
const identity1 = IdentityId.from('identity-1');
const identity2 = IdentityId.from('identity-2');
describe('check', () => {
it('should return true for allowed permission', () => {
const permissions = [
Permission.allow(identity1, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(true);
});
it('should return false for denied permission', () => {
const permissions = [
Permission.deny(identity1, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(false);
});
it('should return undefined when no matching permission exists', () => {
const permissions = [
Permission.allow(identity1, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity2, 'example.com', 'signEvent')).toBeUndefined();
});
it('should check kind-specific permissions first', () => {
const permissions = [
Permission.deny(identity1, 'example.com', 'signEvent', 1), // Deny kind 1
Permission.allow(identity1, 'example.com', 'signEvent'), // Allow all others
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'signEvent', 1)).toBe(false);
expect(checker.check(identity1, 'example.com', 'signEvent', 30023)).toBe(true);
});
it('should handle multiple identities', () => {
const permissions = [
Permission.allow(identity1, 'example.com', 'signEvent'),
Permission.deny(identity2, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(true);
expect(checker.check(identity2, 'example.com', 'signEvent')).toBe(false);
});
it('should handle multiple hosts', () => {
const permissions = [
Permission.allow(identity1, 'allowed.com', 'signEvent'),
Permission.deny(identity1, 'denied.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'allowed.com', 'signEvent')).toBe(true);
expect(checker.check(identity1, 'denied.com', 'signEvent')).toBe(false);
expect(checker.check(identity1, 'unknown.com', 'signEvent')).toBeUndefined();
});
it('should handle multiple methods', () => {
const permissions = [
Permission.allow(identity1, 'example.com', 'getPublicKey'),
Permission.deny(identity1, 'example.com', 'signEvent'),
];
const checker = new PermissionChecker(permissions);
expect(checker.check(identity1, 'example.com', 'getPublicKey')).toBe(true);
expect(checker.check(identity1, 'example.com', 'signEvent')).toBe(false);
});
});
});

View File

@@ -0,0 +1,332 @@
import { IdentityId, PermissionId } from '../value-objects';
import type {
PermissionSnapshot,
ExtensionMethod,
PermissionPolicy,
} from '../repositories/permission-repository';
/**
* Permission entity - represents an authorization decision for
* a specific identity, host, and method combination.
*
* Permissions are immutable once created - to change a permission,
* delete it and create a new one.
*/
export class Permission {
private readonly _id: PermissionId;
private readonly _identityId: IdentityId;
private readonly _host: string;
private readonly _method: ExtensionMethod;
private readonly _policy: PermissionPolicy;
private readonly _kind?: number;
private constructor(
id: PermissionId,
identityId: IdentityId,
host: string,
method: ExtensionMethod,
policy: PermissionPolicy,
kind?: number
) {
this._id = id;
this._identityId = identityId;
this._host = host;
this._method = method;
this._policy = policy;
this._kind = kind;
}
// ─────────────────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Create an "allow" permission.
*/
static allow(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): Permission {
return new Permission(
PermissionId.generate(),
identityId,
Permission.normalizeHost(host),
method,
'allow',
kind
);
}
/**
* Create a "deny" permission.
*/
static deny(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): Permission {
return new Permission(
PermissionId.generate(),
identityId,
Permission.normalizeHost(host),
method,
'deny',
kind
);
}
/**
* Create a permission with explicit policy.
*/
static create(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
policy: PermissionPolicy,
kind?: number
): Permission {
return new Permission(
PermissionId.generate(),
identityId,
Permission.normalizeHost(host),
method,
policy,
kind
);
}
/**
* Reconstitute a permission from storage.
*/
static fromSnapshot(snapshot: PermissionSnapshot): Permission {
return new Permission(
PermissionId.from(snapshot.id),
IdentityId.from(snapshot.identityId),
snapshot.host,
snapshot.method,
snapshot.methodPolicy,
snapshot.kind
);
}
// ─────────────────────────────────────────────────────────────────────────
// Getters
// ─────────────────────────────────────────────────────────────────────────
get id(): PermissionId {
return this._id;
}
get identityId(): IdentityId {
return this._identityId;
}
get host(): string {
return this._host;
}
get method(): ExtensionMethod {
return this._method;
}
get policy(): PermissionPolicy {
return this._policy;
}
get kind(): number | undefined {
return this._kind;
}
// ─────────────────────────────────────────────────────────────────────────
// Behavior
// ─────────────────────────────────────────────────────────────────────────
/**
* Check if this permission allows the action.
*/
isAllowed(): boolean {
return this._policy === 'allow';
}
/**
* Check if this permission denies the action.
*/
isDenied(): boolean {
return this._policy === 'deny';
}
/**
* Check if this permission matches the given criteria.
* For signEvent with kind specified, also checks the kind.
*/
matches(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): boolean {
if (!this._identityId.equals(identityId)) {
return false;
}
if (this._host !== Permission.normalizeHost(host)) {
return false;
}
if (this._method !== method) {
return false;
}
// For signEvent, handle kind matching
if (method === 'signEvent') {
// If this permission has no kind, it matches all kinds
if (this._kind === undefined) {
return true;
}
// If checking a specific kind, must match exactly
return this._kind === kind;
}
return true;
}
/**
* Check if this permission applies to a specific event kind.
* Only relevant for signEvent method.
*/
appliesToKind(kind: number): boolean {
if (this._method !== 'signEvent') {
return false;
}
// No kind restriction means applies to all
if (this._kind === undefined) {
return true;
}
return this._kind === kind;
}
/**
* Check if this is a blanket permission (no kind restriction).
*/
isBlanketPermission(): boolean {
return this._method === 'signEvent' && this._kind === undefined;
}
// ─────────────────────────────────────────────────────────────────────────
// Persistence
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert to a snapshot for persistence.
*/
toSnapshot(): PermissionSnapshot {
const snapshot: PermissionSnapshot = {
id: this._id.value,
identityId: this._identityId.value,
host: this._host,
method: this._method,
methodPolicy: this._policy,
};
if (this._kind !== undefined) {
snapshot.kind = this._kind;
}
return snapshot;
}
// ─────────────────────────────────────────────────────────────────────────
// Equality
// ─────────────────────────────────────────────────────────────────────────
/**
* Check equality based on permission ID.
*/
equals(other: Permission): boolean {
return this._id.equals(other._id);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private static normalizeHost(host: string): string {
return host.toLowerCase().trim();
}
}
/**
* Permission checker - evaluates permissions for a request.
* This encapsulates the permission checking logic.
*/
export class PermissionChecker {
constructor(private readonly permissions: Permission[]) {}
/**
* Check if an action is allowed.
*
* @returns true if allowed, false if denied, undefined if no matching permission
*/
check(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): boolean | undefined {
const matching = this.permissions.filter((p) =>
p.matches(identityId, host, method, kind)
);
if (matching.length === 0) {
return undefined;
}
// For signEvent with kind, check specific rules
// Kind-specific rules take priority over blanket rules
if (method === 'signEvent' && kind !== undefined) {
// Check for specific kind deny first (takes priority)
if (matching.some((p) => p.kind === kind && p.isDenied())) {
return false;
}
// Check for specific kind allow
if (matching.some((p) => p.kind === kind && p.isAllowed())) {
return true;
}
// Fall back to blanket allow (no kind restriction)
if (matching.some((p) => p.isBlanketPermission() && p.isAllowed())) {
return true;
}
// Fall back to blanket deny
if (matching.some((p) => p.isBlanketPermission() && p.isDenied())) {
return false;
}
// No specific rule found
return undefined;
}
// For other methods, all matching permissions must allow
return matching.every((p) => p.isAllowed());
}
/**
* Get all permissions for a specific identity.
*/
forIdentity(identityId: IdentityId): Permission[] {
return this.permissions.filter((p) => p.identityId.equals(identityId));
}
/**
* Get all permissions for a specific host.
*/
forHost(host: string): Permission[] {
const normalizedHost = host.toLowerCase().trim();
return this.permissions.filter((p) => p.host === normalizedHost);
}
}

View File

@@ -0,0 +1,155 @@
import { Relay, InvalidRelayUrlError, toNip65RelayList } from './relay';
import { IdentityId } from '../value-objects';
describe('Relay Entity', () => {
const testIdentityId = IdentityId.from('identity-1');
const validUrl = 'wss://relay.example.com';
describe('create', () => {
it('should create relay with default read/write permissions', () => {
const relay = Relay.create(testIdentityId, validUrl);
expect(relay.url).toEqual(validUrl);
expect(relay.read).toBe(true);
expect(relay.write).toBe(true);
});
it('should create relay with specified permissions', () => {
const relay = Relay.create(testIdentityId, validUrl, true, false);
expect(relay.read).toBe(true);
expect(relay.write).toBe(false);
});
it('should create relay with read-only permissions', () => {
const relay = Relay.create(testIdentityId, validUrl, true, false);
expect(relay.read).toBe(true);
expect(relay.write).toBe(false);
});
it('should create relay with write-only permissions', () => {
const relay = Relay.create(testIdentityId, validUrl, false, true);
expect(relay.read).toBe(false);
expect(relay.write).toBe(true);
});
it('should throw InvalidRelayUrlError for invalid URL', () => {
expect(() => Relay.create(testIdentityId, 'not-a-url')).toThrowError(InvalidRelayUrlError);
});
it('should throw InvalidRelayUrlError for http URL', () => {
expect(() => Relay.create(testIdentityId, 'http://relay.example.com')).toThrowError(InvalidRelayUrlError);
});
it('should accept wss:// URL', () => {
expect(() => Relay.create(testIdentityId, 'wss://relay.example.com')).not.toThrow();
});
it('should accept ws:// URL (for local development)', () => {
expect(() => Relay.create(testIdentityId, 'ws://localhost:8080')).not.toThrow();
});
});
describe('updateUrl', () => {
it('should update URL to valid new URL', () => {
const relay = Relay.create(testIdentityId, validUrl);
relay.updateUrl('wss://new-relay.example.com');
expect(relay.url).toEqual('wss://new-relay.example.com');
});
it('should throw InvalidRelayUrlError for invalid new URL', () => {
const relay = Relay.create(testIdentityId, validUrl);
expect(() => relay.updateUrl('not-a-url')).toThrowError(InvalidRelayUrlError);
});
});
describe('read permission toggling', () => {
it('should enable read', () => {
const relay = Relay.create(testIdentityId, validUrl, false, false);
relay.enableRead();
expect(relay.read).toBe(true);
});
it('should disable read', () => {
const relay = Relay.create(testIdentityId, validUrl, true, true);
relay.disableRead();
expect(relay.read).toBe(false);
});
});
describe('write permission toggling', () => {
it('should enable write', () => {
const relay = Relay.create(testIdentityId, validUrl, false, false);
relay.enableWrite();
expect(relay.write).toBe(true);
});
it('should disable write', () => {
const relay = Relay.create(testIdentityId, validUrl, true, true);
relay.disableWrite();
expect(relay.write).toBe(false);
});
});
describe('fromSnapshot', () => {
it('should reconstruct relay from snapshot', () => {
const original = Relay.create(testIdentityId, validUrl, true, false);
const snapshot = original.toSnapshot();
const restored = Relay.fromSnapshot(snapshot);
expect(restored.url).toEqual(validUrl);
expect(restored.read).toBe(true);
expect(restored.write).toBe(false);
});
});
describe('toSnapshot', () => {
it('should create valid snapshot', () => {
const relay = Relay.create(testIdentityId, validUrl, true, false);
const snapshot = relay.toSnapshot();
expect(snapshot.identityId).toEqual(testIdentityId.toString());
expect(snapshot.url).toEqual(validUrl);
expect(snapshot.read).toBe(true);
expect(snapshot.write).toBe(false);
});
});
});
describe('toNip65RelayList', () => {
const identityId = IdentityId.from('identity-1');
it('should convert relays to NIP-65 format', () => {
const relays = [
Relay.create(identityId, 'wss://relay1.com', true, true),
Relay.create(identityId, 'wss://relay2.com', true, false),
Relay.create(identityId, 'wss://relay3.com', false, true),
];
const nip65List = toNip65RelayList(relays);
expect(nip65List['wss://relay1.com']).toEqual({ read: true, write: true });
expect(nip65List['wss://relay2.com']).toEqual({ read: true, write: false });
expect(nip65List['wss://relay3.com']).toEqual({ read: false, write: true });
});
it('should return empty object for empty relay list', () => {
const nip65List = toNip65RelayList([]);
expect(nip65List).toEqual({});
});
});

View File

@@ -0,0 +1,268 @@
import { IdentityId, RelayId } from '../value-objects';
import type { RelaySnapshot } from '../repositories/relay-repository';
/**
* Relay entity - represents a Nostr relay configuration for an identity.
*/
export class Relay {
private readonly _id: RelayId;
private readonly _identityId: IdentityId;
private _url: string;
private _read: boolean;
private _write: boolean;
private constructor(
id: RelayId,
identityId: IdentityId,
url: string,
read: boolean,
write: boolean
) {
this._id = id;
this._identityId = identityId;
this._url = Relay.normalizeUrl(url);
this._read = read;
this._write = write;
}
// ─────────────────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Create a new relay configuration.
*
* @param identityId - The identity this relay belongs to
* @param url - The relay WebSocket URL
* @param read - Whether to read events from this relay
* @param write - Whether to write events to this relay
*/
static create(
identityId: IdentityId,
url: string,
read = true,
write = true
): Relay {
Relay.validateUrl(url);
return new Relay(
RelayId.generate(),
identityId,
url,
read,
write
);
}
/**
* Reconstitute a relay from storage.
*/
static fromSnapshot(snapshot: RelaySnapshot): Relay {
return new Relay(
RelayId.from(snapshot.id),
IdentityId.from(snapshot.identityId),
snapshot.url,
snapshot.read,
snapshot.write
);
}
// ─────────────────────────────────────────────────────────────────────────
// Getters
// ─────────────────────────────────────────────────────────────────────────
get id(): RelayId {
return this._id;
}
get identityId(): IdentityId {
return this._identityId;
}
get url(): string {
return this._url;
}
get read(): boolean {
return this._read;
}
get write(): boolean {
return this._write;
}
// ─────────────────────────────────────────────────────────────────────────
// Behavior
// ─────────────────────────────────────────────────────────────────────────
/**
* Update the relay URL.
*/
updateUrl(newUrl: string): void {
Relay.validateUrl(newUrl);
this._url = Relay.normalizeUrl(newUrl);
}
/**
* Enable reading from this relay.
*/
enableRead(): void {
this._read = true;
}
/**
* Disable reading from this relay.
*/
disableRead(): void {
this._read = false;
}
/**
* Enable writing to this relay.
*/
enableWrite(): void {
this._write = true;
}
/**
* Disable writing to this relay.
*/
disableWrite(): void {
this._write = false;
}
/**
* Set both read and write permissions.
*/
setPermissions(read: boolean, write: boolean): void {
this._read = read;
this._write = write;
}
/**
* Check if this relay is enabled for either read or write.
*/
isEnabled(): boolean {
return this._read || this._write;
}
/**
* Check if this relay has the same URL as another (case-insensitive).
*/
hasSameUrl(url: string): boolean {
return this._url.toLowerCase() === Relay.normalizeUrl(url).toLowerCase();
}
/**
* Check if this relay belongs to a specific identity.
*/
belongsTo(identityId: IdentityId): boolean {
return this._identityId.equals(identityId);
}
// ─────────────────────────────────────────────────────────────────────────
// Persistence
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert to a snapshot for persistence.
*/
toSnapshot(): RelaySnapshot {
return {
id: this._id.value,
identityId: this._identityId.value,
url: this._url,
read: this._read,
write: this._write,
};
}
/**
* Create a clone for modification without affecting the original.
*/
clone(): Relay {
return new Relay(
this._id,
this._identityId,
this._url,
this._read,
this._write
);
}
// ─────────────────────────────────────────────────────────────────────────
// Equality
// ─────────────────────────────────────────────────────────────────────────
/**
* Check equality based on relay ID.
*/
equals(other: Relay): boolean {
return this._id.equals(other._id);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private static normalizeUrl(url: string): string {
let normalized = url.trim();
// Remove trailing slash
if (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
private static validateUrl(url: string): void {
const normalized = Relay.normalizeUrl(url);
if (!normalized) {
throw new InvalidRelayUrlError('Relay URL cannot be empty');
}
// Must start with wss:// or ws://
if (!normalized.startsWith('wss://') && !normalized.startsWith('ws://')) {
throw new InvalidRelayUrlError(
'Relay URL must start with wss:// or ws://'
);
}
// Try to parse as URL
try {
new URL(normalized);
} catch {
throw new InvalidRelayUrlError(`Invalid relay URL: ${url}`);
}
}
}
/**
* Error thrown when a relay URL is invalid.
*/
export class InvalidRelayUrlError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidRelayUrlError';
}
}
/**
* Helper to convert relay list to NIP-65 format.
*/
export function toNip65RelayList(
relays: Relay[]
): Record<string, { read: boolean; write: boolean }> {
const result: Record<string, { read: boolean; write: boolean }> = {};
for (const relay of relays) {
if (relay.isEnabled()) {
result[relay.url] = {
read: relay.read,
write: relay.write,
};
}
}
return result;
}

View File

@@ -0,0 +1,81 @@
import { DomainEvent, AggregateRoot } from './domain-event';
// Concrete implementation for testing
class TestEvent extends DomainEvent {
readonly eventType = 'test.event';
constructor(readonly testData: string) {
super();
}
}
class TestAggregate extends AggregateRoot {
doSomething(data: string): void {
this.addDomainEvent(new TestEvent(data));
}
}
describe('DomainEvent', () => {
describe('base properties', () => {
it('should have occurredAt timestamp', () => {
const before = new Date();
const event = new TestEvent('test');
const after = new Date();
expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('should have unique eventId', () => {
const event1 = new TestEvent('test1');
const event2 = new TestEvent('test2');
expect(event1.eventId).not.toEqual(event2.eventId);
});
it('should have eventType from subclass', () => {
const event = new TestEvent('test');
expect(event.eventType).toEqual('test.event');
});
});
});
describe('AggregateRoot', () => {
describe('domain events', () => {
it('should collect domain events', () => {
const aggregate = new TestAggregate();
aggregate.doSomething('first');
aggregate.doSomething('second');
const events = aggregate.pullDomainEvents();
expect(events.length).toBe(2);
expect((events[0] as TestEvent).testData).toEqual('first');
expect((events[1] as TestEvent).testData).toEqual('second');
});
it('should clear events after pulling', () => {
const aggregate = new TestAggregate();
aggregate.doSomething('test');
aggregate.pullDomainEvents();
const secondPull = aggregate.pullDomainEvents();
expect(secondPull.length).toBe(0);
});
it('should preserve event order', () => {
const aggregate = new TestAggregate();
aggregate.doSomething('1');
aggregate.doSomething('2');
aggregate.doSomething('3');
const events = aggregate.pullDomainEvents();
expect(events.map(e => (e as TestEvent).testData)).toEqual(['1', '2', '3']);
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* Base class for all domain events.
* Domain events capture significant occurrences in the domain that
* domain experts care about.
*/
export abstract class DomainEvent {
readonly occurredAt: Date;
readonly eventId: string;
constructor() {
this.occurredAt = new Date();
this.eventId = crypto.randomUUID();
}
/**
* Get the event type identifier.
* Used for event routing and serialization.
*/
abstract get eventType(): string;
}
/**
* Interface for entities that can raise domain events.
*/
export interface EventRaiser {
/**
* Pull all pending domain events from the entity.
* This clears the internal event list.
*/
pullDomainEvents(): DomainEvent[];
}
/**
* Base class for aggregate roots that can raise domain events.
*/
export abstract class AggregateRoot implements EventRaiser {
private _domainEvents: DomainEvent[] = [];
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
/**
* Check if there are any pending domain events.
*/
hasPendingEvents(): boolean {
return this._domainEvents.length > 0;
}
}

View File

@@ -0,0 +1,110 @@
import {
IdentityCreated,
IdentityRenamed,
IdentitySelected,
IdentitySigned,
IdentityDeleted,
} from './identity-events';
describe('Identity Domain Events', () => {
describe('IdentityCreated', () => {
it('should store identity creation data', () => {
const event = new IdentityCreated('id-123', 'pubkey-abc', 'Alice');
expect(event.identityId).toEqual('id-123');
expect(event.publicKey).toEqual('pubkey-abc');
expect(event.nickname).toEqual('Alice');
});
it('should have correct event type', () => {
const event = new IdentityCreated('id', 'pubkey', 'name');
expect(event.eventType).toEqual('identity.created');
});
it('should have inherited base properties', () => {
const event = new IdentityCreated('id', 'pubkey', 'name');
expect(event.eventId).toBeTruthy();
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('IdentityRenamed', () => {
it('should store rename data', () => {
const event = new IdentityRenamed('id-123', 'OldName', 'NewName');
expect(event.identityId).toEqual('id-123');
expect(event.oldNickname).toEqual('OldName');
expect(event.newNickname).toEqual('NewName');
});
it('should have correct event type', () => {
const event = new IdentityRenamed('id', 'old', 'new');
expect(event.eventType).toEqual('identity.renamed');
});
});
describe('IdentitySelected', () => {
it('should store selection data with previous identity', () => {
const event = new IdentitySelected('id-new', 'id-old');
expect(event.identityId).toEqual('id-new');
expect(event.previousIdentityId).toEqual('id-old');
});
it('should handle null previous identity', () => {
const event = new IdentitySelected('id-new', null);
expect(event.identityId).toEqual('id-new');
expect(event.previousIdentityId).toBeNull();
});
it('should have correct event type', () => {
const event = new IdentitySelected('id', null);
expect(event.eventType).toEqual('identity.selected');
});
});
describe('IdentitySigned', () => {
it('should store signing data', () => {
const event = new IdentitySigned('id-123', 1, 'event-id-abc');
expect(event.identityId).toEqual('id-123');
expect(event.eventKind).toBe(1);
expect(event.signedEventId).toEqual('event-id-abc');
});
it('should have correct event type', () => {
const event = new IdentitySigned('id', 1, 'event-id');
expect(event.eventType).toEqual('identity.signed');
});
it('should handle various event kinds', () => {
const kindExamples = [0, 1, 3, 4, 7, 30023, 10002];
kindExamples.forEach(kind => {
const event = new IdentitySigned('id', kind, 'event');
expect(event.eventKind).toBe(kind);
});
});
});
describe('IdentityDeleted', () => {
it('should store deletion data', () => {
const event = new IdentityDeleted('id-123', 'pubkey-abc');
expect(event.identityId).toEqual('id-123');
expect(event.publicKey).toEqual('pubkey-abc');
});
it('should have correct event type', () => {
const event = new IdentityDeleted('id', 'pubkey');
expect(event.eventType).toEqual('identity.deleted');
});
});
});

View File

@@ -0,0 +1,74 @@
import { DomainEvent } from './domain-event';
/**
* Event raised when a new identity is created.
*/
export class IdentityCreated extends DomainEvent {
readonly eventType = 'identity.created';
constructor(
readonly identityId: string,
readonly publicKey: string,
readonly nickname: string
) {
super();
}
}
/**
* Event raised when an identity is renamed.
*/
export class IdentityRenamed extends DomainEvent {
readonly eventType = 'identity.renamed';
constructor(
readonly identityId: string,
readonly oldNickname: string,
readonly newNickname: string
) {
super();
}
}
/**
* Event raised when an identity is selected (made active).
*/
export class IdentitySelected extends DomainEvent {
readonly eventType = 'identity.selected';
constructor(
readonly identityId: string,
readonly previousIdentityId: string | null
) {
super();
}
}
/**
* Event raised when an identity signs an event.
*/
export class IdentitySigned extends DomainEvent {
readonly eventType = 'identity.signed';
constructor(
readonly identityId: string,
readonly eventKind: number,
readonly signedEventId: string
) {
super();
}
}
/**
* Event raised when an identity is deleted.
*/
export class IdentityDeleted extends DomainEvent {
readonly eventType = 'identity.deleted';
constructor(
readonly identityId: string,
readonly publicKey: string
) {
super();
}
}

View File

@@ -0,0 +1,9 @@
export { DomainEvent, AggregateRoot } from './domain-event';
export type { EventRaiser } from './domain-event';
export {
IdentityCreated,
IdentityRenamed,
IdentitySelected,
IdentitySigned,
IdentityDeleted,
} from './identity-events';

View File

@@ -0,0 +1,11 @@
// Value Objects
export * from './value-objects';
// Repository Interfaces
export * from './repositories';
// Domain Events
export * from './events';
// Domain Entities
export * from './entities';

View File

@@ -0,0 +1,89 @@
import { IdentityId } from '../value-objects';
/**
* Snapshot of an identity for persistence.
* This is the data structure that gets persisted, separate from the domain entity.
*/
export interface IdentitySnapshot {
id: string;
nick: string;
privkey: string;
createdAt: string;
}
/**
* Repository interface for Identity aggregate.
* Implementations handle encryption and storage specifics.
*/
export interface IdentityRepository {
/**
* Find an identity by its ID.
* Returns undefined if not found.
*/
findById(id: IdentityId): Promise<IdentitySnapshot | undefined>;
/**
* Find an identity by its public key.
* Returns undefined if not found.
*/
findByPublicKey(publicKey: string): Promise<IdentitySnapshot | undefined>;
/**
* Find an identity by its private key.
* Used for duplicate detection.
* Returns undefined if not found.
*/
findByPrivateKey(privateKey: string): Promise<IdentitySnapshot | undefined>;
/**
* Get all identities.
*/
findAll(): Promise<IdentitySnapshot[]>;
/**
* Save a new or updated identity.
* If an identity with the same ID exists, it will be updated.
*/
save(identity: IdentitySnapshot): Promise<void>;
/**
* Delete an identity by its ID.
* Returns true if the identity was deleted, false if it didn't exist.
*/
delete(id: IdentityId): Promise<boolean>;
/**
* Get the currently selected identity ID.
*/
getSelectedId(): Promise<IdentityId | null>;
/**
* Set the currently selected identity ID.
*/
setSelectedId(id: IdentityId | null): Promise<void>;
/**
* Count the total number of identities.
*/
count(): Promise<number>;
}
/**
* Error thrown when an identity operation fails.
*/
export class IdentityRepositoryError extends Error {
constructor(
message: string,
public readonly code: IdentityErrorCode
) {
super(message);
this.name = 'IdentityRepositoryError';
}
}
export enum IdentityErrorCode {
DUPLICATE_PRIVATE_KEY = 'DUPLICATE_PRIVATE_KEY',
NOT_FOUND = 'NOT_FOUND',
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
STORAGE_FAILED = 'STORAGE_FAILED',
}

View File

@@ -0,0 +1,30 @@
export {
IdentityRepositoryError,
IdentityErrorCode,
} from './identity-repository';
export type {
IdentityRepository,
IdentitySnapshot,
} from './identity-repository';
export {
PermissionRepositoryError,
PermissionErrorCode,
} from './permission-repository';
export type {
PermissionRepository,
PermissionSnapshot,
PermissionQuery,
ExtensionMethod,
PermissionPolicy,
} from './permission-repository';
export {
RelayRepositoryError,
RelayErrorCode,
} from './relay-repository';
export type {
RelayRepository,
RelaySnapshot,
RelayQuery,
} from './relay-repository';

View File

@@ -0,0 +1,108 @@
import { IdentityId, PermissionId } from '../value-objects';
import type { ExtensionMethod, Nip07MethodPolicy } from '../../models/nostr';
// Re-export types from models for convenience
// These are the canonical definitions used throughout the app
export type { ExtensionMethod, Nip07MethodPolicy as PermissionPolicy } from '../../models/nostr';
// Local type alias for cleaner code
type PermissionPolicy = Nip07MethodPolicy;
/**
* Snapshot of a permission for persistence.
*/
export interface PermissionSnapshot {
id: string;
identityId: string;
host: string;
method: ExtensionMethod;
methodPolicy: PermissionPolicy;
kind?: number; // For signEvent, filter by event kind
}
/**
* Query criteria for finding permissions.
*/
export interface PermissionQuery {
identityId?: IdentityId;
host?: string;
method?: ExtensionMethod;
kind?: number;
}
/**
* Repository interface for Permission aggregate.
*/
export interface PermissionRepository {
/**
* Find a permission by its ID.
*/
findById(id: PermissionId): Promise<PermissionSnapshot | undefined>;
/**
* Find permissions matching the query criteria.
*/
find(query: PermissionQuery): Promise<PermissionSnapshot[]>;
/**
* Find a specific permission for an identity, host, method, and optionally kind.
* This is the most common lookup for checking if an action is allowed.
*/
findExact(
identityId: IdentityId,
host: string,
method: ExtensionMethod,
kind?: number
): Promise<PermissionSnapshot | undefined>;
/**
* Get all permissions for an identity.
*/
findByIdentity(identityId: IdentityId): Promise<PermissionSnapshot[]>;
/**
* Get all permissions.
*/
findAll(): Promise<PermissionSnapshot[]>;
/**
* Save a new or updated permission.
*/
save(permission: PermissionSnapshot): Promise<void>;
/**
* Delete a permission by its ID.
*/
delete(id: PermissionId): Promise<boolean>;
/**
* Delete all permissions for an identity.
* Used when deleting an identity (cascade delete).
*/
deleteByIdentity(identityId: IdentityId): Promise<number>;
/**
* Count permissions matching the query.
*/
count(query?: PermissionQuery): Promise<number>;
}
/**
* Error thrown when a permission operation fails.
*/
export class PermissionRepositoryError extends Error {
constructor(
message: string,
public readonly code: PermissionErrorCode
) {
super(message);
this.name = 'PermissionRepositoryError';
}
}
export enum PermissionErrorCode {
NOT_FOUND = 'NOT_FOUND',
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
DECRYPTION_FAILED = 'DECRYPTION_FAILED',
STORAGE_FAILED = 'STORAGE_FAILED',
}

View File

@@ -0,0 +1,94 @@
import { IdentityId, RelayId } from '../value-objects';
/**
* Snapshot of a relay for persistence.
*/
export interface RelaySnapshot {
id: string;
identityId: string;
url: string;
read: boolean;
write: boolean;
}
/**
* Query criteria for finding relays.
*/
export interface RelayQuery {
identityId?: IdentityId;
url?: string;
read?: boolean;
write?: boolean;
}
/**
* Repository interface for Relay aggregate.
*/
export interface RelayRepository {
/**
* Find a relay by its ID.
*/
findById(id: RelayId): Promise<RelaySnapshot | undefined>;
/**
* Find relays matching the query criteria.
*/
find(query: RelayQuery): Promise<RelaySnapshot[]>;
/**
* Find a relay by URL for a specific identity.
* Used for duplicate detection.
*/
findByUrl(identityId: IdentityId, url: string): Promise<RelaySnapshot | undefined>;
/**
* Get all relays for an identity.
*/
findByIdentity(identityId: IdentityId): Promise<RelaySnapshot[]>;
/**
* Get all relays.
*/
findAll(): Promise<RelaySnapshot[]>;
/**
* Save a new or updated relay.
*/
save(relay: RelaySnapshot): Promise<void>;
/**
* Delete a relay by its ID.
*/
delete(id: RelayId): Promise<boolean>;
/**
* Delete all relays for an identity.
* Used when deleting an identity (cascade delete).
*/
deleteByIdentity(identityId: IdentityId): Promise<number>;
/**
* Count relays matching the query.
*/
count(query?: RelayQuery): Promise<number>;
}
/**
* Error thrown when a relay operation fails.
*/
export class RelayRepositoryError extends Error {
constructor(
message: string,
public readonly code: RelayErrorCode
) {
super(message);
this.name = 'RelayRepositoryError';
}
}
export enum RelayErrorCode {
DUPLICATE_URL = 'DUPLICATE_URL',
NOT_FOUND = 'NOT_FOUND',
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
STORAGE_FAILED = 'STORAGE_FAILED',
}

View File

@@ -0,0 +1,84 @@
import { IdentityId, PermissionId, RelayId, NwcConnectionId, CashuMintId } from './index';
describe('EntityId Value Objects', () => {
describe('IdentityId', () => {
it('should generate unique IDs', () => {
const id1 = IdentityId.generate();
const id2 = IdentityId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
it('should create from existing string value', () => {
const value = 'test-identity-id-123';
const id = IdentityId.from(value);
expect(id.toString()).toEqual(value);
});
it('should be equal when values match', () => {
const value = 'same-id';
const id1 = IdentityId.from(value);
const id2 = IdentityId.from(value);
expect(id1.equals(id2)).toBe(true);
});
it('should not be equal when values differ', () => {
const id1 = IdentityId.from('id-1');
const id2 = IdentityId.from('id-2');
expect(id1.equals(id2)).toBe(false);
});
});
describe('PermissionId', () => {
it('should generate unique IDs', () => {
const id1 = PermissionId.generate();
const id2 = PermissionId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
it('should create from existing string value', () => {
const value = 'test-permission-id-456';
const id = PermissionId.from(value);
expect(id.toString()).toEqual(value);
});
});
describe('RelayId', () => {
it('should generate unique IDs', () => {
const id1 = RelayId.generate();
const id2 = RelayId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
it('should create from existing string value', () => {
const value = 'test-relay-id-789';
const id = RelayId.from(value);
expect(id.toString()).toEqual(value);
});
});
describe('NwcConnectionId', () => {
it('should generate unique IDs', () => {
const id1 = NwcConnectionId.generate();
const id2 = NwcConnectionId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
});
describe('CashuMintId', () => {
it('should generate unique IDs', () => {
const id1 = CashuMintId.generate();
const id2 = CashuMintId.generate();
expect(id1.toString()).not.toEqual(id2.toString());
});
});
});

View File

@@ -0,0 +1,30 @@
/**
* Base class for strongly-typed entity IDs.
* Prevents mixing up different ID types (e.g., IdentityId vs PermissionId).
*/
export abstract class EntityId<T extends string = string> {
protected constructor(protected readonly _value: string) {
if (!_value || _value.trim() === '') {
throw new Error(`${this.constructor.name} cannot be empty`);
}
}
get value(): string {
return this._value;
}
equals(other: EntityId<T>): boolean {
if (!(other instanceof this.constructor)) {
return false;
}
return this._value === other._value;
}
toString(): string {
return this._value;
}
toJSON(): string {
return this._value;
}
}

View File

@@ -0,0 +1,36 @@
import { v4 as uuidv4 } from 'uuid';
import { EntityId } from './entity-id';
/**
* Strongly-typed identifier for Identity entities.
* Prevents accidental mixing with other ID types.
*/
export class IdentityId extends EntityId<'IdentityId'> {
private readonly _brand = 'IdentityId' as const;
private constructor(value: string) {
super(value);
}
/**
* Generate a new unique IdentityId.
*/
static generate(): IdentityId {
return new IdentityId(uuidv4());
}
/**
* Create an IdentityId from an existing string value.
* Use this when reconstituting from storage.
*/
static from(value: string): IdentityId {
return new IdentityId(value);
}
/**
* Type guard to check if two IDs are equal.
*/
override equals(other: IdentityId): boolean {
return other instanceof IdentityId && this._value === other._value;
}
}

View File

@@ -0,0 +1,16 @@
// Base
export { EntityId } from './entity-id';
// Entity IDs
export { IdentityId } from './identity-id';
export { PermissionId } from './permission-id';
export { RelayId } from './relay-id';
export { NwcConnectionId, CashuMintId } from './wallet-id';
// Domain Value Objects
export { Nickname, InvalidNicknameError } from './nickname';
export {
NostrKeyPair,
NostrPublicKey,
InvalidNostrKeyError,
} from './nostr-keypair';

View File

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

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

View File

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

View 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');
}
}
}

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,2 @@
export * from './encryption';
export * from './repositories';

View File

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

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
StorageService,
StorageServiceConfig,
} from '../storage/storage.service';
import { SyncFlow } from '../storage/types';
@Injectable({
providedIn: 'root',
@@ -25,8 +26,9 @@ export class StartupService {
// Step 1: Load the user settings
const signerMetaData = await this.#storage.loadSignerMetaData();
if (typeof signerMetaData?.syncFlow === 'undefined') {
// Very first run. The user has not set up Plebeian Signer yet.
this.#router.navigateByUrl('/welcome');
// Very first run - default to NO_SYNC (sync can be enabled later via export/import)
await this.#storage.enableBrowserSyncFlow(SyncFlow.NO_SYNC);
this.#router.navigateByUrl('/vault-create/home');
return;
}
this.#storage.enableBrowserSyncFlow(signerMetaData.syncFlow);

View File

@@ -1,12 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData } from './types';
import { VaultSession } from './types';
export abstract class BrowserSessionHandler {
get browserSessionData(): BrowserSessionData | undefined {
return this.#browserSessionData;
get vaultSession(): VaultSession | undefined {
return this.#vaultSession;
}
#browserSessionData?: BrowserSessionData;
/** @deprecated Use vaultSession instead */
get browserSessionData(): VaultSession | undefined {
return this.#vaultSession;
}
#vaultSession?: VaultSession;
/**
* Load the data from the browser session storage. It should be an empty object,
@@ -16,12 +21,12 @@ export abstract class BrowserSessionHandler {
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
*/
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
setFullData(data: BrowserSessionData) {
this.#browserSessionData = JSON.parse(JSON.stringify(data));
setFullData(data: VaultSession) {
this.#vaultSession = JSON.parse(JSON.stringify(data));
}
clearInMemoryData() {
this.#browserSessionData = undefined;
this.#vaultSession = undefined;
}
/**
@@ -29,7 +34,7 @@ export abstract class BrowserSessionHandler {
*
* ATTENTION: Make sure to call "setFullData(..)" afterwards of before to update the in-memory data.
*/
abstract saveFullData(data: BrowserSessionData): Promise<void>;
abstract saveFullData(data: VaultSession): Promise<void>;
abstract clearData(): Promise<void>;
}

View File

@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
EncryptedVault,
StoredCashuMint,
StoredIdentity,
StoredNwcConnection,
StoredPermission,
StoredRelay,
} from './types';
/**
@@ -14,15 +14,20 @@ import {
* some unencrypted properties (like, version and the vault hash).
*/
export abstract class BrowserSyncHandler {
get browserSyncData(): BrowserSyncData | undefined {
return this.#browserSyncData;
get encryptedVault(): EncryptedVault | undefined {
return this.#encryptedVault;
}
/** @deprecated Use encryptedVault instead */
get browserSyncData(): EncryptedVault | undefined {
return this.#encryptedVault;
}
get ignoreProperties(): string[] {
return this.#ignoreProperties;
}
#browserSyncData?: BrowserSyncData;
#encryptedVault?: EncryptedVault;
#ignoreProperties: string[] = [];
setIgnoreProperties(properties: string[]) {
@@ -41,10 +46,10 @@ export abstract class BrowserSyncHandler {
*
* ATTENTION: In your implementation, make sure to call "setFullData(..)" at the end to update the in-memory data.
*/
abstract saveAndSetFullData(data: BrowserSyncData): Promise<void>;
abstract saveAndSetFullData(data: EncryptedVault): Promise<void>;
setFullData(data: BrowserSyncData) {
this.#browserSyncData = JSON.parse(JSON.stringify(data));
setFullData(data: EncryptedVault) {
this.#encryptedVault = JSON.parse(JSON.stringify(data));
}
/**
@@ -53,13 +58,13 @@ export abstract class BrowserSyncHandler {
* ATTENTION: In your implementation, make sure to call "setPartialData_Permissions(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[];
permissions: StoredPermission[];
}): Promise<void>;
setPartialData_Permissions(data: { permissions: Permission_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
setPartialData_Permissions(data: { permissions: StoredPermission[] }) {
if (!this.#encryptedVault) {
return;
}
this.#browserSyncData.permissions = Array.from(data.permissions);
this.#encryptedVault.permissions = Array.from(data.permissions);
}
/**
@@ -68,14 +73,14 @@ export abstract class BrowserSyncHandler {
* ATTENTION: In your implementation, make sure to call "setPartialData_Identities(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[];
identities: StoredIdentity[];
}): Promise<void>;
setPartialData_Identities(data: { identities: Identity_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
setPartialData_Identities(data: { identities: StoredIdentity[] }) {
if (!this.#encryptedVault) {
return;
}
this.#browserSyncData.identities = Array.from(data.identities);
this.#encryptedVault.identities = Array.from(data.identities);
}
/**
@@ -90,20 +95,20 @@ export abstract class BrowserSyncHandler {
setPartialData_SelectedIdentityId(data: {
selectedIdentityId: string | null;
}) {
if (!this.#browserSyncData) {
if (!this.#encryptedVault) {
return;
}
this.#browserSyncData.selectedIdentityId = data.selectedIdentityId;
this.#encryptedVault.selectedIdentityId = data.selectedIdentityId;
}
abstract saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[];
relays: StoredRelay[];
}): Promise<void>;
setPartialData_Relays(data: { relays: Relay_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
setPartialData_Relays(data: { relays: StoredRelay[] }) {
if (!this.#encryptedVault) {
return;
}
this.#browserSyncData.relays = Array.from(data.relays);
this.#encryptedVault.relays = Array.from(data.relays);
}
/**
@@ -112,15 +117,15 @@ export abstract class BrowserSyncHandler {
* ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
nwcConnections: StoredNwcConnection[];
}): Promise<void>;
setPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
nwcConnections: StoredNwcConnection[];
}) {
if (!this.#browserSyncData) {
if (!this.#encryptedVault) {
return;
}
this.#browserSyncData.nwcConnections = Array.from(data.nwcConnections);
this.#encryptedVault.nwcConnections = Array.from(data.nwcConnections);
}
/**
@@ -129,13 +134,13 @@ export abstract class BrowserSyncHandler {
* ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
cashuMints: StoredCashuMint[];
}): Promise<void>;
setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
setPartialData_CashuMints(data: { cashuMints: StoredCashuMint[] }) {
if (!this.#encryptedVault) {
return;
}
this.#browserSyncData.cashuMints = Array.from(data.cashuMints);
this.#encryptedVault.cashuMints = Array.from(data.cashuMints);
}
/**

View File

@@ -1,13 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Bookmark, BrowserSyncData, BrowserSyncFlow, SignerMetaData, SignerMetaData_VaultSnapshot } from './types';
import { Bookmark, EncryptedVault, SyncFlow, ExtensionSettings, VaultSnapshot } from './types';
import { v4 as uuidv4 } from 'uuid';
/**
* Handler for extension settings stored outside the encrypted vault.
* This includes sync preferences, backups, reckless mode, whitelisted hosts, etc.
*/
export abstract class SignerMetaHandler {
get signerMetaData(): SignerMetaData | undefined {
return this.#signerMetaData;
get extensionSettings(): ExtensionSettings | undefined {
return this.#extensionSettings;
}
#signerMetaData?: SignerMetaData;
/** @deprecated Use extensionSettings instead */
get signerMetaData(): ExtensionSettings | undefined {
return this.#extensionSettings;
}
#extensionSettings?: ExtensionSettings;
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode'];
readonly DEFAULT_MAX_BACKUPS = 5;
@@ -20,25 +29,30 @@ export abstract class SignerMetaHandler {
*/
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
setFullData(data: SignerMetaData) {
this.#signerMetaData = data;
setFullData(data: ExtensionSettings) {
this.#extensionSettings = data;
}
abstract saveFullData(data: SignerMetaData): Promise<void>;
abstract saveFullData(data: ExtensionSettings): Promise<void>;
/**
* Sets the browser sync flow for the user and immediately saves it.
* Sets the sync flow preference for the user and immediately saves it.
*/
async setBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
async setSyncFlow(flow: SyncFlow): Promise<void> {
if (!this.#extensionSettings) {
this.#extensionSettings = {
syncFlow: flow,
};
} else {
this.#signerMetaData.syncFlow = flow;
this.#extensionSettings.syncFlow = flow;
}
await this.saveFullData(this.#signerMetaData);
await this.saveFullData(this.#extensionSettings);
}
/** @deprecated Use setSyncFlow instead */
async setBrowserSyncFlow(flow: SyncFlow): Promise<void> {
return this.setSyncFlow(flow);
}
abstract clearData(keep: string[]): Promise<void>;
@@ -47,93 +61,93 @@ export abstract class SignerMetaHandler {
* Sets the reckless mode and immediately saves it.
*/
async setRecklessMode(enabled: boolean): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
if (!this.#extensionSettings) {
this.#extensionSettings = {
recklessMode: enabled,
};
} else {
this.#signerMetaData.recklessMode = enabled;
this.#extensionSettings.recklessMode = enabled;
}
await this.saveFullData(this.#signerMetaData);
await this.saveFullData(this.#extensionSettings);
}
/**
* Sets dev mode and immediately saves it.
*/
async setDevMode(enabled: boolean): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
if (!this.#extensionSettings) {
this.#extensionSettings = {
devMode: enabled,
};
} else {
this.#signerMetaData.devMode = enabled;
this.#extensionSettings.devMode = enabled;
}
await this.saveFullData(this.#signerMetaData);
await this.saveFullData(this.#extensionSettings);
}
/**
* Adds a host to the whitelist and immediately saves it.
*/
async addWhitelistedHost(host: string): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
if (!this.#extensionSettings) {
this.#extensionSettings = {
whitelistedHosts: [host],
};
} else {
const hosts = this.#signerMetaData.whitelistedHosts ?? [];
const hosts = this.#extensionSettings.whitelistedHosts ?? [];
if (!hosts.includes(host)) {
hosts.push(host);
this.#signerMetaData.whitelistedHosts = hosts;
this.#extensionSettings.whitelistedHosts = hosts;
}
}
await this.saveFullData(this.#signerMetaData);
await this.saveFullData(this.#extensionSettings);
}
/**
* Removes a host from the whitelist and immediately saves it.
*/
async removeWhitelistedHost(host: string): Promise<void> {
if (!this.#signerMetaData?.whitelistedHosts) {
if (!this.#extensionSettings?.whitelistedHosts) {
return;
}
this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter(
this.#extensionSettings.whitelistedHosts = this.#extensionSettings.whitelistedHosts.filter(
(h) => h !== host
);
await this.saveFullData(this.#signerMetaData);
await this.saveFullData(this.#extensionSettings);
}
/**
* Sets the bookmarks array and immediately saves it.
*/
async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
if (!this.#extensionSettings) {
this.#extensionSettings = {
bookmarks,
};
} else {
this.#signerMetaData.bookmarks = bookmarks;
this.#extensionSettings.bookmarks = bookmarks;
}
await this.saveFullData(this.#signerMetaData);
await this.saveFullData(this.#extensionSettings);
}
/**
* Gets the current bookmarks.
*/
getBookmarks(): Bookmark[] {
return this.#signerMetaData?.bookmarks ?? [];
return this.#extensionSettings?.bookmarks ?? [];
}
/**
* Gets the maximum number of backups to keep.
*/
getMaxBackups(): number {
return this.#signerMetaData?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
return this.#extensionSettings?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
}
/**
@@ -141,22 +155,22 @@ export abstract class SignerMetaHandler {
*/
async setMaxBackups(count: number): Promise<void> {
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
if (!this.#signerMetaData) {
this.#signerMetaData = {
if (!this.#extensionSettings) {
this.#extensionSettings = {
maxBackups: clampedCount,
};
} else {
this.#signerMetaData.maxBackups = clampedCount;
this.#extensionSettings.maxBackups = clampedCount;
}
await this.saveFullData(this.#signerMetaData);
await this.saveFullData(this.#extensionSettings);
}
/**
* Gets all vault backups, sorted newest first.
*/
getBackups(): SignerMetaData_VaultSnapshot[] {
const backups = this.#signerMetaData?.vaultSnapshots ?? [];
getBackups(): VaultSnapshot[] {
const backups = this.#extensionSettings?.vaultSnapshots ?? [];
return [...backups].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
@@ -165,8 +179,8 @@ export abstract class SignerMetaHandler {
/**
* Gets a specific backup by ID.
*/
getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined {
return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id);
getBackupById(id: string): VaultSnapshot | undefined {
return this.#extensionSettings?.vaultSnapshots?.find(b => b.id === id);
}
/**
@@ -174,28 +188,28 @@ export abstract class SignerMetaHandler {
* Automatically removes old backups if exceeding maxBackups.
*/
async createBackup(
browserSyncData: BrowserSyncData,
encryptedVault: EncryptedVault,
reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
): Promise<SignerMetaData_VaultSnapshot> {
): Promise<VaultSnapshot> {
const now = new Date();
const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const identityCount = browserSyncData.identities?.length ?? 0;
const identityCount = encryptedVault.identities?.length ?? 0;
const snapshot: SignerMetaData_VaultSnapshot = {
const snapshot: VaultSnapshot = {
id: uuidv4(),
fileName: `Vault Backup - ${dateTimeString}`,
createdAt: now.toISOString(),
data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone
data: JSON.parse(JSON.stringify(encryptedVault)), // Deep clone
identityCount,
reason,
};
if (!this.#signerMetaData) {
this.#signerMetaData = {
if (!this.#extensionSettings) {
this.#extensionSettings = {
vaultSnapshots: [snapshot],
};
} else {
const existingBackups = this.#signerMetaData.vaultSnapshots ?? [];
const existingBackups = this.#extensionSettings.vaultSnapshots ?? [];
existingBackups.push(snapshot);
// Enforce max backups limit (only for auto backups, keep manual and pre-restore)
@@ -209,10 +223,10 @@ export abstract class SignerMetaHandler {
);
const trimmedAutoBackups = autoBackups.slice(0, maxBackups);
this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
this.#extensionSettings.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
}
await this.saveFullData(this.#signerMetaData);
await this.saveFullData(this.#extensionSettings);
return snapshot;
}
@@ -220,17 +234,17 @@ export abstract class SignerMetaHandler {
* Deletes a backup by ID.
*/
async deleteBackup(backupId: string): Promise<boolean> {
if (!this.#signerMetaData?.vaultSnapshots) {
if (!this.#extensionSettings?.vaultSnapshots) {
return false;
}
const initialLength = this.#signerMetaData.vaultSnapshots.length;
this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter(
const initialLength = this.#extensionSettings.vaultSnapshots.length;
this.#extensionSettings.vaultSnapshots = this.#extensionSettings.vaultSnapshots.filter(
b => b.id !== backupId
);
if (this.#signerMetaData.vaultSnapshots.length < initialLength) {
await this.saveFullData(this.#signerMetaData);
if (this.#extensionSettings.vaultSnapshots.length < initialLength) {
await this.saveFullData(this.#extensionSettings);
return true;
}
return false;
@@ -240,7 +254,7 @@ export abstract class SignerMetaHandler {
* Gets the data from a backup for restoration.
* Note: The caller should create a pre-restore backup before calling this.
*/
getBackupData(backupId: string): BrowserSyncData | undefined {
getBackupData(backupId: string): EncryptedVault | undefined {
const backup = this.getBackupById(backupId);
return backup?.data;
}

View File

@@ -3,11 +3,13 @@ import { Injectable } from '@angular/core';
import { BrowserSyncHandler } from './browser-sync-handler';
import { BrowserSessionHandler } from './browser-session-handler';
import {
BrowserSessionData,
BrowserSyncData,
BrowserSyncFlow,
SignerMetaData,
Relay_DECRYPTED,
VaultSession,
EncryptedVault,
SyncFlow,
ExtensionSettings,
RelayData,
CashuMintRecord,
CashuProof,
} from './types';
import { SignerMetaHandler } from './signer-meta-handler';
import { CryptoHelper } from '@common';
@@ -30,7 +32,6 @@ import {
deleteCashuMint,
updateCashuMintProofs,
} from './related/cashu';
import { CashuMint_DECRYPTED, CashuProof } from './types';
export interface StorageServiceConfig {
browserSessionHandler: BrowserSessionHandler;
@@ -62,13 +63,13 @@ export class StorageService {
this.isInitialized = true;
}
async enableBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
async enableBrowserSyncFlow(flow: SyncFlow): Promise<void> {
this.assureIsInitialized();
this.#signerMetaHandler.setBrowserSyncFlow(flow);
this.#signerMetaHandler.setSyncFlow(flow);
}
async loadSignerMetaData(): Promise<SignerMetaData | undefined> {
async loadExtensionSettings(): Promise<ExtensionSettings | undefined> {
this.assureIsInitialized();
const data = await this.#signerMetaHandler.loadFullData();
@@ -77,11 +78,16 @@ export class StorageService {
return undefined;
}
this.#signerMetaHandler.setFullData(data as SignerMetaData);
return data as SignerMetaData;
this.#signerMetaHandler.setFullData(data as ExtensionSettings);
return data as ExtensionSettings;
}
async loadBrowserSessionData(): Promise<BrowserSessionData | undefined> {
/** @deprecated Use loadExtensionSettings instead */
async loadSignerMetaData(): Promise<ExtensionSettings | undefined> {
return this.loadExtensionSettings();
}
async loadVaultSession(): Promise<VaultSession | undefined> {
this.assureIsInitialized();
const data = await this.#browserSessionHandler.loadFullData();
@@ -91,22 +97,27 @@ export class StorageService {
}
// Set the existing data for in-memory usage.
this.#browserSessionHandler.setFullData(data as BrowserSessionData);
return data as BrowserSessionData;
this.#browserSessionHandler.setFullData(data as VaultSession);
return data as VaultSession;
}
/** @deprecated Use loadVaultSession instead */
async loadBrowserSessionData(): Promise<VaultSession | undefined> {
return this.loadVaultSession();
}
/**
* Load and migrate the browser sync data. If no data is available yet,
* Load and migrate the encrypted vault data. If no data is available yet,
* the returned object is undefined.
*/
async loadAndMigrateBrowserSyncData(): Promise<BrowserSyncData | undefined> {
async loadAndMigrateEncryptedVault(): Promise<EncryptedVault | undefined> {
this.assureIsInitialized();
const unmigratedBrowserSyncData =
const unmigratedEncryptedVault =
await this.getBrowserSyncHandler().loadUnmigratedData();
const { browserSyncData, migrationWasPerformed } =
this.#migrateBrowserSyncData(unmigratedBrowserSyncData);
const { encryptedVault, migrationWasPerformed } =
this.#migrateEncryptedVault(unmigratedEncryptedVault);
if (!browserSyncData) {
if (!encryptedVault) {
// Nothing to do at this point.
return undefined;
}
@@ -114,13 +125,18 @@ export class StorageService {
// There is data. Check, if it was migrated.
if (migrationWasPerformed) {
// Persist the migrated data back to the browser sync storage.
this.getBrowserSyncHandler().saveAndSetFullData(browserSyncData);
this.getBrowserSyncHandler().saveAndSetFullData(encryptedVault);
} else {
// Set the data for in-memory usage.
this.getBrowserSyncHandler().setFullData(browserSyncData);
this.getBrowserSyncHandler().setFullData(encryptedVault);
}
return browserSyncData;
return encryptedVault;
}
/** @deprecated Use loadAndMigrateEncryptedVault instead */
async loadAndMigrateBrowserSyncData(): Promise<EncryptedVault | undefined> {
return this.loadAndMigrateEncryptedVault();
}
async deleteVault(doNotSetIsInitializedToFalse = false) {
@@ -183,7 +199,7 @@ export class StorageService {
await deleteRelay.call(this, relayId);
}
async updateRelay(relayClone: Relay_DECRYPTED): Promise<void> {
async updateRelay(relayClone: RelayData): Promise<void> {
await updateRelay.call(this, relayClone);
}
@@ -209,7 +225,7 @@ export class StorageService {
name: string;
mintUrl: string;
unit?: string;
}): Promise<CashuMint_DECRYPTED> {
}): Promise<CashuMintRecord> {
return await addCashuMint.call(this, data);
}
@@ -227,36 +243,36 @@ export class StorageService {
exportVault(): string {
this.assureIsInitialized();
const vaultJson = JSON.stringify(
this.getBrowserSyncHandler().browserSyncData,
this.getBrowserSyncHandler().encryptedVault,
undefined,
4
);
return vaultJson;
}
async importVault(allegedBrowserSyncData: BrowserSyncData) {
async importVault(allegedEncryptedVault: EncryptedVault) {
this.assureIsInitialized();
const isValidData = this.#allegedBrowserSyncDataIsValid(
allegedBrowserSyncData
const isValidData = this.#allegedEncryptedVaultIsValid(
allegedEncryptedVault
);
if (!isValidData) {
throw new Error('The imported data is not valid.');
}
await this.getBrowserSyncHandler().saveAndSetFullData(
allegedBrowserSyncData
allegedEncryptedVault
);
}
getBrowserSyncHandler(): BrowserSyncHandler {
this.assureIsInitialized();
switch (this.#signerMetaHandler.signerMetaData?.syncFlow) {
case BrowserSyncFlow.NO_SYNC:
switch (this.#signerMetaHandler.extensionSettings?.syncFlow) {
case SyncFlow.NO_SYNC:
return this.#browserSyncNoHandler;
case BrowserSyncFlow.BROWSER_SYNC:
case SyncFlow.BROWSER_SYNC:
default:
return this.#browserSyncYesHandler;
}
@@ -275,14 +291,14 @@ export class StorageService {
}
/**
* Get the current browser sync flow setting.
* Get the current sync flow setting.
* Returns NO_SYNC if not initialized or no setting found.
*/
getSyncFlow(): BrowserSyncFlow {
if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) {
return BrowserSyncFlow.NO_SYNC;
getSyncFlow(): SyncFlow {
if (!this.isInitialized || !this.#signerMetaHandler?.extensionSettings) {
return SyncFlow.NO_SYNC;
}
return this.#signerMetaHandler.signerMetaData.syncFlow ?? BrowserSyncFlow.NO_SYNC;
return this.#signerMetaHandler.extensionSettings.syncFlow ?? SyncFlow.NO_SYNC;
}
/**
@@ -297,25 +313,24 @@ export class StorageService {
}
async encrypt(value: string): Promise<string> {
const browserSessionData =
this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
const vaultSession = this.getBrowserSessionHandler().vaultSession;
if (!vaultSession) {
throw new Error('Vault session is undefined.');
}
// v2: Use pre-derived key directly with AES-GCM
if (browserSessionData.vaultKey) {
return this.encryptV2(value, browserSessionData.iv, browserSessionData.vaultKey);
if (vaultSession.vaultKey) {
return this.encryptV2(value, vaultSession.iv, vaultSession.vaultKey);
}
// v1: Use PBKDF2 with password
if (!browserSessionData.vaultPassword) {
if (!vaultSession.vaultPassword) {
throw new Error('No vault password or key available.');
}
return CryptoHelper.encrypt(
value,
browserSessionData.iv,
browserSessionData.vaultPassword
vaultSession.iv,
vaultSession.vaultPassword
);
}
@@ -347,31 +362,30 @@ export class StorageService {
value: string,
returnType: 'string' | 'number' | 'boolean'
): Promise<any> {
const browserSessionData =
this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
const vaultSession = this.getBrowserSessionHandler().vaultSession;
if (!vaultSession) {
throw new Error('Vault session is undefined.');
}
// v2: Use pre-derived key directly with AES-GCM
if (browserSessionData.vaultKey) {
if (vaultSession.vaultKey) {
const decryptedValue = await this.decryptV2(
value,
browserSessionData.iv,
browserSessionData.vaultKey
vaultSession.iv,
vaultSession.vaultKey
);
return this.parseDecryptedValue(decryptedValue, returnType);
}
// v1: Use PBKDF2 with password
if (!browserSessionData.vaultPassword) {
if (!vaultSession.vaultPassword) {
throw new Error('No vault password or key available.');
}
return this.decryptWithLockedVault(
value,
returnType,
browserSessionData.iv,
browserSessionData.vaultPassword
vaultSession.iv,
vaultSession.vaultPassword
);
}
@@ -445,28 +459,28 @@ export class StorageService {
}
/**
* Migrate the browser sync data to the latest version.
* Migrate the encrypted vault to the latest version.
*/
#migrateBrowserSyncData(browserSyncData: Partial<Record<string, any>>): {
browserSyncData?: BrowserSyncData;
#migrateEncryptedVault(encryptedVault: Partial<Record<string, any>>): {
encryptedVault?: EncryptedVault;
migrationWasPerformed: boolean;
} {
if (Object.keys(browserSyncData).length === 0) {
// First run. There is no browser sync data yet.
if (Object.keys(encryptedVault).length === 0) {
// First run. There is no encrypted vault yet.
return {
browserSyncData: undefined,
encryptedVault: undefined,
migrationWasPerformed: false,
};
}
// Will be implemented if migration is required.
return {
browserSyncData: browserSyncData as BrowserSyncData,
encryptedVault: encryptedVault as EncryptedVault,
migrationWasPerformed: false,
};
}
#allegedBrowserSyncDataIsValid(data: BrowserSyncData): boolean {
#allegedEncryptedVaultIsValid(data: EncryptedVault): boolean {
if (typeof data.iv === 'undefined') {
return false;
}

View File

@@ -1,15 +1,14 @@
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
export interface Permission_DECRYPTED {
id: string;
identityId: string;
host: string;
method: ExtensionMethod;
methodPolicy: Nip07MethodPolicy;
kind?: number;
}
// =============================================================================
// STORAGE DATA TRANSFER OBJECTS (DTOs)
// These types represent data as stored in browser storage
// =============================================================================
export interface Permission_ENCRYPTED {
/**
* Permission as stored in encrypted vault (encrypted string fields)
*/
export interface StoredPermission {
id: string;
identityId: string;
host: string;
@@ -18,24 +17,37 @@ export interface Permission_ENCRYPTED {
kind?: string;
}
export interface Identity_DECRYPTED {
/**
* Permission in session memory (typed fields)
*/
export interface PermissionData {
id: string;
identityId: string;
host: string;
method: ExtensionMethod;
methodPolicy: Nip07MethodPolicy;
kind?: number;
}
/**
* Identity as stored in encrypted vault
*/
export interface StoredIdentity {
id: string;
createdAt: string;
nick: string;
privkey: string;
}
export type Identity_ENCRYPTED = Identity_DECRYPTED;
/**
* Identity in session memory (same structure, just semantic clarity)
*/
export type IdentityData = StoredIdentity;
export interface Relay_DECRYPTED {
id: string;
identityId: string;
url: string;
read: boolean;
write: boolean;
}
export interface Relay_ENCRYPTED {
/**
* Relay as stored in encrypted vault (encrypted boolean fields)
*/
export interface StoredRelay {
id: string;
identityId: string;
url: string;
@@ -44,10 +56,21 @@ export interface Relay_ENCRYPTED {
}
/**
* NWC (Nostr Wallet Connect) connection - Decrypted
* Relay in session memory (typed boolean fields)
*/
export interface RelayData {
id: string;
identityId: string;
url: string;
read: boolean;
write: boolean;
}
/**
* NWC (Nostr Wallet Connect) connection in session memory
* Stores NIP-47 wallet connection data
*/
export interface NwcConnection_DECRYPTED {
export interface NwcConnectionRecord {
id: string;
name: string; // User-defined wallet name
connectionUrl: string; // Full nostr+walletconnect:// URL
@@ -61,9 +84,9 @@ export interface NwcConnection_DECRYPTED {
}
/**
* NWC connection - Encrypted for storage
* NWC connection as stored in encrypted vault
*/
export interface NwcConnection_ENCRYPTED {
export interface StoredNwcConnection {
id: string;
name: string;
connectionUrl: string;
@@ -89,10 +112,10 @@ export interface CashuProof {
}
/**
* Cashu Mint Connection - Decrypted
* Cashu Mint Connection in session memory
* Stores NIP-60 Cashu mint connection data with local proofs
*/
export interface CashuMint_DECRYPTED {
export interface CashuMintRecord {
id: string;
name: string; // User-defined mint name
mintUrl: string; // Mint API URL
@@ -104,9 +127,9 @@ export interface CashuMint_DECRYPTED {
}
/**
* Cashu Mint Connection - Encrypted for storage
* Cashu Mint Connection as stored in encrypted vault
*/
export interface CashuMint_ENCRYPTED {
export interface StoredCashuMint {
id: string;
name: string;
mintUrl: string;
@@ -117,7 +140,15 @@ export interface CashuMint_ENCRYPTED {
cachedBalanceAt?: string;
}
export interface BrowserSyncData_PART_Unencrypted {
// =============================================================================
// ENCRYPTED VAULT
// The vault is the encrypted container holding all sensitive data
// =============================================================================
/**
* Vault header - unencrypted metadata needed to decrypt the vault
*/
export interface EncryptedVaultHeader {
version: number;
iv: string;
vaultHash: string;
@@ -126,26 +157,42 @@ export interface BrowserSyncData_PART_Unencrypted {
salt?: string;
}
export interface BrowserSyncData_PART_Encrypted {
/**
* Vault content - encrypted payload containing all sensitive data
*/
export interface EncryptedVaultContent {
selectedIdentityId: string | null;
permissions: Permission_ENCRYPTED[];
identities: Identity_ENCRYPTED[];
relays: Relay_ENCRYPTED[];
nwcConnections?: NwcConnection_ENCRYPTED[];
cashuMints?: CashuMint_ENCRYPTED[];
permissions: StoredPermission[];
identities: StoredIdentity[];
relays: StoredRelay[];
nwcConnections?: StoredNwcConnection[];
cashuMints?: StoredCashuMint[];
}
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted &
BrowserSyncData_PART_Encrypted;
/**
* Complete encrypted vault as stored in browser sync storage
*/
export type EncryptedVault = EncryptedVaultHeader & EncryptedVaultContent;
export enum BrowserSyncFlow {
/**
* Sync flow preference for vault data
*/
export enum SyncFlow {
NO_SYNC = 0,
BROWSER_SYNC = 1,
SIGNER_SYNC = 2,
CUSTOM_SYNC = 3,
}
export interface BrowserSessionData {
// =============================================================================
// VAULT SESSION
// Runtime state when vault is unlocked
// =============================================================================
/**
* Vault session - decrypted vault data in session memory
*/
export interface VaultSession {
// The following properties purely come from the browser session storage
// and will never be going into the browser sync storage.
vaultPassword?: string; // v1 only: raw password for PBKDF2
@@ -155,24 +202,32 @@ export interface BrowserSessionData {
iv: string;
// Version 2+: Random salt for Argon2id (base64)
salt?: string;
permissions: Permission_DECRYPTED[];
identities: Identity_DECRYPTED[];
permissions: PermissionData[];
identities: IdentityData[];
selectedIdentityId: string | null;
relays: Relay_DECRYPTED[];
nwcConnections?: NwcConnection_DECRYPTED[];
cashuMints?: CashuMint_DECRYPTED[];
relays: RelayData[];
nwcConnections?: NwcConnectionRecord[];
cashuMints?: CashuMintRecord[];
}
export interface SignerMetaData_VaultSnapshot {
// =============================================================================
// EXTENSION SETTINGS
// Non-vault configuration stored separately
// =============================================================================
/**
* Vault snapshot for backup/restore
*/
export interface VaultSnapshot {
id: string;
fileName: string;
createdAt: string; // ISO timestamp
data: BrowserSyncData;
data: EncryptedVault;
identityCount: number;
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
}
export const SIGNER_META_DATA_KEY = {
export const EXTENSION_SETTINGS_KEYS = {
vaultSnapshots: 'vaultSnapshots',
};
@@ -186,10 +241,13 @@ export interface Bookmark {
createdAt: number;
}
export interface SignerMetaData {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync))
/**
* Extension settings - non-vault configuration
*/
export interface ExtensionSettings {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync)
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
vaultSnapshots?: VaultSnapshot[];
// Maximum number of automatic backups to keep (default: 5)
maxBackups?: number;
@@ -229,3 +287,47 @@ export interface ProfileMetadata {
* Cache for profile metadata, stored in session storage
*/
export type ProfileMetadataCache = Record<string, ProfileMetadata>;
// =============================================================================
// BACKWARDS COMPATIBILITY ALIASES
// These will be removed in a future version
// =============================================================================
/** @deprecated Use StoredPermission instead */
export type Permission_ENCRYPTED = StoredPermission;
/** @deprecated Use PermissionData instead */
export type Permission_DECRYPTED = PermissionData;
/** @deprecated Use StoredIdentity instead */
export type Identity_ENCRYPTED = StoredIdentity;
/** @deprecated Use IdentityData instead */
export type Identity_DECRYPTED = IdentityData;
/** @deprecated Use StoredRelay instead */
export type Relay_ENCRYPTED = StoredRelay;
/** @deprecated Use RelayData instead */
export type Relay_DECRYPTED = RelayData;
/** @deprecated Use StoredNwcConnection instead */
export type NwcConnection_ENCRYPTED = StoredNwcConnection;
/** @deprecated Use NwcConnectionRecord instead */
export type NwcConnection_DECRYPTED = NwcConnectionRecord;
/** @deprecated Use StoredCashuMint instead */
export type CashuMint_ENCRYPTED = StoredCashuMint;
/** @deprecated Use CashuMintRecord instead */
export type CashuMint_DECRYPTED = CashuMintRecord;
/** @deprecated Use EncryptedVaultHeader instead */
export type BrowserSyncData_PART_Unencrypted = EncryptedVaultHeader;
/** @deprecated Use EncryptedVaultContent instead */
export type BrowserSyncData_PART_Encrypted = EncryptedVaultContent;
/** @deprecated Use EncryptedVault instead */
export type BrowserSyncData = EncryptedVault;
/** @deprecated Use SyncFlow instead */
export const BrowserSyncFlow = SyncFlow;
/** @deprecated Use SyncFlow instead */
export type BrowserSyncFlow = SyncFlow;
/** @deprecated Use VaultSession instead */
export type BrowserSessionData = VaultSession;
/** @deprecated Use VaultSnapshot instead */
export type SignerMetaData_VaultSnapshot = VaultSnapshot;
/** @deprecated Use EXTENSION_SETTINGS_KEYS instead */
export const SIGNER_META_DATA_KEY = EXTENSION_SETTINGS_KEYS;
/** @deprecated Use ExtensionSettings instead */
export type SignerMetaData = ExtensionSettings;

View File

@@ -1,5 +1,6 @@
.sam-text-muted {
color: var(--muted-foreground);
.sam-text-muted,
.text-muted {
color: var(--muted-foreground) !important;
}
.sam-text-lg {

View File

@@ -2,11 +2,18 @@
* Public API Surface of common
*/
// Domain (DDD Value Objects & Repository Interfaces)
export * from './lib/domain';
// Infrastructure (Encryption & Repository Implementations)
export * from './lib/infrastructure';
// Common
export * from './lib/common/nav-component';
// Constants
export * from './lib/constants/fallback-relays';
export * from './lib/constants/event-kinds';
// Helpers
export * from './lib/helpers/crypto-helper';

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
"version": "1.1.1",
"version": "1.1.6",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [
@@ -51,7 +51,13 @@
],
"browser_specific_settings": {
"gecko": {
"id": "plebian-signer@mleku.dev"
"id": "plebian-signer@mleku.dev",
"data_collection_permissions": {
"required": [
"none"
],
"optional": []
}
}
}
}

View File

@@ -14,9 +14,9 @@ import { NewIdentityComponent } from './components/new-identity/new-identity.com
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 { WelcomeComponent } from './components/welcome/welcome.component';
import { VaultLoginComponent } from './components/vault-login/vault-login.component';
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
import { VaultImportComponent } from './components/vault-import/vault-import.component';
@@ -24,10 +24,6 @@ import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelis
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
export const routes: Routes = [
{
path: 'welcome',
component: WelcomeComponent,
},
{
path: 'vault-login',
component: VaultLoginComponent,
@@ -112,6 +108,10 @@ export const routes: Routes = [
path: 'keys',
component: EditIdentityKeysComponent,
},
{
path: 'ncryptsec',
component: EditIdentityNcryptsecComponent,
},
{
path: 'permissions',
component: EditIdentityPermissionsComponent,

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SignerMetaData, SignerMetaHandler } from '@common';
import { ExtensionSettings, SignerMetaHandler } from '@common';
import browser from 'webextension-polyfill';
export class FirefoxMetaHandler extends SignerMetaHandler {
@@ -20,7 +20,7 @@ export class FirefoxMetaHandler extends SignerMetaHandler {
return data;
}
async saveFullData(data: SignerMetaData): Promise<void> {
async saveFullData(data: ExtensionSettings): Promise<void> {
await browser.storage.local.set(data as Record<string, any>);
console.log(data);
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData, BrowserSessionHandler } from '@common';
import { VaultSession, BrowserSessionHandler } from '@common';
import browser from 'webextension-polyfill';
export class FirefoxSessionHandler extends BrowserSessionHandler {
@@ -7,7 +7,7 @@ export class FirefoxSessionHandler extends BrowserSessionHandler {
return browser.storage.session.get(null);
}
async saveFullData(data: BrowserSessionData): Promise<void> {
async saveFullData(data: VaultSession): Promise<void> {
await browser.storage.session.set(data as Record<string, any>);
}

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
EncryptedVault,
StoredCashuMint,
StoredIdentity,
StoredNwcConnection,
StoredPermission,
BrowserSyncHandler,
Relay_ENCRYPTED,
StoredRelay,
} from '@common';
import browser from 'webextension-polyfill';
@@ -25,20 +25,20 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
return data;
}
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
await browser.storage.local.set(data as Record<string, any>);
this.setFullData(data);
}
async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[];
permissions: StoredPermission[];
}): Promise<void> {
await browser.storage.local.set(data);
this.setPartialData_Permissions(data);
}
async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[];
identities: StoredIdentity[];
}): Promise<void> {
await browser.storage.local.set(data);
this.setPartialData_Identities(data);
@@ -52,21 +52,21 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
}
async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[];
relays: StoredRelay[];
}): Promise<void> {
await browser.storage.local.set(data);
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
nwcConnections: StoredNwcConnection[];
}): Promise<void> {
await browser.storage.local.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
cashuMints: StoredCashuMint[];
}): Promise<void> {
await browser.storage.local.set(data);
this.setPartialData_CashuMints(data);

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
EncryptedVault,
StoredCashuMint,
StoredIdentity,
StoredNwcConnection,
StoredPermission,
BrowserSyncHandler,
Relay_ENCRYPTED,
StoredRelay,
} from '@common';
import browser from 'webextension-polyfill';
@@ -19,20 +19,20 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
return await browser.storage.sync.get(null);
}
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
await browser.storage.sync.set(data as Record<string, any>);
this.setFullData(data);
}
async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[];
permissions: StoredPermission[];
}): Promise<void> {
await browser.storage.sync.set(data);
this.setPartialData_Permissions(data);
}
async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[];
identities: StoredIdentity[];
}): Promise<void> {
await browser.storage.sync.set(data);
this.setPartialData_Identities(data);
@@ -46,21 +46,21 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
}
async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[];
relays: StoredRelay[];
}): Promise<void> {
await browser.storage.sync.set(data);
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
nwcConnections: StoredNwcConnection[];
}): Promise<void> {
await browser.storage.sync.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
cashuMints: StoredCashuMint[];
}): Promise<void> {
await browser.storage.sync.set(data);
this.setPartialData_CashuMints(data);

View File

@@ -136,6 +136,12 @@
</button>
</div>
</div>
<span class="sam-mt-2">Encrypted Key (NIP-49)</span>
<button class="btn btn-primary sam-mt-h" (click)="navigateToNcryptsec()">
Get ncryptsec
</button>
}
<lib-toast #toast [bottom]="16"></lib-toast>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import {
IconButtonComponent,
NavComponent,
@@ -29,6 +29,7 @@ export class KeysComponent extends NavComponent implements OnInit {
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
@@ -51,6 +52,11 @@ export class KeysComponent extends NavComponent implements OnInit {
}
}
navigateToNcryptsec() {
if (!this.identity) return;
this.#router.navigateByUrl(`/edit-identity/${this.identity.id}/ncryptsec`);
}
async #initialize(identityId: string) {
const identity = this.#storage
.getBrowserSessionHandler()

View File

@@ -0,0 +1,60 @@
<div class="header-pane">
<lib-icon-button
icon="chevron-left"
(click)="navigateBack()"
></lib-icon-button>
<span>Get ncryptsec</span>
</div>
<!-- QR Code (shown after generation) -->
@if (ncryptsec) {
<div class="qr-container">
<button
type="button"
class="qr-button"
title="Copy to clipboard"
(click)="copyToClipboard(ncryptsec); toast.show('Copied to clipboard')"
>
<img [src]="ncryptsecQr" alt="ncryptsec QR code" class="qr-code" />
</button>
</div>
}
<!-- PASSWORD INPUT -->
<div class="password-section">
<label for="ncryptsecPasswordInput">Password</label>
<div class="input-group sam-mt-h">
<input
#passwordInput
id="ncryptsecPasswordInput"
type="password"
class="form-control"
placeholder="Enter encryption password"
[(ngModel)]="ncryptsecPassword"
[disabled]="isGenerating"
(keyup.enter)="generateNcryptsec()"
/>
</div>
</div>
<button
class="btn btn-primary generate-btn"
type="button"
(click)="generateNcryptsec()"
[disabled]="!ncryptsecPassword || isGenerating"
>
@if (isGenerating) {
<span class="spinner-border spinner-border-sm" role="status"></span>
Generating...
} @else {
Generate ncryptsec
}
</button>
<p class="description">
Enter a password to encrypt your private key. The resulting ncryptsec can be
used to securely backup or transfer your key.
</p>
<lib-toast #toast [bottom]="16"></lib-toast>

View File

@@ -0,0 +1,70 @@
:host {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
.header-pane {
display: flex;
flex-direction: row;
column-gap: var(--size-h);
align-items: center;
padding-bottom: var(--size);
background-color: var(--background);
position: sticky;
top: 0;
}
}
.description {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: var(--size);
}
.password-section {
margin-bottom: var(--size);
label {
font-weight: 500;
margin-bottom: var(--size-q);
}
}
.generate-btn {
width: 100%;
margin-bottom: var(--size);
}
.qr-container {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: var(--size);
}
.qr-button {
background: white;
padding: var(--size);
border-radius: 8px;
border: none;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&:active {
transform: scale(0.98);
}
}
.qr-code {
width: 250px;
height: 250px;
display: block;
}

View File

@@ -0,0 +1,100 @@
import {
AfterViewInit,
Component,
ElementRef,
inject,
OnInit,
ViewChild,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
IconButtonComponent,
NavComponent,
NostrHelper,
StorageService,
ToastComponent,
} from '@common';
import { FormsModule } from '@angular/forms';
import * as QRCode from 'qrcode';
@Component({
selector: 'app-ncryptsec',
imports: [IconButtonComponent, FormsModule, ToastComponent],
templateUrl: './ncryptsec.component.html',
styleUrl: './ncryptsec.component.scss',
})
export class NcryptsecComponent
extends NavComponent
implements OnInit, AfterViewInit
{
@ViewChild('passwordInput') passwordInput!: ElementRef<HTMLInputElement>;
privkeyHex = '';
ncryptsecPassword = '';
ncryptsec = '';
ncryptsecQr = '';
isGenerating = false;
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
if (!identityId) {
return;
}
this.#initialize(identityId);
}
ngAfterViewInit(): void {
this.passwordInput.nativeElement.focus();
}
async generateNcryptsec() {
if (!this.privkeyHex || !this.ncryptsecPassword) {
return;
}
this.isGenerating = true;
this.ncryptsec = '';
this.ncryptsecQr = '';
try {
this.ncryptsec = await NostrHelper.privkeyToNcryptsec(
this.privkeyHex,
this.ncryptsecPassword
);
// Generate QR code
this.ncryptsecQr = await QRCode.toDataURL(this.ncryptsec, {
width: 250,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
} catch (error) {
console.error('Failed to generate ncryptsec:', error);
} finally {
this.isGenerating = false;
}
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
#initialize(identityId: string) {
const identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === identityId);
if (!identity) {
return;
}
this.privkeyHex = identity.privkey;
}
}

View File

@@ -30,7 +30,7 @@
>
<span class="text-muted">{{ permission.method }}</span>
@if(typeof permission.kind !== 'undefined') {
<span>(kind {{ permission.kind }})</span>
<span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
}
<div class="sam-flex-grow"></div>
<lib-icon-button

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { IconButtonComponent, Identity_DECRYPTED, NavComponent, Permission_DECRYPTED, StorageService } from '@common';
import { IconButtonComponent, Identity_DECRYPTED, NavComponent, Permission_DECRYPTED, StorageService, getKindName } from '@common';
interface HostPermissions {
host: string;
@@ -80,4 +80,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
});
});
}
getKindTooltip(kind: number): string {
return getKindName(kind);
}
}

View File

@@ -57,73 +57,41 @@
<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
<!-- Suggested mints for quick-add -->
<div class="quick-add-section">
<div class="quick-add-label">Quick Add a Mint</div>
<div class="quick-add-menu">
@for (mint of suggestedMints; track mint.url) {
@if (!isMintAlreadyAdded(mint.url)) {
<button
class="quick-add-item"
[disabled]="addingMint"
(click)="quickAddMint(mint)"
>
<span class="mint-row">
<span class="add-icon">+</span>
<span class="mint-name">{{ mint.name }}</span>
</span>
<span class="mint-desc">{{ mint.description }}</span>
</button>
}
}
</div>
@if (mintError) {
<div class="error-message small">{{ mintError }}</div>
}
</div>
<div class="backup-reminder">
<span>Have you set up backups?</span>
<button class="link-btn" (click)="navigateToSettings()">
Configure Backups
</button>
</div>
}
</div>
</div>
} @else {
<div class="wallet-list">
@@ -134,6 +102,33 @@
</button>
}
</div>
<!-- Quick add disclosure when mints exist -->
@if (hasUnavailableMints()) {
<details class="quick-add-disclosure">
<summary>Quick Add</summary>
<div class="quick-add-menu">
@for (mint of suggestedMints; track mint.url) {
@if (!isMintAlreadyAdded(mint.url)) {
<button
class="quick-add-item"
[disabled]="addingMint"
(click)="quickAddMint(mint)"
>
<span class="mint-row">
<span class="add-icon">+</span>
<span class="mint-name">{{ mint.name }}</span>
</span>
<span class="mint-desc">{{ mint.description }}</span>
</button>
}
}
</div>
@if (mintError) {
<div class="error-message small">{{ mintError }}</div>
}
</details>
}
}
<button class="add-wallet-btn" (click)="showAddMint()">
<span class="emoji">+</span>
@@ -210,6 +205,31 @@
<!-- Cashu add mint form -->
@else if (activeSection === 'cashu-add') {
<div class="add-wallet-form">
<!-- Suggested mints -->
<div class="suggested-mints">
<div class="suggested-label">Quick Add</div>
<div class="suggested-list">
@for (mint of suggestedMints; track mint.url) {
<button
class="suggested-mint-btn"
[class.already-added]="isMintAlreadyAdded(mint.url)"
[disabled]="isMintAlreadyAdded(mint.url) || addingMint"
(click)="selectSuggestedMint(mint)"
[title]="mint.description"
>
<span class="mint-name">{{ mint.name }}</span>
@if (isMintAlreadyAdded(mint.url)) {
<span class="added-badge"></span>
}
</button>
}
</div>
</div>
<div class="form-divider">
<span>or enter manually</span>
</div>
<div class="form-group">
<label for="mintName">Mint Name</label>
<input

View File

@@ -1134,3 +1134,189 @@
padding: var(--size);
}
}
// Suggested mints quick-add
.suggested-mints {
display: flex;
flex-direction: column;
gap: var(--size-h);
.suggested-label {
font-size: 0.75rem;
color: var(--muted-foreground);
text-transform: uppercase;
font-weight: 500;
}
.suggested-list {
display: flex;
flex-wrap: wrap;
gap: var(--size-h);
}
.suggested-mint-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: var(--background-light);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.8rem;
color: var(--foreground);
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--background-light-hover);
border-color: var(--primary);
}
&:disabled {
cursor: not-allowed;
}
&.already-added {
opacity: 0.5;
background: transparent;
.added-badge {
color: var(--success, #22c55e);
font-size: 0.7rem;
}
}
.mint-name {
font-weight: 500;
}
}
}
.form-divider {
display: flex;
align-items: center;
gap: var(--size);
color: var(--muted-foreground);
font-size: 0.75rem;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
}
// Quick add section (empty state)
.quick-add-section {
width: 100%;
margin-top: var(--size);
.quick-add-label {
font-size: 0.75rem;
color: var(--muted-foreground);
text-transform: uppercase;
font-weight: 500;
text-align: center;
margin-bottom: var(--size-h);
}
}
// Backup reminder
.backup-reminder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-h);
margin-top: var(--size);
padding-top: var(--size);
border-top: 1px solid var(--border);
span {
font-size: 0.8rem;
color: var(--muted-foreground);
}
}
// Quick add disclosure (when mints exist)
.quick-add-disclosure {
margin-top: var(--size-h);
summary {
font-size: 0.8rem;
color: var(--muted-foreground);
cursor: pointer;
padding: var(--size-h);
text-align: center;
user-select: none;
&:hover {
color: var(--foreground);
}
&::marker {
color: var(--muted-foreground);
}
}
&[open] summary {
margin-bottom: var(--size-h);
}
}
// Quick add menu (shared by both)
.quick-add-menu {
display: flex;
flex-direction: column;
gap: 2px;
background: var(--background-light);
border-radius: var(--radius-md);
overflow: hidden;
}
.quick-add-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: var(--size-h) var(--size);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background: var(--background-light-hover);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.mint-row {
display: flex;
align-items: center;
gap: var(--size-h);
}
.add-icon {
font-size: 1rem;
font-weight: 600;
color: var(--success, #22c55e);
}
.mint-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
}
.mint-desc {
font-size: 0.75rem;
color: var(--muted-foreground);
padding-left: calc(1rem + var(--size-h));
}
}

View File

@@ -123,6 +123,15 @@ export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
refreshingMint = false;
refreshError = '';
// Suggested mints for quick-add
readonly suggestedMints = [
{ name: 'Minibits', url: 'https://mint.minibits.cash', description: 'Well-established mobile wallet mint' },
{ name: 'Coinos', url: 'https://mint.coinos.io', description: 'Lightning wallet with Cashu integration' },
{ name: '21Mint', url: 'https://21mint.me', description: 'Community mint' },
{ name: 'Macadamia', url: 'https://mint.macadamia.cash', description: 'Reliable community mint' },
{ name: 'Stablenut (USD)', url: 'https://stablenut.umint.cash', unit: 'usd', description: 'USD-denominated mint' },
];
get title(): string {
switch (this.activeSection) {
case 'cashu':
@@ -499,6 +508,35 @@ export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
}
}
selectSuggestedMint(mint: { name: string; url: string }) {
this.newMintName = mint.name;
this.newMintUrl = mint.url;
this.mintError = '';
this.mintTestResult = '';
}
isMintAlreadyAdded(mintUrl: string): boolean {
return this.mints.some(m => m.mintUrl === mintUrl);
}
hasUnavailableMints(): boolean {
return this.suggestedMints.some(m => !this.isMintAlreadyAdded(m.url));
}
async quickAddMint(mint: { name: string; url: string }) {
this.addingMint = true;
this.mintError = '';
try {
await this.cashuService.addMint(mint.name, mint.url);
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Failed to add mint';
} finally {
this.addingMint = false;
}
}
async deleteMint() {
if (!this.selectedMintId) return;

View File

@@ -1,32 +1,120 @@
<div class="vertically-centered">
<div class="sam-flex-column center">
<div class="sam-flex-column gap" style="align-items: center">
<span class="title">Plebeian Signer</span>
<div class="container">
<div class="logo-section">
<div class="logo-frame">
<img src="logo.svg" height="120" width="120" alt="" />
<img src="logo.svg" height="80" width="80" alt="" />
</div>
<span class="title">Plebeian Signer</span>
</div>
<!-- New Identity Section -->
<div class="section">
<h2 class="section-heading">Restore or Create New Identity</h2>
<span class="section-note">Create a new nostr identity or paste in your current nsec.</span>
<input
type="text"
class="form-control"
placeholder="nickname"
[(ngModel)]="nickname"
/>
<div class="input-group">
<input
#nsecInputElement
type="password"
class="form-control"
placeholder="nsec or hex private key"
[(ngModel)]="nsecInput"
(ngModelChange)="validateNsec()"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleVisibility(nsecInputElement)"
title="toggle visibility"
>
<i
class="bi"
[class.bi-eye]="nsecInputElement.type === 'password'"
[class.bi-eye-slash]="nsecInputElement.type === 'text'"
></i>
</button>
<button
class="btn btn-outline-secondary"
type="button"
(click)="copyToClipboard()"
title="copy to clipboard"
>
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="button-row">
<button
type="button"
class="btn btn-outline-secondary generate-btn"
(click)="generateKey()"
title="generate new key"
>
<span>generate</span>
<span></span>
</button>
<button
type="button"
class="sam-mt-2 btn btn-primary"
(click)="router.navigateByUrl('/vault-create/new')"
class="btn btn-primary continue-btn"
[disabled]="!isNsecValid || !nickname"
(click)="onContinueWithNsec()"
>
<div class="sam-flex-row gap-h">
<i class="bi bi-plus-circle" style="height: 22px"></i>
<span>Create a new vault</span>
</div>
<span>Continue</span>
<i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
<span class="sam-text-muted">or</span>
<!-- Import Section -->
<div class="section">
<h2 class="section-heading">Import a Vault</h2>
<input
#fileInput
type="file"
class="file-input"
accept=".json"
(change)="onFileSelected($event)"
/>
<div class="import-controls">
<button
type="button"
class="btn btn-secondary"
(click)="router.navigateByUrl('/vault-import')"
class="btn btn-outline-secondary file-btn"
(click)="fileInput.click()"
>
<span>Import a vault</span>
<i class="bi bi-folder2-open"></i>
<span>Add vault file</span>
</button>
@if (snapshots.length > 0) {
<div class="import-row">
<select class="form-select" [(ngModel)]="selectedSnapshot">
@for (snapshot of snapshots; track snapshot.id) {
<option [ngValue]="snapshot">
{{ snapshot.fileName }} ({{ snapshot.identityCount }} identities)
</option>
}
</select>
<button
type="button"
class="btn btn-primary icon-btn"
[disabled]="!selectedSnapshot"
(click)="onImport()"
title="import vault"
>
<i class="bi bi-arrow-right"></i>
</button>
</div>
}
</div>
</div>
</div>

View File

@@ -2,18 +2,26 @@
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
.vertically-centered {
height: 100%;
.container {
display: flex;
justify-content: center;
flex-direction: column;
padding: var(--size);
gap: var(--size);
}
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-half);
padding-bottom: var(--size-half);
}
.title {
font-size: 20px;
font-weight: 500;
margin-bottom: var(--size);
}
.logo-frame {
@@ -21,8 +29,73 @@
border-radius: 100%;
}
.section {
display: flex;
flex-direction: column;
gap: var(--size);
margin-top: var(--size);
}
.section-heading {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.section-note {
font-size: 14px;
color: var(--muted-foreground);
}
.button-row {
display: flex;
gap: var(--size);
justify-content: flex-end;
}
.generate-btn {
display: flex;
align-items: center;
gap: var(--size-half);
}
.continue-btn {
display: flex;
align-items: center;
gap: var(--size-half);
}
.file-input {
position: absolute;
visibility: hidden;
}
.file-btn {
display: flex;
align-items: center;
gap: var(--size-half);
}
.import-controls {
display: flex;
flex-direction: column;
gap: var(--size);
}
.import-row {
display: flex;
gap: var(--size-half);
select {
flex: 1;
}
}
.icon-btn {
width: 42px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@@ -1,12 +1,161 @@
import { Component, inject } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import {
NavComponent,
NostrHelper,
StorageService,
StartupService,
SignerMetaData_VaultSnapshot,
BrowserSyncData,
} from '@common';
import { generateSecretKey } from 'nostr-tools';
import { bytesToHex } from '@noble/hashes/utils';
import { v4 as uuidv4 } from 'uuid';
import browser from 'webextension-polyfill';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
const VAULT_SNAPSHOTS_KEY = 'vaultSnapshots';
@Component({
selector: 'app-vault-create-home',
imports: [],
imports: [FormsModule],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent {
export class HomeComponent extends NavComponent implements OnInit {
readonly router = inject(Router);
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
nickname = '';
nsecInput = '';
isNsecValid = false;
snapshots: SignerMetaData_VaultSnapshot[] = [];
selectedSnapshot: SignerMetaData_VaultSnapshot | undefined;
ngOnInit(): void {
this.#loadSnapshots();
}
generateKey() {
const sk = generateSecretKey();
const privkey = bytesToHex(sk);
this.nsecInput = NostrHelper.privkey2nsec(privkey);
this.validateNsec();
}
toggleVisibility(element: HTMLInputElement) {
element.type = element.type === 'password' ? 'text' : 'password';
}
async copyToClipboard() {
if (this.nsecInput) {
await navigator.clipboard.writeText(this.nsecInput);
}
}
validateNsec() {
if (!this.nsecInput) {
this.isNsecValid = false;
return;
}
try {
NostrHelper.getNostrPrivkeyObject(this.nsecInput.toLowerCase());
this.isNsecValid = true;
} catch {
this.isNsecValid = false;
}
}
onContinueWithNsec() {
if (!this.isNsecValid || !this.nickname) {
return;
}
// Navigate to password step, passing nsec and nickname in state
this.router.navigateByUrl('/vault-create/new', {
state: { nsec: this.nsecInput, nickname: this.nickname },
});
}
async onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) {
return;
}
try {
const file = files[0];
const text = await file.text();
const vault = JSON.parse(text) as BrowserSyncData;
// Check if file already exists
if (this.snapshots.some((s) => s.fileName === file.name)) {
input.value = '';
return;
}
const newSnapshot: SignerMetaData_VaultSnapshot = {
id: uuidv4(),
fileName: file.name,
createdAt: new Date().toISOString(),
data: vault,
identityCount: vault.identities?.length ?? 0,
reason: 'manual',
};
this.snapshots = [...this.snapshots, newSnapshot].sort((a, b) =>
b.fileName.localeCompare(a.fileName)
);
this.selectedSnapshot = newSnapshot;
await this.#saveSnapshots();
} catch (error) {
console.error('Failed to load vault file:', error);
}
// Reset input so same file can be selected again
input.value = '';
}
async onImport() {
if (!this.selectedSnapshot) {
return;
}
try {
await this.#storage.deleteVault(true);
await this.#storage.importVault(this.selectedSnapshot.data);
// Restart the app to properly reinitialize and route to vault-login
this.#storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to import vault:', error);
}
}
async #loadSnapshots() {
const data = (await browser.storage.local.get(VAULT_SNAPSHOTS_KEY)) as {
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
};
this.snapshots = data.vaultSnapshots
? [...data.vaultSnapshots].sort((a, b) =>
b.fileName.localeCompare(a.fileName)
)
: [];
if (this.snapshots.length > 0) {
this.selectedSnapshot = this.snapshots[0];
}
}
async #saveSnapshots() {
await browser.storage.local.set({
[VAULT_SNAPSHOTS_KEY]: this.snapshots,
});
}
}

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