Nostr_Keynctr/README.md
Avi a1445d17a8 Add contact-aware feed scope
- src/feed.rs: contact_feed + contact_pubkeys fetch the active profile's
  kind 3 contact list and aggregate notes only from those authors;
  aggregate_for + FeedBuilder accept an optional author whitelist
- IPC: FeedGet accepts contacts_only, resolves the active profile npub
- CLI: feed --contacts [limit] filters to the active profile's contacts
- Frontend: Feed screen Everyone/My contacts toggle, scope-aware empty
  states, feedGet(limit, contactsOnly) threading
- Tests for author filtering and the contacts scope in feed.rs and
  FeedScreen.test.tsx
2026-08-06 11:01:27 -05:00

15 KiB

Nostr Feed Manager

A friendly Linux desktop app for managing Nostr profiles, publishing notes, and acting as a NIP-46 remote signer — all while your private keys never leave your machine.

Version License: MIT Platform Status Rust TypeScript

Hosting note: this project is hosted on Forgejo. Throughout this document, replace [YOUR_FORGEJO_INSTANCE_URL] with your instance's base URL (e.g. https://codeberg.org) and <OWNER>/<REPO> with the actual repository path.


📖 Introduction

Nostr Feed Manager pairs a hardened Rust core (the same engine behind the original CLI) with a polished Electron + React desktop interface. All Nostr work — key generation, event signing, relay communication, encryption — happens inside the Rust backend. The GUI talks to it over a secure JSON-lines IPC channel and never sees your secret keys.

It also turns your machine into a NIP-46 remote signer ("bunker"): instead of pasting your nsec into other Nostr apps, they send signing requests here, and you approve each one with a single click. This is central to keeping your keys out of third-party apps while staying interoperable across the Nostr ecosystem.

Status: early beta (v0.1.0). Actively developed and usable daily, but not yet packaged for distribution repositories. Expect some API churn until 1.0. No Docker or database required.

Features

  • Profile management — create and quickly switch between Nostr profiles (npub identities)
  • Compose screen — write with live character count and instant Write / Preview tabs
    • Image attachments — pick a local image, upload to nostr.build, and publish with NIP-92 imeta (plus legacy image) tags so clients render it
    • Link preview cards — up to three URLs per note get a title/image/description preview
  • Publishing receipts — per-relay publish reports, so you always know where a note landed
  • Relay management — add, remove, enable/disable, and latency-test relays from GUI or CLI
    • Feed aggregation — a read-only, newest-first feed of recent text notes aggregated from your enabled relays (24h window), de-duplicated across relays, from a dedicated GUI screen or the CLI. Switch the feed to My contacts to show only notes from the active profile's kind 3 contact list
  • Encrypted vault — secret keys encrypted at rest with AES-256-GCM under a key derived via Argon2id from your password
  • Secret key recovery — reveal a key (hex + nsec1...) only after unlocking, from GUI or CLI
  • NIP-46 remote signer — sign for other Nostr apps; every sign/decrypt request needs your explicit Approve/Reject
  • Settings — light / dark / system theme, publish confirmation, key shortening, vault backup
  • Two interfaces, one core — the same Rust crate powers a full CLI and the GUI's IPC server

🏗️ Architecture

The design keeps a clean, security-critical boundary: the renderer never sees secret keys.

flowchart LR
    subgraph UI[Electron + React]
        R[Renderer<br/>React 18 / TS]
        M[Main process<br/>spawns backend, dialogs, HTTP]
    end
    subgraph Core[Rust backend]
        IPC[JSON-lines IPC]
        S[Signer · NIP-46]
        V[Vault · AES-256-GCM]
        REL[Relay client · nostr-sdk]
    end
    subgraph Net[Network]
        RELAYS[(Relays)]
        HU[nostr.build]
    end

    R <-->|window.backend bridge| M
    M <-->|"stdin/stdout  1 JSON per line"| IPC
    IPC --> S
    IPC --> V
    IPC --> REL
    REL --> V
    S <--> RELAYS
    REL --> HU

Or, as a plain step-by-step flow:

  1. Client (UI) — the React renderer requests an action (e.g. publish_note) through the window.backend bridge.
  2. Electron main — forwards the request to the Rust backend over stdio as one JSON object per line.
  3. Backend — the serve IPC loop dispatches to the right module (profiles, publish, relays, settings, signer).
  4. Relays — the backend publishes signed events to the configured relays and returns per-relay receipts.

Tech stack

Backend (Rust) — Cargo · rustfmt · Clippy

Tool Purpose
nostr-sdk 0.40 (NIP-44, NIP-46, NIP-98) Nostr protocol, relay client, signing, remote signer
tokio async runtime
argon2 + aes-gcm vault key derivation and at-rest encryption
serde / serde_json IPC and storage encoding
rpassword interactive password prompts

Frontend (TypeScript) — npm

Tool Purpose
Electron 33 desktop shell, native dialogs, IPC host
React 18 + TypeScript renderer
Vite 5 build tool + dev server
Vitest + Testing Library renderer tests against a fake backend
ESLint + Prettier linting and formatting
electron-builder Linux packaging

🚀 Installation & setup

Prerequisites

  • Linux with a display server (X11 or Wayland)
  • Rust (stable) and Cargo
  • Node.js 20+ and npm 10+

No Docker, database, or account setup is required.

1. Clone and build

git clone [YOUR_FORGEJO_INSTANCE_URL]/<OWNER>/<REPO>.git
cd nost-feed-manager

# Build the Rust backend (release)
cargo build --release

# Install frontend dependencies
cd frontend
npm install

2. Run

# Renders from the built bundle via app://
cd frontend
npm start

On first launch the app detects a vault left behind by the original Python CLI (if you have one), backs it up to a timestamped *.backup-<ts> file, and imports your profiles — leaving the original untouched.

3. Configure environment

The app reads its configuration from environment variables. It does not load a .env file directly, but you can export these from your shell or source an .env before launching:

Variable Purpose Default
NFM_PASSWORD Vault password for non-interactive CLI use (create / publish / show-secret) prompt
NOSTR_GUI_DEV_URL Renderer dev-server URL for hot reload (development only)
XDG_DATA_HOME Override the data directory ~/.local/share/nost-feed-manager

.env.example — copy to .env and adjust:

# .env — sourced by your shell, not read directly by the app

# Vault password for non-interactive CLI use (create / publish / show-secret)
NFM_PASSWORD=

# Renderer dev-server URL for hot reload (development only)
NOSTR_GUI_DEV_URL=http://localhost:5173

# Override the data directory (defaults to ~/.local/share/nost-feed-manager)
XDG_DATA_HOME=

⚠️ Keep .env out of version control. It is not gitignored by default — add it to .gitignore if you create one in the repo root, and never commit real passwords.

4. Package (optional)

cd frontend
npm run dist    # builds renderer + backend and runs electron-builder

The unpacked app lands in frontend/release/linux-unpacked/; run it with ./nost-feed-manager.

🧑‍💻 Usage guide

Desktop app

Tabs cover Compose, Home, Profiles, Relays, Settings, and Signer. Connect a relay by entering its wss:// URL, then compose a note, attach an image, and publish to see a per-relay report.

Command-line interface

The Rust crate builds a single binary that acts as both the GUI's backend and a full CLI:

cargo run --release -- create "Alice"            # create a profile
cargo run --release -- list                      # list profiles (no secret keys)
cargo run --release -- switch <npub>             # select the active profile
cargo run --release -- publish <npub> "Hello"    # publish a text note
cargo run --release -- feed [--contacts] [limit]   # fetch recent notes; --contacts = your contacts
cargo run --release -- relays list|add|remove|enable|disable|test
cargo run --release -- settings get|set <key> <value>
cargo run --release -- set-password              # encrypt the vault (or change its password)
cargo run --release -- remove-password           # remove vault encryption
cargo run --release -- unlock                    # verify the vault password for this process
cargo run --release -- show-secret <npub>        # reveal a profile's secret key
cargo run --release -- signer status             # show the signer profile + relays
cargo run --release -- signer connect <nostrconnect://...>
cargo run --release -- info                      # show storage locations and version
cargo run --release -- serve                     # JSON-lines IPC server (used by the GUI)

Passwords are read from NFM_PASSWORD when set, otherwise you are prompted. They are never accepted as command-line arguments — create, publish, unlock, and show-secret all unlock the vault automatically when it is encrypted.

CLI + signer caveat: the CLI's blocking signer connect loop cannot answer approval prompts — run the GUI to approve NIP-46 requests.

Remote signer (GUI)

The Signer screen sets up the NIP-46 remote signer with the active profile. Paste a nostrconnect:// link from another Nostr app. When that app asks to sign an event or decrypt/encrypt a message, a Requests waiting for approval panel appears with an Approve/Reject button. Nothing is signed until you confirm it.

IPC protocol (for tooling)

The serve command speaks one JSON object per line — the same protocol Electron adopts:

{"id": 1, "method": "publish_note", "params": {"npub": "...", "content": "Hello world"}}
{"id": 1, "status": "ok", "data": {"npub": "...", "report": {"event_id": "...", "succeeded": ["wss://relay.damus.io"]}}}

🔐 Security notes

Your keys are the crown jewels in any Nostr app, and nothing here compromises them:

  • Secret keys stay in Rust. The renderer never receives secret key material; it only sees profile summaries and publish reports.
  • Encrypted at rest. The vault (profiles_vault.json) is plaintext until you set a password (set-password, or Settings → Storage). Once set, every secret key is encrypted with AES-256-GCM under a key derived from your password with Argon2id. Labels and public keys remain readable so you can browse profiles while the vault is locked.
  • In-memory key only. You unlock once per session; the derived key lives only in memory and is never written to disk.
  • Approve-before-any-signing. The NIP-46 remote signer will not sign, encrypt, or decrypt until you explicitly approve each request.
  • Least-privileged storage. Files are written with directories 0700 and files 0600. NIP-98 auth events authorize image uploads to nostr.build without exposing sk signs.

Defence in depth. Anyone with access to your user account can still read vault files. The password is an additional layer, not a replacement for securing your account, and the underlying OS should be considered your last line of defense.

🛠️ Development

Run with hot reload

# terminal 1 — Vite dev server
cd frontend
npm run dev

# terminal 2 — Electron pointed at the dev server
NOSTR_GUI_DEV_URL=http://localhost:5173 npm start

Verify (all green — cargo 75, npm 71, clippy/fmt/typecheck/lint/format clean)

# Rust
cargo test              # 75 unit tests
cargo fmt --check       # rustfmt
cargo clippy --all-targets

# Frontend
cd frontend
npm run typecheck       # TypeScript (renderer + electron)
npm run lint            # ESLint
npm run format:check    # Prettier
npm test                # Vitest (jsdom) — 71 tests across 13 files

The test suites exercise the real IPC protocol against a fake backend, so features are tested at the protocol boundary rather than in isolation.

Project layout

src/                 Rust library + CLI + IPC server
  app.rs             app state, vault password/unlock lifecycle
  crypto.rs          Argon2id derivation + AES-256-GCM encryption
  errors.rs          structured AppError
  ipc.rs             JSON-lines serve() loop and request/reply envelope
  main.rs            CLI entry point
  profiles.rs        profile create/list/select, secret recovery
  publish.rs         note publishing with receipts, images and imeta tags
  relays.rs          default relays, validation, connection tests
  settings.rs        theme and preferences
  signer.rs          NIP-46 remote signer + approval gate
  uploads.rs         NIP-98 auth for image-host uploads
  vault.rs           vault storage (plaintext or password-encrypted)
frontend/
  electron/          Electron main + preload (backend spawn, dialogs, clipboard)
  src/
    screens/         Compose, Home, Profiles, Relays, Settings, Signer
    lib/             types, api bridge, media helpers
    state/           React context (AppProvider)
    test/            Vitest suite over a fake backend
  package.json       scripts + electron-builder config

🤝 Contributing

Contributions are welcome — bug reports, documentation, and pull requests all help. Please be respectful and constructive; everyone is expected to follow the Code of Conduct.

Reporting bugs

Open an issue on the issue tracker and include:

  • A clear title and description of expected vs. actual behavior
  • Steps to reproduce
  • Platform details (distro, Wayland/X11, version from info)
  • Relevant log output

Opening a pull request

  1. Fork the repository from the Fork button on Forgejo.
  2. Branch from master with a descriptive name (fix/relay-timeout, feat/avatar-support).
  3. Make your change. Match the surrounding style and keep the diff focused.
  4. Run the full verification suite (see Verify) — everything must pass.
  5. Write tests for new behavior (test through the IPC surface wherever possible).
  6. Update docs for user-visible changes.
  7. Commit with a concise imperative subject line, push your branch, and open a pull request against master.
  8. In the PR description, summarize the change, link issues, and list what you tested.

Code style

  • Rust: cargo fmt; cargo clippy --all-targets clean; public items get doc comments
  • TypeScript/React: Prettier + ESLint clean; strict tsconfig
  • General: no secrets in code, commits, or logs; no generated files checked in

📄 License

MIT — see the LICENSE file. Copyright (c) 2026 Avi.