diff --git a/openapi.json b/openapi.json index 6b0c04a..ad70336 100644 --- a/openapi.json +++ b/openapi.json @@ -269,6 +269,65 @@ ] } }, + "/auth/nostr/register": { + "post": { + "tags": [ + "auth" + ], + "summary": "npub-native registration. Verifies a NIP-98 signed-action over a server nonce,\nre-runs the abuse gates keyed on the npub (a create path must not bypass the\nPoW/invite/payment gates that `/auth/extension` enforces), then get-or-creates\nby npub with a `seed_fingerprint` MERGE so a wallet that already has a row\n(BTC-anchored, or previously linked) is never split into two identities.", + "description": "SECURITY NOTE: the proof binds an EMPTY-body descriptor (the same contract as\n`/auth/nostr/link`), so replay is prevented by the single-use nonce and npub\ncontrol is proven, but the chain `keys` are TLS-bound rather than\nproof-bound. Binding the key list into the signed payload is a documented\nhardening follow-up (defends a TLS-breaking active MITM swapping the keys).", + "operationId": "nostr_register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NostrRegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Wallet registered or authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "401": { + "description": "Invalid proof or nonce" + }, + "409": { + "description": "Seed already registered under a different Nostr identity" + } + } + } + }, + "/auth/nostr/register-challenge": { + "get": { + "tags": [ + "auth" + ], + "summary": "Issue a single-use nonce for npub-native registration. UNAUTHENTICATED: no\nuser exists yet, so (unlike the link challenge) the nonce is subject-less; the\nregister signed-action binds it purely as replay protection.", + "operationId": "nostr_register_challenge", + "responses": { + "200": { + "description": "Single-use register nonce", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NostrLinkChallengeResponse" + } + } + } + } + } + } + }, "/auth/payment-invoice": { "post": { "tags": [ @@ -3088,6 +3147,81 @@ } } }, + "NostrRegisterRequest": { + "type": "object", + "description": "Register (or resolve) a wallet keyed by its **Nostr identity**: the\nself-sovereign, npub-native create path. Unlike [`nostr_login`] (create-never)\nand [`nostr_link`] (needs a prior BTC-authed JWT), this MINTS a user from the\nnpub alone: no BTC signature is ever required. The npub is the identity anchor;\nthe chain `keys` still ship (for tip addresses + restore) but are NOT the auth\nproof.", + "required": [ + "nostr_token", + "nonce", + "keys" + ], + "properties": { + "altcha_solution": { + "type": "object" + }, + "invite_code": { + "type": [ + "string", + "null" + ] + }, + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetPublicKey" + }, + "description": "Chain public keys (for tip addresses + restore). A `btc` key is expected\nfor pay-to-register invoice binding, but it is no longer the identity." + }, + "nonce": { + "type": "string", + "description": "The server-issued single-use nonce (from `/auth/nostr/register-challenge`)." + }, + "nostr_token": { + "type": "string", + "description": "`Nostr ` signed-action token proving control of the npub AND\nbinding the server nonce + the `nostr_register` purpose." + }, + "payment_invoice_id": { + "type": [ + "string", + "null" + ] + }, + "seed_fingerprint": { + "type": [ + "string", + "null" + ] + }, + "username": { + "type": [ + "string", + "null" + ] + }, + "wallet_birthday": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Wallet creation time (unix seconds), to bound chain scans." + }, + "wow_start_height": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "xmr_start_height": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + }, "OkResponse": { "type": "object", "required": [ diff --git a/src/api/auth.rs b/src/api/auth.rs index 52028e0..7312faa 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1520,6 +1520,191 @@ pub async fn nostr_link( })) } +// ── GET /auth/nostr/register-challenge (issue the register nonce) ───────────── + +/// Issue a single-use nonce for npub-native registration. UNAUTHENTICATED: no +/// user exists yet, so (unlike the link challenge) the nonce is subject-less; the +/// register signed-action binds it purely as replay protection. +#[utoipa::path( + get, + path = "/auth/nostr/register-challenge", + responses((status = 200, description = "Single-use register nonce", body = NostrLinkChallengeResponse)), + tag = "auth" +)] +#[instrument(skip(state))] +pub async fn nostr_register_challenge( + State(state): State>, +) -> Result, AppError> { + let nonce = state + .db + .issue_challenge("nostr_register", None, NOSTR_LINK_NONCE_TTL_SECS) + .await?; + Ok(Json(NostrLinkChallengeResponse { nonce })) +} + +// ── POST /auth/nostr/register (npub-native create) ─────────────────────────── + +/// Register (or resolve) a wallet keyed by its **Nostr identity**: the +/// self-sovereign, npub-native create path. Unlike [`nostr_login`] (create-never) +/// and [`nostr_link`] (needs a prior BTC-authed JWT), this MINTS a user from the +/// npub alone: no BTC signature is ever required. The npub is the identity anchor; +/// the chain `keys` still ship (for tip addresses + restore) but are NOT the auth +/// proof. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct NostrRegisterRequest { + /// `Nostr ` signed-action token proving control of the npub AND + /// binding the server nonce + the `nostr_register` purpose. + pub nostr_token: String, + /// The server-issued single-use nonce (from `/auth/nostr/register-challenge`). + pub nonce: String, + /// Chain public keys (for tip addresses + restore). A `btc` key is expected + /// for pay-to-register invoice binding, but it is no longer the identity. + pub keys: Vec, + pub username: Option, + /// Wallet creation time (unix seconds), to bound chain scans. + pub wallet_birthday: Option, + pub seed_fingerprint: Option, + pub xmr_start_height: Option, + pub wow_start_height: Option, + #[serde(default)] + #[schema(value_type = Object)] + pub altcha_solution: Option, + #[serde(default)] + pub invite_code: Option, + #[serde(default)] + pub payment_invoice_id: Option, +} + +/// npub-native registration. Verifies a NIP-98 signed-action over a server nonce, +/// re-runs the abuse gates keyed on the npub (a create path must not bypass the +/// PoW/invite/payment gates that `/auth/extension` enforces), then get-or-creates +/// by npub with a `seed_fingerprint` MERGE so a wallet that already has a row +/// (BTC-anchored, or previously linked) is never split into two identities. +/// +/// SECURITY NOTE: the proof binds an EMPTY-body descriptor (the same contract as +/// `/auth/nostr/link`), so replay is prevented by the single-use nonce and npub +/// control is proven, but the chain `keys` are TLS-bound rather than +/// proof-bound. Binding the key list into the signed payload is a documented +/// hardening follow-up (defends a TLS-breaking active MITM swapping the keys). +#[utoipa::path( + post, + path = "/auth/nostr/register", + request_body = NostrRegisterRequest, + responses( + (status = 200, description = "Wallet registered or authenticated", body = AuthResponse), + (status = 401, description = "Invalid proof or nonce"), + (status = 409, description = "Seed already registered under a different Nostr identity") + ), + tag = "auth" +)] +#[instrument(skip(state, headers, req, peer))] +pub async fn nostr_register( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let ip = client_ip(&state, &headers, peer); + if let Some(ref username) = req.username { + validate_username(username)?; + } + + // 1. Atomically consume the subject-less register nonce (single-use replay + // guard). Consume BEFORE proof verification so a failed proof burns it. + let _consumed = state + .db + .consume_challenge(&req.nonce, "nostr_register") + .await? + .ok_or_else(|| AppError::AuthError("Invalid or expired nonce".into()))?; + + // 2. Prove npub control + bind the nonce/purpose. Empty-body descriptor + // contract (mirrors /auth/nostr/link). The verifier lowercases the pubkey. + let url = nip98_url(&state, "/auth/nostr/register")?; + let descriptor = request_descriptor("POST", "/api/v1/auth/nostr/register", "", b""); + let payload_sha256 = descriptor_sha256(&descriptor); + let pubkey = verify_signed_action( + &req.nostr_token, + &url, + "POST", + "nostr_register", + &req.nonce, + &payload_sha256, + None, + None, + Utc::now().timestamp(), + NIP98_ACTION_MAX_AGE_SECS, + ) + .map_err(|_| AppError::AuthError("Invalid Nostr proof".into()))?; + + // 3. Returning npub bypasses the abuse gates, exactly like a returning + // pubkey_hash on the BTC path. + let returning = state.db.find_user_by_nostr_pubkey(&pubkey).await?.is_some(); + + // 4. Re-run the abuse gates on the npub path; a user-minting endpoint must + // not be a gate bypass. PoW keys on the npub (the per-pubkey allowlist is + // BTC-only, so only the GLOBAL requirement applies here). Pay-to-register + // invoices bind to sha256(btc pubkey), so the payment check still uses the + // BTC key carried in `keys` even though it is no longer the identity. + enforce_pow(&state, &pubkey, returning, req.altcha_solution.as_ref())?; + let btc_hash = req + .keys + .iter() + .find(|k| k.asset.eq_ignore_ascii_case("btc")) + .map(|k| hash_public_key(&k.public_key)); + enforce_method_gates( + &state, + returning, + btc_hash.as_deref().unwrap_or(&pubkey), + req.invite_code.as_deref(), + req.payment_invoice_id.as_deref(), + ) + .await?; + + // 5. Get-or-create by npub (seed_fingerprint MERGE dedup inside). + let wallet_birthday = req + .wallet_birthday + .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)); + let user = state + .db + .get_or_create_user_by_nostr_pubkey( + &pubkey, + req.username.clone(), + wallet_birthday, + req.seed_fingerprint.clone(), + req.xmr_start_height, + req.wow_start_height, + ) + .await?; + + // 6. Session. + let pair = issue_session( + &state, + user.id, + Platform::Nostr, + "Nostr", + Some(IpNetwork::from(ip)), + ) + .await?; + let _ = state + .db + .record_login_event( + Some(user.id), + "nostr", + Platform::Nostr.as_str(), + None, + Some(&ip.to_string()), + ) + .await; + info!(user_id = %user.id, is_new = %!returning, "npub-native register/auth (NIP-98)"); + Ok(Json(AuthResponse { + access_token: pair.access_token, + refresh_token: pair.refresh_token, + expires_in: pair.expires_in, + user: user_info(&user), + is_new: !returning, + })) +} + // ── router ─────────────────────────────────────────────────────────────────── /// Auth routes, RELATIVE to the `/api/v1` mount point. The application is @@ -1536,6 +1721,8 @@ pub fn routes() -> Router> { .route("/auth/logout", post(logout)) .route("/auth/me", get(get_me)) .route("/auth/nostr", post(nostr_login)) + .route("/auth/nostr/register-challenge", get(nostr_register_challenge)) + .route("/auth/nostr/register", post(nostr_register)) .route("/auth/nostr/link-challenge", get(nostr_link_challenge)) .route("/auth/nostr/link", post(nostr_link)) } diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 5b2603c..bc2ded3 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -27,6 +27,8 @@ use utoipa::OpenApi; crate::api::auth::logout, crate::api::auth::get_me, crate::api::auth::nostr_login, + crate::api::auth::nostr_register_challenge, + crate::api::auth::nostr_register, crate::api::auth::nostr_link_challenge, crate::api::auth::nostr_link, crate::api::website::website_challenge, @@ -93,6 +95,7 @@ use utoipa::OpenApi; crate::api::auth::NostrLinkChallengeResponse, crate::api::auth::NostrLinkRequest, crate::api::auth::NostrLinkResponse, + crate::api::auth::NostrRegisterRequest, crate::api::website::WebsiteChallengeRequest, crate::api::website::WebsiteChallengeResponse, crate::api::website::AssetSignature, diff --git a/src/infra/db/users.rs b/src/infra/db/users.rs index 3b27311..ca9e112 100644 --- a/src/infra/db/users.rs +++ b/src/infra/db/users.rs @@ -293,6 +293,150 @@ impl Database { .await } + /// Get-or-create a user keyed by their **Nostr pubkey**: the npub-native + /// registration path for the self-sovereign backend (no BTC signature). + /// `nostr_pubkey` is lowercase x-only hex, stored RAW (public, never peppered, + /// mirroring [`find_user_by_nostr_pubkey`]). + /// + /// DEDUP is load-bearing: a wallet may already have a row from the BTC path or + /// a prior link, joined only by `seed_fingerprint` (derivation-independent). A + /// naive insert would collide on the `seed_fingerprint` UNIQUE and split one + /// wallet into two identities. So, in order: + /// 1. npub already known -> that user (COALESCE-backfill the optional fields). + /// 2. else `seed_fingerprint` matches an existing row -> MERGE: backfill the + /// npub onto THAT row. If the row already carries a DIFFERENT npub the + /// wallet is already registered under another identity -> fail closed. + /// 3. else create a fresh npub-keyed row (`pubkey_hash` NULL). + #[instrument(skip(self))] + pub async fn get_or_create_user_by_nostr_pubkey( + &self, + nostr_pubkey: &str, + username: Option, + wallet_birthday: Option>, + seed_fingerprint: Option, + xmr_start_height: Option, + wow_start_height: Option, + ) -> Result { + // 1. Known npub -> that user, backfilling any newly-supplied optionals. + if let Some(existing) = self.find_user_by_nostr_pubkey(nostr_pubkey).await? { + return self + .backfill_optionals( + existing, + wallet_birthday, + seed_fingerprint, + xmr_start_height, + wow_start_height, + ) + .await; + } + + // 2. DEDUP: same seed already has a row (BTC-anchored or link-less) -> MERGE + // the npub onto it rather than split the identity. + if let Some(fp) = seed_fingerprint.as_deref() { + if let Some(existing) = self.get_user_by_seed_fingerprint(fp).await? { + match existing.nostr_pubkey.as_deref() { + Some(pk) if pk == nostr_pubkey => { + // Row already carries this npub (find-by-npub missed only if + // the columns disagree) so treat as the known user. + return self + .backfill_optionals( + existing, + wallet_birthday, + None, + xmr_start_height, + wow_start_height, + ) + .await; + } + Some(_) => { + // Same seed, different npub on file (e.g. a rotation): do not + // silently re-point on an unauthenticated register. + return Err(AppError::Conflict( + "This wallet is already registered under a different Nostr identity. Sign in instead." + .into(), + )); + } + None => { + let sql = format!( + "UPDATE users SET \ + nostr_pubkey = $2, \ + wallet_birthday = COALESCE(wallet_birthday, $3), \ + xmr_start_height = COALESCE(xmr_start_height, $4), \ + wow_start_height = COALESCE(wow_start_height, $5), \ + updated_at = NOW() \ + WHERE id = $1 RETURNING {USER_COLS}" + ); + let updated = sqlx::query_as::<_, User>(&sql) + .bind(existing.id) + .bind(nostr_pubkey) + .bind(wallet_birthday) + .bind(xmr_start_height) + .bind(wow_start_height) + .fetch_one(self.pool()) + .await + .map_err(unique_violation_as( + "This Nostr identity is already linked to another account", + ))?; + return Ok(updated); + } + } + } + } + + // 3. Fresh npub-keyed identity (no BTC anchor). + self.create_user(NewUser { + username, + pubkey_hash: None, + nostr_pubkey: Some(nostr_pubkey.to_string()), + wallet_birthday, + seed_fingerprint, + xmr_start_height, + wow_start_height, + }) + .await + } + + /// COALESCE-backfill only the NULL optional fields on an existing user row, + /// returning it unchanged when nothing is newly supplied. Shared by the + /// BTC and npub get-or-create paths. + async fn backfill_optionals( + &self, + existing: User, + wallet_birthday: Option>, + seed_fingerprint: Option, + xmr_start_height: Option, + wow_start_height: Option, + ) -> Result { + let needs = existing.wallet_birthday.is_none() && wallet_birthday.is_some() + || existing.seed_fingerprint.is_none() && seed_fingerprint.is_some() + || existing.xmr_start_height.is_none() && xmr_start_height.is_some() + || existing.wow_start_height.is_none() && wow_start_height.is_some(); + if !needs { + return Ok(existing); + } + let peppered_fp = seed_fingerprint + .as_deref() + .map(|v| self.pepper("seed_fingerprint", v)); + let sql = format!( + "UPDATE users SET \ + wallet_birthday = COALESCE(wallet_birthday, $2), \ + seed_fingerprint = COALESCE(seed_fingerprint, $3), \ + xmr_start_height = COALESCE(xmr_start_height, $4), \ + wow_start_height = COALESCE(wow_start_height, $5), \ + updated_at = NOW() \ + WHERE id = $1 RETURNING {USER_COLS}" + ); + let updated = sqlx::query_as::<_, User>(&sql) + .bind(existing.id) + .bind(wallet_birthday) + .bind(peppered_fp) + .bind(xmr_start_height) + .bind(wow_start_height) + .fetch_one(self.pool()) + .await?; + Ok(updated) + } + /// Update a user's username. UNIQUE collision -> 409 CONFLICT. #[instrument(skip(self))] pub async fn update_username( @@ -325,13 +469,15 @@ impl Database { Ok(()) } - /// Count registered wallets (users with a pubkey_hash). + /// Count registered wallets. A wallet is anchored by EITHER a BTC pubkey_hash + /// (legacy/BTC path) OR a nostr_pubkey (npub-native path), so count both; an + /// npub-only user is still a registered wallet. #[instrument(skip(self))] pub async fn get_user_count(&self) -> Result { - Ok( - sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE pubkey_hash IS NOT NULL") - .fetch_one(self.pool()) - .await?, + Ok(sqlx::query_scalar( + "SELECT COUNT(*) FROM users WHERE pubkey_hash IS NOT NULL OR nostr_pubkey IS NOT NULL", ) + .fetch_one(self.pool()) + .await?) } } diff --git a/tests/nostr_register.rs b/tests/nostr_register.rs new file mode 100644 index 0000000..31cd21f --- /dev/null +++ b/tests/nostr_register.rs @@ -0,0 +1,194 @@ +//! L1 integration: npub-native registration (`POST /auth/nostr/register`) — the +//! create-by-npub path (no BTC signature) and the `seed_fingerprint` MERGE dedup +//! that must never split one wallet into two identities. Skips without +//! `TEST_DATABASE_URL` (see `common`). + +mod common; + +use axum::http::StatusCode; +use base64::Engine; +use k256::schnorr::SigningKey; +use rand::rngs::OsRng; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use smirk_backend_core::core::crypto::nip98::{descriptor_sha256, request_descriptor}; + +const KIND: u32 = 27235; + +fn random_signer() -> SigningKey { + loop { + let mut b = [0u8; 32]; + OsRng.fill_bytes(&mut b); + if let Ok(sk) = SigningKey::from_bytes(&b) { + return sk; + } + } +} + +/// Build the `Nostr ` register signed-action (empty-body +/// descriptor contract, purpose `nostr_register`). +fn sign_register(sk: &SigningKey, url: &str, nonce: &str) -> String { + let pk = hex::encode(sk.verifying_key().to_bytes()); + let payload = descriptor_sha256(&request_descriptor( + "POST", + "/api/v1/auth/nostr/register", + "", + b"", + )); + let tags = vec![ + vec!["u".to_string(), url.to_string()], + vec!["method".to_string(), "POST".to_string()], + vec!["purpose".to_string(), "nostr_register".to_string()], + vec!["challenge".to_string(), nonce.to_string()], + vec!["payload".to_string(), payload], + ]; + let created_at = chrono::Utc::now().timestamp(); + let serial = + serde_json::to_string(&serde_json::json!([0, pk, created_at, KIND, tags, ""])).unwrap(); + let id = hex::encode(Sha256::digest(serial.as_bytes())); + let sig = sk.sign_raw(&hex::decode(&id).unwrap(), &[0u8; 32]).unwrap(); + let ev = serde_json::json!({ + "id": id, "pubkey": pk, "created_at": created_at, + "kind": KIND, "tags": tags, "content": "", "sig": hex::encode(sig.to_bytes()) + }); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&ev).unwrap()) + ) +} + +/// Full register round-trip: fetch a nonce, sign it, POST the register body. +async fn register( + app: &common::TestApp, + sk: &SigningKey, + base: &str, + seed_fingerprint: Option<&str>, +) -> (StatusCode, serde_json::Value) { + let (_s, chal) = app + .request("GET", "/api/v1/auth/nostr/register-challenge", None, None) + .await; + let nonce = chal["nonce"].as_str().unwrap().to_string(); + let url = format!("{}/auth/nostr/register", base); + let token = sign_register(sk, &url, &nonce); + let pk = hex::encode(sk.verifying_key().to_bytes()); + let body = serde_json::json!({ + "nostr_token": token, + "nonce": nonce, + "keys": [{ "asset": "btc", "public_key": format!("btcpub-{}", &pk[..16]) }], + "seed_fingerprint": seed_fingerprint, + }); + app.request("POST", "/api/v1/auth/nostr/register", None, Some(body)) + .await +} + +#[tokio::test] +async fn register_creates_user_by_npub_and_is_idempotent() { + let app = require_app!(); + let base = app + .state + .config + .identity + .public_api_url + .clone() + .expect("PUBLIC_API_URL"); + let sk = random_signer(); + let pk = hex::encode(sk.verifying_key().to_bytes()); + + // First register mints a user keyed by the npub — no BTC signature. + let (status, body) = register(&app, &sk, &base, None).await; + assert_eq!(status, StatusCode::OK, "body={body}"); + assert_eq!(body["is_new"], true); + assert!(body["access_token"].as_str().is_some()); + assert!(app + .state + .db + .find_user_by_nostr_pubkey(&pk) + .await + .unwrap() + .is_some()); + + // Re-register with the same npub resolves the same user (is_new=false). + let (s2, b2) = register(&app, &sk, &base, None).await; + assert_eq!(s2, StatusCode::OK); + assert_eq!(b2["is_new"], false); +} + +#[tokio::test] +async fn register_replays_are_rejected() { + let app = require_app!(); + let base = app + .state + .config + .identity + .public_api_url + .clone() + .expect("PUBLIC_API_URL"); + let sk = random_signer(); + let (_s, chal) = app + .request("GET", "/api/v1/auth/nostr/register-challenge", None, None) + .await; + let nonce = chal["nonce"].as_str().unwrap().to_string(); + let url = format!("{}/auth/nostr/register", base); + let token = sign_register(&sk, &url, &nonce); + let pk = hex::encode(sk.verifying_key().to_bytes()); + let body = serde_json::json!({ + "nostr_token": token, "nonce": nonce, + "keys": [{ "asset": "btc", "public_key": format!("btcpub-{}", &pk[..16]) }], + }); + let (s1, _) = app + .request("POST", "/api/v1/auth/nostr/register", None, Some(body.clone())) + .await; + assert_eq!(s1, StatusCode::OK); + // Same nonce again -> consumed -> rejected (single-use replay guard). + let (s2, _) = app + .request("POST", "/api/v1/auth/nostr/register", None, Some(body)) + .await; + assert_ne!(s2, StatusCode::OK, "a consumed nonce must not register again"); +} + +#[tokio::test] +async fn register_merges_onto_existing_seed_fingerprint_row() { + let app = require_app!(); + let base = app + .state + .config + .identity + .public_api_url + .clone() + .expect("PUBLIC_API_URL"); + + // A pre-existing BTC-anchored row carrying a seed_fingerprint (no npub yet). + let fp = format!("fp-{}", uuid::Uuid::new_v4()); + let existing = app + .state + .db + .get_or_create_user_by_pubkey_hash( + &format!("pkh-{}", uuid::Uuid::new_v4()), + None, + None, + Some(fp.clone()), + None, + None, + ) + .await + .unwrap(); + assert!(existing.nostr_pubkey.is_none()); + + // Register npub-native with the SAME seed -> MERGE onto that row, not a new one. + let sk = random_signer(); + let pk = hex::encode(sk.verifying_key().to_bytes()); + let (status, body) = register(&app, &sk, &base, Some(&fp)).await; + assert_eq!(status, StatusCode::OK, "body={body}"); + + let merged = app + .state + .db + .find_user_by_nostr_pubkey(&pk) + .await + .unwrap() + .expect("npub resolves after register"); + assert_eq!( + merged.id, existing.id, + "npub register must MERGE onto the existing seed_fingerprint row, not split identity" + ); +}