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(),