From b4935b15e85de8e8fdec6f3ff2453afcbfb56855 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 2 Jul 2026 13:49:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(admin):=20migrate-legacy=20=E2=80=94=20imp?= =?UTF-8?q?ort=20v0.2.x=20user=20identity=20into=20v0.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `smirk-admin migrate-legacy --source-url [--commit] [--limit N]` copies user identity from a legacy v0.2.x backend database into this v0.3 DB, so users keep their handle (and NIP-05 name@domain) when they move to a v0.3 client. - Identity-only, joined on the derivation-independent seed_fingerprint; also carries pubkey_hash (v3 wallets reclaim their row with no rotation step), username, nostr_pubkey, wallet_birthday and chain restore heights. Socials/ tips/keys/wallets are intentionally not carried (no v0.3 home, or re-registered by the client on first unlock). - pubkey_hash + seed_fingerprint are peppered at rest, so the importer builds its target with the real SEED_FINGERPRINT_PEPPER (run with the v0.3 .env). - Idempotent (skips users already present by either peppered identity column), dry-run unless --commit, best-effort per row: a transient error or a concurrent native onboarding is reconciled, never aborting the pass. Reserved/taken/ malformed usernames and already-linked npubs are dropped; the user still lands. - Exports validate_username so the importer applies the identical format + reserved-name rules the HTTP handler does. - docs/operations/LEGACY_MIGRATION.md + scripts/test-legacy-migration.sh, a self-contained E2E that seeds alice/bob/carol plus edge cases and asserts import, peppering, collision handling, and idempotency. Reviewed adversarially (correctness / security / operational) before commit. --- docs/operations/LEGACY_MIGRATION.md | 96 +++++++++ scripts/test-legacy-migration.sh | 100 +++++++++ src/api/users.rs | 6 +- src/bin/smirk-admin.rs | 312 +++++++++++++++++++++++++++- 4 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 docs/operations/LEGACY_MIGRATION.md create mode 100755 scripts/test-legacy-migration.sh diff --git a/docs/operations/LEGACY_MIGRATION.md b/docs/operations/LEGACY_MIGRATION.md new file mode 100644 index 0000000..89689af --- /dev/null +++ b/docs/operations/LEGACY_MIGRATION.md @@ -0,0 +1,96 @@ +# Legacy (v0.2.x → v0.3) user migration + +`smirk-admin migrate-legacy` imports **user identity** from a legacy v0.2.x Smirk +backend database into this v0.3 backend, so users keep their handle (and their +NIP-05 `name@your-domain`) when they move to a v0.3 client. + +It exists because v0.3 is a fresh, clean, non-custodial backend with its own +database — nothing is shared with v0.2.x automatically. Funds are on-chain and +seed-derived, so they never need migrating; the only thing worth carrying is the +**identity** a user chose (their username) plus the small hints that make first +sync fast (wallet birthday, chain restore heights). + +## What it migrates + +Joined on the **`seed_fingerprint`** (`SHA256(SHA256(bip39_seed))`) — the one +identifier that is stable for the same human across both backends and across key +rotation: + +| Column | Carried? | Why | +|---|---|---| +| `seed_fingerprint` | ✅ | the join key; lets a v0.3 login find the imported row | +| `pubkey_hash` | ✅ | so a **v3-derivation** wallet reclaims its row on the first `/auth/extension` with no rotation step | +| `username` | ✅ | the whole point — preserves `name@your-domain` (NIP-05) | +| `nostr_pubkey` | ✅ | preserves an already-linked Nostr identity; dropped only if that npub is already linked to a *different* v0.3 user (they re-link on v0.3) | +| `wallet_birthday`, `xmr_start_height`, `wow_start_height` | ✅ | bound the first chain scan | +| socials (telegram/discord/twitter/…) | ❌ | v0.3 identity is Nostr-native; there are no social columns to import into | +| tips / tip-links | ❌ | the v0.3 public backend has no tips subsystem | +| `user_keys` / `wallets` | ❌ | the v0.3 client re-registers these at v3 derivation on first unlock; importing legacy (possibly v1/v2) keys would just plant stale rows | + +`pubkey_hash` and `seed_fingerprint` are **peppered at rest** by the DB layer, so +the importer builds its target connection with the real `SEED_FINGERPRINT_PEPPER` +— run it with the v0.3 server's own `.env` (see below), or the imported rows +won't match what the live server computes. + +## How a user reclaims their row on first v0.3 login + +- **v3-derivation users** (the common case, and all fresh wallets): the client + sends the same `pubkey_hash`; `get_or_create_user_by_pubkey_hash` finds the + imported row → **instant reclaim, username intact**. +- **v1/v2-derivation users**: their `pubkey_hash` differs, but the + `seed_fingerprint` matches → v0.3's existing **key-rotation path** reclaims the + row (the client signs with the old key). No data is lost either way; at worst a + user briefly lands on a fresh row and re-picks a username. + +## Running it + +Prerequisites: shell access to the v0.3 host, the v0.3 `.env` (for +`DATABASE_URL` + `SEED_FINGERPRINT_PEPPER` + `ADMIN_KEY_INTEGRITY_SECRET`), and a +read path to the legacy Postgres database (same cluster is easiest). + +```bash +cd /path/to/smirk-backend-core # so .env is loaded and readable-only (chmod 600) + +# 1. Dry run — reports exactly what WOULD be imported, no writes: +smirk-admin migrate-legacy --source-url 'postgres://USER:PASS@localhost/smirk_db' + +# 2. Apply: +smirk-admin migrate-legacy --source-url 'postgres://USER:PASS@localhost/smirk_db' --commit +``` + +Flags: + +- `--source-url ` — the **legacy** database (read-only; never written). +- `--commit` — actually write. Omit for a dry run (the default). +- `--limit ` — import only the first `n` legacy users (useful for a staged test). + +## Properties & safety + +- **Read-only on the source.** The legacy DB is only `SELECT`ed. +- **Dry-run by default.** Nothing is written without `--commit`. +- **Idempotent / re-runnable.** A legacy user already present (matched by peppered + `pubkey_hash` *or* `seed_fingerprint`) is skipped. Run it repeatedly during the + transition and once more at cutover; already-migrated and already-on-v0.3-natively + users are left untouched (never clobbered). +- **Username / npub collisions are non-fatal.** A legacy `username` that is + reserved, malformed, or already taken — or a legacy `nostr_pubkey` already linked + to a different v0.3 user — is dropped (reported), and the user is still imported + without it. They re-pick a handle / re-link their npub on v0.3. +- **Users with no username or no `seed_fingerprint`** are still imported (matched + later by `pubkey_hash`, the identity anchor). A legacy row with **no + `pubkey_hash`** is skipped — there is nothing to anchor it to. +- **Best-effort per row.** A transient DB error or a concurrent native onboarding + on one user is reported and skipped/reconciled — it never aborts the whole pass. +- **The source DB is trusted.** `pubkey_hash` / `seed_fingerprint` / `username` / + `nostr_pubkey` are imported verbatim, so only point `--source-url` at your own + v0.2.x database — never an untrusted dump. + +## Transition strategy + +Run v0.3 **alongside** v0.2.x (see the deployment notes). During the transition, +re-run `migrate-legacy` periodically so newly-active legacy users are present on +v0.3 before they upgrade their client; do a final pass at cutover; then retire the +legacy backend once its active users reach ~zero. + +A reproducible end-to-end test of this flow lives in +[`scripts/test-legacy-migration.sh`](../../scripts/test-legacy-migration.sh). diff --git a/scripts/test-legacy-migration.sh b/scripts/test-legacy-migration.sh new file mode 100755 index 0000000..8952ea5 --- /dev/null +++ b/scripts/test-legacy-migration.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# End-to-end test for `smirk-admin migrate-legacy` (v0.2.x → v0.3 user import). +# +# Spins up two throwaway Postgres databases (a legacy-schema source and a +# freshly-migrated v0.3 target), seeds the source with alice/bob/carol plus edge +# cases (reserved name, anonymous, null-fingerprint, target-username collision, +# uppercase-normalization collision, over-length name, carried nostr_pubkey, and +# a nostr_pubkey collision), runs the importer, and asserts the outcome. Cleans +# up after itself. +# +# Usage: scripts/test-legacy-migration.sh +# Env: PG_BASE Postgres base URL WITHOUT a dbname (default: local socket). +# e.g. PG_BASE='postgres://user:pass@localhost' scripts/… +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PG_BASE="${PG_BASE:-postgresql://?host=/var/run/postgresql}" +SRC_DB=smirk_legacy_src_test +TGT_DB=smirk_v3_mig_test +PEPPER="test-pepper-0123456789abcdef-0123456789" +ADMIN_SECRET="test-admin-secret-0123456789abcdef-xyz" +BIN="$ROOT/target/debug/smirk-admin" + +# PG_BASE may be "scheme://host?params" or "scheme://user:pass@host"; splice the +# dbname in before any '?'. +dburl() { local db="$1"; if [[ "$PG_BASE" == *\?* ]]; then echo "${PG_BASE/\?//$db?}"; else echo "$PG_BASE/$db"; fi; } +ADMIN=$(dburl postgres); SRC=$(dburl "$SRC_DB"); TGT=$(dburl "$TGT_DB") +h() { printf '%s' "$1" | sha256sum | cut -d' ' -f1; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +cleanup() { psql "$ADMIN" -q -c "DROP DATABASE IF EXISTS $SRC_DB;" -c "DROP DATABASE IF EXISTS $TGT_DB;" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +echo "==> build smirk-admin" +[ -x "$BIN" ] || cargo build --manifest-path "$ROOT/Cargo.toml" --bin smirk-admin + +echo "==> (re)create databases" +cleanup +psql "$ADMIN" -q -c "CREATE DATABASE $SRC_DB;" -c "CREATE DATABASE $TGT_DB;" + +echo "==> apply v0.3 migrations to target" +for f in "$ROOT"/migrations/*.sql; do psql "$TGT" -q -v ON_ERROR_STOP=1 -f "$f"; done + +LONGNAME=$(printf 'a%.0s' {1..40}) # 40 chars > the 32-char limit -> dropped +echo "==> seed legacy source (alice/bob/carol + edge cases)" +psql "$SRC" -q -v ON_ERROR_STOP=1 < pre-seed native v0.3 rows to force collisions (username dave/eve, nostr shared-npub)" +psql "$TGT" -q \ + -c "INSERT INTO users (username, pubkey_hash) VALUES ('dave', '$(h native-dave)');" \ + -c "INSERT INTO users (username, pubkey_hash) VALUES ('eve', '$(h native-eve)');" \ + -c "INSERT INTO users (pubkey_hash, nostr_pubkey) VALUES ('$(h native-holder)', '$(h shared-npub)');" + +run() { env DATABASE_URL="$TGT" SEED_FINGERPRINT_PEPPER="$PEPPER" ADMIN_KEY_INTEGRITY_SECRET="$ADMIN_SECRET" "$BIN" migrate-legacy --source-url "$SRC" "$@"; } + +echo "==> commit import (run 1)" +OUT1=$(run --commit); echo "$OUT1" | tail -3 +echo "$OUT1" | grep -q "imported=10 skipped(already present)=0 .*username-dropped=4 nostr-dropped=1 errors=0" \ + || fail "run-1 summary unexpected:\n$OUT1" + +TOTAL=$(psql "$TGT" -Atc "SELECT count(*) FROM users;") +UNAMES=$(psql "$TGT" -Atc "SELECT string_agg(username, ',' ORDER BY username) FROM users WHERE username IS NOT NULL;") +[ "$TOTAL" = "13" ] || fail "expected 13 users, got $TOTAL" +[ "$UNAMES" = "alice,bob,carol,dave,eve,heidi,nofp" ] || fail "unexpected usernames: $UNAMES" + +# nostr_pubkey: carried for alice (no collision), dropped for heidi (native holds it). +[ "$(psql "$TGT" -Atc "SELECT nostr_pubkey FROM users WHERE username='alice';")" = "$(h alice-npub)" ] \ + || fail "alice nostr_pubkey not carried" +[ -z "$(psql "$TGT" -Atc "SELECT nostr_pubkey FROM users WHERE username='heidi';")" ] \ + || fail "heidi nostr_pubkey should have been dropped (native holds shared-npub)" + +# peppering: stored pubkey_hash must be a 64-hex HMAC, not the plaintext. +STORED=$(psql "$TGT" -Atc "SELECT pubkey_hash FROM users WHERE username='alice';") +[ "$STORED" != "$(h alice-pk)" ] && [ "${#STORED}" = "64" ] || fail "alice pubkey_hash not peppered: $STORED" + +echo "==> idempotent re-run (must skip all 10)" +OUT2=$(run --commit); echo "$OUT2" | tail -1 +echo "$OUT2" | grep -q "imported=0 skipped(already present)=10" || fail "re-run not idempotent:\n$OUT2" + +echo "PASS: legacy migration imports identity (+nostr), peppers at rest, handles" +echo " reserved/taken/over-length/uppercase usernames + nostr collisions, and is idempotent." diff --git a/src/api/users.rs b/src/api/users.rs index ea19e59..dc82fa0 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -83,7 +83,11 @@ const RESERVED_USERNAMES: &[&str] = &[ /// Mirrors [`crate::api::auth`]'s username rules and adds the anti-impersonation /// reserved-name guard (a 409, the same status a UNIQUE collision yields, so the /// "taken" and "reserved" outcomes are indistinguishable to a probing client). -fn validate_username(username: &str) -> Result<(), AppError> { +/// +/// `pub` so the `smirk-admin migrate-legacy` importer applies the identical +/// format + reserved-name rules before it writes a legacy username directly via +/// the DB layer (which bypasses this HTTP handler's checks). +pub fn validate_username(username: &str) -> Result<(), AppError> { if username.len() < 3 || username.len() > 32 { return Err(AppError::ValidationError( "Username must be 3-32 characters".into(), diff --git a/src/bin/smirk-admin.rs b/src/bin/smirk-admin.rs index 638999b..c81715c 100644 --- a/src/bin/smirk-admin.rs +++ b/src/bin/smirk-admin.rs @@ -16,6 +16,9 @@ //! as pending; latches the bootstrap //! on a fresh instance — a complete //! first-run, no `setup` needed) +//! migrate-legacy --source-url [--commit] (import legacy v0.2.x +//! user identity, joined on the +//! derivation-independent seed_fingerprint) //! doctor //! //! Deferred (documented): remac-keys (integrity-secret rotation must also @@ -29,9 +32,10 @@ use rand::RngCore; use uuid::Uuid; use zeroize::Zeroize; +use smirk_backend_core::api::users::validate_username; use smirk_backend_core::core::invite::{generate_invite_code, hash_invite_code}; use smirk_backend_core::infra::db::{AddKeyOutcome, Database, RevokeKeyOutcome}; -use smirk_backend_core::models::db::{NewAdminAudit, NewAdminKey}; +use smirk_backend_core::models::db::{NewAdminAudit, NewAdminKey, NewUser}; const USAGE: &str = "\ smirk-admin — break-glass admin CLI @@ -48,6 +52,9 @@ COMMANDS: replace-key --old --pubkey <64hex> [--revoke-all] create-admin-wallet --out (generate a key + bootstrap a fresh instance) mint-invite [--count ] [--label ] (registration invite codes) + migrate-legacy --source-url [--commit] [--limit ] + (import v0.2.x user identity into this v0.3 DB, + joined on seed_fingerprint; dry-run unless --commit) doctor "; @@ -99,6 +106,7 @@ async fn run(args: &[String]) -> Result<(), String> { "replace-key" => replace_key(&db, &secret, rest).await, "create-admin-wallet" => create_admin_wallet(&db, &secret, rest).await, "mint-invite" => mint_invite(&db, rest).await, + "migrate-legacy" => migrate_legacy(&db, rest).await, "doctor" => doctor(&db, &secret).await, "-h" | "--help" | "help" => { print!("{USAGE}"); @@ -453,3 +461,305 @@ async fn health(db: &Database) -> &'static str { fn parse_uuid(s: &str) -> Result { Uuid::parse_str(s).map_err(|_| format!("invalid uuid: {s}")) } + +// ── legacy (v0.2.x) user import ────────────────────────────────────────────── + +/// One identity row read from the legacy v0.2.x `users` table. Only the columns +/// that have a home in the v0.3 schema — socials (telegram/discord/…), keys, and +/// wallets are intentionally not carried (the v0.3 client re-registers keys + +/// wallets at v3 derivation on first unlock; socials/tips have no v0.3 model). +#[derive(sqlx::FromRow)] +struct LegacyUser { + username: Option, + pubkey_hash: String, + nostr_pubkey: Option, + seed_fingerprint: Option, + wallet_birthday: Option>, + xmr_start_height: Option, + wow_start_height: Option, +} + +/// Import user identity from a legacy v0.2.x backend database into this v0.3 DB. +/// +/// Join key is the derivation-independent `seed_fingerprint` (`SHA256(SHA256(seed))`); +/// `pubkey_hash` is carried too so a v3-derivation wallet reclaims its row on the +/// first `/auth/extension` without a rotation step. `pubkey_hash` and +/// `seed_fingerprint` are peppered at rest by the DB layer, so this constructs a +/// target `Database` with the real `SEED_FINGERPRINT_PEPPER` (the other admin +/// paths run pepper-less and must not touch identity columns) — run this with the +/// v0.3 server's own `.env`. +/// +/// Idempotent: a legacy user already present (by peppered `pubkey_hash` or +/// `seed_fingerprint`) is skipped, so it is safe to re-run during the transition +/// and once more at cutover. Dry-run unless `--commit`. +async fn migrate_legacy(admin_db: &Database, args: &[String]) -> Result<(), String> { + let source_url = require_flag(args, "--source-url")?; + let commit = has_flag(args, "--commit"); + let limit: Option = match flag(args, "--limit") { + Some(s) => Some( + s.parse() + .map_err(|_| "--limit must be a whole number".to_string())?, + ), + None => None, + }; + + // The peppered target: reuse the connection pool run() already opened to + // DATABASE_URL, but rebuild the Database with the identity pepper so + // create_user() writes at-rest values that match what the live server + // computes (empty pepper here would silently produce unmatchable rows). + let pepper = std::env::var("SEED_FINGERPRINT_PEPPER").map_err(|_| { + "SEED_FINGERPRINT_PEPPER must be set (run with the v0.3 server's .env)".to_string() + })?; + if pepper.len() < 32 { + return Err("SEED_FINGERPRINT_PEPPER must be at least 32 bytes".into()); + } + let target = Database::new(admin_db.pool().clone(), pepper, String::new()); + + let source = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&source_url) + .await + .map_err(|e| format!("connect source (legacy) db: {e}"))?; + + // Deterministic first-n via a SQL LIMIT (bounds memory + source load); the + // `id` tiebreak makes a staged `--limit` select the same rows every run. The + // limit is a parsed usize, so interpolating it is injection-safe. + let sql = format!( + "SELECT username, pubkey_hash, nostr_pubkey, seed_fingerprint, wallet_birthday, \ + xmr_start_height, wow_start_height \ + FROM users WHERE pubkey_hash IS NOT NULL ORDER BY created_at, id{}", + limit.map(|n| format!(" LIMIT {n}")).unwrap_or_default() + ); + let legacy: Vec = sqlx::query_as::<_, LegacyUser>(&sql) + .fetch_all(&source) + .await + .map_err(|e| format!("read legacy users: {e}"))?; + + println!("legacy users read : {}", legacy.len()); + println!( + "mode : {}", + if commit { + "COMMIT (writing to this v0.3 DB)" + } else { + "DRY-RUN (no writes; pass --commit to apply)" + } + ); + println!(); + + let (mut created, mut skipped, mut uname_dropped, mut nostr_dropped, mut errors) = + (0u32, 0u32, 0u32, 0u32, 0u32); + // In-batch dedup: a v0.2.x table can hold pre/post-rotation rows sharing a + // seed_fingerprint/pubkey_hash. Without this a dry-run would double-count + // them (a commit self-heals via the exists-check, but the numbers should + // match). Keys are namespaced so a pubkey_hash can't alias a fingerprint. + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for u in &legacy { + // Best-effort per row: a transient lookup OR insert error on one user + // must not abort the whole pass — idempotent re-runs rely on the run + // making forward progress across a flaky window. + match import_one(&target, u, commit, &mut seen).await { + Ok(ImportOutcome::Skipped) => skipped += 1, + Ok(ImportOutcome::Created { + username_dropped, + nostr_dropped: nd, + }) => { + created += 1; + if username_dropped { + uname_dropped += 1; + } + if nd { + nostr_dropped += 1; + } + println!( + " {}: {}", + if commit { "imported" } else { "would import" }, + legacy_label(u) + ); + } + Err(e) => { + errors += 1; + eprintln!(" ERROR importing {}: {e}", legacy_label(u)); + } + } + } + + println!(); + println!( + "summary: {}={created} skipped(already present)={skipped} \ + username-dropped={uname_dropped} nostr-dropped={nostr_dropped} errors={errors}", + if commit { "imported" } else { "would-import" } + ); + if !commit { + println!("dry-run only — re-run with --commit to apply."); + } + Ok(()) +} + +/// Outcome of importing one legacy row. +enum ImportOutcome { + /// Created (or, in dry-run, would be). Flags note fields dropped on collision. + Created { + username_dropped: bool, + nostr_dropped: bool, + }, + /// Already present on the target (idempotent re-run, in-batch duplicate, or a + /// concurrent native onboarding that won a race) — nothing to do. + Skipped, +} + +/// Display label for a legacy row: its original username, else a pubkey-hash +/// prefix. Used in per-row log lines even when the username is later dropped. +fn legacy_label(u: &LegacyUser) -> String { + u.username + .as_deref() + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("pk:{}…", &u.pubkey_hash[..8.min(u.pubkey_hash.len())])) +} + +/// True if this legacy identity is already on the target (by peppered +/// `pubkey_hash` or `seed_fingerprint`). A read error bubbles up so the caller +/// counts it as a per-row error rather than aborting the whole run. +async fn identity_present(target: &Database, u: &LegacyUser) -> Result { + if target + .get_user_by_pubkey_hash(&u.pubkey_hash) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Ok(true); + } + if let Some(fp) = &u.seed_fingerprint { + if target + .get_user_by_seed_fingerprint(fp) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Ok(true); + } + } + Ok(false) +} + +/// Import a single legacy row. Skips if already present (idempotency / in-batch +/// duplicate). Drops a username or `nostr_pubkey` that is reserved/malformed/taken +/// (the user is still imported). On `commit`, inserts via `create_user` (which +/// peppers `pubkey_hash`/`seed_fingerprint`); a UNIQUE violation from a concurrent +/// native onboarding is reconciled — the identity is re-checked (→ `Skipped`) and, +/// failing that, retried once with the contended handle/npub dropped so the user +/// still lands rather than failing the whole row. +async fn import_one( + target: &Database, + u: &LegacyUser, + commit: bool, + seen: &mut std::collections::HashSet, +) -> Result { + // In-batch dedup (source rotation duplicates); namespaced keys. + if !seen.insert(format!("pk:{}", u.pubkey_hash)) { + return Ok(ImportOutcome::Skipped); + } + if let Some(fp) = &u.seed_fingerprint { + if !seen.insert(format!("fp:{fp}")) { + return Ok(ImportOutcome::Skipped); + } + } + if identity_present(target, u).await? { + return Ok(ImportOutcome::Skipped); + } + + // Username: drop (keep the user) if reserved/malformed/already taken. Mirrors + // the HTTP handler's guards, which create_user bypasses. + let mut username = u + .username + .as_deref() + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()); + let mut username_dropped = false; + if let Some(name) = username.clone() { + let taken = validate_username(&name).is_err() + || target + .get_user_by_username(&name) + .await + .map_err(|e| e.to_string())? + .is_some(); + if taken { + println!(" username {name:?} invalid/reserved/taken -> importing without it"); + username = None; + username_dropped = true; + } + } + + // nostr_pubkey: carried verbatim (public, unpeppered), but dropped on a target + // UNIQUE collision (a native user already linked it) — re-linked on v0.3 then. + let mut nostr_pubkey = u + .nostr_pubkey + .as_deref() + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()); + let mut nostr_dropped = false; + if let Some(npub) = nostr_pubkey.clone() { + if target + .find_user_by_nostr_pubkey(&npub) + .await + .map_err(|e| e.to_string())? + .is_some() + { + println!( + " nostr_pubkey {}… already linked on target -> importing without it", + &npub[..8.min(npub.len())] + ); + nostr_pubkey = None; + nostr_dropped = true; + } + } + + if !commit { + return Ok(ImportOutcome::Created { + username_dropped, + nostr_dropped, + }); + } + + let new_user = |username: Option, nostr_pubkey: Option| NewUser { + username, + pubkey_hash: Some(u.pubkey_hash.clone()), + nostr_pubkey, + wallet_birthday: u.wallet_birthday, + seed_fingerprint: u.seed_fingerprint.clone(), + xmr_start_height: u.xmr_start_height, + wow_start_height: u.wow_start_height, + }; + + match target + .create_user(new_user(username.clone(), nostr_pubkey.clone())) + .await + { + Ok(_) => Ok(ImportOutcome::Created { + username_dropped, + nostr_dropped, + }), + Err(e) => { + // A UNIQUE violation means a concurrent native onboarding won a race + // (v0.3 runs alongside v0.2.x during the transition). If the identity + // is now present it's a benign skip; otherwise a username/npub was + // claimed out from under us — retry once with both dropped so the user + // still imports (handle-less) instead of failing the whole row. + if identity_present(target, u).await? { + return Ok(ImportOutcome::Skipped); + } + if username.is_some() || nostr_pubkey.is_some() { + target + .create_user(new_user(None, None)) + .await + .map_err(|e2| e2.to_string())?; + return Ok(ImportOutcome::Created { + username_dropped: username_dropped || username.is_some(), + nostr_dropped: nostr_dropped || nostr_pubkey.is_some(), + }); + } + Err(e.to_string()) + } + } +}