Privacy-first, E2E encrypted bookmark manager with hybrid P2P + Nostr sync.
- Privacy by default: All data encrypted with AES-256-GCM before leaving device
- Local-first: Full offline support, UI must stay fast with 1k+ bookmarks
- Zero-trust: Neither signaling servers nor Nostr relays see plaintext data
- Hybrid sync: WebRTC for real-time P2P, Nostr relays for async/offline sync
Device A Device B
│ │
└──► WebRTC (real-time, <1s) ◄────────┘
│ │
└──► Nostr Relays (async, encrypted) ◄┘
- WebRTC: Sub-second sync when both devices online (y-webrtc provider)
- Nostr: Encrypted events (kind 30053) stored on relays for offline sync
- Both use the same Yjs document - changes merge via CRDT
| Concept | What It Is | Critical Notes |
|---|---|---|
| LEK | Ledger Encryption Key (AES-256-GCM) | Shared across all paired devices. NEVER log, expose, or transmit raw. |
| Yjs Password | Derived from LEK via HKDF | Used for WebRTC room encryption. Never use raw LEK directly. |
| Nostr Keypair | Secp256k1 keypair derived from LEK | Deterministic - same LEK = same keypair on all devices. |
| Device Keypair | ECDH P-256, non-extractable | For pairing handshake only. Stored in IndexedDB via WebCrypto. |
- Events use kind 30053 (parameterized replaceable)
- Content is always encrypted before publishing
- Keypair is derived from LEK via
deriveNostrKeypair()- NOT random - Sync uses 1.5s debounce to batch rapid changes
- See
docs/nostr-sync-architecture.mdfor full details
- Never expose raw LEK in logs, network, or localStorage
- Never use raw LEK as WebRTC password (derive via HKDF)
- Never publish unencrypted content to Nostr relays
- Never skip verification word confirmation during pairing
- Never make device keypairs extractable (
extractable: false)
- QR contains: sessionId, ephemeral pubkey, signalingUrl, expires
- Devices perform ECDH to derive shared secret
- Verification words shown on both devices (MITM protection)
- User MUST confirm words match before LEK transfer
- Session expires after 5 minutes
-
React, not Preact - Package.json shows react/react-dom. The doc diagrams may say Preact but code uses React.
-
LEK extractability - LEK must be
extractable: trueduring pairing transfer, but ephemeral/device keys should be non-extractable. -
Nostr keypair is deterministic - Same LEK always produces same Nostr keypair. This is intentional for cross-device identity.
-
WebRTC password derivation - Always use
deriveYjsPassword(lek), never pass LEK directly to y-webrtc. -
Yjs singleton - Use
getYdocInstance()from useYjs hook. Never create new Y.Doc instances. -
UndoManager origin - Local bookmark ops must use
LOCAL_ORIGINconstant for proper undo tracking. -
Nostr event validation - Events have strict validation (MAX_CONTENT_SIZE=100KB, timestamp bounds). See
VALIDATION_ERRORSin nostr-sync.js. -
iOS Safari - ~50MB IndexedDB limit. Monitor with
navigator.storage.estimate(). -
Tailwind v4 - Uses CSS-based config (
@config), nottailwind.config.js.
| Field | Strategy |
|---|---|
tags |
Y.Array - add-wins set semantics |
url |
Immutable after create |
title, description, readLater |
Last-write-wins |
createdAt |
Immutable |
updatedAt |
Auto-updated on change |
docs/nostr-sync-architecture.md- Explains hybrid sync systemsrc/services/crypto.js- All crypto primitivessrc/services/nostr-crypto.js- Nostr-specific crypto (keypair derivation)src/services/nostr-sync.js- NostrSyncService classsrc/hooks/useYjs.js- Yjs document + providers setupsrc/services/bookmarks.js- Bookmark CRUDdocs/security.md- Security architecture
npm test # Run all tests
npm run test:security # Security-critical tests only
npm run test:coverage # With coverage reportKey test files:
src/services/nostr-sync.test.js- Sync logicsrc/services/crypto.test.js- Crypto primitivessrc/services/security-audit.test.js- Security invariants
After implementing any feature or fix, you MUST run npm run test:coverage and verify all coverage thresholds pass before committing. If coverage drops below thresholds, add tests for your new code until thresholds are met. IF you can't write meaningful tests to increase coverage, stop and ask me what to do.
Issue tracking via beads_viewer. Issues in .beads/.
bd ready # Find unblocked work
bd show <id> # Issue details
bd update <id> --status=in_progress
bd close <id> # Mark complete
bd sync # Commit beads changesPriority: P0=critical, P1=high, P2=medium, P3=low, P4=backlog
Work is NOT complete until git push succeeds.
# Before ending session:
npm test # If code changed
bd sync # Commit beads
git add . && git commit -m "..."
git pull --rebase && git pushNever stop before pushing. Never say "ready to push when you are" - YOU must push.