feat(admin): migrate-legacy — import v0.2.x user identity into v0.3

`smirk-admin migrate-legacy --source-url <pg> [--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.
This commit is contained in:
jwinterm
2026-07-02 13:49:45 -04:00
parent 635cb64601
commit b4935b15e8
4 changed files with 512 additions and 2 deletions
+5 -1
View File
@@ -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(),
+311 -1
View File
@@ -16,6 +16,9 @@
//! as pending; latches the bootstrap
//! on a fresh instance — a complete
//! first-run, no `setup` needed)
//! migrate-legacy --source-url <pg-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 <uuid> --pubkey <64hex> [--revoke-all]
create-admin-wallet --out <path> (generate a key + bootstrap a fresh instance)
mint-invite [--count <n>] [--label <s>] (registration invite codes)
migrate-legacy --source-url <pg-url> [--commit] [--limit <n>]
(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, String> {
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<String>,
pubkey_hash: String,
nostr_pubkey: Option<String>,
seed_fingerprint: Option<String>,
wallet_birthday: Option<chrono::DateTime<chrono::Utc>>,
xmr_start_height: Option<i64>,
wow_start_height: Option<i64>,
}
/// 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<usize> = 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<LegacyUser> = 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<String> = 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<bool, String> {
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<String>,
) -> Result<ImportOutcome, String> {
// 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<String>, nostr_pubkey: Option<String>| 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())
}
}
}