Files
grimoire/MULTI_ACCOUNT_ARCHITECTURE.md
Claude d21b351f5a docs: add comprehensive multi-account login plan
Add detailed planning documents for multi-account, multi-login method
support in Grimoire:

- MULTI_ACCOUNT_LOGIN_PLAN.md: Complete implementation plan covering
  read-only accounts (npub/nip-05/hex/nprofile), NIP-07 browser
  extensions, NIP-46 remote signers (bunker URLs), and NIP-55 Android
  signers. Includes current state analysis, architecture design,
  login flows, commands specification, UI components, and 4-phase
  implementation roadmap.

- MULTI_ACCOUNT_IMPLEMENTATION_PHASES.md: Step-by-step implementation
  guide with specific files to create/modify for each phase, testing
  checklists, and timeline estimates. Breaks down into Phase 1
  (read-only accounts), Phase 2 (management UI), Phase 3 (NIP-46),
  and Phase 4 (NIP-55).

- MULTI_ACCOUNT_ARCHITECTURE.md: Visual architecture diagrams and
  data flow documentation. Covers system architecture, state
  management, persistence layer, component hierarchy, security
  boundaries, and performance considerations.

The plan integrates with existing applesauce-accounts AccountManager
and applesauce-signers library, with special focus on NIP-46 relay
pool integration for remote signing.
2026-01-04 18:41:41 +00:00

21 KiB

Multi-Account Architecture Diagram

System Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                          User Interface Layer                        │
├─────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────────┐  │
│  │  User Menu   │  │ Login Dialog │  │  Account Manager Window  │  │
│  │              │  │              │  │                          │  │
│  │ • Avatar     │  │ • Method     │  │ • List all accounts      │  │
│  │ • Accounts   │  │   selection  │  │ • Switch / Remove        │  │
│  │ • Switch     │  │ • Smart      │  │ • Labels / Status        │  │
│  │ • Add        │  │   input      │  │ • Connection indicators  │  │
│  └──────────────┘  └──────────────┘  └──────────────────────────┘  │
│                                                                       │
└───────────────────────────────┬───────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                        Command Layer                                 │
├─────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  /login [identifier]     /accounts          /logout [--all]          │
│       │                       │                    │                 │
│       ▼                       ▼                    ▼                 │
│  parseLoginCommand    (opens window)     parseLogoutCommand          │
│                                                                       │
└───────────────────────────────┬───────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                      Account Management Layer                        │
├─────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │            AccountManager (applesauce-accounts)              │   │
│  │                                                              │   │
│  │  • accounts$: Observable<Account[]>                          │   │
│  │  • active$: Observable<Account | null>                       │   │
│  │  • addAccount(account)                                       │   │
│  │  • removeAccount(account)                                    │   │
│  │  • setActive(account)                                        │   │
│  │  • toJSON() / fromJSON()                                     │   │
│  └────────────────────────┬────────────────────────────────────┘   │
│                           │                                          │
│              ┌────────────┼────────────┬────────────┐               │
│              ▼            ▼            ▼            ▼               │
│   ┌────────────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐         │
│   │ ReadOnlyAccount│ │Extension│ │ Remote  │ │ Android  │         │
│   │                │ │ Account │ │ Signer  │ │ Signer   │         │
│   │ • pubkey       │ │         │ │ Account │ │ Account  │         │
│   │ • signer=null  │ │ NIP-07  │ │ NIP-46  │ │ NIP-55   │         │
│   │ • metadata     │ └─────────┘ └─────────┘ └──────────┘         │
│   │   - source     │       │           │            │              │
│   │   - nip05      │       │           │            │              │
│   └────────────────┘       │           │            │              │
│                            ▼           ▼            ▼              │
│                    ┌────────────────────────────────────┐          │
│                    │         Signer Layer               │          │
│                    │  (applesauce-signers)              │          │
│                    │                                    │          │
│                    │  • getPublicKey()                  │          │
│                    │  • signEvent(event)                │          │
│                    │  • nip04/nip44 encrypt/decrypt     │          │
│                    └────────────┬───────────────────────┘          │
│                                 │                                   │
└─────────────────────────────────┼────────────────────────────────────┘
                                  │
                 ┌────────────────┼────────────────┐
                 ▼                ▼                ▼
      ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
      │ window.nostr │  │  Relay Pool  │  │Android Intent│
      │              │  │              │  │              │
      │  (NIP-07)    │  │  (NIP-46)    │  │  (NIP-55)    │
      └──────────────┘  └──────────────┘  └──────────────┘

Data Flow

1. Login Flow

User Input (npub, nip05, bunker://, etc.)
         │
         ▼
detectLoginInputType()
         │
         ▼
createAccountFromInput()
         │
         ├─ npub ────────────────────► ReadOnlyAccount.fromNpub()
         ├─ nip05 ───────────────────► ReadOnlyAccount.fromNip05()
         ├─ hex ─────────────────────► ReadOnlyAccount.fromHex()
         ├─ nprofile ────────────────► ReadOnlyAccount.fromNprofile()
         ├─ bunker:// ───────────────► RemoteSignerAccount.fromBunkerUrl()
         └─ (empty) ─────────────────► Show LoginDialog
                                                │
                                                ▼
                                        User selects method
                                                │
                                                ▼
                                        Create appropriate account
                                                │
                                                ▼
                                      accountManager.addAccount()
                                                │
                                                ▼
                                      accountManager.setActive()
                                                │
                                                ▼
                                      active$ emits new account
                                                │
                                                ▼
                                      useAccountSync() receives update
                                                │
                                                ▼
                                      Update GrimoireState.activeAccount

2. Account Switching Flow

User clicks account in menu
         │
         ▼
accountManager.setActive(account)
         │
         ▼
active$ emits account
         │
         ▼
useAccountSync() hook listens
         │
         ▼
Load relays for account (NIP-65 or cache)
         │
         ▼
setActiveAccount({
  pubkey,
  relays,
  accountType,
  label
})
         │
         ▼
Jotai state updates
         │
         ▼
UI re-renders with new active account

3. Event Signing Flow

User wants to publish event
         │
         ▼
Get active account from accountManager
         │
         ├─ No account ──────────────► Show login prompt
         │
         ▼
Check if account has signer
         │
         ├─ No signer (read-only) ───► Show error + upgrade prompt
         │
         ▼
account.signer.signEvent(event)
         │
         ├─ ExtensionSigner ─────────► window.nostr.signEvent()
         ├─ Nip46Signer ─────────────► Send request via relay
         └─ Nip55Signer ─────────────► Send Android intent
                  │
                  ▼
           User approves (if needed)
                  │
                  ▼
           Signed event returned
                  │
                  ▼
           Publish to relays

4. NIP-46 Connection Flow

RemoteSignerAccount.fromBunkerUrl(url)
         │
         ▼
Parse bunker:// URL
  • pubkey (remote signer)
  • relays
  • secret (optional)
         │
         ▼
Create Nip46Signer({
  remotePubkey,
  relays,
  pool: RelayPool (singleton)
})
         │
         ▼
signer.connect()
         │
         ├─ Connect to relays
         ├─ Subscribe to response events
         └─ Send connect request
                  │
                  ▼
         Remote signer responds
                  │
                  ▼
         Connection established
                  │
                  ▼
         signer.getPublicKey()
                  │
                  ▼
         Create RemoteSignerAccount
                  │
                  ▼
         Monitor connection status
           (connected/disconnected/connecting)

State Management

Jotai State (UI State)

GrimoireState {
  activeAccount?: {
    pubkey: string,
    relays: RelayInfo[],
    accountType: 'readonly' | 'extension' | 'remote' | 'android',
    label?: string
  }
}

AccountManager State (Account State)

AccountManager {
  accounts$: Observable<Account[]>,      // All accounts
  active$: Observable<Account | null>    // Active account
}

Sync Hook (Bridge)

useAccountSync() {
  // Listens to: accountManager.active$
  // Updates:    grimoireState.activeAccount
  // Loads:      relays from NIP-65 or cache
}

Persistence

LocalStorage Keys

┌───────────────────────────────────────────────┐
│  localStorage                                 │
├───────────────────────────────────────────────┤
│                                               │
│  "nostr-accounts": {                          │
│    accounts: [                                │
│      {                                        │
│        id: "readonly:abc123...",              │
│        pubkey: "abc123...",                   │
│        metadata: {                            │
│          type: "readonly",                    │
│          source: "npub",                      │
│          originalInput: "npub1..."            │
│        }                                      │
│      },                                       │
│      {                                        │
│        id: "remote:def456...",                │
│        pubkey: "def456...",                   │
│        metadata: {                            │
│          type: "remote",                      │
│          relays: ["wss://..."],               │
│          remotePubkey: "xyz789..."            │
│        }                                      │
│      }                                        │
│    ]                                          │
│  }                                            │
│                                               │
│  "active-account": "readonly:abc123..."       │
│                                               │
└───────────────────────────────────────────────┘

Initialization Sequence

1. App starts
      ▼
2. Load AccountManager from localStorage
      ▼
3. Register all account types
      ▼
4. Deserialize accounts from JSON
      ▼
5. For each RemoteSignerAccount:
   • Recreate Nip46Signer
   • Connect to relays (async)
      ▼
6. Set active account from localStorage
      ▼
7. useAccountSync() hook activates
      ▼
8. Load relays for active account
      ▼
9. Update GrimoireState
      ▼
10. UI renders with active account

Component Hierarchy

App
 │
 ├─ UserMenu
 │   ├─ AccountList (dropdown)
 │   │   ├─ AccountItem (active)
 │   │   ├─ AccountItem
 │   │   └─ AccountItem
 │   ├─ AddAccountButton
 │   └─ LogoutButton
 │
 ├─ LoginDialog (modal)
 │   ├─ MethodSelector
 │   │   ├─ ExtensionButton
 │   │   ├─ ReadOnlyButton
 │   │   ├─ RemoteSignerButton
 │   │   └─ AndroidButton
 │   └─ SmartInput (auto-detect format)
 │
 └─ Windows
     └─ AccountManager (window app)
         ├─ AccountList
         │   ├─ AccountCard
         │   │   ├─ Avatar
         │   │   ├─ Info (name, type, status)
         │   │   └─ Actions (switch, remove, edit)
         │   └─ ...
         └─ AddAccountButton

Error Handling Strategy

┌──────────────────────────────────────────────────────┐
│  Error Type           │  Handler                     │
├──────────────────────────────────────────────────────┤
│  Invalid input        │  Toast + suggest valid format│
│  NIP-05 failed        │  Toast + retry option        │
│  Extension not found  │  Toast + install link        │
│  NIP-46 connect fail  │  Toast + retry + manual      │
│  Read-only sign       │  Toast + upgrade prompt      │
│  Signer rejected      │  Toast + info                │
│  Network error        │  Toast + retry               │
└──────────────────────────────────────────────────────┘

Security Boundaries

                    ┌─────────────────────┐
                    │   Grimoire App      │
                    │   (Trusted)         │
                    └──────────┬──────────┘
                               │
         ┌─────────────────────┼─────────────────────┐
         │                     │                     │
         ▼                     ▼                     ▼
┌────────────────┐   ┌────────────────┐   ┌────────────────┐
│ Browser Ext    │   │ Remote Signer  │   │ Android App    │
│ (Semi-Trusted) │   │ (Trusted)      │   │ (Trusted)      │
│                │   │                │   │                │
│ • Has keys     │   │ • Has keys     │   │ • Has keys     │
│ • User approves│   │ • User approves│   │ • User approves│
│ • Sandboxed    │   │ • Remote       │   │ • Separate dev │
└────────────────┘   └────────────────┘   └────────────────┘
         │                     │                     │
         │                     │                     │
         └─────────────────────┴─────────────────────┘
                               │
                    Never stored in Grimoire
                    (except read-only pubkeys)

Performance Considerations

Optimization Points

  1. Account List Rendering:

    • Use virtual scrolling for 100+ accounts
    • Memoize account components
    • Lazy load profile metadata
  2. NIP-46 Connections:

    • Maintain persistent connections
    • Connection pooling for multiple accounts
    • Reconnect with exponential backoff
  3. Profile Loading:

    • Cache profile metadata in Dexie
    • Batch profile requests
    • Use stale-while-revalidate pattern
  4. Account Switching:

    • Instant UI update (optimistic)
    • Load relays in background
    • Cancel in-flight requests from previous account

Observability

Events to Log

// Account lifecycle
logger.info("account.added", { accountId, type });
logger.info("account.removed", { accountId });
logger.info("account.switched", { fromId, toId });

// NIP-46 connection
logger.info("nip46.connecting", { accountId, relays });
logger.info("nip46.connected", { accountId, duration });
logger.error("nip46.failed", { accountId, error });

// Signing
logger.info("sign.requested", { accountId, kind });
logger.info("sign.completed", { accountId, eventId });
logger.error("sign.rejected", { accountId, reason });

Metrics to Track

  • Account count by type
  • Active account switch frequency
  • NIP-46 connection success rate
  • Sign request success rate
  • Average connection duration

Migration Path

Existing Users

Current state (v1):

{
  "active-account": "abc123...",
  "nostr-accounts": {
    "accounts": [
      {
        "id": "abc123...",
        "pubkey": "abc123...",
        "type": "extension"  // Old format
      }
    ]
  }
}

After migration (v2):

{
  "active-account": "extension:abc123...",
  "nostr-accounts": {
    "accounts": [
      {
        "id": "extension:abc123...",  // New ID format
        "pubkey": "abc123...",
        "metadata": {
          "type": "extension"  // New metadata format
        }
      }
    ]
  }
}

Migration code in src/services/accounts.ts:

// Detect old format and migrate
if (!account.metadata && account.type) {
  account.metadata = { type: account.type };
  delete account.type;
}

This architecture provides a clean separation of concerns, maintains security boundaries, and scales to support multiple login methods while keeping the implementation straightforward.