Add NIP-05 identifiers: store, publish, GUI modal, CLI helpers
- Store an optional nip05 on each profile; publish it in kind 0 metadata - set-nip05 CLI (+ validation, lower-casing, clear) and nip05-file helper that prints the .well-known/nostr.json document for a domain - Profiles screen: NIP-05 button, handle shown on cards, Nip05Modal with client-side validation and Remove action - Fix Modal stealing focus from autoFocus inputs one frame after open
This commit is contained in:
parent
f2c30d397f
commit
045fa47476
14 changed files with 666 additions and 11 deletions
77
docs/NIP-05.md
Normal file
77
docs/NIP-05.md
Normal file
|
|
@ -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 <npub> boo@l484.com # set + publish
|
||||||
|
$B set-nip05 <npub> 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://<your-domain>/.well-known/nostr.json?name=<local-part>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <npub> 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://<domain>/.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.
|
||||||
|
|
@ -186,6 +186,7 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
||||||
'publish_profile_metadata',
|
'publish_profile_metadata',
|
||||||
'set_profile_picture',
|
'set_profile_picture',
|
||||||
'rename_profile',
|
'rename_profile',
|
||||||
|
'set_nip05',
|
||||||
'delete_profile',
|
'delete_profile',
|
||||||
'undo_delete',
|
'undo_delete',
|
||||||
'publish_note',
|
'publish_note',
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,11 @@ export function Modal({ open, title, onClose, children }: ModalProps) {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
|
// 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();
|
container?.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ export const api = {
|
||||||
'rename_profile',
|
'rename_profile',
|
||||||
{ npub, label },
|
{ npub, label },
|
||||||
),
|
),
|
||||||
|
setNip05: (npub: string, nip05: string | null) =>
|
||||||
|
call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>('set_nip05', {
|
||||||
|
npub,
|
||||||
|
nip05,
|
||||||
|
}),
|
||||||
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
|
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
|
||||||
feedGet: (limit?: number, contactsOnly = false) =>
|
feedGet: (limit?: number, contactsOnly = false) =>
|
||||||
call<FeedItem[]>('feed_get', {
|
call<FeedItem[]>('feed_get', {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export interface ProfileSummary {
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
/** Public URL of the profile picture, when one has been set. */
|
/** Public URL of the profile picture, when one has been set. */
|
||||||
picture?: string | null;
|
picture?: string | null;
|
||||||
|
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
|
||||||
|
nip05?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RelayConfig {
|
export interface RelayConfig {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
|
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
|
||||||
const [pictureTarget, setPictureTarget] = useState<PictureTarget | null>(null);
|
const [pictureTarget, setPictureTarget] = useState<PictureTarget | null>(null);
|
||||||
const [renameTarget, setRenameTarget] = useState<{ npub: string; label: string } | null>(null);
|
const [renameTarget, setRenameTarget] = useState<{ npub: string; label: string } | null>(null);
|
||||||
|
const [nip05Target, setNip05Target] = useState<Nip05Target | null>(null);
|
||||||
|
|
||||||
const profiles = state?.profiles ?? [];
|
const profiles = state?.profiles ?? [];
|
||||||
const shorten = state?.settings.shorten_npub ?? true;
|
const shorten = state?.settings.shorten_npub ?? true;
|
||||||
|
|
@ -151,6 +152,11 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
/>
|
/>
|
||||||
<div className="profile-card-meta">
|
<div className="profile-card-meta">
|
||||||
<h3>{profile.label}</h3>
|
<h3>{profile.label}</h3>
|
||||||
|
{profile.nip05 && (
|
||||||
|
<span className="nip05-handle" title={profile.nip05}>
|
||||||
|
{profile.nip05}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<code className="mono" title={profile.npub}>
|
<code className="mono" title={profile.npub}>
|
||||||
{shortenNpub(profile.npub, shorten)}
|
{shortenNpub(profile.npub, shorten)}
|
||||||
</code>
|
</code>
|
||||||
|
|
@ -177,6 +183,19 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
>
|
>
|
||||||
<Icon name="edit" size={14} /> Edit name
|
<Icon name="edit" size={14} /> Edit name
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setNip05Target({
|
||||||
|
npub: profile.npub,
|
||||||
|
label: profile.label,
|
||||||
|
nip05: profile.nip05 ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size={14} /> NIP-05
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -260,6 +279,19 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
onSavingChange={setPublishing}
|
onSavingChange={setPublishing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{nip05Target && (
|
||||||
|
<Nip05Modal
|
||||||
|
target={nip05Target}
|
||||||
|
onClose={() => setNip05Target(null)}
|
||||||
|
onSaved={(message) => {
|
||||||
|
setNotice(message);
|
||||||
|
setNip05Target(null);
|
||||||
|
}}
|
||||||
|
onError={setError}
|
||||||
|
onSavingChange={setPublishing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -271,6 +303,119 @@ interface PictureTarget {
|
||||||
url: string;
|
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 (
|
||||||
|
<Modal open title={`NIP-05 address — ${target.label}`} onClose={onClose}>
|
||||||
|
<div className="picture-modal">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="profile-nip05">NIP-05 address</label>
|
||||||
|
<input
|
||||||
|
id="profile-nip05"
|
||||||
|
type="text"
|
||||||
|
value={nip05}
|
||||||
|
onChange={(event) => setNip05Value(event.target.value)}
|
||||||
|
placeholder="name@domain.com"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{problem && trimmed && <ErrorText>{problem}</ErrorText>}
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
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
|
||||||
|
<code> /.well-known/nostr.json</code> — see <code>docs/NIP-05.md</code>.
|
||||||
|
</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
{target.nip05 && (
|
||||||
|
<Button variant="danger" onClick={() => void save(null)} disabled={saving}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void save(trimmed ? trimmed.toLowerCase() : null)}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!canSave}
|
||||||
|
>
|
||||||
|
Save & publish
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RenameModal({
|
function RenameModal({
|
||||||
target,
|
target,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ interface AppContextValue {
|
||||||
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
||||||
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
|
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
|
||||||
renameProfile: (npub: string, label: string) => Promise<MetadataPublishReport>;
|
renameProfile: (npub: string, label: string) => Promise<MetadataPublishReport>;
|
||||||
|
setNip05: (npub: string, nip05: string | null) => Promise<MetadataPublishReport>;
|
||||||
publishNote: (content: string) => Promise<PublishReport>;
|
publishNote: (content: string) => Promise<PublishReport>;
|
||||||
recordPublishFailure: (message: string, details?: string | null) => void;
|
recordPublishFailure: (message: string, details?: string | null) => void;
|
||||||
clearLastPublish: () => void;
|
clearLastPublish: () => void;
|
||||||
|
|
@ -144,6 +145,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setNip05 = useCallback(
|
||||||
|
async (npub: string, nip05: string | null): Promise<MetadataPublishReport> => {
|
||||||
|
const result = await api.setNip05(npub, nip05);
|
||||||
|
setState(result.state);
|
||||||
|
return result.report;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const publishNote = useCallback(async (content: string): Promise<PublishReport> => {
|
const publishNote = useCallback(async (content: string): Promise<PublishReport> => {
|
||||||
const report = await api.publishNote(content);
|
const report = await api.publishNote(content);
|
||||||
setLastPublish({ report, error: null, details: null, at: Date.now() });
|
setLastPublish({ report, error: null, details: null, at: Date.now() });
|
||||||
|
|
@ -261,6 +271,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
publishProfileMetadata,
|
publishProfileMetadata,
|
||||||
setProfilePicture,
|
setProfilePicture,
|
||||||
renameProfile,
|
renameProfile,
|
||||||
|
setNip05,
|
||||||
copyText,
|
copyText,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|
@ -274,6 +285,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
publishProfileMetadata,
|
publishProfileMetadata,
|
||||||
setProfilePicture,
|
setProfilePicture,
|
||||||
renameProfile,
|
renameProfile,
|
||||||
|
setNip05,
|
||||||
publishNote,
|
publishNote,
|
||||||
deleteProfile,
|
deleteProfile,
|
||||||
undoDelete,
|
undoDelete,
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,69 @@ describe('ProfilesScreen', () => {
|
||||||
expect(await screen.findByRole('status')).toHaveTextContent(/Renamed to "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(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
|
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
|
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
|
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 () => {
|
it('disables Select for the active profile and copies public keys', async () => {
|
||||||
const backend = createFakeBackend();
|
const backend = createFakeBackend();
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,39 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
return { profile: updated, report: makePublishReport(), state: 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': {
|
case 'publish_note': {
|
||||||
if (publishFailure) {
|
if (publishFailure) {
|
||||||
const failure = publishFailure;
|
const failure = publishFailure;
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,8 @@ impl App {
|
||||||
public_key: restored.npub.clone(),
|
public_key: restored.npub.clone(),
|
||||||
secret_key: "".to_string(),
|
secret_key: "".to_string(),
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
picture: None,
|
picture: restored.picture.clone(),
|
||||||
|
nip05: restored.nip05.clone(),
|
||||||
};
|
};
|
||||||
self.vault.profiles.push(stored);
|
self.vault.profiles.push(stored);
|
||||||
// If no active profile, this restored one becomes active
|
// If no active profile, this restored one becomes active
|
||||||
|
|
|
||||||
14
src/ipc.rs
14
src/ipc.rs
|
|
@ -56,6 +56,12 @@ pub enum Request {
|
||||||
npub: String,
|
npub: String,
|
||||||
label: 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<String>,
|
||||||
|
},
|
||||||
PublishNote {
|
PublishNote {
|
||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
|
|
@ -392,6 +398,14 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
Ok(json!({ "profile": summary, "report": report, "state": app.state_view() }))
|
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 } => {
|
Request::PublishNote { content } => {
|
||||||
let report =
|
let report =
|
||||||
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
||||||
|
|
|
||||||
80
src/main.rs
80
src/main.rs
|
|
@ -22,6 +22,10 @@ Commands:
|
||||||
publish-name <npub> Publish the profile's stored name so other clients show it
|
publish-name <npub> Publish the profile's stored name so other clients show it
|
||||||
set-picture <npub> <url> Set the profile picture (http(s) URL) and publish it
|
set-picture <npub> <url> Set the profile picture (http(s) URL) and publish it
|
||||||
rename <npub> <new-name> Rename a profile and publish the new name
|
rename <npub> <new-name> Rename a profile and publish the new name
|
||||||
|
set-nip05 <npub> <id|clear> Set the NIP-05 address (name@domain) and publish it;
|
||||||
|
pass 'clear' to remove it
|
||||||
|
nip05-file <npub> <id> 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);
|
feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50);
|
||||||
--contacts filters to the active profile's contacts
|
--contacts filters to the active profile's contacts
|
||||||
relays list List configured relays
|
relays list List configured relays
|
||||||
|
|
@ -72,6 +76,8 @@ async fn main() -> ExitCode {
|
||||||
"publish-name" => cli_publish_name(&args),
|
"publish-name" => cli_publish_name(&args),
|
||||||
"set-picture" => cli_set_picture(&args),
|
"set-picture" => cli_set_picture(&args),
|
||||||
"rename" => cli_rename(&args),
|
"rename" => cli_rename(&args),
|
||||||
|
"set-nip05" => cli_set_nip05(&args),
|
||||||
|
"nip05-file" => cli_nip05_file(&args),
|
||||||
"feed" => cli_feed(&args).await,
|
"feed" => cli_feed(&args).await,
|
||||||
"relays" => cli_relays(&args).await,
|
"relays" => cli_relays(&args).await,
|
||||||
"settings" => cli_settings(&args),
|
"settings" => cli_settings(&args),
|
||||||
|
|
@ -210,6 +216,76 @@ fn cli_rename(args: &[String]) -> Result<String, AppError> {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String, AppError> {
|
||||||
|
let npub = args
|
||||||
|
.get(2)
|
||||||
|
.ok_or_else(|| AppError::config("Usage: keynectr nip05-file <npub> <name@domain>"))?;
|
||||||
|
let identifier = args
|
||||||
|
.get(3)
|
||||||
|
.ok_or_else(|| AppError::config("Usage: keynectr nip05-file <npub> <name@domain>"))?;
|
||||||
|
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<serde_json::Value> =
|
||||||
|
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://<your-domain>/.well-known/nostr.json (Content-Type: application/json):\n\n{pretty}\n"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cli_set_nip05(args: &[String]) -> Result<String, AppError> {
|
||||||
|
let npub = args
|
||||||
|
.get(2)
|
||||||
|
.ok_or_else(|| AppError::config("Usage: keynectr set-nip05 <npub> <name@domain|clear>"))?;
|
||||||
|
let raw = args
|
||||||
|
.get(3)
|
||||||
|
.ok_or_else(|| AppError::config("Usage: keynectr set-nip05 <npub> <name@domain|clear>"))?;
|
||||||
|
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<String, AppError> {
|
fn cli_publish_name(args: &[String]) -> Result<String, AppError> {
|
||||||
let npub = args
|
let npub = args
|
||||||
.get(2)
|
.get(2)
|
||||||
|
|
@ -571,6 +647,7 @@ fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result<ProfileSummary
|
||||||
created_at: stored.created_at,
|
created_at: stored.created_at,
|
||||||
is_active: false,
|
is_active: false,
|
||||||
picture: stored.picture,
|
picture: stored.picture,
|
||||||
|
nip05: stored.nip05,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -603,7 +680,8 @@ fn cli_undo_delete() -> Result<String, AppError> {
|
||||||
public_key: restored.npub.clone(),
|
public_key: restored.npub.clone(),
|
||||||
secret_key: "".to_string(),
|
secret_key: "".to_string(),
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
picture: None,
|
picture: restored.picture,
|
||||||
|
nip05: restored.nip05,
|
||||||
};
|
};
|
||||||
app.vault.profiles.push(stored);
|
app.vault.profiles.push(stored);
|
||||||
if app.vault.active_profile.is_none() {
|
if app.vault.active_profile.is_none() {
|
||||||
|
|
|
||||||
230
src/profiles.rs
230
src/profiles.rs
|
|
@ -25,6 +25,8 @@ pub struct ProfileSummary {
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
/// Public URL of the profile picture, when one has been set.
|
/// Public URL of the profile picture, when one has been set.
|
||||||
pub picture: Option<String>,
|
pub picture: Option<String>,
|
||||||
|
/// NIP-05 identifier (e.g. `boo@l484.com`), when one has been set.
|
||||||
|
pub nip05: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A secret key revealed after the vault is unlocked, in both the raw hex and
|
/// 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,
|
secret_key: stored_secret,
|
||||||
created_at,
|
created_at,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_active = vault.active_profile.is_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.
|
// Best-effort: relay failures here never block profile creation.
|
||||||
let relay_urls = relays::enabled_urls(settings);
|
let relay_urls = relays::enabled_urls(settings);
|
||||||
if !relay_urls.is_empty() {
|
if !relay_urls.is_empty() {
|
||||||
publish_metadata_blocking(&keys, &label, None, relay_urls);
|
publish_metadata_blocking(&keys, &label, None, None, relay_urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ProfileSummary {
|
Ok(ProfileSummary {
|
||||||
|
|
@ -97,6 +100,7 @@ pub fn create_profile(
|
||||||
created_at,
|
created_at,
|
||||||
is_active,
|
is_active,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,6 +136,7 @@ pub fn publish_profile_metadata(
|
||||||
&keys,
|
&keys,
|
||||||
&stored.label,
|
&stored.label,
|
||||||
stored.picture.clone(),
|
stored.picture.clone(),
|
||||||
|
stored.nip05.clone(),
|
||||||
relay_urls,
|
relay_urls,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
@ -159,12 +164,13 @@ pub fn set_profile_picture(
|
||||||
|
|
||||||
let stored = find_profile_mut(vault, npub)?;
|
let stored = find_profile_mut(vault, npub)?;
|
||||||
stored.picture = url;
|
stored.picture = url;
|
||||||
let (label, npub, created_at, public_key, picture) = (
|
let (label, npub, created_at, public_key, picture, nip05) = (
|
||||||
stored.label.clone(),
|
stored.label.clone(),
|
||||||
stored.public_key.clone(),
|
stored.public_key.clone(),
|
||||||
stored.created_at,
|
stored.created_at,
|
||||||
stored.public_key.clone(),
|
stored.public_key.clone(),
|
||||||
stored.picture.clone(),
|
stored.picture.clone(),
|
||||||
|
stored.nip05.clone(),
|
||||||
);
|
);
|
||||||
drop(stored);
|
drop(stored);
|
||||||
let summary = ProfileSummary {
|
let summary = ProfileSummary {
|
||||||
|
|
@ -173,6 +179,7 @@ pub fn set_profile_picture(
|
||||||
created_at,
|
created_at,
|
||||||
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
|
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
|
||||||
picture,
|
picture,
|
||||||
|
nip05,
|
||||||
};
|
};
|
||||||
|
|
||||||
let relay_urls = relays::enabled_urls(settings);
|
let relay_urls = relays::enabled_urls(settings);
|
||||||
|
|
@ -189,8 +196,13 @@ pub fn set_profile_picture(
|
||||||
}
|
}
|
||||||
|
|
||||||
let keys = Keys::new(secret_key);
|
let keys = Keys::new(secret_key);
|
||||||
let report =
|
let report = publish_metadata_blocking(
|
||||||
publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls);
|
&keys,
|
||||||
|
&summary.label,
|
||||||
|
summary.picture.clone(),
|
||||||
|
summary.nip05.clone(),
|
||||||
|
relay_urls,
|
||||||
|
);
|
||||||
Ok((summary, report))
|
Ok((summary, report))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,11 +229,12 @@ pub fn rename_profile(
|
||||||
|
|
||||||
let stored = find_profile_mut(vault, npub)?;
|
let stored = find_profile_mut(vault, npub)?;
|
||||||
stored.label = trimmed.to_string();
|
stored.label = trimmed.to_string();
|
||||||
let (label, created_at, public_key, picture) = (
|
let (label, created_at, public_key, picture, nip05) = (
|
||||||
stored.label.clone(),
|
stored.label.clone(),
|
||||||
stored.created_at,
|
stored.created_at,
|
||||||
stored.public_key.clone(),
|
stored.public_key.clone(),
|
||||||
stored.picture.clone(),
|
stored.picture.clone(),
|
||||||
|
stored.nip05.clone(),
|
||||||
);
|
);
|
||||||
let summary = ProfileSummary {
|
let summary = ProfileSummary {
|
||||||
label,
|
label,
|
||||||
|
|
@ -229,6 +242,7 @@ pub fn rename_profile(
|
||||||
created_at,
|
created_at,
|
||||||
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
|
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
|
||||||
picture,
|
picture,
|
||||||
|
nip05,
|
||||||
};
|
};
|
||||||
|
|
||||||
let relay_urls = relays::enabled_urls(settings);
|
let relay_urls = relays::enabled_urls(settings);
|
||||||
|
|
@ -245,11 +259,110 @@ pub fn rename_profile(
|
||||||
}
|
}
|
||||||
|
|
||||||
let keys = Keys::new(secret_key);
|
let keys = Keys::new(secret_key);
|
||||||
let report =
|
let report = publish_metadata_blocking(
|
||||||
publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls);
|
&keys,
|
||||||
|
&summary.label,
|
||||||
|
summary.picture.clone(),
|
||||||
|
summary.nip05.clone(),
|
||||||
|
relay_urls,
|
||||||
|
);
|
||||||
Ok((summary, report))
|
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<String>,
|
||||||
|
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 (`<local-part>@<domain>`), 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<String, AppError> {
|
||||||
|
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.
|
/// Validate that a picture URL is a well-formed http(s) URL.
|
||||||
fn validate_picture_url(url: &str) -> Result<(), AppError> {
|
fn validate_picture_url(url: &str) -> Result<(), AppError> {
|
||||||
let parsed = Url::parse(url)
|
let parsed = Url::parse(url)
|
||||||
|
|
@ -290,6 +403,7 @@ fn publish_metadata_blocking(
|
||||||
keys: &Keys,
|
keys: &Keys,
|
||||||
label: &str,
|
label: &str,
|
||||||
picture: Option<String>,
|
picture: Option<String>,
|
||||||
|
nip05: Option<String>,
|
||||||
relay_urls: Vec<String>,
|
relay_urls: Vec<String>,
|
||||||
) -> MetadataPublishReport {
|
) -> MetadataPublishReport {
|
||||||
let keys = keys.clone();
|
let keys = keys.clone();
|
||||||
|
|
@ -297,7 +411,9 @@ fn publish_metadata_blocking(
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
tokio::runtime::Runtime::new()
|
tokio::runtime::Runtime::new()
|
||||||
.expect("metadata runtime")
|
.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()
|
.join()
|
||||||
.expect("metadata publish thread panicked")
|
.expect("metadata publish thread panicked")
|
||||||
|
|
@ -307,6 +423,7 @@ async fn publish_metadata_async(
|
||||||
keys: &Keys,
|
keys: &Keys,
|
||||||
label: &str,
|
label: &str,
|
||||||
picture: Option<String>,
|
picture: Option<String>,
|
||||||
|
nip05: Option<String>,
|
||||||
relay_urls: Vec<String>,
|
relay_urls: Vec<String>,
|
||||||
) -> MetadataPublishReport {
|
) -> MetadataPublishReport {
|
||||||
let mut metadata = Metadata::new().name(label).display_name(label);
|
let mut metadata = Metadata::new().name(label).display_name(label);
|
||||||
|
|
@ -315,6 +432,9 @@ async fn publish_metadata_async(
|
||||||
metadata = metadata.picture(parsed);
|
metadata = metadata.picture(parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(nip05) = &nip05 {
|
||||||
|
metadata = metadata.nip05(nip05);
|
||||||
|
}
|
||||||
let event = match EventBuilder::new(Kind::Metadata, metadata.as_json())
|
let event = match EventBuilder::new(Kind::Metadata, metadata.as_json())
|
||||||
.sign(keys)
|
.sign(keys)
|
||||||
.await
|
.await
|
||||||
|
|
@ -432,6 +552,7 @@ fn summary_for(vault: &Vault, profile: &StoredProfile) -> ProfileSummary {
|
||||||
created_at: profile.created_at,
|
created_at: profile.created_at,
|
||||||
is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()),
|
is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()),
|
||||||
picture: profile.picture.clone(),
|
picture: profile.picture.clone(),
|
||||||
|
nip05: profile.nip05.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -547,6 +668,7 @@ mod tests {
|
||||||
secret_key: "00".repeat(32),
|
secret_key: "00".repeat(32),
|
||||||
created_at: 1,
|
created_at: 1,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
});
|
});
|
||||||
vault.profiles.push(StoredProfile {
|
vault.profiles.push(StoredProfile {
|
||||||
label: "Bob".to_string(),
|
label: "Bob".to_string(),
|
||||||
|
|
@ -554,6 +676,7 @@ mod tests {
|
||||||
secret_key: "11".repeat(32),
|
secret_key: "11".repeat(32),
|
||||||
created_at: 2,
|
created_at: 2,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
});
|
});
|
||||||
vault
|
vault
|
||||||
}
|
}
|
||||||
|
|
@ -907,6 +1030,96 @@ mod tests {
|
||||||
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
|
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.
|
/// Delete a profile by npub, returning the deleted profile for undo.
|
||||||
/// The vault must not be encrypted, or the key must be provided.
|
/// The vault must not be encrypted, or the key must be provided.
|
||||||
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
|
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
|
||||||
|
|
@ -926,6 +1139,7 @@ mod tests {
|
||||||
created_at: stored.created_at,
|
created_at: stored.created_at,
|
||||||
is_active: false,
|
is_active: false,
|
||||||
picture: stored.picture,
|
picture: stored.picture,
|
||||||
|
nip05: stored.nip05,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ pub struct StoredProfile {
|
||||||
/// profiles stored before pictures were introduced.
|
/// profiles stored before pictures were introduced.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub picture: Option<String>,
|
pub picture: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// KDF parameters that encrypted a vault. Stored so future key-derivation
|
/// KDF parameters that encrypted a vault. Stored so future key-derivation
|
||||||
|
|
@ -457,6 +462,7 @@ mod tests {
|
||||||
secret_key: "00ff".to_string(),
|
secret_key: "00ff".to_string(),
|
||||||
created_at: 1_700_000_000,
|
created_at: 1_700_000_000,
|
||||||
picture: None,
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue