diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 6597edd..81923f2 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,62 +1,516 @@ -# Checkpoint — Profile metadata publishing + profile pictures (2026-08-22) +# Checkpoint — nostr stack 0.45 security migration (2026-08-25) -**App renamed to Keynectr (2026-08-23).** A stopping point you can return to if this session is closed. Everything below was +A stopping point you can return to if this session is closed. Everything below was verified green at the moment this file was written. ## Where things are -- Project: `/home/avi/Projects/Nostr_Keynctr` (renamed from `0_Nostr` after commit `1116dfd`) -- Git repo: `master` @ `a6329d5` ("Publish profile metadata (name + picture) so external - clients show it"). Before it: `db81f8d` (profile deletion with undo), `9a8f334` - (audit checkpoint refresh), and the 2026-08-21 audit-fix commits (`f7db29e`, `d90b6e5`, - `130d7e2`). -- Working tree is **clean** apart from this checkpoint update, which is committed right after. +- Project: `/home/avi/Projects/Nostr_Keynctr` +- Git repo: `master` @ `fa5ba08` ("Security: upgrade nostr stack 0.40->0.45 + clearing 11 RustSec advisories"). Before it: `bf10ae3` (Checkpoint: clarify + NIP-42 status), `e210f17` (Checkpoint: DRY modal save lifecycle / R3), + `7098e75` (refactor: share modal save lifecycle), `e6a1efc` (R4), + `6d5063d` (R2), `1c428a9` (R1). +- Working tree: only the long-standing user changes remain — `concept3.svg` + deleted, `KeynectrAppIconPossibility02.jpeg` untracked. ## What was completed -**Problem:** profiles created in the app never published a Nostr kind 0 metadata event, -so other clients showed generated petnames ("evil iguana", "homeless leech") or a -truncated npub instead of the user's chosen name. +1. **Security migration of the Nostr stack (`fa5ba08`).** `cargo audit` was + installed on this machine (`cargo install cargo-audit`, v0.22.2) and its + first scan found **11 RustSec vulnerabilities** in nostr 0.40 / + relay-pool 0.40.1 — including forged-event signature-bypass (0224), + NIP-46 credential Debug leak (0225), and auth-challenge memory + exhaustion (0231). Upgraded to `nostr 0.45.3` + `nostr-sdk 0.45.2` + (secp256k1 0.30), clearing every vulnerability and all 4 warnings. + Code adaptations: `Client::builder().authenticator(SignerAuthenticator)` + replaces implicit signer (NIP-42 auto-auth stays working); signing moved + from `EventBuilder::sign` to `finalize_async`; nip44 encrypt now takes an + explicit random nonce (getrandom); feed + signer receive loops moved to + `stream_events` (signer's notification stream still opens BEFORE the + announce, preserving the ordering fix); contact p-tags parsed via + `single_letter_tag()`; `relay()` returns Option; `try_connect().timeout()`. + Dropped unused `nip46` feature (signer is hand-rolled). Dry-run first: + bump-only build proved zero dependency-crate failures / no MSRV issues; + all 24 errors were exactly the planned API edits. +2. **New regression tests (+2, suite now 115).** Malformed NIP-44 payloads + (not-base64, empty, truncated, bad version byte) reject cleanly with no + panic; error strings never contain secret-key material (0225 class). +3. Earlier today: R3 modal-save dedup (`7098e75`) completing the DRY audit, + NIP-42 clarification docs (`bf10ae3`). -1. **Automatic metadata on creation** — `create_profile` now publishes a kind 0 event - with the label as `name`/`display_name` to all enabled relays (best-effort; relay - failures never block creation). -2. **"Publish name" action for existing profiles** — new button on every Profiles-screen - card plus CLI `publish-name `. Returns a per-relay report shown in the UI. -3. **Profile pictures end-to-end** — - - Vault: optional `picture: Option` per profile (serde default → old vaults - load unchanged). - - Backend: `set_profile_picture` validates http(s) URLs only, stores the URL, and - publishes kind 0 including `picture`; clearing supported (`None`). - - GUI: "Picture" button opens a modal — paste a URL, upload a file via the existing - nostr.build pipeline, or remove; avatar shows the picture everywhere in-app. - - CLI: `set-picture `. -4. **Nested-runtime safety** — metadata publishing runs on a dedicated OS thread with its - own tokio runtime, so both sync (CLI) and async (IPC server) callers are safe. -5. **Finished prior session's delete/undo work** — exposed `undo_history` in - `AppStateView`, fixed invalid Button variants / missing icon / null-safety errors so - the frontend typechecks again. -6. **Test hygiene fix** — `Settings::default()` points at real relays and tests were - silently publishing events to them (one got rate-limited by damus.io). All test suites - now use offline settings; test time dropped from ~126 s to ~3 s. +## Commits added most recently -## Commits added in this session +- `fa5ba08` Security: upgrade nostr stack 0.40->0.45 clearing 11 RustSec advisories -- `7c6a085` Rename the app to Keynectr -- `ae5ddda` Allow new profile methods through the Electron IPC allowlist -- `a6329d5` Publish profile metadata (name + picture) so external clients show it +## Verification commands run (all green) -**Gotcha for future work:** any new backend request type must also be added to -`RENDERER_METHODS` in `frontend/electron/main.ts`, or the main process rejects it with -"rejected renderer method" before it reaches the Rust backend. +Rust (repo root): cargo fmt --check clean; cargo clippy --all-targets only +the two pre-existing profiles.rs warnings; cargo test 115 passed (release +mode re-run too); cargo build --release success. +Frontend (`frontend/`, untouched): typecheck clean; format:check clean; +npm test 15 files / 99 tests; vite build success; electron:build success. +Live: probe built on 0.45 stack published kind-1 notes accepted by +wss://soloco.nl and wss://relay.primal.net within the 6 s watchdog. +Pre-commit audit per user protocol: staged diff = Cargo.toml/Cargo.lock/src +only; git diff --check clean. + +## How to use / reproduce + +No user-facing change. Rebuild + restart to pick up the new backend: + +```bash +cd ~/Projects/Nostr_Keynctr && cargo build --release +cd frontend && npm run build && npm start +``` + +Re-check Updates card: Rust advisory section should now list no +vulnerabilities (cargo-audit is installed machine-wide). + +## Notes & next steps + +- wss://nostr.l484.com had a DNS-resolution blip at the very end of the + session (gaierror for ~a minute); it worked earlier today including a + stored test event. If it stays down, check the relay host — not app code. +- Probe harnesses live in /tmp/opencode (nip42-probe on 0.45; python raw + readers). Copy into scripts/ if they should survive reboots. +- Pre-existing clippy warnings in src/profiles.rs remain untouched by request. +- Relay health today: nos.lol 502 intermittent, nostr.wine 403, + l484.com transient DNS failure at session end, nostr.band unreachable; + primal/mom/soloco healthy. + +--- + +# Older checkpoint — DRY modal save lifecycle / R3 complete (2026-08-25) + +A stopping point you can return to if this session is closed. Everything below was +verified green at the moment this file was written. + +## Where things are + +- Project: `/home/avi/Projects/Nostr_Keynctr` +- Git repo: `master` @ `7098e75` ("refactor: share modal save lifecycle"). + Before it: `e6a1efc` (refactor: share hex-id shortening in lib/format), + `ec237bf` (Checkpoint: relay pool bootstrap), `6d5063d` + (refactor: share relay pool bootstrap), `1c428a9` (DRY fan-out R1), + `b21678f` (signer-fix checkpoint), `64ad94d` (NIP-46 handshake fix). +- Working tree after this checkpoint commit: only the long-standing user + changes remain — `concept3.svg` deleted and + `KeynectrAppIconPossibility02.jpeg` untracked, exactly as the user had them. + +## What was completed + +1. **R3 modal save-lifecycle dedup (`7098e75`).** The three profile modals in + `frontend/src/screens/ProfilesScreen.tsx` (Picture, Rename, NIP-05) each + carried an identical ~20-line async save lifecycle. Now a single + module-local `runModalSave` helper owns it: clear error → set saving → + claim publishing slot (`onSavingChange(npub)`) → run API → build success + message from the publish report → report via `onSaved`, or on failure + surface the error via `onError` → always release saving state and the slot + in `finally`. Each modal keeps only what is genuinely its own: the API call, + its success-message logic inline (including NIP-05's set/removed branch), + and RenameModal's `canSave` guard outside the helper. No user-facing change: + callback order, messages, buttons, props, and tests untouched. + With this, all four DRY-audit items (R1–R4) are complete. +2. Earlier: R4 hex-id shortening (`e6a1efc`), R2 relay-pool bootstrap + (`6d5063d`), R1 fan-out dedup (`1c428a9`) — details in the older checkpoints + below. + +## Commits added most recently + +- `7098e75` refactor: share modal save lifecycle + +## Verification commands run (all green) + +Rust (repo root): cargo test 113 passed; cargo clippy --all-targets finished +(pre-existing profiles.rs warnings only); cargo fmt --check clean; +cargo build --release success. +Frontend (`frontend/`): npm test 15 files / 99 tests passed; typecheck clean; +lint clean; format:check clean; npm run build success; electron:build success. +Pre-commit audit per user protocol: staged diff contained only +ProfilesScreen.tsx; git diff --check clean; nothing else staged. + +## How to use / reproduce + +No user-facing change. Profiles → Picture / Edit name / NIP-05 modals behave +exactly as before; any future change to the shared save policy happens in one +place: `runModalSave` in `frontend/src/screens/ProfilesScreen.tsx` +(just above `Nip05Modal`). + +## Notes & next steps + +- **NIP-42 status clarified (2026-08-25 addendum).** Earlier notes saying + "l484.com needs NIP-42 auth" described *raw probes* that don't answer + challenges — not a Keynectr gap. Verified: nostr-sdk 0.40 attaches the + signer in `Client::new` and enables NIP-42 auto-authentication by default + (`nip42_auto_authentication: true`), so AUTH challenges on read AND write + are answered automatically on every Keynectr path. Live test against + `wss://nostr.l484.com` replicating Keynectr's exact client setup: event + accepted in 0.3 s (inside the 6 s `RELAY_SEND_TIMEOUT`) and retrievable + afterwards. Known edge, left as-is by choice: the SDK's worst-case auth + dance (~7 s wait + resend) can exceed the 6 s watchdog on a very slow + authed relay → "did not respond in time"; healthy relays finish in <1 s. + Probe harness: `/tmp/opencode/nip42-probe` (throwaway key, same crates). +- DRY audit items R1–R4 are all complete; nothing left on hold from that list. +- Pre-existing clippy warnings in src/profiles.rs remain untouched by request. +- Relay health today: nos.lol 502 (intermittent), nostr.wine 403, + l484.com sends NIP-42 AUTH challenges but works fine with Keynectr + (auto-answered — see addendum above), nostr.band unreachable; + primal/mom/soloco healthy. +- Client visibility rule of thumb confirmed today: a note appears only on + clients whose read relays overlap the relays it was published to; if a + note "is missing" somewhere, first compare relay lists (Primal indexes + broadly; Iris/Yakihonne read narrower sets). + +--- + +# Older checkpoint — DRY hex-id shortening (2026-08-24) + +A stopping point you can return to if this session is closed. Everything below was +verified green at the moment this file was written. + +## Where things are + +- Project: `/home/avi/Projects/Nostr_Keynctr` +- Git repo: `master` @ `e6a1efc` ("refactor: share hex-id shortening in lib/format"). + Before it: `ec237bf` (Checkpoint: relay pool bootstrap), `6d5063d` + (refactor: share relay pool bootstrap), `1c428a9` (DRY fan-out R1), + `b21678f` (signer-fix checkpoint), `64ad94d` (NIP-46 handshake fix), + `dd50b24` (Aurora rename). +- Working tree: clean apart from this checkpoint update. `concept3.svg` deleted + and `KeynectrAppIconPossibility02.jpeg` untracked remain as the user had them. + +## What was completed + +1. **R4 hex-id shortening (`e6a1efc`).** Exported `shortHexId` from + `frontend/src/lib/format.ts` with verbatim logic from the former local + `shortHex` in `SignerScreen.tsx`; SignerScreen now imports and calls + `shortHexId`; added `format.test.ts` with 7 focused tests (empty, len 16, + len 17, 64-hex, shortenNpub disabled, enabled truncation). Output behavior + is byte-identical: >16 uses first 8 chars + Unicode ellipsis U+2026 + last 8, + ≤16 passes through unchanged. Does not reuse `shortenNpub`; preserves + existing visual output exactly per audit constraint. +2. **R2 DRY refactor (`6d5063d`).** New `relays::open_pool(keys, urls, + wait: Option) -> Result` is the single pool-construction + path (add each relay with strict error propagation, connect, optional + wait). Adopted by feed's two fetchers (`Keys::generate()` + 10 s wait) and + note publishing (`keys.clone()`, no wait). `profiles.rs` intentionally NOT + adopted — its unique ignore-add-errors policy stays local; it regained an + explicit `connect()` when `send_to_all_relays` dropped its internal one + (avoids double-connect on the note path). `signer.rs` deliberately + untouched: ordering-critical notification setup + fail-to-status error model. + Net −20 lines; behavior, error strings, timing unchanged. +3. Earlier this session: R1 fan-out dedup (`1c428a9`), NIP-46 handshake fix + (`64ad94d`), Aurora rename + dropdown fix (`dd50b24`), frosted-glass look + (`ecb666b`), glass build fix (`bfe14f0`). + +## Commits added most recently + +- `e6a1efc` refactor: share hex-id shortening in lib/format +- `6d5063d` refactor: share relay pool bootstrap + +## Verification commands run (all green) + +Rust: cargo test 113 passed; clippy only pre-existing profiles.rs warnings; +fmt clean; release binary rebuilt. +Frontend (untouched): npm test 99; typecheck, lint, format, electron:build, +vite build all clean. +Pre-commit audit per user protocol: git diff --check clean; signer.rs empty +diff; 3 test files added (format.test.ts with 7 new tests). + +## How to use / reproduce + +No user-facing change. Future connection-policy work (e.g., NIP-42 auth for +nostr.l484.com, retries) now starts in `relays::open_pool`. + +## Notes & next steps + +- Remaining DRY items by user request on hold: R3 (modal save-lifecycle hook). +- R4 (shortHex vs shortenNpub) is complete: shared `shortHexId` in lib/format, + output preserved exactly, tests green. +- Relay health today: nos.lol 502, nostr.wine 403, l484.com needs NIP-42 auth, + nostr.band unreachable; primal/mom/soloco healthy. +--- + +# Older checkpoint — DRY relay fan-out (2026-08-24) + +A stopping point you can return to if this session is closed. Everything below was +verified green at the moment this file was written. + +## Where things are + +- Project: `/home/avi/Projects/Nostr_Keynctr` +- Git repo: `master` @ `1c428a9` ("DRY: single parallel relay fan-out for notes + and metadata"). Before it: `64ad94d` (NIP-46 handshake fix), `b21678f`/ + `ae84526` (checkpoints), `dd50b24` (Aurora rename). +- Working tree: clean apart from this checkpoint update. `concept3.svg` deleted + and `KeynectrAppIconPossibility02.jpeg` untracked remain as the user had them. + +## What was completed + +1. **R1 DRY refactor (`1c428a9`).** The parallel relay send block (~55 lines: + JoinSet per-relay sends, 6 s timeout, indexed outcome collection, + disconnect, succeeded/failed split) existed twice — in + `publish.rs::publish_with_keys` and `profiles.rs::publish_metadata_async`. + Now one `pub(crate) publish::send_to_all_relays(client, urls, event, noun)` + serves both; callers keep their distinct add-relay policies (note path + aborts on add errors, metadata ignores them) so behavior, error strings, + and public APIs are unchanged. Removed duplicate constant + `METADATA_SEND_TIMEOUT`, dead helper `failure_for`. Net −49 lines. +2. Deliberately NOT done (pending approval): R2 shared client-bootstrap + helper, R3 modal save-lifecycle hook (frontend), R4 shortHex/shortenNpub. + +## Commits added most recently + +- `1c428a9` DRY: single parallel relay fan-out for notes and metadata + +## Verification commands run (all green) + +Rust: cargo fmt --check clean; clippy only pre-existing profiles.rs warnings; +cargo test 113 passed; release binary rebuilt. +Frontend (untouched, suite rerun): npm test 92 passed; typecheck, lint, +format:check, electron:build, vite build all clean. + +## How to use / reproduce + +No user-facing change; note and metadata publishing behave exactly as before. +Any future change to relay-send policy now happens in one place: +`send_to_all_relays` in `src/publish.rs`. + +## Notes & next steps + +- R2–R4 from the DRY audit remain unimplemented by request. +- Relay health today: nos.lol 502, nostr.wine 403, nostr.l484.com needs + NIP-42 auth, nostr.band unreachable; primal/mom/soloco healthy. + +--- + +# Older checkpoint — NIP-46 signer handshake fix (2026-08-24) + +A stopping point you can return to if this session is closed. Everything below was +verified green at the moment this file was written. + +## Where things are + +- Project: `/home/avi/Projects/Nostr_Keynctr` +- Git repo: `master` @ `64ad94d` ("Signer: listen before announcing so the + handshake reply is not lost"). Before it: `dd50b24` (Aurora rename + + dropdown fix), `ecb666b` (frosted-glass look), `bfe14f0` (glass build fix). +- Working tree: clean apart from this checkpoint update. `concept3.svg` deleted + and `KeynectrAppIconPossibility02.jpeg` untracked remain as the user had them. + +## What was completed + +1. **Root-caused the "Yakihonne never leaves the QR screen" bug (`64ad94d`).** + Reproduced with a reference NIP-46 client harness over live relays + (`/tmp/opencode/nc-client`): the app's announcement arrived, the client's + `ack` + `get_public_key` were answered to nobody — the signer task created + its notification receiver *after* publishing the announce, so the client's + millisecond-fast handshake reply landed in the broadcast channel before any + receiver existed and was dropped. Client waits forever → stuck QR. + Fix: open `client.notifications()` immediately after adding relays, before + connect/subscribe/announce. Post-fix round-trip PASSES end-to-end + (announce → ack → get_public_key → pubkey returned). +2. Relay observations from testing: nos.lol 502 today; nostr.wine 403 + (auth/paid); nostr.l484.com sends NIP-42 AUTH challenges; healthy: + relay.primal.net, nostr.mom, soloco.nl. + +## Commits added most recently + +- `64ad94d` Signer: listen before announcing so the handshake reply is not lost + +## Verification commands run (all green) + +Rust (repo root): cargo test 113 passed; clippy (pre-existing profiles.rs +warnings only); fmt clean; release binary rebuilt with the fix. +Frontend (`frontend/`): npm test 92 passed; typecheck, lint, format clean. +End-to-end: reference NIP-46 client + `keynectr signer connect ` over +wss://nostr.mom, relay.primal.net, soloco.nl — full handshake PASS. + +## How to use / reproduce + +GUI: `cd ~/Projects/Nostr_Keynctr/frontend && npm start` → Signer → paste a +client's nostrconnect:// link → approve requests. With Yakihonne: choose its +remote-signer/connect option, copy its link into Keynectr within its timeout; +Yakihonne should now switch from the QR screen to the logged-in state. + +Harness (if ever needed again): `/tmp/opencode/nc-client` — `nc-client` +prints a nostrconnect URI to /tmp/opencode/uri.txt and PASSes on round-trip; +run `target/release/keynectr signer connect $(cat /tmp/opencode/uri.txt)`. + +## Notes & next steps + +- If a client is still slow, remaining edge: relays that reject kind 24133 or + need auth can starve the handshake — consider surfacing per-relay connect + status in the Signer UI later. +- Pre-existing clippy warnings in src/profiles.rs untouched by request. + +--- + +# Older checkpoint — Aurora rename + dropdown fix (2026-08-24) + +A stopping point you can return to if this session is closed. Everything below was +verified green at the moment this file was written. + +## Where things are + +- Project: `/home/avi/Projects/Nostr_Keynctr` +- Git repo: `master` @ `dd50b24` ("Rename glass theme display to Aurora; fix + unreadable select options"). Before it: `ecb666b` (frosted-glass look), + `0cab883`/`1aae81c` (checkpoints), `bfe14f0` (glass fix). +- Working tree: clean apart from this checkpoint update. `concept3.svg` deleted + and `KeynectrAppIconPossibility02.jpeg` untracked remain as the user had them. + +## What was completed + +1. **Theme renamed to Aurora (`dd50b24`).** User picked "Aurora" from options + (Aurora/Frost/Glacier/Ice/keep). Display label only — internal id stays + `glass`, so existing settings.json values need no migration. +2. **Fixed unreadable theme dropdown (`dd50b24`).** Under the glass theme the + native ` setPickedProfile(event.target.value)} + > + {profiles.map((profile) => ( + + ))} + + )} + + + )} + + + + + + ); +} + +function RenameModal({ + target, + onClose, + onSaved, + onError, + onSavingChange, +}: { + target: { npub: string; label: string }; + onClose: () => void; + onSaved: (message: string) => void; + onError: (message: string | null) => void; + onSavingChange: (npub: string | null) => void; +}) { + const { renameProfile } = useApp(); + const [label, setLabel] = useState(target.label); + const [saving, setSaving] = useState(false); + + const trimmed = label.trim(); + const canSave = trimmed.length > 0 && trimmed !== target.label; + + const save = async () => { + if (!canSave) { + return; + } + await runModalSave({ + npub: target.npub, + perform: () => renameProfile(target.npub, trimmed), + successMessage: (report) => + report.failed.length === 0 + ? `Renamed to "${trimmed}" and published to ${report.succeeded.length} relay(s). It may take a minute to appear on other clients.` + : `Renamed to "${trimmed}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`, + onSaved, + onError, + onSavingChange, + setSaving, + }); + }; + + return ( + +
+
+ + setLabel(event.target.value)} + placeholder="My Profile" + autoComplete="off" + autoFocus + /> +
+

+ The name is stored on this computer and published to your enabled relays so other Nostr + clients show it. +

+
+ + +
+
+
+ ); +} + function PictureModal({ target, onClose, @@ -261,22 +530,18 @@ function PictureModal({ const [saving, setSaving] = useState(false); const save = async (nextUrl: string | null) => { - onError(null); - setSaving(true); - onSavingChange(target.npub); - try { - const report = await setProfilePicture(target.npub, nextUrl); - onSaved( + await runModalSave({ + npub: target.npub, + perform: () => setProfilePicture(target.npub, nextUrl), + successMessage: (report) => report.failed.length === 0 ? `Picture for "${target.label}" published to ${report.succeeded.length} relay(s).` : `Picture saved for "${target.label}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`, - ); - } catch (err) { - onError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); - onSavingChange(null); - } + onSaved, + onError, + onSavingChange, + setSaving, + }); }; const onUpload = async () => { diff --git a/frontend/src/screens/SettingsScreen.tsx b/frontend/src/screens/SettingsScreen.tsx index 88d013f..0db240f 100644 --- a/frontend/src/screens/SettingsScreen.tsx +++ b/frontend/src/screens/SettingsScreen.tsx @@ -1,17 +1,24 @@ import { useState } from 'react'; import { Alert } from '../components/Alert'; +import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; import { CopyButton } from '../components/CopyButton'; import { Icon } from '../components/Icon'; +import { Spinner } from '../components/Spinner'; import { Toggle } from '../components/Toggle'; import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal'; -import type { Theme } from '../lib/types'; +import type { Theme, UpdateCheckReport } from '../lib/types'; import { useApp } from '../state/AppProvider'; export function SettingsScreen() { - const { state, updateSettings, backupNow } = useApp(); + const { state, updateSettings, backupNow, updateCheck, updateApply } = useApp(); const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null); const [passwordModal, setPasswordModal] = useState(null); + const [updateReport, setUpdateReport] = useState(null); + const [checking, setChecking] = useState(false); + const [installing, setInstalling] = useState(false); + const [updateError, setUpdateError] = useState(null); + const [applyMessage, setApplyMessage] = useState(null); const settings = state?.settings; const vaultPath = state?.vault_path ?? ''; @@ -44,6 +51,38 @@ export function SettingsScreen() { } }; + const onCheckUpdates = async () => { + setChecking(true); + setUpdateError(null); + setApplyMessage(null); + setUpdateReport(null); + try { + setUpdateReport(await updateCheck()); + } catch (err) { + setUpdateError(err instanceof Error ? err.message : String(err)); + } finally { + setChecking(false); + } + }; + + const onInstallUpdates = async () => { + setInstalling(true); + setUpdateError(null); + setApplyMessage(null); + try { + const result = await updateApply(); + const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)]; + if (result.restart_required) { + lines.push('Rebuild and restart the app (cargo build --release, then relaunch) to finish.'); + } + setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']); + } catch (err) { + setUpdateError(err instanceof Error ? err.message : String(err)); + } finally { + setInstalling(false); + } + }; + return (
@@ -65,9 +104,13 @@ export function SettingsScreen() { > - + + -

“System” follows your desktop's light or dark preference.

+

+ “Light” and “Dark” follow your manual selection. “Aurora” and “Neon” are aesthetic + themes with distinctive color palettes. +

@@ -153,6 +196,128 @@ export function SettingsScreen() { +
+
+

Updates

+
+
+

+ Scans the JavaScript packages and Rust crates this app is built on. Known security + advisories are always listed first; installing applies compatible updates only. +

+
+ + +
+ + {updateError && ( + + {updateError} + + )} + + {checking && } + + {applyMessage && ( + +
    + {applyMessage.map((line) => ( +
  • {line}
  • + ))} +
+
+ )} + + {updateReport && !checking && ( + <> + {updateReport.advisories.length > 0 ? ( + +
    + {updateReport.advisories.map((advisory) => ( +
  • + + {advisory.severity} + {' '} + {advisory.package} + {advisory.title ? ` — ${advisory.title}` : ''} + {advisory.fix && {advisory.fix}} +
  • + ))} +
+
+ ) : ( + + )} + + {updateReport.outdated_npm.length > 0 && ( +
+ JavaScript packages +
    + {updateReport.outdated_npm.map((pkg) => ( +
  • + {pkg.name} {pkg.current} →{' '} + {pkg.available} +
  • + ))} +
+
+ )} + + {updateReport.outdated_cargo.length > 0 && ( +
+ Rust crates +
    + {updateReport.outdated_cargo.map((crateName) => ( +
  • + {crateName.name} {crateName.current} →{' '} + {crateName.available} +
  • + ))} +
+
+ )} + + {updateReport.notes.map((note) => ( +

+ {note} +

+ ))} + + {updateReport.advisories.length === 0 && + updateReport.outdated_npm.length === 0 && + updateReport.outdated_cargo.length === 0 && ( + Everything is up to date. + )} + + )} +
+
+

Advanced

diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index 4f548b2..1b3c1ac 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -4,6 +4,7 @@ import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; +import { shortHexId } from '../lib/format'; import type { SignerStatus } from '../lib/types'; import { useApp } from '../state/AppProvider'; @@ -15,11 +16,6 @@ const EMPTY_STATUS: SignerStatus = { pending: [], }; -/** Shorten a 64-char hex key for display. */ -function shortHex(value: string): string { - return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value; -} - export function SignerScreen() { const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp(); const [status, setStatus] = useState(EMPTY_STATUS); @@ -137,7 +133,7 @@ export function SignerScreen() {
{status.peer ? ( - {shortHex(status.peer)} + {shortHexId(status.peer)} ) : ( None yet diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index b248a2d..0970bf4 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -21,6 +21,8 @@ import type { Settings, SignerStatus, Theme, + UpdateApplyReport, + UpdateCheckReport, UploadedImage, } from '../lib/types'; @@ -41,14 +43,18 @@ interface AppContextValue { selectProfile: (npub: string) => Promise; publishProfileMetadata: (npub: string) => Promise; setProfilePicture: (npub: string, url: string | null) => Promise; + renameProfile: (npub: string, label: string) => Promise; + setNip05: (npub: string, nip05: string | null) => Promise; publishNote: (content: string) => Promise; recordPublishFailure: (message: string, details?: string | null) => void; clearLastPublish: () => void; - feedGet: (limit?: number, contactsOnly?: boolean) => Promise; + feedGet: (limit?: number, contactsOnly?: boolean, authorNpub?: string) => Promise; relayAdd: (url: string) => Promise; relayRemove: (url: string) => Promise; relaySetEnabled: (url: string, enabled: boolean) => Promise; relayTest: (url: string) => Promise; + updateCheck: () => Promise; + updateApply: () => Promise; updateSettings: ( patch: Partial>, ) => Promise; @@ -134,6 +140,24 @@ export function AppProvider({ children }: { children: ReactNode }) { [], ); + const renameProfile = useCallback( + async (npub: string, label: string): Promise => { + const result = await api.renameProfile(npub, label); + setState(result.state); + return result.report; + }, + [], + ); + + const setNip05 = useCallback( + async (npub: string, nip05: string | null): Promise => { + const result = await api.setNip05(npub, nip05); + setState(result.state); + return result.report; + }, + [], + ); + const publishNote = useCallback(async (content: string): Promise => { const report = await api.publishNote(content); setLastPublish({ report, error: null, details: null, at: Date.now() }); @@ -148,8 +172,8 @@ export function AppProvider({ children }: { children: ReactNode }) { setLastPublish(null); }, []); - const feedGet = useCallback((limit?: number, contactsOnly?: boolean) => { - return api.feedGet(limit, contactsOnly); + const feedGet = useCallback((limit?: number, contactsOnly?: boolean, authorNpub?: string) => { + return api.feedGet(limit, contactsOnly, authorNpub); }, []); const applySettings = useCallback((fresh: Settings) => { @@ -170,6 +194,8 @@ export function AppProvider({ children }: { children: ReactNode }) { [applySettings], ); const relayTest = useCallback((url: string) => api.relayTest(url), []); + const updateCheck = useCallback(() => api.updateCheck(), []); + const updateApply = useCallback(() => api.updateApply(), []); const updateSettings = useCallback( async (patch: Partial>) => applySettings(await api.settingsUpdate(patch)), @@ -213,7 +239,11 @@ export function AppProvider({ children }: { children: ReactNode }) { const copyText = useCallback((text: string) => api.copyText(text), []); - useThemeSync(state?.settings.theme); + useEffect(() => { + if (state?.settings.theme) { + applyTheme(state.settings.theme); + } + }, [state?.settings.theme]); const value = useMemo( () => ({ @@ -232,6 +262,8 @@ export function AppProvider({ children }: { children: ReactNode }) { relayRemove, relaySetEnabled, relayTest, + updateCheck, + updateApply, updateSettings, backupNow, setVaultPassword, @@ -250,6 +282,8 @@ export function AppProvider({ children }: { children: ReactNode }) { undoDelete, publishProfileMetadata, setProfilePicture, + renameProfile, + setNip05, copyText, }), [ @@ -262,6 +296,8 @@ export function AppProvider({ children }: { children: ReactNode }) { selectProfile, publishProfileMetadata, setProfilePicture, + renameProfile, + setNip05, publishNote, deleteProfile, undoDelete, @@ -272,6 +308,8 @@ export function AppProvider({ children }: { children: ReactNode }) { relayRemove, relaySetEnabled, relayTest, + updateCheck, + updateApply, updateSettings, backupNow, setVaultPassword, @@ -301,12 +339,13 @@ export function useApp(): AppContextValue { return context; } -/** Apply the requested theme (respecting system preference for `system`). */ +/** Apply the requested theme. */ export function applyTheme(theme: Theme): void { - const prefersDark = - theme === 'dark' || - (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); - document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light'; + if (theme === 'glass' || theme === 'neon') { + document.documentElement.dataset.theme = theme; + return; + } + document.documentElement.dataset.theme = theme; } /** Keep the document theme in sync with settings, watching system changes. */ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index beb19db..947ee5e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -58,6 +58,113 @@ --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.6); } +/* "Neon" — pure-black Matrix/Cyberpunk palette inspired by the CAJAFUERTE + Obsidian theme (MIT): black panels, deep-pink primary (#ff1493), purple + secondary (#8b00ff family), yellow highlights (#ffd700). */ +:root[data-theme='neon'] { + --bg: #000000; + --surface: #0b0b0e; + --surface-2: #131318; + --surface-hover: #191921; + --border: #2b1224; + --border-strong: #4d1c3d; + --text: #f5f2f7; + --text-muted: #a89bb0; + --primary: #ff1493; + --primary-hover: #ff4fb0; + --primary-soft: #2b0a1e; + --on-primary: #ffffff; + --danger: #ff3355; + --danger-soft: #330810; + --warning: #ffd700; + --warning-soft: #332b03; + --success: #00e68a; + --success-soft: #04301f; + --info: #b388ff; + --info-soft: #1c1035; + --focus: #ff1493; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.6), 0 8px 24px rgba(255, 20, 147, 0.07); + --shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.85), 0 0 32px rgba(255, 20, 147, 0.12); +} + +/* "Aurora" theme (internal id "glass") — frosted-glass aesthetic inspired + by the Meridian Obsidian theme (MIT): translucent blurred panels floating + over an aurora-lit deep blue-black field, cyan accents, hairline light + borders. */ +:root[data-theme='glass'] { + --bg: #0a0d13; + --surface: rgba(23, 29, 41, 0.58); + --surface-2: rgba(255, 255, 255, 0.05); + --surface-hover: rgba(255, 255, 255, 0.09); + --border: rgba(255, 255, 255, 0.1); + --border-strong: rgba(255, 255, 255, 0.2); + --text: #e8ecf4; + --text-muted: #96a2b6; + --primary: #7fdbff; + --primary-hover: #a3e5ff; + --primary-soft: rgba(127, 219, 255, 0.13); + --on-primary: #06121c; + --danger: #ff8080; + --danger-soft: rgba(255, 107, 107, 0.14); + --warning: #ffc46b; + --warning-soft: rgba(240, 173, 79, 0.15); + --success: #7ed99a; + --success-soft: rgba(92, 184, 92, 0.16); + --info: #82c9ea; + --info-soft: rgba(91, 192, 222, 0.14); + --focus: #7fdbff; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 36px rgba(0, 0, 0, 0.38); + --shadow-modal: 0 18px 56px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.07) inset; + color-scheme: dark; +} +/* Glass scene: the aurora backdrop lives on ; panels above it are + translucent and blurred so the glow reads through them. */ +html[data-theme='glass'] body { + background: + radial-gradient(1000px 640px at 10% -10%, rgba(127, 219, 255, 0.15), transparent 60%), + radial-gradient(880px 560px at 92% 6%, rgba(150, 130, 255, 0.12), transparent 58%), + radial-gradient(760px 720px at 50% 120%, rgba(91, 192, 222, 0.1), transparent 62%), #0a0d13; +} + +html[data-theme='glass'] .app-shell, +html[data-theme='glass'] .main { + background: transparent; +} + +/* Frosted panes: every major surface blurs whatever sits behind it. */ +html[data-theme='glass'] .card, +html[data-theme='glass'] .sidebar, +html[data-theme='glass'] .modal, +html[data-theme='glass'] .empty-state, +html[data-theme='glass'] .profile-card, +html[data-theme='glass'] .compose-preview { + -webkit-backdrop-filter: blur(18px) saturate(160%); + backdrop-filter: blur(18px) saturate(160%); +} + +html[data-theme='glass'] input[type='text'], +html[data-theme='glass'] input[type='search'], +html[data-theme='glass'] input[type='password'], +html[data-theme='glass'] select, +html[data-theme='glass'] textarea { + background: rgba(255, 255, 255, 0.045); + border-color: rgba(255, 255, 255, 0.16); +} + +/* The native select popup inherits the select's near-transparent + background, which renders white with light text — give options an + explicit dark surface so entries stay readable. */ +html[data-theme='glass'] select option { + background-color: #141a26; + color: var(--text); +} + +html[data-theme='glass'] .modal-backdrop { + background: rgba(4, 7, 12, 0.5); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} + * { box-sizing: border-box; } @@ -252,6 +359,25 @@ a { cursor: not-allowed; } +.feed-profile-select { + height: 34px; +} + +.update-list { + margin: 6px 0 0; + padding-left: 18px; + display: grid; + gap: 4px; + font-size: 14px; +} + +.update-summary { + margin: 6px 0 0; + padding-left: 18px; + display: grid; + gap: 4px; +} + /* ------------------------------------------------------------------------- Sidebar ------------------------------------------------------------------------- */ @@ -280,8 +406,23 @@ a { border-radius: 11px; display: grid; place-items: center; - background: var(--primary); - color: var(--on-primary); + background: #fff; + overflow: hidden; +} + +.sidebar-logo img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* The artwork is black-on-white; flip it in dark themes so it stays black + bird on dark tile instead of a glaring white square. */ +html[data-theme='dark'] .sidebar-logo img, +html[data-theme='neon'] .sidebar-logo img, +html[data-theme='glass'] .sidebar-logo img { + filter: invert(1); } .sidebar-brand strong { @@ -1048,6 +1189,14 @@ select { border-color: var(--success); } +.profile-card:not(.is-active) { + cursor: pointer; +} + +.profile-card:not(.is-active):hover { + border-color: var(--primary); +} + .profile-card-top { display: flex; align-items: center; @@ -1080,6 +1229,7 @@ select { .profile-card-actions { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; margin-top: auto; diff --git a/frontend/src/test/FeedScreen.test.tsx b/frontend/src/test/FeedScreen.test.tsx index d0bacc8..bb2a93c 100644 --- a/frontend/src/test/FeedScreen.test.tsx +++ b/frontend/src/test/FeedScreen.test.tsx @@ -24,7 +24,7 @@ describe('FeedScreen', () => { it('disable relays shows an empty state that can navigate to relays', async () => { const settings = { - theme: 'system' as const, + theme: 'light' as const, confirm_before_publish: true, shorten_npub: true, relays: [ @@ -107,4 +107,55 @@ describe('FeedScreen', () => { expect(await screen.findByText('No notes from your contacts')).toBeInTheDocument(); }); + + it('defaults the "My notes" scope to the active profile', async () => { + const backend = createFakeBackend(); + const user = renderFeed(backend); + renderWithApp(); + + await screen.findByText('Hello from the feed.'); + await user.click(screen.getByRole('button', { name: /My notes/i })); + + expect(await screen.findByText('A note from my own profile.')).toBeInTheDocument(); + expect(screen.queryByText('Hello from the feed.')).not.toBeInTheDocument(); + const authorRequest = backend.requests.find( + (r) => r.method === 'feed_get' && r.params.author != null, + ); + expect(authorRequest).toBeDefined(); + expect(authorRequest?.params.author).toBe('npub1aliceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); + + it('switches profiles with the dropdown', async () => { + const backend = createFakeBackend(); + backend.profileFeedItems = []; + const user = renderFeed(backend); + renderWithApp(); + + await screen.findByText('Hello from the feed.'); + await user.click(screen.getByRole('button', { name: /My notes/i })); + await screen.findByText('No notes from this profile'); + + await user.selectOptions( + screen.getByLabelText('Show notes from profile'), + 'npub1bobbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ); + + await waitFor(() => { + const bobRequests = backend.requests.filter( + (r) => + r.method === 'feed_get' && + r.params.author === 'npub1bobbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ); + expect(bobRequests.length).toBeGreaterThanOrEqual(1); + }); + }); + + it('disables the "My notes" scope when there are no profiles', async () => { + const backend = createFakeBackend(makeEmptyState()); + renderFeed(backend); + renderWithApp(); + + const myNotesButton = await screen.findByRole('button', { name: /My notes/i }); + expect(myNotesButton).toBeDisabled(); + }); }); diff --git a/frontend/src/test/ProfilesScreen.test.tsx b/frontend/src/test/ProfilesScreen.test.tsx index 49cf60d..20759a0 100644 --- a/frontend/src/test/ProfilesScreen.test.tsx +++ b/frontend/src/test/ProfilesScreen.test.tsx @@ -37,6 +37,118 @@ describe('ProfilesScreen', () => { }); }); + it('selects a profile when its card is clicked', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Bob'); + await user.click(screen.getByText('Bob')); + + await waitFor(() => { + expect(backend.state.active_profile?.npub).toBe(BOB); + }); + }); + + it('does not select a profile when an action button inside the card is clicked', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Bob'); + const copyButtons = screen.getAllByRole('button', { name: 'Copy public key' }); + await user.click(copyButtons[copyButtons.length - 1]); + + expect(backend.copied).toContain(BOB); + expect(backend.state.active_profile?.npub).toBe(ALICE); + }); + + it('renames a profile from the Edit name modal and publishes it', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Bob'); + await user.click(screen.getAllByRole('button', { name: 'Edit name' })[1]); + + const input = screen.getByLabelText('Profile name'); + expect(input).toHaveValue('Bob'); + await user.clear(input); + await user.type(input, 'Bobby'); + await user.click(screen.getByRole('button', { name: 'Save & publish' })); + + await waitFor(() => { + expect(backend.state.profiles.find((p) => p.npub === BOB)?.label).toBe('Bobby'); + }); + expect(await screen.findByRole('status')).toHaveTextContent(/Renamed to "Bobby"/); + }); + + it('sets a NIP-05 address from the NIP-05 modal and publishes it', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Bob'); + await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]); + + const input = screen.getByLabelText('NIP-05 address'); + expect(input).toHaveValue(''); + await user.type(input, 'Bob@Example.com'); + await user.click(screen.getByRole('button', { name: 'Save & publish' })); + + await waitFor(() => { + expect(backend.state.profiles.find((p) => p.npub === BOB)?.nip05).toBe('bob@example.com'); + }); + expect(await screen.findByRole('status')).toHaveTextContent(/NIP-05 "bob@example.com"/); + expect(await screen.findByText('bob@example.com')).toBeInTheDocument(); + }); + + it('rejects a malformed NIP-05 address without calling the backend', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Bob'); + await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]); + + const input = screen.getByLabelText('NIP-05 address'); + await user.type(input, 'not-an-address'); + expect(screen.getByRole('button', { name: 'Save & publish' })).toBeDisabled(); + + await user.clear(input); + await user.type(input, 'boo@nodot'); + expect(screen.getByRole('button', { name: 'Save & publish' })).toBeDisabled(); + expect(backend.requests.filter((r) => r.method === 'set_nip05')).toHaveLength(0); + }); + + it('removes an existing NIP-05 address', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + backend.setState({ + ...backend.state, + profiles: backend.state.profiles.map((p) => + p.npub === BOB ? { ...p, nip05: 'bob@example.com' } : p, + ), + active_profile: backend.state.active_profile, + }); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('bob@example.com'); + await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]); + await user.click(screen.getByRole('button', { name: 'Remove' })); + + await waitFor(() => { + expect(backend.state.profiles.find((p) => p.npub === BOB)?.nip05).toBeNull(); + }); + expect(await screen.findByRole('status')).toHaveTextContent(/NIP-05 removed/i); + }); + it('disables Select for the active profile and copies public keys', async () => { const backend = createFakeBackend(); installFakeBackend(backend); diff --git a/frontend/src/test/SettingsScreen.test.tsx b/frontend/src/test/SettingsScreen.test.tsx index 928f5aa..68ecbf2 100644 --- a/frontend/src/test/SettingsScreen.test.tsx +++ b/frontend/src/test/SettingsScreen.test.tsx @@ -29,6 +29,19 @@ describe('SettingsScreen', () => { expect(document.documentElement.dataset.theme).toBe('dark'); }); + it('applies the neon theme to the document', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + renderWithApp(); + + const themeSelect = await screen.findByLabelText('Theme'); + fireEvent.change(themeSelect, { target: { value: 'neon' } }); + await waitFor(() => { + expect(backend.state.settings.theme).toBe('neon'); + }); + expect(document.documentElement.dataset.theme).toBe('neon'); + }); + it('toggles publish confirmation and npub shortening', async () => { const backend = createFakeBackend(); installFakeBackend(backend); @@ -64,4 +77,70 @@ describe('SettingsScreen', () => { expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument(); expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument(); }); + + it('checks for updates and lists security advisories first', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await user.click(await screen.findByRole('button', { name: /Check for updates/i })); + + expect(await screen.findByText('2 security issue(s) found')).toBeInTheDocument(); + expect(screen.getByText(/minimist/)).toBeInTheDocument(); + // Advisories needing a major upgrade explain themselves instead of + // silently surviving an install. + expect( + screen.getByText('Needs electron@43.4.1, a major upgrade — not auto-installed.'), + ).toBeInTheDocument(); + expect(screen.getByText(/minimist/)).toBeInTheDocument(); + expect(screen.getByText('JavaScript packages')).toBeInTheDocument(); + expect(screen.getByText('Rust crates')).toBeInTheDocument(); + const checkRequest = backend.requests.find((r) => r.method === 'update_check'); + expect(checkRequest).toBeDefined(); + }); + + it('reports an up-to-date app when the scan finds nothing', async () => { + const backend = createFakeBackend(); + backend.updateReport = { + outdated_npm: [], + advisories: [], + outdated_cargo: [], + notes: [], + }; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await user.click(await screen.findByRole('button', { name: /Check for updates/i })); + + expect(await screen.findByText('Everything is up to date.')).toBeInTheDocument(); + }); + + it('installs updates and asks for a rebuild + restart', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await user.click(await screen.findByRole('button', { name: /Install updates/i })); + + expect(await screen.findByText(/Rebuild and restart the app/)).toBeInTheDocument(); + expect(screen.getByText('JavaScript security fixes applied')).toBeInTheDocument(); + const applyRequest = backend.requests.find((r) => r.method === 'update_apply'); + expect(applyRequest).toBeDefined(); + }); + + it('surfaces update failures as errors', async () => { + const backend = createFakeBackend(); + backend.nextErrors.update_check = { message: 'npm was not found on this computer.' }; + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await user.click(await screen.findByRole('button', { name: /Check for updates/i })); + + expect(await screen.findByText('Update problem')).toBeInTheDocument(); + expect(screen.getByText('npm was not found on this computer.')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/test/apiMock.ts b/frontend/src/test/apiMock.ts index c5de46c..21b89ee 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -25,7 +25,7 @@ export function makeState(overrides?: Partial): AppState { is_active: false, }; const settings: Settings = { - theme: 'system', + theme: 'light', confirm_before_publish: true, shorten_npub: true, relays: [ diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 314c1e4..cd5d0d7 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -7,6 +7,8 @@ import type { RelayTestResult, Settings, SignerStatus, + UpdateApplyReport, + UpdateCheckReport, } from '../lib/types'; import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock'; @@ -43,6 +45,12 @@ export interface FakeBackend { feedItems: FeedItem[]; /** Notes returned by `feed_get` with `contacts_only: true`. */ contactFeedItems: FeedItem[]; + /** Notes returned by `feed_get` with an `author` filter. */ + profileFeedItems: FeedItem[]; + /** Report returned by `update_check`. */ + updateReport: UpdateCheckReport; + /** Result returned by `update_apply`. */ + updateApplyResult: UpdateApplyReport; } export function createFakeBackend(initial?: AppState): FakeBackend { @@ -126,6 +134,41 @@ export function createFakeBackend(initial?: AppState): FakeBackend { relays: ['wss://relay.damus.io'], }, ], + /** Notes returned by `feed_get` with an `author` filter. */ + profileFeedItems: [ + { + id: 'note1dddddddddddddddddddddddddddddddddddddddddddddddd', + author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', + author_npub: ALICE, + content: 'A note from my own profile.', + created_at: 1700000400, + relays: ['wss://relay.damus.io'], + }, + ], + updateReport: { + outdated_npm: [{ name: 'vite', current: '4.0.0', available: '5.1.0' }], + advisories: [ + { + package: 'minimist', + severity: 'high', + title: 'Prototype Pollution', + fix: null, + }, + { + package: 'electron', + severity: 'critical', + title: 'ASAR Integrity Bypass', + fix: 'Needs electron@43.4.1, a major upgrade — not auto-installed.', + }, + ], + outdated_cargo: [{ name: 'serde', current: '1.0.200', available: '1.0.219' }], + notes: [], + }, + updateApplyResult: { + applied: ['JavaScript security fixes applied', 'Rust crates updated in Cargo.lock'], + failed: [], + restart_required: true, + }, }; async function dispatch(method: string, params: Record): Promise { @@ -163,6 +206,58 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return next; } + case 'rename_profile': { + const npub = String(params.npub); + const label = String(params.label ?? '').trim(); + if (!label) { + throw new Error('The profile name cannot be empty.'); + } + if (!state.profiles.some((p) => p.npub === npub)) { + throw new Error('That profile is not stored on this computer.'); + } + const updated: ProfileSummary = { ...state.profiles.find((p) => p.npub === npub)!, label }; + const next: AppState = { + ...state, + profiles: state.profiles.map((p) => (p.npub === npub ? updated : p)), + active_profile: state.active_profile?.npub === npub ? updated : state.active_profile, + }; + backend.setState(next); + return { profile: updated, report: makePublishReport(), state: next }; + } + + case 'set_nip05': { + const npub = String(params.npub); + const raw = params.nip05; + const nip05 = typeof raw === 'string' ? raw.trim().toLowerCase() : null; + if (nip05) { + const at = nip05.indexOf('@'); + const [local, domain] = [nip05.slice(0, at), nip05.slice(at + 1)]; + if (at <= 0 || at === nip05.length - 1 || nip05.includes('@', at + 1)) { + throw new Error('A NIP-05 address must look like name@domain.com.'); + } + if (local !== '_' && !/^[a-z0-9_-]+$/.test(local)) { + throw new Error( + 'The part before @ may only use letters, numbers, dashes and underscores.', + ); + } + if (!domain.includes('.') || domain.split('.').some((part) => !part)) { + throw new Error('The part after @ must be a domain like example.com.'); + } + } + const existing = state.profiles.find((p) => p.npub === npub); + if (!existing) { + throw new Error('That profile is not stored on this computer.'); + } + const updated: ProfileSummary = { ...existing, nip05: nip05 || null }; + const next: AppState = { + ...state, + profiles: state.profiles.map((p) => (p.npub === npub ? updated : p)), + active_profile: state.active_profile?.npub === npub ? updated : state.active_profile, + }; + backend.setState(next); + return { profile: updated, report: makePublishReport(), state: next }; + } + case 'publish_note': { if (publishFailure) { const failure = publishFailure; @@ -182,6 +277,12 @@ export function createFakeBackend(initial?: AppState): FakeBackend { } case 'feed_get': { + const author = typeof params.author === 'string' ? params.author : null; + if (author) { + return backend.profileFeedItems.filter( + (item) => item.author_npub === author || item.author === author, + ); + } const contactsOnly = Boolean(params.contacts_only); if (contactsOnly) { if (!state.active_profile) { @@ -290,6 +391,12 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return result; } + case 'update_check': + return backend.updateReport; + + case 'update_apply': + return backend.updateApplyResult; + case 'settings_update': { const nextSettings: Settings = { ...state.settings, ...params }; backend.setState({ ...state, settings: nextSettings }); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/app.rs b/src/app.rs index d60fc2b..88b569a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -113,7 +113,8 @@ impl App { public_key: restored.npub.clone(), secret_key: "".to_string(), created_at: restored.created_at, - picture: None, + picture: restored.picture.clone(), + nip05: restored.nip05.clone(), }; self.vault.profiles.push(stored); // If no active profile, this restored one becomes active diff --git a/src/feed.rs b/src/feed.rs index 19258f2..2674a62 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -77,6 +77,19 @@ pub async fn contact_feed( aggregate_for(settings, limit, Some(contacts)).await } +/// Only notes authored by one account (npub or hex). +/// +/// Used by the feed screen's "My notes" scope so the user can read just the +/// posts of one of their own profiles. +pub async fn profile_feed( + settings: &Settings, + limit: usize, + author: &str, +) -> Result, AppError> { + let pubkey = owner_pubkey(author)?; + aggregate_for(settings, limit, Some(vec![pubkey])).await +} + /// Fetch the hex public keys followed by an owner profile (kind 3 contact list). async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result, AppError> { let owner = owner_pubkey(owner_hex)?; @@ -85,43 +98,34 @@ async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result = HashSet::new(); - let mut notifications = client.notifications(); let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT; loop { - match tokio::time::timeout_at(deadline, notifications.recv()).await { - Ok(Ok(RelayPoolNotification::Event { event, .. })) => { + match tokio::time::timeout_at(deadline, events.next()).await { + Ok(Some((_, Ok(event)))) => { if event.kind == Kind::ContactList { - for pubkey in event - .tags - .iter() - .filter_map(|tag| match tag.as_standardized() { - Some(TagStandard::PublicKey { public_key, .. }) => Some(*public_key), - _ => None, - }) - { - contacts.insert(pubkey); + for tag in event.tags.iter() { + if tag.single_letter_tag().map(|s| s.as_char()) == Some('p') { + if let Some(content) = tag.content() { + if let Ok(pubkey) = PublicKey::parse(content) { + contacts.insert(pubkey); + } + } + } } } } - Ok(Ok(_)) => continue, - Ok(Err(_)) | Err(_) => break, + Ok(Some((_, Err(_)))) => continue, + Ok(None) | Err(_) => break, } } @@ -145,15 +149,8 @@ async fn aggregate_for( let effective_limit = if limit == 0 { DEFAULT_LIMIT } else { limit }; // A throwaway identity keeps reading the network completely off the user's keys. - let client = Client::new(Keys::generate()); - for url in &relay_urls { - client - .add_relay(url.as_str()) - .await - .map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?; - } - client.connect().await; - client.wait_for_connection(CONNECT_TIMEOUT).await; + let client = + crate::relays::open_pool(Keys::generate(), &relay_urls, Some(CONNECT_TIMEOUT)).await?; let since = Timestamp::now() - LOOKBACK; let filter = Filter::new() @@ -164,25 +161,22 @@ async fn aggregate_for( Some(authors) => filter.authors(authors.iter().copied()), None => filter, }; - client - .subscribe(filter, None) + let mut events = client + .stream_events(filter) .await .map_err(|e| AppError::network(format!("Could not subscribe for the feed: {e}")))?; let mut feed = FeedBuilder::new(effective_limit, authors.as_deref()); - let mut notifications = client.notifications(); let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT; loop { - match tokio::time::timeout_at(deadline, notifications.recv()).await { - Ok(Ok(RelayPoolNotification::Event { - event, relay_url, .. - })) => { + match tokio::time::timeout_at(deadline, events.next()).await { + Ok(Some((relay_url, Ok(event)))) => { if !feed.add(&event, Some(relay_url)) { break; } } - Ok(Ok(_)) => continue, - Ok(Err(_)) | Err(_) => break, + Ok(Some((_, Err(_)))) => continue, + Ok(None) | Err(_) => break, } } @@ -263,7 +257,7 @@ impl FeedItem { author: event.pubkey.to_hex(), author_npub, content: event.content.trim().to_string(), - created_at: event.created_at.as_u64(), + created_at: event.created_at.as_secs(), relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(), }) } @@ -277,7 +271,7 @@ mod tests { let keys = Keys::generate(); EventBuilder::new(Kind::TextNote, content.to_string()) .custom_created_at(Timestamp::from(created_at)) - .sign(&keys) + .finalize_async(&keys) .await .unwrap() } @@ -334,7 +328,7 @@ mod tests { let mut builder = FeedBuilder::new(10, None); let keys = Keys::generate(); let other = EventBuilder::new(Kind::Metadata, "{}") - .sign(&keys) + .finalize_async(&keys) .await .unwrap(); builder.add(&other, None); @@ -364,12 +358,12 @@ mod tests { let from_followed = EventBuilder::new(Kind::TextNote, "from a contact".to_string()) .custom_created_at(Timestamp::from(5)) - .sign(&followed) + .finalize_async(&followed) .await .unwrap(); let from_stranger = EventBuilder::new(Kind::TextNote, "from a stranger".to_string()) .custom_created_at(Timestamp::from(6)) - .sign(&stranger) + .finalize_async(&stranger) .await .unwrap(); @@ -397,6 +391,30 @@ mod tests { assert!(feed.is_empty()); } + #[tokio::test] + async fn profile_feed_with_no_relays_is_empty() { + let settings = Settings { + relays: Vec::new(), + ..Default::default() + }; + let feed = profile_feed(&settings, DEFAULT_LIMIT, "00".repeat(32).as_str()) + .await + .unwrap(); + assert!(feed.is_empty()); + } + + #[tokio::test] + async fn profile_feed_with_invalid_author_errors() { + let settings = Settings { + relays: Vec::new(), + ..Default::default() + }; + let err = profile_feed(&settings, DEFAULT_LIMIT, "not-a-key") + .await + .expect_err("an invalid author must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); + } + #[tokio::test] async fn contact_feed_with_invalid_owner_errors() { let settings = Settings { diff --git a/src/ipc.rs b/src/ipc.rs index e51c80c..e1ec407 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1,8 +1,9 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::json; +use tokio::sync::Mutex; use crate::app::App; use crate::errors::AppError; @@ -12,6 +13,7 @@ use crate::publish; use crate::relays; use crate::settings::Theme; use crate::signer::Signer; +use crate::updates; /// How long to wait for a relay connection test. const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8); @@ -49,6 +51,18 @@ pub enum Request { npub: String, url: Option, }, + /// Change a profile's label and publish it as part of the profile's kind 0 + /// metadata. + RenameProfile { + npub: String, + label: String, + }, + /// Store a NIP-05 identifier (or clear it with `None`) and publish it as + /// part of the profile's kind 0 metadata. + SetNip05 { + npub: String, + nip05: Option, + }, PublishNote { content: String, }, @@ -56,9 +70,13 @@ pub enum Request { FeedGet { /// Optional cap on how many notes to return; leave `None` for the default. limit: Option, - /// When true, only return notes authored by the active profile's - /// contacts. When no active profile is selected the request errors. + /// When true (and no `author` is given), only return notes authored by + /// the active profile's contacts. When no active profile is selected + /// the request errors. contacts_only: Option, + /// When set (npub or hex), only return notes authored by that account. + /// Takes precedence over `contacts_only`. + author: Option, }, RelayAdd { url: String, @@ -73,6 +91,11 @@ pub enum Request { RelayTest { url: String, }, + /// Scan both dependency sets (npm + cargo) for available updates and + /// security advisories, without changing anything. + UpdateCheck, + /// Install compatible dependency updates (security fixes first). + UpdateApply, SettingsGet, SettingsUpdate { theme: Option, @@ -151,20 +174,26 @@ pub struct ReplyEnvelope { /// Run the JSON-lines IPC server on stdin/stdout. /// -/// The Electron main process spawns `keynectr serve` and -/// exchanges one JSON object per line. Requests are processed sequentially so -/// the shared state never sees concurrent mutations. +/// The Electron main process spawns `keynectr serve` and exchanges one JSON +/// object per line. Requests are handled concurrently — replies are +/// id-correlated, so they may arrive out of order — while every handler that +/// touches shared state locks it for the duration, so mutations remain +/// serialized and never interleave. Long network-only requests (relay tests, +/// feed reads) run without holding that lock so they cannot delay interactive +/// ones such as selecting a profile. pub async fn serve() -> Result<(), AppError> { use tokio::io::AsyncBufReadExt; + use tokio::task::JoinSet; // Shared state, so the NIP-46 signer's background task and the request loop // both see the same vault (including its unlock key) without racing writes. let app = Arc::new(Mutex::new(App::load()?)); - let signer = Signer::new(); + let signer = Arc::new(Signer::new()); + let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); let stdin = tokio::io::stdin(); let mut lines = tokio::io::BufReader::new(stdin).lines(); - let mut stdout = tokio::io::stdout(); + let mut tasks = JoinSet::new(); while let Some(line) = lines .next_line() @@ -183,29 +212,40 @@ pub async fn serve() -> Result<(), AppError> { message: "The request could not be understood.".to_string(), details: Some(e.to_string()), }; - write_line(&mut stdout, ReplyEnvelope { id: 0, reply }).await?; + write_line(&stdout, ReplyEnvelope { id: 0, reply }).await?; continue; } }; - let reply = handle(app.clone(), &signer, envelope.request).await; - write_line( - &mut stdout, - ReplyEnvelope { - id: envelope.id, - reply, - }, - ) - .await?; + let task_app = app.clone(); + let task_signer = signer.clone(); + let task_stdout = stdout.clone(); + tasks.spawn(async move { + let reply = handle(task_app, task_signer, envelope.request).await; + let _ = write_line( + &task_stdout, + ReplyEnvelope { + id: envelope.id, + reply, + }, + ) + .await; + }); } + // Finish in-flight requests before returning so the GUI never sees the + // backend disappear mid-request. + while tasks.join_next().await.is_some() {} Ok(()) } -async fn write_line(writer: &mut W, envelope: ReplyEnvelope) -> Result<(), AppError> -where - W: tokio::io::AsyncWriteExt + Unpin, -{ +async fn write_line( + writer: &tokio::sync::Mutex, + envelope: ReplyEnvelope, +) -> Result<(), AppError> { + use tokio::io::AsyncWriteExt; + + let mut writer = writer.lock().await; let mut line = serde_json::to_string(&envelope) .map_err(|e| AppError::json("Could not prepare a response", e))?; line.push('\n'); @@ -222,10 +262,10 @@ where async fn handle( app: Arc>, - signer: &Signer, + signer: Arc, request: Request, ) -> Reply { - let result = run(&app, signer, request).await; + let result = run(&app, &signer, request).await; match result { Ok(value) => Reply::Ok { data: value }, Err(err) => Reply::Error { @@ -249,19 +289,16 @@ fn error_code(err: &AppError) -> String { } /// Signer control commands never touch the vault directly, so they take the -/// shared handle (a clone) rather than locking the state. Everything else -/// locks the vault for the duration of the call, mirroring the old -/// single-threaded model. -#[allow(clippy::await_holding_lock)] +/// shared handle (a clone) rather than locking the state. Read-only network +/// requests (relay tests, feed reads) grab what they need under a short lock +/// and then run without it, so slow relays cannot delay interactive requests. +/// Everything else locks the state for the duration of the call, so mutations +/// remain serialized and never interleave. async fn run( app: &Arc>, signer: &Signer, request: Request, ) -> Result { - // Signer control commands never touch the vault directly, so they take the - // shared handle (a clone) rather than locking the state. Everything else - // locks the vault for the duration of the call, mirroring the old - // single-threaded model. match request { Request::SignerConnect { uri } => { signer.connect(app.clone(), &uri)?; @@ -276,16 +313,71 @@ async fn run( signer.approve(&id, approved)?; Ok(json!(signer.status())) } + Request::RelayTest { url } => { + // Pure network probe against the given URL; no shared state. + let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?; + Ok(json!(result)) + } + Request::UpdateCheck => { + // Long-running package-manager scan; never touches shared state. + let report = updates::check().await?; + Ok(json!(report)) + } + Request::UpdateApply => { + // Installs updates on disk; a rebuild + restart picks them up. + let report = updates::apply().await?; + Ok(json!(report)) + } + Request::FeedGet { + limit, + contacts_only, + author, + } => { + let limit = limit.unwrap_or(feed::DEFAULT_LIMIT); + let contacts_only = contacts_only.unwrap_or(false); + // Resolve the requested author outside any lock: parsing a key is + // pure and must not queue behind vault mutations. + let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()), + None => None, + }; + // Copy the inputs out of shared state under a short lock so the + // multi-second relay fetches below never block a Select or save. + let (settings, owner_hex) = { + let guard = app.lock().await; + let owner_hex = if author_hex.is_some() { + None + } else if contacts_only { + let npub = guard + .vault + .active_profile + .as_deref() + .ok_or_else(AppError::no_active_profile)?; + Some(feed::owner_pubkey(npub)?.to_hex()) + } else { + None + }; + (guard.settings.clone(), owner_hex) + }; + let items = if let Some(hex) = author_hex { + feed::profile_feed(&settings, limit, &hex).await? + } else if let Some(hex) = owner_hex { + feed::contact_feed(&settings, limit, &hex).await? + } else { + feed::aggregate_feed(&settings, limit).await? + }; + Ok(json!(items)) + } other => { - let mut guard = app.lock().expect("app mutex poisoned"); + let mut guard = app.lock().await; run_with_app(&mut guard, other).await } } } /// Requests dispatched to the vault state. The shared mutex guard is held across -/// the awaited operation on purpose: requests remain effectively sequential, and -/// a concurrent `await` never yields back into a state the loop expects to own. +/// the awaited operation on purpose: mutations stay serialized against each +/// other even though requests themselves are handled concurrently. async fn run_with_app(app: &mut App, request: Request) -> Result { match request { Request::Init | Request::GetState => Ok(json!(app.state_view())), @@ -325,6 +417,27 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + let key = app.vault_key().copied(); + let (summary, report) = profiles::rename_profile( + &mut app.vault, + &npub, + label, + key.as_ref(), + &app.settings, + )?; + app.save_vault()?; + Ok(json!({ "profile": summary, "report": report, "state": app.state_view() })) + } + + Request::SetNip05 { npub, nip05 } => { + let key = app.vault_key().copied(); + let (summary, report) = + profiles::set_nip05(&mut app.vault, &npub, nip05, key.as_ref(), &app.settings)?; + app.save_vault()?; + Ok(json!({ "profile": summary, "report": report, "state": app.state_view() })) + } + Request::PublishNote { content } => { let report = publish::publish_active(&app.vault, &app.settings, &content, app.vault_key()) @@ -332,25 +445,6 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { - let limit = limit.unwrap_or(feed::DEFAULT_LIMIT); - let items = if contacts_only.unwrap_or(false) { - let npub = app - .vault - .active_profile - .as_deref() - .ok_or_else(AppError::no_active_profile)?; - let pubkey = feed::owner_pubkey(npub)?; - feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await? - } else { - feed::aggregate_feed(&app.settings, limit).await? - }; - Ok(json!(items)) - } - Request::RelayAdd { url } => { relays::add_relay(&mut app.settings, &url)?; app.save_settings()?; @@ -369,11 +463,6 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { - let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?; - Ok(json!(result)) - } - Request::SettingsGet => Ok(json!(app.settings)), Request::BackupNow => { diff --git a/src/lib.rs b/src/lib.rs index b489c08..7ca39ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod publish; pub mod relays; pub mod settings; pub mod signer; +pub mod updates; pub mod uploads; pub mod vault; diff --git a/src/main.rs b/src/main.rs index 92cf91a..920afe9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use std::process::ExitCode; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use keynectr::app::App; use keynectr::errors::{AppError, ErrorKind}; @@ -21,6 +21,11 @@ Commands: publish Publish a text note as a specific profile publish-name Publish the profile's stored name so other clients show it set-picture Set the profile picture (http(s) URL) and publish it + rename Rename a profile and publish the new name + set-nip05 Set the NIP-05 address (name@domain) and publish it; + pass 'clear' to remove it + nip05-file Print the .well-known/nostr.json document to serve + on your domain for a NIP-05 address feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50); --contacts filters to the active profile's contacts relays list List configured relays @@ -30,7 +35,7 @@ Commands: relays disable Disable a relay relays test Test a relay connection settings get Show application settings - settings set theme + settings set theme settings set confirm settings set shorten delete-profile Delete a profile (moves it to undo stack) @@ -70,6 +75,9 @@ async fn main() -> ExitCode { "publish" => cli_publish(&args).await, "publish-name" => cli_publish_name(&args), "set-picture" => cli_set_picture(&args), + "rename" => cli_rename(&args), + "set-nip05" => cli_set_nip05(&args), + "nip05-file" => cli_nip05_file(&args), "feed" => cli_feed(&args).await, "relays" => cli_relays(&args).await, "settings" => cli_settings(&args), @@ -187,6 +195,97 @@ fn cli_set_picture(args: &[String]) -> Result { )) } +fn cli_rename(args: &[String]) -> Result { + let npub = args + .get(2) + .ok_or_else(|| AppError::config("Usage: keynectr rename "))?; + let label = args[3..].join(" "); + if label.trim().is_empty() { + return Err(AppError::config("Usage: keynectr rename ")); + } + + let mut app = load_app_with_unlock()?; + let key = app.vault_key().copied(); + let (summary, report) = + profiles::rename_profile(&mut app.vault, npub, label, key.as_ref(), &app.settings)?; + app.save_vault()?; + Ok(format!( + "Renamed to \"{}\"; accepted by {} relay(s).", + summary.label, + report.succeeded.len() + )) +} + +/// Print the `.well-known/nostr.json` document that serves a NIP-05 +/// identifier for a stored profile. Read-only: never unlocks the vault. +fn cli_nip05_file(args: &[String]) -> Result { + let npub = args + .get(2) + .ok_or_else(|| AppError::config("Usage: keynectr nip05-file "))?; + let identifier = args + .get(3) + .ok_or_else(|| AppError::config("Usage: keynectr nip05-file "))?; + let name = profiles::validate_nip05(identifier)?; + let local = name.split('@').next().unwrap_or(&name); + + let app = App::load()?; + let stored = app + .vault + .profiles + .iter() + .find(|p| p.public_key == npub.as_str()) + .ok_or_else(|| AppError::profile_not_found(npub))?; + let hex = keynectr::feed::owner_pubkey(&stored.public_key)?.to_hex(); + let relays = relays::enabled_urls(&app.settings); + + let mut names = serde_json::Map::new(); + names.insert( + local.to_string(), + serde_json::Value::String(hex.to_string()), + ); + let mut doc = serde_json::Map::new(); + doc.insert("names".to_string(), serde_json::Value::Object(names)); + if !relays.is_empty() { + let urls: Vec = + relays.into_iter().map(serde_json::Value::String).collect(); + let mut relay_map = serde_json::Map::new(); + relay_map.insert(hex.to_string(), serde_json::Value::Array(urls)); + doc.insert("relays".to_string(), serde_json::Value::Object(relay_map)); + } + let pretty = serde_json::to_string_pretty(&serde_json::Value::Object(doc)) + .map_err(|e| AppError::internal(format!("Could not render the document: {e}")))?; + Ok(format!( + "Serve this as https:///.well-known/nostr.json (Content-Type: application/json):\n\n{pretty}\n" + )) +} + +fn cli_set_nip05(args: &[String]) -> Result { + let npub = args + .get(2) + .ok_or_else(|| AppError::config("Usage: keynectr set-nip05 "))?; + let raw = args + .get(3) + .ok_or_else(|| AppError::config("Usage: keynectr set-nip05 "))?; + let nip05 = if raw.eq_ignore_ascii_case("clear") { + None + } else { + Some(raw.clone()) + }; + + let mut app = load_app_with_unlock()?; + let key = app.vault_key().copied(); + let (summary, report) = + profiles::set_nip05(&mut app.vault, npub, nip05, key.as_ref(), &app.settings)?; + app.save_vault()?; + match summary.nip05 { + Some(id) => Ok(format!( + "NIP-05 set to \"{id}\"; accepted by {} relay(s).", + report.succeeded.len() + )), + None => Ok("NIP-05 cleared.".to_string()), + } +} + fn cli_publish_name(args: &[String]) -> Result { let npub = args .get(2) @@ -343,7 +442,7 @@ fn cli_settings(args: &[String]) -> Result { match key.as_str() { "theme" => { let theme = Theme::parse(value).ok_or_else(|| { - AppError::config("Theme must be one of: light, dark, system") + AppError::config("Theme must be one of: light, dark, glass, neon") })?; app.settings.theme = theme; } @@ -486,7 +585,7 @@ async fn cli_signer(args: &[String]) -> Result { let uri = args .get(3) .ok_or_else(|| AppError::config("Usage: signer connect "))?; - let app = Arc::new(Mutex::new(load_app_with_unlock()?)); + let app = Arc::new(tokio::sync::Mutex::new(load_app_with_unlock()?)); let signer = Signer::new(); signer.connect(app, uri)?; println!("Connecting to the NIP-46 app… (interrupt with Ctrl-C to stop)"); @@ -548,6 +647,7 @@ fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result Result { public_key: restored.npub.clone(), secret_key: "".to_string(), created_at: restored.created_at, - picture: None, + picture: restored.picture, + nip05: restored.nip05, }; app.vault.profiles.push(stored); if app.vault.active_profile.is_none() { diff --git a/src/profiles.rs b/src/profiles.rs index 2973246..b6ba18a 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -1,6 +1,5 @@ use nostr_sdk::prelude::*; use serde::Serialize; -use std::time::Duration; use zeroize::Zeroizing; use crate::crypto::VaultKey; @@ -10,11 +9,6 @@ use crate::relays; use crate::settings::Settings; use crate::vault::{unix_timestamp, StoredProfile, Vault}; -/// How long to wait for relays to accept a connection attempt. -const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// How long to wait for a single relay to accept the metadata event. -const METADATA_SEND_TIMEOUT: Duration = Duration::from_secs(15); - /// A safe view of a profile that contains no secret key material. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct ProfileSummary { @@ -26,6 +20,8 @@ pub struct ProfileSummary { pub is_active: bool, /// Public URL of the profile picture, when one has been set. pub picture: Option, + /// NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. + pub nip05: Option, } /// A secret key revealed after the vault is unlocked, in both the raw hex and @@ -76,6 +72,7 @@ pub fn create_profile( secret_key: stored_secret, created_at, picture: None, + nip05: None, }; let is_active = vault.active_profile.is_none(); @@ -89,7 +86,7 @@ pub fn create_profile( // Best-effort: relay failures here never block profile creation. let relay_urls = relays::enabled_urls(settings); if !relay_urls.is_empty() { - publish_metadata_blocking(&keys, &label, None, relay_urls); + publish_metadata_blocking(&keys, &label, None, None, relay_urls); } Ok(ProfileSummary { @@ -98,6 +95,7 @@ pub fn create_profile( created_at, is_active, picture: None, + nip05: None, }) } @@ -133,6 +131,7 @@ pub fn publish_profile_metadata( &keys, &stored.label, stored.picture.clone(), + stored.nip05.clone(), relay_urls, )) } @@ -160,12 +159,13 @@ pub fn set_profile_picture( let stored = find_profile_mut(vault, npub)?; stored.picture = url; - let (label, npub, created_at, public_key, picture) = ( + let (label, npub, created_at, public_key, picture, nip05) = ( stored.label.clone(), stored.public_key.clone(), stored.created_at, stored.public_key.clone(), stored.picture.clone(), + stored.nip05.clone(), ); drop(stored); let summary = ProfileSummary { @@ -174,6 +174,7 @@ pub fn set_profile_picture( created_at, is_active: vault.active_profile.as_deref() == Some(public_key.as_str()), picture, + nip05, }; let relay_urls = relays::enabled_urls(settings); @@ -190,11 +191,173 @@ pub fn set_profile_picture( } let keys = Keys::new(secret_key); - let report = - publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls); + let report = publish_metadata_blocking( + &keys, + &summary.label, + summary.picture.clone(), + summary.nip05.clone(), + relay_urls, + ); Ok((summary, report)) } +/// Change a profile's label and immediately republish it as the profile's +/// kind 0 metadata so external clients show the new name. +/// +/// Returns the updated summary plus the per-relay publish report. +pub fn rename_profile( + vault: &mut Vault, + npub: &str, + label: String, + key: Option<&VaultKey>, + settings: &Settings, +) -> Result<(ProfileSummary, MetadataPublishReport), AppError> { + let trimmed = label.trim(); + if trimmed.is_empty() { + return Err(AppError::config("The profile name cannot be empty.")); + } + + // Resolve and sign before mutating so a locked vault or bad key changes + // nothing on disk. + let secret_hex = resolve_secret_key(vault, npub, key)?; + let secret_key = parse_secret_key(&secret_hex)?; + + let stored = find_profile_mut(vault, npub)?; + stored.label = trimmed.to_string(); + let (label, created_at, public_key, picture, nip05) = ( + stored.label.clone(), + stored.created_at, + stored.public_key.clone(), + stored.picture.clone(), + stored.nip05.clone(), + ); + let summary = ProfileSummary { + label, + npub: public_key.clone(), + created_at, + is_active: vault.active_profile.as_deref() == Some(public_key.as_str()), + picture, + nip05, + }; + + let relay_urls = relays::enabled_urls(settings); + if relay_urls.is_empty() { + // The vault change stands; publishing can be retried later via the + // explicit "publish name" action once a relay is enabled. + return Ok(( + summary, + MetadataPublishReport { + succeeded: Vec::new(), + failed: Vec::new(), + }, + )); + } + + let keys = Keys::new(secret_key); + let report = publish_metadata_blocking( + &keys, + &summary.label, + summary.picture.clone(), + summary.nip05.clone(), + relay_urls, + ); + Ok((summary, report)) +} + +/// Store a NIP-05 identifier (e.g. `boo@l484.com`) and immediately publish it +/// as part of the profile's kind 0 metadata. +/// +/// Pass `None` to clear the identifier. Returns the updated summary plus the +/// per-relay publish report. +pub fn set_nip05( + vault: &mut Vault, + npub: &str, + nip05: Option, + key: Option<&VaultKey>, + settings: &Settings, +) -> Result<(ProfileSummary, MetadataPublishReport), AppError> { + let normalised = match &nip05 { + Some(value) => Some(validate_nip05(value)?), + None => None, + }; + + // Resolve and sign before mutating so a locked vault or bad key changes + // nothing on disk. + let secret_hex = resolve_secret_key(vault, npub, key)?; + let secret_key = parse_secret_key(&secret_hex)?; + + let stored = find_profile_mut(vault, npub)?; + stored.nip05 = normalised; + let (label, created_at, public_key, picture, nip05) = ( + stored.label.clone(), + stored.created_at, + stored.public_key.clone(), + stored.picture.clone(), + stored.nip05.clone(), + ); + let summary = ProfileSummary { + label, + npub: public_key.clone(), + created_at, + is_active: vault.active_profile.as_deref() == Some(public_key.as_str()), + picture, + nip05, + }; + + let relay_urls = relays::enabled_urls(settings); + if relay_urls.is_empty() { + // The vault change stands; publishing can be retried later via the + // explicit "publish name" action once a relay is enabled. + return Ok(( + summary, + MetadataPublishReport { + succeeded: Vec::new(), + failed: Vec::new(), + }, + )); + } + + let keys = Keys::new(secret_key); + let report = publish_metadata_blocking( + &keys, + &summary.label, + summary.picture.clone(), + summary.nip05.clone(), + relay_urls, + ); + Ok((summary, report)) +} + +/// Validate a NIP-05 identifier (`@`), returning the +/// lower-cased trimmed form. `_@domain` (the bare-domain form) is allowed. +/// Exposed for the CLI's `.well-known/nostr.json` helper. +pub fn validate_nip05(raw: &str) -> Result { + let trimmed = raw.trim().to_lowercase(); + let (local, domain) = trimmed + .split_once('@') + .ok_or_else(|| AppError::config("A NIP-05 address must look like name@domain.com."))?; + if local.is_empty() || domain.is_empty() || domain.contains('@') { + return Err(AppError::config( + "A NIP-05 address must look like name@domain.com.", + )); + } + if local != "_" + && !local + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(AppError::config( + "The part before @ may only use letters, numbers, dashes and underscores.", + )); + } + if domain.split('.').any(|label| label.is_empty()) || !domain.contains('.') { + return Err(AppError::config( + "The part after @ must be a domain like example.com.", + )); + } + Ok(trimmed) +} + /// Validate that a picture URL is a well-formed http(s) URL. fn validate_picture_url(url: &str) -> Result<(), AppError> { let parsed = Url::parse(url) @@ -235,6 +398,7 @@ fn publish_metadata_blocking( keys: &Keys, label: &str, picture: Option, + nip05: Option, relay_urls: Vec, ) -> MetadataPublishReport { let keys = keys.clone(); @@ -242,7 +406,9 @@ fn publish_metadata_blocking( std::thread::spawn(move || { tokio::runtime::Runtime::new() .expect("metadata runtime") - .block_on(publish_metadata_async(&keys, &label, picture, relay_urls)) + .block_on(publish_metadata_async( + &keys, &label, picture, nip05, relay_urls, + )) }) .join() .expect("metadata publish thread panicked") @@ -252,6 +418,7 @@ async fn publish_metadata_async( keys: &Keys, label: &str, picture: Option, + nip05: Option, relay_urls: Vec, ) -> MetadataPublishReport { let mut metadata = Metadata::new().name(label).display_name(label); @@ -260,8 +427,11 @@ async fn publish_metadata_async( metadata = metadata.picture(parsed); } } + if let Some(nip05) = &nip05 { + metadata = metadata.nip05(nip05); + } let event = match EventBuilder::new(Kind::Metadata, metadata.as_json()) - .sign(keys) + .finalize_async(keys) .await { Ok(event) => event, @@ -280,48 +450,22 @@ async fn publish_metadata_async( } }; - let client = Client::new(keys.clone()); + // This path deliberately builds its own client instead of + // `relays::open_pool`: adding a broken relay must not abort the whole + // metadata publish, so add errors are ignored here. + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys.clone())) + .build(); for url in &relay_urls { let _ = client.add_relay(url.as_str()).await; } client.connect().await; - let _ = client.wait_for_connection(METADATA_CONNECT_TIMEOUT).await; - - let mut succeeded = Vec::new(); - let mut failed = Vec::new(); - for url in &relay_urls { - match client.relay(url.as_str()).await { - Ok(relay) => { - match tokio::time::timeout(METADATA_SEND_TIMEOUT, relay.send_event(&event)).await { - Ok(Ok(_)) => succeeded.push(url.clone()), - Ok(Err(err)) => failed.push(failure_for(url, &err)), - Err(_) => failed.push(RelayFailure { - url: url.clone(), - error: "The relay did not respond in time.".to_string(), - details: Some( - "Timed out while waiting for the relay to accept the metadata." - .to_string(), - ), - }), - } - } - Err(err) => failed.push(failure_for(url, &err)), - } - } - client.disconnect().await; + let (succeeded, failed) = + crate::publish::send_to_all_relays(&client, relay_urls, &event, "metadata").await; MetadataPublishReport { succeeded, failed } } -fn failure_for(url: &str, err: &impl std::fmt::Display) -> RelayFailure { - let (error, details) = crate::publish::relay_error_message(err); - RelayFailure { - url: url.to_string(), - error, - details: Some(details), - } -} - /// Safe summaries of every stored profile, newest last. Never includes /// secret keys. pub fn summaries(vault: &Vault) -> Vec { @@ -349,6 +493,7 @@ fn summary_for(vault: &Vault, profile: &StoredProfile) -> ProfileSummary { created_at: profile.created_at, is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()), picture: profile.picture.clone(), + nip05: profile.nip05.clone(), } } @@ -464,6 +609,7 @@ mod tests { secret_key: "00".repeat(32), created_at: 1, picture: None, + nip05: None, }); vault.profiles.push(StoredProfile { label: "Bob".to_string(), @@ -471,6 +617,7 @@ mod tests { secret_key: "11".repeat(32), created_at: 2, picture: None, + nip05: None, }); vault } @@ -756,6 +903,164 @@ mod tests { assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); } + #[test] + fn rename_profile_updates_label_and_skips_publish_without_relays() { + let mut vault = Vault::empty(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); + + let (renamed, report) = rename_profile( + &mut vault, + &summary.npub, + "Alicia".to_string(), + None, + &offline_settings(), + ) + .expect("renaming must work offline"); + + assert_eq!(renamed.label, "Alicia"); + assert_eq!( + vault.profiles[0].label, "Alicia", + "vault must remember the label" + ); + // No relays enabled: nothing published, but the change still stands. + assert!(report.succeeded.is_empty()); + assert!(report.failed.is_empty()); + + // Leading/trailing whitespace is trimmed. + let (trimmed, _) = rename_profile( + &mut vault, + &summary.npub, + " Ace ".to_string(), + None, + &offline_settings(), + ) + .unwrap(); + assert_eq!(trimmed.label, "Ace"); + assert_eq!(vault.profiles[0].label, "Ace"); + } + + #[test] + fn rename_profile_rejects_empty_names() { + let mut vault = Vault::empty(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); + + for bad in [String::new(), " ".to_string()] { + let err = rename_profile(&mut vault, &summary.npub, bad, None, &offline_settings()) + .expect_err("empty name must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::Config); + } + assert_eq!( + vault.profiles[0].label, "Alice", + "nothing stored on failure" + ); + } + + #[test] + fn rename_profile_missing_profile_errors() { + let mut vault = Vault::empty(); + let err = rename_profile( + &mut vault, + "npub1ghost", + "Ghost".to_string(), + None, + &offline_settings(), + ) + .expect_err("missing profile must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); + } + + #[test] + fn set_nip05_stores_identifier_and_skips_publish_without_relays() { + let mut vault = Vault::empty(); + let summary = + create_profile(&mut vault, "Boo".to_string(), None, &offline_settings()).unwrap(); + + let (updated, report) = set_nip05( + &mut vault, + &summary.npub, + Some("Boo@L484.com".to_string()), + None, + &offline_settings(), + ) + .expect("setting a NIP-05 must work offline"); + + assert_eq!( + updated.nip05.as_deref(), + Some("boo@l484.com"), + "lower-cased" + ); + assert_eq!( + vault.profiles[0].nip05.as_deref(), + Some("boo@l484.com"), + "vault must remember the identifier" + ); + // No relays enabled: nothing published, but the change still stands. + assert!(report.succeeded.is_empty()); + assert!(report.failed.is_empty()); + + // Clearing the identifier also persists. + let (cleared, _) = + set_nip05(&mut vault, &summary.npub, None, None, &offline_settings()).unwrap(); + assert!(cleared.nip05.is_none()); + assert!(vault.profiles[0].nip05.is_none()); + } + + #[test] + fn set_nip05_rejects_malformed_identifiers() { + let mut vault = Vault::empty(); + let summary = + create_profile(&mut vault, "Boo".to_string(), None, &offline_settings()).unwrap(); + + for bad in [ + "just-a-name", + "@l484.com", + "boo@", + "bo o@l484.com", + "boo@nodot", + "boo@@l484.com", + ] { + let err = set_nip05( + &mut vault, + &summary.npub, + Some(bad.to_string()), + None, + &offline_settings(), + ) + .expect_err("malformed NIP-05 must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::Config); + } + assert!( + vault.profiles[0].nip05.is_none(), + "nothing stored on failure" + ); + + // The bare-domain form `_@domain` is valid. + set_nip05( + &mut vault, + &summary.npub, + Some("_@l484.com".to_string()), + None, + &offline_settings(), + ) + .expect("bare-domain form must be accepted"); + } + + #[test] + fn set_nip05_missing_profile_errors() { + let mut vault = Vault::empty(); + let err = set_nip05( + &mut vault, + "npub1ghost", + Some("ghost@example.com".to_string()), + None, + &offline_settings(), + ) + .expect_err("missing profile must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); + } + /// Delete a profile by npub, returning the deleted profile for undo. /// The vault must not be encrypted, or the key must be provided. pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result { @@ -775,6 +1080,7 @@ mod tests { created_at: stored.created_at, is_active: false, picture: stored.picture, + nip05: stored.nip05, }) } } diff --git a/src/publish.rs b/src/publish.rs index 5e4f476..76196b3 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -11,10 +11,9 @@ use crate::relays; use crate::settings::Settings; use crate::vault::Vault; -/// How long to wait for relays to accept a connection attempt. -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// How long to wait for a single relay to accept an event. -const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(15); +/// How long to wait for a single relay to accept an event. Relays are sent +/// to in parallel, so this caps the whole publish, not each relay. +const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(6); /// A relay that rejected a published note. #[derive(Debug, Clone, Serialize)] @@ -168,7 +167,7 @@ async fn publish_with_keys( // reported as such rather than as a network error. let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content)); let event = builder - .sign(keys) + .finalize_async(keys) .await .map_err(|e| AppError::sign_failed(format!("{e}")))?; @@ -177,52 +176,8 @@ async fn publish_with_keys( .to_bech32() .map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?; - let client = Client::new(keys.clone()); - for url in &relay_urls { - client - .add_relay(url.as_str()) - .await - .map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?; - } - client.connect().await; - client.wait_for_connection(CONNECT_TIMEOUT).await; - - // Send to each relay individually so partial failures are fully reported. - let mut succeeded: Vec = Vec::new(); - let mut failed: Vec = Vec::new(); - for url in &relay_urls { - let relay = match client.relay(url.as_str()).await { - Ok(relay) => relay, - Err(err) => { - let (message, details) = relay_error_message(&err); - failed.push(RelayFailure { - url: url.clone(), - error: message, - details: Some(details), - }); - continue; - } - }; - - match tokio::time::timeout(RELAY_SEND_TIMEOUT, relay.send_event(&event)).await { - Ok(Ok(_)) => succeeded.push(url.clone()), - Ok(Err(err)) => { - let (message, details) = relay_error_message(&err); - failed.push(RelayFailure { - url: url.clone(), - error: message, - details: Some(details), - }); - } - Err(_) => failed.push(RelayFailure { - url: url.clone(), - error: "The relay did not respond in time.".to_string(), - details: Some("Timed out while waiting for the relay to accept the note.".into()), - }), - } - } - - client.disconnect().await; + let client = relays::open_pool(keys.clone(), &relay_urls, None).await?; + let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &event, "note").await; if succeeded.is_empty() { return Err(AppError::publish_failed(failed)); @@ -235,6 +190,102 @@ async fn publish_with_keys( }) } +/// Send an already-signed event to every listed relay in parallel so one slow +/// or dead relay cannot drag the whole publish out to (relays x timeout). +/// +/// Callers own opening the pool (see `relays::open_pool`) — including whether +/// they wait for connections, which this deliberately does not: `send_event` +/// waits for each relay to become writable itself, and the per-send timeout +/// below already bounds the whole publish. Waiting for *all* relays first +/// would burn the full timeout whenever a single relay is unreachable. +/// `noun` ("note", "metadata") only shapes user-facing timeout wording. +pub(crate) async fn send_to_all_relays( + client: &Client, + relay_urls: Vec, + event: &Event, + noun: &str, +) -> (Vec, Vec) { + let noun = noun.to_string(); + // No explicit wait for connections here: `send_event` waits for each relay + // to become writable itself, and the per-send timeout below already bounds + // the whole publish. Waiting for *all* relays first would burn the full + // timeout whenever a single relay is unreachable. + + let mut outcomes: Vec>> = + (0..relay_urls.len()).map(|_| None).collect(); + let mut sends = tokio::task::JoinSet::new(); + for (index, url) in relay_urls.iter().cloned().enumerate() { + let client = client.clone(); + let event = event.clone(); + let noun = noun.clone(); + sends.spawn(async move { + match client.relay(url.as_str()).await { + Ok(Some(relay)) => { + match tokio::time::timeout(RELAY_SEND_TIMEOUT, relay.send_event(&event)).await { + Ok(Ok(_)) => (index, Ok(url)), + Ok(Err(err)) => { + let (message, details) = relay_error_message(&err); + ( + index, + Err(RelayFailure { + url, + error: message, + details: Some(details), + }), + ) + } + Err(_) => ( + index, + Err(RelayFailure { + url, + error: "The relay did not respond in time.".to_string(), + details: Some(format!( + "Timed out while waiting for the relay to accept the {noun}." + )), + }), + ), + } + } + Ok(None) => ( + index, + Err(RelayFailure { + url, + error: "Relay is not in the active pool.".to_string(), + details: Some("The client dropped this relay before sending.".to_string()), + }), + ), + Err(err) => { + let (message, details) = relay_error_message(&err); + ( + index, + Err(RelayFailure { + url, + error: message, + details: Some(details), + }), + ) + } + } + }); + } + while let Some(joined) = sends.join_next().await { + let (index, outcome) = joined.expect("relay send task panicked"); + outcomes[index] = Some(outcome); + } + + let mut succeeded = Vec::new(); + let mut failed = Vec::new(); + for outcome in outcomes.into_iter().flatten() { + match outcome { + Ok(url) => succeeded.push(url), + Err(failure) => failed.push(failure), + } + } + + client.disconnect().await; + (succeeded, failed) +} + /// Build a concise user-facing message plus technical detail from a relay /// error, without ever including secret material. /// diff --git a/src/relays.rs b/src/relays.rs index ae0904c..303698c 100644 --- a/src/relays.rs +++ b/src/relays.rs @@ -69,6 +69,35 @@ pub fn enabled_urls(settings: &Settings) -> Vec { .collect() } +/// Build a client pool over `relay_urls`, adding each relay and optionally +/// waiting (up to `wait`) for connections before returning. +/// +/// Fails on the first relay that cannot be added. Callers that should tolerate +/// an unreachable relay instead of aborting add their relays themselves. +pub(crate) async fn open_pool( + keys: Keys, + relay_urls: &[String], + wait: Option, +) -> Result { + // The authenticator answers NIP-42 AUTH challenges automatically on every + // path that opens a client (nostr-sdk >= 0.45 has no implicit signer). + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys)) + .build(); + for url in relay_urls { + client + .add_relay(url.as_str()) + .await + .map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?; + } + // Deprecated `wait_for_connection`; the builder form is the 0.45 way. + match wait { + Some(timeout) => client.connect().and_wait(timeout).await, + None => client.connect().await, + } + Ok(client) +} + /// Result of testing a relay connection. #[derive(Debug, Clone, Serialize)] pub struct RelayTestResult { @@ -83,7 +112,9 @@ pub struct RelayTestResult { /// during a connection test. pub async fn test_connection(url: &str, timeout: Duration) -> Result { let keys = Keys::generate(); - let client = Client::new(keys); + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys)) + .build(); client .add_relay(url) @@ -93,10 +124,12 @@ pub async fn test_connection(url: &str, timeout: Duration) -> Result Some(Self::Light), "dark" => Some(Self::Dark), - "system" => Some(Self::System), + "glass" => Some(Self::Glass), + "neon" => Some(Self::Neon), _ => None, } } @@ -58,7 +60,7 @@ fn default_true() -> bool { impl Default for Settings { fn default() -> Self { Self { - theme: Theme::System, + theme: Theme::Light, confirm_before_publish: true, shorten_npub: true, relays: crate::relays::default_relays(), diff --git a/src/signer.rs b/src/signer.rs index 086f0b3..0ee5d63 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -17,9 +17,9 @@ use std::time::Duration; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; +use getrandom::getrandom; use nostr::nips::nip44::v2; use nostr::nips::nip44::v2::ConversationKey; -use nostr::JsonUtil; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::oneshot; @@ -238,7 +238,7 @@ impl Signer { /// /// `app` is the shared vault state, so the signer reflects the current unlock /// key and active profile. A locked vault cannot sign until it is unlocked. - pub fn connect(&self, app: Arc>, uri: &str) -> Result<(), AppError> { + pub fn connect(&self, app: Arc>, uri: &str) -> Result<(), AppError> { if uri.trim().starts_with("bunker://") { return Err(AppError::config( "That is a bunker:// link, which means routing through *another* signer. \ @@ -363,7 +363,11 @@ fn percent_decode(raw: &str) -> Option { /// NIP-44 encrypt with the conversation key, returned base64-encoded. fn nip44_encrypt(conversation: &ConversationKey, plaintext: &str) -> Result { - let payload = v2::encrypt_to_bytes(conversation, plaintext.as_bytes()) + // nostr-sdk 0.45 removed the convenience wrapper that generated the nonce + // internally; the caller now supplies fresh randomness per message. + let mut nonce = [0u8; 32]; + getrandom(&mut nonce).map_err(|e| AppError::internal(format!("Could not get entropy: {e}")))?; + let payload = v2::encrypt_to_bytes_with_nonce(conversation, plaintext.as_bytes(), nonce) .map_err(|e| AppError::internal(format!("Could not encrypt a message: {e}")))?; Ok(B64.encode(payload)) } @@ -570,8 +574,8 @@ fn sign_event(keys: &Keys, request: &RawRequest) -> Result { let unsigned: UnsignedEvent = serde_json::from_value(value).map_err(|e| format!("Invalid event: {e}"))?; - let event = unsigned - .sign_with_keys(keys) + let event = keys + .sign_event(unsigned) .map_err(|e| format!("The event could not be signed: {e}"))?; Ok(event.as_json()) } @@ -596,16 +600,10 @@ fn nip44(keys: &Keys, request: &RawRequest) -> Result { /// The background loop: connect to the client's relays, announce ourselves, /// subscribe to kind 24133 events, and answer requests until stopped. -async fn run_sign_task(signer: Signer, app: Arc>, uri: ConnectUri) { +async fn run_sign_task(signer: Signer, app: Arc>, uri: ConnectUri) { // 1. Resolve the active profile's key under the current vault lock. let keys = { - let guard = match app.lock() { - Ok(guard) => guard, - Err(_) => { - signer.fail("The vault could not be read."); - return; - } - }; + let guard = app.lock().await; let hex = match profiles::resolve_active_secret_key(&guard.vault, guard.vault_key()) { Ok(hex) => hex, Err(err) => { @@ -632,23 +630,35 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: ConnectUri) { } }; - // 3. Connect to the client's relays. - let client = Client::new(keys.clone()); + // 3. Connect to the client's relays. The notification stream is opened + // BEFORE anything is sent or subscribed: the client acknowledges our + // announcement within milliseconds, and a receiver created afterwards + // would miss those early messages (tokio broadcast semantics), leaving + // the client waiting forever for a reply. + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys.clone())) + .build(); for url in &uri.relays { if let Err(err) = client.add_relay(url.to_string()).await { signer.fail(format!("Could not add relay {url}: {err}")); return; } } - client.connect().await; - client.wait_for_connection(CONNECT_TIMEOUT).await; + client.connect().and_wait(CONNECT_TIMEOUT).await; // 4. Subscribe to the client's kind 24133 events so we hear its requests. + // `stream_events` opens its internal notification receiver as part of + // subscribing, which must happen BEFORE we announce (step 5): the client + // acknowledges within milliseconds and a receiver created afterwards would + // miss those early messages (tokio broadcast semantics). let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer); - if let Err(err) = client.subscribe(filter, None).await { - signer.fail(format!("Could not subscribe for messages: {err}")); - return; - } + let mut events = match client.stream_events(filter).await { + Ok(events) => events, + Err(err) => { + signer.fail(format!("Could not subscribe for messages: {err}")); + return; + } + }; // 5. Announce ourselves: send the connect request with the optional secret. if let Err(err) = send_connect(&client, &keys, &conversation, &uri).await { @@ -657,18 +667,19 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: ConnectUri) { } // 6. Answer requests until the connection goes away or we are stopped. - let mut notifications = client.notifications(); loop { - let notification = match notifications.recv().await { - Ok(notification) => notification, - Err(_) => { + let (relay_url, incoming) = match events.next().await { + Some(next) => next, + None => { signer.fail("The signer connection was closed."); return; } }; - let RelayPoolNotification::Event { event, .. } = notification else { - continue; + let event = match incoming { + Ok(event) => event, + Err(_) => continue, }; + let _ = relay_url; if event.kind != Kind::NostrConnect || event.pubkey != uri.peer { continue; } @@ -730,7 +741,7 @@ async fn publish_payload( let tag = Tag::parse(["p", peer.to_hex().as_str()]).map_err(|e| format!("{e}"))?; let event = EventBuilder::new(Kind::NostrConnect, content) .tags([tag]) - .sign(keys) + .finalize_async(keys) .await .map_err(|e| format!("Could not sign a message: {e}"))?; client @@ -744,6 +755,80 @@ async fn publish_payload( mod tests { use super::*; + /// Malformed NIP-44 payloads (RUSTSEC-2026-0216/0227 classes) must come + /// back as clean errors, never a panic or a hang. A payload that fails to + /// decrypt against the conversation key is also exactly how forged signer + /// messages from a non-peer key are rejected. + #[test] + fn nip44_decrypt_rejects_malformed_payloads() { + let signer = Keys::generate(); + let peer = Keys::generate(); + let conversation = + ConversationKey::derive(signer.secret_key(), &peer.public_key()).unwrap(); + + let not_base64 = "this is !! not base64 !!"; + let empty = ""; + let too_short = B64.encode([0x02u8, 0x00]); + let bad_version = B64.encode([0xFFu8, 0x00, 0x11]); + let truncated = { + // encrypt returns raw bytes; nip44_decrypt takes their base64 form. + let mut bytes = v2::encrypt_to_bytes_with_nonce( + &conversation, + "a message long enough to be truncated meaningfully".as_bytes(), + [7u8; 32], + ) + .unwrap(); + bytes.truncate(bytes.len() / 2); + B64.encode(&bytes) + }; + + for payload in [ + not_base64, + empty, + &too_short, + &bad_version, + truncated.as_str(), + ] { + let result = nip44_decrypt(&conversation, payload); + assert!(result.is_err(), "payload {payload:?} must be rejected"); + if let Err(err) = result { + assert!( + !err.message().is_empty(), + "rejection of {payload:?} must carry a concise reason" + ); + } + } + } + + /// NIP-46 credential-leakage regression (RUSTSEC-2026-0225 class): error + /// strings surfaced to callers or logs must never contain secret-key hex. + #[test] + fn error_paths_never_leak_secret_key_material() { + let signer = Keys::generate(); + let sk_hex = hex::encode(signer.secret_key().secret_bytes()); + assert_eq!(sk_hex.len(), 64); + let peer = Keys::generate(); + let conversation = + ConversationKey::derive(signer.secret_key(), &peer.public_key()).unwrap(); + + // Collect the human-readable reasons our failure paths produce. + let mut failures = Vec::new(); + if let Err(err) = nip44_decrypt(&conversation, "not-base64 !!!") { + failures.push(err.message().to_string()); + } + if let Err(err) = nip44_decrypt(&conversation, &B64.encode([0xFFu8, 0x00, 0x11])) { + failures.push(err.message().to_string()); + } + + assert!(!failures.is_empty(), "expected at least one failure path"); + for message in failures { + assert!( + !message.to_lowercase().contains(&sk_hex), + "error text leaked secret-key material: {message}" + ); + } + } + #[test] fn parse_uri_with_relays_and_secret() { let uri = parse_connect_uri( @@ -764,7 +849,10 @@ mod tests { fn bunker_link_is_rejected() { let signer = Signer::new(); assert!(signer - .connect(Arc::new(Mutex::new(App::load().unwrap())), "bunker://abc") + .connect( + Arc::new(tokio::sync::Mutex::new(App::load().unwrap())), + "bunker://abc" + ) .is_err()); } diff --git a/src/updates.rs b/src/updates.rs new file mode 100644 index 0000000..ef75d9b --- /dev/null +++ b/src/updates.rs @@ -0,0 +1,498 @@ +//! Dependency update scanning and installation for both halves of the app. +//! +//! The app runs from a source checkout, so "updating" means refreshing the +//! JavaScript dependencies (`frontend/`) and the Rust crates (`Cargo.lock`) +//! to their newest compatible versions. Security advisories from `npm audit` +//! are surfaced first; `cargo update` picks up semver-compatible patches for +//! the Rust side. +//! +//! Nothing here touches secret material: only fixed, read-mostly package +//! manager commands are run in the project directories. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Serialize; +use tokio::process::Command; + +use crate::errors::{AppError, ErrorKind}; + +/// Upper bound for a single package-manager command. Installs can be slow on +/// cold caches, but a hung command must still be reaped eventually. +const COMMAND_TIMEOUT: Duration = Duration::from_secs(150); + +/// One dependency with a newer compatible version available. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PackageUpdate { + pub name: String, + pub current: String, + pub available: String, +} + +/// A known security advisory affecting an npm dependency. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SecurityAdvisory { + pub package: String, + /// npm severity label: critical / high / moderate / low / info. + pub severity: String, + /// Short advisory title, when npm reported one. + pub title: Option, + /// What npm says is needed to clear it. `None` means a compatible fix + /// exists that "Install updates" can apply. + pub fix: Option, +} + +/// Everything the scan found, ready to show in one screen. +#[derive(Debug, Clone, Serialize)] +pub struct UpdateCheckReport { + pub outdated_npm: Vec, + pub advisories: Vec, + pub outdated_cargo: Vec, + /// Hints about optional tooling or skipped parts of the scan. + pub notes: Vec, +} + +/// Result of applying updates. +#[derive(Debug, Clone, Serialize)] +pub struct UpdateApplyReport { + pub applied: Vec, + pub failed: Vec, + /// The running binary/bundle cannot hot-swap; the app must be rebuilt + /// and restarted to load the refreshed dependencies. + pub restart_required: bool, +} + +/// The directory this backend was compiled from (the project root). +fn source_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// The frontend source directory (where `package.json` lives). +fn frontend_dir() -> PathBuf { + source_dir().join("frontend") +} + +/// Verify the app is running from its source checkout before shelling out. +fn require_source_checkout() -> Result<(), AppError> { + let manifest = source_dir().join("Cargo.toml"); + let package = frontend_dir().join("package.json"); + if manifest.exists() && package.exists() { + return Ok(()); + } + Err(AppError::simple( + ErrorKind::Config, + "Updates need the app's source folder, which was not found next to the running program.", + )) +} + +/// Run a command with a timeout, capturing stdout/stderr separately. +async fn run(dir: &Path, program: &str, args: &[&str]) -> Result { + let mut command = Command::new(program); + command.current_dir(dir).args(args).kill_on_drop(true); + let future = command.output(); + match tokio::time::timeout(COMMAND_TIMEOUT, future).await { + Ok(Ok(output)) => Ok(output), + Ok(Err(err)) => { + let hint = if err.kind() == std::io::ErrorKind::NotFound { + format!("'{program}' was not found on this computer.") + } else { + format!("Could not run '{program}': {err}") + }; + Err(AppError::simple(ErrorKind::Internal, hint)) + } + Err(_) => Err(AppError::with_details( + ErrorKind::Network, + format!("'{program}' took too long and was stopped."), + "The command exceeded its time limit while updating dependencies.", + )), + } +} + +/// Decode command output as UTF-8, falling back to lossy text. +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +/// Parse `npm outdated --json`. +/// +/// npm exits non-zero when anything is outdated, so exit status is ignored: +/// the JSON body is the data. Unparseable or missing output means no data. +fn parse_npm_outdated(json: &str) -> Vec { + let Ok(value) = serde_json::from_str::(json) else { + return Vec::new(); + }; + let Some(map) = value.as_object() else { + return Vec::new(); + }; + let mut updates = Vec::new(); + for (name, info) in map { + let get = |key: &str| { + info.get(key) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() + }; + let current = get("current"); + let available = if get("latest").is_empty() { + get("wanted") + } else { + get("latest") + }; + updates.push(PackageUpdate { + name: name.clone(), + current, + available, + }); + } + updates.sort_by(|a, b| a.name.cmp(&b.name)); + updates +} + +/// Parse `npm audit --json` into a flat advisory list. +fn parse_npm_audit(json: &str) -> Vec { + let Ok(value) = serde_json::from_str::(json) else { + return Vec::new(); + }; + let Some(vulnerabilities) = value.get("vulnerabilities").and_then(|v| v.as_object()) else { + return Vec::new(); + }; + let mut advisories = Vec::new(); + for (name, info) in vulnerabilities { + let severity = info + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + // `via` holds either plain title strings or full advisory objects. + let title = info.get("via").and_then(|v| v.as_array()).and_then(|list| { + list.iter().find_map(|entry| match entry { + serde_json::Value::String(text) => Some(text.clone()), + serde_json::Value::Object(object) => object + .get("title") + .and_then(|t| t.as_str()) + .map(|t| t.to_string()), + _ => None, + }) + }); + let fix = match info.get("fixAvailable") { + // A compatible fix exists; "Install updates" handles it. + Some(serde_json::Value::Bool(true)) | None => None, + Some(serde_json::Value::Bool(false)) => { + Some("No fix has been published yet.".to_string()) + } + Some(details @ serde_json::Value::Object(_)) => { + let name = details + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("a dependency"); + let version = details + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("latest"); + let major = details + .get("isSemVerMajor") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + Some(format!( + "Needs {name}@{version}{} — not auto-installed.", + if major { ", a major upgrade" } else { "" } + )) + } + Some(_) => None, + }; + advisories.push(SecurityAdvisory { + package: name.clone(), + severity, + title, + fix, + }); + } + const ORDER: [&str; 5] = ["critical", "high", "moderate", "low", "info"]; + advisories.sort_by(|a, b| { + let rank = |s: &str| { + ORDER + .iter() + .position(|known| known.eq_ignore_ascii_case(s)) + .unwrap_or(ORDER.len()) + }; + rank(&a.severity) + .cmp(&rank(&b.severity)) + .then_with(|| a.package.cmp(&b.package)) + }); + advisories +} + +/// Parse `cargo update --dry-run` status lines into crate updates. +fn parse_cargo_updates(text: &str) -> Vec { + let mut updates = Vec::new(); + for line in text.lines() { + let tokens: Vec<&str> = line.split_whitespace().collect(); + // e.g. "Updating serde v1.0.200 -> v1.0.219" (+ optional suffixes). + if tokens.len() >= 5 && tokens[3] == "->" { + let verb = tokens.first().copied().unwrap_or_default(); + if matches!(verb, "Updating" | "Downgrading") { + updates.push(PackageUpdate { + name: tokens[1].to_string(), + current: tokens[2].trim_start_matches('v').to_string(), + available: tokens[4].trim_start_matches('v').to_string(), + }); + } + } + } + updates.sort_by(|a, b| a.name.cmp(&b.name)); + updates +} + +/// Whether the optional RustSec scanner is installed. +async fn cargo_audit_available() -> bool { + run(Path::new("."), "cargo", &["audit", "--version"]) + .await + .map(|output| output.status.success()) + .unwrap_or(false) +} + +/// Scan both dependency sets without changing anything. +pub async fn check() -> Result { + require_source_checkout()?; + let root = source_dir(); + let frontend = frontend_dir(); + + let outdated = run(&frontend, "npm", &["outdated", "--json"]).await?; + let outdated_npm = parse_npm_outdated(&text(&outdated.stdout)); + + let audit = run(&frontend, "npm", &["audit", "--json"]).await?; + let advisories = parse_npm_audit(&text(&audit.stdout)); + + let dry_run = run(&root, "cargo", &["update", "--dry-run"]).await?; + let cargo_text = format!("{}{}", text(&dry_run.stdout), text(&dry_run.stderr)); + let outdated_cargo = parse_cargo_updates(&cargo_text); + + let mut notes = Vec::new(); + if !cargo_audit_available().await { + notes.push( + "Rust advisory scan unavailable: install cargo-audit (cargo install cargo-audit) \ + to also check Rust crates against the RustSec database." + .to_string(), + ); + } + + Ok(UpdateCheckReport { + outdated_npm, + advisories, + outdated_cargo, + notes, + }) +} + +/// Install compatible updates: npm security fixes, then general bumps, then +/// the Rust lockfile. +pub async fn apply() -> Result { + require_source_checkout()?; + let root = source_dir(); + let frontend = frontend_dir(); + + let steps: [(&Path, &str, &[&str], &str); 3] = [ + ( + &frontend, + "npm", + &["audit", "fix"], + "JavaScript security fixes applied", + ), + ( + &frontend, + "npm", + &["update"], + "JavaScript packages updated to their newest compatible versions", + ), + ( + &root, + "cargo", + &["update"], + "Rust crates updated in Cargo.lock", + ), + ]; + + let mut applied = Vec::new(); + let mut failed = Vec::new(); + for (dir, program, args, summary) in steps { + match run(dir, program, args).await { + Ok(output) => { + if output.status.success() { + applied.push(summary.to_string()); + } else { + let stderr = text(&output.stderr); + let tail: String = stderr + .lines() + .filter(|line| !line.trim().is_empty()) + .rev() + .take(3) + .collect::>() + .join(" | "); + failed.push(format!("{program} {args:?}: {tail}")); + } + } + Err(err) => failed.push(format!("{program} {args:?}: {}", err.message())), + } + } + + if applied.is_empty() && !failed.is_empty() { + return Err(AppError::with_details( + ErrorKind::Internal, + "No updates could be installed.", + failed.join("\n"), + )); + } + + let restart_required = !applied.is_empty(); + Ok(UpdateApplyReport { + applied, + failed, + restart_required, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_npm_outdated_entries() { + let json = r#"{ + "left-pad": { "current": "1.0.0", "wanted": "1.3.0", "latest": "1.3.0" }, + "react": { "current": "18.2.0", "wanted": "18.2.0", "latest": "19.0.0" } + }"#; + let updates = parse_npm_outdated(json); + assert_eq!(updates.len(), 2); + assert_eq!( + updates[0], + PackageUpdate { + name: "left-pad".to_string(), + current: "1.0.0".to_string(), + available: "1.3.0".to_string(), + } + ); + assert_eq!(updates[1].name, "react"); + assert_eq!(updates[1].available, "19.0.0"); + } + + #[test] + fn empty_or_garbage_outdated_output_yields_nothing() { + assert!(parse_npm_outdated("").is_empty()); + assert!(parse_npm_outdated("not json at all").is_empty()); + assert!(parse_npm_outdated("{}").is_empty()); + } + + #[test] + fn parses_npm_audit_with_title_objects_and_strings() { + let json = r#"{ + "vulnerabilities": { + "minimist": { + "severity": "high", + "via": [{ "title": "Prototype Pollution", "url": "https://example.com/a" }] + }, + "tar": { "severity": "low", "via": ["Regular Expression Denial of Service"] } + } + }"#; + let advisories = parse_npm_audit(json); + assert_eq!(advisories.len(), 2); + assert_eq!(advisories[0].package, "minimist"); + assert_eq!(advisories[0].severity, "high"); + assert_eq!(advisories[0].title.as_deref(), Some("Prototype Pollution")); + assert_eq!(advisories[1].package, "tar"); + assert_eq!( + advisories[1].title.as_deref(), + Some("Regular Expression Denial of Service") + ); + } + + #[test] + fn advisory_fix_paths_are_classified() { + let json = r#"{ + "vulnerabilities": { + "auto": { "severity": "low", "via": [], "fixAvailable": true }, + "stuck": { "severity": "high", "via": [], "fixAvailable": false }, + "electron": { + "severity": "critical", "via": [], + "fixAvailable": { "name": "electron", "version": "43.4.1", "isSemVerMajor": true } + }, + "minor": { + "severity": "moderate", "via": [], + "fixAvailable": { "name": "left-pad", "version": "1.3.0", "isSemVerMajor": false } + } + } + }"#; + let advisories = parse_npm_audit(json); + let fix_of = |name: &str| { + advisories + .iter() + .find(|a| a.package == name) + .and_then(|a| a.fix.clone()) + }; + // Compatible fixes need no hint: Install updates clears them. + assert_eq!(fix_of("auto"), None); + assert_eq!( + fix_of("stuck").as_deref(), + Some("No fix has been published yet.") + ); + assert_eq!( + fix_of("electron").as_deref(), + Some("Needs electron@43.4.1, a major upgrade — not auto-installed.") + ); + assert_eq!( + fix_of("minor").as_deref(), + Some("Needs left-pad@1.3.0 — not auto-installed.") + ); + } + + #[test] + fn sorts_advisories_by_severity_then_name() { + let json = r#"{ + "vulnerabilities": { + "zeta": { "severity": "critical", "via": [] }, + "alpha": { "severity": "high", "via": [] }, + "beta": { "severity": "critical", "via": [] } + } + }"#; + let advisories = parse_npm_audit(json); + let order: Vec<&str> = advisories.iter().map(|a| a.package.as_str()).collect(); + assert_eq!(order, vec!["beta", "zeta", "alpha"]); + } + + #[test] + fn empty_audit_yields_no_advisories() { + assert!(parse_npm_audit("").is_empty()); + assert!(parse_npm_audit("{}").is_empty()); + assert!(parse_npm_audit("{\"vulnerabilities\":{}}").is_empty()); + } + + #[test] + fn parses_cargo_update_lines() { + let text = "\ + Updating crates.io index\n\ + Locking 2 packages to their latest compatible version\n\ + Updating serde v1.0.200 -> v1.0.219\n\ + Downgrading leftpad v2.0.0 -> v1.9.0 (features: [\"std\"])\n\ + Adding newcrate v0.1.0\n"; + let updates = parse_cargo_updates(text); + assert_eq!(updates.len(), 2); + assert_eq!(updates[0].name, "leftpad"); + assert_eq!(updates[0].current, "2.0.0"); + assert_eq!(updates[0].available, "1.9.0"); + assert_eq!(updates[1].name, "serde"); + assert_eq!(updates[1].available, "1.0.219"); + } + + #[test] + fn unchanged_lockfile_yields_no_updates() { + assert!(parse_cargo_updates("Unchanged").is_empty()); + assert!(parse_cargo_updates("").is_empty()); + } + + #[test] + fn source_layout_holds_for_this_repo() { + // The compile-time paths must exist in the real checkout, otherwise + // every runtime check would fail with a misleading error. + assert!(source_dir().join("Cargo.toml").exists()); + assert!(frontend_dir().join("package.json").exists()); + } +} diff --git a/src/uploads.rs b/src/uploads.rs index 39622ff..a1ed3ac 100644 --- a/src/uploads.rs +++ b/src/uploads.rs @@ -1,3 +1,4 @@ +use nostr::nips::nip98::{HttpData, HttpMethod}; use nostr_sdk::prelude::*; use crate::crypto::VaultKey; diff --git a/src/vault.rs b/src/vault.rs index 72e1f77..e4b078c 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -39,6 +39,11 @@ pub struct StoredProfile { /// profiles stored before pictures were introduced. #[serde(default)] pub picture: Option, + /// NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. + /// Absent for profiles stored before NIP-05 was introduced. Published as + /// part of kind 0 metadata so clients show a human handle. + #[serde(default)] + pub nip05: Option, } /// KDF parameters that encrypted a vault. Stored so future key-derivation @@ -457,6 +462,7 @@ mod tests { secret_key: "00ff".to_string(), created_at: 1_700_000_000, picture: None, + nip05: None, } }