Unverified Commit fdb6efbd authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent bbcfaeb6
Loading
Loading
Loading
Loading
+368 −0
Changes for docs/openapi.yaml: 368 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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]
+18 −0
Changes for internal/api/api.go: 18 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+135 −0
Changes for internal/api/api_test.go: 135 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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) {
+541 −0

File added.

Preview size limit exceeded, changes collapsed.

+349 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading