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.
This commit is contained in:
Claude
2026-01-04 18:41:41 +00:00
parent a4eff14620
commit d21b351f5a
3 changed files with 1806 additions and 0 deletions

View File

@@ -0,0 +1,503 @@
# 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)
```typescript
GrimoireState {
activeAccount?: {
pubkey: string,
relays: RelayInfo[],
accountType: 'readonly' | 'extension' | 'remote' | 'android',
label?: string
}
}
```
### AccountManager State (Account State)
```typescript
AccountManager {
accounts$: Observable<Account[]>, // All accounts
active$: Observable<Account | null> // Active account
}
```
### Sync Hook (Bridge)
```typescript
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
```javascript
// 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):
```json
{
"active-account": "abc123...",
"nostr-accounts": {
"accounts": [
{
"id": "abc123...",
"pubkey": "abc123...",
"type": "extension" // Old format
}
]
}
}
```
After migration (v2):
```json
{
"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`:
```typescript
// 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.

View File

@@ -0,0 +1,460 @@
# Multi-Account Implementation Phases
Quick reference for implementing multi-account support in Grimoire.
## Phase 1: Read-Only Accounts (Week 1)
### Files to Create
1. **`src/lib/account-types.ts`** - Account class implementations
```typescript
export class ReadOnlyAccount implements Account {
// Factory methods: fromNpub, fromNip05, fromHex, fromNprofile
}
```
2. **`src/lib/login-parser.ts`** - Input detection and parsing
```typescript
export function detectLoginInputType(input: string): LoginInputType
export async function createAccountFromInput(input: string)
```
3. **`src/components/LoginDialog.tsx`** - Login method selection UI
- Method buttons (Extension, Read-Only, Remote, Android)
- Smart input field with auto-detection
- Error handling
### Files to Modify
1. **`src/services/accounts.ts`** - Register new account types
```typescript
accountManager.registerAccountType("readonly", ReadOnlyAccount);
```
2. **`src/types/man.ts`** - Add login command
```typescript
login: {
appId: "login-dialog",
argParser: parseLoginCommand,
// ...
}
```
3. **`src/components/nostr/user-menu.tsx`** - Show all accounts
- Subscribe to `accounts.accounts$` for all accounts
- Add click handler to switch accounts
- Add "Add Account" button
### Testing Tasks
- [ ] `login npub1...` creates read-only account
- [ ] `login alice@nostr.com` resolves NIP-05 and creates account
- [ ] `login <hex>` creates read-only account
- [ ] `login nprofile1...` creates account with relay hints
- [ ] Account switching updates active state
- [ ] Accounts persist across page reload
---
## Phase 2: Account Management UI (Week 2)
### Files to Create
1. **`src/components/AccountManager.tsx`** - Full account management window
- List all accounts with type badges
- Switch, remove, edit label actions
- Connection status for NIP-46
- Add account button
2. **`src/components/ui/account-badge.tsx`** - Account type badge component
- Extension icon (🔌)
- Read-only icon (👁️)
- Remote signer icon (🔗)
- Android icon (🤖)
### Files to Modify
1. **`src/types/man.ts`** - Add accounts and logout commands
```typescript
accounts: {
appId: "account-manager",
// ...
},
logout: {
argParser: parseLogoutCommand,
// ...
}
```
2. **`src/core/state.ts`** - Add account metadata fields
```typescript
activeAccount?: {
// ... existing fields
accountType: 'readonly' | 'extension' | 'remote' | 'android';
label?: string;
}
```
3. **`src/hooks/useAccountSync.ts`** - Sync account type and label
### Testing Tasks
- [ ] `/accounts` opens management window
- [ ] Can switch accounts from manager
- [ ] Can remove accounts (with confirmation)
- [ ] Can edit account labels
- [ ] Account type badges display correctly
- [ ] `/logout` removes active account
---
## Phase 3: NIP-46 Remote Signer (Week 3)
### Files to Create
1. **`src/lib/bunker-url.ts`** - Bunker URL parsing utilities
```typescript
export function parseBunkerUrl(url: string)
export function isValidBunkerUrl(url: string): boolean
```
### Files to Modify
1. **`src/lib/account-types.ts`** - Add RemoteSignerAccount
```typescript
export class RemoteSignerAccount implements Account {
signer: Nip46Signer;
// Connection lifecycle management
static async fromBunkerUrl(url: string)
}
```
2. **`src/services/accounts.ts`** - Initialize NIP-46 connections
```typescript
function initializeRemoteSigners() {
// Connect all NIP-46 signers on app start
}
```
3. **`src/lib/login-parser.ts`** - Handle bunker URLs
```typescript
case 'bunker':
return await RemoteSignerAccount.fromBunkerUrl(input);
```
4. **`src/components/LoginDialog.tsx`** - Show connection status
- Loading indicator during connection
- Success/error feedback
- Relay status
5. **`src/components/AccountManager.tsx`** - Connection indicators
- 🟢 Connected
- 🔴 Disconnected
- 🟡 Connecting
### Integration Tasks
- [ ] Hook Nip46Signer with singleton relay pool
- [ ] Monitor connection status via observables
- [ ] Auto-reconnect on disconnect
- [ ] Handle connection errors gracefully
- [ ] Clean up relay subscriptions on account removal
### Testing Tasks
- [ ] `login bunker://...` connects to remote signer
- [ ] Can sign events with remote signer
- [ ] Connection status updates in real-time
- [ ] Reconnects after page reload
- [ ] Handles relay failures gracefully
- [ ] Cleans up connections on logout
---
## Phase 4: NIP-55 Android Signer (Future)
### Research Phase
1. Study NIP-55 specification
2. Find reference implementations
3. Test with Android signer apps
4. Design intent/deep link flow
### Files to Create
1. **`src/lib/account-types.ts`** - AndroidSignerAccount
2. **`src/lib/nip55-handler.ts`** - Android intent handling
3. **`src/components/AndroidSignerSetup.tsx`** - QR code / deep link UI
### Testing Tasks
- [ ] Generate signing request intent
- [ ] Handle response from Android app
- [ ] Sign events via Android signer
- [ ] Handle errors and timeouts
---
## Critical Implementation Notes
### 1. Relay Pool Integration (NIP-46)
NIP-46 signers need to communicate with the relay pool. Two approaches:
**Option A: Separate Pool (Recommended)**
```typescript
// Each Nip46Signer maintains its own relay connections
const signer = new Nip46Signer({
remotePubkey,
relays,
// Creates internal relay connections
});
```
**Option B: Shared Pool**
```typescript
import pool from "@/services/relay-pool";
const signer = new Nip46Signer({
remotePubkey,
relays,
pool, // Pass singleton pool
});
```
**Decision**: Check applesauce-signers API - use Option B if supported, otherwise Option A.
### 2. Account Serialization
When saving to localStorage, NIP-46 accounts need special handling:
```typescript
// Save
toJSON() {
return {
id: this.id,
pubkey: this.pubkey,
metadata: {
type: 'remote',
relays: this.metadata.relays,
remotePubkey: this.metadata.remotePubkey,
// DON'T save signer instance or connection secrets
}
};
}
// Load
static fromJSON(data: any) {
// Recreate signer from metadata
const signer = new Nip46Signer({
remotePubkey: data.metadata.remotePubkey,
relays: data.metadata.relays,
});
const account = new RemoteSignerAccount(/* ... */);
// Connect asynchronously after creation
account.connect().catch(console.error);
return account;
}
```
### 3. Error Handling Patterns
**Read-Only Signing Attempt**:
```typescript
async function publishNote(content: string) {
const account = accountManager.active;
if (!account?.signer) {
toast.error("Cannot sign", {
description: "This is a read-only account. Add a signing account to publish.",
action: {
label: "Add Account",
onClick: () => openLoginDialog()
}
});
return;
}
// Proceed with signing
}
```
**NIP-46 Connection Failure**:
```typescript
async function connectRemoteSigner(account: RemoteSignerAccount) {
try {
await account.signer.connect();
toast.success("Connected to remote signer");
} catch (error) {
toast.error("Connection failed", {
description: error.message,
action: {
label: "Retry",
onClick: () => connectRemoteSigner(account)
}
});
}
}
```
### 4. Account Sync Hook Enhancement
```typescript
// src/hooks/useAccountSync.ts
export function useAccountSync() {
const { setActiveAccount } = useGrimoire();
const eventStore = useEventStore();
useEffect(() => {
const sub = accountManager.active$.subscribe(async (account) => {
if (!account) {
setActiveAccount(undefined);
return;
}
// Get account type from metadata
const accountType = account.metadata?.type || 'extension';
// Load relays from relay list cache or NIP-65
const relays = await loadRelaysForPubkey(account.pubkey, eventStore);
setActiveAccount({
pubkey: account.pubkey,
relays,
accountType,
label: account.metadata?.label,
});
});
return () => sub.unsubscribe();
}, [setActiveAccount, eventStore]);
}
```
---
## Testing Strategy
### Unit Tests
Create test files alongside implementation:
- `src/lib/account-types.test.ts`
- `src/lib/login-parser.test.ts`
- `src/lib/bunker-url.test.ts`
### Integration Tests
Test account lifecycle:
```typescript
describe("Account Management", () => {
it("should add read-only account", async () => {
const account = await createAccountFromInput("npub1...");
accountManager.addAccount(account);
expect(accountManager.accounts.length).toBe(1);
});
it("should switch accounts", () => {
const account1 = /* ... */;
const account2 = /* ... */;
accountManager.addAccount(account1);
accountManager.addAccount(account2);
accountManager.setActive(account2);
expect(accountManager.active).toBe(account2);
});
});
```
### Manual Testing Checklist
**Phase 1**:
- [ ] Login with npub works
- [ ] Login with NIP-05 works
- [ ] Login with hex works
- [ ] Login with nprofile works
- [ ] Can switch between accounts
- [ ] Accounts persist after reload
- [ ] Read-only accounts cannot sign
**Phase 2**:
- [ ] Account manager shows all accounts
- [ ] Can remove accounts
- [ ] Can edit labels
- [ ] Type badges display correctly
- [ ] Active account highlighted
**Phase 3**:
- [ ] Bunker URL parsing works
- [ ] Remote signer connects
- [ ] Can sign with remote signer
- [ ] Connection status accurate
- [ ] Reconnects after reload
- [ ] Handles disconnects gracefully
---
## Quick Start Guide
### To implement Phase 1 today:
1. **Create account types**:
```bash
# Create the file
touch src/lib/account-types.ts
# Implement ReadOnlyAccount class
```
2. **Create login parser**:
```bash
touch src/lib/login-parser.ts
# Implement detectLoginInputType and createAccountFromInput
```
3. **Add login command**:
```typescript
// In src/types/man.ts
login: {
description: "Add a new account",
argParser: async (args) => {
const input = args.join(' ').trim();
if (!input) return { showDialog: true };
const account = await createAccountFromInput(input);
return { account };
},
// ...
}
```
4. **Test it**:
```bash
npm run dev
# In app: login npub1...
```
### Estimated Timeline
- **Phase 1**: 3-5 days (read-only accounts + basic switching)
- **Phase 2**: 2-3 days (account management UI)
- **Phase 3**: 5-7 days (NIP-46 integration + testing)
- **Phase 4**: TBD (future)
**Total**: ~2 weeks for full multi-account support with NIP-46.
---
## Next Steps
1. **Start with Phase 1**: Read-only accounts
2. **Create PR**: Get feedback on architecture
3. **Iterate**: Based on testing and feedback
4. **Phase 2**: Add management UI
5. **Phase 3**: NIP-46 integration
6. **Polish**: UX improvements and edge cases
Let's build this step by step! 🚀

843
MULTI_ACCOUNT_LOGIN_PLAN.md Normal file
View File

@@ -0,0 +1,843 @@
# Multi-Account, Multi-Login Method Plan for Grimoire
## Overview
This document outlines the comprehensive plan for implementing multi-account support with multiple login methods in Grimoire. The implementation will support:
1. **Read-Only Accounts** - Login with pubkey/npub/nprofile/nip-05 (no signing capability)
2. **NIP-07 (Browser Extension)** - Existing, needs enhancement for multi-account
3. **NIP-46 (Nostr Connect/Bunker)** - Remote signing via relay communication
4. **NIP-55 (Android Signer)** - Android app signing (future consideration)
## Current State Analysis
### Existing Implementation
**Accounts Service** (`src/services/accounts.ts`):
- ✅ Uses `AccountManager` from `applesauce-accounts`
- ✅ Registers common account types via `registerCommonAccountTypes`
- ✅ Persists accounts to localStorage (`nostr-accounts` key)
- ✅ Persists active account to localStorage (`active-account` key)
- ✅ RxJS observables for reactive account updates (`accounts$`, `active$`)
- ❌ Only supports `ExtensionAccount` (NIP-07) currently
**User Menu** (`src/components/nostr/user-menu.tsx`):
- ✅ Shows active account with avatar and NIP-05
- ✅ Basic login/logout functionality for NIP-07
- ✅ Shows relay list from active account
- ❌ No account switching UI
- ❌ No multiple login method options
- ❌ No account management features
**State Management** (`src/core/state.ts`):
- ✅ Stores `activeAccount` with `pubkey` and `relays` in Jotai state
- ✅ Has `setActiveAccount()` and `setActiveAccountRelays()` functions
- ✅ Synced via `useAccountSync` hook
**Relay Infrastructure**:
- ✅ Singleton `RelayPool` (`src/services/relay-pool.ts`)
- ✅ Relay authentication state machine (`src/lib/auth-state-machine.ts`)
- ✅ Supports NIP-42 relay auth
### What's Missing
1. **Account Types**:
- Read-only account implementation
- NIP-46 account with relay pool integration
- NIP-55 account (Android signer)
2. **Login Flows**:
- Login command(s) to add accounts
- Support for different input formats (npub, hex, nip-05, bunker URLs, etc.)
- Error handling and validation
3. **Account Management**:
- Switch between accounts
- Remove accounts
- View all accounts
- Set account metadata (labels, colors)
4. **UI Components**:
- Multi-account selector in user menu
- Login dialog with method selection
- Account management settings
## Architecture Design
### Account Type Hierarchy
```typescript
// All accounts will implement the base account interface from applesauce-accounts
1. ReadOnlyAccount
- pubkey: string
- signer: null
- metadata: { type: 'readonly', source: 'npub' | 'nip05' | 'hex' | 'nprofile' }
- Cannot sign events
2. ExtensionAccount (existing)
- pubkey: string
- signer: ExtensionSigner (NIP-07)
- metadata: { type: 'extension' }
- Signs via window.nostr
3. RemoteSignerAccount (NEW - NIP-46)
- pubkey: string
- signer: Nip46Signer
- metadata: {
type: 'remote',
relays: string[],
remotePubkey: string,
connectionStatus: 'connected' | 'disconnected' | 'connecting'
}
- Requires relay pool integration
- Persistent connection management
4. AndroidSignerAccount (FUTURE - NIP-55)
- pubkey: string
- signer: Nip55Signer
- metadata: { type: 'android' }
- Uses Android intent system
```
### Relay Pool Integration for NIP-46
NIP-46 signers need to communicate with remote signers via relays. This requires:
1. **Dedicated Relay Connections**:
- NIP-46 signers maintain their own relay subscriptions
- These are separate from user's content relays
- Must persist across app reloads
2. **Connection Lifecycle**:
```typescript
// On app start
- Load all accounts from localStorage
- For each RemoteSignerAccount:
- Initialize Nip46Signer
- Connect to specified relays
- Subscribe to signer response events
- Update connection status
// On account activation
- Ensure signer is connected
- If disconnected, attempt reconnection
// On account removal
- Disconnect signer
- Clean up relay subscriptions
```
3. **Integration Point**:
- Extend `src/services/accounts.ts` to handle NIP-46 lifecycle
- Add connection status monitoring
- Emit connection events via observables
### Login Flow Design
#### Command: `/login` (or `login` in palette)
**Syntax**: `login [identifier]`
**Identifier Formats**:
- `npub1...` → Read-only account
- `hex pubkey` → Read-only account
- `user@domain.com` (NIP-05) → Resolve to pubkey, create read-only
- `nprofile1...` → Read-only with relay hints
- `bunker://pubkey?relay=...` → NIP-46 remote signer
- `nostrconnect://...` → NIP-46 remote signer
- (no argument) → Open login dialog with method selection
**Examples**:
```bash
login npub1abc... # Read-only from npub
login alice@nostr.com # Read-only from NIP-05
login bunker://abc...?relay=wss:// # NIP-46 remote signer
login # Open dialog
```
#### Login Dialog UI
When `login` is called with no arguments, show a dialog:
```
┌─ Add Account ──────────────────────────────┐
│ │
│ Choose login method: │
│ │
│ [📱 Browser Extension (NIP-07)] │
│ [👁️ Read-Only (View Mode)] │
│ [🔗 Remote Signer (NIP-46)] │
│ [🤖 Android App (NIP-55)] (coming soon) │
│ │
│ ───────────────────────────────────────── │
│ │
│ Or paste any Nostr identifier: │
│ ┌─────────────────────────────────────────┐│
│ │ npub, hex, nip-05, bunker://... ││
│ └─────────────────────────────────────────┘│
│ │
│ [Cancel] [Add Account] │
└─────────────────────────────────────────────┘
```
**Method-Specific Flows**:
1. **Browser Extension**:
- Check `window.nostr` availability
- Request public key
- Create ExtensionAccount
- Add to AccountManager
2. **Read-Only**:
- Show input for npub/hex/nip-05/nprofile
- Validate and parse input
- For NIP-05: Resolve to pubkey
- Create ReadOnlyAccount
- Add to AccountManager
3. **Remote Signer (NIP-46)**:
- Show input for bunker URL or manual config
- Parse bunker URL or collect: pubkey, relays, secret
- Create Nip46Signer
- Attempt connection
- Show connection status
- On success: Create RemoteSignerAccount
- Add to AccountManager
4. **Android Signer (NIP-55)** (future):
- Show QR code or deep link
- Wait for Android app response
- Create AndroidSignerAccount
### Account Management Features
#### Command: `/accounts` (or `accounts` in palette)
Opens an account management window showing:
- List of all accounts with type badges
- Active account indicator
- Quick actions: Switch, Remove, Set Label
- Connection status for NIP-46 accounts
#### User Menu Enhancements
**Current Active Account Section**:
```
┌─ User Menu ────────────────────────┐
│ ● Alice (alice@nostr.com) │ ← Active account with indicator
│ via Browser Extension │ ← Account type
│ │
│ ───────────────────────────────── │
│ Accounts (3): │
│ ● Alice (Browser Extension) │ ← Active
│ Bob (Read-Only) │
│ Carol (Remote Signer) 🟢 │ ← Connection status
│ │
│ ───────────────────────────────── │
│ + Add Account │
│ ⚙ Manage Accounts │
│ │
│ ───────────────────────────────── │
│ Relays (if active account) │
│ ... │
│ │
│ ───────────────────────────────── │
│ Log Out │ ← Removes active account
└─────────────────────────────────────┘
```
**Account Actions**:
- Click account → Switch to that account
- Hover → Show quick actions (Remove, Rename)
- Badge colors:
- 🟢 Green = Connected (NIP-46)
- 🔴 Red = Disconnected (NIP-46)
- 🔵 Blue = Extension available
- ⚪ Gray = Read-only
### State Management Changes
#### Extend `GrimoireState` type:
```typescript
type GrimoireState = {
// ... existing fields
activeAccount?: {
pubkey: string;
relays: RelayInfo[];
accountType: 'readonly' | 'extension' | 'remote' | 'android'; // NEW
label?: string; // NEW - user-defined label
}
}
```
#### Sync with AccountManager:
The `useAccountSync` hook should be enhanced to:
1. Subscribe to `accounts.active$` observable
2. When active account changes:
- Get account type from account metadata
- Update `state.activeAccount`
- Load relays from account or relay list cache
3. When active account removed:
- Clear `state.activeAccount`
- Clear active windows if needed
### Services Architecture
#### Enhanced `src/services/accounts.ts`:
```typescript
import { AccountManager } from "applesauce-accounts";
import { registerCommonAccountTypes } from "applesauce-accounts/accounts";
import { ReadOnlyAccount, RemoteSignerAccount } from "@/lib/account-types"; // NEW
import pool from "@/services/relay-pool";
const accountManager = new AccountManager();
// Register all account types
registerCommonAccountTypes(accountManager);
accountManager.registerAccountType("readonly", ReadOnlyAccount); // NEW
accountManager.registerAccountType("remote", RemoteSignerAccount); // NEW
// ... existing localStorage sync code
// NEW: Initialize NIP-46 connections
function initializeRemoteSigners() {
accountManager.accounts$.subscribe((accounts) => {
accounts.forEach((account) => {
if (account.metadata?.type === 'remote') {
const signer = account.signer as Nip46Signer;
if (!signer.isConnected()) {
signer.connect().catch(console.error);
}
}
});
});
}
initializeRemoteSigners();
export default accountManager;
```
#### New file: `src/lib/account-types.ts`:
```typescript
import type { Account } from "applesauce-accounts";
import { Nip46Signer } from "applesauce-signers";
import { nip19 } from "nostr-tools";
import { resolveNip05 } from "@/lib/nip05";
import pool from "@/services/relay-pool";
/**
* Read-only account - no signing capability
*/
export class ReadOnlyAccount implements Account {
id: string;
pubkey: string;
signer = null;
metadata: {
type: 'readonly';
source: 'npub' | 'nip05' | 'hex' | 'nprofile';
originalInput: string;
relays?: string[]; // from nprofile
};
constructor(pubkey: string, source: string, metadata: any) {
this.id = `readonly:${pubkey}`;
this.pubkey = pubkey;
this.metadata = { type: 'readonly', ...metadata };
}
toJSON() {
return {
id: this.id,
pubkey: this.pubkey,
metadata: this.metadata,
};
}
static fromJSON(data: any): ReadOnlyAccount {
return new ReadOnlyAccount(data.pubkey, data.metadata.source, data.metadata);
}
// Factory methods
static async fromNpub(npub: string): Promise<ReadOnlyAccount> {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') throw new Error('Invalid npub');
return new ReadOnlyAccount(decoded.data, 'npub', { originalInput: npub });
}
static async fromNip05(nip05: string): Promise<ReadOnlyAccount> {
const pubkey = await resolveNip05(nip05);
if (!pubkey) throw new Error('NIP-05 resolution failed');
return new ReadOnlyAccount(pubkey, 'nip05', { originalInput: nip05 });
}
static async fromNprofile(nprofile: string): Promise<ReadOnlyAccount> {
const decoded = nip19.decode(nprofile);
if (decoded.type !== 'nprofile') throw new Error('Invalid nprofile');
return new ReadOnlyAccount(decoded.data.pubkey, 'nprofile', {
originalInput: nprofile,
relays: decoded.data.relays,
});
}
static fromHex(hex: string): ReadOnlyAccount {
if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error('Invalid hex pubkey');
return new ReadOnlyAccount(hex, 'hex', { originalInput: hex });
}
}
/**
* Remote signer account (NIP-46)
*/
export class RemoteSignerAccount implements Account {
id: string;
pubkey: string;
signer: Nip46Signer;
metadata: {
type: 'remote';
relays: string[];
remotePubkey: string;
connectionStatus: 'connected' | 'disconnected' | 'connecting';
};
constructor(pubkey: string, signer: Nip46Signer, relays: string[], remotePubkey: string) {
this.id = `remote:${pubkey}`;
this.pubkey = pubkey;
this.signer = signer;
this.metadata = {
type: 'remote',
relays,
remotePubkey,
connectionStatus: 'disconnected',
};
// Monitor connection status
this.signer.on('connected', () => {
this.metadata.connectionStatus = 'connected';
});
this.signer.on('disconnected', () => {
this.metadata.connectionStatus = 'disconnected';
});
}
toJSON() {
return {
id: this.id,
pubkey: this.pubkey,
metadata: this.metadata,
};
}
static fromJSON(data: any): RemoteSignerAccount {
// Reconstruct signer from saved metadata
const signer = new Nip46Signer({
remotePubkey: data.metadata.remotePubkey,
relays: data.metadata.relays,
pool, // Use singleton relay pool
});
return new RemoteSignerAccount(
data.pubkey,
signer,
data.metadata.relays,
data.metadata.remotePubkey
);
}
// Factory method from bunker URL
static async fromBunkerUrl(bunkerUrl: string): Promise<RemoteSignerAccount> {
const parsed = parseBunkerUrl(bunkerUrl); // Parse bunker:// URL
const signer = new Nip46Signer({
remotePubkey: parsed.pubkey,
relays: parsed.relays,
secret: parsed.secret,
pool, // Use singleton relay pool
});
// Connect and get pubkey
await signer.connect();
const pubkey = await signer.getPublicKey();
return new RemoteSignerAccount(pubkey, signer, parsed.relays, parsed.pubkey);
}
async disconnect() {
await this.signer.disconnect();
}
}
// Helper to parse bunker URLs
function parseBunkerUrl(url: string) {
// bunker://pubkey?relay=wss://...&relay=wss://...&secret=...
const parsed = new URL(url);
return {
pubkey: parsed.pathname.replace('//', ''),
relays: parsed.searchParams.getAll('relay'),
secret: parsed.searchParams.get('secret'),
};
}
```
#### New file: `src/lib/login-parser.ts`:
Parser for the `/login` command to detect input type and create appropriate account.
```typescript
import { nip19 } from "nostr-tools";
import { isNip05 } from "@/lib/nip05";
import { ReadOnlyAccount, RemoteSignerAccount } from "@/lib/account-types";
export type LoginInputType =
| 'npub'
| 'nprofile'
| 'nip05'
| 'hex'
| 'bunker'
| 'extension'
| 'unknown';
/**
* Detect the type of login input
*/
export function detectLoginInputType(input: string): LoginInputType {
if (!input || input.trim() === '') return 'extension'; // Default to extension
const trimmed = input.trim();
// NIP-19 encoded
if (trimmed.startsWith('npub1')) return 'npub';
if (trimmed.startsWith('nprofile1')) return 'nprofile';
// Bunker URL
if (trimmed.startsWith('bunker://')) return 'bunker';
if (trimmed.startsWith('nostrconnect://')) return 'bunker';
// NIP-05
if (isNip05(trimmed)) return 'nip05';
// Hex pubkey (64 char hex string)
if (/^[0-9a-f]{64}$/i.test(trimmed)) return 'hex';
return 'unknown';
}
/**
* Create an account from login input
*/
export async function createAccountFromInput(input: string) {
const type = detectLoginInputType(input);
switch (type) {
case 'npub':
return await ReadOnlyAccount.fromNpub(input);
case 'nprofile':
return await ReadOnlyAccount.fromNprofile(input);
case 'nip05':
return await ReadOnlyAccount.fromNip05(input);
case 'hex':
return ReadOnlyAccount.fromHex(input);
case 'bunker':
return await RemoteSignerAccount.fromBunkerUrl(input);
case 'extension':
// Handle in UI - requires window.nostr
throw new Error('Extension login requires UI interaction');
default:
throw new Error(`Unknown input format: ${input}`);
}
}
```
### Commands
#### 1. `/login [identifier]`
**Location**: `src/types/man.ts` + `src/lib/login-parser.ts`
**Parser**:
```typescript
export async function parseLoginCommand(args: string[]) {
const input = args.join(' ').trim();
if (!input) {
// No args - open login dialog
return { action: 'open-dialog' };
}
// Try to create account from input
try {
const account = await createAccountFromInput(input);
return { action: 'add-account', account };
} catch (error) {
return { action: 'error', message: error.message };
}
}
```
**App ID**: `login-dialog` (shows login method selection)
#### 2. `/accounts`
**Location**: `src/types/man.ts`
Opens account management window showing all accounts with actions.
**App ID**: `account-manager`
#### 3. `/logout`
**Location**: `src/types/man.ts`
Removes the active account (or all accounts if flag provided).
```typescript
export function parseLogoutCommand(args: string[]) {
const all = args.includes('--all');
return { all };
}
```
### UI Components
#### 1. Login Dialog (`src/components/LoginDialog.tsx`)
- Method selection buttons
- Smart input field (auto-detects format)
- Extension availability indicator
- Connection status for NIP-46
- Error handling and validation feedback
#### 2. Account Manager Window (`src/components/AccountManager.tsx`)
- List of all accounts with type badges
- Active account highlight
- Switch account button
- Remove account button (with confirmation)
- Edit label button
- Connection status for NIP-46
- Add account button (opens login dialog)
#### 3. Enhanced User Menu (`src/components/nostr/user-menu.tsx`)
**Changes**:
- Show account type badge next to avatar
- Dropdown shows all accounts (not just active)
- Click account to switch
- "Add Account" option
- "Manage Accounts" option
- Connection status indicators
```typescript
export default function UserMenu() {
const accounts = useObservableMemo(() => accountManager.accounts$, []);
const activeAccount = useObservableMemo(() => accountManager.active$, []);
const [showLoginDialog, setShowLoginDialog] = useState(false);
async function switchAccount(account: Account) {
accountManager.setActive(account);
}
async function addAccount() {
setShowLoginDialog(true);
}
// ... render with all accounts
}
```
## Implementation Steps
### Phase 1: Read-Only Accounts (Priority 1)
**Goal**: Users can add read-only accounts to browse Nostr without signing.
1. ✅ Create `ReadOnlyAccount` class in `src/lib/account-types.ts`
2. ✅ Create `src/lib/login-parser.ts` with input detection
3. ✅ Add login command to `src/types/man.ts`
4. ✅ Create `LoginDialog` component
5. ✅ Enhance user menu to show all accounts
6. ✅ Add account switching logic
7. ✅ Test with npub, hex, nip-05, nprofile
**Deliverable**: Users can `login npub1...` to add read-only accounts and switch between them.
### Phase 2: Account Management UI (Priority 2)
**Goal**: Users can manage multiple accounts via UI.
1. ✅ Create `AccountManager` window component
2. ✅ Add `/accounts` command
3. ✅ Add account labels/metadata support
4. ✅ Add remove account functionality
5. ✅ Enhance user menu dropdown
6. ✅ Add account type badges and icons
7. ✅ Polish UX and animations
**Deliverable**: Full account management interface with switching and removal.
### Phase 3: NIP-46 Remote Signer (Priority 3)
**Goal**: Users can connect remote signers for secure key management.
1. ✅ Create `RemoteSignerAccount` class
2. ✅ Integrate `Nip46Signer` with relay pool
3. ✅ Add connection lifecycle management
4. ✅ Add bunker URL parsing
5. ✅ Add connection status indicators
6. ✅ Test with bunker URLs
7. ✅ Add reconnection logic
8. ✅ Handle connection errors gracefully
**Deliverable**: Users can `login bunker://...` to add remote signers.
### Phase 4: NIP-55 Android Signer (Priority 4 - Future)
**Goal**: Android app signing support.
1. Research NIP-55 implementation patterns
2. Create `AndroidSignerAccount` class
3. Add Android intent handling
4. Add QR code / deep link support
5. Test with Android signer apps
**Deliverable**: Users can sign via Android apps.
## Testing Checklist
### Unit Tests
- [ ] `login-parser.ts` - All input format detection
- [ ] `ReadOnlyAccount` - Factory methods
- [ ] `RemoteSignerAccount` - Connection lifecycle
- [ ] Account type serialization/deserialization
### Integration Tests
- [ ] Add read-only account → Switch → Works
- [ ] Add extension account → Switch → Signs events
- [ ] Add NIP-46 account → Connect → Signs events
- [ ] Remove account → State cleanup
- [ ] Multiple accounts → Persistence across reload
### E2E Scenarios
1. **Read-Only Flow**:
- Login with npub → View profile → Cannot sign → Works
2. **Multi-Account Extension**:
- Login with extension → Add another npub → Switch between → Active account updates
3. **NIP-46 Connection**:
- Login with bunker URL → Connect → Sign event → Disconnect → Reconnect → Works
4. **Account Management**:
- Add 3 accounts → Label them → Remove one → Switch active → Persist → Reload → Still works
## Migration Notes
### Existing Users
Users who already have an `ExtensionAccount` from the current implementation:
1. **No Breaking Changes**: Existing accounts will continue to work
2. **Automatic Migration**: Accounts will be loaded from localStorage
3. **Enhanced Features**: Existing accounts gain new features (labels, management UI)
### LocalStorage Keys
- `nostr-accounts` - All accounts (unchanged)
- `active-account` - Active account ID (unchanged)
## Security Considerations
1. **Read-Only Accounts**:
- ✅ No private key stored
- ✅ Cannot sign events
- ✅ Safe for public viewing
2. **Extension Accounts**:
- ✅ Keys managed by extension
- ✅ User approves each signature
- ⚠️ Trust extension security
3. **NIP-46 Accounts**:
- ✅ Private keys never touch browser
- ✅ Remote signer controls security
- ⚠️ Relay communication must be encrypted
- ⚠️ Verify bunker URL authenticity
- ⚠️ Connection secrets must be secured
4. **Android Signer**:
- ✅ Keys stay on mobile device
- ✅ User approves each signature
- ⚠️ Intent system security
### Best Practices
- Never log or expose connection secrets
- Validate all input formats before processing
- Show clear connection status for NIP-46
- Warn users about bunker URL authenticity
- Encrypt NIP-46 relay communication (enforced by NIP-46 spec)
## Open Questions
1. **Account Labels**: Auto-generate from NIP-05/profile or require user input?
- **Decision**: Auto-generate, allow user to edit
2. **Default Account**: When adding first account, auto-activate?
- **Decision**: Yes, auto-activate first account
3. **Extension Detection**: Prompt to install extension if not found?
- **Decision**: Show helpful message with extension links
4. **NIP-46 Relays**: Allow user to configure or use defaults from bunker URL?
- **Decision**: Use bunker URL relays, allow manual override in settings
5. **Account Icons**: Use profile pictures or type icons?
- **Decision**: Profile pictures with type badge overlay
6. **Signing Errors**: How to handle when read-only account tries to sign?
- **Decision**: Show clear error with upgrade prompt to add signing account
## Success Criteria
1. ✅ Users can add accounts via `/login [identifier]`
2. ✅ Users can switch between accounts seamlessly
3. ✅ Users can manage accounts (add, remove, label)
4. ✅ Read-only accounts work for viewing
5. ✅ NIP-46 accounts connect and sign
6. ✅ Account state persists across reloads
7. ✅ Connection status visible for NIP-46
8. ✅ All NIPs properly implemented (05, 07, 46, 55)
## Future Enhancements
1. **Account Groups**: Organize accounts by category
2. **Quick Switch**: Keyboard shortcut to switch accounts
3. **Account Sync**: Sync accounts across devices (encrypted)
4. **Multi-Sig**: Support for multi-signature accounts
5. **Hardware Wallets**: Support for hardware signer devices
6. **Account Delegation**: NIP-26 delegation support
7. **Session Management**: Temporary sessions without persistence
8. **Account Import/Export**: Backup and restore accounts
## References
- **NIP-05**: Mapping Nostr keys to DNS-based internet identifiers
- **NIP-07**: window.nostr capability for web browsers
- **NIP-46**: Nostr Connect (remote signing)
- **NIP-55**: Android Signer Application
- **applesauce-accounts**: Account management library
- **applesauce-signers**: Signer abstraction library