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>
27 KiB
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:
StorageServicehandles 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:
- Extract domain aggregates with behavior (Identity, Vault, Wallet)
- Separate encryption into an infrastructure layer
- Introduce repository pattern for each aggregate
- 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.tsprojects/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
-
Monorepo Structure
- Clean separation:
projects/common,projects/chrome,projects/firefox - Shared library via
@commonalias - Browser-specific implementations isolated
- Clean separation:
-
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.
-
Encrypted/Decrypted Type Pairs
Identity_DECRYPTED/Identity_ENCRYPTED- Clear distinction between storage states
-
Vault Versioning
- Migration path from v1 (PBKDF2) to v2 (Argon2id)
- Automatic upgrade on unlock
-
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:
// 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:
// 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:
// 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:
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:
// 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:
// 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
// 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
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.
- Create
IdentityId,Nickname,NostrKeyPairvalue objects - Use them in existing interfaces initially
- Add validation in factory methods
- 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.
- Define repository interfaces in domain layer
- Create
IdentityRepository,PermissionRepository, etc. - Move encryption to
EncryptionServiceinfrastructure - Refactor
StorageServiceto 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.
- Convert
Identity_DECRYPTEDinterface toIdentityclass - Move signing logic into
Identity.sign() - Move encryption decision logic into domain
- 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)
- Encapsulate KeyPair operations - Private keys should never be accessed directly
- Enforce invariants - Selected identity must exist, permissions must reference valid identities
- Clear transaction boundaries - What gets saved together?
Medium Priority (Maintainability)
- Split StorageService - Into VaultService, IdentityRepository, PermissionRepository
- Extract EncryptionService - Pure infrastructure concern
- Type-safe IDs - Prevent mixing up identity IDs with permission IDs
Lower Priority (Polish)
- Domain events - For audit trail and extensibility
- Full ubiquitous language - Rename all types
- Discriminated unions - For vault context types
Testing Implications
Current state makes testing difficult because:
StorageServicerequires 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
// 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:
- Start with value objects - Low risk, immediate type safety benefits
- Introduce repositories gradually - Extract one at a time, starting with Identity
- Defer full rich domain model - Until repositories stabilize the architecture
- 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.