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
|
/// 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
|
/// reserved-name guard (a 409, the same status a UNIQUE collision yields, so the
|
||||||
/// "taken" and "reserved" outcomes are indistinguishable to a probing client).
|
/// "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 {
|
if username.len() < 3 || username.len() > 32 {
|
||||||
return Err(AppError::ValidationError(
|
return Err(AppError::ValidationError(
|
||||||
"Username must be 3-32 characters".into(),
|
"Username must be 3-32 characters".into(),
|
||||||
|
|||||||
+311
-1
@@ -16,6 +16,9 @@
|
|||||||
//! as pending; latches the bootstrap
|
//! as pending; latches the bootstrap
|
||||||
//! on a fresh instance — a complete
|
//! on a fresh instance — a complete
|
||||||
//! first-run, no `setup` needed)
|
//! 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
|
//! doctor
|
||||||
//!
|
//!
|
||||||
//! Deferred (documented): remac-keys (integrity-secret rotation must also
|
//! Deferred (documented): remac-keys (integrity-secret rotation must also
|
||||||
@@ -29,9 +32,10 @@ use rand::RngCore;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use zeroize::Zeroize;
|
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::core::invite::{generate_invite_code, hash_invite_code};
|
||||||
use smirk_backend_core::infra::db::{AddKeyOutcome, Database, RevokeKeyOutcome};
|
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 = "\
|
const USAGE: &str = "\
|
||||||
smirk-admin — break-glass admin CLI
|
smirk-admin — break-glass admin CLI
|
||||||
@@ -48,6 +52,9 @@ COMMANDS:
|
|||||||
replace-key --old <uuid> --pubkey <64hex> [--revoke-all]
|
replace-key --old <uuid> --pubkey <64hex> [--revoke-all]
|
||||||
create-admin-wallet --out <path> (generate a key + bootstrap a fresh instance)
|
create-admin-wallet --out <path> (generate a key + bootstrap a fresh instance)
|
||||||
mint-invite [--count <n>] [--label <s>] (registration invite codes)
|
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
|
doctor
|
||||||
";
|
";
|
||||||
|
|
||||||
@@ -99,6 +106,7 @@ async fn run(args: &[String]) -> Result<(), String> {
|
|||||||
"replace-key" => replace_key(&db, &secret, rest).await,
|
"replace-key" => replace_key(&db, &secret, rest).await,
|
||||||
"create-admin-wallet" => create_admin_wallet(&db, &secret, rest).await,
|
"create-admin-wallet" => create_admin_wallet(&db, &secret, rest).await,
|
||||||
"mint-invite" => mint_invite(&db, rest).await,
|
"mint-invite" => mint_invite(&db, rest).await,
|
||||||
|
"migrate-legacy" => migrate_legacy(&db, rest).await,
|
||||||
"doctor" => doctor(&db, &secret).await,
|
"doctor" => doctor(&db, &secret).await,
|
||||||
"-h" | "--help" | "help" => {
|
"-h" | "--help" | "help" => {
|
||||||
print!("{USAGE}");
|
print!("{USAGE}");
|
||||||
@@ -453,3 +461,305 @@ async fn health(db: &Database) -> &'static str {
|
|||||||
fn parse_uuid(s: &str) -> Result<Uuid, String> {
|
fn parse_uuid(s: &str) -> Result<Uuid, String> {
|
||||||
Uuid::parse_str(s).map_err(|_| format!("invalid uuid: {s}"))
|
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