feat(account): migrate a live account's owned servers to a new account (§B3 inherit)

Old account runs /felis migrate in-game to open a migration, proves control via a
fresh web step-up (passkey forced when enrolled, else email-OTP), names the target
and mints a one-time code. The target redeems it while authenticated AS that target:
in one transaction the source's owned servers re-point to the target and the source
is retired (sessions revoked, disabled, soft-deleted), which also spends the code so
it cannot be replayed. Only server ownership moves; the mc_uuid link and web
credentials stay with the source, so migrate is not a credential-theft primitive.

- 0015 migration: account_migrations state machine (initiated -> confirmed ->
  code_issued -> redeemed), one live migration per source
- Repo/PGRepo: Start/ForSource/Confirm/IssueCode/Redeem
- 8 routes (1 internal /felis side, 7 web) with openapi parity
- passkey step-up runs the same clone-signal (sign-count) check as the login door
- code bound to the named target at issue and at redeem

Quota is grandfathered at redeem: no per-target quota re-check when servers move.
This commit is contained in:
flyemoji committed 2026-07-05 20:50:48 +09:00
1 parent bbcfaeb6c4
commit fdb6efbd88
8 files changed
+1692

No files matched your search

@@ -0,0 +1,48 @@
-- Account migration (spec §B3 inherit): a LIVE old account hands its owned servers
-- to a new account and is then retired. This is scenario A (the source runs
-- /felis migrate in-game), distinct from the eviction-inherit hook on
-- player_data_holds (scenario B, a barred UUID's stashed data).
--
-- State machine (one live migration per source at a time):
-- initiated -- /felis migrate in-game put the source account into migrate mode
-- confirmed -- source proved control via a FRESH web step-up (passkey if any is
-- enrolled, else email-OTP) — never mere session possession
-- code_issued -- source named the target account by id and minted a one-time code
-- redeemed -- target logged in, entered the code; owned servers re-pointed to the
-- target and the source account disabled (terminal)
--
-- The transfer moves server ownership only. It does NOT move the mc_uuid link (the
-- target keeps the in-game identity it logged in with) nor web credentials (email /
-- passkeys stay with the source) — moving credentials would make migrate a
-- credential-theft primitive.
CREATE TABLE account_migrations (
id text PRIMARY KEY,
source_user_id text NOT NULL REFERENCES users(id),
target_user_id text REFERENCES users(id), -- NULL until code_issued (named at issue time)
state text NOT NULL, -- initiated|confirmed|code_issued|redeemed
confirm_factor text, -- 'passkey'|'email_otp'; NULL until confirmed
confirmed_at timestamptz, -- when the step-up proof landed
code_hash text, -- sha-256 of the one-time code; NULL until code_issued
code_expires_at timestamptz, -- one-time code TTL; NULL until code_issued
redeemed_at timestamptz, -- when the target spent the code (terminal)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- At most one live (non-terminal) migration per source. Re-running /felis migrate
-- supersedes any earlier unfinished attempt (StartMigration deletes it first), so
-- this guards against two concurrent live migrations racing the same source.
CREATE UNIQUE INDEX idx_account_migrations_live_source
ON account_migrations (source_user_id)
WHERE state <> 'redeemed';
-- Redeem resolves a submitted code to its pending migration. Scoped to code_issued
-- so spent/superseded rows never match.
CREATE INDEX idx_account_migrations_code
ON account_migrations (code_hash)
WHERE code_hash IS NOT NULL AND state = 'code_issued';
-- Reuse the shared updated_at trigger installed in 0010_user_management.sql.
CREATE TRIGGER trg_account_migrations_updated_at
BEFORE UPDATE ON account_migrations
FOR EACH ROW EXECUTE FUNCTION felis_set_updated_at();