feat: nostr identity endpoints#95
Merged
Merged
Conversation
Add an `npub` field (bech32, nullable) to the two "who am I" responses so the frontend can show whether the logged-in account has a Nostr identity linked: - REST `GET /v4/users/me` (MeResponse) — also flows into the create-token and update-username responses via the shared struct. - RPC `whoami` (Res) — kept in sync with the REST surface. `user.npub` already exists on the model (migration 98); this is a purely additive projection change, no query or schema change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two Bearer-authenticated sub-resources for an account's Nostr link:
- GET /v4/users/me/nostr -> { npub } (or null) — lets a client poll
just the link state without the full /me payload.
- DELETE /v4/users/me/nostr -> clears the link via set_npub(None).
Idempotent: returns 200 { npub: null } even when nothing was linked,
since clearing your own link needs no NIP-98 proof.
Registered in the v4 users scope. Removes the now-unused #[allow(dead_code)]
on user::queries::set_npub. Linking/replacing a pubkey (PUT) lands
separately since it additionally requires a NIP-98 ownership proof.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot review on #95 flagged it: get_nostr never touches the pool — the npub is already on auth.user, and the Auth extractor reads the pool from app_data itself, so the Data<MainPool> handler param was dead weight. Removing it makes the signature honest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot review on #95 flagged the public REST docs as incomplete — the new identity sub-resource endpoints weren't listed. Add the Available Endpoints entries plus request/response sections for reading and clearing the linked npub. (PUT link/replace will be documented when that endpoint lands.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Links (or replaces) the Nostr pubkey on an already-authenticated account. This needs TWO credentials at once — a Bearer token to say which account, and a NIP-98 signature to prove control of the pubkey being linked — but Auth and NostrAuth both read the Authorization header. Resolve it by carrying the proof on a dedicated header: - Factor the NIP-98 verification in nostr_auth.rs into a shared `verified_npub(req, header)` helper. - Add a `NostrProof` extractor that reads `X-Nostr-Authorization` (new const `X_NOSTR_AUTHORIZATION`), reusing the same ApiBaseUrl-pinned verification as NostrAuth. NostrAuth keeps reading `Authorization`. Handler: Auth identifies the account, NostrProof proves the pubkey. Conflict is checked at the application level — if the proven npub is already linked to a different account, return 400; re-linking your own npub is an idempotent 200. The empty body keeps the NIP-98 binding to just `u`+`method` (the nip98 module does no body-hash binding). There is deliberately NO DB migration: with no UNIQUE index on user.npub yet, the select-then-set has a documented TOCTOU window that the maintainer-owned partial unique index is meant to close. Flagged in code and reserved for that follow-up rather than pre-empted here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the link/replace endpoint: the dual-credential requirement (Bearer + the NIP-98 proof on the X-Nostr-Authorization header), the exact u/method the proof must sign, and the 200/400/401 responses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The link endpoint's conflict check is a best-effort select-then-set with a documented TOCTOU window (no unique index on user.npub yet). Wrap the set_npub write so a `user.npub` UNIQUE violation maps to 400 instead of a generic 500, reusing nostr::is_unique_violation_on (now pub(crate)). No behavior change against today's schema — no index can fire — but the endpoint becomes race-safe automatically if the maintainer-owned partial unique index on user.npub lands later: the concurrent loser then gets the same 400 as the pre-check path, with no further code change. Extracts the shared 400 into a small npub_conflict() helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Harden the link-endpoint tests so they verify the safety property, not just the status code: - conflict-400: a regression that wrote the npub to the claimer *and* returned 400 would previously pass. Now reload both users and assert the claimer stays unlinked and the owner keeps the npub. - idempotent-same-user: reload and assert the npub is unchanged (not cleared) after a re-link. - replace test: use a freshly generated key for the prior npub instead of the non-bech32 "npub1old" literal, matching what production stores. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migration 101 is the access_token import_origins migration; there is no unique index on user.npub on master. Three comments (from the merged sign-in code in #89) claimed such an index exists and enforces uniqueness. Correct them to reflect reality: uniqueness is enforced at the call sites today, the index is an optional future add, and the in-code recovery branch becomes the atomic path only once it lands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI floats to the latest stable Rust (master removed rust-toolchain.toml), and 1.96 added/strengthened lints that fail the `clippy -- -D warnings` gate on pre-existing code: - unnecessary_sort_by -> sort_by_key(|x| Reverse(..)) in feed/atom.rs, rpc/analytics/get_top_clients.rs, rpc/get_area_dashboard.rs, rest/v4/activity.rs (all sort keys are Copy) - useless_conversion -> drop the redundant .into_iter() in rpc/set_area_icon.rs, rpc/set_area_tag.rs None of these are in the Nostr-identity code; they're master's, surfaced only under 1.96. Verified locally after `rustup update stable` to 1.96.0 to match CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot review on #95 flagged the Update Username response example as stale: update_username returns MeResponse::from(&user), so the response includes npub — and (via the From impl) always-empty saved_places / saved_areas. Update the example and field table to the real shape and note the saved arrays are empty on this endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bubelov
approved these changes
Jun 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Nostr identity endpoints (parity follow-up to #89)
Follow-up to the merged Nostr sign-in endpoint (#89). That PR let a Nostr key
mint a token; this one exposes the linked identity and lets an existing
account manage its Nostr link. All new surface is REST under
/v4/users/me; no new DB migration.What's in here
Expose the linked npub
GET /v4/users/me(MeResponse) gainsnpub: Option<String>(bech32, ornull). Flows into the create-token and update-username responses too viathe shared struct.
whoamiResgets the samenpubfield, so the two "who am I"surfaces stay in sync.
Manage the link (new sub-resource
/v4/users/me/nostr)GET→{ npub }— read the current link (Bearer only).DELETE→ clears it, idempotent200 { npub: null }(Bearer only — clearingyour own link needs no proof).
PUT→ links/replaces the pubkey on the account. Returns200 { npub };400if the npub is already linked to a different account; idempotent re-linkby the same account →
200.user.npubalready exists (migration 98);select_by_npub/set_npubalreadyexisted (the latter was
#[allow(dead_code)], now used). No migration, noschema.sqlchange.Decision that needs your eyes: the PUT dual-credential header
PUT /v4/users/me/nostrneeds two credentials at once — a Bearer token(which account) and a NIP-98 signature (proof you control the pubkey).
Both the
AuthandNostrAuthextractors read theAuthorizationheader, sothey can't share it. I resolved this by carrying the proof on a dedicated
header:
Implementation: factored the NIP-98 verification in
nostr_auth.rsinto ashared
verified_npub(req, header)helper, and added a thinNostrProofextractor that reads
X-Nostr-Authorization(sameApiBaseUrl-pinnedverification as
NostrAuth). The request body stays empty, so there's noSHA-256 body-binding gap. If you'd prefer a different header name or
mechanism, this is the piece to flag — happy to change it.
(Deliberately not the #83 approach of a
nostr_eventbody field —nip98.rswarns body endpoints need body-hash binding, and the client-supplied
urlparam there was the bot-flagged weakness.)
Conflict check is application-level (no unique index yet)
The
PUTconflict check isselect_by_npub→ compare →set_npub, which has adocumented TOCTOU window: two concurrent PUTs linking the same npub to
different accounts could both pass the check. This is intentional for now and
called out in a code comment — the partial unique index on
user.npub(yourin-flight work, closed as #93) is what closes it, after which a
set_npubUNIQUE violation could map to
400. I did not add that index or a migrationhere, to stay off your toes.
Heads-up (not touched): stale comment in the merged sign-in code
src/rest/v4/nostr.rs(merged in #89) has a doc-comment claiming the npubunique index comes "from migration 101" — but on
master, 101 is theaccess_token.import_originsmigration and no such index exists. Therecovery branch it describes is currently dormant. Flagging only; left it
alone since it's coupled to whenever the index actually lands.
Out of scope
create_api_key_with_nostradmin RPC — redundant withPOST /v4/auth/nostr.user.npubunique index / any migration — yours.sub-resource.