mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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:
+5
-1
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user