diff --git a/docs/NIP-05.md b/docs/NIP-05.md new file mode 100644 index 0000000..441951e --- /dev/null +++ b/docs/NIP-05.md @@ -0,0 +1,77 @@ +# NIP-05 identifiers in Keynectr + +A NIP-05 identifier is a human-readable Nostr address that looks like an email +address — for example `boo@l484.com`. When a profile has one, clients such as +Iris, Yakihonne, Amethyst and snort show the handle instead of a raw `npub1…` +key, and users can find and tag you by typing it. + +NIP-05 has two halves. Keynectr does the first half; you do the second half +once on your own domain. + +## 1. Publish it from Keynectr (the app side) + +- **GUI:** Profiles → *NIP-05* button → enter `name@domain.com` → *Save & publish*. + The identifier is stored with the profile and published to your enabled relays + as part of your kind 0 metadata. +- **CLI:** + ```bash + B=~/Projects/Nostr_Keynctr/target/release/keynectr + $B set-nip05 boo@l484.com # set + publish + $B set-nip05 clear # remove + publish the removal + ``` +- The special form `_@domain.com` claims the bare domain itself (the whole + domain shows as your handle). + +## 2. Serve `.well-known/nostr.json` (the domain side) + +Clients verify a NIP-05 claim by fetching: + +``` +https:///.well-known/nostr.json?name= +``` + +That file must live on the domain in the identifier — publishing alone is not +enough. Keynectr prints the exact document to serve: + +```bash +$B nip05-file boo@l484.com +``` + +which outputs something like: + +```json +{ + "names": { + "boo": "3bf0c6…(64-char hex public key)" + }, + "relays": { + "3bf0c6…": ["wss://nos.lol", "wss://relay.primal.net"] + } +} +``` + +Serve it at `https:///.well-known/nostr.json` with: + +- `Content-Type: application/json` (or `application/json; charset=utf-8`) +- CORS header `Access-Control-Allow-Origin: *` (clients fetch it from browsers) + +### nginx example + +```nginx +location = /.well-known/nostr.json { + include snippets/cors.conf; # or add_header Access-Control-Allow-Origin *; + default_type application/json; + root /var/www/static; +} +``` + +Then place the printed document at `/var/www/static/.well-known/nostr.json`. +Regenerate it if you switch profiles or change your relay list. + +## Notes + +- The identifier is lower-cased when stored; validation matches NIP-05's + limited character set (`a-z`, `0-9`, `-`, `_`; `_` for the bare domain). +- Clients cache profiles aggressively — hard-refresh after changing anything. +- Without the well-known file, most clients will not display the handle even + though the metadata was published successfully. diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index b8f0637..22ff4f6 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -186,6 +186,7 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'publish_profile_metadata', 'set_profile_picture', 'rename_profile', + 'set_nip05', 'delete_profile', 'undo_delete', 'publish_note', diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index d3a678e..431c352 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -19,7 +19,11 @@ export function Modal({ open, title, onClose, children }: ModalProps) { const container = containerRef.current; const frame = requestAnimationFrame(() => { - container?.focus(); + // Keep keyboard focus where the user (or autoFocus) put it; only pull + // focus to the dialog itself as a fallback for content without inputs. + if (!container?.contains(document.activeElement)) { + container?.focus(); + } }); const onKeyDown = (event: KeyboardEvent) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9ee9f7f..f5f78e4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -63,6 +63,11 @@ export const api = { 'rename_profile', { npub, label }, ), + setNip05: (npub: string, nip05: string | null) => + call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>('set_nip05', { + npub, + nip05, + }), publishNote: (content: string) => call('publish_note', { content }), feedGet: (limit?: number, contactsOnly = false) => call('feed_get', { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0ea3c99..15c241e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -36,6 +36,8 @@ export interface ProfileSummary { is_active: boolean; /** Public URL of the profile picture, when one has been set. */ picture?: string | null; + /** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */ + nip05?: string | null; } export interface RelayConfig { diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index d437d94..87045b1 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -25,6 +25,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null); const [pictureTarget, setPictureTarget] = useState(null); const [renameTarget, setRenameTarget] = useState<{ npub: string; label: string } | null>(null); + const [nip05Target, setNip05Target] = useState(null); const profiles = state?.profiles ?? []; const shorten = state?.settings.shorten_npub ?? true; @@ -151,6 +152,11 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { />

{profile.label}

+ {profile.nip05 && ( + + {profile.nip05} + + )} {shortenNpub(profile.npub, shorten)} @@ -177,6 +183,19 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { > Edit name +
); @@ -271,6 +303,119 @@ interface PictureTarget { url: string; } +interface Nip05Target { + npub: string; + label: string; + nip05: string; +} + +/** Client-side mirror of the backend's NIP-05 validation, for fast feedback. */ +function nip05Problem(value: string): string | null { + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return null; // Empty clears the identifier. + } + const at = trimmed.indexOf('@'); + if (at <= 0 || at === trimmed.length - 1) { + return 'Use the form name@domain.com.'; + } + const [local, domain] = [trimmed.slice(0, at), trimmed.slice(at + 1)]; + if (local !== '_' && !/^[a-z0-9_-]+$/.test(local)) { + return 'The part before @ may only use letters, numbers, dashes and underscores.'; + } + if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) { + return 'The part after @ must be a domain like example.com.'; + } + return null; +} + +function Nip05Modal({ + target, + onClose, + onSaved, + onError, + onSavingChange, +}: { + target: Nip05Target; + onClose: () => void; + onSaved: (message: string) => void; + onError: (message: string | null) => void; + onSavingChange: (npub: string | null) => void; +}) { + const { setNip05 } = useApp(); + const [nip05, setNip05Value] = useState(target.nip05); + const [saving, setSaving] = useState(false); + + const trimmed = nip05.trim(); + const changed = trimmed !== target.nip05; + const problem = nip05Problem(trimmed); + const canSave = changed && !problem; + + const save = async (next: string | null) => { + onError(null); + setSaving(true); + onSavingChange(target.npub); + try { + const report = await setNip05(target.npub, next); + onSaved( + report.failed.length === 0 + ? next + ? `NIP-05 "${next}" published to ${report.succeeded.length} relay(s). Remember it must also be served from your domain (see docs/NIP-05.md).` + : 'NIP-05 removed and the change published.' + : `NIP-05 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); + } + }; + + return ( + +
+
+ + setNip05Value(event.target.value)} + placeholder="name@domain.com" + autoComplete="off" + autoFocus + /> + {problem && trimmed && {problem}} +
+

+ A NIP-05 address (like an email handle) makes clients show a proper username instead of a + raw key. It is published to your relays and must also be served from your domain as + /.well-known/nostr.json — see docs/NIP-05.md. +

+
+ {target.nip05 && ( + + )} + + +
+
+
+ ); +} + function RenameModal({ target, onClose, diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 6e971e5..cab380d 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -42,6 +42,7 @@ interface AppContextValue { 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; @@ -144,6 +145,15 @@ export function AppProvider({ children }: { children: ReactNode }) { [], ); + 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() }); @@ -261,6 +271,7 @@ export function AppProvider({ children }: { children: ReactNode }) { publishProfileMetadata, setProfilePicture, renameProfile, + setNip05, copyText, }), [ @@ -274,6 +285,7 @@ export function AppProvider({ children }: { children: ReactNode }) { publishProfileMetadata, setProfilePicture, renameProfile, + setNip05, publishNote, deleteProfile, undoDelete, diff --git a/frontend/src/test/ProfilesScreen.test.tsx b/frontend/src/test/ProfilesScreen.test.tsx index ff5fa99..20759a0 100644 --- a/frontend/src/test/ProfilesScreen.test.tsx +++ b/frontend/src/test/ProfilesScreen.test.tsx @@ -86,6 +86,69 @@ describe('ProfilesScreen', () => { 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/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 0e916a0..77fcd42 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -182,6 +182,39 @@ export function createFakeBackend(initial?: AppState): FakeBackend { 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; 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/ipc.rs b/src/ipc.rs index c0c1b04..823eabc 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -56,6 +56,12 @@ pub enum Request { 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, }, @@ -392,6 +398,14 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + 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()) diff --git a/src/main.rs b/src/main.rs index 56a53e9..8c74756 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,10 @@ Commands: 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 @@ -72,6 +76,8 @@ async fn main() -> ExitCode { "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), @@ -210,6 +216,76 @@ fn cli_rename(args: &[String]) -> Result { )) } +/// 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) @@ -571,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 35ed677..885ba0c 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -25,6 +25,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 @@ -75,6 +77,7 @@ pub fn create_profile( secret_key: stored_secret, created_at, picture: None, + nip05: None, }; let is_active = vault.active_profile.is_none(); @@ -88,7 +91,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 { @@ -97,6 +100,7 @@ pub fn create_profile( created_at, is_active, picture: None, + nip05: None, }) } @@ -132,6 +136,7 @@ pub fn publish_profile_metadata( &keys, &stored.label, stored.picture.clone(), + stored.nip05.clone(), relay_urls, )) } @@ -159,12 +164,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 { @@ -173,6 +179,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); @@ -189,8 +196,13 @@ 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)) } @@ -217,11 +229,12 @@ pub fn rename_profile( let stored = find_profile_mut(vault, npub)?; stored.label = trimmed.to_string(); - let (label, created_at, public_key, picture) = ( + 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, @@ -229,6 +242,7 @@ pub fn rename_profile( created_at, is_active: vault.active_profile.as_deref() == Some(public_key.as_str()), picture, + nip05, }; let relay_urls = relays::enabled_urls(settings); @@ -245,11 +259,110 @@ pub fn rename_profile( } 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)) } +/// 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) @@ -290,6 +403,7 @@ fn publish_metadata_blocking( keys: &Keys, label: &str, picture: Option, + nip05: Option, relay_urls: Vec, ) -> MetadataPublishReport { let keys = keys.clone(); @@ -297,7 +411,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") @@ -307,6 +423,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); @@ -315,6 +432,9 @@ 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) .await @@ -432,6 +552,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(), } } @@ -547,6 +668,7 @@ mod tests { secret_key: "00".repeat(32), created_at: 1, picture: None, + nip05: None, }); vault.profiles.push(StoredProfile { label: "Bob".to_string(), @@ -554,6 +676,7 @@ mod tests { secret_key: "11".repeat(32), created_at: 2, picture: None, + nip05: None, }); vault } @@ -907,6 +1030,96 @@ mod tests { 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 { @@ -926,6 +1139,7 @@ mod tests { created_at: stored.created_at, is_active: false, picture: stored.picture, + nip05: stored.nip05, }) } } 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, } }