Files
plebeian-signer/DDD_ANALYSIS.md
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

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

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

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.

  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
// 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.