mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +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:
@@ -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 <pg-url>` — the **legacy** database (read-only; never written).
|
||||
- `--commit` — actually write. Omit for a dry run (the default).
|
||||
- `--limit <n>` — 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).
|
||||
Executable
+100
@@ -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 <<SQL
|
||||
CREATE TABLE users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username varchar, pubkey_hash varchar, nostr_pubkey varchar, seed_fingerprint varchar,
|
||||
wallet_birthday timestamptz, xmr_start_height bigint, wow_start_height bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO users (username,pubkey_hash,nostr_pubkey,seed_fingerprint,wallet_birthday,xmr_start_height,wow_start_height,created_at) VALUES
|
||||
('alice','$(h alice-pk)','$(h alice-npub)','$(h alice-fp)','2026-01-15T00:00:00Z',2800000,500000,'2026-01-15'),
|
||||
('bob', '$(h bob-pk)', NULL, '$(h bob-fp)', '2026-02-20T00:00:00Z',2850000,510000,'2026-02-20'),
|
||||
('carol','$(h carol-pk)',NULL, '$(h carol-fp)','2026-03-01T00:00:00Z',2900000,520000,'2026-03-01'),
|
||||
('root', '$(h root-pk)', NULL, '$(h root-fp)', '2026-03-05T00:00:00Z',NULL,NULL,'2026-03-05'),
|
||||
(NULL, '$(h anon-pk)', NULL, '$(h anon-fp)', NULL,NULL,NULL,'2026-03-06'),
|
||||
('nofp', '$(h nofp-pk)', NULL, NULL, '2026-03-07T00:00:00Z',2910000,NULL,'2026-03-07'),
|
||||
('dave', '$(h dave-pk)', NULL, '$(h dave-fp)', '2026-03-08T00:00:00Z',NULL,NULL,'2026-03-08'),
|
||||
('Eve', '$(h eve-pk)', NULL, '$(h eve-fp)', '2026-03-09T00:00:00Z',NULL,NULL,'2026-03-09'),
|
||||
('$LONGNAME','$(h long-pk)',NULL, '$(h long-fp)', '2026-03-10T00:00:00Z',NULL,NULL,'2026-03-10'),
|
||||
('heidi','$(h heidi-pk)','$(h shared-npub)','$(h heidi-fp)','2026-03-11T00:00:00Z',NULL,NULL,'2026-03-11');
|
||||
SQL
|
||||
|
||||
echo "==> 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."
|
||||
+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