From fdb6efbd883cdbd45ae5ff368b280d8b890617e2 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Sun, 5 Jul 2026 20:50:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(account):=20migrate=20a=20live=20account's?= =?UTF-8?q?=20owned=20servers=20to=20a=20new=20account=20(=C2=A7B3=20inher?= =?UTF-8?q?it)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/openapi.yaml | 368 ++++++++++++ internal/api/api.go | 18 + internal/api/api_test.go | 135 +++++ internal/api/handlers_account_migrate.go | 541 ++++++++++++++++++ internal/api/handlers_account_migrate_test.go | 349 +++++++++++ internal/api/pgrepo.go | 180 ++++++ internal/api/repo.go | 53 ++ .../migrations/0015_account_migration.sql | 48 ++ 8 files changed, 1692 insertions(+) create mode 100644 internal/api/handlers_account_migrate.go create mode 100644 internal/api/handlers_account_migrate_test.go create mode 100644 internal/store/migrations/0015_account_migration.sql diff --git a/docs/openapi.yaml b/docs/openapi.yaml index bcb19ef..c9c7777 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -805,6 +805,56 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /api/v1/internal/account/migrate/start: + post: + tags: [account-internal] + operationId: migrateStart + summary: Put the account linked to a verified in-game UUID into migrate mode (spec §B3 inherit, in-game side). + description: > + Internal-only. The in-game /felis migrate command calls this for the running + player's verified UUID: it resolves the linked account and opens a fresh + migration in the initiated state, superseding any earlier unfinished attempt + by the same source. The web side then drives a fresh step-up confirmation. + The transfer itself moves server ownership only — never the mc_uuid link nor + web credentials — so this endpoint starts a flow, it does not move anything. + x-felis-face: [internal] + x-felis-tier: service + security: [{ serviceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [mc_uuid] + properties: + mc_uuid: { type: string, format: uuid } + responses: + '201': + description: Migration opened in the initiated state. + content: + application/json: + schema: + type: object + required: [started, state] + properties: + started: { type: boolean, const: true } + state: { type: string, const: initiated } + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: The UUID is not linked to any account (not_linked). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: The source account has already been retired by a completed migration (account_retired). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /api/v1/internal/player/reclaim: post: tags: [account-internal] @@ -3264,6 +3314,324 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /api/v1/account/migrate: + get: + tags: [account] + operationId: migrateStatus + summary: Report the caller's active account-migration and where it is in the flow (spec §B3 inherit, web side). + description: > + Read-only. Returns the live migration whose source is the authenticated + principal, if any, so the web onboarding can resume the flow: whether a + confirmation step-up is still needed, which factor confirmed it, the named + target, and the one-time code's expiry once issued. active:false when the + caller has no live migration. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '200': + description: The caller's live migration, or active:false. + content: + application/json: + schema: + type: object + required: [active] + properties: + active: { type: boolean } + state: + type: string + enum: [initiated, confirmed, code_issued] + description: Present only when active; a redeemed migration is terminal and not reported here. + target_user_id: { type: string } + confirm_factor: + type: string + enum: [passkey, email_otp] + code_expires_at: { type: string, format: date-time } + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/account/migrate/confirm/otp/start: + post: + tags: [account] + operationId: migrateConfirmOtpStart + summary: Send a fresh email one-time code to confirm control of the migrating source account (spec §B3 step-up). + description: > + Opens the email-OTP confirmation factor for the caller's initiated migration. + This is a FRESH step-up bound to the migrate purpose, never mere session + possession. If the account has ANY passkey enrolled, email-OTP is refused with + 409 passkey_required — the stronger factor is forced. The code is delivered out + of band and never returned; requires a verified email on the account. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '202': + description: Confirmation code minted and dispatched. + content: + application/json: + schema: + type: object + required: [sent, expires_at] + properties: + sent: { type: boolean, const: true } + expires_at: { type: string, format: date-time } + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: The caller has no initiated migration to confirm (no_migration). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: > + A passkey is enrolled so email-OTP is forbidden (passkey_required); the + migration is already confirmed (already_confirmed); or the account has no + email step-up factor (no_step_up_factor). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: Resend requested before the cooldown elapsed. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/account/migrate/confirm/otp/verify: + post: + tags: [account] + operationId: migrateConfirmOtpVerify + summary: Redeem the email one-time code and confirm the migration (spec §B3 step-up). + description: > + Consumes the fresh migrate-purpose email code for the caller's initiated + migration and advances it to confirmed with confirm_factor email_otp. Too many + wrong attempts lock the code (429 otp_locked); an unknown, expired, consumed, or + mismatched code is a 400 invalid_code. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string } + responses: + '200': + description: Migration confirmed. + content: + application/json: + schema: + type: object + required: [confirmed] + properties: + confirmed: { type: boolean, const: true } + '400': + description: Invalid or expired code (invalid_code). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: The caller has no initiated migration to confirm (no_migration). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: The migration is already confirmed (already_confirmed). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: The code is locked after too many wrong attempts (otp_locked). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/account/migrate/confirm/passkey/begin: + post: + tags: [account] + operationId: migrateConfirmPasskeyBegin + summary: Begin a fresh passkey assertion to confirm control of the migrating source account (spec §B3 step-up). + description: > + Returns WebAuthn assertion request options for the caller's own enrolled + passkeys, bound to a fresh migrate-purpose challenge. This is the forced factor + whenever a passkey exists. The finish call proves the assertion and, exactly as + the login door does, runs the clone-signal (sign-count) check before confirming. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '200': + description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get. + content: + application/json: + schema: { type: object, description: Opaque WebAuthn PublicKeyCredentialRequestOptions. } + '400': + description: The caller has no enrolled passkey (no_passkey). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: The caller has no initiated migration to confirm (no_migration). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + + /api/v1/account/migrate/confirm/passkey/finish: + post: + tags: [account] + operationId: migrateConfirmPasskeyFinish + summary: Finish the passkey assertion and confirm the migration (spec §B3 step-up). + description: > + Verifies the WebAuthn assertion against the fresh migrate-purpose challenge and, + like the login door, applies the authenticator sign-count clone check: a cloned + authenticator is rejected fail-closed (400 passkey_login_invalid) and audited. On + success the migration advances to confirmed with confirm_factor passkey. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [assertion] + properties: + assertion: + type: object + description: The navigator.credentials.get() PublicKeyCredential assertion. + responses: + '200': + description: Migration confirmed. + content: + application/json: + schema: + type: object + required: [confirmed] + properties: + confirmed: { type: boolean, const: true } + '400': + description: Assertion invalid, challenge stale, or a cloned authenticator was detected (passkey_login_invalid). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: The caller has no initiated migration to confirm (no_migration). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + + /api/v1/account/migrate/issue-code: + post: + tags: [account] + operationId: migrateIssueCode + summary: Name the target account and mint the one-time migration code (spec §B3 inherit). + description: > + For a confirmed migration, binds the named target account and mints a single + one-time code (only its hash is stored) that the target must redeem while logged + in AS that target — an intercepted code is useless to anyone else. The target + must exist and be neither disabled nor soft-deleted, and cannot be the source. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [target_user_id] + properties: + target_user_id: { type: string } + responses: + '201': + description: Code minted, bound to the named target. + content: + application/json: + schema: + type: object + required: [code, expires_at] + properties: + code: { type: string } + expires_at: { type: string, format: date-time } + '400': + description: > + The target is the source itself (invalid_target), does not exist + (target_not_found), or is disabled/retired (target_unavailable). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: The caller has no migration to issue against (no_migration). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: The migration has not been confirmed by a step-up yet (not_confirmed). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/account/migrate/redeem: + post: + tags: [account] + operationId: migrateRedeem + summary: Redeem a migration code as the named target and inherit the source's owned servers (spec §B3 inherit). + description: > + The authenticated caller — who must be the target named at issue time — spends + the one-time code. In a single atomic step the source's owned servers are + re-pointed to the caller and the source account is retired (disabled and + soft-deleted), which also spends the code so it cannot be replayed. The caller + keeps its own in-game identity and credentials; only server ownership moves. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string } + responses: + '200': + description: Migration redeemed; owned servers moved to the caller. + content: + application/json: + schema: + type: object + required: [migrated, servers_moved, servers] + properties: + migrated: { type: boolean, const: true } + servers_moved: { type: integer, format: int32 } + servers: + type: array + items: { type: string } + '400': + description: Unknown, expired, or already-spent code, or the caller is not the named target (invalid_code). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/me/submissions: post: tags: [submissions] diff --git a/internal/api/api.go b/internal/api/api.go index c67edb8..408856c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -243,6 +243,11 @@ func (a *API) internalAPIRoutes() []apiRoute { // and keyed by the verified UUID (not the scanned code), so it consumes nothing // and is safe to poll repeatedly. {Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", h: a.handleLinkStatus}, + // Account migration (spec §B3 inherit), in-game side: /felis migrate puts the + // account linked to the running player's verified UUID into migrate mode. Internal + // only — the initiator is proven by online-mode auth, and the sensitive proof + // (step-up) still happens web-side before anything transfers. + {Method: "POST", Pattern: "/api/v1/internal/account/migrate/start", h: a.handleMigrateStart}, // Username-collision reclaim (spec §B3): velocity records a Mojang-priority // reclaim (bar the squatter UUID + stash its data for 30 days) and gates the // limbo login by checking whether a connecting UUID was barred. Internal-only — @@ -365,6 +370,19 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish}, {Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList}, {Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete}, + // Account migration (spec §B3 inherit), web side. App-tier, principal-scoped: the + // SOURCE drives status → step-up confirm (passkey forced when enrolled, else + // email-OTP) → issue-code+name-target; the TARGET drives redeem as itself. Not + // SetupAllowed — migrating is a normal post-onboarding operation, never part of + // lockdown enrollment. The step-up is a FRESH proof, so a stolen session alone + // cannot advance a migration. + {Method: "GET", Pattern: "/api/v1/account/migrate", h: a.handleMigrateStatus}, + {Method: "POST", Pattern: "/api/v1/account/migrate/confirm/otp/start", h: a.handleMigrateConfirmOTPStart}, + {Method: "POST", Pattern: "/api/v1/account/migrate/confirm/otp/verify", h: a.handleMigrateConfirmOTPVerify}, + {Method: "POST", Pattern: "/api/v1/account/migrate/confirm/passkey/begin", h: a.handleMigrateConfirmPasskeyBegin}, + {Method: "POST", Pattern: "/api/v1/account/migrate/confirm/passkey/finish", h: a.handleMigrateConfirmPasskeyFinish}, + {Method: "POST", Pattern: "/api/v1/account/migrate/issue-code", h: a.handleMigrateIssueCode}, + {Method: "POST", Pattern: "/api/v1/account/migrate/redeem", h: a.handleMigrateRedeem}, // Modpack submission (user-directed lane over §16), user side: a user files an upload for review // and lists their own. App-tier — the submitter and the "my uploads" scope are // both taken from the principal, never the body, so an ordinary authenticated diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 5614f0c..8fa7784 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -94,6 +94,12 @@ type fakeRepo struct { // pingErr, when non-nil, is returned by Ping to simulate DB liveness check // failures in /readyz tests. pingErr error + // account migration (spec §B3 inherit, scenario A) keyed by row id, mirroring + // account_migrations. Server ownership for the transfer is read/written on + // byName[*].OwnerID (the servers projection), and the source's retirement is + // applied to seededUsers, so the fake exercises the same all-or-nothing shape the + // PG transaction enforces. + migrations map[string]*fakeMigration } // fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the @@ -213,6 +219,7 @@ func newFakeRepo() *fakeRepo { discoverableChallenges: map[string]*fakeDiscoverableChallenge{}, fakeQuotas: map[string]*QuotaView{}, + migrations: map[string]*fakeMigration{}, } } @@ -921,6 +928,134 @@ func (f *fakeRepo) SetUserDisabled(_ context.Context, userID string, disabled bo return ErrNotFound } +// ---- account migration fakes (spec §B3 inherit, scenario A) ---- + +// fakeMigration mirrors an account_migrations row through its state machine. Zero +// times mean the corresponding NULL column (not yet confirmed / no code issued). +type fakeMigration struct { + id string + sourceUserID string + targetUserID string + state string + confirmFactor string + confirmedAt time.Time + codeHash string + codeExpiresAt time.Time + redeemedAt time.Time + createdAt time.Time +} + +func (m *fakeMigration) view() *MigrationView { + v := &MigrationView{ + ID: m.id, SourceUserID: m.sourceUserID, TargetUserID: m.targetUserID, + State: m.state, ConfirmFactor: m.confirmFactor, CreatedAt: m.createdAt, + } + if !m.confirmedAt.IsZero() { + t := m.confirmedAt + v.ConfirmedAt = &t + } + if !m.codeExpiresAt.IsZero() { + t := m.codeExpiresAt + v.CodeExpiresAt = &t + } + return v +} + +// userLive mirrors the PG "id = $1 AND deleted_at IS NULL" guard: a seeded, not-yet- +// retired user is live; an unknown or soft-deleted one is not. +func (f *fakeRepo) userLive(userID string) bool { + for _, su := range f.seededUsers { + if su.view.ID == userID { + return su.detail.DeletedAt == nil + } + } + return false +} + +func (f *fakeRepo) StartMigration(_ context.Context, id, sourceUserID string, now time.Time) error { + if !f.userLive(sourceUserID) { + return ErrNotFound + } + for k, m := range f.migrations { // supersede any prior non-redeemed row for the source + if m.sourceUserID == sourceUserID && m.state != "redeemed" { + delete(f.migrations, k) + } + } + f.migrations[id] = &fakeMigration{ + id: id, sourceUserID: sourceUserID, state: "initiated", createdAt: now, + } + return nil +} + +func (f *fakeRepo) MigrationForSource(_ context.Context, sourceUserID string) (*MigrationView, error) { + for _, m := range f.migrations { + if m.sourceUserID == sourceUserID && m.state != "redeemed" { + return m.view(), nil + } + } + return nil, ErrNotFound +} + +func (f *fakeRepo) ConfirmMigration(_ context.Context, sourceUserID, factor string, now time.Time) error { + for _, m := range f.migrations { + if m.sourceUserID == sourceUserID && m.state == "initiated" { + m.state = "confirmed" + m.confirmFactor = factor + m.confirmedAt = now + return nil + } + } + return ErrConflict +} + +func (f *fakeRepo) IssueMigrationCode(_ context.Context, sourceUserID, targetUserID, codeHash string, expiresAt time.Time) error { + for _, m := range f.migrations { + if m.sourceUserID == sourceUserID && m.state == "confirmed" { + m.state = "code_issued" + m.targetUserID = targetUserID + m.codeHash = codeHash + m.codeExpiresAt = expiresAt + return nil + } + } + return ErrConflict +} + +func (f *fakeRepo) RedeemMigration(_ context.Context, targetUserID, codeHash string, now time.Time) (string, []string, error) { + var mig *fakeMigration + for _, m := range f.migrations { + if m.codeHash == codeHash && m.targetUserID == targetUserID && + m.state == "code_issued" && m.codeExpiresAt.After(now) { + mig = m + break + } + } + if mig == nil { + return "", nil, ErrLinkCodeInvalid + } + // Re-point every server the source owns to the target (byName holds pointers). + var moved []string + for name, rec := range f.byName { + if rec.OwnerID == mig.sourceUserID { + rec.OwnerID = targetUserID + moved = append(moved, name) + } + } + sort.Strings(moved) // deterministic for assertions + // Retire the source: disable + soft-delete. + for i, su := range f.seededUsers { + if su.view.ID == mig.sourceUserID { + f.seededUsers[i].view.Disabled = true + f.seededUsers[i].detail.Disabled = true + t := now + f.seededUsers[i].detail.DeletedAt = &t + } + } + mig.state = "redeemed" + mig.redeemedAt = now + return mig.sourceUserID, moved, nil +} + // ---- quota admin fakes ---- func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) { diff --git a/internal/api/handlers_account_migrate.go b/internal/api/handlers_account_migrate.go new file mode 100644 index 0000000..4dd3a1e --- /dev/null +++ b/internal/api/handlers_account_migrate.go @@ -0,0 +1,541 @@ +package api + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strings" + "time" +) + +// Account migration (spec §B3 inherit, scenario A). A LIVE old account hands its +// owned servers to a new account and is retired. The flow, and which side of the +// house each step lives on: +// +// 1. in-game /felis migrate → handleMigrateStart (internal face): the +// source, known by its verified mc_uuid, enters +// migrate mode (state 'initiated'). +// 2. web step-up confirm → handleMigrateConfirm{OTP,Passkey}*: the +// source proves control with a FRESH factor — +// passkey if any is enrolled (forced), else an +// email-OTP — advancing to 'confirmed'. Mere +// session possession is never enough; a stolen +// session cannot read the mailbox nor present the +// authenticator. +// 3. web issue code + name target → handleMigrateIssueCode: the source names the +// target account by id and mints a one-time code +// ('code_issued'). +// 4. web target redeems code → handleMigrateRedeem: the target, logged in as +// itself, submits the code; ownership of the +// source's servers moves to the target and the +// source is retired ('redeemed'). +// +// The code is bound to the named target at issue AND the redeemer must authenticate AS +// that target, so an intercepted code is useless to anyone else. Only server ownership +// moves — the mc_uuid link and web credentials (email, passkeys) stay with their +// accounts; moving credentials would make migrate a credential-theft primitive. +// +// CODE-ONLY (Java/Velocity, not represented here): the /felis migrate command that calls +// handleMigrateStart, and the web forms that drive steps 2–4. + +const ( + // otpPurposeMigrate scopes an email-OTP to the migration step-up, so a + // migrate-confirm code never collides with an onboarding or login code for the + // same user (see otpPurposeOnboard). + otpPurposeMigrate = "migrate_confirm" + // passkeyPurposeMigrate scopes a passkey assertion challenge to the migration + // step-up, keeping it apart from the login assertion challenge (passkeyPurposeLogin). + passkeyPurposeMigrate = "passkey_migrate" + // migrateCodeTTL bounds the one-time code the source hands to the target. Short + // enough that a leaked code is useless soon, long enough to switch accounts and type. + migrateCodeTTL = 10 * time.Minute +) + +// newMigrationID returns an opaque random row id (128 bits, hex) for an +// account_migrations row, mirroring the other one-time-handle mints. +func newMigrationID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// migrateStartRequest is the in-game /felis migrate callback body (internal face): the +// verified UUID of the player who ran the command. Its linked account becomes the +// migration source. +type migrateStartRequest struct { + MCUUID string `json:"mc_uuid"` +} + +// handleMigrateStart puts the account linked to a verified in-game UUID into migrate +// mode (spec §B3, internal face). It is the server side of /felis migrate: velocity has +// already established the UUID via online-mode auth, so the initiator is trustworthy; +// the sensitive proof (step-up) still happens on the web before anything transfers. An +// unlinked UUID has no account to migrate (404); a retired/already-migrated account +// cannot re-initiate (409). +func (a *API) handleMigrateStart(w http.ResponseWriter, r *http.Request) { + var req migrateStartRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + mcUUID := strings.TrimSpace(req.MCUUID) + if mcUUID == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) + return + } + sourceUserID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, r, newError(http.StatusNotFound, "not_linked", + "this in-game identity is not linked to a Felis account")) + return + } + writeError(w, r, err) + return + } + id, err := newMigrationID() + if err != nil { + writeError(w, r, err) + return + } + if err := a.Repo.StartMigration(r.Context(), id, sourceUserID, a.now()); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, r, newError(http.StatusConflict, "account_retired", + "the linked account can no longer start a migration")) + return + } + writeError(w, r, err) + return + } + // Internal-face event: attribute to the in-game initiator, Source 'internal'. + _ = a.Repo.Audit(r.Context(), AuditEntry{ + Actor: "mc:" + mcUUID, + Source: "internal", + Action: "account.migrate.start", + RequestID: requestIDFromContext(r.Context()), + }) + writeJSON(w, http.StatusCreated, map[string]any{"started": true, "state": "initiated"}) +} + +// handleMigrateStatus reports the caller's live migration for the web flow to drive its +// next step (spec §B3, external app face). No migration in flight → {active:false}. +func (a *API) handleMigrateStatus(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + m, err := a.Repo.MigrationForSource(r.Context(), p.UserID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeJSON(w, http.StatusOK, map[string]any{"active": false}) + return + } + writeError(w, r, err) + return + } + resp := map[string]any{"active": true, "state": m.State} + if m.TargetUserID != "" { + resp["target_user_id"] = m.TargetUserID + } + if m.ConfirmFactor != "" { + resp["confirm_factor"] = m.ConfirmFactor + } + if m.CodeExpiresAt != nil { + resp["code_expires_at"] = m.CodeExpiresAt.UTC() + } + writeJSON(w, http.StatusOK, resp) +} + +// requireInitiatedMigration loads the caller's live migration and requires it be in +// 'initiated' — the only state from which step-up may run. It writes the right error and +// returns ok=false when the caller should stop, so the confirm handlers stay flat. +func (a *API) requireInitiatedMigration(w http.ResponseWriter, r *http.Request, userID string) (*MigrationView, bool) { + m, err := a.Repo.MigrationForSource(r.Context(), userID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, r, newError(http.StatusNotFound, "no_migration", + "no migration is in progress; start one in-game with /felis migrate")) + return nil, false + } + writeError(w, r, err) + return nil, false + } + if m.State != "initiated" { + writeError(w, r, newError(http.StatusConflict, "already_confirmed", + "this migration has already been confirmed")) + return nil, false + } + return m, true +} + +// userHasPasskey reports whether the account has any passkey enrolled — the predicate +// that forces the passkey factor for the step-up. +func (a *API) userHasPasskey(ctx context.Context, userID string) (bool, error) { + creds, err := a.Repo.PasskeyCredentialsForUser(ctx, userID) + if err != nil { + return false, err + } + return len(creds) > 0, nil +} + +// handleMigrateConfirmOTPStart mints and delivers a fresh email-OTP for the migration +// step-up (spec §B3, external app face). It is refused when the account has a passkey +// enrolled — a strong factor must not be downgradable to email for an identity transfer. +func (a *API) handleMigrateConfirmOTPStart(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok { + return + } + hasPk, err := a.userHasPasskey(r.Context(), p.UserID) + if err != nil { + writeError(w, r, err) + return + } + if hasPk { + writeError(w, r, newError(http.StatusConflict, "passkey_required", + "this account has a passkey; confirm the migration with your passkey")) + return + } + if p.Email == "" { + writeError(w, r, newError(http.StatusConflict, "no_step_up_factor", + "no verified email or passkey on this account to confirm the migration")) + return + } + // Per-recipient cooldown, namespaced apart from the other OTP doors so they never + // perturb each other's throttle. + emailKey := "migrate:confirm:" + strings.ToLower(p.Email) + lim := a.otpLimiter() + emailAt, ok := lim.reserve(emailKey, otpResendCooldown) + if !ok { + writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown", + "a code was sent recently; wait a moment before requesting another")) + return + } + committed := false + defer func() { + if !committed { + lim.release(emailKey, emailAt) + } + }() + code, err := newEmailOTP() + if err != nil { + writeError(w, r, err) + return + } + id, err := newOTPID() + if err != nil { + writeError(w, r, err) + return + } + expiresAt := a.now().Add(otpTTL) + if err := a.Repo.CreateEmailOTP(r.Context(), id, p.UserID, p.Email, otpCodeHash(code), otpPurposeMigrate, expiresAt); err != nil { + writeError(w, r, err) + return + } + if err := a.deliverOTP(r.Context(), p.Email, code); err != nil { + writeError(w, r, err) + return + } + committed = true + a.audit(r, auditActor(p), "account.migrate.confirm_otp_sent", "") + writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()}) +} + +// migrateConfirmOTPVerifyRequest is the OTP step-up verify body: the code from the email. +type migrateConfirmOTPVerifyRequest struct { + Code string `json:"code"` +} + +// handleMigrateConfirmOTPVerify redeems the migration step-up code and, on a match, +// advances the migration to 'confirmed' (spec §B3, external app face). The code lifecycle +// is the login-door one (no identity side-effect): the address is already proven. +func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + var req migrateConfirmOTPVerifyRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + code := strings.TrimSpace(req.Code) + if code == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required")) + return + } + if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok { + return + } + switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, otpPurposeMigrate, otpCodeHash(code), a.now()); { + case errors.Is(err, ErrOTPLocked): + writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked", + "too many incorrect attempts; request a new code")) + return + case errors.Is(err, ErrOTPInvalid): + writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired")) + return + case err != nil: + writeError(w, r, err) + return + } + if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "email_otp", a.now()); err != nil { + if errors.Is(err, ErrConflict) { + writeError(w, r, newError(http.StatusConflict, "already_confirmed", + "this migration has already been confirmed")) + return + } + writeError(w, r, err) + return + } + a.audit(r, auditActor(p), "account.migrate.confirmed", "") + writeJSON(w, http.StatusOK, map[string]any{"confirmed": true}) +} + +// migratePasskeyUser builds the PasskeyUser the assertion ceremony needs for the +// already-logged-in source (contrast the login door, which resolves it from a typed +// email). The credential set must be identical between begin and finish. +func migratePasskeyUser(p *Principal, creds []PasskeyCredential) PasskeyUser { + name := p.Email + if name == "" { + name = p.UserID + } + return PasskeyUser{ID: p.UserID, Name: name, DisplayName: name, Credentials: creds} +} + +// handleMigrateConfirmPasskeyBegin starts a fresh passkey assertion bound to the +// migration step-up (spec §B3, external app face). Unlike the login door it needs no +// email — the caller is already authenticated — so it scopes the challenge to the +// session principal and purpose passkeyPurposeMigrate. +func (a *API) handleMigrateConfirmPasskeyBegin(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + if a.Passkey == nil { + writeError(w, r, errPasskeyUnavailable) + return + } + if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok { + return + } + creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) + if err != nil { + writeError(w, r, err) + return + } + if len(creds) == 0 { + writeError(w, r, newError(http.StatusBadRequest, "no_passkey", + "no passkey enrolled; confirm the migration with an email code")) + return + } + options, sessionData, err := a.Passkey.BeginLogin(migratePasskeyUser(p, creds)) + if err != nil { + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed", + "could not start passkey confirmation")) + return + } + id, err := newPasskeyID() + if err != nil { + writeError(w, r, err) + return + } + expiresAt := a.now().Add(passkeyChallengeTTL) + if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, p.UserID, passkeyPurposeMigrate, sessionData, expiresAt); err != nil { + writeError(w, r, err) + return + } + writeJSON(w, http.StatusOK, options) +} + +// migrateConfirmPasskeyFinishRequest is the assertion the browser produced, captured +// as raw bytes so the exact response reaches the verifier without re-encoding. +type migrateConfirmPasskeyFinishRequest struct { + Assertion json.RawMessage `json:"assertion"` +} + +// handleMigrateConfirmPasskeyFinish verifies the migration step-up assertion and, on +// success, advances the migration to 'confirmed' (spec §B3, external app face). It +// consumes the stashed migrate challenge atomically (a missing/expired one → 400). +func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + if a.Passkey == nil { + writeError(w, r, errPasskeyUnavailable) + return + } + var req migrateConfirmPasskeyFinishRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + if len(req.Assertion) == 0 { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required")) + return + } + if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok { + return + } + sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeMigrate, a.now()) + if err != nil { + if errors.Is(err, ErrPasskeyChallengeInvalid) { + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey confirmation could not be completed; begin again")) + return + } + writeError(w, r, err) + return + } + creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) + if err != nil { + writeError(w, r, err) + return + } + va, err := a.Passkey.FinishLogin(migratePasskeyUser(p, creds), sessionData, bytes.NewReader(req.Assertion)) + if err != nil { + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey confirmation could not be completed; begin again")) + return + } + // Same clone policy as the login door (applyAssertionCounter): a rolled-back counter + // fails closed with the opaque envelope and advances nothing, so the migrate step-up is + // never a weaker sibling that would accept an authenticator login refuses. A clean + // assertion advances the stored sign-count, keeping the clone signal meaningful for the + // next login. + if err := a.applyAssertionCounter(r.Context(), va); err != nil { + if errors.Is(err, errPasskeyClonedAuthenticator) { + a.audit(r, auditActor(p), "auth.passkey_clone_rejected", va.CredentialID) + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey confirmation could not be completed; begin again")) + return + } + writeError(w, r, err) + return + } + if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "passkey", a.now()); err != nil { + if errors.Is(err, ErrConflict) { + writeError(w, r, newError(http.StatusConflict, "already_confirmed", + "this migration has already been confirmed")) + return + } + writeError(w, r, err) + return + } + a.audit(r, auditActor(p), "account.migrate.confirmed", "") + writeJSON(w, http.StatusOK, map[string]any{"confirmed": true}) +} + +// migrateIssueCodeRequest is the issue-code body: the id of the new account the source +// nominates to receive its servers. +type migrateIssueCodeRequest struct { + TargetUserID string `json:"target_user_id"` +} + +// handleMigrateIssueCode binds the named target and mints the one-time migrate code +// (spec §B3, external app face). Requires the migration to be 'confirmed' (step-up done). +// The target must be a live account other than the source. The code is returned once, +// out of band to the target; only its hash is stored. +func (a *API) handleMigrateIssueCode(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + var req migrateIssueCodeRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + targetID := strings.TrimSpace(req.TargetUserID) + if targetID == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "target_user_id is required")) + return + } + if targetID == p.UserID { + writeError(w, r, newError(http.StatusBadRequest, "invalid_target", + "the target account must be different from the source")) + return + } + m, err := a.Repo.MigrationForSource(r.Context(), p.UserID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, r, newError(http.StatusNotFound, "no_migration", + "no migration is in progress; start one in-game with /felis migrate")) + return + } + writeError(w, r, err) + return + } + if m.State != "confirmed" { + writeError(w, r, newError(http.StatusConflict, "not_confirmed", + "confirm the migration before issuing a code")) + return + } + // The target must exist and be a live (non-deleted, non-disabled) account. Validate + // here so a typo'd id fails with a clear message rather than a bare FK error. + target, err := a.Repo.UserDetail(r.Context(), targetID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, r, newError(http.StatusBadRequest, "target_not_found", "no account with that id")) + return + } + writeError(w, r, err) + return + } + if target.DeletedAt != nil || target.Disabled { + writeError(w, r, newError(http.StatusBadRequest, "target_unavailable", + "the target account is not available")) + return + } + code, err := newLinkCode() + if err != nil { + writeError(w, r, err) + return + } + expiresAt := a.now().Add(migrateCodeTTL) + if err := a.Repo.IssueMigrationCode(r.Context(), p.UserID, targetID, otpCodeHash(code), expiresAt); err != nil { + if errors.Is(err, ErrConflict) { + writeError(w, r, newError(http.StatusConflict, "not_confirmed", + "confirm the migration before issuing a code")) + return + } + writeError(w, r, err) + return + } + a.audit(r, auditActor(p), "account.migrate.code_issued", targetID) + writeJSON(w, http.StatusCreated, map[string]any{"code": code, "expires_at": expiresAt.UTC()}) +} + +// migrateRedeemRequest is the redeem body: the one-time code the target received. +type migrateRedeemRequest struct { + Code string `json:"code"` +} + +// handleMigrateRedeem spends the migrate code as the named target (spec §B3, external +// app face). The redeemer must be authenticated as the account the code was bound to; +// a code whose target is a different account simply does not match (an intercepted code +// is useless). On success the source's servers are re-pointed to the caller and the +// source account is retired, atomically. +func (a *API) handleMigrateRedeem(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + var req migrateRedeemRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + // Trim + uppercase so a target who typed the code with stray spaces or in lowercase + // still matches the minted value (the alphabet is uppercase); then hash — the raw + // code is never compared against the database. + code := strings.ToUpper(strings.TrimSpace(req.Code)) + if code == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required")) + return + } + sourceUserID, moved, err := a.Repo.RedeemMigration(r.Context(), p.UserID, otpCodeHash(code), a.now()) + if err != nil { + if errors.Is(err, ErrLinkCodeInvalid) { + writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "migrate code is invalid or expired")) + return + } + writeError(w, r, err) + return + } + a.audit(r, auditActor(p), "account.migrate.redeemed", sourceUserID) + writeJSON(w, http.StatusOK, map[string]any{ + "migrated": true, + "servers_moved": len(moved), + "servers": moved, + }) +} diff --git a/internal/api/handlers_account_migrate_test.go b/internal/api/handlers_account_migrate_test.go new file mode 100644 index 0000000..dadcbc9 --- /dev/null +++ b/internal/api/handlers_account_migrate_test.go @@ -0,0 +1,349 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// Account-migration tests (spec §B3 inherit, scenario A) across BOTH faces. What they +// prove is the handler + state machine (initiated → confirmed → code_issued → redeemed) +// and the load-bearing security properties of the flow, against the fakeRepo and a fake +// PasskeyVerifier — not the pgrepo SQL nor the WebAuthn crypto (both mirrored here). The +// decisive properties, in flow order: +// +// - Step-up is a FRESH proof, and force-passkey is not downgradable: an account with a +// passkey cannot confirm by email (no OTP is even minted). +// - The one-time code is bound to the NAMED target at issue AND the redeemer must be +// that target, so an intercepted code is useless to anyone else. +// - The step-up is never weaker than the login door: a cloned authenticator the login +// door refuses is refused here too, and confirms nothing. +// - Redeem is a double-spend-safe, atomic transfer: the source's servers move to the +// target, the source is retired, and the code cannot be replayed. + +// migrateEnv wires the migration doors over one shared fakeRepo: a capturing mailer (so a +// test can read the OTP that production only ever emails) and a fake passkey verifier +// primed with fixed options + a verified assertion. mk builds a face for a given +// principal — the internal handler ignores it, each external handler is scoped to one +// caller — so a test can drive the source and the target (and an interloper) against the +// same store. +func migrateEnv(repo *fakeRepo) (mk func(*Principal) *API, mailer *captureMailer, v *fakePasskeyVerifier) { + mailer = &captureMailer{} + v = &fakePasskeyVerifier{ + options: json.RawMessage(`{"publicKey":{"challenge":"bWlncmF0ZQ"}}`), + assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true}, + } + cl := newFakeCluster() + mk = func(p *Principal) *API { + a := newTestAPI(repo, cl) + a.External = staticExternal{p: p} + a.Mailer = mailer + a.Passkey = v + return a + } + return mk, mailer, v +} + +// startMigrate drives the in-game /felis migrate call (internal face) for a linked UUID. +func startMigrate(t *testing.T, ih http.Handler, mcUUID string) *httptest.ResponseRecorder { + t.Helper() + return do(ih, "POST", "/api/v1/internal/account/migrate/start", `{"mc_uuid":"`+mcUUID+`"}`, jsonHeader) +} + +// TestMigrateVertical walks the whole slice: an in-game start, an email-OTP step-up +// (the source holds no passkey, so email is allowed), a code issued against a named +// target, and the target redeeming it — moving exactly the source's servers and retiring +// the source. The single-use property closes it: the spent code cannot be replayed. +func TestMigrateVertical(t *testing.T) { + const uuid = "11111111-1111-1111-1111-111111111111" + src := &Principal{UserID: "u1", Email: "old@example.net", Role: "user"} + tgt := &Principal{UserID: "u2", Email: "new@example.net", Role: "user"} + + repo := newFakeRepo() + repo.seedUser(UserView{ID: "u1", Username: "old", Email: "old@example.net", Role: "user"}) + repo.seedUser(UserView{ID: "u2", Username: "new", Email: "new@example.net", Role: "user"}) + repo.links[uuid] = "u1" + repo.byName["alpha"] = &ServerRecord{Name: "alpha", OwnerID: "u1"} + repo.byName["beta"] = &ServerRecord{Name: "beta", OwnerID: "u1"} + repo.byName["other"] = &ServerRecord{Name: "other", OwnerID: "u2"} // the target's own — must NOT move + + mk, mailer, _ := migrateEnv(repo) + ih := mk(src).InternalHandler() + ehSrc := mk(src).ExternalHandler() + ehTgt := mk(tgt).ExternalHandler() + + // 1) in-game start puts the linked account into migrate mode. + if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated { + t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + if w := do(ehSrc, "GET", "/api/v1/account/migrate", "", nil); acctBody(t, w)["state"] != "initiated" { + t.Fatalf("status after start = %s, want initiated", w.Body.String()) + } + + // 2) email-OTP step-up. The code is delivered out of band (captured here), never in + // the response, and verifying it advances the migration to confirmed. + w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader) + if w.Code != http.StatusAccepted { + t.Fatalf("otp start: code = %d, want 202 (%s)", w.Code, w.Body.String()) + } + if _, leaked := acctBody(t, w)["code"]; leaked { + t.Error("otp start response must NEVER carry the code") + } + code := mailer.code + if code == "" { + t.Fatal("no OTP delivered") + } + w = do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+code+`"}`, jsonHeader) + if w.Code != http.StatusOK || acctBody(t, w)["confirmed"] != true { + t.Fatalf("otp verify: code = %d body %s, want 200 confirmed", w.Code, w.Body.String()) + } + if b := do(ehSrc, "GET", "/api/v1/account/migrate", "", nil); acctBody(t, b)["confirm_factor"] != "email_otp" { + t.Fatalf("status after confirm = %s, want confirm_factor email_otp", b.Body.String()) + } + + // 3) the source names the target and mints a one-time code. + w = do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u2"}`, jsonHeader) + if w.Code != http.StatusCreated { + t.Fatalf("issue-code: code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + mcode, _ := acctBody(t, w)["code"].(string) + if mcode == "" { + t.Fatal("issue-code returned no code") + } + + // 4) the target redeems: exactly the source's two servers move; the target's own is + // untouched; the source is retired. + w = do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("redeem: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + rb := acctBody(t, w) + if rb["migrated"] != true || rb["servers_moved"] != float64(2) { + t.Fatalf("redeem body = %v, want migrated:true servers_moved:2", rb) + } + if repo.byName["alpha"].OwnerID != "u2" || repo.byName["beta"].OwnerID != "u2" { + t.Fatalf("source servers not moved: alpha=%s beta=%s", repo.byName["alpha"].OwnerID, repo.byName["beta"].OwnerID) + } + if repo.byName["other"].OwnerID != "u2" { // was u2 already; a move would be a bug either way + t.Fatalf("target's own server changed owner to %s", repo.byName["other"].OwnerID) + } + if d, _ := repo.UserDetail(context.Background(), "u1"); d == nil || d.DeletedAt == nil || !d.Disabled { + t.Fatalf("source account not retired: %+v", d) + } + + // 5) single-use: the spent code cannot be replayed. + if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" { + t.Fatalf("replay of spent code: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String()) + } +} + +// TestMigrateForcePasskey pins the contract's "若有 Passkey 强制 Passkey": an account with +// a passkey enrolled cannot step up by email — the OTP door refuses with passkey_required +// and mints NOTHING, so the strong factor is not downgradable for an identity transfer. +func TestMigrateForcePasskey(t *testing.T) { + const uuid = "22222222-2222-2222-2222-222222222222" + src := &Principal{UserID: "u1", Email: "old@example.net", Role: "user"} + + repo := newFakeRepo() + repo.seedUser(UserView{ID: "u1", Username: "old", Email: "old@example.net", Role: "user"}) + repo.links[uuid] = "u1" + repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow} + + mk, mailer, _ := migrateEnv(repo) + ih := mk(src).InternalHandler() + ehSrc := mk(src).ExternalHandler() + + if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated { + t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader) + if w.Code != http.StatusConflict || decodeErr(t, w) != "passkey_required" { + t.Fatalf("otp start with passkey enrolled: code = %d body %s, want 409 passkey_required", w.Code, w.Body.String()) + } + if mailer.calls != 0 { + t.Fatalf("an OTP was minted despite an enrolled passkey (calls=%d)", mailer.calls) + } +} + +// TestMigratePasskeyConfirm drives the passkey step-up: begin stashes exactly one +// migrate-purpose challenge for the caller, and finish verifies the assertion against the +// SERVER-STASHED session data (the body carries no challenge) and advances the migration +// to confirmed with factor 'passkey'. +func TestMigratePasskeyConfirm(t *testing.T) { + const uuid = "33333333-3333-3333-3333-333333333333" + src := &Principal{UserID: "u1", Email: "old@example.net", Role: "user"} + + repo := newFakeRepo() + repo.seedUser(UserView{ID: "u1", Username: "old", Email: "old@example.net", Role: "user"}) + repo.links[uuid] = "u1" + repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow} + + mk, _, v := migrateEnv(repo) + ih := mk(src).InternalHandler() + ehSrc := mk(src).ExternalHandler() + + if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated { + t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + + // begin: exactly one migrate-purpose challenge stashed for u1, options returned verbatim. + w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/begin", "", jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("passkey begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if len(repo.passkeyChallenges) != 1 { + t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges)) + } + for _, c := range repo.passkeyChallenges { + if c.userID != "u1" || c.purpose != passkeyPurposeMigrate { + t.Errorf("stashed challenge = %+v, want user u1 purpose %q", c, passkeyPurposeMigrate) + } + } + + // finish: the body carries NO challenge; the stashed blob reaches the verifier only via + // store-stash → consume, and a verified assertion confirms the migration. + w = do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/finish", + `{"assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + if w.Code != http.StatusOK || acctBody(t, w)["confirmed"] != true { + t.Fatalf("passkey finish: code = %d body %s, want 200 confirmed", w.Code, w.Body.String()) + } + if len(v.lastSession) == 0 { + t.Error("finish must feed the verifier the server-stashed session data") + } + if m, _ := repo.MigrationForSource(context.Background(), "u1"); m == nil || m.State != "confirmed" || m.ConfirmFactor != "passkey" { + t.Fatalf("migration after passkey confirm = %+v, want state confirmed factor passkey", m) + } +} + +// TestMigratePasskeyCloneRejected proves the step-up is never weaker than the login door: +// a cloned authenticator (rolled-back counter) is refused with the opaque envelope and +// confirms nothing — the migration stays in initiated. +func TestMigratePasskeyCloneRejected(t *testing.T) { + const uuid = "44444444-4444-4444-4444-444444444444" + src := &Principal{UserID: "u1", Email: "old@example.net", Role: "user"} + + repo := newFakeRepo() + repo.seedUser(UserView{ID: "u1", Username: "old", Email: "old@example.net", Role: "user"}) + repo.links[uuid] = "u1" + repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow} + + mk, _, v := migrateEnv(repo) + v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, CloneWarning: true} + ih := mk(src).InternalHandler() + ehSrc := mk(src).ExternalHandler() + + if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated { + t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/begin", "", jsonHeader); w.Code != http.StatusOK { + t.Fatalf("passkey begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/finish", + `{"assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" { + t.Fatalf("cloned authenticator: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String()) + } + if m, _ := repo.MigrationForSource(context.Background(), "u1"); m == nil || m.State != "initiated" { + t.Fatalf("migration after clone-rejected finish = %+v, want still initiated", m) + } +} + +// TestMigrateRedeemBinding proves the one-time code is bound to the NAMED target: an +// interloper who holds the correct code but is a different account cannot redeem it (it +// simply does not match), the transfer does not happen, and the code survives for the +// genuine target to spend. +func TestMigrateRedeemBinding(t *testing.T) { + const uuid = "55555555-5555-5555-5555-555555555555" + src := &Principal{UserID: "u1", Email: "old@example.net", Role: "user"} + tgt := &Principal{UserID: "u2", Email: "new@example.net", Role: "user"} + interloper := &Principal{UserID: "u3", Email: "evil@example.net", Role: "user"} + + repo := newFakeRepo() + repo.seedUser(UserView{ID: "u1", Username: "old", Email: "old@example.net", Role: "user"}) + repo.seedUser(UserView{ID: "u2", Username: "new", Email: "new@example.net", Role: "user"}) + repo.links[uuid] = "u1" + repo.byName["alpha"] = &ServerRecord{Name: "alpha", OwnerID: "u1"} + + mk, mailer, _ := migrateEnv(repo) + ih := mk(src).InternalHandler() + ehSrc := mk(src).ExternalHandler() + + // Drive to a code issued against u2. + if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated { + t.Fatalf("start: %d (%s)", w.Code, w.Body.String()) + } + if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader); w.Code != http.StatusAccepted { + t.Fatalf("otp start: %d (%s)", w.Code, w.Body.String()) + } + if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+mailer.code+`"}`, jsonHeader); w.Code != http.StatusOK { + t.Fatalf("otp verify: %d (%s)", w.Code, w.Body.String()) + } + w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u2"}`, jsonHeader) + if w.Code != http.StatusCreated { + t.Fatalf("issue-code: %d (%s)", w.Code, w.Body.String()) + } + mcode, _ := acctBody(t, w)["code"].(string) + + // The interloper holds the correct code but is not the named target: no match. + ehEvil := mk(interloper).ExternalHandler() + if w := do(ehEvil, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" { + t.Fatalf("interloper redeem: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String()) + } + if repo.byName["alpha"].OwnerID != "u1" { + t.Fatalf("server moved on a non-target redeem: owner=%s", repo.byName["alpha"].OwnerID) + } + if d, _ := repo.UserDetail(context.Background(), "u1"); d.DeletedAt != nil { + t.Fatal("source retired on a non-target redeem") + } + + // The genuine target still spends the same code — the failed attempt consumed nothing. + ehTgt := mk(tgt).ExternalHandler() + if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusOK { + t.Fatalf("genuine target redeem: code = %d body %s, want 200", w.Code, w.Body.String()) + } + if repo.byName["alpha"].OwnerID != "u2" { + t.Fatalf("server not moved to genuine target: owner=%s", repo.byName["alpha"].OwnerID) + } +} + +// TestMigrateGuards covers the input/state refusals: an unlinked UUID has no account to +// migrate; a code cannot be issued before confirmation; the target may be neither the +// source itself nor an unknown account. +func TestMigrateGuards(t *testing.T) { + const uuid = "66666666-6666-6666-6666-666666666666" + src := &Principal{UserID: "u1", Email: "old@example.net", Role: "user"} + + repo := newFakeRepo() + repo.seedUser(UserView{ID: "u1", Username: "old", Email: "old@example.net", Role: "user"}) + repo.links[uuid] = "u1" + + mk, mailer, _ := migrateEnv(repo) + ih := mk(src).InternalHandler() + ehSrc := mk(src).ExternalHandler() + + // start on an unlinked UUID → 404 not_linked. + if w := startMigrate(t, ih, "00000000-0000-0000-0000-000000000000"); w.Code != http.StatusNotFound || decodeErr(t, w) != "not_linked" { + t.Fatalf("start unlinked: code = %d body %s, want 404 not_linked", w.Code, w.Body.String()) + } + + // A real start, then issue-code BEFORE confirming → 409 not_confirmed. + if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated { + t.Fatalf("start: %d (%s)", w.Code, w.Body.String()) + } + if w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u1"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_target" { + t.Fatalf("issue with target==source: code = %d body %s, want 400 invalid_target", w.Code, w.Body.String()) + } + + // Confirm (email; no passkey on this account), then the target guards apply. + if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader); w.Code != http.StatusAccepted { + t.Fatalf("otp start: %d (%s)", w.Code, w.Body.String()) + } + if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+mailer.code+`"}`, jsonHeader); w.Code != http.StatusOK { + t.Fatalf("otp verify: %d (%s)", w.Code, w.Body.String()) + } + if w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"ghost"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "target_not_found" { + t.Fatalf("issue with unknown target: code = %d body %s, want 400 target_not_found", w.Code, w.Body.String()) + } +} diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 3baecb9..cc2f3c3 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -1635,6 +1635,186 @@ func (p *PGRepo) LinkAccount(ctx context.Context, userID, mcUUID, authSource str return nil } +// ---- account migration (spec §B3 inherit, scenario A) ---- + +// StartMigration puts a live source account into migrate mode. It supersedes any +// earlier unfinished migration for the source (so re-running /felis migrate restarts +// cleanly, invalidating a prior outstanding code) and inserts a fresh 'initiated' row, +// both under one transaction so the partial unique index never sees two live rows. +func (p *PGRepo) StartMigration(ctx context.Context, id, sourceUserID string, now time.Time) error { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + + // The source must be a live (non-deleted) account; a retired one can never + // re-initiate a migration. + var live bool + if err := tx.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL)`, + sourceUserID).Scan(&live); err != nil { + return err + } + if !live { + return ErrNotFound + } + + if _, err := tx.ExecContext(ctx, + `DELETE FROM account_migrations WHERE source_user_id = $1 AND state <> 'redeemed'`, + sourceUserID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO account_migrations (id, source_user_id, state, created_at, updated_at) + VALUES ($1, $2, 'initiated', $3, $3)`, + id, sourceUserID, now); err != nil { + return err + } + return tx.Commit() +} + +// MigrationForSource loads the live (non-redeemed) migration for a source, or +// ErrNotFound when none is in flight. +func (p *PGRepo) MigrationForSource(ctx context.Context, sourceUserID string) (*MigrationView, error) { + const q = `SELECT id, source_user_id, COALESCE(target_user_id, ''), state, + COALESCE(confirm_factor, ''), confirmed_at, code_expires_at, created_at + FROM account_migrations + WHERE source_user_id = $1 AND state <> 'redeemed'` + var v MigrationView + switch err := p.db.QueryRowContext(ctx, q, sourceUserID).Scan( + &v.ID, &v.SourceUserID, &v.TargetUserID, &v.State, + &v.ConfirmFactor, &v.ConfirmedAt, &v.CodeExpiresAt, &v.CreatedAt); { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrNotFound + case err != nil: + return nil, err + } + return &v, nil +} + +// ConfirmMigration advances 'initiated' → 'confirmed' for the source, stamping the +// step-up factor + time. It only advances from 'initiated' (0 rows → ErrConflict), so +// the step-up can never be replayed against a later state. +func (p *PGRepo) ConfirmMigration(ctx context.Context, sourceUserID, factor string, now time.Time) error { + res, err := p.db.ExecContext(ctx, + `UPDATE account_migrations + SET state = 'confirmed', confirm_factor = $2, confirmed_at = $3 + WHERE source_user_id = $1 AND state = 'initiated'`, + sourceUserID, factor, now) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrConflict + } + return nil +} + +// IssueMigrationCode advances 'confirmed' → 'code_issued', binding the target and +// storing the one-time code hash + TTL. The caller has already validated the target is +// a live account other than the source; the target FK is the backstop. Not-in-confirmed +// → ErrConflict. +func (p *PGRepo) IssueMigrationCode(ctx context.Context, sourceUserID, targetUserID, codeHash string, expiresAt time.Time) error { + res, err := p.db.ExecContext(ctx, + `UPDATE account_migrations + SET state = 'code_issued', target_user_id = $2, code_hash = $3, code_expires_at = $4 + WHERE source_user_id = $1 AND state = 'confirmed'`, + sourceUserID, targetUserID, codeHash, expiresAt) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrConflict + } + return nil +} + +// RedeemMigration performs the atomic transfer + retirement in one transaction. See +// the interface doc for the full contract. +func (p *PGRepo) RedeemMigration(ctx context.Context, targetUserID, codeHash string, now time.Time) (string, []string, error) { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return "", nil, err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + + // Find and lock the pending migration whose named target is exactly this user. A + // code whose target is a different user simply does not match — an intercepted + // code is useless to a non-target. FOR UPDATE serializes concurrent redeems. + var migID, sourceUserID string + switch err := tx.QueryRowContext(ctx, + `SELECT id, source_user_id FROM account_migrations + WHERE code_hash = $1 AND target_user_id = $2 AND state = 'code_issued' + AND code_expires_at > $3 + FOR UPDATE`, + codeHash, targetUserID, now).Scan(&migID, &sourceUserID); { + case errors.Is(err, sql.ErrNoRows): + return "", nil, ErrLinkCodeInvalid + case err != nil: + return "", nil, err + } + + // Re-point every server the source owns to the target, collecting the names for + // the audit trail. Server ownership is the only thing that moves. + rows, err := tx.QueryContext(ctx, + `UPDATE servers SET owner_id = $2, claimed_at = now() + WHERE owner_id = $1 AND deleted_at IS NULL + RETURNING name`, + sourceUserID, targetUserID) + if err != nil { + return "", nil, err + } + var moved []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return "", nil, err + } + moved = append(moved, name) + } + if err := rows.Err(); err != nil { + rows.Close() + return "", nil, err + } + rows.Close() + + // Retire the source: revoke its live sessions and soft-delete it so it can neither + // log in nor start another migration (double-spend defense). The servers just moved + // away, so there is nothing left to release. + if _, err := tx.ExecContext(ctx, + `UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`, + sourceUserID); err != nil { + return "", nil, err + } + if _, err := tx.ExecContext(ctx, + `UPDATE users SET disabled = true, deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, + sourceUserID); err != nil { + return "", nil, err + } + + // Mark the migration terminal. + if _, err := tx.ExecContext(ctx, + `UPDATE account_migrations SET state = 'redeemed', redeemed_at = $2 WHERE id = $1`, + migID, now); err != nil { + return "", nil, err + } + + if err := tx.Commit(); err != nil { + return "", nil, err + } + return sourceUserID, moved, nil +} + // ---- pre-session email login (spec §B) ---- // UserByEmail resolves a VERIFIED email address to its login projection, or diff --git a/internal/api/repo.go b/internal/api/repo.go index 8ccfe52..50b3fc1 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -141,6 +141,22 @@ type OpLoginRequest struct { CreatedAt time.Time } +// MigrationView is the live account-migration for a source user (spec §B3 inherit, +// scenario A): its state-machine position and the fields the web step-up, issue-code, +// and status paths read. TargetUserID is empty until a code is issued; ConfirmFactor +// and ConfirmedAt are empty/nil until the source completes step-up; CodeExpiresAt is +// nil until code_issued. +type MigrationView struct { + ID string + SourceUserID string + TargetUserID string + State string + ConfirmFactor string + ConfirmedAt *time.Time + CodeExpiresAt *time.Time + CreatedAt time.Time +} + // Repo is the business-layer data access the API depends on. It is an interface // so handlers are tested against an in-memory fake; the Postgres implementation // (pgRepo) is integration-tested only — it requires a live database. @@ -557,6 +573,43 @@ type Repo interface { // idempotent. authSource records which Yggdrasil established the UUID // (mojang | thirdparty, spec §10 dual-Yggdrasil). LinkAccount(ctx context.Context, userID, mcUUID, authSource string) error + + // ---- account migration (spec §B3 inherit, scenario A) ---- + + // StartMigration puts a LIVE source account into migrate mode: it supersedes any + // earlier unfinished migration for the source (so re-running /felis migrate + // restarts cleanly, invalidating a prior outstanding code) and inserts a fresh row + // in state 'initiated'. id is the opaque handle. The source must be a live + // (non-deleted) account — ErrNotFound otherwise, so a retired account can never + // re-initiate. now stamps the row. + StartMigration(ctx context.Context, id, sourceUserID string, now time.Time) error + // MigrationForSource loads the live (non-redeemed) migration for a source, or + // ErrNotFound when none is in flight. The web status/confirm/issue paths use it to + // gate each step on the correct prior state. + MigrationForSource(ctx context.Context, sourceUserID string) (*MigrationView, error) + // ConfirmMigration records that the source proved control via a FRESH step-up + // (factor 'passkey' | 'email_otp'), advancing 'initiated' → 'confirmed'. It only + // advances from 'initiated'; any other current state (or no migration) → ErrConflict, + // so a confirmed/code_issued/redeemed migration can never be re-confirmed and the + // step-up cannot be replayed. now stamps confirmed_at. + ConfirmMigration(ctx context.Context, sourceUserID, factor string, now time.Time) error + // IssueMigrationCode binds the named target and stores the one-time code hash, + // advancing 'confirmed' → 'code_issued'. targetUserID must be a live account other + // than the source (validated by the caller before this call); the target FK also + // guarantees the row exists. codeHash is the sha-256 of the code; expiresAt is its + // TTL. A migration not in 'confirmed' → ErrConflict. + IssueMigrationCode(ctx context.Context, sourceUserID, targetUserID, codeHash string, expiresAt time.Time) error + // RedeemMigration is the ATOMIC transfer: keyed by (codeHash, targetUserID) it + // finds the 'code_issued', unexpired migration whose named target is exactly the + // redeeming user, re-points every server owned by the source to the target, retires + // the source account (disabled + soft-deleted, its live sessions revoked), and marks + // the migration 'redeemed' — all in one transaction. It returns the source user id + // and the moved server names for the audit trail. No matching or expired code, or a + // code whose named target is a different user → ErrLinkCodeInvalid (an intercepted + // code is useless to anyone but the named target). Server ownership is the only thing + // moved — the mc_uuid link and web credentials stay with their accounts. now drives + // expiry and the terminal timestamps. + RedeemMigration(ctx context.Context, targetUserID, codeHash string, now time.Time) (sourceUserID string, movedServers []string, err error) } // ---- user admin types ---- diff --git a/internal/store/migrations/0015_account_migration.sql b/internal/store/migrations/0015_account_migration.sql new file mode 100644 index 0000000..a0c2103 --- /dev/null +++ b/internal/store/migrations/0015_account_migration.sql @@ -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();